diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 22fb956a1a5..03677628674 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -475,6 +475,44 @@ jobs: --exclude=tests/e2e/cross-version-wire/** \ --shard=${{ matrix.shard }}/${{ matrix.shard_total }} + # Why a separate job: the test needs a real Chrome, and the 32-way `test` matrix + # would pay for it 32 times to run one file in whichever shard it landed in. + orcad_browser: + name: orcad browser provider + needs: [code_paths] + if: needs.code_paths.outputs.should_run == 'true' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + # Why no native-runtime: the provider drives the prebuilt agent-browser binary + # shipped in node_modules and never touches node-pty. + - uses: ./.github/actions/install-node-dependencies + + # Why the runner's Google Chrome and not its chromium: Ubuntu 24.04 only ships an + # AppArmor userns profile for the Chrome .deb, so chromium dies with "No usable + # sandbox" and the provider passes no --no-sandbox. Why fail instead of skip: an + # unset ORCA_BROWSER_EXECUTABLE is exactly how this test went uncovered for so long. + - name: Resolve Chrome for the browser provider + run: | + set -euo pipefail + chrome="$(command -v google-chrome || command -v google-chrome-stable || true)" + if [ -z "$chrome" ]; then + echo "::error::No Google Chrome on the runner; the browser provider test would silently skip." + exit 1 + fi + "$chrome" --version + echo "ORCA_BROWSER_EXECUTABLE=$chrome" >> "$GITHUB_ENV" + + - name: Test external Chromium browser provider + run: | + pnpm exec vitest run --config config/vitest.config.ts \ + src/main/orcad/external-chromium-browser-process.integration.test.ts + cross-version-wire: name: cross-version wire compatibility needs: [code_paths] @@ -642,6 +680,7 @@ jobs: - name: Test Windows-specific boundaries run: >- pnpm exec vitest run --config config/vitest.config.ts + config/scripts/rebuild-native-deps.test.mjs src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts src/shared/child-process/windows-command-line.win32.test.ts @@ -658,6 +697,8 @@ jobs: src/main/runtime/repo-worktree-admin-fingerprint.test.ts src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts src/shared/secure-file-fsync-flags.test.ts + src/main/ipc/pty-codex-account-attribution.test.ts + src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts # Why the :parallel variant: identical to build:release except the three # electron-vite targets overlap instead of running back to back. The Linux package @@ -744,6 +785,7 @@ jobs: - xterm_patch_sync - shell_contracts - test + - orcad_browser - managed_hook_node18 - package - package_windows @@ -769,6 +811,7 @@ jobs: XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }} SHELL_CONTRACTS: ${{ needs.shell_contracts.result }} TEST: ${{ needs.test.result }} + ORCAD_BROWSER: ${{ needs.orcad_browser.result }} MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }} PACKAGE: ${{ needs.package.result }} PACKAGE_WINDOWS: ${{ needs.package_windows.result }} @@ -788,6 +831,7 @@ jobs: "$XTERM_PATCH_SYNC" \ "$SHELL_CONTRACTS" \ "$TEST" \ + "$ORCAD_BROWSER" \ "$MANAGED_HOOK_NODE18" \ "$PACKAGE" \ "$PACKAGE_WINDOWS"; do @@ -807,6 +851,7 @@ jobs: "$XTERM_PATCH_SYNC" \ "$SHELL_CONTRACTS" \ "$TEST" \ + "$ORCAD_BROWSER" \ "$MANAGED_HOOK_NODE18" \ "$PACKAGE" \ "$PACKAGE_WINDOWS"; do diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 33e31aa2605..bf7d5b1382c 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -14,6 +14,9 @@ const { const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs') const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs') const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs') +const { + verifyPackagedNodePtyJobOwnership +} = require('./scripts/verify-packaged-node-pty-job-ownership.cjs') const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs') // Why: dev-channel builds must carry the *release* identity — same bundle id, @@ -266,6 +269,13 @@ module.exports = { const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 } const hostArchEnum = archEnumByNodeArch[process.arch] const canExecuteTargetArch = context.arch === hostArchEnum || context.arch === 4 + if (context.electronPlatformName === 'win32') { + if (process.platform === 'win32' && canExecuteTargetArch) { + verifyPackagedNodePtyJobOwnership(resourcesDir) + } else { + console.log('[verify-packaged-node-pty] skipped cross-platform or cross-arch package') + } + } verifySkillsCliRuntime(join(resourcesDir, 'app.asar.unpacked', 'out'), resourcesDir, { executeCommands: canExecuteTargetArch }) diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 940129be633..e1bebf1b4b3 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -3,7 +3,6 @@ # split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines"). # Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only) inline src/cli/handlers/automations.ts -inline src/cli/handlers/orchestration.ts inline src/cli/help.ts inline src/main/agent-hooks/server.ts inline src/main/automations/external-manager.ts @@ -14,47 +13,29 @@ inline src/main/browser/browser-manager.ts inline src/main/browser/cdp-bridge.ts inline src/main/browser/grab-guest-script.ts inline src/main/claude-accounts/runtime-auth-service.ts -inline src/main/claude-accounts/service.ts inline src/main/cli/cli-installer.ts inline src/main/cli/wsl-cli-installer.ts inline src/main/codex-accounts/runtime-home-service.ts inline src/main/codex-accounts/service.ts -inline src/main/codex/config-toml-trust.ts inline src/main/codex/hook-service.ts inline src/main/daemon/daemon-init.ts -inline src/main/daemon/daemon-pty-adapter.ts -inline src/main/daemon/daemon-server.ts -inline src/main/daemon/pty-subprocess.ts -inline src/main/git/repo.ts inline src/main/git/runner.ts inline src/main/git/status.ts inline src/main/git/worktree.ts -inline src/main/github/pr-refresh-coordinator.ts inline src/main/github/project-view.ts -inline src/main/github/project-view/mutations.ts -inline src/main/github/work-item-details.ts -inline src/main/gitlab/client.ts inline src/main/index.ts inline src/main/ipc/filesystem-watcher.ts inline src/main/ipc/filesystem.ts -inline src/main/ipc/github.ts -inline src/main/ipc/pty.ts inline src/main/ipc/repos.ts inline src/main/ipc/ssh.ts inline src/main/ipc/worktree-remote.ts -inline src/main/ipc/worktrees.ts -inline src/main/jira/issues.ts inline src/main/keybindings/keybinding-file.ts inline src/main/linear/issues.ts -inline src/main/linear/projects.ts inline src/main/memory/collector.ts -inline src/main/opencode/hook-service.ts -inline src/main/persistence/loading-store/store.ts inline src/main/ports/advertised-url-watcher.ts inline src/main/ports/local-workspace-port-scanner.ts inline src/main/project-groups/nested-repo-discovery.ts inline src/main/providers/local-pty-provider.ts -inline src/main/providers/ssh-git-provider.ts inline src/main/rate-limits/claude-fetcher.ts inline src/main/rate-limits/claude-pty.ts inline src/main/rate-limits/codex-fetcher.ts @@ -65,7 +46,6 @@ inline src/main/runtime/orca-runtime-git.ts inline src/main/runtime/orca-runtime.test.ts inline src/main/runtime/orca-runtime.ts inline src/main/runtime/rpc/methods/orchestration.ts -inline src/main/runtime/rpc/methods/terminal.ts inline src/main/runtime/runtime-rpc.ts inline src/main/source-control/hosted-review-creation.ts inline src/main/speech/model-manager.ts @@ -78,22 +58,16 @@ inline src/main/text-generation/commit-message-text-generation.ts inline src/main/updater.ts inline src/main/window/attach-main-window-services.ts inline src/main/window/createMainWindow.ts -inline src/main/workspace-space-analysis.ts inline src/preload/index.ts inline src/relay/dispatcher.ts -inline src/relay/external-automations-handler.ts inline src/relay/git-handler.ts inline src/relay/pty-handler.ts -inline src/relay/relay.ts inline src/relay/workspace-space-scan.ts inline src/renderer/src/components/GitLabItemDialog.tsx inline src/renderer/src/components/JiraIssueWorkspace.tsx -inline src/renderer/src/components/LinearIssueWorkspace.tsx inline src/renderer/src/components/LinearItemDrawer.tsx -inline src/renderer/src/components/NewWorkspaceComposerCard.tsx inline src/renderer/src/components/TaskPage.tsx inline src/renderer/src/components/Terminal.tsx -inline src/renderer/src/components/UpdateCard.tsx inline src/renderer/src/components/WorktreeJumpPalette.tsx inline src/renderer/src/components/activity/ActivityPrototypePage.tsx inline src/renderer/src/components/automations/AutomationsPage.tsx @@ -101,26 +75,16 @@ inline src/renderer/src/components/editor/CombinedDiffViewer.tsx inline src/renderer/src/components/editor/EditorContent.tsx inline src/renderer/src/components/editor/IpynbViewer.tsx inline src/renderer/src/components/editor/MarkdownPreview.tsx -inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx -inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx -inline src/renderer/src/components/github-project/ProjectCell.tsx -inline src/renderer/src/components/github-project/ProjectPicker.tsx -inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx -inline src/renderer/src/components/linear-project-view-surfaces.tsx inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx inline src/renderer/src/components/onboarding/use-onboarding-flow.ts inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx inline src/renderer/src/components/right-sidebar/PortsPanel.tsx inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx inline src/renderer/src/components/settings/AccountsPane.tsx -inline src/renderer/src/components/settings/AgentsPane.tsx -inline src/renderer/src/components/settings/RepositoryHooksSection.tsx inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx inline src/renderer/src/components/settings/Settings.tsx -inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx -inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx inline src/renderer/src/components/status-bar/StatusBar.tsx @@ -132,9 +96,7 @@ inline src/renderer/src/components/terminal-pane/pty-transport.ts inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts inline src/renderer/src/hooks/useAutomationDispatchEvents.ts -inline src/renderer/src/hooks/useComposerState.ts inline src/renderer/src/hooks/useEditorExternalWatch.ts -inline src/renderer/src/hooks/useIpcEvents.ts inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -148,18 +110,12 @@ inline src/renderer/src/runtime/web-session-tabs-sync.ts inline src/renderer/src/store/slices/agent-status.ts inline src/renderer/src/store/slices/browser.ts inline src/renderer/src/store/slices/diffComments.ts -inline src/renderer/src/store/slices/github.ts inline src/renderer/src/store/slices/hosted-review.ts -inline src/renderer/src/store/slices/jira.ts inline src/renderer/src/store/slices/linear.ts inline src/renderer/src/store/slices/repos.ts inline src/renderer/src/store/slices/tabs.ts -inline src/renderer/src/store/slices/terminals.ts inline src/renderer/src/store/slices/ui.ts -inline src/renderer/src/store/slices/workspace-cleanup.ts -inline src/renderer/src/web/web-preload-api.ts inline src/renderer/src/web/web-runtime-client.ts -inline src/shared/agent-hook-listener.ts inline src/shared/automation-schedules.ts inline src/shared/commit-message-agent-spec.ts inline src/shared/constants.ts diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index 488b1bec409..88dccc5746d 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -348,6 +348,35 @@ function prunePackagedNodePty(resourcesDir, electronPlatformName, electronArch) return } + // Why delete only conpty.node: node-pty's loader tries build/Release, then + // build/Debug, then prebuilds/-, swallowing every failure in + // between. Only the source build carries Orca's job-object exports, so an ABI + // mismatch or an AV quarantine of build/Release/conpty.node would silently + // fall through to the UNPATCHED prebuild -- teardown back to guessing by PID + // ancestry, with no error anywhere. + // + // Why NOT the whole prebuilds/ tree: Orca's own patch deletes the + // `conpty_console_list` and winpty `pty` gyp targets, so a Windows source + // build emits conpty.node and nothing else. conpty_console_list.node, + // pty.node, winpty.dll and winpty-agent.exe exist ONLY here. Removing them + // silently kills console-membership probing (the forked agent throws at + // require, and its caller resolves null with silent: true), and removes the + // winpty backend that node-pty still selects below Windows build 18309. + // + // Why the arch check: a cross-arch package copies the host's build/Release, + // so its mere presence does not mean it matches electronArch -- deleting the + // target-arch prebuild would then remove the only loadable binary. + if ( + electronPlatformName === 'win32' && + electronArch === process.arch && + existsSync(join(nodePtyDir, 'build', 'Release', 'conpty.node')) + ) { + const prebuildDir = join(nodePtyDir, 'prebuilds', `win32-${electronArch}`) + for (const staleFallback of ['conpty.node', 'conpty.pdb']) { + rmSync(join(prebuildDir, staleFallback), { force: true }) + } + } + const allowedPrebuildPrefix = NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM[electronPlatformName] if (allowedPrebuildPrefix) { pruneNodePtyNativeDirectories( diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index 3daad4fde68..c5c35191f35 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -2,7 +2,12 @@ diff --git a/binding.gyp b/binding.gyp index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644 --- a/binding.gyp +++ b/binding.gyp -@@ -5,9 +5,6 @@ +@@ -1,13 +1,10 @@ + { + 'target_defaults': { + 'dependencies': [ +- " 0) { - console.error( - `[build-orcad] ${electronImporters.size} module(s) in the bundle import electron: -${[...electronImporters].map((f) => ` - ${f}`).join('\n')}` + graphErrors.push( + `${electronImporters.size} module(s) in the bundle import electron:\n${[...electronImporters] + .map((file) => ` - ${file}`) + .join('\n')}` ) +} +if (sqliteImporters.size > 0) { + graphErrors.push( + `${sqliteImporters.size} module(s) in the bundle import node:sqlite:\n${[...sqliteImporters] + .map((file) => ` - ${file}`) + .join('\n')}` + ) +} + +if (graphErrors.length > 0) { + console.error(`[build-orcad] ${graphErrors.join('\n')}`) // Why this can exceed the ratchet baseline: the ratchet measures the graph reachable // from orca-runtime + runtime-rpc, but this entry also imports ipc/pty directly to // install the PTY controller. Once orcad ships, it should become a ratchet entry // point so the two numbers cannot drift. process.exitCode = 1 } else { + // Why smoke-load and not just read the metafile: the import scan proves no module + // *names* electron, but a graph can still fail to resolve under plain Node — a + // dynamic require, a missing native, a top-level throw. The plain-node-entry-guard + // smoke-loads its entries for exactly this reason, and orcad cannot join that guard + // because it is an esbuild artifact rather than a rollup input. + const smoke = spawnSync(process.execPath, [OUT_FILE, '--orcad-smoke-load-check'], { + encoding: 'utf8', + timeout: 60_000 + }) + const smokeOutput = `${smoke.stdout ?? ''}${smoke.stderr ?? ''}` + if ( + smoke.error || + smoke.signal || + !/Unknown argument: --orcad-smoke-load-check/.test(smokeOutput) + ) { + console.error( + `[build-orcad] the bundle did not load under plain Node.\n` + + `Expected argv rejection, got signal=${smoke.signal ?? 'none'} ` + + `error=${smoke.error?.message ?? 'none'}\n${smokeOutput.slice(0, 2000)}` + ) + process.exitCode = 1 + } + const watcherFailure = await smokeLoadWatcherChild() + if (watcherFailure) { + console.error( + `[build-orcad] the watcher child did not run under plain Node.\n${watcherFailure}` + ) + process.exitCode = 1 + } +} + +if (process.exitCode !== 1) { console.log( - `[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron imports.` + `[build-orcad] ok — ${(output.bytes / 1024 / 1024).toFixed(2)} MB, ${Object.keys(output.inputs).length} modules, zero electron and node:sqlite imports.` ) } + +/** + * Fork the shipped watcher child and drive one message through it. + * + * Why a real fork and not existsSync: the file being present says nothing about whether + * its graph resolves under plain Node, and this child is only ever reached through + * `fork()` at runtime — a broken one degrades silently to in-process watching. + * `subscribe-started` is acked before the native module is touched, so this passes on a + * build machine with no compiled @parcel/watcher. + */ +async function smokeLoadWatcherChild() { + const probeDir = mkdtempSync(join(tmpdir(), 'orcad-watcher-smoke-')) + const child = fork(WATCHER_OUT_FILE, [], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }) + let stderr = '' + child.stderr?.on('data', (chunk) => { + stderr += String(chunk) + }) + try { + return await new Promise((resolve) => { + const timer = setTimeout(() => { + child.kill('SIGKILL') + resolve(`No 'subscribe-started' ack within 30s.\n${stderr.slice(0, 2000)}`) + }, 30_000) + const settle = (failure) => { + clearTimeout(timer) + resolve(failure) + } + child.on('message', (message) => { + if (message?.op === 'subscribe-started') { + child.disconnect() + } + }) + child.on('error', (error) => settle(`fork failed: ${error.message}`)) + // Why exit and not disconnect: the child exits 0 on disconnect, so a non-zero code + // or a signal here is a load failure rather than a clean teardown. + child.on('exit', (code, signal) => + settle(code === 0 ? null : `exit code=${code} signal=${signal}\n${stderr.slice(0, 2000)}`) + ) + child.send({ op: 'subscribe', id: 1, dir: probeDir, opts: {} }) + }) + } finally { + rmSync(probeDir, { recursive: true, force: true }) + } +} diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs index 2a3542e78b9..615a2402bcf 100644 --- a/config/scripts/check-changed-code-quality.mjs +++ b/config/scripts/check-changed-code-quality.mjs @@ -139,7 +139,99 @@ function diagnosticLineRange(root, filename, span) { return { start: startLine, end: startLine + (highlighted.match(/\n/g)?.length ?? 0) } } -export function diagnosticTouchesAddedLines(diagnostic, rangesByFile, root = process.cwd()) { +// Why: a file-splitting refactor makes every line of the new module an "added" +// line, so pre-existing lint debt in code that merely MOVED starts failing the +// changed-lines gate. The only way to satisfy it is to edit the moved code, +// which is exactly what a behavior-preserving refactor must not do. So a +// diagnostic is exempt when its highlighted lines already existed, verbatim and +// contiguous, somewhere in the base revision of the files this change touches. +function normalizeSourceLine(line) { + return line.replace(/\s+/g, ' ').trim() +} + +export function collectBaseLineBlocks(root, comparisonBase, files = null) { + // Why: in a split, the moved code's base text lives in the ORIGINAL file, which is + // often deleted or renamed away. Deleted paths never reach the changed-file list + // (it filters to ACMRTUB), so read every path the diff touches, deletions included. + const paths = + files ?? + splitNullDelimited(runGit(root, ['diff', '--name-only', '-z', comparisonBase, '--'])).filter( + (file) => SOURCE_FILE_PATTERN.test(file) + ) + const blocks = [] + for (const file of paths) { + const result = spawnSync('git', ['show', `${comparisonBase}:${file}`], { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024 + }) + if (result.status !== 0 || typeof result.stdout !== 'string') { + continue + } + blocks.push( + result.stdout + .split(/\r?\n/) + .map(normalizeSourceLine) + .filter((line) => line !== '') + ) + } + return blocks +} + +export function isMovedCode(highlightedLines, baseBlocks) { + const needle = highlightedLines.map(normalizeSourceLine).filter((line) => line !== '') + if (needle.length === 0) { + return false + } + // Why a near-match rather than an exact contiguous one: a split moves a block + // verbatim but a diagnostic's span often reaches past it — most commonly to a + // hook dependency array, which legitimately grows when closure variables become + // props. Requiring every line to match would report the moved body as new. So: + // the block must still start at the same line in the base and appear IN ORDER, + // and nearly all of it must be present. Genuinely new code shares neither the + // anchor nor the ordering, so it stays reported. + const MIN_COVERAGE = 0.9 + return baseBlocks.some((rawHaystack) => { + const haystack = rawHaystack.map(normalizeSourceLine).filter((line) => line !== '') + for (let start = 0; start < haystack.length; start += 1) { + if (haystack[start] !== needle[0]) { + continue + } + let matched = 1 + let cursor = start + 1 + for (let index = 1; index < needle.length && cursor < haystack.length; index += 1) { + while (cursor < haystack.length && haystack[cursor] !== needle[index]) { + cursor += 1 + } + if (cursor < haystack.length) { + matched += 1 + cursor += 1 + } + } + if (matched / needle.length >= MIN_COVERAGE) { + return true + } + } + return false + }) +} + +function diagnosticHighlightedLines(root, filename, span) { + const absolutePath = path.isAbsolute(filename) ? filename : path.join(root, filename) + const source = readFileSync(absolutePath, 'utf8').split(/\r?\n/) + const range = diagnosticLineRange(root, filename, span) + if (range === null) { + return [] + } + return source.slice(range.start - 1, range.end) +} + +export function diagnosticTouchesAddedLines( + diagnostic, + rangesByFile, + root = process.cwd(), + baseBlocks = [] +) { const file = normalizedDiagnosticPath(root, diagnostic.filename) const ranges = rangesByFile.get(file) if (!ranges) { @@ -147,7 +239,13 @@ export function diagnosticTouchesAddedLines(diagnostic, rangesByFile, root = pro } return (diagnostic.labels ?? []).some((label) => { const lineRange = diagnosticLineRange(root, diagnostic.filename, label.span) - return lineRange !== null && overlapsAddedLines(lineRange.start, lineRange.end, ranges) + if (lineRange === null || !overlapsAddedLines(lineRange.start, lineRange.end, ranges)) { + return false + } + return !isMovedCode( + diagnosticHighlightedLines(root, diagnostic.filename, label.span), + baseBlocks + ) }) } @@ -193,10 +291,12 @@ export function main( return 0 } + const baseBlocks = collectBaseLineBlocks(root, comparisonBase) + let failures = 0 for (const scan of OXLINT_SCANS) { const diagnostics = runOxlintScan(root, scan, files).filter((diagnostic) => - diagnosticTouchesAddedLines(diagnostic, rangesByFile, root) + diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks) ) for (const diagnostic of diagnostics) { printDiagnostic(diagnostic, root) diff --git a/config/scripts/check-changed-code-quality.test.mjs b/config/scripts/check-changed-code-quality.test.mjs index 5daca8da49e..76a25802e5c 100644 --- a/config/scripts/check-changed-code-quality.test.mjs +++ b/config/scripts/check-changed-code-quality.test.mjs @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { OXLINT_SCANS, diagnosticTouchesAddedLines, + isMovedCode, overlapsAddedLines, parseAddedLineRanges } from './check-changed-code-quality.mjs' @@ -52,3 +53,54 @@ describe('changed-code quality line matching', () => { expect(scan.args).not.toContain('--disable-nested-config') }) }) + +describe('moved-code exemption', () => { + it('treats a verbatim contiguous block from the base as moved', () => { + const base = [['const a = 1', 'items.map((item, index) => (', 'key={index}', '))']] + expect(isMovedCode(['items.map((item, index) => (', 'key={index}', '))'], base)).toBe(true) + }) + + it('ignores indentation and whitespace changes from the move', () => { + const base = [[' items.map((item, index) => (', ' key={index}']] + expect(isMovedCode(['items.map((item, index) => (', 'key={index}'], base)).toBe(true) + }) + + it('does not exempt a genuinely new violation', () => { + const base = [['const a = 1', 'const b = 2']] + expect(isMovedCode(['rows.map((row, i) => )'], base)).toBe(false) + }) + + it('does not exempt a block that is only partly present in the base', () => { + const base = [['doThing()', 'unrelated()']] + expect(isMovedCode(['doThing()', 'newlyAddedSideEffect()'], base)).toBe(false) + }) + + it('tolerates a few lines appended inside the moved block', () => { + // A split commonly grows a hook dependency array when closure variables + // become props; the moved body around it is still moved. + const body = Array.from({ length: 20 }, (_, i) => `line${i}()`) + const base = [body] + const moved = [...body.slice(0, 19), 'newDep,', body[19]] + expect(isMovedCode(moved, base)).toBe(true) + }) + + it('does not exempt when the anchor line is absent from the base', () => { + const base = [['doThing()', 'filler()', 'other()']] + expect(isMovedCode(['brandNewCall()', 'doThing()', 'other()'], base)).toBe(false) + }) + + it('does not exempt when most of the block is absent from the base', () => { + const base = [['keep0()', 'keep1()', 'unrelated()']] + const mostlyNew = ['keep0()', ...Array.from({ length: 18 }, (_, i) => `fresh${i}()`)] + expect(isMovedCode(mostlyNew, base)).toBe(false) + }) + + it('ignores blank lines when matching', () => { + const base = [['a()', 'b()']] + expect(isMovedCode(['a()', '', 'b()'], base)).toBe(true) + }) + + it('never exempts an empty highlight', () => { + expect(isMovedCode(['', ' '], [['a()']])).toBe(false) + }) +}) diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs index c50d5df8f46..7c165bfc02b 100644 --- a/config/scripts/check-runtime-electron-ratchet.mjs +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -34,7 +34,11 @@ const BASELINE_PATH = path.join(ROOT, 'config', 'runtime-electron-baseline.txt') // itself and the RPC server that fronts it. const ENTRY_POINTS = [ path.join(ROOT, 'src', 'main', 'runtime', 'orca-runtime.ts'), - path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts') + path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts'), + // Why orcad too: it imports ipc/pty directly to install the PTY controller, so its + // graph is strictly larger than the two runtime entries. Measuring only those let the + // two numbers drift — the gate would read zero while the shipped artifact regressed. + path.join(ROOT, 'src', 'main', 'orcad', 'main.ts') ] // Native addons and electron cannot be bundled; externalising them is what the diff --git a/config/scripts/ensure-native-runtime-job-ownership.test.mjs b/config/scripts/ensure-native-runtime-job-ownership.test.mjs new file mode 100644 index 00000000000..91b4c5d328d --- /dev/null +++ b/config/scripts/ensure-native-runtime-job-ownership.test.mjs @@ -0,0 +1,67 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs') +const NODE_PTY_PATCH = readFileSync( + new URL('../patches/node-pty@1.1.0.patch', import.meta.url), + 'utf8' +) + +const PATCHED = { + dir: 'build/Release/', + module: { + listJobProcessIds: () => [], + terminateJob: () => true, + assignCurrentProcessToJob: () => true + } +} +const PREBUILD = { + dir: 'prebuilds/win32-x64/', + module: { + startProcess: () => {}, + connect: () => {}, + resize: () => {}, + clear: () => {}, + kill: () => {} + } +} + +describe('assertNodePtyJobOwnership', () => { + it('keeps node-addon-api project paths absolute during Windows source builds', () => { + expect(NODE_PTY_PATCH).toContain( + `+ " { + expect(NODE_PTY_PATCH).toContain("- 'target_name': 'conpty_console_list'") + expect(NODE_PTY_PATCH).toContain("- 'target_name': 'pty'") + }) + + it('rejects the prebuild that shipped without the job exports', () => { + expect(() => + assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD }) + ).toThrow(/listJobProcessIds, terminateJob, assignCurrentProcessToJob/) + }) + + it('names where the bad native came from, so the fix is obvious', () => { + expect(() => + assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD }) + ).toThrow(/prebuilds\/win32-x64/) + }) + + it('accepts a source build carrying the patch', () => { + expect(() => + assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PATCHED }) + ).not.toThrow() + }) + + it.each([ + ['non-Windows hosts', { platform: 'darwin', nativeName: 'pty' }], + ['the pre-ConPTY winpty backend', { platform: 'win32', nativeName: 'pty' }] + ])('stays out of the way on %s', (_case, spec) => { + expect(() => assertNodePtyJobOwnership({ ...spec, native: PREBUILD })).not.toThrow() + }) +}) diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index 941049472fb..b570ac0e7ae 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -7,6 +7,7 @@ import { release } from 'node:os' import { basename, resolve } from 'node:path' const require = createRequire(import.meta.url) +const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs') const scriptPath = import.meta.filename const projectDir = resolve(import.meta.dirname, '../..') const runtime = readRuntimeArg() @@ -277,6 +278,7 @@ function loadNodePtyNativeModule() { // terminal is created, so require('node-pty') alone can miss ABI mismatches. const native = loadNativeModule(nativeName) assertNodePtyWindowsConptyRuntime(native?.dir) + assertNodePtyJobOwnership({ nativeName, native }) if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native?.dir)) { throw new Error( `node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active` diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index 0bc8e5ae077..d1ea104b863 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -14,6 +14,9 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' const sourceScriptPath = fileURLToPath(new URL('./ensure-native-runtime.mjs', import.meta.url)) +const sourceNodePtyJobOwnershipPath = fileURLToPath( + new URL('./node-pty-job-ownership.cjs', import.meta.url) +) describe('ensure-native-runtime', () => { it('rechecks Node native modules in fresh child processes after rebuilding', () => { @@ -156,6 +159,10 @@ describe('ensure-native-runtime', () => { function mkTempProject() { const projectDir = mkdtempSync(join(tmpdir(), 'orca-native-runtime-')) mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true }) + copyFileSync( + sourceNodePtyJobOwnershipPath, + join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs') + ) return projectDir } diff --git a/config/scripts/node-pty-job-ownership.cjs b/config/scripts/node-pty-job-ownership.cjs new file mode 100644 index 00000000000..35884c1414a --- /dev/null +++ b/config/scripts/node-pty-job-ownership.cjs @@ -0,0 +1,29 @@ +'use strict' + +const NODE_PTY_JOB_EXPORTS = [ + 'listJobProcessIds', + 'terminateJob', + 'assignCurrentProcessToJob' +] + +function assertNodePtyJobOwnership({ nativeName, native, platform = process.platform }) { + if (platform !== 'win32' || nativeName !== 'conpty') { + return + } + const exported = native?.module ?? native + const missing = NODE_PTY_JOB_EXPORTS.filter((name) => typeof exported?.[name] !== 'function') + if (missing.length === 0) { + return + } + throw new Error( + [ + `node-pty's conpty native is missing ${missing.join(', ')}.`, + `Resolved from: ${native?.dir ?? 'unknown'}`, + 'That build cannot own a PTY tree, so terminatePtyJob degrades to "unavailable"', + 'and pane teardown falls back to guessing by PID ancestry.', + 'Rebuild node-pty from source so config/patches/node-pty@1.1.0.patch applies.' + ].join(' ') + ) +} + +module.exports = { assertNodePtyJobOwnership } diff --git a/config/scripts/packaged-node-pty-prebuild-prune.test.mjs b/config/scripts/packaged-node-pty-prebuild-prune.test.mjs new file mode 100644 index 00000000000..c0b24b8a8a2 --- /dev/null +++ b/config/scripts/packaged-node-pty-prebuild-prune.test.mjs @@ -0,0 +1,105 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { prunePackagedNodePty } = require('../packaged-runtime-node-modules.cjs') + +/** + * node-pty's loader tries build/Release, then build/Debug, then + * prebuilds/-, swallowing failures in between. Only the source + * build carries Orca's job-object exports, so leaving the prebuilt conpty.node + * beside it means an ABI mismatch or an AV quarantine silently downgrades the + * app to a binary that cannot own a PTY tree. + * + * The siblings must survive: Orca's patch deletes the `conpty_console_list` and + * winpty `pty` gyp targets, so those binaries exist nowhere but here. + */ +describe('prunePackagedNodePty: the Windows conpty fallback', () => { + let resources + const HOST_ARCH = process.arch + + const nodePty = () => join(resources, 'node_modules', 'node-pty') + const write = (relative) => { + const target = join(nodePty(), relative) + mkdirSync(join(target, '..'), { recursive: true }) + writeFileSync(target, 'x') + } + const prebuilt = (name, arch = HOST_ARCH) => join(nodePty(), 'prebuilds', `win32-${arch}`, name) + + /** What a real packaged win32 prebuilds/ directory holds. */ + const SIBLINGS = ['conpty_console_list.node', 'pty.node', 'winpty.dll', 'winpty-agent.exe'] + + const seedWindowsTree = (arch = HOST_ARCH) => { + write(join('build', 'Release', 'conpty.node')) + write(join('third_party', 'conpty', 'v1', `win10-${arch}`, 'conpty.dll')) + write(join('third_party', 'conpty', 'v1', `win10-${arch}`, 'OpenConsole.exe')) + write(join('prebuilds', `win32-${arch}`, 'conpty.node')) + for (const sibling of SIBLINGS) { + write(join('prebuilds', `win32-${arch}`, sibling)) + } + } + + beforeEach(() => { + resources = mkdtempSync(join(tmpdir(), 'orca-prune-')) + }) + afterEach(() => { + rmSync(resources, { recursive: true, force: true }) + }) + + it('removes the stale conpty fallback when the source build is present', () => { + seedWindowsTree() + + prunePackagedNodePty(resources, 'win32', HOST_ARCH) + + expect(existsSync(join(nodePty(), 'build', 'Release', 'conpty.node'))).toBe(true) + expect(existsSync(prebuilt('conpty.node'))).toBe(false) + }) + + it.each(SIBLINGS)('keeps %s, which exists nowhere else in a packaged build', (sibling) => { + // Orca's patch removes the conpty_console_list and winpty gyp targets, so a + // Windows source build never produces these. Deleting them kills console + // membership silently and breaks PTY spawn below Windows build 18309. + seedWindowsTree() + + prunePackagedNodePty(resources, 'win32', HOST_ARCH) + + expect(existsSync(prebuilt(sibling))).toBe(true) + }) + + it('keeps the fallback when there is no source build to prefer', () => { + write(join('prebuilds', `win32-${HOST_ARCH}`, 'conpty.node')) + write(join('third_party', 'conpty', 'v1', `win10-${HOST_ARCH}`, 'conpty.dll')) + write(join('third_party', 'conpty', 'v1', `win10-${HOST_ARCH}`, 'OpenConsole.exe')) + + prunePackagedNodePty(resources, 'win32', HOST_ARCH) + + expect(existsSync(prebuilt('conpty.node'))).toBe(true) + }) + + it('keeps the fallback on a cross-arch package, where build/Release is the host arch', () => { + // electron-builder --win --arm64 on an x64 host copies an x64 + // build/Release; deleting the arm64 prebuild would remove the only binary + // the shipped app could load. + const target = HOST_ARCH === 'arm64' ? 'x64' : 'arm64' + seedWindowsTree(target) + + prunePackagedNodePty(resources, 'win32', target) + + expect(existsSync(prebuilt('conpty.node', target))).toBe(true) + }) + + it.each([ + ['darwin', 'arm64'], + ['linux', 'x64'] + ])('leaves %s prebuilds alone', (platform, arch) => { + write(join('build', 'Release', 'conpty.node')) + write(join('prebuilds', `${platform}-${arch}`, 'pty.node')) + + prunePackagedNodePty(resources, platform, arch) + + expect(existsSync(join(nodePty(), 'prebuilds', `${platform}-${arch}`, 'pty.node'))).toBe(true) + }) +}) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index f529849447b..42540f6bcb4 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -345,6 +345,7 @@ describe('PR workflow parallelism', () => { 'xterm_patch_sync', 'shell_contracts', 'test', + 'orcad_browser', 'managed_hook_node18', 'package', 'package_windows' @@ -354,5 +355,9 @@ describe('PR workflow parallelism', () => { ) expect(verifyStep.env.MANAGED_HOOK_NODE18).toBe('${{ needs.managed_hook_node18.result }}') expect(verifyStep.run).toContain('"$MANAGED_HOOK_NODE18"') + // Why assert this one too: the browser provider test skips itself without + // ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it. + expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}') + expect(verifyStep.run).toContain('"$ORCAD_BROWSER"') }) }) diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 6d776abd1ca..78e25d3634c 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -475,9 +475,14 @@ function loadNativeModule(moduleName) { } if (moduleName === 'node-pty') { projectRequire('node-pty') + const { assertNodePtyJobOwnership } = projectRequire( + './config/scripts/node-pty-job-ownership.cjs' + ) const { loadNativeModule } = projectRequire('node-pty/lib/utils') - const native = loadNativeModule(getNodePtyNativeModuleName()) + const nativeName = getNodePtyNativeModuleName() + const native = loadNativeModule(nativeName) assertNodePtyWindowsConptyRuntime(native.dir) + assertNodePtyJobOwnership({ nativeName, native }) if (requirePatchedNodePtySourceBuild && !isNodePtyReleaseBuildDir(native.dir)) { throw new Error( 'node-pty resolved to ' + diff --git a/config/scripts/rebuild-native-deps.test.mjs b/config/scripts/rebuild-native-deps.test.mjs index 0ab28de7a65..b158ee57f21 100644 --- a/config/scripts/rebuild-native-deps.test.mjs +++ b/config/scripts/rebuild-native-deps.test.mjs @@ -18,6 +18,9 @@ const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', impo const sourceInstallScriptPath = fileURLToPath( new URL('./install-electron-package-binary.mjs', import.meta.url) ) +const sourceNodePtyJobOwnershipPath = fileURLToPath( + new URL('./node-pty-job-ownership.cjs', import.meta.url) +) describe('rebuild-native-deps Electron install fallback', () => { it('continues non-strict postinstall when Electron retry download fails', () => { @@ -135,6 +138,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' }) writeFakeWindowsRegistry(projectDir) + writeFakeWindowsProcessTree(projectDir) writeFakeNodePtyConptyPayload(projectDir, process.arch) const result = runRebuildScript(projectDir, { @@ -189,6 +193,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) writeFakeLoadableNodePty(projectDir) + writeFakeWindowsProcessTree(projectDir) writeFakeNodePtyConptyPayload(projectDir, process.arch) const result = runRebuildScript(projectDir, { @@ -207,6 +212,36 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { } ) + it.skipIf(process.platform !== 'win32')( + 'rebuilds a loadable ConPTY native that lacks Orca job ownership', + () => { + const projectDir = mkTempProject() + + try { + const rebuildLogPath = join(projectDir, 'electron-rebuild.log') + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + writeFakeLoadableNodePty(projectDir, { ownsPtyJob: false }) + writeFakeWindowsRegistry(projectDir) + writeFakeWindowsProcessTree(projectDir) + + const result = runRebuildScript(projectDir, { + ORCA_REBUILD_TEST_LOG: rebuildLogPath, + npm_config_platform: 'win32', + npm_config_arch: process.arch + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('Rebuilding failed native modules: node-pty') + expect(result.stdout).toContain('missing listJobProcessIds') + const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) + expect(rebuildCall.onlyModules).toEqual(['node-pty']) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + } + ) + it.skipIf(process.platform === 'win32')( 'rebuilds when Electron can load node-pty but patched build artifacts are missing', () => { @@ -305,6 +340,10 @@ function mkTempProject() { sourceInstallScriptPath, join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs') ) + copyFileSync( + sourceNodePtyJobOwnershipPath, + join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs') + ) return projectDir } @@ -495,7 +534,10 @@ function writeFakeNodePtyConptyPayload(projectDir, arch) { writeFileSync(join(sourceDir, 'OpenConsole.exe'), `OpenConsole.exe ${arch}`) } -function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } = {}) { +function writeFakeLoadableNodePty( + projectDir, + { nativeDir = 'prebuilds/pty', ownsPtyJob = true } = {} +) { const nodePtyDir = join(projectDir, 'node_modules', 'node-pty') mkdirSync(join(nodePtyDir, 'lib'), { recursive: true }) writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n') @@ -503,7 +545,19 @@ function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } = join(nodePtyDir, 'lib', 'utils.js'), ` exports.loadNativeModule = function loadNativeModule(nativeName) { - return { dir: ${JSON.stringify(nativeDir)}, module: { nativeName } } + return { + dir: ${JSON.stringify(nativeDir)}, + module: { + nativeName, + ...(${JSON.stringify(ownsPtyJob)} + ? { + listJobProcessIds() {}, + terminateJob() {}, + assignCurrentProcessToJob() {} + } + : {}) + } + } } ` ) @@ -518,6 +572,12 @@ function writeFakeWindowsRegistry(projectDir) { ) } +function writeFakeWindowsProcessTree(projectDir) { + const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') + mkdirSync(processTreeDir, { recursive: true }) + writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n') +} + function writeNodePtyPatchFile(projectDir) { mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true }) writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n') diff --git a/config/scripts/runtime-serve-terminal-smoke.mjs b/config/scripts/runtime-serve-terminal-smoke.mjs index 21b6cea6b0b..a03c85983d8 100644 --- a/config/scripts/runtime-serve-terminal-smoke.mjs +++ b/config/scripts/runtime-serve-terminal-smoke.mjs @@ -18,11 +18,14 @@ * - a paired client can list worktrees and create a terminal, * - a command run in that terminal produces its output, * - the server exits when asked. + * Optional `--browser` assertions: + * - create, navigate, evaluate, and screenshot through the selected host provider. */ import { spawn, spawnSync } from 'node:child_process' import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' import { randomBytes } from 'node:crypto' import process from 'node:process' @@ -274,6 +277,50 @@ async function main() { throw new Error('worktree.create succeeded but worktree.show cannot resolve it') } log(`server resolves ${shown.id}`) + if (process.argv.includes('--browser')) { + const status = orca(pairingCode, ['status']) + if (!status?.runtime?.capabilities?.includes('browser.headless.v1')) { + throw new Error( + `status omitted browser.headless.v1: ${JSON.stringify(status?.runtime?.capabilities)}` + ) + } + const fixturePath = join(userDataDir, 'browser-smoke.html') + writeFileSync( + fixturePath, + 'Orcad Browser Smoke
browser-ready
' + ) + const browserPageId = orca(pairingCode, [ + 'tab', + 'create', + '--worktree', + created.id, + '--url', + 'about:blank' + ])?.browserPageId + if (!browserPageId) { + throw new Error('browser.tabCreate returned no browser page id') + } + const targetFlags = ['--worktree', created.id, '--page', browserPageId] + const targetUrl = pathToFileURL(fixturePath).href + const navigation = orca(pairingCode, ['goto', ...targetFlags, '--url', targetUrl]) + if (navigation?.url !== targetUrl || navigation?.title !== 'Orcad Browser Smoke') { + throw new Error(`browser.goto returned the wrong page: ${JSON.stringify(navigation)}`) + } + const evaluated = orca(pairingCode, [ + 'eval', + ...targetFlags, + '--expression', + 'document.querySelector("main")?.textContent' + ]) + if (evaluated?.result !== 'browser-ready') { + throw new Error(`browser.eval returned ${JSON.stringify(evaluated)}`) + } + const screenshot = orca(pairingCode, ['screenshot', ...targetFlags]) + if (screenshot?.format !== 'png' || typeof screenshot.data !== 'string' || !screenshot.data) { + throw new Error('browser.screenshot returned no PNG data') + } + log('browser navigate/evaluate/screenshot round trip OK') + } const terminal = orca(pairingCode, ['terminal', 'create', '--worktree', created.id])?.terminal if (!terminal?.handle) { diff --git a/config/scripts/verify-packaged-node-pty-job-ownership.cjs b/config/scripts/verify-packaged-node-pty-job-ownership.cjs new file mode 100644 index 00000000000..5ba21cee8ec --- /dev/null +++ b/config/scripts/verify-packaged-node-pty-job-ownership.cjs @@ -0,0 +1,25 @@ +const { createRequire } = require('node:module') +const { join } = require('node:path') +const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs') + +function loadPackagedConpty(resourcesDir) { + const packagedRequire = createRequire(join(resourcesDir, 'package.json')) + const { loadNativeModule } = packagedRequire('./node_modules/node-pty/lib/utils') + return loadNativeModule('conpty') +} + +function verifyPackagedNodePtyJobOwnership(resourcesDir, options = {}) { + const platform = options.platform ?? process.platform + if (platform !== 'win32') { + return + } + + const native = (options.loadNative ?? loadPackagedConpty)(resourcesDir) + assertNodePtyJobOwnership({ platform, nativeName: 'conpty', native }) + if (!native.dir.replace(/\\/g, '/').includes('build/Release/')) { + throw new Error(`Packaged node-pty resolved to ${native.dir}; expected patched build/Release`) + } + console.log('[verify-packaged-node-pty] OK — packaged ConPTY owns process trees') +} + +module.exports = { verifyPackagedNodePtyJobOwnership } diff --git a/config/scripts/verify-packaged-node-pty-job-ownership.test.mjs b/config/scripts/verify-packaged-node-pty-job-ownership.test.mjs new file mode 100644 index 00000000000..26a33cfbbd6 --- /dev/null +++ b/config/scripts/verify-packaged-node-pty-job-ownership.test.mjs @@ -0,0 +1,51 @@ +import { createRequire } from 'node:module' +import { describe, expect, it, vi } from 'vitest' + +const require = createRequire(import.meta.url) +const { + verifyPackagedNodePtyJobOwnership +} = require('./verify-packaged-node-pty-job-ownership.cjs') + +const PATCHED = { + dir: '../build/Release/', + module: { + listJobProcessIds: () => [], + terminateJob: () => true, + assignCurrentProcessToJob: () => true + } +} + +describe('verifyPackagedNodePtyJobOwnership', () => { + it('accepts the packaged patched ConPTY binding', () => { + expect(() => + verifyPackagedNodePtyJobOwnership('resources', { + platform: 'win32', + loadNative: () => PATCHED + }) + ).not.toThrow() + }) + + it('rejects a packaged upstream prebuild', () => { + expect(() => + verifyPackagedNodePtyJobOwnership('resources', { + platform: 'win32', + loadNative: () => ({ dir: '../prebuilds/win32-x64/', module: {} }) + }) + ).toThrow(/missing listJobProcessIds, terminateJob, assignCurrentProcessToJob/) + }) + + it('requires the patched source-build directory', () => { + expect(() => + verifyPackagedNodePtyJobOwnership('resources', { + platform: 'win32', + loadNative: () => ({ ...PATCHED, dir: '../prebuilds/win32-x64/' }) + }) + ).toThrow(/expected patched build\/Release/) + }) + + it('does not load Windows natives for other targets', () => { + const loadNative = vi.fn() + verifyPackagedNodePtyJobOwnership('resources', { platform: 'linux', loadNative }) + expect(loadNative).not.toHaveBeenCalled() + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 42b3d0772d0..65116437cc1 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -46,6 +46,7 @@ "../src/main/codex/codex-managed-trust-reconciliation.ts", "../src/main/codex/codex-process-exit-deadline.ts", "../src/main/codex/codex-state-db.ts", + "../src/main/codex/codex-trust-identity.ts", "../src/main/codex/codex-trust-config-rollback.ts", "../src/main/codex/codex-trust-grant-telemetry.ts", "../src/main/codex/codex-trust-grant-host.ts", @@ -58,10 +59,16 @@ "../src/main/codex/config-settings-promotion.ts", "../src/main/codex/config-settings-promotion-write-target.ts", "../src/main/codex/config-sync-stall.ts", + "../src/main/codex/config-toml-atomic-write.ts", "../src/main/codex/config-toml-deprecated-hook-flag.ts", + "../src/main/codex/config-toml-hook-trust-blocks.ts", + "../src/main/codex/config-toml-hook-trust-edit.ts", + "../src/main/codex/config-toml-hook-trust-read.ts", "../src/main/codex/config-toml-key-path.ts", "../src/main/codex/config-toml-line-scan.ts", + "../src/main/codex/config-toml-project-trust.ts", "../src/main/codex/config-toml-runtime-owned-sections.ts", + "../src/main/codex/config-toml-syntax.ts", "../src/main/codex/config-toml-trust.ts", "../src/main/codex/hook-service.ts", "../src/main/codex/hook-trust-promotion.ts", diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 33a5e63807a..73785a3cce9 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -1,1362 +1,3 @@ -import { useState, useCallback, useEffect, useMemo, useRef } from 'react' -import { View, Text, StyleSheet, Pressable, FlatList, Alert } from 'react-native' -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' -import { useRouter, useFocusEffect } from 'expo-router' -import { QrCode, Settings, ChevronRight, Terminal, ListTodo } from 'lucide-react-native' -import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons' -import { - type AccountsSnapshot, - type ProviderKey, - decodeAccountsSnapshot, - getActiveProviderRateLimits, - getUsageBarState, - hasActiveProviderUsage, - hasRenderableUsage, - UsageBar -} from '../src/components/AccountUsage' -import AsyncStorage from '@react-native-async-storage/async-storage' -import { loadHostCatalog } from '../src/transport/host-store' -import { selectConnectableHostProfiles } from '../src/transport/host-catalog-selection' -import { useOpenMobileHostEdit } from '../src/transport/use-open-mobile-host-edit' -import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' -import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch' -import { totalHomeStats, type HomeStatsSummary } from '../src/stats/home-stats-total' -import type { HomeWorktreeSummary, HostWorktreeInfo } from '../src/worktree/home-worktree-info' -import type { RpcClient } from '../src/transport/rpc-client' -import { createHostConnectRefetchGate } from '../src/transport/host-connect-refetch-gate' -import { sendSingleFlightRequest } from '../src/transport/request-single-flight' -import { - useDisconnectHostClient, - useForceReconnect, - useForgetHostClient, - usePrimeHosts -} from '../src/transport/client-context' -import { useAllHostClients } from '../src/transport/use-all-host-clients' -import { - resolveHomeHostConnectionState, - selectHomeAutoConnectHostIds -} from '../src/transport/home-host-auto-connect' -import { classifyConnection } from '../src/transport/connection-health' -import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications' -import { - loadMobileOnboardingSteps, - mobileOnboardingDestination -} from '../src/onboarding/mobile-onboarding-plan' -import type { ConnectionState, HostCatalogEntry, HostProfile } from '../src/transport/types' -import { triggerMediumImpact } from '../src/platform/haptics' -import { OrcaLogo } from '../src/components/OrcaLogo' -import { MobileHostCard } from '../src/components/MobileHostCard' -import { MobileHomeQuickActions } from '../src/components/MobileHomeQuickActions' -import { TaskProviderLogo } from '../src/components/TaskProviderLogo' -import { ActionSheetModal } from '../src/components/ActionSheetModal' -import { getHostListActionSheetActions } from '../src/host-list-action-sheet-actions' -import { ConfirmModal } from '../src/components/ConfirmModal' -import { - setCachedWorktrees, - getCachedWorktrees, - getProvenCachedWorktrees -} from '../src/cache/worktree-cache' -import { - LAST_VISITED_WORKTREE_STORAGE_KEY, - readLastVisitedWorktreeRecord -} from '../src/worktree/last-visited-worktree-repo' -import { loadHomeSnapshot, saveHomeSnapshot } from '../src/cache/home-snapshot-cache' -import { colors, spacing, radii } from '../src/theme/mobile-theme' -import { - filterAvailableTaskProviders, - normalizeVisibleTaskProviders, - type TaskProvider -} from '../src/tasks/mobile-task-providers' -import { useOpenMobileTasks } from '../src/tasks/use-open-mobile-tasks' -import { useResponsiveLayout } from '../src/layout/responsive-layout' -import { useOpenMobileSession } from '../src/session/use-open-mobile-session' -import { useOpenMobileAccounts } from '../src/accounts/use-open-mobile-accounts' -import { - isResumeTargetConfirmedMissing, - selectHomeResumeCard, - type HomeResumeCard -} from '../src/worktree/home-resume-card' -import { hostRouteWithNotice } from '../src/host-route-notice' -import { hostNewWorktreeRoute } from '../src/host-route-action-state' -import { hostEndpointLabel } from '../src/transport/host-endpoint-label' +import { MobileHomeScreen } from '../src/home/MobileHomeScreen' -type HomeTaskSettings = { - visibleTaskProviders?: unknown -} - -type HomePreflightStatus = { - glab?: { installed?: boolean } -} - -type HomeLinearStatus = { - connected?: boolean -} - -const TASK_PROVIDER_LABELS: Record = { - github: 'GitHub', - gitlab: 'GitLab', - linear: 'Linear' -} - -function formatDuration(ms: number): string { - const totalMinutes = Math.floor(ms / 60_000) - const totalHours = Math.floor(totalMinutes / 60) - const days = Math.floor(totalHours / 24) - const hours = totalHours % 24 - if (days > 0) { - return `${days}d ${hours}h` - } - const minutes = totalMinutes % 60 - if (totalHours > 0) { - return `${totalHours}h ${minutes}m` - } - return `${totalMinutes}m` -} - -// Why: stable per-instance RpcClient identity so wireUp's dep key changes when forceReconnect swaps the client, re-attaching listeners. -const clientIdentities = new WeakMap() -let nextClientIdentity = 1 -function clientKey(client: RpcClient): number { - let id = clientIdentities.get(client) - if (id == null) { - id = nextClientIdentity++ - clientIdentities.set(client, id) - } - return id -} - -function fetchStats( - client: RpcClient, - hostId: string, - setStats: ( - updater: (prev: Record) => Record - ) => void, - disposed: () => boolean -) { - sendSingleFlightRequest(client, hostId, 'stats.summary') - .then((response) => { - if (disposed()) { - return - } - if (response.ok) { - // Keyed by host: the header totals every desktop instead of showing whoever replied last. - setStats((prev) => ({ ...prev, [hostId]: response.result as HomeStatsSummary })) - } - }) - .catch(() => {}) -} - -function fetchAccountsSnapshot( - client: RpcClient, - hostId: string, - setSnapshots: ( - updater: (prev: Record) => Record - ) => void, - disposed: () => boolean -) { - sendSingleFlightRequest(client, hostId, 'accounts.list') - .then((response) => { - if (disposed()) { - return - } - if (response.ok) { - const snapshot = decodeAccountsSnapshot(response.result) - setSnapshots((prev) => ({ ...prev, [hostId]: snapshot })) - } - }) - .catch(() => {}) -} - -function fetchTaskProviders( - client: RpcClient, - hostId: string, - setProviders: ( - updater: (prev: Record) => Record - ) => void, - disposed: () => boolean -) { - Promise.all([ - sendSingleFlightRequest(client, hostId, 'settings.get'), - sendSingleFlightRequest(client, hostId, 'preflight.check'), - sendSingleFlightRequest(client, hostId, 'linear.status') - ]) - .then(([settingsResponse, preflightResponse, linearResponse]) => { - if (disposed()) { - return - } - const settings = settingsResponse.ok - ? (((settingsResponse.result as { settings?: HomeTaskSettings }).settings ?? - {}) as HomeTaskSettings) - : {} - const preflight = preflightResponse.ok - ? (preflightResponse.result as HomePreflightStatus) - : null - const linear = linearResponse.ok ? (linearResponse.result as HomeLinearStatus) : null - const providers = filterAvailableTaskProviders( - normalizeVisibleTaskProviders(settings.visibleTaskProviders), - { - gitlabInstalled: preflight?.glab?.installed === true, - linearConnected: linear?.connected === true - } - ) - setProviders((prev) => ({ ...prev, [hostId]: providers })) - }) - .catch(() => { - if (disposed()) { - return - } - setProviders((prev) => (prev[hostId] ? prev : { ...prev, [hostId]: ['github'] })) - }) -} - -// Why: hash repo name to a stable color, matching the host detail page's dots. -const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'] -function repoColor(name: string): string { - let hash = 0 - for (let i = 0; i < name.length; i++) { - hash = (hash * 31 + name.charCodeAt(i)) | 0 - } - return REPO_COLORS[Math.abs(hash) % REPO_COLORS.length] -} - -export default function HomeScreen() { - const router = useRouter() - const openMobileHostEdit = useOpenMobileHostEdit() - const openMobileTasks = useOpenMobileTasks() - const openMobileSession = useOpenMobileSession() - const openMobileAccounts = useOpenMobileAccounts() - const insets = useSafeAreaInsets() - // Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad. - const { isWideLayout, contentMaxWidth } = useResponsiveLayout() - const [hostCatalog, setHostCatalog] = useState([]) - const [actionTarget, setActionTarget] = useState(null) - const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null) - const [hostStates, setHostStates] = useState>({}) - const [hostAttempts, setHostAttempts] = useState>({}) - const [hostLastConnected, setHostLastConnected] = useState>({}) - const [statsByHost, setStatsByHost] = useState>({}) - const [worktreeInfo, setWorktreeInfo] = useState>({}) - const [accountsByHost, setAccountsByHost] = useState>({}) - const [taskProvidersByHost, setTaskProvidersByHost] = useState>({}) - const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>( - null - ) - // Why: focus can fire repeatedly while an async gate is pending; one probe per - // mount avoids duplicate storage/permission reads and competing navigation. - const onboardingOptInCheckedRef = useRef(false) - - // Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md. - const hosts = useMemo(() => selectConnectableHostProfiles(hostCatalog), [hostCatalog]) - const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) - // Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total. - const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds]) - const autoConnectHostIds = useMemo(() => selectHomeAutoConnectHostIds(hosts), [hosts]) - const allClients = useAllHostClients(hostIds, { - autoConnectHostIds, - closeUnusedOnRelease: true - }) - const hostPaths = useMemo( - () => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])), - [allClients] - ) - const hostPendingPaths = useMemo( - () => Object.fromEntries(allClients.map(({ hostId, pendingPath }) => [hostId, pendingPath])), - [allClients] - ) - const hostPairingRejected = useMemo( - () => - Object.fromEntries( - allClients.map(({ hostId, pairingRejected }) => [hostId, pairingRejected]) - ), - [allClients] - ) - const disconnectHostClient = useDisconnectHostClient() - const forgetHostClient = useForgetHostClient() - const forceReconnectHost = useForceReconnect() - const primeHosts = usePrimeHosts() - // Why: prime the cache with loaded HostProfiles to avoid a second serialized Keychain pass (multi-second connect latency) on cold start. - useEffect(() => { - if (hosts.length > 0) { - primeHosts(hosts) - } - }, [hosts, primeHosts]) - const allClientsRef = useRef>([]) - // Why: keep the focus callback stable (no refetch per render) while still exposing the latest host clients. - allClientsRef.current = allClients.map((entry) => ({ - hostId: entry.hostId, - client: entry.client - })) - - // Why: hydrate from a persisted snapshot on cold-start so Resume + Account cards paint immediately instead of flashing empty. - const hydratedRef = useRef(false) - useEffect(() => { - if (hydratedRef.current) { - return - } - hydratedRef.current = true - let cancelled = false - void loadHomeSnapshot().then((snap) => { - if (cancelled || !snap) { - return - } - setWorktreeInfo((prev) => (Object.keys(prev).length > 0 ? prev : snap.worktreeInfo)) - setAccountsByHost((prev) => (Object.keys(prev).length > 0 ? prev : snap.accountsByHost)) - for (const [hostId, info] of Object.entries(snap.worktreeInfo)) { - const wt = info.lastActiveWorktree - if (wt) { - // Why: seed the in-memory cache so resumeWorktree's lastVisited fast-path finds the worktree object. - setCachedWorktrees(hostId, [wt]) - } - } - }) - return () => { - cancelled = true - } - }, []) - - // Why: persist the merged snapshot on each update so the next cold-start has fresh seed data (cache debounces writes). - useEffect(() => { - if (Object.keys(worktreeInfo).length === 0 && Object.keys(accountsByHost).length === 0) { - return - } - saveHomeSnapshot({ - worktreeInfo, - accountsByHost, - savedAt: Date.now() - }) - }, [worktreeInfo, accountsByHost]) - - useFocusEffect( - useCallback(() => { - let stale = false - void loadHostCatalog().then(async (catalog) => { - if (stale) { - return - } - setHostCatalog(catalog) - if (catalog.length === 0 || onboardingOptInCheckedRef.current) { - return - } - onboardingOptInCheckedRef.current = true - const onboardingSteps = await loadMobileOnboardingSteps() - if (stale) { - return - } - if (onboardingSteps.length > 0) { - router.replace(mobileOnboardingDestination(onboardingSteps)) - } - }) - void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => { - if (stale) { - return - } - // Why the validating reader: this record becomes the Resume card's navigation target, - // so a malformed or older-shaped payload must read as no history, not a broken route. - setLastVisited(readLastVisitedWorktreeRecord(raw)) - }) - for (const entry of allClientsRef.current) { - if (entry.client.getState() === 'connected') { - fetchStats(entry.client, entry.hostId, setStatsByHost, () => stale) - void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale) - fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale) - fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale) - } - } - return () => { - stale = true - } - }, [router]) - ) - - const sortedHosts = useMemo( - () => [...hosts].sort((a, b) => b.lastConnected - a.lastConnected), - [hosts] - ) - const sortedHostCatalog = useMemo( - () => [...hostCatalog].sort((a, b) => b.lastConnected - a.lastConnected), - [hostCatalog] - ) - - // Why: mirror per-host connection state into hostStates so existing render code (status dots) keeps working. - useEffect(() => { - setHostAttempts((prev) => { - const next: Record = { ...prev } - let changed = false - for (const entry of allClients) { - const a = entry.client.getReconnectAttempt() - if (next[entry.hostId] !== a) { - next[entry.hostId] = a - changed = true - } - } - return changed ? next : prev - }) - setHostLastConnected((prev) => { - const next: Record = { ...prev } - let changed = false - for (const entry of allClients) { - const t = entry.client.getLastConnectedAt() - if (next[entry.hostId] !== t) { - next[entry.hostId] = t - changed = true - } - } - return changed ? next : prev - }) - setHostStates((prev) => { - const next: Record = { ...prev } - let changed = false - const liveIds = new Set(allClients.map((e) => e.hostId)) - for (const entry of allClients) { - if (next[entry.hostId] !== entry.state) { - next[entry.hostId] = entry.state - changed = true - } - } - // Why: reflect hosts that dropped from allClients, but only if already tracked — else the initial-acquire frame flips all to 'disconnected'. - for (const host of hostCatalog) { - if (liveIds.has(host.id)) { - continue - } - if (host.credentialStatus === 'missing') { - if (next[host.id] !== 'auth-failed') { - next[host.id] = 'auth-failed' - changed = true - } - continue - } - if (host.credentialStatus === 'temporarily-unavailable') { - if (next[host.id] !== 'disconnected') { - next[host.id] = 'disconnected' - changed = true - } - continue - } - const prevState = next[host.id] - if (prevState && prevState !== 'disconnected' && prevState !== 'auth-failed') { - next[host.id] = 'disconnected' - changed = true - } - } - // Drop entries for hosts we no longer track at all. - for (const id of Object.keys(next)) { - if (!liveIds.has(id) && hostCatalog.some((h) => h.id === id) === false) { - delete next[id] - changed = true - } - } - return changed ? next : prev - }) - }, [allClients, hostCatalog]) - - // Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body - // because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the - // returned disposer owns every handle allocated here. - const wireHostSubscriptions = (entry: { - hostId: string - client: RpcClient - state: ConnectionState - }) => { - let unsubNotif: (() => void) | null = null - let unsubAccounts: (() => void) | null = null - const refetchGate = createHostConnectRefetchGate() - const wireUp = (state: ConnectionState) => { - const reconnected = refetchGate.observe(state) - if (state === 'connected') { - if (!unsubNotif) { - unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId) - } - if (!unsubAccounts) { - unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => { - if (!payload || typeof payload !== 'object') { - return - } - const evt = payload as { type?: string; snapshot?: unknown } - if (evt.type === 'ready' || evt.type === 'snapshot') { - try { - const snapshot = decodeAccountsSnapshot(evt.snapshot) - setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot })) - } catch { - // Keep the last proven snapshot; malformed remote data must - // not enter render state or crash the home host cards. - } - } - }) - } - // Why: the socket survives backgrounding/handoffs by reconnecting, so re-read the host - // snapshot on every reconnect — a one-shot latch left the card on stale data forever. - if (reconnected) { - fetchStats(entry.client, entry.hostId, setStatsByHost, () => false) - void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false) - fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false) - } - } else { - if (unsubNotif) { - unsubNotif() - unsubNotif = null - } - if (unsubAccounts) { - unsubAccounts() - unsubAccounts = null - } - } - } - wireUp(entry.state) - const unsubState = entry.client.onStateChange(wireUp) - return () => { - unsubState() - unsubNotif?.() - unsubAccounts?.() - } - } - - // Re-runs per (hostId, client) pair; the socket stays open so it's cheap. - useEffect(() => { - const cleanups = allClients.map((entry) => wireHostSubscriptions(entry)) - return () => { - for (const c of cleanups) { - c() - } - } - // Why: key on host-id set + each client's identity so resubs fire when forceReconnect swaps a host's client, not on every render. - }, [ - allClients - .map((e) => `${e.hostId}:${clientKey(e.client)}`) - .sort() - .join(',') - ]) - - // Why: the card renders from cached/snapshot data the moment a candidate exists — see - // selectHomeResumeCard for why its slot must not wait for the host to connect. - const resumeCard = useMemo( - () => - selectHomeResumeCard({ - hosts: sortedHosts, - hostStates, - worktreeInfo, - lastVisited, - cachedWorktrees: (hostId) => getCachedWorktrees(hostId) as HomeWorktreeSummary[] | null - }), - [sortedHosts, hostStates, worktreeInfo, lastVisited] - ) - - // Why: the card is drawn from a snapshot that can name a workspace the desktop has since - // deleted. When the host has proven otherwise, open its workspace list rather than a session - // screen whose every RPC would fail. An unproven catalog is not evidence — that tap goes - // through and the session screen bounces once the host answers (F7). - const openResume = useCallback( - (card: HomeResumeCard) => { - if ( - isResumeTargetConfirmedMissing( - card, - getProvenCachedWorktrees(card.hostId) as HomeWorktreeSummary[] | null - ) - ) { - router.push(hostRouteWithNotice(card.hostId, 'worktree-missing')) - return - } - openMobileSession({ - hostId: card.hostId, - worktreeId: card.worktree.worktreeId, - name: card.worktree.displayName || card.worktree.repo - }) - }, - [openMobileSession, router] - ) - - // Why: only show Account usage for connected hosts; stale cached usage would imply live data. - const accountsHosts = useMemo(() => { - const items: Array<{ host: HostProfile; snapshot: AccountsSnapshot }> = [] - for (const host of sortedHosts) { - if (hostStates[host.id] !== 'connected') { - continue - } - const snap = accountsByHost[host.id] - if (!snap) { - continue - } - // Why: also show hosts whose only usage is the system-default login, else those users see no usage section. - if (hasRenderableUsage(snap, 'claude') || hasRenderableUsage(snap, 'codex')) { - items.push({ host, snapshot: snap }) - } - } - return items - }, [sortedHosts, hostStates, accountsByHost]) - - const connectedHosts = useMemo( - () => sortedHosts.filter((host) => hostStates[host.id] === 'connected'), - [sortedHosts, hostStates] - ) - const primaryConnectedHost = connectedHosts[0] ?? null - const primaryTaskProviders = primaryConnectedHost - ? (taskProvidersByHost[primaryConnectedHost.id] ?? ['github']) - : [] - const openTasks = useCallback( - (provider?: TaskProvider) => { - if (!primaryConnectedHost) { - return - } - openMobileTasks(primaryConnectedHost.id, provider) - }, - [openMobileTasks, primaryConnectedHost] - ) - const renderTaskHomeCard = () => ( - [ - styles.taskHomeCard, - !primaryConnectedHost && styles.cardDisabled, - pressed && styles.hostCardPressed - ]} - onPress={() => { - openTasks() - }} - > - - - - - Tasks - - {primaryTaskProviders.length > 0 - ? primaryTaskProviders.map((provider) => TASK_PROVIDER_LABELS[provider]).join(' · ') - : 'No task sources connected'} - - - - TASK_PROVIDER_LABELS[provider]) - .join(', ')} - > - {primaryTaskProviders.map((provider) => ( - [ - styles.taskHomeProviderButton, - pressed && styles.taskHomeProviderButtonPressed - ]} - onPress={(event) => { - event.stopPropagation() - openTasks(provider) - }} - > - - - ))} - - - - - ) - - async function handleRemove() { - if (!confirmRemove) { - return - } - const hostToRemove = confirmRemove - try { - await removeHostAndCloseClient(hostToRemove.id, forgetHostClient) - setConfirmRemove(null) - setHostCatalog(await loadHostCatalog()) - } catch { - // Why: ConfirmModal closes on confirm; re-open for retry so the failure isn't silent. - setConfirmRemove(hostToRemove) - Alert.alert('Could not remove host', 'Please try again.') - } - } - - return ( - - {/* ─── Top bar ─── */} - - - - - - Orca - - [styles.iconButton, pressed && styles.iconButtonPressed]} - onPress={() => router.push('/settings')} - > - - - - - {hostCatalog.length === 0 ? ( - /* ─── Empty state: onboarding ─── */ - - - Connect your desktop - - Pair with Orca on your computer to check on your agents, jump into any terminal, and - drive work from your phone. - - router.push('/pair-scan')}> - - Pair Desktop - - - - - How it works - {ONBOARDING_STEPS.map((step, i) => ( - 0 && styles.stepRowBorder]}> - - {i + 1} - - - {step.title} - {step.desc} - - - ))} - - - ) : ( - /* ─── Populated state ─── */ - h.id} - // Why: reserve insets.bottom so the last row stays reachable above the system nav bar / home indicator. - contentContainerStyle={[ - styles.list, - { paddingBottom: spacing.xl + insets.bottom }, - isWideLayout && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } - ]} - ListHeaderComponent={ - - - Welcome back - - - {stats && ( - - - - {stats.totalAgentsSpawned.toLocaleString()} - - Agents spawned - - - {formatDuration(stats.totalAgentTimeMs)} - Agent time - - - {stats.totalPRsCreated.toLocaleString()} - PRs created - - - )} - - Desktops - - } - ItemSeparatorComponent={CardGap} - renderItem={({ item }) => { - const state = resolveHomeHostConnectionState( - item.id, - hostStates[item.id], - autoConnectHostIds - ) - const attempts = hostAttempts[item.id] ?? 0 - const lastConnectedAt = hostLastConnected[item.id] ?? null - const verdict = classifyConnection({ - state, - reconnectAttempts: attempts, - lastConnectedAt, - endpoint: item.endpoint, - pendingPath: hostPendingPaths[item.id] ?? null, - pairingRejected: hostPairingRejected[item.id] ?? false - }) - return ( - { - if (item.credentialStatus === 'missing') { - router.push('/pair-scan') - } else if (item.credentialStatus === 'temporarily-unavailable') { - void loadHostCatalog() - .then(setHostCatalog) - .catch(() => Alert.alert('Could not check pairing', 'Please try again.')) - } else { - router.push(`/h/${item.id}`) - } - }} - onLongPress={() => { - triggerMediumImpact() - if (item.profile) { - setActionTarget(item.profile) - } else { - setConfirmRemove(item) - } - }} - onOpenActions={() => { - if (item.profile) { - setActionTarget(item.profile) - } else { - setConfirmRemove(item) - } - }} - /> - ) - }} - ListFooterComponent={ - - {/* ─── Resume card ─── */} - {resumeCard ? ( - <> - Resume - [ - styles.resumeCard, - !resumeCard.actionable && styles.cardDisabled, - pressed && styles.hostCardPressed - ]} - onPress={() => openResume(resumeCard)} - > - - - - - - {resumeCard.worktree.displayName} - - - - - {resumeCard.worktree.repo} - {' · '} - {resumeCard.worktree.branch} - - - - - - - ) : null} - Tasks - {renderTaskHomeCard()} - - {/* ─── Quick actions ─── */} - router.push('/pair-scan')} - onCreateWorkspace={(hostId) => router.push(hostNewWorktreeRoute(hostId))} - /> - - {/* ─── Account usage ─── */} - {accountsHosts.length > 0 ? ( - <> - - Account usage - - {accountsHosts.map(({ host, snapshot }) => { - const claudeActiveId = snapshot.claude.activeAccountId - const claudeActive = - snapshot.claude.accounts.find((a) => a.id === claudeActiveId) ?? null - const codexActiveId = snapshot.codex.activeAccountId - const codexActive = - snapshot.codex.accounts.find((a) => a.id === codexActiveId) ?? null - const showHostName = accountsHosts.length > 1 - return ( - [ - styles.accountsCard, - pressed && styles.hostCardPressed - ]} - onPress={() => openMobileAccounts(host.id)} - > - {showHostName ? ( - - {host.name} - - ) : null} - {(['claude', 'codex'] as ProviderKey[]).map((provider) => { - const active = provider === 'claude' ? claudeActive : codexActive - const accounts = - provider === 'claude' - ? snapshot.claude.accounts - : snapshot.codex.accounts - const limits = getActiveProviderRateLimits(snapshot, provider) - // Why: with no managed accounts, still render the row when the active target has live usage data. - if (accounts.length === 0 && !hasActiveProviderUsage(limits)) { - return null - } - const sessionBar = getUsageBarState(limits, 'session') - const weeklyBar = getUsageBarState(limits, 'weekly') - return ( - - - {provider === 'claude' ? ( - - ) : ( - - )} - - - - {active?.email ?? 'System default'} - - - - - - - - ) - })} - - ) - })} - - ) : null} - - } - /> - )} - - {/* ─── Action sheets (shared by both states) ─── */} - setActionTarget(null), - onReconnect: (hostId) => void forceReconnectHost(hostId), - onDisconnect: disconnectHostClient, - onEdit: openMobileHostEdit, - onRemove: (host) => setConfirmRemove(host) - })} - onClose={() => setActionTarget(null)} - /> - - void handleRemove()} - onCancel={() => setConfirmRemove(null)} - /> - - ) -} - -function CardGap() { - return -} - -const ONBOARDING_STEPS = [ - { - title: 'Open Orca desktop', - desc: 'Go to Settings → Mobile and generate a pairing QR code.' - }, - { - title: 'Scan the code', - desc: 'Tap the button above to open the scanner. Point at the QR code on your screen.' - }, - { - title: "You're connected", - desc: 'Your desktop will appear here. Everything is encrypted end-to-end.' - } -] - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase - }, - - /* ─── Top bar ─── */ - topBar: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: spacing.lg, - paddingTop: spacing.sm, - paddingBottom: spacing.md - }, - brandLockup: { - flexDirection: 'row', - alignItems: 'center', - minWidth: 0 - }, - logoMark: { - marginRight: spacing.sm - }, - brandName: { - color: colors.textPrimary, - fontSize: 17, - fontWeight: '700' - }, - iconButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center' - }, - iconButtonPressed: { - backgroundColor: colors.bgRaised - }, - - /* ─── Hero / greeting ─── */ - hero: { - paddingTop: spacing.xs, - paddingBottom: spacing.md - }, - heroTitle: { - color: colors.textPrimary, - fontSize: 24, - fontWeight: '800', - letterSpacing: -0.3 - }, - - /* ─── Stat cards ─── */ - statsRow: { - flexDirection: 'row', - gap: 10, - marginBottom: spacing.lg - }, - statCard: { - flex: 1, - backgroundColor: 'rgba(26,26,26,0.6)', - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: 10, - paddingVertical: 8, - paddingHorizontal: spacing.md - }, - statValue: { - color: colors.textPrimary, - fontSize: 18, - fontWeight: '700', - letterSpacing: -0.3 - }, - statLabel: { - color: colors.textMuted, - fontSize: 11, - fontWeight: '500', - marginTop: 2 - }, - - /* ─── Section heading ─── */ - sectionHeading: { - fontSize: 11, - fontWeight: '600', - color: colors.textMuted, - textTransform: 'uppercase', - letterSpacing: 0.6, - marginBottom: spacing.sm, - paddingHorizontal: spacing.xs - }, - sectionHeadingTightTop: { - marginTop: spacing.lg - }, - - /* ─── List ─── */ - list: { - paddingHorizontal: spacing.lg, - paddingBottom: spacing.xl - }, - cardGap: { - height: spacing.sm - }, - - /* ─── Host cards ─── */ - hostCardPressed: { - backgroundColor: colors.bgRaised - }, - - /* ─── Resume card ─── */ - resumeCard: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.card, - paddingLeft: spacing.md, - paddingRight: spacing.md, - paddingVertical: 12 - }, - resumeIcon: { - width: 46, - height: 46, - borderRadius: 13, - backgroundColor: colors.bgRaised, - alignItems: 'center', - justifyContent: 'center', - marginRight: 14 - }, - resumeMain: { - flex: 1, - minWidth: 0 - }, - resumeTitle: { - fontSize: 13, - fontWeight: '600', - color: colors.textPrimary - }, - resumeSub: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - marginTop: 3 - }, - repoDot: { - width: 7, - height: 7, - borderRadius: 3.5 - }, - resumeSubText: { - fontSize: 12, - color: colors.textSecondary, - flex: 1 - }, - - /* ─── Tasks card ─── */ - taskHomeCard: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.card, - minHeight: 72, - paddingLeft: spacing.md, - paddingRight: spacing.md, - paddingVertical: 12 - }, - cardDisabled: { - opacity: 0.45 - }, - taskHomeIcon: { - width: 46, - height: 46, - borderRadius: 13, - backgroundColor: colors.bgRaised, - alignItems: 'center', - justifyContent: 'center', - marginRight: 14 - }, - taskHomeMain: { - flex: 1, - minWidth: 0 - }, - taskHomeTitle: { - fontSize: 13, - fontWeight: '600', - color: colors.textPrimary - }, - taskHomeSubtitle: { - fontSize: 12, - color: colors.textSecondary, - marginTop: 3 - }, - taskHomeTrailing: { - flexDirection: 'row', - alignItems: 'center', - flexShrink: 0, - marginLeft: spacing.sm - }, - taskHomeProviderRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'flex-end', - gap: 2 - }, - taskHomeProviderButton: { - width: 34, - height: 34, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radii.button - }, - taskHomeProviderButtonPressed: { - backgroundColor: colors.bgRaised - }, - - /* ─── Account usage ─── */ - accountsCard: { - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.card, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm + 2, - gap: spacing.sm, - marginBottom: spacing.sm - }, - accountsHostLabel: { - fontSize: 11, - color: colors.textMuted, - fontWeight: '500', - textTransform: 'uppercase', - letterSpacing: 0.4 - }, - accountsRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm + 2 - }, - accountsIcon: { - width: 32, - height: 32, - borderRadius: 9, - backgroundColor: colors.bgRaised, - alignItems: 'center', - justifyContent: 'center' - }, - accountsInfo: { - flex: 1, - minWidth: 0, - gap: 2 - }, - accountsEmail: { - fontSize: 13, - fontWeight: '600', - color: colors.textPrimary - }, - accountsBars: { - flexDirection: 'row', - gap: spacing.md, - marginTop: 4 - }, - - /* ─── Empty state ─── */ - emptyContainer: { - flex: 1 - }, - emptyGreeting: { - paddingHorizontal: spacing.lg, - paddingTop: spacing.md, - paddingBottom: spacing.sm - }, - emptyHero: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 32, - paddingBottom: 40 - }, - emptyTitle: { - fontSize: 22, - fontWeight: '700', - color: colors.textPrimary, - textAlign: 'center', - marginBottom: 10 - }, - emptyBody: { - fontSize: 15, - color: colors.textSecondary, - textAlign: 'center', - lineHeight: 22, - marginBottom: 32 - }, - primaryButton: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - backgroundColor: colors.textPrimary, - paddingHorizontal: 28, - paddingVertical: 14, - borderRadius: radii.card - }, - primaryButtonText: { - color: colors.bgBase, - fontSize: 15, - fontWeight: '700' - }, - - /* ─── Onboarding steps ─── */ - stepsSection: { - paddingHorizontal: spacing.xl - }, - stepRow: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: 14, - paddingVertical: spacing.lg - }, - stepRowBorder: { - borderTopWidth: 1, - borderTopColor: colors.borderSubtle - }, - stepNum: { - width: 28, - height: 28, - borderRadius: 8, - backgroundColor: 'rgba(255,255,255,0.04)', - borderWidth: 1, - borderColor: colors.borderSubtle, - alignItems: 'center', - justifyContent: 'center', - marginTop: 1 - }, - stepNumText: { - fontSize: 12, - fontWeight: '700', - color: colors.textSecondary - }, - stepText: { - flex: 1 - }, - stepTitle: { - fontSize: 14, - fontWeight: '600', - color: colors.textPrimary, - marginBottom: 3 - }, - stepDesc: { - fontSize: 12, - color: colors.textMuted, - lineHeight: 17 - } -}) +export default MobileHomeScreen diff --git a/mobile/src/accounts/mobile-accounts-route.test.ts b/mobile/src/accounts/mobile-accounts-route.test.ts index f938a98772c..0b25d44d3f0 100644 --- a/mobile/src/accounts/mobile-accounts-route.test.ts +++ b/mobile/src/accounts/mobile-accounts-route.test.ts @@ -7,7 +7,11 @@ import { type HostStackNavigationState } from '../navigation/host-stack-navigation' -const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8') +const homeSource = readFileSync(new URL('../home/MobileHomeScreen.tsx', import.meta.url), 'utf8') +const accountCardsSource = readFileSync( + new URL('../home/MobileHomeAccountUsageCards.tsx', import.meta.url), + 'utf8' +) function navigationHarness(initialState: HostStackNavigationState) { const stateListeners = new Set<() => void>() @@ -83,14 +87,8 @@ describe('mobile accounts route', () => { }) it('opens the home account-usage card through the cold-navigator-safe transition', () => { - const start = homeSource.indexOf('{/* ─── Account usage ─── */}') - - // Assert the marker first: a renamed banner would otherwise slice garbage and report a - // missing call instead of the real cause. - expect(start).toBeGreaterThanOrEqual(0) - - const accountsSection = homeSource.slice(start) - expect(accountsSection).toContain('openMobileAccounts(host.id)') - expect(accountsSection).not.toContain('/accounts`') + expect(homeSource).toContain('onOpenAccounts={openMobileAccounts}') + expect(accountCardsSource).toContain('props.onOpen(host.id)') + expect(accountCardsSource).not.toContain('/accounts`') }) }) diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx index 673bbff049e..a5e81ec07a9 100644 --- a/mobile/src/components/AgentSpinner.tsx +++ b/mobile/src/components/AgentSpinner.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef } from 'react' +import { Radio } from 'lucide-react-native' import { Animated, Easing, StyleSheet, View } from 'react-native' +import type { AgentWorkingMode } from '../../../src/shared/agent-status-types' type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive' @@ -17,11 +19,18 @@ const STATUS_COLORS: Record = { inactive: 'rgba(115,115,115,0.4)' } -export function AgentSpinner({ status }: { status: WorktreeStatus }) { +export function AgentSpinner({ + status, + workingMode +}: { + status: WorktreeStatus + workingMode?: AgentWorkingMode +}) { const spinValue = useRef(new Animated.Value(0)).current + const monitoring = status === 'working' && workingMode === 'monitoring' useEffect(() => { - if (status === 'working') { + if (status === 'working' && !monitoring) { const animation = Animated.loop( Animated.timing(spinValue, { toValue: 1, @@ -34,10 +43,18 @@ export function AgentSpinner({ status }: { status: WorktreeStatus }) { return () => animation.stop() } spinValue.setValue(0) - }, [status, spinValue]) + }, [monitoring, status, spinValue]) const color = STATUS_COLORS[status] ?? STATUS_COLORS.inactive + if (monitoring) { + return ( + + + + ) + } + if (status === 'working') { const rotate = spinValue.interpolate({ inputRange: [0, 1], diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index 916525ef366..4d596b7e094 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react' +import { Radio } from 'lucide-react-native' import { Animated, Easing, StyleSheet, View } from 'react-native' import type { AgentDotState } from '../worktree/agent-row-display' @@ -7,13 +8,14 @@ import type { AgentDotState } from '../worktree/agent-row-display' // emerald for 'done', red for blocked/waiting/interrupted (attention), neutral // for idle. Distinct from the worktree-level AgentSpinner, which collapses the // agent vocabulary into the 5-state rollup the sidebar dot uses. -const DOT_COLORS: Record, string> = { +const DOT_COLORS: Record, string> = { done: '#10b981', blocked: '#ef4444', waiting: '#ef4444', interrupted: '#ef4444', idle: 'rgba(115,115,115,0.4)' } +const WORKING_COLOR = '#eab308' export function AgentStateDot({ state }: { state: AgentDotState }) { const spinValue = useRef(new Animated.Value(0)).current @@ -44,6 +46,14 @@ export function AgentStateDot({ state }: { state: AgentDotState }) { ) } + if (state === 'monitoring') { + return ( + + + + ) + } + return ( @@ -59,7 +69,7 @@ const styles = StyleSheet.create({ height: 6, borderRadius: 3, borderWidth: 1.5, - borderColor: '#eab308', + borderColor: WORKING_COLOR, borderTopColor: 'transparent' } }) diff --git a/mobile/src/components/NewWorkspaceSetupScriptField.tsx b/mobile/src/components/NewWorkspaceSetupScriptField.tsx new file mode 100644 index 00000000000..84e28c5de33 --- /dev/null +++ b/mobile/src/components/NewWorkspaceSetupScriptField.tsx @@ -0,0 +1,76 @@ +import { Pressable, Switch, Text, View } from 'react-native' +import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params' +import { colors } from '../theme/mobile-theme' +import { newWorktreeFormStyles as styles } from './new-worktree-form-styles' +import type { SetupRunPolicy } from './new-worktree-modal-types' + +export function NewWorkspaceSetupScriptField({ + command, + source, + runPolicy, + decision, + runSetup, + onDecisionChange, + onRunSetupChange +}: { + command: string + source: string | null + runPolicy: SetupRunPolicy + decision: Exclude | null + runSetup: boolean + onDecisionChange: (decision: Exclude) => void + onRunSetupChange: (run: boolean) => void +}) { + return ( + + + Setup script + {source ? ( + + + {source === 'orca.yaml' ? 'ORCA.YAML' : 'HOOKS'} + + + ) : null} + + + {runPolicy === 'ask' ? ( + + onDecisionChange('run')} + > + Run + + onDecisionChange('skip')} + > + Skip + + + ) : ( + + Run setup command + + + )} + + {command} + + + + ) +} diff --git a/mobile/src/components/NewWorkspaceSshConnectionField.tsx b/mobile/src/components/NewWorkspaceSshConnectionField.tsx new file mode 100644 index 00000000000..38e3eeaea9b --- /dev/null +++ b/mobile/src/components/NewWorkspaceSshConnectionField.tsx @@ -0,0 +1,52 @@ +import { Pressable, Text, View } from 'react-native' +import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate' +import { workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate' +import { newWorktreeFormStyles as styles } from './new-worktree-form-styles' + +export function NewWorkspaceSshConnectionField({ + repoName, + sshGate, + onConnect +}: { + repoName: string + sshGate: WorkspaceSshGate + onConnect: () => void +}) { + return ( + + SSH Connection + + + + + + {repoName} + + {workspaceSshStatusLabel(sshGate.status)} + + {sshGate.status === 'connected' ? null : ( + + + {sshGate.connectInProgress ? 'Connecting...' : 'Connect'} + + + )} + + {sshGate.error ? {sshGate.error} : null} + + + ) +} diff --git a/mobile/src/components/NewWorktreeFormSheet.tsx b/mobile/src/components/NewWorktreeFormSheet.tsx new file mode 100644 index 00000000000..2a6587ffa87 --- /dev/null +++ b/mobile/src/components/NewWorktreeFormSheet.tsx @@ -0,0 +1,184 @@ +import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native' +import { ChevronDown, ChevronUp } from 'lucide-react-native' +import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params' +import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate' +import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' +import { MobileAgentIcon } from './MobileAgentIcon' +import type { NewWorktreeAgentOption } from './new-worktree-agent-selection' +import { newWorktreeFormStyles as styles } from './new-worktree-form-styles' +import type { SetupRunPolicy } from './new-worktree-modal-types' +import { NewWorktreeProjectTargetFields } from './NewWorktreeProjectTargetFields' +import { NewWorkspaceSetupScriptField } from './NewWorkspaceSetupScriptField' +import { NewWorkspaceSshConnectionField } from './NewWorkspaceSshConnectionField' +import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields' +import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField' + +type Composer = ReturnType +type Selection = { label: string; detail?: string } + +export function NewWorktreeFormSheet(props: { + visible: boolean + interactive: boolean + loading: boolean + hasRepos: boolean + project: Selection | null + runTarget: Selection | null + projectBadgeColor: string | null + selectedRepoIsGit: boolean + selectedRepoConnectionId: string | null + selectedRepoName: string + sshGate: WorkspaceSshGate + composer: Composer + selectedAgent: NewWorktreeAgentOption + showAdvanced: boolean + note: string + setupCommand: string | null + setupSource: string | null + setupRunPolicy: SetupRunPolicy + setupDecisionChoice: Exclude | null + runSetup: boolean + error: string + creating: boolean + canCreate: boolean + onClose: () => void + onOpenProject: () => void + onOpenRunTarget: () => void + onOpenSource: () => void + onClearError: () => void + onConnect: () => void + onOpenAgent: () => void + onShowAdvancedChange: (show: boolean) => void + onNoteChange: (note: string) => void + onSetupDecisionChange: (decision: Exclude) => void + onRunSetupChange: (run: boolean) => void + onCreate: () => void +}) { + return ( + + + Create worktree + + + {props.loading ? ( + + + + ) : !props.hasRepos ? ( + + No projects found + + ) : ( + <> + + + + + {props.composer.forkPushWarning ? ( + {props.composer.forkPushWarning} + ) : null} + + {props.selectedRepoConnectionId ? ( + + ) : null} + + + Agent + + + + {props.sshGate.requiresConnection + ? 'Connect target first' + : props.selectedAgent.label} + + + + + + props.onShowAdvancedChange(!props.showAdvanced)} + > + Advanced + {props.showAdvanced ? ( + + ) : ( + + )} + + + {props.showAdvanced ? ( + <> + + + Note + + + {props.setupCommand ? ( + + ) : null} + + ) : null} + + {props.error ? {props.error} : null} + + + {props.creating ? ( + + ) : ( + + {props.sshGate.requiresConnection ? 'Connect target' : 'Create worktree'} + + )} + + + + )} + + ) +} diff --git a/mobile/src/components/NewWorktreeModal.test.tsx b/mobile/src/components/NewWorktreeModal.test.tsx index 4d954a2464f..0bbdb462d4c 100644 --- a/mobile/src/components/NewWorktreeModal.test.tsx +++ b/mobile/src/components/NewWorktreeModal.test.tsx @@ -68,6 +68,21 @@ function pickerItems( return picker?.props.items ?? [] } +function sourceInputs(renderer: ReactTestRenderer) { + return renderer.root.findAll( + (node) => + node.type === 'TextInput' && node.props.placeholder === 'Type a name or search a source' + ) +} + +async function flushUpdates(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) +} + describe('NewWorktreeModal project targets', () => { let renderer: ReactTestRenderer @@ -164,4 +179,224 @@ describe('NewWorktreeModal project targets', () => { }) ]) }) + + it('loads SSH state before detecting agents on the selected remote target', async () => { + const remoteRepo = { + id: 'repo-remote', + displayName: 'orca', + path: '/home/dev/orca', + connectionId: 'build-server', + kind: 'git', + upstream: { owner: 'stablyai', repo: 'orca' } + } + setCachedRepos('host-ssh', [remoteRepo]) + const sendRequest = vi.fn().mockImplementation((method: string) => { + if (method === 'repo.list') { + return Promise.resolve({ ok: true, result: { repos: [remoteRepo] } }) + } + if (method === 'ssh.getState') { + return Promise.resolve({ + ok: true, + result: { + state: { + targetId: 'build-server', + status: 'connected', + error: null, + reconnectAttempt: 0 + } + } + }) + } + if (method === 'preflight.detectRemoteAgents') { + return Promise.resolve({ ok: true, result: ['codex'] }) + } + if (method === 'settings.get') { + return Promise.resolve({ ok: true, result: { settings: { defaultTuiAgent: 'codex' } } }) + } + if (method === 'ui.get') { + return Promise.resolve({ ok: true, result: { ui: {} } }) + } + if (method === 'preflight.check') { + return Promise.resolve({ ok: true, result: {} }) + } + if (method === 'linear.status') { + return Promise.resolve({ ok: true, result: {} }) + } + if (method === 'status.get') { + return Promise.resolve({ ok: true, result: { hostPlatform: 'linux' } }) + } + if (method === 'repo.hooks') { + return Promise.resolve({ ok: true, result: { hooks: null, source: null } }) + } + return Promise.resolve({ ok: true, result: {} }) + }) + const client = { sendRequest } as unknown as RpcClient + + await act(async () => { + renderer = create( + createElement(NewWorktreeModal, { + visible: true, + client, + hostId: 'host-ssh', + onCreated: () => {}, + onClose: () => {} + }) + ) + }) + await flushUpdates() + + expect(sendRequest).toHaveBeenCalledWith('ssh.getState', { targetId: 'build-server' }) + expect(sendRequest).toHaveBeenCalledWith('preflight.detectRemoteAgents', { + connectionId: 'build-server' + }) + }) + + it('keeps folder workspaces on the local execution and text-only source path', async () => { + const folderRepo = { + id: 'folder-1', + displayName: 'notes', + path: '/src/notes', + kind: 'folder' + } + setCachedRepos('host-folder', [folderRepo]) + const sendRequest = vi.fn().mockImplementation((method: string) => { + if (method === 'repo.list') { + return Promise.resolve({ ok: true, result: { repos: [folderRepo] } }) + } + if (method === 'preflight.detectAgents') { + return Promise.resolve({ ok: true, result: ['codex'] }) + } + if (method === 'settings.get') { + return Promise.resolve({ ok: true, result: { settings: { defaultTuiAgent: 'codex' } } }) + } + if (method === 'ui.get') { + return Promise.resolve({ ok: true, result: { ui: {} } }) + } + if (method === 'preflight.check') { + return Promise.resolve({ ok: true, result: {} }) + } + if (method === 'linear.status') { + return Promise.resolve({ ok: true, result: {} }) + } + if (method === 'status.get') { + return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } }) + } + if (method === 'repo.hooks') { + return Promise.resolve({ ok: true, result: { hooks: null, source: null } }) + } + return Promise.resolve({ ok: true, result: {} }) + }) + const client = { sendRequest } as unknown as RpcClient + + await act(async () => { + renderer = create( + createElement(NewWorktreeModal, { + visible: true, + client, + hostId: 'host-folder', + onCreated: () => {}, + onClose: () => {} + }) + ) + }) + await flushUpdates() + + expect(renderer.root.findAll((node) => node.props.label === 'Workspace name')).toHaveLength(1) + expect(sendRequest).toHaveBeenCalledWith('preflight.detectAgents') + expect(sendRequest).not.toHaveBeenCalledWith('ssh.getState', expect.anything()) + }) + + it('ignores a stale repo list after the client changes', async () => { + let resolveOldList: ((value: unknown) => void) | undefined + const oldList = new Promise((resolve) => { + resolveOldList = resolve + }) + const freshRepo = { ...repos[0]!, id: 'repo-fresh', displayName: 'fresh' } + const oldClient = { + sendRequest: vi.fn().mockImplementation((method: string) => { + if (method === 'repo.list') { + return oldList + } + return new Promise(() => {}) + }) + } as unknown as RpcClient + const freshClient = { + sendRequest: vi.fn().mockImplementation((method: string) => { + if (method === 'repo.list') { + return Promise.resolve({ ok: true, result: { repos: [freshRepo] } }) + } + if (method === 'status.get') { + return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } }) + } + return new Promise(() => {}) + }) + } as unknown as RpcClient + const modalProps = { + visible: true, + hostId: 'host-1', + onCreated: () => {}, + onClose: () => {} + } + + await act(async () => { + renderer = create(createElement(NewWorktreeModal, { ...modalProps, client: oldClient })) + }) + const sourceInput = sourceInputs(renderer)[0]! + act(() => sourceInput.props.onChangeText('stale-client-name')) + expect(sourceInput.props.value).toBe('stale-client-name') + await act(async () => { + renderer.update(createElement(NewWorktreeModal, { ...modalProps, client: freshClient })) + await Promise.resolve() + }) + expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', '']) + resolveOldList?.({ ok: true, result: { repos } }) + await flushUpdates() + + expect(pickerItems(renderer, 'Project')).toEqual([expect.objectContaining({ label: 'fresh' })]) + }) + + it('remounts before the reopened session renders, so no stale instance sees visible', async () => { + const sendRequest = vi.fn().mockImplementation(() => new Promise(() => {})) + const client = { sendRequest } as unknown as RpcClient + const modalProps = { + client, + hostId: 'host-1', + onCreated: () => {}, + onClose: () => {} + } + + await act(async () => { + renderer = create(createElement(NewWorktreeModal, { ...modalProps, visible: true })) + }) + act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: false }))) + act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: true }))) + + // One repo.list per opening. A third means the previous session's instance + // re-ran its visible-gated effects before the remount key caught up. + const repoListCalls = sendRequest.mock.calls.filter(([method]) => method === 'repo.list') + expect(repoListCalls).toHaveLength(2) + }) + + it('starts with fresh form state after closing and reopening', async () => { + const client = { + sendRequest: vi.fn().mockImplementation(() => new Promise(() => {})) + } as unknown as RpcClient + const modalProps = { + client, + hostId: 'host-1', + onCreated: () => {}, + onClose: () => {} + } + + await act(async () => { + renderer = create(createElement(NewWorktreeModal, { ...modalProps, visible: true })) + }) + const sourceInput = sourceInputs(renderer)[0]! + act(() => sourceInput.props.onChangeText('previous-workspace')) + + act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: false }))) + act(() => renderer.update(createElement(NewWorktreeModal, { ...modalProps, visible: true }))) + + expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', '']) + }) }) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 8c6f96f4d6c..a6a6f1fb9bd 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -1,201 +1,95 @@ -import { useState, useEffect, useMemo, useRef } from 'react' -import { - View, - Text, - TextInput, - Pressable, - Switch, - StyleSheet, - Platform, - ActivityIndicator, - Keyboard -} from 'react-native' -import { ChevronDown, ChevronUp, Monitor } from 'lucide-react-native' -import type { RpcClient } from '../transport/rpc-client' -import type { RpcResponse, RpcSuccess } from '../transport/types' -import { colors, spacing, radii, typography } from '../theme/mobile-theme' -import { BottomDrawer } from './BottomDrawer' -import { BottomDrawerModalHost } from './bottom-drawer-modal-host' -import { useNewWorktreeDrawerNavigation } from './use-new-worktree-drawer-navigation' -import { PickerListDrawer } from './PickerListDrawer' -import { MobileAgentIcon } from './MobileAgentIcon' -import { getSuggestedCreatureName } from './worktree-name-suggestion' -import { useRetiredWorktreeNames } from '../worktree/use-retired-worktree-names' -import { repoColor } from '../worktree/repo-color' -import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate' -import { - isSetupHookTrusted, - normalizeSetupHookTrust, - persistSetupHookTrustApproval, - wasSetupHookPreviouslyApproved, - type SetupHookTrust -} from '../tasks/setup-hook-trust' -import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents' -import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types' -import type { Repo as SharedRepo } from '../../../src/shared/repo-types' -import type { TuiAgent } from '../../../src/shared/tui-agent' -import type { SshConnectionState } from '../../../src/shared/ssh-types' +import { useMemo, useRef, useState } from 'react' +import { Keyboard } from 'react-native' +import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection' import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection' -import { - NEW_WORKTREE_AGENT_OPTIONS as AGENT_OPTIONS, - NEW_WORKTREE_BLANK_AGENT as BLANK_TERMINAL, - pickPreferredNewWorktreeAgent, - resolveNewWorktreeAgentSelection, - type NewWorktreeAgentOption as AgentOption -} from './new-worktree-agent-selection' -import { getCachedRepos, setCachedRepos } from '../cache/repo-cache' -import { useLastVisitedWorktreeRepoId } from '../worktree/use-last-visited-worktree-repo' -import { - getMobileNewWorkspaceDialogEligibleRepos, - refreshMobileNewWorkspaceDialogSelectedRepo, - resolveMobileNewWorkspaceDialogRepoId -} from '../worktree/new-workspace-dialog-repo-selection' -import { createBlankWorkspace } from '../tasks/blank-workspace-create' -import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create' -import { useNewWorktreeRuntimeCapabilities } from '../tasks/worktree-create-capability' -import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection' -import { - filterAvailableTaskProviders, - normalizeVisibleTaskProviders, - type TaskProvider -} from '../tasks/mobile-task-providers' -import { useMobileComposerSource } from '../tasks/use-mobile-composer-source' +import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source' import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes' import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent' -import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source' -import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection' -import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField' -import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer' -import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields' -import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer' -import { NewWorktreeProjectTargetFields } from './NewWorktreeProjectTargetFields' +import { useMobileComposerSource } from '../tasks/use-mobile-composer-source' +import { useNewWorktreeRuntimeCapabilities } from '../tasks/worktree-create-capability' +import { useRetiredWorktreeNames } from '../worktree/use-retired-worktree-names' +import { BottomDrawerModalHost } from './bottom-drawer-modal-host' +import { + getMobileWorkspaceRepoBadgeColor, + type MobileWorkspaceRepo, + type NewWorktreeModalProps +} from './new-worktree-modal-types' import { buildNewWorkspaceProjectOptions, buildNewWorkspaceRunTargetOptions, getNewWorkspaceRunTarget } from './new-workspace-project-targets' +import { NewWorktreeFormSheet } from './NewWorktreeFormSheet' +import { NewWorktreeModalDrawers } from './NewWorktreeModalDrawers' +import { useNewWorkspaceAgentSelection } from './use-new-workspace-agent-selection' +import { useNewWorkspaceCreateSubmit } from './use-new-workspace-create-submit' +import { useNewWorkspaceExecutionTarget } from './use-new-workspace-execution-target' +import { useNewWorkspaceRepositories } from './use-new-workspace-repositories' +import { useNewWorkspaceRuntimeContext } from './use-new-workspace-runtime-context' +import { useNewWorkspaceSetupScript } from './use-new-workspace-setup-script' +import { useNewWorktreeDrawerNavigation } from './use-new-worktree-drawer-navigation' -type Repo = Pick & - Partial< - Pick< - SharedRepo, - | 'badgeColor' - | 'connectionId' - | 'executionHostId' - | 'kind' - | 'upstream' - | 'repoIcon' - | 'gitRemoteIdentity' - > - > - -type SetupDecision = 'inherit' | 'run' | 'skip' -type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default' -type RuntimeSettings = { - defaultTuiAgent?: TuiAgent | 'blank' | null - disabledTuiAgents?: TuiAgent[] -} - -type RepoHooksResponse = { - hooks: { scripts?: { setup?: string } } | null - source: string | null - setupRunPolicy?: SetupRunPolicy - setupTrust?: SetupHookTrust -} - -type SetupHookDetails = { - repoId: string - command: string | null - source: string | null - trust: SetupHookTrust | null - runPolicy: SetupRunPolicy -} - -type DetectedAgentIdsState = { - connectionId: string | null - ids: Set -} - -type CreateOptions = { - setupOverride?: Exclude - approvedSetupContentHash?: string -} - -function repoBadgeColor(repo: Repo | null): string { - return repo?.badgeColor || repoColor(repo?.displayName ?? 'repository') -} - -// ── Main modal ────────────────────────────────────────────────────── - -type Props = { - visible: boolean - client: RpcClient | null - hostId?: string - // Why: existing worktree paths from the host so we can pick a unique - // marine-creature default when the user leaves the name blank, matching - // the desktop UI's behavior. The "already exists locally" collision is - // on the on-disk directory basename, so paths (not displayNames) are - // what the suggestion logic must dedupe against. - existingWorktreePaths?: readonly string[] - existingWorktrees?: readonly { repoId: string; branch: string }[] - onCreated: (worktreeId: string, name: string) => void - onClose: () => void -} - -export function NewWorktreeModal({ - visible, - client, - hostId, - existingWorktreePaths, - existingWorktrees, - onCreated, - onClose -}: Props) { +export function NewWorktreeModal(props: NewWorktreeModalProps) { const openEpochRef = useRef(0) const wasVisibleRef = useRef(false) - const clientEpochRef = useRef({ client, epoch: 0 }) + const clientEpochRef = useRef({ client: props.client, epoch: 0 }) // Why: each drawer opening is a fresh form session; remounting resets local // form state before paint instead of clearing it in a visible-prop Effect. - if (visible && !wasVisibleRef.current) { + if (props.visible && !wasVisibleRef.current) { openEpochRef.current += 1 } - wasVisibleRef.current = visible - if (clientEpochRef.current.client !== client) { - clientEpochRef.current = { client, epoch: clientEpochRef.current.epoch + 1 } + wasVisibleRef.current = props.visible + if (clientEpochRef.current.client !== props.client) { + clientEpochRef.current = { client: props.client, epoch: clientEpochRef.current.epoch + 1 } } return ( ) } -function NewWorktreeModalContent({ - visible, - client, - hostId, - existingWorktreePaths, - existingWorktrees, - onCreated, - onClose -}: Props) { - const [initialRepos] = useState(() => (hostId ? (getCachedRepos(hostId) as Repo[] | null) : null)) - const [repos, setRepos] = useState(initialRepos ?? []) - const [selectedRepo, setSelectedRepo] = useState(null) - // Why: a deleted workspace's directory can still hold agent conversation state keyed by cwd, so - // its name must never be suggested again. Fetched per selected repo while the sheet is open. - // Keyed on the path set rather than the array so a poll that changes nothing does not refetch. +function NewWorktreeModalContent(props: NewWorktreeModalProps) { + const { visible, client, hostId, existingWorktreePaths, existingWorktrees, onCreated, onClose } = + props + const { repos, selectedRepo, setSelectedRepo, loading } = useNewWorkspaceRepositories({ + client, + hostId, + visible + }) + const navigation = useNewWorktreeDrawerNavigation(visible) + const [note, setNote] = useState('') + const [error, setError] = useState('') + const runtime = useNewWorkspaceRuntimeContext(client, visible, hostId) + const { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport } = + useNewWorktreeRuntimeCapabilities(client, visible) + const selectedRepoConnectionId = selectedRepo?.connectionId ?? null + const executionTarget = useNewWorkspaceExecutionTarget({ + client, + connectionId: selectedRepoConnectionId, + visible + }) + const setupScript = useNewWorkspaceSetupScript({ client, selectedRepo }) + const selectedRepoWorktreeBranches = useMemo( + () => getComposerRepoWorktreeBranches(existingWorktrees ?? [], selectedRepo?.id ?? null), + [existingWorktrees, selectedRepo] + ) + const composer = useMobileComposerSource({ + client, + selectedRepoId: selectedRepo?.id ?? null, + worktreeBranches: selectedRepoWorktreeBranches, + onError: setError + }) + const agentSelection = useNewWorkspaceAgentSelection({ + visible, + runtimeSettings: runtime.runtimeSettings, + detectedAgentIds: executionTarget.detectedAgentIds + }) const retiredNamesRefreshKey = useMemo( - () => [...(existingWorktreePaths ?? [])].sort().join('\0'), + () => (existingWorktreePaths ?? []).toSorted().join('\0'), [existingWorktreePaths] ) const retiredWorktreeNames = useRetiredWorktreeNames( @@ -203,90 +97,42 @@ function NewWorktreeModalContent({ selectedRepo?.id, retiredNamesRefreshKey ) - const { drawerView, formSheetVisible, formSheetInteractive, transitionDrawer, openSourceDrawer } = - useNewWorktreeDrawerNavigation(visible) - const createInFlightRef = useRef(false) - const setupTrustActionInFlightRef = useRef(false) - const [selectedAgentState, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) - const [runtimeSettings, setRuntimeSettings] = useState(null) - const [detectedAgentIdsState, setDetectedAgentIdsState] = useState( - null - ) - const [agentOverriddenState, setAgentOverridden] = useState(false) - const [sshState, setSshState] = useState(null) - const [sshConnectingTargetId, setSshConnectingTargetId] = useState(null) - const [note, setNote] = useState('') - const [availableProviders, setAvailableProviders] = useState([]) - const { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport } = - useNewWorktreeRuntimeCapabilities(client, visible) - const [showAdvanced, setShowAdvanced] = useState(false) - const [setupHookDetails, setSetupHookDetails] = useState(null) - const [trustedOrcaHooks, setTrustedOrcaHooks] = useState({}) - const [setupTrustPrompt, setSetupTrustPrompt] = useState(null) - const [setupDecisionChoice, setSetupDecisionChoice] = useState | null>(null) - const [runSetup, setRunSetup] = useState(true) - const [creating, setCreating] = useState(false) - const [error, setError] = useState('') - const [loading, setLoading] = useState(initialRepos == null) - const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible) - const selectedRepoWorktreeBranches = useMemo( - () => getComposerRepoWorktreeBranches(existingWorktrees ?? [], selectedRepo?.id ?? null), - [existingWorktrees, selectedRepo] - ) - - const composer = useMobileComposerSource({ + const createSubmit = useNewWorkspaceCreateSubmit({ client, - selectedRepoId: selectedRepo?.id ?? null, - worktreeBranches: selectedRepoWorktreeBranches, - onError: setError + selectedRepo, + selectedAgent: agentSelection.selectedAgent, + setSelectedAgent: agentSelection.setSelectedAgent, + setAgentOverridden: agentSelection.setAgentOverridden, + runtimeSettings: runtime.runtimeSettings, + setRuntimeSettings: runtime.setRuntimeSettings, + detectedAgentIds: executionTarget.detectedAgentIds, + sshGate: executionTarget.sshGate, + composer, + note, + existingWorktreePaths, + retiredWorktreeNames, + setupCommand: setupScript.setupCommand, + setupTrust: setupScript.setupTrust, + setupRunPolicy: setupScript.setupRunPolicy, + setupDecisionChoice: setupScript.setupDecisionChoice, + runSetup: setupScript.runSetup, + trustedOrcaHooks: runtime.trustedOrcaHooks, + setTrustedOrcaHooks: runtime.setTrustedOrcaHooks, + getWorktreeCreateCutoverSupport, + transitionDrawer: navigation.transitionDrawer, + setError, + onCreated, + onClose }) - const selectedRepoConnectionId = selectedRepo?.connectionId ?? null - const sshGate = deriveWorkspaceSshGate({ - connectionId: selectedRepoConnectionId, - state: sshState, - connecting: sshConnectingTargetId === selectedRepoConnectionId - }) - const detectedAgentIds = - detectedAgentIdsState?.connectionId === selectedRepoConnectionId && - (selectedRepoConnectionId === null || sshGate.status === 'connected') - ? detectedAgentIdsState.ids - : null - const activeSetupHookDetails = - selectedRepo && setupHookDetails?.repoId === selectedRepo.id ? setupHookDetails : null - const setupCommand = activeSetupHookDetails?.command ?? null - const setupSource = activeSetupHookDetails?.source ?? null - const setupTrust = activeSetupHookDetails?.trust ?? null - const setupRunPolicy = activeSetupHookDetails?.runPolicy ?? 'run-by-default' - const selectedAgentResolution = resolveNewWorktreeAgentSelection({ - visible, - selectedAgent: selectedAgentState, - agentOverridden: agentOverriddenState, - runtimeSettings, - detectedAgentIds - }) - // Why: agent preference repair is pure render dataflow; doing it here - // avoids a stale selected-agent commit while preserving user overrides. - if ( - selectedAgentState.id !== selectedAgentResolution.selectedAgent.id || - agentOverriddenState !== selectedAgentResolution.agentOverridden - ) { - setSelectedAgent(selectedAgentResolution.selectedAgent) - setAgentOverridden(selectedAgentResolution.agentOverridden) - } - const selectedAgent = selectedAgentResolution.selectedAgent - const selectedRepoIsGit = selectedRepo ? selectedRepo.kind !== 'folder' : true const sourceAvailability: SmartModeAvailabilityInput = { textOnly: selectedRepo != null && !selectedRepoIsGit, tasksSupported, hasRepo: selectedRepo != null, - githubAvailable: availableProviders.includes('github'), - gitlabAvailable: availableProviders.includes('gitlab'), - linearAvailable: availableProviders.includes('linear') + githubAvailable: runtime.availableProviders.includes('github'), + gitlabAvailable: runtime.availableProviders.includes('gitlab'), + linearAvailable: runtime.availableProviders.includes('linear') } const pasteRepos = useMemo( () => @@ -297,423 +143,6 @@ function NewWorktreeModalContent({ })), [repos] ) - - useEffect(() => { - if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) { - return - } - const eligibleRepos = getMobileNewWorkspaceDialogEligibleRepos(repos) - const preferredRepoId = resolveMobileNewWorkspaceDialogRepoId({ - eligibleRepos, - activeRepoId: lastVisitedRepo.repoId - }) - const preferredRepo = repos.find((repo) => repo.id === preferredRepoId) ?? null - if (preferredRepo) { - setSelectedRepo(preferredRepo) - } - }, [lastVisitedRepo.loaded, lastVisitedRepo.repoId, repos, selectedRepo, visible]) - - useEffect(() => { - if (!visible || !client) { - return - } - let stale = false - - if (repos.length === 0) { - setLoading(true) - } - - void client - .sendRequest('repo.list') - .then((repoResponse) => { - if (stale) { - return - } - if (repoResponse.ok) { - const result = (repoResponse as RpcSuccess).result as { repos: Repo[] } - setRepos(result.repos) - if (hostId) { - setCachedRepos(hostId, result.repos) - } - setSelectedRepo((current) => { - // Why: the optimistic cache can include repos removed before the - // fresh repo.list returns; never create against a stale repo id. - return refreshMobileNewWorkspaceDialogSelectedRepo(result.repos, current) - }) - } - }) - // Why (F10): a dropped connection rejects this call — keep the last-good list (the content - // remounts with the new host's cache when the client changes) instead of emptying the picker. - .catch(() => undefined) - .finally(() => { - if (!stale) { - setLoading(false) - } - }) - - void (async () => { - // Why: settle each RPC independently so a flaky availability probe (e.g. a - // linear.status timeout, which rejects rather than resolving {ok:false}) - // can't discard the already-resolved critical settings/ui results. - const probes = Promise.allSettled([ - client.sendRequest('preflight.check'), - client.sendRequest('linear.status') - ]) - const okResult = (entry: PromiseSettledResult): RpcSuccess | null => - entry.status === 'fulfilled' && entry.value.ok ? (entry.value as RpcSuccess) : null - // Why: hydrate settings/trust the moment their own RPCs settle — gating them - // on the probes (a first-open preflight.check can take seconds) widens the - // window where an already-trusted setup hook spuriously re-prompts on create. - const [settingsRes, uiRes] = await Promise.allSettled([ - client.sendRequest('settings.get'), - client.sendRequest('ui.get') - ]) - if (stale) { - return - } - - const settingsResult = okResult(settingsRes) - const settingsValue = settingsResult - ? ( - settingsResult.result as { - settings: RuntimeSettings & { visibleTaskProviders?: unknown } - } - ).settings - : null - if (settingsValue) { - setRuntimeSettings(settingsValue) - } - const uiResult = okResult(uiRes) - if (uiResult) { - const ui = (uiResult.result as { ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } }).ui - setTrustedOrcaHooks(ui?.trustedOrcaHooks ?? {}) - } - - const [preflightRes, linearRes] = await probes - if (stale) { - return - } - const glabInstalled = - (okResult(preflightRes)?.result as { glab?: { installed?: boolean } } | undefined)?.glab - ?.installed === true - const linearConnected = - (okResult(linearRes)?.result as { connected?: boolean } | undefined)?.connected === true - const visibleProviders = normalizeVisibleTaskProviders(settingsValue?.visibleTaskProviders) - setAvailableProviders( - // Drop filterAvailableTaskProviders' forced 'github' fallback when the user - // hid GitHub; the Branch tab always guarantees at least one tab remains. - filterAvailableTaskProviders(visibleProviders, { - gitlabInstalled: glabInstalled, - linearConnected - }).filter((provider) => visibleProviders.includes(provider)) - ) - })() - return () => { - stale = true - } - }, [visible, client, hostId]) - - useEffect(() => { - if (!visible || !client || !selectedRepoConnectionId) { - return - } - let stale = false - void client - .sendRequest('ssh.getState', { targetId: selectedRepoConnectionId }) - .then((response) => { - if (stale) { - return - } - if (!response.ok) { - throw new Error(response.error.message) - } - const state = (response as RpcSuccess).result as { state?: SshConnectionState | null } - setSshState( - state.state ?? { - targetId: selectedRepoConnectionId, - status: 'disconnected', - error: null, - reconnectAttempt: 0 - } - ) - }) - .catch((err) => { - if (!stale) { - setSshState({ - targetId: selectedRepoConnectionId, - status: 'error', - error: err instanceof Error ? err.message : 'Failed to read SSH connection state.', - reconnectAttempt: 0 - }) - } - }) - return () => { - stale = true - } - }, [client, selectedRepoConnectionId, visible]) - - useEffect(() => { - if (!visible || !client) { - return - } - if (selectedRepoConnectionId && sshGate.status !== 'connected') { - return - } - let stale = false - void (async () => { - try { - const response = selectedRepoConnectionId - ? await client.sendRequest('preflight.detectRemoteAgents', { - connectionId: selectedRepoConnectionId - }) - : await client.sendRequest('preflight.detectAgents') - if (stale) { - return - } - setDetectedAgentIdsState({ - connectionId: selectedRepoConnectionId, - ids: response.ok ? new Set((response as RpcSuccess).result as string[]) : new Set() - }) - } catch { - if (!stale) { - setDetectedAgentIdsState({ connectionId: selectedRepoConnectionId, ids: new Set() }) - } - } - })() - return () => { - stale = true - } - }, [client, selectedRepoConnectionId, sshGate.status, visible]) - - useEffect(() => { - if (!client || !selectedRepo) { - return - } - let stale = false - void (async () => { - try { - const response = await client.sendRequest('repo.hooks', { - repo: `id:${selectedRepo.id}` - }) - if (stale) { - return - } - if (response.ok) { - const result = (response as RpcSuccess).result as RepoHooksResponse - const cmd = result.hooks?.scripts?.setup?.trim() || null - const policy = result.setupRunPolicy ?? 'run-by-default' - setSetupHookDetails({ - repoId: selectedRepo.id, - command: cmd, - source: result.source, - trust: normalizeSetupHookTrust(result.setupTrust), - runPolicy: policy - }) - setSetupDecisionChoice(null) - setRunSetup(policy !== 'skip-by-default') - if (cmd && policy === 'ask') { - setShowAdvanced(true) - } - } - } catch { - if (!stale) { - setSetupHookDetails({ - repoId: selectedRepo.id, - command: null, - source: null, - trust: null, - runPolicy: 'run-by-default' - }) - setSetupDecisionChoice(null) - } - } - })() - return () => { - stale = true - } - }, [client, selectedRepo]) - - async function connectSelectedSshRepo(): Promise { - if (!client || !selectedRepoConnectionId) { - return - } - setSshConnectingTargetId(selectedRepoConnectionId) - setSshState({ - targetId: selectedRepoConnectionId, - status: 'connecting', - error: null, - reconnectAttempt: 0 - }) - try { - const response = await client.sendRequest( - 'ssh.connect', - { targetId: selectedRepoConnectionId }, - { timeoutMs: 120_000 } - ) - if (!response.ok) { - throw new Error(response.error.message) - } - const result = (response as RpcSuccess).result as { state?: SshConnectionState | null } - setSshState( - result.state ?? { - targetId: selectedRepoConnectionId, - status: 'connected', - error: null, - reconnectAttempt: 0 - } - ) - } catch (err) { - setSshState({ - targetId: selectedRepoConnectionId, - status: 'error', - error: err instanceof Error ? err.message : 'Failed to connect to SSH repository.', - reconnectAttempt: 0 - }) - } finally { - setSshConnectingTargetId((current) => (current === selectedRepoConnectionId ? null : current)) - } - } - - async function handleCreate(options: CreateOptions = {}) { - if (!client || !selectedRepo || createInFlightRef.current) { - return - } - createInFlightRef.current = true - setCreating(true) - setError('') - - try { - if (sshGate.requiresConnection) { - setError(`Connect ${selectedRepo.displayName} before creating a workspace.`) - return - } - let latestRuntimeSettings = runtimeSettings - try { - const settingsResponse = await client.sendRequest('settings.get') - if (settingsResponse.ok) { - const result = (settingsResponse as RpcSuccess).result as { settings: RuntimeSettings } - latestRuntimeSettings = result.settings - setRuntimeSettings(result.settings) - } - } catch { - // Best-effort refresh; the runtime validates the same setting before spawning. - } - if ( - selectedAgent.id !== '__blank__' && - !isMobileTuiAgentEnabled(selectedAgent.id, latestRuntimeSettings?.disabledTuiAgents) - ) { - setSelectedAgent(pickPreferredNewWorktreeAgent(latestRuntimeSettings, detectedAgentIds)) - setAgentOverridden(false) - setError('Selected agent is disabled. Choose an enabled agent before creating.') - return - } - - // Why: blank name field — match desktop behavior by computing the - // next available marine-creature name at submit time and passing it - // to the server. The server's worktree.create rejects empty/invalid - // names, so we must generate one client-side rather than letting the - // server invent one. The pre-flight basename dedupe is only a hint; - // the authoritative collision is checked server-side against git - // branches/remotes/PRs, so we also retry-with-suffix on conflict. - const trimmedName = composer.name.trim() - const baseName = - trimmedName || - getSuggestedCreatureName(existingWorktreePaths ?? [], undefined, retiredWorktreeNames) - - let setupDecision: SetupDecision = 'inherit' - if (setupCommand) { - if (options.setupOverride) { - setupDecision = options.setupOverride - } else if (setupRunPolicy === 'ask') { - if (!setupDecisionChoice) { - setError('Choose whether to run the setup script.') - return - } - setupDecision = setupDecisionChoice - } else { - setupDecision = runSetup ? 'run' : 'skip' - } - } - if ( - setupDecision === 'run' && - setupTrust && - setupTrust.contentHash !== options.approvedSetupContentHash && - !isSetupHookTrusted(trustedOrcaHooks, selectedRepo.id, setupTrust.contentHash) - ) { - // Why: desktop prompts before running repo-owned orca.yaml setup hooks. - // Mobile stores the same trust hash so approvals carry across surfaces. - setSetupTrustPrompt({ - repoId: selectedRepo.id, - repoName: selectedRepo.displayName, - scriptContent: setupTrust.scriptContent, - contentHash: setupTrust.contentHash, - previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, selectedRepo.id) - }) - transitionDrawer('trust') - return - } - - const createdWithAgentId = selectedAgent.id !== '__blank__' ? selectedAgent.id : undefined - const trimmedNote = note.trim() || undefined - const createSelection = composer.createSelection - const result = createSelection - ? await createWorkspaceFromComposerSource({ - client, - selection: createSelection, - targetRepoId: selectedRepo.id, - setupDecision, - agent: { choice: normalizeWorkspaceAgent(selectedAgent.id) ?? 'blank' }, - workspaceName: trimmedName || undefined, - note: trimmedNote, - nameIsAutoManaged: composer.isNameAutoManaged, - supportsIdempotentCutoverRetry: getWorktreeCreateCutoverSupport() - }) - : await createBlankWorkspace({ - client, - repoId: selectedRepo.id, - baseName, - // `baseName` is the suggestion exactly when the user typed nothing, so no identity check - // is needed — desktop's seeded composer needs one (useComposerState `nameWasGenerated`). - nameWasGenerated: !trimmedName, - createdWithAgentId, - comment: trimmedNote, - setupDecision, - supportsIdempotentCutoverRetry: getWorktreeCreateCutoverSupport() - }) - if ('error' in result) { - setError(result.error) - return - } - onClose() - onCreated(result.worktreeId, result.name) - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to create workspace') - } finally { - createInFlightRef.current = false - setCreating(false) - } - } - - const needsSetupChoice = Boolean(setupCommand) && setupRunPolicy === 'ask' - const canCreate = - selectedRepo != null && - !creating && - !sshGate.requiresConnection && - (!needsSetupChoice || setupDecisionChoice != null) - const visibleAgentOptions = - detectedAgentIds === null - ? AGENT_OPTIONS.filter( - (agent) => - agent.id !== '__blank__' && - isMobileTuiAgentEnabled(agent.id, runtimeSettings?.disabledTuiAgents) - ) - : AGENT_OPTIONS.filter( - (agent) => - agent.id !== '__blank__' && - detectedAgentIds.has(agent.id) && - isMobileTuiAgentEnabled(agent.id, runtimeSettings?.disabledTuiAgents) - ) - const pickerAgentOptions = [...visibleAgentOptions, BLANK_TERMINAL] const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos]) const selectedProjectId = selectedRepo ? getProjectIdentityKey(selectedRepo) : null const selectedProject = @@ -725,604 +154,108 @@ function NewWorktreeModalContent({ const selectedRunTarget = selectedRepo ? getNewWorkspaceRunTarget(selectedRepo, hostPlatform) : null + const needsSetupChoice = Boolean(setupScript.setupCommand) && setupScript.setupRunPolicy === 'ask' + const canCreate = + selectedRepo != null && + !createSubmit.creating && + !executionTarget.sshGate.requiresConnection && + (!needsSetupChoice || setupScript.setupDecisionChoice != null) - function prepareSelectionPickerOpen(): void { - // Why: picker taps can beat an open soft keyboard; dismissing it prevents the - // keyboard from reopening under the picker drawer. + function openPicker(view: 'project' | 'runTarget' | 'agent'): void { Keyboard.dismiss() + navigation.transitionDrawer(view) } - function handleRepoSelected(repo: Repo): void { + function selectRepo(repo: MobileWorkspaceRepo, clearRepoScopedSource: boolean): void { const repoChanged = repo.id !== selectedRepo?.id setSelectedRepo(repo) - // Branch and provider-backed sources are repo-scoped; Linear/Jira are global - // work context and survive choosing a different implementation repo. - if (repoChanged && !shouldPreserveWorkspaceSourceOnRepoChange(composer.linkedWorkItem)) { + if ( + clearRepoScopedSource && + repoChanged && + !shouldPreserveWorkspaceSourceOnRepoChange(composer.linkedWorkItem) + ) { composer.handleClearSmartNameSelection() } } - async function approveSetupTrust(alwaysTrust: boolean): Promise { - if ( - !client || - !setupTrustPrompt || - setupTrustActionInFlightRef.current || - createInFlightRef.current - ) { - return + function requestClose(): void { + if (navigation.drawerView === 'form') { + onClose() + } else if (navigation.drawerView === 'trust') { + createSubmit.closeSetupTrust() + } else { + navigation.transitionDrawer('form') } - setupTrustActionInFlightRef.current = true - setCreating(true) - try { - const nextTrust = await persistSetupHookTrustApproval({ - client, - trust: trustedOrcaHooks, - repoId: setupTrustPrompt.repoId, - contentHash: setupTrustPrompt.contentHash, - alwaysTrust - }) - setTrustedOrcaHooks(nextTrust) - const approvedHash = setupTrustPrompt.contentHash - setSetupTrustPrompt(null) - transitionDrawer('form') - await handleCreate({ setupOverride: 'run', approvedSetupContentHash: approvedHash }) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to trust setup script.') - } finally { - setupTrustActionInFlightRef.current = false - if (!createInFlightRef.current) { - setCreating(false) - } - } - } - - function closeSetupTrust(): void { - if (setupTrustActionInFlightRef.current || createInFlightRef.current) { - return - } - setSetupTrustPrompt(null) - transitionDrawer('form') - } - - function skipSetupTrust(): void { - if (setupTrustActionInFlightRef.current || createInFlightRef.current) { - return - } - closeSetupTrust() - void handleCreate({ setupOverride: 'skip' }) } return ( - // Why: hosting the form and every picker in one persistent native Modal makes - // form → repo/agent transitions in-window view swaps, avoiding the iOS - // dismiss-then-present race that left the dropdowns unresponsive. Native back - // closes the flow from the form, routes the trust prompt through its in-flight - // guard, and otherwise returns to the form from a picker. - { - if (drawerView === 'form') { - onClose() - } else if (drawerView === 'trust') { - closeSetupTrust() - } else { - transitionDrawer('form') - } - }} - > - - - Create worktree - + + 0} + project={selectedProject} + runTarget={selectedRunTarget} + projectBadgeColor={selectedRepo ? getMobileWorkspaceRepoBadgeColor(selectedRepo) : null} + selectedRepoIsGit={selectedRepoIsGit} + selectedRepoConnectionId={selectedRepoConnectionId} + selectedRepoName={selectedRepo?.displayName ?? 'Remote repository'} + sshGate={executionTarget.sshGate} + composer={composer} + selectedAgent={agentSelection.selectedAgent} + showAdvanced={setupScript.showAdvanced} + note={note} + setupCommand={setupScript.setupCommand} + setupSource={setupScript.setupSource} + setupRunPolicy={setupScript.setupRunPolicy} + setupDecisionChoice={setupScript.setupDecisionChoice} + runSetup={setupScript.runSetup} + error={error} + creating={createSubmit.creating} + canCreate={canCreate} + onClose={onClose} + onOpenProject={() => openPicker('project')} + onOpenRunTarget={() => openPicker('runTarget')} + onOpenSource={navigation.openSourceDrawer} + onClearError={() => setError('')} + onConnect={() => void executionTarget.connect()} + onOpenAgent={() => openPicker('agent')} + onShowAdvancedChange={setupScript.setShowAdvanced} + onNoteChange={setNote} + onSetupDecisionChange={setupScript.setSetupDecisionChoice} + onRunSetupChange={setupScript.setRunSetup} + onCreate={() => void createSubmit.create()} + /> - {loading ? ( - - - - ) : repos.length === 0 ? ( - - No projects found - - ) : ( - <> - { - prepareSelectionPickerOpen() - transitionDrawer('project') - }} - onOpenRunTarget={() => { - prepareSelectionPickerOpen() - transitionDrawer('runTarget') - }} - /> - - setError('')} - onOpenDrawer={openSourceDrawer} - /> - - {composer.forkPushWarning ? ( - {composer.forkPushWarning} - ) : null} - - {selectedRepoConnectionId ? ( - - SSH Connection - - - - - - {selectedRepo?.displayName ?? 'Remote repository'} - - - {workspaceSshStatusLabel(sshGate.status)} - - - {sshGate.status === 'connected' ? null : ( - void connectSelectedSshRepo()} - > - - {sshGate.connectInProgress ? 'Connecting...' : 'Connect'} - - - )} - - {sshGate.error ? {sshGate.error} : null} - - - ) : null} - - - Agent - { - prepareSelectionPickerOpen() - transitionDrawer('agent') - }} - > - - - {sshGate.requiresConnection ? 'Connect target first' : selectedAgent.label} - - - - - - setShowAdvanced(!showAdvanced)}> - Advanced - {showAdvanced ? ( - - ) : ( - - )} - - - {showAdvanced && ( - <> - - - - Note - - - - {setupCommand ? ( - - - Setup script - {setupSource && ( - - - {setupSource === 'orca.yaml' ? 'ORCA.YAML' : 'HOOKS'} - - - )} - - - {setupRunPolicy === 'ask' ? ( - - setSetupDecisionChoice('run')} - > - Run - - setSetupDecisionChoice('skip')} - > - Skip - - - ) : ( - - Run setup command - - - )} - - {setupCommand} - - - - ) : null} - - )} - - {error ? {error} : null} - - - void handleCreate()} - > - {creating ? ( - - ) : ( - - {sshGate.requiresConnection ? 'Connect target' : 'Create worktree'} - - )} - - - - )} - - - {/* Why: list drawers stay outside the form's ScrollView, and the transition - state lets each hosted overlay finish hiding before the next appears. */} - { - const nextRepo = repos.find((repo) => repo.id === repoId) - if (nextRepo) { - setSelectedRepo(nextRepo) - } + sourceAvailability={sourceAvailability} + selectedRepo={selectedRepo} + repos={repos} + pasteRepos={pasteRepos} + sshReady={!executionTarget.sshGate.requiresConnection} + projectPickerItems={projectPickerItems} + selectedProjectId={selectedProjectId} + runTargetPickerItems={runTargetPickerItems} + pickerAgentOptions={agentSelection.pickerAgentOptions} + selectedAgent={agentSelection.selectedAgent} + setupTrustPrompt={createSubmit.setupTrustPrompt} + creating={createSubmit.creating} + onSourceRepoChange={(repo) => selectRepo(repo, false)} + onRepoChange={(repo) => selectRepo(repo, true)} + onAgentChange={(agent) => { + agentSelection.setAgentOverridden(true) + agentSelection.setSelectedAgent(agent) }} - onClose={() => transitionDrawer('form')} - /> - - handleRepoSelected(item.repo)} - onClose={() => transitionDrawer('form')} - renderIcon={(item) => { - return - }} - /> - - handleRepoSelected(item.repo)} - onClose={() => transitionDrawer('form')} - renderIcon={() => } - /> - - { - setAgentOverridden(true) - setSelectedAgent(agent) - }} - onClose={() => transitionDrawer('form')} - renderIcon={(agent) => } - /> - - void approveSetupTrust(false)} - onAlwaysTrust={() => void approveSetupTrust(true)} - onDontRun={skipSetupTrust} - onClose={closeSetupTrust} + onTransitionToForm={() => navigation.transitionDrawer('form')} + onApproveSetupTrust={(alwaysTrust) => void createSubmit.approveSetupTrust(alwaysTrust)} + onSkipSetupTrust={createSubmit.skipSetupTrust} + onCloseSetupTrust={createSubmit.closeSetupTrust} /> ) } - -const styles = StyleSheet.create({ - header: { - paddingHorizontal: spacing.xs, - marginBottom: spacing.md - }, - title: { - fontSize: 15, - fontWeight: '600', - color: colors.textPrimary - }, - loadingContainer: { - paddingVertical: spacing.xl, - alignItems: 'center' - }, - emptyText: { - color: colors.textSecondary, - fontSize: typography.bodySize - }, - field: { - marginBottom: spacing.md - }, - label: { - fontSize: 13, - fontWeight: '500', - color: colors.textSecondary, - marginBottom: spacing.xs - }, - labelHint: { - fontWeight: '400', - color: colors.textMuted - }, - fieldButton: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, - borderWidth: 1, - borderColor: colors.borderSubtle - }, - fieldButtonText: { - fontSize: typography.bodySize, - color: colors.textPrimary - }, - fieldButtonPlaceholder: { - color: colors.textMuted - }, - repoDot: { - width: 8, - height: 8, - borderRadius: 999 - }, - disabled: { - opacity: 0.55 - }, - sshBox: { - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - borderWidth: 1, - borderColor: colors.borderSubtle, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - gap: spacing.xs - }, - sshRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm - }, - sshDot: { - width: 8, - height: 8, - borderRadius: 999 - }, - sshDotConnected: { - backgroundColor: colors.statusGreen - }, - sshDotProgress: { - backgroundColor: colors.statusAmber - }, - sshDotDisconnected: { - backgroundColor: colors.statusRed - }, - sshCopy: { - flex: 1, - minWidth: 0 - }, - sshTitle: { - fontSize: typography.bodySize, - color: colors.textPrimary, - fontWeight: '600' - }, - sshSubtitle: { - fontSize: 12, - color: colors.textSecondary, - marginTop: 1 - }, - sshConnectButton: { - borderRadius: radii.button, - borderWidth: 1, - borderColor: colors.borderSubtle, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs - }, - sshConnectText: { - color: colors.textPrimary, - fontSize: 12, - fontWeight: '600' - }, - errorInline: { - color: colors.statusRed, - fontSize: 12 - }, - input: { - backgroundColor: colors.bgRaised, - color: colors.textPrimary, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, - fontSize: typography.bodySize, - borderWidth: 1, - borderColor: colors.borderSubtle - }, - error: { - color: colors.statusRed, - fontSize: 13, - marginBottom: spacing.md - }, - sourceWarning: { - marginTop: -spacing.sm, - marginBottom: spacing.md, - fontSize: 12, - color: colors.statusAmber - }, - advancedToggle: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - paddingVertical: spacing.sm, - marginBottom: spacing.xs - }, - advancedText: { - fontSize: typography.bodySize, - fontWeight: '500', - color: colors.textSecondary - }, - setupHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: spacing.xs - }, - sourceBadge: { - backgroundColor: colors.bgRaised, - borderRadius: 4, - paddingHorizontal: spacing.xs + 2, - paddingVertical: 2 - }, - sourceBadgeText: { - fontSize: 10, - fontWeight: '600', - color: colors.textMuted, - letterSpacing: 0.5 - }, - setupBox: { - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - borderWidth: 1, - borderColor: colors.borderSubtle, - padding: spacing.md - }, - setupToggleRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: spacing.sm - }, - setupToggleLabel: { - fontSize: 13, - color: colors.textSecondary - }, - setupChoiceRow: { - flexDirection: 'row', - gap: spacing.sm, - marginBottom: spacing.sm - }, - setupChoiceButton: { - flex: 1, - alignItems: 'center', - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.button, - paddingVertical: spacing.sm - }, - setupChoiceButtonSelected: { - backgroundColor: colors.bgPanel, - borderColor: colors.textSecondary - }, - setupChoiceText: { - fontSize: 13, - fontWeight: '600', - color: colors.textPrimary - }, - setupSwitch: { - transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }] - }, - setupCommandBlock: { - backgroundColor: colors.bgBase, - borderRadius: 6, - paddingHorizontal: spacing.sm + 2, - paddingVertical: spacing.sm - }, - setupCommand: { - fontSize: 13, - fontFamily: typography.monoFamily, - color: colors.textPrimary - }, - actions: { - flexDirection: 'row', - justifyContent: 'flex-end', - marginTop: spacing.sm - }, - createButton: { - backgroundColor: colors.textPrimary, - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, - borderRadius: radii.button, - minWidth: 160, - alignItems: 'center' - }, - createButtonDisabled: { - opacity: 0.4 - }, - createText: { - color: colors.bgBase, - fontSize: typography.bodySize, - fontWeight: '600' - } -}) diff --git a/mobile/src/components/NewWorktreeModalDrawers.tsx b/mobile/src/components/NewWorktreeModalDrawers.tsx new file mode 100644 index 00000000000..70ce22cb807 --- /dev/null +++ b/mobile/src/components/NewWorktreeModalDrawers.tsx @@ -0,0 +1,115 @@ +import { View } from 'react-native' +import { Monitor } from 'lucide-react-native' +import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes' +import type { PasteRepoCandidate } from '../tasks/smart-source-paste-intent' +import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors } from '../theme/mobile-theme' +import { MobileAgentIcon } from './MobileAgentIcon' +import type { NewWorktreeAgentOption } from './new-worktree-agent-selection' +import { newWorktreeFormStyles as styles } from './new-worktree-form-styles' +import type { MobileWorkspaceRepo } from './new-worktree-modal-types' +import type { + NewWorkspaceProjectOption, + NewWorkspaceRunTargetOption +} from './new-workspace-project-targets' +import { getMobileWorkspaceRepoBadgeColor } from './new-worktree-modal-types' +import { PickerListDrawer } from './PickerListDrawer' +import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer' +import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer' +import type { NewWorktreeDrawerView } from './use-new-worktree-drawer-navigation' + +type Composer = ReturnType + +export function NewWorktreeModalDrawers(props: { + visible: boolean + drawerView: NewWorktreeDrawerView + client: Parameters[0]['client'] + composer: Composer + sourceAvailability: SmartModeAvailabilityInput + selectedRepo: MobileWorkspaceRepo | null + repos: MobileWorkspaceRepo[] + pasteRepos: PasteRepoCandidate[] + sshReady: boolean + projectPickerItems: NewWorkspaceProjectOption[] + selectedProjectId: string | null + runTargetPickerItems: NewWorkspaceRunTargetOption[] + pickerAgentOptions: NewWorktreeAgentOption[] + selectedAgent: NewWorktreeAgentOption + setupTrustPrompt: SetupTrustPrompt | null + creating: boolean + onSourceRepoChange: (repo: MobileWorkspaceRepo) => void + onRepoChange: (repo: MobileWorkspaceRepo) => void + onAgentChange: (agent: NewWorktreeAgentOption) => void + onTransitionToForm: () => void + onApproveSetupTrust: (alwaysTrust: boolean) => void + onSkipSetupTrust: () => void + onCloseSetupTrust: () => void +}) { + return ( + <> + { + const nextRepo = props.repos.find((repo) => repo.id === repoId) + if (nextRepo) { + props.onSourceRepoChange(nextRepo) + } + }} + onClose={props.onTransitionToForm} + /> + + props.onRepoChange(item.repo)} + onClose={props.onTransitionToForm} + renderIcon={(item) => ( + + )} + /> + + props.onRepoChange(item.repo)} + onClose={props.onTransitionToForm} + renderIcon={() => } + /> + + } + /> + + props.onApproveSetupTrust(false)} + onAlwaysTrust={() => props.onApproveSetupTrust(true)} + onDontRun={props.onSkipSetupTrust} + onClose={props.onCloseSetupTrust} + /> + + ) +} diff --git a/mobile/src/components/WorktreeListRow.test.ts b/mobile/src/components/WorktreeListRow.test.ts index d0825412636..01e0128cdc1 100644 --- a/mobile/src/components/WorktreeListRow.test.ts +++ b/mobile/src/components/WorktreeListRow.test.ts @@ -199,7 +199,7 @@ describe('memoized worktree rows', () => { await act(async () => { renderer!.update( createElement(WorktreeAgentRow, { - agent: agent({ state: 'done', updatedAt: 2_000 }), + agent: agent({ state: 'working', workingMode: 'monitoring', updatedAt: 2_000 }), depth: 0, now: 2_000, unvisited: false @@ -207,5 +207,22 @@ describe('memoized worktree rows', () => { ) }) expect(agentStateDotRender).toHaveBeenCalledTimes(2) + expect(agentStateDotRender).toHaveBeenLastCalledWith({ state: 'monitoring' }) + }) + + it('passes workspace monitoring mode to the status indicator', async () => { + await act(async () => { + renderer = create( + createElement(ListRowHarness, { + item: { ...baseItem, status: 'working', workingMode: 'monitoring' }, + now: 2_000 + }) + ) + }) + + expect(agentSpinnerRender).toHaveBeenLastCalledWith({ + status: 'working', + workingMode: 'monitoring' + }) }) }) diff --git a/mobile/src/components/WorktreeListRow.tsx b/mobile/src/components/WorktreeListRow.tsx index 74b716c2b30..ba1c3865fc7 100644 --- a/mobile/src/components/WorktreeListRow.tsx +++ b/mobile/src/components/WorktreeListRow.tsx @@ -2,6 +2,7 @@ import { memo } from 'react' import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native' import { Pressable, StyleSheet, Text, View } from 'react-native' import type { RepoIcon } from '../../../src/shared/repo-icon' +import type { AgentWorkingMode } from '../../../src/shared/agent-status-types' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' import { triggerMediumImpact } from '../platform/haptics' import { colors, radii, spacing, typography } from '../theme/mobile-theme' @@ -39,6 +40,7 @@ export type WorktreeListRowItem = { lineageChildCount?: number lineageCollapsed?: boolean agents?: RuntimeWorktreeAgentRow[] + workingMode?: AgentWorkingMode } type WorktreeRollupStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive' @@ -97,7 +99,7 @@ function WorktreeListRowComponent({ delayLongPress={400} > - + {item.unread && ( } + } + unmount(): void +} + +const { animationLoop, animationTiming, setValue } = vi.hoisted(() => ({ + animationLoop: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), + animationTiming: vi.fn(() => ({})), + setValue: vi.fn() +})) + +vi.mock('lucide-react-native', () => ({ Radio: 'Radio' })) +vi.mock('react-native', () => ({ + Animated: { + Value: function Value() { + return { interpolate: vi.fn(() => 'rotation'), setValue } + }, + View: 'AnimatedView', + loop: animationLoop, + timing: animationTiming + }, + Easing: { linear: 'linear' }, + StyleSheet: { create: (styles: T) => styles }, + View: 'View' +})) + +describe('mobile monitoring indicators', () => { + let renderer: MonitoringTestRenderer | null = null + + beforeEach(() => { + animationLoop.mockClear() + animationTiming.mockClear() + setValue.mockClear() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('renders a static Radio for a monitoring agent', async () => { + await act(async () => { + renderer = create(createElement(AgentStateDot, { state: 'monitoring' })) + }) + + expect(renderer?.root.findByType('Radio').props).toMatchObject({ + color: DESKTOP_WORKING_COLOR, + size: 10 + }) + expect(animationTiming).not.toHaveBeenCalled() + expect(animationLoop).not.toHaveBeenCalled() + }) + + it('renders a static Radio for an all-monitoring workspace', async () => { + await act(async () => { + renderer = create( + createElement(AgentSpinner, { status: 'working', workingMode: 'monitoring' }) + ) + }) + + expect(renderer?.root.findByType('Radio').props).toMatchObject({ + color: DESKTOP_WORKING_COLOR, + size: 12 + }) + expect(animationTiming).not.toHaveBeenCalled() + expect(animationLoop).not.toHaveBeenCalled() + }) + + it('keeps the spinner fallback when workingMode is absent', async () => { + await act(async () => { + renderer = create(createElement(AgentSpinner, { status: 'working' })) + }) + + expect(animationTiming).toHaveBeenCalledOnce() + expect(animationLoop).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/components/new-worktree-form-styles.ts b/mobile/src/components/new-worktree-form-styles.ts new file mode 100644 index 00000000000..e6ccff91751 --- /dev/null +++ b/mobile/src/components/new-worktree-form-styles.ts @@ -0,0 +1,244 @@ +import { Platform, StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const newWorktreeFormStyles = StyleSheet.create({ + header: { + paddingHorizontal: spacing.xs, + marginBottom: spacing.md + }, + title: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + loadingContainer: { + paddingVertical: spacing.xl, + alignItems: 'center' + }, + emptyText: { + color: colors.textSecondary, + fontSize: typography.bodySize + }, + field: { + marginBottom: spacing.md + }, + label: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + labelHint: { + fontWeight: '400', + color: colors.textMuted + }, + fieldButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + fieldButtonText: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + fieldButtonPlaceholder: { + color: colors.textMuted + }, + repoDot: { + width: 8, + height: 8, + borderRadius: 999 + }, + disabled: { + opacity: 0.55 + }, + sshBox: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + gap: spacing.xs + }, + sshRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + sshDot: { + width: 8, + height: 8, + borderRadius: 999 + }, + sshDotConnected: { + backgroundColor: colors.statusGreen + }, + sshDotProgress: { + backgroundColor: colors.statusAmber + }, + sshDotDisconnected: { + backgroundColor: colors.statusRed + }, + sshCopy: { + flex: 1, + minWidth: 0 + }, + sshTitle: { + fontSize: typography.bodySize, + color: colors.textPrimary, + fontWeight: '600' + }, + sshSubtitle: { + fontSize: 12, + color: colors.textSecondary, + marginTop: 1 + }, + sshConnectButton: { + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + sshConnectText: { + color: colors.textPrimary, + fontSize: 12, + fontWeight: '600' + }, + errorInline: { + color: colors.statusRed, + fontSize: 12 + }, + input: { + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, + fontSize: typography.bodySize, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + error: { + color: colors.statusRed, + fontSize: 13, + marginBottom: spacing.md + }, + sourceWarning: { + marginTop: -spacing.sm, + marginBottom: spacing.md, + fontSize: 12, + color: colors.statusAmber + }, + advancedToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingVertical: spacing.sm, + marginBottom: spacing.xs + }, + advancedText: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textSecondary + }, + setupHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.xs + }, + sourceBadge: { + backgroundColor: colors.bgRaised, + borderRadius: 4, + paddingHorizontal: spacing.xs + 2, + paddingVertical: 2 + }, + sourceBadgeText: { + fontSize: 10, + fontWeight: '600', + color: colors.textMuted, + letterSpacing: 0.5 + }, + setupBox: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md + }, + setupToggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.sm + }, + setupToggleLabel: { + fontSize: 13, + color: colors.textSecondary + }, + setupChoiceRow: { + flexDirection: 'row', + gap: spacing.sm, + marginBottom: spacing.sm + }, + setupChoiceButton: { + flex: 1, + alignItems: 'center', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.button, + paddingVertical: spacing.sm + }, + setupChoiceButtonSelected: { + backgroundColor: colors.bgPanel, + borderColor: colors.textSecondary + }, + setupChoiceText: { + fontSize: 13, + fontWeight: '600', + color: colors.textPrimary + }, + setupSwitch: { + transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }] + }, + setupCommandBlock: { + backgroundColor: colors.bgBase, + borderRadius: 6, + paddingHorizontal: spacing.sm + 2, + paddingVertical: spacing.sm + }, + setupCommand: { + fontSize: 13, + fontFamily: typography.monoFamily, + color: colors.textPrimary + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: spacing.sm + }, + createButton: { + backgroundColor: colors.textPrimary, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radii.button, + minWidth: 160, + alignItems: 'center' + }, + createButtonDisabled: { + opacity: 0.4 + }, + createText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + } +}) diff --git a/mobile/src/components/new-worktree-modal-types.ts b/mobile/src/components/new-worktree-modal-types.ts new file mode 100644 index 00000000000..4246dd6f29f --- /dev/null +++ b/mobile/src/components/new-worktree-modal-types.ts @@ -0,0 +1,49 @@ +import type { Repo as SharedRepo } from '../../../src/shared/repo-types' +import type { RpcClient } from '../transport/rpc-client' +import type { SetupHookTrust } from '../tasks/setup-hook-trust' +import { repoColor } from '../worktree/repo-color' + +export type MobileWorkspaceRepo = Pick & + Partial< + Pick< + SharedRepo, + | 'badgeColor' + | 'connectionId' + | 'executionHostId' + | 'kind' + | 'upstream' + | 'repoIcon' + | 'gitRemoteIdentity' + > + > + +export type NewWorktreeModalProps = { + visible: boolean + client: RpcClient | null + hostId?: string + existingWorktreePaths?: readonly string[] + existingWorktrees?: readonly { repoId: string; branch: string }[] + onCreated: (worktreeId: string, name: string) => void + onClose: () => void +} + +export type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default' + +export type RepoHooksResponse = { + hooks: { scripts?: { setup?: string } } | null + source: string | null + setupRunPolicy?: SetupRunPolicy + setupTrust?: SetupHookTrust +} + +export type SetupHookDetails = { + repoId: string + command: string | null + source: string | null + trust: SetupHookTrust | null + runPolicy: SetupRunPolicy +} + +export function getMobileWorkspaceRepoBadgeColor(repo: MobileWorkspaceRepo | null): string { + return repo?.badgeColor || repoColor(repo?.displayName ?? 'repository') +} diff --git a/mobile/src/components/use-new-workspace-agent-selection.ts b/mobile/src/components/use-new-workspace-agent-selection.ts new file mode 100644 index 00000000000..3f3a50c993f --- /dev/null +++ b/mobile/src/components/use-new-workspace-agent-selection.ts @@ -0,0 +1,51 @@ +import { useState } from 'react' +import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents' +import { + NEW_WORKTREE_AGENT_OPTIONS, + NEW_WORKTREE_BLANK_AGENT, + resolveNewWorktreeAgentSelection, + type NewWorktreeAgentOption, + type NewWorktreeRuntimeSettings +} from './new-worktree-agent-selection' + +export function useNewWorkspaceAgentSelection(args: { + visible: boolean + runtimeSettings: NewWorktreeRuntimeSettings | null + detectedAgentIds: Set | null +}): { + selectedAgent: NewWorktreeAgentOption + setSelectedAgent: (agent: NewWorktreeAgentOption) => void + setAgentOverridden: (overridden: boolean) => void + pickerAgentOptions: NewWorktreeAgentOption[] +} { + const [selectedAgentState, setSelectedAgent] = useState( + NEW_WORKTREE_AGENT_OPTIONS[0]! + ) + const [agentOverriddenState, setAgentOverridden] = useState(false) + const resolution = resolveNewWorktreeAgentSelection({ + visible: args.visible, + selectedAgent: selectedAgentState, + agentOverridden: agentOverriddenState, + runtimeSettings: args.runtimeSettings, + detectedAgentIds: args.detectedAgentIds + }) + if ( + selectedAgentState.id !== resolution.selectedAgent.id || + agentOverriddenState !== resolution.agentOverridden + ) { + setSelectedAgent(resolution.selectedAgent) + setAgentOverridden(resolution.agentOverridden) + } + const visibleAgentOptions = NEW_WORKTREE_AGENT_OPTIONS.filter( + (agent) => + agent.id !== '__blank__' && + (args.detectedAgentIds === null || args.detectedAgentIds.has(agent.id)) && + isMobileTuiAgentEnabled(agent.id, args.runtimeSettings?.disabledTuiAgents) + ) + return { + selectedAgent: resolution.selectedAgent, + setSelectedAgent, + setAgentOverridden, + pickerAgentOptions: [...visibleAgentOptions, NEW_WORKTREE_BLANK_AGENT] + } +} diff --git a/mobile/src/components/use-new-workspace-create-submit.ts b/mobile/src/components/use-new-workspace-create-submit.ts new file mode 100644 index 00000000000..14d9f9bfae0 --- /dev/null +++ b/mobile/src/components/use-new-workspace-create-submit.ts @@ -0,0 +1,243 @@ +import { useRef, useState, type Dispatch, type SetStateAction } from 'react' +import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types' +import type { RetiredNameRegistry } from '../../../src/shared/worktree/retired-name-registry' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { createBlankWorkspace } from '../tasks/blank-workspace-create' +import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents' +import { + isSetupHookTrusted, + persistSetupHookTrustApproval, + wasSetupHookPreviouslyApproved, + type SetupHookTrust +} from '../tasks/setup-hook-trust' +import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create' +import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection' +import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params' +import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate' +import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source' +import { + pickPreferredNewWorktreeAgent, + type NewWorktreeAgentOption, + type NewWorktreeRuntimeSettings +} from './new-worktree-agent-selection' +import type { MobileWorkspaceRepo, SetupRunPolicy } from './new-worktree-modal-types' +import type { SetupTrustPrompt } from './SetupHookTrustDrawer' +import type { NewWorktreeDrawerView } from './use-new-worktree-drawer-navigation' +import { getSuggestedCreatureName } from './worktree-name-suggestion' + +type CreateOptions = { + setupOverride?: Exclude + approvedSetupContentHash?: string +} + +type Composer = ReturnType + +export function useNewWorkspaceCreateSubmit(args: { + client: RpcClient | null + selectedRepo: MobileWorkspaceRepo | null + selectedAgent: NewWorktreeAgentOption + setSelectedAgent: (agent: NewWorktreeAgentOption) => void + setAgentOverridden: (overridden: boolean) => void + runtimeSettings: NewWorktreeRuntimeSettings | null + setRuntimeSettings: (settings: NewWorktreeRuntimeSettings) => void + detectedAgentIds: Set | null + sshGate: WorkspaceSshGate + composer: Composer + note: string + existingWorktreePaths?: readonly string[] + retiredWorktreeNames: RetiredNameRegistry + setupCommand: string | null + setupTrust: SetupHookTrust | null + setupRunPolicy: SetupRunPolicy + setupDecisionChoice: Exclude | null + runSetup: boolean + trustedOrcaHooks: PersistedTrustedOrcaHooks + setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void + getWorktreeCreateCutoverSupport: () => Promise + transitionDrawer: (view: Exclude) => void + setError: Dispatch> + onCreated: (worktreeId: string, name: string) => void + onClose: () => void +}): { + creating: boolean + setupTrustPrompt: SetupTrustPrompt | null + create: () => Promise + approveSetupTrust: (alwaysTrust: boolean) => Promise + closeSetupTrust: () => void + skipSetupTrust: () => void +} { + const createInFlightRef = useRef(false) + const setupTrustActionInFlightRef = useRef(false) + const [creating, setCreating] = useState(false) + const [setupTrustPrompt, setSetupTrustPrompt] = useState(null) + + async function create(options: CreateOptions = {}): Promise { + const { client, selectedRepo } = args + if (!client || !selectedRepo || createInFlightRef.current) { + return + } + createInFlightRef.current = true + setCreating(true) + args.setError('') + try { + if (args.sshGate.requiresConnection) { + args.setError(`Connect ${selectedRepo.displayName} before creating a workspace.`) + return + } + let latestRuntimeSettings = args.runtimeSettings + try { + const settingsResponse = await client.sendRequest('settings.get') + if (settingsResponse.ok) { + const result = (settingsResponse as RpcSuccess).result as { + settings: NewWorktreeRuntimeSettings + } + latestRuntimeSettings = result.settings + args.setRuntimeSettings(result.settings) + } + } catch { + // The runtime validates the same setting before spawning. + } + if ( + args.selectedAgent.id !== '__blank__' && + !isMobileTuiAgentEnabled(args.selectedAgent.id, latestRuntimeSettings?.disabledTuiAgents) + ) { + args.setSelectedAgent( + pickPreferredNewWorktreeAgent(latestRuntimeSettings, args.detectedAgentIds) + ) + args.setAgentOverridden(false) + args.setError('Selected agent is disabled. Choose an enabled agent before creating.') + return + } + + const trimmedName = args.composer.name.trim() + const baseName = + trimmedName || + getSuggestedCreatureName( + args.existingWorktreePaths ?? [], + undefined, + args.retiredWorktreeNames + ) + let setupDecision: WorkspaceCreateSetupDecision = 'inherit' + if (args.setupCommand) { + if (options.setupOverride) { + setupDecision = options.setupOverride + } else if (args.setupRunPolicy === 'ask') { + if (!args.setupDecisionChoice) { + args.setError('Choose whether to run the setup script.') + return + } + setupDecision = args.setupDecisionChoice + } else { + setupDecision = args.runSetup ? 'run' : 'skip' + } + } + if ( + setupDecision === 'run' && + args.setupTrust && + args.setupTrust.contentHash !== options.approvedSetupContentHash && + !isSetupHookTrusted(args.trustedOrcaHooks, selectedRepo.id, args.setupTrust.contentHash) + ) { + setSetupTrustPrompt({ + repoId: selectedRepo.id, + repoName: selectedRepo.displayName, + scriptContent: args.setupTrust.scriptContent, + contentHash: args.setupTrust.contentHash, + previouslyApproved: wasSetupHookPreviouslyApproved(args.trustedOrcaHooks, selectedRepo.id) + }) + args.transitionDrawer('trust') + return + } + + const createdWithAgentId = + args.selectedAgent.id !== '__blank__' ? args.selectedAgent.id : undefined + const trimmedNote = args.note.trim() || undefined + const selection = args.composer.createSelection + const result = selection + ? await createWorkspaceFromComposerSource({ + client, + selection, + targetRepoId: selectedRepo.id, + setupDecision, + agent: { choice: normalizeWorkspaceAgent(args.selectedAgent.id) ?? 'blank' }, + workspaceName: trimmedName || undefined, + note: trimmedNote, + nameIsAutoManaged: args.composer.isNameAutoManaged, + supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport() + }) + : await createBlankWorkspace({ + client, + repoId: selectedRepo.id, + baseName, + nameWasGenerated: !trimmedName, + createdWithAgentId, + comment: trimmedNote, + setupDecision, + supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport() + }) + if ('error' in result) { + args.setError(result.error) + return + } + args.onClose() + args.onCreated(result.worktreeId, result.name) + } catch (error) { + args.setError(error instanceof Error ? error.message : 'Failed to create workspace') + } finally { + createInFlightRef.current = false + setCreating(false) + } + } + + async function approveSetupTrust(alwaysTrust: boolean): Promise { + if ( + !args.client || + !setupTrustPrompt || + setupTrustActionInFlightRef.current || + createInFlightRef.current + ) { + return + } + setupTrustActionInFlightRef.current = true + setCreating(true) + try { + const nextTrust = await persistSetupHookTrustApproval({ + client: args.client, + trust: args.trustedOrcaHooks, + repoId: setupTrustPrompt.repoId, + contentHash: setupTrustPrompt.contentHash, + alwaysTrust + }) + args.setTrustedOrcaHooks(nextTrust) + const approvedHash = setupTrustPrompt.contentHash + setSetupTrustPrompt(null) + args.transitionDrawer('form') + await create({ setupOverride: 'run', approvedSetupContentHash: approvedHash }) + } catch (error) { + args.setError(error instanceof Error ? error.message : 'Failed to trust setup script.') + } finally { + setupTrustActionInFlightRef.current = false + if (!createInFlightRef.current) { + setCreating(false) + } + } + } + + function closeSetupTrust(): void { + if (setupTrustActionInFlightRef.current || createInFlightRef.current) { + return + } + setSetupTrustPrompt(null) + args.transitionDrawer('form') + } + + function skipSetupTrust(): void { + if (setupTrustActionInFlightRef.current || createInFlightRef.current) { + return + } + closeSetupTrust() + void create({ setupOverride: 'skip' }) + } + + return { creating, setupTrustPrompt, create, approveSetupTrust, closeSetupTrust, skipSetupTrust } +} diff --git a/mobile/src/components/use-new-workspace-execution-target.ts b/mobile/src/components/use-new-workspace-execution-target.ts new file mode 100644 index 00000000000..27302d9ecb2 --- /dev/null +++ b/mobile/src/components/use-new-workspace-execution-target.ts @@ -0,0 +1,137 @@ +import { useEffect, useState } from 'react' +import type { SshConnectionState } from '../../../src/shared/ssh-types' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { deriveWorkspaceSshGate, type WorkspaceSshGate } from '../tasks/workspace-ssh-gate' + +type DetectedAgentIdsState = { + connectionId: string | null + ids: Set +} + +function fallbackSshState( + targetId: string, + status: SshConnectionState['status'], + error: string | null +): SshConnectionState { + return { targetId, status, error, reconnectAttempt: 0 } +} + +export function useNewWorkspaceExecutionTarget(args: { + client: RpcClient | null + connectionId: string | null + visible: boolean +}): { + sshGate: WorkspaceSshGate + detectedAgentIds: Set | null + connect: () => Promise +} { + const { client, connectionId, visible } = args + const [sshState, setSshState] = useState(null) + const [connectingTargetId, setConnectingTargetId] = useState(null) + const [detectedAgentIdsState, setDetectedAgentIdsState] = useState( + null + ) + const sshGate = deriveWorkspaceSshGate({ + connectionId, + state: sshState, + connecting: connectingTargetId === connectionId + }) + const detectedAgentIds = + detectedAgentIdsState?.connectionId === connectionId && + (connectionId === null || sshGate.status === 'connected') + ? detectedAgentIdsState.ids + : null + + useEffect(() => { + if (!visible || !client || !connectionId) { + return + } + let stale = false + void client + .sendRequest('ssh.getState', { targetId: connectionId }) + .then((response) => { + if (stale) { + return + } + if (!response.ok) { + throw new Error(response.error.message) + } + const state = (response as RpcSuccess).result as { state?: SshConnectionState | null } + setSshState(state.state ?? fallbackSshState(connectionId, 'disconnected', null)) + }) + .catch((error) => { + if (!stale) { + setSshState( + fallbackSshState( + connectionId, + 'error', + error instanceof Error ? error.message : 'Failed to read SSH connection state.' + ) + ) + } + }) + return () => { + stale = true + } + }, [client, connectionId, visible]) + + useEffect(() => { + if (!visible || !client || (connectionId && sshGate.status !== 'connected')) { + return + } + let stale = false + void (async () => { + try { + const response = connectionId + ? await client.sendRequest('preflight.detectRemoteAgents', { connectionId }) + : await client.sendRequest('preflight.detectAgents') + if (!stale) { + setDetectedAgentIdsState({ + connectionId, + ids: response.ok ? new Set((response as RpcSuccess).result as string[]) : new Set() + }) + } + } catch { + if (!stale) { + setDetectedAgentIdsState({ connectionId, ids: new Set() }) + } + } + })() + return () => { + stale = true + } + }, [client, connectionId, sshGate.status, visible]) + + async function connect(): Promise { + if (!client || !connectionId) { + return + } + setConnectingTargetId(connectionId) + setSshState(fallbackSshState(connectionId, 'connecting', null)) + try { + const response = await client.sendRequest( + 'ssh.connect', + { targetId: connectionId }, + { timeoutMs: 120_000 } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = (response as RpcSuccess).result as { state?: SshConnectionState | null } + setSshState(result.state ?? fallbackSshState(connectionId, 'connected', null)) + } catch (error) { + setSshState( + fallbackSshState( + connectionId, + 'error', + error instanceof Error ? error.message : 'Failed to connect to SSH repository.' + ) + ) + } finally { + setConnectingTargetId((current) => (current === connectionId ? null : current)) + } + } + + return { sshGate, detectedAgentIds, connect } +} diff --git a/mobile/src/components/use-new-workspace-repositories.ts b/mobile/src/components/use-new-workspace-repositories.ts new file mode 100644 index 00000000000..d2b700cc9ab --- /dev/null +++ b/mobile/src/components/use-new-workspace-repositories.ts @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { getCachedRepos, setCachedRepos } from '../cache/repo-cache' +import { useLastVisitedWorktreeRepoId } from '../worktree/use-last-visited-worktree-repo' +import { + getMobileNewWorkspaceDialogEligibleRepos, + refreshMobileNewWorkspaceDialogSelectedRepo, + resolveMobileNewWorkspaceDialogRepoId +} from '../worktree/new-workspace-dialog-repo-selection' +import type { MobileWorkspaceRepo } from './new-worktree-modal-types' + +export function useNewWorkspaceRepositories(args: { + client: RpcClient | null + hostId?: string + visible: boolean +}): { + repos: MobileWorkspaceRepo[] + selectedRepo: MobileWorkspaceRepo | null + setSelectedRepo: (repo: MobileWorkspaceRepo | null) => void + loading: boolean +} { + const { client, hostId, visible } = args + const [initialRepos] = useState(() => + hostId ? (getCachedRepos(hostId) as MobileWorkspaceRepo[] | null) : null + ) + const [repos, setRepos] = useState(initialRepos ?? []) + const [selectedRepo, setSelectedRepo] = useState(null) + const [loading, setLoading] = useState(initialRepos == null) + const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible) + + useEffect(() => { + if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) { + return + } + const eligibleRepos = getMobileNewWorkspaceDialogEligibleRepos(repos) + const preferredRepoId = resolveMobileNewWorkspaceDialogRepoId({ + eligibleRepos, + activeRepoId: lastVisitedRepo.repoId + }) + const preferredRepo = repos.find((repo) => repo.id === preferredRepoId) ?? null + if (preferredRepo) { + setSelectedRepo(preferredRepo) + } + }, [lastVisitedRepo.loaded, lastVisitedRepo.repoId, repos, selectedRepo, visible]) + + useEffect(() => { + if (!visible || !client) { + return + } + let stale = false + setLoading(true) + void client + .sendRequest('repo.list') + .then((response) => { + if (stale || !response.ok) { + return + } + const result = (response as RpcSuccess).result as { repos: MobileWorkspaceRepo[] } + setRepos(result.repos) + if (hostId) { + setCachedRepos(hostId, result.repos) + } + setSelectedRepo((current) => + refreshMobileNewWorkspaceDialogSelectedRepo(result.repos, current) + ) + }) + .catch(() => undefined) + .finally(() => { + if (!stale) { + setLoading(false) + } + }) + return () => { + stale = true + } + }, [visible, client, hostId]) + + return { repos, selectedRepo, setSelectedRepo, loading: loading && repos.length === 0 } +} diff --git a/mobile/src/components/use-new-workspace-runtime-context.ts b/mobile/src/components/use-new-workspace-runtime-context.ts new file mode 100644 index 00000000000..05df7b53fe3 --- /dev/null +++ b/mobile/src/components/use-new-workspace-runtime-context.ts @@ -0,0 +1,96 @@ +import { useEffect, useState } from 'react' +import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse, RpcSuccess } from '../transport/types' +import { + filterAvailableTaskProviders, + normalizeVisibleTaskProviders, + type TaskProvider +} from '../tasks/mobile-task-providers' +import type { NewWorktreeRuntimeSettings } from './new-worktree-agent-selection' + +function settledSuccess(entry: PromiseSettledResult): RpcSuccess | null { + return entry.status === 'fulfilled' && entry.value.ok ? (entry.value as RpcSuccess) : null +} + +export function useNewWorkspaceRuntimeContext( + client: RpcClient | null, + visible: boolean, + hostId?: string +): { + runtimeSettings: NewWorktreeRuntimeSettings | null + setRuntimeSettings: (settings: NewWorktreeRuntimeSettings) => void + trustedOrcaHooks: PersistedTrustedOrcaHooks + setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void + availableProviders: TaskProvider[] +} { + const [runtimeSettings, setRuntimeSettings] = useState(null) + const [trustedOrcaHooks, setTrustedOrcaHooks] = useState({}) + const [availableProviders, setAvailableProviders] = useState([]) + + useEffect(() => { + if (!visible || !client) { + return + } + let stale = false + void (async () => { + const probes = Promise.allSettled([ + client.sendRequest('preflight.check'), + client.sendRequest('linear.status') + ]) + const [settingsRes, uiRes] = await Promise.allSettled([ + client.sendRequest('settings.get'), + client.sendRequest('ui.get') + ]) + if (stale) { + return + } + + const settingsResult = settledSuccess(settingsRes) + const settingsValue = settingsResult + ? ( + settingsResult.result as { + settings: NewWorktreeRuntimeSettings & { visibleTaskProviders?: unknown } + } + ).settings + : null + if (settingsValue) { + setRuntimeSettings(settingsValue) + } + const uiResult = settledSuccess(uiRes) + if (uiResult) { + const ui = (uiResult.result as { ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } }).ui + setTrustedOrcaHooks(ui?.trustedOrcaHooks ?? {}) + } + + const [preflightRes, linearRes] = await probes + if (stale) { + return + } + const glabInstalled = + (settledSuccess(preflightRes)?.result as { glab?: { installed?: boolean } } | undefined) + ?.glab?.installed === true + const linearConnected = + (settledSuccess(linearRes)?.result as { connected?: boolean } | undefined)?.connected === + true + const visibleProviders = normalizeVisibleTaskProviders(settingsValue?.visibleTaskProviders) + setAvailableProviders( + filterAvailableTaskProviders(visibleProviders, { + gitlabInstalled: glabInstalled, + linearConnected + }).filter((provider) => visibleProviders.includes(provider)) + ) + })() + return () => { + stale = true + } + }, [visible, client, hostId]) + + return { + runtimeSettings, + setRuntimeSettings, + trustedOrcaHooks, + setTrustedOrcaHooks, + availableProviders + } +} diff --git a/mobile/src/components/use-new-workspace-setup-script.ts b/mobile/src/components/use-new-workspace-setup-script.ts new file mode 100644 index 00000000000..e564b8365e3 --- /dev/null +++ b/mobile/src/components/use-new-workspace-setup-script.ts @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { normalizeSetupHookTrust } from '../tasks/setup-hook-trust' +import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params' +import type { + MobileWorkspaceRepo, + RepoHooksResponse, + SetupHookDetails +} from './new-worktree-modal-types' + +export function useNewWorkspaceSetupScript(args: { + client: RpcClient | null + selectedRepo: MobileWorkspaceRepo | null +}): { + setupCommand: string | null + setupSource: string | null + setupTrust: SetupHookDetails['trust'] + setupRunPolicy: SetupHookDetails['runPolicy'] + setupDecisionChoice: Exclude | null + setSetupDecisionChoice: (decision: Exclude) => void + runSetup: boolean + setRunSetup: (run: boolean) => void + showAdvanced: boolean + setShowAdvanced: (show: boolean) => void +} { + const { client, selectedRepo } = args + const [details, setDetails] = useState(null) + const [setupDecisionChoice, setSetupDecisionChoice] = useState | null>(null) + const [runSetup, setRunSetup] = useState(true) + const [showAdvanced, setShowAdvanced] = useState(false) + const activeDetails = selectedRepo && details?.repoId === selectedRepo.id ? details : null + + useEffect(() => { + if (!client || !selectedRepo) { + return + } + let stale = false + void client + .sendRequest('repo.hooks', { repo: `id:${selectedRepo.id}` }) + .then((response) => { + if (stale || !response.ok) { + return + } + const result = (response as RpcSuccess).result as RepoHooksResponse + const command = result.hooks?.scripts?.setup?.trim() || null + const runPolicy = result.setupRunPolicy ?? 'run-by-default' + setDetails({ + repoId: selectedRepo.id, + command, + source: result.source, + trust: normalizeSetupHookTrust(result.setupTrust), + runPolicy + }) + setSetupDecisionChoice(null) + setRunSetup(runPolicy !== 'skip-by-default') + if (command && runPolicy === 'ask') { + setShowAdvanced(true) + } + }) + .catch(() => { + if (!stale) { + setDetails({ + repoId: selectedRepo.id, + command: null, + source: null, + trust: null, + runPolicy: 'run-by-default' + }) + setSetupDecisionChoice(null) + } + }) + return () => { + stale = true + } + }, [client, selectedRepo]) + + return { + setupCommand: activeDetails?.command ?? null, + setupSource: activeDetails?.source ?? null, + setupTrust: activeDetails?.trust ?? null, + setupRunPolicy: activeDetails?.runPolicy ?? 'run-by-default', + setupDecisionChoice, + setSetupDecisionChoice, + runSetup, + setRunSetup, + showAdvanced, + setShowAdvanced + } +} diff --git a/mobile/src/home-host-edit-navigation-source.test.ts b/mobile/src/home-host-edit-navigation-source.test.ts index 4597a138e24..617dd030c8a 100644 --- a/mobile/src/home-host-edit-navigation-source.test.ts +++ b/mobile/src/home-host-edit-navigation-source.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' -const homeSource = readFileSync(new URL('../app/index.tsx', import.meta.url), 'utf8') +const homeSource = readFileSync(new URL('./home/MobileHomeScreen.tsx', import.meta.url), 'utf8') describe('Home host edit navigation wiring', () => { it('uses the cold-navigator-safe edit transition', () => { diff --git a/mobile/src/home/MobileHomeAccountUsageCards.tsx b/mobile/src/home/MobileHomeAccountUsageCards.tsx new file mode 100644 index 00000000000..af0b6a91e21 --- /dev/null +++ b/mobile/src/home/MobileHomeAccountUsageCards.tsx @@ -0,0 +1,133 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { ClaudeIcon, OpenAIIcon } from '../components/AgentIcons' +import { + getActiveProviderRateLimits, + getUsageBarState, + hasActiveProviderUsage, + UsageBar, + type AccountsSnapshot, + type ProviderKey +} from '../components/AccountUsage' +import { colors, radii, spacing } from '../theme/mobile-theme' +import type { HostProfile } from '../transport/types' + +export function MobileHomeAccountUsageCards(props: { + items: { host: HostProfile; snapshot: AccountsSnapshot }[] + onOpen: (hostId: string) => void +}) { + if (props.items.length === 0) { + return null + } + return ( + <> + Account usage + {props.items.map(({ host, snapshot }) => { + const claudeActive = + snapshot.claude.accounts.find( + (account) => account.id === snapshot.claude.activeAccountId + ) ?? null + const codexActive = + snapshot.codex.accounts.find( + (account) => account.id === snapshot.codex.activeAccountId + ) ?? null + return ( + [styles.card, pressed && styles.cardPressed]} + onPress={() => props.onOpen(host.id)} + > + {props.items.length > 1 ? ( + + {host.name} + + ) : null} + {(['claude', 'codex'] as ProviderKey[]).map((provider) => { + const active = provider === 'claude' ? claudeActive : codexActive + const accounts = + provider === 'claude' ? snapshot.claude.accounts : snapshot.codex.accounts + const limits = getActiveProviderRateLimits(snapshot, provider) + if (accounts.length === 0 && !hasActiveProviderUsage(limits)) { + return null + } + const sessionBar = getUsageBarState(limits, 'session') + const weeklyBar = getUsageBarState(limits, 'weekly') + return ( + + + {provider === 'claude' ? ( + + ) : ( + + )} + + + + {active?.email ?? 'System default'} + + + + + + + + ) + })} + + ) + })} + + ) +} + +const styles = StyleSheet.create({ + sectionHeading: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.6, + marginTop: spacing.xl, + marginBottom: spacing.sm, + paddingHorizontal: spacing.xs + }, + card: { + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2, + gap: spacing.sm, + marginBottom: spacing.sm + }, + cardPressed: { backgroundColor: colors.bgRaised }, + hostLabel: { + fontSize: 11, + color: colors.textMuted, + fontWeight: '500', + textTransform: 'uppercase', + letterSpacing: 0.4 + }, + row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm + 2 }, + icon: { + width: 32, + height: 32, + borderRadius: 9, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center' + }, + info: { flex: 1, minWidth: 0, gap: 2 }, + email: { fontSize: 13, fontWeight: '600', color: colors.textPrimary }, + bars: { flexDirection: 'row', gap: spacing.md, marginTop: 4 } +}) diff --git a/mobile/src/home/MobileHomeEmptyState.tsx b/mobile/src/home/MobileHomeEmptyState.tsx new file mode 100644 index 00000000000..eda2496094d --- /dev/null +++ b/mobile/src/home/MobileHomeEmptyState.tsx @@ -0,0 +1,137 @@ +import { QrCode } from 'lucide-react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { colors, radii, spacing } from '../theme/mobile-theme' + +const ONBOARDING_STEPS = [ + { + title: 'Open Orca desktop', + desc: 'Go to Settings → Mobile and generate a pairing QR code.' + }, + { + title: 'Scan the code', + desc: 'Tap the button above to open the scanner. Point at the QR code on your screen.' + }, + { + title: "You're connected", + desc: 'Your desktop will appear here. Everything is encrypted end-to-end.' + } +] + +export function MobileHomeEmptyState(props: { + bottomInset: number + contentMaxWidth: number + isWideLayout: boolean + onPairDesktop: () => void +}) { + return ( + + + Connect your desktop + + Pair with Orca on your computer to check on your agents, jump into any terminal, and drive + work from your phone. + + + + Pair Desktop + + + + How it works + {ONBOARDING_STEPS.map((step, index) => ( + 0 && styles.stepRowBorder]}> + + {index + 1} + + + {step.title} + {step.desc} + + + ))} + + + ) +} + +const styles = StyleSheet.create({ + emptyContainer: { flex: 1 }, + emptyHero: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 32, + paddingBottom: 40 + }, + emptyTitle: { + fontSize: 22, + fontWeight: '700', + color: colors.textPrimary, + textAlign: 'center', + marginBottom: 10 + }, + emptyBody: { + fontSize: 15, + color: colors.textSecondary, + textAlign: 'center', + lineHeight: 22, + marginBottom: 32 + }, + primaryButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + backgroundColor: colors.textPrimary, + paddingHorizontal: 28, + paddingVertical: 14, + borderRadius: radii.card + }, + primaryButtonText: { color: colors.bgBase, fontSize: 15, fontWeight: '700' }, + stepsSection: { paddingHorizontal: spacing.xl }, + sectionHeading: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.6, + marginBottom: spacing.sm, + paddingHorizontal: spacing.xs + }, + stepRow: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 14, + paddingVertical: spacing.lg + }, + stepRowBorder: { borderTopWidth: 1, borderTopColor: colors.borderSubtle }, + stepNum: { + width: 28, + height: 28, + borderRadius: 8, + backgroundColor: 'rgba(255,255,255,0.04)', + borderWidth: 1, + borderColor: colors.borderSubtle, + alignItems: 'center', + justifyContent: 'center', + marginTop: 1 + }, + stepNumText: { fontSize: 12, fontWeight: '700', color: colors.textSecondary }, + stepText: { flex: 1 }, + stepTitle: { + fontSize: 14, + fontWeight: '600', + color: colors.textPrimary, + marginBottom: 3 + }, + stepDesc: { fontSize: 12, color: colors.textMuted, lineHeight: 17 } +}) diff --git a/mobile/src/home/MobileHomeHostList.tsx b/mobile/src/home/MobileHomeHostList.tsx new file mode 100644 index 00000000000..3907df16f03 --- /dev/null +++ b/mobile/src/home/MobileHomeHostList.tsx @@ -0,0 +1,144 @@ +import { memo, useCallback, type ReactElement } from 'react' +import { FlatList, StyleSheet, View } from 'react-native' +import type { ListRenderItemInfo } from 'react-native' +import { MobileHostCard } from '../components/MobileHostCard' +import type { HomeStatsSummary } from '../stats/home-stats-total' +import { spacing } from '../theme/mobile-theme' +import { classifyConnection } from '../transport/connection-health' +import { resolveHomeHostConnectionState } from '../transport/home-host-auto-connect' +import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' +import type { ConnectionState, HostCatalogEntry } from '../transport/types' +import type { HostWorktreeInfo } from '../worktree/home-worktree-info' +import { MobileHomeListHeader } from './MobileHomeListHeader' + +type MobileHomeHostListProps = { + autoConnectHostIds: string[] + bottomInset: number + contentMaxWidth: number + footer: ReactElement + hostAttempts: Record + hostLastConnected: Record + hostPairingRejected: Record + hostPaths: Record + hostPendingPaths: Record + hosts: HostCatalogEntry[] + hostStates: Record + isWideLayout: boolean + stats: HomeStatsSummary | null + worktreeInfo: Record + onOpen: (host: HostCatalogEntry) => void + onLongPress: (host: HostCatalogEntry) => void + onOpenActions: (host: HostCatalogEntry) => void +} + +export function MobileHomeHostList(props: MobileHomeHostListProps) { + const renderHost = useCallback( + ({ item }: ListRenderItemInfo) => ( + + ), + [ + props.autoConnectHostIds, + props.hostAttempts, + props.hostLastConnected, + props.hostPairingRejected, + props.hostPaths, + props.hostPendingPaths, + props.hostStates, + props.onLongPress, + props.onOpen, + props.onOpenActions, + props.worktreeInfo + ] + ) + + return ( + host.id} + contentContainerStyle={[ + styles.list, + { paddingBottom: spacing.xl + props.bottomInset }, + props.isWideLayout && { + maxWidth: props.contentMaxWidth, + width: '100%', + alignSelf: 'center' + } + ]} + ListHeaderComponent={} + ItemSeparatorComponent={CardGap} + renderItem={renderHost} + ListFooterComponent={props.footer} + /> + ) +} + +type MobileHomeHostRowProps = Pick< + MobileHomeHostListProps, + | 'autoConnectHostIds' + | 'hostAttempts' + | 'hostLastConnected' + | 'hostPairingRejected' + | 'hostPaths' + | 'hostPendingPaths' + | 'hostStates' + | 'worktreeInfo' + | 'onOpen' + | 'onLongPress' + | 'onOpenActions' +> & { item: HostCatalogEntry } + +const MobileHomeHostRow = memo(function MobileHomeHostRow(props: MobileHomeHostRowProps) { + const { item, onLongPress, onOpen, onOpenActions } = props + const state = resolveHomeHostConnectionState( + item.id, + props.hostStates[item.id], + props.autoConnectHostIds + ) + const verdict = classifyConnection({ + state, + reconnectAttempts: props.hostAttempts[item.id] ?? 0, + lastConnectedAt: props.hostLastConnected[item.id] ?? null, + endpoint: item.endpoint, + pendingPath: props.hostPendingPaths[item.id] ?? null, + pairingRejected: props.hostPairingRejected[item.id] ?? false + }) + const open = useCallback(() => onOpen(item), [item, onOpen]) + const longPress = useCallback(() => onLongPress(item), [item, onLongPress]) + const openActions = useCallback(() => onOpenActions(item), [item, onOpenActions]) + + return ( + + ) +}) + +function CardGap() { + return +} + +const styles = StyleSheet.create({ + list: { paddingHorizontal: spacing.lg, paddingBottom: spacing.xl }, + cardGap: { height: spacing.sm } +}) diff --git a/mobile/src/home/MobileHomeListFooter.tsx b/mobile/src/home/MobileHomeListFooter.tsx new file mode 100644 index 00000000000..8e39b9ffd2e --- /dev/null +++ b/mobile/src/home/MobileHomeListFooter.tsx @@ -0,0 +1,59 @@ +import { StyleSheet, Text, View } from 'react-native' +import type { AccountsSnapshot } from '../components/AccountUsage' +import { MobileHomeQuickActions } from '../components/MobileHomeQuickActions' +import type { TaskProvider } from '../tasks/mobile-task-providers' +import { colors, spacing } from '../theme/mobile-theme' +import type { HostProfile } from '../transport/types' +import type { HomeResumeCard } from '../worktree/home-resume-card' +import { MobileHomeAccountUsageCards } from './MobileHomeAccountUsageCards' +import { MobileHomeResumeCard } from './MobileHomeResumeCard' +import { MobileHomeTasksCard } from './MobileHomeTasksCard' + +export function MobileHomeListFooter(props: { + accountsHosts: { host: HostProfile; snapshot: AccountsSnapshot }[] + connectedHosts: HostProfile[] + primaryHost: HostProfile | null + primaryTaskProviders: TaskProvider[] + resumeCard: HomeResumeCard | null + onCreateWorkspace: (hostId: string) => void + onOpenAccounts: (hostId: string) => void + onOpenResume: (card: HomeResumeCard) => void + onOpenTasks: (provider?: TaskProvider) => void + onPairDesktop: () => void +}) { + return ( + + {props.resumeCard ? ( + <> + Resume + + + ) : null} + Tasks + + + + + ) +} + +const styles = StyleSheet.create({ + sectionHeading: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.6, + marginTop: spacing.lg, + marginBottom: spacing.sm, + paddingHorizontal: spacing.xs + } +}) diff --git a/mobile/src/home/MobileHomeListHeader.tsx b/mobile/src/home/MobileHomeListHeader.tsx new file mode 100644 index 00000000000..7e47c9ae9d6 --- /dev/null +++ b/mobile/src/home/MobileHomeListHeader.tsx @@ -0,0 +1,78 @@ +import { StyleSheet, Text, View } from 'react-native' +import type { HomeStatsSummary } from '../stats/home-stats-total' +import { colors, spacing } from '../theme/mobile-theme' + +function formatDuration(ms: number): string { + const totalMinutes = Math.floor(ms / 60_000) + const totalHours = Math.floor(totalMinutes / 60) + const days = Math.floor(totalHours / 24) + const hours = totalHours % 24 + if (days > 0) { + return `${days}d ${hours}h` + } + const minutes = totalMinutes % 60 + return totalHours > 0 ? `${totalHours}h ${minutes}m` : `${totalMinutes}m` +} + +export function MobileHomeListHeader({ stats }: { stats: HomeStatsSummary | null }) { + return ( + + + Welcome back + + {stats ? ( + + + {stats.totalAgentsSpawned.toLocaleString()} + Agents spawned + + + {formatDuration(stats.totalAgentTimeMs)} + Agent time + + + {stats.totalPRsCreated.toLocaleString()} + PRs created + + + ) : null} + Desktops + + ) +} + +const styles = StyleSheet.create({ + hero: { paddingTop: spacing.xs, paddingBottom: spacing.md }, + heroTitle: { + color: colors.textPrimary, + fontSize: 24, + fontWeight: '800', + letterSpacing: -0.3 + }, + statsRow: { flexDirection: 'row', gap: 10, marginBottom: spacing.lg }, + statCard: { + flex: 1, + backgroundColor: 'rgba(26,26,26,0.6)', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: 10, + paddingVertical: 8, + paddingHorizontal: spacing.md + }, + statValue: { + color: colors.textPrimary, + fontSize: 18, + fontWeight: '700', + letterSpacing: -0.3 + }, + statLabel: { color: colors.textMuted, fontSize: 11, fontWeight: '500', marginTop: 2 }, + sectionHeading: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.6, + marginBottom: spacing.sm, + paddingHorizontal: spacing.xs + } +}) diff --git a/mobile/src/home/MobileHomeResumeCard.tsx b/mobile/src/home/MobileHomeResumeCard.tsx new file mode 100644 index 00000000000..e64bea9b3b3 --- /dev/null +++ b/mobile/src/home/MobileHomeResumeCard.tsx @@ -0,0 +1,84 @@ +import { ChevronRight, Terminal } from 'lucide-react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { colors, radii, spacing } from '../theme/mobile-theme' +import type { HomeResumeCard } from '../worktree/home-resume-card' + +const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'] + +function homeResumeRepoColor(name: string): string { + let hash = 0 + for (let index = 0; index < name.length; index += 1) { + hash = (hash * 31 + name.charCodeAt(index)) | 0 + } + return REPO_COLORS[Math.abs(hash) % REPO_COLORS.length] +} + +export function MobileHomeResumeCard(props: { + card: HomeResumeCard + onOpen: (card: HomeResumeCard) => void +}) { + return ( + [ + styles.resumeCard, + !props.card.actionable && styles.cardDisabled, + pressed && styles.cardPressed + ]} + onPress={() => props.onOpen(props.card)} + > + + + + + + {props.card.worktree.displayName} + + + + + {props.card.worktree.repo} + {' · '} + {props.card.worktree.branch} + + + + + + ) +} + +const styles = StyleSheet.create({ + resumeCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + paddingLeft: spacing.md, + paddingRight: spacing.md, + paddingVertical: 12 + }, + cardDisabled: { opacity: 0.45 }, + cardPressed: { backgroundColor: colors.bgRaised }, + resumeIcon: { + width: 46, + height: 46, + borderRadius: 13, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center', + marginRight: 14 + }, + resumeMain: { flex: 1, minWidth: 0 }, + resumeTitle: { fontSize: 13, fontWeight: '600', color: colors.textPrimary }, + resumeSub: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 3 }, + repoDot: { width: 7, height: 7, borderRadius: 3.5 }, + resumeSubText: { fontSize: 12, color: colors.textSecondary, flex: 1 } +}) diff --git a/mobile/src/home/MobileHomeScreen.tsx b/mobile/src/home/MobileHomeScreen.tsx new file mode 100644 index 00000000000..44c65bf018a --- /dev/null +++ b/mobile/src/home/MobileHomeScreen.tsx @@ -0,0 +1,200 @@ +import { useCallback, useState } from 'react' +import { Alert, StyleSheet } from 'react-native' +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' +import { useOpenMobileAccounts } from '../accounts/use-open-mobile-accounts' +import { getProvenCachedWorktrees } from '../cache/worktree-cache' +import { ActionSheetModal } from '../components/ActionSheetModal' +import { ConfirmModal } from '../components/ConfirmModal' +import { getHostListActionSheetActions } from '../host-list-action-sheet-actions' +import { hostNewWorktreeRoute } from '../host-route-action-state' +import { hostRouteWithNotice } from '../host-route-notice' +import { useResponsiveLayout } from '../layout/responsive-layout' +import { triggerMediumImpact } from '../platform/haptics' +import { useOpenMobileSession } from '../session/use-open-mobile-session' +import type { TaskProvider } from '../tasks/mobile-task-providers' +import { useOpenMobileTasks } from '../tasks/use-open-mobile-tasks' +import { colors } from '../theme/mobile-theme' +import { + useDisconnectHostClient, + useForceReconnect, + useForgetHostClient +} from '../transport/client-context' +import { hostEndpointLabel } from '../transport/host-endpoint-label' +import { resolveHomeHostConnectionState } from '../transport/home-host-auto-connect' +import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle' +import { loadHostCatalog } from '../transport/host-store' +import type { HostCatalogEntry, HostProfile } from '../transport/types' +import { useOpenMobileHostEdit } from '../transport/use-open-mobile-host-edit' +import type { HomeWorktreeSummary } from '../worktree/home-worktree-info' +import { isResumeTargetConfirmedMissing, type HomeResumeCard } from '../worktree/home-resume-card' +import { MobileHomeEmptyState } from './MobileHomeEmptyState' +import { MobileHomeHostList } from './MobileHomeHostList' +import { MobileHomeListFooter } from './MobileHomeListFooter' +import { MobileHomeTopBar } from './MobileHomeTopBar' +import { useMobileHomeData } from './use-mobile-home-data' + +export function MobileHomeScreen() { + const data = useMobileHomeData() + const insets = useSafeAreaInsets() + const { isWideLayout, contentMaxWidth } = useResponsiveLayout() + const openMobileHostEdit = useOpenMobileHostEdit() + const openMobileTasks = useOpenMobileTasks() + const openMobileSession = useOpenMobileSession() + const openMobileAccounts = useOpenMobileAccounts() + const disconnectHostClient = useDisconnectHostClient() + const forgetHostClient = useForgetHostClient() + const forceReconnectHost = useForceReconnect() + const [actionTarget, setActionTarget] = useState(null) + const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null) + + const openResume = useCallback( + (card: HomeResumeCard) => { + if ( + isResumeTargetConfirmedMissing( + card, + getProvenCachedWorktrees(card.hostId) as HomeWorktreeSummary[] | null + ) + ) { + data.router.push(hostRouteWithNotice(card.hostId, 'worktree-missing')) + return + } + openMobileSession({ + hostId: card.hostId, + worktreeId: card.worktree.worktreeId, + name: card.worktree.displayName || card.worktree.repo + }) + }, + [data.router, openMobileSession] + ) + + const openTasks = useCallback( + (provider?: TaskProvider) => { + if (data.primaryHost) { + openMobileTasks(data.primaryHost.id, provider) + } + }, + [data.primaryHost, openMobileTasks] + ) + + function openHost(host: HostCatalogEntry): void { + if (host.credentialStatus === 'missing') { + data.router.push('/pair-scan') + } else if (host.credentialStatus === 'temporarily-unavailable') { + void loadHostCatalog() + .then(data.setHostCatalog) + .catch(() => Alert.alert('Could not check pairing', 'Please try again.')) + } else { + data.router.push(`/h/${host.id}`) + } + } + + function openHostActions(host: HostCatalogEntry): void { + if (host.profile) { + setActionTarget(host.profile) + } else { + setConfirmRemove(host) + } + } + + async function handleRemove(): Promise { + if (!confirmRemove) { + return + } + const host = confirmRemove + try { + await removeHostAndCloseClient(host.id, forgetHostClient) + setConfirmRemove(null) + data.setHostCatalog(await loadHostCatalog()) + } catch { + setConfirmRemove(host) + Alert.alert('Could not remove host', 'Please try again.') + } + } + + return ( + + data.router.push('/settings')} /> + {data.hostCatalog.length === 0 ? ( + data.router.push('/pair-scan')} + /> + ) : ( + data.router.push(hostNewWorktreeRoute(hostId))} + onOpenAccounts={openMobileAccounts} + onOpenResume={openResume} + onOpenTasks={openTasks} + onPairDesktop={() => data.router.push('/pair-scan')} + /> + } + hostAttempts={data.hostAttempts} + hostLastConnected={data.hostLastConnected} + hostPairingRejected={data.hostPairingRejected} + hostPaths={data.hostPaths} + hostPendingPaths={data.hostPendingPaths} + hosts={data.sortedHostCatalog} + hostStates={data.hostStates} + isWideLayout={isWideLayout} + stats={data.stats} + worktreeInfo={data.worktreeInfo} + onOpen={openHost} + onLongPress={(host) => { + triggerMediumImpact() + openHostActions(host) + }} + onOpenActions={openHostActions} + /> + )} + setActionTarget(null), + onReconnect: (hostId) => void forceReconnectHost(hostId), + onDisconnect: disconnectHostClient, + onEdit: openMobileHostEdit, + onRemove: (host) => setConfirmRemove(host) + })} + onClose={() => setActionTarget(null)} + /> + void handleRemove()} + onCancel={() => setConfirmRemove(null)} + /> + + ) +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bgBase } +}) diff --git a/mobile/src/home/MobileHomeTasksCard.tsx b/mobile/src/home/MobileHomeTasksCard.tsx new file mode 100644 index 00000000000..c68b3575c2a --- /dev/null +++ b/mobile/src/home/MobileHomeTasksCard.tsx @@ -0,0 +1,118 @@ +import { ChevronRight, ListTodo } from 'lucide-react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { TaskProviderLogo } from '../components/TaskProviderLogo' +import type { TaskProvider } from '../tasks/mobile-task-providers' +import { colors, radii, spacing } from '../theme/mobile-theme' + +const TASK_PROVIDER_LABELS: Record = { + github: 'GitHub', + gitlab: 'GitLab', + linear: 'Linear' +} + +export function MobileHomeTasksCard(props: { + enabled: boolean + providers: TaskProvider[] + onOpen: (provider?: TaskProvider) => void +}) { + return ( + [ + styles.card, + !props.enabled && styles.cardDisabled, + pressed && styles.cardPressed + ]} + onPress={() => props.onOpen()} + > + + + + + Tasks + + {props.providers.length > 0 + ? props.providers.map((provider) => TASK_PROVIDER_LABELS[provider]).join(' · ') + : 'No task sources connected'} + + + + TASK_PROVIDER_LABELS[provider]) + .join(', ')} + > + {props.providers.map((provider) => ( + [ + styles.providerButton, + pressed && styles.providerButtonPressed + ]} + onPress={(event) => { + event.stopPropagation() + props.onOpen(provider) + }} + > + + + ))} + + + + + ) +} + +const styles = StyleSheet.create({ + card: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + minHeight: 72, + paddingLeft: spacing.md, + paddingRight: spacing.md, + paddingVertical: 12 + }, + cardDisabled: { opacity: 0.45 }, + cardPressed: { backgroundColor: colors.bgRaised }, + icon: { + width: 46, + height: 46, + borderRadius: 13, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center', + marginRight: 14 + }, + main: { flex: 1, minWidth: 0 }, + title: { fontSize: 13, fontWeight: '600', color: colors.textPrimary }, + subtitle: { fontSize: 12, color: colors.textSecondary, marginTop: 3 }, + trailing: { + flexDirection: 'row', + alignItems: 'center', + flexShrink: 0, + marginLeft: spacing.sm + }, + providerRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 2 + }, + providerButton: { + width: 34, + height: 34, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button + }, + providerButtonPressed: { backgroundColor: colors.bgRaised } +}) diff --git a/mobile/src/home/MobileHomeTopBar.tsx b/mobile/src/home/MobileHomeTopBar.tsx new file mode 100644 index 00000000000..ac6d800c7f6 --- /dev/null +++ b/mobile/src/home/MobileHomeTopBar.tsx @@ -0,0 +1,45 @@ +import { Settings } from 'lucide-react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { OrcaLogo } from '../components/OrcaLogo' +import { colors, spacing } from '../theme/mobile-theme' + +export function MobileHomeTopBar({ onOpenSettings }: { onOpenSettings: () => void }) { + return ( + + + + + + Orca + + [styles.iconButton, pressed && styles.iconButtonPressed]} + onPress={onOpenSettings} + > + + + + ) +} + +const styles = StyleSheet.create({ + topBar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingTop: spacing.sm, + paddingBottom: spacing.md + }, + brandLockup: { flexDirection: 'row', alignItems: 'center', minWidth: 0 }, + logoMark: { marginRight: spacing.sm }, + brandName: { color: colors.textPrimary, fontSize: 17, fontWeight: '700' }, + iconButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center' + }, + iconButtonPressed: { backgroundColor: colors.bgRaised } +}) diff --git a/mobile/src/home/mobile-home-connection-state.test.ts b/mobile/src/home/mobile-home-connection-state.test.ts new file mode 100644 index 00000000000..7e625034a0a --- /dev/null +++ b/mobile/src/home/mobile-home-connection-state.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { HostCatalogEntry } from '../transport/types' +import { + reconcileMobileHomeHostStates, + type MobileHomeClientEntry +} from './mobile-home-connection-state' + +function catalogHost( + id: string, + credentialStatus: HostCatalogEntry['credentialStatus'] +): HostCatalogEntry { + return { + id, + name: id, + endpoint: `ws://${id}`, + publicKeyB64: `key-${id}`, + lastConnected: 0, + credentialStatus, + profile: null + } +} + +function liveClient(hostId: string, state: MobileHomeClientEntry['state']): MobileHomeClientEntry { + return { hostId, state, client: {} as RpcClient } +} + +describe('mobile home connection state', () => { + it('keeps an unacquired ready host absent on the initial frame', () => { + const previous = {} + const next = reconcileMobileHomeHostStates(previous, [], [catalogHost('host-1', 'ready')]) + + expect(next).toBe(previous) + }) + + it('uses credential status when a host has no live client', () => { + const next = reconcileMobileHomeHostStates( + { ready: 'connected' }, + [], + [ + catalogHost('missing', 'missing'), + catalogHost('locked', 'temporarily-unavailable'), + catalogHost('ready', 'ready') + ] + ) + + expect(next).toEqual({ missing: 'auth-failed', locked: 'disconnected', ready: 'disconnected' }) + }) + + it('prefers the live client and drops hosts removed from the catalog', () => { + const next = reconcileMobileHomeHostStates( + { removed: 'connected', current: 'reconnecting' }, + [liveClient('current', 'connected')], + [catalogHost('current', 'missing')] + ) + + expect(next).toEqual({ current: 'connected' }) + }) +}) diff --git a/mobile/src/home/mobile-home-connection-state.ts b/mobile/src/home/mobile-home-connection-state.ts new file mode 100644 index 00000000000..8d922f36b9a --- /dev/null +++ b/mobile/src/home/mobile-home-connection-state.ts @@ -0,0 +1,77 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState, HostCatalogEntry } from '../transport/types' + +export type MobileHomeClientEntry = { + hostId: string + client: RpcClient + state: ConnectionState +} + +export function readMobileHomeReconnectAttempts( + previous: Record, + clients: MobileHomeClientEntry[] +): Record { + const next = { ...previous } + let changed = false + for (const entry of clients) { + const attempts = entry.client.getReconnectAttempt() + if (next[entry.hostId] !== attempts) { + next[entry.hostId] = attempts + changed = true + } + } + return changed ? next : previous +} + +export function readMobileHomeLastConnected( + previous: Record, + clients: MobileHomeClientEntry[] +): Record { + const next = { ...previous } + let changed = false + for (const entry of clients) { + const connectedAt = entry.client.getLastConnectedAt() + if (next[entry.hostId] !== connectedAt) { + next[entry.hostId] = connectedAt + changed = true + } + } + return changed ? next : previous +} + +export function reconcileMobileHomeHostStates( + previous: Record, + clients: MobileHomeClientEntry[], + hostCatalog: HostCatalogEntry[] +): Record { + const next = { ...previous } + let changed = false + const liveIds = new Set(clients.map((entry) => entry.hostId)) + for (const entry of clients) { + if (next[entry.hostId] !== entry.state) { + next[entry.hostId] = entry.state + changed = true + } + } + for (const host of hostCatalog) { + if (liveIds.has(host.id)) { + continue + } + const fallback = host.credentialStatus === 'missing' ? 'auth-failed' : 'disconnected' + const previousState = next[host.id] + const shouldTrack = + host.credentialStatus !== 'ready' || + (previousState != null && previousState !== 'disconnected' && previousState !== 'auth-failed') + if (shouldTrack && previousState !== fallback) { + next[host.id] = fallback + changed = true + } + } + for (const id of Object.keys(next)) { + if (!liveIds.has(id) && !hostCatalog.some((host) => host.id === id)) { + delete next[id] + changed = true + } + } + return changed ? next : previous +} diff --git a/mobile/src/home/mobile-home-host-requests.ts b/mobile/src/home/mobile-home-host-requests.ts new file mode 100644 index 00000000000..e0e19a036d3 --- /dev/null +++ b/mobile/src/home/mobile-home-host-requests.ts @@ -0,0 +1,108 @@ +import { decodeAccountsSnapshot, type AccountsSnapshot } from '../components/AccountUsage' +import type { HomeStatsSummary } from '../stats/home-stats-total' +import { + filterAvailableTaskProviders, + normalizeVisibleTaskProviders, + type TaskProvider +} from '../tasks/mobile-task-providers' +import type { RpcClient } from '../transport/rpc-client' +import { sendSingleFlightRequest } from '../transport/request-single-flight' + +type HomeTaskSettings = { + visibleTaskProviders?: unknown +} + +type HomePreflightStatus = { + glab?: { installed?: boolean } +} + +type HomeLinearStatus = { + connected?: boolean +} + +export type HomeStatsSetter = ( + updater: (previous: Record) => Record +) => void + +export type HomeAccountsSetter = ( + updater: (previous: Record) => Record +) => void + +export type HomeTaskProvidersSetter = ( + updater: (previous: Record) => Record +) => void + +export function fetchMobileHomeStats( + client: RpcClient, + hostId: string, + setStats: HomeStatsSetter, + disposed: () => boolean +): void { + sendSingleFlightRequest(client, hostId, 'stats.summary') + .then((response) => { + if (!disposed() && response.ok) { + setStats((previous) => ({ + ...previous, + [hostId]: response.result as HomeStatsSummary + })) + } + }) + .catch(() => {}) +} + +export function fetchMobileHomeAccounts( + client: RpcClient, + hostId: string, + setSnapshots: HomeAccountsSetter, + disposed: () => boolean +): void { + sendSingleFlightRequest(client, hostId, 'accounts.list') + .then((response) => { + if (!disposed() && response.ok) { + const snapshot = decodeAccountsSnapshot(response.result) + setSnapshots((previous) => ({ ...previous, [hostId]: snapshot })) + } + }) + .catch(() => {}) +} + +export function fetchMobileHomeTaskProviders( + client: RpcClient, + hostId: string, + setProviders: HomeTaskProvidersSetter, + disposed: () => boolean +): void { + Promise.all([ + sendSingleFlightRequest(client, hostId, 'settings.get'), + sendSingleFlightRequest(client, hostId, 'preflight.check'), + sendSingleFlightRequest(client, hostId, 'linear.status') + ]) + .then(([settingsResponse, preflightResponse, linearResponse]) => { + if (disposed()) { + return + } + const settings = settingsResponse.ok + ? (((settingsResponse.result as { settings?: HomeTaskSettings }).settings ?? + {}) as HomeTaskSettings) + : {} + const preflight = preflightResponse.ok + ? (preflightResponse.result as HomePreflightStatus) + : null + const linear = linearResponse.ok ? (linearResponse.result as HomeLinearStatus) : null + const providers = filterAvailableTaskProviders( + normalizeVisibleTaskProviders(settings.visibleTaskProviders), + { + gitlabInstalled: preflight?.glab?.installed === true, + linearConnected: linear?.connected === true + } + ) + setProviders((previous) => ({ ...previous, [hostId]: providers })) + }) + .catch(() => { + if (!disposed()) { + setProviders((previous) => + previous[hostId] ? previous : { ...previous, [hostId]: ['github'] } + ) + } + }) +} diff --git a/mobile/src/home/use-mobile-home-data.ts b/mobile/src/home/use-mobile-home-data.ts new file mode 100644 index 00000000000..4061d885bc2 --- /dev/null +++ b/mobile/src/home/use-mobile-home-data.ts @@ -0,0 +1,200 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { useRouter, useFocusEffect } from 'expo-router' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { AccountsSnapshot } from '../components/AccountUsage' +import { hasRenderableUsage } from '../components/AccountUsage' +import { loadHomeSnapshot, saveHomeSnapshot } from '../cache/home-snapshot-cache' +import { getCachedWorktrees, setCachedWorktrees } from '../cache/worktree-cache' +import { + loadMobileOnboardingSteps, + mobileOnboardingDestination +} from '../onboarding/mobile-onboarding-plan' +import { totalHomeStats, type HomeStatsSummary } from '../stats/home-stats-total' +import type { TaskProvider } from '../tasks/mobile-task-providers' +import { selectConnectableHostProfiles } from '../transport/host-catalog-selection' +import { loadHostCatalog } from '../transport/host-store' +import type { HostCatalogEntry, HostProfile } from '../transport/types' +import { fetchHomeHostWorktreeInfo } from '../worktree/home-host-worktree-fetch' +import type { HomeWorktreeSummary, HostWorktreeInfo } from '../worktree/home-worktree-info' +import { + LAST_VISITED_WORKTREE_STORAGE_KEY, + readLastVisitedWorktreeRecord +} from '../worktree/last-visited-worktree-repo' +import { selectHomeResumeCard } from '../worktree/home-resume-card' +import { + fetchMobileHomeAccounts, + fetchMobileHomeStats, + fetchMobileHomeTaskProviders +} from './mobile-home-host-requests' +import { useMobileHomeHostConnections } from './use-mobile-home-host-connections' + +export function useMobileHomeData() { + const router = useRouter() + const [hostCatalog, setHostCatalog] = useState([]) + const [statsByHost, setStatsByHost] = useState>({}) + const [worktreeInfo, setWorktreeInfo] = useState>({}) + const [accountsByHost, setAccountsByHost] = useState>({}) + const [taskProvidersByHost, setTaskProvidersByHost] = useState>({}) + const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>( + null + ) + const onboardingCheckedRef = useRef(false) + const hydratedRef = useRef(false) + const hosts = useMemo(() => selectConnectableHostProfiles(hostCatalog), [hostCatalog]) + const connections = useMobileHomeHostConnections(hosts, hostCatalog, { + setStats: setStatsByHost, + setWorktreeInfo, + setAccounts: setAccountsByHost, + setTaskProviders: setTaskProvidersByHost + }) + const allClientsRef = useRef(connections.allClients) + + useEffect(() => { + if (hydratedRef.current) { + return + } + hydratedRef.current = true + let cancelled = false + void loadHomeSnapshot().then((snapshot) => { + if (cancelled || !snapshot) { + return + } + setWorktreeInfo((previous) => + Object.keys(previous).length > 0 ? previous : snapshot.worktreeInfo + ) + setAccountsByHost((previous) => + Object.keys(previous).length > 0 ? previous : snapshot.accountsByHost + ) + for (const [hostId, info] of Object.entries(snapshot.worktreeInfo)) { + if (info.lastActiveWorktree) { + setCachedWorktrees(hostId, [info.lastActiveWorktree]) + } + } + }) + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + if (Object.keys(worktreeInfo).length > 0 || Object.keys(accountsByHost).length > 0) { + saveHomeSnapshot({ worktreeInfo, accountsByHost, savedAt: Date.now() }) + } + }, [worktreeInfo, accountsByHost]) + + useEffect(() => { + allClientsRef.current = connections.allClients + }, [connections.allClients]) + + useFocusEffect( + useCallback(() => { + let stale = false + void loadHostCatalog().then(async (catalog) => { + if (stale) { + return + } + setHostCatalog(catalog) + if (catalog.length === 0 || onboardingCheckedRef.current) { + return + } + onboardingCheckedRef.current = true + const steps = await loadMobileOnboardingSteps() + if (!stale && steps.length > 0) { + router.replace(mobileOnboardingDestination(steps)) + } + }) + void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => { + if (!stale) { + setLastVisited(readLastVisitedWorktreeRecord(raw)) + } + }) + for (const entry of allClientsRef.current) { + if (entry.client.getState() === 'connected') { + fetchMobileHomeStats(entry.client, entry.hostId, setStatsByHost, () => stale) + void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale) + fetchMobileHomeAccounts(entry.client, entry.hostId, setAccountsByHost, () => stale) + fetchMobileHomeTaskProviders( + entry.client, + entry.hostId, + setTaskProvidersByHost, + () => stale + ) + } + } + return () => { + stale = true + } + }, [router]) + ) + + const sortedHosts = useMemo( + () => hosts.toSorted((left, right) => right.lastConnected - left.lastConnected), + [hosts] + ) + const sortedHostCatalog = useMemo( + () => hostCatalog.toSorted((left, right) => right.lastConnected - left.lastConnected), + [hostCatalog] + ) + const hostIds = useMemo(() => hosts.map((host) => host.id), [hosts]) + const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds]) + const resumeCard = useMemo( + () => + selectHomeResumeCard({ + hosts: sortedHosts, + hostStates: connections.hostStates, + worktreeInfo, + lastVisited, + cachedWorktrees: (hostId) => getCachedWorktrees(hostId) as HomeWorktreeSummary[] | null + }), + [sortedHosts, connections.hostStates, worktreeInfo, lastVisited] + ) + const accountsHosts = useMemo(() => { + const items: { host: HostProfile; snapshot: AccountsSnapshot }[] = [] + for (const host of sortedHosts) { + const snapshot = accountsByHost[host.id] + if ( + connections.hostStates[host.id] === 'connected' && + snapshot && + (hasRenderableUsage(snapshot, 'claude') || hasRenderableUsage(snapshot, 'codex')) + ) { + items.push({ host, snapshot }) + } + } + return items + }, [sortedHosts, connections.hostStates, accountsByHost]) + const connectedHosts = useMemo( + () => sortedHosts.filter((host) => connections.hostStates[host.id] === 'connected'), + [sortedHosts, connections.hostStates] + ) + const primaryHost = connectedHosts[0] ?? null + const primaryTaskProviders = primaryHost + ? (taskProvidersByHost[primaryHost.id] ?? ['github']) + : [] + const hostPaths = Object.fromEntries( + connections.allClients.map(({ hostId, path }) => [hostId, path]) + ) + const hostPendingPaths = Object.fromEntries( + connections.allClients.map(({ hostId, pendingPath }) => [hostId, pendingPath]) + ) + const hostPairingRejected = Object.fromEntries( + connections.allClients.map(({ hostId, pairingRejected }) => [hostId, pairingRejected]) + ) + + return { + ...connections, + accountsHosts, + connectedHosts, + hostCatalog, + hostPairingRejected, + hostPaths, + hostPendingPaths, + primaryHost, + primaryTaskProviders, + resumeCard, + router, + setHostCatalog, + sortedHostCatalog, + stats, + worktreeInfo + } +} diff --git a/mobile/src/home/use-mobile-home-host-connections.ts b/mobile/src/home/use-mobile-home-host-connections.ts new file mode 100644 index 00000000000..989583f11ab --- /dev/null +++ b/mobile/src/home/use-mobile-home-host-connections.ts @@ -0,0 +1,157 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { decodeAccountsSnapshot } from '../components/AccountUsage' +import { subscribeToDesktopNotifications } from '../notifications/mobile-notifications' +import { usePrimeHosts } from '../transport/client-context' +import { createHostConnectRefetchGate } from '../transport/host-connect-refetch-gate' +import { selectHomeAutoConnectHostIds } from '../transport/home-host-auto-connect' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState, HostCatalogEntry, HostProfile } from '../transport/types' +import { useAllHostClients } from '../transport/use-all-host-clients' +import { + fetchHomeHostWorktreeInfo, + type HostWorktreeInfoSetter +} from '../worktree/home-host-worktree-fetch' +import { + readMobileHomeLastConnected, + readMobileHomeReconnectAttempts, + reconcileMobileHomeHostStates +} from './mobile-home-connection-state' +import { + fetchMobileHomeStats, + fetchMobileHomeTaskProviders, + type HomeAccountsSetter, + type HomeStatsSetter, + type HomeTaskProvidersSetter +} from './mobile-home-host-requests' + +type Setters = { + setStats: HomeStatsSetter + setWorktreeInfo: HostWorktreeInfoSetter + setAccounts: HomeAccountsSetter + setTaskProviders: HomeTaskProvidersSetter +} + +function wireMobileHomeHostSubscriptions( + entry: { hostId: string; client: RpcClient; state: ConnectionState }, + setters: Setters +): () => void { + let unsubscribeNotifications: (() => void) | null = null + let unsubscribeAccounts: (() => void) | null = null + const refetchGate = createHostConnectRefetchGate() + const wireState = (state: ConnectionState): void => { + const reconnected = refetchGate.observe(state) + if (state === 'connected') { + unsubscribeNotifications ??= subscribeToDesktopNotifications(entry.client, entry.hostId) + unsubscribeAccounts ??= entry.client.subscribe('accounts.subscribe', null, (payload) => { + if (!payload || typeof payload !== 'object') { + return + } + const event = payload as { type?: string; snapshot?: unknown } + if (event.type !== 'ready' && event.type !== 'snapshot') { + return + } + try { + const snapshot = decodeAccountsSnapshot(event.snapshot) + setters.setAccounts((previous) => ({ ...previous, [entry.hostId]: snapshot })) + } catch { + // Keep the last proven snapshot when a mixed-version host publishes malformed data. + } + }) + if (reconnected) { + fetchMobileHomeStats(entry.client, entry.hostId, setters.setStats, () => false) + void fetchHomeHostWorktreeInfo( + entry.client, + entry.hostId, + setters.setWorktreeInfo, + () => false + ) + fetchMobileHomeTaskProviders( + entry.client, + entry.hostId, + setters.setTaskProviders, + () => false + ) + } + return + } + unsubscribeNotifications?.() + unsubscribeNotifications = null + unsubscribeAccounts?.() + unsubscribeAccounts = null + } + wireState(entry.state) + const unsubscribeState = entry.client.onStateChange(wireState) + return () => { + unsubscribeState() + unsubscribeNotifications?.() + unsubscribeAccounts?.() + } +} + +export function useMobileHomeHostConnections( + hosts: HostProfile[], + hostCatalog: HostCatalogEntry[], + setters: Setters +) { + const [hostStates, setHostStates] = useState>({}) + const [hostAttempts, setHostAttempts] = useState>({}) + const [hostLastConnected, setHostLastConnected] = useState>({}) + const hostIds = useMemo(() => hosts.map((host) => host.id), [hosts]) + const autoConnectHostIds = useMemo(() => selectHomeAutoConnectHostIds(hosts), [hosts]) + const allClients = useAllHostClients(hostIds, { + autoConnectHostIds, + closeUnusedOnRelease: true + }) + const primeHosts = usePrimeHosts() + const subscriptionsRef = useRef(new Map void }>()) + const { setAccounts, setStats, setTaskProviders, setWorktreeInfo } = setters + + useEffect(() => { + if (hosts.length > 0) { + primeHosts(hosts) + } + }, [hosts, primeHosts]) + + useEffect(() => { + setHostAttempts((previous) => readMobileHomeReconnectAttempts(previous, allClients)) + setHostLastConnected((previous) => readMobileHomeLastConnected(previous, allClients)) + setHostStates((previous) => reconcileMobileHomeHostStates(previous, allClients, hostCatalog)) + }, [allClients, hostCatalog]) + + useEffect(() => { + const clientsByHostId = new Map(allClients.map((entry) => [entry.hostId, entry.client])) + for (const [hostId, subscription] of subscriptionsRef.current) { + if (clientsByHostId.get(hostId) !== subscription.client) { + subscription.cleanup() + subscriptionsRef.current.delete(hostId) + } + } + const activeSetters = { setAccounts, setStats, setTaskProviders, setWorktreeInfo } + for (const entry of allClients) { + if (!subscriptionsRef.current.has(entry.hostId)) { + subscriptionsRef.current.set(entry.hostId, { + client: entry.client, + cleanup: wireMobileHomeHostSubscriptions(entry, activeSetters) + }) + } + } + }, [allClients, setAccounts, setStats, setTaskProviders, setWorktreeInfo]) + + useEffect(() => { + const subscriptions = subscriptionsRef.current + return () => { + for (const subscription of subscriptions.values()) { + subscription.cleanup() + } + subscriptions.clear() + } + }, []) + + return { + allClients, + autoConnectHostIds, + hostAttempts, + hostLastConnected, + hostStates + } +} diff --git a/mobile/src/session/mobile-session-route.test.ts b/mobile/src/session/mobile-session-route.test.ts index d9126475bc3..33872e0271f 100644 --- a/mobile/src/session/mobile-session-route.test.ts +++ b/mobile/src/session/mobile-session-route.test.ts @@ -7,7 +7,7 @@ import { type HostStackNavigationState } from '../navigation/host-stack-navigation' -const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8') +const homeSource = readFileSync(new URL('../home/MobileHomeScreen.tsx', import.meta.url), 'utf8') function navigationHarness(initialState: HostStackNavigationState) { const stateListeners = new Set<() => void>() @@ -97,28 +97,18 @@ describe('mobile session route', () => { }) it('routes the home Resume card through the cold-navigator-safe transition', () => { - const start = homeSource.indexOf('{/* ─── Resume card ─── */}') - const end = homeSource.indexOf('{/* ─── Quick actions ─── */}', start) - - // Assert the markers first: a renamed banner would otherwise slice garbage and - // report a missing call instead of the real cause. - expect(start).toBeGreaterThanOrEqual(0) - expect(end).toBeGreaterThan(start) - - const resumeCard = homeSource.slice(start, end) - expect(resumeCard).toContain('openResume(') - expect(resumeCard).not.toContain('router.push(') + expect(homeSource).toContain('onOpenResume={openResume}') // The tap handler itself must go through the coordinated transition; its only // direct push is the shallow noticed host-index route for a proven-missing target. const handlerStart = homeSource.indexOf('const openResume = useCallback(') - const handlerEnd = homeSource.indexOf('[openMobileSession, router]', handlerStart) + const handlerEnd = homeSource.indexOf('[data.router, openMobileSession]', handlerStart) expect(handlerStart).toBeGreaterThanOrEqual(0) expect(handlerEnd).toBeGreaterThan(handlerStart) const openResume = homeSource.slice(handlerStart, handlerEnd) expect(openResume).toContain('openMobileSession({') expect(openResume.match(/router\.push\(/g)).toHaveLength(1) - expect(openResume).toContain('router.push(hostRouteWithNotice(') + expect(openResume).toContain('data.router.push(hostRouteWithNotice(') }) }) diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index a6308d08be5..3f4c0b7907b 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -697,7 +697,9 @@ describe('useMobileNativeChatController streaming scope', () => { launchAgent: 'claude', agentStatus: { state: 'working', + workingMode: undefined as 'monitoring' | undefined, agentType: 'claude', + lastAssistantMessage: 'Partial reply', providerSession: { id: 'session-1' } }, isActive: true @@ -723,6 +725,7 @@ describe('useMobileNativeChatController streaming scope', () => { beforeEach(() => { viewMode.isTabChatView = () => true + workingTab.agentStatus.workingMode = undefined act(() => { renderer = create(createElement(Harness)) }) @@ -737,6 +740,7 @@ describe('useMobileNativeChatController streaming scope', () => { it('holds the stream scope and liveness while the user peeks at the terminal', () => { expect(controller?.showNativeChat).toBe(true) + expect(controller?.nativeChatAgentWorking).toBe(true) const scopeKey = controller?.nativeChatStreamScopeKey expect(scopeKey).toContain('session-1') expect(controller?.nativeChatStreamLive).toBe(true) @@ -750,6 +754,16 @@ describe('useMobileNativeChatController streaming scope', () => { expect(controller?.nativeChatStreamLive).toBe(true) }) + it('treats passive monitoring as outside the foreground turn', () => { + workingTab.agentStatus.workingMode = 'monitoring' + act(() => renderer?.update(createElement(Harness))) + + expect(controller?.nativeChatAgentWorking).toBe(false) + expect(controller?.nativeChatStreamLive).toBe(false) + expect(controller?.nativeChatStreamingText).toBeUndefined() + expect(controller?.nativeChatSessionOptions?.isWorking).toBe(false) + }) + it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => { const before = answerSendArgs.at(-1)?.streamIdentity expect(before).toBe(controller?.nativeChatStreamScopeKey) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 27ad8049c1d..ab1319c7847 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -115,11 +115,14 @@ export function useMobileNativeChatController(args: { transcriptSettled: nativeChatSession.status === 'ready' }) - const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null - const nativeChatAgentWorking = nativeChatStatus?.state === 'working' + const activeTabStatus = activeSessionTab?.agentStatus + const activeTabAgentWorking = + activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring' + const nativeChatStatus = activeChatResolution ? activeTabStatus : null + const nativeChatAgentWorking = activeChatResolution != null && activeTabAgentWorking // Deliberately not gated on the chat view being visible: the streaming gate // has to tell "hidden mid-turn" from "the turn ended". - const nativeChatStreamLive = activeSessionTab?.agentStatus?.state === 'working' + const nativeChatStreamLive = activeTabAgentWorking // Throttle the streaming bubble: OpenCode emits a status frame per streamed // part, and each one re-renders and re-parses the whole accumulated markdown. const nativeChatStreamingText = useThrottledLatestValue( diff --git a/mobile/src/transport/direct-rpc-client.ts b/mobile/src/transport/direct-rpc-client.ts new file mode 100644 index 00000000000..4ac7e9fb361 --- /dev/null +++ b/mobile/src/transport/direct-rpc-client.ts @@ -0,0 +1,324 @@ +import type { ConnectOptions, RpcClient, SendRequestOptions } from './rpc-client' +import { RpcClientAuthenticationRetry } from './rpc-client-authentication-retry' +import { RpcClientConnectionState } from './rpc-client-connection-state' +import { + RpcClientReconnectSchedule, + RPC_RECONNECT_ATTEMPT_LIMIT +} from './rpc-client-reconnect-schedule' +import { RpcClientRequestTracker } from './rpc-client-request-tracker' +import { RpcClientSocketCloseController } from './rpc-client-socket-close-controller' +import { RpcClientSocketFactory } from './rpc-client-socket-factory' +import type { RpcClientSocketSession } from './rpc-client-socket-session' +import { + RpcClientStreamRegistry, + type RpcStreamingListener, + type RpcStreamSubscribeOptions +} from './rpc-client-stream-registry' +import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog' +import { isStaleForegroundDial } from './rpc-stale-dial' +import type { + ConnectionLogLevel, + ConnectionState, + ForegroundNudgeReason, + RpcResponse +} from './types' + +const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-' + +export class DirectRpcClient implements RpcClient { + private socketSession: RpcClientSocketSession | null = null + private readonly connectionState: RpcClientConnectionState + private readonly reconnect: RpcClientReconnectSchedule + private readonly requests: RpcClientRequestTracker + private readonly streams: RpcClientStreamRegistry + private readonly liveness: RpcSessionLivenessWatchdog + private readonly socketFactory: RpcClientSocketFactory + private readonly authenticationRetry: RpcClientAuthenticationRetry + private readonly socketClose: RpcClientSocketCloseController + private requestCounter = 0 + private logCounter = 0 + private intentionallyClosed = false + private authenticationGeneration = 0 + private livenessSession: RpcClientSocketSession | null = null + + constructor( + private readonly endpoint: string, + private readonly deviceToken: string, + serverPublicKeyB64: string, + private readonly options: ConnectOptions + ) { + this.reconnect = new RpcClientReconnectSchedule({ + openConnection: () => this.openConnection(), + rejectConnectWaiters: (reason) => this.connectionState.rejectWaiters(reason), + emitLog: (message, detail) => this.emitLog('info', message, detail) + }) + this.connectionState = new RpcClientConnectionState({ + endpoint, + initialListener: options.onStateChange, + getReconnectAttempt: () => this.reconnect.getAttempt(), + isClosed: () => this.intentionallyClosed + }) + this.streams = new RpcClientStreamRegistry({ + nextId: () => this.nextId(), + deviceToken, + getState: () => this.connectionState.get(), + sendEncrypted: (request) => this.sendEncrypted(request) + }) + this.requests = new RpcClientRequestTracker({ + nextId: () => this.nextId(), + deviceToken, + getState: () => this.connectionState.get(), + waitForConnected: (timeoutMs) => this.waitForConnected(timeoutMs), + sendEncrypted: (request) => this.sendEncrypted(request) + }) + this.liveness = new RpcSessionLivenessWatchdog({ + transport: 'direct', + sendProbe: (identity) => this.sendLivenessProbe(identity), + terminate: (identity) => { + if (identity === this.livenessSession && this.socketSession === this.livenessSession) { + this.socketClose.forceClose(this.livenessSession) + } + } + }) + this.socketFactory = new RpcClientSocketFactory({ + endpoint, + deviceToken, + serverPublicKeyB64, + getCurrentSocket: () => this.socketSession?.socket ?? null, + getState: () => this.getState(), + getReconnectAttempt: () => this.getReconnectAttempt(), + getLastConnectedAt: () => this.getLastConnectedAt(), + isIntentionallyClosed: () => this.intentionallyClosed, + emitLog: (level, message, detail) => this.emitLog(level, message, detail), + onHandshakeStarted: () => this.connectionState.publish('handshaking'), + onAuthenticated: (session) => this.handleAuthenticated(session), + onAuthRejected: (reason) => this.authenticationRetry.reject(reason), + onRpcResponse: (response) => this.handleRpcResponse(response), + onBinary: (bytes) => this.streams.handleBinary(bytes), + onAuthenticatedInbound: (session) => this.liveness.noteAuthenticatedInbound(session), + onClosed: (session, closeCode) => this.socketClose.handle(session, closeCode), + onForcedClose: (session) => this.socketClose.forceClose(session) + }) + this.authenticationRetry = new RpcClientAuthenticationRetry({ + endpoint, + stopLiveness: () => this.stopLiveness(), + emitWarning: (message, detail) => this.emitLog('warn', message, detail), + retry: (reason) => this.retryAuthentication(reason), + latchFailure: (reason) => this.latchAuthenticationFailure(reason) + }) + this.socketClose = new RpcClientSocketCloseController({ + connectionState: this.connectionState, + reconnect: this.reconnect, + requests: this.requests, + streams: this.streams, + socketFactory: this.socketFactory, + authenticationRetry: this.authenticationRetry, + getCurrentSession: () => this.socketSession, + clearCurrentSession: () => (this.socketSession = null), + getAuthenticationGeneration: () => this.authenticationGeneration, + isIntentionallyClosed: () => this.intentionallyClosed, + stopLiveness: (session) => { + if (this.livenessSession === session) { + this.stopLiveness() + } + }, + emitWarning: (message, detail) => this.emitLog('warn', message, detail) + }) + this.openConnection() + } + + sendRequest( + method: string, + params?: unknown, + options?: SendRequestOptions + ): Promise { + return this.requests.sendRequest(method, params, options) + } + + subscribe( + method: string, + params: unknown, + onData: RpcStreamingListener, + options?: RpcStreamSubscribeOptions + ): () => void { + return this.streams.subscribe(method, params, onData, options) + } + + updateTerminalSubscriptionViewport( + terminal: string, + viewport: { cols: number; rows: number } + ): void { + this.streams.updateTerminalViewport(terminal, viewport) + } + + getState(): ConnectionState { + return this.connectionState.get() + } + + getReconnectAttempt(): number { + return this.reconnect.getAttempt() + } + + getLastConnectedAt(): number | null { + return this.connectionState.getLastConnectedAt() + } + + getLastInboundAt = (): number | null => this.liveness.getLastInboundAt() || null + + onStateChange(listener: (state: ConnectionState) => void): () => void { + return this.connectionState.addListener(listener) + } + + notifyForeground(_reason?: ForegroundNudgeReason): void { + if (this.intentionallyClosed) { + return + } + if (this.getState() === 'connected') { + console.log('[net] foreground — probing live connection') + if (this.livenessSession) { + this.liveness.probeNow(this.livenessSession) + } + return + } + const dialing = this.socketSession + const dialAgeMs = Date.now() - this.socketFactory.getDialStartedAt() + let abandoned = false + if (dialing && isStaleForegroundDial(this.getState(), dialAgeMs)) { + console.log('[net] foreground — abandoning stale dial', { + state: this.getState(), + dialAgeMs + }) + this.socketClose.forceClose(dialing) + abandoned = true + } + if (this.getState() === 'reconnecting') { + console.log('[net] foreground — restarting reconnect loop', { + attempt: this.getReconnectAttempt(), + hadTimer: this.reconnect.hasTimer() + }) + this.reconnect.redialNow(!abandoned) + } + } + + close(): void { + this.intentionallyClosed = true + this.reconnect.cancel() + const session = this.socketSession + session?.clearTimers() + if (this.livenessSession) { + this.liveness.stop(this.livenessSession) + this.livenessSession = null + } + session?.close() + this.socketSession = null + session?.clearKey() + this.connectionState.publish('disconnected') + this.requests.rejectAll('Client closed', { deliveryUnknown: true }) + } + + private openConnection(): void { + if (this.intentionallyClosed) { + return + } + this.connectionState.publish('connecting') + this.socketSession = this.socketFactory.open() + } + + private handleAuthenticated(session: RpcClientSocketSession): void { + console.log('[net] e2ee_authenticated — connected', { streamCount: this.streams.size() }) + this.livenessSession = session + this.liveness.start(session) + this.authenticationGeneration++ + this.reconnect.authenticated() + this.authenticationRetry.accepted() + this.connectionState.publish('connected') + this.emitLog('success', 'Authenticated', 'Channel ready for RPC') + this.streams.replayAfterAuthentication() + } + + private handleRpcResponse(response: RpcResponse): void { + if (response.id.startsWith(LIVENESS_REQUEST_ID_PREFIX)) { + return + } + if (!response.ok && response.error.code === 'unauthorized') { + this.authenticationRetry.reject('Unauthorized — pairing may be revoked') + return + } + if (!this.streams.handleResponse(response)) { + this.requests.resolve(response) + } + } + + private stopLiveness(): void { + if (this.livenessSession) { + this.liveness.stop(this.livenessSession) + this.livenessSession = null + } + } + + private retryAuthentication(reason: string): void { + const closing = this.socketSession + this.socketSession = null + closing?.clearKey() + this.streams.markForReplay() + this.requests.rejectAll(reason) + closing?.close() + this.connectionState.publish('reconnecting') + this.reconnect.schedule() + } + + private latchAuthenticationFailure(reason: string): void { + this.intentionallyClosed = true + this.socketSession?.close() + this.socketSession = null + this.connectionState.publish('auth-failed') + this.requests.rejectAll(reason) + } + + private sendEncrypted(request: unknown): boolean { + if (this.socketSession) { + return this.socketSession.sendEncrypted(request) + } + console.log('[net] sendEncrypted FAILED — channel not ready', { + hasWs: false, + hasKey: false, + state: this.getState() + }) + return false + } + + private sendLivenessProbe(identity: object): boolean { + if (identity !== this.livenessSession || this.getState() !== 'connected') { + return false + } + return this.sendEncrypted({ + id: `${LIVENESS_REQUEST_ID_PREFIX}${this.nextId()}`, + deviceToken: this.deviceToken, + method: 'status.get' + }) + } + + private waitForConnected(timeoutMs?: number): Promise { + if ( + this.getState() === 'reconnecting' && + this.getReconnectAttempt() >= RPC_RECONNECT_ATTEMPT_LIMIT + ) { + return Promise.reject(new Error('Connection retry limit reached')) + } + return this.connectionState.waitForConnected(timeoutMs) + } + + private nextId(): string { + return `rpc-${++this.requestCounter}-${Date.now()}` + } + + private emitLog(level: ConnectionLogLevel, message: string, detail?: string): void { + this.options.onLog?.({ + id: `log-${++this.logCounter}-${Date.now()}`, + ts: Date.now(), + level, + message, + detail + }) + } +} diff --git a/mobile/src/transport/mobile-relay-e2ee-link.test.ts b/mobile/src/transport/mobile-relay-e2ee-link.test.ts index d9093568fc1..aa2d42b13f0 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.test.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.test.ts @@ -59,4 +59,114 @@ describe('MobileRelayE2eeLink', () => { ) expect(socket.close).toHaveBeenCalledOnce() }) + + it('keeps a typed close code when transport error precedes close', () => { + const socket = new ThrowingSocket() + const onError = vi.fn() + new MobileRelayE2eeLink({ + endpoint: { + cellUrl: 'https://relay-c1.onorca.dev', + relayHostId: 'AbCdEf0123_-xyZ9' + }, + credential: 'credential', + expectedCredentialKind: 'resume', + deviceToken: 'device-token', + desktopPublicKeyB64: 'desktop-key', + onAuthenticated: vi.fn(), + onText: vi.fn(), + onBinary: vi.fn(), + onError, + createSocket: () => socket as unknown as WebSocket + }) + + socket.onerror?.() + socket.onclose?.({ code: 4409 }) + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_4409' })) + }) + + it('classifies an opaque close after transport error as 1006', () => { + const socket = new ThrowingSocket() + const onError = vi.fn() + new MobileRelayE2eeLink({ + endpoint: { + cellUrl: 'https://relay-c1.onorca.dev', + relayHostId: 'AbCdEf0123_-xyZ9' + }, + credential: 'credential', + expectedCredentialKind: 'resume', + deviceToken: 'device-token', + desktopPublicKeyB64: 'desktop-key', + onAuthenticated: vi.fn(), + onText: vi.fn(), + onBinary: vi.fn(), + onError, + createSocket: () => socket as unknown as WebSocket + }) + + socket.onerror?.() + socket.onclose?.({ code: 0 }) + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_1006' })) + }) + + it('bounds an error when the platform never emits close', async () => { + vi.useFakeTimers() + try { + const socket = new ThrowingSocket() + const onError = vi.fn() + const link = new MobileRelayE2eeLink({ + endpoint: { + cellUrl: 'https://relay-c1.onorca.dev', + relayHostId: 'AbCdEf0123_-xyZ9' + }, + credential: 'credential', + expectedCredentialKind: 'resume', + deviceToken: 'device-token', + desktopPublicKeyB64: 'desktop-key', + onAuthenticated: vi.fn(), + onText: vi.fn(), + onBinary: vi.fn(), + onError, + createSocket: () => socket as unknown as WebSocket + }) + socket.onerror?.() + await vi.advanceTimersByTimeAsync(250) + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'relay_outer_1006' })) + link.close() + } finally { + vi.useRealTimers() + } + }) + + it('cancels the missing-close timer when explicitly closed', async () => { + vi.useFakeTimers() + try { + const socket = new ThrowingSocket() + const onError = vi.fn() + const link = new MobileRelayE2eeLink({ + endpoint: { + cellUrl: 'https://relay-c1.onorca.dev', + relayHostId: 'AbCdEf0123_-xyZ9' + }, + credential: 'credential', + expectedCredentialKind: 'resume', + deviceToken: 'device-token', + desktopPublicKeyB64: 'desktop-key', + onAuthenticated: vi.fn(), + onText: vi.fn(), + onBinary: vi.fn(), + onError, + createSocket: () => socket as unknown as WebSocket + }) + socket.onerror?.() + link.close() + await vi.advanceTimersByTimeAsync(250) + + expect(onError).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) }) diff --git a/mobile/src/transport/mobile-relay-e2ee-link.ts b/mobile/src/transport/mobile-relay-e2ee-link.ts index 80d91589c53..f19417a60f1 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.ts @@ -6,6 +6,10 @@ import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel' import { websocketPayloadToUint8 } from './websocket-payload-bytes' +// Native WebSockets normally emit close immediately after error; bound the +// missing-close case so a dead socket cannot leave recovery pending forever. +const RELAY_ERROR_CLOSE_GRACE_MS = 250 + export class RelayOuterError extends Error { constructor(readonly code: number) { super(`relay_outer_${code}`) @@ -32,6 +36,7 @@ export class MobileRelayE2eeLink { private readonly channel: MobileE2EEV2PhysicalChannel private outerReady = false private closed = false + private transportErrorTimer: ReturnType | null = null private inboundChain: Promise = Promise.resolve() constructor(options: MobileRelayE2eeLinkOptions) { @@ -70,6 +75,10 @@ export class MobileRelayE2eeLink { return } this.closed = true + if (this.transportErrorTimer) { + clearTimeout(this.transportErrorTimer) + this.transportErrorTimer = null + } this.channel.dispose() this.socket.close() } @@ -103,8 +112,21 @@ export class MobileRelayE2eeLink { }) .catch((error: unknown) => this.fail(asError(error))) } - this.socket.onerror = () => this.fail(new Error('relay transport error')) - this.socket.onclose = (event) => this.fail(new RelayOuterError(event.code || 1006)) + // `error` is often delivered just before `close`; wait for close so a + // typed relay code is not replaced by a generic transport error. + this.socket.onerror = () => { + this.transportErrorTimer ??= setTimeout(() => { + this.transportErrorTimer = null + this.fail(new RelayOuterError(1006)) + }, RELAY_ERROR_CLOSE_GRACE_MS) + } + this.socket.onclose = (event) => { + if (this.transportErrorTimer) { + clearTimeout(this.transportErrorTimer) + this.transportErrorTimer = null + } + this.fail(new RelayOuterError(event.code || 1006)) + } } private acceptHello(raw: unknown): void { @@ -137,6 +159,10 @@ export class MobileRelayE2eeLink { return } this.closed = true + if (this.transportErrorTimer) { + clearTimeout(this.transportErrorTimer) + this.transportErrorTimer = null + } this.channel.dispose() this.options.onError(error) this.socket.close() diff --git a/mobile/src/transport/mobile-relay-invite-director.test.ts b/mobile/src/transport/mobile-relay-invite-director.test.ts index 04508024d07..77a0cdde270 100644 --- a/mobile/src/transport/mobile-relay-invite-director.test.ts +++ b/mobile/src/transport/mobile-relay-invite-director.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director' +import { + RelayDirectorMoveNotNewerError, + resolvePairingInviteThroughDirector +} from './mobile-relay-invite-director' class FakeSocket { sent: string[] = [] @@ -59,7 +62,7 @@ describe('pairing invite director resolution', () => { }) }) - it('rejects same/older epochs and untrusted extra fields', async () => { + it('rejects malformed moves with untrusted extra fields', async () => { const socket = new FakeSocket() const resolving = resolvePairingInviteThroughDirector({ relay, @@ -75,6 +78,31 @@ describe('pairing invite director resolution', () => { }) }) - await expect(resolving).rejects.toThrow(/not strictly newer/) + await expect(resolving).rejects.toThrow(/invalid relay director move/) + }) + + it('describes a same-epoch move without accepting it', async () => { + const socket = new FakeSocket() + const resolving = resolvePairingInviteThroughDirector({ + relay, + createSocket: () => socket as unknown as WebSocket + }) + socket.onmessage?.({ + data: JSON.stringify({ + type: 'relay-moved', + v: 1, + cellUrl: relay.cellUrl, + assignmentEpoch: relay.assignmentEpoch + }) + }) + + const error = await resolving.catch((value: unknown) => value) + expect(error).toBeInstanceOf(RelayDirectorMoveNotNewerError) + expect(error).toMatchObject({ + cellUrl: relay.cellUrl, + assignmentEpoch: relay.assignmentEpoch, + currentCellUrl: relay.cellUrl, + currentAssignmentEpoch: relay.assignmentEpoch + }) }) }) diff --git a/mobile/src/transport/mobile-relay-invite-director.ts b/mobile/src/transport/mobile-relay-invite-director.ts index 5dc83f526b6..408f83c8c09 100644 --- a/mobile/src/transport/mobile-relay-invite-director.ts +++ b/mobile/src/transport/mobile-relay-invite-director.ts @@ -1,6 +1,27 @@ import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' import { RelayMovedSchema } from '../../../src/shared/mobile-relay-phone-protocol' +export class RelayDirectorMoveNotNewerError extends Error { + readonly cellUrl: string + readonly assignmentEpoch: number + readonly currentCellUrl: string + readonly currentAssignmentEpoch: number + + constructor(args: { + cellUrl: string + assignmentEpoch: number + currentCellUrl: string + currentAssignmentEpoch: number + }) { + super('relay director move was not strictly newer') + this.name = 'RelayDirectorMoveNotNewerError' + this.cellUrl = args.cellUrl + this.assignmentEpoch = args.assignmentEpoch + this.currentCellUrl = args.currentCellUrl + this.currentAssignmentEpoch = args.currentAssignmentEpoch + } +} + export function resolvePairingInviteThroughDirector(args: { relay: PairingRelay timeoutMs?: number @@ -38,8 +59,19 @@ export function resolvePairingInviteThroughDirector(args: { return } const moved = RelayMovedSchema.safeParse(value) - if (!moved.success || moved.data.assignmentEpoch <= args.relay.assignmentEpoch) { - finish(new Error('relay director move was not strictly newer')) + if (!moved.success) { + finish(new Error('invalid relay director move')) + return + } + if (moved.data.assignmentEpoch <= args.relay.assignmentEpoch) { + finish( + new RelayDirectorMoveNotNewerError({ + cellUrl: moved.data.cellUrl, + assignmentEpoch: moved.data.assignmentEpoch, + currentCellUrl: args.relay.cellUrl, + currentAssignmentEpoch: args.relay.assignmentEpoch + }) + ) return } settled = true diff --git a/mobile/src/transport/mobile-relay-physical-client.test.ts b/mobile/src/transport/mobile-relay-physical-client.test.ts index 77972cc6dfd..cf59264a407 100644 --- a/mobile/src/transport/mobile-relay-physical-client.test.ts +++ b/mobile/src/transport/mobile-relay-physical-client.test.ts @@ -134,6 +134,57 @@ describe('mobile relay physical pairing client', () => { expect(fakes.start).not.toHaveBeenCalled() }) + it('keeps the typed close code when transport error precedes close', async () => { + const socket = new FakeSocket() + const client = connectMobileRelayForPairing({ + relay, + deviceToken: 'device-token', + desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + createSocket: () => socket as unknown as WebSocket + }) + const status = client.sendRequest('status.get') + socket.onerror?.() + socket.onclose?.({ code: 4409 }) + + await expect(status).rejects.toEqual(new RelayOuterError(4409)) + }) + + it('classifies an opaque close after transport error as 1006', async () => { + const socket = new FakeSocket() + const client = connectMobileRelayForPairing({ + relay, + deviceToken: 'device-token', + desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + createSocket: () => socket as unknown as WebSocket + }) + const status = client.sendRequest('status.get') + socket.onerror?.() + socket.onclose?.({ code: 0 }) + + await expect(status).rejects.toEqual(new RelayOuterError(1006)) + }) + + it('settles after an error when the platform never emits close', async () => { + vi.useFakeTimers() + try { + const socket = new FakeSocket() + const client = connectMobileRelayForPairing({ + relay, + deviceToken: 'device-token', + desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + createSocket: () => socket as unknown as WebSocket + }) + const status = client.sendRequest('status.get') + const rejected = expect(status).rejects.toEqual(new RelayOuterError(1006)) + socket.onerror?.() + await vi.advanceTimersByTimeAsync(250) + + await rejected + } finally { + vi.useRealTimers() + } + }) + it('narrates dial, outer auth, handshake and authentication without leaking the invite', async () => { const socket = new FakeSocket() const entries: ConnectionLogEntry[] = [] diff --git a/mobile/src/transport/mobile-relay-physical-client.ts b/mobile/src/transport/mobile-relay-physical-client.ts index 37b5d64857e..9e3e4c259f8 100644 --- a/mobile/src/transport/mobile-relay-physical-client.ts +++ b/mobile/src/transport/mobile-relay-physical-client.ts @@ -10,6 +10,8 @@ import { websocketPayloadToUint8 } from './websocket-payload-bytes' export { RelayOuterError } from './mobile-relay-e2ee-link' import { RelayOuterError } from './mobile-relay-e2ee-link' +const RELAY_ERROR_CLOSE_GRACE_MS = 250 + type PendingRequest = { resolve: (response: RpcResponse) => void reject: (error: Error) => void @@ -46,6 +48,7 @@ export function connectMobileRelayForPairing(args: { let requestCounter = 0 let closed = false let intentionallyClosed = false + let transportErrorTimer: ReturnType | null = null let outerReady = false let authenticated = false let resolveAuthenticated!: () => void @@ -111,8 +114,21 @@ export function connectMobileRelayForPairing(args: { }) .catch((error: unknown) => fail(asError(error))) } - socket.onerror = () => fail(new Error('relay transport error')) - socket.onclose = (event) => fail(new RelayOuterError(event.code || 1006)) + // WebSocket implementations commonly emit `error` immediately before + // `close`; the bounded fallback represents an opaque 1006 close. + socket.onerror = () => { + transportErrorTimer ??= setTimeout(() => { + transportErrorTimer = null + fail(new RelayOuterError(1006)) + }, RELAY_ERROR_CLOSE_GRACE_MS) + } + socket.onclose = (event) => { + if (transportErrorTimer) { + clearTimeout(transportErrorTimer) + transportErrorTimer = null + } + fail(new RelayOuterError(event.code || 1006)) + } function acceptRelayHello(raw: unknown): void { if (typeof raw !== 'string') { @@ -144,6 +160,10 @@ export function connectMobileRelayForPairing(args: { return } closed = true + if (transportErrorTimer) { + clearTimeout(transportErrorTimer) + transportErrorTimer = null + } if (intentionallyClosed) { log('info', 'Relay: pairing socket closed', cellHost) } else { diff --git a/mobile/src/transport/pairing-relay-candidate.test.ts b/mobile/src/transport/pairing-relay-candidate.test.ts index 07d315a487d..5586ee7b801 100644 --- a/mobile/src/transport/pairing-relay-candidate.test.ts +++ b/mobile/src/transport/pairing-relay-candidate.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { PairingCandidateClient } from './mobile-relay-physical-client' import { RelayOuterError } from './mobile-relay-physical-client' import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' +import { RelayDirectorMoveNotNewerError } from './mobile-relay-invite-director' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' import type { ConnectionLogEntry } from './types' @@ -89,6 +90,109 @@ describe('recovering pairing relay candidate', () => { expect(stale.close).toHaveBeenCalledOnce() }) + it('retries the authoritative cell when the director confirms the same assignment', async () => { + const stale = client(Promise.reject(new RelayOuterError(1006))) + const target = client(Promise.resolve(success())) + const persistMove = vi.fn() + let connects = 0 + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: (relay) => { + expect(relay.cellUrl).toBe(journal.metadata.relay.cellUrl) + expect(relay.assignmentEpoch).toBe(journal.metadata.relay.assignmentEpoch) + return connects++ === 0 ? stale : target + }, + resolveDirector: async (relay) => { + throw new RelayDirectorMoveNotNewerError({ + cellUrl: relay.cellUrl, + assignmentEpoch: relay.assignmentEpoch, + currentCellUrl: relay.cellUrl, + currentAssignmentEpoch: relay.assignmentEpoch + }) + }, + persistMove, + now: () => 1, + random: () => 0, + sleep: async () => {} + }) + + await expect(candidate.sendRequest('status.get')).resolves.toEqual(success()) + expect(connects).toBe(2) + expect(persistMove).not.toHaveBeenCalled() + }) + + it('keeps recovery bounded when the authoritative-cell constructor throws', async () => { + const stale = client(Promise.reject(new RelayOuterError(1006))) + const target = client(Promise.resolve(success())) + const entries: ConnectionLogEntry[] = [] + let connects = 0 + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: () => { + connects++ + if (connects === 1) { + return stale + } + if (connects === 2) { + throw new Error('relay constructor failed') + } + return target + }, + resolveDirector: async (relay) => { + throw new RelayDirectorMoveNotNewerError({ + cellUrl: relay.cellUrl, + assignmentEpoch: relay.assignmentEpoch, + currentCellUrl: relay.cellUrl, + currentAssignmentEpoch: relay.assignmentEpoch + }) + }, + persistMove: vi.fn(), + now: () => 1, + random: () => 0, + sleep: async () => {}, + onLog: (entry) => entries.push(entry) + }) + + await expect(candidate.sendRequest('status.get')).resolves.toEqual(success()) + expect(connects).toBe(3) + expect(entries).toContainEqual( + expect.objectContaining({ + level: 'warn', + message: 'Relay: recovery attempt 1 failed', + detail: 'Error: relay constructor failed' + }) + ) + }) + + it('does not retry a same-epoch move to a different cell', async () => { + const stale = client(Promise.reject(new RelayOuterError(1006))) + const resolveDirector = vi.fn(async (relay) => { + throw new RelayDirectorMoveNotNewerError({ + cellUrl: 'https://relay-c2.onorca.dev', + assignmentEpoch: relay.assignmentEpoch, + currentCellUrl: relay.cellUrl, + currentAssignmentEpoch: relay.assignmentEpoch + }) + }) + let connects = 0 + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: () => { + connects++ + return stale + }, + resolveDirector, + persistMove: vi.fn(), + now: () => 1, + random: () => 0, + sleep: async () => {} + }) + + await expect(candidate.sendRequest('status.get')).rejects.toThrow(/not strictly newer/) + expect(resolveDirector).toHaveBeenCalledTimes(3) + expect(connects).toBe(1) + }) + it('does not ask the director to reinterpret endpoint-scoped host-offline', async () => { const offline = client(Promise.reject(new RelayOuterError(4404))) const resolveDirector = vi.fn() diff --git a/mobile/src/transport/pairing-relay-candidate.ts b/mobile/src/transport/pairing-relay-candidate.ts index 2293ce1855f..5158bc1e26d 100644 --- a/mobile/src/transport/pairing-relay-candidate.ts +++ b/mobile/src/transport/pairing-relay-candidate.ts @@ -1,6 +1,7 @@ import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' import { RelayOuterError, type PairingCandidateClient } from './mobile-relay-physical-client' +import { RelayDirectorMoveNotNewerError } from './mobile-relay-invite-director' import { createPairingRelayLogger, pairingRelayErrorDetail } from './pairing-relay-log' import { redactSocketEndpoint } from './socket-event-debug' import type { ConnectionLogSink } from './types' @@ -88,6 +89,35 @@ export function createRecoveringPairingRelayCandidate(args: { return await client.sendRequest(method, params) } catch (error) { lastError = error + if (isCurrentAssignmentMove(error, relay)) { + log( + 'info', + 'Relay: director kept current assignment', + redactSocketEndpoint(relay.cellUrl) + ) + client.close() + await backOff(attempt) + if (closed) { + throw new Error('relay pairing client closed') + } + try { + client = args.connect(relay, args.onLog) + return await client.sendRequest(method, params) + } catch (retryError) { + lastError = retryError + log( + 'warn', + `Relay: recovery attempt ${attempt + 1} failed`, + pairingRelayErrorDetail(retryError) + ) + if (!isDirectorRecoverable(retryError) || attempt + 1 >= maxAttempts) { + log('error', 'Relay: recovery gave up', `after ${attempt + 1} attempt(s)`) + throw retryError + } + await backOff(attempt) + continue + } + } log('warn', `Relay: recovery attempt ${attempt + 1} failed`, pairingRelayErrorDetail(error)) if (!isDirectorRecoverable(error) || attempt + 1 >= maxAttempts) { log('error', 'Relay: recovery gave up', `after ${attempt + 1} attempt(s)`) @@ -100,6 +130,16 @@ export function createRecoveringPairingRelayCandidate(args: { } } +function isCurrentAssignmentMove(error: unknown, relay: PairingRelay): boolean { + return ( + error instanceof RelayDirectorMoveNotNewerError && + error.assignmentEpoch === relay.assignmentEpoch && + error.cellUrl === relay.cellUrl && + error.currentAssignmentEpoch === relay.assignmentEpoch && + error.currentCellUrl === relay.cellUrl + ) +} + function pairingRelayFromJournal(journal: MobileRelayPairingJournal): PairingRelay { return { ...journal.metadata.relay, diff --git a/mobile/src/transport/rpc-client-authentication-retry.ts b/mobile/src/transport/rpc-client-authentication-retry.ts new file mode 100644 index 00000000000..da40929365d --- /dev/null +++ b/mobile/src/transport/rpc-client-authentication-retry.ts @@ -0,0 +1,46 @@ +import { redactSocketEndpoint } from './socket-event-debug' + +const AUTH_RETRY_BUDGET = 3 + +type AuthenticationRetryOptions = { + endpoint: string + stopLiveness: () => void + emitWarning: (message: string, detail: string) => void + retry: (reason: string) => void + latchFailure: (reason: string) => void +} + +export class RpcClientAuthenticationRetry { + private rejectionCount = 0 + + constructor(private readonly options: AuthenticationRetryOptions) {} + + accepted(): void { + this.rejectionCount = 0 + } + + reject(reason: string, preserveRecovery = false): void { + this.options.stopLiveness() + this.rejectionCount++ + if (this.rejectionCount < AUTH_RETRY_BUDGET) { + console.log('[net] auth rejected — retrying handshake', { + attempt: this.rejectionCount, + budget: AUTH_RETRY_BUDGET, + endpoint: redactSocketEndpoint(this.options.endpoint) + }) + this.options.emitWarning( + 'Authentication rejected', + `Retrying (${this.rejectionCount}/${AUTH_RETRY_BUDGET})` + ) + if (!preserveRecovery) { + this.options.retry(reason) + } + return + } + console.log('[net] auth rejected — budget exhausted, latching auth-failed', { + attempt: this.rejectionCount, + endpoint: redactSocketEndpoint(this.options.endpoint) + }) + this.options.latchFailure(reason) + } +} diff --git a/mobile/src/transport/rpc-client-connection-state.ts b/mobile/src/transport/rpc-client-connection-state.ts new file mode 100644 index 00000000000..83714ecd0c0 --- /dev/null +++ b/mobile/src/transport/rpc-client-connection-state.ts @@ -0,0 +1,114 @@ +import { redactSocketEndpoint } from './socket-event-debug' +import type { ConnectionState } from './types' + +type ConnectWaiter = { + resolve: () => void + reject: (error: Error) => void + timeout: ReturnType | null +} + +type ConnectionStateOptions = { + endpoint: string + initialListener?: (state: ConnectionState) => void + getReconnectAttempt: () => number + isClosed: () => boolean +} + +export class RpcClientConnectionState { + private state: ConnectionState = 'disconnected' + private lastConnectedAt: number | null = null + private stateEnteredAt = Date.now() + private readonly listeners = new Set<(state: ConnectionState) => void>() + private readonly waiters: ConnectWaiter[] = [] + + constructor(private readonly options: ConnectionStateOptions) { + if (options.initialListener) { + this.listeners.add(options.initialListener) + } + } + + get(): ConnectionState { + return this.state + } + + getLastConnectedAt(): number | null { + return this.lastConnectedAt + } + + publish(next: ConnectionState): void { + if (this.state === next) { + return + } + const previous = this.state + const dweltMs = Date.now() - this.stateEnteredAt + this.state = next + this.stateEnteredAt = Date.now() + console.log('[net] state', { + from: previous, + to: next, + dweltMs, + attempt: this.options.getReconnectAttempt(), + endpoint: redactSocketEndpoint(this.options.endpoint) + }) + if (next === 'connected') { + this.lastConnectedAt = Date.now() + this.resolveWaiters() + } else if (next === 'disconnected' || next === 'auth-failed') { + this.rejectWaiters( + next === 'auth-failed' ? 'Unauthorized — pairing may be revoked' : 'Connection closed' + ) + } + for (const listener of this.listeners) { + listener(next) + } + } + + waitForConnected(timeoutMs?: number): Promise { + if (this.state === 'connected') { + return Promise.resolve() + } + if (this.options.isClosed()) { + return Promise.reject(new Error('Client closed')) + } + return new Promise((resolve, reject) => { + const waiter: ConnectWaiter = { resolve, reject, timeout: null } + if (timeoutMs !== undefined) { + waiter.timeout = setTimeout( + () => { + const index = this.waiters.indexOf(waiter) + if (index !== -1) { + this.waiters.splice(index, 1) + } + reject(new Error('Timed out while connecting to the remote Orca runtime.')) + }, + Math.max(0, timeoutMs) + ) + } + this.waiters.push(waiter) + }) + } + + rejectWaiters(reason: string): void { + const error = new Error(reason) + for (const waiter of this.waiters.splice(0)) { + if (waiter.timeout) { + clearTimeout(waiter.timeout) + } + waiter.reject(error) + } + } + + addListener(listener: (state: ConnectionState) => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + private resolveWaiters(): void { + for (const waiter of this.waiters.splice(0)) { + if (waiter.timeout) { + clearTimeout(waiter.timeout) + } + waiter.resolve() + } + } +} diff --git a/mobile/src/transport/rpc-client-reconnect-schedule.ts b/mobile/src/transport/rpc-client-reconnect-schedule.ts new file mode 100644 index 00000000000..7497b776ebc --- /dev/null +++ b/mobile/src/transport/rpc-client-reconnect-schedule.ts @@ -0,0 +1,68 @@ +const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000] +export const RPC_RECONNECT_ATTEMPT_LIMIT = 12 +const TRICKLE_RECONNECT_DELAY_MS = 90_000 + +type ReconnectScheduleOptions = { + openConnection: () => void + rejectConnectWaiters: (reason: string) => void + emitLog: (message: string, detail: string) => void +} + +export class RpcClientReconnectSchedule { + private attempt = 0 + private timer: ReturnType | null = null + + constructor(private readonly options: ReconnectScheduleOptions) {} + + getAttempt(): number { + return this.attempt + } + + authenticated(): void { + this.attempt = 0 + } + + schedule(): void { + const trickle = this.attempt >= RPC_RECONNECT_ATTEMPT_LIMIT + let delayMs: number + if (trickle) { + delayMs = TRICKLE_RECONNECT_DELAY_MS + this.options.rejectConnectWaiters('Connection retry limit reached') + } else { + delayMs = RECONNECT_DELAYS[Math.min(this.attempt, RECONNECT_DELAYS.length - 1)]! + this.attempt++ + } + console.log('[net] scheduleReconnect', { + delayMs, + attempt: this.attempt, + trickle + }) + this.options.emitLog( + `Reconnect scheduled in ${delayMs}ms`, + trickle ? `Attempt ${this.attempt} (slow retry)` : `Attempt ${this.attempt}` + ) + this.timer = setTimeout(() => { + this.timer = null + this.options.openConnection() + }, delayMs) + } + + redialNow(resetAttempts: boolean): void { + this.cancel() + if (resetAttempts) { + this.attempt = 0 + } + this.options.openConnection() + } + + hasTimer(): boolean { + return this.timer !== null + } + + cancel(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + } +} diff --git a/mobile/src/transport/rpc-client-request-tracker.ts b/mobile/src/transport/rpc-client-request-tracker.ts new file mode 100644 index 00000000000..34edd2631e2 --- /dev/null +++ b/mobile/src/transport/rpc-client-request-tracker.ts @@ -0,0 +1,105 @@ +import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget' +import type { SendRequestOptions } from './rpc-client' +import type { ConnectionState, RpcResponse } from './types' + +const REQUEST_TIMEOUT_MS = 30_000 + +type PendingRequest = { + resolve: (response: RpcResponse) => void + reject: (error: Error) => void +} + +type RequestTrackerOptions = { + nextId: () => string + getState: () => ConnectionState + waitForConnected: (timeoutMs?: number) => Promise + sendEncrypted: (request: unknown) => boolean + deviceToken: string +} + +export class RpcClientRequestTracker { + private readonly pending = new Map() + + constructor(private readonly options: RequestTrackerOptions) {} + + async sendRequest( + method: string, + params?: unknown, + requestOptions?: SendRequestOptions + ): Promise { + const budget = openRpcRequestBudget(requestOptions) + const waitStart = budget.startedAt + const wasConnected = this.options.getState() === 'connected' + if (requestOptions?.failWhenDisconnected && !wasConnected) { + throw new Error(`Not connected: ${method}`) + } + await this.options.waitForConnected(requestOptions?.timeoutMs) + if (!wasConnected) { + console.log('[net] sendRequest waited for connect', { + method, + waitedMs: Date.now() - waitStart + }) + } + + return new Promise((resolve, reject) => { + const id = this.options.nextId() + const timeoutMs = resolvePostConnectRequestTimeout(budget, REQUEST_TIMEOUT_MS) + const timeout = setTimeout(() => { + this.pending.delete(id) + console.log('[net] sendRequest TIMEOUT', { + method, + timeoutMs, + state: this.options.getState() + }) + reject(markRpcDeliveryUnknown(new Error(`Request timed out: ${method}`))) + }, timeoutMs) + this.pending.set(id, { + resolve: (response) => { + clearTimeout(timeout) + resolve(response) + }, + reject: (error) => { + clearTimeout(timeout) + reject(error) + } + }) + if ( + !this.options.sendEncrypted({ + id, + deviceToken: this.options.deviceToken, + method, + params + }) + ) { + this.pending.delete(id) + clearTimeout(timeout) + reject(new Error('Connection interrupted')) + } + }) + } + + resolve(response: RpcResponse): boolean { + const request = this.pending.get(response.id) + if (!request) { + return false + } + this.pending.delete(response.id) + request.resolve(response) + return true + } + + rejectAll(reason: string, options?: { deliveryUnknown?: boolean }): void { + const error = options?.deliveryUnknown + ? markRpcDeliveryUnknown(new Error(reason)) + : new Error(reason) + for (const [id, request] of this.pending) { + this.pending.delete(id) + queueMicrotask(() => request.reject(error)) + } + } + + size(): number { + return this.pending.size + } +} diff --git a/mobile/src/transport/rpc-client-server-subscription.ts b/mobile/src/transport/rpc-client-server-subscription.ts new file mode 100644 index 00000000000..689d8ac3e6c --- /dev/null +++ b/mobile/src/transport/rpc-client-server-subscription.ts @@ -0,0 +1,12 @@ +export function buildReadyStreamUnsubscribe( + method: string, + subscriptionId: string +): { method: string; params: { subscriptionId: string } } | null { + if (method === 'browser.screencast') { + return { method: 'browser.screencast.unsubscribe', params: { subscriptionId } } + } + if (method === 'runtime.clientEvents.subscribe') { + return { method: 'runtime.clientEvents.unsubscribe', params: { subscriptionId } } + } + return null +} diff --git a/mobile/src/transport/rpc-client-socket-close-controller.ts b/mobile/src/transport/rpc-client-socket-close-controller.ts new file mode 100644 index 00000000000..a03096757c6 --- /dev/null +++ b/mobile/src/transport/rpc-client-socket-close-controller.ts @@ -0,0 +1,88 @@ +import type { RpcClientAuthenticationRetry } from './rpc-client-authentication-retry' +import type { RpcClientConnectionState } from './rpc-client-connection-state' +import type { RpcClientReconnectSchedule } from './rpc-client-reconnect-schedule' +import type { RpcClientRequestTracker } from './rpc-client-request-tracker' +import type { RpcClientSocketFactory } from './rpc-client-socket-factory' +import type { RpcClientSocketSession } from './rpc-client-socket-session' +import type { RpcClientStreamRegistry } from './rpc-client-stream-registry' +import { RpcSynthesizedCloseIndex } from './rpc-socket-close-evidence' + +const UNAUTHORIZED_CLOSE_CODE = 4001 + +type SocketCloseControllerOptions = { + connectionState: RpcClientConnectionState + reconnect: RpcClientReconnectSchedule + requests: RpcClientRequestTracker + streams: RpcClientStreamRegistry + socketFactory: RpcClientSocketFactory + authenticationRetry: RpcClientAuthenticationRetry + getCurrentSession: () => RpcClientSocketSession | null + clearCurrentSession: () => void + getAuthenticationGeneration: () => number + isIntentionallyClosed: () => boolean + stopLiveness: (session: RpcClientSocketSession) => void + emitWarning: (message: string, detail: string) => void +} + +export class RpcClientSocketCloseController { + private readonly synthesizedCloses = new RpcSynthesizedCloseIndex() + + constructor(private readonly options: SocketCloseControllerOptions) {} + + forceClose(session: RpcClientSocketSession): void { + session.close() + if (this.options.getCurrentSession() === session) { + this.synthesizedCloses.remember(session.socket, this.options.getAuthenticationGeneration()) + this.handle(session) + } + } + + handle(session: RpcClientSocketSession, closeCode?: number): void { + if (this.options.getCurrentSession() !== session) { + if ( + this.synthesizedCloses.takeUnauthorized( + session.socket, + closeCode, + this.options.getAuthenticationGeneration(), + UNAUTHORIZED_CLOSE_CODE + ) + ) { + this.options.authenticationRetry.reject('Unauthorized — pairing may be revoked', true) + return + } + console.log('[net] handleSocketClosed STALE — ignoring (ws already swapped)', { + state: this.options.connectionState.get(), + attempt: this.options.reconnect.getAttempt() + }) + return + } + this.options.socketFactory.noteClosed() + session.clearTimers() + session.clearKey() + this.options.clearCurrentSession() + this.options.streams.markForReplay() + this.options.stopLiveness(session) + if (this.options.isIntentionallyClosed()) { + console.log('[net] handleSocketClosed — intentional close') + this.options.connectionState.publish('disconnected') + this.options.requests.rejectAll('Connection closed', { deliveryUnknown: true }) + return + } + if (closeCode === UNAUTHORIZED_CLOSE_CODE) { + console.log('[net] handleSocketClosed — unauthorized close code', { + attempt: this.options.reconnect.getAttempt() + }) + this.options.authenticationRetry.reject('Unauthorized — pairing may be revoked') + return + } + console.log('[net] handleSocketClosed → reconnect', { + pendingCount: this.options.requests.size(), + streamCount: this.options.streams.size(), + attempt: this.options.reconnect.getAttempt() + }) + this.options.emitWarning('WebSocket closed', 'Will attempt to reconnect') + this.options.requests.rejectAll('Connection interrupted', { deliveryUnknown: true }) + this.options.connectionState.publish('reconnecting') + this.options.reconnect.schedule() + } +} diff --git a/mobile/src/transport/rpc-client-socket-factory.ts b/mobile/src/transport/rpc-client-socket-factory.ts new file mode 100644 index 00000000000..1c21c0dd4e0 --- /dev/null +++ b/mobile/src/transport/rpc-client-socket-factory.ts @@ -0,0 +1,85 @@ +import { publicKeyFromBase64 } from './e2ee' +import { RpcClientSocketSession } from './rpc-client-socket-session' +import { redactSocketEndpoint } from './socket-event-debug' +import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types' + +type SocketFactoryOptions = { + endpoint: string + deviceToken: string + serverPublicKeyB64: string + getCurrentSocket: () => WebSocket | null + getState: () => ConnectionState + getReconnectAttempt: () => number + getLastConnectedAt: () => number | null + isIntentionallyClosed: () => boolean + emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void + onHandshakeStarted: () => void + onAuthenticated: (session: RpcClientSocketSession) => void + onAuthRejected: (reason: string) => void + onRpcResponse: (response: RpcResponse) => void + onBinary: (bytes: Uint8Array) => void + onAuthenticatedInbound: (session: RpcClientSocketSession) => void + onClosed: (session: RpcClientSocketSession, closeCode?: number) => void + onForcedClose: (session: RpcClientSocketSession) => void +} + +export class RpcClientSocketFactory { + private readonly serverPublicKey: Uint8Array + private lastInboundAt: number | null = null + private lastSocketClosedAt: number | null = null + private constructionCount = 0 + private dialStartedAt = 0 + + constructor(private readonly options: SocketFactoryOptions) { + this.serverPublicKey = publicKeyFromBase64(options.serverPublicKeyB64) + } + + open(): RpcClientSocketSession { + const now = Date.now() + const lastConnectedAt = this.options.getLastConnectedAt() + this.constructionCount++ + console.log('[net] openConnection', { + attempt: this.options.getReconnectAttempt(), + endpoint: redactSocketEndpoint(this.options.endpoint), + wsCount: this.constructionCount, + msSinceLastConnected: lastConnectedAt !== null ? now - lastConnectedAt : null, + msSinceLastClose: this.lastSocketClosedAt !== null ? now - this.lastSocketClosedAt : null, + msSinceLastInbound: this.lastInboundAt !== null ? now - this.lastInboundAt : null + }) + this.dialStartedAt = now + this.options.emitLog( + 'info', + this.options.getReconnectAttempt() > 0 + ? `Reconnecting (attempt ${this.options.getReconnectAttempt() + 1})` + : 'Opening WebSocket', + redactSocketEndpoint(this.options.endpoint) + ) + return new RpcClientSocketSession({ + endpoint: this.options.endpoint, + deviceToken: this.options.deviceToken, + serverPublicKey: this.serverPublicKey, + getCurrentSocket: this.options.getCurrentSocket, + getState: this.options.getState, + getReconnectAttempt: this.options.getReconnectAttempt, + isIntentionallyClosed: this.options.isIntentionallyClosed, + emitLog: this.options.emitLog, + onHandshakeStarted: this.options.onHandshakeStarted, + onAuthenticated: this.options.onAuthenticated, + onAuthRejected: this.options.onAuthRejected, + onRpcResponse: this.options.onRpcResponse, + onBinary: this.options.onBinary, + onAnyInbound: (receivedAt) => (this.lastInboundAt = receivedAt), + onAuthenticatedInbound: this.options.onAuthenticatedInbound, + onClosed: this.options.onClosed, + onForcedClose: this.options.onForcedClose + }) + } + + getDialStartedAt(): number { + return this.dialStartedAt + } + + noteClosed(): void { + this.lastSocketClosedAt = Date.now() + } +} diff --git a/mobile/src/transport/rpc-client-socket-session.ts b/mobile/src/transport/rpc-client-socket-session.ts new file mode 100644 index 00000000000..6eebc6b02b9 --- /dev/null +++ b/mobile/src/transport/rpc-client-socket-session.ts @@ -0,0 +1,312 @@ +import { + decrypt, + decryptBytes, + deriveSharedKey, + encrypt, + generateKeyPair, + publicKeyToBase64 +} from './e2ee' +import { isRpcResponse } from './rpc-response-shape' +import { isStaleRpcSocketEvent, logRpcSocketClose } from './rpc-socket-close-evidence' +import { describeSocketEvent, redactSocketEndpoint } from './socket-event-debug' +import type { ConnectionLogLevel, ConnectionState, RpcResponse } from './types' +import { websocketPayloadToUint8 } from './websocket-payload-bytes' + +const CONNECT_TIMEOUT_MS = 12_000 +const HANDSHAKE_TIMEOUT_MS = 5_000 +const WEBSOCKET_CONNECTING_STATE = 0 + +type SocketSessionOptions = { + endpoint: string + deviceToken: string + serverPublicKey: Uint8Array + getCurrentSocket: () => WebSocket | null + getState: () => ConnectionState + getReconnectAttempt: () => number + isIntentionallyClosed: () => boolean + emitLog: (level: ConnectionLogLevel, message: string, detail?: string) => void + onHandshakeStarted: () => void + onAuthenticated: (session: RpcClientSocketSession) => void + onAuthRejected: (reason: string) => void + onRpcResponse: (response: RpcResponse) => void + onBinary: (bytes: Uint8Array) => void + onAnyInbound: (receivedAt: number) => void + onAuthenticatedInbound: (session: RpcClientSocketSession) => void + onClosed: (session: RpcClientSocketSession, closeCode?: number) => void + onForcedClose: (session: RpcClientSocketSession) => void +} + +export class RpcClientSocketSession { + readonly socket: WebSocket + readonly constructedAt = Date.now() + private sharedKey: Uint8Array | null = null + private authenticated = false + private lastInboundAt: number | null = null + private connectTimer: ReturnType | null = null + private handshakeTimer: ReturnType | null = null + + constructor(private readonly options: SocketSessionOptions) { + this.socket = new WebSocket(options.endpoint) + this.attachHandlers() + this.armConnectTimeout() + } + + sendEncrypted(request: unknown): boolean { + if (this.socket.readyState === WebSocket.OPEN && this.sharedKey) { + try { + this.socket.send(encrypt(JSON.stringify(request), this.sharedKey)) + return true + } catch { + if (this.options.getCurrentSocket() === this.socket) { + this.options.onForcedClose(this) + } + return false + } + } + console.log('[net] sendEncrypted FAILED — channel not ready', { + hasWs: this.options.getCurrentSocket() !== null, + readyState: this.socket.readyState, + hasKey: this.sharedKey !== null, + state: this.options.getState() + }) + if ( + this.options.getState() === 'connected' && + this.options.getCurrentSocket() === this.socket && + this.socket.readyState !== WebSocket.OPEN + ) { + console.log('[net] sendEncrypted detected ws desync — forcing reconnect', { + readyState: this.socket.readyState + }) + this.options.onForcedClose(this) + } + return false + } + + close(): void { + this.socket.close() + } + + clearTimers(): void { + if (this.connectTimer) { + clearTimeout(this.connectTimer) + this.connectTimer = null + } + if (this.handshakeTimer) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + } + } + + clearKey(): void { + this.sharedKey = null + } + + private attachHandlers(): void { + this.socket.onopen = () => { + if (this.isStale('open')) { + return + } + console.log('[net] ws.onopen', { attempt: this.options.getReconnectAttempt() }) + this.clearConnectTimer() + this.options.onHandshakeStarted() + this.options.emitLog('success', 'WebSocket open', 'Starting E2EE handshake') + const ephemeral = generateKeyPair() + const hello = JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ephemeral.publicKey) + }) + try { + this.socket.send(hello) + } catch { + this.options.onForcedClose(this) + return + } + this.options.emitLog('info', 'Sent e2ee_hello', 'Awaiting server e2ee_ready') + this.sharedKey = deriveSharedKey(ephemeral.secretKey, this.options.serverPublicKey) + this.armHandshakeTimeout() + } + this.socket.onmessage = (event) => { + if (!this.isStale('message')) { + void this.handleMessage(event.data) + } + } + this.socket.onclose = (event) => { + const closeCode = logRpcSocketClose({ + event, + state: this.options.getState(), + attempt: this.options.getReconnectAttempt(), + intentionallyClosed: this.options.isIntentionallyClosed(), + endpoint: redactSocketEndpoint(this.options.endpoint), + constructedAt: this.constructedAt, + authenticated: this.authenticated, + lastInboundAt: this.lastInboundAt + }) + this.options.onClosed(this, closeCode) + } + this.socket.onerror = (event) => { + if (this.isStale('error')) { + return + } + const error = event as { message?: string } | undefined + const description = describeSocketEvent(event) + console.log('[net] ws.onerror', { + message: error?.message, + state: this.options.getState(), + attempt: this.options.getReconnectAttempt(), + eventKeys: description.keys, + eventStr: description.json + }) + } + } + + private async handleMessage(rawData: unknown): Promise { + const receivedAt = Date.now() + this.lastInboundAt = receivedAt + this.options.onAnyInbound(receivedAt) + const raw = typeof rawData === 'string' ? rawData : null + if (!this.authenticated) { + this.handleHandshakeMessage(raw) + return + } + if (!this.sharedKey || this.sharedKey.length !== 32) { + return + } + if (raw === null) { + const bytes = await websocketPayloadToUint8(rawData) + if (this.options.getCurrentSocket() !== this.socket || !bytes) { + return + } + const plaintext = decryptBytes(bytes, this.sharedKey) + if (!plaintext) { + return + } + this.options.onAuthenticatedInbound(this) + this.options.onBinary(plaintext) + return + } + const plaintext = decrypt(raw, this.sharedKey) + if (plaintext === null) { + return + } + this.options.onAuthenticatedInbound(this) + let response: unknown + try { + response = JSON.parse(plaintext) + } catch { + return + } + if (isRpcResponse(response)) { + this.options.onRpcResponse(response) + } + } + + private handleHandshakeMessage(raw: string | null): void { + if (raw === null) { + return + } + try { + const message = JSON.parse(raw) as { type?: unknown } + if (message.type === 'e2ee_ready') { + this.options.emitLog('success', 'Received e2ee_ready', 'Sending device token') + this.sendEncrypted({ type: 'e2ee_auth', deviceToken: this.options.deviceToken }) + return + } + } catch { + // The authenticated handshake messages are encrypted. + } + if (!this.sharedKey || this.sharedKey.length !== 32) { + return + } + const plaintext = decrypt(raw, this.sharedKey) + if (plaintext === null) { + return + } + try { + const message = JSON.parse(plaintext) as { + type?: unknown + ok?: unknown + error?: { code?: unknown } + } + if (message.type === 'e2ee_authenticated') { + this.clearHandshakeTimer() + this.authenticated = true + this.options.onAuthenticated(this) + } else if ( + message.type === 'e2ee_error' || + (message.ok === false && message.error?.code === 'unauthorized') + ) { + console.log('[net] e2ee auth FAILED', { + msgType: message.type, + error: message.error + }) + this.clearHandshakeTimer() + this.options.onAuthRejected('Unauthorized — pairing may be revoked') + } + } catch { + // Ignore malformed handshake payloads. + } + } + + private armConnectTimeout(): void { + this.connectTimer = setTimeout(() => { + this.connectTimer = null + if ( + this.options.getCurrentSocket() === this.socket && + this.socket.readyState === WEBSOCKET_CONNECTING_STATE + ) { + console.log('[net] connect-timeout fired (onopen never arrived)', { + attempt: this.options.getReconnectAttempt(), + timeoutMs: CONNECT_TIMEOUT_MS + }) + this.options.emitLog( + 'error', + 'WebSocket connect timeout', + `No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?` + ) + this.options.onForcedClose(this) + } + }, CONNECT_TIMEOUT_MS) + } + + private armHandshakeTimeout(): void { + this.handshakeTimer = setTimeout(() => { + this.handshakeTimer = null + if (this.options.getCurrentSocket() !== this.socket || this.authenticated) { + return + } + console.log('[net] handshake-timeout fired (e2ee_authenticated never arrived)', { + timeoutMs: HANDSHAKE_TIMEOUT_MS + }) + this.options.emitLog( + 'error', + 'Handshake timeout', + `No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s` + ) + this.options.onForcedClose(this) + }, HANDSHAKE_TIMEOUT_MS) + } + + private clearConnectTimer(): void { + if (this.connectTimer) { + clearTimeout(this.connectTimer) + this.connectTimer = null + } + } + + private clearHandshakeTimer(): void { + if (this.handshakeTimer) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = null + } + } + + private isStale(eventName: string): boolean { + return isStaleRpcSocketEvent( + this.options.getCurrentSocket(), + this.socket, + eventName, + this.options.getState(), + this.options.getReconnectAttempt() + ) + } +} diff --git a/mobile/src/transport/rpc-client-stream-registry.test.ts b/mobile/src/transport/rpc-client-stream-registry.test.ts new file mode 100644 index 00000000000..0e7d561be6e --- /dev/null +++ b/mobile/src/transport/rpc-client-stream-registry.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { RpcClientStreamRegistry } from './rpc-client-stream-registry' +import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol' +import type { ConnectionState, RpcResponse } from './types' + +type SentRequest = { + id: string + method: string + params?: unknown +} + +function createRegistry(initialState: ConnectionState = 'connected') { + const sent: SentRequest[] = [] + let state = initialState + let id = 0 + const registry = new RpcClientStreamRegistry({ + nextId: () => `rpc-${++id}`, + deviceToken: 'device-token', + getState: () => state, + sendEncrypted: (request) => { + sent.push(request as SentRequest) + return true + } + }) + return { + registry, + sent, + setState(next: ConnectionState) { + state = next + } + } +} + +function streamingResponse(id: string, result: unknown): RpcResponse { + return { + id, + ok: true, + streaming: true, + result, + _meta: { runtimeId: 'runtime-1' } + } +} + +function terminalOutput(streamId: number, chunk: string): Uint8Array { + return encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId, + seq: 1, + payload: new TextEncoder().encode(chunk) + }) +} + +describe('RpcClientStreamRegistry', () => { + it('replays the latest terminal viewport without retaining stale stream routing', () => { + const { registry, sent } = createRegistry() + const events: unknown[] = [] + registry.subscribe( + 'terminal.subscribe', + { terminal: 'term-1', viewport: { cols: 45, rows: 20 } }, + (event) => events.push(event) + ) + const first = sent[0]! + registry.handleResponse(streamingResponse(first.id, { type: 'subscribed', streamId: 7 })) + registry.handleBinary(terminalOutput(7, 'before')) + + registry.updateTerminalViewport('term-1', { cols: 60, rows: 24 }) + registry.markForReplay() + registry.replayAfterAuthentication() + registry.handleBinary(terminalOutput(7, 'stale')) + + expect(sent[1]).toMatchObject({ + id: first.id, + method: 'terminal.subscribe', + params: { terminal: 'term-1', viewport: { cols: 60, rows: 24 } } + }) + expect(events).toEqual([ + { type: 'subscribed', streamId: 7 }, + { type: 'data', streamId: 7, chunk: 'before' } + ]) + }) + + it('keeps a disposed browser tombstone until ready can be unsubscribed', () => { + const { registry, sent } = createRegistry() + const dispose = registry.subscribe('browser.screencast', { page: 'page-1' }, () => {}) + const request = sent[0]! + + dispose() + expect(sent).toHaveLength(1) + + registry.handleResponse( + streamingResponse(request.id, { + type: 'ready', + subscriptionId: 'browser-screencast:page-1:test' + }) + ) + expect(sent[1]).toMatchObject({ + method: 'browser.screencast.unsubscribe', + params: { subscriptionId: 'browser-screencast:page-1:test' } + }) + }) +}) diff --git a/mobile/src/transport/rpc-client-stream-registry.ts b/mobile/src/transport/rpc-client-stream-registry.ts new file mode 100644 index 00000000000..ac20acdecc0 --- /dev/null +++ b/mobile/src/transport/rpc-client-stream-registry.ts @@ -0,0 +1,315 @@ +import { + decodeBrowserScreencastFrame, + type BrowserScreencastFrame +} from './browser-screencast-protocol' +import { + buildStreamUnsubscribe, + buildTerminalUnsubscribeParams, + updateTerminalSubscriptionViewport +} from './rpc-client-terminal-subscription' +import { buildReadyStreamUnsubscribe } from './rpc-client-server-subscription' +import { + isStreamingSubscriptionReadyResult, + isTerminalSubscribedResult +} from './rpc-subscription-result-shapes' +import { RpcClientTerminalStreamRouter } from './rpc-client-terminal-stream-router' +import type { ConnectionState, RpcResponse, RpcSuccess } from './types' + +export type RpcStreamingListener = (result: unknown) => void + +export type RpcStreamSubscribeOptions = { + onBinaryFrame?: (frame: BrowserScreencastFrame) => void +} + +type StreamRequest = { + method: string + params: unknown + listener: RpcStreamingListener + onBinaryFrame?: (frame: BrowserScreencastFrame) => void + subscriptionId?: string + cancelled?: boolean + sent?: boolean +} + +type StreamRegistryOptions = { + nextId: () => string + deviceToken: string + getState: () => ConnectionState + sendEncrypted: (request: unknown) => boolean +} + +export class RpcClientStreamRegistry { + private readonly streams = new Map() + private readonly terminalRouter = new RpcClientTerminalStreamRouter() + private activeBrowserRequestId: string | null = null + private pendingBrowserRequestId: string | null = null + + constructor(private readonly options: StreamRegistryOptions) {} + + subscribe( + method: string, + params: unknown, + listener: RpcStreamingListener, + subscribeOptions?: RpcStreamSubscribeOptions + ): () => void { + const id = this.options.nextId() + const stream: StreamRequest = { + method, + params, + listener, + onBinaryFrame: subscribeOptions?.onBinaryFrame + } + this.streams.set(id, stream) + if (method === 'browser.screencast') { + this.replaceBrowserStream(id) + } + if (this.options.getState() === 'connected') { + if (this.send(id, stream)) { + stream.sent = true + } else { + this.emitError(stream, 'Connection interrupted') + this.remove(id) + } + } else { + console.log('[net] subscribe queued — waiting for connected', { + method, + state: this.options.getState() + }) + } + return () => this.dispose(id) + } + + replayAfterAuthentication(): void { + for (const [id, stream] of this.streams) { + if (stream.cancelled) { + this.remove(id) + continue + } + if (stream.sent) { + continue + } + if (stream.method === 'browser.screencast') { + this.pendingBrowserRequestId = id + this.activeBrowserRequestId = null + } + this.resetTerminalRouting(id) + if (this.send(id, stream)) { + stream.sent = true + } else { + this.markForReplay() + break + } + } + } + + markForReplay(): void { + this.activeBrowserRequestId = null + this.pendingBrowserRequestId = null + for (const [id, stream] of this.streams) { + stream.sent = false + this.resetTerminalRouting(id) + } + } + + handleResponse(response: RpcResponse): boolean { + if (response.ok && response.streaming === true) { + this.handleStreamingResponse(response) + return true + } + const stream = this.streams.get(response.id) + if (response.ok) { + const result = (response as RpcSuccess).result as Record | null + if (stream && result?.type === 'end') { + if (!stream.cancelled) { + stream.listener(result) + } + this.remove(response.id) + return true + } + if (stream && result?.type === 'scrollback') { + stream.listener(result) + return true + } + } + if (!stream) { + return false + } + this.emitError( + stream, + response.ok ? 'Streaming request ended before it was ready.' : response.error.message, + response.ok ? undefined : response.error + ) + this.remove(response.id) + return true + } + + handleBinary(bytes: Uint8Array): void { + const browserFrame = decodeBrowserScreencastFrame(bytes) + if (browserFrame) { + this.handleBrowserFrame(browserFrame) + return + } + this.terminalRouter.handle(bytes) + } + + updateTerminalViewport(terminal: string, viewport: { cols: number; rows: number }): void { + updateTerminalSubscriptionViewport(this.streams.values(), terminal, viewport) + } + + size(): number { + return this.streams.size + } + + private handleStreamingResponse(response: RpcSuccess): void { + const stream = this.streams.get(response.id) + if (!stream) { + return + } + const result = response.result + if (isStreamingSubscriptionReadyResult(result)) { + stream.subscriptionId = result.subscriptionId + if (stream.cancelled) { + this.sendServerSubscriptionUnsubscribe(stream) + this.remove(response.id) + return + } + if (stream.method === 'browser.screencast') { + if ( + this.pendingBrowserRequestId !== response.id && + this.activeBrowserRequestId !== response.id + ) { + this.sendBrowserUnsubscribe(result.subscriptionId) + this.remove(response.id) + return + } + this.pendingBrowserRequestId = null + this.activeBrowserRequestId = response.id + } + } + if (isTerminalSubscribedResult(result)) { + this.terminalRouter.register(response.id, result.streamId, stream.listener) + } + if (!stream.cancelled) { + stream.listener(result) + } + } + + private dispose(id: string): void { + const stream = this.streams.get(id) + if (stream?.method === 'browser.screencast') { + stream.cancelled = true + this.clearBrowserRequest(id) + this.disposeServerSubscription(id, stream) + return + } + if (stream?.method === 'runtime.clientEvents.subscribe') { + this.disposeServerSubscription(id, stream) + return + } + if (stream?.method === 'terminal.subscribe') { + const params = buildTerminalUnsubscribeParams(stream.params) + if (params) { + this.sendRpc('terminal.unsubscribe', params) + } + } else { + const unsubscribe = buildStreamUnsubscribe(stream?.method, stream?.params) + if (unsubscribe) { + this.sendRpc(unsubscribe.method, unsubscribe.params) + } + } + this.remove(id) + } + + private replaceBrowserStream(id: string): void { + if (this.activeBrowserRequestId && this.activeBrowserRequestId !== id) { + this.dispose(this.activeBrowserRequestId) + } + if (this.pendingBrowserRequestId && this.pendingBrowserRequestId !== id) { + this.dispose(this.pendingBrowserRequestId) + } + this.pendingBrowserRequestId = id + this.activeBrowserRequestId = null + } + + private disposeServerSubscription(id: string, stream: StreamRequest): void { + stream.cancelled = true + if (stream.subscriptionId) { + this.sendServerSubscriptionUnsubscribe(stream) + this.remove(id) + } else if (!stream.sent) { + this.remove(id) + } + } + + private sendServerSubscriptionUnsubscribe(stream: StreamRequest): void { + if (!stream.subscriptionId) { + return + } + const unsubscribe = buildReadyStreamUnsubscribe(stream.method, stream.subscriptionId) + if (unsubscribe) { + this.sendRpc(unsubscribe.method, unsubscribe.params) + } + } + + private sendBrowserUnsubscribe(subscriptionId: string): void { + this.sendRpc('browser.screencast.unsubscribe', { subscriptionId }) + } + + private sendRpc(method: string, params: unknown): boolean { + return this.options.sendEncrypted({ + id: this.options.nextId(), + deviceToken: this.options.deviceToken, + method, + params + }) + } + + private send(id: string, stream: StreamRequest): boolean { + return this.options.sendEncrypted({ + id, + deviceToken: this.options.deviceToken, + method: stream.method, + params: stream.params + }) + } + + private remove(id: string): void { + const stream = this.streams.get(id) + this.streams.delete(id) + this.clearBrowserRequest(id) + this.resetTerminalRouting(id) + if (stream?.method === 'browser.screencast') { + stream.cancelled = true + } + } + + private clearBrowserRequest(id: string): void { + if (this.activeBrowserRequestId === id) { + this.activeBrowserRequestId = null + } + if (this.pendingBrowserRequestId === id) { + this.pendingBrowserRequestId = null + } + } + + private resetTerminalRouting(id: string): void { + this.terminalRouter.reset(id) + } + + private handleBrowserFrame(frame: BrowserScreencastFrame): void { + if (!this.activeBrowserRequestId) { + return + } + const stream = this.streams.get(this.activeBrowserRequestId) + if (!stream || stream.cancelled || stream.method !== 'browser.screencast') { + return + } + stream.onBinaryFrame?.(frame) + } + + private emitError(stream: StreamRequest, message: string, error?: unknown): void { + if (!stream.cancelled) { + stream.listener({ type: 'error', message, error }) + } + } +} diff --git a/mobile/src/transport/rpc-client-terminal-stream-router.ts b/mobile/src/transport/rpc-client-terminal-stream-router.ts new file mode 100644 index 00000000000..0ac4d26c3fc --- /dev/null +++ b/mobile/src/transport/rpc-client-terminal-stream-router.ts @@ -0,0 +1,38 @@ +import { + handleTerminalBinaryFrame, + type TerminalSnapshotState +} from './rpc-client-terminal-binary-frame' + +type TerminalListener = (result: unknown) => void + +export class RpcClientTerminalStreamRouter { + private readonly listeners = new Map() + private readonly idsByRequest = new Map>() + private readonly snapshots = new Map() + + register(requestId: string, streamId: number, listener: TerminalListener): void { + const ids = this.idsByRequest.get(requestId) ?? new Set() + this.idsByRequest.set(requestId, ids) + ids.add(streamId) + this.listeners.set(streamId, listener) + } + + reset(requestId: string): void { + const streamIds = this.idsByRequest.get(requestId) + if (!streamIds) { + return + } + for (const streamId of streamIds) { + this.listeners.delete(streamId) + this.snapshots.delete(streamId) + } + this.idsByRequest.delete(requestId) + } + + handle(bytes: Uint8Array): void { + handleTerminalBinaryFrame(bytes, { + terminalSnapshots: this.snapshots, + getListener: (streamId) => this.listeners.get(streamId) + }) + } +} diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index b3c44504ae6..10f586d2780 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -1,76 +1,17 @@ +import type { BrowserScreencastFrame } from './browser-screencast-protocol' +import { DirectRpcClient } from './direct-rpc-client' import type { - RpcResponse, - RpcSuccess, - ConnectionState, - ConnectionLogLevel, ConnectionLogSink, - ForegroundNudgeReason + ConnectionState, + ForegroundNudgeReason, + RpcResponse } from './types' -import { - generateKeyPair, - deriveSharedKey, - publicKeyFromBase64, - publicKeyToBase64, - encrypt, - decrypt, - decryptBytes -} from './e2ee' -import { - handleTerminalBinaryFrame, - type TerminalSnapshotState -} from './rpc-client-terminal-binary-frame' -import { - decodeBrowserScreencastFrame, - type BrowserScreencastFrame -} from './browser-screencast-protocol' -import { - buildStreamUnsubscribe, - buildTerminalUnsubscribeParams, - updateTerminalSubscriptionViewport as updateCachedTerminalSubscriptionViewport -} from './rpc-client-terminal-subscription' -import { describeSocketEvent, redactSocketEndpoint } from './socket-event-debug' -import { - isStaleRpcSocketEvent, - logRpcSocketClose, - RpcSynthesizedCloseIndex -} from './rpc-socket-close-evidence' -import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' -import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget' -import { isRpcResponse } from './rpc-response-shape' -import { - isStreamingSubscriptionReadyResult, - isTerminalSubscribedResult -} from './rpc-subscription-result-shapes' -import { - RpcSessionLivenessWatchdog, - type RpcSessionIdentity -} from './rpc-session-liveness-watchdog' -import { isStaleForegroundDial } from './rpc-stale-dial' -import { websocketPayloadToUint8 } from './websocket-payload-bytes' - -type PendingRequest = { - resolve: (response: RpcResponse) => void - reject: (error: Error) => void -} - -type ConnectWaiter = { - resolve: () => void - reject: (error: Error) => void - timeout: ReturnType | null -} export type SendRequestOptions = { timeoutMs?: number - /** Spend `timeoutMs` across connect-wait AND the request instead of giving each - * phase its own. Interactive chat writes need it: they run as sequential loops - * under one shared budget, so a per-phase clock lets the composer sit `sending` - * for a multiple of the stated ceiling. Off by default — the long-running - * callers (worktree create, dictation finish, credit reset) sized their budgets - * against the post-connect clock, and squeezing them to the floor after a slow - * reconnect would fail sends that used to land. */ + /** Include the connect wait in the caller's timeout budget. */ budgetSpansConnect?: boolean - /** Reject immediately when not connected — a send parked in the connect wait - * replays stale terminal bytes into the PTY after reconnect. */ + /** Reject instead of replaying the request after reconnect. */ failWhenDisconnected?: boolean } @@ -80,16 +21,6 @@ type SubscribeOptions = { type StreamingListener = (result: unknown) => void -type StreamRequest = { - method: string - params: unknown - listener: StreamingListener - onBinaryFrame?: (frame: BrowserScreencastFrame) => void - subscriptionId?: string - cancelled?: boolean - sent?: boolean -} - export type RpcClient = { sendRequest: ( method: string, @@ -107,44 +38,16 @@ export type RpcClient = { viewport: { cols: number; rows: number } ) => void getState: () => ConnectionState - // 0 means never failed (reset once the handshake authenticates); the UI escalates "Reconnecting…" to "Can't connect" past a threshold. getReconnectAttempt: () => number - // Last 'connected' timestamp (ms epoch); null = never connected. Lets the UI tell "never reachable" from "transient blip". getLastConnectedAt: () => number | null - // Wall-clock stamp of the last inbound frame on the current session, or null when the - // transport can't vouch for one. Optional so older/foreign RpcClient shapes stay valid; - // callers must treat absent/null as "unknown" and fall back to a safe bound. getLastInboundAt?: () => number | null onStateChange: (listener: (state: ConnectionState) => void) => () => void - // Why: app-resume hook — iOS/Android can kill the TCP path while backgrounded; call on AppState 'active' to recover. - // The reason routes relay handling (probe vs replace); the direct socket probes regardless. notifyForeground: (reason?: ForegroundNudgeReason) => void close: () => void } -// Why: tiered backoff — fast early entries recover blips; the slow tail avoids burning a SYN every 4s on an unreachable desktop. -const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000] -// Why: ≈6 min of failure before the re-pair banner; MUST stay aligned with connection-health.ts UNREACHABLE_ATTEMPTS. -const GIVE_UP_AFTER_ATTEMPTS = 12 -// Why: never park past the cap — a wedged VPN fires no AppState/network nudge to revive it, so trickle-dial every 90s to self-heal. -const TRICKLE_RECONNECT_DELAY_MS = 90_000 -// Why: one unauthorized isn't proof the pairing is dead (issue #5200) — retry the handshake this many times before latching auth-failed. -const AUTH_RETRY_BUDGET = 3 -// Why: a desktop that regenerated its E2EE keypair sends an e2ee_error we can't decrypt — the 4001 close code is the only surviving auth-failure signal. -const UNAUTHORIZED_CLOSE_CODE = 4001 -const REQUEST_TIMEOUT_MS = 30_000 -// Why: an explicit `timeoutMs` is one budget for the whole call. If the connect wait -// ate nearly all of it, still give the written frame a moment to be answered rather -// than arming a 1ms timer. -const CONNECT_TIMEOUT_MS = 12_000 -const HANDSHAKE_TIMEOUT_MS = 5_000 -// Why: RN may not expose WebSocket.readyState constants, but the CONNECTING protocol value (0) is stable across runtimes. -const WEBSOCKET_CONNECTING_STATE = 0 -const LIVENESS_REQUEST_ID_PREFIX = 'mobile-liveness-' - export type ConnectOptions = { onStateChange?: (state: ConnectionState) => void - // Fires for every lifecycle event so the UI can show where 'Connecting…' is stuck (e.g. broken Tailscale route). onLog?: ConnectionLogSink } @@ -154,1071 +57,9 @@ export function connect( serverPublicKeyB64: string, optionsOrLegacy?: ConnectOptions | ((state: ConnectionState) => void) ): RpcClient { - // Why: keep backward-compat with callers that pass a bare onStateChange fn. const options: ConnectOptions = typeof optionsOrLegacy === 'function' ? { onStateChange: optionsOrLegacy } : (optionsOrLegacy ?? {}) - const onStateChange = options.onStateChange - const onLog = options.onLog - let logCounter = 0 - function emitLog(level: ConnectionLogLevel, message: string, detail?: string) { - if (!onLog) { - return - } - onLog({ - id: `log-${++logCounter}-${Date.now()}`, - ts: Date.now(), - level, - message, - detail - }) - } - let ws: WebSocket | null = null - const synthesizedCloses = new RpcSynthesizedCloseIndex() - let state: ConnectionState = 'disconnected' - let requestCounter = 0 - let reconnectAttempt = 0 - let reconnectTimer: ReturnType | null = null - let connectTimer: ReturnType | null = null - let handshakeTimer: ReturnType | null = null - let intentionallyClosed = false - // Consecutive auth rejections; tolerate up to AUTH_RETRY_BUDGET (issue #5200) before latching to avoid a needless re-pair. - let authRejectionCount = 0 - let authenticationGeneration = 0 - let lastConnectedAt: number | null = null - // Why: cheap diagnostics for RN/OkHttp process-state poisoning (retry cadence, inbound traffic, close timing). - let lastInboundAt: number | null = null - let livenessIdentity: (RpcSessionIdentity & { socket: WebSocket }) | null = null - let lastWsClosedAt: number | null = null - let wsConstructionCounter = 0 - let dialStartedAt = 0 - - // Why: fresh ephemeral keypair per connection provides forward secrecy. - let sharedKey: Uint8Array | null = null - const serverPublicKey = publicKeyFromBase64(serverPublicKeyB64) - - const pending = new Map() - const streamListeners = new Map() - const terminalStreamListeners = new Map() - const terminalStreamIdsByRequest = new Map>() - const terminalSnapshots = new Map() - let activeBrowserScreencastRequestId: string | null = null - let pendingBrowserScreencastRequestId: string | null = null - const stateListeners = new Set<(state: ConnectionState) => void>() - const connectWaiters: ConnectWaiter[] = [] - - if (onStateChange) { - stateListeners.add(onStateChange) - } - - // Diagnostic: dwell time in the current state, for spotting "stuck in connecting/reconnecting". - let stateEnteredAt = Date.now() - - function rejectConnectWaiters(reason: string) { - const error = new Error(reason) - for (const waiter of connectWaiters.splice(0)) { - if (waiter.timeout) { - clearTimeout(waiter.timeout) - } - waiter.reject(error) - } - } - - function setState(next: ConnectionState) { - if (state === next) { - return - } - const prev = state - const dwelt = Date.now() - stateEnteredAt - state = next - stateEnteredAt = Date.now() - console.log('[net] state', { - from: prev, - to: next, - dweltMs: dwelt, - attempt: reconnectAttempt, - endpoint: redactSocketEndpoint(endpoint) - }) - if (next === 'connected') { - lastConnectedAt = Date.now() - authenticationGeneration++ - // Why: only a completed E2EE handshake proves the path is healthy (issue #10119). - reconnectAttempt = 0 - // Why: a clean handshake proves the token is valid — reset the auth retry budget. - authRejectionCount = 0 - for (const waiter of connectWaiters.splice(0)) { - if (waiter.timeout) { - clearTimeout(waiter.timeout) - } - waiter.resolve() - } - } else if (next === 'disconnected' || next === 'auth-failed') { - const reason = - next === 'auth-failed' ? 'Unauthorized — pairing may be revoked' : 'Connection closed' - rejectConnectWaiters(reason) - } - for (const listener of stateListeners) { - listener(next) - } - } - - function waitForConnected(timeoutMs?: number): Promise { - if (state === 'connected') { - return Promise.resolve() - } - if (intentionallyClosed) { - return Promise.reject(new Error('Client closed')) - } - if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) { - // Why: past the cap the loop only trickles every 90s — fail fast instead of hanging on a long-unreachable host. - return Promise.reject(new Error('Connection retry limit reached')) - } - return new Promise((resolve, reject) => { - const waiter: ConnectWaiter = { resolve, reject, timeout: null } - if (timeoutMs !== undefined) { - // Why: per-request timeouts must cover offline/reconnect waiting, not just the RPC after connect. - waiter.timeout = setTimeout( - () => { - const index = connectWaiters.indexOf(waiter) - if (index !== -1) { - connectWaiters.splice(index, 1) - } - reject(new Error('Timed out while connecting to the remote Orca runtime.')) - }, - Math.max(0, timeoutMs) - ) - } - connectWaiters.push(waiter) - }) - } - - function nextId(): string { - return `rpc-${++requestCounter}-${Date.now()}` - } - - function openConnection() { - if (intentionallyClosed) { - return - } - - const now = Date.now() - wsConstructionCounter++ - console.log('[net] openConnection', { - attempt: reconnectAttempt, - endpoint: redactSocketEndpoint(endpoint), - // Why: diagnostic for RN/OkHttp pool corruption — high wsCount + repeated 1006 closes means process-state stuck. - wsCount: wsConstructionCounter, - msSinceLastConnected: lastConnectedAt != null ? now - lastConnectedAt : null, - msSinceLastClose: lastWsClosedAt != null ? now - lastWsClosedAt : null, - msSinceLastInbound: lastInboundAt != null ? now - lastInboundAt : null - }) - setState('connecting') - dialStartedAt = now - sharedKey = null - - emitLog( - 'info', - reconnectAttempt > 0 ? `Reconnecting (attempt ${reconnectAttempt + 1})` : 'Opening WebSocket', - redactSocketEndpoint(endpoint) - ) - - ws = new WebSocket(endpoint) - const openingWs = ws - let openingWsAuthenticated = false - let openingWsLastInboundAt: number | null = null - let openingLivenessIdentity: (RpcSessionIdentity & { socket: WebSocket }) | null = null - - // Why: RN can leave opens pending forever on flaky handoffs — force reconnect if onopen never arrives. - connectTimer = setTimeout(() => { - connectTimer = null - if (ws === openingWs && openingWs.readyState === WEBSOCKET_CONNECTING_STATE) { - console.log('[net] connect-timeout fired (onopen never arrived)', { - attempt: reconnectAttempt, - timeoutMs: CONNECT_TIMEOUT_MS - }) - emitLog( - 'error', - 'WebSocket connect timeout', - `No TCP/WS handshake within ${CONNECT_TIMEOUT_MS / 1000}s — endpoint unreachable?` - ) - closeAndSynthesize(openingWs) - } - }, CONNECT_TIMEOUT_MS) - - ws.onopen = () => { - if (isStaleRpcSocketEvent(ws, openingWs, 'open', state, reconnectAttempt)) { - return - } - console.log('[net] ws.onopen', { attempt: reconnectAttempt }) - clearConnectTimer() - // Why: no reconnectAttempt reset here — an open socket isn't a healthy session - // until e2ee_authenticated. Resetting pre-handshake pinned the counter at 0↔1, - // so a handshake-stall loop never escalated past "Connecting…" (issue #10119). - setState('handshaking') - emitLog('success', 'WebSocket open', 'Starting E2EE handshake') - - // Why: fresh ephemeral keypair per connection provides forward secrecy. - const ephemeral = generateKeyPair() - const hello = JSON.stringify({ - type: 'e2ee_hello', - publicKeyB64: publicKeyToBase64(ephemeral.publicKey) - }) - try { - openingWs.send(hello) - } catch { - closeAndSynthesize(openingWs) - return - } - emitLog('info', 'Sent e2ee_hello', 'Awaiting server e2ee_ready') - - sharedKey = deriveSharedKey(ephemeral.secretKey, serverPublicKey) - - handshakeTimer = setTimeout(() => { - handshakeTimer = null - if (ws !== openingWs || state !== 'handshaking') { - return - } - console.log('[net] handshake-timeout fired (e2ee_authenticated never arrived)', { - timeoutMs: HANDSHAKE_TIMEOUT_MS - }) - emitLog( - 'error', - 'Handshake timeout', - `No e2ee_ready/e2ee_authenticated within ${HANDSHAKE_TIMEOUT_MS / 1000}s` - ) - closeAndSynthesize(openingWs) - }, HANDSHAKE_TIMEOUT_MS) - } - - ws.onmessage = (event) => { - if (isStaleRpcSocketEvent(ws, openingWs, 'message', state, reconnectAttempt)) { - return - } - void handleSocketMessage(event.data) - } - - async function handleSocketMessage(rawData: unknown) { - const receivedAt = Date.now() - lastInboundAt = receivedAt - openingWsLastInboundAt = receivedAt - const raw = typeof rawData === 'string' ? rawData : null - - // Why: e2ee_ready is plaintext (precedes encrypted auth); e2ee_authenticated/e2ee_error are encrypted. - if (state === 'handshaking') { - if (raw === null) { - return - } - try { - const msg = JSON.parse(raw) - if (msg.type === 'e2ee_ready') { - emitLog('success', 'Received e2ee_ready', 'Sending device token') - sendEncrypted({ type: 'e2ee_auth', deviceToken }) - return - } - } catch { - // Not plaintext JSON — fall through and try encrypted handshake messages. - } - - if (!sharedKey || sharedKey.length !== 32) { - return - } - - const plaintext = decrypt(raw, sharedKey) - if (plaintext === null) { - return - } - - try { - const msg = JSON.parse(plaintext) - if (msg.type === 'e2ee_authenticated') { - clearHandshakeTimer() - console.log('[net] e2ee_authenticated — connected', { - streamCount: streamListeners.size - }) - openingWsAuthenticated = true - openingLivenessIdentity = { socket: openingWs } - livenessIdentity = openingLivenessIdentity - livenessWatchdog.start(openingLivenessIdentity) - setState('connected') - emitLog('success', 'Authenticated', 'Channel ready for RPC') - for (const [id, stream] of streamListeners) { - if (stream.cancelled) { - removeStreamListener(id) - continue - } - // Why: a UI listener notified synchronously by setState('connected') may already have sent this stream — skip it. - if (stream.sent) { - continue - } - if (stream.method === 'browser.screencast') { - pendingBrowserScreencastRequestId = id - activeBrowserScreencastRequestId = null - } - resetTerminalStreamRoutingForRequest(id) - if ( - sendEncrypted({ id, deviceToken, method: stream.method, params: stream.params }) - ) { - stream.sent = true - } else { - // The failed write already starts recovery; retain every stream for replay. - markStreamsForReplay() - break - } - } - } else if (msg.type === 'e2ee_error' || (!msg.ok && msg.error?.code === 'unauthorized')) { - console.log('[net] e2ee auth FAILED', { msgType: msg.type, error: msg.error }) - clearHandshakeTimer() - handleAuthRejection('Unauthorized — pairing may be revoked') - } - } catch { - // Not JSON — ignore during handshake. - } - return - } - - // Why: sharedKey can be null after destroy() or a reconnect race — don't decrypt with an invalid key. - if (!sharedKey || sharedKey.length !== 32) { - return - } - - if (raw === null) { - const bytes = await websocketPayloadToUint8(rawData) - if (ws !== openingWs) { - return - } - if (!bytes) { - return - } - const plaintextBytes = decryptBytes(bytes, sharedKey) - if (!plaintextBytes) { - return - } - if (openingLivenessIdentity) { - livenessWatchdog.noteAuthenticatedInbound(openingLivenessIdentity) - } - handleBinaryFrame(plaintextBytes) - return - } - - const plaintext = decrypt(raw, sharedKey) - if (plaintext === null) { - return - } - if (openingLivenessIdentity) { - livenessWatchdog.noteAuthenticatedInbound(openingLivenessIdentity) - } - - let response: unknown - try { - response = JSON.parse(plaintext) - } catch { - return - } - if (!isRpcResponse(response)) { - return - } - if (response.id.startsWith(LIVENESS_REQUEST_ID_PREFIX)) { - return - } - // Why: a mid-session unauthorized may be transient (issue #5200) — handleAuthRejection retries before latching auth-failed. - if (!response.ok && response.error.code === 'unauthorized') { - handleAuthRejection('Unauthorized — pairing may be revoked') - return - } - - const isStreaming = response.ok && (response as RpcSuccess).streaming === true - - if (isStreaming) { - const stream = streamListeners.get(response.id) - if (stream && response.ok) { - const result = (response as RpcSuccess).result - if (isStreamingSubscriptionReadyResult(result)) { - stream.subscriptionId = result.subscriptionId - if (stream.cancelled) { - sendServerSubscriptionUnsubscribe(stream) - removeStreamListener(response.id) - return - } - if (stream.method === 'browser.screencast') { - if ( - pendingBrowserScreencastRequestId !== response.id && - activeBrowserScreencastRequestId !== response.id - ) { - sendBrowserScreencastUnsubscribe(result.subscriptionId) - removeStreamListener(response.id) - return - } - pendingBrowserScreencastRequestId = null - activeBrowserScreencastRequestId = response.id - } - } - if (isTerminalSubscribedResult(result)) { - let ids = terminalStreamIdsByRequest.get(response.id) - if (!ids) { - ids = new Set() - terminalStreamIdsByRequest.set(response.id, ids) - } - ids.add(result.streamId) - terminalStreamListeners.set(result.streamId, stream.listener) - } - if (!stream.cancelled) { - stream.listener(result) - } - } - return - } - - if (response.ok) { - const result = (response as RpcSuccess).result as Record | null - if (result && result.type === 'end') { - const stream = streamListeners.get(response.id) - if (stream) { - if (!stream.cancelled) { - stream.listener(result) - } - removeStreamListener(response.id) - return - } - } - if (result && result.type === 'scrollback') { - const stream = streamListeners.get(response.id) - if (stream) { - stream.listener(result) - return - } - } - } - - const stream = streamListeners.get(response.id) - if (stream) { - if (!response.ok) { - emitStreamError(stream, response.error.message, response.error) - } else { - emitStreamError(stream, 'Streaming request ended before it was ready.') - } - removeStreamListener(response.id) - return - } - - const req = pending.get(response.id) - if (req) { - pending.delete(response.id) - req.resolve(response) - } - } - - ws.onclose = (event) => { - const closeCode = logRpcSocketClose({ - event, - state, - attempt: reconnectAttempt, - intentionallyClosed, - endpoint: redactSocketEndpoint(endpoint), - constructedAt: now, - authenticated: openingWsAuthenticated, - lastInboundAt: openingWsLastInboundAt - }) - handleSocketClosed(openingWs, { closeCode }) - } - - ws.onerror = (event) => { - if (isStaleRpcSocketEvent(ws, openingWs, 'error', state, reconnectAttempt)) { - return - } - // Why: RN surfaces the original network error here — onclose follows but its close code alone hides the cause. - const e = event as { message?: string } | undefined - const errEvent = describeSocketEvent(event) - console.log('[net] ws.onerror', { - message: e?.message, - state, - attempt: reconnectAttempt, - eventKeys: errEvent.keys, - eventStr: errEvent.json - }) - } - } - - function handleSocketClosed( - closedWs: WebSocket, - opts: { timedOut?: boolean; closeCode?: number } = {} - ) { - if (ws !== closedWs) { - if ( - synthesizedCloses.takeUnauthorized( - closedWs, - opts.closeCode, - authenticationGeneration, - UNAUTHORIZED_CLOSE_CODE - ) - ) { - handleAuthRejection('Unauthorized — pairing may be revoked', true) - return - } - console.log('[net] handleSocketClosed STALE — ignoring (ws already swapped)', { - state, - attempt: reconnectAttempt - }) - return - } - lastWsClosedAt = Date.now() - clearConnectTimer() - ws = null - sharedKey = null - activeBrowserScreencastRequestId = null - pendingBrowserScreencastRequestId = null - markStreamsForReplay() - clearHandshakeTimer() - if (livenessIdentity?.socket === closedWs) { - livenessWatchdog.stop(livenessIdentity) - livenessIdentity = null - } - if (intentionallyClosed) { - console.log('[net] handleSocketClosed — intentional close') - setState('disconnected') - rejectAllPending('Connection closed', { deliveryUnknown: true }) - return - } - // Why: a bare 4001 close means the desktop rejected our pairing but the encrypted - // e2ee_error never arrived (or was undecryptable) — count it against the auth - // retry budget instead of looping the generic reconnect forever. - if (opts.closeCode === UNAUTHORIZED_CLOSE_CODE) { - console.log('[net] handleSocketClosed — unauthorized close code', { - attempt: reconnectAttempt - }) - handleAuthRejection('Unauthorized — pairing may be revoked') - return - } - console.log('[net] handleSocketClosed → reconnect', { - timedOut: !!opts.timedOut, - pendingCount: pending.size, - streamCount: streamListeners.size, - attempt: reconnectAttempt - }) - emitLog('warn', 'WebSocket closed', 'Will attempt to reconnect') - rejectAllPending('Connection interrupted', { deliveryUnknown: true }) - setState('reconnecting') - scheduleReconnect() - } - - // Why: an auth rejection may be transient (issue #5200) — retry up to AUTH_RETRY_BUDGET times before latching auth-failed. - function handleAuthRejection(reason: string, preserveRecovery = false): void { - if (livenessIdentity) { - livenessWatchdog.stop(livenessIdentity) - livenessIdentity = null - } - authRejectionCount++ - if (authRejectionCount < AUTH_RETRY_BUDGET) { - console.log('[net] auth rejected — retrying handshake', { - attempt: authRejectionCount, - budget: AUTH_RETRY_BUDGET, - endpoint: redactSocketEndpoint(endpoint) - }) - emitLog( - 'warn', - 'Authentication rejected', - `Retrying (${authRejectionCount}/${AUTH_RETRY_BUDGET})` - ) - if (preserveRecovery) { - return - } - activeBrowserScreencastRequestId = null - pendingBrowserScreencastRequestId = null - // Why: close without setting intentionallyClosed so handleSocketClosed routes to reconnect and retries the handshake. - const closing = ws - ws = null - sharedKey = null - // Why: close cleanup stale-bails here, so mark active streams for replay. - markStreamsForReplay() - rejectAllPending(reason) - if (closing) { - closing.close() - } - setState('reconnecting') - scheduleReconnect() - return - } - activeBrowserScreencastRequestId = null - pendingBrowserScreencastRequestId = null - console.log('[net] auth rejected — budget exhausted, latching auth-failed', { - attempt: authRejectionCount, - endpoint: redactSocketEndpoint(endpoint) - }) - intentionallyClosed = true - ws?.close() - ws = null - setState('auth-failed') - rejectAllPending(reason) - } - - function scheduleReconnect() { - // Why: past the cap, trickle (never park) — a parked loop only revives on a network transition a wedged VPN never produces. - const pastGiveUpCap = reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS - let delay: number - if (pastGiveUpCap) { - // Why: hold the counter at the cap — connection-health's "Can't reach desktop" verdict keys off attempts >= 12. - delay = TRICKLE_RECONNECT_DELAY_MS - rejectConnectWaiters('Connection retry limit reached') - } else { - delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]! - reconnectAttempt++ - } - console.log('[net] scheduleReconnect', { - delayMs: delay, - attempt: reconnectAttempt, - trickle: pastGiveUpCap - }) - emitLog( - 'info', - `Reconnect scheduled in ${delay}ms`, - pastGiveUpCap ? `Attempt ${reconnectAttempt} (slow retry)` : `Attempt ${reconnectAttempt}` - ) - reconnectTimer = setTimeout(() => { - reconnectTimer = null - openConnection() - }, delay) - } - - function clearConnectTimer() { - if (connectTimer) { - clearTimeout(connectTimer) - connectTimer = null - } - } - - function clearHandshakeTimer() { - if (handshakeTimer) { - clearTimeout(handshakeTimer) - handshakeTimer = null - } - } - - // Why: a revival signal dials at once rather than waiting out the armed backoff. - // Only the caller knows whether the attempt that led here should be forgiven. - function redialNow(resetAttempts: boolean) { - if (reconnectTimer) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } - if (resetAttempts) { - reconnectAttempt = 0 - } - openConnection() - } - - // Why: React Native can omit onclose for a wedged iOS transport, so every forced - // close has to synthesize the close event it may never deliver. - function closeAndSynthesize(socket: WebSocket) { - socket.close() - if (ws === socket) { - synthesizedCloses.remember(socket, authenticationGeneration) - handleSocketClosed(socket, { timedOut: true }) - } - } - - function rejectAllPending(reason: string, options?: { deliveryUnknown?: boolean }) { - // Why: pending entries only exist after a successful socket write, so a close - // here means the host may have processed them — mark the ambiguity for callers. - const error = options?.deliveryUnknown - ? markRpcDeliveryUnknown(new Error(reason)) - : new Error(reason) - for (const [id, req] of pending) { - pending.delete(id) - queueMicrotask(() => req.reject(error)) - } - } - - function removeStreamListener(id: string): void { - const stream = streamListeners.get(id) - streamListeners.delete(id) - if (activeBrowserScreencastRequestId === id) { - activeBrowserScreencastRequestId = null - } - if (pendingBrowserScreencastRequestId === id) { - pendingBrowserScreencastRequestId = null - } - const terminalStreamIds = terminalStreamIdsByRequest.get(id) - if (terminalStreamIds) { - for (const streamId of terminalStreamIds) { - terminalStreamListeners.delete(streamId) - terminalSnapshots.delete(streamId) - } - terminalStreamIdsByRequest.delete(id) - } - if (stream?.method === 'browser.screencast') { - stream.cancelled = true - } - } - - function markStreamsForReplay(): void { - for (const [id, stream] of streamListeners) { - stream.sent = false - resetTerminalStreamRoutingForRequest(id) - } - } - - function resetTerminalStreamRoutingForRequest(id: string): void { - const terminalStreamIds = terminalStreamIdsByRequest.get(id) - if (!terminalStreamIds) { - return - } - for (const streamId of terminalStreamIds) { - terminalStreamListeners.delete(streamId) - terminalSnapshots.delete(streamId) - } - terminalStreamIdsByRequest.delete(id) - } - - function emitStreamError(stream: StreamRequest, message: string, error?: unknown): void { - if (stream.cancelled) { - return - } - stream.listener({ type: 'error', message, error }) - } - - function disposeBrowserScreencastStream(id: string): void { - const stream = streamListeners.get(id) - if (!stream || stream.method !== 'browser.screencast') { - return - } - stream.cancelled = true - if (activeBrowserScreencastRequestId === id) { - activeBrowserScreencastRequestId = null - } - if (pendingBrowserScreencastRequestId === id) { - pendingBrowserScreencastRequestId = null - } - disposeServerSubscriptionStream(id, stream) - } - - function disposeRuntimeClientEventsStream(id: string): void { - const stream = streamListeners.get(id) - if (!stream || stream.method !== 'runtime.clientEvents.subscribe') { - return - } - disposeServerSubscriptionStream(id, stream) - } - - function disposeServerSubscriptionStream(id: string, stream: StreamRequest): void { - stream.cancelled = true - if (stream.subscriptionId) { - sendServerSubscriptionUnsubscribe(stream) - removeStreamListener(id) - return - } - // Why: a sent stream may still reply `ready`; keep the tombstone to unsubscribe it (queued streams never reached the desktop). - if (!stream.sent) { - removeStreamListener(id) - } - } - - function handleBinaryFrame(bytes: Uint8Array): void { - const browserFrame = decodeBrowserScreencastFrame(bytes) - if (browserFrame) { - handleBrowserBinaryFrame(browserFrame) - return - } - handleTerminalBinaryFrame(bytes, { - terminalSnapshots, - getListener: (streamId) => terminalStreamListeners.get(streamId) - }) - } - - function handleBrowserBinaryFrame(frame: BrowserScreencastFrame) { - if (!activeBrowserScreencastRequestId) { - return - } - const stream = streamListeners.get(activeBrowserScreencastRequestId) - if (!stream || stream.cancelled || stream.method !== 'browser.screencast') { - return - } - stream.onBinaryFrame?.(frame) - } - - function sendEncrypted(request: unknown): boolean { - if (ws && ws.readyState === WebSocket.OPEN && sharedKey) { - const sendingWs = ws - try { - sendingWs.send(encrypt(JSON.stringify(request), sharedKey)) - return true - } catch { - if (ws === sendingWs) { - closeAndSynthesize(sendingWs) - } - return false - } - } - console.log('[net] sendEncrypted FAILED — channel not ready', { - hasWs: !!ws, - readyState: ws?.readyState, - hasKey: !!sharedKey, - state - }) - // Why: RN can drop onclose, leaving state 'connected' over a dead socket; force reconnect or every send silently fails forever. - if (state === 'connected' && ws && ws.readyState !== WebSocket.OPEN) { - console.log('[net] sendEncrypted detected ws desync — forcing reconnect', { - readyState: ws.readyState - }) - synthesizedCloses.remember(ws, authenticationGeneration) - handleSocketClosed(ws, { timedOut: false }) - } - return false - } - - function sendBrowserScreencastUnsubscribe(subscriptionId: string): void { - sendEncrypted({ - id: nextId(), - deviceToken, - method: 'browser.screencast.unsubscribe', - params: { subscriptionId } - }) - } - - function sendServerSubscriptionUnsubscribe(stream: StreamRequest): void { - if (!stream.subscriptionId) { - return - } - if (stream.method === 'browser.screencast') { - sendBrowserScreencastUnsubscribe(stream.subscriptionId) - return - } - if (stream.method === 'runtime.clientEvents.subscribe') { - sendEncrypted({ - id: nextId(), - deviceToken, - method: 'runtime.clientEvents.unsubscribe', - params: { subscriptionId: stream.subscriptionId } - }) - } - } - - const livenessWatchdog = new RpcSessionLivenessWatchdog({ - transport: 'direct', - sendProbe: (identity) => { - if (identity !== livenessIdentity || state !== 'connected') { - return false - } - return sendEncrypted({ - id: `${LIVENESS_REQUEST_ID_PREFIX}${nextId()}`, - deviceToken, - method: 'status.get' - }) - }, - terminate: (identity) => { - if (identity === livenessIdentity && livenessIdentity.socket === ws) { - closeAndSynthesize(livenessIdentity.socket) - } - } - }) - - openConnection() - - return { - async sendRequest( - method: string, - params?: unknown, - options?: SendRequestOptions - ): Promise { - const budget = openRpcRequestBudget(options) - const waitStart = budget.startedAt - const wasConnected = state === 'connected' - if (options?.failWhenDisconnected && !wasConnected) { - throw new Error(`Not connected: ${method}`) - } - await waitForConnected(options?.timeoutMs) - if (!wasConnected) { - console.log('[net] sendRequest waited for connect', { - method, - waitedMs: Date.now() - waitStart - }) - } - - return new Promise((resolve, reject) => { - const id = nextId() - const timeoutMs = resolvePostConnectRequestTimeout(budget, REQUEST_TIMEOUT_MS) - const timeout = setTimeout(() => { - pending.delete(id) - console.log('[net] sendRequest TIMEOUT', { - method, - timeoutMs, - state - }) - // Why: the frame was written 30s ago — the host may have processed it. - reject(markRpcDeliveryUnknown(new Error(`Request timed out: ${method}`))) - }, timeoutMs) - - pending.set(id, { - resolve: (response) => { - clearTimeout(timeout) - resolve(response) - }, - reject: (error) => { - clearTimeout(timeout) - reject(error) - } - }) - - if (!sendEncrypted({ id, deviceToken, method, params })) { - pending.delete(id) - clearTimeout(timeout) - reject(new Error('Connection interrupted')) - } - }) - }, - - subscribe( - method: string, - params: unknown, - onData: StreamingListener, - options?: SubscribeOptions - ): () => void { - const id = nextId() - const stream: StreamRequest = { - method, - params, - listener: onData, - onBinaryFrame: options?.onBinaryFrame - } - streamListeners.set(id, stream) - if (method === 'browser.screencast') { - if (activeBrowserScreencastRequestId && activeBrowserScreencastRequestId !== id) { - disposeBrowserScreencastStream(activeBrowserScreencastRequestId) - } - if (pendingBrowserScreencastRequestId && pendingBrowserScreencastRequestId !== id) { - disposeBrowserScreencastStream(pendingBrowserScreencastRequestId) - } - // Why: screencast frames carry no stream id, so route only after the new stream's ready to drop stale old-page pixels. - pendingBrowserScreencastRequestId = id - activeBrowserScreencastRequestId = null - } - - if (state === 'connected') { - if (sendEncrypted({ id, deviceToken, method, params })) { - stream.sent = true - } else { - emitStreamError(stream, 'Connection interrupted') - removeStreamListener(id) - } - } else { - // Registered now; the outbound subscribe is (re-)sent once the channel reaches 'connected'. - console.log('[net] subscribe queued — waiting for connected', { method, state }) - } - - return () => { - const stream = streamListeners.get(id) - if (stream?.method === 'browser.screencast') { - disposeBrowserScreencastStream(id) - return - } - if (stream?.method === 'runtime.clientEvents.subscribe') { - disposeRuntimeClientEventsStream(id) - return - } - if (stream?.method === 'terminal.subscribe') { - // Why: server keys cleanup by composite `${terminal}:${clientId}` so two phones don't evict each other. See docs/mobile-presence-lock.md. - const unsubscribeParams = buildTerminalUnsubscribeParams(stream.params) - if (unsubscribeParams) { - sendEncrypted({ - id: nextId(), - deviceToken, - method: 'terminal.unsubscribe', - params: unsubscribeParams - }) - } - } else { - const unsub = buildStreamUnsubscribe(stream?.method, stream?.params) - if (unsub) { - sendEncrypted({ id: nextId(), deviceToken, method: unsub.method, params: unsub.params }) - } - } - removeStreamListener(id) - } - }, - - updateTerminalSubscriptionViewport( - terminal: string, - viewport: { cols: number; rows: number } - ): void { - updateCachedTerminalSubscriptionViewport(streamListeners.values(), terminal, viewport) - }, - - getState(): ConnectionState { - return state - }, - - getReconnectAttempt(): number { - return reconnectAttempt - }, - - getLastConnectedAt(): number | null { - return lastConnectedAt - }, - - getLastInboundAt(): number | null { - return livenessWatchdog.getLastInboundAt() || null - }, - - onStateChange(listener: (state: ConnectionState) => void): () => void { - stateListeners.add(listener) - return () => stateListeners.delete(listener) - }, - - notifyForeground(): void { - if (intentionallyClosed) { - return - } - if (state === 'connected') { - // Why: resume probes now; three fair misses detect a half-open socket within 24s. - console.log('[net] foreground — probing live connection') - if (livenessIdentity) { - livenessWatchdog.probeNow(livenessIdentity) - } - return - } - const dialing = ws - const dialAgeMs = Date.now() - dialStartedAt - let abandoned = false - if (dialing && isStaleForegroundDial(state, dialAgeMs)) { - console.log('[net] foreground — abandoning stale dial', { state, dialAgeMs }) - closeAndSynthesize(dialing) - abandoned = true - } - if (state === 'reconnecting') { - // Why: foreground is a strong user signal — restart immediately instead of waiting out a 60s/90s backoff timer. - console.log('[net] foreground — restarting reconnect loop', { - attempt: reconnectAttempt, - hadTimer: !!reconnectTimer - }) - // Why: an abandoned dial keeps the failure it already represents. It never - // authenticated, so it is the same failure the connect timeout would have - // booked had we waited it out — we skip the wait, we don't pardon it. Zeroing - // there would let a resume (or a flapping network) reset the counter faster - // than it climbs, pinning the card at "Connecting…" through a real outage - // (issue #10119). A redial with no dial to abandon is a genuinely fresh start. - redialNow(!abandoned) - } - }, - - close() { - intentionallyClosed = true - if (reconnectTimer) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } - clearConnectTimer() - clearHandshakeTimer() - if (livenessIdentity) { - livenessWatchdog.stop(livenessIdentity) - livenessIdentity = null - } - if (ws) { - ws.close() - ws = null - } - sharedKey = null - setState('disconnected') - // Why: closing the client cannot retract request frames already written. - rejectAllPending('Client closed', { deliveryUnknown: true }) - } - } + return new DirectRpcClient(endpoint, deviceToken, serverPublicKeyB64, options) } diff --git a/mobile/src/worktree/agent-row-display.test.ts b/mobile/src/worktree/agent-row-display.test.ts index 80889753def..ee872e40f26 100644 --- a/mobile/src/worktree/agent-row-display.test.ts +++ b/mobile/src/worktree/agent-row-display.test.ts @@ -28,6 +28,9 @@ function row(overrides: Partial = {}): RuntimeWorktreeA describe('agentDotState', () => { it('maps known states through and unknown to idle', () => { expect(agentDotState(row({ state: 'working', updatedAt: 0 }), 0)).toBe('working') + expect( + agentDotState(row({ state: 'working', workingMode: 'monitoring', updatedAt: 0 }), 0) + ).toBe('monitoring') expect(agentDotState(row({ state: 'blocked', updatedAt: 0 }), 0)).toBe('blocked') expect(agentDotState(row({ state: 'waiting', updatedAt: 0 }), 0)).toBe('waiting') expect(agentDotState(row({ state: 'done', updatedAt: 0 }), 0)).toBe('done') @@ -65,6 +68,12 @@ describe('agentDisplayLabel', () => { expect(agentDisplayLabel(row({ state: 'working', prompt: '', updatedAt: 0 }), 0)).toBe( 'Working' ) + expect( + agentDisplayLabel( + row({ state: 'working', workingMode: 'monitoring', prompt: '', updatedAt: 0 }), + 0 + ) + ).toBe('Monitoring background tasks') }) it('falls back to the decayed state label when stale', () => { diff --git a/mobile/src/worktree/agent-row-display.ts b/mobile/src/worktree/agent-row-display.ts index 05391b88b89..c678b522a15 100644 --- a/mobile/src/worktree/agent-row-display.ts +++ b/mobile/src/worktree/agent-row-display.ts @@ -9,23 +9,34 @@ export const AGENT_STATUS_STALE_AFTER_MS = 30 * 60 * 1000 // Mirrors the desktop AgentStateDot vocabulary. The wire `state` is the agent // status state; 'blocked'/'waiting' read as attention states, 'done' as // complete, everything else idle. -export type AgentDotState = 'working' | 'blocked' | 'waiting' | 'done' | 'idle' | 'interrupted' +export type AgentDotState = + | 'working' + | 'monitoring' + | 'blocked' + | 'waiting' + | 'done' + | 'idle' + | 'interrupted' export function agentDotState( - row: Pick, + row: Pick, now: number ): AgentDotState { if (row.interrupted) { return 'interrupted' } switch (row.state) { - case 'working': case 'blocked': case 'waiting': // Why: an agent that exits without a final report would otherwise read as // active forever. Decay a stale active state to idle, matching desktop's // renderer-side staleness decay (worktree-agent-rows.ts). return now - row.updatedAt > AGENT_STATUS_STALE_AFTER_MS ? 'idle' : row.state + case 'working': + if (now - row.updatedAt > AGENT_STATUS_STALE_AFTER_MS) { + return 'idle' + } + return row.workingMode === 'monitoring' ? 'monitoring' : 'working' case 'done': return 'done' } @@ -37,6 +48,8 @@ export function agentStateLabel(state: AgentDotState): string { switch (state) { case 'working': return 'Working' + case 'monitoring': + return 'Monitoring background tasks' case 'blocked': return 'Blocked' case 'waiting': diff --git a/mobile/src/worktree/home-resume-card.test.ts b/mobile/src/worktree/home-resume-card.test.ts index 949ef157785..85c6cbab8ee 100644 --- a/mobile/src/worktree/home-resume-card.test.ts +++ b/mobile/src/worktree/home-resume-card.test.ts @@ -7,7 +7,10 @@ import { } from './home-resume-card' import type { HomeWorktreeSummary, HostWorktreeInfo } from './home-worktree-info' -const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8') +const resumeCardSource = readFileSync( + new URL('../home/MobileHomeResumeCard.tsx', import.meta.url), + 'utf8' +) function worktree(worktreeId: string): HomeWorktreeSummary { return { @@ -120,17 +123,8 @@ describe('home resume card', () => { }) it('renders the home Resume card inert until its host connects', () => { - const start = homeSource.indexOf('{/* ─── Resume card ─── */}') - const end = homeSource.indexOf('{/* ─── Quick actions ─── */}', start) - - // Assert the markers first: a renamed banner would otherwise slice garbage and report a - // missing prop instead of the real cause. - expect(start).toBeGreaterThanOrEqual(0) - expect(end).toBeGreaterThan(start) - - const resumeCard = homeSource.slice(start, end) - expect(resumeCard).toContain('disabled={!resumeCard.actionable}') - expect(resumeCard).toContain('!resumeCard.actionable && styles.cardDisabled') + expect(resumeCardSource).toContain('disabled={!props.card.actionable}') + expect(resumeCardSource).toContain('!props.card.actionable && styles.cardDisabled') }) }) diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index 4ddb135affe..afa937f8baa 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -1,4 +1,5 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host' +import type { AgentWorkingMode } from '../../../src/shared/agent-status-types' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' export type Worktree = { @@ -45,6 +46,7 @@ export type Worktree = { linkedGitLabIssue?: number | null comment?: string status?: 'working' | 'active' | 'permission' | 'done' | 'inactive' + workingMode?: AgentWorkingMode agents?: RuntimeWorktreeAgentRow[] } diff --git a/mobile/src/worktree/worktree-list-snapshot.test.ts b/mobile/src/worktree/worktree-list-snapshot.test.ts index d6d3af1930c..c7c5756160f 100644 --- a/mobile/src/worktree/worktree-list-snapshot.test.ts +++ b/mobile/src/worktree/worktree-list-snapshot.test.ts @@ -160,6 +160,20 @@ describe('areWorktreeListsEqual', () => { expect(areWorktreeListsEqual(first, second)).toBe(false) }) + it('detects monitoring mode changes within working', () => { + const first = [worktree({ agents: [agent({ state: 'working' })] })] + const second = [worktree({ agents: [agent({ state: 'working', workingMode: 'monitoring' })] })] + + expect(areWorktreeListsEqual(first, second)).toBe(false) + }) + + it('detects workspace monitoring mode changes within working', () => { + const first = [worktree({ status: 'working' })] + const second = [worktree({ status: 'working', workingMode: 'monitoring' })] + + expect(areWorktreeListsEqual(first, second)).toBe(false) + }) + it('treats missing and empty agent arrays as equivalent for rendering', () => { const first = [worktree({ agents: undefined })] const second = [worktree({ agents: [] })] diff --git a/mobile/src/worktree/worktree-list-snapshot.ts b/mobile/src/worktree/worktree-list-snapshot.ts index bb9df6cf9af..8fa27c2a197 100644 --- a/mobile/src/worktree/worktree-list-snapshot.ts +++ b/mobile/src/worktree/worktree-list-snapshot.ts @@ -55,6 +55,7 @@ function areWorktreesEqual(left: Worktree, right: Worktree): boolean { (left.linkedGitLabIssue ?? null) === (right.linkedGitLabIssue ?? null) && (left.comment ?? '') === (right.comment ?? '') && (left.status ?? null) === (right.status ?? null) && + (left.workingMode ?? null) === (right.workingMode ?? null) && arePullRequestsEqual(left.linkedPR, right.linkedPR) && areAgentRowsEqual(left.agents ?? [], right.agents ?? []) ) @@ -102,6 +103,7 @@ function areAgentRowsEqual( a.paneKey !== b.paneKey || a.parentPaneKey !== b.parentPaneKey || a.state !== b.state || + a.workingMode !== b.workingMode || a.agentType !== b.agentType || a.prompt !== b.prompt || a.lastAssistantMessage !== b.lastAssistantMessage || diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ee3caf6950..6d739a42d25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,7 @@ patchedDependencies: hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 path: config/patches/lint-staged@16.4.0.patch node-pty@1.1.0: - hash: 710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5 + hash: 9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1 path: config/patches/node-pty@1.1.0.patch importers: @@ -69,7 +69,7 @@ importers: version: 3.3.1 node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5) + version: 1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1) posthog-node: specifier: ^5.33.3 version: 5.33.3 @@ -12046,7 +12046,7 @@ snapshots: node-int64@0.4.0: {} - node-pty@1.1.0(patch_hash=710d5c74d1b738c41316e997e7cb860b61147604f98380ccf4f5bb20b6ed0ac5): + node-pty@1.1.0(patch_hash=9c665d9e41571341cb8986607d531b953a7f32c0dd60e1e74e633c098d80f2c1): dependencies: node-addon-api: 7.1.1 diff --git a/src/cli/handlers/account.test.ts b/src/cli/handlers/account.test.ts index 8642df27ea4..c35f2270e6c 100644 --- a/src/cli/handlers/account.test.ts +++ b/src/cli/handlers/account.test.ts @@ -1,3 +1,4 @@ +import type * as NodeCliCommandResolutionModule from '../../shared/node-cli-command-resolution' import { EventEmitter } from 'node:events' import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' @@ -12,6 +13,7 @@ const { resolveCliCommandMock, rmSyncMock, spawnMock, + stdioForWindowsInteractiveChildMock, writeKeychainMock } = vi.hoisted(() => ({ deleteKeychainMock: vi.fn(), @@ -20,6 +22,7 @@ const { resolveCliCommandMock: vi.fn(), rmSyncMock: vi.fn(), spawnMock: vi.fn(), + stdioForWindowsInteractiveChildMock: vi.fn(), writeKeychainMock: vi.fn() })) @@ -41,10 +44,16 @@ vi.mock('../../main/claude-accounts/keychain', () => ({ readActiveClaudeKeychainCredentialsStrict: readKeychainMock, writeActiveClaudeKeychainCredentials: writeKeychainMock })) -vi.mock('../../shared/node-cli-command-resolution', () => ({ +// Why importOriginal: withCliRuntimeOnPath is a pure filesystem-probing helper, +// and the PATH assertions below are only meaningful against the real one. +vi.mock('../../shared/node-cli-command-resolution', async (importOriginal) => ({ + ...(await importOriginal()), getVersionManagerBinPaths: getVersionManagerBinPathsMock, resolveCliCommand: resolveCliCommandMock })) +vi.mock('../../shared/windows-console-input', () => ({ + stdioForWindowsInteractiveChild: stdioForWindowsInteractiveChildMock +})) import { ACCOUNT_HANDLERS } from './account' import type { HandlerContext } from '../dispatch' @@ -105,6 +114,10 @@ describe('account CLI handlers', () => { beforeEach(() => { Object.defineProperty(process, 'platform', originalPlatform) spawnMock.mockReset().mockImplementation(() => successfulChild()) + stdioForWindowsInteractiveChildMock.mockReset().mockImplementation((json: boolean) => ({ + stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'], + dispose: vi.fn() + })) resolveCliCommandMock.mockReset().mockImplementation((command: string) => command) getVersionManagerBinPathsMock.mockReset().mockReturnValue([]) readKeychainMock.mockReset().mockResolvedValue(null) @@ -159,6 +172,56 @@ describe('account CLI handlers', () => { }) }) + it('passes Windows console device handles to the login child instead of inheriting Electron stdio', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const dispose = vi.fn() + stdioForWindowsInteractiveChildMock.mockReturnValue({ + stdio: [11, 'inherit', 'inherit'], + dispose + }) + + await ACCOUNT_HANDLERS['account add'](context('codex')) + + expect(spawnMock).toHaveBeenCalledWith( + 'codex', + ['login', '--device-auth'], + expect.objectContaining({ stdio: [11, 'inherit', 'inherit'] }) + ) + expect(dispose).toHaveBeenCalledTimes(1) + }) + + it('disposes the Windows console input fd when spawn throws synchronously', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const dispose = vi.fn() + stdioForWindowsInteractiveChildMock.mockReturnValue({ + stdio: [11, 'inherit', 'inherit'], + dispose + }) + spawnMock.mockImplementationOnce(() => { + throw new Error('invalid stdio') + }) + + await expect(ACCOUNT_HANDLERS['account add'](context('codex'))).rejects.toThrow('invalid stdio') + + expect(dispose).toHaveBeenCalledOnce() + }) + + it('keeps JSON login prompts off the CLI stdout envelope when console fds are attached', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + stdioForWindowsInteractiveChildMock.mockReturnValue({ + stdio: [11, process.stderr, 'inherit'], + dispose: vi.fn() + }) + + await ACCOUNT_HANDLERS['account add'](context('codex', true)) + + expect(spawnMock).toHaveBeenCalledWith( + 'codex', + ['login', '--device-auth'], + expect.objectContaining({ stdio: [11, process.stderr, 'inherit'] }) + ) + }) + it('routes Windows package-manager shims through the safe cmd launcher', async () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) resolveCliCommandMock.mockReturnValue('C:\\tools\\codex.cmd') diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index 0f7f95bc17e..a6ee5d73246 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -14,13 +14,15 @@ import { } from '../../main/claude-accounts/keychain' import { getVersionManagerBinPaths, - resolveCliCommand + resolveCliCommand, + withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { getSpawnArgsForWindows, UnsafeWindowsBatchArgumentsError, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../../shared/windows-batch-spawn' +import { stdioForWindowsInteractiveChild } from '../../shared/windows-console-input' import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import type { RuntimeStatus } from '../../shared/runtime-types' import type { @@ -112,13 +114,25 @@ async function runAgentLoginInTerminal( ) return } - const env = addAgentNodePaths({ ...stripElectronRunAsNode(process.env), ...extraEnv }) - const child = spawn(spawnCmd, spawnArgs, { - // Why: JSON mode reserves stdout for the response envelope while keeping - // the interactive login attached to the user's terminal via stderr. - stdio: ['inherit', json ? process.stderr : 'inherit', 'inherit'], - env - }) + // Why paired after the seed: addAgentNodePaths prepends the *newest* version + // manager bin, which is not necessarily where this CLI lives. Pairing last puts + // the CLI's own node in front of that seed (stablyai/orca#10932). + const env = withCliRuntimeOnPath( + resolvedCommand, + addAgentNodePaths({ ...stripElectronRunAsNode(process.env), ...extraEnv }) + ) + const consoleStdio = stdioForWindowsInteractiveChild(json) + let child: ReturnType + try { + child = spawn(spawnCmd, spawnArgs, { + // Why: JSON mode reserves stdout for the response envelope while keeping + // the interactive login attached to the user's terminal via stderr. + stdio: consoleStdio.stdio, + env + }) + } finally { + consoleStdio.dispose() + } session.child = child child.once('error', (error) => rejectPromise( diff --git a/src/cli/handlers/orchestration-module-boundaries.test.ts b/src/cli/handlers/orchestration-module-boundaries.test.ts new file mode 100644 index 00000000000..baca7e38b41 --- /dev/null +++ b/src/cli/handlers/orchestration-module-boundaries.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../format', () => ({ printResult: vi.fn() })) + +import { printResult } from '../format' +import { HANDLER_GROUPS } from '../handler-group-manifest' +import { ORCHESTRATION_HANDLERS } from './orchestration' +import { + ORCHESTRATION_DISPATCH_HANDLER, + ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS +} from './orchestration/dispatch-handlers' +import { getOptionalStructuredMessagePayload } from './orchestration/message-payload' +import { getOptionalPositiveIntegerValueFlag } from './orchestration/numeric-flags' +import { formatWorkerRead, formatWorkerRelease } from './orchestration/worker-output' + +describe('extracted orchestration flag parsing', () => { + it('serializes phase and omits empty CSV entries', () => { + const payload = getOptionalStructuredMessagePayload( + new Map([ + ['phase', 'verification'], + ['files-modified', ' src/a.ts, ,src/b.ts, '] + ]) + ) + + expect(JSON.parse(payload ?? '')).toEqual({ + filesModified: ['src/a.ts', 'src/b.ts'], + phase: 'verification' + }) + }) + + it('rejects unsafe positive integers before dispatch', () => { + expect(() => + getOptionalPositiveIntegerValueFlag( + new Map([['timeout-ms', '9007199254740992']]), + 'timeout-ms' + ) + ).toThrow('Invalid positive safe integer for --timeout-ms') + }) +}) + +it('composes handlers in the canonical command order', () => { + const declared = HANDLER_GROUPS.find((group) => group.name === 'orchestration')?.keys + expect(Object.keys(ORCHESTRATION_HANDLERS)).toEqual(declared) +}) + +describe('extracted orchestration worker formatting', () => { + it('renders terminal tails without changing line boundaries', () => { + expect( + formatWorkerRead({ + dispatchId: 'dispatch_1', + terminal: { tail: ['first', '', 'third'] } + } as never) + ).toBe('first\n\nthird') + }) + + it('renders transcript blocks and survives unserializable tool input', () => { + const circular: { self?: unknown } = {} + circular.self = circular + + expect( + formatWorkerRead({ + source: 'transcript', + transcript: { + messages: [ + { + id: 'message_1', + role: 'assistant', + blocks: [ + { type: 'text', text: 'working' }, + { type: 'tool-call', name: 'inspect', input: circular }, + { type: 'tool-result', output: 'failed', isError: true }, + { type: 'image-ref', url: 'https://example.test/proof.png' } + ], + timestamp: null, + source: 'transcript' + } + ] + } + } as never) + ).toBe( + '[assistant] working\n[tool inspect] [unserializable input]\n[tool result error] failed\n[image] https://example.test/proof.png' + ) + }) + + it('renders release evidence in receipt order', () => { + expect( + formatWorkerRelease({ + dispatchId: 'dispatch_1', + state: 'release_unknown', + reason: 'host_unreachable', + processAction: 'unverifiable', + archive: { source: 'transcript', status: 'saved' }, + lastError: 'transport closed', + recovery: 'Reconnect and retry.' + }) + ).toBe( + 'Worker dispatch_1 terminal [release_unknown] reason=host_unreachable process=unverifiable\n' + + 'archive transcript [saved]\ntransport closed\nReconnect and retry.' + ) + }) +}) + +describe('extracted orchestration dispatch handlers', () => { + const call = vi.fn() + + beforeEach(() => { + call.mockReset() + vi.mocked(printResult).mockReset() + }) + + it('allows a dry run without a recipient and returns only its preamble', async () => { + call.mockResolvedValue({ + result: { dispatch: null, dryRun: true, preamble: 'preview preamble' } + }) + + await ORCHESTRATION_DISPATCH_HANDLER['orchestration dispatch']({ + flags: new Map([ + ['task', 'task_1'], + ['from', 'terminal_coordinator'], + ['dry-run', true], + ['return-preamble', true] + ]), + client: { call }, + cwd: '/repo', + json: false + } as never) + + expect(call).toHaveBeenCalledWith('orchestration.dispatch', { + task: 'task_1', + run: undefined, + to: undefined, + from: 'terminal_coordinator', + inject: undefined, + dryRun: true, + returnPreamble: true, + devMode: false + }) + expect(renderedValue({ dispatch: null, dryRun: true, preamble: 'preview preamble' })).toBe( + 'preview preamble' + ) + }) + + it('does not resolve coordinator identity for a non-preamble dispatch lookup', async () => { + call.mockResolvedValue({ result: { dispatch: null } }) + + await ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS['orchestration dispatch-show']({ + flags: new Map([['task', 'task_1']]), + client: { call }, + cwd: '/repo', + json: false + } as never) + + expect(call).toHaveBeenCalledWith('orchestration.dispatchShow', { + task: 'task_1', + preamble: undefined, + from: undefined, + devMode: false + }) + expect(renderedValue({ dispatch: null })).toBe('No dispatch context found.') + }) +}) + +function renderedValue(value: unknown): string { + const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as (result: unknown) => string + return formatter(value) +} diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index 43fffc54b8c..f1f1c70bc39 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -1,1327 +1,32 @@ -/* eslint-disable max-lines -- Why: orchestration CLI handlers share flag-parsing helpers and dispatch/preamble logic; splitting by verb would fragment the RuntimeClient call shape without reducing complexity. */ import type { CommandHandler } from '../dispatch' -import type { RuntimeClient } from '../runtime-client' -import { printResult } from '../format' +import { ORCHESTRATION_CHECK_HANDLER } from './orchestration/message-check-handler' import { - getOptionalPositiveIntegerFlag, - getOptionalStringFlag, - getRequiredStringFlag -} from '../flags' -import { RuntimeClientError } from '../runtime-client' -import { requireWorkerDoneSettlement } from './orchestration-worker-settlement' -import { getTerminalHandle } from '../selectors' -import { - clampOrchestrationAskTimeoutMs, - resolveOrchestrationAskClientTimeoutMs -} from '../../shared/orchestration-ask-timeout' -import { abbreviateOrchestrationTasks } from '../../shared/orchestration-task-summary' -import { parsePositiveSafeIntegerText } from '../../shared/timer-delay' -import type { - OrchestrationWorkerReadResult, - OrchestrationWorkerReadSource -} from '../../shared/orchestration-worker-output' -import type { NativeChatMessage } from '../../shared/native-chat-types' -import type { RuntimeStatus, RuntimeTerminalRead } from '../../shared/runtime-types' -import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../shared/protocol-version' -import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract' -import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../shared/orchestration-run-pagination' -import { - formatMessageReadOnlyTag, - formatOrchestrationCheckText, - prepareOrchestrationCheckOutput, - type LegacyCompatibilityResult, - type OrchestrationMessageSummary as MessageSummary -} from '../../shared/orchestration-check-output' -import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery' - -// Why: 15 s is well under Claude Code's ~2 min Bash-tool silence budget while keeping log volume low. See design doc §3.4. -const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000 -function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { - return `${type} messages belong to one exact Dispatch and cannot target a group address.` -} - -// Why: test-only escape hatch so subprocess tests avoid the full 15 s window; bogus values fall back to the default. -function resolveKeepaliveIntervalMs(): number { - const raw = process.env.ORCA_KEEPALIVE_INTERVAL_MS ?? process.env.ORCA_HEARTBEAT_INTERVAL_MS - if (!raw) { - return DEFAULT_KEEPALIVE_INTERVAL_MS - } - const parsed = Number(raw) - if (!Number.isFinite(parsed) || parsed <= 0) { - return DEFAULT_KEEPALIVE_INTERVAL_MS - } - return parsed -} - -function startCheckKeepalive(deadlineMs: number | undefined): () => void { - const startedAt = Date.now() - const interval = setInterval(() => { - const payload = { - _keepalive: true, - // Why: retain the old marker for scripts still filtering it while callers migrate to _keepalive. - _heartbeat: true, - elapsedMs: Date.now() - startedAt, - deadlineMs: deadlineMs ?? null - } - // Why: process.stderr.write is line-flushed per-call in Node; a buffered writer would hold keepalives until exit. See §3.4. - process.stderr.write(`${JSON.stringify(payload)}\n`) - }, resolveKeepaliveIntervalMs()) - if (typeof interval.unref === 'function') { - interval.unref() - } - return () => clearInterval(interval) -} - -// Why: mirrors TaskStatus (orchestration/types.ts) so the CLI surfaces an enum-aware error before the generic RPC message. -const TASK_STATUS_VALUES = [ - 'pending', - 'ready', - 'dispatched', - 'completed', - 'failed', - 'blocked' -] as const - -// Why: mirrors WorkerTerminalListState (orchestration/types.ts) so a bad --terminal-state fails before the RPC. -const WORKER_TERMINAL_LIST_STATES = [ - 'active', - 'reclaimable', - 'retained', - 'release_pending', - 'release_unknown', - 'released' -] as const - -type LifecycleSendResult = - | { - action: 'completed' | 'failed' - authority?: 'run_home' | 'worker_server_legacy' - } - | { action: 'settled'; outcome: 'succeeded' | 'failed'; duplicate?: boolean } - | { action: 'rejected'; code: string; reason: string } - -type SendRecipientWarning = { - code: string - recipient: string - message: string -} - -type OrchestrationSendResult = - | { - message: { id: string; run_id?: string } - lifecycle?: LifecycleSendResult - warnings?: SendRecipientWarning[] - } - | { - messages: { id: string }[] - recipients: number - warnings?: SendRecipientWarning[] - } - | { - relay: { - messageId: string - sequence: number - dispatchId: string - destination?: 'run_home' | 'worker' - accepted: true - } - lifecycle?: LifecycleSendResult - warnings?: SendRecipientWarning[] - } - -function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' { - const configured = process.env.ORCA_CLI_COMMAND - if (configured === 'orca' || configured === 'orca-ide' || configured === 'orca-dev') { - return configured - } - return process.platform === 'linux' ? 'orca-ide' : 'orca' -} - -function resolvePackagedWindowsCompatibilityCommand(): 'orca' | 'orca-ide' | undefined { - if (process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER !== '1') { - return undefined - } - const command = process.env.ORCA_CLI_COMMAND - if (command === 'orca' || command === 'orca-ide') { - return command - } - throw new RuntimeClientError( - 'invalid_argument', - 'The packaged Orca launcher did not provide a valid resume command. No question was created.' - ) -} - -async function flushStdout(): Promise { - await new Promise((resolve, reject) => { - process.stdout.write('', (error) => { - if (error) { - reject(error) - } else { - resolve() - } - }) - }) -} - -function getOptionalStructuredMessagePayload( - flags: Map -): string | undefined { - const rawPayload = getOptionalStringFlag(flags, 'payload') - const taskId = getOptionalStringFlag(flags, 'task-id') - const dispatchId = getOptionalStringFlag(flags, 'dispatch-id') - const outcome = getOptionalStringFlag(flags, 'outcome') - const filesModified = getOptionalStringFlag(flags, 'files-modified') - const reportPath = getOptionalStringFlag(flags, 'report-path') - const phase = getOptionalStringFlag(flags, 'phase') - const hasStructuredPayload = - taskId !== undefined || - dispatchId !== undefined || - outcome !== undefined || - filesModified !== undefined || - reportPath !== undefined || - phase !== undefined - if (!hasStructuredPayload) { - return rawPayload - } - if (rawPayload !== undefined) { - throw new RuntimeClientError( - 'invalid_argument', - 'Use either --payload or structured payload flags, not both.' - ) - } - // Why: raw JSON args are fragile in Windows PowerShell; these flags avoid shell-specific quoting. - const payload: Record = {} - if (taskId) { - payload.taskId = taskId - } - if (dispatchId) { - payload.dispatchId = dispatchId - } - if (outcome) { - if (outcome !== 'succeeded' && outcome !== 'failed') { - throw new RuntimeClientError( - 'invalid_argument', - 'Invalid --outcome. Expected succeeded or failed.' - ) - } - payload.outcome = outcome - } - if (filesModified) { - payload.filesModified = filesModified - .split(',') - .map((file) => file.trim()) - .filter(Boolean) - } - if (reportPath) { - payload.reportPath = reportPath - } - if (phase) { - payload.phase = phase - } - return JSON.stringify(payload) -} - -async function resolveOrchestrationTerminalHandle( - flags: Map, - cwd: string, - client: Parameters[0]['client'], - flagName: 'from' | 'terminal', - options: { validateEnvHandle?: boolean } = {} -): Promise { - const explicit = getOptionalStringFlag(flags, flagName) - if (explicit) { - return explicit - } - const envHandle = process.env.ORCA_TERMINAL_HANDLE - if (envHandle && envHandle.length > 0) { - if (flagName === 'from' && options.validateEnvHandle) { - // Why: long-lived shells can retain a stale ORCA_TERMINAL_HANDLE after remint; don't bake it into coordinator preambles. - const live = await isLiveTerminalHandle(envHandle, client) - if (!live) { - const reminted = await resolveOrchestrationPaneTerminalHandle(client) - if (reminted) { - return reminted - } - throwNoActiveSenderTerminal() - } - } - return envHandle - } - if (flagName === 'from') { - return await resolveImplicitOrchestrationSender(flags, cwd, client) - } - return await getTerminalHandle(flags, cwd, client) -} - -async function isLiveTerminalHandle( - handle: string, - client: Parameters[0]['client'] -): Promise { - try { - await client.call('terminal.show', { terminal: handle }) - return true - } catch (err) { - if (isStaleTerminalIdentityError(err)) { - return false - } - throw err - } -} - -function getClientErrorCode(err: unknown): string | undefined { - if (!err || typeof err !== 'object') { - return undefined - } - const code = (err as { code?: unknown }).code - return typeof code === 'string' ? code : undefined -} - -function isStaleTerminalIdentityError(err: unknown): boolean { - const code = getClientErrorCode(err) - return code === 'terminal_handle_stale' || code === 'terminal_gone' -} - -function isNoActiveTerminalError(err: unknown): boolean { - return getClientErrorCode(err) === 'no_active_terminal' -} - -async function resolveOrchestrationPaneTerminalHandle( - client: Parameters[0]['client'], - options: { optional?: boolean } = {} -): Promise { - const paneKey = process.env.ORCA_PANE_KEY - if (!paneKey || paneKey.length === 0) { - return undefined - } - try { - // Why: pane-key reminting preserves caller identity; focus-based active-terminal fallback can point at a different pane. - const response = await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', { - paneKey - }) - return response.result.terminal.handle - } catch (err) { - if ( - isPaneRemintUnavailableError(err) || - (options.optional === true && isOptionalPaneRemintUnavailableError(err)) - ) { - return undefined - } - throw err - } -} - -function isPaneRemintUnavailableError(err: unknown): boolean { - const code = getClientErrorCode(err) - const message = getClientErrorMessage(err) - return ( - code === 'terminal_not_found' || - code === 'terminal_handle_stale' || - code === 'terminal_gone' || - message === 'terminal_not_found' || - message === 'terminal_handle_stale' || - message === 'terminal_gone' - ) -} - -function isOptionalPaneRemintUnavailableError(err: unknown): boolean { - return getClientErrorCode(err) === 'runtime_unavailable' -} - -function getClientErrorMessage(err: unknown): string | undefined { - if (err instanceof Error) { - return err.message - } - if (!err || typeof err !== 'object') { - return undefined - } - const message = (err as { message?: unknown }).message - return typeof message === 'string' ? message : undefined -} - -async function resolveCoordinatorTerminalHandle( - flags: Map, - cwd: string, - client: Parameters[0]['client'] -): Promise { - return await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from', { - validateEnvHandle: true - }) -} - -async function resolveImplicitOrchestrationSender( - flags: Map, - cwd: string, - client: Parameters[0]['client'] -): Promise { - try { - return await getTerminalHandle(flags, cwd, client) - } catch (err) { - if (!isNoActiveTerminalError(err)) { - throw err - } - throwNoActiveSenderTerminal() - } -} - -function throwNoActiveSenderTerminal(): never { - throw new RuntimeClientError( - 'no_active_sender_terminal', - 'Could not determine the sender terminal for this orchestration command. ' + - 'Pass --from or run the command inside a live Orca terminal with ORCA_TERMINAL_HANDLE set.' - ) -} - -function isDevCliInvocation(): boolean { - return ( - process.env.ORCA_DEV_CLI_INVOCATION === '1' || - (process.env.ORCA_USER_DATA_PATH?.includes('orca-dev') ?? false) - ) -} - -function getOptionalPositiveIntegerValueFlag( - flags: Map, - name: string -): number | undefined { - if (!flags.has(name)) { - return undefined - } - const raw = flags.get(name) - if (typeof raw !== 'string' || raw.length === 0) { - throw new RuntimeClientError('invalid_argument', `Missing value for --${name}.`) - } - const value = parsePositiveSafeIntegerText(raw) - if (value === null) { - throw new RuntimeClientError( - 'invalid_argument', - `Invalid positive safe integer for --${name}: ${raw}` - ) - } - return value -} - -function rejectLifecycleGroupRecipient(type: string | undefined, to: string): void { - if ((type === 'worker_done' || type === 'heartbeat') && to.startsWith('@')) { - throw new RuntimeClientError('invalid_argument', getLifecycleGroupRecipientError(type)) - } -} - -function callMutation( - client: RuntimeClient, - flags: Map, - method: string, - params: unknown, - options?: { timeoutMs?: number; orchestrationCapability?: string } -) { - const requestId = getOptionalStringFlag(flags, 'retry-request') - const result = requestId - ? client.call(method, params, { ...options, orchestrationRequestId: requestId }) - : options - ? client.call(method, params, options) - : client.call(method, params) - return result.catch((error) => { - throw orchestrationMutationRecoveryError(error) - }) -} - -type LegacyWorkerReadResult = { - dispatchId: string - terminal: RuntimeTerminalRead -} - -function formatWorkerRead(value: OrchestrationWorkerReadResult | LegacyWorkerReadResult): string { - if (!('source' in value) || value.source === 'terminal') { - return value.terminal.tail.join('\n') - } - return value.transcript.messages.map(formatWorkerTranscriptMessage).join('\n\n') -} - -function formatWorkerTranscriptMessage(message: NativeChatMessage): string { - const blocks = message.blocks.map((block) => { - if (block.type === 'text') { - return block.text - } - if (block.type === 'tool-call') { - return `[tool ${block.name}] ${safeJson(block.input)}` - } - if (block.type === 'tool-result') { - return `[tool result${block.isError ? ' error' : ''}] ${block.output}` - } - return block.url ? `[image] ${block.url}` : `[image omitted]` - }) - return `[${message.role}] ${blocks.join('\n')}`.trimEnd() -} - -type WorkerReleaseReceipt = { - dispatchId: string - state: string - reason?: string - processAction: string - archive: { source: string | null; status: string | null } | null - recovery?: string - lastError?: string -} - -function formatWorkerRelease(value: WorkerReleaseReceipt): string { - const head = `Worker ${value.dispatchId} terminal [${value.state}]` - const lines = [ - `${head}${value.reason ? ` reason=${value.reason}` : ''} process=${value.processAction}` - ] - if (value.archive) { - lines.push(`archive ${value.archive.source ?? 'none'} [${value.archive.status ?? 'unknown'}]`) - } - if (value.lastError) { - lines.push(value.lastError) - } - if (value.recovery) { - lines.push(value.recovery) - } - return lines.join('\n') -} - -function safeJson(value: unknown): string { - try { - return JSON.stringify(value) - } catch { - return '[unserializable input]' - } -} + ORCHESTRATION_DISPATCH_HANDLER, + ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS +} from './orchestration/dispatch-handlers' +import { ORCHESTRATION_GATE_HANDLERS } from './orchestration/gate-handlers' +import { ORCHESTRATION_INBOX_HANDLERS } from './orchestration/message-inbox-handlers' +import { ORCHESTRATION_QUESTION_HANDLER } from './orchestration/question-handler' +import { ORCHESTRATION_RESET_HANDLER } from './orchestration/reset-handler' +import { ORCHESTRATION_RUN_HANDLERS } from './orchestration/run-handlers' +import { ORCHESTRATION_SEND_HANDLER } from './orchestration/message-send-handler' +import { ORCHESTRATION_TASK_HANDLERS } from './orchestration/task-handlers' +import { ORCHESTRATION_WORKER_LAUNCH_HANDLER } from './orchestration/worker-launch-handler' +import { ORCHESTRATION_WORKER_OBSERVATION_HANDLERS } from './orchestration/worker-observation-handlers' +import { ORCHESTRATION_WORKER_TERMINAL_HANDLERS } from './orchestration/worker-terminal-handlers' export const ORCHESTRATION_HANDLERS: Record = { - 'orchestration run-create': async ({ flags, client, cwd, json }) => { - const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await callMutation<{ - run: { id: string; objective: string; consumer_generation: number } - }>(client, flags, 'orchestration.runCreate', { - objective: getRequiredStringFlag(flags, 'objective'), - from - }) - printResult(result, json, (r) => `Run ${r.run.id} created and bound: ${r.run.objective}`) - }, - - 'orchestration run-use': async ({ flags, client, cwd, json }) => { - const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await callMutation<{ - run: { id: string; objective: string; consumer_generation: number } - }>(client, flags, 'orchestration.runUse', { - id: getRequiredStringFlag(flags, 'id'), - from, - ...(flags.has('takeover-legacy') ? { takeoverLegacy: true } : {}) - }) - printResult(result, json, (r) => `Using Run ${r.run.id}: ${r.run.objective}`) - }, - - 'orchestration run-current': async ({ flags, client, cwd, json }) => { - const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await client.call<{ - run: { id: string; objective: string } | null - }>('orchestration.runCurrent', { from }) - printResult(result, json, (r) => - r.run ? `${r.run.id} ${r.run.objective}` : 'No Run is bound to this terminal.' - ) - }, - - 'orchestration run-list': async ({ flags, client, json }) => { - const result = await client.call<{ - runs: { id: string; objective: string; legacy: number }[] - nextCursor: string | null - }>('orchestration.runList', { - limit: getOptionalPositiveIntegerFlag(flags, 'limit') ?? ORCHESTRATION_RUN_PAGE_LIMIT, - cursor: getOptionalStringFlag(flags, 'cursor') - }) - printResult(result, json, (r) => { - const rows = - r.runs.length === 0 - ? 'No Runs found.' - : r.runs - .map( - (run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}` - ) - .join('\n') - return r.nextCursor ? `${rows}\nMore Runs: --cursor ${r.nextCursor}` : rows - }) - }, - - 'orchestration run-show': async ({ flags, client, json }) => { - const result = await client.call<{ - run: { - id: string - objective: string - consumer_generation: number - legacy: number - created_at: string - } - }>('orchestration.runShow', { id: getRequiredStringFlag(flags, 'id') }) - printResult( - result, - json, - (r) => - `${r.run.id}${r.run.legacy ? ' [legacy, inspect only]' : ''} ${r.run.objective}\n` + - `consumer generation ${r.run.consumer_generation}; created ${r.run.created_at}` - ) - }, - - 'orchestration send': async ({ flags, client, cwd, json }) => { - const to = getOptionalStringFlag(flags, 'to') - const type = getOptionalStringFlag(flags, 'type') - if (to) { - rejectLifecycleGroupRecipient(type, to) - } - const outcome = getOptionalStringFlag(flags, 'outcome') - if (type !== 'worker_done' && outcome !== undefined) { - throw new RuntimeClientError( - 'invalid_argument', - '--outcome is only valid with --type worker_done.' - ) - } - - if ( - (type === 'worker_done' || type === 'heartbeat') && - !getOptionalStringFlag(flags, 'from') && - !process.env.ORCA_TERMINAL_HANDLE - ) { - // Why: focus isn't lifecycle authority — an identity-less subprocess must fail closed rather than guess the worker. - throwNoActiveSenderTerminal() - } - - // Why: lifecycle senders preserve ORCA_TERMINAL_HANDLE across restarts for older runtimes. - const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') - const sendParams = { - from, - to, - run: getOptionalStringFlag(flags, 'run'), - subject: getRequiredStringFlag(flags, 'subject'), - body: getOptionalStringFlag(flags, 'body'), - type, - priority: getOptionalStringFlag(flags, 'priority'), - threadId: getOptionalStringFlag(flags, 'thread-id'), - payload: getOptionalStructuredMessagePayload(flags), - // Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it. - senderPaneKey: process.env.ORCA_PANE_KEY || undefined, - waitForLifecycleSettlement: type === 'worker_done' ? true : undefined, - devMode: isDevCliInvocation() - } - const dispatchCapability = getOptionalStringFlag(flags, 'dispatch-capability') - const result = await callMutation( - client, - flags, - 'orchestration.send', - sendParams, - dispatchCapability ? { orchestrationCapability: dispatchCapability } : undefined - ) - await requireWorkerDoneSettlement(client, type, sendParams.payload, result.result) - if ('lifecycle' in result.result && result.result.lifecycle?.action === 'rejected') { - throw new RuntimeClientError(result.result.lifecycle.code, result.result.lifecycle.reason) - } - printResult(result, json, (r) => { - const warnings = 'warnings' in r ? (r.warnings ?? []) : [] - const withWarnings = (line: string): string => - warnings.length > 0 - ? [line, ...warnings.map((warning) => `Warning: ${warning.message}`)].join('\n') - : line - if ('message' in r) { - return withWarnings(`Sent ${r.message.id}`) - } - if ('relay' in r) { - if (r.relay.destination === 'worker') { - return withWarnings( - `Queued ${r.relay.messageId} for worker Dispatch ${r.relay.dispatchId}` - ) - } - return withWarnings( - `Queued ${r.relay.messageId} for Run home (Dispatch ${r.relay.dispatchId})` - ) - } - return withWarnings(`Sent ${r.messages.length} messages to ${r.recipients} recipients`) - }) - }, - - 'orchestration check': async ({ flags, client, cwd, json }) => { - const wait = flags.has('wait') - const peek = flags.has('peek') - // Why: enforce mode exclusivity client-side — older runtimes strip unknown peek and run --unread --peek as destructive mark-read. - if ([flags.has('unread'), peek, flags.has('all')].filter(Boolean).length > 1) { - throw new RuntimeClientError( - 'invalid_argument', - 'Choose at most one message read mode: --unread, --peek, or --all.' - ) - } - const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') - const explicitTerminal = getOptionalStringFlag(flags, 'terminal') - const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') - - // Why: Claude Code auto-backgrounds subprocesses silent ~2 min; emit JSON keepalives to stderr (stdout stays one payload). See §3.4. - const stopKeepalive = wait ? startCheckKeepalive(timeoutMs) : null - type CheckResult = { - messages: MessageSummary[] - count: number - formatted?: string - deliveryId?: string | null - runId?: string - timedOut?: boolean - cancelled?: boolean - connectionLost?: boolean - legacyCompatibility?: LegacyCompatibilityResult - } - let result: Awaited>> - try { - result = await callMutation(client, flags, 'orchestration.check', { - terminal, - terminalPaneKey: explicitTerminal ? undefined : process.env.ORCA_PANE_KEY || undefined, - // Why: peek also sends unread:false so pre-peek runtimes degrade to non-consuming all mode instead of destructive mark-read. - unread: flags.has('unread') ? true : peek ? false : undefined, - peek: peek ? true : undefined, - all: flags.has('all') ? true : undefined, - types: getOptionalStringFlag(flags, 'types'), - format: flags.has('format') ? true : undefined, - inject: flags.has('inject') ? true : undefined, - compatibilityCliCommand: resolveCompatibilityCliCommand(), - run: getOptionalStringFlag(flags, 'run'), - ack: getOptionalStringFlag(flags, 'ack'), - wait: wait ? true : undefined, - timeoutMs - }) - } finally { - stopKeepalive?.() - } - if (peek) { - const rawRowCount = result.result.messages.length - const unreadOnly = result.result.messages.filter((m) => m.read !== 1) - const removedReadRows = unreadOnly.length !== rawRowCount - // Why: read rows mean a pre-peek runtime ran the all mode and returned instead of blocking; can't honor --wait, so fail loudly. - if (wait && removedReadRows && unreadOnly.length === 0) { - throw new RuntimeClientError( - 'peek_wait_unsupported', - 'The connected runtime does not support --peek with --wait; upgrade the runtime or use --wait without --peek.' - ) - } - // Why: pre-peek runtimes cap the all mode at 100 rows; a full page may hide older unread — warn on stderr. - if (removedReadRows && rawRowCount >= 100) { - console.error( - 'Warning: this runtime returned only its newest 100 messages for --peek; older unread messages may be missing. Upgrade the runtime for exact peek results.' - ) - } - result = { - ...result, - result: { - ...result.result, - // Why: a pre-peek runtime builds `formatted` from all rows; drop it so output matches the filtered peek set. - ...(removedReadRows ? { formatted: undefined } : {}), - messages: unreadOnly, - count: unreadOnly.length - } - } - } - result = { - ...result, - result: prepareOrchestrationCheckOutput(result.result, terminal, flags.has('format')) - } - printResult(result, json, (r) => formatOrchestrationCheckText(r, terminal)) - const compatibilityAck = result.result.legacyCompatibility?.ackMessageIds - if (compatibilityAck && compatibilityAck.length > 0) { - await flushStdout() - await client.call('orchestration.check', { - terminal, - compatibilityAck: JSON.stringify({ - messageIds: compatibilityAck, - types: getOptionalStringFlag(flags, 'types') - ?.split(',') - .map((type) => type.trim()) - .filter(Boolean) - }) - }) - } - }, - - 'orchestration reply': async ({ flags, client, cwd, json }) => { - const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') - const result = await callMutation<{ message: { id: string } }>( - client, - flags, - 'orchestration.reply', - { - id: getRequiredStringFlag(flags, 'id'), - body: getRequiredStringFlag(flags, 'body'), - run: getOptionalStringFlag(flags, 'run'), - from - } - ) - printResult(result, json, (r) => `Replied ${r.message.id}`) - }, - - 'orchestration inbox': async ({ flags, client, json }) => { - const full = flags.has('full') - const result = await client.call<{ - messages: MessageSummary[] - count: number - }>('orchestration.inbox', { - limit: getOptionalPositiveIntegerFlag(flags, 'limit'), - terminal: getOptionalStringFlag(flags, 'terminal') - }) - printResult(result, json, (r) => { - if (r.count === 0) { - return 'No messages.' - } - // Why: default output omits body/payload for at-a-glance sweeps; --full prints them for auditing. - return r.messages - .map((m) => { - const head = `${m.id}${formatMessageReadOnlyTag(m)} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"` - if (!full) { - return head - } - const parts = [head] - if (m.body && m.body.length > 0) { - parts.push(m.body) - } - if (m.payload) { - parts.push(`[payload] ${m.payload}`) - } - return parts.join('\n') - }) - .join(full ? '\n\n' : '\n') - }) - }, - - 'orchestration task-create': async ({ flags, client, cwd, json }) => { - const callerTerminalHandle = await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await callMutation<{ task: { id: string; status: string } }>( - client, - flags, - 'orchestration.taskCreate', - { - spec: getRequiredStringFlag(flags, 'spec'), - taskTitle: getOptionalStringFlag(flags, 'task-title'), - displayName: getOptionalStringFlag(flags, 'display-name'), - deps: getOptionalStringFlag(flags, 'deps'), - parent: getOptionalStringFlag(flags, 'parent'), - run: getOptionalStringFlag(flags, 'run'), - callerTerminalHandle - } - ) - printResult(result, json, (r) => `Created ${r.task.id} [${r.task.status}]`) - }, - - 'orchestration task-list': async ({ flags, client, cwd, json }) => { - const brief = flags.has('brief') - const run = getOptionalStringFlag(flags, 'run') - const callerTerminalHandle = run - ? undefined - : await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await client.call<{ - tasks: { - id: string - spec: string - task_title?: string | null - display_name?: string | null - status: string - assignee_handle?: string | null - dispatch_id?: string | null - spec_truncated?: boolean - }[] - count: number - runId?: string - legacyReadOnly?: boolean - }>('orchestration.taskList', { - status: getOptionalStringFlag(flags, 'status'), - ready: flags.has('ready') ? true : undefined, - brief: brief ? true : undefined, - run, - callerTerminalHandle - }) - // Why: only older runtimes (no spec_truncated) skip server-side abbreviation and need this client-side fallback. - const needsClientAbbreviation = - brief && result.result.tasks.some((task) => task.spec_truncated === undefined) - const output = needsClientAbbreviation - ? { - ...result, - result: { ...result.result, tasks: abbreviateOrchestrationTasks(result.result.tasks) } - } - : result - printResult(output, json, (r) => { - if (r.count === 0) { - return r.legacyReadOnly ? 'No legacy tasks (read-only).' : 'No tasks.' - } - const tasks = r.tasks - .map((t) => { - const label = t.display_name ?? t.task_title ?? t.spec - const head = `${t.id} [${t.status}] ${label.slice(0, 60)}` - if (t.status === 'dispatched' && t.assignee_handle) { - return `${head} -> ${t.assignee_handle} (${t.dispatch_id ?? '?'})` - } - return head - }) - .join('\n') - return r.legacyReadOnly ? `Legacy Run ${r.runId} (read-only)\n${tasks}` : tasks - }) - }, - - 'orchestration task-update': async ({ flags, client, cwd, json }) => { - const status = getRequiredStringFlag(flags, 'status') - if (!TASK_STATUS_VALUES.includes(status as (typeof TASK_STATUS_VALUES)[number])) { - throw new RuntimeClientError( - 'invalid_argument', - `invalid status '${status}', expected one of: ${TASK_STATUS_VALUES.join(', ')}` - ) - } - const result = await callMutation<{ task: { id: string; status: string } }>( - client, - flags, - 'orchestration.taskUpdate', - { - id: getRequiredStringFlag(flags, 'id'), - status, - result: getOptionalStringFlag(flags, 'result'), - run: getOptionalStringFlag(flags, 'run'), - callerTerminalHandle: await resolveCoordinatorTerminalHandle(flags, cwd, client) - } - ) - printResult(result, json, (r) => `Updated ${r.task.id} -> ${r.task.status}`) - }, - - 'orchestration worker-start': async ({ flags, client, cwd, json }) => { - const model = getOptionalStringFlag(flags, 'model') - const effort = getOptionalStringFlag(flags, 'effort') - if (model || effort) { - const status = await client.call('status.get') - if ( - !status.result.capabilities?.includes( - ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY - ) - ) { - throw new RuntimeClientError( - 'incompatible_runtime', - 'The connected Orca runtime does not support worker model or effort overrides. Update or restart Orca and try again.' - ) - } - } - const result = await callMutation<{ - runId: string - taskId: string - dispatchId: string - state: string - failedStage?: string - lastError?: string - warning?: string - effects: unknown[] - residualResources: unknown[] - }>(client, flags, 'orchestration.workerStart', { - task: getRequiredStringFlag(flags, 'task'), - on: getOptionalStringFlag(flags, 'on'), - worktree: getOptionalStringFlag(flags, 'worktree'), - name: getOptionalStringFlag(flags, 'name'), - repo: getOptionalStringFlag(flags, 'repo'), - baseBranch: getOptionalStringFlag(flags, 'base-branch'), - displayName: getOptionalStringFlag(flags, 'display-name'), - comment: getOptionalStringFlag(flags, 'comment'), - setup: getOptionalStringFlag(flags, 'setup'), - agent: getOptionalStringFlag(flags, 'agent'), - model, - effort, - terminal: getOptionalStringFlag(flags, 'terminal'), - retryOf: getOptionalStringFlag(flags, 'retry-of'), - timeoutMs: getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms'), - run: getOptionalStringFlag(flags, 'run'), - from: await resolveCoordinatorTerminalHandle(flags, cwd, client), - devMode: isDevCliInvocation() - }) - if (result.result.state !== 'ready') { - process.exitCode = 1 - } - printResult(result, json, (worker) => { - const base = `Worker ${worker.dispatchId} [${worker.state}] for ${worker.taskId}` - if (worker.lastError) { - return `${base}\n${worker.failedStage ?? 'start'}: ${worker.lastError}` - } - return worker.warning ? `${base}\nWarning: ${worker.warning}` : base - }) - }, - - 'orchestration worker-show': async ({ flags, client, json }) => { - const result = await client.call<{ - dispatch: { id: string; task_id: string; status: string } - worker: { state: string; stage: string; agent_terminal_handle: string | null } - observation?: { agentWait?: { source: string; reason?: string } | null } - }>('orchestration.workerShow', { - dispatch: getRequiredStringFlag(flags, 'dispatch') - }) - printResult(result, json, (value) => { - const base = `${value.dispatch.id} task=${value.dispatch.task_id} [${value.worker.state}] stage=${value.worker.stage}` - // Why a whole line and not a suffix: this is the one state where the lane is healthy - // and still needs a person, so it must not read as another status token. An absent - // field is unknown, which must not print the same as an evaluated "none". - if (value.observation === undefined || !('agentWait' in value.observation)) { - return `${base}\nInteractive wait: unknown (not evaluated)` - } - const wait = value.observation.agentWait - return wait - ? `${base}\nWaiting on a human: ${wait.reason ?? 'interactive prompt'} (via ${wait.source})` - : `${base}\nInteractive wait: none` - }) - }, - - 'orchestration worker-read': async ({ flags, client, json }) => { - const cursorFlag = getOptionalStringFlag(flags, 'cursor') - const cursor = - cursorFlag !== undefined && /^\d+$/.test(cursorFlag) - ? Number.parseInt(cursorFlag, 10) - : cursorFlag - const source = getOptionalStringFlag(flags, 'source') - if (source && !['auto', 'transcript', 'terminal'].includes(source)) { - throw new RuntimeClientError( - 'invalid_argument', - '--source must be auto, transcript, or terminal' - ) - } - const result = await client.call( - 'orchestration.workerRead', - { - dispatch: getRequiredStringFlag(flags, 'dispatch'), - cursor, - limit: getOptionalPositiveIntegerFlag(flags, 'limit'), - source: source as OrchestrationWorkerReadSource | undefined - } - ) - printResult(result, json, formatWorkerRead) - }, - - 'orchestration worker-stop': async ({ flags, client, json }) => { - const result = await callMutation<{ - dispatchId: string - state: string - processAction: string - lastError?: string - warning?: string - }>(client, flags, 'orchestration.workerStop', { - dispatch: getRequiredStringFlag(flags, 'dispatch') - }) - if (result.result.state === 'stop_unknown') { - process.exitCode = 1 - } - printResult( - result, - json, - (value) => - `Worker ${value.dispatchId} [${value.state}] process=${value.processAction}${value.lastError ? `\n${value.lastError}` : ''}${value.warning ? `\nWarning: ${value.warning}` : ''}` - ) - }, - - 'orchestration worker-abandon': async ({ flags, client, json }) => { - const result = await callMutation<{ - dispatchId: string - state: string - warning: string - }>(client, flags, 'orchestration.workerAbandon', { - dispatch: getRequiredStringFlag(flags, 'dispatch') - }) - printResult( - result, - json, - (value) => `Worker ${value.dispatchId} [${value.state}]\nWarning: ${value.warning}` - ) - }, - - 'orchestration worker-release': async ({ flags, client, json }) => { - const result = await callMutation( - client, - flags, - 'orchestration.workerRelease', - { dispatch: getRequiredStringFlag(flags, 'dispatch') } - ) - // Why: only an unprovable close is a failure; retained/pending/already-released are settled answers. - if (result.result.state === 'release_unknown') { - process.exitCode = 1 - } - printResult(result, json, formatWorkerRelease) - }, - - 'orchestration worker-retain': async ({ flags, client, json }) => { - const result = await callMutation( - client, - flags, - 'orchestration.workerRetain', - { dispatch: getRequiredStringFlag(flags, 'dispatch') } - ) - if (result.result.state === 'release_unknown') { - process.exitCode = 1 - } - printResult(result, json, formatWorkerRelease) - }, - - 'orchestration worker-list': async ({ flags, client, json }) => { - const terminalState = getOptionalStringFlag(flags, 'terminal-state') - if ( - terminalState && - !WORKER_TERMINAL_LIST_STATES.includes( - terminalState as (typeof WORKER_TERMINAL_LIST_STATES)[number] - ) - ) { - throw new RuntimeClientError( - 'invalid_argument', - `invalid --terminal-state '${terminalState}', expected one of: ${WORKER_TERMINAL_LIST_STATES.join(', ')}` - ) - } - const result = await client.call<{ - workers: { - dispatchId: string - taskId: string - runId: string - workerState: string - dispatchStatus: string - agentTerminalHandle: string | null - terminalState: string | null - resource: unknown - }[] - counts: Record - }>('orchestration.workerList', { - run: getOptionalStringFlag(flags, 'run'), - terminalState - }) - printResult(result, json, (r) => { - if (r.workers.length === 0) { - return 'No workers found.' - } - const rows = r.workers - .map( - (w) => - `${w.dispatchId} task=${w.taskId} [${w.workerState}] terminal=${w.terminalState ?? 'none'}` - ) - .join('\n') - const counts = Object.entries(r.counts) - .map(([state, count]) => `${state}=${count}`) - .join(' ') - return counts ? `${rows}\nTerminals: ${counts}` : rows - }) - }, - - 'orchestration dispatch': async ({ flags, client, cwd, json }) => { - const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) - const dryRun = flags.has('dry-run') ? true : undefined - const returnPreamble = flags.has('return-preamble') ? true : undefined - // Why: --to is only required for non-dry-run; the RPC handler re-enforces. - const to = dryRun ? getOptionalStringFlag(flags, 'to') : getRequiredStringFlag(flags, 'to') - const result = await callMutation<{ - dispatch: { id: string; task_id: string; status: string } | null - injected?: boolean - dryRun?: boolean - preamble?: string - }>(client, flags, 'orchestration.dispatch', { - task: getRequiredStringFlag(flags, 'task'), - run: getOptionalStringFlag(flags, 'run'), - to, - from, - inject: flags.has('inject') ? true : undefined, - dryRun, - returnPreamble, - devMode: isDevCliInvocation() - }) - printResult(result, json, (r) => { - if (r.dryRun) { - return r.preamble ?? '' - } - const base = `Dispatched ${r.dispatch?.task_id} -> ${r.dispatch?.id} [${r.dispatch?.status}]` - return r.preamble ? `${base}\n\n--- Preamble ---\n${r.preamble}` : base - }) - }, - - 'orchestration ask': async ({ flags, client, cwd, json }) => { - const parsedTimeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') - const timeoutMs = clampOrchestrationAskTimeoutMs(parsedTimeoutMs) - const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') - const question = getOptionalStringFlag(flags, 'question') - const resume = getOptionalStringFlag(flags, 'resume') - if ((question ? 1 : 0) + (resume ? 1 : 0) !== 1) { - throw new RuntimeClientError( - 'invalid_argument', - 'Choose exactly one of --question or --resume.' - ) - } - if (resume && flags.has('options')) { - throw new RuntimeClientError( - 'invalid_argument', - '--options is only valid when creating a new question.' - ) - } - const result = await callMutation<{ - answer: string | null - messageId: string | null - threadId: string - timedOut: boolean - timeoutMs?: number - cancelled?: boolean - connectionLost?: boolean - answerMessageId?: string | null - legacyCompatibility?: LegacyCompatibilityResult - }>( - client, - flags, - 'orchestration.ask', - { - to: getOptionalStringFlag(flags, 'to'), - run: getOptionalStringFlag(flags, 'run'), - question, - resume, - options: getOptionalStringFlag(flags, 'options'), - timeoutMs: parsedTimeoutMs === undefined ? undefined : timeoutMs, - from, - compatibilityCliCommand: resolveCompatibilityCliCommand(), - compatibilityWindowsCommand: resolvePackagedWindowsCompatibilityCommand() - }, - // Why: extend past timeoutMs so the RPC transport's 60s default doesn't abort before the runtime's own timeout resolves. - { - timeoutMs: resolveOrchestrationAskClientTimeoutMs(parsedTimeoutMs), - orchestrationCapability: getOptionalStringFlag(flags, 'dispatch-capability') - } - ) - // Why: bypass printResult so --json emits a bare JSON object (no envelope) pipeable via `jq -r .answer`, unlike other verbs. - if (json) { - console.log(JSON.stringify(result.result)) - } else if (result.result.legacyCompatibility?.resumeRequired) { - console.log(`Question ${result.result.messageId} committed.`) - console.log(`Resume with: ${result.result.legacyCompatibility.resumeCommand}`) - } else if (result.result.answer !== null) { - console.log(result.result.answer) - } - if (result.result.legacyCompatibility?.resumeRequired) { - await flushStdout() - process.exitCode = 75 - return - } - const answerAck = result.result.legacyCompatibility?.answerAcknowledgement - if (answerAck && result.result.answer !== null) { - await flushStdout() - await client.call('orchestration.check', { - terminal: from, - compatibilityQuestionAck: JSON.stringify(answerAck) - }) - } - if (result.result.timedOut) { - if (!json) { - // Why: report the server's effective budget — it clamps large values, so the requested one would overstate the wait. - const waitedMs = result.result.timeoutMs ?? timeoutMs - console.error(`ask timeout after ${waitedMs}ms (thread ${result.result.threadId})`) - } - process.exitCode = 1 - } - if (result.result.cancelled) { - if (!json) { - console.error( - result.result.connectionLost - ? `ask connection closed (question ${result.result.messageId})` - : `ask cancelled (question ${result.result.messageId})` - ) - } - process.exitCode = 1 - } - }, - - 'orchestration dispatch-show': async ({ flags, client, cwd, json }) => { - const showPreamble = flags.has('preamble') ? true : undefined - // Why: resolve --from so the previewed preamble embeds a real coordinator handle like an actual dispatch. - const from = showPreamble - ? await resolveCoordinatorTerminalHandle(flags, cwd, client) - : undefined - const result = await client.call<{ - dispatch: { id: string; task_id: string; status: string } | null - preamble?: string - }>('orchestration.dispatchShow', { - task: getRequiredStringFlag(flags, 'task'), - preamble: showPreamble, - from, - devMode: isDevCliInvocation() - }) - printResult(result, json, (r) => { - if (r.preamble && showPreamble) { - return r.preamble - } - if (!r.dispatch) { - return 'No dispatch context found.' - } - return `${r.dispatch.id} task=${r.dispatch.task_id} [${r.dispatch.status}]` - }) - }, - - 'orchestration coordinator-start': async () => { - throw new RuntimeClientError( - 'orchestration_migration_required', - 'The legacy automatic coordinator command is retired. No effects were applied.', - orchestrationMigrationData('command_retired') - ) - }, - - 'orchestration coordinator-stop': async () => { - throw new RuntimeClientError( - 'orchestration_migration_required', - 'The legacy automatic coordinator command is retired. No effects were applied.', - orchestrationMigrationData('command_retired') - ) - }, - - 'orchestration gate-create': async ({ flags, client, cwd, json }) => { - const result = await callMutation<{ - gate: { id: string; task_id: string; status: string } - }>(client, flags, 'orchestration.gateCreate', { - task: getRequiredStringFlag(flags, 'task'), - question: getRequiredStringFlag(flags, 'question'), - options: getOptionalStringFlag(flags, 'options'), - // Why: gates are Run-scoped, so the coordinator handle is the caller identity the runtime authorizes against. - from: await resolveCoordinatorTerminalHandle(flags, cwd, client) - }) - printResult( - result, - json, - (r) => `Gate ${r.gate.id} created for task ${r.gate.task_id} [${r.gate.status}]` - ) - }, - - 'orchestration gate-resolve': async ({ flags, client, cwd, json }) => { - const result = await callMutation<{ - gate: { id: string; task_id: string; status: string; resolution: string } - }>(client, flags, 'orchestration.gateResolve', { - id: getRequiredStringFlag(flags, 'id'), - resolution: getRequiredStringFlag(flags, 'resolution'), - from: await resolveCoordinatorTerminalHandle(flags, cwd, client) - }) - printResult(result, json, (r) => `Gate ${r.gate.id} resolved: ${r.gate.resolution}`) - }, - - 'orchestration gate-list': async ({ flags, client, cwd, json }) => { - const run = getOptionalStringFlag(flags, 'run') - // Why: named runs remain inspectable without a pane; only implicit runs resolve identity. - const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await client.call<{ - gates: { id: string; task_id: string; question: string; status: string }[] - count: number - runId?: string - }>('orchestration.gateList', { - task: getOptionalStringFlag(flags, 'task'), - status: getOptionalStringFlag(flags, 'status'), - run, - from - }) - printResult(result, json, (r) => { - if (r.gates.length === 0) { - return 'No gates found.' - } - return r.gates - .map((g) => `${g.id} task=${g.task_id} [${g.status}] "${g.question}"`) - .join('\n') - }) - }, - - 'orchestration reset': async ({ flags, client, json }) => { - const scopeCount = [flags.has('all'), flags.has('tasks'), flags.has('messages')].filter( - Boolean - ).length - if (scopeCount !== 1) { - throw new RuntimeClientError( - 'invalid_argument', - 'Choose exactly one reset scope: --all, --tasks, or --messages.' - ) - } - const result = await callMutation<{ reset: string }>(client, flags, 'orchestration.reset', { - all: flags.has('all') ? true : undefined, - tasks: flags.has('tasks') ? true : undefined, - messages: flags.has('messages') ? true : undefined - }) - printResult(result, json, (r) => `Reset: ${r.reset}`) - } + ...ORCHESTRATION_RUN_HANDLERS, + ...ORCHESTRATION_SEND_HANDLER, + ...ORCHESTRATION_CHECK_HANDLER, + ...ORCHESTRATION_INBOX_HANDLERS, + ...ORCHESTRATION_TASK_HANDLERS, + ...ORCHESTRATION_WORKER_LAUNCH_HANDLER, + ...ORCHESTRATION_WORKER_OBSERVATION_HANDLERS, + ...ORCHESTRATION_WORKER_TERMINAL_HANDLERS, + ...ORCHESTRATION_DISPATCH_HANDLER, + ...ORCHESTRATION_QUESTION_HANDLER, + ...ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS, + ...ORCHESTRATION_GATE_HANDLERS, + ...ORCHESTRATION_RESET_HANDLER } diff --git a/src/cli/handlers/orchestration/check-keepalive.ts b/src/cli/handlers/orchestration/check-keepalive.ts new file mode 100644 index 00000000000..a87f6065428 --- /dev/null +++ b/src/cli/handlers/orchestration/check-keepalive.ts @@ -0,0 +1,32 @@ +const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000 + +// Why: test-only escape hatch so subprocess tests avoid the full 15 s window; bogus values fall back to the default. +function resolveKeepaliveIntervalMs(): number { + const raw = process.env.ORCA_KEEPALIVE_INTERVAL_MS ?? process.env.ORCA_HEARTBEAT_INTERVAL_MS + if (!raw) { + return DEFAULT_KEEPALIVE_INTERVAL_MS + } + const parsed = Number(raw) + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_KEEPALIVE_INTERVAL_MS + } + return parsed +} + +export function startCheckKeepalive(deadlineMs: number | undefined): () => void { + const startedAt = Date.now() + const interval = setInterval(() => { + const payload = { + _keepalive: true, + // Why: retain the old marker for scripts still filtering it while callers migrate to _keepalive. + _heartbeat: true, + elapsedMs: Date.now() - startedAt, + deadlineMs: deadlineMs ?? null + } + process.stderr.write(`${JSON.stringify(payload)}\n`) + }, resolveKeepaliveIntervalMs()) + if (typeof interval.unref === 'function') { + interval.unref() + } + return () => clearInterval(interval) +} diff --git a/src/cli/handlers/orchestration/dispatch-handlers.ts b/src/cli/handlers/orchestration/dispatch-handlers.ts new file mode 100644 index 00000000000..afe79ab8e2f --- /dev/null +++ b/src/cli/handlers/orchestration/dispatch-handlers.ts @@ -0,0 +1,84 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { orchestrationMigrationData } from '../../../shared/orchestration-rpc-contract' +import { callOrchestrationMutation } from './mutation-request' +import { isDevCliInvocation } from './runtime-compatibility' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_DISPATCH_HANDLER: Record = { + 'orchestration dispatch': async ({ flags, client, cwd, json }) => { + const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const dryRun = flags.has('dry-run') ? true : undefined + const returnPreamble = flags.has('return-preamble') ? true : undefined + // Why: --to is only required for non-dry-run; the RPC handler re-enforces. + const to = dryRun ? getOptionalStringFlag(flags, 'to') : getRequiredStringFlag(flags, 'to') + const result = await callOrchestrationMutation<{ + dispatch: { id: string; task_id: string; status: string } | null + injected?: boolean + dryRun?: boolean + preamble?: string + }>(client, flags, 'orchestration.dispatch', { + task: getRequiredStringFlag(flags, 'task'), + run: getOptionalStringFlag(flags, 'run'), + to, + from, + inject: flags.has('inject') ? true : undefined, + dryRun, + returnPreamble, + devMode: isDevCliInvocation() + }) + printResult(result, json, (value) => { + if (value.dryRun) { + return value.preamble ?? '' + } + const base = `Dispatched ${value.dispatch?.task_id} -> ${value.dispatch?.id} [${value.dispatch?.status}]` + return value.preamble ? `${base}\n\n--- Preamble ---\n${value.preamble}` : base + }) + } +} + +export const ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS: Record = { + 'orchestration dispatch-show': async ({ flags, client, cwd, json }) => { + const showPreamble = flags.has('preamble') ? true : undefined + // Why: a preview must embed the same real coordinator handle as an actual dispatch. + const from = showPreamble + ? await resolveCoordinatorTerminalHandle(flags, cwd, client) + : undefined + const result = await client.call<{ + dispatch: { id: string; task_id: string; status: string } | null + preamble?: string + }>('orchestration.dispatchShow', { + task: getRequiredStringFlag(flags, 'task'), + preamble: showPreamble, + from, + devMode: isDevCliInvocation() + }) + printResult(result, json, (value) => { + if (value.preamble && showPreamble) { + return value.preamble + } + if (!value.dispatch) { + return 'No dispatch context found.' + } + return `${value.dispatch.id} task=${value.dispatch.task_id} [${value.dispatch.status}]` + }) + }, + + 'orchestration coordinator-start': async () => { + throw new RuntimeClientError( + 'orchestration_migration_required', + 'The legacy automatic coordinator command is retired. No effects were applied.', + orchestrationMigrationData('command_retired') + ) + }, + + 'orchestration coordinator-stop': async () => { + throw new RuntimeClientError( + 'orchestration_migration_required', + 'The legacy automatic coordinator command is retired. No effects were applied.', + orchestrationMigrationData('command_retired') + ) + } +} diff --git a/src/cli/handlers/orchestration/gate-handlers.ts b/src/cli/handlers/orchestration/gate-handlers.ts new file mode 100644 index 00000000000..27d3cc1e3b7 --- /dev/null +++ b/src/cli/handlers/orchestration/gate-handlers.ts @@ -0,0 +1,60 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { callOrchestrationMutation } from './mutation-request' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_GATE_HANDLERS: Record = { + 'orchestration gate-create': async ({ flags, client, cwd, json }) => { + const result = await callOrchestrationMutation<{ + gate: { id: string; task_id: string; status: string } + }>(client, flags, 'orchestration.gateCreate', { + task: getRequiredStringFlag(flags, 'task'), + question: getRequiredStringFlag(flags, 'question'), + options: getOptionalStringFlag(flags, 'options'), + // Why: gates are Run-scoped, so the coordinator handle is the authorized caller identity. + from: await resolveCoordinatorTerminalHandle(flags, cwd, client) + }) + printResult( + result, + json, + (value) => + `Gate ${value.gate.id} created for task ${value.gate.task_id} [${value.gate.status}]` + ) + }, + + 'orchestration gate-resolve': async ({ flags, client, cwd, json }) => { + const result = await callOrchestrationMutation<{ + gate: { id: string; task_id: string; status: string; resolution: string } + }>(client, flags, 'orchestration.gateResolve', { + id: getRequiredStringFlag(flags, 'id'), + resolution: getRequiredStringFlag(flags, 'resolution'), + from: await resolveCoordinatorTerminalHandle(flags, cwd, client) + }) + printResult(result, json, (value) => `Gate ${value.gate.id} resolved: ${value.gate.resolution}`) + }, + + 'orchestration gate-list': async ({ flags, client, cwd, json }) => { + const run = getOptionalStringFlag(flags, 'run') + // Why: named runs remain inspectable without a pane; only implicit runs resolve identity. + const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await client.call<{ + gates: { id: string; task_id: string; question: string; status: string }[] + count: number + runId?: string + }>('orchestration.gateList', { + task: getOptionalStringFlag(flags, 'task'), + status: getOptionalStringFlag(flags, 'status'), + run, + from + }) + printResult(result, json, (value) => { + if (value.gates.length === 0) { + return 'No gates found.' + } + return value.gates + .map((gate) => `${gate.id} task=${gate.task_id} [${gate.status}] "${gate.question}"`) + .join('\n') + }) + } +} diff --git a/src/cli/handlers/orchestration/message-check-handler.ts b/src/cli/handlers/orchestration/message-check-handler.ts new file mode 100644 index 00000000000..f2af827ee04 --- /dev/null +++ b/src/cli/handlers/orchestration/message-check-handler.ts @@ -0,0 +1,120 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import type { RuntimeRpcSuccess } from '../../runtime-client' +import { + formatOrchestrationCheckText, + prepareOrchestrationCheckOutput, + type LegacyCompatibilityResult, + type OrchestrationMessageSummary as MessageSummary +} from '../../../shared/orchestration-check-output' +import { startCheckKeepalive } from './check-keepalive' +import { callOrchestrationMutation } from './mutation-request' +import { getOptionalPositiveIntegerValueFlag } from './numeric-flags' +import { flushOrchestrationStdout, resolveCompatibilityCliCommand } from './runtime-compatibility' +import { resolveOrchestrationTerminalHandle } from './terminal-identity' + +type CheckResult = { + messages: MessageSummary[] + count: number + formatted?: string + deliveryId?: string | null + runId?: string + timedOut?: boolean + cancelled?: boolean + connectionLost?: boolean + legacyCompatibility?: LegacyCompatibilityResult +} + +export const ORCHESTRATION_CHECK_HANDLER: Record = { + 'orchestration check': async ({ flags, client, cwd, json }) => { + const wait = flags.has('wait') + const peek = flags.has('peek') + // Why: older runtimes strip unknown peek and run --unread --peek as destructive mark-read. + if ([flags.has('unread'), peek, flags.has('all')].filter(Boolean).length > 1) { + throw new RuntimeClientError( + 'invalid_argument', + 'Choose at most one message read mode: --unread, --peek, or --all.' + ) + } + const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') + const explicitTerminal = getOptionalStringFlag(flags, 'terminal') + const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') + const stopKeepalive = wait ? startCheckKeepalive(timeoutMs) : null + let result: Awaited>> + try { + result = await callOrchestrationMutation(client, flags, 'orchestration.check', { + terminal, + terminalPaneKey: explicitTerminal ? undefined : process.env.ORCA_PANE_KEY || undefined, + // Why: old runtimes degrade peek to non-consuming all mode instead of destructive mark-read. + unread: flags.has('unread') ? true : peek ? false : undefined, + peek: peek ? true : undefined, + all: flags.has('all') ? true : undefined, + types: getOptionalStringFlag(flags, 'types'), + format: flags.has('format') ? true : undefined, + inject: flags.has('inject') ? true : undefined, + compatibilityCliCommand: resolveCompatibilityCliCommand(), + run: getOptionalStringFlag(flags, 'run'), + ack: getOptionalStringFlag(flags, 'ack'), + wait: wait ? true : undefined, + timeoutMs + }) + } finally { + stopKeepalive?.() + } + if (peek) { + result = filterLegacyPeekResult(result, wait) + } + result = { + ...result, + result: prepareOrchestrationCheckOutput(result.result, terminal, flags.has('format')) + } + printResult(result, json, (value) => formatOrchestrationCheckText(value, terminal)) + const compatibilityAck = result.result.legacyCompatibility?.ackMessageIds + if (compatibilityAck && compatibilityAck.length > 0) { + await flushOrchestrationStdout() + await client.call('orchestration.check', { + terminal, + compatibilityAck: JSON.stringify({ + messageIds: compatibilityAck, + types: getOptionalStringFlag(flags, 'types') + ?.split(',') + .map((type) => type.trim()) + .filter(Boolean) + }) + }) + } + } +} + +function filterLegacyPeekResult( + result: RuntimeRpcSuccess, + wait: boolean +): RuntimeRpcSuccess { + const rawRowCount = result.result.messages.length + const unreadOnly = result.result.messages.filter((message) => message.read !== 1) + const removedReadRows = unreadOnly.length !== rawRowCount + // Why: read rows prove a pre-peek runtime cannot honor wait, so fail instead of returning early. + if (wait && removedReadRows && unreadOnly.length === 0) { + throw new RuntimeClientError( + 'peek_wait_unsupported', + 'The connected runtime does not support --peek with --wait; upgrade the runtime or use --wait without --peek.' + ) + } + if (removedReadRows && rawRowCount >= 100) { + console.error( + 'Warning: this runtime returned only its newest 100 messages for --peek; older unread messages may be missing. Upgrade the runtime for exact peek results.' + ) + } + return { + ...result, + result: { + ...result.result, + // Why: a pre-peek runtime formats all rows, which no longer matches the filtered set. + ...(removedReadRows ? { formatted: undefined } : {}), + messages: unreadOnly, + count: unreadOnly.length + } + } +} diff --git a/src/cli/handlers/orchestration/message-inbox-handlers.ts b/src/cli/handlers/orchestration/message-inbox-handlers.ts new file mode 100644 index 00000000000..c15c4edfafa --- /dev/null +++ b/src/cli/handlers/orchestration/message-inbox-handlers.ts @@ -0,0 +1,64 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../../flags' +import { + formatMessageReadOnlyTag, + type OrchestrationMessageSummary as MessageSummary +} from '../../../shared/orchestration-check-output' +import { callOrchestrationMutation } from './mutation-request' +import { resolveOrchestrationTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_INBOX_HANDLERS: Record = { + 'orchestration reply': async ({ flags, client, cwd, json }) => { + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + const result = await callOrchestrationMutation<{ message: { id: string } }>( + client, + flags, + 'orchestration.reply', + { + id: getRequiredStringFlag(flags, 'id'), + body: getRequiredStringFlag(flags, 'body'), + run: getOptionalStringFlag(flags, 'run'), + from + } + ) + printResult(result, json, (value) => `Replied ${value.message.id}`) + }, + + 'orchestration inbox': async ({ flags, client, json }) => { + const full = flags.has('full') + const result = await client.call<{ + messages: MessageSummary[] + count: number + }>('orchestration.inbox', { + limit: getOptionalPositiveIntegerFlag(flags, 'limit'), + terminal: getOptionalStringFlag(flags, 'terminal') + }) + printResult(result, json, (value) => { + if (value.count === 0) { + return 'No messages.' + } + // Why: default output omits body/payload for at-a-glance sweeps; --full prints them for auditing. + return value.messages + .map((message) => { + const head = `${message.id}${formatMessageReadOnlyTag(message)} ${message.from_handle} -> ${message.to_handle ?? '?'}: "${message.subject}"` + if (!full) { + return head + } + const parts = [head] + if (message.body && message.body.length > 0) { + parts.push(message.body) + } + if (message.payload) { + parts.push(`[payload] ${message.payload}`) + } + return parts.join('\n') + }) + .join(full ? '\n\n' : '\n') + }) + } +} diff --git a/src/cli/handlers/orchestration/message-payload.ts b/src/cli/handlers/orchestration/message-payload.ts new file mode 100644 index 00000000000..3c1e36b2ed8 --- /dev/null +++ b/src/cli/handlers/orchestration/message-payload.ts @@ -0,0 +1,60 @@ +import { getOptionalStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' + +export function getOptionalStructuredMessagePayload( + flags: Map +): string | undefined { + const rawPayload = getOptionalStringFlag(flags, 'payload') + const taskId = getOptionalStringFlag(flags, 'task-id') + const dispatchId = getOptionalStringFlag(flags, 'dispatch-id') + const outcome = getOptionalStringFlag(flags, 'outcome') + const filesModified = getOptionalStringFlag(flags, 'files-modified') + const reportPath = getOptionalStringFlag(flags, 'report-path') + const phase = getOptionalStringFlag(flags, 'phase') + const hasStructuredPayload = + taskId !== undefined || + dispatchId !== undefined || + outcome !== undefined || + filesModified !== undefined || + reportPath !== undefined || + phase !== undefined + if (!hasStructuredPayload) { + return rawPayload + } + if (rawPayload !== undefined) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --payload or structured payload flags, not both.' + ) + } + // Why: raw JSON args are fragile in Windows PowerShell; these flags avoid shell-specific quoting. + const payload: Record = {} + if (taskId) { + payload.taskId = taskId + } + if (dispatchId) { + payload.dispatchId = dispatchId + } + if (outcome) { + if (outcome !== 'succeeded' && outcome !== 'failed') { + throw new RuntimeClientError( + 'invalid_argument', + 'Invalid --outcome. Expected succeeded or failed.' + ) + } + payload.outcome = outcome + } + if (filesModified) { + payload.filesModified = filesModified + .split(',') + .map((file) => file.trim()) + .filter(Boolean) + } + if (reportPath) { + payload.reportPath = reportPath + } + if (phase) { + payload.phase = phase + } + return JSON.stringify(payload) +} diff --git a/src/cli/handlers/orchestration/message-send-handler.ts b/src/cli/handlers/orchestration/message-send-handler.ts new file mode 100644 index 00000000000..088d1361e69 --- /dev/null +++ b/src/cli/handlers/orchestration/message-send-handler.ts @@ -0,0 +1,137 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { requireWorkerDoneSettlement } from '../orchestration-worker-settlement' +import { getOptionalStructuredMessagePayload } from './message-payload' +import { callOrchestrationMutation } from './mutation-request' +import { isDevCliInvocation } from './runtime-compatibility' +import { + resolveOrchestrationTerminalHandle, + throwNoActiveSenderTerminal +} from './terminal-identity' + +type LifecycleSendResult = + | { + action: 'completed' | 'failed' + authority?: 'run_home' | 'worker_server_legacy' + } + | { action: 'settled'; outcome: 'succeeded' | 'failed'; duplicate?: boolean } + | { action: 'rejected'; code: string; reason: string } + +type SendRecipientWarning = { + code: string + recipient: string + message: string +} + +type OrchestrationSendResult = + | { + message: { id: string; run_id?: string } + lifecycle?: LifecycleSendResult + warnings?: SendRecipientWarning[] + } + | { + messages: { id: string }[] + recipients: number + warnings?: SendRecipientWarning[] + } + | { + relay: { + messageId: string + sequence: number + dispatchId: string + destination?: 'run_home' | 'worker' + accepted: true + } + lifecycle?: LifecycleSendResult + warnings?: SendRecipientWarning[] + } + +function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { + return `${type} messages belong to one exact Dispatch and cannot target a group address.` +} + +function rejectLifecycleGroupRecipient(type: string | undefined, to: string): void { + if ((type === 'worker_done' || type === 'heartbeat') && to.startsWith('@')) { + throw new RuntimeClientError('invalid_argument', getLifecycleGroupRecipientError(type)) + } +} + +export const ORCHESTRATION_SEND_HANDLER: Record = { + 'orchestration send': async ({ flags, client, cwd, json }) => { + const to = getOptionalStringFlag(flags, 'to') + const type = getOptionalStringFlag(flags, 'type') + if (to) { + rejectLifecycleGroupRecipient(type, to) + } + const outcome = getOptionalStringFlag(flags, 'outcome') + if (type !== 'worker_done' && outcome !== undefined) { + throw new RuntimeClientError( + 'invalid_argument', + '--outcome is only valid with --type worker_done.' + ) + } + if ( + (type === 'worker_done' || type === 'heartbeat') && + !getOptionalStringFlag(flags, 'from') && + !process.env.ORCA_TERMINAL_HANDLE + ) { + // Why: focus isn't lifecycle authority — an identity-less subprocess must fail closed rather than guess the worker. + throwNoActiveSenderTerminal() + } + + // Why: lifecycle senders preserve ORCA_TERMINAL_HANDLE across restarts for older runtimes. + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + const sendParams = { + from, + to, + run: getOptionalStringFlag(flags, 'run'), + subject: getRequiredStringFlag(flags, 'subject'), + body: getOptionalStringFlag(flags, 'body'), + type, + priority: getOptionalStringFlag(flags, 'priority'), + threadId: getOptionalStringFlag(flags, 'thread-id'), + payload: getOptionalStructuredMessagePayload(flags), + // Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it. + senderPaneKey: process.env.ORCA_PANE_KEY || undefined, + waitForLifecycleSettlement: type === 'worker_done' ? true : undefined, + devMode: isDevCliInvocation() + } + const dispatchCapability = getOptionalStringFlag(flags, 'dispatch-capability') + const result = await callOrchestrationMutation( + client, + flags, + 'orchestration.send', + sendParams, + dispatchCapability ? { orchestrationCapability: dispatchCapability } : undefined + ) + await requireWorkerDoneSettlement(client, type, sendParams.payload, result.result) + if ('lifecycle' in result.result && result.result.lifecycle?.action === 'rejected') { + throw new RuntimeClientError(result.result.lifecycle.code, result.result.lifecycle.reason) + } + printResult(result, json, (value) => { + const warnings = 'warnings' in value ? (value.warnings ?? []) : [] + const withWarnings = (line: string): string => + warnings.length > 0 + ? [line, ...warnings.map((warning) => `Warning: ${warning.message}`)].join('\n') + : line + if ('message' in value) { + return withWarnings(`Sent ${value.message.id}`) + } + if ('relay' in value) { + if (value.relay.destination === 'worker') { + return withWarnings( + `Queued ${value.relay.messageId} for worker Dispatch ${value.relay.dispatchId}` + ) + } + return withWarnings( + `Queued ${value.relay.messageId} for Run home (Dispatch ${value.relay.dispatchId})` + ) + } + return withWarnings( + `Sent ${value.messages.length} messages to ${value.recipients} recipients` + ) + }) + } +} diff --git a/src/cli/handlers/orchestration/mutation-request.ts b/src/cli/handlers/orchestration/mutation-request.ts new file mode 100644 index 00000000000..98b44e4046c --- /dev/null +++ b/src/cli/handlers/orchestration/mutation-request.ts @@ -0,0 +1,21 @@ +import type { RuntimeClient } from '../../runtime-client' +import { getOptionalStringFlag } from '../../flags' +import { orchestrationMutationRecoveryError } from '../../orchestration-mutation-recovery' + +export function callOrchestrationMutation( + client: RuntimeClient, + flags: Map, + method: string, + params: unknown, + options?: { timeoutMs?: number; orchestrationCapability?: string } +) { + const requestId = getOptionalStringFlag(flags, 'retry-request') + const result = requestId + ? client.call(method, params, { ...options, orchestrationRequestId: requestId }) + : options + ? client.call(method, params, options) + : client.call(method, params) + return result.catch((error) => { + throw orchestrationMutationRecoveryError(error) + }) +} diff --git a/src/cli/handlers/orchestration/numeric-flags.ts b/src/cli/handlers/orchestration/numeric-flags.ts new file mode 100644 index 00000000000..9d3a142150c --- /dev/null +++ b/src/cli/handlers/orchestration/numeric-flags.ts @@ -0,0 +1,23 @@ +import { parsePositiveSafeIntegerText } from '../../../shared/timer-delay' +import { RuntimeClientError } from '../../runtime-client' + +export function getOptionalPositiveIntegerValueFlag( + flags: Map, + name: string +): number | undefined { + if (!flags.has(name)) { + return undefined + } + const raw = flags.get(name) + if (typeof raw !== 'string' || raw.length === 0) { + throw new RuntimeClientError('invalid_argument', `Missing value for --${name}.`) + } + const value = parsePositiveSafeIntegerText(raw) + if (value === null) { + throw new RuntimeClientError( + 'invalid_argument', + `Invalid positive safe integer for --${name}: ${raw}` + ) + } + return value +} diff --git a/src/cli/handlers/orchestration/question-handler.ts b/src/cli/handlers/orchestration/question-handler.ts new file mode 100644 index 00000000000..1801f7f9b3c --- /dev/null +++ b/src/cli/handlers/orchestration/question-handler.ts @@ -0,0 +1,109 @@ +import type { CommandHandler } from '../../dispatch' +import { getOptionalStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { + clampOrchestrationAskTimeoutMs, + resolveOrchestrationAskClientTimeoutMs +} from '../../../shared/orchestration-ask-timeout' +import type { LegacyCompatibilityResult } from '../../../shared/orchestration-check-output' +import { callOrchestrationMutation } from './mutation-request' +import { getOptionalPositiveIntegerValueFlag } from './numeric-flags' +import { + flushOrchestrationStdout, + resolveCompatibilityCliCommand, + resolvePackagedWindowsCompatibilityCommand +} from './runtime-compatibility' +import { resolveOrchestrationTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_QUESTION_HANDLER: Record = { + 'orchestration ask': async ({ flags, client, cwd, json }) => { + const parsedTimeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') + const timeoutMs = clampOrchestrationAskTimeoutMs(parsedTimeoutMs) + const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') + const question = getOptionalStringFlag(flags, 'question') + const resume = getOptionalStringFlag(flags, 'resume') + if ((question ? 1 : 0) + (resume ? 1 : 0) !== 1) { + throw new RuntimeClientError( + 'invalid_argument', + 'Choose exactly one of --question or --resume.' + ) + } + if (resume && flags.has('options')) { + throw new RuntimeClientError( + 'invalid_argument', + '--options is only valid when creating a new question.' + ) + } + const result = await callOrchestrationMutation<{ + answer: string | null + messageId: string | null + threadId: string + timedOut: boolean + timeoutMs?: number + cancelled?: boolean + connectionLost?: boolean + answerMessageId?: string | null + legacyCompatibility?: LegacyCompatibilityResult + }>( + client, + flags, + 'orchestration.ask', + { + to: getOptionalStringFlag(flags, 'to'), + run: getOptionalStringFlag(flags, 'run'), + question, + resume, + options: getOptionalStringFlag(flags, 'options'), + timeoutMs: parsedTimeoutMs === undefined ? undefined : timeoutMs, + from, + compatibilityCliCommand: resolveCompatibilityCliCommand(), + compatibilityWindowsCommand: resolvePackagedWindowsCompatibilityCommand() + }, + // Why: extend past timeoutMs so transport does not abort before the runtime resolves its timeout. + { + timeoutMs: resolveOrchestrationAskClientTimeoutMs(parsedTimeoutMs), + orchestrationCapability: getOptionalStringFlag(flags, 'dispatch-capability') + } + ) + // Why: ask JSON is intentionally a bare object for `jq -r .answer`, unlike other verbs. + if (json) { + console.log(JSON.stringify(result.result)) + } else if (result.result.legacyCompatibility?.resumeRequired) { + console.log(`Question ${result.result.messageId} committed.`) + console.log(`Resume with: ${result.result.legacyCompatibility.resumeCommand}`) + } else if (result.result.answer !== null) { + console.log(result.result.answer) + } + if (result.result.legacyCompatibility?.resumeRequired) { + await flushOrchestrationStdout() + process.exitCode = 75 + return + } + const answerAck = result.result.legacyCompatibility?.answerAcknowledgement + if (answerAck && result.result.answer !== null) { + await flushOrchestrationStdout() + await client.call('orchestration.check', { + terminal: from, + compatibilityQuestionAck: JSON.stringify(answerAck) + }) + } + if (result.result.timedOut) { + if (!json) { + // Why: report the server's clamped effective budget rather than overstating the wait. + const waitedMs = result.result.timeoutMs ?? timeoutMs + console.error(`ask timeout after ${waitedMs}ms (thread ${result.result.threadId})`) + } + process.exitCode = 1 + } + if (result.result.cancelled) { + if (!json) { + console.error( + result.result.connectionLost + ? `ask connection closed (question ${result.result.messageId})` + : `ask cancelled (question ${result.result.messageId})` + ) + } + process.exitCode = 1 + } + } +} diff --git a/src/cli/handlers/orchestration/reset-handler.ts b/src/cli/handlers/orchestration/reset-handler.ts new file mode 100644 index 00000000000..536cc9e8933 --- /dev/null +++ b/src/cli/handlers/orchestration/reset-handler.ts @@ -0,0 +1,29 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { RuntimeClientError } from '../../runtime-client' +import { callOrchestrationMutation } from './mutation-request' + +export const ORCHESTRATION_RESET_HANDLER: Record = { + 'orchestration reset': async ({ flags, client, json }) => { + const scopeCount = [flags.has('all'), flags.has('tasks'), flags.has('messages')].filter( + Boolean + ).length + if (scopeCount !== 1) { + throw new RuntimeClientError( + 'invalid_argument', + 'Choose exactly one reset scope: --all, --tasks, or --messages.' + ) + } + const result = await callOrchestrationMutation<{ reset: string }>( + client, + flags, + 'orchestration.reset', + { + all: flags.has('all') ? true : undefined, + tasks: flags.has('tasks') ? true : undefined, + messages: flags.has('messages') ? true : undefined + } + ) + printResult(result, json, (value) => `Reset: ${value.reset}`) + } +} diff --git a/src/cli/handlers/orchestration/run-handlers.ts b/src/cli/handlers/orchestration/run-handlers.ts new file mode 100644 index 00000000000..7c77913df15 --- /dev/null +++ b/src/cli/handlers/orchestration/run-handlers.ts @@ -0,0 +1,85 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../../flags' +import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../shared/orchestration-run-pagination' +import { callOrchestrationMutation } from './mutation-request' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_RUN_HANDLERS: Record = { + 'orchestration run-create': async ({ flags, client, cwd, json }) => { + const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await callOrchestrationMutation<{ + run: { id: string; objective: string; consumer_generation: number } + }>(client, flags, 'orchestration.runCreate', { + objective: getRequiredStringFlag(flags, 'objective'), + from + }) + printResult(result, json, (r) => `Run ${r.run.id} created and bound: ${r.run.objective}`) + }, + + 'orchestration run-use': async ({ flags, client, cwd, json }) => { + const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await callOrchestrationMutation<{ + run: { id: string; objective: string; consumer_generation: number } + }>(client, flags, 'orchestration.runUse', { + id: getRequiredStringFlag(flags, 'id'), + from, + ...(flags.has('takeover-legacy') ? { takeoverLegacy: true } : {}) + }) + printResult(result, json, (r) => `Using Run ${r.run.id}: ${r.run.objective}`) + }, + + 'orchestration run-current': async ({ flags, client, cwd, json }) => { + const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await client.call<{ + run: { id: string; objective: string } | null + }>('orchestration.runCurrent', { from }) + printResult(result, json, (r) => + r.run ? `${r.run.id} ${r.run.objective}` : 'No Run is bound to this terminal.' + ) + }, + + 'orchestration run-list': async ({ flags, client, json }) => { + const result = await client.call<{ + runs: { id: string; objective: string; legacy: number }[] + nextCursor: string | null + }>('orchestration.runList', { + limit: getOptionalPositiveIntegerFlag(flags, 'limit') ?? ORCHESTRATION_RUN_PAGE_LIMIT, + cursor: getOptionalStringFlag(flags, 'cursor') + }) + printResult(result, json, (r) => { + const rows = + r.runs.length === 0 + ? 'No Runs found.' + : r.runs + .map( + (run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}` + ) + .join('\n') + return r.nextCursor ? `${rows}\nMore Runs: --cursor ${r.nextCursor}` : rows + }) + }, + + 'orchestration run-show': async ({ flags, client, json }) => { + const result = await client.call<{ + run: { + id: string + objective: string + consumer_generation: number + legacy: number + created_at: string + } + }>('orchestration.runShow', { id: getRequiredStringFlag(flags, 'id') }) + printResult( + result, + json, + (r) => + `${r.run.id}${r.run.legacy ? ' [legacy, inspect only]' : ''} ${r.run.objective}\n` + + `consumer generation ${r.run.consumer_generation}; created ${r.run.created_at}` + ) + } +} diff --git a/src/cli/handlers/orchestration/runtime-compatibility.ts b/src/cli/handlers/orchestration/runtime-compatibility.ts new file mode 100644 index 00000000000..e4076534daa --- /dev/null +++ b/src/cli/handlers/orchestration/runtime-compatibility.ts @@ -0,0 +1,42 @@ +import { RuntimeClientError } from '../../runtime-client' + +export function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' { + const configured = process.env.ORCA_CLI_COMMAND + if (configured === 'orca' || configured === 'orca-ide' || configured === 'orca-dev') { + return configured + } + return process.platform === 'linux' ? 'orca-ide' : 'orca' +} + +export function resolvePackagedWindowsCompatibilityCommand(): 'orca' | 'orca-ide' | undefined { + if (process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER !== '1') { + return undefined + } + const command = process.env.ORCA_CLI_COMMAND + if (command === 'orca' || command === 'orca-ide') { + return command + } + throw new RuntimeClientError( + 'invalid_argument', + 'The packaged Orca launcher did not provide a valid resume command. No question was created.' + ) +} + +export async function flushOrchestrationStdout(): Promise { + await new Promise((resolve, reject) => { + process.stdout.write('', (error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) +} + +export function isDevCliInvocation(): boolean { + return ( + process.env.ORCA_DEV_CLI_INVOCATION === '1' || + (process.env.ORCA_USER_DATA_PATH?.includes('orca-dev') ?? false) + ) +} diff --git a/src/cli/handlers/orchestration/task-handlers.ts b/src/cli/handlers/orchestration/task-handlers.ts new file mode 100644 index 00000000000..c4c943261e8 --- /dev/null +++ b/src/cli/handlers/orchestration/task-handlers.ts @@ -0,0 +1,114 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { abbreviateOrchestrationTasks } from '../../../shared/orchestration-task-summary' +import { callOrchestrationMutation } from './mutation-request' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' + +const TASK_STATUS_VALUES = [ + 'pending', + 'ready', + 'dispatched', + 'completed', + 'failed', + 'blocked' +] as const + +export const ORCHESTRATION_TASK_HANDLERS: Record = { + 'orchestration task-create': async ({ flags, client, cwd, json }) => { + const callerTerminalHandle = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await callOrchestrationMutation<{ task: { id: string; status: string } }>( + client, + flags, + 'orchestration.taskCreate', + { + spec: getRequiredStringFlag(flags, 'spec'), + taskTitle: getOptionalStringFlag(flags, 'task-title'), + displayName: getOptionalStringFlag(flags, 'display-name'), + deps: getOptionalStringFlag(flags, 'deps'), + parent: getOptionalStringFlag(flags, 'parent'), + run: getOptionalStringFlag(flags, 'run'), + callerTerminalHandle + } + ) + printResult(result, json, (r) => `Created ${r.task.id} [${r.task.status}]`) + }, + + 'orchestration task-list': async ({ flags, client, cwd, json }) => { + const brief = flags.has('brief') + const run = getOptionalStringFlag(flags, 'run') + const callerTerminalHandle = run + ? undefined + : await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await client.call<{ + tasks: { + id: string + spec: string + task_title?: string | null + display_name?: string | null + status: string + assignee_handle?: string | null + dispatch_id?: string | null + spec_truncated?: boolean + }[] + count: number + runId?: string + legacyReadOnly?: boolean + }>('orchestration.taskList', { + status: getOptionalStringFlag(flags, 'status'), + ready: flags.has('ready') ? true : undefined, + brief: brief ? true : undefined, + run, + callerTerminalHandle + }) + // Why: only older runtimes (no spec_truncated) skip server-side abbreviation and need this client-side fallback. + const needsClientAbbreviation = + brief && result.result.tasks.some((task) => task.spec_truncated === undefined) + const output = needsClientAbbreviation + ? { + ...result, + result: { ...result.result, tasks: abbreviateOrchestrationTasks(result.result.tasks) } + } + : result + printResult(output, json, (r) => { + if (r.count === 0) { + return r.legacyReadOnly ? 'No legacy tasks (read-only).' : 'No tasks.' + } + const tasks = r.tasks + .map((task) => { + const label = task.display_name ?? task.task_title ?? task.spec + const head = `${task.id} [${task.status}] ${label.slice(0, 60)}` + if (task.status === 'dispatched' && task.assignee_handle) { + return `${head} -> ${task.assignee_handle} (${task.dispatch_id ?? '?'})` + } + return head + }) + .join('\n') + return r.legacyReadOnly ? `Legacy Run ${r.runId} (read-only)\n${tasks}` : tasks + }) + }, + + 'orchestration task-update': async ({ flags, client, cwd, json }) => { + const status = getRequiredStringFlag(flags, 'status') + if (!TASK_STATUS_VALUES.includes(status as (typeof TASK_STATUS_VALUES)[number])) { + throw new RuntimeClientError( + 'invalid_argument', + `invalid status '${status}', expected one of: ${TASK_STATUS_VALUES.join(', ')}` + ) + } + const result = await callOrchestrationMutation<{ task: { id: string; status: string } }>( + client, + flags, + 'orchestration.taskUpdate', + { + id: getRequiredStringFlag(flags, 'id'), + status, + result: getOptionalStringFlag(flags, 'result'), + run: getOptionalStringFlag(flags, 'run'), + callerTerminalHandle: await resolveCoordinatorTerminalHandle(flags, cwd, client) + } + ) + printResult(result, json, (r) => `Updated ${r.task.id} -> ${r.task.status}`) + } +} diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts new file mode 100644 index 00000000000..5da11afac1d --- /dev/null +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -0,0 +1,151 @@ +import type { RuntimeClient } from '../../runtime-client' +import { getOptionalStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { getTerminalHandle } from '../../selectors' + +export async function resolveOrchestrationTerminalHandle( + flags: Map, + cwd: string, + client: RuntimeClient, + flagName: 'from' | 'terminal', + options: { validateEnvHandle?: boolean } = {} +): Promise { + const explicit = getOptionalStringFlag(flags, flagName) + if (explicit) { + return explicit + } + const envHandle = process.env.ORCA_TERMINAL_HANDLE + if (envHandle && envHandle.length > 0) { + if (flagName === 'from' && options.validateEnvHandle) { + // Why: long-lived shells can retain a stale ORCA_TERMINAL_HANDLE after remint; don't bake it into coordinator preambles. + const live = await isLiveTerminalHandle(envHandle, client) + if (!live) { + const reminted = await resolveOrchestrationPaneTerminalHandle(client) + if (reminted) { + return reminted + } + throwNoActiveSenderTerminal() + } + } + return envHandle + } + if (flagName === 'from') { + return await resolveImplicitOrchestrationSender(flags, cwd, client) + } + return await getTerminalHandle(flags, cwd, client) +} + +async function isLiveTerminalHandle(handle: string, client: RuntimeClient): Promise { + try { + await client.call('terminal.show', { terminal: handle }) + return true + } catch (err) { + if (isStaleTerminalIdentityError(err)) { + return false + } + throw err + } +} + +function getClientErrorCode(err: unknown): string | undefined { + if (!err || typeof err !== 'object') { + return undefined + } + const code = (err as { code?: unknown }).code + return typeof code === 'string' ? code : undefined +} + +function isStaleTerminalIdentityError(err: unknown): boolean { + const code = getClientErrorCode(err) + return code === 'terminal_handle_stale' || code === 'terminal_gone' +} + +function isNoActiveTerminalError(err: unknown): boolean { + return getClientErrorCode(err) === 'no_active_terminal' +} + +async function resolveOrchestrationPaneTerminalHandle( + client: RuntimeClient, + options: { optional?: boolean } = {} +): Promise { + const paneKey = process.env.ORCA_PANE_KEY + if (!paneKey || paneKey.length === 0) { + return undefined + } + try { + // Why: pane-key reminting preserves caller identity; focus-based active-terminal fallback can point at a different pane. + const response = await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', { + paneKey + }) + return response.result.terminal.handle + } catch (err) { + if ( + isPaneRemintUnavailableError(err) || + (options.optional === true && isOptionalPaneRemintUnavailableError(err)) + ) { + return undefined + } + throw err + } +} + +function isPaneRemintUnavailableError(err: unknown): boolean { + const code = getClientErrorCode(err) + const message = getClientErrorMessage(err) + return ( + code === 'terminal_not_found' || + code === 'terminal_handle_stale' || + code === 'terminal_gone' || + message === 'terminal_not_found' || + message === 'terminal_handle_stale' || + message === 'terminal_gone' + ) +} + +function isOptionalPaneRemintUnavailableError(err: unknown): boolean { + return getClientErrorCode(err) === 'runtime_unavailable' +} + +function getClientErrorMessage(err: unknown): string | undefined { + if (err instanceof Error) { + return err.message + } + if (!err || typeof err !== 'object') { + return undefined + } + const message = (err as { message?: unknown }).message + return typeof message === 'string' ? message : undefined +} + +export async function resolveCoordinatorTerminalHandle( + flags: Map, + cwd: string, + client: RuntimeClient +): Promise { + return await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from', { + validateEnvHandle: true + }) +} + +async function resolveImplicitOrchestrationSender( + flags: Map, + cwd: string, + client: RuntimeClient +): Promise { + try { + return await getTerminalHandle(flags, cwd, client) + } catch (err) { + if (!isNoActiveTerminalError(err)) { + throw err + } + throwNoActiveSenderTerminal() + } +} + +export function throwNoActiveSenderTerminal(): never { + throw new RuntimeClientError( + 'no_active_sender_terminal', + 'Could not determine the sender terminal for this orchestration command. ' + + 'Pass --from or run the command inside a live Orca terminal with ORCA_TERMINAL_HANDLE set.' + ) +} diff --git a/src/cli/handlers/orchestration/worker-launch-handler.ts b/src/cli/handlers/orchestration/worker-launch-handler.ts new file mode 100644 index 00000000000..a6161e61ef0 --- /dev/null +++ b/src/cli/handlers/orchestration/worker-launch-handler.ts @@ -0,0 +1,70 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { callOrchestrationMutation } from './mutation-request' +import { getOptionalPositiveIntegerValueFlag } from './numeric-flags' +import { isDevCliInvocation } from './runtime-compatibility' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' + +export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record = { + 'orchestration worker-start': async ({ flags, client, cwd, json }) => { + const model = getOptionalStringFlag(flags, 'model') + const effort = getOptionalStringFlag(flags, 'effort') + if (model || effort) { + const status = await client.call('status.get') + if ( + !status.result.capabilities?.includes( + ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY + ) + ) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'The connected Orca runtime does not support worker model or effort overrides. Update or restart Orca and try again.' + ) + } + } + const result = await callOrchestrationMutation<{ + runId: string + taskId: string + dispatchId: string + state: string + failedStage?: string + lastError?: string + warning?: string + effects: unknown[] + residualResources: unknown[] + }>(client, flags, 'orchestration.workerStart', { + task: getRequiredStringFlag(flags, 'task'), + on: getOptionalStringFlag(flags, 'on'), + worktree: getOptionalStringFlag(flags, 'worktree'), + name: getOptionalStringFlag(flags, 'name'), + repo: getOptionalStringFlag(flags, 'repo'), + baseBranch: getOptionalStringFlag(flags, 'base-branch'), + displayName: getOptionalStringFlag(flags, 'display-name'), + comment: getOptionalStringFlag(flags, 'comment'), + setup: getOptionalStringFlag(flags, 'setup'), + agent: getOptionalStringFlag(flags, 'agent'), + model, + effort, + terminal: getOptionalStringFlag(flags, 'terminal'), + retryOf: getOptionalStringFlag(flags, 'retry-of'), + timeoutMs: getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms'), + run: getOptionalStringFlag(flags, 'run'), + from: await resolveCoordinatorTerminalHandle(flags, cwd, client), + devMode: isDevCliInvocation() + }) + if (result.result.state !== 'ready') { + process.exitCode = 1 + } + printResult(result, json, (worker) => { + const base = `Worker ${worker.dispatchId} [${worker.state}] for ${worker.taskId}` + if (worker.lastError) { + return `${base}\n${worker.failedStage ?? 'start'}: ${worker.lastError}` + } + return worker.warning ? `${base}\nWarning: ${worker.warning}` : base + }) + } +} diff --git a/src/cli/handlers/orchestration/worker-observation-handlers.ts b/src/cli/handlers/orchestration/worker-observation-handlers.ts new file mode 100644 index 00000000000..29841f7390f --- /dev/null +++ b/src/cli/handlers/orchestration/worker-observation-handlers.ts @@ -0,0 +1,61 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import type { + OrchestrationWorkerReadResult, + OrchestrationWorkerReadSource +} from '../../../shared/orchestration-worker-output' +import { formatWorkerRead, type LegacyWorkerReadResult } from './worker-output' + +export const ORCHESTRATION_WORKER_OBSERVATION_HANDLERS: Record = { + 'orchestration worker-show': async ({ flags, client, json }) => { + const result = await client.call<{ + dispatch: { id: string; task_id: string; status: string } + worker: { state: string; stage: string; agent_terminal_handle: string | null } + observation?: { agentWait?: { source: string; reason?: string } | null } + }>('orchestration.workerShow', { + dispatch: getRequiredStringFlag(flags, 'dispatch') + }) + printResult(result, json, (value) => { + const base = `${value.dispatch.id} task=${value.dispatch.task_id} [${value.worker.state}] stage=${value.worker.stage}` + // Why: absent means unknown on older runtimes, distinct from an evaluated null wait. + if (value.observation === undefined || !('agentWait' in value.observation)) { + return `${base}\nInteractive wait: unknown (not evaluated)` + } + const wait = value.observation.agentWait + return wait + ? `${base}\nWaiting on a human: ${wait.reason ?? 'interactive prompt'} (via ${wait.source})` + : `${base}\nInteractive wait: none` + }) + }, + + 'orchestration worker-read': async ({ flags, client, json }) => { + const cursorFlag = getOptionalStringFlag(flags, 'cursor') + const cursor = + cursorFlag !== undefined && /^\d+$/.test(cursorFlag) + ? Number.parseInt(cursorFlag, 10) + : cursorFlag + const source = getOptionalStringFlag(flags, 'source') + if (source && !['auto', 'transcript', 'terminal'].includes(source)) { + throw new RuntimeClientError( + 'invalid_argument', + '--source must be auto, transcript, or terminal' + ) + } + const result = await client.call( + 'orchestration.workerRead', + { + dispatch: getRequiredStringFlag(flags, 'dispatch'), + cursor, + limit: getOptionalPositiveIntegerFlag(flags, 'limit'), + source: source as OrchestrationWorkerReadSource | undefined + } + ) + printResult(result, json, formatWorkerRead) + } +} diff --git a/src/cli/handlers/orchestration/worker-output.ts b/src/cli/handlers/orchestration/worker-output.ts new file mode 100644 index 00000000000..572f33117dd --- /dev/null +++ b/src/cli/handlers/orchestration/worker-output.ts @@ -0,0 +1,68 @@ +import type { NativeChatMessage } from '../../../shared/native-chat-types' +import type { RuntimeTerminalRead } from '../../../shared/runtime-types' +import type { OrchestrationWorkerReadResult } from '../../../shared/orchestration-worker-output' + +export type LegacyWorkerReadResult = { + dispatchId: string + terminal: RuntimeTerminalRead +} + +export function formatWorkerRead( + value: OrchestrationWorkerReadResult | LegacyWorkerReadResult +): string { + if (!('source' in value) || value.source === 'terminal') { + return value.terminal.tail.join('\n') + } + return value.transcript.messages.map(formatWorkerTranscriptMessage).join('\n\n') +} + +function formatWorkerTranscriptMessage(message: NativeChatMessage): string { + const blocks = message.blocks.map((block) => { + if (block.type === 'text') { + return block.text + } + if (block.type === 'tool-call') { + return `[tool ${block.name}] ${safeJson(block.input)}` + } + if (block.type === 'tool-result') { + return `[tool result${block.isError ? ' error' : ''}] ${block.output}` + } + return block.url ? `[image] ${block.url}` : `[image omitted]` + }) + return `[${message.role}] ${blocks.join('\n')}`.trimEnd() +} + +export type WorkerReleaseReceipt = { + dispatchId: string + state: string + reason?: string + processAction: string + archive: { source: string | null; status: string | null } | null + recovery?: string + lastError?: string +} + +export function formatWorkerRelease(value: WorkerReleaseReceipt): string { + const head = `Worker ${value.dispatchId} terminal [${value.state}]` + const lines = [ + `${head}${value.reason ? ` reason=${value.reason}` : ''} process=${value.processAction}` + ] + if (value.archive) { + lines.push(`archive ${value.archive.source ?? 'none'} [${value.archive.status ?? 'unknown'}]`) + } + if (value.lastError) { + lines.push(value.lastError) + } + if (value.recovery) { + lines.push(value.recovery) + } + return lines.join('\n') +} + +function safeJson(value: unknown): string { + try { + return JSON.stringify(value) + } catch { + return '[unserializable input]' + } +} diff --git a/src/cli/handlers/orchestration/worker-terminal-handlers.ts b/src/cli/handlers/orchestration/worker-terminal-handlers.ts new file mode 100644 index 00000000000..20e14c1da7f --- /dev/null +++ b/src/cli/handlers/orchestration/worker-terminal-handlers.ts @@ -0,0 +1,126 @@ +import type { CommandHandler } from '../../dispatch' +import { printResult } from '../../format' +import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' +import { RuntimeClientError } from '../../runtime-client' +import { callOrchestrationMutation } from './mutation-request' +import { formatWorkerRelease, type WorkerReleaseReceipt } from './worker-output' + +const WORKER_TERMINAL_LIST_STATES = [ + 'active', + 'reclaimable', + 'retained', + 'release_pending', + 'release_unknown', + 'released' +] as const + +export const ORCHESTRATION_WORKER_TERMINAL_HANDLERS: Record = { + 'orchestration worker-stop': async ({ flags, client, json }) => { + const result = await callOrchestrationMutation<{ + dispatchId: string + state: string + processAction: string + lastError?: string + warning?: string + }>(client, flags, 'orchestration.workerStop', { + dispatch: getRequiredStringFlag(flags, 'dispatch') + }) + if (result.result.state === 'stop_unknown') { + process.exitCode = 1 + } + printResult( + result, + json, + (value) => + `Worker ${value.dispatchId} [${value.state}] process=${value.processAction}${value.lastError ? `\n${value.lastError}` : ''}${value.warning ? `\nWarning: ${value.warning}` : ''}` + ) + }, + + 'orchestration worker-abandon': async ({ flags, client, json }) => { + const result = await callOrchestrationMutation<{ + dispatchId: string + state: string + warning: string + }>(client, flags, 'orchestration.workerAbandon', { + dispatch: getRequiredStringFlag(flags, 'dispatch') + }) + printResult( + result, + json, + (value) => `Worker ${value.dispatchId} [${value.state}]\nWarning: ${value.warning}` + ) + }, + + 'orchestration worker-release': async ({ flags, client, json }) => { + const result = await callOrchestrationMutation( + client, + flags, + 'orchestration.workerRelease', + { dispatch: getRequiredStringFlag(flags, 'dispatch') } + ) + // Why: only an unprovable close is a failure; retained/pending/already-released are settled answers. + if (result.result.state === 'release_unknown') { + process.exitCode = 1 + } + printResult(result, json, formatWorkerRelease) + }, + + 'orchestration worker-retain': async ({ flags, client, json }) => { + const result = await callOrchestrationMutation( + client, + flags, + 'orchestration.workerRetain', + { dispatch: getRequiredStringFlag(flags, 'dispatch') } + ) + if (result.result.state === 'release_unknown') { + process.exitCode = 1 + } + printResult(result, json, formatWorkerRelease) + }, + + 'orchestration worker-list': async ({ flags, client, json }) => { + const terminalState = getOptionalStringFlag(flags, 'terminal-state') + if ( + terminalState && + !WORKER_TERMINAL_LIST_STATES.includes( + terminalState as (typeof WORKER_TERMINAL_LIST_STATES)[number] + ) + ) { + throw new RuntimeClientError( + 'invalid_argument', + `invalid --terminal-state '${terminalState}', expected one of: ${WORKER_TERMINAL_LIST_STATES.join(', ')}` + ) + } + const result = await client.call<{ + workers: { + dispatchId: string + taskId: string + runId: string + workerState: string + dispatchStatus: string + agentTerminalHandle: string | null + terminalState: string | null + resource: unknown + }[] + counts: Record + }>('orchestration.workerList', { + run: getOptionalStringFlag(flags, 'run'), + terminalState + }) + printResult(result, json, (value) => { + if (value.workers.length === 0) { + return 'No workers found.' + } + const rows = value.workers + .map( + (worker) => + `${worker.dispatchId} task=${worker.taskId} [${worker.workerState}] terminal=${worker.terminalState ?? 'none'}` + ) + .join('\n') + const counts = Object.entries(value.counts) + .map(([state, count]) => `${state}=${count}`) + .join(' ') + return counts ? `${rows}\nTerminals: ${counts}` : rows + }) + } +} diff --git a/src/cli/handlers/skills.ts b/src/cli/handlers/skills.ts index 4e8db5dbdae..fb0880617ad 100644 --- a/src/cli/handlers/skills.ts +++ b/src/cli/handlers/skills.ts @@ -1,9 +1,8 @@ import { spawn } from 'node:child_process' import type { CommandHandler } from '../dispatch' import { RuntimeClientError } from '../runtime-client' -import { delimiter, dirname } from 'node:path' import { getRepeatedStringFlag } from '../flags' -import { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { resolveCliCommand, withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { detectCommandsInInstallDirs } from '../../shared/local-agent-install-dir-detection' import { getTuiAgentDetectionProbeCommands, @@ -103,16 +102,6 @@ function resolveSelectedSkillNames( } /** PATH with the resolved npx's own directory first, so its `env node` shebang resolves. */ -function buildNpxPath(resolvedNpx: string): string { - // Why: resolveCliCommand falls back to the bare name when it finds nothing, and - // dirname('npx') is '.', so prepending it blindly would run ./npx out of the - // caller's checkout instead of reporting that npx is missing. - const own = dirname(resolvedNpx) - const existing = process.env.PATH ?? process.env.Path ?? '' - // Why: every version manager ships node beside npx in the same bin directory, - // so the resolved sibling is all the child needs to run npx's shebang. - return [own === '.' ? '' : own, existing].filter(Boolean).join(delimiter) -} function runNpxSkills(args: string[]): Promise { return new Promise((resolve, reject) => { @@ -145,7 +134,10 @@ function runNpxSkills(args: string[]): Promise { // 'error' event and the message below never fires. const child = spawn(spawnCmd, spawnArgs, { stdio: 'inherit', - env: { ...process.env, PATH: buildNpxPath(resolved) } + // Why the shared helper: it verifies the sibling node actually exists, + // skips a bare-name fallback whose dirname is '.', and writes the key + // Windows will really read — all of which a local join got wrong. + env: withCliRuntimeOnPath(resolved, { ...process.env }) }) // Why: a missing npx/Node on a headless host surfaces as a raw spawn ENOENT; // wrap it so the CLI reports an actionable message like every other failure here. diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index e97c8e28c9e..31c5b94e5c9 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -172,7 +172,8 @@ export class RuntimeClient { ...(response.result.remoteUpdateSupport ? { remoteUpdateSupport: response.result.remoteUpdateSupport } : {}), - ...(response.result.capabilities ? { capabilities: response.result.capabilities } : {}) + ...(response.result.capabilities ? { capabilities: response.result.capabilities } : {}), + ...(response.result.degradations ? { degradations: response.result.degradations } : {}) }, graph: { state: graphState diff --git a/src/cli/runtime/status.test.ts b/src/cli/runtime/status.test.ts index 68dbbf0e962..9cc595122ef 100644 --- a/src/cli/runtime/status.test.ts +++ b/src/cli/runtime/status.test.ts @@ -47,7 +47,14 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => { rendererGraphEpoch: 1, graphStatus: 'ready', authoritativeWindowId: null, - liveTabCount: 0 + liveTabCount: 0, + degradations: [ + { + code: 'browser_unavailable', + capability: 'browser.headless.v1', + message: 'Browser automation is unavailable.' + } + ] }, _meta: { runtimeId: 'runtime-legacy' } })}\n` @@ -72,7 +79,8 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => { expect(status.result.runtime).toMatchObject({ reachable: true, runtimeId: 'runtime-legacy', - state: 'ready' + state: 'ready', + degradations: [expect.objectContaining({ code: 'browser_unavailable' })] }) }) }) diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index 299527eb4e8..e4a181a8d51 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -56,7 +56,8 @@ export async function getCliStatus( ...(response.result.remoteUpdateSupport ? { remoteUpdateSupport: response.result.remoteUpdateSupport } : {}), - ...(response.result.capabilities ? { capabilities: response.result.capabilities } : {}) + ...(response.result.capabilities ? { capabilities: response.result.capabilities } : {}), + ...(response.result.degradations ? { degradations: response.result.degradations } : {}) }, graph: { state: graphState diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 996f708d76f..7f12c6957d9 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { WebSocketServer } from 'ws' import { encodePairingOffer, type PairingOffer } from '../../shared/pairing' +import type { RuntimeStatus } from '../../shared/runtime-types' import { decrypt, deriveSharedKey, @@ -90,7 +91,14 @@ describe('CLI remote WebSocket transport', () => { automatic: false, reason: 'manual-service-update-required' }, - capabilities: ['updater.remote-control.v1'] + capabilities: ['updater.remote-control.v1'], + degradations: [ + { + code: 'browser_unavailable', + capability: 'browser.headless.v1', + message: 'Browser automation is unavailable.' + } + ] }) servers.push(runtime) const offer: PairingOffer = { @@ -113,7 +121,8 @@ describe('CLI remote WebSocket transport', () => { expect(status.result.runtime).toMatchObject({ appVersion: '1.5.0', remoteUpdateSupport: { automatic: false, reason: 'manual-service-update-required' }, - capabilities: ['updater.remote-control.v1'] + capabilities: ['updater.remote-control.v1'], + degradations: [expect.objectContaining({ code: 'browser_unavailable' })] }) }) @@ -243,6 +252,7 @@ async function startTestRuntime( reason: 'manual-service-update-required' } capabilities?: string[] + degradations?: RuntimeStatus['degradations'] } = {} ): Promise { const serverKeyPair = generateKeyPair() @@ -314,7 +324,8 @@ async function startTestRuntime( MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, appVersion: statusOverrides.appVersion, remoteUpdateSupport: statusOverrides.remoteUpdateSupport, - capabilities: statusOverrides.capabilities + capabilities: statusOverrides.capabilities, + degradations: statusOverrides.degradations }, _meta: { runtimeId } } diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index 2d4a63774ad..d64c16b0d26 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -1,6 +1,8 @@ +import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { delimiter } from 'node:path' +import { delimiter, join } from 'node:path' import type * as CodexCliCommandModule from '../shared/node-cli-command-resolution' import { WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../shared/windows-batch-spawn' @@ -656,9 +658,16 @@ describe('orca skills CLI', () => { }) it('puts the resolved npx directory on the child PATH', async () => { + // Why a real directory with a real sibling node: pairing only fires when the + // node it would add actually exists, so a fictional path proves nothing. + const npxBin = mkdtempSync(join(tmpdir(), 'orca-npx-')) + for (const name of ['node', 'npx']) { + writeFileSync(join(npxBin, name), '') + chmodSync(join(npxBin, name), 0o755) + } const child = createFakeChild() spawnMock.mockReturnValue(child) - resolveCliCommandMock.mockReturnValue('/home/alice/.nvm/versions/node/v22/bin/npx') + resolveCliCommandMock.mockReturnValue(join(npxBin, 'npx')) vi.stubEnv('PATH', `/usr/bin${delimiter}/bin`) vi.spyOn(process.stderr, 'write').mockImplementation(() => true) @@ -672,12 +681,30 @@ describe('orca skills CLI', () => { const env = spawnMock.mock.calls[0]?.[2]?.env // Why: the child still needs the inherited PATH and the rest of the parent // environment; replacing it outright breaks git, node, HOME and npm config. - expect(env?.PATH).toBe( - `/home/alice/.nvm/versions/node/v22/bin${delimiter}/usr/bin${delimiter}/bin` - ) + expect(env?.PATH).toBe(`${npxBin}${delimiter}/usr/bin${delimiter}/bin`) expect(env?.HOME ?? env?.USERPROFILE).toBe(process.env.HOME ?? process.env.USERPROFILE) }) + it('leaves PATH untouched when no node ships beside the resolved npx', async () => { + // Why: prepending a directory that has no node buys nothing and would shadow + // the caller's own ordering for every other binary the child resolves. + const npxBin = mkdtempSync(join(tmpdir(), 'orca-npx-bare-')) + writeFileSync(join(npxBin, 'npx'), '') + chmodSync(join(npxBin, 'npx'), 0o755) + const child = createFakeChild() + spawnMock.mockReturnValue(child) + resolveCliCommandMock.mockReturnValue(join(npxBin, 'npx')) + vi.stubEnv('PATH', `/usr/bin${delimiter}/bin`) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo') + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + child.emit('exit', 0, null) + await resultPromise + + expect(spawnMock.mock.calls[0]?.[2]?.env?.PATH).toBe(`/usr/bin${delimiter}/bin`) + }) + it('reports a Windows npx path cmd.exe would reinterpret', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') vi.stubEnv('ComSpec', 'C:\\Windows\\System32\\cmd.exe') diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts index d554c6e4e00..fc2482cbdb0 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' import { createHookStatusSessionTabsInvalidator } from './hook-status-session-tabs-invalidation' @@ -38,6 +38,7 @@ describe('createHookStatusSessionTabsInvalidator', () => { it.each([ ['state', { state: 'waiting' as const }], + ['workingMode', { workingMode: 'monitoring' as const }], ['prompt', { prompt: 'ship it' }], ['agentType', { agentType: 'codex' }], ['toolName', { toolName: 'Bash' }], diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts index 61d9fe00f0e..04902579855 100644 --- a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts @@ -1,4 +1,4 @@ -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' type KnownStatus = { @@ -33,6 +33,7 @@ export function createHookStatusSessionTabsInvalidator(): { return ( !previous || previous.payload.state !== next.state || + previous.payload.workingMode !== next.workingMode || previous.payload.prompt !== next.prompt || (previous.payload.agentType ?? null) !== (next.agentType ?? null) || (previous.payload.toolName ?? null) !== (next.toolName ?? null) || diff --git a/src/main/agent-hooks/manual-compact-hook-stream.test.ts b/src/main/agent-hooks/manual-compact-hook-stream.test.ts index 1a122349ca2..c44a28f13d7 100644 --- a/src/main/agent-hooks/manual-compact-hook-stream.test.ts +++ b/src/main/agent-hooks/manual-compact-hook-stream.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { RelayAgentHookServer } from '../../relay/agent-hook-server' -import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener' +import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener/providers/claude-roster-state' import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay' import { makePaneKey } from '../../shared/stable-pane-id' import { AgentHookServer } from './server' diff --git a/src/main/agent-hooks/server-authority-evidence.test.ts b/src/main/agent-hooks/server-authority-evidence.test.ts index 182eddada02..d6acf1886da 100644 --- a/src/main/agent-hooks/server-authority-evidence.test.ts +++ b/src/main/agent-hooks/server-authority-evidence.test.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { afterEach, describe, expect, it } from 'vitest' -import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import { makePaneKey } from '../../shared/stable-pane-id' import { AgentHookServer } from './server' diff --git a/src/main/agent-hooks/server-grok-discovery.test.ts b/src/main/agent-hooks/server-grok-discovery.test.ts index cea007c3d2f..f3ea95bc595 100644 --- a/src/main/agent-hooks/server-grok-discovery.test.ts +++ b/src/main/agent-hooks/server-grok-discovery.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import * as agentHookListener from '../../shared/agent-hook-listener' +import * as agentHookListener from '../../shared/agent-hook-listener/grok-result-discovery' import { makePaneKey } from '../../shared/stable-pane-id' import { AgentHookServer } from './server' diff --git a/src/main/agent-hooks/server-hook-http-ingest.test.ts b/src/main/agent-hooks/server-hook-http-ingest.test.ts index 622fc7d428b..55f2c6e1007 100644 --- a/src/main/agent-hooks/server-hook-http-ingest.test.ts +++ b/src/main/agent-hooks/server-hook-http-ingest.test.ts @@ -28,7 +28,101 @@ afterEach(() => { vi.restoreAllMocks() }) +async function postClaudeHook( + server: AgentHookServer, + payload: Record +): Promise { + const env = server.buildPtyEnv() + return fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload)) + }) +} + describe('AgentHookServer listener replay', () => { + it('caches and notifies status/main/plugin before retry scheduling and HTTP response', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + const order: string[] = [] + const internal = server as unknown as { + scheduleAssistantMessageRetry: (...args: unknown[]) => void + scheduleCodexSubagentPoll: (...args: unknown[]) => void + } + const originalAssistantRetry = internal.scheduleAssistantMessageRetry.bind(server) + const originalCodexRetry = internal.scheduleCodexSubagentPoll.bind(server) + const assistantRetry = vi + .spyOn(internal, 'scheduleAssistantMessageRetry') + .mockImplementation((...args) => { + order.push('assistant-retry') + originalAssistantRetry(...args) + }) + const codexRetry = vi + .spyOn(internal, 'scheduleCodexSubagentPoll') + .mockImplementation((...args) => { + order.push('codex-retry') + originalCodexRetry(...args) + }) + const unsubscribeStatus = server.subscribeStatusChanges(() => order.push('status-change')) + server.setListener(() => { + expect(server.getStatusSnapshotForPane(PANE)).toHaveLength(1) + order.push('main-listener') + }) + const unsubscribePlugin = server.subscribeEnrichedStatus(() => order.push('plugin-listener')) + try { + const response = await postClaudeHook(server, { + hook_event_name: 'UserPromptSubmit', + prompt: 'ordered' + }) + order.push('response') + expect(response.status).toBe(204) + expect(order).toEqual([ + 'status-change', + 'main-listener', + 'plugin-listener', + 'assistant-retry', + 'codex-retry', + 'response' + ]) + } finally { + unsubscribeStatus() + unsubscribePlugin() + assistantRetry.mockRestore() + codexRetry.mockRestore() + server.stop() + } + }) + + it('fails open after a throwing callback with cache retained and retries skipped', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + const internal = server as unknown as { + scheduleAssistantMessageRetry: (...args: unknown[]) => void + scheduleCodexSubagentPoll: (...args: unknown[]) => void + } + const assistantRetry = vi.spyOn(internal, 'scheduleAssistantMessageRetry') + const codexRetry = vi.spyOn(internal, 'scheduleCodexSubagentPoll') + server.setListener(() => { + throw new Error('listener failed') + }) + try { + const response = await postClaudeHook(server, { + hook_event_name: 'UserPromptSubmit', + prompt: 'cached before callback' + }) + expect(response.status).toBe(204) + expect(server.getStatusSnapshotForPane(PANE)).toHaveLength(1) + expect(assistantRetry).not.toHaveBeenCalled() + expect(codexRetry).not.toHaveBeenCalled() + } finally { + assistantRetry.mockRestore() + codexRetry.mockRestore() + server.stop() + } + }) it('ignores local nested Claude Stop while a parent Codex hook status is active', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) diff --git a/src/main/agent-hooks/server-ingest-terminal-status.test.ts b/src/main/agent-hooks/server-ingest-terminal-status.test.ts index 8661701e1dc..0ac729fa72e 100644 --- a/src/main/agent-hooks/server-ingest-terminal-status.test.ts +++ b/src/main/agent-hooks/server-ingest-terminal-status.test.ts @@ -27,6 +27,52 @@ afterEach(() => { }) describe('AgentHookServer ingestTerminalStatus', () => { + it('keeps hook monitoring mode across an equivalent OSC ping until a hook clears it', () => { + const server = new AgentHookServer() + + server.ingestRemote( + { + paneKey: PANE, + source: 'claude', + hookEventName: 'Stop', + payload: { + state: 'working', + workingMode: 'monitoring', + prompt: 'watch the build', + agentType: 'claude' + } + }, + 'conn-1' + ) + server.ingestTerminalStatus({ + paneKey: PANE, + connectionId: 'conn-1', + payload: { state: 'working', prompt: 'watch the build', agentType: 'claude' } + }) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + workingMode: 'monitoring' + }) + + server.ingestRemote( + { + paneKey: PANE, + source: 'claude', + hookEventName: 'PreToolUse', + payload: { + state: 'working', + prompt: 'watch the build', + agentType: 'claude', + toolName: 'Read' + } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()[0]?.workingMode).toBeUndefined() + }) + it('preserves a hook turn stamp when an OSC repaint omits hook-only completion text', () => { const server = new AgentHookServer() const listener = vi.fn() diff --git a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts index 8d0d6a3975c..29803adf020 100644 --- a/src/main/agent-hooks/server-interrupt-inference-guards.test.ts +++ b/src/main/agent-hooks/server-interrupt-inference-guards.test.ts @@ -228,6 +228,7 @@ describe('AgentHookServer listener replay', () => { const baseline = server.getStatusSnapshot()[0] expect(baseline).not.toHaveProperty('claudeRunningNonAgentTask') + expect(baseline).toMatchObject({ state: 'working', workingMode: 'monitoring' }) expect( server.inferInterrupt({ paneKey: PANE, diff --git a/src/main/agent-hooks/server-last-status-write.test.ts b/src/main/agent-hooks/server-last-status-write.test.ts index f693c2d4c36..b0bfb7db929 100644 --- a/src/main/agent-hooks/server-last-status-write.test.ts +++ b/src/main/agent-hooks/server-last-status-write.test.ts @@ -77,7 +77,11 @@ describe('Last-status persistence', () => { worktreeId: 'wt-1', receivedAt: expect.any(Number), stateStartedAt: expect.any(Number), - payload: expect.objectContaining({ state: 'working', prompt: 'persist me' }) + payload: expect.objectContaining({ + state: 'working', + workingMode: 'monitoring', + prompt: 'persist me' + }) }) expect(file.entries[PANE].launchToken).toBeUndefined() expect(file.entries[PANE].launchTokenHash).toBe( diff --git a/src/main/agent-hooks/server-relay-listener-replay.test.ts b/src/main/agent-hooks/server-relay-listener-replay.test.ts index c7795fb8069..6c96d9c3bb8 100644 --- a/src/main/agent-hooks/server-relay-listener-replay.test.ts +++ b/src/main/agent-hooks/server-relay-listener-replay.test.ts @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentHookServer, _internals } from './server' import { createHookListenerState, - normalizeHookPayload, type HookListenerState -} from '../../shared/agent-hook-listener' +} from '../../shared/agent-hook-listener/listener-state' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' import { createShedSubagentsField } from '../../shared/agent-hook-relay' import { buildBody, PANE } from './server.test-fixtures' diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 6def8b6b6c2..e447be40262 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -13,33 +13,45 @@ import { clearAllListenerCaches, clearPaneCacheState, paneHasStateClaims, - clearClaudeAnsweredQuestionWait, createHookListenerState, - getEndpointFileName, - hasCodexTranscriptSubagents, - hasPendingAgentResultText, - HOOK_REQUEST_SLOWLORIS_MS, - isNewTurnEvent, - markClaudeLeadTurnInterrupted, - markCodexLeadTurnInterrupted, - MAX_PANE_KEY_LEN, movePaneCacheState, - normalizeClaudePromptId, - normalizeHookPayload, - parseFormEncodedBody, - readRequestBody, - reapRestoredClaudeSubagentsForDeadPane, - reconcileRemoteCodexState, - resolveHookSource, - preparePendingGrokResultDiscovery, - seedClaudeLeadTurnFromPersistedStatus, - seedClaudeSubagentRosterFromSnapshots, - seedCodexStateFromSnapshot, - warnOnHookEnvOrVersionMismatch, - writeEndpointFile, - type AgentHookEventPayload, type HookListenerState -} from '../../shared/agent-hook-listener' +} from '../../shared/agent-hook-listener/listener-state' +import { + clearClaudeAnsweredQuestionWait, + markClaudeLeadTurnInterrupted, + reapRestoredClaudeSubagentsForDeadPane, + seedClaudeLeadTurnFromPersistedStatus, + seedClaudeSubagentRosterFromSnapshots +} from '../../shared/agent-hook-listener/providers/claude-roster-state' +import { + getEndpointFileName, + writeEndpointFile +} from '../../shared/agent-hook-listener/endpoint-publication' +import { + hasCodexTranscriptSubagents, + markCodexLeadTurnInterrupted, + reconcileRemoteCodexState, + seedCodexStateFromSnapshot +} from '../../shared/agent-hook-listener/providers/codex-state' +import { + hasPendingAgentResultText, + preparePendingGrokResultDiscovery +} from '../../shared/agent-hook-listener/grok-result-discovery' +import { + HOOK_REQUEST_SLOWLORIS_MS, + MAX_PANE_KEY_LEN, + normalizeClaudePromptId, + warnOnHookEnvOrVersionMismatch +} from '../../shared/agent-hook-listener/listener-limits' +import { isNewTurnEvent } from '../../shared/agent-hook-listener/provider-event-routing' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' +import { + parseFormEncodedBody, + readRequestBody +} from '../../shared/agent-hook-listener/request-body' +import { resolveHookSource } from '../../shared/agent-hook-listener/source-routing' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import { canAcceptClaudeCompactCompletion, isClaudeCompactCompletionConsumed, @@ -77,6 +89,7 @@ import { type ParsedAgentStatusPayload, normalizeAgentStatusPayload } from '../../shared/agent-status-types' +import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' import { AgentStatusObservationSequencer, createAgentStatusAuthorityId, @@ -453,30 +466,6 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta } } -// Why: OSC never carries model/children; omit both so an equivalent OSC ping preserves the hook-cached identity graph. -function equivalentParsedAgentStatusPayload( - a: ParsedAgentStatusPayload, - b: ParsedAgentStatusPayload, - preserveActiveTurnStamp = false -): boolean { - return ( - a.state === b.state && - a.prompt === b.prompt && - a.agentType === b.agentType && - a.toolName === b.toolName && - a.toolInput === b.toolInput && - a.interactivePrompt === b.interactivePrompt && - (a.lastAssistantMessage === b.lastAssistantMessage || - (preserveActiveTurnStamp && b.lastAssistantMessage === undefined)) && - a.interrupted === b.interrupted && - // Why: a session-boundary done must never be deduped against a cached real done — - // the flag has to reach receivers deterministically (STA-3386). - a.sessionBoundary === b.sessionBoundary && - (a.turnCompletedAt === b.turnCompletedAt || - (preserveActiveTurnStamp && b.turnCompletedAt === undefined)) - ) -} - function trackEmptyPaneKeyHook(body: unknown): void { if (typeof body !== 'object' || body === null) { return @@ -1044,6 +1033,7 @@ export class AgentHookServer { providerSession: existing.providerSession, payload: { state: restored.state, + ...(restored.workingMode ? { workingMode: restored.workingMode } : {}), prompt: payload.prompt, agentType: payload.agentType, ...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}), @@ -2205,7 +2195,7 @@ export class AgentHookServer { previous?.connectionId === connectionId && previous.tabId === tabId && previous.worktreeId === worktreeId && - equivalentParsedAgentStatusPayload(previous.payload, event.payload, preserveActiveTurnStamp) + terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) ) { return } @@ -3053,7 +3043,12 @@ export class AgentHookServer { ...(state !== 'done' && restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), receivedAt: reconciledAt, stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt, - payload: { ...enriched.payload, state, subagents } + payload: { + ...enriched.payload, + state, + workingMode: state === 'working' ? enriched.payload.workingMode : undefined, + subagents + } } this.state.lastStatusByPaneKey.set(paneKey, reconciled) } diff --git a/src/main/app-relaunch.test.ts b/src/main/app-relaunch.test.ts index 26c98c98bb3..4ba8c6ce5a0 100644 --- a/src/main/app-relaunch.test.ts +++ b/src/main/app-relaunch.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { appRelaunchMock, recordDurableCrashBreadcrumbMock } = vi.hoisted(() => ({ appRelaunchMock: vi.fn(), @@ -11,12 +11,24 @@ vi.mock('./crash-reporting/durable-crash-breadcrumb', () => ({ })) import { relaunchApp } from './app-relaunch' +import { _resetHydrateShellPathCache, _setLaunchPathForTests } from './startup/hydrate-shell-path' beforeEach(() => { appRelaunchMock.mockReset() recordDurableCrashBreadcrumbMock.mockReset() }) +const originalPath = process.env.PATH + +afterEach(() => { + _resetHydrateShellPathCache() + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } +}) + describe('relaunchApp', () => { it('durably records the reason before scheduling the replacement process', () => { relaunchApp('gpu-fallback', { processReason: 'crashed', exitCode: 5 }) @@ -32,4 +44,18 @@ describe('relaunchApp', () => { appRelaunchMock.mock.invocationCallOrder[0] ) }) + + it('does not carry Orca PATH seeds into the replacement process', () => { + process.env.PATH = '/seeded/newest-nvm/bin:/usr/bin' + _setLaunchPathForTests('/usr/bin') + let inheritedPath: string | undefined + appRelaunchMock.mockImplementation(() => { + inheritedPath = process.env.PATH + }) + + relaunchApp('renderer-request') + + expect(inheritedPath).toBe('/usr/bin') + expect(process.env.PATH).toBe('/seeded/newest-nvm/bin:/usr/bin') + }) }) diff --git a/src/main/app-relaunch.ts b/src/main/app-relaunch.ts index ab7a725c847..ce96af1e96f 100644 --- a/src/main/app-relaunch.ts +++ b/src/main/app-relaunch.ts @@ -1,6 +1,7 @@ import { app } from 'electron' import type { CrashReportBreadcrumbData } from '../shared/crash-reporting' import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb' +import { runWithLaunchPath } from './startup/hydrate-shell-path' export type AppRelaunchReason = | 'admin-restart' @@ -13,5 +14,5 @@ export function relaunchApp(reason: AppRelaunchReason, data?: CrashReportBreadcr // Why: the current process can exit immediately after app.relaunch(), so // persist the cause before Electron schedules the replacement process. recordDurableCrashBreadcrumb('app_relaunch_requested', { ...data, reason }) - app.relaunch() + runWithLaunchPath(() => app.relaunch()) } diff --git a/src/main/claude-accounts/claude-account-registration.ts b/src/main/claude-accounts/claude-account-registration.ts new file mode 100644 index 00000000000..bebc26be680 --- /dev/null +++ b/src/main/claude-accounts/claude-account-registration.ts @@ -0,0 +1,250 @@ +import { randomUUID } from 'node:crypto' +import type { + ClaudeManagedAccount, + ClaudeRateLimitAccountsState +} from '../../shared/managed-account-types' +import type { Store } from '../persistence' +import type { RateLimitService } from '../rate-limits/service' +import { findDuplicateClaudeAccount } from './claude-duplicate-account' +import type { CapturedClaudeAuth } from './claude-auth-capture' +import type { + ClaudeManagedAuthLocation, + ClaudeManagedAuthSnapshot, + ClaudeManagedAuthTarget +} from './claude-managed-auth-storage' +import type { ClaudeRuntimeAuthService } from './runtime-auth-service' +import { + getClaudeSelectionTargetForAccount, + normalizeClaudeRuntimeSelection +} from './runtime-selection' +import type { ClaudeAccountSelection } from './claude-account-selection' + +type ClaudeAccountRegistrationDependencies = { + store: Store + rateLimits: RateLimitService + runtimeAuth: ClaudeRuntimeAuthService + selection: ClaudeAccountSelection + createManagedAuth: ( + accountId: string, + target?: ClaudeManagedAuthTarget + ) => Promise + assertManagedAuth: (path: string, accountId: string) => Promise + removeManagedAuth: (accountId: string, path: string) => Promise + writeManagedAuth: (accountId: string, path: string, captured: CapturedClaudeAuth) => Promise + writeCredentials: (accountId: string, path: string, value: string) => Promise + writeOauth: (accountId: string, path: string, value: unknown) => Promise + readSnapshot: (accountId: string, path: string) => Promise + restoreCredentials: ( + accountId: string, + path: string, + snapshot: ClaudeManagedAuthSnapshot + ) => Promise + restoreOauth: ( + accountId: string, + path: string, + snapshot: ClaudeManagedAuthSnapshot + ) => Promise + login: (location: ClaudeManagedAuthLocation) => Promise + captureExisting: ( + configDir: string, + previousLegacyCredentialsSha256?: string | null + ) => Promise +} + +class DuplicateClaudeAccountError extends Error {} + +export class ClaudeAccountRegistration { + constructor(private readonly dependencies: ClaudeAccountRegistrationDependencies) {} + + async add(target?: ClaudeManagedAuthTarget): Promise { + const accountId = randomUUID() + const location = await this.dependencies.createManagedAuth(accountId, target) + const previousSettings = this.dependencies.store.getSettings() + try { + const captured = await this.dependencies.login(location) + return await this.persist(accountId, location, previousSettings, captured) + } catch (error) { + await this.cleanupFailedAdd(accountId, location.managedAuthPath, previousSettings, error) + throw error + } + } + + async addFromConfigDir( + configDir: string, + options?: ClaudeManagedAuthTarget & { previousLegacyCredentialsSha256?: string | null } + ): Promise { + const accountId = randomUUID() + const location = await this.dependencies.createManagedAuth(accountId, options) + const previousSettings = this.dependencies.store.getSettings() + try { + const captured = await this.dependencies.captureExisting( + configDir, + options?.previousLegacyCredentialsSha256 + ) + return await this.persist(accountId, location, previousSettings, captured) + } catch (error) { + await this.cleanupFailedAdd(accountId, location.managedAuthPath, previousSettings, error) + throw error + } + } + + async reauthenticate(accountId: string): Promise { + const account = this.dependencies.selection.requireAccount(accountId) + const managedAuthPath = await this.dependencies.assertManagedAuth( + account.managedAuthPath, + accountId + ) + const previousSettings = this.dependencies.store.getSettings() + const previousAuth = await this.dependencies.readSnapshot(accountId, managedAuthPath) + const captured = await this.dependencies.login({ + managedAuthPath, + managedAuthRuntime: account.managedAuthRuntime ?? 'host', + wslDistro: account.wslDistro ?? null, + wslLinuxAuthPath: account.wslLinuxAuthPath ?? null + }) + if (!captured.identity.email) { + throw new Error('Claude login completed, but Orca could not resolve the account email.') + } + + const settings = this.dependencies.store.getSettings() + const now = Date.now() + const nextAccounts = settings.claudeManagedAccounts.map((entry) => + entry.id === accountId + ? { + ...entry, + email: captured.identity.email!, + organizationUuid: captured.identity.organizationUuid, + organizationName: captured.identity.organizationName, + updatedAt: now, + lastAuthenticatedAt: now + } + : entry + ) + let wroteCredentials = false + try { + await this.dependencies.writeOauth(accountId, managedAuthPath, captured.oauthAccount) + await this.dependencies.writeCredentials(accountId, managedAuthPath, captured.credentialsJson) + wroteCredentials = true + this.dependencies.store.updateSettings({ claudeManagedAccounts: nextAccounts }) + this.dependencies.runtimeAuth.clearLastWrittenCredentialsJson(accountId) + this.dependencies.rateLimits.evictInactiveClaudeCache(accountId) + const target = getClaudeSelectionTargetForAccount(account) + await this.dependencies.selection.syncRuntimeAuth(target) + await this.dependencies.rateLimits.refreshForClaudeAccountChange(undefined, target) + return this.dependencies.selection.snapshot() + } catch (error) { + await this.rollbackReauthentication( + accountId, + managedAuthPath, + previousAuth, + previousSettings, + nextAccounts, + wroteCredentials + ) + throw error + } + } + + private async persist( + accountId: string, + location: ClaudeManagedAuthLocation, + previousSettings: ReturnType, + captured: CapturedClaudeAuth + ): Promise { + if (!captured.identity.email) { + throw new Error('Claude login completed, but Orca could not resolve the account email.') + } + if ( + findDuplicateClaudeAccount(previousSettings.claudeManagedAccounts, { + email: captured.identity.email, + organizationUuid: captured.identity.organizationUuid, + managedAuthRuntime: location.managedAuthRuntime, + wslDistro: location.wslDistro + }) + ) { + throw new DuplicateClaudeAccountError('This Claude account is already added.') + } + await this.dependencies.writeManagedAuth(accountId, location.managedAuthPath, captured) + const now = Date.now() + const account: ClaudeManagedAccount = { + id: accountId, + email: captured.identity.email, + managedAuthPath: location.managedAuthPath, + managedAuthRuntime: location.managedAuthRuntime, + wslDistro: location.wslDistro, + wslLinuxAuthPath: location.wslLinuxAuthPath, + authMethod: 'subscription-oauth', + organizationUuid: captured.identity.organizationUuid, + organizationName: captured.identity.organizationName, + createdAt: now, + updatedAt: now, + lastAuthenticatedAt: now + } + const selection = normalizeClaudeRuntimeSelection(previousSettings) + this.dependencies.store.updateSettings({ + claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], + activeClaudeManagedAccountId: selection.host, + activeClaudeManagedAccountIdsByRuntime: selection + }) + this.dependencies.runtimeAuth.clearLastWrittenCredentialsJson(accountId) + this.dependencies.rateLimits.evictInactiveClaudeCache(accountId) + return this.dependencies.selection.snapshot() + } + + private async cleanupFailedAdd( + accountId: string, + managedAuthPath: string, + previousSettings: ReturnType, + error: unknown + ): Promise { + if (error instanceof DuplicateClaudeAccountError) { + await this.dependencies.removeManagedAuth(accountId, managedAuthPath) + return + } + this.dependencies.selection.restoreSettings(previousSettings) + try { + await this.dependencies.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + } catch (rollbackError) { + console.warn('[claude-accounts] Rollback rematerialization failed:', rollbackError) + } + await this.dependencies.removeManagedAuth(accountId, managedAuthPath) + } + + private async rollbackReauthentication( + accountId: string, + path: string, + snapshot: ClaudeManagedAuthSnapshot, + previousSettings: ReturnType, + nextAccounts: ClaudeManagedAccount[], + wroteCredentials: boolean + ): Promise { + let restoredCredentials = false + try { + await this.dependencies.restoreCredentials(accountId, path, snapshot) + restoredCredentials = true + } catch (rollbackError) { + console.warn( + '[claude-accounts] Failed to restore managed credentials during rollback:', + rollbackError + ) + } + if (restoredCredentials || !wroteCredentials) { + try { + await this.dependencies.restoreOauth(accountId, path, snapshot) + } catch (rollbackError) { + console.warn( + '[claude-accounts] Failed to restore managed oauth metadata during rollback:', + rollbackError + ) + } + } + if (restoredCredentials) { + this.dependencies.selection.restoreSettings(previousSettings) + await this.dependencies.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + } else if (wroteCredentials) { + this.dependencies.store.updateSettings({ claudeManagedAccounts: nextAccounts }) + } else { + this.dependencies.selection.restoreSettings(previousSettings) + } + } +} diff --git a/src/main/claude-accounts/claude-account-selection.ts b/src/main/claude-accounts/claude-account-selection.ts new file mode 100644 index 00000000000..d81f2346eb7 --- /dev/null +++ b/src/main/claude-accounts/claude-account-selection.ts @@ -0,0 +1,191 @@ +import type { + ClaudeManagedAccount, + ClaudeManagedAccountSummary, + ClaudeRateLimitAccountsState +} from '../../shared/managed-account-types' +import type { Store } from '../persistence' +import type { RateLimitService } from '../rate-limits/service' +import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from './live-pty-gate' +import type { ClaudeRuntimeAuthService } from './runtime-auth-service' +import { + getClaudeSelectionTargetForAccount, + getSelectedClaudeAccountIdForTarget, + normalizeClaudeAccountSelectionTarget, + normalizeClaudeRuntimeSelection, + pruneInvalidClaudeRuntimeSelection, + removeClaudeAccountIdFromSelection, + setSelectedClaudeAccountIdForTarget, + type ClaudeAccountSelectionTarget +} from './runtime-selection' + +export class ClaudeAccountSelection { + constructor( + private readonly store: Store, + private readonly rateLimits: RateLimitService, + private readonly runtimeAuth: ClaudeRuntimeAuthService, + private readonly removeManagedAuth: (accountId: string, path: string) => Promise + ) {} + + list(): ClaudeRateLimitAccountsState { + this.normalizeActiveSelection() + return this.snapshot() + } + + async remove(accountId: string): Promise { + const account = this.requireAccount(accountId) + const settings = this.store.getSettings() + const nextAccounts = settings.claudeManagedAccounts.filter((entry) => entry.id !== accountId) + const nextSelection = removeClaudeAccountIdFromSelection( + normalizeClaudeRuntimeSelection(settings), + accountId + ) + const nextActiveId = + settings.activeClaudeManagedAccountId === accountId ? null : nextSelection.host + const target = getClaudeSelectionTargetForAccount(account) + const wasSelected = getSelectedClaudeAccountIdForTarget(settings, target) === accountId + try { + if (wasSelected) { + this.store.updateSettings({ + activeClaudeManagedAccountId: nextActiveId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + await this.syncRuntimeAuth(target) + this.store.updateSettings({ claudeManagedAccounts: nextAccounts }) + } else { + this.store.updateSettings({ + claudeManagedAccounts: nextAccounts, + activeClaudeManagedAccountId: nextActiveId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + await this.syncRuntimeAuth(target) + } + await this.removeManagedAuth(accountId, account.managedAuthPath) + this.rateLimits.evictInactiveClaudeCache(accountId) + await this.rateLimits.refreshForClaudeAccountChange( + wasSelected ? accountId : undefined, + target + ) + return this.snapshot() + } catch (error) { + this.restoreSettings(settings) + await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + throw error + } + } + + async select( + accountId: string | null, + target?: ClaudeAccountSelectionTarget + ): Promise { + let effectiveTarget = target + if (accountId !== null) { + const account = this.requireAccount(accountId) + const accountTarget = getClaudeSelectionTargetForAccount(account) + const requestedTarget = normalizeClaudeAccountSelectionTarget(target ?? accountTarget) + const normalizedAccountTarget = normalizeClaudeAccountSelectionTarget(accountTarget) + if ( + requestedTarget.runtime !== normalizedAccountTarget.runtime || + (requestedTarget.wslDistro !== null && + requestedTarget.wslDistro !== normalizedAccountTarget.wslDistro) + ) { + throw new Error('That Claude account belongs to a different runtime.') + } + effectiveTarget = accountTarget + } + const previousSettings = this.store.getSettings() + const outgoingAccountId = getSelectedClaudeAccountIdForTarget(previousSettings, effectiveTarget) + const nextSelection = setSelectedClaudeAccountIdForTarget( + normalizeClaudeRuntimeSelection(previousSettings), + accountId, + effectiveTarget + ) + this.store.updateSettings({ + activeClaudeManagedAccountId: + effectiveTarget?.runtime === 'wsl' ? nextSelection.host : accountId, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + try { + await this.syncRuntimeAuth(effectiveTarget) + await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId, effectiveTarget) + return this.snapshot() + } catch (error) { + this.restoreSettings(previousSettings) + await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() + throw error + } + } + + snapshot(): ClaudeRateLimitAccountsState { + const settings = this.store.getSettings() + return { + accounts: settings.claudeManagedAccounts + .map(toClaudeAccountSummary) + .sort((a, b) => b.updatedAt - a.updatedAt), + activeAccountId: normalizeClaudeRuntimeSelection(settings).host, + activeAccountIdsByRuntime: normalizeClaudeRuntimeSelection(settings) + } + } + + requireAccount(accountId: string): ClaudeManagedAccount { + const account = this.store + .getSettings() + .claudeManagedAccounts.find((entry) => entry.id === accountId) + if (!account) { + throw new Error('That Claude account no longer exists.') + } + return account + } + + restoreSettings(settings: ReturnType): void { + this.store.updateSettings({ + claudeManagedAccounts: settings.claudeManagedAccounts, + activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId, + activeClaudeManagedAccountIdsByRuntime: settings.activeClaudeManagedAccountIdsByRuntime + }) + } + + async syncRuntimeAuth( + target?: ClaudeAccountSelectionTarget, + operation?: () => Promise + ): Promise { + beginClaudeAuthSwitch() + try { + await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection(target)) + } finally { + endClaudeAuthSwitch() + } + } + + private normalizeActiveSelection(): void { + const settings = this.store.getSettings() + const currentSelection = normalizeClaudeRuntimeSelection(settings) + const nextSelection = pruneInvalidClaudeRuntimeSelection( + currentSelection, + settings.claudeManagedAccounts + ) + if ( + nextSelection.host !== settings.activeClaudeManagedAccountId || + JSON.stringify(nextSelection) !== JSON.stringify(currentSelection) + ) { + this.store.updateSettings({ + activeClaudeManagedAccountId: nextSelection.host, + activeClaudeManagedAccountIdsByRuntime: nextSelection + }) + } + } +} + +function toClaudeAccountSummary(account: ClaudeManagedAccount): ClaudeManagedAccountSummary { + return { + id: account.id, + email: account.email, + managedAuthRuntime: account.managedAuthRuntime ?? 'host', + wslDistro: account.wslDistro ?? null, + authMethod: account.authMethod ?? 'unknown', + organizationUuid: account.organizationUuid ?? null, + organizationName: account.organizationName ?? null, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + lastAuthenticatedAt: account.lastAuthenticatedAt + } +} diff --git a/src/main/claude-accounts/claude-account-service-api-parity.test.ts b/src/main/claude-accounts/claude-account-service-api-parity.test.ts new file mode 100644 index 00000000000..206ccbd82af --- /dev/null +++ b/src/main/claude-accounts/claude-account-service-api-parity.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' +import { ClaudeAccountService } from './service' +import type { ClaudeAccountAddTarget, ClaudeAccountImportOptions } from './service' + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-claude-api-parity' } })) + +type PublicClaudeAccountService = { + listAccounts(): ClaudeRateLimitAccountsState + addAccount(target?: ClaudeAccountAddTarget): Promise + addAccountFromConfigDir( + configDir: string, + options?: ClaudeAccountImportOptions + ): Promise + reauthenticateAccount(accountId: string): Promise + removeAccount(accountId: string): Promise + selectAccount(accountId: string | null): Promise + selectAccountForTarget( + accountId: string | null, + target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null } + ): Promise + cancelPendingLogin(): boolean + getRuntimeConfigDir(target?: { runtime?: 'host' | 'wsl'; wslDistro?: string | null }): string +} + +function createService(): ClaudeAccountService { + const settings = { + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, + activeClaudeManagedAccountIdsByRuntime: { host: null, wsl: {} } + } + return new ClaudeAccountService( + { getSettings: () => settings, updateSettings: vi.fn() } as never, + {} as never, + { getRuntimeConfigDir: () => '/tmp/claude' } as never + ) +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + return { promise: new Promise((done) => (resolve = done)), resolve } +} + +describe('ClaudeAccountService API parity', () => { + it('keeps the exact runtime export and assignable public surface', async () => { + const runtimeExports = await import('./service') + const service: PublicClaudeAccountService = createService() + + expect(Object.keys(runtimeExports)).toEqual(['ClaudeAccountService']) + expect(service.cancelPendingLogin()).toBe(false) + expect(service.getRuntimeConfigDir()).toBe('/tmp/claude') + }) + + it('serializes mutations within one service', async () => { + const service = createService() + const first = deferred() + const calls: string[] = [] + const registration = ( + service as unknown as { + registration: { + add(target?: ClaudeAccountAddTarget): Promise + } + } + ).registration + registration.add = vi.fn(async (target) => { + calls.push(target?.wslDistro ?? 'host') + if (calls.length === 1) { + await first.promise + } + return { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } + }) + + const firstAdd = service.addAccount({ runtime: 'wsl', wslDistro: 'one' }) + const secondAdd = service.addAccount({ runtime: 'wsl', wslDistro: 'two' }) + await vi.waitFor(() => expect(calls).toEqual(['one'])) + first.resolve() + await Promise.all([firstAdd, secondAdd]) + expect(calls).toEqual(['one', 'two']) + }) + + it('isolates mutation and cancellation state between runtime hosts', async () => { + const firstService = createService() + const secondService = createService() + const first = deferred() + const firstAdd = vi.fn(async () => { + await first.promise + return { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } + }) + const secondAdd = vi.fn(async () => ({ + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + })) + ;(firstService as unknown as { registration: { add: typeof firstAdd } }).registration.add = + firstAdd + ;(secondService as unknown as { registration: { add: typeof secondAdd } }).registration.add = + secondAdd + ;( + firstService as unknown as { cancelPendingClaudeLogin: () => boolean } + ).cancelPendingClaudeLogin = vi.fn(() => true) + + const pendingFirst = firstService.addAccount() + await expect(secondService.addAccount()).resolves.toMatchObject({ accounts: [] }) + expect(firstService.cancelPendingLogin()).toBe(true) + expect(secondService.cancelPendingLogin()).toBe(false) + first.resolve() + await pendingFirst + }) +}) diff --git a/src/main/claude-accounts/claude-account-service-login-process.test.ts b/src/main/claude-accounts/claude-account-service-login-process.test.ts index cb387a659d5..43e051a95fd 100644 --- a/src/main/claude-accounts/claude-account-service-login-process.test.ts +++ b/src/main/claude-accounts/claude-account-service-login-process.test.ts @@ -6,6 +6,7 @@ import { join } from 'node:path' import { PassThrough } from 'node:stream' import type { ClaudeManagedAccount } from '../../shared/managed-account-types' import { readActiveClaudeKeychainCredentials } from './keychain' +import { getCmdExePath } from '../../shared/windows-batch-spawn' import { createService, resetClaudeKeychainMocks, @@ -516,7 +517,24 @@ describe('ClaudeAccountService credential capture', () => { const destroyStdin = vi.spyOn(child.stdin, 'destroy') const taskkill = new EventEmitter() const spawnMock = vi.fn((command: string) => (command === 'taskkill.exe' ? taskkill : child)) + const cleanupInteractiveLogin = vi.fn() + let publishTerminationPid: (pid: number) => void = () => {} + const buildInteractiveLoginSpawn = vi.fn(() => ({ + command: getCmdExePath(), + args: ['/d', '/c', 'start', '', '/wait', 'claude', 'auth', 'login', '--claudeai'], + stdio: 'ignore' as const, + windowsHide: true, + cleanup: cleanupInteractiveLogin, + getTerminationPid: () => null, + waitForTerminationPid: () => + new Promise((resolve) => { + publishTerminationPid = resolve + }) + })) vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ + buildWindowsHostInteractiveLoginSpawn: buildInteractiveLoginSpawn + })) try { const { ClaudeAccountService } = await import('./service') @@ -548,28 +566,43 @@ describe('ClaudeAccountService credential capture', () => { const addPromise = service.addAccount() await vi.waitFor(() => { + expect(buildInteractiveLoginSpawn).toHaveBeenCalledWith('claude', [ + 'auth', + 'login', + '--claudeai' + ]) expect(spawnMock).toHaveBeenCalledWith( - process.env.ComSpec ?? 'cmd.exe', - ['/d', '/v:off', '/s', '/c', '""claude" "auth" "login" "--claudeai""'], - expect.objectContaining({ shell: false, windowsVerbatimArguments: true }) + getCmdExePath(), + ['/d', '/c', 'start', '', '/wait', 'claude', 'auth', 'login', '--claudeai'], + expect.objectContaining({ stdio: 'ignore', windowsHide: true }) ) }) expect(service.cancelPendingLogin()).toBe(true) const rejection = expect(addPromise).rejects.toThrow('Claude sign-in was cancelled.') expect(child.kill).not.toHaveBeenCalled() - expect(spawnMock).toHaveBeenCalledWith( + expect(spawnMock).not.toHaveBeenCalledWith( 'taskkill.exe', - ['/pid', '1234', '/t', '/f'], - expect.objectContaining({ stdio: 'ignore', windowsHide: true }) + expect.anything(), + expect.anything() ) + publishTerminationPid(9876) + await vi.waitFor(() => { + expect(spawnMock).toHaveBeenCalledWith( + 'taskkill.exe', + ['/pid', '9876', '/t', '/f'], + expect.objectContaining({ stdio: 'ignore', windowsHide: true }) + ) + }) expect(destroyStdin).not.toHaveBeenCalled() taskkill.emit('close', 0) await rejection expect(destroyStdin).toHaveBeenCalledTimes(1) + expect(cleanupInteractiveLogin).toHaveBeenCalledTimes(1) expect(service.cancelPendingLogin()).toBe(false) } finally { vi.doUnmock('node:child_process') + vi.doUnmock('../../shared/windows-interactive-login-spawn') } }) }) diff --git a/src/main/claude-accounts/claude-auth-capture.ts b/src/main/claude-accounts/claude-auth-capture.ts new file mode 100644 index 00000000000..2acaa440868 --- /dev/null +++ b/src/main/claude-accounts/claude-auth-capture.ts @@ -0,0 +1,176 @@ +import { createHash } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { readActiveClaudeKeychainCredentialsStrict } from './keychain' + +export type ClaudeIdentity = { + email: string | null + organizationUuid: string | null + organizationName: string | null +} + +export type CapturedClaudeAuth = { + credentialsJson: string + oauthAccount: unknown + identity: ClaudeIdentity +} + +export type ClaudeCaptureCommand = ( + args: string[], + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, + timeoutMs: number, + options?: { allowFailure?: boolean } +) => Promise + +const STATUS_TIMEOUT_MS = 20_000 + +export async function captureClaudeAuthFromExistingConfigDir( + configDir: string, + previousLegacyCredentialsSha256: string | null | undefined, + runCommand: ClaudeCaptureCommand +): Promise { + const trimmed = configDir.trim() + if (!trimmed) { + throw new Error('A Claude config directory path is required.') + } + const resolvedDir = resolve(trimmed) + if (process.platform !== 'darwin' && !existsSync(join(resolvedDir, '.credentials.json'))) { + throw new Error( + `No Claude credentials found in ${resolvedDir}. Run \`claude login\` into this directory first.` + ) + } + + let status = '' + try { + status = await runCommand( + ['auth', 'status', '--json'], + { windowsPath: resolvedDir, linuxPath: null, wslDistro: null }, + STATUS_TIMEOUT_MS, + { allowFailure: true } + ) + } catch (error) { + console.warn('[claude-accounts] Could not read `claude auth status`:', error) + } + const currentLegacyKeychain = await readActiveClaudeKeychainCredentialsStrict() + return captureClaudeAuthFromConfigDir( + resolvedDir, + status, + currentLegacyKeychain, + previousLegacyCredentialsSha256 + ) +} + +export async function captureClaudeAuthFromConfigDir( + configDir: string, + statusOutput: string, + previousLegacyKeychain: string | null, + previousLegacyCredentialsSha256?: string | null, + readCredentials = readCapturedClaudeCredentials +): Promise { + const credentialsJson = await readCredentials( + configDir, + previousLegacyKeychain, + previousLegacyCredentialsSha256 + ) + if (!credentialsJson) { + throw new Error('Claude login completed, but no OAuth credentials were captured.') + } + const oauthAccount = readClaudeOauthAccount(configDir) + return { + credentialsJson, + oauthAccount, + identity: resolveClaudeIdentity(statusOutput, oauthAccount, credentialsJson) + } +} + +export async function readCapturedClaudeCredentials( + configDir: string, + previousLegacyKeychain: string | null, + previousLegacyCredentialsSha256?: string | null +): Promise { + if (process.platform === 'darwin') { + const scopedCredentialsJson = await readActiveClaudeKeychainCredentialsStrict(configDir) + if (scopedCredentialsJson) { + return scopedCredentialsJson + } + const legacyCredentialsJson = await readActiveClaudeKeychainCredentialsStrict() + const legacyChanged = + previousLegacyCredentialsSha256 === undefined + ? legacyCredentialsJson !== previousLegacyKeychain + : legacyCredentialsJson !== null && + createHash('sha256').update(legacyCredentialsJson).digest('hex') !== + previousLegacyCredentialsSha256 + if (legacyCredentialsJson && legacyChanged) { + return legacyCredentialsJson + } + } + const credentialsPath = join(configDir, '.credentials.json') + return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null +} + +function readClaudeOauthAccount(configDir: string): unknown { + for (const configPath of [join(configDir, '.claude.json'), join(configDir, '.config.json')]) { + if (!existsSync(configPath)) { + continue + } + try { + const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Record + if (parsed.oauthAccount) { + return parsed.oauthAccount + } + } catch { + continue + } + } + return null +} + +function resolveClaudeIdentity( + statusOutput: string, + oauthAccount: unknown, + credentialsJson: string +): ClaudeIdentity { + const status = parseJsonObject(statusOutput) + const oauth = asRecord(oauthAccount) + const credentialOauth = asRecord(parseJsonObject(credentialsJson)?.claudeAiOauth) + return { + email: normalizeField( + readString(status, 'email') ?? + readString(oauth, 'emailAddress') ?? + readString(oauth, 'email') ?? + readString(credentialOauth, 'email') + ), + organizationUuid: normalizeField( + readString(status, 'organizationUuid') ?? + readString(status, 'organizationId') ?? + readString(oauth, 'organizationUuid') ?? + readString(oauth, 'organizationId') + ), + organizationName: normalizeField( + readString(status, 'organizationName') ?? readString(oauth, 'organizationName') + ) + } +} + +function parseJsonObject(value: string): Record | null { + try { + return asRecord(JSON.parse(value) as unknown) + } catch { + return null + } +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function readString(value: Record | null, key: string): string | null { + return typeof value?.[key] === 'string' ? value[key] : null +} + +function normalizeField(value: string | null | undefined): string | null { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} diff --git a/src/main/claude-accounts/claude-command-process.ts b/src/main/claude-accounts/claude-command-process.ts new file mode 100644 index 00000000000..d3ef1f68113 --- /dev/null +++ b/src/main/claude-accounts/claude-command-process.ts @@ -0,0 +1,302 @@ +import { spawnProcess, type ChildProcessHandle } from '../../shared/child-process/run-process' +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { + buildWindowsHostInteractiveLoginSpawn, + type WindowsHostInteractiveLoginSpawn +} from '../../shared/windows-interactive-login-spawn' +import { resolveClaudeCommand } from '../codex-cli/command' +import { buildWindowsCommandInvocation } from './windows-command-invocation' + +const MAX_COMMAND_OUTPUT_CHARS = 4_000 +const WINDOWS_TASKKILL_TIMEOUT_MS = 5_000 +const CLAUDE_AUTH_DENIED_PATTERN = + /\baccess_denied\b|authorization (?:request )?(?:was )?denied|sign-?in (?:was )?denied|login (?:was )?denied/i + +export type ClaudeCommandConfig = { + windowsPath: string + linuxPath: string | null + wslDistro: string | null +} + +export type ClaudeCommandOptions = { + allowFailure?: boolean + signal?: AbortSignal + keepStdinOpen?: boolean +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + +export function runClaudeCommandProcess( + args: string[], + configDir: ClaudeCommandConfig, + timeoutMs: number, + options?: ClaudeCommandOptions +): Promise { + return new Promise((resolvePromise, rejectPromise) => { + const isWindowsHostInteractiveLogin = + process.platform === 'win32' && + configDir.linuxPath === null && + configDir.wslDistro === null && + args[0] === 'auth' && + args[1] === 'login' + // Why lazy: the WSL branch runs `claude` inside the distro, so resolving a + // host binary there would be wasted filesystem probing for a path never used. + let cachedHostClaudeCommand: string | null = null + const hostClaudeCommand = (): string => (cachedHostClaudeCommand ??= resolveClaudeCommand()) + // The native login needs its own visible console, so it runs behind a + // start /wait wrapper that relays the real login PID back for termination. + const interactiveLogin = isWindowsHostInteractiveLogin + ? buildWindowsHostInteractiveLoginSpawn(hostClaudeCommand(), args) + : null + const spawnConfig = resolveClaudeInvocation( + args, + configDir, + interactiveLogin, + hostClaudeCommand + ) + const child = spawnProcess({ + program: spawnConfig.command, + args: spawnConfig.args, + env: spawnConfig.env, + // Why: Claude's browser auth can bind its callback lifetime to stdin. + // Keeping stdin open prevents hidden managed-login runs from tearing down + // the local callback server before the browser returns. + stdio: interactiveLogin + ? interactiveLogin.stdio + : [options?.keepStdinOpen ? 'pipe' : 'ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + windowsVerbatimArguments: spawnConfig.windowsVerbatimArguments + }) + const stdout = child.stdout + const stderr = child.stderr + if (!interactiveLogin && (!stdout || !stderr)) { + if (options?.keepStdinOpen) { + child.stdin?.destroy() + } + child.kill() + rejectPromise(new Error('Claude command failed to open output streams.')) + return + } + const completesOnExit = + process.platform === 'win32' && + configDir.linuxPath === null && + configDir.wslDistro === null && + args[0] === 'auth' && + args[1] === 'login' + const completionEvent = completesOnExit ? 'exit' : 'close' + let settled = false + let output = '' + let timeout: ReturnType | null = null + let terminationPending = false + + const cleanupListeners = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + stdout?.off('data', appendOutput) + stderr?.off('data', appendOutput) + child.off('error', onError) + child.off(completionEvent, onDone) + options?.signal?.removeEventListener('abort', onAbort) + interactiveLogin?.cleanup?.() + if (options?.keepStdinOpen) { + child.stdin?.destroy() + } + if (completesOnExit) { + stdout?.destroy() + stderr?.destroy() + } + } + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + cleanupListeners() + callback() + } + const killChild = (afterKill: () => void): void => { + if (terminationPending || settled) { + return + } + terminationPending = true + terminateClaudeProcess(child, interactiveLogin, afterKill) + } + const appendOutput = (chunk: Buffer): void => { + output = `${output}${chunk.toString()}` + if (output.length > MAX_COMMAND_OUTPUT_CHARS) { + output = output.slice(-MAX_COMMAND_OUTPUT_CHARS) + } + if (CLAUDE_AUTH_DENIED_PATTERN.test(output)) { + killChild(() => + settle(() => rejectPromise(new Error('Claude sign-in was denied. Please try again.'))) + ) + } + } + const onAbort = (): void => { + killChild(() => settle(() => rejectPromise(new Error('Claude sign-in was cancelled.')))) + } + const onError = (error: Error): void => { + if (!terminationPending) { + settle(() => rejectPromise(error)) + } + } + const onDone = (code: number | null): void => { + if (terminationPending) { + return + } + settle(() => { + if (code === 0 || options?.allowFailure) { + resolvePromise(output) + return + } + const trimmedOutput = output.trim() + rejectPromise( + new Error( + trimmedOutput + ? `Claude command failed: ${trimmedOutput}` + : `Claude command exited with code ${code ?? 'unknown'}.` + ) + ) + }) + } + + timeout = setTimeout(() => { + killChild(() => + settle(() => rejectPromise(new Error('Claude sign-in took too long to finish.'))) + ) + }, timeoutMs) + stdout?.on('data', appendOutput) + stderr?.on('data', appendOutput) + child.on('error', onError) + child.on(completionEvent, onDone) + if (options?.signal?.aborted) { + onAbort() + } else { + options?.signal?.addEventListener('abort', onAbort, { once: true }) + } + }) +} + +type ClaudeSpawnConfig = { + command: string + args: string[] + env: NodeJS.ProcessEnv + windowsVerbatimArguments: boolean +} + +function resolveClaudeInvocation( + args: string[], + configDir: ClaudeCommandConfig, + interactiveLogin: WindowsHostInteractiveLoginSpawn | null, + hostClaudeCommand: () => string +): ClaudeSpawnConfig { + const spawnConfig = interactiveLogin + ? { + command: interactiveLogin.command, + args: interactiveLogin.args, + env: withCliRuntimeOnPath(hostClaudeCommand(), { + ...process.env, + CLAUDE_CONFIG_DIR: configDir.windowsPath + }), + windowsVerbatimArguments: false + } + : configDir.linuxPath && configDir.wslDistro + ? { + command: 'wsl.exe', + args: [ + '-d', + configDir.wslDistro, + '--exec', + 'bash', + '-lc', + `export CLAUDE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; exec claude ${args.map(shellQuote).join(' ')}` + ], + env: process.env, + windowsVerbatimArguments: false + } + : process.platform === 'win32' + ? { + ...buildWindowsCommandInvocation(hostClaudeCommand(), args), + env: withCliRuntimeOnPath(hostClaudeCommand(), { + ...process.env, + CLAUDE_CONFIG_DIR: configDir.windowsPath + }) + } + : { + command: hostClaudeCommand(), + args, + env: withCliRuntimeOnPath(hostClaudeCommand(), { + ...process.env, + CLAUDE_CONFIG_DIR: configDir.windowsPath + }), + windowsVerbatimArguments: false + } + return spawnConfig +} + +function terminateClaudeProcess( + child: ChildProcessHandle, + interactiveLogin: WindowsHostInteractiveLoginSpawn | null, + afterKill: () => void +): void { + const killWindowsTree = (windowsTerminationPid: number): void => { + const taskkill = spawnProcess({ + program: 'taskkill.exe', + args: ['/pid', String(windowsTerminationPid), '/t', '/f'], + stdio: 'ignore' + }) + let finished = false + const finish = (succeeded: boolean): void => { + if (finished) { + return + } + finished = true + clearTimeout(taskkillTimeout) + if (!succeeded) { + child.kill() + } + afterKill() + } + const taskkillTimeout = setTimeout(() => { + taskkill.kill() + finish(false) + }, WINDOWS_TASKKILL_TIMEOUT_MS) + taskkill.once('error', () => finish(false)) + taskkill.once('close', (code) => finish(code === 0)) + } + if (process.platform === 'win32') { + // The wrapper's own PID never owns the login tree, so prefer the relayed PID. + const resolveTerminationPid = interactiveLogin?.waitForTerminationPid + ? interactiveLogin.waitForTerminationPid() + : Promise.resolve(interactiveLogin?.getTerminationPid?.() ?? child.pid ?? null) + void resolveTerminationPid + .then((windowsTerminationPid) => { + if (windowsTerminationPid) { + killWindowsTree(windowsTerminationPid) + return + } + child.kill() + afterKill() + }) + .catch(() => { + child.kill() + afterKill() + }) + return + } + if (child.pid) { + try { + process.kill(-child.pid) + afterKill() + return + } catch { + // The direct child remains the only safe fallback when group lookup fails. + } + } + child.kill() + afterKill() +} diff --git a/src/main/claude-accounts/claude-login-session.ts b/src/main/claude-accounts/claude-login-session.ts new file mode 100644 index 00000000000..a02c07da415 --- /dev/null +++ b/src/main/claude-accounts/claude-login-session.ts @@ -0,0 +1,142 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { toWindowsWslPath } from '../wsl' +import { runWslProcess } from '../wsl/wsl-runner' +import type { CapturedClaudeAuth } from './claude-auth-capture' +import type { ClaudeCommandConfig, ClaudeCommandOptions } from './claude-command-process' +import type { ClaudeManagedAuthLocation } from './claude-managed-auth-storage' +import { + deleteActiveClaudeKeychainCredentialsStrict, + readActiveClaudeKeychainCredentials, + writeActiveClaudeKeychainCredentials +} from './keychain' + +const LOGIN_TIMEOUT_MS = 180_000 +const STATUS_TIMEOUT_MS = 20_000 + +type ClaudeLoginSessionDependencies = { + runCommand: ( + args: string[], + config: ClaudeCommandConfig, + timeoutMs: number, + options?: ClaudeCommandOptions + ) => Promise + capture: ( + configDir: string, + statusOutput: string, + previousLegacyKeychain: string | null + ) => Promise + setCancel: (cancel: (() => boolean) | null) => void +} + +export async function runClaudeLoginSession( + location: ClaudeManagedAuthLocation, + dependencies: ClaudeLoginSessionDependencies +): Promise { + const tempConfig = await createTemporaryClaudeConfigDir(location) + const controller = new AbortController() + dependencies.setCancel(() => { + if (controller.signal.aborted) { + return false + } + controller.abort() + return true + }) + const previousLegacyKeychain = await readActiveClaudeKeychainCredentials() + let captured: CapturedClaudeAuth | null = null + let captureError: unknown = null + let cleanupError: unknown = null + try { + if (controller.signal.aborted) { + throw new Error('Claude sign-in was cancelled.') + } + await dependencies.runCommand(['auth', 'login', '--claudeai'], tempConfig, LOGIN_TIMEOUT_MS, { + signal: controller.signal, + keepStdinOpen: true + }) + dependencies.setCancel(null) + const status = await dependencies.runCommand( + ['auth', 'status', '--json'], + tempConfig, + STATUS_TIMEOUT_MS, + { allowFailure: true } + ) + captured = await dependencies.capture(tempConfig.windowsPath, status, previousLegacyKeychain) + } catch (error) { + captureError = error + } finally { + if (process.platform === 'darwin') { + try { + await deleteActiveClaudeKeychainCredentialsStrict(tempConfig.windowsPath) + } catch (error) { + console.warn('[claude-accounts] Failed to clean temporary Claude Keychain item:', error) + } + try { + await (previousLegacyKeychain + ? writeActiveClaudeKeychainCredentials(previousLegacyKeychain) + : deleteActiveClaudeKeychainCredentialsStrict()) + } catch (error) { + cleanupError = error + } + } + await removeTemporaryClaudeConfigDir(tempConfig) + dependencies.setCancel(null) + } + if (captureError) { + throw captureError + } + if (cleanupError) { + throw cleanupError + } + return captured! +} + +async function createTemporaryClaudeConfigDir( + location: ClaudeManagedAuthLocation +): Promise { + if (location.managedAuthRuntime !== 'wsl') { + return { + windowsPath: mkdtempSync(join(tmpdir(), 'orca-claude-login-')), + linuxPath: null, + wslDistro: null + } + } + if (!location.wslDistro) { + throw new Error('Could not resolve the active WSL distribution for Claude login.') + } + const created = await runWslProcess({ + distro: location.wslDistro, + loginPath: 'none', + shell: 'bash', + script: 'mktemp -d "${TMPDIR:-/tmp}/orca-claude-login.XXXXXX"', + timeoutMs: 5000 + }) + const linuxPath = created.stdout.replaceAll(String.fromCharCode(0), '').trim() + if (created.code !== 0 || created.timedOut || !linuxPath.startsWith('/')) { + throw new Error('Could not create a temporary WSL Claude login directory.') + } + return { + windowsPath: toWindowsWslPath(linuxPath, location.wslDistro), + linuxPath, + wslDistro: location.wslDistro + } +} + +async function removeTemporaryClaudeConfigDir(config: ClaudeCommandConfig): Promise { + if (config.linuxPath && config.wslDistro) { + try { + await runWslProcess({ + distro: config.wslDistro, + loginPath: 'none', + program: 'rm', + args: ['-rf', '--', config.linuxPath], + timeoutMs: 5000 + }) + } catch { + // Cleanup cannot mask the login result. + } + return + } + rmSync(config.windowsPath, { recursive: true, force: true }) +} diff --git a/src/main/claude-accounts/claude-managed-auth-storage.ts b/src/main/claude-accounts/claude-managed-auth-storage.ts new file mode 100644 index 00000000000..970202c8210 --- /dev/null +++ b/src/main/claude-accounts/claude-managed-auth-storage.ts @@ -0,0 +1,281 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join, relative, resolve, sep } from 'node:path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' +import { runWslProcess } from '../wsl/wsl-runner' +import { + getClaudeManagedAccountsRoot, + readClaudeManagedAuthFile, + resolveOwnedClaudeManagedAuthPath, + writeClaudeManagedAuthFile +} from './managed-auth-path' +import { + deleteManagedClaudeKeychainCredentials, + readManagedClaudeKeychainCredentials, + writeManagedClaudeKeychainCredentials +} from './keychain' + +export type ClaudeManagedAuthLocation = { + managedAuthPath: string + managedAuthRuntime: 'host' | 'wsl' + wslDistro: string | null + wslLinuxAuthPath: string | null +} + +export type ClaudeManagedAuthSnapshot = { + credentialsJson: string | null + oauthAccountJson: string | null +} + +export type ClaudeManagedAuthTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} + +export class ClaudeManagedAuthStorage { + async create( + accountId: string, + target?: ClaudeManagedAuthTarget + ): Promise { + const wslAuth = await this.tryCreateWsl(accountId, target) + if (wslAuth) { + return wslAuth + } + const managedAuthPath = join(this.getRoot(), accountId, 'auth') + mkdirSync(managedAuthPath, { recursive: true, mode: 0o700 }) + writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, { + encoding: 'utf-8', + mode: 0o600 + }) + return { + managedAuthPath: await this.assertOwned(managedAuthPath, accountId), + managedAuthRuntime: 'host', + wslDistro: null, + wslLinuxAuthPath: null + } + } + + async writeAuth( + accountId: string, + managedAuthPath: string, + captured: { credentialsJson: string; oauthAccount: unknown } + ): Promise { + await this.writeCredentials(accountId, managedAuthPath, captured.credentialsJson) + await this.writeOauthAccount(accountId, managedAuthPath, captured.oauthAccount) + } + + async writeCredentials( + accountId: string, + managedAuthPath: string, + credentialsJson: string + ): Promise { + const trustedPath = await this.assertOwned(managedAuthPath, accountId) + if (process.platform === 'darwin') { + await writeManagedClaudeKeychainCredentials(accountId, credentialsJson) + } else { + writeClaudeManagedAuthFile(trustedPath, '.credentials.json', credentialsJson) + } + } + + async writeOauthAccount( + accountId: string, + managedAuthPath: string, + oauthAccount: unknown + ): Promise { + const trustedPath = await this.assertOwned(managedAuthPath, accountId) + writeClaudeManagedAuthFile( + trustedPath, + 'oauth-account.json', + `${JSON.stringify(oauthAccount, null, 2)}\n` + ) + } + + async readSnapshot( + accountId: string, + managedAuthPath: string + ): Promise { + const trustedPath = await this.assertOwned(managedAuthPath, accountId) + return { + credentialsJson: + process.platform === 'darwin' + ? await readManagedClaudeKeychainCredentials(accountId) + : readClaudeManagedAuthFile(trustedPath, '.credentials.json'), + oauthAccountJson: readClaudeManagedAuthFile(trustedPath, 'oauth-account.json') + } + } + + async restoreCredentials( + accountId: string, + managedAuthPath: string, + snapshot: ClaudeManagedAuthSnapshot + ): Promise { + const trustedPath = await this.assertOwned(managedAuthPath, accountId) + if (process.platform === 'darwin') { + await (snapshot.credentialsJson !== null + ? writeManagedClaudeKeychainCredentials(accountId, snapshot.credentialsJson) + : deleteManagedClaudeKeychainCredentials(accountId)) + } else if (snapshot.credentialsJson !== null) { + writeClaudeManagedAuthFile(trustedPath, '.credentials.json', snapshot.credentialsJson) + } else { + rmSync(join(trustedPath, '.credentials.json'), { force: true }) + } + } + + async restoreOauth( + accountId: string, + managedAuthPath: string, + snapshot: ClaudeManagedAuthSnapshot + ): Promise { + const trustedPath = await this.assertOwned(managedAuthPath, accountId) + if (snapshot.oauthAccountJson !== null) { + writeClaudeManagedAuthFile(trustedPath, 'oauth-account.json', snapshot.oauthAccountJson) + } else { + rmSync(join(trustedPath, 'oauth-account.json'), { force: true }) + } + } + + async remove(accountId: string, candidatePath: string): Promise { + try { + const managedAuthPath = await this.assertOwned(candidatePath, accountId) + rmSync(resolve(managedAuthPath, '..'), { recursive: true, force: true }) + } catch (error) { + console.warn('[claude-accounts] Refusing to remove untrusted managed auth:', error) + } + await deleteManagedClaudeKeychainCredentials(accountId) + } + + async assertOwned(candidatePath: string, expectedAccountId?: string): Promise { + const wslInfo = parseWslUncPath(candidatePath) + if (wslInfo) { + return this.assertOwnedWsl(candidatePath, wslInfo, expectedAccountId) + } + this.getRoot() + const accountId = expectedAccountId ?? this.readAccountId(candidatePath) + if (!accountId || (expectedAccountId && accountId !== expectedAccountId)) { + throw new Error('Managed Claude auth directory does not exist on disk.') + } + const trustedPath = resolveOwnedClaudeManagedAuthPath(accountId, candidatePath, { + adoptLegacyMarker: true + }) + if (!trustedPath) { + throw new Error('Managed Claude auth storage is not owned by Orca.') + } + return trustedPath + } + + private async tryCreateWsl( + accountId: string, + target?: ClaudeManagedAuthTarget + ): Promise { + if (process.platform !== 'win32' || target?.runtime !== 'wsl') { + return null + } + const requestedDistro = target.wslDistro?.trim() || undefined + const info = await runWslProcess({ + distro: requestedDistro, + loginPath: 'none', + shell: 'bash', + script: 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"', + timeoutMs: 5000 + }) + const [rawDistro, rawHome] = + info.code === 0 && !info.timedOut + ? info.stdout + .replaceAll(String.fromCharCode(0), '') + .split(/\r?\n/) + .map((line) => line.trim()) + : [] + const distro = requestedDistro || rawDistro + const home = rawHome + if (!distro || !home?.startsWith('/')) { + throw new Error('Could not resolve the active WSL home directory for Claude login.') + } + const linuxPath = `${home.replace(/\/$/, '')}/.local/share/orca/claude-accounts/${accountId}/auth` + const created = await runWslProcess({ + distro, + loginPath: 'none', + shell: 'bash', + script: 'umask 077; mkdir -p "$1" && printf \'%s\\n\' "$2" > "$1/.orca-managed-claude-auth"', + args: [linuxPath, accountId], + timeoutMs: 5000 + }) + if (created.code !== 0 || created.timedOut) { + throw new Error('Could not create the managed WSL Claude auth directory.') + } + const managedAuthPath = toWindowsWslPath(linuxPath, distro) + return { + managedAuthPath: await this.assertOwned(managedAuthPath, accountId), + managedAuthRuntime: 'wsl', + wslDistro: distro, + wslLinuxAuthPath: linuxPath + } + } + + private async assertOwnedWsl( + candidatePath: string, + wslInfo: NonNullable>, + expectedAccountId?: string + ): Promise { + if ( + !wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') || + !wslInfo.linuxPath.endsWith('/auth') + ) { + throw new Error('Managed WSL Claude auth storage is outside Orca account storage.') + } + if (process.platform !== 'win32') { + if ( + !existsSync(candidatePath) || + !existsSync(join(candidatePath, '.orca-managed-claude-auth')) + ) { + throw new Error('Managed Claude auth storage is not owned by Orca.') + } + return candidatePath + } + try { + const expected = expectedAccountId + ? `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(expectedAccountId)}` + : 'test -n "$(cat "$candidate_real/.orca-managed-claude-auth")"' + const owned = await runWslProcess({ + distro: wslInfo.distro, + loginPath: 'none', + shell: 'bash', + script: [ + 'set -euo pipefail', + `candidate=${shellQuote(wslInfo.linuxPath)}`, + 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', + 'candidate_real=$(readlink -f -- "$candidate")', + 'managed_root_real=$(readlink -f -- "$managed_root")', + 'test -f "$candidate_real/.orca-managed-claude-auth"', + expected, + 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' + ].join('\n'), + timeoutMs: 5000 + }) + const canonicalPath = owned.stdout.trim() + if (owned.code !== 0 || owned.timedOut || !canonicalPath) { + throw new Error('Managed Claude auth directory does not exist on disk.') + } + return toWindowsWslPath(canonicalPath, wslInfo.distro) + } catch (error) { + throw new Error('Managed WSL Claude auth storage is outside Orca account storage.', { + cause: error + }) + } + } + + private getRoot(): string { + const root = getClaudeManagedAccountsRoot() + mkdirSync(root, { recursive: true, mode: 0o700 }) + return root + } + + private readAccountId(candidatePath: string): string | null { + const relativePath = relative(resolve(this.getRoot()), resolve(candidatePath)) + const parts = relativePath.split(sep) + return parts.length === 2 && parts[1] === 'auth' ? parts[0] : null + } +} diff --git a/src/main/claude-accounts/claude-windows-interactive-login.test.ts b/src/main/claude-accounts/claude-windows-interactive-login.test.ts new file mode 100644 index 00000000000..69ca24c21a8 --- /dev/null +++ b/src/main/claude-accounts/claude-windows-interactive-login.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { getCmdExePath } from '../../shared/windows-batch-spawn' +import { createService, restorePlatform, setPlatform } from './claude-account-service-test-harness' + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-claude-windows-login-test' + } +})) + +vi.mock('../codex-cli/command', () => ({ + resolveClaudeCommand: vi.fn(() => 'C:\\Tools\\claude.cmd') +})) + +describe('Claude Windows host interactive login', () => { + afterEach(() => { + restorePlatform() + }) + + it('resolves a Windows auth login whose child has no piped streams', async () => { + setPlatform('win32') + vi.resetModules() + const child = new EventEmitter() as EventEmitter & { + stdout: null + stderr: null + kill: ReturnType + pid: number + } + child.stdout = null + child.stderr = null + child.kill = vi.fn() + child.pid = 4242 + const spawnMock = vi.fn(() => { + queueMicrotask(() => child.emit('exit', 0)) + return child + }) + const buildInteractiveLoginSpawn = vi.fn(() => ({ + command: getCmdExePath(), + args: [ + '/d', + '/c', + 'start', + '', + '/wait', + 'C:\\Tools\\claude.cmd', + 'auth', + 'login', + '--claudeai' + ], + stdio: 'ignore' as const, + windowsHide: true + })) + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ + buildWindowsHostInteractiveLoginSpawn: buildInteractiveLoginSpawn + })) + + try { + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + createService() as never, + createService() as never, + createService() as never + ) + await ( + service as unknown as { + runClaudeCommand( + args: string[], + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, + timeoutMs: number + ): Promise + } + ).runClaudeCommand( + ['auth', 'login', '--claudeai'], + { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, + 1000 + ) + + expect(buildInteractiveLoginSpawn).toHaveBeenCalledWith('C:\\Tools\\claude.cmd', [ + 'auth', + 'login', + '--claudeai' + ]) + expect(spawnMock).toHaveBeenCalledWith( + getCmdExePath(), + expect.arrayContaining(['start', '', '/wait']), + expect.objectContaining({ + stdio: 'ignore', + windowsHide: true + }) + ) + expect(child.kill).not.toHaveBeenCalled() + } finally { + vi.doUnmock('node:child_process') + vi.doUnmock('../../shared/windows-interactive-login-spawn') + } + }) + + it('times out a denied Windows login that stays alive without piped output', async () => { + setPlatform('win32') + vi.resetModules() + vi.useFakeTimers() + const child = new EventEmitter() as EventEmitter & { + stdout: null + stderr: null + kill: ReturnType + pid: number + } + child.stdout = null + child.stderr = null + child.kill = vi.fn() + child.pid = 0 + const spawnMock = vi.fn(() => child) + vi.doMock('node:child_process', () => ({ spawn: spawnMock })) + + try { + const { ClaudeAccountService } = await import('./service') + const service = new ClaudeAccountService( + createService() as never, + createService() as never, + createService() as never + ) + const login = ( + service as unknown as { + runClaudeCommand( + args: string[], + configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, + timeoutMs: number + ): Promise + } + ).runClaudeCommand( + ['auth', 'login', '--claudeai'], + { windowsPath: 'C:\\tmp\\claude-auth', linuxPath: null, wslDistro: null }, + 1000 + ) + const rejection = expect(login).rejects.toThrow('Claude sign-in took too long to finish.') + + await vi.advanceTimersByTimeAsync(3_000) + + await rejection + expect(child.kill).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + vi.doUnmock('node:child_process') + } + }) +}) diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 2e3206eab69..8f1077ba1a5 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -1,75 +1,26 @@ -/* eslint-disable max-lines -- Why: Claude managed accounts need one audited owner -for login, credential capture, Keychain storage, selection, and rate-limit refresh. */ -import { createHash, randomUUID } from 'node:crypto' -import { spawn } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join, relative, resolve, sep } from 'node:path' -import type { - ClaudeManagedAccount, - ClaudeManagedAccountSummary, - ClaudeRateLimitAccountsState -} from '../../shared/managed-account-types' +import type { ClaudeRateLimitAccountsState } from '../../shared/managed-account-types' import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' -import { resolveClaudeCommand } from '../codex-cli/command' +import { ClaudeAccountRegistration } from './claude-account-registration' +import { ClaudeAccountSelection } from './claude-account-selection' +import { + captureClaudeAuthFromConfigDir, + captureClaudeAuthFromExistingConfigDir, + readCapturedClaudeCredentials, + type CapturedClaudeAuth +} from './claude-auth-capture' +import { + runClaudeCommandProcess, + type ClaudeCommandConfig, + type ClaudeCommandOptions +} from './claude-command-process' +import { runClaudeLoginSession } from './claude-login-session' +import { + ClaudeManagedAuthStorage, + type ClaudeManagedAuthLocation +} from './claude-managed-auth-storage' import type { ClaudeRuntimeAuthService } from './runtime-auth-service' -import { - getClaudeManagedAccountsRoot, - readClaudeManagedAuthFile, - resolveOwnedClaudeManagedAuthPath, - writeClaudeManagedAuthFile -} from './managed-auth-path' -import { - deleteActiveClaudeKeychainCredentialsStrict, - deleteManagedClaudeKeychainCredentials, - readActiveClaudeKeychainCredentials, - readActiveClaudeKeychainCredentialsStrict, - readManagedClaudeKeychainCredentials, - writeActiveClaudeKeychainCredentials, - writeManagedClaudeKeychainCredentials -} from './keychain' -import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from './live-pty-gate' -import { findDuplicateClaudeAccount } from './claude-duplicate-account' -import { parseWslUncPath } from '../../shared/wsl-paths' -import { toWindowsWslPath } from '../wsl' -import { runWslProcess } from '../wsl/wsl-runner' -import { buildWindowsCommandInvocation } from './windows-command-invocation' -import { - getClaudeSelectionTargetForAccount, - getSelectedClaudeAccountIdForTarget, - normalizeClaudeAccountSelectionTarget, - normalizeClaudeRuntimeSelection, - pruneInvalidClaudeRuntimeSelection, - removeClaudeAccountIdFromSelection, - setSelectedClaudeAccountIdForTarget, - type ClaudeAccountSelectionTarget -} from './runtime-selection' - -const LOGIN_TIMEOUT_MS = 180_000 -const STATUS_TIMEOUT_MS = 20_000 -const MAX_COMMAND_OUTPUT_CHARS = 4_000 -const WINDOWS_TASKKILL_TIMEOUT_MS = 5_000 -// Claude leaves the login process running after an OAuth denial; fail fast so Settings can clear loading state. -const CLAUDE_AUTH_DENIED_PATTERN = - /\baccess_denied\b|authorization (?:request )?(?:was )?denied|sign-?in (?:was )?denied|login (?:was )?denied/i - -type ClaudeIdentity = { - email: string | null - organizationUuid: string | null - organizationName: string | null -} - -type CapturedClaudeAuth = { - credentialsJson: string - oauthAccount: unknown - identity: ClaudeIdentity -} - -type ManagedClaudeAuthSnapshot = { - credentialsJson: string | null - oauthAccountJson: string | null -} +import type { ClaudeAccountSelectionTarget } from './runtime-selection' export type ClaudeAccountAddTarget = { runtime?: 'host' | 'wsl' @@ -80,68 +31,78 @@ export type ClaudeAccountImportOptions = ClaudeAccountAddTarget & { previousLegacyCredentialsSha256?: string | null } -type ManagedClaudeAuthLocation = { - managedAuthPath: string - managedAuthRuntime: 'host' | 'wsl' - wslDistro: string | null - wslLinuxAuthPath: string | null -} - -class DuplicateClaudeAccountError extends Error {} - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - export class ClaudeAccountService { private mutationQueue: Promise = Promise.resolve() private cancelPendingClaudeLogin: (() => boolean) | null = null + private readonly storage = new ClaudeManagedAuthStorage() + private readonly selection: ClaudeAccountSelection + private readonly registration: ClaudeAccountRegistration constructor( - private readonly store: Store, - private readonly rateLimits: RateLimitService, + store: Store, + rateLimits: RateLimitService, private readonly runtimeAuth: ClaudeRuntimeAuthService - ) {} + ) { + this.selection = new ClaudeAccountSelection(store, rateLimits, runtimeAuth, (accountId, path) => + this.safeRemoveManagedAuth(accountId, path) + ) + this.registration = new ClaudeAccountRegistration({ + store, + rateLimits, + runtimeAuth, + selection: this.selection, + createManagedAuth: (accountId, target) => this.storage.create(accountId, target), + assertManagedAuth: (path, accountId) => this.storage.assertOwned(path, accountId), + removeManagedAuth: (accountId, path) => this.safeRemoveManagedAuth(accountId, path), + writeManagedAuth: (accountId, path, captured) => + this.writeManagedAuth(accountId, path, captured), + writeCredentials: (accountId, path, value) => + this.storage.writeCredentials(accountId, path, value), + writeOauth: (accountId, path, value) => + this.storage.writeOauthAccount(accountId, path, value), + readSnapshot: (accountId, path) => this.storage.readSnapshot(accountId, path), + restoreCredentials: (accountId, path, snapshot) => + this.storage.restoreCredentials(accountId, path, snapshot), + restoreOauth: (accountId, path, snapshot) => + this.storage.restoreOauth(accountId, path, snapshot), + login: (location) => this.runClaudeLoginAndCapture(location), + captureExisting: (configDir, previousDigest) => + this.captureFromExistingConfigDir(configDir, previousDigest) + }) + } listAccounts(): ClaudeRateLimitAccountsState { - this.normalizeActiveSelection() - return this.getSnapshot() + return this.selection.list() } async addAccount(target?: ClaudeAccountAddTarget): Promise { - return this.serializeMutation(() => this.doAddAccount(target)) + return this.serializeMutation(() => this.registration.add(target)) } - /** - * Adds a managed Claude account from an already-authenticated `CLAUDE_CONFIG_DIR` - * instead of driving the interactive browser login here. Enables the - * `orca account add` CLI to run `claude login` in the user's own terminal on a - * headless host, then register the captured credentials without a desktop GUI. - */ async addAccountFromConfigDir( configDir: string, options?: ClaudeAccountImportOptions ): Promise { - return this.serializeMutation(() => this.doAddAccountFromConfigDir(configDir, options)) + return this.serializeMutation(() => this.registration.addFromConfigDir(configDir, options)) } async reauthenticateAccount(accountId: string): Promise { - return this.serializeMutation(() => this.doReauthenticateAccount(accountId)) + return this.serializeMutation(() => this.registration.reauthenticate(accountId)) } async removeAccount(accountId: string): Promise { - return this.serializeMutation(() => this.doRemoveAccount(accountId)) + return this.serializeMutation(() => this.selection.remove(accountId)) } async selectAccount(accountId: string | null): Promise { - return this.serializeMutation(() => this.doSelectAccount(accountId)) + return this.serializeMutation(() => this.selection.select(accountId)) } async selectAccountForTarget( accountId: string | null, target?: ClaudeAccountSelectionTarget ): Promise { - return this.serializeMutation(() => this.doSelectAccount(accountId, target)) + return this.serializeMutation(() => this.selection.select(accountId, target)) } cancelPendingLogin(): boolean { @@ -152,1123 +113,87 @@ export class ClaudeAccountService { return this.runtimeAuth.getRuntimeConfigDir(target) } - private serializeMutation(fn: () => Promise): Promise { - const next = this.mutationQueue.then(fn, fn) + private serializeMutation(operation: () => Promise): Promise { + const next = this.mutationQueue.then(operation, operation) this.mutationQueue = next.catch(() => {}) return next } - private async doAddAccount( - target?: ClaudeAccountAddTarget - ): Promise { - const accountId = randomUUID() - const managedAuth = await this.createManagedAuthDir(accountId, target) - const previousSettings = this.store.getSettings() - try { - const captured = await this.runClaudeLoginAndCapture(managedAuth) - return await this.persistCapturedClaudeAccount( - accountId, - managedAuth, - previousSettings, - captured - ) - } catch (error) { - await this.cleanupFailedAdd(accountId, managedAuth.managedAuthPath, previousSettings, error) - throw error - } - } - - private async doAddAccountFromConfigDir( - configDir: string, - options?: ClaudeAccountImportOptions - ): Promise { - const accountId = randomUUID() - const managedAuth = await this.createManagedAuthDir(accountId, options) - const previousSettings = this.store.getSettings() - try { - const captured = await this.captureFromExistingConfigDir( - configDir, - options?.previousLegacyCredentialsSha256 - ) - return await this.persistCapturedClaudeAccount( - accountId, - managedAuth, - previousSettings, - captured - ) - } catch (error) { - await this.cleanupFailedAdd(accountId, managedAuth.managedAuthPath, previousSettings, error) - throw error - } - } - - // Why: capture credentials from a CLAUDE_CONFIG_DIR the caller already - // authenticated (e.g. a temp dir the CLI ran `claude login` into), mirroring - // runClaudeLoginAndCapture's capture step but without spawning the interactive - // login. On Linux/Windows the credentials live in a plaintext `.credentials.json`. - private async captureFromExistingConfigDir( - configDir: string, - previousLegacyCredentialsSha256?: string | null - ): Promise { - const trimmed = configDir.trim() - if (!trimmed) { - throw new Error('A Claude config directory path is required.') - } - const resolvedDir = resolve(trimmed) - // Why: macOS keeps Claude credentials in the Keychain rather than a file, so - // only require `.credentials.json` off-darwin; captureAuthFromConfigDir reads - // the scoped Keychain item on macOS. - if (process.platform !== 'darwin' && !existsSync(join(resolvedDir, '.credentials.json'))) { - throw new Error( - `No Claude credentials found in ${resolvedDir}. Run \`claude login\` into this directory first.` - ) - } - // Why: `allowFailure` covers a non-zero exit but not a spawn error, and unlike - // the GUI flow nothing has run `claude` in this process yet — a daemon started - // with a minimal PATH (launchd/systemd) would hard-fail an add the user already - // signed in for. Identity still resolves from the config dir's oauthAccount. - let status = '' - try { - status = await this.runClaudeCommand( - ['auth', 'status', '--json'], - { windowsPath: resolvedDir, linuxPath: null, wslDistro: null }, - STATUS_TIMEOUT_MS, - { allowFailure: true } - ) - } catch (error) { - console.warn('[claude-accounts] Could not read `claude auth status`:', error) - } - // Why: this post-login RPC did not observe the legacy Keychain value before - // login unless the CLI supplied its one-way pre-login credential baseline. - const currentLegacyKeychain = await readActiveClaudeKeychainCredentialsStrict() - return this.captureAuthFromConfigDir( - resolvedDir, - status, - currentLegacyKeychain, - previousLegacyCredentialsSha256 - ) - } - - private async persistCapturedClaudeAccount( - accountId: string, - managedAuth: ManagedClaudeAuthLocation, - previousSettings: ReturnType, - captured: CapturedClaudeAuth - ): Promise { - if (!captured.identity.email) { - throw new Error('Claude login completed, but Orca could not resolve the account email.') - } - // Why: duplicate rows confuse selection and rate-limit tracking; re-authentication - // is the supported way to refresh an account that is already managed. - if ( - findDuplicateClaudeAccount(previousSettings.claudeManagedAccounts, { - email: captured.identity.email, - organizationUuid: captured.identity.organizationUuid, - managedAuthRuntime: managedAuth.managedAuthRuntime, - wslDistro: managedAuth.wslDistro - }) - ) { - throw new DuplicateClaudeAccountError('This Claude account is already added.') - } - await this.writeManagedAuth(accountId, managedAuth.managedAuthPath, captured) - - const now = Date.now() - const account: ClaudeManagedAccount = { - id: accountId, - email: captured.identity.email, - managedAuthPath: managedAuth.managedAuthPath, - managedAuthRuntime: managedAuth.managedAuthRuntime, - wslDistro: managedAuth.wslDistro, - wslLinuxAuthPath: managedAuth.wslLinuxAuthPath, - authMethod: 'subscription-oauth', - organizationUuid: captured.identity.organizationUuid, - organizationName: captured.identity.organizationName, - createdAt: now, - updatedAt: now, - lastAuthenticatedAt: now - } - - const selection = normalizeClaudeRuntimeSelection(previousSettings) - this.store.updateSettings({ - claudeManagedAccounts: [...previousSettings.claudeManagedAccounts, account], - activeClaudeManagedAccountId: selection.host, - activeClaudeManagedAccountIdsByRuntime: selection - }) - this.runtimeAuth.clearLastWrittenCredentialsJson(accountId) - this.rateLimits.evictInactiveClaudeCache(accountId) - return this.getSnapshot() - } - - private async rollbackAddAccount( - accountId: string, - managedAuthPath: string, - previousSettings: ReturnType - ): Promise { - this.restoreClaudeSettings(previousSettings) - // Why: rollback is best-effort — a failed rematerialization must not skip the - // managed-auth cleanup below, and the caller rethrows the original add error. - try { - await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() - } catch (rollbackError) { - console.warn('[claude-accounts] Rollback rematerialization failed:', rollbackError) - } - await this.safeRemoveManagedAuth(accountId, managedAuthPath) - } - - private async cleanupFailedAdd( - accountId: string, - managedAuthPath: string, - previousSettings: ReturnType, - error: unknown - ): Promise { - if (error instanceof DuplicateClaudeAccountError) { - // Why: duplicate detection precedes writes; rollback I/O could only mask - // the useful duplicate-account error. - await this.safeRemoveManagedAuth(accountId, managedAuthPath) - return - } - await this.rollbackAddAccount(accountId, managedAuthPath, previousSettings) - } - - private async doReauthenticateAccount(accountId: string): Promise { - const account = this.requireAccount(accountId) - const managedAuthPath = await this.assertManagedAuthPath(account.managedAuthPath, accountId) - const previousSettings = this.store.getSettings() - const previousManagedAuth = await this.readManagedAuthSnapshot(accountId, managedAuthPath) - const captured = await this.runClaudeLoginAndCapture({ - managedAuthPath, - managedAuthRuntime: account.managedAuthRuntime ?? 'host', - wslDistro: account.wslDistro ?? null, - wslLinuxAuthPath: account.wslLinuxAuthPath ?? null - }) - if (!captured.identity.email) { - throw new Error('Claude login completed, but Orca could not resolve the account email.') - } - - const settings = this.store.getSettings() - const now = Date.now() - const reauthenticatedAccounts = settings.claudeManagedAccounts.map((entry) => - entry.id === accountId - ? { - ...entry, - email: captured.identity.email!, - organizationUuid: captured.identity.organizationUuid, - organizationName: captured.identity.organizationName, - updatedAt: now, - lastAuthenticatedAt: now - } - : entry - ) - let wroteManagedCredentials = false - try { - await this.writeManagedOauthAccount(accountId, managedAuthPath, captured.oauthAccount) - await this.writeManagedCredentials(accountId, managedAuthPath, captured.credentialsJson) - wroteManagedCredentials = true - this.store.updateSettings({ claudeManagedAccounts: reauthenticatedAccounts }) - this.runtimeAuth.clearLastWrittenCredentialsJson(accountId) - this.rateLimits.evictInactiveClaudeCache(accountId) - await this.syncRuntimeAuthWithLivePtyGate(getClaudeSelectionTargetForAccount(account)) - await this.rateLimits.refreshForClaudeAccountChange( - undefined, - getClaudeSelectionTargetForAccount(account) - ) - return this.getSnapshot() - } catch (error) { - let restoredManagedCredentials = false - try { - await this.restoreManagedCredentialsSnapshot( - accountId, - managedAuthPath, - previousManagedAuth - ) - restoredManagedCredentials = true - } catch (rollbackError) { - console.warn( - '[claude-accounts] Failed to restore managed credentials during rollback:', - rollbackError - ) - } - if (restoredManagedCredentials || !wroteManagedCredentials) { - try { - await this.restoreManagedOauthSnapshot(accountId, managedAuthPath, previousManagedAuth) - } catch (rollbackError) { - console.warn( - '[claude-accounts] Failed to restore managed oauth metadata during rollback:', - rollbackError - ) - } - } - if (restoredManagedCredentials) { - this.restoreClaudeSettings(previousSettings) - await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() - } else if (wroteManagedCredentials) { - this.store.updateSettings({ claudeManagedAccounts: reauthenticatedAccounts }) - } else { - this.restoreClaudeSettings(previousSettings) - } - throw error - } - } - - private async doRemoveAccount(accountId: string): Promise { - const account = this.requireAccount(accountId) - const settings = this.store.getSettings() - const nextAccounts = settings.claudeManagedAccounts.filter((entry) => entry.id !== accountId) - const nextSelection = removeClaudeAccountIdFromSelection( - normalizeClaudeRuntimeSelection(settings), - accountId - ) - const nextActiveId = - settings.activeClaudeManagedAccountId === accountId ? null : nextSelection.host - - try { - if ( - getSelectedClaudeAccountIdForTarget( - settings, - getClaudeSelectionTargetForAccount(account) - ) === accountId - ) { - this.store.updateSettings({ - activeClaudeManagedAccountId: nextActiveId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - await this.syncRuntimeAuthWithLivePtyGate(getClaudeSelectionTargetForAccount(account)) - this.store.updateSettings({ claudeManagedAccounts: nextAccounts }) - } else { - this.store.updateSettings({ - claudeManagedAccounts: nextAccounts, - activeClaudeManagedAccountId: nextActiveId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - await this.syncRuntimeAuthWithLivePtyGate(getClaudeSelectionTargetForAccount(account)) - } - await this.safeRemoveManagedAuth(accountId, account.managedAuthPath) - this.rateLimits.evictInactiveClaudeCache(accountId) - await this.rateLimits.refreshForClaudeAccountChange( - getSelectedClaudeAccountIdForTarget( - settings, - getClaudeSelectionTargetForAccount(account) - ) === accountId - ? accountId - : undefined, - getClaudeSelectionTargetForAccount(account) - ) - return this.getSnapshot() - } catch (error) { - this.restoreClaudeSettings(settings) - await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() - throw error - } - } - - private async doSelectAccount( - accountId: string | null, - target?: ClaudeAccountSelectionTarget - ): Promise { - let effectiveTarget = target - if (accountId !== null) { - const account = this.requireAccount(accountId) - const accountTarget = getClaudeSelectionTargetForAccount(account) - const requestedTarget = normalizeClaudeAccountSelectionTarget(target ?? accountTarget) - const normalizedAccountTarget = normalizeClaudeAccountSelectionTarget(accountTarget) - if ( - requestedTarget.runtime !== normalizedAccountTarget.runtime || - (requestedTarget.wslDistro !== null && - requestedTarget.wslDistro !== normalizedAccountTarget.wslDistro) - ) { - throw new Error('That Claude account belongs to a different runtime.') - } - effectiveTarget = accountTarget - } - const previousSettings = this.store.getSettings() - const selection = normalizeClaudeRuntimeSelection(previousSettings) - const outgoingAccountId = getSelectedClaudeAccountIdForTarget(previousSettings, effectiveTarget) - const nextSelection = setSelectedClaudeAccountIdForTarget(selection, accountId, effectiveTarget) - this.store.updateSettings({ - activeClaudeManagedAccountId: - effectiveTarget?.runtime === 'wsl' ? nextSelection.host : accountId, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - try { - await this.syncRuntimeAuthWithLivePtyGate(effectiveTarget) - await this.rateLimits.refreshForClaudeAccountChange(outgoingAccountId, effectiveTarget) - return this.getSnapshot() - } catch (error) { - this.restoreClaudeSettings(previousSettings) - await this.runtimeAuth.forceMaterializeCurrentSelectionForRollback() - throw error - } - } - - private getSnapshot(): ClaudeRateLimitAccountsState { - const settings = this.store.getSettings() - return { - accounts: settings.claudeManagedAccounts - .map((account) => this.toSummary(account)) - .sort((a, b) => b.updatedAt - a.updatedAt), - activeAccountId: normalizeClaudeRuntimeSelection(settings).host, - activeAccountIdsByRuntime: normalizeClaudeRuntimeSelection(settings) - } - } - - private toSummary(account: ClaudeManagedAccount): ClaudeManagedAccountSummary { - return { - id: account.id, - email: account.email, - managedAuthRuntime: account.managedAuthRuntime ?? 'host', - wslDistro: account.wslDistro ?? null, - authMethod: account.authMethod ?? 'unknown', - organizationUuid: account.organizationUuid ?? null, - organizationName: account.organizationName ?? null, - createdAt: account.createdAt, - updatedAt: account.updatedAt, - lastAuthenticatedAt: account.lastAuthenticatedAt - } - } - - private requireAccount(accountId: string): ClaudeManagedAccount { - const account = this.store - .getSettings() - .claudeManagedAccounts.find((entry) => entry.id === accountId) - if (!account) { - throw new Error('That Claude account no longer exists.') - } - return account - } - - private normalizeActiveSelection(): void { - const settings = this.store.getSettings() - const nextSelection = pruneInvalidClaudeRuntimeSelection( - normalizeClaudeRuntimeSelection(settings), - settings.claudeManagedAccounts - ) - if ( - nextSelection.host !== settings.activeClaudeManagedAccountId || - JSON.stringify(nextSelection) !== JSON.stringify(normalizeClaudeRuntimeSelection(settings)) - ) { - this.store.updateSettings({ - activeClaudeManagedAccountId: nextSelection.host, - activeClaudeManagedAccountIdsByRuntime: nextSelection - }) - } - } - - private restoreClaudeSettings(settings: ReturnType): void { - this.store.updateSettings({ - claudeManagedAccounts: settings.claudeManagedAccounts, - activeClaudeManagedAccountId: settings.activeClaudeManagedAccountId, - activeClaudeManagedAccountIdsByRuntime: settings.activeClaudeManagedAccountIdsByRuntime - }) - } - - private async syncRuntimeAuthWithLivePtyGate( - target?: ClaudeAccountSelectionTarget, - operation?: () => Promise - ): Promise { - beginClaudeAuthSwitch() - try { - await (operation ? operation() : this.runtimeAuth.syncForCurrentSelection(target)) - } finally { - endClaudeAuthSwitch() - } - } - - private async runClaudeLoginAndCapture( - location: ManagedClaudeAuthLocation = { + private runClaudeLoginAndCapture( + location: ClaudeManagedAuthLocation = { managedAuthPath: '', managedAuthRuntime: 'host', wslDistro: null, wslLinuxAuthPath: null } ): Promise { - const tempConfig = await this.createTemporaryClaudeConfigDir(location) - const loginAbortController = new AbortController() - this.cancelPendingClaudeLogin = () => { - if (loginAbortController.signal.aborted) { - return false + return runClaudeLoginSession(location, { + runCommand: (args, config, timeoutMs, options) => + this.runClaudeCommand(args, config, timeoutMs, options), + capture: (configDir, status, previousLegacy) => + this.captureAuthFromConfigDir(configDir, status, previousLegacy), + setCancel: (cancel) => { + this.cancelPendingClaudeLogin = cancel } - loginAbortController.abort() - return true - } - const previousLegacyKeychain = await readActiveClaudeKeychainCredentials() - let captured: CapturedClaudeAuth | null = null - let captureError: unknown = null - let cleanupError: unknown = null - try { - if (loginAbortController.signal.aborted) { - throw new Error('Claude sign-in was cancelled.') - } - await this.runClaudeCommand(['auth', 'login', '--claudeai'], tempConfig, LOGIN_TIMEOUT_MS, { - signal: loginAbortController.signal, - keepStdinOpen: true - }) - this.cancelPendingClaudeLogin = null - const status = await this.runClaudeCommand( - ['auth', 'status', '--json'], - tempConfig, - STATUS_TIMEOUT_MS, - { allowFailure: true } - ) - captured = await this.captureAuthFromConfigDir( - tempConfig.windowsPath, - status, - previousLegacyKeychain - ) - } catch (error) { - captureError = error - } finally { - if (process.platform === 'darwin') { - try { - await deleteActiveClaudeKeychainCredentialsStrict(tempConfig.windowsPath) - } catch (error) { - console.warn('[claude-accounts] Failed to clean temporary Claude Keychain item:', error) - } - } - if (process.platform === 'darwin') { - try { - // Why: older Claude versions ignored CLAUDE_CONFIG_DIR and wrote the - // legacy active Keychain item. Preserve that external CLI state. - await (previousLegacyKeychain - ? writeActiveClaudeKeychainCredentials(previousLegacyKeychain) - : deleteActiveClaudeKeychainCredentialsStrict()) - } catch (error) { - cleanupError = error - } - } - await this.removeTemporaryClaudeConfigDir(tempConfig) - this.cancelPendingClaudeLogin = null - } - if (captureError) { - throw captureError - } - if (cleanupError) { - throw cleanupError - } - return captured! - } - - private async createTemporaryClaudeConfigDir(location: ManagedClaudeAuthLocation): Promise<{ - windowsPath: string - linuxPath: string | null - wslDistro: string | null - }> { - if (location.managedAuthRuntime !== 'wsl') { - return { - windowsPath: mkdtempSync(join(tmpdir(), 'orca-claude-login-')), - linuxPath: null, - wslDistro: null - } - } - if (!location.wslDistro) { - throw new Error('Could not resolve the active WSL distribution for Claude login.') - } - const created = await runWslProcess({ - distro: location.wslDistro, - loginPath: 'none', - shell: 'bash', - script: 'mktemp -d "${TMPDIR:-/tmp}/orca-claude-login.XXXXXX"', - timeoutMs: 5000 }) - const linuxPath = created.stdout.replaceAll(String.fromCharCode(0), '').trim() - if (created.code !== 0 || created.timedOut || !linuxPath.startsWith('/')) { - throw new Error('Could not create a temporary WSL Claude login directory.') - } - return { - windowsPath: toWindowsWslPath(linuxPath, location.wslDistro), - linuxPath, - wslDistro: location.wslDistro - } } - private async removeTemporaryClaudeConfigDir(tempConfig: { - windowsPath: string - linuxPath: string | null - wslDistro: string | null - }): Promise { - if (tempConfig.linuxPath && tempConfig.wslDistro) { - try { - await runWslProcess({ - distro: tempConfig.wslDistro, - loginPath: 'none', - program: 'rm', - args: ['-rf', '--', tempConfig.linuxPath], - timeoutMs: 5000 - }) - } catch { - // Best-effort cleanup. - } - return - } - rmSync(tempConfig.windowsPath, { recursive: true, force: true }) + private runClaudeCommand( + args: string[], + configDir: ClaudeCommandConfig, + timeoutMs: number, + options?: ClaudeCommandOptions + ): Promise { + return runClaudeCommandProcess(args, configDir, timeoutMs, options) } - private async captureAuthFromConfigDir( + private captureFromExistingConfigDir( + configDir: string, + previousLegacyCredentialsSha256?: string | null + ): Promise { + return captureClaudeAuthFromExistingConfigDir( + configDir, + previousLegacyCredentialsSha256, + (args, config, timeoutMs, options) => this.runClaudeCommand(args, config, timeoutMs, options) + ) + } + + private captureAuthFromConfigDir( configDir: string, statusOutput: string, previousLegacyKeychain: string | null, previousLegacyCredentialsSha256?: string | null ): Promise { - const credentialsJson = await this.readCapturedCredentials( + return captureClaudeAuthFromConfigDir( configDir, + statusOutput, previousLegacyKeychain, - previousLegacyCredentialsSha256 + previousLegacyCredentialsSha256, + (path, previous, digest) => this.readCapturedCredentials(path, previous, digest) ) - if (!credentialsJson) { - throw new Error('Claude login completed, but no OAuth credentials were captured.') - } - const oauthAccount = this.readOauthAccountFromConfigDir(configDir) - const identity = this.resolveIdentity(statusOutput, oauthAccount, credentialsJson) - return { credentialsJson, oauthAccount, identity } } - private async readCapturedCredentials( + private readCapturedCredentials( configDir: string, previousLegacyKeychain: string | null, previousLegacyCredentialsSha256?: string | null ): Promise { - if (process.platform === 'darwin') { - const scopedCredentialsJson = await readActiveClaudeKeychainCredentialsStrict(configDir) - if (scopedCredentialsJson) { - return scopedCredentialsJson - } - const legacyCredentialsJson = await readActiveClaudeKeychainCredentialsStrict() - const legacyChanged = - previousLegacyCredentialsSha256 === undefined - ? legacyCredentialsJson !== previousLegacyKeychain - : legacyCredentialsJson !== null && - createHash('sha256').update(legacyCredentialsJson).digest('hex') !== - previousLegacyCredentialsSha256 - if (legacyCredentialsJson && legacyChanged) { - return legacyCredentialsJson - } - } - const credentialsPath = join(configDir, '.credentials.json') - return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null + return readCapturedClaudeCredentials( + configDir, + previousLegacyKeychain, + previousLegacyCredentialsSha256 + ) } - private readOauthAccountFromConfigDir(configDir: string): unknown { - for (const configPath of [join(configDir, '.claude.json'), join(configDir, '.config.json')]) { - if (!existsSync(configPath)) { - continue - } - try { - const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) as Record - if (parsed.oauthAccount) { - return parsed.oauthAccount - } - } catch { - continue - } - } - return null - } - - private resolveIdentity( - statusOutput: string, - oauthAccount: unknown, - credentialsJson: string - ): ClaudeIdentity { - const status = this.parseJsonObject(statusOutput) - const oauth = this.asRecord(oauthAccount) - const credentials = this.parseJsonObject(credentialsJson) - const credentialOauth = this.asRecord(credentials?.claudeAiOauth) - - return { - email: this.normalizeField( - this.readString(status, 'email') ?? - this.readString(oauth, 'emailAddress') ?? - this.readString(oauth, 'email') ?? - this.readString(credentialOauth, 'email') - ), - organizationUuid: this.normalizeField( - this.readString(status, 'organizationUuid') ?? - this.readString(status, 'organizationId') ?? - this.readString(oauth, 'organizationUuid') ?? - this.readString(oauth, 'organizationId') - ), - organizationName: this.normalizeField( - this.readString(status, 'organizationName') ?? this.readString(oauth, 'organizationName') - ) - } - } - - private async writeManagedAuth( + private writeManagedAuth( accountId: string, managedAuthPath: string, captured: CapturedClaudeAuth ): Promise { - await this.writeManagedCredentials(accountId, managedAuthPath, captured.credentialsJson) - await this.writeManagedOauthAccount(accountId, managedAuthPath, captured.oauthAccount) + return this.storage.writeAuth(accountId, managedAuthPath, captured) } - private async writeManagedCredentials( - accountId: string, - managedAuthPath: string, - credentialsJson: string - ): Promise { - const trustedPath = await this.assertManagedAuthPath(managedAuthPath, accountId) - if (process.platform === 'darwin') { - await writeManagedClaudeKeychainCredentials(accountId, credentialsJson) - } else { - writeClaudeManagedAuthFile(trustedPath, '.credentials.json', credentialsJson) - } - } - - private async writeManagedOauthAccount( - accountId: string, - managedAuthPath: string, - oauthAccount: unknown - ): Promise { - const trustedPath = await this.assertManagedAuthPath(managedAuthPath, accountId) - writeClaudeManagedAuthFile( - trustedPath, - 'oauth-account.json', - `${JSON.stringify(oauthAccount, null, 2)}\n` - ) - } - - private async readManagedAuthSnapshot( - accountId: string, - managedAuthPath: string - ): Promise { - const trustedPath = await this.assertManagedAuthPath(managedAuthPath, accountId) - return { - credentialsJson: - process.platform === 'darwin' - ? await readManagedClaudeKeychainCredentials(accountId) - : readClaudeManagedAuthFile(trustedPath, '.credentials.json'), - oauthAccountJson: readClaudeManagedAuthFile(trustedPath, 'oauth-account.json') - } - } - - private async restoreManagedCredentialsSnapshot( - accountId: string, - managedAuthPath: string, - snapshot: ManagedClaudeAuthSnapshot - ): Promise { - const trustedPath = await this.assertManagedAuthPath(managedAuthPath, accountId) - const credentialsPath = join(trustedPath, '.credentials.json') - if (process.platform === 'darwin') { - await (snapshot.credentialsJson !== null - ? writeManagedClaudeKeychainCredentials(accountId, snapshot.credentialsJson) - : deleteManagedClaudeKeychainCredentials(accountId)) - } else if (snapshot.credentialsJson !== null) { - writeClaudeManagedAuthFile(trustedPath, '.credentials.json', snapshot.credentialsJson) - } else { - rmSync(credentialsPath, { force: true }) - } - } - - private async restoreManagedOauthSnapshot( - accountId: string, - managedAuthPath: string, - snapshot: ManagedClaudeAuthSnapshot - ): Promise { - const trustedPath = await this.assertManagedAuthPath(managedAuthPath, accountId) - const oauthPath = join(trustedPath, 'oauth-account.json') - if (snapshot.oauthAccountJson !== null) { - writeClaudeManagedAuthFile(trustedPath, 'oauth-account.json', snapshot.oauthAccountJson) - } else { - rmSync(oauthPath, { force: true }) - } - } - - private async createManagedAuthDir( - accountId: string, - target?: ClaudeAccountAddTarget - ): Promise { - const wslAuth = await this.tryCreateWslManagedAuthDir(accountId, target) - if (wslAuth) { - return wslAuth - } - - const managedAuthPath = join(this.getManagedAccountsRoot(), accountId, 'auth') - mkdirSync(managedAuthPath, { recursive: true }) - writeFileSync(join(managedAuthPath, '.orca-managed-claude-auth'), `${accountId}\n`, 'utf-8') - return { - managedAuthPath: await this.assertManagedAuthPath(managedAuthPath, accountId), - managedAuthRuntime: 'host', - wslDistro: null, - wslLinuxAuthPath: null - } - } - - private async tryCreateWslManagedAuthDir( - accountId: string, - target?: ClaudeAccountAddTarget - ): Promise { - if (process.platform !== 'win32' || target?.runtime !== 'wsl') { - return null - } - - const requestedDistro = target.wslDistro?.trim() || undefined - const info = await runWslProcess({ - distro: requestedDistro, - loginPath: 'none', - shell: 'bash', - script: 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"', - timeoutMs: 5000 - }) - const [rawDistro, rawHome] = - info.code === 0 && !info.timedOut - ? info.stdout - .replaceAll(String.fromCharCode(0), '') - .split(/\r?\n/) - .map((line) => line.trim()) - : [] - const distro = requestedDistro || rawDistro - const home = rawHome - if (!distro || !home?.startsWith('/')) { - throw new Error('Could not resolve the active WSL home directory for Claude login.') - } - - const wslLinuxAuthPath = `${home.replace(/\/$/, '')}/.local/share/orca/claude-accounts/${accountId}/auth` - const created = await runWslProcess({ - distro, - loginPath: 'none', - shell: 'bash', - script: 'mkdir -p "$1" && printf \'%s\\n\' "$2" > "$1/.orca-managed-claude-auth"', - args: [wslLinuxAuthPath, accountId], - timeoutMs: 5000 - }) - if (created.code !== 0 || created.timedOut) { - throw new Error('Could not create the managed WSL Claude auth directory.') - } - - const managedAuthPath = toWindowsWslPath(wslLinuxAuthPath, distro) - return { - managedAuthPath: await this.assertManagedAuthPath(managedAuthPath, accountId), - managedAuthRuntime: 'wsl', - wslDistro: distro, - wslLinuxAuthPath - } - } - - private getManagedAccountsRoot(): string { - const root = getClaudeManagedAccountsRoot() - mkdirSync(root, { recursive: true }) - return root - } - - private async assertManagedAuthPath( - candidatePath: string, - expectedAccountId?: string - ): Promise { - const wslInfo = parseWslUncPath(candidatePath) - if (wslInfo) { - if ( - !wslInfo.linuxPath.includes('/.local/share/orca/claude-accounts/') || - !wslInfo.linuxPath.endsWith('/auth') - ) { - throw new Error('Managed WSL Claude auth storage is outside Orca account storage.') - } - if (process.platform === 'win32') { - try { - const owned = await runWslProcess({ - distro: wslInfo.distro, - loginPath: 'none', - shell: 'bash', - script: [ - 'set -euo pipefail', - `candidate=${shellQuote(wslInfo.linuxPath)}`, - 'managed_root="${HOME%/}/.local/share/orca/claude-accounts"', - 'candidate_real=$(readlink -f -- "$candidate")', - 'managed_root_real=$(readlink -f -- "$managed_root")', - 'test -f "$candidate_real/.orca-managed-claude-auth"', - expectedAccountId - ? `test "$(cat "$candidate_real/.orca-managed-claude-auth")" = ${shellQuote(expectedAccountId)}` - : 'test -n "$(cat "$candidate_real/.orca-managed-claude-auth")"', - 'case "$candidate_real" in "$managed_root_real"/*/auth) printf "%s\\n" "$candidate_real" ;; *) exit 35 ;; esac' - ].join('\n'), - timeoutMs: 5000 - }) - if (owned.code !== 0 || owned.timedOut) { - throw new Error('Managed Claude auth directory does not exist on disk.') - } - const canonicalLinuxPath = owned.stdout.trim() - if (!canonicalLinuxPath) { - throw new Error('Managed Claude auth directory does not exist on disk.') - } - return toWindowsWslPath(canonicalLinuxPath, wslInfo.distro) - } catch (error) { - throw new Error('Managed WSL Claude auth storage is outside Orca account storage.', { - cause: error - }) - } - } - if ( - !existsSync(candidatePath) || - !existsSync(join(candidatePath, '.orca-managed-claude-auth')) - ) { - throw new Error('Managed Claude auth storage is not owned by Orca.') - } - return candidatePath - } - - this.getManagedAccountsRoot() - const accountId = expectedAccountId ?? this.readManagedAuthAccountIdFromPath(candidatePath) - if (!accountId || (expectedAccountId && accountId !== expectedAccountId)) { - throw new Error('Managed Claude auth directory does not exist on disk.') - } - const trustedPath = resolveOwnedClaudeManagedAuthPath(accountId, candidatePath, { - adoptLegacyMarker: true - }) - if (!trustedPath) { - throw new Error('Managed Claude auth storage is not owned by Orca.') - } - return trustedPath - } - - private readManagedAuthAccountIdFromPath(candidatePath: string): string | null { - const rootPath = this.getManagedAccountsRoot() - const relativePath = relative(resolve(rootPath), resolve(candidatePath)) - const parts = relativePath.split(sep) - return parts.length === 2 && parts[1] === 'auth' ? parts[0] : null - } - - private async safeRemoveManagedAuth(accountId: string, candidatePath: string): Promise { - try { - const managedAuthPath = await this.assertManagedAuthPath(candidatePath, accountId) - rmSync(resolve(managedAuthPath, '..'), { recursive: true, force: true }) - } catch (error) { - console.warn('[claude-accounts] Refusing to remove untrusted managed auth:', error) - } - await deleteManagedClaudeKeychainCredentials(accountId) - } - - private runClaudeCommand( - args: string[], - configDir: { windowsPath: string; linuxPath: string | null; wslDistro: string | null }, - timeoutMs: number, - options?: { allowFailure?: boolean; signal?: AbortSignal; keepStdinOpen?: boolean } - ): Promise { - return new Promise((resolvePromise, rejectPromise) => { - const spawnConfig = - configDir.linuxPath && configDir.wslDistro - ? { - command: 'wsl.exe', - args: [ - '-d', - configDir.wslDistro, - '--exec', - 'bash', - '-lc', - `export CLAUDE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; exec claude ${args.map(shellQuote).join(' ')}` - ], - env: process.env, - shell: false, - windowsVerbatimArguments: false - } - : process.platform === 'win32' - ? { - ...buildWindowsCommandInvocation(resolveClaudeCommand(), args), - env: { - ...process.env, - CLAUDE_CONFIG_DIR: configDir.windowsPath - }, - shell: false - } - : { - command: resolveClaudeCommand(), - args, - env: { - ...process.env, - CLAUDE_CONFIG_DIR: configDir.windowsPath - }, - shell: false, - windowsVerbatimArguments: false - } - const child = spawn(spawnConfig.command, spawnConfig.args, { - // Why: Claude's browser auth can bind its callback lifetime to stdin. - // Keeping stdin open prevents hidden managed-login runs from tearing down - // the local callback server before the browser returns. - stdio: [options?.keepStdinOpen ? 'pipe' : 'ignore', 'pipe', 'pipe'], - shell: spawnConfig.shell, - // On Windows this is wsl.exe or a cmd invocation; without the flag it - // opens a console and steals foreground on every managed login (#10488). - windowsHide: true, - windowsVerbatimArguments: spawnConfig.windowsVerbatimArguments, - env: spawnConfig.env, - // Why: Claude auth can leave browser/login descendants alive after denial. - // A process group lets cancellation terminate the whole POSIX login tree. - detached: process.platform !== 'win32' - }) - const stdout = child.stdout - const stderr = child.stderr - if (!stdout || !stderr) { - if (options?.keepStdinOpen) { - child.stdin?.destroy() - } - child.kill() - rejectPromise(new Error('Claude command failed to open output streams.')) - return - } - const completesOnExit = - process.platform === 'win32' && - configDir.linuxPath === null && - configDir.wslDistro === null && - args[0] === 'auth' && - args[1] === 'login' - const completionEvent = completesOnExit ? 'exit' : 'close' - - let settled = false - let output = '' - const appendOutput = (chunk: Buffer): void => { - output = `${output}${chunk.toString()}` - if (output.length > MAX_COMMAND_OUTPUT_CHARS) { - output = output.slice(-MAX_COMMAND_OUTPUT_CHARS) - } - if (CLAUDE_AUTH_DENIED_PATTERN.test(output)) { - killChild(() => - settle(() => rejectPromise(new Error('Claude sign-in was denied. Please try again.'))) - ) - } - } - let timeout: ReturnType | null = null - const cleanupListeners = (): void => { - if (timeout) { - clearTimeout(timeout) - timeout = null - } - stdout.off('data', appendOutput) - stderr.off('data', appendOutput) - child.off('error', onError) - child.off(completionEvent, onDone) - options?.signal?.removeEventListener('abort', onAbort) - if (options?.keepStdinOpen) { - child.stdin?.destroy() - } - if (completesOnExit) { - stdout.destroy() - stderr.destroy() - } - } - const settle = (callback: () => void): void => { - if (settled) { - return - } - settled = true - cleanupListeners() - callback() - } - const timeoutError = new Error('Claude sign-in took too long to finish.') - const cancelError = new Error('Claude sign-in was cancelled.') - let terminationPending = false - const killChild = (afterKill: () => void): void => { - if (terminationPending || settled) { - return - } - terminationPending = true - if (process.platform === 'win32' && child.pid) { - const taskkill = spawn('taskkill.exe', ['/pid', String(child.pid), '/t', '/f'], { - stdio: 'ignore', - windowsHide: true - }) - let taskkillFinished = false - const finishTaskkill = (succeeded: boolean): void => { - if (taskkillFinished) { - return - } - taskkillFinished = true - clearTimeout(taskkillTimeout) - if (!succeeded) { - child.kill() - } - afterKill() - } - const taskkillTimeout = setTimeout(() => { - taskkill.kill() - finishTaskkill(false) - }, WINDOWS_TASKKILL_TIMEOUT_MS) - taskkill.once('error', () => finishTaskkill(false)) - taskkill.once('close', (code) => finishTaskkill(code === 0)) - return - } - if (process.platform !== 'win32' && child.pid) { - try { - process.kill(-child.pid) - afterKill() - return - } catch { - // Fall back to the direct child if the process group is unavailable. - } - } - child.kill() - afterKill() - } - timeout = setTimeout(() => { - killChild(() => settle(() => rejectPromise(timeoutError))) - }, timeoutMs) - - const onAbort = (): void => { - killChild(() => settle(() => rejectPromise(cancelError))) - } - const onError = (error: Error): void => { - if (terminationPending) { - return - } - settle(() => rejectPromise(error)) - } - const onDone = (code: number | null): void => { - if (terminationPending) { - return - } - settle(() => { - if (code === 0 || options?.allowFailure) { - resolvePromise(output) - return - } - const trimmedOutput = output.trim() - rejectPromise( - new Error( - trimmedOutput - ? `Claude command failed: ${trimmedOutput}` - : `Claude command exited with code ${code ?? 'unknown'}.` - ) - ) - }) - } - - stdout.on('data', appendOutput) - stderr.on('data', appendOutput) - child.on('error', onError) - // Native Windows browsers can inherit these pipes and indefinitely delay close. - child.on(completionEvent, onDone) - - if (options?.signal?.aborted) { - onAbort() - } else { - options?.signal?.addEventListener('abort', onAbort, { once: true }) - } - }) - } - - private parseJsonObject(value: string): Record | null { - try { - const parsed = JSON.parse(value) as unknown - return this.asRecord(parsed) - } catch { - return null - } - } - - private asRecord(value: unknown): Record | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - return value as Record - } - - private readString(value: Record | null, key: string): string | null { - const field = value?.[key] - return typeof field === 'string' ? field : null - } - - private normalizeField(value: string | null | undefined): string | null { - if (!value) { - return null - } - const trimmed = value.trim() - return trimmed === '' ? null : trimmed + private safeRemoveManagedAuth(accountId: string, path: string): Promise { + return this.storage.remove(accountId, path) } } diff --git a/src/main/claude/compact-status-registration.test.ts b/src/main/claude/compact-status-registration.test.ts index f17e14630e5..eef03297279 100644 --- a/src/main/claude/compact-status-registration.test.ts +++ b/src/main/claude/compact-status-registration.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest' +import { normalizeHookPayload } from '../../shared/agent-hook-listener' import { clearAllListenerCaches, clearPaneCacheState, createHookListenerState, movePaneCacheState, - normalizeHookPayload, - seedClaudeSubagentRosterFromSnapshots, - type AgentHookEventPayload, type HookListenerState -} from '../../shared/agent-hook-listener' +} from '../../shared/agent-hook-listener/listener-state' +import { seedClaudeSubagentRosterFromSnapshots } from '../../shared/agent-hook-listener/providers/claude-roster-state' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' import { makePaneKey } from '../../shared/stable-pane-id' import { applyManagedHooks, CLAUDE_EVENTS } from './hook-settings' @@ -209,7 +209,7 @@ describe('an auto compact claims nothing', () => { expect(state.lastStatusByPaneKey.get(PANE_KEY)?.payload.state).toBe('done') }) - it('writes no compact-scoped state, so it cannot spend the pane\'s consumed-compact slot', () => { + it("writes no compact-scoped state, so it cannot spend the pane's consumed-compact slot", () => { const state = createHookListenerState() startTurn(state) diff --git a/src/main/codex-accounts/codex-windows-interactive-login.test.ts b/src/main/codex-accounts/codex-windows-interactive-login.test.ts new file mode 100644 index 00000000000..f9ceb64b4d3 --- /dev/null +++ b/src/main/codex-accounts/codex-windows-interactive-login.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import type * as NodeOs from 'node:os' +import { getCmdExePath } from '../../shared/windows-batch-spawn' +import { + createRateLimits, + createRuntimeHome, + createSettings, + createStore, + registerCodexAccountsTestHomes, + testState +} from './service-test-harness' + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.userDataDir + } +})) + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { + ...actual, + homedir: () => testState.fakeHomeDir + } +}) + +describe('Codex Windows host interactive login', () => { + registerCodexAccountsTestHomes() + + it('resolves a Windows login whose child has no piped streams', async () => { + vi.resetModules() + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + const child = new EventEmitter() as EventEmitter & { + stdout: null + stderr: null + kill: ReturnType + pid: number + } + child.stdout = null + child.stderr = null + child.kill = vi.fn() + child.pid = 4242 + const spawnMock = vi.fn(() => { + queueMicrotask(() => child.emit('close', 0)) + return child + }) + const buildInteractiveLoginSpawn = vi.fn(() => ({ + command: getCmdExePath(), + args: ['/d', '/c', 'start', '', '/wait', 'C:\\Tools\\codex.cmd', 'login'], + stdio: 'ignore' as const, + windowsHide: true + })) + vi.doMock('node:child_process', () => ({ + execFileSync: vi.fn(), + spawn: spawnMock + })) + vi.doMock('../codex-cli/command', () => ({ + resolveCodexCommand: () => 'C:\\Tools\\codex.cmd' + })) + vi.doMock('../../shared/windows-interactive-login-spawn', () => ({ + buildWindowsHostInteractiveLoginSpawn: buildInteractiveLoginSpawn + })) + + try { + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + createStore(createSettings()) as never, + createRateLimits() as never, + createRuntimeHome() as never + ) + await ( + service as unknown as { + runCodexLogin(managedHomePath: string): Promise + } + ).runCodexLogin(testState.fakeHomeDir) + + expect(buildInteractiveLoginSpawn).toHaveBeenCalledWith('C:\\Tools\\codex.cmd', ['login']) + expect(spawnMock).toHaveBeenCalledWith( + getCmdExePath(), + expect.arrayContaining(['start', '', '/wait']), + expect.objectContaining({ + stdio: 'ignore', + windowsHide: true + }) + ) + expect(child.kill).not.toHaveBeenCalled() + } finally { + Object.defineProperty(process, 'platform', originalPlatform) + vi.doUnmock('node:child_process') + vi.doUnmock('../codex-cli/command') + vi.doUnmock('../../shared/windows-interactive-login-spawn') + } + }) +}) diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index cd790207823..56cfd630f2d 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -6,6 +6,10 @@ import { dirname, join, resolve, sep } from 'node:path' import { homedir } from 'node:os' import { app } from 'electron' import { getSpawnArgsForWindows } from '../win32-utils' +import { + buildWindowsHostInteractiveLoginSpawn, + type WindowsHostInteractiveLoginSpawn +} from '../../shared/windows-interactive-login-spawn' import type { CodexManagedAccount, CodexManagedAccountSummary, @@ -36,6 +40,7 @@ import { getSystemCodexHomePath } from '../codex/codex-home-paths' import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils' import { readCodexTopLevelModelProvider } from '../codex/codex-model-provider-config' import { resolveCodexCommand } from '../codex-cli/command' +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import type { Store } from '../persistence' import type { RateLimitService } from '../rate-limits/service' import { parseWslUncPath } from '../../shared/wsl-paths' @@ -211,10 +216,14 @@ function removeManagedHomeTreeSync(targetPath: string): void { }) } -function killLoginProcessTree(child: ChildProcess): void { +function killLoginProcessTree( + child: ChildProcess, + interactiveLogin?: WindowsHostInteractiveLoginSpawn | null +): void { + const terminationPid = interactiveLogin?.getTerminationPid?.() ?? child.pid if ( process.platform === 'win32' && - typeof child.pid === 'number' && + typeof terminationPid === 'number' && child.exitCode === null && child.signalCode === null ) { @@ -222,7 +231,7 @@ function killLoginProcessTree(child: ChildProcess): void { // Why: child.kill() only reaches the direct child (cmd.exe for npm .cmd // shims); taskkill /t also ends codex descendants whose open handles on // the managed home make post-login file operations fail with ENOTEMPTY. - execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + execFileSync('taskkill', ['/pid', String(terminationPid), '/t', '/f'], { windowsHide: true, timeout: WINDOWS_LOGIN_TREE_KILL_TIMEOUT_MS, stdio: 'ignore' @@ -1711,25 +1720,36 @@ export class CodexAccountService { command: 'wsl.exe', args: buildWslCodexLoginArgs(wslInfo.distro, wslInfo.linuxPath), env: process.env, - codexCommand: 'codex' + codexCommand: 'codex', + interactiveLogin: null } : (() => { const codexCommand = resolveCodexCommand() - // Why: Windows codex may be a .cmd/.bat; spawn+shell:true would trigger DEP0190, so invoke cmd.exe /c explicitly. - const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(codexCommand, ['login']) + // Why: Windows host login needs a real console; otherwise inherit/hide + // leaves the child unable to read a paste-code / device-auth prompt. + const interactiveLogin = + process.platform === 'win32' + ? buildWindowsHostInteractiveLoginSpawn(codexCommand, ['login']) + : null + const { spawnCmd, spawnArgs } = interactiveLogin + ? { spawnCmd: interactiveLogin.command, spawnArgs: interactiveLogin.args } + : getSpawnArgsForWindows(codexCommand, ['login']) return { command: spawnCmd, args: spawnArgs, - env: { + env: withCliRuntimeOnPath(codexCommand, { ...process.env, CODEX_HOME: managedHomePath - }, - codexCommand + }), + codexCommand, + interactiveLogin } })() const child = spawn(spawnConfig.command, spawnConfig.args, { - stdio: ['ignore', 'pipe', 'pipe'], - // Why: prevents a console window flash for .cmd/.bat entrypoints routed through cmd.exe on Windows. + stdio: spawnConfig.interactiveLogin + ? spawnConfig.interactiveLogin.stdio + : ['ignore', 'pipe', 'pipe'], + // Why: hide the outer wrapper only. A dedicated login console stays visible. windowsHide: true, env: spawnConfig.env }) @@ -1761,10 +1781,11 @@ export class CodexAccountService { clearTimeout(postAuthExitTimeout) postAuthExitTimeout = null } - child.stdout.off('data', appendOutput) - child.stderr.off('data', appendOutput) + child.stdout?.off('data', appendOutput) + child.stderr?.off('data', appendOutput) child.off('error', onError) child.off('close', onClose) + spawnConfig.interactiveLogin?.cleanup?.() } const settle = (callback: () => void): void => { @@ -1778,7 +1799,7 @@ export class CodexAccountService { const timeoutError = new Error('Codex sign-in took too long to finish. Please try again.') timeout = setTimeout(() => { - killLoginProcessTree(child) + killLoginProcessTree(child, spawnConfig.interactiveLogin) settle(() => { rejectPromise(timeoutError) }) @@ -1799,7 +1820,7 @@ export class CodexAccountService { } postAuthExitTimeout = setTimeout(() => { loginTreeKilledAfterAuth = true - killLoginProcessTree(child) + killLoginProcessTree(child, spawnConfig.interactiveLogin) }, WINDOWS_LOGIN_POST_AUTH_EXIT_GRACE_MS) }, WINDOWS_LOGIN_AUTH_POLL_INTERVAL_MS) } @@ -1845,8 +1866,8 @@ export class CodexAccountService { }) } - child.stdout.on('data', appendOutput) - child.stderr.on('data', appendOutput) + child.stdout?.on('data', appendOutput) + child.stderr?.on('data', appendOutput) child.on('error', onError) child.on('close', onClose) }) diff --git a/src/main/codex-cli/command.test.ts b/src/main/codex-cli/command.test.ts index 045048f2919..dbe1c358418 100644 --- a/src/main/codex-cli/command.test.ts +++ b/src/main/codex-cli/command.test.ts @@ -4,6 +4,7 @@ import { delimiter, dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { getVersionManagerBinPaths, + withCliRuntimeOnPath, resolveClaudeCommand, resolveCliCommands, resolveCodexCommand @@ -313,3 +314,79 @@ describe('getVersionManagerBinPaths', () => { expect(paths).toContain(join(root, 'AppData', 'Roaming', 'npm')) }) }) + +describe('withCliRuntimeOnPath', () => { + it('pairs a version-manager CLI with its sibling node (stablyai/orca#10932)', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-pair-')) + const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin') + const v22 = join(root, '.nvm', 'versions', 'node', 'v22.9.0', 'bin') + makeExecutable(join(v20, 'node')) + makeExecutable(join(v20, 'codex')) + makeExecutable(join(v22, 'node')) + + // default is v22, but codex only exists under v20 + const env = { PATH: [v22, '/usr/bin'].join(delimiter) } + const codex = resolveCodexCommand({ platform: 'darwin', pathEnv: env.PATH, homePath: root }) + expect(codex).toBe(join(v20, 'codex')) + + const paired = withCliRuntimeOnPath(codex, env, { platform: 'darwin' }) + // the shebang's `node` must now come from v20, not v22 + expect(paired.PATH.split(delimiter)[0]).toBe(v20) + }) + + it('leaves PATH untouched for a CLI whose directory ships no node', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-pair-')) + const brew = join(root, 'opt', 'homebrew', 'bin') + makeExecutable(join(brew, 'codex')) + const env = { PATH: '/usr/bin' } + + expect(withCliRuntimeOnPath(join(brew, 'codex'), env, { platform: 'darwin' })).toBe(env) + }) + + it('leaves PATH untouched for a bare command name', () => { + const env = { PATH: '/usr/bin' } + expect(withCliRuntimeOnPath('codex', env, { platform: 'darwin' })).toBe(env) + }) + + it('is a no-op when the runtime directory already leads PATH', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-pair-')) + const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin') + makeExecutable(join(v20, 'node')) + makeExecutable(join(v20, 'codex')) + const env = { PATH: [v20, '/usr/bin'].join(delimiter) } + + expect(withCliRuntimeOnPath(join(v20, 'codex'), env, { platform: 'darwin' })).toBe(env) + }) + + it('reads the Windows path key the child will actually use, whatever its casing', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-pair-')) + const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin') + makeExecutable(join(v20, 'node.exe')) + makeExecutable(join(v20, 'codex.cmd')) + // Why: win32 resolves env names case-insensitively, so a block may spell it + // any way. Reading a narrower set than the twin-dedupe deletes would drop + // this entry unread and hand the child a PATH containing only our directory. + const env = { path: 'C:\\Windows;C:\\Windows\\System32', HOME: 'x' } + + const paired = withCliRuntimeOnPath(join(v20, 'codex.cmd'), env, { platform: 'win32' }) + expect(paired.path).toBe([v20, 'C:\\Windows', 'C:\\Windows\\System32'].join(';')) + expect(Object.keys(paired).filter((key) => key.toLowerCase() === 'path')).toEqual(['path']) + }) + + it('writes the Windows Path key without leaving a differently-cased twin', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-pair-')) + const v20 = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin') + makeExecutable(join(v20, 'node.exe')) + makeExecutable(join(v20, 'codex.cmd')) + // Why both keys: with only `Path` seeded the assertion is vacuous — the + // helper cannot invent a `PATH` key, so the dedupe loop could be deleted + // wholesale and this test would still pass. + const env = { Path: 'C:\\Windows;C:\\Windows\\System32', PATH: 'C:\\Stale' } + + const paired = withCliRuntimeOnPath(join(v20, 'codex.cmd'), env, { platform: 'win32' }) + expect(Object.keys(paired)).toEqual(['Path']) + // Why the whole string: splitting on the host delimiter while joining on ';' + // shredded every drive letter into `C;\\Windows`. + expect(paired.Path).toBe([v20, 'C:\\Windows', 'C:\\Windows\\System32'].join(';')) + }) +}) diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts index 382f9494dd4..121aebe7255 100644 --- a/src/main/codex/codex-app-server-client.test.ts +++ b/src/main/codex/codex-app-server-client.test.ts @@ -132,6 +132,7 @@ function createStubRequest(options: { request: { invocation: { command: process.execPath, + cliPath: null, args: [stubPath], env: { STUB_CONFIG: JSON.stringify({ @@ -227,7 +228,7 @@ describe('runCodexHookTrustGrantSession', () => { const spawnImpl = vi.fn(() => child) as unknown as typeof spawn const session = runCodexAppServerSession( - { command: 'codex', args: ['app-server'], timeoutMs: 2_000 }, + { command: 'codex', cliPath: null, args: ['app-server'], timeoutMs: 2_000 }, async () => undefined, spawnImpl ) @@ -456,6 +457,7 @@ describe('runCodexHookTrustGrantSession', () => { const request: CodexHookTrustGrantRequest = { invocation: { command: join(tmpdir(), 'orca-codex-missing-binary-does-not-exist'), + cliPath: null, args: [], timeoutMs: 2_000 }, @@ -479,7 +481,7 @@ describe('runCodexHookTrustGrantSessionSync', () => { } const baseRequest: CodexHookTrustGrantRequest = { - invocation: { command: 'codex', args: ['app-server'], timeoutMs: 1_000 }, + invocation: { command: 'codex', cliPath: null, args: ['app-server'], timeoutMs: 1_000 }, hooksListCwd: '/tmp', expectedTrustKeys: ['k'], managedCommand: MANAGED_COMMAND diff --git a/src/main/codex/codex-app-server-session.test.ts b/src/main/codex/codex-app-server-session.test.ts index 3d15ef825b9..079b7ee8ddc 100644 --- a/src/main/codex/codex-app-server-session.test.ts +++ b/src/main/codex/codex-app-server-session.test.ts @@ -29,6 +29,7 @@ describe('runCodexAppServerSession environment', () => { const result = await runCodexAppServerSession( { command: process.execPath, + cliPath: null, args: ['-e', server], envToDelete: ['CODEX_HOME'], timeoutMs: 5_000 diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index 04748e6eaa2..76e6acf9337 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from 'node:child_process' import { waitForProcessExitUntil } from './codex-process-exit-deadline' import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal' +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' // Why: `codex app-server` is Orca's sanctioned RPC surface into Codex-owned // state (hook trust hashes, the sqlite thread index). This module owns the @@ -10,6 +11,17 @@ import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-s export type CodexAppServerInvocation = { command: string args: string[] + /** + * The resolved CLI path, used to pair the CLI with the `node` it was installed + * against — without it a CLI resolved out of a version-manager directory runs + * under whatever node leads PATH and dies on a NODE_MODULE_VERSION mismatch + * (stablyai/orca#10932). + * + * Required, and `null` only for a guest-side launcher (wsl.exe) where the host + * path means nothing. Optional would let a native builder omit it and silently + * fall back to pairing against a cmd.exe wrapper with no type error. + */ + cliPath: string | null /** Overlay applied on top of the inherited environment (e.g. CODEX_HOME). */ env?: Record /** Env keys stripped from the inherited environment before spawn (e.g. an @@ -111,8 +123,11 @@ export async function runCodexAppServerSession( for (const key of invocation.envToDelete ?? []) { delete childEnv[key] } + const pairedEnv = invocation.cliPath + ? withCliRuntimeOnPath(invocation.cliPath, childEnv) + : childEnv const child = spawnImpl(invocation.command, invocation.args, { - env: childEnv, + env: pairedEnv, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }) as ChildProcessWithoutNullStreams diff --git a/src/main/codex/codex-session-index-heal.test.ts b/src/main/codex/codex-session-index-heal.test.ts index a12dd7dc5bd..20051fe1058 100644 --- a/src/main/codex/codex-session-index-heal.test.ts +++ b/src/main/codex/codex-session-index-heal.test.ts @@ -147,6 +147,7 @@ function createHealRig(options: { readLogFile, buildInvocation: (_systemCodexHomePath, timeoutMs) => ({ command: process.execPath, + cliPath: null, args: [stubPath], env: { STUB_CONFIG: JSON.stringify({ diff --git a/src/main/codex/codex-session-index-heal.ts b/src/main/codex/codex-session-index-heal.ts index 49e575f4f12..baaba13358b 100644 --- a/src/main/codex/codex-session-index-heal.ts +++ b/src/main/codex/codex-session-index-heal.ts @@ -267,6 +267,7 @@ function buildNativeHealInvocation( return { command: spawnCmd, args: spawnArgs, + cliPath: command, // Why: pin the real home explicitly — nested Orca launches can inherit a // managed CODEX_HOME from the daemon environment, which would index the // wrong sqlite DB. diff --git a/src/main/codex/codex-state-db-backfill-recovery.ts b/src/main/codex/codex-state-db-backfill-recovery.ts index 81ebced8cb3..16d56d5907f 100644 --- a/src/main/codex/codex-state-db-backfill-recovery.ts +++ b/src/main/codex/codex-state-db-backfill-recovery.ts @@ -8,6 +8,7 @@ import { withManagedHookInstallLock } from '../agent-hooks/managed-hook-install- import { readManagedHookHostIdentity } from '../agent-hooks/managed-hook-owner-identity' import { buildWslCodexAppServerArgs } from '../codex-accounts/wsl-codex-command' import { resolveCodexCommand } from '../codex-cli/command' +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { CODEX_READ_ONLY_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' import { terminateCodexProbeChild } from '../rate-limits/codex-probe-termination' import { getSpawnArgsForWindows } from '../win32-utils' @@ -110,7 +111,7 @@ function spawnRecoveryProcess( cwd: codexHomePath, stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true, - env: { ...process.env, CODEX_HOME: codexHomePath } + env: withCliRuntimeOnPath(command, { ...process.env, CODEX_HOME: codexHomePath }) }) } diff --git a/src/main/codex/codex-trust-grant-host.ts b/src/main/codex/codex-trust-grant-host.ts index b769445ef0a..c74f8f19af9 100644 --- a/src/main/codex/codex-trust-grant-host.ts +++ b/src/main/codex/codex-trust-grant-host.ts @@ -43,6 +43,8 @@ export function resolveCodexTrustGrantHost(host: CodexTrustGrantHost): ResolvedC buildRequest: (input) => ({ invocation: { command: 'wsl.exe', + // Why null: the guest resolves `codex` inside the distro, so a host path pairs nothing. + cliPath: null, args: buildWslCodexAppServerArgs(host.distro, host.linuxRuntimeHome), timeoutMs: WSL_GRANT_TIMEOUT_MS }, @@ -65,6 +67,7 @@ export function resolveCodexTrustGrantHost(host: CodexTrustGrantHost): ResolvedC invocation: { command: spawnCmd, args: spawnArgs, + cliPath: command, ...(useDefaultCodexHome ? { envToDelete: ['CODEX_HOME'] } : { env: { CODEX_HOME: input.runtimeHomePath } }), diff --git a/src/main/codex/codex-trust-identity.ts b/src/main/codex/codex-trust-identity.ts new file mode 100644 index 00000000000..f1011f28177 --- /dev/null +++ b/src/main/codex/codex-trust-identity.ts @@ -0,0 +1,224 @@ +import { createHash } from 'node:crypto' +import { realpathSync } from 'node:fs' +import { basename, dirname, join, posix as pathPosix, win32 as pathWin32 } from 'node:path' +import { foldWslUncPathCaseInsensitiveParts } from '../../shared/wsl-paths' +import type { CodexEventLabel, CodexTrustEntry } from './config-toml-trust' + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize) + } + if (value && typeof value === 'object') { + const sorted: Record = {} + for (const key of Object.keys(value as Record).sort()) { + sorted[key] = canonicalize((value as Record)[key]) + } + return sorted + } + return value +} + +function matcherPatternForEvent( + eventLabel: CodexEventLabel, + matcher: string | undefined +): string | undefined { + switch (eventLabel) { + case 'user_prompt_submit': + case 'stop': + return undefined + case 'pre_tool_use': + case 'permission_request': + case 'post_tool_use': + case 'pre_compact': + case 'post_compact': + case 'session_start': + case 'subagent_start': + case 'subagent_stop': + return matcher + } +} + +export function computeCodexTrustedHash(entry: CodexTrustEntry): string { + const handler: Record = { + type: 'command', + command: entry.command, + timeout: Math.max(1, entry.timeoutSec ?? 600), + async: entry.async ?? false + } + if (entry.statusMessage !== undefined) { + handler.statusMessage = entry.statusMessage + } + const identity: Record = { + event_name: entry.eventLabel, + hooks: [handler] + } + const matcher = matcherPatternForEvent(entry.eventLabel, entry.matcher) + if (matcher !== undefined) { + identity.matcher = matcher + } + const serialized = JSON.stringify(canonicalize(identity)) + return `sha256:${createHash('sha256').update(serialized).digest('hex')}` +} + +export function computeCodexTrustKey(entry: CodexTrustEntry): string { + return `${normalizeCodexTrustSourcePath(entry.sourcePath)}:${entry.eventLabel}:${entry.groupIndex}:${entry.handlerIndex}` +} + +export function getExplicitHomeCodexHookSourcePath(sourcePath: string): string { + if (process.platform !== 'win32' && isUnambiguousWindowsPath(sourcePath)) { + return normalizeCodexTrustSourcePath(sourcePath) + } + try { + // Why: hook discovery resolves the explicit home but keeps the hooks.json leaf logical. + return normalizeCodexTrustSourcePath( + join(realpathSync.native(dirname(sourcePath)), basename(sourcePath)) + ) + } catch { + return normalizeCodexTrustSourcePath(sourcePath) + } +} + +export function normalizeCodexTrustSourcePath(sourcePath: string): string { + if (isWindowsPathForTrustSource(sourcePath)) { + const withoutDevicePrefix = stripWindowsDevicePrefix(sourcePath) + const normalized = pathWin32.isAbsolute(withoutDevicePrefix) + ? pathWin32.normalize(withoutDevicePrefix) + : pathWin32.resolve(withoutDevicePrefix) + return trimNonRootTrailingSeparators(normalized, pathWin32.parse(normalized).root, /[\\/]/) + } + const normalized = pathPosix.isAbsolute(sourcePath) + ? pathPosix.normalize(sourcePath) + : pathPosix.resolve(sourcePath) + return trimNonRootTrailingSeparators(normalized, pathPosix.parse(normalized).root, /\//) +} + +function trimNonRootTrailingSeparators(path: string, root: string, separators: RegExp): string { + let end = path.length + while (end > root.length && separators.test(path[end - 1]!)) { + end -= 1 + } + return path.slice(0, end) +} + +function stripWindowsDevicePrefix(sourcePath: string): string { + const unc = /^(?:\\\\\?|\\\\\.)\\UNC\\/i.exec(sourcePath) + if (unc) { + return `\\\\${sourcePath.slice(unc[0].length)}` + } + const drive = /^(?:\\\\\?|\\\\\.)\\(?=[A-Za-z]:[\\/])/i.exec(sourcePath) + return drive ? sourcePath.slice(drive[0].length) : sourcePath +} + +export function usesWindowsCodexPathSeparators(sourcePath: string): boolean { + return isUnambiguousWindowsPath(sourcePath) || sourcePath.startsWith('//') +} + +function isUnambiguousWindowsPath(sourcePath: string): boolean { + return /^[A-Za-z]:[\\/]/.test(sourcePath) || sourcePath.startsWith('\\\\') +} + +function isWindowsPathForTrustSource(sourcePath: string): boolean { + return ( + isUnambiguousWindowsPath(sourcePath) || + (process.platform === 'win32' && + (sourcePath.startsWith('//') || !pathPosix.isAbsolute(sourcePath))) + ) +} + +export function normalizeCodexTrustProjectPath(projectPath: string): string { + if (!usesWindowsCodexPathSeparators(projectPath)) { + return projectPath + } + const slashedPath = projectPath.replace(/\\/g, '/') + return foldWslUncPathCaseInsensitiveParts(slashedPath) ?? slashedPath.toLowerCase() +} + +export function codexTrustSourcePathsEqual(left: string, right: string): boolean { + const normalizeForLookup = (sourcePath: string): string => + normalizeCodexTrustProjectPath( + sourcePath.startsWith('//') ? sourcePath : normalizeCodexTrustSourcePath(sourcePath) + ) + return normalizeForLookup(left) === normalizeForLookup(right) +} + +export function normalizeCodexTrustProjectRevocationPath(projectPath: string): string { + const normalized = normalizeCodexTrustProjectPath(projectPath) + return usesWindowsCodexPathSeparators(projectPath) ? normalized.toLowerCase() : normalized +} + +export type ParsedCodexTrustKey = { + sourcePath: string + eventLabel: CodexEventLabel + groupIndex: number + handlerIndex: number +} + +export function parseCodexTrustKey(key: string): ParsedCodexTrustKey | null { + const lastColon = key.lastIndexOf(':') + if (lastColon === -1) { + return null + } + const handlerStr = key.slice(lastColon + 1) + if (!isCanonicalNonNegativeInt(handlerStr)) { + return null + } + const secondLast = key.lastIndexOf(':', lastColon - 1) + if (secondLast === -1) { + return null + } + const groupStr = key.slice(secondLast + 1, lastColon) + if (!isCanonicalNonNegativeInt(groupStr)) { + return null + } + const thirdLast = key.lastIndexOf(':', secondLast - 1) + if (thirdLast === -1) { + return null + } + const eventLabel = key.slice(thirdLast + 1, secondLast) + if (!isCodexEventLabel(eventLabel)) { + return null + } + const sourcePath = key.slice(0, thirdLast) + if (sourcePath.length === 0) { + return null + } + return { + sourcePath, + eventLabel, + groupIndex: Number(groupStr), + handlerIndex: Number(handlerStr) + } +} + +function isCanonicalNonNegativeInt(value: string): boolean { + return /^(0|[1-9]\d*)$/.test(value) +} + +function isCodexEventLabel(value: string): value is CodexEventLabel { + return ( + value === 'pre_tool_use' || + value === 'permission_request' || + value === 'post_tool_use' || + value === 'pre_compact' || + value === 'post_compact' || + value === 'session_start' || + value === 'user_prompt_submit' || + value === 'subagent_start' || + value === 'subagent_stop' || + value === 'stop' + ) +} + +export function normalizeCodexHookTrustLookupKey(key: string): string { + const parsed = parseCodexTrustKey(key) + const foldedPath = normalizeCodexTrustProjectPath( + parsed + ? parsed.sourcePath.startsWith('//') + ? parsed.sourcePath + : normalizeCodexTrustSourcePath(parsed.sourcePath) + : key + ) + return parsed + ? `${foldedPath}:${parsed.eventLabel}:${parsed.groupIndex}:${parsed.handlerIndex}` + : foldedPath +} diff --git a/src/main/codex/codex-user-hook-trust-rebase-client.test.ts b/src/main/codex/codex-user-hook-trust-rebase-client.test.ts index 8759ad03ab1..65c610e8404 100644 --- a/src/main/codex/codex-user-hook-trust-rebase-client.test.ts +++ b/src/main/codex/codex-user-hook-trust-rebase-client.test.ts @@ -15,7 +15,7 @@ vi.mock('./codex-app-server-session', async (importOriginal) => { import { runCodexUserHookTrustRebaseSession } from './codex-user-hook-trust-rebase-client' -const invocation = { command: 'codex', args: ['app-server'], timeoutMs: 1000 } +const invocation = { command: 'codex', cliPath: null, args: ['app-server'], timeoutMs: 1000 } const oldTrusted = '/home/a/.codex/hooks.json:stop:1:0' const oldUntrusted = '/home/a/.codex/hooks.json:stop:2:0' const newTrusted = '/home/a/.codex/hooks.json:stop:0:0' diff --git a/src/main/codex/config-toml-atomic-write.ts b/src/main/codex/config-toml-atomic-write.ts new file mode 100644 index 00000000000..2a0a84884d0 --- /dev/null +++ b/src/main/codex/config-toml-atomic-write.ts @@ -0,0 +1,51 @@ +import { randomUUID } from 'node:crypto' +import { + existsSync, + lstatSync, + mkdirSync, + realpathSync, + statSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { dirname, join } from 'node:path' +import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' +import { writeRollingFileBackup } from '../rolling-file-backup' + +export function writeTomlConfigAtomically(configPath: string, contents: string): void { + const writePath = resolveTomlWritePath(configPath) + const directory = dirname(writePath) + mkdirSync(directory, { recursive: true }) + const temporaryPath = join(directory, `.${Date.now()}-${randomUUID()}.tmp`) + const existingMode = existsSync(writePath) ? statSync(writePath).mode : undefined + let renamed = false + try { + writeFileSync(temporaryPath, contents, { encoding: 'utf-8', mode: existingMode }) + if (existsSync(writePath)) { + writeRollingFileBackup(writePath, `${writePath}.bak`) + } + renameFileWithWindowsRetry(temporaryPath, writePath) + renamed = true + } finally { + if (!renamed && existsSync(temporaryPath)) { + try { + unlinkSync(temporaryPath) + } catch { + // Why: cleanup failure must not mask the write failure. + } + } + } +} + +function resolveTomlWritePath(configPath: string): string { + let isSymlink = false + try { + isSymlink = lstatSync(configPath).isSymbolicLink() + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } + // Why: replacing the lexical path would destroy a dotfiles symlink; dangling links fail closed. + return isSymlink ? realpathSync.native(configPath) : configPath +} diff --git a/src/main/codex/config-toml-hook-trust-blocks.ts b/src/main/codex/config-toml-hook-trust-blocks.ts new file mode 100644 index 00000000000..e97b17faecd --- /dev/null +++ b/src/main/codex/config-toml-hook-trust-blocks.ts @@ -0,0 +1,100 @@ +import { + createTomlLineScanState, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' +import { normalizeCodexHookTrustLookupKey } from './codex-trust-identity' +import { findNextTomlTableHeader, parseHookStateTomlHeaderKey } from './config-toml-syntax' + +export type HookTrustBlockRange = { + start: number + headerLineEnd: number + contentStart: number + end: number +} + +export function findHookTrustBlockRanges( + content: string, + normalizedKeys: ReadonlySet +): HookTrustBlockRange[] { + const ranges: HookTrustBlockRange[] = [] + if (normalizedKeys.size === 0) { + return ranges + } + let cursor = 0 + let scanState = createTomlLineScanState() + while (cursor < content.length) { + const newlineIndex = content.indexOf('\n', cursor) + const lineEnd = newlineIndex === -1 ? content.length : newlineIndex + const rawLine = content.slice(cursor, lineEnd) + const lineWithoutCr = rawLine.replace(/\r$/, '') + const line = + cursor === 0 && lineWithoutCr.charCodeAt(0) === 0xfeff + ? lineWithoutCr.slice(1) + : lineWithoutCr + const nextCursor = newlineIndex === -1 ? content.length : newlineIndex + 1 + const headerKey = isTomlStructuralLine(scanState) ? parseHookStateTomlHeaderKey(line) : null + if (headerKey !== null && normalizedKeys.has(normalizeCodexHookTrustLookupKey(headerKey))) { + const headerLineEnd = rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd + const nextHeaderOffset = findNextTomlTableHeader(content.slice(nextCursor)) + const blockEnd = nextHeaderOffset === -1 ? content.length : nextCursor + nextHeaderOffset + ranges.push({ start: cursor, headerLineEnd, contentStart: nextCursor, end: blockEnd }) + cursor = Math.max(blockEnd, nextCursor) + continue + } + scanState = updateTomlLineScanState(scanState, line) + cursor = nextCursor + } + return ranges +} + +export function findAllHookTrustBlocks(content: string): (HookTrustBlockRange & { key: string })[] { + const blocks: (HookTrustBlockRange & { key: string })[] = [] + let cursor = 0 + let scanState = createTomlLineScanState() + while (cursor < content.length) { + const newlineIndex = content.indexOf('\n', cursor) + const lineEnd = newlineIndex === -1 ? content.length : newlineIndex + const rawLine = content.slice(cursor, lineEnd) + const lineWithoutCr = rawLine.replace(/\r$/, '') + const line = + cursor === 0 && lineWithoutCr.charCodeAt(0) === 0xfeff + ? lineWithoutCr.slice(1) + : lineWithoutCr + const nextCursor = newlineIndex === -1 ? content.length : newlineIndex + 1 + const key = isTomlStructuralLine(scanState) ? parseHookStateTomlHeaderKey(line) : null + if (key !== null) { + const nextHeaderOffset = findNextTomlTableHeader(content.slice(nextCursor)) + const blockEnd = nextHeaderOffset === -1 ? content.length : nextCursor + nextHeaderOffset + blocks.push({ + key, + start: cursor, + headerLineEnd: rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd, + contentStart: nextCursor, + end: blockEnd + }) + cursor = nextCursor + continue + } + scanState = updateTomlLineScanState(scanState, line) + cursor = nextCursor + } + return blocks +} + +export function ensureHooksStateParentTable(content: string): string { + if (/^[ \t]*\[hooks\.state\][ \t]*(?:#[^\r\n]*)?$/m.test(content)) { + return content + } + const eol = content.includes('\r\n') ? '\r\n' : '\n' + const parent = `[hooks.state]${eol}` + const hookHeader = /^[ \t]*\[hooks\.state\.(?:"|')/m.exec(content) + if (hookHeader) { + return `${content.slice(0, hookHeader.index)}${parent}${eol}${content.slice(hookHeader.index)}` + } + if (content.length === 0) { + return parent + } + const separator = content.endsWith(`${eol}${eol}`) ? '' : content.endsWith(eol) ? eol : eol + eol + return `${content}${separator}${parent}` +} diff --git a/src/main/codex/config-toml-hook-trust-edit.ts b/src/main/codex/config-toml-hook-trust-edit.ts new file mode 100644 index 00000000000..9d6e5dbc2d5 --- /dev/null +++ b/src/main/codex/config-toml-hook-trust-edit.ts @@ -0,0 +1,146 @@ +import type { CodexTrustEntry } from './config-toml-trust' +import { + computeCodexTrustedHash, + computeCodexTrustKey, + normalizeCodexHookTrustLookupKey, + normalizeCodexTrustSourcePath, + parseCodexTrustKey, + usesWindowsCodexPathSeparators +} from './codex-trust-identity' +import { + ensureHooksStateParentTable, + findHookTrustBlockRanges, + type HookTrustBlockRange +} from './config-toml-hook-trust-blocks' +import { escapeTomlBasicString } from './config-toml-syntax' + +export function upsertHookTrustContent( + existingContent: string, + entries: readonly CodexTrustEntry[] +): string { + const existing = stripLeadingBom(existingContent) + let updated = entries.some((entry) => + usesWindowsCodexPathSeparators(normalizeCodexTrustSourcePath(entry.sourcePath)) + ) + ? ensureHooksStateParentTable(existing) + : existing + for (const entry of entries) { + updated = upsertTrustBlocks( + updated, + getTrustKeyWriteVariants(computeCodexTrustKey(entry)), + entry.trustedHash ?? computeCodexTrustedHash(entry), + entry.enabled + ) + } + return updated +} + +export function removeHookTrustContent(content: string, keys: readonly string[]): string { + const normalizedKeys = new Set(keys.map(normalizeCodexHookTrustLookupKey)) + const ranges = findHookTrustBlockRanges(content, normalizedKeys) + if (ranges.length === 0) { + return content + } + let cursor = 0 + let updated = '' + for (const range of ranges) { + updated += content.slice(cursor, range.start) + cursor = range.end + } + return updated + content.slice(cursor) +} + +function upsertTrustBlocks( + content: string, + keys: readonly string[], + hash: string, + explicitEnabled?: boolean +): string { + const ranges = getUniqueTrustBlockRanges(content, keys) + if (ranges.length === 0) { + return appendTrustBlocks(content, keys, hash, explicitEnabled ?? true) + } + const enabled = explicitEnabled ?? !ranges.some((range) => isBlockDisabled(content, range)) + const block = buildTrustBlocks(keys, hash, enabled) + let cursor = 0 + let deduped = '' + ranges.forEach((range, index) => { + deduped += content.slice(cursor, range.start) + if (index === 0) { + deduped += `${block}\n` + } + cursor = range.end + }) + return deduped + content.slice(cursor) +} + +function getUniqueTrustBlockRanges( + content: string, + keys: readonly string[] +): HookTrustBlockRange[] { + const normalizedKeys = new Set(keys.map(normalizeCodexHookTrustLookupKey)) + return findHookTrustBlockRanges(content, normalizedKeys) + .filter( + (range, index, ranges) => + ranges.findIndex( + (candidate) => candidate.start === range.start && candidate.end === range.end + ) === index + ) + .sort((left, right) => left.start - right.start) +} + +function isBlockDisabled(content: string, range: HookTrustBlockRange): boolean { + const block = content.slice(range.headerLineEnd, range.end) + const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec(block) + return enabledMatch?.[1] === 'false' +} + +function appendTrustBlocks( + content: string, + keys: readonly string[], + hash: string, + enabled: boolean +): string { + const block = buildTrustBlocks(keys, hash, enabled) + if (content.length === 0) { + return `${block}\n` + } + const separator = content.endsWith('\n\n') ? '' : content.endsWith('\n') ? '\n' : '\n\n' + return `${content}${separator}${block}\n` +} + +function buildTrustBlocks(keys: readonly string[], hash: string, enabled: boolean): string { + return keys.map((key) => buildTrustBlock(key, hash, enabled)).join('\n\n') +} + +function buildTrustBlock(key: string, hash: string, enabled: boolean): string { + return [ + `[hooks.state.${formatHookStateTableKey(key)}]`, + `enabled = ${enabled}`, + `trusted_hash = "${escapeTomlBasicString(hash)}"` + ].join('\n') +} + +function formatHookStateTableKey(key: string): string { + const parsed = parseCodexTrustKey(key) + if (parsed && usesWindowsCodexPathSeparators(parsed.sourcePath) && !key.includes("'")) { + return `'${key}'` + } + return `"${escapeTomlBasicString(key)}"` +} + +function getTrustKeyWriteVariants(key: string): string[] { + const parsed = parseCodexTrustKey(key) + if (!parsed || !usesWindowsCodexPathSeparators(parsed.sourcePath)) { + return [key] + } + const suffix = `:${parsed.eventLabel}:${parsed.groupIndex}:${parsed.handlerIndex}` + return [ + `${parsed.sourcePath.replace(/\//g, '\\')}${suffix}`, + `${parsed.sourcePath.replace(/\\/g, '/')}${suffix}` + ].filter((variant, index, variants) => variants.indexOf(variant) === index) +} + +function stripLeadingBom(content: string): string { + return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content +} diff --git a/src/main/codex/config-toml-hook-trust-read.ts b/src/main/codex/config-toml-hook-trust-read.ts new file mode 100644 index 00000000000..690a2a8538a --- /dev/null +++ b/src/main/codex/config-toml-hook-trust-read.ts @@ -0,0 +1,87 @@ +import type { CodexHookTrustState } from './config-toml-trust' +import { normalizeCodexHookTrustLookupKey } from './codex-trust-identity' +import { findAllHookTrustBlocks } from './config-toml-hook-trust-blocks' +import { + createTomlLineScanState, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' +import { unescapeTomlBasicString } from './config-toml-syntax' + +export class CodexHookTrustEntryMap extends Map { + override get(key: string): CodexHookTrustState | undefined { + return super.get(normalizeCodexHookTrustLookupKey(key)) + } + + override has(key: string): boolean { + return super.has(normalizeCodexHookTrustLookupKey(key)) + } + + override delete(key: string): boolean { + return super.delete(normalizeCodexHookTrustLookupKey(key)) + } + + override set(key: string, value: CodexHookTrustState): this { + return super.set(normalizeCodexHookTrustLookupKey(key), value) + } +} + +export function readHookTrustContent(content: string): Map { + const result = new CodexHookTrustEntryMap() + const conflictingTrustedHashKeys = new Set() + for (const block of findAllHookTrustBlocks(content)) { + const state = readHookTrustBlockState(content.slice(block.contentStart, block.end)) + const normalizedKey = normalizeCodexHookTrustLookupKey(block.key) + const existingState = result.get(normalizedKey) + const trustedHash = + state.trustedHashes.size === 1 ? state.trustedHashes.values().next().value : undefined + if ( + state.trustedHashes.size > 1 || + (trustedHash !== undefined && + existingState?.trustedHash !== undefined && + existingState.trustedHash !== trustedHash) + ) { + conflictingTrustedHashKeys.add(normalizedKey) + } + result.set(normalizedKey, { + trustedHash: conflictingTrustedHashKeys.has(normalizedKey) + ? undefined + : (trustedHash ?? existingState?.trustedHash), + enabled: + existingState?.enabled === false || state.enabled === false + ? false + : (state.enabled ?? existingState?.enabled) + }) + } + return result +} + +function readHookTrustBlockState(block: string): { + trustedHashes: Set + enabled?: boolean +} { + const trustedHashes = new Set() + let enabled: boolean | undefined + let cursor = 0 + let scanState = createTomlLineScanState() + while (cursor < block.length) { + const newlineIndex = block.indexOf('\n', cursor) + const lineEnd = newlineIndex === -1 ? block.length : newlineIndex + const line = block.slice(cursor, lineEnd).replace(/\r$/, '') + if (isTomlStructuralLine(scanState)) { + const hashMatch = /^[ \t]*trusted_hash[ \t]*=[ \t]*"((?:[^"\\]|\\.)*)"[ \t]*(?:#.*)?$/.exec( + line + ) + if (hashMatch) { + trustedHashes.add(unescapeTomlBasicString(hashMatch[1]!)) + } + const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t]*(?:#.*)?$/.exec(line) + if (enabledMatch) { + enabled = enabled !== false && enabledMatch[1] === 'true' + } + } + scanState = updateTomlLineScanState(scanState, line) + cursor = newlineIndex === -1 ? block.length : newlineIndex + 1 + } + return { trustedHashes, enabled } +} diff --git a/src/main/codex/config-toml-project-trust.ts b/src/main/codex/config-toml-project-trust.ts new file mode 100644 index 00000000000..c4cd8f149d1 --- /dev/null +++ b/src/main/codex/config-toml-project-trust.ts @@ -0,0 +1,96 @@ +import { realpathSync } from 'node:fs' +import type { CodexProjectTrustLevel } from './config-toml-trust' +import { normalizeCodexTrustProjectPath } from './codex-trust-identity' +import { + createTomlLineScanState, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' +import { + escapeTomlBasicString, + findNextTomlTableHeader, + parseProjectTomlHeaderPath +} from './config-toml-syntax' + +export function upsertProjectTrustContent( + existingContent: string, + projectPath: string, + trustLevel: CodexProjectTrustLevel, + options?: { alreadyCanonical?: boolean } +): string { + const existing = stripLeadingBom(existingContent) + const trustedProjectPath = options?.alreadyCanonical + ? projectPath + : canonicalizeLocalProjectPath(projectPath) + const headerLineEnd = findProjectHeaderLineEnd(existing, trustedProjectPath) + const eol = existing.includes('\r\n') ? '\r\n' : '\n' + const trustLine = `trust_level = "${trustLevel}"` + if (headerLineEnd === null) { + return appendProjectTrustBlock(existing, trustedProjectPath, trustLine, eol) + } + const nextHeaderOffset = findNextTomlTableHeader(existing.slice(headerLineEnd)) + const blockEnd = nextHeaderOffset === -1 ? existing.length : headerLineEnd + nextHeaderOffset + const existingBlock = existing.slice(headerLineEnd, blockEnd) + const trustLevelPattern = + /^[ \t]*trust_level[ \t]*=[ \t]*(?:"(?:trusted|untrusted)"|'(?:trusted|untrusted)')[ \t\r]*(?:#.*)?$/m + if (trustLevelPattern.test(existingBlock)) { + return ( + existing.slice(0, headerLineEnd) + + existingBlock.replace(trustLevelPattern, trustLine) + + existing.slice(blockEnd) + ) + } + return `${existing.slice(0, headerLineEnd)}${eol}${trustLine}${existing.slice(headerLineEnd)}` +} + +function canonicalizeLocalProjectPath(projectPath: string): string { + try { + return realpathSync.native(projectPath) + } catch { + return projectPath + } +} + +function appendProjectTrustBlock( + existing: string, + projectPath: string, + trustLine: string, + eol: string +): string { + const block = [`[projects."${escapeTomlBasicString(projectPath)}"]`, trustLine].join(eol) + if (existing.length === 0) { + return `${block}${eol}` + } + const separator = existing.endsWith(`${eol}${eol}`) + ? '' + : existing.endsWith(eol) + ? eol + : eol + eol + return `${existing}${separator}${block}${eol}` +} + +function findProjectHeaderLineEnd(content: string, projectPath: string): number | null { + const lookupPath = normalizeCodexTrustProjectPath(projectPath) + let cursor = 0 + let scanState = createTomlLineScanState() + while (cursor < content.length) { + const newlineIndex = content.indexOf('\n', cursor) + const lineEnd = newlineIndex === -1 ? content.length : newlineIndex + const rawLine = content.slice(cursor, lineEnd) + const line = rawLine.replace(/\r$/, '') + const existingPath = isTomlStructuralLine(scanState) ? parseProjectTomlHeaderPath(line) : null + if (existingPath !== null && normalizeCodexTrustProjectPath(existingPath) === lookupPath) { + return rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd + } + scanState = updateTomlLineScanState(scanState, line) + if (newlineIndex === -1) { + return null + } + cursor = newlineIndex + 1 + } + return null +} + +function stripLeadingBom(content: string): string { + return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content +} diff --git a/src/main/codex/config-toml-syntax.ts b/src/main/codex/config-toml-syntax.ts new file mode 100644 index 00000000000..e3db4d546d3 --- /dev/null +++ b/src/main/codex/config-toml-syntax.ts @@ -0,0 +1,179 @@ +import { + createTomlLineScanState, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' + +type ParsedTomlString = { + value: string + endIndex: number +} + +export function escapeTomlBasicString(value: string): string { + return value + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"') + .replaceAll('\b', '\\b') + .replaceAll('\f', '\\f') + .replaceAll('\n', '\\n') + .replaceAll('\r', '\\r') + .replaceAll('\t', '\\t') +} + +export function parseHookStateTomlHeaderKey(line: string): string | null { + const trimmed = line.trimStart() + const prefixMatch = /^\[[ \t]*hooks[ \t]*\.[ \t]*state[ \t]*\.[ \t]*/.exec(trimmed) + return prefixMatch ? parseTomlTableStringKey(trimmed, prefixMatch[0].length) : null +} + +export function parseProjectTomlHeaderPath(line: string): string | null { + const trimmed = line.replace(/\r$/, '').trimStart() + const prefixMatch = /^\[[ \t]*projects[ \t]*\.[ \t]*/.exec(trimmed) + return prefixMatch ? parseTomlTableStringKey(trimmed, prefixMatch[0].length) : null +} + +function parseTomlTableStringKey(line: string, startIndex: number): string | null { + const parsedKey = parseTomlSingleLineString(line, startIndex) + if (!parsedKey) { + return null + } + let index = skipTomlInlineWhitespace(line, parsedKey.endIndex) + if (line[index] !== ']') { + return null + } + index = skipTomlInlineWhitespace(line, index + 1) + return index === line.length || line[index] === '#' ? parsedKey.value : null +} + +function parseTomlSingleLineString(line: string, startIndex: number): ParsedTomlString | null { + if (line[startIndex] === '"') { + return parseTomlBasicSingleLineString(line, startIndex + 1) + } + if (line[startIndex] === "'") { + return parseTomlLiteralSingleLineString(line, startIndex + 1) + } + return null +} + +function parseTomlBasicSingleLineString(line: string, startIndex: number): ParsedTomlString | null { + let value = '' + let index = startIndex + while (index < line.length) { + const char = line[index] + if (char === '"') { + return { value, endIndex: index + 1 } + } + if (char === '\\' && index + 1 < line.length) { + value += unescapeTomlBasicStringEscape(line[index + 1]!) + index += 2 + continue + } + value += char + index += 1 + } + return null +} + +function parseTomlLiteralSingleLineString( + line: string, + startIndex: number +): ParsedTomlString | null { + const endIndex = line.indexOf("'", startIndex) + return endIndex === -1 + ? null + : { value: line.slice(startIndex, endIndex), endIndex: endIndex + 1 } +} + +function skipTomlInlineWhitespace(line: string, startIndex: number): number { + let index = startIndex + while (line[index] === ' ' || line[index] === '\t') { + index += 1 + } + return index +} + +export function findNextTomlTableHeader(text: string): number { + let cursor = 0 + let scanState = createTomlLineScanState() + while (cursor < text.length) { + const newlineIndex = text.indexOf('\n', cursor) + const lineEnd = newlineIndex === -1 ? text.length : newlineIndex + const line = text.slice(cursor, lineEnd).replace(/\r$/, '') + if (isTomlStructuralLine(scanState)) { + const trimmed = line.trimStart() + if (trimmed.startsWith('[') && isCompleteTomlTableHeader(trimmed)) { + return cursor + } + } + scanState = updateTomlLineScanState(scanState, line) + if (newlineIndex === -1) { + return -1 + } + cursor = newlineIndex + 1 + } + return -1 +} + +function isCompleteTomlTableHeader(line: string): boolean { + const isArrayHeader = line.startsWith('[[') + if (!line.startsWith('[')) { + return false + } + let index = isArrayHeader ? 2 : 1 + let quote: '"' | "'" | null = null + while (index < line.length) { + const char = line[index] + if (quote === '"' && char === '\\' && index + 1 < line.length) { + index += 2 + continue + } + if (quote && char === quote) { + quote = null + index += 1 + continue + } + if (!quote && (char === '"' || char === "'")) { + quote = char + index += 1 + continue + } + if (!quote && char === ']') { + if (isArrayHeader && line[index + 1] !== ']') { + return false + } + const tail = line.slice(index + (isArrayHeader ? 2 : 1)) + return /^\s*(#.*)?$/.test(tail) + } + index += 1 + } + return false +} + +function unescapeTomlBasicStringEscape(next: string): string { + const escaped: Record = { + n: '\n', + r: '\r', + t: '\t', + b: '\b', + f: '\f', + '"': '"', + '\\': '\\' + } + return escaped[next] ?? `\\${next}` +} + +export function unescapeTomlBasicString(escaped: string): string { + let result = '' + let index = 0 + while (index < escaped.length) { + const char = escaped[index] + if (char === '\\' && index + 1 < escaped.length) { + result += unescapeTomlBasicStringEscape(escaped[index + 1]!) + index += 2 + continue + } + result += char + index += 1 + } + return result +} diff --git a/src/main/codex/config-toml-trust-api-parity.test.ts b/src/main/codex/config-toml-trust-api-parity.test.ts new file mode 100644 index 00000000000..d20cde89b8e --- /dev/null +++ b/src/main/codex/config-toml-trust-api-parity.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, expectTypeOf, it } from 'vitest' +import { chmodSync, readFileSync, readdirSync, statSync } from 'node:fs' +import * as trustApi from './config-toml-trust' +import type { + CodexEventLabel, + CodexHookTrustState, + CodexProjectTrustLevel, + CodexTrustEntry +} from './config-toml-trust' +import { + createTrustConfigFixture, + removeTrustConfigFixture +} from './config-toml-trust-test-fixtures' + +const fixtures: string[] = [] + +afterEach(() => { + for (const fixture of fixtures.splice(0)) { + removeTrustConfigFixture(fixture) + } +}) + +describe('config-toml-trust public API', () => { + it('retains the exact runtime export surface', () => { + expect(Object.keys(trustApi).sort()).toEqual( + [ + 'codexHookSourcePathsEqual', + 'computeTrustKey', + 'computeTrustedHash', + 'escapeTomlString', + 'getCodexExplicitHomeHookSourcePath', + 'normalizeCodexHookSourcePath', + 'normalizeCodexProjectPathForLookup', + 'normalizeCodexProjectPathForRevocationLookup', + 'normalizeHookTrustKeyForLookup', + 'parseCodexProjectHeaderPath', + 'parseTrustKey', + 'readHookTrustEntries', + 'readHookTrustEntriesFromContent', + 'removeHookTrustEntries', + 'removeHookTrustEntriesFromContent', + 'upsertHookTrustEntries', + 'upsertHookTrustEntriesInContent', + 'upsertProjectTrustLevel', + 'upsertProjectTrustLevelInContent', + 'writeConfigAtomically' + ].sort() + ) + }) + + it('retains the public type contracts', () => { + expectTypeOf<'stop' | 'session_start'>().toMatchTypeOf() + expectTypeOf().toEqualTypeOf<'trusted' | 'untrusted'>() + expectTypeOf().toHaveProperty('sourcePath').toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + }) +}) + +describe('config.toml partial and stale writes', () => { + it('preserves a truncated malformed prefix while appending trust', () => { + const partial = ['model = "gpt-5"', '[mcp_servers.partial', 'command = "still-user-data'].join( + '\n' + ) + const updated = trustApi.upsertProjectTrustLevelInContent( + partial, + 'C:/Remote/Repo', + 'trusted', + { alreadyCanonical: true } + ) + + expect(updated.startsWith(partial)).toBe(true) + expect(updated).toContain('[projects."C:/Remote/Repo"]') + }) + + it('pins the no-lock stale-writer policy to one complete last snapshot', () => { + const fixture = createTrustConfigFixture() + fixtures.push(fixture.tmpDir) + const original = 'model = "gpt-5"\n' + const first = trustApi.upsertProjectTrustLevelInContent(original, '/remote/first', 'trusted', { + alreadyCanonical: true + }) + const second = trustApi.upsertProjectTrustLevelInContent( + original, + '/remote/second', + 'trusted', + { alreadyCanonical: true } + ) + + trustApi.writeConfigAtomically(fixture.configPath, first) + trustApi.writeConfigAtomically(fixture.configPath, second) + + expect(readFileSync(fixture.configPath, 'utf-8')).toBe(second) + expect(readFileSync(fixture.configPath, 'utf-8')).not.toContain('/remote/first') + expect(readdirSync(fixture.tmpDir).filter((name) => name.endsWith('.tmp'))).toEqual([]) + }) + + it.skipIf(process.platform === 'win32')( + 'preserves permissions across stale atomic writers', + () => { + const fixture = createTrustConfigFixture() + fixtures.push(fixture.tmpDir) + trustApi.writeConfigAtomically(fixture.configPath, 'model = "first"\n') + chmodSync(fixture.configPath, 0o600) + + trustApi.writeConfigAtomically(fixture.configPath, 'model = "second"\n') + + expect(statSync(fixture.configPath).mode & 0o777).toBe(0o600) + } + ) +}) diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index d641c664f24..8b1943c058e 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -1,28 +1,21 @@ -/* eslint-disable max-lines -- Why: Codex hook trust parsing, hashing, and byte-preserving TOML edits share one fragile file-format contract; splitting would make the compatibility shim harder to audit. */ +import { existsSync, readFileSync } from 'node:fs' import { - existsSync, - lstatSync, - mkdirSync, - readFileSync, - realpathSync, - statSync, - unlinkSync, - writeFileSync -} from 'node:fs' -import { basename, dirname, join, posix as pathPosix, win32 as pathWin32 } from 'node:path' -import { createHash, randomUUID } from 'node:crypto' -import { renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' + codexTrustSourcePathsEqual, + computeCodexTrustedHash, + computeCodexTrustKey, + getExplicitHomeCodexHookSourcePath, + normalizeCodexHookTrustLookupKey, + normalizeCodexTrustProjectPath, + normalizeCodexTrustProjectRevocationPath, + normalizeCodexTrustSourcePath, + parseCodexTrustKey +} from './codex-trust-identity' +import { writeTomlConfigAtomically } from './config-toml-atomic-write' +import { removeHookTrustContent, upsertHookTrustContent } from './config-toml-hook-trust-edit' +import { CodexHookTrustEntryMap, readHookTrustContent } from './config-toml-hook-trust-read' +import { upsertProjectTrustContent } from './config-toml-project-trust' +import { escapeTomlBasicString, parseProjectTomlHeaderPath } from './config-toml-syntax' import { observe } from './codex-path-observation' -import { foldWslUncPathCaseInsensitiveParts } from '../../shared/wsl-paths' -import { writeRollingFileBackup } from '../rolling-file-backup' -import { - createTomlLineScanState, - isTomlStructuralLine, - updateTomlLineScanState -} from './config-toml-line-scan' - -// Why: Codex 0.129+ gates each hook on a `trusted_hash` in config.toml under [hooks.state.""]; without it the hook never fires (agent-status goes blank). -// Hash algorithm reverse-engineered from codex-rs/hooks/src/engine/discovery.rs (command_hook_hash) + config/src/fingerprint.rs (version_for_toml). export type CodexEventLabel = | 'pre_tool_use' @@ -55,7 +48,7 @@ export type CodexTrustEntry = { matcher?: string /** Optional statusMessage field. */ statusMessage?: string - /** Verbatim hash to write instead of computing one (survives Codex hash-algorithm drift). Never fed into hashing. */ + /** Verbatim hash to write instead of computing one. Never fed into hashing. */ trustedHash?: string /** Explicit enabled state to write; when absent, a pre-existing `enabled = false` is preserved. */ enabled?: boolean @@ -68,199 +61,32 @@ export type CodexHookTrustState = { export type CodexProjectTrustLevel = 'trusted' | 'untrusted' -// Why: normalize keys at the Map edge so Codex-written separator/casing variants match computeTrustKey() lookups. -class HookTrustEntryMap extends Map { - override get(key: string): CodexHookTrustState | undefined { - return super.get(normalizeHookTrustKeyForLookup(key)) - } - - override has(key: string): boolean { - return super.has(normalizeHookTrustKeyForLookup(key)) - } - - override delete(key: string): boolean { - return super.delete(normalizeHookTrustKeyForLookup(key)) - } - - override set(key: string, value: CodexHookTrustState): this { - return super.set(normalizeHookTrustKeyForLookup(key), value) - } -} - -// Why: matches Codex's canonical_json — recursively sorts object keys before hashing; arrays keep order. -function canonicalize(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(canonicalize) - } - if (value && typeof value === 'object') { - const sorted: Record = {} - for (const key of Object.keys(value as Record).sort()) { - sorted[key] = canonicalize((value as Record)[key]) - } - return sorted - } - return value -} - -// Why: mirrors codex-rs matcher_pattern_for_event (hooks/src/events/common.rs) — Codex drops matchers on user_prompt_submit/stop before hashing, so including one yields a hash it never writes → endless re-trust. -function matcherPatternForEvent( - eventLabel: CodexEventLabel, - matcher: string | undefined -): string | undefined { - switch (eventLabel) { - case 'user_prompt_submit': - case 'stop': - return undefined - case 'pre_tool_use': - case 'permission_request': - case 'post_tool_use': - case 'pre_compact': - case 'post_compact': - case 'session_start': - case 'subagent_start': - case 'subagent_stop': - return matcher - } -} - -// Why: reproduces Codex's command_hook_hash; wire shape is { event_name, matcher?, hooks:[handler] } with matcher omitted (not null) when absent. export function computeTrustedHash(entry: CodexTrustEntry): string { - const handler: Record = { - type: 'command', - command: entry.command, - timeout: Math.max(1, entry.timeoutSec ?? 600), - async: entry.async ?? false - } - if (entry.statusMessage !== undefined) { - handler.statusMessage = entry.statusMessage - } - const identity: Record = { - event_name: entry.eventLabel, - hooks: [handler] - } - const matcher = matcherPatternForEvent(entry.eventLabel, entry.matcher) - if (matcher !== undefined) { - identity.matcher = matcher - } - const serialized = JSON.stringify(canonicalize(identity)) - return `sha256:${createHash('sha256').update(serialized).digest('hex')}` + return computeCodexTrustedHash(entry) } -// Why: key_source is already home-derived by discovery. Realpath here would -// change default-home keys; explicit-home callers canonicalize the home first. export function computeTrustKey(entry: CodexTrustEntry): string { - return `${normalizeCodexHookSourcePath(entry.sourcePath)}:${entry.eventLabel}:${entry.groupIndex}:${entry.handlerIndex}` + return computeCodexTrustKey(entry) } -/** - * Returns the hook source path Codex derives after an explicit CODEX_HOME is - * canonicalized. The source leaf remains logical because hook discovery only - * normalizes the joined path lexically. - */ export function getCodexExplicitHomeHookSourcePath(sourcePath: string): string { - if (process.platform !== 'win32' && isUnambiguousWindowsPath(sourcePath)) { - return normalizeCodexHookSourcePath(sourcePath) - } - try { - // Why: explicit CODEX_HOME resolves the home directory before hooks.json - // is appended, so a symlinked leaf must remain logical in the reported key. - return normalizeCodexHookSourcePath( - join(realpathSync.native(dirname(sourcePath)), basename(sourcePath)) - ) - } catch { - return normalizeCodexHookSourcePath(sourcePath) - } + return getExplicitHomeCodexHookSourcePath(sourcePath) } -/** Matches the platform-native lexical path displayed by hook discovery. */ export function normalizeCodexHookSourcePath(sourcePath: string): string { - if (isWindowsPathForTrustSource(sourcePath)) { - const withoutDevicePrefix = stripWindowsDevicePrefix(sourcePath) - const normalized = pathWin32.isAbsolute(withoutDevicePrefix) - ? pathWin32.normalize(withoutDevicePrefix) - : pathWin32.resolve(withoutDevicePrefix) - return trimNonRootTrailingSeparators(normalized, pathWin32.parse(normalized).root, /[\\/]/) - } - const normalized = pathPosix.isAbsolute(sourcePath) - ? pathPosix.normalize(sourcePath) - : pathPosix.resolve(sourcePath) - return trimNonRootTrailingSeparators(normalized, pathPosix.parse(normalized).root, /\//) + return normalizeCodexTrustSourcePath(sourcePath) } -function trimNonRootTrailingSeparators(path: string, root: string, separators: RegExp): string { - let end = path.length - while (end > root.length && separators.test(path[end - 1]!)) { - end -= 1 - } - return path.slice(0, end) -} - -function stripWindowsDevicePrefix(sourcePath: string): string { - const unc = /^(?:\\\\\?|\\\\\.)\\UNC\\/i.exec(sourcePath) - if (unc) { - return `\\\\${sourcePath.slice(unc[0].length)}` - } - const drive = /^(?:\\\\\?|\\\\\.)\\(?=[A-Za-z]:[\\/])/i.exec(sourcePath) - return drive ? sourcePath.slice(drive[0].length) : sourcePath -} - -function getCodexCanonicalProjectPath(projectPath: string): string { - try { - // Why: local trust needs Codex's realpath shape, but remote SSH callers already pass a canonical path. - return realpathSync.native(projectPath) - } catch { - return projectPath - } -} - -function normalizeWindowsPathSeparators(sourcePath: string): string { - if (!usesWindowsPathSeparators(sourcePath)) { - return sourcePath - } - return sourcePath.replace(/\\/g, '/') -} - -function usesWindowsPathSeparators(sourcePath: string): boolean { - return isUnambiguousWindowsPath(sourcePath) || sourcePath.startsWith('//') -} - -function isUnambiguousWindowsPath(sourcePath: string): boolean { - return /^[A-Za-z]:[\\/]/.test(sourcePath) || sourcePath.startsWith('\\\\') -} - -function isWindowsPathForTrustSource(sourcePath: string): boolean { - return ( - isUnambiguousWindowsPath(sourcePath) || - (process.platform === 'win32' && - (sourcePath.startsWith('//') || !pathPosix.isAbsolute(sourcePath))) - ) -} - -// Why: Codex and Orca can disagree on quote style, separators, and casing for the same Windows project. export function normalizeCodexProjectPathForLookup(projectPath: string): string { - if (!usesWindowsPathSeparators(projectPath)) { - return projectPath - } - // Why: the Linux tail under a WSL share is case-sensitive, so folding it would conflate distinct dirs onto one key. - const slashedPath = normalizeWindowsPathSeparators(projectPath) - return foldWslUncPathCaseInsensitiveParts(slashedPath) ?? slashedPath.toLowerCase() + return normalizeCodexTrustProjectPath(projectPath) } export function codexHookSourcePathsEqual(left: string, right: string): boolean { - // Why: TrustMap iteration yields lookup-normalized keys, so Windows paths - // may be lowercase even when the live filesystem preserves mixed casing. - const normalizeForLookup = (sourcePath: string): string => - normalizeCodexProjectPathForLookup( - sourcePath.startsWith('//') ? sourcePath : normalizeCodexHookSourcePath(sourcePath) - ) - return normalizeForLookup(left) === normalizeForLookup(right) + return codexTrustSourcePathsEqual(left, right) } -// Why: trust revocations recorded before WSL tails compared case-sensitively -// can carry drifted casing; fold fully so matching errs toward revoked. export function normalizeCodexProjectPathForRevocationLookup(projectPath: string): string { - const normalized = normalizeCodexProjectPathForLookup(projectPath) - return usesWindowsPathSeparators(projectPath) ? normalized.toLowerCase() : normalized + return normalizeCodexTrustProjectRevocationPath(projectPath) } export function parseTrustKey(key: string): { @@ -269,111 +95,26 @@ export function parseTrustKey(key: string): { groupIndex: number handlerIndex: number } | null { - // Why: sourcePath may contain `:` (Windows drive letters), so anchor the parse at the last three colons. - const lastColon = key.lastIndexOf(':') - if (lastColon === -1) { - return null - } - const handlerStr = key.slice(lastColon + 1) - if (!isCanonicalNonNegativeInt(handlerStr)) { - return null - } - const secondLast = key.lastIndexOf(':', lastColon - 1) - if (secondLast === -1) { - return null - } - const groupStr = key.slice(secondLast + 1, lastColon) - if (!isCanonicalNonNegativeInt(groupStr)) { - return null - } - const thirdLast = key.lastIndexOf(':', secondLast - 1) - if (thirdLast === -1) { - return null - } - const eventLabel = key.slice(thirdLast + 1, secondLast) - if (!isCodexEventLabel(eventLabel)) { - return null - } - const sourcePath = key.slice(0, thirdLast) - if (sourcePath.length === 0) { - return null - } - return { - sourcePath, - eventLabel, - groupIndex: Number(groupStr), - handlerIndex: Number(handlerStr) - } + return parseCodexTrustKey(key) } -// Why: Number('') === 0 and Number('1e2') === 100 both pass Number.isInteger, so reject non-canonical decimal forms first. -function isCanonicalNonNegativeInt(value: string): boolean { - return /^(0|[1-9]\d*)$/.test(value) -} - -function isCodexEventLabel(value: string): value is CodexEventLabel { - return ( - value === 'pre_tool_use' || - value === 'permission_request' || - value === 'post_tool_use' || - value === 'pre_compact' || - value === 'post_compact' || - value === 'session_start' || - value === 'user_prompt_submit' || - value === 'subagent_start' || - value === 'subagent_stop' || - value === 'stop' - ) -} - -// Why: strip a leading BOM (some Windows editors write one) so header regexes anchored at `^[ \t]*\[` still match. -function readTomlFile(configPath: string): string { - const raw = readFileSync(configPath, 'utf-8') - return raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw -} - -// Why: regex-edit config.toml (not parse+reserialize) to byte-preserve user comments, key ordering, and inline-table style. -// Why: this read-modify-write has no lock and races Codex's /hooks writer, but idempotent install() repairs any lost update. +// Why: trust edits preserve unrelated bytes instead of reserializing the user's config. export function upsertHookTrustEntries( configPath: string, entries: readonly CodexTrustEntry[] ): void { - // Why: `existsSync` collapses an indeterminate probe into the same `false` as - // absence. If the path recovers before the write, rebuilding from '' replaces - // the user's model, provider, MCP servers, approvals and comments with a - // trust-only stub. Only a definitive absence may seed from empty. - const observation = observe(() => readTomlFile(configPath)) - if (observation.kind === 'indeterminate') { - throw observation.error - } - const existing = observation.kind === 'present' ? observation.value : '' + const existing = readTomlForMutation(configPath) const updated = upsertHookTrustEntriesInContent(existing, entries) - if (updated === existing) { - return + if (updated !== existing) { + writeConfigAtomically(configPath, updated) } - writeConfigAtomically(configPath, updated) } export function upsertHookTrustEntriesInContent( existingContent: string, entries: readonly CodexTrustEntry[] ): string { - const existing = - existingContent.charCodeAt(0) === 0xfeff ? existingContent.slice(1) : existingContent - let updated = entries.some((entry) => - usesWindowsPathSeparators(normalizeCodexHookSourcePath(entry.sourcePath)) - ) - ? ensureHooksStateParentTable(existing) - : existing - for (const entry of entries) { - updated = upsertTrustBlocks( - updated, - getTrustKeyWriteVariants(computeTrustKey(entry)), - entry.trustedHash ?? computeTrustedHash(entry), - entry.enabled - ) - } - return updated + return upsertHookTrustContent(existingContent, entries) } export function upsertProjectTrustLevel( @@ -381,16 +122,11 @@ export function upsertProjectTrustLevel( projectPath: string, trustLevel: CodexProjectTrustLevel ): void { - const observation = observe(() => readTomlFile(configPath)) - if (observation.kind === 'indeterminate') { - throw observation.error - } - const existing = observation.kind === 'present' ? observation.value : '' + const existing = readTomlForMutation(configPath) const updated = upsertProjectTrustLevelInContent(existing, projectPath, trustLevel) - if (updated === existing) { - return + if (updated !== existing) { + writeConfigAtomically(configPath, updated) } - writeConfigAtomically(configPath, updated) } export function upsertProjectTrustLevelInContent( @@ -399,454 +135,23 @@ export function upsertProjectTrustLevelInContent( trustLevel: CodexProjectTrustLevel, options?: { alreadyCanonical?: boolean } ): string { - const existing = - existingContent.charCodeAt(0) === 0xfeff ? existingContent.slice(1) : existingContent - const trustedProjectPath = options?.alreadyCanonical - ? projectPath - : getCodexCanonicalProjectPath(projectPath) - const headerLineEnd = findProjectHeaderLineEnd(existing, trustedProjectPath) - const eol = existing.includes('\r\n') ? '\r\n' : '\n' - const trustLine = `trust_level = "${trustLevel}"` - - if (headerLineEnd === null) { - const block = [`[projects."${escapeTomlString(trustedProjectPath)}"]`, trustLine].join(eol) - if (existing.length === 0) { - return `${block}${eol}` - } - const separator = existing.endsWith(`${eol}${eol}`) - ? '' - : existing.endsWith(eol) - ? eol - : eol + eol - return `${existing}${separator}${block}${eol}` - } - - const after = existing.slice(headerLineEnd) - const nextHeaderRel = findNextTableHeader(after) - const blockEnd = nextHeaderRel === -1 ? existing.length : headerLineEnd + nextHeaderRel - const existingBlock = existing.slice(headerLineEnd, blockEnd) - const trustLevelLinePattern = - /^[ \t]*trust_level[ \t]*=[ \t]*(?:"(?:trusted|untrusted)"|'(?:trusted|untrusted)')[ \t\r]*(?:#.*)?$/m - if (trustLevelLinePattern.test(existingBlock)) { - return ( - existing.slice(0, headerLineEnd) + - existingBlock.replace(trustLevelLinePattern, trustLine) + - existing.slice(blockEnd) - ) - } - return `${existing.slice(0, headerLineEnd)}${eol}${trustLine}${existing.slice(headerLineEnd)}` + return upsertProjectTrustContent(existingContent, projectPath, trustLevel, options) } -// Why: field names mirror Codex's HookStateToml (/hooks approval); `enabled` is plumbed so a user-set `enabled = false` survives reinstall. -function buildTrustBlock(key: string, hash: string, enabled: boolean): string { - return [ - `[hooks.state.${formatHookStateTableKey(key)}]`, - `enabled = ${enabled}`, - `trusted_hash = "${escapeTomlString(hash)}"` - ].join('\n') -} - -function formatHookStateTableKey(key: string): string { - const parsed = parseTrustKey(key) - if (parsed && usesWindowsPathSeparators(parsed.sourcePath) && !key.includes("'")) { - // Why: Codex 0.140 trusts Windows hooks only when state table keys match the raw native path shape it writes. - return `'${key}'` - } - return `"${escapeTomlString(key)}"` -} - -function getTrustKeyWriteVariants(key: string): string[] { - const parsed = parseTrustKey(key) - if (!parsed || !usesWindowsPathSeparators(parsed.sourcePath)) { - return [key] - } - const suffix = `:${parsed.eventLabel}:${parsed.groupIndex}:${parsed.handlerIndex}` - return [ - `${parsed.sourcePath.replace(/\//g, '\\')}${suffix}`, - `${parsed.sourcePath.replace(/\\/g, '/')}${suffix}` - ].filter((variant, index, variants) => variants.indexOf(variant) === index) -} - -// Why: escape backslash first so later substitutions don't double-escape the inserted backslashes. export function escapeTomlString(value: string): string { - return value - .replaceAll('\\', '\\\\') - .replaceAll('"', '\\"') - .replaceAll('\b', '\\b') - .replaceAll('\f', '\\f') - .replaceAll('\n', '\\n') - .replaceAll('\r', '\\r') - .replaceAll('\t', '\\t') + return escapeTomlBasicString(value) } -function upsertTrustBlocks( - content: string, - keys: readonly string[], - hash: string, - explicitEnabled?: boolean -): string { - const ranges = keys - .flatMap((key) => findTrustBlockRanges(content, key)) - .filter( - (range, index, ranges) => - ranges.findIndex( - (candidate) => candidate.start === range.start && candidate.end === range.end - ) === index - ) - .sort((a, b) => a.start - b.start) - if (ranges.length === 0) { - const block = buildTrustBlocks(keys, hash, explicitEnabled ?? true) - if (content.length === 0) { - return `${block}\n` - } - // Why: one blank line before the appended block, without compounding separators when the file already ends blank. - const separator = content.endsWith('\n\n') ? '' : content.endsWith('\n') ? '\n' : '\n\n' - return `${content}${separator}${block}\n` - } - - // Why: preserve a user-set `enabled = false` (any disabled duplicate wins) unless an explicit state overrides it. - const enabled = - explicitEnabled ?? - !ranges.some((range) => { - const existingBlock = content.slice(range.headerLineEnd, range.end) - const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t\r]*(?:#.*)?$/m.exec( - existingBlock - ) - return enabledMatch?.[1] === 'false' - }) - const block = buildTrustBlocks(keys, hash, enabled) - let cursor = 0 - let deduped = '' - ranges.forEach((range, index) => { - deduped += content.slice(cursor, range.start) - if (index === 0) { - deduped += `${block}\n` - } - cursor = range.end - }) - return deduped + content.slice(cursor) -} - -function buildTrustBlocks(keys: readonly string[], hash: string, enabled: boolean): string { - // Why: Codex 0.140 exposes Windows hook-state keys with either backslashes or forward slashes depending on startup cwd. - return keys.map((key) => buildTrustBlock(key, hash, enabled)).join('\n\n') -} - -function ensureHooksStateParentTable(content: string): string { - if (/^[ \t]*\[hooks\.state\][ \t]*(?:#[^\r\n]*)?$/m.test(content)) { - return content - } - const eol = content.includes('\r\n') ? '\r\n' : '\n' - const parent = `[hooks.state]${eol}` - const hookHeader = /^[ \t]*\[hooks\.state\.(?:"|')/m.exec(content) - if (hookHeader) { - return `${content.slice(0, hookHeader.index)}${parent}${eol}${content.slice(hookHeader.index)}` - } - if (content.length === 0) { - return parent - } - const separator = content.endsWith(`${eol}${eol}`) ? '' : content.endsWith(eol) ? eol : eol + eol - return `${content}${separator}${parent}` -} - -type TrustBlockRange = { - start: number - headerLineEnd: number - end: number -} - -// Why: separator/casing drift between Codex-written and Orca-built keys must not stop findTrustBlockRanges from matching. export function normalizeHookTrustKeyForLookup(key: string): string { - const parsed = parseTrustKey(key) - // Why: fold by path shape, not host platform — hook sources on WSL and SSH - // Windows remotes need the same folding when Orca runs on macOS or Linux. - const foldedPath = normalizeCodexProjectPathForLookup( - parsed - ? parsed.sourcePath.startsWith('//') - ? parsed.sourcePath - : normalizeCodexHookSourcePath(parsed.sourcePath) - : key - ) - return parsed - ? `${foldedPath}:${parsed.eventLabel}:${parsed.groupIndex}:${parsed.handlerIndex}` - : foldedPath -} - -function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] { - return findTrustBlockRangesForNormalizedKeys( - content, - new Set([normalizeHookTrustKeyForLookup(key)]) - ) -} - -function findTrustBlockRangesForNormalizedKeys( - content: string, - normalizedKeys: ReadonlySet -): TrustBlockRange[] { - const ranges: TrustBlockRange[] = [] - if (normalizedKeys.size === 0) { - return ranges - } - let cursor = 0 - let scanState = createTomlLineScanState() - while (cursor < content.length) { - const newlineIdx = content.indexOf('\n', cursor) - const lineEnd = newlineIdx === -1 ? content.length : newlineIdx - const rawLine = content.slice(cursor, lineEnd) - const lineWithoutCr = rawLine.replace(/\r$/, '') - const line = - cursor === 0 && lineWithoutCr.charCodeAt(0) === 0xfeff - ? lineWithoutCr.slice(1) - : lineWithoutCr - const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1 - const headerKey = isTomlStructuralLine(scanState) ? parseHookStateHeaderKey(line) : null - if (headerKey !== null && normalizedKeys.has(normalizeHookTrustKeyForLookup(headerKey))) { - const headerLineEnd = rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd - const after = content.slice(headerLineEnd) - const nextHeaderRel = findNextTableHeader(after) - const blockEnd = nextHeaderRel === -1 ? content.length : headerLineEnd + nextHeaderRel - ranges.push({ start: cursor, headerLineEnd, end: blockEnd }) - cursor = Math.max(blockEnd, nextCursor) - continue - } - scanState = updateTomlLineScanState(scanState, line) - cursor = nextCursor - } - return ranges -} - -type ParsedTomlString = { - value: string - endIndex: number -} - -// Why: hook-state keys appear as both TOML basic strings and equivalent literal-string keys. -function parseHookStateHeaderKey(line: string): string | null { - const trimmed = line.trimStart() - const prefixMatch = /^\[[ \t]*hooks[ \t]*\.[ \t]*state[ \t]*\.[ \t]*/.exec(trimmed) - if (!prefixMatch) { - return null - } - const parsedKey = parseTomlSingleLineString(trimmed, prefixMatch[0].length) - if (!parsedKey) { - return null - } - let index = skipTomlInlineWhitespace(trimmed, parsedKey.endIndex) - if (trimmed[index] !== ']') { - return null - } - index = skipTomlInlineWhitespace(trimmed, index + 1) - return index === trimmed.length || trimmed[index] === '#' ? parsedKey.value : null + return normalizeCodexHookTrustLookupKey(key) } export function parseCodexProjectHeaderPath(line: string): string | null { - // Why: mirror section headers retain a terminal CR (split CRLF files) while direct upserts scan CR-stripped lines. - const trimmed = line.replace(/\r$/, '').trimStart() - const prefixMatch = /^\[[ \t]*projects[ \t]*\.[ \t]*/.exec(trimmed) - if (!prefixMatch) { - return null - } - const parsedPath = parseTomlSingleLineString(trimmed, prefixMatch[0].length) - if (!parsedPath) { - return null - } - let index = skipTomlInlineWhitespace(trimmed, parsedPath.endIndex) - if (trimmed[index] !== ']') { - return null - } - index = skipTomlInlineWhitespace(trimmed, index + 1) - return index === trimmed.length || trimmed[index] === '#' ? parsedPath.value : null + return parseProjectTomlHeaderPath(line) } -function findProjectHeaderLineEnd(content: string, projectPath: string): number | null { - const lookupPath = normalizeCodexProjectPathForLookup(projectPath) - let cursor = 0 - let scanState = createTomlLineScanState() - while (cursor < content.length) { - const newlineIndex = content.indexOf('\n', cursor) - const lineEnd = newlineIndex === -1 ? content.length : newlineIndex - const rawLine = content.slice(cursor, lineEnd) - const line = rawLine.replace(/\r$/, '') - const existingPath = isTomlStructuralLine(scanState) ? parseCodexProjectHeaderPath(line) : null - if (existingPath !== null && normalizeCodexProjectPathForLookup(existingPath) === lookupPath) { - return rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd - } - scanState = updateTomlLineScanState(scanState, line) - if (newlineIndex === -1) { - return null - } - cursor = newlineIndex + 1 - } - return null -} - -function parseTomlSingleLineString(line: string, startIndex: number): ParsedTomlString | null { - if (line[startIndex] === '"') { - return parseTomlBasicSingleLineString(line, startIndex + 1) - } - if (line[startIndex] === "'") { - return parseTomlLiteralSingleLineString(line, startIndex + 1) - } - return null -} - -function parseTomlBasicSingleLineString(line: string, startIndex: number): ParsedTomlString | null { - let value = '' - let index = startIndex - while (index < line.length) { - const char = line[index] - if (char === '"') { - return { value, endIndex: index + 1 } - } - if (char === '\\' && index + 1 < line.length) { - const next = line[index + 1] - value += unescapeTomlBasicStringEscape(next) - index += 2 - continue - } - value += char - index++ - } - return null -} - -function parseTomlLiteralSingleLineString( - line: string, - startIndex: number -): ParsedTomlString | null { - const endIndex = line.indexOf("'", startIndex) - if (endIndex === -1) { - return null - } - return { value: line.slice(startIndex, endIndex), endIndex: endIndex + 1 } -} - -function skipTomlInlineWhitespace(line: string, startIndex: number): number { - let index = startIndex - while (line[index] === ' ' || line[index] === '\t') { - index++ - } - return index -} -// Why: quoted keys can contain `]` and `[` lines inside multi-line strings aren't headers, so a flat regex misclassifies both — need a stateful scan. -function findNextTableHeader(text: string): number { - let cursor = 0 - let scanState = createTomlLineScanState() - while (cursor < text.length) { - const newlineIdx = text.indexOf('\n', cursor) - const lineEnd = newlineIdx === -1 ? text.length : newlineIdx - const rawLine = text.slice(cursor, lineEnd) - const line = rawLine.replace(/\r$/, '') - if (isTomlStructuralLine(scanState)) { - const trimmed = line.trimStart() - // Why: stop at both `[table]` and `[[array.of.tables]]`; skipping `[[ ]]` would let the slice consume unrelated content. - if (trimmed.startsWith('[') && isCompleteTableHeader(trimmed)) { - return cursor - } - } - scanState = updateTomlLineScanState(scanState, line) - if (newlineIdx === -1) { - return -1 - } - cursor = newlineIdx + 1 - } - return -1 -} - -// Why: walk byte-by-byte so a `]` inside a quoted key segment doesn't terminate the header early. -function isCompleteTableHeader(line: string): boolean { - if (!line.startsWith('[')) { - return false - } - const isArrayHeader = line.startsWith('[[') - let i = isArrayHeader ? 2 : 1 - let inBasicQuote = false - let inLiteralQuote = false - while (i < line.length) { - const ch = line[i] - if (inBasicQuote) { - if (ch === '\\' && i + 1 < line.length) { - i += 2 - continue - } - if (ch === '"') { - inBasicQuote = false - } - i++ - continue - } - if (inLiteralQuote) { - if (ch === "'") { - inLiteralQuote = false - } - i++ - continue - } - if (ch === '"') { - inBasicQuote = true - i++ - continue - } - if (ch === "'") { - inLiteralQuote = true - i++ - continue - } - if (ch === ']') { - if (isArrayHeader) { - if (line[i + 1] !== ']') { - return false - } - const tail = line.slice(i + 2) - return /^\s*(#.*)?$/.test(tail) - } - const tail = line.slice(i + 1) - return /^\s*(#.*)?$/.test(tail) - } - i++ - } - return false -} - -// Why: a half-written config.toml can brick Codex, so write to a random-suffix tmp then rename (.bak rotation), avoiding cross-process races. export function writeConfigAtomically(configPath: string, contents: string): void { - let writePath = configPath - let isSymlink = false - try { - isSymlink = lstatSync(configPath).isSymbolicLink() - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error - } - } - if (isSymlink) { - // Why: atomic rename at the lexical path replaces the user's dotfiles - // link. A dangling link must fail closed rather than be replaced. - writePath = realpathSync.native(configPath) - } - const dir = dirname(writePath) - mkdirSync(dir, { recursive: true }) - const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) - const existingMode = existsSync(writePath) ? statSync(writePath).mode : undefined - let renamed = false - try { - // Why: real-home trust cleanup must not widen a dotfiles-managed config's - // restrictive permissions when the atomic rename installs new bytes. - writeFileSync(tmpPath, contents, { encoding: 'utf-8', mode: existingMode }) - if (existsSync(writePath)) { - writeRollingFileBackup(writePath, `${writePath}.bak`) - } - renameFileWithWindowsRetry(tmpPath, writePath) - renamed = true - } finally { - if (!renamed && existsSync(tmpPath)) { - try { - unlinkSync(tmpPath) - } catch { - // best effort — surfacing the cleanup failure would mask the original write error - } - } - } + writeTomlConfigAtomically(configPath, contents) } export function removeHookTrustEntries(configPath: string, keys: readonly string[]): void { @@ -855,166 +160,38 @@ export function removeHookTrustEntries(configPath: string, keys: readonly string } const existing = readTomlFile(configPath) const updated = removeHookTrustEntriesFromContent(existing, keys) - if (updated === existing) { - return + if (updated !== existing) { + writeConfigAtomically(configPath, updated) } - writeConfigAtomically(configPath, updated) } export function removeHookTrustEntriesFromContent( content: string, keys: readonly string[] ): string { - const normalizedKeys = new Set(keys.map(normalizeHookTrustKeyForLookup)) - const ranges = findTrustBlockRangesForNormalizedKeys(content, normalizedKeys) - if (ranges.length === 0) { - return content - } - - let cursor = 0 - let updated = '' - for (const range of ranges) { - updated += content.slice(cursor, range.start) - cursor = range.end - } - return updated + content.slice(cursor) + return removeHookTrustContent(content, keys) } export function readHookTrustEntries(configPath: string): Map { - if (!existsSync(configPath)) { - return new HookTrustEntryMap() - } - return readHookTrustEntriesFromContent(readTomlFile(configPath)) -} - -function readHookTrustBlockState(block: string): { - trustedHashes: Set - enabled?: boolean -} { - const trustedHashes = new Set() - let enabled: boolean | undefined - let cursor = 0 - let scanState = createTomlLineScanState() - while (cursor < block.length) { - const newlineIdx = block.indexOf('\n', cursor) - const lineEnd = newlineIdx === -1 ? block.length : newlineIdx - const line = block.slice(cursor, lineEnd).replace(/\r$/, '') - if (isTomlStructuralLine(scanState)) { - const hashMatch = /^[ \t]*trusted_hash[ \t]*=[ \t]*"((?:[^"\\]|\\.)*)"[ \t]*(?:#.*)?$/.exec( - line - ) - if (hashMatch) { - trustedHashes.add(unescapeTomlString(hashMatch[1])) - } - const enabledMatch = /^[ \t]*enabled[ \t]*=[ \t]*(true|false)[ \t]*(?:#.*)?$/.exec(line) - if (enabledMatch) { - enabled = enabled !== false && enabledMatch[1] === 'true' - } - } - scanState = updateTomlLineScanState(scanState, line) - cursor = newlineIdx === -1 ? block.length : newlineIdx + 1 - } - return { trustedHashes, enabled } + return existsSync(configPath) + ? readHookTrustEntriesFromContent(readTomlFile(configPath)) + : new CodexHookTrustEntryMap() } export function readHookTrustEntriesFromContent(content: string): Map { - const result = new HookTrustEntryMap() - const conflictingTrustedHashKeys = new Set() - // Why: walk line-by-line so `[hooks.state."..."]` inside a `"""..."""` or - // `'''...'''` multi-line string isn't mistaken for a real header. - let cursor = 0 - let scanState = createTomlLineScanState() - while (cursor < content.length) { - const newlineIdx = content.indexOf('\n', cursor) - const lineEnd = newlineIdx === -1 ? content.length : newlineIdx - const rawLine = content.slice(cursor, lineEnd) - const lineWithoutCr = rawLine.replace(/\r$/, '') - const line = - cursor === 0 && lineWithoutCr.charCodeAt(0) === 0xfeff - ? lineWithoutCr.slice(1) - : lineWithoutCr - const nextCursor = newlineIdx === -1 ? content.length : newlineIdx + 1 - const key = isTomlStructuralLine(scanState) ? parseHookStateHeaderKey(line) : null - if (key !== null) { - const after = content.slice(nextCursor) - const nextHeaderRel = findNextTableHeader(after) - const blockEnd = nextHeaderRel === -1 ? content.length : nextCursor + nextHeaderRel - const block = content.slice(nextCursor, blockEnd) - const blockState = readHookTrustBlockState(block) - const normalizedKey = normalizeHookTrustKeyForLookup(key) - const existingState = result.get(normalizedKey) - const trustedHash = - blockState.trustedHashes.size === 1 - ? blockState.trustedHashes.values().next().value - : undefined - if ( - blockState.trustedHashes.size > 1 || - (trustedHash !== undefined && - existingState?.trustedHash !== undefined && - existingState.trustedHash !== trustedHash) - ) { - // Why: conflicting normalized duplicates are malformed and cannot prove - // which block is owned, so trust cleanup must preserve them all. - conflictingTrustedHashKeys.add(normalizedKey) - } - result.set(normalizedKey, { - trustedHash: conflictingTrustedHashKeys.has(normalizedKey) - ? undefined - : (trustedHash ?? existingState?.trustedHash), - // Why: Windows writes both slash variants for one hook; a disabled copy - // must remain authoritative regardless of which variant appears last. - enabled: - existingState?.enabled === false || blockState.enabled === false - ? false - : (blockState.enabled ?? existingState?.enabled) - }) - cursor = nextCursor - continue - } - scanState = updateTomlLineScanState(scanState, line) - cursor = nextCursor - } - return result + return readHookTrustContent(content) } -function unescapeTomlBasicStringEscape(next: string): string { - if (next === 'n') { - return '\n' +function readTomlForMutation(configPath: string): string { + // Why: only definitive absence may seed empty; an indeterminate read must preserve the existing file. + const observation = observe(() => readTomlFile(configPath)) + if (observation.kind === 'indeterminate') { + throw observation.error } - if (next === 'r') { - return '\r' - } - if (next === 't') { - return '\t' - } - if (next === 'b') { - return '\b' - } - if (next === 'f') { - return '\f' - } - if (next === '"') { - return '"' - } - if (next === '\\') { - return '\\' - } - // Why: unknown escapes round-trip — preserve the backslash so info isn't dropped. - return `\\${next}` + return observation.kind === 'present' ? observation.value : '' } -function unescapeTomlString(escaped: string): string { - let result = '' - let i = 0 - while (i < escaped.length) { - const ch = escaped[i] - if (ch === '\\' && i + 1 < escaped.length) { - result += unescapeTomlBasicStringEscape(escaped[i + 1]) - i += 2 - } else { - result += ch - i++ - } - } - return result +function readTomlFile(configPath: string): string { + const raw = readFileSync(configPath, 'utf-8') + return raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw } diff --git a/src/main/crash-reporting/crash-breadcrumb-renderer-attribution.test.ts b/src/main/crash-reporting/crash-breadcrumb-renderer-attribution.test.ts new file mode 100644 index 00000000000..3431a35089b --- /dev/null +++ b/src/main/crash-reporting/crash-breadcrumb-renderer-attribution.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { + clearCrashBreadcrumbsForTest, + getCrashBreadcrumbSnapshot, + recordCoalescedCrashBreadcrumb, + recordCrashBreadcrumb +} from './crash-breadcrumb-store' + +describe('renderer crash breadcrumb attribution', () => { + beforeEach(() => { + clearCrashBreadcrumbsForTest() + }) + + it('filters a renderer report to its own breadcrumbs while retaining global evidence', () => { + recordCrashBreadcrumb('app_started', { packaged: true }) + recordCrashBreadcrumb('renderer_a_event', undefined, 'renderer:11') + recordCrashBreadcrumb('renderer_b_event', undefined, 'renderer:22') + + expect(getCrashBreadcrumbSnapshot('renderer:11').map((entry) => entry.name)).toEqual([ + 'app_started', + 'renderer_a_event' + ]) + expect(getCrashBreadcrumbSnapshot('renderer:22').map((entry) => entry.name)).toEqual([ + 'app_started', + 'renderer_b_event' + ]) + expect(getCrashBreadcrumbSnapshot().map((entry) => entry.name)).toEqual([ + 'app_started', + 'renderer_a_event', + 'renderer_b_event' + ]) + }) + + it('does not merge identical coalesced storms from sibling renderers', () => { + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:11\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:11' + }) + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:22\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:22' + }) + + expect(getCrashBreadcrumbSnapshot('renderer:11')).toHaveLength(1) + expect(getCrashBreadcrumbSnapshot('renderer:22')).toHaveLength(1) + expect(getCrashBreadcrumbSnapshot('renderer:11')[0]?.origin).toBe('renderer:11') + expect(getCrashBreadcrumbSnapshot('renderer:22')[0]?.origin).toBe('renderer:22') + }) + + it('retains identical high-water profiles independently per renderer', () => { + const profile = { rendererSurface: 'main', thresholdPct: 80, usedHeapMB: 512 } + recordCrashBreadcrumb('renderer_memory_highwater', profile, 'renderer:11') + recordCrashBreadcrumb('renderer_memory_highwater', profile, 'renderer:22') + + expect(getCrashBreadcrumbSnapshot('renderer:11')[0]?.origin).toBe('renderer:11') + expect(getCrashBreadcrumbSnapshot('renderer:22')[0]?.origin).toBe('renderer:22') + }) + + it('preserves the reporter trail when sibling renderer traffic fills the ring', () => { + for (let index = 0; index < 4; index += 1) { + recordCrashBreadcrumb('renderer_memory_highwater', { + rendererSurface: `surface-${index}`, + thresholdPct: 80 + }) + } + recordCrashBreadcrumb('renderer_a_event', undefined, 'renderer:11') + for (let index = 0; index < 26; index += 1) { + recordCrashBreadcrumb(`renderer_b_event_${index}`, undefined, 'renderer:22') + } + + expect(getCrashBreadcrumbSnapshot('renderer:11').map((entry) => entry.name)).toContain( + 'renderer_a_event' + ) + }) + + it('folds pending repeats using the reporter-specific evidence window', () => { + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:11\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:11' + }) + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:22\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:22' + }) + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:22\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:22' + }) + recordCoalescedCrashBreadcrumb({ + name: 'renderer_error', + data: { message: 'boom' }, + coalesceKey: 'renderer:11\u0000renderer_error:boom', + minIntervalMs: 30_000, + origin: 'renderer:11' + }) + for (let index = 0; index < 4; index += 1) { + recordCrashBreadcrumb( + 'renderer_memory_highwater', + { rendererSurface: `surface-${index}`, thresholdPct: 80 }, + 'renderer:22' + ) + } + for (let index = 0; index < 25; index += 1) { + recordCrashBreadcrumb(`renderer_b_event_${index}`, undefined, 'renderer:22') + } + + const snapshot = getCrashBreadcrumbSnapshot('renderer:11') + + expect(snapshot).toHaveLength(1) + expect(snapshot[0]?.data).toEqual({ message: 'boom', suppressedSinceLast: 1 }) + + const siblingSnapshot = getCrashBreadcrumbSnapshot('renderer:22') + expect(siblingSnapshot.find((entry) => entry.name === 'renderer_error')?.data).toEqual({ + message: 'boom', + suppressedSinceLast: 1 + }) + }) +}) diff --git a/src/main/crash-reporting/crash-breadcrumb-store.ts b/src/main/crash-reporting/crash-breadcrumb-store.ts index e5f96e8f05e..0a9a71f452e 100644 --- a/src/main/crash-reporting/crash-breadcrumb-store.ts +++ b/src/main/crash-reporting/crash-breadcrumb-store.ts @@ -30,6 +30,7 @@ type CoalescedBreadcrumbState = { emitted?: CrashReportBreadcrumb /** Newest suppressed payload, sanitized only if a snapshot actually asks for it. */ pending?: CrashReportBreadcrumbData + origin?: string } let breadcrumbs: CrashReportBreadcrumb[] = [] @@ -42,19 +43,21 @@ function retainedBreadcrumbKey(breadcrumb: CrashReportBreadcrumb): string | null } const surface = breadcrumb.data?.rendererSurface const threshold = breadcrumb.data?.thresholdPct - return `${breadcrumb.name}:${String(surface)}:${String(threshold)}` + return `${breadcrumb.name}:${String(surface)}:${String(threshold)}:${breadcrumb.origin ?? 'global'}` } /** Returns the stored breadcrumb so coalescing can refresh the entry it owns. */ export function recordCrashBreadcrumb( name: string, - data?: CrashReportBreadcrumbData + data?: CrashReportBreadcrumbData, + origin?: string ): CrashReportBreadcrumb | undefined { const sanitized = sanitizeCrashReportBreadcrumbs([ { createdAt: new Date().toISOString(), name, - data + data, + ...(origin ? { origin } : {}) } ]) const breadcrumb = sanitized?.[0] @@ -85,12 +88,14 @@ export function recordCoalescedCrashBreadcrumb({ name, data, coalesceKey, - minIntervalMs + minIntervalMs, + origin }: { name: string data?: CrashReportBreadcrumbData coalesceKey: string minIntervalMs: number + origin?: string }): { suppressedSinceLast: number } | undefined { const now = monotonicNow() const previous = coalescedBreadcrumbs.get(coalesceKey) @@ -136,7 +141,8 @@ export function recordCoalescedCrashBreadcrumb({ windowStartedAtMs: now, suppressed: 0, carried: suppressedSinceLast, - resolved: 0 + resolved: 0, + ...(origin ? { origin } : {}) } coalescedBreadcrumbs.set(coalesceKey, state) while (coalescedBreadcrumbs.size > MAX_COALESCE_KEYS) { @@ -149,7 +155,8 @@ export function recordCoalescedCrashBreadcrumb({ } state.emitted = recordCrashBreadcrumb( name, - suppressedSinceLast > 0 ? { ...data, suppressedSinceLast } : data + suppressedSinceLast > 0 ? { ...data, suppressedSinceLast } : data, + origin ) return { suppressedSinceLast } } @@ -157,22 +164,29 @@ export function recordCoalescedCrashBreadcrumb({ /** Whether any future snapshot can still include this crumb. The ring only * appends and the retained map only grows toward its cap, so an entry pushed * past the snapshot budget is invisible forever, not just for now. */ -function isCoalescedCrumbStillInEvidence(crumb: CrashReportBreadcrumb): boolean { - const visibleFrom = breadcrumbs.length - (MAX_BREADCRUMBS - retainedBreadcrumbs.size) - const index = breadcrumbs.indexOf(crumb) - if (index !== -1 && index >= visibleFrom) { +function isCoalescedCrumbStillInEvidence( + crumb: CrashReportBreadcrumb, + reporterOrigin?: string +): boolean { + const retained = [...retainedBreadcrumbs.values()].filter((breadcrumb) => + isVisibleToReporter(breadcrumb, reporterOrigin) + ) + if (retained.some((retainedBreadcrumb) => retainedBreadcrumb === crumb)) { return true } - for (const retained of retainedBreadcrumbs.values()) { - if (retained === crumb) { - return true - } - } - return false + const visibleRecent = breadcrumbs.filter((breadcrumb) => + isVisibleToReporter(breadcrumb, reporterOrigin) + ) + return visibleRecent + .slice(-(MAX_BREADCRUMBS - retained.length)) + .some((recentBreadcrumb) => recentBreadcrumb === crumb) } /** Fold a key's newest suppressed payload into the ring entry it owns. */ -function resolvePendingCoalescedBreadcrumb(state: CoalescedBreadcrumbState): void { +function resolvePendingCoalescedBreadcrumb( + state: CoalescedBreadcrumbState, + reporterOrigin?: string +): void { // `data` is optional, so the count—not pending payload presence—marks unresolved work. if (!state.emitted || state.suppressed <= state.resolved) { return @@ -182,7 +196,7 @@ function resolvePendingCoalescedBreadcrumb(state: CoalescedBreadcrumbState): voi // then claim nothing — the burst vanishes from the record entirely. Drop the // handle (keeping `resolved` for folds that landed while it was live) so a // later emit or bounded cleanup can materialize the unclaimed repeats. - if (!isCoalescedCrumbStillInEvidence(state.emitted)) { + if (!isCoalescedCrumbStillInEvidence(state.emitted, reporterOrigin)) { state.emitted = undefined return } @@ -201,30 +215,46 @@ function resolvePendingCoalescedBreadcrumb(state: CoalescedBreadcrumbState): voi /** Resolve into the owned crumb, or emit the unclaimed repeats before bounded * cleanup drops an orphan's last accounting state. */ function preservePendingCoalescedBreadcrumb(state: CoalescedBreadcrumbState): void { - resolvePendingCoalescedBreadcrumb(state) + resolvePendingCoalescedBreadcrumb(state, state.origin) const unresolved = state.suppressed - state.resolved if (state.emitted || unresolved <= 0) { return } - recordCrashBreadcrumb(state.name, { - ...state.pending, - suppressedSinceLast: unresolved - }) + recordCrashBreadcrumb( + state.name, + { ...state.pending, suppressedSinceLast: unresolved }, + state.origin + ) state.resolved = state.suppressed state.pending = undefined } -function resolveAllPendingCoalescedBreadcrumbs(): void { +function resolveAllPendingCoalescedBreadcrumbs(reporterOrigin?: string): void { for (const state of coalescedBreadcrumbs.values()) { - resolvePendingCoalescedBreadcrumb(state) + if (reporterOrigin && state.origin && state.origin !== reporterOrigin) { + continue + } + resolvePendingCoalescedBreadcrumb(state, reporterOrigin) } } -export function getCrashBreadcrumbSnapshot(): CrashReportBreadcrumb[] { - resolveAllPendingCoalescedBreadcrumbs() +function isVisibleToReporter( + breadcrumb: CrashReportBreadcrumb, + reporterOrigin: string | undefined +): boolean { + return !reporterOrigin || !breadcrumb.origin || breadcrumb.origin === reporterOrigin +} + +export function getCrashBreadcrumbSnapshot(reporterOrigin?: string): CrashReportBreadcrumb[] { + resolveAllPendingCoalescedBreadcrumbs(reporterOrigin) // Why: long sessions must retain threshold profiles without growing the 30-entry budget. - const retained = [...retainedBreadcrumbs.values()] - const recent = breadcrumbs.slice(-(MAX_BREADCRUMBS - retained.length)) + const retained = [...retainedBreadcrumbs.values()].filter((breadcrumb) => + isVisibleToReporter(breadcrumb, reporterOrigin) + ) + const visibleRecent = breadcrumbs.filter((breadcrumb) => + isVisibleToReporter(breadcrumb, reporterOrigin) + ) + const recent = visibleRecent.slice(-(MAX_BREADCRUMBS - retained.length)) return [...retained, ...recent] .sort((left, right) => left.createdAt.localeCompare(right.createdAt)) .map((breadcrumb) => ({ diff --git a/src/main/crash-reporting/crash-report-store.test.ts b/src/main/crash-reporting/crash-report-store.test.ts index d594bd2090a..8a99a79df4a 100644 --- a/src/main/crash-reporting/crash-report-store.test.ts +++ b/src/main/crash-reporting/crash-report-store.test.ts @@ -41,7 +41,8 @@ function input(reason = 'crashed'): CrashReportCreateInput { { createdAt: '2026-05-16T01:00:00.000Z', name: 'workspace_opened', - data: { path: '/Users/alice/project', ssh: false } + data: { path: '/Users/alice/project', ssh: false }, + origin: 'renderer:42' } ] } diff --git a/src/main/crash-reporting/crash-report-store.ts b/src/main/crash-reporting/crash-report-store.ts index 64028b8e2fb..143210418ce 100644 --- a/src/main/crash-reporting/crash-report-store.ts +++ b/src/main/crash-reporting/crash-report-store.ts @@ -96,7 +96,9 @@ export class CrashReportStore { createdAt: new Date().toISOString(), status: 'pending', details: sanitizeCrashReportDetails(input.details), - breadcrumbs: sanitizeCrashReportBreadcrumbs(input.breadcrumbs) + breadcrumbs: sanitizeCrashReportBreadcrumbs(input.breadcrumbs)?.map( + ({ origin: _origin, ...breadcrumb }) => breadcrumb + ) } return { reports: [report, ...reports].slice(0, MAX_REPORTS), diff --git a/src/main/crash-reporting/durable-crash-breadcrumb.ts b/src/main/crash-reporting/durable-crash-breadcrumb.ts index e4271fb61d8..ff1c77bc9c6 100644 --- a/src/main/crash-reporting/durable-crash-breadcrumb.ts +++ b/src/main/crash-reporting/durable-crash-breadcrumb.ts @@ -41,11 +41,12 @@ function traceDurableBreadcrumb( export function recordDurableCrashBreadcrumb( name: string, data?: CrashReportBreadcrumbData, - failureCause?: string + failureCause?: string, + origin?: string ): void { const sanitizedName = sanitizeCrashReportString(name) const lifecycleData = buildLifecycleData(data) - recordCrashBreadcrumb(sanitizedName, lifecycleData) + recordCrashBreadcrumb(sanitizedName, lifecycleData, origin) traceDurableBreadcrumb(sanitizedName, lifecycleData, failureCause) } @@ -56,12 +57,14 @@ export function recordCoalescedDurableCrashBreadcrumb({ name, data, coalesceKey, - minIntervalMs + minIntervalMs, + origin }: { name: string data?: CrashReportBreadcrumbData coalesceKey: string minIntervalMs: number + origin?: string }): void { const sanitizedName = sanitizeCrashReportString(name) const lifecycleData = buildLifecycleData(data) @@ -69,7 +72,8 @@ export function recordCoalescedDurableCrashBreadcrumb({ name: sanitizedName, data: lifecycleData, coalesceKey, - minIntervalMs + minIntervalMs, + ...(origin ? { origin } : {}) }) if (!coalesced) { return diff --git a/src/main/crash-reporting/process-gone-dedupe.test.ts b/src/main/crash-reporting/process-gone-dedupe.test.ts index c4a2dc714fc..b3db4b4a0fd 100644 --- a/src/main/crash-reporting/process-gone-dedupe.test.ts +++ b/src/main/crash-reporting/process-gone-dedupe.test.ts @@ -77,6 +77,23 @@ describe('ProcessGoneDedupe', () => { ).toBe(true) }) + it('does not suppress simultaneous crashes from different renderers', () => { + const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 }) + + expect( + dedupe.shouldRecord(getProcessGoneDedupeKey('renderer', 'renderer', 'crashed', 5, 11), 1_000) + ).toBe(true) + expect( + dedupe.shouldRecord(getProcessGoneDedupeKey('renderer', 'renderer', 'crashed', 5, 22), 1_001) + ).toBe(true) + expect( + dedupe.shouldRecord( + getProcessGoneDedupeKey('renderer', 'renderer', 'oom', -536870904, 11), + 1_002 + ) + ).toBe(false) + }) + it('keeps child process crash tuples distinct inside renderer burst windows', () => { const dedupe = new ProcessGoneDedupe({ windowMs: 2_000 }) diff --git a/src/main/crash-reporting/process-gone-dedupe.ts b/src/main/crash-reporting/process-gone-dedupe.ts index b35cccc6576..861e6738e4e 100644 --- a/src/main/crash-reporting/process-gone-dedupe.ts +++ b/src/main/crash-reporting/process-gone-dedupe.ts @@ -80,12 +80,14 @@ export function getProcessGoneDedupeKey( source: 'renderer' | 'child', processType: string, reason: string, - exitCode: number | null + exitCode: number | null, + webContentsId?: number ): string { // Why: one renderer death can surface as crashed/oom/launch-failed in a - // burst. Coalesce that prompt noise while keeping child identities precise. + // burst. Coalesce that prompt noise per renderer while keeping child + // identities precise. if (source === 'renderer') { - return `${source}:${processType}` + return `${source}:${processType}:${webContentsId ?? 'global'}` } return `${source}:${processType}:${reason}:${exitCode ?? 'null'}` } diff --git a/src/main/crash-reporting/process-gone-recorder.test.ts b/src/main/crash-reporting/process-gone-recorder.test.ts index 24749b1bacf..9f564712e24 100644 --- a/src/main/crash-reporting/process-gone-recorder.test.ts +++ b/src/main/crash-reporting/process-gone-recorder.test.ts @@ -114,6 +114,22 @@ describe('recordProcessGoneCrash', () => { expect(sink.flushMock).toHaveBeenCalledOnce() }) + it('keeps suppressed renderer evidence scoped to its renderer', () => { + const dedupe = new ProcessGoneDedupe() + const suppressed = (webContentsId: number) => + event({ reason: 'killed', exitCode: 1, expectedTeardown: 'renderer-reload', webContentsId }) + + recordProcessGoneCrash({ record: vi.fn() } as never, suppressed(11), dedupe) + recordProcessGoneCrash({ record: vi.fn() } as never, suppressed(22), dedupe) + + expect(getCrashBreadcrumbSnapshot('renderer:11')).toEqual([ + expect.objectContaining({ origin: 'renderer:11' }) + ]) + expect(getCrashBreadcrumbSnapshot('renderer:22')).toEqual([ + expect.objectContaining({ origin: 'renderer:22' }) + ]) + }) + it('coalesces a recoverable-service crash loop instead of flushing every event', () => { const record = vi.fn() const dedupe = new ProcessGoneDedupe() @@ -314,6 +330,26 @@ describe('recordProcessGoneCrash', () => { expect(sink.flushMock).toHaveBeenCalledOnce() }) + it('keeps simultaneous renderer reports distinct by webContents identity', async () => { + const record = vi.fn().mockResolvedValue({ id: 'report-1' }) + const dedupe = new ProcessGoneDedupe() + + recordProcessGoneCrash( + { record, attachDetails } as never, + event({ webContentsId: 11 }), + dedupe, + noMinidump + ) + recordProcessGoneCrash( + { record, attachDetails } as never, + event({ webContentsId: 22 }), + dedupe, + noMinidump + ) + + await vi.waitFor(() => expect(record).toHaveBeenCalledTimes(2)) + }) + it('still persists the report when the forced trace flush fails', async () => { const record = vi.fn().mockResolvedValue({ id: 'report-1' }) sink.flushMock.mockImplementation(() => { diff --git a/src/main/crash-reporting/process-gone-recorder.ts b/src/main/crash-reporting/process-gone-recorder.ts index 947e8165bb3..ec9d53bdfff 100644 --- a/src/main/crash-reporting/process-gone-recorder.ts +++ b/src/main/crash-reporting/process-gone-recorder.ts @@ -7,6 +7,7 @@ import { type CrashReportBreadcrumbData } from '../../shared/crash-reporting' import { decodePosixWaitStatus, describePosixWaitStatus } from '../../shared/posix-wait-status' +import { rendererCrashBreadcrumbOrigin } from '../../shared/crash-breadcrumb-origin' import type { CrashReportStore } from './crash-report-store' import { getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store' import { @@ -41,6 +42,7 @@ export type ProcessGoneCrashEvent = { exitCode: number | null expectedTeardown: ExpectedTeardownScope details: Record + webContentsId?: number } type CrashReportRecorderStore = Pick @@ -74,6 +76,12 @@ function processGoneBreadcrumbData(event: ProcessGoneCrashEvent) { return buildSuppressedProcessGoneBreadcrumbData(event) } +function processGoneRendererOrigin(event: ProcessGoneCrashEvent): string | undefined { + return event.webContentsId === undefined + ? undefined + : rendererCrashBreadcrumbOrigin(event.webContentsId) +} + // Why: key off the emitted breadcrumb, not the crash-report dedupe key, so two // different recoverable services can never suppress each other's evidence. function suppressedProcessGoneCoalesceKey(data: CrashReportBreadcrumbData): string { @@ -179,11 +187,15 @@ export function recordProcessGoneCrash( // 1459/min) and each suppressed event costs a span plus a forced disk flush, // which both floods the 30-entry ring and evicts the real pre-crash trail. const suppressedData = processGoneBreadcrumbData(event) + const origin = processGoneRendererOrigin(event) recordCoalescedDurableCrashBreadcrumb({ name: 'process_gone_suppressed', data: suppressedData, - coalesceKey: suppressedProcessGoneCoalesceKey(suppressedData), - minIntervalMs: SUPPRESSED_PROCESS_GONE_COALESCE_MS + coalesceKey: origin + ? `${origin}\u0000${suppressedProcessGoneCoalesceKey(suppressedData)}` + : suppressedProcessGoneCoalesceKey(suppressedData), + minIntervalMs: SUPPRESSED_PROCESS_GONE_COALESCE_MS, + ...(origin ? { origin } : {}) }) return } @@ -191,12 +203,19 @@ export function recordProcessGoneCrash( recordDurableCrashBreadcrumb( 'crash_report_store_unavailable', processGoneBreadcrumbData(event), - 'Crash report store unavailable' + 'Crash report store unavailable', + processGoneRendererOrigin(event) ) return } - const key = getProcessGoneDedupeKey(event.source, event.processType, event.reason, event.exitCode) + const key = getProcessGoneDedupeKey( + event.source, + event.processType, + event.reason, + event.exitCode, + event.webContentsId + ) const claim = dedupe.tryClaim(key) if (!claim) { return @@ -209,7 +228,8 @@ export function recordProcessGoneCrash( }, event.processType ) - const breadcrumbs = getCrashBreadcrumbSnapshot() + const breadcrumbs = getCrashBreadcrumbSnapshot(processGoneRendererOrigin(event)) + const reportBreadcrumbs = breadcrumbs?.map(({ origin: _origin, ...breadcrumb }) => breadcrumb) const span = startSpan('electron.process_gone', { attributes: { 'crash.source': event.source, @@ -227,7 +247,7 @@ export function recordProcessGoneCrash( 'app.main_process.launch_id': mainProcessLifecycle.mainProcessLaunchId, 'app.main_process.started_at': mainProcessLifecycle.mainProcessStartedAt, details: crashDetails, - breadcrumbs + breadcrumbs: reportBreadcrumbs } }) // Why: a renderer crash can be followed by another process exit before the @@ -252,7 +272,7 @@ export function recordProcessGoneCrash( electronVersion: process.versions.electron ?? 'unknown', chromeVersion: process.versions.chrome ?? 'unknown', details: crashDetails, - breadcrumbs + breadcrumbs: reportBreadcrumbs }) .then((report) => { // Why: kept off the returned chain so a minidump failure can never reach @@ -268,7 +288,8 @@ export function recordProcessGoneCrash( recordDurableCrashBreadcrumb( 'minidump_signature_attach_failed', processGoneBreadcrumbData(event), - error instanceof Error ? error.message : String(error) + error instanceof Error ? error.message : String(error), + processGoneRendererOrigin(event) ) }) }) @@ -279,7 +300,8 @@ export function recordProcessGoneCrash( recordDurableCrashBreadcrumb( 'crash_report_persist_failed', data, - `${String(data.errorName)}: ${String(data.errorMessage)}` + `${String(data.errorName)}: ${String(data.errorMessage)}`, + processGoneRendererOrigin(event) ) }) } diff --git a/src/main/daemon/daemon-client-connections.ts b/src/main/daemon/daemon-client-connections.ts new file mode 100644 index 00000000000..52f8b0db8cd --- /dev/null +++ b/src/main/daemon/daemon-client-connections.ts @@ -0,0 +1,249 @@ +import type { Socket } from 'node:net' +import { StringDecoder } from 'node:string_decoder' +import type { DaemonFileLog } from './daemon-file-log' +import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import { createNdjsonParser, encodeNdjson } from './ndjson' +import type { DaemonRequest, HelloMessage } from './types' + +export type ConnectedDaemonClient = { + clientId: string + controlSocket: Socket + streamSocket: Socket | null + authenticatedPairEstablished: boolean +} + +type DaemonClientConnectionOptions = { + token: string + protocolVersion: number + identity: { + launchNonce: string | null + startedAtMs: number | null + entryPath: string | null + appVersion: string | null + spawnerExecPath: string | null + } + log: DaemonFileLog + streamDataBatcher: DaemonStreamDataBatcher + isAcceptingWork: () => boolean + onTransportChanged: () => void + onConnectionAccepted: () => void + onAuthenticatedPair: () => void + onLastAuthenticatedClientDisconnected: () => void + onControlRequest: (socket: Socket, clientId: string, request: DaemonRequest) => void + onControlReplaced: (clientId: string) => void + onClientDisconnected: (clientId: string) => void + onStreamDisconnected: (clientId: string) => void +} + +export class DaemonClientConnections { + private readonly clients = new Map() + private readonly transportSockets = new Set() + + constructor(private readonly options: DaemonClientConnectionOptions) {} + + accept(socket: Socket): void { + this.options.onConnectionAccepted() + this.transportSockets.add(socket) + socket.once('close', () => { + this.transportSockets.delete(socket) + this.options.onTransportChanged() + }) + socket.on('error', () => socket.destroy()) + + if (!this.options.isAcceptingWork()) { + socket.end( + encodeNdjson({ + type: 'hello', + ok: false, + error: 'Daemon temporarily unavailable; reconnect', + retryable: true + }) + ) + return + } + const decoder = new StringDecoder('utf8') + const parser = createNdjsonParser( + (message) => this.handleFirstMessage(socket, message), + () => socket.destroy() + ) + socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) + } + + get(clientId: string): ConnectedDaemonClient | undefined { + return this.clients.get(clientId) + } + + values(): IterableIterator { + return this.clients.values() + } + + get size(): number { + return this.clients.size + } + + get transportCount(): number { + return this.transportSockets.size + } + + hasOnlyTransportsFor(client: ConnectedDaemonClient): boolean { + return [...this.transportSockets].every( + (transport) => transport === client.controlSocket || transport === client.streamSocket + ) + } + + dispose(): void { + for (const client of this.clients.values()) { + client.controlSocket.destroy() + client.streamSocket?.destroy() + } + this.clients.clear() + for (const socket of this.transportSockets) { + socket.destroy() + } + this.transportSockets.clear() + } + + private handleFirstMessage(socket: Socket, message: unknown): void { + const hello = message as HelloMessage + if (hello.type !== 'hello') { + this.options.log.log('client-hello-rejected', { reason: 'expected-hello' }) + socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Expected hello' })) + socket.destroy() + return + } + if (hello.version !== this.options.protocolVersion) { + this.options.log.log('client-hello-rejected', { + reason: 'protocol-mismatch', + clientVersion: hello.version + }) + socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Protocol version mismatch' })) + socket.destroy() + return + } + if (hello.token !== this.options.token) { + this.options.log.log('client-hello-rejected', { reason: 'invalid-token', role: hello.role }) + socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' })) + socket.destroy() + return + } + if (hello.role !== 'control' && hello.role !== 'stream') { + this.options.log.log('client-hello-rejected', { + reason: 'invalid-role', + role: hello.role + }) + socket.end(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid role' })) + return + } + + this.options.log.log('client-hello-accepted', { role: hello.role, clientId: hello.clientId }) + const identity = this.options.identity + socket.write( + encodeNdjson({ + type: 'hello', + ok: true, + ...(identity.launchNonce && identity.startedAtMs + ? { + daemonIdentity: { + pid: process.pid, + startedAtMs: identity.startedAtMs, + launchNonce: identity.launchNonce, + ...(identity.entryPath ? { entryPath: identity.entryPath } : {}), + ...(identity.appVersion ? { appVersion: identity.appVersion } : {}), + ...(identity.spawnerExecPath ? { spawnerExecPath: identity.spawnerExecPath } : {}) + } + } + : {}) + }) + ) + + if (hello.role === 'control') { + this.installControlSocket(socket, hello.clientId) + return + } + if (hello.role === 'stream') { + const client = this.clients.get(hello.clientId) + if (!client) { + socket.destroy() + return + } + this.installStreamSocket(socket, client) + client.authenticatedPairEstablished = true + this.options.onAuthenticatedPair() + return + } + // Parsed wire data is not made safe by the HelloMessage assertion above. + socket.destroy() + } + + private installControlSocket(socket: Socket, clientId: string): void { + const previous = this.clients.get(clientId) + const client: ConnectedDaemonClient = { + clientId, + controlSocket: socket, + streamSocket: null, + authenticatedPairEstablished: false + } + this.clients.set(clientId, client) + this.setupControlParser(socket, clientId) + if (!previous) { + return + } + this.options.onControlReplaced(clientId) + this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished) + previous.streamSocket?.destroy() + previous.controlSocket.destroy() + } + + private setupControlParser(socket: Socket, clientId: string): void { + const decoder = new StringDecoder('utf8') + const parser = createNdjsonParser( + (message) => this.options.onControlRequest(socket, clientId, message as DaemonRequest), + () => {} + ) + socket.removeAllListeners('data') + socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) + socket.on('close', () => { + const client = this.clients.get(clientId) + if (client?.controlSocket !== socket) { + return + } + this.options.onClientDisconnected(clientId) + const wasFullyAuthenticated = client.authenticatedPairEstablished + client.streamSocket?.destroy() + this.clients.delete(clientId) + this.recordFullyAuthenticatedDisconnect(wasFullyAuthenticated) + this.options.onTransportChanged() + }) + } + + private recordFullyAuthenticatedDisconnect(wasFullyAuthenticated: boolean): void { + if ( + wasFullyAuthenticated && + ![...this.clients.values()].some((client) => client.authenticatedPairEstablished) && + this.options.isAcceptingWork() + ) { + this.options.onLastAuthenticatedClientDisconnected() + } + } + + private installStreamSocket(socket: Socket, client: ConnectedDaemonClient): void { + const previous = client.streamSocket + socket.removeAllListeners('data') + client.streamSocket = socket + socket.on('drain', () => this.options.streamDataBatcher.flush(client.clientId)) + const cleanup = (): void => { + socket.removeListener('close', cleanup) + socket.removeListener('error', cleanup) + if (this.clients.get(client.clientId) !== client || client.streamSocket !== socket) { + return + } + this.options.onStreamDisconnected(client.clientId) + client.streamSocket = null + } + socket.on('close', cleanup) + socket.on('error', cleanup) + if (previous && previous !== socket) { + previous.destroy() + } + } +} diff --git a/src/main/daemon/daemon-client-rpc-request.test.ts b/src/main/daemon/daemon-client-rpc-request.test.ts index 87935bb589e..b495ee07425 100644 --- a/src/main/daemon/daemon-client-rpc-request.test.ts +++ b/src/main/daemon/daemon-client-rpc-request.test.ts @@ -2,7 +2,7 @@ import type { Socket } from 'node:net' import { afterEach, describe, expect, it, vi } from 'vitest' import { DaemonPendingRequests } from './daemon-client-pending-requests' import { requestDaemonRpc } from './daemon-client-rpc-request' -import { isDaemonGoneError } from './daemon-pty-adapter' +import { isDaemonGoneError } from './daemon-endpoint-errors' import { DAEMON_UNAVAILABLE_RECONNECT_MESSAGE, DaemonConnectionLostError, diff --git a/src/main/daemon/daemon-endpoint-errors.ts b/src/main/daemon/daemon-endpoint-errors.ts new file mode 100644 index 00000000000..663b11ff3fa --- /dev/null +++ b/src/main/daemon/daemon-endpoint-errors.ts @@ -0,0 +1,40 @@ +import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership' +import { DAEMON_UNAVAILABLE_RECONNECT_MESSAGE } from './types' + +/** + * Narrow on purpose: only the daemon's own reply for a request type it does not implement. + * A transient failure must stay unproven rather than be mistaken for a missing capability. + * The server throws `Unknown request type: `; the client rejects with that text, which + * `addNodePtyRecoveryHint` only ever prepends to. + */ +export function isUnknownRequestTypeError(err: unknown): boolean { + return err instanceof Error && err.message.includes('Unknown request type') +} + +// Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall); +// message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn. +export function isDaemonGoneError(err: unknown): boolean { + if (!(err instanceof Error)) { + return false + } + const errno = err as NodeJS.ErrnoException + if ((errno.code === 'ENOENT' || errno.code === 'ECONNREFUSED') && errno.syscall === 'connect') { + return true + } + const msg = err.message + return ( + msg === 'Connection lost' || + msg === 'Not connected' || + msg === 'Hello response timed out' || + msg === DAEMON_UNAVAILABLE_RECONNECT_MESSAGE || + msg === DAEMON_ENDPOINT_LOST_MESSAGE + ) +} + +export function isMissingWindowsNamedPipeError(err: unknown): boolean { + if (process.platform !== 'win32' || !(err instanceof Error)) { + return false + } + const errno = err as NodeJS.ErrnoException + return errno.code === 'ENOENT' && errno.syscall === 'connect' +} diff --git a/src/main/daemon/daemon-endpoint-incarnation.ts b/src/main/daemon/daemon-endpoint-incarnation.ts new file mode 100644 index 00000000000..4a7dc6ea4b4 --- /dev/null +++ b/src/main/daemon/daemon-endpoint-incarnation.ts @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs' +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import type { ExactDaemonIncarnation } from './daemon-incarnation-evidence' +import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' + +export function sameEndpointIdentity( + left: DaemonEndpointIdentity, + right: DaemonEndpointIdentity +): boolean { + return ( + left.pid === right.pid && + left.startedAtMs === right.startedAtMs && + left.launchNonce === right.launchNonce + ) +} + +export function exactDaemonIncarnationForPidRecord( + identity: DaemonEndpointIdentity, + pidRecord: ParsedDaemonPid | null +): ExactDaemonIncarnation { + return { + identity: { ...identity }, + ...(process.platform === 'linux' && + pidRecord?.pid === identity.pid && + pidRecord.startedAtMs === identity.startedAtMs && + pidRecord.launchNonce === identity.launchNonce && + pidRecord.linuxStartTicks && + pidRecord.bootId + ? { + linuxStartTicks: pidRecord.linuxStartTicks, + bootId: pidRecord.bootId + } + : {}) + } +} + +export function readDaemonPidRecord(pidPath: string | null): ParsedDaemonPid | null { + if (!pidPath) { + return null + } + try { + return parseDaemonPidFile(readFileSync(pidPath, 'utf8')) + } catch { + return null + } +} diff --git a/src/main/daemon/daemon-endpoint-lifecycle.ts b/src/main/daemon/daemon-endpoint-lifecycle.ts new file mode 100644 index 00000000000..035d56af574 --- /dev/null +++ b/src/main/daemon/daemon-endpoint-lifecycle.ts @@ -0,0 +1,173 @@ +import { chmodSync, unlinkSync, writeFileSync } from 'node:fs' +import type { DaemonFileLog } from './daemon-file-log' +import { + DaemonEndpointUnavailableError, + getDaemonSocketBindPath, + publishDaemonEndpoint, + readDaemonEndpointOwnershipState, + type DaemonEndpointOwnershipState, + type DaemonSocketIdentity +} from './daemon-endpoint-ownership' +import { probeSocketConnect } from './daemon-endpoint-probe' +import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner' + +type DaemonEndpointLifecycleOptions = { + socketPath: string + tokenPath: string + pidPath: string | null + launchNonce: string | null + token: string + publishEndpointOwnership: () => void + log: DaemonFileLog + isServing: () => boolean + onOwnershipLost: () => void +} + +export class DaemonEndpointLifecycle { + private static readonly OWNERSHIP_POLL_MS = 30 * 1000 + private static readonly LOSS_CONFIRMATIONS = 2 + private ownedSocketIdentity: DaemonSocketIdentity | null = null + private ownershipTimer: ReturnType | null = null + private ownershipLossStreak = 0 + private ownershipLost = false + + constructor(private readonly options: DaemonEndpointLifecycleOptions) {} + + bindPath(): string { + return process.platform === 'win32' + ? this.options.socketPath + : getDaemonSocketBindPath(this.options.socketPath) + } + + secureBindPath(bindPath: string): void { + try { + chmodSync(bindPath, 0o600) + } catch { + // Best-effort on platforms that support it. + } + } + + abandonBindPath(bindPath: string): void { + if (process.platform === 'win32') { + return + } + try { + unlinkSync(bindPath) + } catch { + // Already consumed by publication, or never created. + } + } + + async publish(bindPath: string): Promise { + const outcome = await publishDaemonEndpoint( + bindPath, + this.options.socketPath, + probeSocketConnect + ) + if (outcome.status !== 'published') { + this.options.log.log('endpoint-publish-declined', { reason: outcome.status }) + console.warn(`[daemon] Endpoint unavailable at startup: reason=${outcome.status}`) + throw new DaemonEndpointUnavailableError(outcome.status) + } + this.ownedSocketIdentity = outcome.identity + let publishedOwnership = false + try { + this.options.publishEndpointOwnership() + publishedOwnership = true + writeFileSync(this.options.tokenPath, this.options.token, { mode: 0o600 }) + } catch (error) { + if (publishedOwnership && this.options.pidPath && this.options.launchNonce) { + unlinkOwnedDaemonPidFile(this.options.pidPath, process.pid, this.options.launchNonce) + } + this.ownedSocketIdentity = null + throw error + } + } + + retireUnstarted(): void { + this.stopOwnershipWatch() + this.unlinkOwnedArtifacts() + } + + unlinkOwnedArtifacts(): void { + unlinkOwnedDaemonTokenFile(this.options.tokenPath, this.options.token) + if (this.options.pidPath && this.options.launchNonce) { + unlinkOwnedDaemonPidFile(this.options.pidPath, process.pid, this.options.launchNonce) + } + // The canonical endpoint stays: removing it could delete a replacement's name. + this.ownedSocketIdentity = null + } + + stopOwnershipWatch(): void { + if (this.ownershipTimer === null) { + return + } + clearInterval(this.ownershipTimer) + this.ownershipTimer = null + } + + hasLostOwnership(): boolean { + if (this.ownershipLost) { + return true + } + return this.observeOwnership() === 'lost' + } + + get lost(): boolean { + return this.ownershipLost + } + + requestRetirementForLoss(): void { + const alreadyLost = this.ownershipLost + this.ownershipLost = true + this.ownedSocketIdentity = null + if (!alreadyLost) { + this.options.log.log('endpoint-ownership-lost', { + socketPath: this.options.socketPath + }) + console.warn( + '[daemon] Endpoint ownership lost to another daemon — retiring once existing sessions end' + ) + } + this.stopOwnershipWatch() + this.options.onOwnershipLost() + } + + startOwnershipWatch(): void { + if (process.platform === 'win32' || !this.ownedSocketIdentity) { + return + } + this.ownershipTimer = setInterval( + () => this.checkOwnership(), + DaemonEndpointLifecycle.OWNERSHIP_POLL_MS + ) + this.ownershipTimer.unref() + } + + private checkOwnership(): void { + if (process.platform === 'win32' || !this.ownedSocketIdentity) { + return + } + if (this.observeOwnership() !== 'lost') { + return + } + this.ownershipLossStreak++ + if (this.ownershipLossStreak >= DaemonEndpointLifecycle.LOSS_CONFIRMATIONS) { + this.requestRetirementForLoss() + } + } + + private observeOwnership(): DaemonEndpointOwnershipState { + if (process.platform === 'win32' || !this.ownedSocketIdentity || !this.options.isServing()) { + return 'indeterminate' + } + const state = readDaemonEndpointOwnershipState( + this.options.socketPath, + this.ownedSocketIdentity + ) + if (state !== 'lost') { + this.ownershipLossStreak = 0 + } + return state + } +} diff --git a/src/main/daemon/daemon-endpoint-ownership.test.ts b/src/main/daemon/daemon-endpoint-ownership.test.ts index adb8eca4ae9..cf6af2e0956 100644 --- a/src/main/daemon/daemon-endpoint-ownership.test.ts +++ b/src/main/daemon/daemon-endpoint-ownership.test.ts @@ -205,13 +205,13 @@ describe('daemon endpoint ownership publication', () => { await server.start() const daemon = server as unknown as { - checkEndpointOwnership: () => void - retirementRequested: boolean + endpoint: { checkOwnership: () => void } + lifecycle: { retirementRequested: boolean } } // An inconclusive or matching probe must never retire a healthy daemon, however often it runs. - daemon.checkEndpointOwnership() - daemon.checkEndpointOwnership() - expect(daemon.retirementRequested).toBe(false) + daemon.endpoint.checkOwnership() + daemon.endpoint.checkOwnership() + expect(daemon.lifecycle.retirementRequested).toBe(false) // Another daemon takes the endpoint name. unlinkSync(socketPath) @@ -224,13 +224,13 @@ describe('daemon endpoint ownership publication', () => { try { // A single observation can land inside a replacement's unlink-then-link gap, so the // first one must not retire anything. - daemon.checkEndpointOwnership() - expect(daemon.retirementRequested).toBe(false) + daemon.endpoint.checkOwnership() + expect(daemon.lifecycle.retirementRequested).toBe(false) - daemon.checkEndpointOwnership() + daemon.endpoint.checkOwnership() // Why: retirement drains rather than kills — an orphaned daemon stops being a // permanent unreachable host without tearing live sessions out from under the user. - expect(daemon.retirementRequested).toBe(true) + expect(daemon.lifecycle.retirementRequested).toBe(true) } finally { await new Promise((resolve) => usurper.close(() => resolve())) try { diff --git a/src/main/daemon/daemon-history-recovery-freeze.ts b/src/main/daemon/daemon-history-recovery-freeze.ts new file mode 100644 index 00000000000..c9c3f66b2b3 --- /dev/null +++ b/src/main/daemon/daemon-history-recovery-freeze.ts @@ -0,0 +1,13 @@ +import type { HistoryRecoveryContext } from './daemon-pty-runtime-state' +import type { HistoryRecoveryFreeze } from './history-manager' + +// Taking and clearing together prevents any recovery branch from retaining a consumed freeze. +export function takeHistoryRecoveryFreeze( + historyRecovery: HistoryRecoveryContext, + sessionId: string +): HistoryRecoveryFreeze | undefined { + const freeze = + historyRecovery.freeze?.sessionId === sessionId ? historyRecovery.freeze : undefined + historyRecovery.freeze = null + return freeze +} diff --git a/src/main/daemon/daemon-idle-shutdown.test.ts b/src/main/daemon/daemon-idle-shutdown.test.ts index 7dce6f7f763..05f4b3aa39f 100644 --- a/src/main/daemon/daemon-idle-shutdown.test.ts +++ b/src/main/daemon/daemon-idle-shutdown.test.ts @@ -58,6 +58,22 @@ class ManualIdleClock { return this.timers.size } } +type DaemonServerInternals = { + connections: { + clients: Map + accept(socket: Socket): void + } + admission: { createOrAttachInFlight: number } + lifecycle: { + retirementRequested: boolean + state: string + beginIdleShutdown(): void + } + requestRouter: { + route(clientId: string, request: unknown): Promise + } + host: { dispose: () => Promise } +} function createMockSubprocess(): SubprocessHandle & { exit(code: number): void } { let onExit: ((code: number) => void) | null = null @@ -222,11 +238,8 @@ describe('current daemon lifecycle retirement', () => { await startServer() const client = new DaemonClient({ socketPath, tokenPath }) await client.ensureConnected() - const daemon = server as unknown as { - clients: Map - host: { dispose: () => Promise } - } - const controlSocket = [...daemon.clients.values()][0].controlSocket + const daemon = server as unknown as DaemonServerInternals + const controlSocket = [...daemon.connections.clients.values()][0].controlSocket const originalWrite = controlSocket.write.bind(controlSocket) let replyFlushed: (() => void) | undefined vi.spyOn(controlSocket, 'write').mockImplementation((( @@ -252,11 +265,8 @@ describe('current daemon lifecycle retirement', () => { await startServer() const client = new DaemonClient({ socketPath, tokenPath }) await client.ensureConnected() - const daemon = server as unknown as { - clients: Map - host: { dispose: () => Promise } - } - const controlSocket = [...daemon.clients.values()][0].controlSocket + const daemon = server as unknown as DaemonServerInternals + const controlSocket = [...daemon.connections.clients.values()][0].controlSocket const originalWrite = controlSocket.write.bind(controlSocket) vi.spyOn(controlSocket, 'write').mockImplementation(((chunk: string | Uint8Array) => originalWrite(chunk)) as unknown as Socket['write']) @@ -301,8 +311,8 @@ describe('current daemon lifecycle retirement', () => { await startServer() const client = new DaemonClient({ socketPath, tokenPath }) await client.ensureConnected() - const daemon = server as unknown as { createOrAttachInFlight: number } - daemon.createOrAttachInFlight = 1 + const daemon = server as unknown as DaemonServerInternals + daemon.admission.createOrAttachInFlight = 1 await expect(client.request('shutdownIfIdle', undefined)).resolves.toEqual({ retiring: false @@ -395,13 +405,13 @@ describe('current daemon lifecycle retirement', () => { await first.ensureConnected() await first.request('createOrAttach', { sessionId: 'reconnected', cols: 80, rows: 24 }) first.disconnect() - const daemon = server as unknown as { retirementRequested: boolean } - await waitFor(() => daemon.retirementRequested) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.lifecycle.retirementRequested) expect(onIdleShutdown).not.toHaveBeenCalled() const second = new DaemonClient({ socketPath, tokenPath }) await second.ensureConnected() - await waitFor(() => !daemon.retirementRequested) + await waitFor(() => !daemon.lifecycle.retirementRequested) subprocess.exit(0) expect(onIdleShutdown).not.toHaveBeenCalled() @@ -444,8 +454,8 @@ describe('current daemon lifecycle retirement', () => { role: 'control' })}\n` ) - const daemon = server as unknown as { clients: Map } - await waitFor(() => daemon.clients.has('startup-health-control')) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.connections.clients.has('startup-health-control')) clock.advanceBy(1_000) healthControl.destroy() @@ -465,18 +475,15 @@ describe('current daemon lifecycle retirement', () => { role: 'control' })}\n` ) - const daemon = server as unknown as { - clients: Map - retirementRequested: boolean - } - await waitFor(() => daemon.clients.has('startup-control-create')) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.connections.clients.has('startup-control-create')) const response = await requestOnRawSocket(control, { id: 'control-only-create', type: 'createOrAttach', payload: { sessionId: 'must-not-start', cols: 80, rows: 24 } }) expect(response.error).toContain('connection is incomplete') - expect(daemon.retirementRequested).toBe(false) + expect(daemon.lifecycle.retirementRequested).toBe(false) expect(clock.pendingCount).toBe(0) clock.advanceBy(1_000) @@ -501,14 +508,11 @@ describe('current daemon lifecycle retirement', () => { role: 'control' })}\n` ) - const daemon = server as unknown as { - clients: Map - retirementRequested: boolean - } - await waitFor(() => daemon.clients.has('control-only-overlap')) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.connections.clients.has('control-only-overlap')) paired.disconnect() - await waitFor(() => daemon.retirementRequested) + await waitFor(() => daemon.lifecycle.retirementRequested) expect(clock.pendingCount).toBe(0) const response = await requestOnRawSocket(incomplete, { id: 'overlap-control-create', @@ -516,7 +520,7 @@ describe('current daemon lifecycle retirement', () => { payload: { sessionId: 'must-not-start', cols: 80, rows: 24 } }) expect(response.error).toContain('connection is incomplete') - expect(daemon.retirementRequested).toBe(true) + expect(daemon.lifecycle.retirementRequested).toBe(true) incomplete.destroy() await waitFor(() => onIdleShutdown.mock.calls.length === 1) @@ -538,10 +542,8 @@ describe('current daemon lifecycle retirement', () => { role: 'control' })}\n` ) - const daemon = server as unknown as { - retirementRequested: boolean - } - await waitFor(() => daemon.retirementRequested) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.lifecycle.retirementRequested) expect(clock.pendingCount).toBe(0) replacementControl.destroy() @@ -554,8 +556,8 @@ describe('current daemon lifecycle retirement', () => { await client.ensureConnected() await client.request('createOrAttach', { sessionId: 'live', cols: 80, rows: 24 }) client.disconnect() - const daemon = server as unknown as { retirementRequested: boolean } - await waitFor(() => daemon.retirementRequested) + const daemon = server as unknown as DaemonServerInternals + await waitFor(() => daemon.lifecycle.retirementRequested) expect(onIdleShutdown).not.toHaveBeenCalled() expect(clock.pendingCount).toBe(0) @@ -612,14 +614,11 @@ describe('current daemon lifecycle retirement', () => { it('rejects create or attach once the idle admission fence is pending', async () => { await startServer() - const daemon = server as unknown as { - idleShutdownState: string - routeRequest(clientId: string, request: unknown): Promise - } - daemon.idleShutdownState = 'idle-shutdown-pending' + const daemon = server as unknown as DaemonServerInternals + daemon.lifecycle.state = 'idle-shutdown-pending' await expect( - daemon.routeRequest('late-client', { + daemon.requestRouter.route('late-client', { id: 'late-create', type: 'createOrAttach', payload: { sessionId: 'late', cols: 80, rows: 24 } @@ -629,30 +628,23 @@ describe('current daemon lifecycle retirement', () => { it('aborts the pending shutdown when create or attach started before the fence', async () => { await startServer() - const daemon = server as unknown as { - createOrAttachInFlight: number - idleShutdownState: string - beginIdleShutdown(): void - } - daemon.createOrAttachInFlight = 1 + const daemon = server as unknown as DaemonServerInternals + daemon.admission.createOrAttachInFlight = 1 - daemon.beginIdleShutdown() + daemon.lifecycle.beginIdleShutdown() - expect(daemon.idleShutdownState).toBe('running') + expect(daemon.lifecycle.state).toBe('running') expect(onIdleShutdown).not.toHaveBeenCalled() }) it('explicitly marks a post-fence accepted transport as retryable', async () => { await startServer() - const daemon = server as unknown as { - idleShutdownState: string - handleConnection(socket: Socket): void - } - daemon.idleShutdownState = 'idle-shutdown-pending' + const daemon = server as unknown as DaemonServerInternals + daemon.lifecycle.state = 'idle-shutdown-pending' const socket = new EventEmitter() as Socket socket.end = vi.fn() as unknown as Socket['end'] - daemon.handleConnection(socket) + daemon.connections.accept(socket) const payload = vi.mocked(socket.end).mock.calls[0]?.[0] expect(JSON.parse(String(payload))).toMatchObject({ ok: false, retryable: true }) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index e79d4b9dfc8..d6c4240b19b 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -18,7 +18,8 @@ import { type DaemonProcessHandle } from './daemon-spawner' import { DAEMON_EXIT_ENDPOINT_OCCUPIED } from './daemon-endpoint-ownership' -import { DaemonPtyAdapter, type DaemonRespawnReason } from './daemon-pty-adapter' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import type { DaemonRespawnReason } from './daemon-pty-runtime-state' import { DaemonPtyRouter } from './daemon-pty-router' import { DaemonClient } from './client' import { diff --git a/src/main/daemon/daemon-listener-registry.ts b/src/main/daemon/daemon-listener-registry.ts new file mode 100644 index 00000000000..2fef9e7eef6 --- /dev/null +++ b/src/main/daemon/daemon-listener-registry.ts @@ -0,0 +1,19 @@ +export function removeDaemonListener(listeners: T[], listener: T): void { + const index = listeners.indexOf(listener) + if (index !== -1) { + listeners.splice(index, 1) + } +} + +export function notifyDaemonAuditListeners( + listeners: readonly ((value: T) => void)[], + value: T +): void { + for (const listener of listeners.slice()) { + try { + listener(value) + } catch { + // Audit observers cannot affect daemon operations. + } + } +} diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index 63e763474bc..b2683b0af99 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -1,4 +1,5 @@ -import { DaemonServer, type DaemonServerOptions } from './daemon-server' +import { DaemonServer } from './daemon-server' +import type { DaemonServerOptions } from './daemon-server-options' import type { DaemonFileLog } from './daemon-file-log' export type DaemonStartOptions = { diff --git a/src/main/daemon/daemon-preflight-client-replacement.test.ts b/src/main/daemon/daemon-preflight-client-replacement.test.ts index e6204708c25..7fbb46ebfd5 100644 --- a/src/main/daemon/daemon-preflight-client-replacement.test.ts +++ b/src/main/daemon/daemon-preflight-client-replacement.test.ts @@ -10,8 +10,8 @@ import { PROTOCOL_VERSION } from './types' import type { SubprocessHandle } from './session-subprocess-handle' type DaemonServerPrivate = { - pendingPtySpawnPreparations: Map> - clients: Map + preparations: { pending: Map> } + connections: { clients: Map } } function createMockSubprocess(): SubprocessHandle { @@ -96,7 +96,7 @@ describe('daemon preflight client replacement', () => { await replacement.ensureConnected() finishPreparation() await vi.waitFor(() => - expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0) + expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0) ) expect(spawnSubprocess).not.toHaveBeenCalled() @@ -139,12 +139,13 @@ describe('daemon preflight client replacement', () => { disconnected.stream.destroy() await vi.waitFor(() => expect( - (server as unknown as DaemonServerPrivate).clients.get(disconnectedClientId)?.streamSocket + (server as unknown as DaemonServerPrivate).connections.clients.get(disconnectedClientId) + ?.streamSocket ).toBeNull() ) finishPreparation() await vi.waitFor(() => - expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0) + expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0) ) expect(spawnSubprocess).toHaveBeenCalledOnce() diff --git a/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts b/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts index 0ee95478743..aa47fac186e 100644 --- a/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts +++ b/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { rmSync } from 'node:fs' import { DaemonClient } from './client' -import { DaemonPtyAdapter, LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-adapter' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { LIVENESS_PROBE_TIMEOUT_MS } from './daemon-pty-session-control' import { COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 56bad83e39e..42bf0a8c267 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -1,2886 +1,9 @@ -/* oxlint-disable max-lines -- Why: history .catch() safety wiring spread across spawn/event-routing is tightly coupled to the adapter↔history lifecycle. */ -import { basename } from 'node:path' -import { existsSync, readFileSync } from 'node:fs' -import { readFile } from 'node:fs/promises' -import { DaemonClient } from './client' -import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership' -import { getMacDaemonSystemResolverHealth } from './daemon-health' -import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution' -import { isDaemonStaleForCurrentBundle } from './daemon-bundle-staleness' -import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' -import { - HistoryManager, - type HistoryCheckpointResult, - type HistoryRecoveryFreeze -} from './history-manager' -import { HistoryReader, type ColdRestoreInfo } from './history-reader' -import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' -import { mintPtySessionId, parsePtySessionId } from './pty-session-id' -import { supportsPtyStartupBarrier } from './shell-ready' -import { CODEX_SHELL_READY_TIMEOUT_MS } from './session-shell-ready-barrier' -import { - CLEAN_DISCONNECT_PROTOCOL_VERSION, - COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, - GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, - AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, - AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, - DAEMON_UNAVAILABLE_RECONNECT_MESSAGE, - GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, - PROTOCOL_VERSION, - supportsMode2031UnsubscribeFact, - supportsPtyStartupIngress, - type CreateOrAttachResult, - type DaemonEvent, - type GetSnapshotResult, - type ListSessionsResult, - SessionNotFoundError, - type SessionInfo, - type TakePendingOutputResult -} from './types' -import { - GET_SIZE_PROTOCOL_VERSION, - HISTORY_SEED_TRANSFER_PROTOCOL_VERSION, - SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION, - STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION -} from './daemon-protocol-version' -import { - isAgentSessionClaimedSpawnResult, - isAgentSessionOwnerBinding, - type AgentSessionOwnerBinding -} from '../../shared/agent-session-host-authority' -import { MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES } from '../../shared/claimed-agent-pty-owner' -import { cloneAgentSessionOwnerBinding } from '../../shared/claimed-agent-pty-owner-snapshot' -import type { - IPtyProvider, - PtyBackgroundStreamEvent, - PtyProviderBufferSnapshot, - PtyProcessInfo, - PtySpawnOptions, - PtySpawnResult -} from '../providers/types' -import type { PtyProcessInspection } from '../providers/pty-process-inspection' -import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' -import { isShellProcess } from '../../shared/agent-detection' -import { resolveWslSessionContext } from './wsl-session-context' -import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' -import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' -import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' -import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' -import { resolveUnixShellPath } from '../providers/local-pty-utils' -import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history' -import { addWslEnvKeys } from '../wsl-env' -import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' -import { ColdRestorePayloadCache, type ColdRestorePayload } from './cold-restore-payload-cache' -import { CheckpointSessionQueue } from './daemon-checkpoint-session-queue' -import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot' -import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth' -import { DAEMON_SESSION_SCROLLBACK_ROWS } from './daemon-session-scrollback-window' -import { PtyProcessListAdmission } from '../providers/pty-process-list-admission' -import { - iterateTerminalHistorySeedChunks, - measureTerminalHistorySeed, - TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS -} from './terminal-history-seed-chunks' -import { NdjsonLineTooLongError } from './ndjson' -import type { DaemonEndpointIdentity } from './daemon-hello-protocol' -import { - classifyDaemonAuditFailure, - recordAuthenticatedInventory, - type DaemonAuditContext, - type DaemonAuditObservation, - type DaemonAuditTrigger -} from './daemon-audit-classifier' -import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence' -import { createDaemonAuditEligibilityTracker } from './daemon-audit-eligibility-event' -import { normalizeDesktopTerminalSnapshotRows } from '../../shared/terminal-scrollback-policy' -import type { TerminalExitCause } from '../../shared/terminal-exit-cause' +import { DaemonPtyDaemonRecovery } from './daemon-pty-daemon-recovery' +import { supportsMode2031UnsubscribeFact, type DaemonEvent } from './types' +import type { IPtyProvider } from '../providers/types' -type PendingDaemonSpawnOperation = { - exitsBySessionId: Map - ignoredExitIncarnationIds: Set - ignoreNextExit: boolean -} - -type HistoryRecoveryContext = { - freeze: HistoryRecoveryFreeze | null - unreadableSessionId: string | null - identityChanged: boolean -} - -type SnapshotCheckpointResult = { - checkpoint: HistoryCheckpointResult - snapshot: NonNullable | null -} - -// Why take-and-clear together: every consuming branch must reset the field, so pairing them stops one from forgetting. -function takeRecoveryFreeze( - historyRecovery: HistoryRecoveryContext, - sessionId: string -): HistoryRecoveryFreeze | undefined { - const freeze = - historyRecovery.freeze?.sessionId === sessionId ? historyRecovery.freeze : undefined - historyRecovery.freeze = null - return freeze -} -function providerSequenceFromCreateOrAttach( - result: CreateOrAttachResult -): PtySpawnResult['providerSequence'] { - if (result.isNew) { - return { value: 0, generation: 'reset' } - } - return typeof result.snapshot?.outputSequence === 'number' - ? { value: result.snapshot.outputSequence, generation: 'continued' } - : undefined -} - -export type DaemonPtyAdapterOptions = { - socketPath: string - tokenPath: string - pidPath?: string - profileScope?: string - protocolVersion?: number - /** Directory for disk-based terminal history; when set, raw PTY output is written to disk for cold restore on daemon crash. */ - historyPath?: string - /** Runtime profile directory used to verify daemon TCC attribution. */ - runtimeDir?: string - /** Current packaged version, or null for unpackaged builds. */ - packagedAppVersion?: string | null - /** Forks a fresh daemon after endpoint death or a confirmed health replacement. */ - respawn?: (reason: DaemonRespawnReason) => Promise void)> -} - -export type DaemonRespawnReason = - | 'daemon_died' - | 'unhealthy_resolver' - | 'stale_bundle' - | 'severed_tcc_attribution' - -export type DaemonIdentityChangeEvent = { - previous: DaemonEndpointIdentity - current: DaemonEndpointIdentity -} - -const MAX_TOMBSTONES = 1000 -const MAX_CONCURRENT_CHECKPOINTS = 4 - -// Why a reattach deadline at all: a warm reattach is a user click, so it must never wait on a -// stalled history filesystem. Why 5s: a healthy deep-history rebuild replays at most -// DAEMON_RESTORE_SCROLLBACK_ROWS through the headless emulator in well under a second, so this -// only fires when the write path is genuinely wedged (STA-4173). -const DURABLE_HISTORY_OVERLAY_DEADLINE_MS = 5_000 - -// Why far below the client's 30s default: a wedged daemon holds its socket open, so an unbounded -// probe stalls a pane mount for the full request timeout — and the owner fan-out waits on every -// adapter, so one hung daemon stalls each restoring pane. Answering "unknown" quickly is strictly -// better here: unknown never authorizes retirement, it only defers it. -export const LIVENESS_PROBE_TIMEOUT_MS = 2_000 - -// Why: providers take an absolute teardown deadline, but the client RPC takes a -// relative timeout — convert only here, at the request itself, so sequential RPCs -// naturally share the remaining budget (undefined keeps the client's 30s default). -function remainingRequestTimeoutMs(deadlineMs: number | undefined): number | undefined { - return deadlineMs === undefined ? undefined : Math.max(1, deadlineMs - Date.now()) -} - -// Why a distinct error: teardown callers must read this as "not proven stopped" and leave the PTY -// alive, not as a kill that failed. It never matches isPtyAlreadyGoneError, so `stopAndWait` reports -// the pty unverified and worktree sleep declines to commit it. -export class FinalCheckpointWaitExpiredError extends Error { - constructor(sessionId: string) { - super(`Final history checkpoint did not settle within the teardown deadline: ${sessionId}`) - this.name = 'FinalCheckpointWaitExpiredError' - } -} - -// Why only the caller's wait is bounded and never the work itself: cancelling durable work would -// silently drop what the user left on screen. This decides how long a caller blocks, nothing more — -// `work` keeps running behind an abandoned wait and still commits. False means the caller gave up. -// A rejection before the deadline still propagates, so a genuinely failed operation is not masked. -async function awaitWithinCallerDeadline( - work: Promise, - deadlineMs: number -): Promise { - // Why up front: once the race is abandoned nothing observes a later rejection here. - void work.catch(() => {}) - let timer: ReturnType | undefined - try { - return await Promise.race([ - work.then(() => true), - new Promise((resolve) => { - timer = setTimeout(() => resolve(false), Math.max(1, deadlineMs - Date.now())) - timer.unref?.() - }) - ]) - } finally { - clearTimeout(timer) - } -} - -export class TerminalKilledError extends Error { - constructor(sessionId: string) { - super(`Session "${sessionId}" was explicitly killed`) - this.name = 'TerminalKilledError' - } -} - -export class DaemonPtyAdapter implements IPtyProvider { - readonly protocolVersion: number - private socketPath: string - private tokenPath: string - private pidPath: string | null - private pidRecord: ParsedDaemonPid | null - private client: DaemonClient - private auditContext: DaemonAuditContext - private lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null - private exactDaemonIncarnation: ExactDaemonIncarnation | null = null - private lastAuditObservation: DaemonAuditObservation | null = null - // Why: every listProcesses call republishes the same observation; unthrottled it drains the shared per-session telemetry ceiling. - private readonly trackAuditEligibility = createDaemonAuditEligibilityTracker() - private auditObservationListeners: ((observation: DaemonAuditObservation) => void)[] = [] - private identityChangeListeners: ((event: DaemonIdentityChangeEvent) => void)[] = [] - private historyManager: HistoryManager | null - private historyReader: HistoryReader | null - private respawnFn: DaemonPtyAdapterOptions['respawn'] | null - private runtimeDir: string | null - private packagedAppVersion: string | null - private pendingRespawnAdoptionRelease: (() => void) | null = null - private respawnAdoptionClosed = false - // Why: concurrent spawn() calls hitting a dead daemon would each fork their own; this promise coalesces respawns so only the first forks and the rest await it. - private respawnPromise: Promise | null = null - private staleBundleReplacementPromise: Promise | null = null - private writeRecoveryPromise: Promise | null = null - private writeRecoveryAttempted = false - private dataListeners: ((payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - seq?: number - }) => void)[] = [] - private exitListeners: ((payload: { - id: string - code: number - incarnationId?: PtyIncarnationId - cause?: TerminalExitCause - }) => void)[] = [] - private backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] - // Why: lets main fan a dead-endpoint signal to every affected pane, not just the written one (STA-2373 sibling-freeze). - private writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] - private removeEventListener: (() => void) | null = null - private initialCwds = new Map() - private wslDistrosBySessionId = new Map() - // Why: StrictMode/re-render remounts can call createOrAttach for a just-killed session; tombstones stop the daemon resurrecting it (Map evicts oldest-first, per terminal-host.ts). - private killedSessionTombstones = new Map() - // Why: React StrictMode double-mounts; this sticky cache returns the same cold restore data on remount until the renderer acknowledges it. - private sleepRestoreSessionIds = new Set() - private coldRestoreCache = new ColdRestorePayloadCache(undefined, (sessionId) => { - this.sleepRestoreSessionIds.delete(sessionId) - }) - private activeSessionIds = new Set() - // Set only once this daemon has rejected `getSize` as unknown; its protocol number cannot prove it. - private getSizeUnsupported = false - // A replacement daemon has none of the old PTYs; only createOrAttach can make their bindings writable again. - private sessionsAwaitingDaemonRecovery = new Set() - private sessionIncarnations = new Map() - private pendingSpawnOperationsBySessionId = new Map>() - private pendingClaimSpawnOperations = new Set() - private historySpawnLocks = new Map>() - private dirtySessionVersions = new Map() - // Why: a cold-restored session is a fresh shell atop a pre-crash log; incremental appends would be rejected on restore, so the first tick re-anchors with a full snapshot. - private sessionsNeedingFullCheckpoint = new Set() - // Why: overflow or an unpersisted drain breaks disk-to-queue continuity, so only the daemon emulator can safely re-anchor the next checkpoint. - private sessionsNeedingLiveCheckpoint = new Set() - // Why: a fresh adapter may reuse deep disk history only when the checkpoint proves it ends at the daemon's preceding pending-output batch. - private sessionsNeedingContinuityCheckpoint = new Set() - private checkpointTimer: ReturnType | null = null - private checkpointInFlight: Promise | null = null - // Why per session: exclusivity protects one session directory's tmp-write/rename, so ordering - // every session behind one tail let a single stalled checkpoint block all reattaches (STA-4173). - private checkpointQueue = new CheckpointSessionQueue() - private nonFinalCheckpointAdmissionSessionIds = new Set() - private nonFinalAdmissionDeniedSessionIds = new Set() - private nonFinalGlobalAdmissionWarningActive = false - private overlayDeadlineWarnedSessionIds = new Set() - private periodicDeadlineWarnedSessionIds = new Set() - private keepHistoryShutdowns = new Set>() - private disconnectOnlyPromise: Promise | null = null - // Why: checkpoint persistence needs the getSnapshot RPC (v4+); legacy daemons reject it, spamming logs every 5s. - private supportsCheckpoints: boolean - // Why: incremental checkpoints need the takePendingOutput RPC (v13+); older daemons fall back to full-snapshot checkpoints. - private supportsIncrementalCheckpoints: boolean - // Why: producer pause/resume notifications require v19+; gate them to silent no-ops on legacy daemons. - private supportsProducerFlowControl: boolean - private supportsAuthoritativeBufferSnapshots: boolean - private supportsStartupIngress: boolean - private pausedProducerSessionIds = new Set() - // Why tracked here: the daemon's background set dies with the daemon process/socket; re-sync on a fresh connection so hidden panes stay thinned. - private backgroundedSessionIds = new Set() - // Why: a daemon surviving a socket drop can hold a pause whose resume died with the connection; owe a resume on reconnect (daemon's 5s failsafe covers the gap). - private producerResumesOwedOnReconnect = new Set() - private static CHECKPOINT_INTERVAL_MS = 5_000 - // Why the background pass gets a longer deadline: deferral keeps the session dirty, so this - // should sit above slow-but-working disk rather than churn it. - private static PERIODIC_CHECKPOINT_DEADLINE_MS = 15_000 - // Why: streaming sessions re-trigger full multi-MB checkpoints every tick; this cooldown caps cap/overflow snapshots per session (~9x less writes, bounded cold-crash staleness). - private static FULL_CHECKPOINT_COOLDOWN_MS = 45_000 - private lastFullCheckpointAt = new Map() - - supportsGitCredentialGuardHost(): boolean { - return this.protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION - } - - canProvideAuthoritativeBufferSnapshot(_id: string): boolean { - return this.supportsAuthoritativeBufferSnapshots - } - - // Why one predicate (#9993): the attach-time clear and setPtyBackgrounded must agree on - // which daemons may hold a background hint. Daemons outlive the desktop that set it, so - // if these two drift a preserved daemon keeps a hint this process would never grant. - private get canDelegateBackgroundToDaemon(): boolean { - return ( - this.supportsAuthoritativeBufferSnapshots && - supportsMode2031UnsubscribeFact(this.protocolVersion) - ) - } - - constructor(opts: DaemonPtyAdapterOptions) { - this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION - this.socketPath = opts.socketPath - this.tokenPath = opts.tokenPath - this.pidPath = opts.pidPath ?? null - this.pidRecord = readDaemonPidRecord(this.pidPath) - this.auditContext = { - protocolGeneration: this.protocolVersion, - provider: 'local-daemon', - endpoint: opts.socketPath, - tokenPath: opts.tokenPath, - endpointKind: process.platform === 'win32' ? 'windows-named-pipe' : 'unix-socket', - profileScope: opts.profileScope ?? '' - } - this.client = new DaemonClient({ - socketPath: opts.socketPath, - tokenPath: opts.tokenPath, - protocolVersion: opts.protocolVersion - }) - this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null - this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null - this.respawnFn = opts.respawn ?? null - this.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null - this.packagedAppVersion = opts.packagedAppVersion ?? null - this.supportsCheckpoints = this.protocolVersion >= 4 - this.supportsIncrementalCheckpoints = this.protocolVersion >= 13 - this.supportsProducerFlowControl = this.protocolVersion >= 19 - this.supportsAuthoritativeBufferSnapshots = - this.protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION - this.supportsStartupIngress = supportsPtyStartupIngress(this.protocolVersion) - this.client.onDisconnected(() => { - if (!this.respawnAdoptionClosed) { - // Why re-arm here: the latch is otherwise only cleared when every awaiting - // session rebinds, and background sessions (no mounted pane, so nothing ever - // calls createOrAttach for them) never do — which would leave the fan-out - // permanently latched off after the first death. Fires once per connection. - this.writeRecoveryAttempted = false - for (const id of this.activeSessionIds) { - this.sessionsAwaitingDaemonRecovery.add(id) - } - } - for (const id of this.pausedProducerSessionIds) { - this.producerResumesOwedOnReconnect.add(id) - } - this.pausedProducerSessionIds.clear() - this.observeAuditFailure('transport_closed') - }) - } - - getHistoryManager(): HistoryManager | null { - return this.historyManager - } - - getLastAuthenticatedDaemonIdentity(): DaemonEndpointIdentity | null { - return this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null - } - - getLastAuditObservation(): DaemonAuditObservation | null { - return this.lastAuditObservation - } - - onDaemonIdentityChanged(listener: (event: DaemonIdentityChangeEvent) => void): () => void { - this.identityChangeListeners.push(listener) - return () => removeListener(this.identityChangeListeners, listener) - } - - onAuditEligibilityObservation( - listener: (observation: DaemonAuditObservation) => void - ): () => void { - this.auditObservationListeners.push(listener) - return () => removeListener(this.auditObservationListeners, listener) - } - - supportsAgentSessionClaims(): boolean { - return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION - } - - providesAgentSessionOwnerListings(_ptyId: string): boolean { - return this.supportsAgentSessionClaims() - } - - supportsAgentSessionCreateOperations(): boolean { - // Why: old daemons never advertised the lower-owner protocol, so preserve their legacy launch. - return this.protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION - } - - async spawn(opts: PtySpawnOptions): Promise { - const spawnOpts = this.withHistoryIsolation(opts) - const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId) - const operation = { - exitsBySessionId: new Map(), - ignoredExitIncarnationIds: new Set(), - ignoreNextExit: false - } - const operations = this.pendingSpawnOperationsBySessionId.get(sessionId) ?? new Set() - operations.add(operation) - this.pendingSpawnOperationsBySessionId.set(sessionId, operations) - if (opts.agentSessionEnsure) { - this.pendingClaimSpawnOperations.add(operation) - } - const historyRecovery: HistoryRecoveryContext = { - freeze: null, - unreadableSessionId: null, - identityChanged: false - } - try { - return await this.withHistorySpawnLock(sessionId, () => - this.withDaemonRetry(() => - this.doSpawn({ ...spawnOpts, sessionId }, operation, historyRecovery) - ) - ) - } finally { - if (historyRecovery.freeze) { - this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze) - } - this.pendingClaimSpawnOperations.delete(operation) - operations.delete(operation) - if (operations.size === 0) { - this.pendingSpawnOperationsBySessionId.delete(sessionId) - } - } - } - - private withHistoryIsolation(opts: PtySpawnOptions): PtySpawnOptions { - const wslContext = resolveWslSessionContext({ - cwd: opts.cwd, - sessionId: opts.sessionId, - shellOverride: opts.shellOverride, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro - }) - if ( - opts.attachOnly === true || - (opts.sessionId !== undefined && opts.isNewSession !== true) || - !opts.worktreeId || - opts.historyIsolationEnabled !== true || - (process.platform === 'win32' && !wslContext) - ) { - return opts - } - const env = { ...opts.env } - const preferredShell = wslContext - ? 'bash' - : opts.shellOverride || env.SHELL || process.env.SHELL || '/bin/zsh' - const shellPath = resolveUnixShellPath(preferredShell) - const historyArgs = [ - env, - opts.worktreeId, - shellPath, - opts.cwd ?? resolveSafePtyDefaultCwd() - ] as const - const result = wslContext - ? injectHistoryEnv(...historyArgs, { wslDistro: wslContext.distro }) - : injectHistoryEnv(...historyArgs) - if (wslContext) { - injectWslFishHistoryEnv(env, opts.worktreeId, wslContext.distro) - addWslEnvKeys(env, ['HISTFILE', 'fish_history']) - } - logHistoryInjection(opts.worktreeId, result) - return { ...opts, env } - } - - private async doSpawn( - opts: PtySpawnOptions, - operation: PendingDaemonSpawnOperation, - historyRecovery: HistoryRecoveryContext - ): Promise { - if ( - opts.agentSessionEnsure && - this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION - ) { - throw new Error('agent_session_claim_unavailable') - } - const requestedSessionId = opts.sessionId! - // Why: v30 daemons survive upgrades; reject their accidental create result before publication. - const attachOnly = opts.attachOnly === true - const emulateLegacyAttachOnly = - attachOnly && this.protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION - let sessionId = requestedSessionId - let wslDistro = resolveWslSessionContext({ - cwd: opts.cwd, - sessionId, - shellOverride: opts.shellOverride, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro - })?.distro - const freezeHistory = async (): Promise => { - if (!this.historyManager) { - return - } - if (historyRecovery.freeze?.sessionId === sessionId) { - return - } - if (historyRecovery.freeze) { - this.historyManager.abandonRecoveryFreeze(historyRecovery.freeze) - } - historyRecovery.freeze = await this.historyManager.freezeForRecovery(sessionId) - historyRecovery.unreadableSessionId = null - } - const detectColdRestore = async (options?: { - ignoreCleanEnd?: boolean - }): Promise => { - if (!this.historyReader) { - return null - } - await freezeHistory() - const detection = await this.historyReader.detectColdRestoreState(sessionId, { - ...options, - wslDistro - }) - if (detection.status === 'unreadable') { - historyRecovery.unreadableSessionId = detection.sessionId - return null - } - const restoreInfo = detection.status === 'restored' ? detection.restoreInfo : null - if (detection.status === 'restored' && detection.hasUnreadableRecovery) { - historyRecovery.unreadableSessionId = detection.sessionId - } - if (!restoreInfo) { - return null - } - return { - ...restoreInfo, - cwd: - normalizeWslColdRestoreCwd({ - recoveredCwd: restoreInfo.cwd, - requestedCwd: opts.cwd ?? resolveSafePtyDefaultCwd(), - wslDistro - }) ?? '' - } - } - - if (this.killedSessionTombstones.has(sessionId)) { - throw new TerminalKilledError(sessionId) - } - - if (opts.isNewSession) { - await this.replaceUnhealthyMacResolverDaemonBeforeNewPty() - await this.replaceStaleBundleDaemonBeforeNewPty() - await this.replaceSeveredMacTccDaemonBeforeNewPty() - } - - await this.ensureConnected() - // Why before createOrAttach: a preserved daemon may still think this session is backgrounded — from - // a v19 that thins without a recoverable seq, or (#9993) from a pre-v29 that a previous desktop - // handed 2031 scan authority to and can never retract it. Clear it before any bytes are attached. - if (!this.canDelegateBackgroundToDaemon) { - this.setPtyBackgrounded(sessionId, false) - } - - // Why detect crash-recovery history before spawning: the revived shell should inherit the recovered cwd/dims, not the renderer's mount-time request. - // Why probe aliveness first: detectColdRestore replays up to ~5MB on the main process, but a live session's snapshot supersedes disk, so the replay would be wasted. - let restoreInfo: ColdRestoreInfo | null = null - let restoreSkippedForLiveSession = false - const historyProbe = opts.attachOnly - ? undefined - : this.historyReader?.probeRestorableHistory(sessionId) - if (historyProbe && historyProbe.status !== 'none') { - if ((await this.getAppliedSize(sessionId)) !== null) { - restoreSkippedForLiveSession = true - if (this.historyManager && !this.historyManager.hasWriter(sessionId)) { - await detectColdRestore() - restoreInfo = null - } - } else { - restoreInfo = await detectColdRestore() - } - } - let effectiveCwd = restoreInfo?.cwd ?? opts.cwd - let effectiveCols = restoreInfo?.cols ?? opts.cols - let effectiveRows = restoreInfo?.rows ?? opts.rows - - const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false - const isCodexStartupCommand = - recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex' - const shouldWaitForShellReady = - isCodexStartupCommand && - shouldUseShellReadyStartupDelivery({ - command: opts.command, - startupCommandDelivery: opts.startupCommandDelivery - }) - const shellReadyTimeoutMs = - shellReadySupported && isCodexStartupCommand && !shouldWaitForShellReady - ? CODEX_SHELL_READY_TIMEOUT_MS - : undefined - - const requestCreateOrAttach = ( - historySeed: string | undefined, - historySeedTransferId: string | undefined - ) => { - if (opts.signal?.aborted) { - throw new Error('client_disconnected') - } - const payload = { - sessionId, - cols: effectiveCols, - rows: effectiveRows, - cwd: attachOnly ? undefined : effectiveCwd, - env: attachOnly ? undefined : opts.env, - envToDelete: attachOnly ? undefined : opts.envToDelete, - command: attachOnly ? undefined : opts.command, - startupCommandDelivery: attachOnly ? undefined : opts.startupCommandDelivery, - launchAgent: attachOnly ? undefined : opts.launchAgent, - ...(attachOnly && !emulateLegacyAttachOnly ? { attachOnly: true } : {}), - // Why: without forwarding the override, the daemon falls back to cmd.exe/PowerShell, ignoring the shell the renderer chose; this matches LocalPtyProvider. - shellOverride: attachOnly ? undefined : opts.shellOverride, - terminalWindowsWslDistro: attachOnly ? undefined : opts.terminalWindowsWslDistro, - terminalWindowsPowerShellImplementation: attachOnly - ? undefined - : opts.terminalWindowsPowerShellImplementation, - shellReadySupported: attachOnly ? false : shellReadySupported, - ...(!attachOnly && shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs } : {}), - ...(historySeed ? { historySeed } : {}), - ...(historySeedTransferId ? { historySeedTransferId } : {}), - ...(this.supportsStartupIngress && !attachOnly && opts.startupIngress - ? { startupIngress: opts.startupIngress } - : {}), - ...(!attachOnly && opts.agentSessionEnsure - ? { agentSessionEnsure: opts.agentSessionEnsure } - : {}) - } - return opts.signal - ? this.client.request( - 'createOrAttach', - payload, - undefined, - opts.signal - ) - : this.client.request('createOrAttach', payload) - } - - const createOrAttach = async ( - historySeedSegments: readonly string[] | null - ): Promise => { - // Why scoped per call: the aliveness-probe retry re-runs this with its own seed, so a first-call - // delivery failure must not force historySeeded=false on a retry that seeded successfully. - let historySeedUnavailable = false - const deliverSeedAndCreate = async (): Promise => { - if (!historySeedSegments || historySeedSegments.length === 0) { - return requestCreateOrAttach(undefined, undefined) - } - const metrics = measureTerminalHistorySeed(historySeedSegments) - if (metrics.codeUnits <= TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS) { - try { - return await requestCreateOrAttach(historySeedSegments.join(''), undefined) - } catch (error) { - if (!(error instanceof NdjsonLineTooLongError)) { - throw error - } - historySeedUnavailable = true - return requestCreateOrAttach(undefined, undefined) - } - } - if (this.protocolVersion < HISTORY_SEED_TRANSFER_PROTOCOL_VERSION) { - historySeedUnavailable = true - return requestCreateOrAttach(undefined, undefined) - } - - let transferId: string | undefined - try { - const started = await this.client.request<{ transferId: string }>( - 'startHistorySeedTransfer', - metrics - ) - transferId = started.transferId - let index = 0 - for (const data of iterateTerminalHistorySeedChunks(historySeedSegments)) { - await this.client.request('appendHistorySeedTransfer', { transferId, index, data }) - index += 1 - } - await this.client.request('finishHistorySeedTransfer', { transferId }) - } catch (error) { - if (transferId) { - await this.client.request('abortHistorySeedTransfer', { transferId }).catch(() => {}) - } - if (isDaemonGoneError(error)) { - throw error - } - historySeedUnavailable = true - return requestCreateOrAttach(undefined, undefined) - } - return requestCreateOrAttach(undefined, transferId) - } - const result = await deliverSeedAndCreate() - return historySeedUnavailable && result.historySeeded === undefined - ? { ...result, historySeeded: false } - : result - } - - let historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null - const adoptSpawnResultSession = async (spawnResult: CreateOrAttachResult): Promise => { - const requestedSessionId = sessionId - if ( - opts.agentSessionEnsure && - !isAgentSessionClaimedSpawnResult(spawnResult.agentSessionEnsure) - ) { - // Why: a claim-incapable owner may already have spawned before returning - // a malformed response; retire only this requested session before failing closed. - await this.client.request('kill', { sessionId: requestedSessionId }).catch(() => {}) - throw new Error('agent_session_claim_unavailable') - } - sessionId = spawnResult.agentSessionEnsure?.owner.ptyId ?? requestedSessionId - if (requestedSessionId === sessionId) { - return - } - if (historyRecovery.freeze) { - this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze) - historyRecovery.freeze = null - } - historyRecovery.unreadableSessionId = null - historyRecovery.identityChanged = true - restoreInfo = null - historySeedSegments = null - } - let result = await createOrAttach(historySeedSegments) - if (emulateLegacyAttachOnly && result.isNew) { - operation.ignoreNextExit = true - await this.client.request('kill', { sessionId: requestedSessionId, immediate: true }) - throw new SessionNotFoundError(requestedSessionId) - } - await adoptSpawnResultSession(result) - // Both ids: adoptSpawnResultSession may have rewritten sessionId to the claim owner. - this.clearSessionAwaitingDaemonRecovery(requestedSessionId) - this.clearSessionAwaitingDaemonRecovery(sessionId) - const exitedResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) - if (exitedResult) { - return exitedResult - } - if (result.incarnationId) { - this.sessionIncarnations.set(sessionId, result.incarnationId) - } - const claimResult = (): Pick | Record => - result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {} - const incarnationResult = (): Pick | Record => - result.incarnationId ? { incarnationId: result.incarnationId } : {} - let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro - // Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons. - wslDistro = providerWslDistro ?? undefined - if (wslDistro) { - this.wslDistrosBySessionId.set(sessionId, wslDistro) - } else if (providerWslDistro === null || result.isNew) { - this.wslDistrosBySessionId.delete(sessionId) - } - const launchIdentity = (): { launchAgent?: NonNullable } => - result.launchAgent ? { launchAgent: result.launchAgent } : {} - - if (effectiveCwd) { - this.initialCwds.set(sessionId, effectiveCwd) - } - - // Why: surface the daemon's shell pid via PtySpawnResult so ipc/pty registers with the memory collector without a provider-specific accessor. - let pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null - - // Why: check sticky cache first — StrictMode double-mounts call spawn twice; the second call (isNew=false) must still return cached cold restore data. - const cachedRestore = this.coldRestoreCache.get(sessionId) - if (cachedRestore) { - // Why: wake-after-sleep lands here too; sleep dropped active tracking + the history writer, so re-register both or the next sleep/wake restores a blank terminal. - this.activeSessionIds.add(sessionId) - if (this.historyManager && !historyRecovery.identityChanged) { - const recoveryFreeze = takeRecoveryFreeze(historyRecovery, sessionId) - if (historyRecovery.unreadableSessionId === sessionId) { - this.historyManager.suspendSession(sessionId, recoveryFreeze) - } else { - this.historyManager.reopenSession(sessionId, recoveryFreeze) - } - } - return { - id: sessionId, - ...incarnationResult(), - pid, - ...claimResult(), - ...launchIdentity(), - coldRestore: cachedRestore, - ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), - ...(!result.isNew ? { isReattach: true } : {}) - } - } - - // Why: the probe→createOrAttach gap is racy — the session can exit in between, so re-detect to match the unprobed restore path. - // Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it. - if (!historyRecovery.identityChanged && result.isNew && restoreSkippedForLiveSession) { - restoreInfo = await detectColdRestore({ ignoreCleanEnd: true }) - historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null - if (restoreInfo && historySeedSegments && historySeedSegments.length > 0) { - // Why: the aliveness probe raced with session death, so the first - // create lacked recovery bytes. Replace it before exposing the PTY. - if (result.incarnationId) { - operation.ignoredExitIncarnationIds.add(result.incarnationId) - } - operation.ignoreNextExit = true - await this.client.request('kill', { sessionId, immediate: true }) - effectiveCwd = restoreInfo.cwd - effectiveCols = restoreInfo.cols - effectiveRows = restoreInfo.rows - result = await createOrAttach(historySeedSegments) - await adoptSpawnResultSession(result) - const exitedRetryResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) - if (exitedRetryResult) { - return exitedRetryResult - } - if (result.incarnationId) { - this.sessionIncarnations.set(sessionId, result.incarnationId) - } - providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro - wslDistro = providerWslDistro ?? undefined - if (wslDistro) { - this.wslDistrosBySessionId.set(sessionId, wslDistro) - } else if (providerWslDistro === null || result.isNew) { - this.wslDistrosBySessionId.delete(sessionId) - } - pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null - this.initialCwds.set(sessionId, effectiveCwd) - } - } else if ( - !historyRecovery.identityChanged && - !result.isNew && - result.historySeeded === false - ) { - restoreInfo = await detectColdRestore() - historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null - } - - const wasAlreadyManaged = this.activeSessionIds.has(sessionId) - this.activeSessionIds.add(sessionId) - const providerSequence = providerSequenceFromCreateOrAttach(result) - - // Cold restore: daemon made a new session but disk history shows an unclean shutdown → return saved scrollback. - if (restoreInfo && (result.isNew || result.historySeeded === false)) { - const coldRestore = this.buildColdRestorePayload(restoreInfo) - const canReanchorHistory = - !historySeedSegments || historySeedSegments.length === 0 || result.historySeeded === true - // Why: registerWriter (not openSession) avoids deleting checkpoint.json — the only recovery data if the revived daemon crashes before the next tick. - if (this.historyManager && !historyRecovery.identityChanged) { - const recoveryFreeze = takeRecoveryFreeze(historyRecovery, sessionId) - if (historyRecovery.unreadableSessionId === sessionId) { - await this.historyManager.openSession(sessionId, { - cwd: effectiveCwd ?? '', - cols: effectiveCols, - rows: effectiveRows, - ...(recoveryFreeze ? { recoveryFreeze } : {}), - quarantineUnreadableRecovery: true - }) - if (this.historyManager.hasWriter(sessionId)) { - this.sessionsNeedingFullCheckpoint.add(sessionId) - this.sessionsNeedingLiveCheckpoint.add(sessionId) - this.lastFullCheckpointAt.delete(sessionId) - } - } else if (canReanchorHistory) { - this.historyManager.registerWriter(sessionId, recoveryFreeze) - this.sessionsNeedingFullCheckpoint.add(sessionId) - // Why: the revived generation has no valid checkpoint yet; a cooldown inherited from the pre-crash generation must not defer this re-anchor. - this.lastFullCheckpointAt.delete(sessionId) - } else { - // Preserve old recovery files when the new daemon can't include them; a fresh-only checkpoint would make the data loss permanent. - this.historyManager.suspendSession(sessionId, recoveryFreeze) - } - } - if (coldRestore) { - this.coldRestoreCache.set(sessionId, coldRestore) - return { - id: sessionId, - ...incarnationResult(), - pid, - ...claimResult(), - ...launchIdentity(), - coldRestore, - ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), - ...(providerSequence ? { providerSequence } : {}), - ...(!result.isNew ? { isReattach: true } : {}) - } - } - return { - id: sessionId, - ...incarnationResult(), - pid, - ...claimResult(), - ...launchIdentity(), - ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), - ...(providerSequence ? { providerSequence } : {}) - } - } - - if (this.historyManager && !historyRecovery.identityChanged && result.isNew) { - const recoveryFreeze = takeRecoveryFreeze(historyRecovery, sessionId) - await this.historyManager.openSession(sessionId, { - cwd: effectiveCwd ?? '', - cols: effectiveCols, - rows: effectiveRows, - ...(recoveryFreeze ? { recoveryFreeze } : {}), - ...(historyRecovery.unreadableSessionId === sessionId - ? { quarantineUnreadableRecovery: true } - : {}) - }) - } else if ( - this.historyManager && - !historyRecovery.identityChanged && - (result.historySeeded === false || historyRecovery.unreadableSessionId === sessionId) - ) { - // Why: the daemon keeps this failure bit with the live session, so a new adapter can't promote its fresh-only snapshot after restart. - this.historyManager.suspendSession(sessionId, takeRecoveryFreeze(historyRecovery, sessionId)) - } else if (this.historyManager && !historyRecovery.identityChanged) { - // Why: on warm reattach after relaunch the HistoryManager is fresh; registerWriter adds a writer without deleting the still-only-valid checkpoint. - this.historyManager.registerWriter(sessionId, takeRecoveryFreeze(historyRecovery, sessionId)) - if (!wasAlreadyManaged) { - // Why: a previous adapter may have drained records it never persisted, so the first compact must prove disk-to-daemon continuity. - this.sessionsNeedingFullCheckpoint.add(sessionId) - this.sessionsNeedingContinuityCheckpoint.add(sessionId) - this.lastFullCheckpointAt.delete(sessionId) - } - } - - const isReattach = !result.isNew - if (!isReattach || !result.snapshot) { - return { - id: sessionId, - ...incarnationResult(), - pid, - ...claimResult(), - ...launchIdentity(), - ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), - ...(providerSequence ? { providerSequence } : {}), - ...(isReattach ? { isReattach: true } : {}) - } - } - - const reattachSnapshot = await this.overlayDurableRestoreSnapshot(sessionId, result.snapshot) - const reattachProviderSequence = - typeof reattachSnapshot.outputSequence === 'number' - ? { value: reattachSnapshot.outputSequence, generation: 'continued' as const } - : providerSequence - const isAltScreen = reattachSnapshot.modes.alternateScreen - const snapshotPrefix = reattachSnapshot.scrollbackAnsi + reattachSnapshot.rehydrateSequences - const snapshotFrame = reattachSnapshot.snapshotAnsi - const snapshotPayload = snapshotPrefix + snapshotFrame - // Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty). - // Why known `0` is no longer dropped: the pane tracker must be able to tell - // "the app negotiated nothing" from "this reattach proved nothing". - const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags( - reattachSnapshot.modes.kittyKeyboardFlags - ) - return { - id: sessionId, - ...incarnationResult(), - pid, - ...claimResult(), - ...launchIdentity(), - ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), - snapshot: snapshotPayload, - snapshotCols: reattachSnapshot.cols, - snapshotRows: reattachSnapshot.rows, - // Why only for an alt frame: normal history remains safe to replay at its capture grid. - ...(isAltScreen && snapshotFrame && reattachSnapshot.frameRestoreAnsi - ? { - snapshotPrefixAnsi: snapshotPrefix, - snapshotFrameAnsi: snapshotFrame, - snapshotFrameRestoreAnsi: reattachSnapshot.frameRestoreAnsi - } - : {}), - ...(reattachProviderSequence ? { providerSequence: reattachProviderSequence } : {}), - ...(kittyKeyboardFlags !== undefined - ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } - : {}), - isReattach: true, - isAlternateScreen: isAltScreen, - // Why: the snapshot ANSI has no title frame; carry lastTitle beside it so main can seed title records after a relaunch. - ...(reattachSnapshot.lastTitle ? { lastTitle: reattachSnapshot.lastTitle } : {}), - // Why: carry the mid-escape tail so the renderer writes it after the reattach reset, else a split escape renders literally (#7329). - ...(reattachSnapshot.pendingEscapeTailAnsi - ? { pendingEscapeTailAnsi: reattachSnapshot.pendingEscapeTailAnsi } - : {}) - } - } - - private resultForExitBeforeSpawnReply( - sessionId: string, - result: CreateOrAttachResult, - operation: PendingDaemonSpawnOperation - ): PtySpawnResult | null { - const matchingExit = (operation.exitsBySessionId.get(sessionId) ?? []).some( - (exit) => - !(exit.incarnationId && operation.ignoredExitIncarnationIds.has(exit.incarnationId)) && - (!exit.incarnationId || - !result.incarnationId || - exit.incarnationId === result.incarnationId) - ) - if (!matchingExit) { - return null - } - // Why: stream exit can beat the control reply; return proof upward without republishing dead adapter state. - const exitedResult: PtySpawnResult = { - id: sessionId, - exitedBeforeSpawnReply: true, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), - ...(!result.isNew ? { isReattach: true } : {}) - } - return exitedResult - } - - didExitBeforeSpawnReply(result: PtySpawnResult): boolean { - return result.exitedBeforeSpawnReply === true - } - - async attach(id: string): Promise | void> { - await this.ensureConnected() - if (!this.canDelegateBackgroundToDaemon) { - this.setPtyBackgrounded(id, false) - } - - // Why size-first: attach must ride the session's own geometry — a fixed - // 80×24 here could resize a live agent's TUI — and a null size means the - // daemon cannot prove the session, so refuse rather than risk a create. - const size = await this.getAppliedSize(id) - if (!size) { - throw new SessionNotFoundError(id) - } - const result = await this.client.request('createOrAttach', { - sessionId: id, - cols: size.cols, - rows: size.rows, - attachOnly: true - }) - if (result.isNew) { - // Why: a pre-v31 daemon ignores attachOnly; retire its accidental spawn - // instead of publishing a fresh shell as an attach. - await this.client.request('kill', { sessionId: id, immediate: true }).catch((error) => { - // Why surface, not swallow: a failed retire leaves an untracked orphan shell. - console.warn('[daemon] attach-only retire of accidental legacy spawn failed', { - sessionId: id, - error - }) - }) - throw new SessionNotFoundError(id) - } - this.clearSessionAwaitingDaemonRecovery(id) - const providerSequence = providerSequenceFromCreateOrAttach(result) - return providerSequence ? { providerSequence } : undefined - } - - hasPty(id: string): boolean { - return this.activeSessionIds.has(id) - } - - async probePtyLiveness(id: string): Promise { - try { - if (!this.getSizeUnsupported && this.protocolVersion >= GET_SIZE_PROTOCOL_VERSION) { - try { - const result = await this.client.request<{ size: { cols: number; rows: number } | null }>( - 'getSize', - { sessionId: id }, - LIVENESS_PROBE_TIMEOUT_MS - ) - return result.size !== null - } catch (error) { - // Why the capability probe rather than the version alone: `getSize` shipped into an - // already-released protocol without a bump, so a daemon can report a version that - // implies support and still reject the request. Ask what it can do, not what its - // number implies — and remember the answer so later probes skip the dead round trip. - if (!isUnknownRequestTypeError(error)) { - throw error - } - this.getSizeUnsupported = true - } - } - // Why: a daemon without `getSize` would otherwise answer `null` forever, and one `null` - // makes the whole owner fan-out unprovable — a dead pane could then never be retired. - // `listSessions` is the same inventory legacy discovery routes by, and has existed since - // the first daemon protocol. Requested directly rather than through `listProcesses` so a - // liveness probe does not publish inventory audit observations as a side effect; both - // rethrow on failure, so either way a dead socket stays `null` instead of reading absent. - const { sessions } = await this.client.request( - 'listSessions', - undefined, - LIVENESS_PROBE_TIMEOUT_MS - ) - return sessions.some((session) => session.sessionId === id && session.isAlive) - } catch { - return null - } - } - - write(id: string, data: string): boolean { - const recoverable = this.prepareWrite(id) - return this.finishWrite(id, this.client.notify('write', { sessionId: id, data }), recoverable) - } - - async writeWithSettlement(id: string, data: string): Promise { - const recoverable = this.prepareWrite(id) - return this.finishWrite( - id, - await this.client.notifyWithSettlement('write', { sessionId: id, data }), - recoverable - ) - } - - private prepareWrite(id: string): boolean { - this.markSessionDirty(id) - // Why recoverable and not just active: rejecting a write asks the pane to remount, - // which only helps if this endpoint can come back. A legacy adapter has no respawn, - // so its reattach fails and the pane rebuilds empty — losing scrollback the user - // could still read. Keep the pre-existing silent drop for those. - const recoverable = - this.activeSessionIds.has(id) && !this.respawnAdoptionClosed && Boolean(this.respawnFn) - if ( - recoverable && - (this.sessionsAwaitingDaemonRecovery.has(id) || !this.client.isConnected()) - ) { - this.sessionsAwaitingDaemonRecovery.add(id) - this.reconnectAfterWriteFailure() - throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`) - } - return recoverable - } - - private finishWrite(id: string, delivered: boolean, recoverable: boolean): boolean { - if (!delivered && recoverable) { - this.sessionsAwaitingDaemonRecovery.add(id) - this.reconnectAfterWriteFailure() - throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`) - } - return delivered - } - - resize(id: string, cols: number, rows: number): void { - this.markSessionDirty(id) - this.client.notify('resize', { sessionId: id, cols, rows }) - } - - pauseProducer(id: string): void { - if (!this.supportsProducerFlowControl) { - return - } - this.pausedProducerSessionIds.add(id) - this.client.notify('pausePty', { sessionId: id }) - } - - resumeProducer(id: string): void { - this.producerResumesOwedOnReconnect.delete(id) - if (!this.supportsProducerFlowControl) { - return - } - this.pausedProducerSessionIds.delete(id) - this.client.notify('resumePty', { sessionId: id }) - } - - // Why fire-and-forget (like pausePty): just a delivery hint for the daemon's keep-tail stream thinning. - setPtyBackgrounded(id: string, background: boolean): void { - if (!this.supportsProducerFlowControl) { - return - } - // Why: preserved daemons without a sequence-safe, faithful serializer cannot heal a thinned stream. - // Why also gate on 2031 (#9993): backgrounding is what hands transient-fact scan - // authority to the daemon. A pre-v29 daemon can announce a 2031 subscribe but never - // retract it, so a TUI exiting while hidden would strand the subscription and the - // next theme flip would inject CSI 997 into its replacement shell. Declining to - // background keeps main's scanner — which emits both facts — authoritative. - const safeBackground = this.canDelegateBackgroundToDaemon && background - if (safeBackground) { - this.backgroundedSessionIds.add(id) - } else { - this.backgroundedSessionIds.delete(id) - } - this.client.notify('setSessionBackground', { sessionId: id, background: safeBackground }) - } - - async shutdown( - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise { - if (opts.keepHistory && this.disconnectOnlyPromise) { - throw new Error('Cannot keep history after daemon disconnect has started') - } - const shutdown = this.withHistorySpawnLock(id, () => this.shutdownWithHistoryLock(id, opts)) - if (!opts.keepHistory) { - await shutdown - return - } - this.keepHistoryShutdowns.add(shutdown) - try { - await shutdown - } finally { - this.keepHistoryShutdowns.delete(shutdown) - } - } - - private async shutdownWithHistoryLock( - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise { - // Why: shutdown can be the first lazy-client operation after restart; connect - // before killing so a healthy daemon session is not orphaned (#7742). Connect, - // the final-checkpoint wait, and kill all share the caller's one absolute - // deadline, so neither a wedged handshake nor a stalled history write can burn - // the whole teardown budget before the kill even starts. Only the waits are - // bounded — the checkpoint itself stays deadline-free and lossless (STA-4228). - await this.ensureConnected(opts.deadlineMs) - // Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run. - // Force a final snapshot so wake can restore the pane users left. - if (opts.keepHistory) { - const committed = await this.runExclusiveCheckpoint( - async () => { - await this.checkpointSessions([id], { final: true, teardown: true }) - }, - { callerDeadlineMs: opts.deadlineMs } - ) - // Why throw instead of killing anyway: the snapshot the caller asked us to prove is still - // being written. Killing here would race the wake-time restore source to disk, so report the - // pty unverified and leave it alive — worktree sleep declines to commit it and retries. - if (!committed) { - throw new FinalCheckpointWaitExpiredError(id) - } - const wslDistro = this.wslDistrosBySessionId.get(id) - const detection = await this.historyReader?.detectColdRestoreState(id, { wslDistro }) - const detected = detection?.status === 'restored' ? detection.restoreInfo : null - const restoreInfo = detected - ? { - ...detected, - cwd: - normalizeWslColdRestoreCwd({ - recoveredCwd: detected.cwd, - requestedCwd: this.initialCwds.get(id) ?? resolveSafePtyDefaultCwd(), - wslDistro - }) ?? '' - } - : null - const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null - if (coldRestore) { - this.coldRestoreCache.set(id, coldRestore) - if (this.coldRestoreCache.has(id)) { - this.sleepRestoreSessionIds.add(id) - } - // Why: physical exit must not mark intentional sleep as a clean end; the final checkpoint stays the wake-time recovery authority. - this.historyManager?.suspendSession(id) - } else if ( - detection?.status === 'unreadable' || - (detection?.status === 'restored' && detection.hasUnreadableRecovery) - ) { - this.historyManager?.suspendSession(id) - } - } - await this.client.request( - 'kill', - { sessionId: id, immediate: opts.immediate ?? false }, - remainingRequestTimeoutMs(opts.deadlineMs) - ) - this.activeSessionIds.delete(id) - this.clearSessionAwaitingDaemonRecovery(id) - this.dirtySessionVersions.delete(id) - if (!opts.keepHistory) { - this.coldRestoreCache.delete(id) - this.sleepRestoreSessionIds.delete(id) - } - // Why: the !keepHistory path takes no final checkpoint, so clear sessionsNeedingFullCheckpoint here or it stays stranded (no-op under keepHistory). - this.sessionsNeedingFullCheckpoint.delete(id) - this.sessionsNeedingLiveCheckpoint.delete(id) - this.sessionsNeedingContinuityCheckpoint.delete(id) - this.overlayDeadlineWarnedSessionIds.delete(id) - this.periodicDeadlineWarnedSessionIds.delete(id) - this.nonFinalAdmissionDeniedSessionIds.delete(id) - this.lastFullCheckpointAt.delete(id) - this.stopCheckpointTimerIfIdle() - this.initialCwds.delete(id) - this.wslDistrosBySessionId.delete(id) - // Why: only remove history on explicit close; sleep also calls shutdown but wake needs the dir intact for cold restore (opts.keepHistory). - if (this.historyManager && !opts.keepHistory) { - await this.historyManager - .removeSession(id) - .catch((err) => console.warn('[history] removeSession failed:', id, err)) - } - - // Why: the tombstone rejects reattach to a user-killed session; sleep legitimately reattaches on wake, so skip it under keepHistory. - if (!opts.keepHistory) { - this.killedSessionTombstones.delete(id) - this.killedSessionTombstones.set(id, Date.now()) - if (this.killedSessionTombstones.size > MAX_TOMBSTONES) { - const oldest = this.killedSessionTombstones.keys().next().value - if (oldest) { - this.killedSessionTombstones.delete(oldest) - } - } - } - } - - ackColdRestore(sessionId: string): void { - this.coldRestoreCache.delete(sessionId) - this.sleepRestoreSessionIds.delete(sessionId) - } - - clearTombstone(sessionId: string): void { - this.killedSessionTombstones.delete(sessionId) - } - - private buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null { - // Why: alt-screen prefers normal scrollback, else snapshotAnsi alone — not rehydrate, which starts with \x1b[?1049h that POST_REPLAY_MODE_RESET won't undo — so a hibernated TUI's last frame isn't blank on wake. - const scrollback = restoreInfo.modes.alternateScreen - ? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null - : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi - if (!scrollback) { - return null - } - return { - scrollback, - cwd: restoreInfo.cwd, - cols: restoreInfo.cols, - rows: restoreInfo.rows, - oscLinks: restoreInfo.oscLinks, - ...(restoreInfo.lastTitle ? { lastTitle: restoreInfo.lastTitle } : {}) - } - } - - async sendSignal(id: string, signal: string): Promise { - await this.client.request('signal', { sessionId: id, signal }) - } - - async getCwd(id: string): Promise { - try { - const result = await this.client.request<{ cwd: string | null }>('getCwd', { - sessionId: id - }) - return result.cwd ?? '' - } catch { - return '' - } - } - - async getInitialCwd(id: string): Promise { - return this.initialCwds.get(id) ?? '' - } - - // Why: resize() is fire-and-forget and can be dropped daemon-side; read the actually-applied size so the renderer can detect drift and re-assert. - async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> { - try { - const result = await this.client.request<{ size: { cols: number; rows: number } | null }>( - 'getSize', - { sessionId: id } - ) - return result.size ?? null - } catch { - return null - } - } - - async getBufferSnapshot( - id: string, - opts: { scrollbackRows?: number } = {} - ): Promise { - if (!this.supportsAuthoritativeBufferSnapshots) { - return null - } - try { - const scrollbackRows = normalizeDesktopTerminalSnapshotRows(opts.scrollbackRows) - const result = await this.client.request('getSnapshot', { - sessionId: id, - ...(scrollbackRows !== undefined ? { scrollbackRows } : {}) - }) - const snapshot = result.snapshot - // Why: older v19 daemons lack an absolute output sequence, so their snapshot can't reconcile bytes queued on the other socket. - if (!snapshot || typeof snapshot.outputSequence !== 'number') { - return null - } - const restored = - this.historyManager && - this.historyReader && - (scrollbackRows === undefined || scrollbackRows > DAEMON_SESSION_SCROLLBACK_ROWS) - ? await this.overlayDurableRestoreSnapshot(id, snapshot, scrollbackRows) - : snapshot - return this.toProviderBufferSnapshot(restored) - } catch { - return null - } - } - - private toProviderBufferSnapshot( - snapshot: NonNullable - ): PtyProviderBufferSnapshot | null { - if (typeof snapshot.outputSequence !== 'number') { - return null - } - const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.modes.kittyKeyboardFlags) - return { - data: snapshot.rehydrateSequences + snapshot.snapshotAnsi, - frameRestoreAnsi: snapshot.frameRestoreAnsi, - scrollbackAnsi: snapshot.scrollbackAnsi, - cols: snapshot.cols, - rows: snapshot.rows, - cwd: snapshot.cwd, - lastTitle: snapshot.lastTitle, - seq: snapshot.outputSequence, - source: 'headless', - oscLinks: snapshot.oscLinks, - alternateScreen: snapshot.modes.alternateScreen, - // Why known `0` is carried too: it proves the app negotiated nothing at - // this boundary, which is a different fact from a source that cannot say. - ...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}), - ...(snapshot.pendingEscapeTailAnsi - ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } - : {}) - } - } - - private async overlayDurableRestoreSnapshot( - sessionId: string, - liveSnapshot: NonNullable, - scrollbackRows?: number - ): Promise> { - if (!this.historyManager || !this.historyReader) { - return liveSnapshot - } - // Why turn the caller away instead of queueing: this session already has a - // compact in flight whose result a third one would only duplicate, and an - // unbounded queue is how a stalled history filesystem grows without limit. - if (this.checkpointQueue.isSaturated(sessionId)) { - return liveSnapshot - } - // Why reserve before enqueueing: pane mounts can arrive in one turn, before any compact starts. - // Count abandoned waits until their writes settle so a relaunch cannot fan out unbounded work. - if (!this.tryAdmitNonFinalCheckpoint(sessionId)) { - return liveSnapshot - } - // Why per session with a deadline: a reattach is a user click, so it must wait - // on this session's own compact and nothing else. A blown deadline does not - // cancel that compact — it keeps running and still commits — so the fallback - // costs restore depth for this one reattach, never durable history (STA-4173). - return await this.checkpointQueue.runWithDeadline( - sessionId, - async () => { - try { - return await this.compactDurableRestoreSnapshot(sessionId, liveSnapshot, scrollbackRows) - } finally { - this.releaseNonFinalCheckpointAdmission(sessionId) - this.overlayDeadlineWarnedSessionIds.delete(sessionId) - } - }, - DURABLE_HISTORY_OVERLAY_DEADLINE_MS, - liveSnapshot, - { - onDeadline: () => { - if (!this.overlayDeadlineWarnedSessionIds.has(sessionId)) { - this.overlayDeadlineWarnedSessionIds.add(sessionId) - console.warn('[history] durable snapshot overlay deadline exceeded:', sessionId) - } - } - } - ) - } - - private tryAdmitNonFinalCheckpoint(sessionId: string): boolean { - if (this.nonFinalCheckpointAdmissionSessionIds.has(sessionId)) { - if (!this.nonFinalAdmissionDeniedSessionIds.has(sessionId)) { - this.nonFinalAdmissionDeniedSessionIds.add(sessionId) - console.warn('[history] non-final checkpoint already in flight:', sessionId) - } - return false - } - if (this.nonFinalCheckpointAdmissionSessionIds.size >= MAX_CONCURRENT_CHECKPOINTS) { - this.reportNonFinalGlobalAdmissionDenial(sessionId) - return false - } - this.nonFinalAdmissionDeniedSessionIds.delete(sessionId) - this.nonFinalCheckpointAdmissionSessionIds.add(sessionId) - return true - } - - private reportNonFinalGlobalAdmissionDenial(sessionId: string): void { - if (!this.nonFinalGlobalAdmissionWarningActive) { - this.nonFinalGlobalAdmissionWarningActive = true - console.warn('[history] non-final checkpoint global admission limit reached:', sessionId) - } - } - - private releaseNonFinalCheckpointAdmission(sessionId: string): void { - if (this.nonFinalCheckpointAdmissionSessionIds.delete(sessionId)) { - this.nonFinalAdmissionDeniedSessionIds.delete(sessionId) - this.nonFinalGlobalAdmissionWarningActive = false - } - } - - private async compactDurableRestoreSnapshot( - sessionId: string, - liveSnapshot: NonNullable, - scrollbackRows?: number - ): Promise> { - if (!this.historyReader) { - return liveSnapshot - } - try { - // Why compact before reading: an independent take/append races the 5s tick, can - // seq-gap the log, and would remount a stale checkpoint over the live window. - const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { - teardown: false, - forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId), - requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId) - }) - if (checkpoint.checkpoint === 'committed') { - this.sessionsNeedingFullCheckpoint.delete(sessionId) - } - if (checkpoint.checkpoint !== 'committed' || !checkpoint.snapshot) { - return checkpoint.snapshot ?? liveSnapshot - } - if (scrollbackRows === undefined || scrollbackRows >= DAEMON_RESTORE_SCROLLBACK_ROWS) { - return checkpoint.snapshot - } - const restoreInfo = await this.historyReader.detectColdRestore(sessionId, { - ignoreCleanEnd: true, - wslDistro: this.wslDistrosBySessionId.get(sessionId) - }) - if (!restoreInfo) { - return liveSnapshot - } - return await buildDurableCheckpointSnapshot({ - liveSnapshot: checkpoint.snapshot, - restoreInfo, - scrollbackRows - }) - } catch (error) { - console.warn('[history] durable snapshot overlay failed:', sessionId, error) - return liveSnapshot - } - } - - async clearBuffer(id: string): Promise { - await this.client.request('clearScrollback', { sessionId: id }) - this.markSessionDirty(id) - } - - acknowledgeDataEvent(_id: string, _charCount: number): void { - // No flow control for daemon-backed terminals - } - - // Why: daemon-backed PTYs can host long-lived agents while detached; cleanup prompts must not treat them as idle shells. - private hasChildProcessesFromForeground(foregroundProcess: string | null): boolean { - return foregroundProcess !== null && !isShellProcess(foregroundProcess) - } - - async hasChildProcesses(id: string): Promise { - if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { - return true - } - return this.hasChildProcessesFromForeground(await this.getForegroundProcess(id)) - } - - async inspectProcess(id: string): Promise { - if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { - return { foregroundProcess: null, hasChildProcesses: true, unavailable: true } - } - if (this.protocolVersion < COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION) { - // Why: pre-v27 daemons survive an in-place app update; compose the inspection client-side from the - // one call they do support instead of throwing, or completion detection stays dead until recreate. - // Requests directly (not via getForegroundProcess) so a dead socket still rejects rather than - // reading as an idle foreground and dispatching a false completion. - const { foregroundProcess } = await this.client.request<{ - foregroundProcess: string | null - }>('getForegroundProcess', { sessionId: id }) - return { - foregroundProcess, - hasChildProcesses: this.hasChildProcessesFromForeground(foregroundProcess) - } - } - return this.client.request<{ - foregroundProcess: string | null - hasChildProcesses: boolean - }>('inspectProcess', { sessionId: id }) - } - - async getForegroundProcess(id: string): Promise { - if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { - return null - } - try { - const result = await this.client.request<{ foregroundProcess: string | null }>( - 'getForegroundProcess', - { sessionId: id } - ) - return result.foregroundProcess - } catch { - return null - } - } - - async confirmForegroundProcess(id: string): Promise { - try { - const result = await this.client.request<{ foregroundProcess: string | null }>( - 'confirmForegroundProcess', - { sessionId: id } - ) - return result.foregroundProcess - } catch { - return null - } - } - - async serialize(ids: string[]): Promise { - const sessions: Record = {} - for (const id of ids) { - sessions[id] = { initialCwd: this.initialCwds.get(id) } - } - return JSON.stringify(sessions) - } - - async revive(_state: string): Promise { - // Sessions already live in the daemon — no revival needed - } - - /** Called on app launch. Lists daemon sessions, kills orphans whose workspaceId - * no longer exists, and caches alive session IDs. - * - * IMPORTANT: a session id embeds the worktree's path at spawn time, so a renamed - * worktree keeps its old id. Callers MUST seed `validWorktreeIds` with each live - * worktree's `WorktreeMeta.priorWorktreeIds` or those sessions get reaped as false - * orphans. No production caller yet; wire the alias in when it gains one. */ - async reconcileOnStartup(validWorktreeIds: Set): Promise<{ - alive: string[] - killed: string[] - }> { - await this.ensureConnected() - const result = await this.client.request('listSessions', undefined) - - const alive: string[] = [] - const killed: string[] = [] - - for (const session of result.sessions) { - if (!session.isAlive) { - continue - } - // Why: an unminted session id (worktreeId === null) can't be tied to a live worktree, so it's treated as an orphan. - const { worktreeId } = parsePtySessionId(session.sessionId) - - if (worktreeId === null || !validWorktreeIds.has(worktreeId)) { - try { - await this.client.request('kill', { sessionId: session.sessionId }) - } catch { - /* already dead */ - } - killed.push(session.sessionId) - } else { - alive.push(session.sessionId) - // Why: track background sessions in the checkpoint set so disconnectOnly's final checkpoint doesn't leave stale recovery data. - this.activeSessionIds.add(session.sessionId) - await this.reconcileLiveSessionHistory(session).catch((err) => - console.warn('[history] live-session reconciliation failed:', session.sessionId, err) - ) - } - } - - return { alive, killed } - } - - private async reconcileLiveSessionHistory(session: SessionInfo): Promise { - const historyManager = this.historyManager - const historyReader = this.historyReader - if (!historyManager || !historyReader) { - return - } - await this.withHistorySpawnLock(session.sessionId, async () => { - if (historyManager.hasWriter(session.sessionId)) { - return - } - const probe = historyReader.probeRestorableHistory(session.sessionId) - if (probe.status === 'unreadable') { - return - } - if (probe.status === 'none') { - await historyManager.openSession(session.sessionId, { - cwd: session.cwd ?? '', - cols: session.cols, - rows: session.rows - }) - } else { - const recoveryFreeze = await historyManager.freezeForRecovery(session.sessionId) - try { - const detection = await historyReader.detectColdRestoreState(session.sessionId, { - wslDistro: session.wslDistro ?? undefined - }) - if ( - detection.status === 'unreadable' || - (detection.status === 'restored' && detection.hasUnreadableRecovery) - ) { - historyManager.suspendSession(session.sessionId, recoveryFreeze) - return - } - historyManager.reopenSession(session.sessionId, recoveryFreeze) - } finally { - historyManager.abandonRecoveryFreeze(recoveryFreeze) - } - } - if (historyManager.hasWriter(session.sessionId)) { - this.sessionsNeedingFullCheckpoint.add(session.sessionId) - this.sessionsNeedingContinuityCheckpoint.add(session.sessionId) - this.lastFullCheckpointAt.delete(session.sessionId) - this.markSessionDirty(session.sessionId) - } - }) - } - - async listProcesses(opts?: { deadlineMs?: number }): Promise { - // Why: snapshotted before the request so ids spawned mid-flight can never - // be reconciled away below. - const preRequestActiveIds = new Set(this.activeSessionIds) - try { - // Why: connect + listSessions share the caller's one absolute deadline so a - // wedged handshake cannot burn the whole teardown budget before the list issues. - await this.ensureConnected(opts?.deadlineMs) - const result = await this.client.request( - 'listSessions', - undefined, - remainingRequestTimeoutMs(opts?.deadlineMs) - ) - const admission = new PtyProcessListAdmission() - const processes: PtyProcessInfo[] = [] - const aliveSessionIds = new Set() - for (const session of result.sessions) { - if (!session.isAlive) { - continue - } - aliveSessionIds.add(session.sessionId) - const { worktreeId } = parsePtySessionId(session.sessionId) - processes.push( - admission.admit({ - id: session.sessionId, - ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), - // Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd. - cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '', - title: 'shell', - ...(worktreeId ? { worktreeId } : {}), - ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), - ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}), - ...this.validatedAgentSessionOwners(session.agentSessionOwners) - }) - ) - } - // Why: hasPty reads activeSessionIds, and an exit missed while the socket - // was disconnected otherwise survives an authoritative inventory forever — - // defeating every absence proof built on the cache. - for (const id of preRequestActiveIds) { - if (!aliveSessionIds.has(id)) { - this.activeSessionIds.delete(id) - } - } - this.publishAuditObservation( - recordAuthenticatedInventory(this.auditContext, this.exactDaemonIncarnation) - ) - return processes - } catch (error) { - const missingAuthenticatedToken = this.isRetiredEndpointTokenMissing() - const missingNamedPipe = isMissingWindowsNamedPipeError(error) - this.observeAuditFailure( - missingAuthenticatedToken - ? 'token_missing_after_authenticated_disconnect' - : 'inventory_failed', - this.exactDaemonIncarnation, - [ - ...(missingAuthenticatedToken ? (['token_file'] as const) : []), - ...(missingNamedPipe ? (['windows_named_pipe'] as const) : []) - ], - missingNamedPipe ? 'windows_named_pipe_missing' : undefined - ) - throw error - } - } - - private validatedAgentSessionOwners( - owners: unknown - ): { agentSessionOwners: AgentSessionOwnerBinding[] } | Record { - if (owners === undefined) { - return {} - } - if ( - !Array.isArray(owners) || - owners.length > MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES || - !owners.every((owner) => isAgentSessionOwnerBinding(owner) && owner.phase === 'live') - ) { - throw new Error('agent_session_ownership_unknown') - } - return owners.length > 0 - ? { agentSessionOwners: owners.map(cloneAgentSessionOwnerBinding) } - : {} - } - - // Why: the Manage Sessions panel needs the full SessionInfo (pid, state, - // createdAt) per session for display; listProcesses drops that detail for - // the IPtyProvider contract. Keep both in parallel rather than widening - // the provider surface. - async listSessions(): Promise { - await this.ensureConnected() - const result = await this.client.request('listSessions', undefined) - return result.sessions - .filter((s) => s.isAlive) - .map((session) => ({ - ...session, - ...this.validatedAgentSessionOwners(session.agentSessionOwners) - })) - } - - getActiveSessionIds(): string[] { - return [...this.activeSessionIds] - } - - // Why: the daemon's kill-all-and-shutdown path suppresses onExit fanout (session.ts:246-252), so synthesize pty:exit - // for every live session before teardown or renderer panes black-hole writes to a disposed adapter forever. - fanoutSyntheticExits(code: number): void { - const ids = [...this.activeSessionIds] - this.activeSessionIds.clear() - this.sessionsAwaitingDaemonRecovery.clear() - this.writeRecoveryAttempted = false - this.dirtySessionVersions.clear() - this.lastFullCheckpointAt.clear() - this.sessionsNeedingFullCheckpoint.clear() - this.sessionsNeedingLiveCheckpoint.clear() - this.sessionsNeedingContinuityCheckpoint.clear() - this.overlayDeadlineWarnedSessionIds.clear() - this.periodicDeadlineWarnedSessionIds.clear() - this.nonFinalAdmissionDeniedSessionIds.clear() - this.pausedProducerSessionIds.clear() - this.producerResumesOwedOnReconnect.clear() - this.stopCheckpointTimer() - for (const id of ids) { - this.coldRestoreCache.delete(id) - // Why: don't catch listener throws — matches the natural onExit fanout so synthetic exits keep the same error semantics. - // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration - for (const listener of [...this.exitListeners]) { - listener({ - id, - code, - ...(this.sessionIncarnations.get(id) - ? { incarnationId: this.sessionIncarnations.get(id) } - : {}) - }) - } - this.sessionIncarnations.delete(id) - } - } - - async getDefaultShell(): Promise { - if (process.platform === 'win32') { - return process.env.COMSPEC || 'powershell.exe' - } - return process.env.SHELL || '/bin/zsh' - } - - async getProfiles(): Promise<{ name: string; path: string }[]> { - if (process.platform === 'win32') { - return [ - { name: 'PowerShell', path: 'powershell.exe' }, - { name: 'Command Prompt', path: 'cmd.exe' } - ] - } - const shells = ['/bin/zsh', '/bin/bash', '/bin/sh'] - return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s })) - } - - onData( - callback: (payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - seq?: number - }) => void - ): () => void { - this.dataListeners.push(callback) - return () => { - const idx = this.dataListeners.indexOf(callback) - if (idx !== -1) { - this.dataListeners.splice(idx, 1) - } - } - } - - onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { - this.backgroundStreamListeners.push(callback) - return () => { - const idx = this.backgroundStreamListeners.indexOf(callback) - if (idx !== -1) { - this.backgroundStreamListeners.splice(idx, 1) - } - } - } - - onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { - return () => {} - } - - onExit( - callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void - ): () => void { - this.exitListeners.push(callback) - return () => { - const idx = this.exitListeners.indexOf(callback) - if (idx !== -1) { - this.exitListeners.splice(idx, 1) - } - } - } - - onWriteUnavailable(callback: (payload: { id: string }) => void): () => void { - this.writeUnavailableListeners.push(callback) - return () => { - const idx = this.writeUnavailableListeners.indexOf(callback) - if (idx !== -1) { - this.writeUnavailableListeners.splice(idx, 1) - } - } - } - - private emitWriteUnavailable(id: string): void { - // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration - for (const listener of [...this.writeUnavailableListeners]) { - listener({ id }) - } - } - - dispose(): void { - this.respawnAdoptionClosed = true - this.sessionsAwaitingDaemonRecovery.clear() - this.writeRecoveryAttempted = false - this.releasePendingRespawnAdoptionLease() - this.stopCheckpointTimer() - this.dirtySessionVersions.clear() - this.lastFullCheckpointAt.clear() - this.sessionsNeedingFullCheckpoint.clear() - this.sessionsNeedingLiveCheckpoint.clear() - this.sessionsNeedingContinuityCheckpoint.clear() - this.overlayDeadlineWarnedSessionIds.clear() - this.periodicDeadlineWarnedSessionIds.clear() - this.nonFinalAdmissionDeniedSessionIds.clear() - this.coldRestoreCache.clear() - this.wslDistrosBySessionId.clear() - this.pausedProducerSessionIds.clear() - this.producerResumesOwedOnReconnect.clear() - this.auditObservationListeners.length = 0 - this.identityChangeListeners.length = 0 - this.removeEventListener?.() - this.removeEventListener = null - // Why: final checkpoints are written daemon-side (TerminalHost.dispose); here the adapter only marks sessions - // cleanly ended so they don't trigger false cold restores. - if (this.historyManager) { - void this.historyManager - .dispose() - .catch((err) => console.warn('[history] dispose failed:', err)) - } - this.client.disconnect() - } - - async establishLifecycleLease(): Promise { - if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) { - return - } - // Why: an authenticated pair cancels the adoption watchdog and lets a never-used adapter retire its empty daemon on quit. - await this.client.ensureConnected() - this.recordAuthenticatedIdentity() - } - - // Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable, - // but still write a final checkpoint so a daemon crash while Orca is closed has recovery data. - async disconnectOnly(): Promise { - if (!this.disconnectOnlyPromise) { - this.respawnAdoptionClosed = true - this.sessionsAwaitingDaemonRecovery.clear() - this.writeRecoveryAttempted = false - this.releasePendingRespawnAdoptionLease() - this.disconnectOnlyPromise = this.finishDisconnectOnly([...this.keepHistoryShutdowns]) - } - await this.disconnectOnlyPromise - } - - private async finishDisconnectOnly(keepHistoryShutdowns: Promise[]): Promise { - // Why: sleep shutdowns still detect recovery and kill after checkpointing; disconnecting first rejects those admitted operations. - await Promise.allSettled(keepHistoryShutdowns) - this.respawnAdoptionClosed = true - // Why: a final checkpoint covers sessions opened since the last tick (else cold restore finds nothing if the daemon - // later dies). Await it — fire-and-forget would race client.disconnect() and reject the pending getSnapshot RPCs. - await this.runExclusiveCheckpoint(() => this.checkpointAllSessions(), { - rescheduleDirty: false - }) - this.dirtySessionVersions.clear() - this.lastFullCheckpointAt.clear() - this.coldRestoreCache.clear() - this.wslDistrosBySessionId.clear() - // Why: the detached daemon keeps these PTYs alive for warm reattach; a leftover pause would stall shells for a failsafe window. - for (const id of this.pausedProducerSessionIds) { - this.client.notify('resumePty', { sessionId: id }) - } - this.pausedProducerSessionIds.clear() - this.producerResumesOwedOnReconnect.clear() - this.removeEventListener?.() - this.removeEventListener = null - if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { - try { - // Why: only the authenticated daemon can atomically prove it's empty; a shared budget keeps this off quit's critical path. - const deadlineMs = Date.now() + 250 - if (!this.client.isConnected()) { - await this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now())) - } - await this.client.request('shutdownIfIdle', undefined, Math.max(1, deadlineMs - Date.now())) - } catch { - // An unreachable daemon falls back to event-driven retirement once its auth sockets close and it proves itself empty. - } - } - this.client.disconnect() - } - - private async ensureConnected(deadlineMs?: number): Promise { - try { - // Why: destructive teardown bounds the handshake by its deadline so a wedged - // connect fails fast; undefined keeps the default connect behavior. - await (deadlineMs !== undefined - ? this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now())) - : this.client.ensureConnected()) - } finally { - // Why: a respawn launcher holds a temporary pair until this adapter's permanent reconnect, preventing both gaps and leaks. - this.releasePendingRespawnAdoptionLease() - } - this.recordAuthenticatedIdentity() - // Why sampled before setupEventRouting: "no listener yet" identifies a fresh connect — the only time the - // daemon-side backgrounded set (process state lost with the old daemon) needs a resync. - const isFreshConnection = this.removeEventListener === null - this.setupEventRouting() - this.scheduleCheckpointTimer() - this.flushOwedProducerResumes() - if (isFreshConnection) { - this.resyncBackgroundedSessions() - } - } - - private recordAuthenticatedIdentity(): void { - const current = this.client.getDaemonIdentity() - if (!current) { - return - } - const previous = this.lastAuthenticatedIdentity - if (previous && sameEndpointIdentity(previous, current)) { - return - } - this.lastAuthenticatedIdentity = { ...current } - this.exactDaemonIncarnation = exactDaemonIncarnationForPidRecord(current, this.pidRecord) - if (!previous) { - return - } - const event = { previous: { ...previous }, current: { ...current } } - notifyAuditListeners(this.identityChangeListeners, event) - } - - private async resolveExactDaemonIncarnation( - exactIncarnation: ExactDaemonIncarnation | null - ): Promise { - if ( - !exactIncarnation || - process.platform !== 'linux' || - (exactIncarnation.linuxStartTicks && exactIncarnation.bootId) - ) { - return exactIncarnation - } - const cachedIncarnation = exactDaemonIncarnationForPidRecord( - exactIncarnation.identity, - this.pidRecord - ) - if (cachedIncarnation.linuxStartTicks && cachedIncarnation.bootId) { - return cachedIncarnation - } - const pidRecord = await this.readMatchingPidRecord(exactIncarnation.identity) - this.pidRecord = pidRecord ?? this.pidRecord - return pidRecord?.linuxStartTicks && pidRecord.bootId - ? { - identity: { ...exactIncarnation.identity }, - linuxStartTicks: pidRecord.linuxStartTicks, - bootId: pidRecord.bootId - } - : exactIncarnation - } - - private cacheExactDaemonIncarnation(exactIncarnation: ExactDaemonIncarnation | null): void { - if ( - exactIncarnation && - this.lastAuthenticatedIdentity && - sameEndpointIdentity(exactIncarnation.identity, this.lastAuthenticatedIdentity) - ) { - this.exactDaemonIncarnation = exactIncarnation - } - } - - private async readMatchingPidRecord( - identity: DaemonEndpointIdentity - ): Promise { - if (!this.pidPath) { - return null - } - try { - const parsed = parseDaemonPidFile(await readFile(this.pidPath, 'utf8')) - return parsed?.pid === identity.pid && - parsed.startedAtMs === identity.startedAtMs && - parsed.launchNonce === identity.launchNonce - ? parsed - : null - } catch { - return null - } - } - - private observeAuditFailure( - trigger: Exclude, - exactIncarnation = this.exactDaemonIncarnation, - additionalEvidenceSources: readonly DaemonEvidenceSource[] = [], - endpointGoneProof?: 'windows_named_pipe_missing' - ): void { - void this.resolveExactDaemonIncarnation(exactIncarnation) - .then((resolvedIncarnation) => { - this.cacheExactDaemonIncarnation(resolvedIncarnation) - return classifyDaemonAuditFailure(this.auditContext, trigger, resolvedIncarnation, { - additionalEvidenceSources, - endpointGoneProof - }) - }) - .then((observation) => this.publishAuditObservation(observation)) - .catch(() => {}) - } - - private publishAuditObservation(observation: DaemonAuditObservation): void { - this.lastAuditObservation = observation - this.trackAuditEligibility(observation) - notifyAuditListeners(this.auditObservationListeners, observation) - } - - private resyncBackgroundedSessions(): void { - for (const id of this.backgroundedSessionIds) { - // Harmless no-op for sessions the daemon doesn't know (yet). - this.client.notify('setSessionBackground', { sessionId: id, background: true }) - } - } - - private flushOwedProducerResumes(): void { - if (this.producerResumesOwedOnReconnect.size === 0) { - return - } - for (const id of this.producerResumesOwedOnReconnect) { - // Why: resuming an unknown session is a harmless no-op; leaving a survivor paused would waste 5s of failsafe latency. - this.client.notify('resumePty', { sessionId: id }) - } - this.producerResumesOwedOnReconnect.clear() - } - - private stopCheckpointTimer(): void { - if (!this.checkpointTimer) { - return - } - clearTimeout(this.checkpointTimer) - this.checkpointTimer = null - } - - private stopCheckpointTimerIfIdle(): void { - if (this.dirtySessionVersions.size === 0) { - this.stopCheckpointTimer() - } - } - - private scheduleCheckpointTimer(): void { - if ( - this.checkpointTimer || - !this.historyManager || - !this.supportsCheckpoints || - this.dirtySessionVersions.size === 0 - ) { - return - } - // Why: dirty-gate the timer — a permanent 5s interval woke the main process for idle terminals with nothing to write. - this.checkpointTimer = setTimeout(() => { - this.checkpointTimer = null - // Why: don't overlap checkpoint passes — concurrent tmp-file writes can lose a rename and disable future history writes. - if (this.checkpointInFlight) { - this.scheduleCheckpointTimer() - return - } - const checkpoint = this.checkpointDirtySessions() - this.checkpointInFlight = checkpoint - void checkpoint - .finally(() => { - if (this.checkpointInFlight === checkpoint) { - this.checkpointInFlight = null - this.scheduleCheckpointTimer() - } - }) - // Why: .finally() re-throws, so a rejected checkpoint would surface as an unhandled rejection here. - .catch(() => {}) - }, DaemonPtyAdapter.CHECKPOINT_INTERVAL_MS) - } - - private markSessionDirty(sessionId: string): void { - if (!this.activeSessionIds.has(sessionId)) { - return - } - this.dirtySessionVersions.set(sessionId, (this.dirtySessionVersions.get(sessionId) ?? 0) + 1) - this.scheduleCheckpointTimer() - } - - private async checkpointDirtySessions(): Promise { - if (!this.historyManager || this.dirtySessionVersions.size === 0) { - return - } - // Why: dirty-version filtering avoids re-serializing every idle session every 5s (CPU/disk on large workspaces) - // while not dropping writes that arrive mid-checkpoint. - const versions = new Map( - [...this.dirtySessionVersions].filter(([sessionId]) => this.activeSessionIds.has(sessionId)) - ) - if (versions.size === 0) { - this.dirtySessionVersions.clear() - this.stopCheckpointTimer() - return - } - const completed = await this.checkpointSessions(versions.keys()) - for (const [sessionId, version] of versions) { - if (completed.has(sessionId) && this.dirtySessionVersions.get(sessionId) === version) { - this.dirtySessionVersions.delete(sessionId) - } - } - this.stopCheckpointTimerIfIdle() - } - - /** False only when `callerDeadlineMs` expired first; the checkpoint itself keeps running. */ - private async runExclusiveCheckpoint( - operation: () => Promise, - options: { rescheduleDirty?: boolean; callerDeadlineMs?: number } = {} - ): Promise { - this.stopCheckpointTimer() - // Why: a promise tail keeps every waiter ordered; awaiting one active operation lets sibling waiters resume together. - const previous = this.checkpointInFlight ?? Promise.resolve() - const checkpoint = previous.catch(() => {}).then(operation) - this.checkpointInFlight = checkpoint - // Why the release rides the checkpoint instead of the caller's await: a caller that walks away - // at its deadline must leave this checkpoint as the tail, so the durable write still runs to - // completion, still commits, and the next waiter still queues behind it (STA-4228). - const settled = checkpoint.then( - () => this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty), - (err: unknown) => { - this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty) - throw err - } - ) - if (options.callerDeadlineMs === undefined) { - await settled - return true - } - return await awaitWithinCallerDeadline(settled, options.callerDeadlineMs) - } - - private releaseExclusiveCheckpoint( - checkpoint: Promise, - rescheduleDirty: boolean | undefined - ): void { - if (this.checkpointInFlight === checkpoint) { - this.checkpointInFlight = null - } - this.stopCheckpointTimer() - if (rescheduleDirty !== false) { - this.scheduleCheckpointTimer() - } - } - - // Why final=true not teardown: clean disconnect needs the full daemon-window snapshot as the restore source, but the - // detached daemon's PTYs keep running for warm reattach, so shell-ready scanner state must stay intact. - private async checkpointAllSessions(): Promise { - const completed = await this.checkpointSessions(this.activeSessionIds, { final: true }) - for (const sessionId of completed) { - this.dirtySessionVersions.delete(sessionId) - } - } - - private async checkpointSessions( - sessionIds: Iterable, - opts?: { final?: boolean; teardown?: boolean } - ): Promise> { - const completed = new Set() - if (!this.historyManager) { - return completed - } - const ids = Array.from(sessionIds) - let nextIndex = 0 - - const checkpointNext = async (): Promise => { - for (;;) { - // No worker in this pass awaits abandoned work, so a full admission set cannot open here. - if ( - opts?.final !== true && - this.nonFinalCheckpointAdmissionSessionIds.size >= MAX_CONCURRENT_CHECKPOINTS - ) { - const deferredSessionId = ids[nextIndex] - if (deferredSessionId !== undefined) { - this.reportNonFinalGlobalAdmissionDenial(deferredSessionId) - } - return - } - const index = nextIndex - nextIndex++ - if (index >= ids.length) { - return - } - const sessionId = ids[index] - await this.checkpointSession(sessionId, { - final: opts?.final === true, - teardown: opts?.teardown === true - }) - .then((result) => { - // Why: deferred sessions stay dirty so the checkpoint timer keeps retrying until their full-snapshot cooldown expires. - if (result === 'done') { - completed.add(sessionId) - } - }) - .catch((err) => console.warn('[history] checkpoint failed:', sessionId, err)) - } - } - // Why: snapshot/checkpoint writes are CPU/disk heavy; cap prevents one tick snapshotting every dirty terminal at once. - const workers = Array.from({ length: Math.min(MAX_CONCURRENT_CHECKPOINTS, ids.length) }, () => - checkpointNext() - ) - await Promise.all(workers) - return completed - } - - // Why cooldown starts only after the first full snapshot: a checkpoint-less session must be able to write one immediately. - private isFullCheckpointCoolingDown(sessionId: string): boolean { - const last = this.lastFullCheckpointAt.get(sessionId) - if (last === undefined) { - return false - } - const elapsed = Date.now() - last - // Why elapsed < 0 counts as expired: a backward wall-clock jump must not extend the deferral window. - return elapsed >= 0 && elapsed < DaemonPtyAdapter.FULL_CHECKPOINT_COOLDOWN_MS - } - - private async checkpointSession( - sessionId: string, - opts: { final: boolean; teardown: boolean } - ): Promise<'done' | 'deferred'> { - // Why final waits without a deadline: sleep/disconnect needs the last snapshot on disk, and - // deferring there would silently drop what the user left on screen rather than delay it. - if (opts.final) { - return await this.checkpointQueue.run(sessionId, () => - this.writeSessionCheckpoint(sessionId, opts) - ) - } - // Why 'deferred' is safe: the session stays dirty, so the operation that beat us to the queue - // still commits and the next tick retries this one. Nothing on disk is discarded. - if ( - this.checkpointQueue.isSaturated(sessionId) || - !this.tryAdmitNonFinalCheckpoint(sessionId) - ) { - return 'deferred' - } - const run = async (): Promise<'done' | 'deferred'> => { - try { - return await this.writeSessionCheckpoint(sessionId, opts) - } finally { - this.releaseNonFinalCheckpointAdmission(sessionId) - this.periodicDeadlineWarnedSessionIds.delete(sessionId) - } - } - return await this.checkpointQueue.runWithDeadline( - sessionId, - run, - DaemonPtyAdapter.PERIODIC_CHECKPOINT_DEADLINE_MS, - 'deferred', - { - onDeadline: () => { - if (!this.periodicDeadlineWarnedSessionIds.has(sessionId)) { - this.periodicDeadlineWarnedSessionIds.add(sessionId) - console.warn('[history] periodic checkpoint deadline exceeded:', sessionId) - } - }, - onAbandonedRejection: (error) => { - console.warn('[history] checkpoint failed:', sessionId, error) - } - } - ) - } - - // Why 'deferred' exists: a full snapshot inside the cooldown is postponed and the session stays dirty for retry; - // skipping append meanwhile keeps the on-disk log a consistent (stale) prefix instead of punching a hole. - private async writeSessionCheckpoint( - sessionId: string, - opts: { final: boolean; teardown: boolean } - ): Promise<'done' | 'deferred'> { - if (!this.supportsIncrementalCheckpoints) { - const result = await this.client.request('getSnapshot', { sessionId }) - if (result.snapshot && this.historyManager) { - const checkpoint = await this.historyManager.checkpoint(sessionId, result.snapshot) - return checkpoint === 'retryable' ? 'deferred' : 'done' - } - return 'done' - } - if (opts.final || this.sessionsNeedingFullCheckpoint.has(sessionId)) { - if (!opts.final && this.isFullCheckpointCoolingDown(sessionId)) { - return 'deferred' - } - // Why take-with-snapshot not plain getSnapshot: it clears pending records in the same turn as the serialize, - // so a warm reattach won't re-append records the checkpoint already contains (double-replay on cold restore). - const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { - teardown: opts.teardown, - forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId), - requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId) - }) - if (checkpoint.checkpoint === 'retryable') { - this.sessionsNeedingFullCheckpoint.add(sessionId) - return 'deferred' - } - this.sessionsNeedingFullCheckpoint.delete(sessionId) - this.sessionsNeedingLiveCheckpoint.delete(sessionId) - this.sessionsNeedingContinuityCheckpoint.delete(sessionId) - return 'done' - } - const take = await this.client.request('takePendingOutput', { - sessionId - }) - if (!take) { - return 'done' - } - if (take.overflowed) { - // Why: overflow dropped records (log has a hole); only a full snapshot can re-anchor it. - if (this.isFullCheckpointCoolingDown(sessionId)) { - this.sessionsNeedingFullCheckpoint.add(sessionId) - return 'deferred' - } - const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { - teardown: false, - forceLiveSnapshot: true - }) - if (checkpoint.checkpoint === 'retryable') { - this.sessionsNeedingFullCheckpoint.add(sessionId) - return 'deferred' - } - return 'done' - } - if (take.records.length === 0) { - return 'done' - } - if (!this.historyManager) { - return 'done' - } - const appendResult = await this.historyManager.appendIncrements( - sessionId, - take.seq, - take.records - ) - if (appendResult === 'needs-checkpoint') { - // Why dropping take.records is lossless: applied to the emulator before the take, so the snapshot below contains them. - if (this.isFullCheckpointCoolingDown(sessionId)) { - this.sessionsNeedingFullCheckpoint.add(sessionId) - return 'deferred' - } - const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { - teardown: false, - forceLiveSnapshot: true - }) - if (checkpoint.checkpoint === 'retryable') { - this.sessionsNeedingFullCheckpoint.add(sessionId) - return 'deferred' - } - } - return 'done' - } - - private async takeSnapshotAndCheckpoint( - sessionId: string, - opts: { - teardown: boolean - forceLiveSnapshot?: boolean - requireContinuityProof?: boolean - } - ): Promise { - const take = await this.client.request('takePendingOutput', { - sessionId, - includeSnapshot: true, - teardownSnapshot: opts.teardown - }) - if (take?.snapshot && this.historyManager) { - // Why require drainedRecords: an older daemon still empties the pending - // queue on includeSnapshot but omits the field. Treating absence as [] - // would compact stale disk history and reset the log. - const snapshot = - take.drainedRecords === undefined || opts.forceLiveSnapshot === true || take.overflowed - ? take.snapshot - : await this.buildDurableHistorySnapshot( - sessionId, - take.snapshot, - [...take.drainedRecords, ...take.records], - { - pendingRecordsAreComplete: take.seq === 1, - ...(opts.requireContinuityProof === true - ? { requiredPreviousPendingOutputSeq: take.seq - 1 } - : {}) - } - ) - const checkpoint = await this.historyManager.checkpoint(sessionId, snapshot, { - pendingOutputSeq: take.seq - }) - if (checkpoint === 'retryable') { - // Why take.records is dropped, not appended: the pending output this take drained went into the snapshot that - // failed to land, so appending the held tail at the next contiguous seq would splice it over that hole and - // defeat the log's seq-gap detection. A stale prefix beats an undetectable hole. - this.sessionsNeedingFullCheckpoint.add(sessionId) - this.sessionsNeedingLiveCheckpoint.add(sessionId) - this.sessionsNeedingContinuityCheckpoint.delete(sessionId) - this.markSessionDirty(sessionId) - return { checkpoint, snapshot: take.snapshot } - } - if (checkpoint === 'unavailable') { - this.sessionsNeedingFullCheckpoint.delete(sessionId) - this.sessionsNeedingLiveCheckpoint.delete(sessionId) - this.sessionsNeedingContinuityCheckpoint.delete(sessionId) - return { checkpoint, snapshot: take.snapshot } - } - this.lastFullCheckpointAt.set(sessionId, Date.now()) - if (take.records.length > 0 && snapshot === take.snapshot) { - // Why: live-window fallback still lacks held parser-state bytes; keep them as a post-checkpoint log tail. - await this.historyManager.appendIncrements(sessionId, take.seq, take.records) - } - this.sessionsNeedingLiveCheckpoint.delete(sessionId) - this.sessionsNeedingContinuityCheckpoint.delete(sessionId) - return { checkpoint: 'committed', snapshot } - } - this.sessionsNeedingFullCheckpoint.delete(sessionId) - this.sessionsNeedingLiveCheckpoint.delete(sessionId) - this.sessionsNeedingContinuityCheckpoint.delete(sessionId) - return { checkpoint: 'unavailable', snapshot: take?.snapshot ?? null } - } - - private async buildDurableHistorySnapshot( - sessionId: string, - liveSnapshot: NonNullable, - pendingRecords: TakePendingOutputResult['records'], - opts: { - pendingRecordsAreComplete: boolean - requiredPreviousPendingOutputSeq?: number - } - ): Promise> { - if (!this.historyReader) { - return liveSnapshot - } - try { - const restoreInfo = await this.historyReader.detectColdRestore(sessionId, { - ignoreCleanEnd: true, - wslDistro: this.wslDistrosBySessionId.get(sessionId) - }) - if ( - (!restoreInfo && !opts.pendingRecordsAreComplete) || - (opts.requiredPreviousPendingOutputSeq !== undefined && - restoreInfo?.pendingOutputSeq !== opts.requiredPreviousPendingOutputSeq) - ) { - console.warn('[history] durable continuity unproven; using live snapshot:', sessionId) - return liveSnapshot - } - return await buildDurableCheckpointSnapshot({ - liveSnapshot, - restoreInfo, - pendingRecords - }) - } catch (error) { - console.warn('[history] durable history rebuild failed:', sessionId, error) - return liveSnapshot - } - } - - // Why: the token read no longer throws, so audit its absence directly after an authenticated drop. - private isRetiredEndpointTokenMissing(): boolean { - return this.client.hasObservedAuthenticatedDisconnect() && !existsSync(this.tokenPath) - } - - private async withDaemonRetry(fn: () => Promise): Promise { - try { - return await fn() - } catch (err) { - const missingRetiredEndpointToken = this.isRetiredEndpointTokenMissing() - if (missingRetiredEndpointToken) { - this.observeAuditFailure( - 'token_missing_after_authenticated_disconnect', - this.exactDaemonIncarnation, - ['token_file'] - ) - } - if (this.respawnAdoptionClosed || !this.respawnFn || !isDaemonGoneError(err)) { - throw err - } - if (!this.respawnPromise) { - this.respawnPromise = this.doRespawn().finally(() => { - this.respawnPromise = null - }) - } - await this.respawnPromise - try { - return await fn() - } finally { - // Why: the retried op may reject before any connection attempt (e.g. a tombstone racing respawn). - this.releasePendingRespawnAdoptionLease() - } - } - } - - private reconnectAfterWriteFailure(): void { - if ( - this.writeRecoveryPromise || - this.writeRecoveryAttempted || - this.respawnAdoptionClosed || - !this.respawnFn - ) { - return - } - this.writeRecoveryAttempted = true - // Why: the dead endpoint took down every session on this daemon. Signal all - // active panes now — while they are still in activeSessionIds, so the - // renderer's liveness gate still reads them live — so background panes - // remount + re-attach alongside the one that was written, instead of being - // left frozen with silently dropped input until each is typed into. - this.notifyActiveSessionsWriteUnavailable() - const recovery = this.withDaemonRetry(() => this.ensureConnected()) - .catch((error) => console.warn('[daemon] Failed to recover after rejected PTY input:', error)) - .finally(() => { - this.releasePendingRespawnAdoptionLease() - if (this.writeRecoveryPromise === recovery) { - this.writeRecoveryPromise = null - } - }) - this.writeRecoveryPromise = recovery - } - - private notifyActiveSessionsWriteUnavailable(): void { - // Snapshot first: a listener that kills a pane would mutate activeSessionIds - // mid-iteration and silently skip the sibling this fan-out exists to reach. - const ids = [...this.activeSessionIds] - for (const id of ids) { - this.sessionsAwaitingDaemonRecovery.add(id) - this.emitWriteUnavailable(id) - } - } - - private clearSessionAwaitingDaemonRecovery(sessionId: string): void { - this.sessionsAwaitingDaemonRecovery.delete(sessionId) - if (this.sessionsAwaitingDaemonRecovery.size === 0) { - this.writeRecoveryAttempted = false - } - } - - private async withHistorySpawnLock( - sessionId: string, - operation: () => Promise - ): Promise { - if (!this.historyManager) { - return await operation() - } - const previous = this.historySpawnLocks.get(sessionId) ?? Promise.resolve() - let release!: () => void - const current = new Promise((resolve) => { - release = resolve - }) - const tail = previous.then( - () => current, - () => current - ) - this.historySpawnLocks.set(sessionId, tail) - await previous.catch(() => {}) - try { - return await operation() - } finally { - release() - if (this.historySpawnLocks.get(sessionId) === tail) { - this.historySpawnLocks.delete(sessionId) - } - } - } - - private async replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise { - if (!this.respawnFn) { - return - } - - const health = await getMacDaemonSystemResolverHealth( - this.socketPath, - this.tokenPath, - this.protocolVersion - ) - if (health !== 'unhealthy') { - return - } - - const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() - const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) - if (daemonLiveSessionCount === null || liveSessionCount > 0) { - console.warn( - daemonLiveSessionCount === null - ? '[daemon] macOS system resolver unavailable - preserving daemon because live session state could not be verified' - : `[daemon] macOS system resolver unavailable - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` - ) - return - } - - // Why: replacing the daemon kills its sessions without exit fanout; emit exits first so panes don't write to dead PTYs. - this.fanoutSyntheticExits(-1) - if (!this.respawnPromise) { - this.respawnPromise = this.doRespawn( - '[daemon] macOS system resolver unavailable - respawning daemon', - 'unhealthy_resolver' - ).finally(() => { - this.respawnPromise = null - }) - } - await this.respawnPromise - } - - /** Replace a stale packaged daemon only after its live sessions drain. */ - private async replaceStaleBundleDaemonBeforeNewPty(): Promise { - if (!this.respawnFn || !this.runtimeDir || !this.packagedAppVersion) { - return - } - if (!this.staleBundleReplacementPromise) { - this.staleBundleReplacementPromise = this.replaceStaleBundleDaemonOnce( - this.runtimeDir, - this.packagedAppVersion - ).finally(() => { - this.staleBundleReplacementPromise = null - }) - } - await this.staleBundleReplacementPromise - } - - private async replaceStaleBundleDaemonOnce( - runtimeDir: string, - packagedAppVersion: string - ): Promise { - const stale = await isDaemonStaleForCurrentBundle( - runtimeDir, - this.socketPath, - this.tokenPath, - packagedAppVersion, - this.protocolVersion - ) - if (!stale) { - return - } - - const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() - const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) - if (daemonLiveSessionCount === null || liveSessionCount > 0) { - console.warn( - daemonLiveSessionCount === null - ? '[daemon] Packaged daemon is stale - preserving it because live session state could not be verified' - : `[daemon] Packaged daemon is stale - preserving it because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` - ) - return - } - - this.fanoutSyntheticExits(-1) - if (!this.respawnPromise) { - this.respawnPromise = this.doRespawn( - '[daemon] Packaged daemon is stale - respawning from the current app bundle', - 'stale_bundle' - ).finally(() => { - this.respawnPromise = null - }) - } - await this.respawnPromise - } - - /** Replace a TCC-severed daemon only after its live sessions drain. */ - private async replaceSeveredMacTccDaemonBeforeNewPty(): Promise { - // Why no platform gate: getMacDaemonTccAttributionHealth returns 'unknown' off macOS. - if (!this.respawnFn || !this.runtimeDir) { - return - } - - const health = await getMacDaemonTccAttributionHealth( - this.runtimeDir, - this.socketPath, - this.tokenPath, - this.protocolVersion - ) - if (health !== 'severed') { - return - } - - const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() - const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) - if (daemonLiveSessionCount === null || liveSessionCount > 0) { - console.warn( - daemonLiveSessionCount === null - ? '[daemon] macOS TCC attribution severed - preserving daemon because live session state could not be verified' - : `[daemon] macOS TCC attribution severed - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}; restart from Manage Sessions when ready` - ) - return - } - - this.fanoutSyntheticExits(-1) - if (!this.respawnPromise) { - this.respawnPromise = this.doRespawn( - '[daemon] macOS TCC attribution severed - respawning daemon under the current app binary', - 'severed_tcc_attribution' - ).finally(() => { - this.respawnPromise = null - }) - } - await this.respawnPromise - } - - private async getDaemonLiveSessionCount(): Promise { - try { - await this.client.ensureConnected() - const result = await this.client.request('listSessions', undefined) - return result.sessions.filter((session) => session.isAlive).length - } catch { - return null - } - } - - private emitBackgroundStreamEvent(payload: PtyBackgroundStreamEvent): void { - // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration - for (const listener of [...this.backgroundStreamListeners]) { - listener(payload) - } - } - - private async doRespawn( - message = '[daemon] Daemon died — respawning', - reason: DaemonRespawnReason = 'daemon_died' - ): Promise { - console.warn(message) - this.removeEventListener?.() - this.removeEventListener = null - this.client.disconnect() - const releaseAdoptionLease = await this.respawnFn!(reason) - if (this.respawnAdoptionClosed) { - // Why: app teardown may win mid-respawn; a late result must not reinstall a lease nobody owns. - releaseAdoptionLease?.() - throw new Error('Daemon adapter closed during respawn') - } - this.pendingRespawnAdoptionRelease = releaseAdoptionLease ?? null - } - - private releasePendingRespawnAdoptionLease(): void { - const release = this.pendingRespawnAdoptionRelease - this.pendingRespawnAdoptionRelease = null - release?.() - } - - private setupEventRouting(): void { +export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyProvider { + protected setupEventRouting(): void { if (this.removeEventListener) { return } @@ -2927,12 +50,8 @@ export class DaemonPtyAdapter implements IPtyProvider { : { sequenceChars: event.payload.sequenceChars }) }) } else if (event.event === 'transientFact') { - // Why (#9993): belt-and-braces behind the setPtyBackgrounded gate. A pre-v29 - // daemon is never asked to background, so it should emit no transient facts at - // all — but one preserved across a reconnect could still have a stale relay - // tracker. An unretractable subscribe is the harmful direction, so drop it. - // An unsubscribe is always forwarded: retiring a subscription main registered - // can only ever help, never strand one. + // Why (#9993): a preserved pre-v29 daemon can retain a stale relay tracker. Its + // unretractable subscribe is harmful; an unsubscribe is always safe to forward. if ( event.payload.kind === '2031-subscribe' && !supportsMode2031UnsubscribeFact(this.protocolVersion) @@ -2971,21 +90,18 @@ export class DaemonPtyAdapter implements IPtyProvider { this.activeSessionIds.delete(event.sessionId) this.clearSessionAwaitingDaemonRecovery(event.sessionId) this.dirtySessionVersions.delete(event.sessionId) - // Why: a reused sessionId must not inherit the dead session's owed resume (stray resumePty) or backgrounded/thinned state. this.pausedProducerSessionIds.delete(event.sessionId) this.producerResumesOwedOnReconnect.delete(event.sessionId) this.backgroundedSessionIds.delete(event.sessionId) if (!this.sleepRestoreSessionIds.has(event.sessionId)) { this.coldRestoreCache.delete(event.sessionId) } - // Why: an exited session can't be checkpointed again; clearing its pending-full flag prevents a permanent leak. this.sessionsNeedingFullCheckpoint.delete(event.sessionId) this.sessionsNeedingLiveCheckpoint.delete(event.sessionId) this.sessionsNeedingContinuityCheckpoint.delete(event.sessionId) this.overlayDeadlineWarnedSessionIds.delete(event.sessionId) this.periodicDeadlineWarnedSessionIds.delete(event.sessionId) this.nonFinalAdmissionDeniedSessionIds.delete(event.sessionId) - // Why: a reused sessionId (renderer respawns a persisted ptyId) must not inherit the dead session's snapshot cooldown. this.lastFullCheckpointAt.delete(event.sessionId) this.stopCheckpointTimerIfIdle() if (this.historyManager) { @@ -3019,104 +135,3 @@ export class DaemonPtyAdapter implements IPtyProvider { return result.appliedSeq } } - -function sameEndpointIdentity( - left: DaemonEndpointIdentity, - right: DaemonEndpointIdentity -): boolean { - return ( - left.pid === right.pid && - left.startedAtMs === right.startedAtMs && - left.launchNonce === right.launchNonce - ) -} - -function exactDaemonIncarnationForPidRecord( - identity: DaemonEndpointIdentity, - pidRecord: ParsedDaemonPid | null -): ExactDaemonIncarnation { - return { - identity: { ...identity }, - ...(process.platform === 'linux' && - pidRecord?.pid === identity.pid && - pidRecord.startedAtMs === identity.startedAtMs && - pidRecord.launchNonce === identity.launchNonce && - pidRecord.linuxStartTicks && - pidRecord.bootId - ? { - linuxStartTicks: pidRecord.linuxStartTicks, - bootId: pidRecord.bootId - } - : {}) - } -} - -function readDaemonPidRecord(pidPath: string | null): ParsedDaemonPid | null { - if (!pidPath) { - return null - } - try { - return parseDaemonPidFile(readFileSync(pidPath, 'utf8')) - } catch { - return null - } -} - -function removeListener(listeners: T[], listener: T): void { - const index = listeners.indexOf(listener) - if (index !== -1) { - listeners.splice(index, 1) - } -} - -function notifyAuditListeners(listeners: readonly ((value: T) => void)[], value: T): void { - for (const listener of listeners.slice()) { - try { - listener(value) - } catch { - // Audit observers cannot affect daemon operations. - } - } -} - -/** - * Narrow on purpose: only the daemon's own reply for a request type it does not implement. - * A transient failure must stay unproven rather than be mistaken for a missing capability. - * The server throws `Unknown request type: `; the client rejects with that text, which - * `addNodePtyRecoveryHint` only ever prepends to. - */ -function isUnknownRequestTypeError(err: unknown): boolean { - return err instanceof Error && err.message.includes('Unknown request type') -} - -// Why: syscall='connect' distinguishes a dead-socket ENOENT/ECONNREFUSED from token-file ENOENT (no syscall); -// message strings incl. wedged-daemon "Hello response timed out" (#8689) also warrant a respawn. -/** Exported so a test can pin it against the server's refusal wording, which it must match. */ -export function isDaemonGoneError(err: unknown): boolean { - if (!(err instanceof Error)) { - return false - } - const errno = err as NodeJS.ErrnoException - if ((errno.code === 'ENOENT' || errno.code === 'ECONNREFUSED') && errno.syscall === 'connect') { - return true - } - const msg = err.message - return ( - msg === 'Connection lost' || - msg === 'Not connected' || - msg === 'Hello response timed out' || - // Both the daemon's own drain refusal and the client's wedged-daemon signal. - msg === DAEMON_UNAVAILABLE_RECONNECT_MESSAGE || - // Why retry: the daemon refused because the endpoint now resolves elsewhere. Reconnecting - // reaches whoever owns it; surfacing this to the user would strand the request instead. - msg === DAEMON_ENDPOINT_LOST_MESSAGE - ) -} - -function isMissingWindowsNamedPipeError(err: unknown): boolean { - if (process.platform !== 'win32' || !(err instanceof Error)) { - return false - } - const errno = err as NodeJS.ErrnoException - return errno.code === 'ENOENT' && errno.syscall === 'connect' -} diff --git a/src/main/daemon/daemon-pty-buffer-snapshots.ts b/src/main/daemon/daemon-pty-buffer-snapshots.ts new file mode 100644 index 00000000000..cf65d85fa61 --- /dev/null +++ b/src/main/daemon/daemon-pty-buffer-snapshots.ts @@ -0,0 +1,202 @@ +import { normalizeDesktopTerminalSnapshotRows } from '../../shared/terminal-scrollback-policy' +import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' +import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot' +import { DaemonPtySessionControl } from './daemon-pty-session-control' +import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth' +import { DAEMON_SESSION_SCROLLBACK_ROWS } from './daemon-session-scrollback-window' +import type { GetSnapshotResult } from './types' +import type { PtyProviderBufferSnapshot } from '../providers/types' + +const DURABLE_HISTORY_OVERLAY_DEADLINE_MS = 5_000 + +export abstract class DaemonPtyBufferSnapshots extends DaemonPtySessionControl { + async getBufferSnapshot( + id: string, + opts: { scrollbackRows?: number } = {} + ): Promise { + if (!this.supportsAuthoritativeBufferSnapshots) { + return null + } + try { + const scrollbackRows = normalizeDesktopTerminalSnapshotRows(opts.scrollbackRows) + const result = await this.client.request('getSnapshot', { + sessionId: id, + ...(scrollbackRows !== undefined ? { scrollbackRows } : {}) + }) + const snapshot = result.snapshot + // Why: older v19 daemons lack an absolute output sequence, so their snapshot can't reconcile bytes queued on the other socket. + if (!snapshot || typeof snapshot.outputSequence !== 'number') { + return null + } + const restored = + this.historyManager && + this.historyReader && + (scrollbackRows === undefined || scrollbackRows > DAEMON_SESSION_SCROLLBACK_ROWS) + ? await this.overlayDurableRestoreSnapshot(id, snapshot, scrollbackRows) + : snapshot + return this.toProviderBufferSnapshot(restored) + } catch { + return null + } + } + + protected toProviderBufferSnapshot( + snapshot: NonNullable + ): PtyProviderBufferSnapshot | null { + if (typeof snapshot.outputSequence !== 'number') { + return null + } + const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.modes.kittyKeyboardFlags) + return { + data: snapshot.rehydrateSequences + snapshot.snapshotAnsi, + frameRestoreAnsi: snapshot.frameRestoreAnsi, + scrollbackAnsi: snapshot.scrollbackAnsi, + cols: snapshot.cols, + rows: snapshot.rows, + cwd: snapshot.cwd, + lastTitle: snapshot.lastTitle, + seq: snapshot.outputSequence, + source: 'headless', + oscLinks: snapshot.oscLinks, + alternateScreen: snapshot.modes.alternateScreen, + // Why known `0` is carried too: it proves the app negotiated nothing at + // this boundary, which is a different fact from a source that cannot say. + ...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}), + ...(snapshot.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } + : {}) + } + } + + protected async overlayDurableRestoreSnapshot( + sessionId: string, + liveSnapshot: NonNullable, + scrollbackRows?: number + ): Promise> { + if (!this.historyManager || !this.historyReader) { + return liveSnapshot + } + // Why turn the caller away instead of queueing: this session already has a + // compact in flight whose result a third one would only duplicate, and an + // unbounded queue is how a stalled history filesystem grows without limit. + if (this.checkpointQueue.isSaturated(sessionId)) { + return liveSnapshot + } + // Why reserve before enqueueing: pane mounts can arrive in one turn, before any compact starts. + // Count abandoned waits until their writes settle so a relaunch cannot fan out unbounded work. + if (!this.tryAdmitNonFinalCheckpoint(sessionId)) { + return liveSnapshot + } + // Why per session with a deadline: a reattach is a user click, so it must wait + // on this session's own compact and nothing else. A blown deadline does not + // cancel that compact — it keeps running and still commits — so the fallback + // costs restore depth for this one reattach, never durable history (STA-4173). + return await this.checkpointQueue.runWithDeadline( + sessionId, + async () => { + try { + return await this.compactDurableRestoreSnapshot(sessionId, liveSnapshot, scrollbackRows) + } finally { + this.releaseNonFinalCheckpointAdmission(sessionId) + this.overlayDeadlineWarnedSessionIds.delete(sessionId) + } + }, + DURABLE_HISTORY_OVERLAY_DEADLINE_MS, + liveSnapshot, + { + onDeadline: () => { + if (!this.overlayDeadlineWarnedSessionIds.has(sessionId)) { + this.overlayDeadlineWarnedSessionIds.add(sessionId) + console.warn('[history] durable snapshot overlay deadline exceeded:', sessionId) + } + } + } + ) + } + + protected tryAdmitNonFinalCheckpoint(sessionId: string): boolean { + if (this.nonFinalCheckpointAdmissionSessionIds.has(sessionId)) { + if (!this.nonFinalAdmissionDeniedSessionIds.has(sessionId)) { + this.nonFinalAdmissionDeniedSessionIds.add(sessionId) + console.warn('[history] non-final checkpoint already in flight:', sessionId) + } + return false + } + if ( + this.nonFinalCheckpointAdmissionSessionIds.size >= + (this.constructor as typeof DaemonPtyBufferSnapshots).MAX_CONCURRENT_CHECKPOINTS + ) { + this.reportNonFinalGlobalAdmissionDenial(sessionId) + return false + } + this.nonFinalAdmissionDeniedSessionIds.delete(sessionId) + this.nonFinalCheckpointAdmissionSessionIds.add(sessionId) + return true + } + + protected reportNonFinalGlobalAdmissionDenial(sessionId: string): void { + if (!this.nonFinalGlobalAdmissionWarningActive) { + this.nonFinalGlobalAdmissionWarningActive = true + console.warn('[history] non-final checkpoint global admission limit reached:', sessionId) + } + } + + protected releaseNonFinalCheckpointAdmission(sessionId: string): void { + if (this.nonFinalCheckpointAdmissionSessionIds.delete(sessionId)) { + this.nonFinalAdmissionDeniedSessionIds.delete(sessionId) + this.nonFinalGlobalAdmissionWarningActive = false + } + } + + protected async compactDurableRestoreSnapshot( + sessionId: string, + liveSnapshot: NonNullable, + scrollbackRows?: number + ): Promise> { + if (!this.historyReader) { + return liveSnapshot + } + try { + // Why compact before reading: an independent take/append races the 5s tick, can + // seq-gap the log, and would remount a stale checkpoint over the live window. + const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { + teardown: false, + forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId), + requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId) + }) + if (checkpoint.checkpoint === 'committed') { + this.sessionsNeedingFullCheckpoint.delete(sessionId) + } + if (checkpoint.checkpoint !== 'committed' || !checkpoint.snapshot) { + return checkpoint.snapshot ?? liveSnapshot + } + if (scrollbackRows === undefined || scrollbackRows >= DAEMON_RESTORE_SCROLLBACK_ROWS) { + return checkpoint.snapshot + } + const restoreInfo = await this.historyReader.detectColdRestore(sessionId, { + ignoreCleanEnd: true, + wslDistro: this.wslDistrosBySessionId.get(sessionId) + }) + if (!restoreInfo) { + return liveSnapshot + } + return await buildDurableCheckpointSnapshot({ + liveSnapshot: checkpoint.snapshot, + restoreInfo, + scrollbackRows + }) + } catch (error) { + console.warn('[history] durable snapshot overlay failed:', sessionId, error) + return liveSnapshot + } + } + + async clearBuffer(id: string): Promise { + await this.client.request('clearScrollback', { sessionId: id }) + this.markSessionDirty(id) + } + + acknowledgeDataEvent(_id: string, _charCount: number): void { + // No flow control for daemon-backed terminals + } +} diff --git a/src/main/daemon/daemon-pty-checkpoint-persistence.ts b/src/main/daemon/daemon-pty-checkpoint-persistence.ts new file mode 100644 index 00000000000..ed8ffd3f26f --- /dev/null +++ b/src/main/daemon/daemon-pty-checkpoint-persistence.ts @@ -0,0 +1,192 @@ +import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot' +import { DaemonPtyCheckpointScheduler } from './daemon-pty-checkpoint-scheduler' +import type { SnapshotCheckpointResult } from './daemon-pty-runtime-state' +import type { GetSnapshotResult, TakePendingOutputResult } from './types' + +export abstract class DaemonPtyCheckpointPersistence extends DaemonPtyCheckpointScheduler { + // Why 'deferred' exists: a full snapshot inside the cooldown is postponed and the session stays dirty for retry; + // skipping append meanwhile keeps the on-disk log a consistent (stale) prefix instead of punching a hole. + protected async writeSessionCheckpoint( + sessionId: string, + opts: { final: boolean; teardown: boolean } + ): Promise<'done' | 'deferred'> { + if (!this.supportsIncrementalCheckpoints) { + const result = await this.client.request('getSnapshot', { sessionId }) + if (result.snapshot && this.historyManager) { + const checkpoint = await this.historyManager.checkpoint(sessionId, result.snapshot) + return checkpoint === 'retryable' ? 'deferred' : 'done' + } + return 'done' + } + if (opts.final || this.sessionsNeedingFullCheckpoint.has(sessionId)) { + if (!opts.final && this.isFullCheckpointCoolingDown(sessionId)) { + return 'deferred' + } + // Why take-with-snapshot not plain getSnapshot: it clears pending records in the same turn as the serialize, + // so a warm reattach won't re-append records the checkpoint already contains (double-replay on cold restore). + const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { + teardown: opts.teardown, + forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId), + requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId) + }) + if (checkpoint.checkpoint === 'retryable') { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } + this.sessionsNeedingFullCheckpoint.delete(sessionId) + this.sessionsNeedingLiveCheckpoint.delete(sessionId) + this.sessionsNeedingContinuityCheckpoint.delete(sessionId) + return 'done' + } + const take = await this.client.request('takePendingOutput', { + sessionId + }) + if (!take) { + return 'done' + } + if (take.overflowed) { + // Why: overflow dropped records (log has a hole); only a full snapshot can re-anchor it. + if (this.isFullCheckpointCoolingDown(sessionId)) { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } + const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { + teardown: false, + forceLiveSnapshot: true + }) + if (checkpoint.checkpoint === 'retryable') { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } + return 'done' + } + if (take.records.length === 0) { + return 'done' + } + if (!this.historyManager) { + return 'done' + } + const appendResult = await this.historyManager.appendIncrements( + sessionId, + take.seq, + take.records + ) + if (appendResult === 'needs-checkpoint') { + // Why dropping take.records is lossless: applied to the emulator before the take, so the snapshot below contains them. + if (this.isFullCheckpointCoolingDown(sessionId)) { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } + const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { + teardown: false, + forceLiveSnapshot: true + }) + if (checkpoint.checkpoint === 'retryable') { + this.sessionsNeedingFullCheckpoint.add(sessionId) + return 'deferred' + } + } + return 'done' + } + + protected async takeSnapshotAndCheckpoint( + sessionId: string, + opts: { + teardown: boolean + forceLiveSnapshot?: boolean + requireContinuityProof?: boolean + } + ): Promise { + const take = await this.client.request('takePendingOutput', { + sessionId, + includeSnapshot: true, + teardownSnapshot: opts.teardown + }) + if (take?.snapshot && this.historyManager) { + // Why require drainedRecords: an older daemon still empties the pending + // queue on includeSnapshot but omits the field. Treating absence as [] + // would compact stale disk history and reset the log. + const snapshot = + take.drainedRecords === undefined || opts.forceLiveSnapshot === true || take.overflowed + ? take.snapshot + : await this.buildDurableHistorySnapshot( + sessionId, + take.snapshot, + [...take.drainedRecords, ...take.records], + { + pendingRecordsAreComplete: take.seq === 1, + ...(opts.requireContinuityProof === true + ? { requiredPreviousPendingOutputSeq: take.seq - 1 } + : {}) + } + ) + const checkpoint = await this.historyManager.checkpoint(sessionId, snapshot, { + pendingOutputSeq: take.seq + }) + if (checkpoint === 'retryable') { + // Why take.records is dropped, not appended: the pending output this take drained went into the snapshot that + // failed to land, so appending the held tail at the next contiguous seq would splice it over that hole and + // defeat the log's seq-gap detection. A stale prefix beats an undetectable hole. + this.sessionsNeedingFullCheckpoint.add(sessionId) + this.sessionsNeedingLiveCheckpoint.add(sessionId) + this.sessionsNeedingContinuityCheckpoint.delete(sessionId) + this.markSessionDirty(sessionId) + return { checkpoint, snapshot: take.snapshot } + } + if (checkpoint === 'unavailable') { + this.sessionsNeedingFullCheckpoint.delete(sessionId) + this.sessionsNeedingLiveCheckpoint.delete(sessionId) + this.sessionsNeedingContinuityCheckpoint.delete(sessionId) + return { checkpoint, snapshot: take.snapshot } + } + this.lastFullCheckpointAt.set(sessionId, Date.now()) + if (take.records.length > 0 && snapshot === take.snapshot) { + // Why: live-window fallback still lacks held parser-state bytes; keep them as a post-checkpoint log tail. + await this.historyManager.appendIncrements(sessionId, take.seq, take.records) + } + this.sessionsNeedingLiveCheckpoint.delete(sessionId) + this.sessionsNeedingContinuityCheckpoint.delete(sessionId) + return { checkpoint: 'committed', snapshot } + } + this.sessionsNeedingFullCheckpoint.delete(sessionId) + this.sessionsNeedingLiveCheckpoint.delete(sessionId) + this.sessionsNeedingContinuityCheckpoint.delete(sessionId) + return { checkpoint: 'unavailable', snapshot: take?.snapshot ?? null } + } + + protected async buildDurableHistorySnapshot( + sessionId: string, + liveSnapshot: NonNullable, + pendingRecords: TakePendingOutputResult['records'], + opts: { + pendingRecordsAreComplete: boolean + requiredPreviousPendingOutputSeq?: number + } + ): Promise> { + if (!this.historyReader) { + return liveSnapshot + } + try { + const restoreInfo = await this.historyReader.detectColdRestore(sessionId, { + ignoreCleanEnd: true, + wslDistro: this.wslDistrosBySessionId.get(sessionId) + }) + if ( + (!restoreInfo && !opts.pendingRecordsAreComplete) || + (opts.requiredPreviousPendingOutputSeq !== undefined && + restoreInfo?.pendingOutputSeq !== opts.requiredPreviousPendingOutputSeq) + ) { + console.warn('[history] durable continuity unproven; using live snapshot:', sessionId) + return liveSnapshot + } + return await buildDurableCheckpointSnapshot({ + liveSnapshot, + restoreInfo, + pendingRecords + }) + } catch (error) { + console.warn('[history] durable history rebuild failed:', sessionId, error) + return liveSnapshot + } + } +} diff --git a/src/main/daemon/daemon-pty-checkpoint-scheduler.ts b/src/main/daemon/daemon-pty-checkpoint-scheduler.ts new file mode 100644 index 00000000000..56561f88a6c --- /dev/null +++ b/src/main/daemon/daemon-pty-checkpoint-scheduler.ts @@ -0,0 +1,251 @@ +import { awaitDaemonWorkWithinCallerDeadline } from './daemon-request-deadline' +import { DaemonPtyConnectionLifecycle } from './daemon-pty-connection-lifecycle' + +export abstract class DaemonPtyCheckpointScheduler extends DaemonPtyConnectionLifecycle { + protected stopCheckpointTimer(): void { + if (!this.checkpointTimer) { + return + } + clearTimeout(this.checkpointTimer) + this.checkpointTimer = null + } + + protected stopCheckpointTimerIfIdle(): void { + if (this.dirtySessionVersions.size === 0) { + this.stopCheckpointTimer() + } + } + + protected scheduleCheckpointTimer(): void { + if ( + this.checkpointTimer || + !this.historyManager || + !this.supportsCheckpoints || + this.dirtySessionVersions.size === 0 + ) { + return + } + // Why: dirty-gate the timer — a permanent 5s interval woke the main process for idle terminals with nothing to write. + this.checkpointTimer = setTimeout( + () => { + this.checkpointTimer = null + // Why: don't overlap checkpoint passes — concurrent tmp-file writes can lose a rename and disable future history writes. + if (this.checkpointInFlight) { + this.scheduleCheckpointTimer() + return + } + const checkpoint = this.checkpointDirtySessions() + this.checkpointInFlight = checkpoint + void checkpoint + .finally(() => { + if (this.checkpointInFlight === checkpoint) { + this.checkpointInFlight = null + this.scheduleCheckpointTimer() + } + }) + // Why: .finally() re-throws, so a rejected checkpoint would surface as an unhandled rejection here. + .catch(() => {}) + }, + (this.constructor as typeof DaemonPtyCheckpointScheduler).CHECKPOINT_INTERVAL_MS + ) + } + + protected markSessionDirty(sessionId: string): void { + if (!this.activeSessionIds.has(sessionId)) { + return + } + this.dirtySessionVersions.set(sessionId, (this.dirtySessionVersions.get(sessionId) ?? 0) + 1) + this.scheduleCheckpointTimer() + } + + protected async checkpointDirtySessions(): Promise { + if (!this.historyManager || this.dirtySessionVersions.size === 0) { + return + } + // Why: dirty-version filtering avoids re-serializing every idle session every 5s (CPU/disk on large workspaces) + // while not dropping writes that arrive mid-checkpoint. + const versions = new Map( + [...this.dirtySessionVersions].filter(([sessionId]) => this.activeSessionIds.has(sessionId)) + ) + if (versions.size === 0) { + this.dirtySessionVersions.clear() + this.stopCheckpointTimer() + return + } + const completed = await this.checkpointSessions(versions.keys()) + for (const [sessionId, version] of versions) { + if (completed.has(sessionId) && this.dirtySessionVersions.get(sessionId) === version) { + this.dirtySessionVersions.delete(sessionId) + } + } + this.stopCheckpointTimerIfIdle() + } + + /** False only when `callerDeadlineMs` expired first; the checkpoint itself keeps running. */ + protected async runExclusiveCheckpoint( + operation: () => Promise, + options: { rescheduleDirty?: boolean; callerDeadlineMs?: number } = {} + ): Promise { + this.stopCheckpointTimer() + // Why: a promise tail keeps every waiter ordered; awaiting one active operation lets sibling waiters resume together. + const previous = this.checkpointInFlight ?? Promise.resolve() + const checkpoint = previous.catch(() => {}).then(operation) + this.checkpointInFlight = checkpoint + // Why the release rides the checkpoint instead of the caller's await: a caller that walks away + // at its deadline must leave this checkpoint as the tail, so the durable write still runs to + // completion, still commits, and the next waiter still queues behind it (STA-4228). + const settled = checkpoint.then( + () => this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty), + (err: unknown) => { + this.releaseExclusiveCheckpoint(checkpoint, options.rescheduleDirty) + throw err + } + ) + if (options.callerDeadlineMs === undefined) { + await settled + return true + } + return await awaitDaemonWorkWithinCallerDeadline(settled, options.callerDeadlineMs) + } + + protected releaseExclusiveCheckpoint( + checkpoint: Promise, + rescheduleDirty: boolean | undefined + ): void { + if (this.checkpointInFlight === checkpoint) { + this.checkpointInFlight = null + } + this.stopCheckpointTimer() + if (rescheduleDirty !== false) { + this.scheduleCheckpointTimer() + } + } + + // Why final=true not teardown: clean disconnect needs the full daemon-window snapshot as the restore source, but the + // detached daemon's PTYs keep running for warm reattach, so shell-ready scanner state must stay intact. + protected async checkpointAllSessions(): Promise { + const completed = await this.checkpointSessions(this.activeSessionIds, { final: true }) + for (const sessionId of completed) { + this.dirtySessionVersions.delete(sessionId) + } + } + + protected async checkpointSessions( + sessionIds: Iterable, + opts?: { final?: boolean; teardown?: boolean } + ): Promise> { + const completed = new Set() + if (!this.historyManager) { + return completed + } + const ids = Array.from(sessionIds) + let nextIndex = 0 + + const checkpointNext = async (): Promise => { + for (;;) { + // No worker in this pass awaits abandoned work, so a full admission set cannot open here. + if ( + opts?.final !== true && + this.nonFinalCheckpointAdmissionSessionIds.size >= + (this.constructor as typeof DaemonPtyCheckpointScheduler).MAX_CONCURRENT_CHECKPOINTS + ) { + const deferredSessionId = ids[nextIndex] + if (deferredSessionId !== undefined) { + this.reportNonFinalGlobalAdmissionDenial(deferredSessionId) + } + return + } + const index = nextIndex + nextIndex++ + if (index >= ids.length) { + return + } + const sessionId = ids[index] + await this.checkpointSession(sessionId, { + final: opts?.final === true, + teardown: opts?.teardown === true + }) + .then((result) => { + // Why: deferred sessions stay dirty so the checkpoint timer keeps retrying until their full-snapshot cooldown expires. + if (result === 'done') { + completed.add(sessionId) + } + }) + .catch((err) => console.warn('[history] checkpoint failed:', sessionId, err)) + } + } + // Why: snapshot/checkpoint writes are CPU/disk heavy; cap prevents one tick snapshotting every dirty terminal at once. + const workers = Array.from( + { + length: Math.min( + (this.constructor as typeof DaemonPtyCheckpointScheduler).MAX_CONCURRENT_CHECKPOINTS, + ids.length + ) + }, + () => checkpointNext() + ) + await Promise.all(workers) + return completed + } + + // Why cooldown starts only after the first full snapshot: a checkpoint-less session must be able to write one immediately. + protected isFullCheckpointCoolingDown(sessionId: string): boolean { + const last = this.lastFullCheckpointAt.get(sessionId) + if (last === undefined) { + return false + } + const elapsed = Date.now() - last + // Why elapsed < 0 counts as expired: a backward wall-clock jump must not extend the deferral window. + return ( + elapsed >= 0 && + elapsed < + (this.constructor as typeof DaemonPtyCheckpointScheduler).FULL_CHECKPOINT_COOLDOWN_MS + ) + } + + protected async checkpointSession( + sessionId: string, + opts: { final: boolean; teardown: boolean } + ): Promise<'done' | 'deferred'> { + // Why final waits without a deadline: sleep/disconnect needs the last snapshot on disk, and + // deferring there would silently drop what the user left on screen rather than delay it. + if (opts.final) { + return await this.checkpointQueue.run(sessionId, () => + this.writeSessionCheckpoint(sessionId, opts) + ) + } + // Why 'deferred' is safe: the session stays dirty, so the operation that beat us to the queue + // still commits and the next tick retries this one. Nothing on disk is discarded. + if ( + this.checkpointQueue.isSaturated(sessionId) || + !this.tryAdmitNonFinalCheckpoint(sessionId) + ) { + return 'deferred' + } + const run = async (): Promise<'done' | 'deferred'> => { + try { + return await this.writeSessionCheckpoint(sessionId, opts) + } finally { + this.releaseNonFinalCheckpointAdmission(sessionId) + this.periodicDeadlineWarnedSessionIds.delete(sessionId) + } + } + return await this.checkpointQueue.runWithDeadline( + sessionId, + run, + (this.constructor as typeof DaemonPtyCheckpointScheduler).PERIODIC_CHECKPOINT_DEADLINE_MS, + 'deferred', + { + onDeadline: () => { + if (!this.periodicDeadlineWarnedSessionIds.has(sessionId)) { + this.periodicDeadlineWarnedSessionIds.add(sessionId) + console.warn('[history] periodic checkpoint deadline exceeded:', sessionId) + } + }, + onAbandonedRejection: (error) => { + console.warn('[history] checkpoint failed:', sessionId, error) + } + } + ) + } +} diff --git a/src/main/daemon/daemon-pty-connection-lifecycle.ts b/src/main/daemon/daemon-pty-connection-lifecycle.ts new file mode 100644 index 00000000000..496961dff16 --- /dev/null +++ b/src/main/daemon/daemon-pty-connection-lifecycle.ts @@ -0,0 +1,156 @@ +import { readFile } from 'node:fs/promises' +import { + classifyDaemonAuditFailure, + type DaemonAuditObservation, + type DaemonAuditTrigger +} from './daemon-audit-classifier' +import { + exactDaemonIncarnationForPidRecord, + sameEndpointIdentity +} from './daemon-endpoint-incarnation' +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence' +import { notifyDaemonAuditListeners } from './daemon-listener-registry' +import { DaemonPtyEventSubscriptions } from './daemon-pty-event-subscriptions' +import { parseDaemonPidFile, type ParsedDaemonPid } from './daemon-pid-file-parse' + +export abstract class DaemonPtyConnectionLifecycle extends DaemonPtyEventSubscriptions { + protected async ensureConnected(deadlineMs?: number): Promise { + try { + // Why: destructive teardown bounds the handshake by its deadline so a wedged + // connect fails fast; undefined keeps the default connect behavior. + await (deadlineMs !== undefined + ? this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now())) + : this.client.ensureConnected()) + } finally { + // Why: a respawn launcher holds a temporary pair until this adapter's permanent reconnect, preventing both gaps and leaks. + this.releasePendingRespawnAdoptionLease() + } + this.recordAuthenticatedIdentity() + // Why sampled before setupEventRouting: "no listener yet" identifies a fresh connect — the only time the + // daemon-side backgrounded set (process state lost with the old daemon) needs a resync. + const isFreshConnection = this.removeEventListener === null + this.setupEventRouting() + this.scheduleCheckpointTimer() + this.flushOwedProducerResumes() + if (isFreshConnection) { + this.resyncBackgroundedSessions() + } + } + + protected recordAuthenticatedIdentity(): void { + const current = this.client.getDaemonIdentity() + if (!current) { + return + } + const previous = this.lastAuthenticatedIdentity + if (previous && sameEndpointIdentity(previous, current)) { + return + } + this.lastAuthenticatedIdentity = { ...current } + this.exactDaemonIncarnation = exactDaemonIncarnationForPidRecord(current, this.pidRecord) + if (!previous) { + return + } + const event = { previous: { ...previous }, current: { ...current } } + notifyDaemonAuditListeners(this.identityChangeListeners, event) + } + + protected async resolveExactDaemonIncarnation( + exactIncarnation: ExactDaemonIncarnation | null + ): Promise { + if ( + !exactIncarnation || + process.platform !== 'linux' || + (exactIncarnation.linuxStartTicks && exactIncarnation.bootId) + ) { + return exactIncarnation + } + const cachedIncarnation = exactDaemonIncarnationForPidRecord( + exactIncarnation.identity, + this.pidRecord + ) + if (cachedIncarnation.linuxStartTicks && cachedIncarnation.bootId) { + return cachedIncarnation + } + const pidRecord = await this.readMatchingPidRecord(exactIncarnation.identity) + this.pidRecord = pidRecord ?? this.pidRecord + return pidRecord?.linuxStartTicks && pidRecord.bootId + ? { + identity: { ...exactIncarnation.identity }, + linuxStartTicks: pidRecord.linuxStartTicks, + bootId: pidRecord.bootId + } + : exactIncarnation + } + + protected cacheExactDaemonIncarnation(exactIncarnation: ExactDaemonIncarnation | null): void { + if ( + exactIncarnation && + this.lastAuthenticatedIdentity && + sameEndpointIdentity(exactIncarnation.identity, this.lastAuthenticatedIdentity) + ) { + this.exactDaemonIncarnation = exactIncarnation + } + } + + protected async readMatchingPidRecord( + identity: DaemonEndpointIdentity + ): Promise { + if (!this.pidPath) { + return null + } + try { + const parsed = parseDaemonPidFile(await readFile(this.pidPath, 'utf8')) + return parsed?.pid === identity.pid && + parsed.startedAtMs === identity.startedAtMs && + parsed.launchNonce === identity.launchNonce + ? parsed + : null + } catch { + return null + } + } + + protected observeAuditFailure( + trigger: Exclude, + exactIncarnation = this.exactDaemonIncarnation, + additionalEvidenceSources: readonly DaemonEvidenceSource[] = [], + endpointGoneProof?: 'windows_named_pipe_missing' + ): void { + void this.resolveExactDaemonIncarnation(exactIncarnation) + .then((resolvedIncarnation) => { + this.cacheExactDaemonIncarnation(resolvedIncarnation) + return classifyDaemonAuditFailure(this.auditContext, trigger, resolvedIncarnation, { + additionalEvidenceSources, + endpointGoneProof + }) + }) + .then((observation) => this.publishAuditObservation(observation)) + .catch(() => {}) + } + + protected publishAuditObservation(observation: DaemonAuditObservation): void { + this.lastAuditObservation = observation + this.trackAuditEligibility(observation) + notifyDaemonAuditListeners(this.auditObservationListeners, observation) + } + + protected resyncBackgroundedSessions(): void { + for (const id of this.backgroundedSessionIds) { + // Harmless no-op for sessions the daemon doesn't know (yet). + this.client.notify('setSessionBackground', { sessionId: id, background: true }) + } + } + + protected flushOwedProducerResumes(): void { + if (this.producerResumesOwedOnReconnect.size === 0) { + return + } + for (const id of this.producerResumesOwedOnReconnect) { + // Why: resuming an unknown session is a harmless no-op; leaving a survivor paused would waste 5s of failsafe latency. + this.client.notify('resumePty', { sessionId: id }) + } + this.producerResumesOwedOnReconnect.clear() + } +} diff --git a/src/main/daemon/daemon-pty-daemon-recovery.ts b/src/main/daemon/daemon-pty-daemon-recovery.ts new file mode 100644 index 00000000000..73ef2591be6 --- /dev/null +++ b/src/main/daemon/daemon-pty-daemon-recovery.ts @@ -0,0 +1,290 @@ +import { existsSync } from 'node:fs' +import { getMacDaemonSystemResolverHealth } from './daemon-health' +import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution' +import { isDaemonStaleForCurrentBundle } from './daemon-bundle-staleness' +import { isDaemonGoneError } from './daemon-endpoint-errors' +import { DaemonPtyCheckpointPersistence } from './daemon-pty-checkpoint-persistence' +import type { DaemonRespawnReason } from './daemon-pty-runtime-state' +import type { ListSessionsResult } from './types' +import type { PtyBackgroundStreamEvent } from '../providers/types' + +export abstract class DaemonPtyDaemonRecovery extends DaemonPtyCheckpointPersistence { + // Why: the token read no longer throws, so audit its absence directly after an authenticated drop. + protected isRetiredEndpointTokenMissing(): boolean { + return this.client.hasObservedAuthenticatedDisconnect() && !existsSync(this.tokenPath) + } + + protected async withDaemonRetry(fn: () => Promise): Promise { + try { + return await fn() + } catch (err) { + const missingRetiredEndpointToken = this.isRetiredEndpointTokenMissing() + if (missingRetiredEndpointToken) { + this.observeAuditFailure( + 'token_missing_after_authenticated_disconnect', + this.exactDaemonIncarnation, + ['token_file'] + ) + } + if (this.respawnAdoptionClosed || !this.respawnFn || !isDaemonGoneError(err)) { + throw err + } + if (!this.respawnPromise) { + this.respawnPromise = this.doRespawn().finally(() => { + this.respawnPromise = null + }) + } + await this.respawnPromise + try { + return await fn() + } finally { + // Why: the retried op may reject before any connection attempt (e.g. a tombstone racing respawn). + this.releasePendingRespawnAdoptionLease() + } + } + } + + protected reconnectAfterWriteFailure(): void { + if ( + this.writeRecoveryPromise || + this.writeRecoveryAttempted || + this.respawnAdoptionClosed || + !this.respawnFn + ) { + return + } + this.writeRecoveryAttempted = true + // Why: the dead endpoint took down every session on this daemon. Signal all + // active panes now — while they are still in activeSessionIds, so the + // renderer's liveness gate still reads them live — so background panes + // remount + re-attach alongside the one that was written, instead of being + // left frozen with silently dropped input until each is typed into. + this.notifyActiveSessionsWriteUnavailable() + const recovery = this.withDaemonRetry(() => this.ensureConnected()) + .catch((error) => console.warn('[daemon] Failed to recover after rejected PTY input:', error)) + .finally(() => { + this.releasePendingRespawnAdoptionLease() + if (this.writeRecoveryPromise === recovery) { + this.writeRecoveryPromise = null + } + }) + this.writeRecoveryPromise = recovery + } + + protected notifyActiveSessionsWriteUnavailable(): void { + // Snapshot first: a listener that kills a pane would mutate activeSessionIds + // mid-iteration and silently skip the sibling this fan-out exists to reach. + const ids = [...this.activeSessionIds] + for (const id of ids) { + this.sessionsAwaitingDaemonRecovery.add(id) + this.emitWriteUnavailable(id) + } + } + + protected clearSessionAwaitingDaemonRecovery(sessionId: string): void { + this.sessionsAwaitingDaemonRecovery.delete(sessionId) + if (this.sessionsAwaitingDaemonRecovery.size === 0) { + this.writeRecoveryAttempted = false + } + } + + protected async withHistorySpawnLock( + sessionId: string, + operation: () => Promise + ): Promise { + if (!this.historyManager) { + return await operation() + } + const previous = this.historySpawnLocks.get(sessionId) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + const tail = previous.then( + () => current, + () => current + ) + this.historySpawnLocks.set(sessionId, tail) + await previous.catch(() => {}) + try { + return await operation() + } finally { + release() + if (this.historySpawnLocks.get(sessionId) === tail) { + this.historySpawnLocks.delete(sessionId) + } + } + } + + protected async replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise { + if (!this.respawnFn) { + return + } + + const health = await getMacDaemonSystemResolverHealth( + this.socketPath, + this.tokenPath, + this.protocolVersion + ) + if (health !== 'unhealthy') { + return + } + + const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() + const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) + if (daemonLiveSessionCount === null || liveSessionCount > 0) { + console.warn( + daemonLiveSessionCount === null + ? '[daemon] macOS system resolver unavailable - preserving daemon because live session state could not be verified' + : `[daemon] macOS system resolver unavailable - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + ) + return + } + + // Why: replacing the daemon kills its sessions without exit fanout; emit exits first so panes don't write to dead PTYs. + this.fanoutSyntheticExits(-1) + if (!this.respawnPromise) { + this.respawnPromise = this.doRespawn( + '[daemon] macOS system resolver unavailable - respawning daemon', + 'unhealthy_resolver' + ).finally(() => { + this.respawnPromise = null + }) + } + await this.respawnPromise + } + + /** Replace a stale packaged daemon only after its live sessions drain. */ + protected async replaceStaleBundleDaemonBeforeNewPty(): Promise { + if (!this.respawnFn || !this.runtimeDir || !this.packagedAppVersion) { + return + } + if (!this.staleBundleReplacementPromise) { + this.staleBundleReplacementPromise = this.replaceStaleBundleDaemonOnce( + this.runtimeDir, + this.packagedAppVersion + ).finally(() => { + this.staleBundleReplacementPromise = null + }) + } + await this.staleBundleReplacementPromise + } + + protected async replaceStaleBundleDaemonOnce( + runtimeDir: string, + packagedAppVersion: string + ): Promise { + const stale = await isDaemonStaleForCurrentBundle( + runtimeDir, + this.socketPath, + this.tokenPath, + packagedAppVersion, + this.protocolVersion + ) + if (!stale) { + return + } + + const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() + const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) + if (daemonLiveSessionCount === null || liveSessionCount > 0) { + console.warn( + daemonLiveSessionCount === null + ? '[daemon] Packaged daemon is stale - preserving it because live session state could not be verified' + : `[daemon] Packaged daemon is stale - preserving it because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}` + ) + return + } + + this.fanoutSyntheticExits(-1) + if (!this.respawnPromise) { + this.respawnPromise = this.doRespawn( + '[daemon] Packaged daemon is stale - respawning from the current app bundle', + 'stale_bundle' + ).finally(() => { + this.respawnPromise = null + }) + } + await this.respawnPromise + } + + /** Replace a TCC-severed daemon only after its live sessions drain. */ + protected async replaceSeveredMacTccDaemonBeforeNewPty(): Promise { + // Why no platform gate: getMacDaemonTccAttributionHealth returns 'unknown' off macOS. + if (!this.respawnFn || !this.runtimeDir) { + return + } + + const health = await getMacDaemonTccAttributionHealth( + this.runtimeDir, + this.socketPath, + this.tokenPath, + this.protocolVersion + ) + if (health !== 'severed') { + return + } + + const daemonLiveSessionCount = await this.getDaemonLiveSessionCount() + const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0) + if (daemonLiveSessionCount === null || liveSessionCount > 0) { + console.warn( + daemonLiveSessionCount === null + ? '[daemon] macOS TCC attribution severed - preserving daemon because live session state could not be verified' + : `[daemon] macOS TCC attribution severed - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}; restart from Manage Sessions when ready` + ) + return + } + + this.fanoutSyntheticExits(-1) + if (!this.respawnPromise) { + this.respawnPromise = this.doRespawn( + '[daemon] macOS TCC attribution severed - respawning daemon under the current app binary', + 'severed_tcc_attribution' + ).finally(() => { + this.respawnPromise = null + }) + } + await this.respawnPromise + } + + protected async getDaemonLiveSessionCount(): Promise { + try { + await this.client.ensureConnected() + const result = await this.client.request('listSessions', undefined) + return result.sessions.filter((session) => session.isAlive).length + } catch { + return null + } + } + + protected emitBackgroundStreamEvent(payload: PtyBackgroundStreamEvent): void { + // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration + for (const listener of [...this.backgroundStreamListeners]) { + listener(payload) + } + } + + protected async doRespawn( + message = '[daemon] Daemon died — respawning', + reason: DaemonRespawnReason = 'daemon_died' + ): Promise { + console.warn(message) + this.removeEventListener?.() + this.removeEventListener = null + this.client.disconnect() + const releaseAdoptionLease = await this.respawnFn!(reason) + if (this.respawnAdoptionClosed) { + // Why: app teardown may win mid-respawn; a late result must not reinstall a lease nobody owns. + releaseAdoptionLease?.() + throw new Error('Daemon adapter closed during respawn') + } + this.pendingRespawnAdoptionRelease = releaseAdoptionLease ?? null + } + + protected releasePendingRespawnAdoptionLease(): void { + const release = this.pendingRespawnAdoptionRelease + this.pendingRespawnAdoptionRelease = null + release?.() + } +} diff --git a/src/main/daemon/daemon-pty-event-subscriptions.ts b/src/main/daemon/daemon-pty-event-subscriptions.ts new file mode 100644 index 00000000000..20929def1ba --- /dev/null +++ b/src/main/daemon/daemon-pty-event-subscriptions.ts @@ -0,0 +1,157 @@ +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import { DaemonPtySessionInventory } from './daemon-pty-session-inventory' +import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types' +import type { PtyBackgroundStreamEvent } from '../providers/types' + +export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInventory { + onData( + callback: (payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number + }) => void + ): () => void { + this.dataListeners.push(callback) + return () => { + const idx = this.dataListeners.indexOf(callback) + if (idx !== -1) { + this.dataListeners.splice(idx, 1) + } + } + } + + onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { + this.backgroundStreamListeners.push(callback) + return () => { + const idx = this.backgroundStreamListeners.indexOf(callback) + if (idx !== -1) { + this.backgroundStreamListeners.splice(idx, 1) + } + } + } + + onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { + return () => {} + } + + onExit( + callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void + ): () => void { + this.exitListeners.push(callback) + return () => { + const idx = this.exitListeners.indexOf(callback) + if (idx !== -1) { + this.exitListeners.splice(idx, 1) + } + } + } + + onWriteUnavailable(callback: (payload: { id: string }) => void): () => void { + this.writeUnavailableListeners.push(callback) + return () => { + const idx = this.writeUnavailableListeners.indexOf(callback) + if (idx !== -1) { + this.writeUnavailableListeners.splice(idx, 1) + } + } + } + + protected emitWriteUnavailable(id: string): void { + // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration + for (const listener of [...this.writeUnavailableListeners]) { + listener({ id }) + } + } + + dispose(): void { + this.respawnAdoptionClosed = true + this.sessionsAwaitingDaemonRecovery.clear() + this.writeRecoveryAttempted = false + this.releasePendingRespawnAdoptionLease() + this.stopCheckpointTimer() + this.dirtySessionVersions.clear() + this.lastFullCheckpointAt.clear() + this.sessionsNeedingFullCheckpoint.clear() + this.sessionsNeedingLiveCheckpoint.clear() + this.sessionsNeedingContinuityCheckpoint.clear() + this.overlayDeadlineWarnedSessionIds.clear() + this.periodicDeadlineWarnedSessionIds.clear() + this.nonFinalAdmissionDeniedSessionIds.clear() + this.coldRestoreCache.clear() + this.wslDistrosBySessionId.clear() + this.pausedProducerSessionIds.clear() + this.producerResumesOwedOnReconnect.clear() + this.auditObservationListeners.length = 0 + this.identityChangeListeners.length = 0 + this.removeEventListener?.() + this.removeEventListener = null + // Why: final checkpoints are written daemon-side (TerminalHost.dispose); here the adapter only marks sessions + // cleanly ended so they don't trigger false cold restores. + if (this.historyManager) { + void this.historyManager + .dispose() + .catch((err) => console.warn('[history] dispose failed:', err)) + } + this.client.disconnect() + } + + async establishLifecycleLease(): Promise { + if (this.protocolVersion < CLEAN_DISCONNECT_PROTOCOL_VERSION) { + return + } + // Why: an authenticated pair cancels the adoption watchdog and lets a never-used adapter retire its empty daemon on quit. + await this.client.ensureConnected() + this.recordAuthenticatedIdentity() + } + + // Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable, + // but still write a final checkpoint so a daemon crash while Orca is closed has recovery data. + async disconnectOnly(): Promise { + if (!this.disconnectOnlyPromise) { + this.respawnAdoptionClosed = true + this.sessionsAwaitingDaemonRecovery.clear() + this.writeRecoveryAttempted = false + this.releasePendingRespawnAdoptionLease() + this.disconnectOnlyPromise = this.finishDisconnectOnly([...this.keepHistoryShutdowns]) + } + await this.disconnectOnlyPromise + } + + protected async finishDisconnectOnly(keepHistoryShutdowns: Promise[]): Promise { + // Why: sleep shutdowns still detect recovery and kill after checkpointing; disconnecting first rejects those admitted operations. + await Promise.allSettled(keepHistoryShutdowns) + this.respawnAdoptionClosed = true + // Why: a final checkpoint covers sessions opened since the last tick (else cold restore finds nothing if the daemon + // later dies). Await it — fire-and-forget would race client.disconnect() and reject the pending getSnapshot RPCs. + await this.runExclusiveCheckpoint(() => this.checkpointAllSessions(), { + rescheduleDirty: false + }) + this.dirtySessionVersions.clear() + this.lastFullCheckpointAt.clear() + this.coldRestoreCache.clear() + this.wslDistrosBySessionId.clear() + // Why: the detached daemon keeps these PTYs alive for warm reattach; a leftover pause would stall shells for a failsafe window. + for (const id of this.pausedProducerSessionIds) { + this.client.notify('resumePty', { sessionId: id }) + } + this.pausedProducerSessionIds.clear() + this.producerResumesOwedOnReconnect.clear() + this.removeEventListener?.() + this.removeEventListener = null + if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { + try { + // Why: only the authenticated daemon can atomically prove it's empty; a shared budget keeps this off quit's critical path. + const deadlineMs = Date.now() + 250 + if (!this.client.isConnected()) { + await this.client.ensureConnectedWithin(Math.max(1, deadlineMs - Date.now())) + } + await this.client.request('shutdownIfIdle', undefined, Math.max(1, deadlineMs - Date.now())) + } catch { + // An unreachable daemon falls back to event-driven retirement once its auth sockets close and it proves itself empty. + } + } + this.client.disconnect() + } +} diff --git a/src/main/daemon/daemon-pty-lifecycle-errors.ts b/src/main/daemon/daemon-pty-lifecycle-errors.ts new file mode 100644 index 00000000000..d27a362a8b9 --- /dev/null +++ b/src/main/daemon/daemon-pty-lifecycle-errors.ts @@ -0,0 +1,13 @@ +export class FinalCheckpointWaitExpiredError extends Error { + constructor(sessionId: string) { + super(`Final history checkpoint did not settle within the teardown deadline: ${sessionId}`) + this.name = 'FinalCheckpointWaitExpiredError' + } +} + +export class TerminalKilledError extends Error { + constructor(sessionId: string) { + super(`Session "${sessionId}" was explicitly killed`) + this.name = 'TerminalKilledError' + } +} diff --git a/src/main/daemon/daemon-pty-process-inspection.ts b/src/main/daemon/daemon-pty-process-inspection.ts new file mode 100644 index 00000000000..d1dd3ad4e36 --- /dev/null +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -0,0 +1,177 @@ +import { isShellProcess } from '../../shared/agent-detection' +import { DaemonPtyBufferSnapshots } from './daemon-pty-buffer-snapshots' +import { parsePtySessionId } from './pty-session-id' +import { + COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, + GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, + type ListSessionsResult, + type SessionInfo +} from './types' +import type { PtyProcessInspection } from '../providers/pty-process-inspection' + +export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshots { + // Why: daemon-backed PTYs can host long-lived agents while detached; cleanup prompts must not treat them as idle shells. + protected hasChildProcessesFromForeground(foregroundProcess: string | null): boolean { + return foregroundProcess !== null && !isShellProcess(foregroundProcess) + } + + async hasChildProcesses(id: string): Promise { + if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { + return true + } + return this.hasChildProcessesFromForeground(await this.getForegroundProcess(id)) + } + + async inspectProcess(id: string): Promise { + if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { + return { foregroundProcess: null, hasChildProcesses: true, unavailable: true } + } + if (this.protocolVersion < COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION) { + // Why: pre-v27 daemons survive an in-place app update; compose the inspection client-side from the + // one call they do support instead of throwing, or completion detection stays dead until recreate. + // Requests directly (not via getForegroundProcess) so a dead socket still rejects rather than + // reading as an idle foreground and dispatching a false completion. + const { foregroundProcess } = await this.client.request<{ + foregroundProcess: string | null + }>('getForegroundProcess', { sessionId: id }) + return { + foregroundProcess, + hasChildProcesses: this.hasChildProcessesFromForeground(foregroundProcess) + } + } + return this.client.request<{ + foregroundProcess: string | null + hasChildProcesses: boolean + }>('inspectProcess', { sessionId: id }) + } + + async getForegroundProcess(id: string): Promise { + if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { + return null + } + try { + const result = await this.client.request<{ foregroundProcess: string | null }>( + 'getForegroundProcess', + { sessionId: id } + ) + return result.foregroundProcess + } catch { + return null + } + } + + async confirmForegroundProcess(id: string): Promise { + try { + const result = await this.client.request<{ foregroundProcess: string | null }>( + 'confirmForegroundProcess', + { sessionId: id } + ) + return result.foregroundProcess + } catch { + return null + } + } + + async serialize(ids: string[]): Promise { + const sessions: Record = {} + for (const id of ids) { + sessions[id] = { initialCwd: this.initialCwds.get(id) } + } + return JSON.stringify(sessions) + } + + async revive(_state: string): Promise { + // Sessions already live in the daemon — no revival needed + } + + /** Called on app launch. Lists daemon sessions, kills orphans whose workspaceId + * no longer exists, and caches alive session IDs. + * + * IMPORTANT: a session id embeds the worktree's path at spawn time, so a renamed + * worktree keeps its old id. Callers MUST seed `validWorktreeIds` with each live + * worktree's `WorktreeMeta.priorWorktreeIds` or those sessions get reaped as false + * orphans. No production caller yet; wire the alias in when it gains one. */ + async reconcileOnStartup(validWorktreeIds: Set): Promise<{ + alive: string[] + killed: string[] + }> { + await this.ensureConnected() + const result = await this.client.request('listSessions', undefined) + + const alive: string[] = [] + const killed: string[] = [] + + for (const session of result.sessions) { + if (!session.isAlive) { + continue + } + // Why: an unminted session id (worktreeId === null) can't be tied to a live worktree, so it's treated as an orphan. + const { worktreeId } = parsePtySessionId(session.sessionId) + + if (worktreeId === null || !validWorktreeIds.has(worktreeId)) { + try { + await this.client.request('kill', { sessionId: session.sessionId }) + } catch { + /* already dead */ + } + killed.push(session.sessionId) + } else { + alive.push(session.sessionId) + // Why: track background sessions in the checkpoint set so disconnectOnly's final checkpoint doesn't leave stale recovery data. + this.activeSessionIds.add(session.sessionId) + await this.reconcileLiveSessionHistory(session).catch((err) => + console.warn('[history] live-session reconciliation failed:', session.sessionId, err) + ) + } + } + + return { alive, killed } + } + + protected async reconcileLiveSessionHistory(session: SessionInfo): Promise { + const historyManager = this.historyManager + const historyReader = this.historyReader + if (!historyManager || !historyReader) { + return + } + await this.withHistorySpawnLock(session.sessionId, async () => { + if (historyManager.hasWriter(session.sessionId)) { + return + } + const probe = historyReader.probeRestorableHistory(session.sessionId) + if (probe.status === 'unreadable') { + return + } + if (probe.status === 'none') { + await historyManager.openSession(session.sessionId, { + cwd: session.cwd ?? '', + cols: session.cols, + rows: session.rows + }) + } else { + const recoveryFreeze = await historyManager.freezeForRecovery(session.sessionId) + try { + const detection = await historyReader.detectColdRestoreState(session.sessionId, { + wslDistro: session.wslDistro ?? undefined + }) + if ( + detection.status === 'unreadable' || + (detection.status === 'restored' && detection.hasUnreadableRecovery) + ) { + historyManager.suspendSession(session.sessionId, recoveryFreeze) + return + } + historyManager.reopenSession(session.sessionId, recoveryFreeze) + } finally { + historyManager.abandonRecoveryFreeze(recoveryFreeze) + } + } + if (historyManager.hasWriter(session.sessionId)) { + this.sessionsNeedingFullCheckpoint.add(session.sessionId) + this.sessionsNeedingContinuityCheckpoint.add(session.sessionId) + this.lastFullCheckpointAt.delete(session.sessionId) + this.markSessionDirty(session.sessionId) + } + }) + } +} diff --git a/src/main/daemon/daemon-pty-provider-sequence.ts b/src/main/daemon/daemon-pty-provider-sequence.ts new file mode 100644 index 00000000000..fbc38ab9221 --- /dev/null +++ b/src/main/daemon/daemon-pty-provider-sequence.ts @@ -0,0 +1,13 @@ +import type { CreateOrAttachResult } from './types' +import type { PtySpawnResult } from '../providers/types' + +export function providerSequenceFromCreateOrAttach( + result: CreateOrAttachResult +): PtySpawnResult['providerSequence'] { + if (result.isNew) { + return { value: 0, generation: 'reset' } + } + return typeof result.snapshot?.outputSequence === 'number' + ? { value: result.snapshot.outputSequence, generation: 'continued' } + : undefined +} diff --git a/src/main/daemon/daemon-pty-runtime-state.ts b/src/main/daemon/daemon-pty-runtime-state.ts new file mode 100644 index 00000000000..5a1a7f60a90 --- /dev/null +++ b/src/main/daemon/daemon-pty-runtime-state.ts @@ -0,0 +1,260 @@ +import { DaemonClient } from './client' +import { createDaemonAuditEligibilityTracker } from './daemon-audit-eligibility-event' +import type { + DaemonAuditContext, + DaemonAuditObservation, + DaemonAuditTrigger +} from './daemon-audit-classifier' +import { CheckpointSessionQueue } from './daemon-checkpoint-session-queue' +import { SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version' +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence' +import { readDaemonPidRecord } from './daemon-endpoint-incarnation' +import { removeDaemonListener } from './daemon-listener-registry' +import type { ParsedDaemonPid } from './daemon-pid-file-parse' +import { + AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, + AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, + GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, + PROTOCOL_VERSION, + supportsMode2031UnsubscribeFact, + supportsPtyStartupIngress, + type TakePendingOutputResult +} from './types' +import { ColdRestorePayloadCache } from './cold-restore-payload-cache' +import { + HistoryManager, + type HistoryCheckpointResult, + type HistoryRecoveryFreeze +} from './history-manager' +import { HistoryReader } from './history-reader' +import type { PtyBackgroundStreamEvent } from '../providers/types' +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { TerminalExitCause } from '../../shared/terminal-exit-cause' + +export type PendingDaemonSpawnOperation = { + exitsBySessionId: Map + ignoredExitIncarnationIds: Set + ignoreNextExit: boolean +} + +export type HistoryRecoveryContext = { + freeze: HistoryRecoveryFreeze | null + unreadableSessionId: string | null + identityChanged: boolean +} + +export type SnapshotCheckpointResult = { + checkpoint: HistoryCheckpointResult + snapshot: NonNullable | null +} + +export type DaemonPtyAdapterOptions = { + socketPath: string + tokenPath: string + pidPath?: string + profileScope?: string + protocolVersion?: number + historyPath?: string + runtimeDir?: string + packagedAppVersion?: string | null + respawn?: (reason: DaemonRespawnReason) => Promise void)> +} + +export type DaemonRespawnReason = + | 'daemon_died' + | 'unhealthy_resolver' + | 'stale_bundle' + | 'severed_tcc_attribution' + +export type DaemonIdentityChangeEvent = { + previous: DaemonEndpointIdentity + current: DaemonEndpointIdentity +} + +export abstract class DaemonPtyRuntimeState { + readonly protocolVersion: number + protected socketPath: string + protected tokenPath: string + protected pidPath: string | null + protected pidRecord: ParsedDaemonPid | null + protected client: DaemonClient + protected auditContext: DaemonAuditContext + protected lastAuthenticatedIdentity: DaemonEndpointIdentity | null = null + protected exactDaemonIncarnation: ExactDaemonIncarnation | null = null + protected lastAuditObservation: DaemonAuditObservation | null = null + protected readonly trackAuditEligibility = createDaemonAuditEligibilityTracker() + protected auditObservationListeners: ((observation: DaemonAuditObservation) => void)[] = [] + protected identityChangeListeners: ((event: DaemonIdentityChangeEvent) => void)[] = [] + protected historyManager: HistoryManager | null + protected historyReader: HistoryReader | null + protected respawnFn: DaemonPtyAdapterOptions['respawn'] | null + protected runtimeDir: string | null + protected packagedAppVersion: string | null + protected pendingRespawnAdoptionRelease: (() => void) | null = null + protected respawnAdoptionClosed = false + protected respawnPromise: Promise | null = null + protected staleBundleReplacementPromise: Promise | null = null + protected writeRecoveryPromise: Promise | null = null + protected writeRecoveryAttempted = false + protected dataListeners: ((payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + seq?: number + }) => void)[] = [] + protected exitListeners: ((payload: { + id: string + code: number + incarnationId?: PtyIncarnationId + cause?: TerminalExitCause + }) => void)[] = [] + protected backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] + protected writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] + protected removeEventListener: (() => void) | null = null + protected initialCwds = new Map() + protected wslDistrosBySessionId = new Map() + protected killedSessionTombstones = new Map() + protected sleepRestoreSessionIds = new Set() + protected coldRestoreCache = new ColdRestorePayloadCache(undefined, (sessionId) => { + this.sleepRestoreSessionIds.delete(sessionId) + }) + protected activeSessionIds = new Set() + protected getSizeUnsupported = false + protected sessionsAwaitingDaemonRecovery = new Set() + protected sessionIncarnations = new Map() + protected pendingSpawnOperationsBySessionId = new Map>() + protected pendingClaimSpawnOperations = new Set() + protected historySpawnLocks = new Map>() + protected dirtySessionVersions = new Map() + protected sessionsNeedingFullCheckpoint = new Set() + protected sessionsNeedingLiveCheckpoint = new Set() + protected sessionsNeedingContinuityCheckpoint = new Set() + protected checkpointTimer: ReturnType | null = null + protected checkpointInFlight: Promise | null = null + protected checkpointQueue = new CheckpointSessionQueue() + protected nonFinalCheckpointAdmissionSessionIds = new Set() + protected nonFinalAdmissionDeniedSessionIds = new Set() + protected nonFinalGlobalAdmissionWarningActive = false + protected overlayDeadlineWarnedSessionIds = new Set() + protected periodicDeadlineWarnedSessionIds = new Set() + protected keepHistoryShutdowns = new Set>() + protected disconnectOnlyPromise: Promise | null = null + protected supportsCheckpoints: boolean + protected supportsIncrementalCheckpoints: boolean + protected supportsProducerFlowControl: boolean + protected supportsAuthoritativeBufferSnapshots: boolean + protected supportsStartupIngress: boolean + protected pausedProducerSessionIds = new Set() + protected backgroundedSessionIds = new Set() + protected producerResumesOwedOnReconnect = new Set() + protected static CHECKPOINT_INTERVAL_MS = 5_000 + protected static PERIODIC_CHECKPOINT_DEADLINE_MS = 15_000 + protected static MAX_CONCURRENT_CHECKPOINTS = 4 + protected static FULL_CHECKPOINT_COOLDOWN_MS = 45_000 + protected lastFullCheckpointAt = new Map() + + protected abstract observeAuditFailure( + trigger: Exclude, + exactIncarnation?: ExactDaemonIncarnation | null, + additionalEvidenceSources?: readonly DaemonEvidenceSource[], + endpointGoneProof?: 'windows_named_pipe_missing' + ): void + + constructor(opts: DaemonPtyAdapterOptions) { + this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION + this.socketPath = opts.socketPath + this.tokenPath = opts.tokenPath + this.pidPath = opts.pidPath ?? null + this.pidRecord = readDaemonPidRecord(this.pidPath) + this.auditContext = { + protocolGeneration: this.protocolVersion, + provider: 'local-daemon', + endpoint: opts.socketPath, + tokenPath: opts.tokenPath, + endpointKind: process.platform === 'win32' ? 'windows-named-pipe' : 'unix-socket', + profileScope: opts.profileScope ?? '' + } + this.client = new DaemonClient({ + socketPath: opts.socketPath, + tokenPath: opts.tokenPath, + protocolVersion: opts.protocolVersion + }) + this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null + this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null + this.respawnFn = opts.respawn ?? null + this.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null + this.packagedAppVersion = opts.packagedAppVersion ?? null + this.supportsCheckpoints = this.protocolVersion >= 4 + this.supportsIncrementalCheckpoints = this.protocolVersion >= 13 + this.supportsProducerFlowControl = this.protocolVersion >= 19 + this.supportsAuthoritativeBufferSnapshots = + this.protocolVersion >= SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION + this.supportsStartupIngress = supportsPtyStartupIngress(this.protocolVersion) + this.client.onDisconnected(() => { + if (!this.respawnAdoptionClosed) { + this.writeRecoveryAttempted = false + for (const id of this.activeSessionIds) { + this.sessionsAwaitingDaemonRecovery.add(id) + } + } + for (const id of this.pausedProducerSessionIds) { + this.producerResumesOwedOnReconnect.add(id) + } + this.pausedProducerSessionIds.clear() + this.observeAuditFailure('transport_closed') + }) + } + + supportsGitCredentialGuardHost(): boolean { + return this.protocolVersion >= GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION + } + + canProvideAuthoritativeBufferSnapshot(_id: string): boolean { + return this.supportsAuthoritativeBufferSnapshots + } + + protected get canDelegateBackgroundToDaemon(): boolean { + return ( + this.supportsAuthoritativeBufferSnapshots && + supportsMode2031UnsubscribeFact(this.protocolVersion) + ) + } + + getHistoryManager(): HistoryManager | null { + return this.historyManager + } + + getLastAuthenticatedDaemonIdentity(): DaemonEndpointIdentity | null { + return this.lastAuthenticatedIdentity ? { ...this.lastAuthenticatedIdentity } : null + } + + getLastAuditObservation(): DaemonAuditObservation | null { + return this.lastAuditObservation + } + + onDaemonIdentityChanged(listener: (event: DaemonIdentityChangeEvent) => void): () => void { + this.identityChangeListeners.push(listener) + return () => removeDaemonListener(this.identityChangeListeners, listener) + } + + onAuditEligibilityObservation( + listener: (observation: DaemonAuditObservation) => void + ): () => void { + this.auditObservationListeners.push(listener) + return () => removeDaemonListener(this.auditObservationListeners, listener) + } + + supportsAgentSessionClaims(): boolean { + return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION + } + + providesAgentSessionOwnerListings(_ptyId: string): boolean { + return this.supportsAgentSessionClaims() + } + + supportsAgentSessionCreateOperations(): boolean { + return this.protocolVersion >= AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION + } +} diff --git a/src/main/daemon/daemon-pty-session-control.ts b/src/main/daemon/daemon-pty-session-control.ts new file mode 100644 index 00000000000..f213129a116 --- /dev/null +++ b/src/main/daemon/daemon-pty-session-control.ts @@ -0,0 +1,357 @@ +import type { ColdRestorePayload } from './cold-restore-payload-cache' +import { isUnknownRequestTypeError } from './daemon-endpoint-errors' +import { GET_SIZE_PROTOCOL_VERSION } from './daemon-protocol-version' +import { FinalCheckpointWaitExpiredError } from './daemon-pty-lifecycle-errors' +import { DaemonPtySessionSpawn } from './daemon-pty-session-spawn' +import { providerSequenceFromCreateOrAttach } from './daemon-pty-provider-sequence' +import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline' +import type { ColdRestoreInfo } from './history-reader' +import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' +import { SessionNotFoundError, type CreateOrAttachResult, type ListSessionsResult } from './types' +import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' +import type { PtySpawnResult } from '../providers/types' +import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' +export const LIVENESS_PROBE_TIMEOUT_MS = 2_000 + +const MAX_TOMBSTONES = 1000 + +export abstract class DaemonPtySessionControl extends DaemonPtySessionSpawn { + async attach(id: string): Promise | void> { + await this.ensureConnected() + if (!this.canDelegateBackgroundToDaemon) { + this.setPtyBackgrounded(id, false) + } + + // Why size-first: attach must ride the session's own geometry — a fixed + // 80×24 here could resize a live agent's TUI — and a null size means the + // daemon cannot prove the session, so refuse rather than risk a create. + const size = await this.getAppliedSize(id) + if (!size) { + throw new SessionNotFoundError(id) + } + const result = await this.client.request('createOrAttach', { + sessionId: id, + cols: size.cols, + rows: size.rows, + attachOnly: true + }) + if (result.isNew) { + // Why: a pre-v31 daemon ignores attachOnly; retire its accidental spawn + // instead of publishing a fresh shell as an attach. + await this.client.request('kill', { sessionId: id, immediate: true }).catch((error) => { + // Why surface, not swallow: a failed retire leaves an untracked orphan shell. + console.warn('[daemon] attach-only retire of accidental legacy spawn failed', { + sessionId: id, + error + }) + }) + throw new SessionNotFoundError(id) + } + this.clearSessionAwaitingDaemonRecovery(id) + const providerSequence = providerSequenceFromCreateOrAttach(result) + return providerSequence ? { providerSequence } : undefined + } + + hasPty(id: string): boolean { + return this.activeSessionIds.has(id) + } + + async probePtyLiveness(id: string): Promise { + try { + if (!this.getSizeUnsupported && this.protocolVersion >= GET_SIZE_PROTOCOL_VERSION) { + try { + const result = await this.client.request<{ size: { cols: number; rows: number } | null }>( + 'getSize', + { sessionId: id }, + LIVENESS_PROBE_TIMEOUT_MS + ) + return result.size !== null + } catch (error) { + // Why the capability probe rather than the version alone: `getSize` shipped into an + // already-released protocol without a bump, so a daemon can report a version that + // implies support and still reject the request. Ask what it can do, not what its + // number implies — and remember the answer so later probes skip the dead round trip. + if (!isUnknownRequestTypeError(error)) { + throw error + } + this.getSizeUnsupported = true + } + } + // Why: a daemon without `getSize` would otherwise answer `null` forever, and one `null` + // makes the whole owner fan-out unprovable — a dead pane could then never be retired. + // `listSessions` is the same inventory legacy discovery routes by, and has existed since + // the first daemon protocol. Requested directly rather than through `listProcesses` so a + // liveness probe does not publish inventory audit observations as a side effect; both + // rethrow on failure, so either way a dead socket stays `null` instead of reading absent. + const { sessions } = await this.client.request( + 'listSessions', + undefined, + LIVENESS_PROBE_TIMEOUT_MS + ) + return sessions.some((session) => session.sessionId === id && session.isAlive) + } catch { + return null + } + } + + write(id: string, data: string): boolean { + const recoverable = this.prepareWrite(id) + return this.finishWrite(id, this.client.notify('write', { sessionId: id, data }), recoverable) + } + + async writeWithSettlement(id: string, data: string): Promise { + const recoverable = this.prepareWrite(id) + return this.finishWrite( + id, + await this.client.notifyWithSettlement('write', { sessionId: id, data }), + recoverable + ) + } + + protected prepareWrite(id: string): boolean { + this.markSessionDirty(id) + // Why recoverable and not just active: rejecting a write asks the pane to remount, + // which only helps if this endpoint can come back. A legacy adapter has no respawn, + // so its reattach fails and the pane rebuilds empty — losing scrollback the user + // could still read. Keep the pre-existing silent drop for those. + const recoverable = + this.activeSessionIds.has(id) && !this.respawnAdoptionClosed && Boolean(this.respawnFn) + if ( + recoverable && + (this.sessionsAwaitingDaemonRecovery.has(id) || !this.client.isConnected()) + ) { + this.sessionsAwaitingDaemonRecovery.add(id) + this.reconnectAfterWriteFailure() + throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`) + } + return recoverable + } + + protected finishWrite(id: string, delivered: boolean, recoverable: boolean): boolean { + if (!delivered && recoverable) { + this.sessionsAwaitingDaemonRecovery.add(id) + this.reconnectAfterWriteFailure() + throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`) + } + return delivered + } + + resize(id: string, cols: number, rows: number): void { + this.markSessionDirty(id) + this.client.notify('resize', { sessionId: id, cols, rows }) + } + + pauseProducer(id: string): void { + if (!this.supportsProducerFlowControl) { + return + } + this.pausedProducerSessionIds.add(id) + this.client.notify('pausePty', { sessionId: id }) + } + + resumeProducer(id: string): void { + this.producerResumesOwedOnReconnect.delete(id) + if (!this.supportsProducerFlowControl) { + return + } + this.pausedProducerSessionIds.delete(id) + this.client.notify('resumePty', { sessionId: id }) + } + + // Why fire-and-forget (like pausePty): just a delivery hint for the daemon's keep-tail stream thinning. + setPtyBackgrounded(id: string, background: boolean): void { + if (!this.supportsProducerFlowControl) { + return + } + // Why: preserved daemons without a sequence-safe, faithful serializer cannot heal a thinned stream. + // Why also gate on 2031 (#9993): backgrounding is what hands transient-fact scan + // authority to the daemon. A pre-v29 daemon can announce a 2031 subscribe but never + // retract it, so a TUI exiting while hidden would strand the subscription and the + // next theme flip would inject CSI 997 into its replacement shell. Declining to + // background keeps main's scanner — which emits both facts — authoritative. + const safeBackground = this.canDelegateBackgroundToDaemon && background + if (safeBackground) { + this.backgroundedSessionIds.add(id) + } else { + this.backgroundedSessionIds.delete(id) + } + this.client.notify('setSessionBackground', { sessionId: id, background: safeBackground }) + } + + async shutdown( + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ): Promise { + if (opts.keepHistory && this.disconnectOnlyPromise) { + throw new Error('Cannot keep history after daemon disconnect has started') + } + const shutdown = this.withHistorySpawnLock(id, () => this.shutdownWithHistoryLock(id, opts)) + if (!opts.keepHistory) { + await shutdown + return + } + this.keepHistoryShutdowns.add(shutdown) + try { + await shutdown + } finally { + this.keepHistoryShutdowns.delete(shutdown) + } + } + + protected async shutdownWithHistoryLock( + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ): Promise { + // Why: shutdown can be the first lazy-client operation after restart; connect + // before killing so a healthy daemon session is not orphaned (#7742). Connect, + // the final-checkpoint wait, and kill all share the caller's one absolute + // deadline, so neither a wedged handshake nor a stalled history write can burn + // the whole teardown budget before the kill even starts. Only the waits are + // bounded — the checkpoint itself stays deadline-free and lossless (STA-4228). + await this.ensureConnected(opts.deadlineMs) + // Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run. + // Force a final snapshot so wake can restore the pane users left. + if (opts.keepHistory) { + const committed = await this.runExclusiveCheckpoint( + async () => { + await this.checkpointSessions([id], { final: true, teardown: true }) + }, + { callerDeadlineMs: opts.deadlineMs } + ) + // Why throw instead of killing anyway: the snapshot the caller asked us to prove is still + // being written. Killing here would race the wake-time restore source to disk, so report the + // pty unverified and leave it alive — worktree sleep declines to commit it and retries. + if (!committed) { + throw new FinalCheckpointWaitExpiredError(id) + } + const wslDistro = this.wslDistrosBySessionId.get(id) + const detection = await this.historyReader?.detectColdRestoreState(id, { wslDistro }) + const detected = detection?.status === 'restored' ? detection.restoreInfo : null + const restoreInfo = detected + ? { + ...detected, + cwd: + normalizeWslColdRestoreCwd({ + recoveredCwd: detected.cwd, + requestedCwd: this.initialCwds.get(id) ?? resolveSafePtyDefaultCwd(), + wslDistro + }) ?? '' + } + : null + const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null + if (coldRestore) { + this.coldRestoreCache.set(id, coldRestore) + if (this.coldRestoreCache.has(id)) { + this.sleepRestoreSessionIds.add(id) + } + // Why: physical exit must not mark intentional sleep as a clean end; the final checkpoint stays the wake-time recovery authority. + this.historyManager?.suspendSession(id) + } else if ( + detection?.status === 'unreadable' || + (detection?.status === 'restored' && detection.hasUnreadableRecovery) + ) { + this.historyManager?.suspendSession(id) + } + } + await this.client.request( + 'kill', + { sessionId: id, immediate: opts.immediate ?? false }, + remainingDaemonRequestTimeoutMs(opts.deadlineMs) + ) + this.activeSessionIds.delete(id) + this.clearSessionAwaitingDaemonRecovery(id) + this.dirtySessionVersions.delete(id) + if (!opts.keepHistory) { + this.coldRestoreCache.delete(id) + this.sleepRestoreSessionIds.delete(id) + } + // Why: the !keepHistory path takes no final checkpoint, so clear sessionsNeedingFullCheckpoint here or it stays stranded (no-op under keepHistory). + this.sessionsNeedingFullCheckpoint.delete(id) + this.sessionsNeedingLiveCheckpoint.delete(id) + this.sessionsNeedingContinuityCheckpoint.delete(id) + this.overlayDeadlineWarnedSessionIds.delete(id) + this.periodicDeadlineWarnedSessionIds.delete(id) + this.nonFinalAdmissionDeniedSessionIds.delete(id) + this.lastFullCheckpointAt.delete(id) + this.stopCheckpointTimerIfIdle() + this.initialCwds.delete(id) + this.wslDistrosBySessionId.delete(id) + // Why: only remove history on explicit close; sleep also calls shutdown but wake needs the dir intact for cold restore (opts.keepHistory). + if (this.historyManager && !opts.keepHistory) { + await this.historyManager + .removeSession(id) + .catch((err) => console.warn('[history] removeSession failed:', id, err)) + } + + // Why: the tombstone rejects reattach to a user-killed session; sleep legitimately reattaches on wake, so skip it under keepHistory. + if (!opts.keepHistory) { + this.killedSessionTombstones.delete(id) + this.killedSessionTombstones.set(id, Date.now()) + if (this.killedSessionTombstones.size > MAX_TOMBSTONES) { + const oldest = this.killedSessionTombstones.keys().next().value + if (oldest) { + this.killedSessionTombstones.delete(oldest) + } + } + } + } + + ackColdRestore(sessionId: string): void { + this.coldRestoreCache.delete(sessionId) + this.sleepRestoreSessionIds.delete(sessionId) + } + + clearTombstone(sessionId: string): void { + this.killedSessionTombstones.delete(sessionId) + } + + protected buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null { + // Why: alt-screen prefers normal scrollback, else snapshotAnsi alone — not rehydrate, which starts with \x1b[?1049h that POST_REPLAY_MODE_RESET won't undo — so a hibernated TUI's last frame isn't blank on wake. + const scrollback = restoreInfo.modes.alternateScreen + ? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null + : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi + if (!scrollback) { + return null + } + return { + scrollback, + cwd: restoreInfo.cwd, + cols: restoreInfo.cols, + rows: restoreInfo.rows, + oscLinks: restoreInfo.oscLinks, + ...(restoreInfo.lastTitle ? { lastTitle: restoreInfo.lastTitle } : {}) + } + } + + async sendSignal(id: string, signal: string): Promise { + await this.client.request('signal', { sessionId: id, signal }) + } + + async getCwd(id: string): Promise { + try { + const result = await this.client.request<{ cwd: string | null }>('getCwd', { + sessionId: id + }) + return result.cwd ?? '' + } catch { + return '' + } + } + + async getInitialCwd(id: string): Promise { + return this.initialCwds.get(id) ?? '' + } + + // Why: resize() is fire-and-forget and can be dropped daemon-side; read the actually-applied size so the renderer can detect drift and re-assert. + async getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> { + try { + const result = await this.client.request<{ size: { cols: number; rows: number } | null }>( + 'getSize', + { sessionId: id } + ) + return result.size ?? null + } catch { + return null + } + } +} diff --git a/src/main/daemon/daemon-pty-session-inventory.ts b/src/main/daemon/daemon-pty-session-inventory.ts new file mode 100644 index 00000000000..7a02a7e1e3d --- /dev/null +++ b/src/main/daemon/daemon-pty-session-inventory.ts @@ -0,0 +1,174 @@ +import { basename } from 'node:path' +import { existsSync } from 'node:fs' +import { + isAgentSessionOwnerBinding, + type AgentSessionOwnerBinding +} from '../../shared/agent-session-host-authority' +import { MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES } from '../../shared/claimed-agent-pty-owner' +import { cloneAgentSessionOwnerBinding } from '../../shared/claimed-agent-pty-owner-snapshot' +import { recordAuthenticatedInventory } from './daemon-audit-classifier' +import { isMissingWindowsNamedPipeError } from './daemon-endpoint-errors' +import { DaemonPtyProcessInspection } from './daemon-pty-process-inspection' +import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline' +import { parsePtySessionId } from './pty-session-id' +import type { ListSessionsResult, SessionInfo } from './types' +import { PtyProcessListAdmission } from '../providers/pty-process-list-admission' +import type { PtyProcessInfo } from '../providers/types' + +export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspection { + async listProcesses(opts?: { deadlineMs?: number }): Promise { + // Why: snapshotted before the request so ids spawned mid-flight can never + // be reconciled away below. + const preRequestActiveIds = new Set(this.activeSessionIds) + try { + // Why: connect + listSessions share the caller's one absolute deadline so a + // wedged handshake cannot burn the whole teardown budget before the list issues. + await this.ensureConnected(opts?.deadlineMs) + const result = await this.client.request( + 'listSessions', + undefined, + remainingDaemonRequestTimeoutMs(opts?.deadlineMs) + ) + const admission = new PtyProcessListAdmission() + const processes: PtyProcessInfo[] = [] + const aliveSessionIds = new Set() + for (const session of result.sessions) { + if (!session.isAlive) { + continue + } + aliveSessionIds.add(session.sessionId) + const { worktreeId } = parsePtySessionId(session.sessionId) + processes.push( + admission.admit({ + id: session.sessionId, + ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + // Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd. + cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '', + title: 'shell', + ...(worktreeId ? { worktreeId } : {}), + ...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}), + ...(session.wslDistro !== undefined ? { wslDistro: session.wslDistro } : {}), + ...this.validatedAgentSessionOwners(session.agentSessionOwners) + }) + ) + } + // Why: hasPty reads activeSessionIds, and an exit missed while the socket + // was disconnected otherwise survives an authoritative inventory forever — + // defeating every absence proof built on the cache. + for (const id of preRequestActiveIds) { + if (!aliveSessionIds.has(id)) { + this.activeSessionIds.delete(id) + } + } + this.publishAuditObservation( + recordAuthenticatedInventory(this.auditContext, this.exactDaemonIncarnation) + ) + return processes + } catch (error) { + const missingAuthenticatedToken = this.isRetiredEndpointTokenMissing() + const missingNamedPipe = isMissingWindowsNamedPipeError(error) + this.observeAuditFailure( + missingAuthenticatedToken + ? 'token_missing_after_authenticated_disconnect' + : 'inventory_failed', + this.exactDaemonIncarnation, + [ + ...(missingAuthenticatedToken ? (['token_file'] as const) : []), + ...(missingNamedPipe ? (['windows_named_pipe'] as const) : []) + ], + missingNamedPipe ? 'windows_named_pipe_missing' : undefined + ) + throw error + } + } + + protected validatedAgentSessionOwners( + owners: unknown + ): { agentSessionOwners: AgentSessionOwnerBinding[] } | Record { + if (owners === undefined) { + return {} + } + if ( + !Array.isArray(owners) || + owners.length > MAX_CLAIMED_AGENT_PTY_OWNER_ENTRIES || + !owners.every((owner) => isAgentSessionOwnerBinding(owner) && owner.phase === 'live') + ) { + throw new Error('agent_session_ownership_unknown') + } + return owners.length > 0 + ? { agentSessionOwners: owners.map(cloneAgentSessionOwnerBinding) } + : {} + } + + // Why: the Manage Sessions panel needs the full SessionInfo (pid, state, + // createdAt) per session for display; listProcesses drops that detail for + // the IPtyProvider contract. Keep both in parallel rather than widening + // the provider surface. + async listSessions(): Promise { + await this.ensureConnected() + const result = await this.client.request('listSessions', undefined) + return result.sessions + .filter((s) => s.isAlive) + .map((session) => ({ + ...session, + ...this.validatedAgentSessionOwners(session.agentSessionOwners) + })) + } + + getActiveSessionIds(): string[] { + return [...this.activeSessionIds] + } + + // Why: the daemon's kill-all-and-shutdown path suppresses onExit fanout (session.ts:246-252), so synthesize pty:exit + // for every live session before teardown or renderer panes black-hole writes to a disposed adapter forever. + fanoutSyntheticExits(code: number): void { + const ids = [...this.activeSessionIds] + this.activeSessionIds.clear() + this.sessionsAwaitingDaemonRecovery.clear() + this.writeRecoveryAttempted = false + this.dirtySessionVersions.clear() + this.lastFullCheckpointAt.clear() + this.sessionsNeedingFullCheckpoint.clear() + this.sessionsNeedingLiveCheckpoint.clear() + this.sessionsNeedingContinuityCheckpoint.clear() + this.overlayDeadlineWarnedSessionIds.clear() + this.periodicDeadlineWarnedSessionIds.clear() + this.nonFinalAdmissionDeniedSessionIds.clear() + this.pausedProducerSessionIds.clear() + this.producerResumesOwedOnReconnect.clear() + this.stopCheckpointTimer() + for (const id of ids) { + this.coldRestoreCache.delete(id) + // Why: don't catch listener throws — matches the natural onExit fanout so synthetic exits keep the same error semantics. + // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration + for (const listener of [...this.exitListeners]) { + listener({ + id, + code, + ...(this.sessionIncarnations.get(id) + ? { incarnationId: this.sessionIncarnations.get(id) } + : {}) + }) + } + this.sessionIncarnations.delete(id) + } + } + + async getDefaultShell(): Promise { + if (process.platform === 'win32') { + return process.env.COMSPEC || 'powershell.exe' + } + return process.env.SHELL || '/bin/zsh' + } + + async getProfiles(): Promise<{ name: string; path: string }[]> { + if (process.platform === 'win32') { + return [ + { name: 'PowerShell', path: 'powershell.exe' }, + { name: 'Command Prompt', path: 'cmd.exe' } + ] + } + const shells = ['/bin/zsh', '/bin/bash', '/bin/sh'] + return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s })) + } +} diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts new file mode 100644 index 00000000000..d277a52866f --- /dev/null +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -0,0 +1,281 @@ +import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' +import type { + HistoryRecoveryContext, + PendingDaemonSpawnOperation +} from './daemon-pty-runtime-state' +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' +import type { DaemonPtySpawnContext } from './daemon-pty-spawn-request' +import type { ColdRestoreInfo } from './history-reader' +import { mintPtySessionId } from './pty-session-id' +import { CODEX_SHELL_READY_TIMEOUT_MS } from './session-shell-ready-barrier' +import { supportsPtyStartupBarrier } from './shell-ready' +import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' +import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types' +import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' +import { resolveWslSessionContext } from './wsl-session-context' +import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' +import { resolveUnixShellPath } from '../providers/local-pty-utils' +import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' +import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history' +import { addWslEnvKeys } from '../wsl-env' + +export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { + async spawn(opts: PtySpawnOptions): Promise { + const spawnOpts = this.withHistoryIsolation(opts) + const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId) + const operation = { + exitsBySessionId: new Map(), + ignoredExitIncarnationIds: new Set(), + ignoreNextExit: false + } + const operations = this.pendingSpawnOperationsBySessionId.get(sessionId) ?? new Set() + operations.add(operation) + this.pendingSpawnOperationsBySessionId.set(sessionId, operations) + if (opts.agentSessionEnsure) { + this.pendingClaimSpawnOperations.add(operation) + } + const historyRecovery: HistoryRecoveryContext = { + freeze: null, + unreadableSessionId: null, + identityChanged: false + } + try { + return await this.withHistorySpawnLock(sessionId, () => + this.withDaemonRetry(() => + this.doSpawn({ ...spawnOpts, sessionId }, operation, historyRecovery) + ) + ) + } finally { + if (historyRecovery.freeze) { + this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze) + } + this.pendingClaimSpawnOperations.delete(operation) + operations.delete(operation) + if (operations.size === 0) { + this.pendingSpawnOperationsBySessionId.delete(sessionId) + } + } + } + + protected withHistoryIsolation(opts: PtySpawnOptions): PtySpawnOptions { + const wslContext = resolveWslSessionContext({ + cwd: opts.cwd, + sessionId: opts.sessionId, + shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro + }) + if ( + opts.attachOnly === true || + (opts.sessionId !== undefined && opts.isNewSession !== true) || + !opts.worktreeId || + opts.historyIsolationEnabled !== true || + (process.platform === 'win32' && !wslContext) + ) { + return opts + } + const env = { ...opts.env } + const preferredShell = wslContext + ? 'bash' + : opts.shellOverride || env.SHELL || process.env.SHELL || '/bin/zsh' + const shellPath = resolveUnixShellPath(preferredShell) + const historyArgs = [ + env, + opts.worktreeId, + shellPath, + opts.cwd ?? resolveSafePtyDefaultCwd() + ] as const + const result = wslContext + ? injectHistoryEnv(...historyArgs, { wslDistro: wslContext.distro }) + : injectHistoryEnv(...historyArgs) + if (wslContext) { + injectWslFishHistoryEnv(env, opts.worktreeId, wslContext.distro) + addWslEnvKeys(env, ['HISTFILE', 'fish_history']) + } + logHistoryInjection(opts.worktreeId, result) + return { ...opts, env } + } + + protected async doSpawn( + opts: PtySpawnOptions, + operation: PendingDaemonSpawnOperation, + historyRecovery: HistoryRecoveryContext + ): Promise { + if ( + opts.agentSessionEnsure && + this.protocolVersion < AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION + ) { + throw new Error('agent_session_claim_unavailable') + } + const requestedSessionId = opts.sessionId! + // Why: v30 daemons survive upgrades; reject their accidental create result before publication. + const attachOnly = opts.attachOnly === true + const emulateLegacyAttachOnly = + attachOnly && this.protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION + let sessionId = requestedSessionId + let wslDistro = resolveWslSessionContext({ + cwd: opts.cwd, + sessionId, + shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro + })?.distro + let activeSpawnContext: DaemonPtySpawnContext | null = null + const freezeHistory = async (): Promise => { + if (!this.historyManager) { + return + } + const recoverySessionId = activeSpawnContext?.sessionId ?? sessionId + if (historyRecovery.freeze?.sessionId === recoverySessionId) { + return + } + if (historyRecovery.freeze) { + this.historyManager.abandonRecoveryFreeze(historyRecovery.freeze) + } + historyRecovery.freeze = await this.historyManager.freezeForRecovery(recoverySessionId) + historyRecovery.unreadableSessionId = null + } + const detectColdRestore = async (options?: { + ignoreCleanEnd?: boolean + }): Promise => { + if (!this.historyReader) { + return null + } + await freezeHistory() + const recoverySessionId = activeSpawnContext?.sessionId ?? sessionId + const recoveryWslDistro = activeSpawnContext?.wslDistro ?? wslDistro + const detection = await this.historyReader.detectColdRestoreState(recoverySessionId, { + ...options, + wslDistro: recoveryWslDistro + }) + if (detection.status === 'unreadable') { + historyRecovery.unreadableSessionId = detection.sessionId + return null + } + const restoreInfo = detection.status === 'restored' ? detection.restoreInfo : null + if (detection.status === 'restored' && detection.hasUnreadableRecovery) { + historyRecovery.unreadableSessionId = detection.sessionId + } + if (!restoreInfo) { + return null + } + return { + ...restoreInfo, + cwd: + normalizeWslColdRestoreCwd({ + recoveredCwd: restoreInfo.cwd, + requestedCwd: opts.cwd ?? resolveSafePtyDefaultCwd(), + wslDistro: recoveryWslDistro + }) ?? '' + } + } + + if (this.killedSessionTombstones.has(sessionId)) { + throw new TerminalKilledError(sessionId) + } + + if (opts.isNewSession) { + await this.replaceUnhealthyMacResolverDaemonBeforeNewPty() + await this.replaceStaleBundleDaemonBeforeNewPty() + await this.replaceSeveredMacTccDaemonBeforeNewPty() + } + + await this.ensureConnected() + // Why before createOrAttach: a preserved daemon may still think this session is backgrounded — from + // a v19 that thins without a recoverable seq, or (#9993) from a pre-v29 that a previous desktop + // handed 2031 scan authority to and can never retract it. Clear it before any bytes are attached. + if (!this.canDelegateBackgroundToDaemon) { + this.setPtyBackgrounded(sessionId, false) + } + + // Why detect crash-recovery history before spawning: the revived shell should inherit the recovered cwd/dims, not the renderer's mount-time request. + // Why probe aliveness first: detectColdRestore replays up to ~5MB on the main process, but a live session's snapshot supersedes disk, so the replay would be wasted. + let restoreInfo: ColdRestoreInfo | null = null + let restoreSkippedForLiveSession = false + const historyProbe = opts.attachOnly + ? undefined + : this.historyReader?.probeRestorableHistory(sessionId) + if (historyProbe && historyProbe.status !== 'none') { + if ((await this.getAppliedSize(sessionId)) !== null) { + restoreSkippedForLiveSession = true + if (this.historyManager && !this.historyManager.hasWriter(sessionId)) { + await detectColdRestore() + restoreInfo = null + } + } else { + restoreInfo = await detectColdRestore() + } + } + let effectiveCwd = restoreInfo?.cwd ?? opts.cwd + let effectiveCols = restoreInfo?.cols ?? opts.cols + let effectiveRows = restoreInfo?.rows ?? opts.rows + + const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false + const isCodexStartupCommand = + recognizeAgentProcessFromCommandLine(opts.command)?.agent === 'codex' + const shouldWaitForShellReady = + isCodexStartupCommand && + shouldUseShellReadyStartupDelivery({ + command: opts.command, + startupCommandDelivery: opts.startupCommandDelivery + }) + const shellReadyTimeoutMs = + shellReadySupported && isCodexStartupCommand && !shouldWaitForShellReady + ? CODEX_SHELL_READY_TIMEOUT_MS + : undefined + + const context: DaemonPtySpawnContext = { + opts, + operation, + historyRecovery, + requestedSessionId, + attachOnly, + emulateLegacyAttachOnly, + sessionId, + wslDistro, + restoreInfo, + restoreSkippedForLiveSession, + effectiveCwd, + effectiveCols, + effectiveRows, + shellReadySupported, + shellReadyTimeoutMs, + historySeedSegments: restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null, + detectColdRestore + } + activeSpawnContext = context + const result = await this.createOrAttachSpawn(context, context.historySeedSegments) + return this.finishSpawn(context, result) + } + + protected resultForExitBeforeSpawnReply( + sessionId: string, + result: CreateOrAttachResult, + operation: PendingDaemonSpawnOperation + ): PtySpawnResult | null { + const matchingExit = (operation.exitsBySessionId.get(sessionId) ?? []).some( + (exit) => + !(exit.incarnationId && operation.ignoredExitIncarnationIds.has(exit.incarnationId)) && + (!exit.incarnationId || + !result.incarnationId || + exit.incarnationId === result.incarnationId) + ) + if (!matchingExit) { + return null + } + // Why: stream exit can beat the control reply; return proof upward without republishing dead adapter state. + const exitedResult: PtySpawnResult = { + id: sessionId, + exitedBeforeSpawnReply: true, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}), + ...(!result.isNew ? { isReattach: true } : {}) + } + return exitedResult + } + + didExitBeforeSpawnReply(result: PtySpawnResult): boolean { + return result.exitedBeforeSpawnReply === true + } +} diff --git a/src/main/daemon/daemon-pty-spawn-preparations.ts b/src/main/daemon/daemon-pty-spawn-preparations.ts new file mode 100644 index 00000000000..633f0d1c7f5 --- /dev/null +++ b/src/main/daemon/daemon-pty-spawn-preparations.ts @@ -0,0 +1,118 @@ +import { TerminalAttachCanceledError } from './types' + +export type PendingPtySpawnPreparation = { + canceled: boolean + // Why: `canceled` is only polled between spawn steps; the signal is what + // actually interrupts an in-progress cwd probe on a dead share. + readonly controller: AbortController + cancelTimer?: ReturnType + // Why: preparations are keyed by sessionId, but a control-socket close must + // cancel only the disconnecting client's preps, not another client's (F4). + clientId: string + requestId: string +} + +export class DaemonPtySpawnPreparations { + private readonly pending = new Map>() + private readonly cancellationByPreparation = new WeakMap< + PendingPtySpawnPreparation, + { promise: Promise; resolve: () => void } + >() + + constructor(private readonly preparePtySpawn: () => Promise) {} + + register( + sessionId: string, + clientId: string, + requestId: string, + cancelAfterMs: unknown + ): PendingPtySpawnPreparation { + const preparation: PendingPtySpawnPreparation = { + canceled: false, + controller: new AbortController(), + clientId, + requestId + } + this.cancellationByPreparation.set(preparation, Promise.withResolvers()) + if (Number.isSafeInteger(cancelAfterMs) && Number(cancelAfterMs) > 0) { + preparation.cancelTimer = setTimeout( + () => this.cancelPreparation(preparation), + Math.min(Number(cancelAfterMs), 300_000) + ) + preparation.cancelTimer.unref() + } + const pendingForSession = this.pending.get(sessionId) ?? new Set() + pendingForSession.add(preparation) + this.pending.set(sessionId, pendingForSession) + return preparation + } + + async prepareUnlessCanceled( + sessionId: string, + preparation: PendingPtySpawnPreparation + ): Promise { + // Race cancellation against preflight so shutdown/disconnect can return the + // protocol cancellation error before tearing down the client transport. + const cancellation = this.cancellationByPreparation.get(preparation) + const preparationTask = cancellation + ? Promise.race([this.preparePtySpawn(), cancellation.promise]) + : this.preparePtySpawn() + await preparationTask + if (preparation.canceled) { + throw new TerminalAttachCanceledError(sessionId) + } + } + + finish(sessionId: string, preparation: PendingPtySpawnPreparation): void { + if (preparation.cancelTimer) { + clearTimeout(preparation.cancelTimer) + } + this.cancellationByPreparation.delete(preparation) + const pendingForSession = this.pending.get(sessionId) + pendingForSession?.delete(preparation) + if (pendingForSession?.size === 0) { + this.pending.delete(sessionId) + } + } + + cancel(sessionId: string, request?: { clientId: string; requestId?: string }): boolean { + const pendingForSession = this.pending.get(sessionId) + if (!pendingForSession) { + return false + } + let canceled = false + for (const preparation of pendingForSession) { + if ( + request && + (preparation.clientId !== request.clientId || + (request.requestId !== undefined && preparation.requestId !== request.requestId)) + ) { + continue + } + this.cancelPreparation(preparation) + canceled = true + } + return canceled + } + + cancelForClient(clientId: string): void { + for (const pendingForSession of this.pending.values()) { + for (const preparation of pendingForSession) { + if (preparation.clientId === clientId) { + this.cancelPreparation(preparation) + } + } + } + } + private cancelPreparation(preparation: PendingPtySpawnPreparation): void { + preparation.canceled = true + preparation.controller.abort() + this.cancellationByPreparation.get(preparation)?.resolve() + } + + cancelAll(): void { + for (const sessionId of this.pending.keys()) { + this.cancel(sessionId) + } + } +} diff --git a/src/main/daemon/daemon-pty-spawn-request.ts b/src/main/daemon/daemon-pty-spawn-request.ts new file mode 100644 index 00000000000..8078a9068f3 --- /dev/null +++ b/src/main/daemon/daemon-pty-spawn-request.ts @@ -0,0 +1,202 @@ +import type { ColdRestorePayload } from './cold-restore-payload-cache' +import type { DaemonAuditObservation } from './daemon-audit-classifier' +import { + DaemonPtyRuntimeState, + type HistoryRecoveryContext, + type PendingDaemonSpawnOperation, + type SnapshotCheckpointResult +} from './daemon-pty-runtime-state' +import { isDaemonGoneError } from './daemon-endpoint-errors' +import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version' +import type { ColdRestoreInfo } from './history-reader' +import { NdjsonLineTooLongError } from './ndjson' +import { + iterateTerminalHistorySeedChunks, + measureTerminalHistorySeed, + TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS +} from './terminal-history-seed-chunks' +import type { CreateOrAttachResult, GetSnapshotResult } from './types' +import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' + +export type DaemonPtySpawnContext = { + opts: PtySpawnOptions + operation: PendingDaemonSpawnOperation + historyRecovery: HistoryRecoveryContext + requestedSessionId: string + attachOnly: boolean + emulateLegacyAttachOnly: boolean + sessionId: string + wslDistro: string | undefined + restoreInfo: ColdRestoreInfo | null + restoreSkippedForLiveSession: boolean + effectiveCwd: string | undefined + effectiveCols: number + effectiveRows: number + shellReadySupported: boolean + shellReadyTimeoutMs: number | undefined + historySeedSegments: readonly string[] | null + detectColdRestore(options?: { ignoreCleanEnd?: boolean }): Promise +} + +export abstract class DaemonPtySpawnRequest extends DaemonPtyRuntimeState { + protected abstract takeSnapshotAndCheckpoint( + sessionId: string, + opts: { + teardown: boolean + forceLiveSnapshot?: boolean + requireContinuityProof?: boolean + } + ): Promise + protected abstract markSessionDirty(sessionId: string): void + protected abstract writeSessionCheckpoint( + sessionId: string, + opts: { final: boolean; teardown: boolean } + ): Promise<'done' | 'deferred'> + protected abstract releasePendingRespawnAdoptionLease(): void + protected abstract setupEventRouting(): void + protected abstract scheduleCheckpointTimer(): void + protected abstract stopCheckpointTimer(): void + protected abstract stopCheckpointTimerIfIdle(): void + protected abstract recordAuthenticatedIdentity(): void + protected abstract runExclusiveCheckpoint( + operation: () => Promise, + options?: { rescheduleDirty?: boolean; callerDeadlineMs?: number } + ): Promise + protected abstract checkpointAllSessions(): Promise + protected abstract ensureConnected(deadlineMs?: number): Promise + protected abstract withHistorySpawnLock( + sessionId: string, + operation: () => Promise + ): Promise + protected abstract clearSessionAwaitingDaemonRecovery(sessionId: string): void + protected abstract reconnectAfterWriteFailure(): void + protected abstract checkpointSessions( + sessionIds: Iterable, + opts?: { final?: boolean; teardown?: boolean } + ): Promise> + protected abstract publishAuditObservation(observation: DaemonAuditObservation): void + protected abstract isRetiredEndpointTokenMissing(): boolean + protected abstract withDaemonRetry(fn: () => Promise): Promise + protected abstract replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise + protected abstract replaceStaleBundleDaemonBeforeNewPty(): Promise + protected abstract replaceSeveredMacTccDaemonBeforeNewPty(): Promise + abstract setPtyBackgrounded(id: string, background: boolean): void + abstract getAppliedSize(id: string): Promise<{ cols: number; rows: number } | null> + protected abstract resultForExitBeforeSpawnReply( + sessionId: string, + result: CreateOrAttachResult, + operation: PendingDaemonSpawnOperation + ): PtySpawnResult | null + protected abstract buildColdRestorePayload( + restoreInfo: ColdRestoreInfo + ): ColdRestorePayload | null + protected abstract overlayDurableRestoreSnapshot( + sessionId: string, + liveSnapshot: NonNullable, + scrollbackRows?: number + ): Promise> + + protected async createOrAttachSpawn( + context: DaemonPtySpawnContext, + historySeedSegments: readonly string[] | null + ): Promise { + const requestCreateOrAttach = ( + historySeed: string | undefined, + historySeedTransferId: string | undefined + ) => { + const { opts } = context + if (opts.signal?.aborted) { + throw new Error('client_disconnected') + } + const payload = { + sessionId: context.sessionId, + cols: context.effectiveCols, + rows: context.effectiveRows, + cwd: context.attachOnly ? undefined : context.effectiveCwd, + env: context.attachOnly ? undefined : opts.env, + envToDelete: context.attachOnly ? undefined : opts.envToDelete, + command: context.attachOnly ? undefined : opts.command, + startupCommandDelivery: context.attachOnly ? undefined : opts.startupCommandDelivery, + launchAgent: context.attachOnly ? undefined : opts.launchAgent, + ...(context.attachOnly && !context.emulateLegacyAttachOnly ? { attachOnly: true } : {}), + shellOverride: context.attachOnly ? undefined : opts.shellOverride, + terminalWindowsWslDistro: context.attachOnly ? undefined : opts.terminalWindowsWslDistro, + terminalWindowsPowerShellImplementation: context.attachOnly + ? undefined + : opts.terminalWindowsPowerShellImplementation, + shellReadySupported: context.attachOnly ? false : context.shellReadySupported, + ...(!context.attachOnly && context.shellReadyTimeoutMs !== undefined + ? { shellReadyTimeoutMs: context.shellReadyTimeoutMs } + : {}), + ...(historySeed ? { historySeed } : {}), + ...(historySeedTransferId ? { historySeedTransferId } : {}), + ...(this.supportsStartupIngress && !context.attachOnly && opts.startupIngress + ? { startupIngress: opts.startupIngress } + : {}), + ...(!context.attachOnly && opts.agentSessionEnsure + ? { agentSessionEnsure: opts.agentSessionEnsure } + : {}) + } + return opts.signal + ? this.client.request( + 'createOrAttach', + payload, + undefined, + opts.signal + ) + : this.client.request('createOrAttach', payload) + } + + let historySeedUnavailable = false + const deliverSeedAndCreate = async (): Promise => { + if (!historySeedSegments || historySeedSegments.length === 0) { + return requestCreateOrAttach(undefined, undefined) + } + const metrics = measureTerminalHistorySeed(historySeedSegments) + if (metrics.codeUnits <= TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS) { + try { + return await requestCreateOrAttach(historySeedSegments.join(''), undefined) + } catch (error) { + if (!(error instanceof NdjsonLineTooLongError)) { + throw error + } + historySeedUnavailable = true + return requestCreateOrAttach(undefined, undefined) + } + } + if (this.protocolVersion < HISTORY_SEED_TRANSFER_PROTOCOL_VERSION) { + historySeedUnavailable = true + return requestCreateOrAttach(undefined, undefined) + } + + let transferId: string | undefined + try { + const started = await this.client.request<{ transferId: string }>( + 'startHistorySeedTransfer', + metrics + ) + transferId = started.transferId + let index = 0 + for (const data of iterateTerminalHistorySeedChunks(historySeedSegments)) { + await this.client.request('appendHistorySeedTransfer', { transferId, index, data }) + index += 1 + } + await this.client.request('finishHistorySeedTransfer', { transferId }) + } catch (error) { + if (transferId) { + await this.client.request('abortHistorySeedTransfer', { transferId }).catch(() => {}) + } + if (isDaemonGoneError(error)) { + throw error + } + historySeedUnavailable = true + return requestCreateOrAttach(undefined, undefined) + } + return requestCreateOrAttach(undefined, transferId) + } + const result = await deliverSeedAndCreate() + return historySeedUnavailable && result.historySeeded === undefined + ? { ...result, historySeeded: false } + : result + } +} diff --git a/src/main/daemon/daemon-pty-spawn-result.ts b/src/main/daemon/daemon-pty-spawn-result.ts new file mode 100644 index 00000000000..c40e8b6a9bd --- /dev/null +++ b/src/main/daemon/daemon-pty-spawn-result.ts @@ -0,0 +1,326 @@ +import { isAgentSessionClaimedSpawnResult } from '../../shared/agent-session-host-authority' +import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' +import { DaemonPtySpawnRequest, type DaemonPtySpawnContext } from './daemon-pty-spawn-request' +import { providerSequenceFromCreateOrAttach } from './daemon-pty-provider-sequence' +import { takeHistoryRecoveryFreeze } from './daemon-history-recovery-freeze' +import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segments' +import { SessionNotFoundError, type CreateOrAttachResult } from './types' +import type { PtySpawnResult } from '../providers/types' + +export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { + protected async finishSpawn( + context: DaemonPtySpawnContext, + initialResult: CreateOrAttachResult + ): Promise { + const { + opts, + operation, + historyRecovery, + requestedSessionId, + emulateLegacyAttachOnly, + restoreSkippedForLiveSession, + detectColdRestore + } = context + let { sessionId, wslDistro, restoreInfo, effectiveCwd, effectiveCols, effectiveRows } = context + const createOrAttach = (historySeedSegments: readonly string[] | null) => { + Object.assign(context, { + sessionId, + effectiveCwd, + effectiveCols, + effectiveRows + }) + return this.createOrAttachSpawn(context, historySeedSegments) + } + let result = initialResult + let historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null + const adoptSpawnResultSession = async (spawnResult: CreateOrAttachResult): Promise => { + const requestedSessionId = sessionId + if ( + opts.agentSessionEnsure && + !isAgentSessionClaimedSpawnResult(spawnResult.agentSessionEnsure) + ) { + // Why: a claim-incapable owner may already have spawned before returning + // a malformed response; retire only this requested session before failing closed. + await this.client.request('kill', { sessionId: requestedSessionId }).catch(() => {}) + throw new Error('agent_session_claim_unavailable') + } + sessionId = spawnResult.agentSessionEnsure?.owner.ptyId ?? requestedSessionId + context.sessionId = sessionId + if (requestedSessionId === sessionId) { + return + } + if (historyRecovery.freeze) { + this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze) + historyRecovery.freeze = null + } + historyRecovery.unreadableSessionId = null + historyRecovery.identityChanged = true + restoreInfo = null + historySeedSegments = null + } + if (emulateLegacyAttachOnly && result.isNew) { + operation.ignoreNextExit = true + await this.client.request('kill', { sessionId: requestedSessionId, immediate: true }) + throw new SessionNotFoundError(requestedSessionId) + } + await adoptSpawnResultSession(result) + // Both ids: adoptSpawnResultSession may have rewritten sessionId to the claim owner. + this.clearSessionAwaitingDaemonRecovery(requestedSessionId) + this.clearSessionAwaitingDaemonRecovery(sessionId) + const exitedResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) + if (exitedResult) { + return exitedResult + } + if (result.incarnationId) { + this.sessionIncarnations.set(sessionId, result.incarnationId) + } + const claimResult = (): Pick | Record => + result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {} + const incarnationResult = (): Pick | Record => + result.incarnationId ? { incarnationId: result.incarnationId } : {} + let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro + // Why: explicit null from a current daemon overrides the caller's WSL preference; undefined keeps compatibility with older daemons. + wslDistro = providerWslDistro ?? undefined + context.wslDistro = wslDistro + if (wslDistro) { + this.wslDistrosBySessionId.set(sessionId, wslDistro) + } else if (providerWslDistro === null || result.isNew) { + this.wslDistrosBySessionId.delete(sessionId) + } + const launchIdentity = (): { launchAgent?: NonNullable } => + result.launchAgent ? { launchAgent: result.launchAgent } : {} + + if (effectiveCwd) { + this.initialCwds.set(sessionId, effectiveCwd) + } + + // Why: surface the daemon's shell pid via PtySpawnResult so ipc/pty registers with the memory collector without a provider-specific accessor. + let pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null + + // Why: check sticky cache first — StrictMode double-mounts call spawn twice; the second call (isNew=false) must still return cached cold restore data. + const cachedRestore = this.coldRestoreCache.get(sessionId) + if (cachedRestore) { + // Why: wake-after-sleep lands here too; sleep dropped active tracking + the history writer, so re-register both or the next sleep/wake restores a blank terminal. + this.activeSessionIds.add(sessionId) + if (this.historyManager && !historyRecovery.identityChanged) { + const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId) + if (historyRecovery.unreadableSessionId === sessionId) { + this.historyManager.suspendSession(sessionId, recoveryFreeze) + } else { + this.historyManager.reopenSession(sessionId, recoveryFreeze) + } + } + return { + id: sessionId, + ...incarnationResult(), + pid, + ...claimResult(), + ...launchIdentity(), + coldRestore: cachedRestore, + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), + ...(!result.isNew ? { isReattach: true } : {}) + } + } + + // Why: the probe→createOrAttach gap is racy — the session can exit in between, so re-detect to match the unprobed restore path. + // Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it. + if (!historyRecovery.identityChanged && result.isNew && restoreSkippedForLiveSession) { + restoreInfo = await detectColdRestore({ ignoreCleanEnd: true }) + historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null + if (restoreInfo && historySeedSegments && historySeedSegments.length > 0) { + // Why: the aliveness probe raced with session death, so the first + // create lacked recovery bytes. Replace it before exposing the PTY. + if (result.incarnationId) { + operation.ignoredExitIncarnationIds.add(result.incarnationId) + } + operation.ignoreNextExit = true + await this.client.request('kill', { sessionId, immediate: true }) + effectiveCwd = restoreInfo.cwd + effectiveCols = restoreInfo.cols + effectiveRows = restoreInfo.rows + result = await createOrAttach(historySeedSegments) + await adoptSpawnResultSession(result) + const exitedRetryResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) + if (exitedRetryResult) { + return exitedRetryResult + } + if (result.incarnationId) { + this.sessionIncarnations.set(sessionId, result.incarnationId) + } + providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro + wslDistro = providerWslDistro ?? undefined + context.wslDistro = wslDistro + if (wslDistro) { + this.wslDistrosBySessionId.set(sessionId, wslDistro) + } else if (providerWslDistro === null || result.isNew) { + this.wslDistrosBySessionId.delete(sessionId) + } + pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null + this.initialCwds.set(sessionId, effectiveCwd) + } + } else if ( + !historyRecovery.identityChanged && + !result.isNew && + result.historySeeded === false + ) { + restoreInfo = await detectColdRestore() + historySeedSegments = restoreInfo ? getRecoveredHistorySeedSegments(restoreInfo) : null + } + + const wasAlreadyManaged = this.activeSessionIds.has(sessionId) + this.activeSessionIds.add(sessionId) + const providerSequence = providerSequenceFromCreateOrAttach(result) + + // Cold restore: daemon made a new session but disk history shows an unclean shutdown → return saved scrollback. + if (restoreInfo && (result.isNew || result.historySeeded === false)) { + const coldRestore = this.buildColdRestorePayload(restoreInfo) + const canReanchorHistory = + !historySeedSegments || historySeedSegments.length === 0 || result.historySeeded === true + // Why: registerWriter (not openSession) avoids deleting checkpoint.json — the only recovery data if the revived daemon crashes before the next tick. + if (this.historyManager && !historyRecovery.identityChanged) { + const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId) + if (historyRecovery.unreadableSessionId === sessionId) { + await this.historyManager.openSession(sessionId, { + cwd: effectiveCwd ?? '', + cols: effectiveCols, + rows: effectiveRows, + ...(recoveryFreeze ? { recoveryFreeze } : {}), + quarantineUnreadableRecovery: true + }) + if (this.historyManager.hasWriter(sessionId)) { + this.sessionsNeedingFullCheckpoint.add(sessionId) + this.sessionsNeedingLiveCheckpoint.add(sessionId) + this.lastFullCheckpointAt.delete(sessionId) + } + } else if (canReanchorHistory) { + this.historyManager.registerWriter(sessionId, recoveryFreeze) + this.sessionsNeedingFullCheckpoint.add(sessionId) + // Why: the revived generation has no valid checkpoint yet; a cooldown inherited from the pre-crash generation must not defer this re-anchor. + this.lastFullCheckpointAt.delete(sessionId) + } else { + // Preserve old recovery files when the new daemon can't include them; a fresh-only checkpoint would make the data loss permanent. + this.historyManager.suspendSession(sessionId, recoveryFreeze) + } + } + if (coldRestore) { + this.coldRestoreCache.set(sessionId, coldRestore) + return { + id: sessionId, + ...incarnationResult(), + pid, + ...claimResult(), + ...launchIdentity(), + coldRestore, + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), + ...(providerSequence ? { providerSequence } : {}), + ...(!result.isNew ? { isReattach: true } : {}) + } + } + return { + id: sessionId, + ...incarnationResult(), + pid, + ...claimResult(), + ...launchIdentity(), + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), + ...(providerSequence ? { providerSequence } : {}) + } + } + + if (this.historyManager && !historyRecovery.identityChanged && result.isNew) { + const recoveryFreeze = takeHistoryRecoveryFreeze(historyRecovery, sessionId) + await this.historyManager.openSession(sessionId, { + cwd: effectiveCwd ?? '', + cols: effectiveCols, + rows: effectiveRows, + ...(recoveryFreeze ? { recoveryFreeze } : {}), + ...(historyRecovery.unreadableSessionId === sessionId + ? { quarantineUnreadableRecovery: true } + : {}) + }) + } else if ( + this.historyManager && + !historyRecovery.identityChanged && + (result.historySeeded === false || historyRecovery.unreadableSessionId === sessionId) + ) { + // Why: the daemon keeps this failure bit with the live session, so a new adapter can't promote its fresh-only snapshot after restart. + this.historyManager.suspendSession( + sessionId, + takeHistoryRecoveryFreeze(historyRecovery, sessionId) + ) + } else if (this.historyManager && !historyRecovery.identityChanged) { + // Why: on warm reattach after relaunch the HistoryManager is fresh; registerWriter adds a writer without deleting the still-only-valid checkpoint. + this.historyManager.registerWriter( + sessionId, + takeHistoryRecoveryFreeze(historyRecovery, sessionId) + ) + if (!wasAlreadyManaged) { + // Why: a previous adapter may have drained records it never persisted, so the first compact must prove disk-to-daemon continuity. + this.sessionsNeedingFullCheckpoint.add(sessionId) + this.sessionsNeedingContinuityCheckpoint.add(sessionId) + this.lastFullCheckpointAt.delete(sessionId) + } + } + + const isReattach = !result.isNew + if (!isReattach || !result.snapshot) { + return { + id: sessionId, + ...incarnationResult(), + pid, + ...claimResult(), + ...launchIdentity(), + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), + ...(providerSequence ? { providerSequence } : {}), + ...(isReattach ? { isReattach: true } : {}) + } + } + + const reattachSnapshot = await this.overlayDurableRestoreSnapshot(sessionId, result.snapshot) + const reattachProviderSequence = + typeof reattachSnapshot.outputSequence === 'number' + ? { value: reattachSnapshot.outputSequence, generation: 'continued' as const } + : providerSequence + const isAltScreen = reattachSnapshot.modes.alternateScreen + const snapshotPrefix = reattachSnapshot.scrollbackAnsi + reattachSnapshot.rehydrateSequences + const snapshotFrame = reattachSnapshot.snapshotAnsi + const snapshotPayload = snapshotPrefix + snapshotFrame + // Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty). + // Why known `0` is no longer dropped: the pane tracker must be able to tell + // "the app negotiated nothing" from "this reattach proved nothing". + const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags( + reattachSnapshot.modes.kittyKeyboardFlags + ) + return { + id: sessionId, + ...incarnationResult(), + pid, + ...claimResult(), + ...launchIdentity(), + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), + snapshot: snapshotPayload, + snapshotCols: reattachSnapshot.cols, + snapshotRows: reattachSnapshot.rows, + // Why only for an alt frame: normal history remains safe to replay at its capture grid. + ...(isAltScreen && snapshotFrame && reattachSnapshot.frameRestoreAnsi + ? { + snapshotPrefixAnsi: snapshotPrefix, + snapshotFrameAnsi: snapshotFrame, + snapshotFrameRestoreAnsi: reattachSnapshot.frameRestoreAnsi + } + : {}), + ...(reattachProviderSequence ? { providerSequence: reattachProviderSequence } : {}), + ...(kittyKeyboardFlags !== undefined + ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } + : {}), + isReattach: true, + isAlternateScreen: isAltScreen, + // Why: the snapshot ANSI has no title frame; carry lastTitle beside it so main can seed title records after a relaunch. + ...(reattachSnapshot.lastTitle ? { lastTitle: reattachSnapshot.lastTitle } : {}), + // Why: carry the mid-escape tail so the renderer writes it after the reattach reset, else a split escape renders literally (#7329). + ...(reattachSnapshot.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: reattachSnapshot.pendingEscapeTailAnsi } + : {}) + } + } +} diff --git a/src/main/daemon/daemon-request-deadline.ts b/src/main/daemon/daemon-request-deadline.ts new file mode 100644 index 00000000000..d98c1c5f7dd --- /dev/null +++ b/src/main/daemon/daemon-request-deadline.ts @@ -0,0 +1,25 @@ +export function remainingDaemonRequestTimeoutMs( + deadlineMs: number | undefined +): number | undefined { + return deadlineMs === undefined ? undefined : Math.max(1, deadlineMs - Date.now()) +} + +// The caller's wait is bounded without cancelling durable work, which keeps running and committing. +export async function awaitDaemonWorkWithinCallerDeadline( + work: Promise, + deadlineMs: number +): Promise { + void work.catch(() => {}) + let timer: ReturnType | undefined + try { + return await Promise.race([ + work.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), Math.max(1, deadlineMs - Date.now())) + timer.unref?.() + }) + ]) + } finally { + clearTimeout(timer) + } +} diff --git a/src/main/daemon/daemon-request-router.ts b/src/main/daemon/daemon-request-router.ts new file mode 100644 index 00000000000..826c73b4370 --- /dev/null +++ b/src/main/daemon/daemon-request-router.ts @@ -0,0 +1,282 @@ +import { performance } from 'node:perf_hooks' +import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' +import type { ConnectedDaemonClient, DaemonClientConnections } from './daemon-client-connections' +import type { DaemonFileLog } from './daemon-file-log' +import type { DaemonPtySpawnPreparations } from './daemon-pty-spawn-preparations' +import type { DaemonServerLifecycle } from './daemon-server-lifecycle' +import type { DaemonSessionAttachments } from './daemon-session-attachments' +import type { DaemonSessionBackgroundRouting } from './daemon-session-background-routing' +import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import type { DaemonTerminalAdmission } from './daemon-terminal-admission' +import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' +import type { TerminalHost } from './terminal-host' +import { SessionNotFoundError, type DaemonRequest } from './types' + +type DaemonRequestRouterOptions = { + host: TerminalHost + connections: DaemonClientConnections + lifecycle: DaemonServerLifecycle + admission: DaemonTerminalAdmission + preparations: DaemonPtySpawnPreparations + attachments: DaemonSessionAttachments + historySeedTransfers: TerminalHistorySeedTransferRegistry + sessionBackgroundRouting: DaemonSessionBackgroundRouting + streamDataBatcher: DaemonStreamDataBatcher + ptySpawnHealthCheck: () => Promise + log: DaemonFileLog +} + +export class DaemonRequestRouter { + constructor(private readonly options: DaemonRequestRouterOptions) {} + + async route(clientId: string, request: DaemonRequest): Promise { + const client = this.options.connections.get(clientId) + switch (request.type) { + case 'startHistorySeedTransfer': { + if (!client?.authenticatedPairEstablished || client.streamSocket === null) { + throw new Error('Daemon client connection is incomplete; reconnect') + } + return { + transferId: this.options.historySeedTransfers.start(clientId, request.payload) + } + } + case 'appendHistorySeedTransfer': + this.options.historySeedTransfers.append( + clientId, + request.payload.transferId, + request.payload.index, + request.payload.data + ) + return {} + case 'finishHistorySeedTransfer': + this.options.historySeedTransfers.finish(clientId, request.payload.transferId) + return {} + case 'abortHistorySeedTransfer': + this.options.historySeedTransfers.abort(clientId, request.payload.transferId) + return {} + case 'createOrAttach': + return this.options.admission.createOrAttach(clientId, request) + case 'cancelCreateOrAttach': + return { + canceled: this.options.preparations.cancel(request.payload.sessionId, { + clientId, + ...(typeof request.payload.requestId === 'string' + ? { requestId: request.payload.requestId } + : {}) + }) + } + case 'closeStartupQueryAuthority': + return { + appliedSeq: this.options.host.closeStartupQueryAuthority(request.payload.sessionId) + } + case 'write': + return this.write(client, request.payload.sessionId, request.payload.data) + case 'resize': + return this.resize( + client, + request.payload.sessionId, + request.payload.cols, + request.payload.rows + ) + case 'pausePty': + this.options.host.pauseProducer(request.payload.sessionId) + return {} + case 'resumePty': + this.options.host.resumeProducer(request.payload.sessionId) + return {} + case 'setSessionBackground': + return this.options.sessionBackgroundRouting.setBackground( + request.payload.sessionId, + request.payload.background === true + ) + case 'kill': + return this.kill(clientId, request.payload.sessionId, request.payload.immediate) + case 'signal': + this.options.host.signal(request.payload.sessionId, request.payload.signal) + return {} + case 'detach': + this.options.attachments.detachSessionForClient(request.payload.sessionId, clientId) + this.options.log.log('session-detached', { sessionId: request.payload.sessionId }) + return {} + case 'getCwd': + return { cwd: await this.options.host.getCwd(request.payload.sessionId) } + case 'getForegroundProcess': + return { + foregroundProcess: this.options.host.getForegroundProcess(request.payload.sessionId) + } + case 'inspectProcess': + return this.options.host.inspectProcess(request.payload.sessionId) + case 'confirmForegroundProcess': + return { + foregroundProcess: await this.options.host.confirmForegroundProcess( + request.payload.sessionId + ) + } + case 'clearScrollback': + this.options.host.clearScrollback(request.payload.sessionId) + return {} + case 'listSessions': + return { sessions: this.options.host.listSessions() } + case 'shutdownIfIdle': + return this.shutdownIfIdle(clientId, request.id) + case 'getSnapshot': + return this.getSnapshot(request.payload.sessionId, request.payload.scrollbackRows) + case 'getSize': + return { size: this.options.host.getAppliedSize(request.payload.sessionId) } + case 'takePendingOutput': + return this.options.host.takePendingOutput( + request.payload.sessionId, + request.payload.includeSnapshot === true, + { teardownSnapshot: request.payload.teardownSnapshot === true } + ) + case 'ping': + return { pong: true } + case 'systemResolverHealth': + return { health: await readCurrentProcessMacSystemResolverHealth() } + case 'ptySpawnHealth': + await this.options.ptySpawnHealthCheck() + return { healthy: true } + case 'shutdown': + return this.shutdown(clientId, request.id, request.payload.killSessions) + } + throw new Error(`Unknown request type: ${(request as { type: string }).type}`) + } + + private write( + client: ConnectedDaemonClient | undefined, + sessionId: string, + data: string + ): Record { + try { + this.options.attachments.recordInput(sessionId) + this.options.host.write(sessionId, data) + } catch (error) { + this.options.attachments.clearInput(sessionId) + if (error instanceof SessionNotFoundError) { + this.sendExitEvent(client, sessionId, -1) + } + throw error + } + return {} + } + + private resize( + client: ConnectedDaemonClient | undefined, + sessionId: string, + cols: number, + rows: number + ): Record { + try { + this.options.host.resize(sessionId, cols, rows) + } catch (error) { + if (error instanceof SessionNotFoundError) { + this.sendExitEvent(client, sessionId, -1) + } + throw error + } + return {} + } + + private async kill( + clientId: string, + sessionId: string, + immediate: boolean | undefined + ): Promise> { + const canceledPendingSpawn = this.options.preparations.cancel(sessionId) + this.options.attachments.clearInput(sessionId) + const attribution = { sessionId, immediate: immediate === true, clientId } + try { + await this.options.host.kill(sessionId, { immediate }) + } catch (error) { + if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) { + this.options.log.log('session-kill-failed', attribution) + throw error + } + } + this.options.log.log('session-killed', attribution) + return {} + } + + private shutdownIfIdle(clientId: string, requestId: string): { retiring: boolean } { + const client = this.options.connections.get(clientId) + const retiring = + client !== undefined && + client.streamSocket !== null && + this.options.connections.size === 1 && + this.options.admission.inFlight === 0 && + this.options.host.listSessions().length === 0 && + this.options.connections.hasOnlyTransportsFor(client) + if (retiring) { + this.options.lifecycle.retireAfterIdleReply(clientId, requestId, client.controlSocket) + } + return { retiring } + } + + private getSnapshot(sessionId: string, requestedRows: unknown): unknown { + const startedAt = performance.now() + const scrollbackRows = + typeof requestedRows === 'number' && Number.isFinite(requestedRows) + ? Math.max(0, Math.min(50_000, Math.floor(requestedRows))) + : undefined + const snapshot = this.options.host.getSnapshot(sessionId, { scrollbackRows }) + const snapshotMs = performance.now() - startedAt + if (snapshotMs >= 25) { + recordDaemonStreamBacklogEvent('slowGetSnapshot', { + sessionIdSuffix: sessionId.slice(-10), + snapshotMs: Math.round(snapshotMs) + }) + } + return { snapshot } + } + + private async shutdown( + clientId: string, + requestId: string, + killSessions: boolean + ): Promise> { + this.options.log.log('shutdown', { + reason: 'rpc', + killSessions: killSessions === true + }) + const serverClose = this.options.lifecycle.beginOrdinaryShutdownFence() + if (killSessions) { + try { + await this.options.host.dispose() + } catch (error) { + this.options.log.log('shutdown-dispose-failed', { + error: error instanceof Error ? error.message : String(error) + }) + } + } + const controlSocket = this.options.connections.get(clientId)?.controlSocket + if (controlSocket) { + this.options.lifecycle.deferRpcShutdownUntilReply( + clientId, + requestId, + controlSocket, + serverClose + ) + } else { + this.options.lifecycle.finishRpcShutdownWithoutReply(serverClose) + } + return {} + } + + private sendExitEvent( + client: ConnectedDaemonClient | undefined, + sessionId: string, + code: number + ): void { + if (!client?.streamSocket) { + return + } + this.options.streamDataBatcher.enqueueControlEvent(client.clientId, sessionId, { + type: 'event', + event: 'exit', + sessionId, + payload: { code } + }) + this.options.streamDataBatcher.flush(client.clientId) + } +} diff --git a/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts b/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts index 6440fd123db..57585dc9318 100644 --- a/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts +++ b/src/main/daemon/daemon-server-async-spawn-cancellation.test.ts @@ -6,6 +6,12 @@ import { DaemonClient } from './client' import { createMockSubprocess } from './daemon-pty-adapter-test-harness' import { DaemonServer } from './daemon-server' import { getDaemonSocketPath } from './daemon-spawner' +type DaemonServerInternals = { + preparations: { + pending: Map> + } + host: { listSessions: () => unknown[] } +} describe('daemon async spawn cancellation', () => { let directory: string @@ -82,14 +88,11 @@ describe('daemon async spawn cancellation', () => { await spawnStarted client.disconnect() - const daemon = server as unknown as { - pendingPtySpawnPreparations: Map> - host: { listSessions: () => unknown[] } - } + const daemon = server as unknown as DaemonServerInternals await vi.waitFor(() => - expect( - [...(daemon.pendingPtySpawnPreparations.get('disconnected-spawn') ?? [])][0]?.canceled - ).toBe(true) + expect([...(daemon.preparations.pending.get('disconnected-spawn') ?? [])][0]?.canceled).toBe( + true + ) ) releaseSpawn() await create @@ -145,14 +148,10 @@ describe('daemon async spawn cancellation', () => { 30_000, abort.signal ) - const daemon = server as unknown as { - pendingPtySpawnPreparations: Map> - } + const daemon = server as unknown as DaemonServerInternals // Attach-only used to register nothing, so the daemon could not match the // cancel and the client dropped its only timeout. - await vi.waitFor(() => - expect(daemon.pendingPtySpawnPreparations.get('shared-session')?.size).toBe(2) - ) + await vi.waitFor(() => expect(daemon.preparations.pending.get('shared-session')?.size).toBe(2)) abort.abort() await expect(attach).rejects.toThrow('client_disconnected') @@ -175,12 +174,8 @@ describe('daemon async spawn cancellation', () => { abort.signal ) await spawnStarted - const daemon = server as unknown as { - pendingPtySpawnPreparations: Map> - } - await vi.waitFor(() => - expect(daemon.pendingPtySpawnPreparations.get('shared-session')?.size).toBe(2) - ) + const daemon = server as unknown as DaemonServerInternals + await vi.waitFor(() => expect(daemon.preparations.pending.get('shared-session')?.size).toBe(2)) abort.abort() await expect(second).rejects.toThrow('client_disconnected') diff --git a/src/main/daemon/daemon-server-attachment-lifecycle.test.ts b/src/main/daemon/daemon-server-attachment-lifecycle.test.ts index 34d78739f13..2f4d1b734ac 100644 --- a/src/main/daemon/daemon-server-attachment-lifecycle.test.ts +++ b/src/main/daemon/daemon-server-attachment-lifecycle.test.ts @@ -35,9 +35,13 @@ type DaemonAttachmentPrivate = { createOrAttach: (options: CreateOrAttachOptions) => Promise sessions: Map } - clients: Map - streamClientIdBySessionId: Map - attachTokenBySessionId: Map + connections: { + clients: Map + } + attachments: { + clientIdBySessionId: Map + tokenBySessionId: Map + } } describe('DaemonServer attachment lifecycle', () => { @@ -73,7 +77,7 @@ describe('DaemonServer attachment lifecycle', () => { await createSession('transport-owned-session') const daemon = server as unknown as DaemonAttachmentPrivate const detachClients = vi.spyOn(daemon.host, 'detachClients') - const streamSocket = [...daemon.clients.values()][0]?.streamSocket + const streamSocket = [...daemon.connections.clients.values()][0]?.streamSocket streamSocket?.destroy() @@ -82,8 +86,8 @@ describe('DaemonServer attachment lifecycle', () => { { sessionId: 'transport-owned-session', token: expect.any(Symbol) } ]) expect(daemon.host.sessions.get('transport-owned-session')?.hasAttachedClients).toBe(false) - expect(daemon.streamClientIdBySessionId.has('transport-owned-session')).toBe(false) - expect(daemon.attachTokenBySessionId.has('transport-owned-session')).toBe(false) + expect(daemon.attachments.clientIdBySessionId.has('transport-owned-session')).toBe(false) + expect(daemon.attachments.tokenBySessionId.has('transport-owned-session')).toBe(false) }) it('parks only the requesting client attachment on explicit detach', async () => { @@ -95,8 +99,8 @@ describe('DaemonServer attachment lifecycle', () => { expect(detach).toHaveBeenCalledWith('explicitly-detached-session', expect.any(Symbol)) expect(daemon.host.sessions.get('explicitly-detached-session')?.hasAttachedClients).toBe(false) - expect(daemon.streamClientIdBySessionId.has('explicitly-detached-session')).toBe(false) - expect(daemon.attachTokenBySessionId.has('explicitly-detached-session')).toBe(false) + expect(daemon.attachments.clientIdBySessionId.has('explicitly-detached-session')).toBe(false) + expect(daemon.attachments.tokenBySessionId.has('explicitly-detached-session')).toBe(false) }) it('parks an attachment completed after its transport already closed', async () => { @@ -122,7 +126,7 @@ describe('DaemonServer attachment lifecycle', () => { await created client.disconnect() - await vi.waitFor(() => expect(daemon.clients.size).toBe(0)) + await vi.waitFor(() => expect(daemon.connections.clients.size).toBe(0)) expect(daemon.host.sessions.get('close-race-session')?.hasAttachedClients).toBe(true) finishRequest() @@ -130,7 +134,7 @@ describe('DaemonServer attachment lifecycle', () => { await vi.waitFor(() => expect(daemon.host.sessions.get('close-race-session')?.hasAttachedClients).toBe(false) ) - expect(daemon.streamClientIdBySessionId.has('close-race-session')).toBe(false) - expect(daemon.attachTokenBySessionId.has('close-race-session')).toBe(false) + expect(daemon.attachments.clientIdBySessionId.has('close-race-session')).toBe(false) + expect(daemon.attachments.tokenBySessionId.has('close-race-session')).toBe(false) }) }) diff --git a/src/main/daemon/daemon-server-error-handling.test.ts b/src/main/daemon/daemon-server-error-handling.test.ts index 3d61551cda0..7ab9d1ffa95 100644 --- a/src/main/daemon/daemon-server-error-handling.test.ts +++ b/src/main/daemon/daemon-server-error-handling.test.ts @@ -1,14 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { chmodSync, linkSync, mkdtempSync, rmSync, unlinkSync } from 'node:fs' +import { chmodSync, linkSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { createServer, type Server } from 'node:net' +import { connect, createServer, type Server } from 'node:net' import { DaemonServer } from './daemon-server' +import type { ConnectedDaemonClient } from './daemon-client-connections' import { DaemonClient } from './client' -import { isDaemonGoneError } from './daemon-pty-adapter' +import { isDaemonGoneError } from './daemon-endpoint-errors' import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership' +import { encodeNdjson } from './ndjson' import { getDaemonSocketPath } from './daemon-spawner' import type { SubprocessHandle } from './session-subprocess-handle' +import { PROTOCOL_VERSION } from './types' import { waitForEndpointUnreachable } from './daemon-endpoint-reachability-test-harness' // A killed process must actually report its exit: teardown waits @@ -32,6 +35,21 @@ function createMockSubprocess(): SubprocessHandle { dispose() {} } } +type DaemonServerInternals = { + endpoint: { + hasLostOwnership(): boolean + requestRetirementForLoss(): void + checkOwnership(): void + ownershipLossStreak: number + ownershipLost: boolean + } + lifecycle: { + retirementRequested: boolean + state: string + server: Server | null + } + connections: { clients: Map } +} describe('daemon server error handling', () => { let dir: string @@ -88,8 +106,8 @@ describe('daemon server error handling', () => { ).rejects.toThrow(/no longer owns its endpoint/) // And it stands down rather than lingering as an unreachable host. - const daemon = server as unknown as { retirementRequested: boolean } - expect(daemon.retirementRequested).toBe(true) + const daemon = server as unknown as DaemonServerInternals + expect(daemon.lifecycle.retirementRequested).toBe(true) } finally { await new Promise((resolve) => usurper.close(() => resolve())) } @@ -108,17 +126,14 @@ describe('daemon server error handling', () => { spawnSubprocess: () => createMockSubprocess() }) await server.start() - const daemon = server as unknown as { - hasLostEndpointOwnership: () => boolean - retirementRequested: boolean - } + const daemon = server as unknown as DaemonServerInternals try { // Drop search permission on the directory so the ownership stat fails EACCES. chmodSync(dir, 0o600) - expect(daemon.hasLostEndpointOwnership()).toBe(false) - expect(daemon.retirementRequested).toBe(false) + expect(daemon.endpoint.hasLostOwnership()).toBe(false) + expect(daemon.lifecycle.retirementRequested).toBe(false) } finally { chmodSync(dir, 0o700) } @@ -150,8 +165,8 @@ describe('daemon server error handling', () => { linkSync(usurperBind, socketPath) // The guard is still armed here — no creation has been refused yet. - const daemon = server as unknown as { hasLostEndpointOwnership: () => boolean } - expect(daemon.hasLostEndpointOwnership()).toBe(true) + const daemon = server as unknown as DaemonServerInternals + expect(daemon.endpoint.hasLostOwnership()).toBe(true) await expect( client.request('createOrAttach', { @@ -198,14 +213,11 @@ describe('daemon server error handling', () => { ).rejects.toThrow(/no longer owns its endpoint/) // Simulate a late client completing its handshake and clearing pending retirement. - const daemon = server as unknown as { - retirementRequested: boolean - hasLostEndpointOwnership: () => boolean - } - daemon.retirementRequested = false + const daemon = server as unknown as DaemonServerInternals + daemon.lifecycle.retirementRequested = false // The loss must still be remembered, so creation stays closed. - expect(daemon.hasLostEndpointOwnership()).toBe(true) + expect(daemon.endpoint.hasLostOwnership()).toBe(true) await expect( client.request('createOrAttach', { sessionId: 'b', cols: 80, rows: 24 }) ).rejects.toThrow(/no longer owns its endpoint/) @@ -248,16 +260,13 @@ describe('daemon server error handling', () => { // Losing the endpoint with nothing left to drain must stand the daemon down, even // though this client is still connected. - const daemon = server as unknown as { - requestRetirementForLostEndpoint: () => void - idleShutdownState: string - } - expect(daemon.idleShutdownState).toBe('running') - daemon.requestRetirementForLostEndpoint() + const daemon = server as unknown as DaemonServerInternals + expect(daemon.lifecycle.state).toBe('running') + daemon.endpoint.requestRetirementForLoss() // Drained and unreachable: it must begin standing down rather than wait for this // client, which can never make it routable again. - expect(daemon.idleShutdownState).not.toBe('running') + expect(daemon.lifecycle.state).not.toBe('running') } finally { await new Promise((resolve) => usurper.close(() => resolve())) } @@ -320,19 +329,15 @@ describe('daemon server error handling', () => { unlinkSync(socketPath) linkSync(usurperBind, socketPath) - const daemon = server as unknown as { - retirementRequested: boolean - endpointOwnershipLost: boolean - checkEndpointOwnership: () => void - } + const daemon = server as unknown as DaemonServerInternals // Retirement is already pending for an unrelated reason. - daemon.retirementRequested = true + daemon.lifecycle.retirementRequested = true - daemon.checkEndpointOwnership() - daemon.checkEndpointOwnership() + daemon.endpoint.checkOwnership() + daemon.endpoint.checkOwnership() // The loss must be recorded regardless, so a later hello cannot undo it. - expect(daemon.endpointOwnershipLost).toBe(true) + expect(daemon.endpoint.ownershipLost).toBe(true) } finally { await new Promise((resolve) => usurper.close(() => resolve())) } @@ -352,12 +357,7 @@ describe('daemon server error handling', () => { spawnSubprocess: () => createMockSubprocess() }) await server.start() - const daemon = server as unknown as { - checkEndpointOwnership: () => void - hasLostEndpointOwnership: () => boolean - endpointOwnershipLost: boolean - endpointOwnershipLossStreak: number - } + const daemon = server as unknown as DaemonServerInternals // A second name for our own socket inode, so ownership can be handed back. const ourAlias = join(dir, '.ours') linkSync(socketPath, ourAlias) @@ -369,21 +369,21 @@ describe('daemon server error handling', () => { // First loss. unlinkSync(socketPath) linkSync(usurperBind, socketPath) - daemon.checkEndpointOwnership() - expect(daemon.endpointOwnershipLossStreak).toBe(1) - expect(daemon.endpointOwnershipLost).toBe(false) + daemon.endpoint.checkOwnership() + expect(daemon.endpoint.ownershipLossStreak).toBe(1) + expect(daemon.endpoint.ownershipLost).toBe(false) // Ownership demonstrably returns, observed through the admission path. unlinkSync(socketPath) linkSync(ourAlias, socketPath) - expect(daemon.hasLostEndpointOwnership()).toBe(false) - expect(daemon.endpointOwnershipLossStreak).toBe(0) + expect(daemon.endpoint.hasLostOwnership()).toBe(false) + expect(daemon.endpoint.ownershipLossStreak).toBe(0) // A later isolated loss is confirmation #1, not #2, so it must not retire. unlinkSync(socketPath) linkSync(usurperBind, socketPath) - daemon.checkEndpointOwnership() - expect(daemon.endpointOwnershipLost).toBe(false) + daemon.endpoint.checkOwnership() + expect(daemon.endpoint.ownershipLost).toBe(false) } finally { await new Promise((resolve) => usurper.close(() => resolve())) } @@ -400,19 +400,21 @@ describe('daemon server error handling', () => { spawnSubprocess: () => createMockSubprocess() }) await server.start() - const daemon = server as unknown as { server: Server | null } + const daemon = server as unknown as DaemonServerInternals - expect(daemon.server?.listenerCount('error')).toBe(1) + expect(daemon.lifecycle.server?.listenerCount('error')).toBe(1) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { // Twice on purpose: a one-shot listener survives the first error and dies on the second, // so a single emit cannot tell a permanent handler from `once`. - expect(() => daemon.server?.emit('error', new Error('EMFILE: accept failed'))).not.toThrow() expect(() => - daemon.server?.emit('error', new Error('EMFILE: accept failed again')) + daemon.lifecycle.server?.emit('error', new Error('EMFILE: accept failed')) + ).not.toThrow() + expect(() => + daemon.lifecycle.server?.emit('error', new Error('EMFILE: accept failed again')) ).not.toThrow() expect(warn).toHaveBeenCalledTimes(2) - expect(daemon.server?.listenerCount('error')).toBe(1) + expect(daemon.lifecycle.server?.listenerCount('error')).toBe(1) } finally { warn.mockRestore() } @@ -422,6 +424,44 @@ describe('daemon server error handling', () => { expect(client.isConnected()).toBe(true) }) + it('rejects unsupported hello roles without replacing an authenticated stream', async () => { + const onAuthenticatedClientPair = vi.fn() + server = new DaemonServer({ + socketPath, + tokenPath, + onAuthenticatedClientPair, + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + const daemon = server as unknown as DaemonServerInternals + const connectedClient = [...daemon.connections.clients.values()][0] + const originalStream = connectedClient.streamSocket + + const unsupported = connect(socketPath) + unsupported.on('error', () => {}) + await new Promise((resolve) => unsupported.once('connect', resolve)) + const response = new Promise<{ ok: boolean; error?: string }>((resolve) => { + unsupported.once('data', (data) => resolve(JSON.parse(data.toString()))) + }) + unsupported.write( + encodeNdjson({ + type: 'hello', + version: PROTOCOL_VERSION, + token: readFileSync(tokenPath, 'utf8'), + clientId: connectedClient.clientId, + role: 'observer' + }) + ) + await expect(response).resolves.toMatchObject({ ok: false, error: 'Invalid role' }) + unsupported.destroy() + + expect(connectedClient.streamSocket).toBe(originalStream) + expect(connectedClient.authenticatedPairEstablished).toBe(true) + expect(onAuthenticatedClientPair).toHaveBeenCalledOnce() + }) + it.skipIf(process.platform === 'win32')( 'stands down instead of serving when the server errors while publication is in flight', async () => { @@ -443,14 +483,14 @@ describe('daemon server error handling', () => { spawnSubprocess: () => createMockSubprocess() }) const started = server.start() - const daemon = server as unknown as { server: Server | null } - await vi.waitFor(() => expect(daemon.server?.listening).toBe(true)) - daemon.server?.emit('error', new Error('injected accept failure')) + const daemon = server as unknown as DaemonServerInternals + await vi.waitFor(() => expect(daemon.lifecycle.server?.listening).toBe(true)) + daemon.lifecycle.server?.emit('error', new Error('injected accept failure')) await expect(started).rejects.toThrow('injected accept failure') // Why wait: start() rejects the moment the error lands, while publication is still in // flight. Reporting failure must end with the daemon actually not serving. - await vi.waitFor(() => expect(daemon.server).toBeNull()) + await vi.waitFor(() => expect(daemon.lifecycle.server).toBeNull()) await expect(waitForEndpointUnreachable(socketPath)).resolves.toBe(true) } ) diff --git a/src/main/daemon/daemon-server-kill-attribution.test.ts b/src/main/daemon/daemon-server-kill-attribution.test.ts index 2c4f903ce38..8fa6805e862 100644 --- a/src/main/daemon/daemon-server-kill-attribution.test.ts +++ b/src/main/daemon/daemon-server-kill-attribution.test.ts @@ -7,8 +7,12 @@ import { SessionNotFoundError, type DaemonRequest } from './types' type DaemonServerPrivate = { host: { kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise } - pendingPtySpawnPreparations: Map> - routeRequest(clientId: string, request: DaemonRequest): Promise + preparations: { + pending: Map> + } + requestRouter: { + route(clientId: string, request: DaemonRequest): Promise + } } describe('daemon kill attribution', () => { @@ -38,7 +42,7 @@ describe('daemon kill attribution', () => { }) vi.spyOn(daemon.host, 'kill').mockReturnValue(killFinished) - const request = daemon.routeRequest('control-42', { + const request = daemon.requestRouter.route('control-42', { id: 'kill-1', type: 'kill', payload: { sessionId: 'agent-session', immediate: true } @@ -71,7 +75,7 @@ describe('daemon kill attribution', () => { vi.spyOn(daemon.host, 'kill').mockRejectedValue(new Error('kill refused')) await expect( - daemon.routeRequest('control-42', { + daemon.requestRouter.route('control-42', { id: 'kill-1', type: 'kill', payload: { sessionId: 'agent-session', immediate: true } @@ -103,10 +107,10 @@ describe('daemon kill attribution', () => { controller: new AbortController(), clientId: 'control-42' } - daemon.pendingPtySpawnPreparations.set('agent-session', new Set([pendingPreparation])) + daemon.preparations.pending.set('agent-session', new Set([pendingPreparation])) vi.spyOn(daemon.host, 'kill').mockRejectedValue(new SessionNotFoundError('agent-session')) - await daemon.routeRequest('control-42', { + await daemon.requestRouter.route('control-42', { id: 'kill-1', type: 'kill', payload: { sessionId: 'agent-session', immediate: true } diff --git a/src/main/daemon/daemon-server-lifecycle.ts b/src/main/daemon/daemon-server-lifecycle.ts new file mode 100644 index 00000000000..0d4379b95e0 --- /dev/null +++ b/src/main/daemon/daemon-server-lifecycle.ts @@ -0,0 +1,278 @@ +import { createServer, type Server, type Socket } from 'node:net' +import type { DaemonEndpointLifecycle } from './daemon-endpoint-lifecycle' +import type { DaemonFileLog } from './daemon-file-log' +import { CLEAN_DISCONNECT_PROTOCOL_VERSION } from './types' + +type PendingShutdownReply = { start: () => void } + +type DaemonServerLifecycleOptions = { + protocolVersion: number + initialAdoptionTimeoutMs: number + clock: { + setTimeout(callback: () => void, delayMs: number): unknown + clearTimeout(handle: unknown): void + now(): number + } + endpoint: DaemonEndpointLifecycle + log: DaemonFileLog + isIdle: () => boolean + disposeResources: () => Promise + onIdleShutdown: () => void + onRpcShutdown: () => void +} + +export class DaemonServerLifecycle { + private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000 + private server: Server | null = null + private startupFailure: Error | null = null + private state: 'running' | 'idle-shutdown-pending' | 'shutting-down' = 'running' + private initialAdoptionTimer: unknown = null + private initialAdoptionDeadlineMs: number | null = null + private retirementRequested = false + private shutdownPromise: Promise | null = null + private ordinaryShutdownServerClose: Promise | null = null + private readonly pendingShutdownReplies = new Map() + + constructor(private readonly options: DaemonServerLifecycleOptions) {} + + isAcceptingWork(): boolean { + return this.state === 'running' + } + + async start(handleConnection: (socket: Socket) => void): Promise { + return new Promise((resolve, reject) => { + this.server = createServer(handleConnection) + let startupSettled = false + const onServerError = (error: Error): void => { + if (startupSettled) { + this.options.log.log('server-error', { message: error.message }) + console.warn(`[daemon] Socket server error: ${error.message}`) + return + } + startupSettled = true + this.startupFailure = error + reject(error) + } + this.server.on('error', onServerError) + const bindPath = this.options.endpoint.bindPath() + this.server.listen(bindPath, () => { + this.options.endpoint.secureBindPath(bindPath) + const abandonStartup = (error: unknown): void => { + startupSettled = true + const server = this.server + this.server = null + reject(error) + server?.close() + this.options.endpoint.abandonBindPath(bindPath) + } + void this.options.endpoint.publish(bindPath).then(() => { + if (this.startupFailure) { + this.options.endpoint.retireUnstarted() + this.cancelInitialAdoptionTimer() + abandonStartup(this.startupFailure) + return + } + if (this.options.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { + this.armInitialAdoptionTimeout() + } + this.options.endpoint.startOwnershipWatch() + startupSettled = true + resolve() + }, abandonStartup) + }) + }) + } + + shutdown(): Promise { + if (!this.shutdownPromise) { + const serverClose = this.beginOrdinaryShutdownFence() + this.shutdownPromise = this.finishOrdinaryShutdown(serverClose) + } + return this.shutdownPromise + } + + onConnectionAccepted(): void { + this.cancelInitialAdoptionTimer() + } + + onAuthenticatedPair(): void { + this.initialAdoptionDeadlineMs = null + this.cancelInitialAdoptionTimer() + if (!this.options.endpoint.lost) { + this.retirementRequested = false + } + } + + onLastAuthenticatedClientDisconnected(): void { + if (this.state === 'running') { + this.retirementRequested = true + } + } + + onEndpointOwnershipLost(): void { + this.retirementRequested = true + this.reevaluateIdleShutdown() + } + + reevaluateIdleShutdown(): void { + if (this.state !== 'running') { + return + } + if (this.retirementRequested) { + this.cancelInitialAdoptionTimer() + if (this.options.isIdle()) { + this.beginIdleShutdown() + } + return + } + if (!this.options.isIdle() || this.initialAdoptionDeadlineMs === null) { + this.cancelInitialAdoptionTimer() + return + } + if (this.initialAdoptionTimer !== null) { + return + } + const remainingMs = Math.max(0, this.initialAdoptionDeadlineMs - this.options.clock.now()) + if (remainingMs === 0) { + this.initialAdoptionDeadlineMs = null + this.retirementRequested = true + this.beginIdleShutdown() + return + } + this.initialAdoptionTimer = this.options.clock.setTimeout(() => { + this.initialAdoptionTimer = null + this.initialAdoptionDeadlineMs = null + this.retirementRequested = true + this.beginIdleShutdown() + }, remainingMs) + } + + beginOrdinaryShutdownFence(): Promise { + this.state = 'shutting-down' + this.cancelInitialAdoptionTimer() + this.ordinaryShutdownServerClose ??= this.beginServerClose() + return this.ordinaryShutdownServerClose + } + + deferRpcShutdownUntilReply( + clientId: string, + requestId: string, + socket: Socket, + serverClose: Promise + ): void { + this.deferShutdownUntilReply(clientId, requestId, socket, () => + this.finishRpcShutdown(serverClose) + ) + } + finishRpcShutdownWithoutReply(serverClose: Promise): void { + this.shutdownPromise ??= this.finishRpcShutdown(serverClose) + } + + retireAfterIdleReply(clientId: string, requestId: string, socket: Socket): void { + this.state = 'shutting-down' + this.initialAdoptionDeadlineMs = null + this.retirementRequested = false + this.cancelInitialAdoptionTimer() + const serverClose = this.beginServerClose() + this.deferShutdownUntilReply(clientId, requestId, socket, () => + this.finishIdleShutdown(serverClose) + ) + } + + startPendingShutdownReply(clientId: string, requestId: string): void { + this.pendingShutdownReplies.get(this.shutdownReplyKey(clientId, requestId))?.start() + } + + private armInitialAdoptionTimeout(): void { + this.initialAdoptionDeadlineMs = + this.options.clock.now() + this.options.initialAdoptionTimeoutMs + this.reevaluateIdleShutdown() + } + + private cancelInitialAdoptionTimer(): void { + if (this.initialAdoptionTimer === null) { + return + } + this.options.clock.clearTimeout(this.initialAdoptionTimer) + this.initialAdoptionTimer = null + } + + private beginIdleShutdown(): void { + this.initialAdoptionTimer = null + if (this.state !== 'running') { + return + } + this.state = 'idle-shutdown-pending' + if (!this.options.isIdle()) { + this.state = 'running' + this.reevaluateIdleShutdown() + return + } + this.state = 'shutting-down' + const serverClose = this.beginServerClose() + this.shutdownPromise = this.finishIdleShutdown(serverClose) + } + + private async finishOrdinaryShutdown(serverClose: Promise): Promise { + this.options.endpoint.unlinkOwnedArtifacts() + await this.disposeResources() + await serverClose + } + + private async finishRpcShutdown(serverClose: Promise): Promise { + await this.finishOrdinaryShutdown(serverClose) + this.options.onRpcShutdown() + } + + private async finishIdleShutdown(serverClose: Promise): Promise { + this.options.endpoint.unlinkOwnedArtifacts() + await this.disposeResources() + await serverClose + this.options.onIdleShutdown() + } + + private async disposeResources(): Promise { + await this.options.disposeResources() + this.pendingShutdownReplies.clear() + } + + private beginServerClose(): Promise { + const server = this.server + this.server = null + if (!server) { + return Promise.resolve() + } + return new Promise((resolve) => server.close(() => resolve())) + } + + private deferShutdownUntilReply( + clientId: string, + requestId: string, + socket: Socket, + finish: () => Promise + ): void { + const key = this.shutdownReplyKey(clientId, requestId) + let started = false + let timer: ReturnType + const start = (): void => { + if (started) { + return + } + started = true + clearTimeout(timer) + socket.off('close', start) + socket.off('error', start) + this.pendingShutdownReplies.delete(key) + this.shutdownPromise ??= finish() + } + timer = setTimeout(start, DaemonServerLifecycle.SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS) + timer.unref() + socket.once('close', start) + socket.once('error', start) + this.pendingShutdownReplies.set(key, { start }) + } + + private shutdownReplyKey(clientId: string, requestId: string): string { + return `${clientId}\u0000${requestId}` + } +} diff --git a/src/main/daemon/daemon-server-options.ts b/src/main/daemon/daemon-server-options.ts new file mode 100644 index 00000000000..06f2cda6cb9 --- /dev/null +++ b/src/main/daemon/daemon-server-options.ts @@ -0,0 +1,40 @@ +import type { DaemonFileLog } from './daemon-file-log' +import type { SubprocessHandle } from './session-subprocess-handle' + +export type DaemonServerOptions = { + socketPath: string + tokenPath: string + pidPath?: string + launchNonce?: string + startedAtMs?: number + publishEndpointOwnership?: () => void + entryPath?: string + appVersion?: string + spawnerExecPath?: string + protocolVersion?: number + onIdleShutdown?: () => void + onRpcShutdown?: () => void + initialAdoptionTestConfig?: { + timeoutMs: number + clock: { + setTimeout(callback: () => void, delayMs: number): unknown + clearTimeout(handle: unknown): void + now(): number + } + } + ptySpawnHealthCheck?: () => Promise + preparePtySpawn?: () => Promise + onPtySessionExit?: (sessionId: string) => void + onAuthenticatedClientPair?: () => void + log?: DaemonFileLog + spawnSubprocess: (opts: { + sessionId: string + cols: number + rows: number + cwd?: string + env?: Record + command?: string + shellOverride?: string + isCanceled?: () => boolean + }) => SubprocessHandle | Promise +} diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 1eaafb78ffa..27727aa2b3d 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs' import { DaemonServer } from './daemon-server' +import type { ConnectedDaemonClient } from './daemon-client-connections' import { DaemonClient } from './client' import { encodeNdjson } from './ndjson' import { PROTOCOL_VERSION, type DaemonRequest } from './types' @@ -50,21 +51,16 @@ function createMockSubprocess(): SubprocessHandle & { } type DaemonServerPrivate = { - server: Server | null - pendingPtySpawnPreparations: Map> + lifecycle: { server: Server | null } + preparations: { pending: Map> } host: { kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise + dispose: () => Promise + } + connections: { clients: Map } + requestRouter: { + route(clientId: string, request: DaemonRequest): Promise } - clients: Map< - string, - { - clientId: string - controlSocket: Socket - streamSocket: Socket | null - authenticatedPairEstablished: boolean - } - > - routeRequest(clientId: string, request: DaemonRequest): Promise } describe('DaemonServer', () => { @@ -345,11 +341,11 @@ describe('DaemonServer', () => { // Wait for the server to process the close (and cancel the prep) before // releasing the preflight, else the resumed spawn races ahead of cancellation. await vi.waitFor(() => - expect((server as unknown as DaemonServerPrivate).clients.size).toBe(0) + expect((server as unknown as DaemonServerPrivate).connections.clients.size).toBe(0) ) finishPreparation() await vi.waitFor(() => - expect((server as unknown as DaemonServerPrivate).pendingPtySpawnPreparations.size).toBe(0) + expect((server as unknown as DaemonServerPrivate).preparations.pending.size).toBe(0) ) expect(spawnSubprocess).not.toHaveBeenCalled() }) @@ -486,8 +482,8 @@ describe('DaemonServer', () => { const kill = vi.spyOn(daemon.host, 'kill').mockReturnValue(teardown) let acknowledged = false - const routed = daemon - .routeRequest('client-1', { + const routed = daemon.requestRouter + .route('client-1', { id: 'kill-1', type: 'kill', payload: { sessionId: 'agent-session', immediate: true } @@ -588,14 +584,14 @@ describe('DaemonServer', () => { write: vi.fn() } as unknown as Socket & { write: ReturnType } - daemon.clients.set('client-1', { + daemon.connections.clients.set('client-1', { clientId: 'client-1', controlSocket, streamSocket, authenticatedPairEstablished: true }) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'req-1', type: 'createOrAttach', payload: { sessionId: 'test-session', cols: 80, rows: 24 } @@ -608,7 +604,7 @@ describe('DaemonServer', () => { expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('"data":"background"') streamSocket.write.mockClear() - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'req-2', type: 'write', payload: { sessionId: 'test-session', data: 'x' } @@ -645,14 +641,14 @@ describe('DaemonServer', () => { write: vi.fn() } as unknown as Socket & { write: ReturnType } - daemon.clients.set('client-1', { + daemon.connections.clients.set('client-1', { clientId: 'client-1', controlSocket, streamSocket, authenticatedPairEstablished: true }) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'req-1', type: 'createOrAttach', payload: { sessionId: 'test-session', cols: 80, rows: 24 } @@ -703,13 +699,13 @@ describe('DaemonServer', () => { writableLength: number } - daemon.clients.set('client-1', { + daemon.connections.clients.set('client-1', { clientId: 'client-1', controlSocket, streamSocket, authenticatedPairEstablished: true }) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'req-1', type: 'createOrAttach', payload: { sessionId: 'test-session', cols: 80, rows: 24 } @@ -776,11 +772,11 @@ describe('DaemonServer', () => { const control = await connectRawHello('control', 'raw-client') const stream = await connectRawHello('stream', 'raw-client') - expect(daemon.clients.get('raw-client')?.streamSocket).toBeTruthy() + expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeTruthy() stream.destroy() - await waitFor(() => daemon.clients.get('raw-client')?.streamSocket === null) + await waitFor(() => daemon.connections.clients.get('raw-client')?.streamSocket === null) control.destroy() }) @@ -797,7 +793,7 @@ describe('DaemonServer', () => { const secondStream = await connectRawHello('stream', 'raw-client') await waitFor(() => firstClosed) - expect(daemon.clients.get('raw-client')?.streamSocket).toBeTruthy() + expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeTruthy() secondStream.destroy() control.destroy() }) @@ -819,8 +815,8 @@ describe('DaemonServer', () => { const secondControl = await connectRawHello('control', 'raw-client') await waitFor(() => firstControlClosed && firstStreamClosed) - expect(daemon.clients.get('raw-client')?.controlSocket).toBeTruthy() - expect(daemon.clients.get('raw-client')?.streamSocket).toBeNull() + expect(daemon.connections.clients.get('raw-client')?.controlSocket).toBeTruthy() + expect(daemon.connections.clients.get('raw-client')?.streamSocket).toBeNull() secondControl.destroy() }) @@ -834,7 +830,7 @@ describe('DaemonServer', () => { }) await waitFor(() => closed || stream.destroyed) - expect(daemon.clients.has('missing-client')).toBe(false) + expect(daemon.connections.clients.has('missing-client')).toBe(false) }) }) @@ -846,7 +842,7 @@ describe('DaemonServer', () => { const daemon = server as unknown as DaemonServerPrivate & { host: { dispose: () => Promise } } - const controlSocket = [...daemon.clients.values()][0].controlSocket + const controlSocket = [...daemon.connections.clients.values()][0].controlSocket const originalWrite = controlSocket.write.bind(controlSocket) let replyFlushed: (() => void) | undefined vi.spyOn(controlSocket, 'write').mockImplementation((( @@ -928,7 +924,7 @@ describe('DaemonServer', () => { const c = await connectClient() await expect(c.request('shutdown', { killSessions: true })).resolves.toEqual({}) - await waitFor(() => daemon.server === null) + await waitFor(() => daemon.lifecycle.server === null) // Why not existsSync: the dead entry remains for the next publisher to replace. expect(await waitForEndpointUnreachable(socketPath)).toBe(true) const late = new DaemonClient({ socketPath, tokenPath }) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index b276b3cb0ba..21514b0a850 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -1,854 +1,255 @@ -/* eslint-disable max-lines -- Why: one class owns the daemon socket protocol, routing, stream fanout, and session lifecycle. */ -import { createServer, type Server, type Socket } from 'node:net' import { randomUUID } from 'node:crypto' -import { performance } from 'node:perf_hooks' -import { writeFileSync, chmodSync, unlinkSync } from 'node:fs' -import { StringDecoder } from 'node:string_decoder' -import { encodeNdjson, createNdjsonParser } from './ndjson' -import { TerminalHost } from './terminal-host' -import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import type { Socket } from 'node:net' +import { setImmediate as waitForImmediate } from 'node:timers/promises' +import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' import { BackgroundTransientFactRelay, BACKGROUND_STREAM_DROP_ENABLED } from './daemon-background-transient-facts' -import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' -import { - recordDaemonStreamBacklogEvent, - startDaemonStreamBacklogProbe -} from './daemon-stream-backlog-probe' -import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' -import type { SubprocessHandle } from './session-subprocess-handle' -import { checkPtySpawnHealth } from './pty-subprocess' +import { DaemonClientConnections } from './daemon-client-connections' +import { DaemonEndpointLifecycle } from './daemon-endpoint-lifecycle' import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log' -import { isTuiAgent } from '../../shared/tui-agent-config' -import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress' -import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner' -import { - DAEMON_ENDPOINT_LOST_MESSAGE, - DaemonEndpointUnavailableError, - type DaemonEndpointOwnershipState, - getDaemonSocketBindPath, - publishDaemonEndpoint, - readDaemonEndpointOwnershipState, - type DaemonSocketIdentity -} from './daemon-endpoint-ownership' -import { probeSocketConnect } from './daemon-endpoint-probe' +import { DaemonPtySpawnPreparations } from './daemon-pty-spawn-preparations' +import { DaemonRequestRouter } from './daemon-request-router' +import { DaemonServerLifecycle } from './daemon-server-lifecycle' +import type { DaemonServerOptions } from './daemon-server-options' +import { DaemonSessionAttachments } from './daemon-session-attachments' +import { DaemonSessionBackgroundRouting } from './daemon-session-background-routing' +import { startDaemonStreamBacklogProbe } from './daemon-stream-backlog-probe' +import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import { DaemonTerminalAdmission } from './daemon-terminal-admission' +import { checkPtySpawnHealth } from './pty-subprocess' +import { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' +import { TerminalHost } from './terminal-host' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, - PROTOCOL_VERSION, NOTIFY_PREFIX, - SessionNotFoundError, - TerminalAttachCanceledError, - type HelloMessage, + PROTOCOL_VERSION, type DaemonRequest } from './types' -import { - isAgentSessionExecutionClaim, - isAgentSessionSurfaceBinding -} from '../../shared/agent-session-host-authority' -import { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' - -export type DaemonServerOptions = { - socketPath: string - tokenPath: string - pidPath?: string - launchNonce?: string - startedAtMs?: number - publishEndpointOwnership?: () => void - /** Reported in the hello so a repaired PID record can carry the real owner's metadata. */ - entryPath?: string - appVersion?: string - spawnerExecPath?: string - /** Direct-construction seam for protocol fixture tests; production never overrides it. */ - protocolVersion?: number - onIdleShutdown?: () => void - onRpcShutdown?: () => void - /** Direct-construction-only controls; production uses the compiled initial-adoption timeout. */ - initialAdoptionTestConfig?: { - timeoutMs: number - clock: { - setTimeout(callback: () => void, delayMs: number): unknown - clearTimeout(handle: unknown): void - now(): number - } - } - ptySpawnHealthCheck?: () => Promise - preparePtySpawn?: () => Promise - // Why: login-session death detection (#7936) probes on PTY-exit bursts and fresh app connections. - onPtySessionExit?: (sessionId: string) => void - onAuthenticatedClientPair?: () => void - log?: DaemonFileLog - spawnSubprocess: (opts: { - sessionId: string - cols: number - rows: number - cwd?: string - env?: Record - command?: string - shellOverride?: string - isCanceled?: () => boolean - // Async production spawns and sync test stubs share this boundary. - }) => SubprocessHandle | Promise -} - -type ConnectedClient = { - clientId: string - controlSocket: Socket - streamSocket: Socket | null - authenticatedPairEstablished: boolean -} - -type PendingPtySpawnPreparation = { - canceled: boolean - // Why: `canceled` is only polled between spawn steps; the signal is what - // actually interrupts an in-progress cwd probe on a dead share. - readonly controller: AbortController - cancelTimer?: ReturnType - // Why: preparations are keyed by sessionId, but a control-socket close must - // cancel only the disconnecting client's preps, not another client's (F4). - clientId: string - requestId: string -} - -function cancelPtySpawnPreparation(preparation: PendingPtySpawnPreparation): void { - preparation.canceled = true - preparation.controller.abort() -} - -type PendingShutdownReply = { - start: () => void -} +import { encodeNdjson } from './ndjson' export class DaemonServer { - // Why: survive long enough to adopt a first client pair, but don't orphan forever if the parent crashes first. private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000 - private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000 - private static readonly ENDPOINT_OWNERSHIP_POLL_MS = 30 * 1000 - private static readonly ENDPOINT_OWNERSHIP_LOSS_CONFIRMATIONS = 2 - private server: Server | null = null - private token: string - private host: TerminalHost - private socketPath: string - private tokenPath: string - private pidPath: string | null - private launchNonce: string | null - private startedAtMs: number | null - private publishEndpointOwnership: () => void - private entryPath: string | null - private appVersion: string | null - private spawnerExecPath: string | null - private ownedSocketIdentity: DaemonSocketIdentity | null = null - /** Set once start() has been rejected, so async publication can tell it is no longer wanted. */ - private startupFailure: Error | null = null - private endpointOwnershipTimer: ReturnType | null = null - private endpointOwnershipLossStreak = 0 - /** - * Sticky, and not inferred from `ownedSocketIdentity`: retiring nulls that, so a socket accepted - * before the takeover could finish its hello and reopen sessions on an unreachable daemon. - */ - private endpointOwnershipLost = false - private protocolVersion: number - private onIdleShutdown: () => void - private onRpcShutdown: () => void - private onAuthenticatedClientPair: () => void - private ptySpawnHealthCheck: () => Promise - private preparePtySpawn: () => Promise - private log: DaemonFileLog - private transportSockets = new Set() - private createOrAttachInFlight = 0 - private idleShutdownState: 'running' | 'idle-shutdown-pending' | 'shutting-down' = 'running' - private initialAdoptionTimer: unknown = null - private initialAdoptionDeadlineMs: number | null = null - private retirementRequested = false - private shutdownPromise: Promise | null = null - private ordinaryShutdownServerClose: Promise | null = null - private pendingShutdownReplies = new Map() - private initialAdoptionTimeoutMs: number - private lifecycleClock: NonNullable['clock'] - private clients = new Map() - private streamDataBatcher = new DaemonStreamDataBatcher( - (clientId) => this.clients.get(clientId), - { - isSessionDroppable: (sessionId) => - BACKGROUND_STREAM_DROP_ENABLED && this.transientFactRelay.isBackgrounded(sessionId), - salvageDroppedData: (dropped) => { - if (!dropped.includes('\x1b')) { - return '' - } - const extracted = extractHiddenStartupRendererQueryData(dropped, '') - return ( - extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData - ) - } - } - ) - // Facts ride the stream queue as control entries so they hold byte order (else a fact could arrive after the reveal snapshot). - private transientFactRelay = new BackgroundTransientFactRelay((sessionId, fact) => { - const clientId = this.streamClientIdBySessionId.get(sessionId) - if (clientId) { - this.streamDataBatcher.enqueueControlEvent(clientId, sessionId, { - type: 'event', - event: 'transientFact', - sessionId, - payload: fact - }) - } - }) - private streamClientIdBySessionId = new Map() - private attachTokenBySessionId = new Map() - private lastInputAtBySessionId = new Map() - private pendingPtySpawnPreparations = new Map>() - private historySeedTransfers = new TerminalHistorySeedTransferRegistry() + private readonly log: DaemonFileLog + private readonly host: TerminalHost + private readonly transientFactRelay: BackgroundTransientFactRelay + private readonly streamDataBatcher: DaemonStreamDataBatcher + private readonly attachments: DaemonSessionAttachments + private readonly historySeedTransfers = new TerminalHistorySeedTransferRegistry() + private readonly preparations: DaemonPtySpawnPreparations + private readonly connections: DaemonClientConnections + private readonly endpoint: DaemonEndpointLifecycle + private readonly lifecycle: DaemonServerLifecycle + private readonly admission: DaemonTerminalAdmission + private readonly requestRouter: DaemonRequestRouter private stopStreamBacklogProbe: () => void = () => {} - // Why: bypass batching within this window so keystroke echo/redraws skip the daemon's fixed batch delay. - private static readonly INTERACTIVE_OUTPUT_WINDOW_MS = 100 - private static readonly INTERACTIVE_OUTPUT_MAX_CHARS = 1024 + constructor(options: DaemonServerOptions) { + this.log = options.log ?? createNoopDaemonFileLog() + this.host = new TerminalHost({ + spawnSubprocess: options.spawnSubprocess, + reportReadinessEvent: (event, details) => this.log.log(event, details), + onSessionReaped: (sessionId) => { + this.attachments.release(sessionId) + this.transientFactRelay.onSessionExit(sessionId) + this.streamDataBatcher.refreshSessionDroppability(sessionId) + options.onPtySessionExit?.(sessionId) + this.lifecycle.reevaluateIdleShutdown() + } + }) + this.attachments = new DaemonSessionAttachments(this.host) + this.transientFactRelay = new BackgroundTransientFactRelay((sessionId, fact) => { + const clientId = this.attachments.clientIdForSession(sessionId) + if (clientId) { + this.streamDataBatcher.enqueueControlEvent(clientId, sessionId, { + type: 'event', + event: 'transientFact', + sessionId, + payload: fact + }) + } + }) + this.streamDataBatcher = new DaemonStreamDataBatcher( + (clientId) => this.connections.get(clientId), + { + isSessionDroppable: (sessionId) => + BACKGROUND_STREAM_DROP_ENABLED && this.transientFactRelay.isBackgrounded(sessionId), + salvageDroppedData: (dropped) => { + if (!dropped.includes('\x1b')) { + return '' + } + const extracted = extractHiddenStartupRendererQueryData(dropped, '') + return ( + extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData + ) + } + } + ) + this.preparations = new DaemonPtySpawnPreparations( + options.preparePtySpawn ?? (() => Promise.resolve()) + ) - constructor(opts: DaemonServerOptions) { - this.socketPath = opts.socketPath - this.tokenPath = opts.tokenPath - this.pidPath = opts.pidPath ?? null - this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION - this.launchNonce = - opts.launchNonce ?? - (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION ? randomUUID() : null) - this.startedAtMs = - opts.startedAtMs ?? - (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION + const protocolVersion = options.protocolVersion ?? PROTOCOL_VERSION + const launchNonce = + options.launchNonce ?? + (protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION ? randomUUID() : null) + const startedAtMs = + options.startedAtMs ?? + (protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION ? Date.now() - process.uptime() * 1000 : null) - this.publishEndpointOwnership = opts.publishEndpointOwnership ?? (() => {}) - this.entryPath = opts.entryPath ?? null - this.appVersion = opts.appVersion ?? null - this.spawnerExecPath = opts.spawnerExecPath ?? null - this.onIdleShutdown = opts.onIdleShutdown ?? (() => {}) - this.onRpcShutdown = opts.onRpcShutdown ?? (() => {}) - this.initialAdoptionTimeoutMs = - opts.initialAdoptionTestConfig?.timeoutMs ?? DaemonServer.INITIAL_ADOPTION_TIMEOUT_MS - this.lifecycleClock = opts.initialAdoptionTestConfig?.clock ?? { - setTimeout: (callback, delayMs) => { + const token = randomUUID() + + this.connections = new DaemonClientConnections({ + token, + protocolVersion, + identity: { + launchNonce, + startedAtMs, + entryPath: options.entryPath ?? null, + appVersion: options.appVersion ?? null, + spawnerExecPath: options.spawnerExecPath ?? null + }, + log: this.log, + streamDataBatcher: this.streamDataBatcher, + isAcceptingWork: () => this.lifecycle.isAcceptingWork(), + onTransportChanged: () => this.lifecycle.reevaluateIdleShutdown(), + onConnectionAccepted: () => this.lifecycle.onConnectionAccepted(), + onAuthenticatedPair: () => { + options.onAuthenticatedClientPair?.() + this.lifecycle.onAuthenticatedPair() + }, + onLastAuthenticatedClientDisconnected: () => + this.lifecycle.onLastAuthenticatedClientDisconnected(), + onControlRequest: (socket, clientId, request) => + void this.handleRequest(socket, clientId, request), + onControlReplaced: (clientId) => { + this.preparations.cancelForClient(clientId) + this.historySeedTransfers.clearOwner(clientId) + }, + onClientDisconnected: (clientId) => { + this.preparations.cancelForClient(clientId) + this.historySeedTransfers.clearOwner(clientId) + this.streamDataBatcher.clear(clientId) + this.attachments.detachClientSessions(clientId) + }, + onStreamDisconnected: (clientId) => { + this.preparations.cancelForClient(clientId) + this.streamDataBatcher.clear(clientId) + this.attachments.detachClientSessions(clientId) + } + }) + this.endpoint = new DaemonEndpointLifecycle({ + socketPath: options.socketPath, + tokenPath: options.tokenPath, + pidPath: options.pidPath ?? null, + launchNonce, + token, + publishEndpointOwnership: options.publishEndpointOwnership ?? (() => {}), + log: this.log, + isServing: () => this.lifecycle.isAcceptingWork(), + onOwnershipLost: () => this.lifecycle.onEndpointOwnershipLost() + }) + const lifecycleClock = options.initialAdoptionTestConfig?.clock ?? { + setTimeout: (callback: () => void, delayMs: number) => { const timer = setTimeout(callback, delayMs) timer.unref() return timer }, - clearTimeout: (handle) => clearTimeout(handle as ReturnType), + clearTimeout: (handle: unknown) => clearTimeout(handle as ReturnType), now: () => Date.now() } - this.token = randomUUID() - this.onAuthenticatedClientPair = opts.onAuthenticatedClientPair ?? (() => {}) - this.host = new TerminalHost({ - spawnSubprocess: opts.spawnSubprocess, - // Why a closure: this.log is assigned after this constructor call, and the - // callback only fires once a session is live. - reportReadinessEvent: (event, details) => this.log.log(event, details), - // Why host-level and not the attach callback: a session whose client transport already dropped - // has no attachment left to fire exit bookkeeping, and the daemon must still notice it can idle. - onSessionReaped: (sessionId) => { - this.streamClientIdBySessionId.delete(sessionId) - this.attachTokenBySessionId.delete(sessionId) - this.lastInputAtBySessionId.delete(sessionId) - this.transientFactRelay.onSessionExit(sessionId) - this.streamDataBatcher.refreshSessionDroppability(sessionId) - opts.onPtySessionExit?.(sessionId) - this.reevaluateIdleShutdown() - } + this.lifecycle = new DaemonServerLifecycle({ + protocolVersion, + initialAdoptionTimeoutMs: + options.initialAdoptionTestConfig?.timeoutMs ?? DaemonServer.INITIAL_ADOPTION_TIMEOUT_MS, + clock: lifecycleClock, + endpoint: this.endpoint, + log: this.log, + isIdle: () => this.isIdle(), + disposeResources: () => this.disposeResources(), + onIdleShutdown: options.onIdleShutdown ?? (() => {}), + onRpcShutdown: options.onRpcShutdown ?? (() => {}) + }) + this.admission = new DaemonTerminalAdmission({ + host: this.host, + connections: this.connections, + endpoint: this.endpoint, + preparations: this.preparations, + attachments: this.attachments, + historySeedTransfers: this.historySeedTransfers, + transientFactRelay: this.transientFactRelay, + streamDataBatcher: this.streamDataBatcher, + log: this.log, + isAcceptingWork: () => this.lifecycle.isAcceptingWork(), + requestEndpointRetirement: () => this.endpoint.requestRetirementForLoss(), + reevaluateIdleShutdown: () => this.lifecycle.reevaluateIdleShutdown() + }) + this.requestRouter = new DaemonRequestRouter({ + host: this.host, + connections: this.connections, + lifecycle: this.lifecycle, + admission: this.admission, + preparations: this.preparations, + attachments: this.attachments, + historySeedTransfers: this.historySeedTransfers, + sessionBackgroundRouting: new DaemonSessionBackgroundRouting({ + host: this.host, + attachments: this.attachments, + transientFactRelay: this.transientFactRelay, + streamDataBatcher: this.streamDataBatcher + }), + streamDataBatcher: this.streamDataBatcher, + ptySpawnHealthCheck: options.ptySpawnHealthCheck ?? checkPtySpawnHealth, + log: this.log }) - this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth - this.preparePtySpawn = opts.preparePtySpawn ?? (() => Promise.resolve()) this.stopStreamBacklogProbe = startDaemonStreamBacklogProbe(() => ({ - clients: Array.from(this.clients.values(), (client) => ({ + clients: Array.from(this.connections.values(), (client) => ({ clientId: client.clientId, socketBufferedBytes: client.streamSocket?.writableLength ?? 0, batcherQueuedChars: this.streamDataBatcher.queuedCharsForClient(client.clientId) })), backgroundedSessionIdSuffixes: this.transientFactRelay.backgroundedSessionIdSuffixes() })) - this.log = opts.log ?? createNoopDaemonFileLog() } - async start(): Promise { - return new Promise((resolve, reject) => { - this.server = createServer((socket) => this.handleConnection(socket)) - // Permanent, not `once`: an unhandled 'error' is an uncaught exception, so a post-startup - // accept failure (EMFILE) would kill a daemon hosting every terminal. - let startupSettled = false - const onServerError = (err: Error): void => { - if (startupSettled) { - // The daemon is serving; an operational error must not read as a startup failure. - this.log.log('server-error', { message: err.message }) - console.warn(`[daemon] Socket server error: ${err.message}`) - return - } - startupSettled = true - // Publishing is async, so the continuation reads this and tears down instead of arming - // a daemon whose caller was already told startup failed. - this.startupFailure = err - reject(err) - } - - this.server.on('error', onServerError) - - // Private bind name, linked into place: libuv's close-time unlink can then only ever - // remove our own name, never a replacement daemon's endpoint. - const bindPath = - process.platform === 'win32' ? this.socketPath : getDaemonSocketBindPath(this.socketPath) - - this.server.listen(bindPath, () => { - try { - // So the endpoint is never briefly reachable with default permissions. - chmodSync(bindPath, 0o600) - } catch { - // Best-effort on platforms that support it - } - const abandonStartup = (error: unknown): void => { - startupSettled = true - const server = this.server - this.server = null - // Settle before close: an accepted connection defers the close callback indefinitely. - reject(error) - server?.close() - if (process.platform !== 'win32') { - try { - unlinkSync(bindPath) - } catch { - // Already consumed by a successful link or rename, or never created. - } - } - } - void this.publishAndArm(bindPath).then(() => { - // The server can fail while publishing awaits its probe; resolving then would strand a - // live daemon behind a caller told startup failed, who would launch a replacement. - if (this.startupFailure) { - this.retireUnstartedDaemon() - abandonStartup(this.startupFailure) - return - } - // Serving from here, so later server errors are logged, not treated as startup failure. - startupSettled = true - resolve() - }, abandonStartup) - }) - }) + start(): Promise { + return this.lifecycle.start((socket) => this.connections.accept(socket)) } - /** - * Takes the canonical endpoint, then makes this listener adoptable — in that order. Never rolled - * back: an aborting daemon just closes, and the next publisher replaces the dead entry. - */ - private async publishAndArm(bindPath: string): Promise { - const outcome = await publishDaemonEndpoint(bindPath, this.socketPath, probeSocketConnect) - if (outcome.status !== 'published') { - // The only point the design declines to serve, so a field regression surfaces here. - this.log.log('endpoint-publish-declined', { reason: outcome.status }) - console.warn(`[daemon] Endpoint unavailable at startup: reason=${outcome.status}`) - throw new DaemonEndpointUnavailableError(outcome.status) + shutdown(): Promise { + return this.lifecycle.shutdown() + } + + private isIdle(): boolean { + if (this.admission.inFlight > 0 || this.host.listSessions().length > 0) { + return false } - this.ownedSocketIdentity = outcome.identity - let publishedOwnership = false - try { - // The PID/nonce record must exist before the token makes this listener adoptable. - this.publishEndpointOwnership() - publishedOwnership = true - writeFileSync(this.tokenPath, this.token, { mode: 0o600 }) - } catch (error) { - // Roll back only a record we wrote; anything else at that path belongs to another daemon. - if (publishedOwnership && this.pidPath && this.launchNonce) { - unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce) - } - this.ownedSocketIdentity = null - throw error - } - if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { - // A parent crash before the first client pair must not strand an empty daemon forever. - this.armInitialAdoptionTimeout() - } - this.startEndpointOwnershipWatch() - } - - /** - * Stands down a daemon that published after its startup was already reported failed. The - * endpoint stays, as on every exit path — removing it could delete a replacement's name. - */ - private retireUnstartedDaemon(): void { - this.stopEndpointOwnershipWatch() - this.cancelInitialAdoptionTimer() - this.unlinkOwnedEndpointArtifacts() - } - - async shutdown(): Promise { - if (this.shutdownPromise) { - return this.shutdownPromise - } - const serverClose = this.beginOrdinaryShutdownFence() - this.shutdownPromise = this.finishOrdinaryShutdown(serverClose) - return this.shutdownPromise - } - - private beginOrdinaryShutdownFence(): Promise { - this.idleShutdownState = 'shutting-down' - this.cancelInitialAdoptionTimer() - this.ordinaryShutdownServerClose ??= this.beginServerClose() - return this.ordinaryShutdownServerClose - } - - private async finishOrdinaryShutdown(serverClose: Promise): Promise { - this.unlinkOwnedEndpointArtifacts() - await this.disposeDaemonResources() - await serverClose - } - - private async finishRpcShutdown(serverClose: Promise): Promise { - await this.finishOrdinaryShutdown(serverClose) - this.onRpcShutdown() - } - - private unlinkOwnedEndpointArtifacts(): void { - // Ownership-checked so a late replacement's token or PID record is never removed. - unlinkOwnedDaemonTokenFile(this.tokenPath, this.token) - if (this.pidPath && this.launchNonce) { - unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce) - } - // The endpoint is deliberately left: fencing its removal against a replacement that published - // meanwhile was this component's largest source of defects, and a dead entry costs nothing. - this.ownedSocketIdentity = null - } - - private startEndpointOwnershipWatch(): void { - if (process.platform === 'win32' || !this.ownedSocketIdentity) { - return - } - this.endpointOwnershipTimer = setInterval( - () => this.checkEndpointOwnership(), - DaemonServer.ENDPOINT_OWNERSHIP_POLL_MS - ) - // A liveness poll must never be the reason the process cannot exit. - this.endpointOwnershipTimer.unref() - } - - private stopEndpointOwnershipWatch(): void { - if (this.endpointOwnershipTimer === null) { - return - } - clearInterval(this.endpointOwnershipTimer) - this.endpointOwnershipTimer = null - } - - /** - * Retires a daemon that no longer owns the canonical endpoint. - * - * Such a daemon keeps hosting PTYs no client can reach — terminals that take input and never - * run it. Retirement drains rather than kills: sessions finish and the process exits once idle. - */ - private checkEndpointOwnership(): void { - if (process.platform === 'win32' || !this.ownedSocketIdentity || this.shutdownPromise) { - return - } - // An inconclusive stat resets too: EACCES must not retire a daemon that is still serving. - if (this.observeEndpointOwnership() !== 'lost') { - return - } - this.endpointOwnershipLossStreak++ - // Two, though a single rename leaves no gap to misread: this is a backstop, not the detector. - if (this.endpointOwnershipLossStreak < DaemonServer.ENDPOINT_OWNERSHIP_LOSS_CONFIRMATIONS) { - return - } - this.requestRetirementForLostEndpoint() - } - - /** - * Whether the endpoint demonstrably no longer resolves to this daemon. Only positive evidence - * counts — treating an unreadable stat as loss would refuse sessions on a healthy daemon. - */ - private hasLostEndpointOwnership(): boolean { - if (this.endpointOwnershipLost) { + if (this.endpoint.lost) { return true } - return this.observeEndpointOwnership() === 'lost' + return this.connections.transportCount === 0 && this.connections.size === 0 } - /** - * Reads ownership and keeps the watchdog's loss streak honest. Both callers come through here - * because the watchdog retires on *consecutive* losses: a read that skipped the streak let two - * losses separated by an owned observation count as consecutive, poisoning a healthy daemon. - */ - private observeEndpointOwnership(): DaemonEndpointOwnershipState { - // The running check, not just `shutdownPromise`: both shutdown routes close the server before - // assigning it, and in that window the listener is gone while the recorded identity is not. - if ( - process.platform === 'win32' || - !this.ownedSocketIdentity || - this.idleShutdownState !== 'running' - ) { - return 'indeterminate' - } - const state = readDaemonEndpointOwnershipState(this.socketPath, this.ownedSocketIdentity) - if (state !== 'lost') { - this.endpointOwnershipLossStreak = 0 - } - return state - } - - private requestRetirementForLostEndpoint(): void { - // Recorded before any dedup and not gated on retirement: folding the two together left the - // loss unrecorded when already retiring, and a later hello returned the daemon to service. - const alreadyLost = this.endpointOwnershipLost - this.endpointOwnershipLost = true - this.ownedSocketIdentity = null - if (!alreadyLost) { - this.log.log('endpoint-ownership-lost', { socketPath: this.socketPath }) - console.warn( - '[daemon] Endpoint ownership lost to another daemon — retiring once existing sessions end' - ) - } - this.retirementRequested = true - // The identity it compares against was just cleared, so every later tick early-returns. - this.stopEndpointOwnershipWatch() - this.reevaluateIdleShutdown() - } - - private async disposeDaemonResources(): Promise { - this.stopEndpointOwnershipWatch() + private async disposeResources(): Promise { + this.endpoint.stopOwnershipWatch() this.stopStreamBacklogProbe() this.transientFactRelay.dispose() - this.cancelAllPendingPtySpawnPreparations() + this.preparations.cancelAll() try { await this.host.dispose() - } catch (err) { - // Why: an unreapable child must not block daemon exit — post-exit it reparents to init anyway. + } catch (error) { this.log.log('shutdown-dispose-failed', { - error: err instanceof Error ? err.message : String(err) + error: error instanceof Error ? error.message : String(error) }) } this.streamDataBatcher.clear() this.historySeedTransfers.dispose() - this.pendingShutdownReplies.clear() - - for (const [, client] of this.clients) { - client.controlSocket.destroy() - client.streamSocket?.destroy() - } - this.clients.clear() - for (const socket of this.transportSockets) { - socket.destroy() - } - this.transportSockets.clear() - } - - private beginServerClose(): Promise { - const server = this.server - this.server = null - if (!server) { - return Promise.resolve() - } - return new Promise((resolve) => { - // Why: close synchronously before any awaited cleanup so no new transport enters after the empty proof. - server.close(() => { - // Why: libuv unlinks the path this server bound, which is our private bind name and is - // already gone. Nothing removes the canonical endpoint — a departing daemon leaves it - // for the next publisher to replace — so closing late cannot delete a replacement's. - resolve() - }) - }) - } - - private isIdle(): boolean { - if (this.createOrAttachInFlight > 0 || this.host.listSessions().length > 0) { - return false - } - // Why open connections stop counting once the endpoint is lost: they belong to clients that - // reached this daemon before the takeover, and holding them open cannot make it reachable to - // anyone new. Waiting for them turns a drained retirement into an orphan that outlives its - // usefulness — the sessions are gone and nothing can route a new one here. - if (this.endpointOwnershipLost) { - return true - } - return this.transportSockets.size === 0 && this.clients.size === 0 - } - - private reevaluateIdleShutdown(): void { - if (this.idleShutdownState !== 'running') { - return - } - if (this.retirementRequested) { - this.cancelInitialAdoptionTimer() - if (this.isIdle()) { - this.beginIdleShutdown() - } - return - } - if (!this.isIdle() || this.initialAdoptionDeadlineMs === null) { - this.cancelInitialAdoptionTimer() - return - } - if (this.initialAdoptionTimer !== null) { - return - } - const remainingMs = Math.max(0, this.initialAdoptionDeadlineMs - this.lifecycleClock.now()) - if (remainingMs === 0) { - this.initialAdoptionDeadlineMs = null - this.retirementRequested = true - this.beginIdleShutdown() - return - } - this.initialAdoptionTimer = this.lifecycleClock.setTimeout(() => { - this.initialAdoptionTimer = null - this.initialAdoptionDeadlineMs = null - this.retirementRequested = true - this.beginIdleShutdown() - }, remainingMs) - } - - private armInitialAdoptionTimeout(): void { - this.initialAdoptionDeadlineMs = this.lifecycleClock.now() + this.initialAdoptionTimeoutMs - this.reevaluateIdleShutdown() - } - - private cancelInitialAdoptionTimer(): void { - if (this.initialAdoptionTimer === null) { - return - } - this.lifecycleClock.clearTimeout(this.initialAdoptionTimer) - this.initialAdoptionTimer = null - } - - private beginIdleShutdown(): void { - this.initialAdoptionTimer = null - if (this.idleShutdownState !== 'running') { - return - } - this.idleShutdownState = 'idle-shutdown-pending' - if (!this.isIdle()) { - // Why: work admitted before the fence wins; clear pending state to keep it usable. - this.idleShutdownState = 'running' - this.reevaluateIdleShutdown() - return - } - - this.idleShutdownState = 'shutting-down' - // beginServerClose() runs synchronously up to server.close() before any yield to a racing connection. - const serverClose = this.beginServerClose() - this.shutdownPromise = this.finishIdleShutdown(serverClose) - } - - private async finishIdleShutdown(serverClose: Promise): Promise { - this.unlinkOwnedEndpointArtifacts() - await this.disposeDaemonResources() - await serverClose - this.onIdleShutdown() - } - - private handleConnection(socket: Socket): void { - this.cancelInitialAdoptionTimer() - this.transportSockets.add(socket) - const removeTransport = (): void => { - this.transportSockets.delete(socket) - this.reevaluateIdleShutdown() - } - socket.once('close', removeTransport) - socket.on('error', () => socket.destroy()) - - if (this.idleShutdownState !== 'running') { - // Why: a connection accepted just before close() gets an explicit retry signal instead of dying mid-auth. - socket.end( - encodeNdjson({ - type: 'hello', - ok: false, - error: 'Daemon temporarily unavailable; reconnect', - retryable: true - }) - ) - return - } - // Why: keep UTF-8 sequences intact across socket chunks before NDJSON parsing. - const decoder = new StringDecoder('utf8') - const parser = createNdjsonParser( - (msg) => this.handleFirstMessage(socket, msg, parser), - () => { - socket.destroy() - } - ) - - socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) - } - - private handleFirstMessage( - socket: Socket, - msg: unknown, - _parser: ReturnType - ): void { - const hello = msg as HelloMessage - if (hello.type !== 'hello') { - this.log.log('client-hello-rejected', { reason: 'expected-hello' }) - socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Expected hello' })) - socket.destroy() - return - } - - if (hello.version !== this.protocolVersion) { - this.log.log('client-hello-rejected', { - reason: 'protocol-mismatch', - clientVersion: hello.version - }) - socket.write( - encodeNdjson({ - type: 'hello', - ok: false, - error: 'Protocol version mismatch' - }) - ) - socket.destroy() - return - } - - if (hello.token !== this.token) { - this.log.log('client-hello-rejected', { - reason: 'invalid-token', - role: hello.role - }) - socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' })) - socket.destroy() - return - } - - this.log.log('client-hello-accepted', { - role: hello.role, - clientId: hello.clientId - }) - socket.write( - encodeNdjson({ - type: 'hello', - ok: true, - ...(this.launchNonce && this.startedAtMs - ? { - daemonIdentity: { - pid: process.pid, - startedAtMs: this.startedAtMs, - launchNonce: this.launchNonce, - ...(this.entryPath ? { entryPath: this.entryPath } : {}), - ...(this.appVersion ? { appVersion: this.appVersion } : {}), - ...(this.spawnerExecPath ? { spawnerExecPath: this.spawnerExecPath } : {}) - } - } - : {}) - }) - ) - - if (hello.role === 'control') { - const previous = this.clients.get(hello.clientId) - const client: ConnectedClient = { - clientId: hello.clientId, - controlSocket: socket, - streamSocket: null, - authenticatedPairEstablished: false - } - this.clients.set(hello.clientId, client) - this.setupControlSocket(socket, hello.clientId) - if (previous) { - // Why: reconnect reuses clientId before stale close fires; cancel the old owner's preflight at handoff. - this.cancelPendingPtySpawnPreparationsForClient(hello.clientId) - this.historySeedTransfers.clearOwner(hello.clientId) - this.recordFullyAuthenticatedDisconnect(previous.authenticatedPairEstablished) - // Why: tear down the old sockets after installing the new owner so a stale close can't delete the replacement. - previous.streamSocket?.destroy() - previous.controlSocket.destroy() - } - } else if (hello.role === 'stream') { - const client = this.clients.get(hello.clientId) - if (!client) { - // Why: a stream socket is meaningless without its control socket; drop the orphan. - socket.destroy() - return - } - this.setupStreamSocket(socket, client) - client.authenticatedPairEstablished = true - // Why: one-shot health probes authenticate only a control socket; they are not fresh app activity. - this.onAuthenticatedClientPair() - // A complete app connection (unlike a probe) re-owns the endpoint and cancels pending - // retirement — but not a retirement caused by losing the endpoint itself. A client that - // connected before the takeover cannot make this daemon reachable again, and treating it - // as re-ownership would reopen session creation on a daemon nothing can find. - this.initialAdoptionDeadlineMs = null - this.cancelInitialAdoptionTimer() - if (!this.endpointOwnershipLost) { - this.retirementRequested = false - } - } - } - - private setupControlSocket(socket: Socket, clientId: string): void { - // Why: decode as a UTF-8 stream so emoji/Unicode split across chunks isn't corrupted. - const decoder = new StringDecoder('utf8') - const parser = createNdjsonParser( - (msg) => this.handleRequest(socket, clientId, msg as DaemonRequest), - () => {} // Ignore parse errors - ) - - // Remove the initial data listener and replace with the RPC parser - socket.removeAllListeners('data') - socket.on('data', (chunk) => parser.feed(decoder.write(chunk))) - - socket.on('close', () => { - const client = this.clients.get(clientId) - if (client?.controlSocket !== socket) { - return - } - // Why: a client that disconnects mid-preflight would otherwise still create - // its daemon PTY, orphaning a durable, unattached session — cancel its preps (F4). - this.cancelPendingPtySpawnPreparationsForClient(clientId) - this.historySeedTransfers.clearOwner(clientId) - const wasFullyAuthenticated = client.authenticatedPairEstablished - this.streamDataBatcher.clear(clientId) - this.detachClientSessions(clientId) - client.streamSocket?.destroy() - this.clients.delete(clientId) - this.recordFullyAuthenticatedDisconnect(wasFullyAuthenticated) - this.reevaluateIdleShutdown() - }) - } - - private recordFullyAuthenticatedDisconnect(wasFullyAuthenticated: boolean): void { - if ( - !wasFullyAuthenticated || - [...this.clients.values()].some((remaining) => remaining.authenticatedPairEstablished) || - this.idleShutdownState !== 'running' - ) { - return - } - // Why: once the last full client is gone, incomplete transports may block retirement but never erase it. - this.retirementRequested = true - } - - private setupStreamSocket(socket: Socket, client: ConnectedClient): void { - const previous = client.streamSocket - socket.removeAllListeners('data') - client.streamSocket = socket - // Why: 'drain' is the wake-up for the batcher's shallow-gate held bulk. - socket.on('drain', () => { - this.streamDataBatcher.flush(client.clientId) - }) - - const cleanup = (): void => { - socket.removeListener('close', cleanup) - socket.removeListener('error', cleanup) - if (this.clients.get(client.clientId) !== client || client.streamSocket !== socket) { - return - } - // Why: a preflight that outlives its output channel would create an unattached daemon PTY. - this.cancelPendingPtySpawnPreparationsForClient(client.clientId) - this.streamDataBatcher.clear(client.clientId) - this.detachClientSessions(client.clientId) - client.streamSocket = null - } - - socket.on('close', cleanup) - socket.on('error', cleanup) - - if (previous && previous !== socket) { - // Why: replacing a stream socket must not leave the old channel alive and untracked. - previous.destroy() - } + // Let canceled in-flight RPCs enqueue their protocol errors before transport destruction. + await waitForImmediate() + this.connections.dispose() } private async handleRequest( @@ -857,660 +258,23 @@ export class DaemonServer { request: DaemonRequest ): Promise { const isNotify = request.id.startsWith(NOTIFY_PREFIX) - try { - const result = await this.routeRequest(clientId, request) + const result = await this.requestRouter.route(clientId, request) if (!isNotify) { - const pendingShutdown = this.pendingShutdownReplies.get( - this.shutdownReplyKey(clientId, request.id) - ) socket.write(encodeNdjson({ id: request.id, ok: true, payload: result }), () => { - pendingShutdown?.start() + this.lifecycle.startPendingShutdownReply(clientId, request.id) }) } - } catch (err) { + } catch (error) { if (!isNotify) { socket.write( encodeNdjson({ id: request.id, ok: false, - error: err instanceof Error ? err.message : String(err) + error: error instanceof Error ? error.message : String(error) }) ) } } } - - private shutdownReplyKey(clientId: string, requestId: string): string { - return `${clientId}\u0000${requestId}` - } - - private deferShutdownUntilReply( - clientId: string, - requestId: string, - socket: Socket, - finish: () => Promise - ): void { - const key = this.shutdownReplyKey(clientId, requestId) - let started = false - let timer: ReturnType - const start = (): void => { - if (started) { - return - } - started = true - clearTimeout(timer) - socket.off('close', start) - socket.off('error', start) - this.pendingShutdownReplies.delete(key) - if (!this.shutdownPromise) { - this.shutdownPromise = finish() - } - } - // Why: a non-reading peer must not pin a fenced daemon by holding its ack behind permanent socket backpressure. - timer = setTimeout(start, DaemonServer.SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS) - timer.unref() - socket.once('close', start) - socket.once('error', start) - this.pendingShutdownReplies.set(key, { start }) - } - - /** - * Registers a cancellable preparation without doing spawn preflight. Every - * createOrAttach registers one, including attach-only: without it the server - * cannot match the client's cancel, and an attach-only request queued behind a - * hung create is bounded by neither side. - */ - private registerPtySpawnPreparation( - sessionId: string, - clientId: string, - requestId: string, - cancelAfterMs: unknown - ): PendingPtySpawnPreparation { - const preparation: PendingPtySpawnPreparation = { - canceled: false, - controller: new AbortController(), - clientId, - requestId - } - if (Number.isSafeInteger(cancelAfterMs) && Number(cancelAfterMs) > 0) { - preparation.cancelTimer = setTimeout( - () => { - cancelPtySpawnPreparation(preparation) - }, - Math.min(Number(cancelAfterMs), 300_000) - ) - preparation.cancelTimer.unref() - } - const pending = this.pendingPtySpawnPreparations.get(sessionId) ?? new Set() - pending.add(preparation) - this.pendingPtySpawnPreparations.set(sessionId, pending) - return preparation - } - - private async preparePtySpawnUnlessCanceled( - sessionId: string, - preparation: PendingPtySpawnPreparation - ): Promise { - // Why: registered before the async probe so a concurrent close can cancel this creation before a subprocess exists. - await this.preparePtySpawn() - if (preparation.canceled) { - throw new TerminalAttachCanceledError(sessionId) - } - } - - private finishPtySpawnPreparation( - sessionId: string, - preparation: PendingPtySpawnPreparation - ): void { - if (preparation.cancelTimer) { - clearTimeout(preparation.cancelTimer) - } - const pending = this.pendingPtySpawnPreparations.get(sessionId) - pending?.delete(preparation) - if (pending?.size === 0) { - this.pendingPtySpawnPreparations.delete(sessionId) - } - } - - private cancelPendingPtySpawnPreparations( - sessionId: string, - request?: { clientId: string; requestId?: string } - ): boolean { - const pending = this.pendingPtySpawnPreparations.get(sessionId) - if (!pending) { - return false - } - let canceled = false - for (const preparation of pending) { - if ( - request && - (preparation.clientId !== request.clientId || - (request.requestId !== undefined && preparation.requestId !== request.requestId)) - ) { - continue - } - cancelPtySpawnPreparation(preparation) - canceled = true - } - return canceled - } - - private cancelAllPendingPtySpawnPreparations(): void { - for (const sessionId of this.pendingPtySpawnPreparations.keys()) { - this.cancelPendingPtySpawnPreparations(sessionId) - } - } - - private cancelPendingPtySpawnPreparationsForClient(clientId: string): void { - for (const pending of this.pendingPtySpawnPreparations.values()) { - for (const preparation of pending) { - if (preparation.clientId === clientId) { - cancelPtySpawnPreparation(preparation) - } - } - } - } - - private detachClientSessions(clientId: string): void { - const attachments: { sessionId: string; token: symbol }[] = [] - for (const [sessionId, attachedClientId] of this.streamClientIdBySessionId) { - if (attachedClientId !== clientId) { - continue - } - const token = this.attachTokenBySessionId.get(sessionId) - if (token) { - attachments.push({ sessionId, token }) - } - this.streamClientIdBySessionId.delete(sessionId) - this.attachTokenBySessionId.delete(sessionId) - } - if (attachments.length > 0) { - this.host.detachClients(attachments) - } - } - - private detachSessionForClient(sessionId: string, clientId: string): void { - if (this.streamClientIdBySessionId.get(sessionId) !== clientId) { - return - } - const token = this.attachTokenBySessionId.get(sessionId) - if (token) { - this.host.detach(sessionId, token) - } - this.streamClientIdBySessionId.delete(sessionId) - this.attachTokenBySessionId.delete(sessionId) - } - - private async routeRequest(clientId: string, request: DaemonRequest): Promise { - const client = this.clients.get(clientId) - - switch (request.type) { - case 'startHistorySeedTransfer': { - if (!client?.authenticatedPairEstablished || client.streamSocket === null) { - throw new Error('Daemon client connection is incomplete; reconnect') - } - const transferId = this.historySeedTransfers.start(clientId, request.payload) - return { transferId } - } - - case 'appendHistorySeedTransfer': - this.historySeedTransfers.append( - clientId, - request.payload.transferId, - request.payload.index, - request.payload.data - ) - return {} - - case 'finishHistorySeedTransfer': - this.historySeedTransfers.finish(clientId, request.payload.transferId) - return {} - - case 'abortHistorySeedTransfer': - this.historySeedTransfers.abort(clientId, request.payload.transferId) - return {} - - case 'createOrAttach': { - if (this.idleShutdownState !== 'running') { - throw new Error('Daemon temporarily unavailable; reconnect') - } - if (!client?.authenticatedPairEstablished || client.streamSocket === null) { - // Why: a control-only replacement can't own terminal admission or erase the prior client's retirement request. - throw new Error('Daemon client connection is incomplete; reconnect') - } - const p = request.payload - const attachOnly = p.attachOnly === true - // Why check here and not only on the watchdog: publishing cannot be made atomic against - // a publisher preempted between proving an entry dead and replacing it, so this daemon - // can lose the endpoint at any moment. The watchdog notices within a poll, which is far - // too late if a session was accepted in between — that session is then reachable by - // nobody, and the user sees a terminal that acknowledges input and never runs it. - // Why creation only: an attach reaches a session this daemon already hosts, over a - // connection that already exists. Refusing that would break the drain a retiring daemon - // depends on, and it strands nothing — the session is already here. - if (!attachOnly && this.hasLostEndpointOwnership()) { - this.requestRetirementForLostEndpoint() - throw new Error(DAEMON_ENDPOINT_LOST_MESSAGE) - } - this.createOrAttachInFlight++ - let routedSessionId = p.sessionId - let result: Awaited> - let spawnPreparation: PendingPtySpawnPreparation | null = null - try { - if ( - p.agentSessionEnsure !== undefined && - (!isAgentSessionExecutionClaim(p.agentSessionEnsure.claim) || - !isAgentSessionSurfaceBinding(p.agentSessionEnsure.surface)) - ) { - throw new Error('agent_session_identity_required') - } - spawnPreparation = this.registerPtySpawnPreparation( - p.sessionId, - clientId, - request.id, - p.cancelAfterMs - ) - if (!attachOnly) { - await this.preparePtySpawnUnlessCanceled(p.sessionId, spawnPreparation) - } - if (p.historySeed !== undefined && p.historySeedTransferId !== undefined) { - throw new Error('Multiple terminal history seed sources') - } - const historySeedChunks = - p.historySeedTransferId !== undefined - ? this.historySeedTransfers.take(clientId, p.historySeedTransferId) - : p.historySeed !== undefined - ? [p.historySeed] - : undefined - result = await this.host.createOrAttach({ - sessionId: p.sessionId, - cols: p.cols, - rows: p.rows, - cwd: p.cwd, - env: p.env, - envToDelete: p.envToDelete, - command: p.command, - startupCommandDelivery: p.startupCommandDelivery, - ...(attachOnly ? { attachOnly: true } : {}), - // Why: RPC payloads are untrusted JSON; persist only the allowlisted routing enum, never arbitrary identity. - ...(isTuiAgent(p.launchAgent) ? { launchAgent: p.launchAgent } : {}), - shellOverride: p.shellOverride, - terminalWindowsWslDistro: p.terminalWindowsWslDistro, - terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, - shellReadySupported: p.shellReadySupported, - historySeedChunks, - startupIngress: parsePtyStartupIngressIntent(p.startupIngress), - ...(p.shellReadyTimeoutMs !== undefined - ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs } - : {}), - ...(p.agentSessionEnsure ? { agentSessionEnsure: p.agentSessionEnsure } : {}), - ...(spawnPreparation - ? { - isCanceled: () => spawnPreparation?.canceled === true, - cancelSignal: spawnPreparation.controller.signal - } - : {}), - onSessionResolved: (sessionId) => { - routedSessionId = sessionId - }, - streamClient: { - onData: (data, rawLength = data.length, transformed = false, seq) => { - // Scan BEFORE enqueue: the batcher may drop this chunk, but its facts must be captured regardless. - this.transientFactRelay.onSessionData(routedSessionId, data) - const lastInputAt = this.lastInputAtBySessionId.get(routedSessionId) - const isInteractiveOutput = - data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS && - lastInputAt !== undefined && - performance.now() - lastInputAt <= DaemonServer.INTERACTIVE_OUTPUT_WINDOW_MS - this.streamDataBatcher.enqueue(clientId, routedSessionId, data, { - flushImmediately: isInteractiveOutput, - flushMaxChars: DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS, - rawLength, - transformed, - seq - }) - }, - onExit: (code, incarnationId, cause) => { - // Why: exit tears down renderer handlers, so it must ride the ordered queue behind final output. - this.log.log('session-exited', { - sessionId: routedSessionId, - code, - cause: cause?.kind - }) - this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { - type: 'event', - event: 'exit', - sessionId: routedSessionId, - payload: { code, incarnationId, ...(cause ? { cause } : {}) } - }) - this.streamDataBatcher.flush(clientId) - recordDaemonStreamBacklogEvent('sessionExit', { - sessionIdSuffix: routedSessionId.slice(-10) - }) - this.transientFactRelay.onSessionExit(routedSessionId) - this.streamDataBatcher.refreshSessionDroppability(routedSessionId) - this.streamClientIdBySessionId.delete(routedSessionId) - this.attachTokenBySessionId.delete(routedSessionId) - this.lastInputAtBySessionId.delete(routedSessionId) - this.reevaluateIdleShutdown() - } - } - }) - } finally { - if (spawnPreparation) { - this.finishPtySpawnPreparation(p.sessionId, spawnPreparation) - } - this.createOrAttachInFlight-- - this.reevaluateIdleShutdown() - } - routedSessionId = result.agentSessionEnsure?.owner.ptyId ?? p.sessionId - if ( - this.clients.get(clientId) !== client || - !client.authenticatedPairEstablished || - client.streamSocket === null - ) { - this.host.detach(routedSessionId, result.attachToken) - throw new TerminalAttachCanceledError(routedSessionId) - } - this.streamClientIdBySessionId.set(routedSessionId, clientId) - this.attachTokenBySessionId.set(routedSessionId, result.attachToken) - this.streamDataBatcher.refreshSessionDroppability(routedSessionId) - // Why an attach-time marker: background resync can precede this attach, so scan suppression must start at the new stream's head. - if (this.transientFactRelay.isBackgrounded(routedSessionId)) { - this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { - type: 'event', - event: 'sessionBackgroundMarker', - sessionId: routedSessionId, - payload: { background: true } - }) - } - this.log.log(result.isNew ? 'session-created' : 'session-attached', { - sessionId: routedSessionId, - pid: result.pid - }) - return { - isNew: result.isNew, - snapshot: result.snapshot, - pid: result.pid, - shellState: result.shellState, - incarnationId: result.incarnationId, - ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), - wslDistro: result.wslDistro, - ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}), - ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) - } - } - - case 'cancelCreateOrAttach': - return { - canceled: this.cancelPendingPtySpawnPreparations(request.payload.sessionId, { - clientId, - ...(typeof request.payload.requestId === 'string' - ? { requestId: request.payload.requestId } - : {}) - }) - } - - case 'closeStartupQueryAuthority': - return { - appliedSeq: this.host.closeStartupQueryAuthority(request.payload.sessionId) - } - - case 'write': - try { - this.lastInputAtBySessionId.set(request.payload.sessionId, performance.now()) - this.host.write(request.payload.sessionId, request.payload.data) - } catch (err) { - this.lastInputAtBySessionId.delete(request.payload.sessionId) - if (err instanceof SessionNotFoundError) { - this.sendExitEvent(client, request.payload.sessionId, -1) - } - throw err - } - return {} - - case 'resize': - try { - this.host.resize(request.payload.sessionId, request.payload.cols, request.payload.rows) - } catch (err) { - if (err instanceof SessionNotFoundError) { - this.sendExitEvent(client, request.payload.sessionId, -1) - } - throw err - } - return {} - - case 'pausePty': - this.host.pauseProducer(request.payload.sessionId) - return {} - - case 'resumePty': - this.host.resumeProducer(request.payload.sessionId) - return {} - - case 'setSessionBackground': { - const sessionId = request.payload.sessionId - const background = request.payload.background === true - recordDaemonStreamBacklogEvent('setSessionBackground', { - sessionIdSuffix: sessionId.slice(-10), - background - }) - const backgroundChanged = this.transientFactRelay.setSessionBackground( - sessionId, - background - ) - this.streamDataBatcher.refreshSessionDroppability(sessionId) - if (!backgroundChanged) { - return {} - } - if (background) { - // Seed the fresh relay tracker with the emulator's dangling escape so a handoff-split sequence still parses. - this.transientFactRelay.seedSessionScanState( - sessionId, - this.host.getPartialEscapeTailAnsi(sessionId) - ) - } - const streamClientId = this.streamClientIdBySessionId.get(sessionId) - if (!streamClientId) { - // Not attached yet — the attach-time marker covers the handoff. - return {} - } - // Reveal intentionally keeps the queued tail: main needs those bytes, and the normal flush/drain delivers them in order ahead of the marker. - const mode2031State = this.transientFactRelay.getMode2031ReplyScanState(sessionId) - const scanSeedAnsi = background - ? '' - : mode2031State.pendingSubscribe - ? mode2031State.tail - : this.host.getPartialEscapeTailAnsi(sessionId) - this.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, { - type: 'event', - event: 'sessionBackgroundMarker', - sessionId, - payload: { - background, - ...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}), - ...(mode2031State.pendingSubscribe ? { mode2031PendingSubscribe: true as const } : {}) - } - }) - return {} - } - - case 'kill': { - const canceledPendingSpawn = this.cancelPendingPtySpawnPreparations( - request.payload.sessionId - ) - this.lastInputAtBySessionId.delete(request.payload.sessionId) - const attribution = { - sessionId: request.payload.sessionId, - immediate: request.payload.immediate === true, - // Daemon control identity, not the paired-device bearer credential. - clientId - } - try { - await this.host.kill(request.payload.sessionId, { - immediate: request.payload.immediate - }) - } catch (error) { - // Why: a kill that wins before session registration already canceled the pending spawn, so its intent is done. - if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) { - this.log.log('session-kill-failed', attribution) - throw error - } - } - this.log.log('session-killed', attribution) - return {} - } - - case 'signal': - this.host.signal(request.payload.sessionId, request.payload.signal) - return {} - - case 'detach': - this.detachSessionForClient(request.payload.sessionId, clientId) - this.log.log('session-detached', { - sessionId: request.payload.sessionId - }) - return {} - - case 'getCwd': - return { cwd: await this.host.getCwd(request.payload.sessionId) } - - case 'getForegroundProcess': - return { - foregroundProcess: this.host.getForegroundProcess(request.payload.sessionId) - } - - case 'inspectProcess': - return this.host.inspectProcess(request.payload.sessionId) - - case 'confirmForegroundProcess': - return { - foregroundProcess: await this.host.confirmForegroundProcess(request.payload.sessionId) - } - - case 'clearScrollback': - this.host.clearScrollback(request.payload.sessionId) - return {} - - case 'listSessions': - return { sessions: this.host.listSessions() } - - case 'shutdownIfIdle': { - const authenticatedClient = this.clients.get(clientId) - const retiring = - authenticatedClient !== undefined && - authenticatedClient.streamSocket !== null && - this.clients.size === 1 && - this.createOrAttachInFlight === 0 && - this.host.listSessions().length === 0 && - [...this.transportSockets].every( - (transport) => - transport === authenticatedClient.controlSocket || - transport === authenticatedClient.streamSocket - ) - if (!retiring) { - return { retiring: false } - } - this.idleShutdownState = 'shutting-down' - this.initialAdoptionDeadlineMs = null - this.retirementRequested = false - this.cancelInitialAdoptionTimer() - // Why: close before acknowledging retirement so no new terminal races between the empty proof and disposal. - const serverClose = this.beginServerClose() - this.deferShutdownUntilReply(clientId, request.id, authenticatedClient.controlSocket, () => - this.finishIdleShutdown(serverClose) - ) - return { retiring: true } - } - - case 'getSnapshot': { - const snapshotStart = performance.now() - const requestedScrollbackRows = request.payload.scrollbackRows - const scrollbackRows = - typeof requestedScrollbackRows === 'number' && Number.isFinite(requestedScrollbackRows) - ? Math.max(0, Math.min(50_000, Math.floor(requestedScrollbackRows))) - : undefined - const snapshot = this.host.getSnapshot(request.payload.sessionId, { - scrollbackRows - }) - const snapshotMs = performance.now() - snapshotStart - if (snapshotMs >= 25) { - // Serialize stalls block the daemon's single thread; surface them to attribute field typing stalls (issue #5096 family). - recordDaemonStreamBacklogEvent('slowGetSnapshot', { - sessionIdSuffix: request.payload.sessionId.slice(-10), - snapshotMs: Math.round(snapshotMs) - }) - } - return { snapshot } - } - - case 'getSize': - return { size: this.host.getAppliedSize(request.payload.sessionId) } - - case 'takePendingOutput': - // Why no await: with includeSnapshot, drain+serialize must share one sync turn or cold restore replays doubled PTY bytes. - return this.host.takePendingOutput( - request.payload.sessionId, - request.payload.includeSnapshot === true, - { teardownSnapshot: request.payload.teardownSnapshot === true } - ) - - case 'ping': - return { pong: true } - - case 'systemResolverHealth': - return { health: await readCurrentProcessMacSystemResolverHealth() } - - case 'ptySpawnHealth': - await this.ptySpawnHealthCheck() - return { healthy: true } - - case 'shutdown': { - this.log.log('shutdown', { - reason: 'rpc', - killSessions: request.payload.killSessions === true - }) - const serverClose = this.beginOrdinaryShutdownFence() - if (request.payload.killSessions) { - try { - await this.host.dispose() - } catch (err) { - // Why: shutdown must always self-terminate; failed owners stay retryable for the follow-up shutdown() below. - this.log.log('shutdown-dispose-failed', { - error: err instanceof Error ? err.message : String(err) - }) - } - } - const controlSocket = this.clients.get(clientId)?.controlSocket - if (controlSocket) { - this.deferShutdownUntilReply(clientId, request.id, controlSocket, () => - this.finishRpcShutdown(serverClose) - ) - } else if (!this.shutdownPromise) { - this.shutdownPromise = this.finishRpcShutdown(serverClose) - } - return {} - } - } - throw new Error(`Unknown request type: ${(request as { type: string }).type}`) - } - - private sendExitEvent( - client: ConnectedClient | undefined, - sessionId: string, - code: number - ): void { - if (!client?.streamSocket) { - return - } - // Why: write/resize don't wait for replies, so this synthetic exit is the renderer's only signal to clear stale pane bindings. - this.streamDataBatcher.enqueueControlEvent(client.clientId, sessionId, { - type: 'event', - event: 'exit', - sessionId, - payload: { code } - }) - this.streamDataBatcher.flush(client.clientId) - } } diff --git a/src/main/daemon/daemon-session-attachments.ts b/src/main/daemon/daemon-session-attachments.ts new file mode 100644 index 00000000000..2a012c1c9d4 --- /dev/null +++ b/src/main/daemon/daemon-session-attachments.ts @@ -0,0 +1,67 @@ +import { performance } from 'node:perf_hooks' +import type { TerminalHost } from './terminal-host' + +export class DaemonSessionAttachments { + private readonly clientIdBySessionId = new Map() + private readonly tokenBySessionId = new Map() + private readonly lastInputAtBySessionId = new Map() + + constructor(private readonly host: TerminalHost) {} + + attach(sessionId: string, clientId: string, token: symbol): void { + this.clientIdBySessionId.set(sessionId, clientId) + this.tokenBySessionId.set(sessionId, token) + } + + clientIdForSession(sessionId: string): string | undefined { + return this.clientIdBySessionId.get(sessionId) + } + + recordInput(sessionId: string): void { + this.lastInputAtBySessionId.set(sessionId, performance.now()) + } + + lastInputAt(sessionId: string): number | undefined { + return this.lastInputAtBySessionId.get(sessionId) + } + + clearInput(sessionId: string): void { + this.lastInputAtBySessionId.delete(sessionId) + } + + release(sessionId: string): void { + this.clientIdBySessionId.delete(sessionId) + this.tokenBySessionId.delete(sessionId) + this.lastInputAtBySessionId.delete(sessionId) + } + + detachSessionForClient(sessionId: string, clientId: string): void { + if (this.clientIdBySessionId.get(sessionId) !== clientId) { + return + } + const token = this.tokenBySessionId.get(sessionId) + if (token) { + this.host.detach(sessionId, token) + } + this.clientIdBySessionId.delete(sessionId) + this.tokenBySessionId.delete(sessionId) + } + + detachClientSessions(clientId: string): void { + const attachments: { sessionId: string; token: symbol }[] = [] + for (const [sessionId, attachedClientId] of this.clientIdBySessionId) { + if (attachedClientId !== clientId) { + continue + } + const token = this.tokenBySessionId.get(sessionId) + if (token) { + attachments.push({ sessionId, token }) + } + this.clientIdBySessionId.delete(sessionId) + this.tokenBySessionId.delete(sessionId) + } + if (attachments.length > 0) { + this.host.detachClients(attachments) + } + } +} diff --git a/src/main/daemon/daemon-session-background-routing.ts b/src/main/daemon/daemon-session-background-routing.ts new file mode 100644 index 00000000000..f3e45ba7abc --- /dev/null +++ b/src/main/daemon/daemon-session-background-routing.ts @@ -0,0 +1,55 @@ +import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts' +import type { DaemonSessionAttachments } from './daemon-session-attachments' +import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import type { TerminalHost } from './terminal-host' + +type DaemonSessionBackgroundRoutingOptions = { + host: TerminalHost + attachments: DaemonSessionAttachments + transientFactRelay: BackgroundTransientFactRelay + streamDataBatcher: DaemonStreamDataBatcher +} + +export class DaemonSessionBackgroundRouting { + constructor(private readonly options: DaemonSessionBackgroundRoutingOptions) {} + + setBackground(sessionId: string, background: boolean): Record { + recordDaemonStreamBacklogEvent('setSessionBackground', { + sessionIdSuffix: sessionId.slice(-10), + background + }) + const changed = this.options.transientFactRelay.setSessionBackground(sessionId, background) + this.options.streamDataBatcher.refreshSessionDroppability(sessionId) + if (!changed) { + return {} + } + if (background) { + this.options.transientFactRelay.seedSessionScanState( + sessionId, + this.options.host.getPartialEscapeTailAnsi(sessionId) + ) + } + const streamClientId = this.options.attachments.clientIdForSession(sessionId) + if (!streamClientId) { + return {} + } + const mode2031State = this.options.transientFactRelay.getMode2031ReplyScanState(sessionId) + const scanSeedAnsi = background + ? '' + : mode2031State.pendingSubscribe + ? mode2031State.tail + : this.options.host.getPartialEscapeTailAnsi(sessionId) + this.options.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, { + type: 'event', + event: 'sessionBackgroundMarker', + sessionId, + payload: { + background, + ...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}), + ...(mode2031State.pendingSubscribe ? { mode2031PendingSubscribe: true as const } : {}) + } + }) + return {} + } +} diff --git a/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts b/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts index 1f1f0dce8d2..64ae0f77ba0 100644 --- a/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts +++ b/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { DaemonPtyAdapter, FinalCheckpointWaitExpiredError } from './daemon-pty-adapter' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { FinalCheckpointWaitExpiredError } from './daemon-pty-lifecycle-errors' import { DaemonServer } from './daemon-server' import { getDaemonSocketPath } from './daemon-spawner' import type { DaemonFileLog } from './daemon-file-log' diff --git a/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts b/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts index f62cec1179c..fda1fe25b98 100644 --- a/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts +++ b/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts @@ -4,6 +4,7 @@ import type { Socket } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { DaemonServer } from './daemon-server' +import type { ConnectedDaemonClient } from './daemon-client-connections' import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts' import type { PendingStreamDataBatch } from './daemon-stream-keep-tail-drop' @@ -16,22 +17,14 @@ type MockSubprocess = SubprocessHandle & { } type DaemonLifecyclePrivate = { - clients: Map< - string, - { - clientId: string - controlSocket: Socket - streamSocket: Socket | null - authenticatedPairEstablished: boolean - } - > - host: { - getPartialEscapeTailAnsi(sessionId: string): string + connections: { clients: Map } + host: { getPartialEscapeTailAnsi(sessionId: string): string } + requestRouter: { + route(clientId: string, request: DaemonRequest): Promise } - routeRequest(clientId: string, request: DaemonRequest): Promise streamDataBatcher: DaemonStreamDataBatcher transientFactRelay: BackgroundTransientFactRelay - streamClientIdBySessionId: Map + attachments: { clientIdBySessionId: Map } } function createMockSubprocess(): MockSubprocess { @@ -95,7 +88,7 @@ function addClient( write: ReturnType writableLength: number } - daemon.clients.set('client-1', { + daemon.connections.clients.set('client-1', { clientId: 'client-1', controlSocket, streamSocket, @@ -134,7 +127,7 @@ describe('daemon stream droppability lifecycle', () => { server = harness.server const { daemon } = harness addClient(daemon) - daemon.streamClientIdBySessionId.set('session-toggle', 'client-1') + daemon.attachments.clientIdBySessionId.set('session-toggle', 'client-1') vi.spyOn(daemon.host, 'getPartialEscapeTailAnsi').mockReturnValue('') const lifecycle: string[] = [] vi.spyOn(daemon.streamDataBatcher, 'refreshSessionDroppability').mockImplementation( @@ -152,7 +145,7 @@ describe('daemon stream droppability lifecycle', () => { } ) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'background', type: 'setSessionBackground', payload: { sessionId: 'session-toggle', background: true } @@ -160,7 +153,7 @@ describe('daemon stream droppability lifecycle', () => { expect(lifecycle).toEqual(['refresh:true', 'marker:true']) lifecycle.length = 0 - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'duplicate-background', type: 'setSessionBackground', payload: { sessionId: 'session-toggle', background: true } @@ -168,7 +161,7 @@ describe('daemon stream droppability lifecycle', () => { expect(lifecycle).toEqual(['refresh:true']) lifecycle.length = 0 - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'foreground', type: 'setSessionBackground', payload: { sessionId: 'session-toggle', background: false } @@ -181,13 +174,13 @@ describe('daemon stream droppability lifecycle', () => { server = harness.server const { daemon } = harness addClient(daemon) - daemon.streamClientIdBySessionId.set('session-toggle', 'client-1') + daemon.attachments.clientIdBySessionId.set('session-toggle', 'client-1') daemon.transientFactRelay.setSessionBackground('session-toggle', true) daemon.transientFactRelay.onSessionData('session-toggle', '\x1b[?2031h\x1b[?') vi.spyOn(daemon.host, 'getPartialEscapeTailAnsi').mockReturnValue('\x1b[?') const enqueue = vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent') - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'foreground', type: 'setSessionBackground', payload: { sessionId: 'session-toggle', background: false } @@ -216,7 +209,9 @@ describe('daemon stream droppability lifecycle', () => { const lifecycle: string[] = [] vi.spyOn(daemon.streamDataBatcher, 'refreshSessionDroppability').mockImplementation( (sessionId) => { - lifecycle.push(`refresh:${daemon.streamClientIdBySessionId.get(sessionId) ?? 'unrouted'}`) + lifecycle.push( + `refresh:${daemon.attachments.clientIdBySessionId.get(sessionId) ?? 'unrouted'}` + ) } ) vi.spyOn(daemon.streamDataBatcher, 'enqueueControlEvent').mockImplementation( @@ -225,7 +220,7 @@ describe('daemon stream droppability lifecycle', () => { } ) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'attach', type: 'createOrAttach', payload: { sessionId: 'session-attach', cols: 80, rows: 24 } @@ -241,7 +236,7 @@ describe('daemon stream droppability lifecycle', () => { addClient(daemon, 128 * 1024) daemon.transientFactRelay.setSessionBackground('session-exit', true) - await daemon.routeRequest('client-1', { + await daemon.requestRouter.route('client-1', { id: 'attach', type: 'createOrAttach', payload: { sessionId: 'session-exit', cols: 80, rows: 24 } @@ -262,6 +257,6 @@ describe('daemon stream droppability lifecycle', () => { payload: { code: 42 } }) expect(daemon.transientFactRelay.isBackgrounded('session-exit')).toBe(false) - expect(daemon.streamClientIdBySessionId.has('session-exit')).toBe(false) + expect(daemon.attachments.clientIdBySessionId.has('session-exit')).toBe(false) }) }) diff --git a/src/main/daemon/daemon-terminal-admission.ts b/src/main/daemon/daemon-terminal-admission.ts new file mode 100644 index 00000000000..b47b497fe43 --- /dev/null +++ b/src/main/daemon/daemon-terminal-admission.ts @@ -0,0 +1,213 @@ +import { performance } from 'node:perf_hooks' +import type { BackgroundTransientFactRelay } from './daemon-background-transient-facts' +import type { DaemonClientConnections } from './daemon-client-connections' +import type { DaemonEndpointLifecycle } from './daemon-endpoint-lifecycle' +import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership' +import type { DaemonFileLog } from './daemon-file-log' +import type { + DaemonPtySpawnPreparations, + PendingPtySpawnPreparation +} from './daemon-pty-spawn-preparations' +import type { DaemonSessionAttachments } from './daemon-session-attachments' +import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import type { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import type { TerminalHistorySeedTransferRegistry } from './terminal-history-seed-transfer-registry' +import type { CreateOrAttachOptions, CreateOrAttachResult, TerminalHost } from './terminal-host' +import { isTuiAgent } from '../../shared/tui-agent-config' +import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress' +import { + isAgentSessionExecutionClaim, + isAgentSessionSurfaceBinding +} from '../../shared/agent-session-host-authority' +import { type DaemonRequest, TerminalAttachCanceledError } from './types' + +type CreateOrAttachRequest = Extract + +type DaemonTerminalAdmissionOptions = { + host: TerminalHost + connections: DaemonClientConnections + endpoint: DaemonEndpointLifecycle + preparations: DaemonPtySpawnPreparations + attachments: DaemonSessionAttachments + historySeedTransfers: TerminalHistorySeedTransferRegistry + transientFactRelay: BackgroundTransientFactRelay + streamDataBatcher: DaemonStreamDataBatcher + log: DaemonFileLog + isAcceptingWork: () => boolean + requestEndpointRetirement: () => void + reevaluateIdleShutdown: () => void +} + +export class DaemonTerminalAdmission { + private static readonly INTERACTIVE_OUTPUT_WINDOW_MS = 100 + private static readonly INTERACTIVE_OUTPUT_MAX_CHARS = 1024 + private createOrAttachInFlight = 0 + + constructor(private readonly options: DaemonTerminalAdmissionOptions) {} + + get inFlight(): number { + return this.createOrAttachInFlight + } + + async createOrAttach(clientId: string, request: CreateOrAttachRequest): Promise { + const client = this.options.connections.get(clientId) + if (!this.options.isAcceptingWork()) { + throw new Error('Daemon temporarily unavailable; reconnect') + } + if (!client?.authenticatedPairEstablished || client.streamSocket === null) { + throw new Error('Daemon client connection is incomplete; reconnect') + } + const payload = request.payload + const attachOnly = payload.attachOnly === true + if (!attachOnly && this.options.endpoint.hasLostOwnership()) { + this.options.requestEndpointRetirement() + throw new Error(DAEMON_ENDPOINT_LOST_MESSAGE) + } + this.createOrAttachInFlight++ + let routedSessionId = payload.sessionId + let result: CreateOrAttachResult + let spawnPreparation: PendingPtySpawnPreparation | null = null + try { + if ( + payload.agentSessionEnsure !== undefined && + (!isAgentSessionExecutionClaim(payload.agentSessionEnsure.claim) || + !isAgentSessionSurfaceBinding(payload.agentSessionEnsure.surface)) + ) { + throw new Error('agent_session_identity_required') + } + spawnPreparation = this.options.preparations.register( + payload.sessionId, + clientId, + request.id, + payload.cancelAfterMs + ) + if (!attachOnly) { + await this.options.preparations.prepareUnlessCanceled(payload.sessionId, spawnPreparation) + } + if (payload.historySeed !== undefined && payload.historySeedTransferId !== undefined) { + throw new Error('Multiple terminal history seed sources') + } + const historySeedChunks = + payload.historySeedTransferId !== undefined + ? this.options.historySeedTransfers.take(clientId, payload.historySeedTransferId) + : payload.historySeed !== undefined + ? [payload.historySeed] + : undefined + result = await this.options.host.createOrAttach({ + sessionId: payload.sessionId, + cols: payload.cols, + rows: payload.rows, + cwd: payload.cwd, + env: payload.env, + envToDelete: payload.envToDelete, + command: payload.command, + startupCommandDelivery: payload.startupCommandDelivery, + ...(attachOnly ? { attachOnly: true } : {}), + ...(isTuiAgent(payload.launchAgent) ? { launchAgent: payload.launchAgent } : {}), + shellOverride: payload.shellOverride, + terminalWindowsWslDistro: payload.terminalWindowsWslDistro, + terminalWindowsPowerShellImplementation: payload.terminalWindowsPowerShellImplementation, + shellReadySupported: payload.shellReadySupported, + historySeedChunks, + startupIngress: parsePtyStartupIngressIntent(payload.startupIngress), + ...(payload.shellReadyTimeoutMs !== undefined + ? { shellReadyTimeoutMs: payload.shellReadyTimeoutMs } + : {}), + ...(payload.agentSessionEnsure ? { agentSessionEnsure: payload.agentSessionEnsure } : {}), + isCanceled: () => spawnPreparation?.canceled === true, + cancelSignal: spawnPreparation.controller.signal, + onSessionResolved: (sessionId) => { + routedSessionId = sessionId + }, + streamClient: this.createStreamClient(clientId, () => routedSessionId) + }) + } finally { + if (spawnPreparation) { + this.options.preparations.finish(payload.sessionId, spawnPreparation) + } + this.createOrAttachInFlight-- + this.options.reevaluateIdleShutdown() + } + + routedSessionId = result.agentSessionEnsure?.owner.ptyId ?? payload.sessionId + if ( + this.options.connections.get(clientId) !== client || + !client.authenticatedPairEstablished || + client.streamSocket === null + ) { + this.options.host.detach(routedSessionId, result.attachToken) + throw new TerminalAttachCanceledError(routedSessionId) + } + this.options.attachments.attach(routedSessionId, clientId, result.attachToken) + this.options.streamDataBatcher.refreshSessionDroppability(routedSessionId) + if (this.options.transientFactRelay.isBackgrounded(routedSessionId)) { + this.options.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { + type: 'event', + event: 'sessionBackgroundMarker', + sessionId: routedSessionId, + payload: { background: true } + }) + } + this.options.log.log(result.isNew ? 'session-created' : 'session-attached', { + sessionId: routedSessionId, + pid: result.pid + }) + return { + isNew: result.isNew, + snapshot: result.snapshot, + pid: result.pid, + shellState: result.shellState, + incarnationId: result.incarnationId, + ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), + wslDistro: result.wslDistro, + ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}), + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) + } + } + + private createStreamClient( + clientId: string, + sessionId: () => string + ): CreateOrAttachOptions['streamClient'] { + return { + onData: (data, rawLength = data.length, transformed = false, seq) => { + const routedSessionId = sessionId() + this.options.transientFactRelay.onSessionData(routedSessionId, data) + const lastInputAt = this.options.attachments.lastInputAt(routedSessionId) + const isInteractiveOutput = + data.length <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS && + lastInputAt !== undefined && + performance.now() - lastInputAt <= DaemonTerminalAdmission.INTERACTIVE_OUTPUT_WINDOW_MS + this.options.streamDataBatcher.enqueue(clientId, routedSessionId, data, { + flushImmediately: isInteractiveOutput, + flushMaxChars: DaemonTerminalAdmission.INTERACTIVE_OUTPUT_MAX_CHARS, + rawLength, + transformed, + seq + }) + }, + onExit: (code, incarnationId, cause) => { + const routedSessionId = sessionId() + this.options.log.log('session-exited', { + sessionId: routedSessionId, + code, + cause: cause?.kind + }) + this.options.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { + type: 'event', + event: 'exit', + sessionId: routedSessionId, + payload: { code, incarnationId, ...(cause ? { cause } : {}) } + }) + this.options.streamDataBatcher.flush(clientId) + recordDaemonStreamBacklogEvent('sessionExit', { + sessionIdSuffix: routedSessionId.slice(-10) + }) + this.options.transientFactRelay.onSessionExit(routedSessionId) + this.options.streamDataBatcher.refreshSessionDroppability(routedSessionId) + this.options.attachments.release(routedSessionId) + this.options.reevaluateIdleShutdown() + } + } + } +} diff --git a/src/main/daemon/pty-subprocess-test-harness.ts b/src/main/daemon/pty-subprocess-test-harness.ts index 7113af73ef8..623dd20bf63 100644 --- a/src/main/daemon/pty-subprocess-test-harness.ts +++ b/src/main/daemon/pty-subprocess-test-harness.ts @@ -11,7 +11,10 @@ const ORCA_SHELL_WRAPPER_ENV = [ 'ORCA_MIMOCODE_HOME', 'ORCA_PI_CODING_AGENT_DIR', 'ORCA_OMP_CODING_AGENT_DIR', - 'ORCA_CODEX_HOME' + 'ORCA_OMP_STATUS_EXTENSION', + 'ORCA_CODEX_HOME', + 'ORCA_AGENT_TEAMS_SHIM_DIR', + 'ORCA_REMOTE_CLI_BIN_DIR' ] as const export const POWERLEVEL10K_WIZARD_DISABLE_ENV = 'POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD' diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 2f983e4f7c5..8e8d2314783 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -1,128 +1,20 @@ -/* eslint-disable max-lines -- Why: daemon PTY spawning must keep platform launch setup, preflight, and lifecycle guards in one execution path. */ -import * as pty from 'node-pty' -import { statSync } from 'node:fs' -import { release } from 'node:os' -import { delimiter, win32 as pathWin32 } from 'node:path' import type { SubprocessHandle } from './session-subprocess-handle' -import { DaemonProtocolError } from './types' -import { getShellLaunchConfig, resolvePtyShellPath } from './shell-ready' -import { selectShellStartupFeatures } from '../shell-startup-features' -import { isValidPtySize, normalizePtySize } from './daemon-pty-size' -import { - ensureNodePtySpawnHelperExecutable, - getNodePtySpawnHelperCandidates, - resolveUnixShellPath, - validateWorkingDirectoryAsync, - WorkingDirectoryValidationAbortedError -} from '../providers/local-pty-utils' -import { - hostReportsChildExitStatus, - wrapShellSpawnForMacosTccAttribution -} from '../providers/macos-tcc-login-shell' -import { resolveProcessExitCause, type TerminalExitCause } from '../../shared/terminal-exit-cause' -import { signalPosixPtyForegroundGroup } from '../pty/posix-pty-foreground-group' -import { readPtsName } from '../pty/node-pty-pts-name' -import { - ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV, - resolveWindowsShellLaunchArgs -} from '../providers/windows-shell-args' -import { - resolveEffectiveWindowsPowerShell, - shouldProbeWindowsPowerShellAvailability, - type WindowsPowerShellShellFamily -} from '../providers/windows-powershell' -import { - buildWindowsPowerShellSpawnAttempts, - type WindowsShellSpawnAttempt -} from '../providers/windows-shell-fallback-chain' -import { isPwshAvailable } from '../pwsh' -import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' -import { removeInheritedNoColor } from '../pty/terminal-color-env' -import { dropInheritedOrcaFishHistory } from '../fish-history-session' -import { dropInheritedOrcaHistFile } from '../worktree-history-file-path' -import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' -import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' -import { stripLegacyTerminalShimEnv } from '../pty/legacy-terminal-shim-dir' -import { dropIncoherentCondaActivationEnv } from '../pty/conda-activation-env' -import { resolvePathEnvKey } from '../pty/windows-environment-path' -import { parseWslPath } from '../wsl' -import { addWslEnvKeys } from '../wsl-env' -import { - gitCredentialPromptGuardEnv, - mergeGitConfigEnvProtocol -} from '../../shared/git-credential-prompt-env' -import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../shared/terminal-git-credential-guard' -import { resolveWslSessionContext } from './wsl-session-context' -import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' -import { - POWERLEVEL10K_WIZARD_DISABLE_ENV, - seedPowerlevel10kWizardEnv -} from '../pty/powerlevel10k-wizard-env' -import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../git-bash' -import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell' -import { resolveAgentForegroundProcessWithAvailability } from '../providers/agent-foreground-process' -import { readWindowsConptyProcessIds } from '../providers/windows-conpty-process-membership' -import { assignHostProcessToKillOnCloseJob, terminatePtyJob } from '../windows/windows-pty-job' -import { - isAgentForegroundWrapperProcess, - recognizeAgentProcess, - recognizeAgentProcessFromCommandLine -} from '../../shared/agent-process-recognition' -import { shouldInspectOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent' -import { - shouldUseShellReadyStartupDelivery, - type StartupCommandDelivery -} from '../../shared/codex-startup-delivery' -import { isShellProcess } from '../../shared/shell-process-detection' +import { normalizePtySize } from './daemon-pty-size' import { TerminalAttachCanceledError } from './daemon-errors' -import { parsePtySessionId } from './pty-session-id' -import { getAgentForegroundContextPaths } from '../providers/agent-foreground-context-paths' -import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' -import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query' -import type { TuiAgent } from '../../shared/tui-agent' +import { createDaemonPtyEnvironment } from './pty-subprocess/spawn-environment' +import { createPtyShellLaunchPlan } from './pty-subprocess/shell-launch-plan' +import { spawnNativeDaemonPty, type SpawnedDaemonPty } from './pty-subprocess/native-pty-spawn' import { - expandWindowsEnvironmentVariables, - expandWindowsPathEnvironmentVariables -} from '../../shared/windows-environment-expansion' -import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups' -import { readPtySlavePath } from '../../shared/pty-slave-line-discipline-echo' + formatPtySpawnError, + preflightPtySpawn, + preflightPtySpawnHealth, + runPtySpawnHealthProbe +} from './pty-subprocess/spawn-preflight' +import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle' +import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' +import type { TuiAgent } from '../../shared/tui-agent' -const PANE_IDENTITY_ENV_KEYS = [ - 'ORCA_PANE_KEY', - 'ORCA_TAB_ID', - 'ORCA_WORKTREE_ID', - 'ORCA_AGENT_LAUNCH_TOKEN' -] as const -const WINDOWS_PATH_ENV_KEY_RE = /^path$/i -const FOREGROUND_AGENT_CACHE_TTL_MS = 1000 -const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000 -// Why: a Windows refresh forks a heavy powershell.exe CIM scan (~10-40x POSIX `ps`); idle shells retry slower, output re-arms the fast retry. -const WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS = 15_000 -const SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS = 10_000 -const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000 -const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000 -// Why: retry once so a transient slow spawn doesn't route every terminal to the local fallback, losing daemon persistence. const PTY_SPAWN_HEALTH_RETRY_ATTEMPTS = 2 -const PENDING_PRE_LISTENER_DATA_MAX_CHARS = 512 * 1024 - -function shouldInspectOuterWrapperFallback(processName: string | null): boolean { - const recognized = recognizeAgentProcess(processName) - return recognized !== null && shouldInspectOuterWrapperForegroundProcess(recognized) -} - -function composeGuardedDaemonGitConfigEnv( - env: Record, - explicitEnv: Record | undefined, - launchAgent: TuiAgent | undefined -): void { - const policy = explicitEnv?.[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV] - delete env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV] - if (policy !== 'guard' && launchAgent === undefined) { - return - } - // Why: the daemon can outlive Electron, so its process.env is the authoritative inherited config; append only the guard. - Object.assign(env, gitCredentialPromptGuardEnv(env, process.platform)) -} export type PtySubprocessOptions = { sessionId: string @@ -134,9 +26,7 @@ export type PtySubprocessOptions = { command?: string startupCommandDelivery?: StartupCommandDelivery launchAgent?: TuiAgent - /** Explicit shell executable path/basename the renderer asked for. - * Overrides env.COMSPEC / env.SHELL resolution inside the daemon so a user - * who picks "New WSL terminal" from the "+" menu actually gets WSL. */ + /** Explicit shell executable path/basename requested by the renderer. */ shellOverride?: string terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' @@ -146,391 +36,21 @@ export type PtySubprocessOptions = { onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void } -function deleteRequestedDaemonEnvKeys( - env: Record, - keys: readonly string[] | undefined -): void { - // Why: the persistent daemon's inherited env can differ from Electron's. - // Compare ownership here so real-home routing neither leaks an Orca overlay - // nor deletes a user-owned CODEX_HOME chosen by the daemon's host context. - const deleteOrcaOwnedCodexHome = - keys?.includes('ORCA_CODEX_HOME') === true && - env.ORCA_CODEX_HOME !== undefined && - env.CODEX_HOME === env.ORCA_CODEX_HOME - for (const key of keys ?? []) { - delete env[key] - } - if (deleteOrcaOwnedCodexHome) { - delete env.CODEX_HOME - } -} - -/** - * Returns a stable default working directory for daemon-spawned PTYs. - */ -function getDefaultCwd(): string { - return resolveSafePtyDefaultCwd() -} - -/** - * Removes pane identity inherited from the daemon parent unless explicitly set. - */ -function removeUnspecifiedPaneIdentityEnv( - env: Record, - explicitEnv: Record | undefined -): void { - for (const key of PANE_IDENTITY_ENV_KEYS) { - if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) { - delete env[key] - } - } -} - -/** Removes the second PATH key only when the daemon's env merge created it. */ -function collapseWindowsPathEnvKeys( - env: Record, - requestedEnv: Record | undefined -): void { - if (process.platform !== 'win32') { - return - } - const pathKeys = Object.keys(env).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key)) - if (pathKeys.length < 2) { - return - } - // Why: a one-key main patch is authoritative; zero or two keys came from inherited state. - const requestedKeys = requestedEnv - ? Object.keys(requestedEnv).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key)) - : [] - if (requestedKeys.length !== 1) { - return - } - const survivingKey = requestedKeys[0] - if (!survivingKey || env[survivingKey] === undefined) { - return - } - for (const key of pathKeys) { - if (key !== survivingKey) { - delete env[key] - } - } -} - -/** - * Promotes the agent-teams shim path ahead of inherited PATH entries. - */ -function promoteAgentTeamsShimPath( - env: Record, - requestedPath: string | undefined -): void { - if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) { - return - } - const normalizedRequestedPath = - process.platform === 'win32' - ? expandWindowsEnvironmentVariables(requestedPath, env) - : requestedPath - const pathDelimiter = process.platform === 'win32' ? ';' : delimiter - const shimDir = normalizedRequestedPath.split(pathDelimiter)[0] - if (!shimDir) { - return - } - const pathKey = resolvePathEnvKey(env, process.platform) - const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? [] - env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter) -} - -/** - * Removes stale development hook endpoints inherited by daemon children. - */ -function removeInheritedDevAgentHookEndpoint( - env: Record, - explicitEnv: Record | undefined -): void { - if (explicitEnv?.ORCA_AGENT_HOOK_ENV === 'development' && !explicitEnv.ORCA_AGENT_HOOK_ENDPOINT) { - // Why: strip only stale inherited endpoints; a fresh explicit one is needed by hooks that scrub token-like env vars before exec. - delete env.ORCA_AGENT_HOOK_ENDPOINT - } -} - -/** - * Strips Electron's internal run-as-node flag from user shell environments. - */ -function removeInheritedElectronRunAsNode(env: Record): void { - // Why: user shells must not inherit ELECTRON_RUN_AS_NODE or nested Electron commands run as plain Node. - delete env.ELECTRON_RUN_AS_NODE -} - -function daemonEnvironmentDiagSuffix(): string { - const orca = process.env.ORCA_APP_VERSION?.trim() || '0.0.0-dev' - const systemVersion = - (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() || - release() - const platform = `${process.platform} ${systemVersion}` - return ` (orca: ${orca}, arch: ${process.arch}, platform: ${platform})` -} - -/** - * Formats a daemon preflight failure with the same ENOENT details node-pty exposes. - */ -function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError { - const detailName = kind === 'helper' ? 'helper' : 'cwd' - const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd' - const missingTarget = kind === 'helper' ? 'node-pty install' : 'working directory' - const diag = daemonEnvironmentDiagSuffix() - return new DaemonProtocolError( - `Daemon's ${missingTarget} is gone (worktree deleted?). Restart Orca. node-pty: ${step} failed: ENOENT (errno 2, No such file or directory) - ${detailName}='${path}'${diag}` - ) -} - -/** - * Checks whether a path currently exists and is a directory. - */ -function isExistingDirectory(path: string | undefined): path is string { - if (!path) { - return false - } - try { - return statSync(path).isDirectory() - } catch { - return false - } -} - -/** - * Moves the daemon process to a stable cwd after its original cwd disappears. - */ -function repairDaemonCwd(): string | null { - const candidates = [process.env.ORCA_USER_DATA_PATH] - try { - candidates.push(getDefaultCwd()) - } catch { - // Keep daemon cwd repair best-effort even when no user terminal cwd is safe. - } - candidates.push(process.platform === 'win32' ? 'C:\\' : '/') - for (const candidate of candidates) { - if (isExistingDirectory(candidate)) { - try { - process.chdir(candidate) - return candidate - } catch { - // Try the next stable cwd candidate. - } - } - } - return null -} - -/** - * Ensures the daemon cwd is valid before native PTY spawning. - */ -function preflightDaemonCwd(): void { - let daemonCwd = '' - try { - daemonCwd = process.cwd() - if (isExistingDirectory(daemonCwd)) { - return - } - } catch { - // Recover below; process.cwd() throws after the original cwd is deleted. - } - - // Why: if the daemon's launch worktree disappears, node-pty's macOS spawn-helper fails even when the terminal cwd is valid. - if (repairDaemonCwd()) { - return - } - throw formatMissingDaemonPathError('cwd', daemonCwd) -} - -/** - * Validates macOS node-pty helper availability before spawning terminals. - */ -function preflightMacNodePtySpawnEnvironment(): void { - if (process.platform !== 'darwin') { - return - } - - let candidates: string[] - try { - candidates = getNodePtySpawnHelperCandidates() - } catch { - throw formatMissingDaemonPathError('helper', '') - } - - for (const candidate of candidates) { - try { - if (statSync(candidate).isFile()) { - return - } - } catch { - // Try the next node-pty native location. - } - } - - throw formatMissingDaemonPathError('helper', candidates[0] ?? '') -} - -/** - * Ensures POSIX daemon-owned native PTY spawn prerequisites are still valid. - */ -function preflightUnixPtySpawnEnvironment(): void { - if (process.platform === 'win32') { - return - } - - // Why: detached daemons can outlive their launch cwd; repair before every spawn so Linux/macOS don't wait for health recovery. - preflightDaemonCwd() - preflightMacNodePtySpawnEnvironment() -} - -/** - * Detects native Windows paths that should be validated before spawn. - */ -function isNativeWindowsPath(path: string): boolean { - return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\') -} - -/** - * Validates explicit native Windows cwd paths before ConPTY launch. - */ -async function preflightWindowsPtySpawnEnvironment(args: { - validationCwd: string - cwdWasExplicit: boolean - signal?: AbortSignal -}): Promise { - if (process.platform !== 'win32' || !args.cwdWasExplicit) { - return - } - - if (!isNativeWindowsPath(args.validationCwd)) { - return - } - - await validateWorkingDirectoryAsync( - args.validationCwd, - args.signal ? { signal: args.signal } : {} - ) -} - -/** - * Validates POSIX spawn cwd before node-pty can fail with an opaque ENOENT. - */ -async function preflightPosixPtySpawnEnvironment( - validationCwd: string, - signal?: AbortSignal -): Promise { - if (process.platform === 'win32') { - return - } - await validateWorkingDirectoryAsync(validationCwd, signal ? { signal } : {}) -} - -/** - * Wraps native PTY spawn failures with shell and cwd context. - */ -function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): Error { - const message = err instanceof Error ? err.message : String(err) - const diag = daemonEnvironmentDiagSuffix() - const formatted = new DaemonProtocolError( - `Daemon failed to spawn shell "${shellPath}" with cwd "${spawnCwd}": ${message}${diag}` - ) - if (err instanceof Error && err.stack) { - formatted.stack = err.stack - } - return formatted -} - -/** - * Runs one short native PTY spawn probe (spawn `/bin/sh -c 'exit 0'`). - */ -function runSinglePtySpawnHealthProbe(): Promise { - const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH) - ? process.env.ORCA_USER_DATA_PATH - : getDefaultCwd() - - let proc: pty.IPty - try { - proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], { - name: 'xterm-256color', - cols: 2, - rows: 1, - cwd, - env: { - ...process.env, - TERM: 'xterm-256color' - } - }) - } catch (err) { - throw formatPtySpawnError(err, '/bin/sh', cwd) - } - - return new Promise((resolve, reject) => { - let settled = false - let exitDisposable: { dispose(): void } | undefined - const finish = (error?: Error, opts?: { kill?: boolean }): void => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - exitDisposable?.dispose() - if (opts?.kill) { - try { - proc.kill() - } catch { - // Best-effort cleanup for a short-lived health probe. - } - } - if (error) { - reject(error) - return - } - resolve() - } - const timer = setTimeout(() => { - finish(new Error(`PTY spawn health check timed out after ${PTY_SPAWN_HEALTH_TIMEOUT_MS}ms`), { - kill: true - }) - }, PTY_SPAWN_HEALTH_TIMEOUT_MS) - - // Why: ping only proves the protocol is alive; a real spawn catches stale node-pty helper paths. - exitDisposable = proc.onExit(({ exitCode }) => { - if (exitCode === 0) { - finish() - return - } - finish(new Error(`PTY spawn health check exited with code ${exitCode}`)) - }) - }) -} - -/** - * Runs a short native PTY spawn probe for daemon health checks, retrying once - * so a transient stall (e.g. a busy machine right after an upgrade) does not - * mis-classify a healthy daemon as unable to spawn PTYs. - */ export async function checkPtySpawnHealth(): Promise { - if (process.platform === 'win32') { + if (!preflightPtySpawnHealth()) { return } - - // Why: a real short-lived spawn catches a deleted cwd or stale native PTY path before panes are routed to a daemon that can't spawn. - if (process.platform === 'darwin') { - ensureNodePtySpawnHelperExecutable() - } - preflightUnixPtySpawnEnvironment() - let lastError: unknown for (let attempt = 1; attempt <= PTY_SPAWN_HEALTH_RETRY_ATTEMPTS; attempt++) { try { - await runSinglePtySpawnHealthProbe() + await runPtySpawnHealthProbe() return - } catch (err) { - lastError = err + } catch (error) { + lastError = error if (attempt < PTY_SPAWN_HEALTH_RETRY_ATTEMPTS) { console.warn( `[daemon] PTY spawn health probe attempt ${attempt} failed; retrying`, - err instanceof Error ? err.message : err + error instanceof Error ? error.message : error ) } } @@ -538,883 +58,57 @@ export async function checkPtySpawnHealth(): Promise { throw lastError instanceof Error ? lastError : new Error(String(lastError)) } -/** - * Normalizes node-pty foreground process strings to executable basenames. - */ -function normalizeForegroundProcessName(processName: string | null | undefined): string | null { - const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' - if (!trimmed || trimmed === 'xterm-256color') { - return null - } - return trimmed.split(/[\\/]/).pop() || null -} - -/** - * Falls back to the spawned Windows shell when node-pty reports a terminal name. - */ -function resolveFallbackForegroundProcess( - processName: string | null | undefined, - shellPath: string -): string | null { - const normalized = normalizeForegroundProcessName(processName) - if (normalized || process.platform !== 'win32') { - return normalized - } - // Why: Windows node-pty can report the terminal name instead of the shell; fall back to the spawned shell. - return normalizeForegroundProcessName(pathWin32.basename(shellPath)) -} - -/** - * Spawns the daemon PTY, walking the Windows PowerShell -> cmd.exe fallback - * chain when ConPTY rejects the primary shell with ERROR_ACCESS_DENIED. - * - * Why: the daemon has no LocalPtyProvider, so it owns its chain walk; later attempts recompute args so the cmd.exe fallback still gets `chcp 65001`. - */ -function spawnDaemonPtyWithWindowsFallback(args: { - shellPath: string - shellArgs: string[] - spawnCwd: string - env: Record - cols: number - rows: number - windowsFallbackAttempts: WindowsShellSpawnAttempt[] - onMacosTccSpawnStrategy?: PtySubprocessOptions['onMacosTccSpawnStrategy'] -}): { - process: pty.IPty - shellPath: string - spawnCwd: string - startupCommandDeliveredInShellArgs?: boolean - /** False when a wrapper owns the reported status, so no exit code may be read from it. */ - reportsChildExitStatus: boolean -} { - let reportsChildExitStatus = true - const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty => { - const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env) - // Why here: children inherit job membership, so the host job has to exist - // before the first pty -- but resolving it loads the ConPTY addon, and - // paying that at startup delayed the daemon's endpoint past the boot smoke - // test's readiness check. This path already pays ConPTY cost. Memoised. - if (process.platform === 'win32') { - assignHostProcessToKillOnCloseJob() - } - const proc = pty.spawn(wrapped.file, wrapped.args, { - name: args.env.TERM ?? 'xterm-256color', - cols: args.cols, - rows: args.rows, - cwd, - env: args.env, - // Why: legacy system ConPTY can corrupt full-width TUI rows in scrollback; bundled ConPTY has the wrap-marker behavior xterm expects. - ...(process.platform === 'win32' ? { useConptyDll: true } : {}) - }) - reportsChildExitStatus = hostReportsChildExitStatus(wrapped.file) - args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped') - return proc - } - - try { - const process_ = spawnAt(args.shellPath, args.shellArgs, args.spawnCwd) - return { - process: process_, - shellPath: args.shellPath, - spawnCwd: args.spawnCwd, - reportsChildExitStatus - } - } catch (primaryErr) { - if (process.platform !== 'win32') { - throw primaryErr - } - // Skip the first entry: it is the primary that already failed above. - for (const attempt of args.windowsFallbackAttempts.slice(1)) { - try { - const process = spawnAt(attempt.shellPath, attempt.shellArgs, attempt.effectiveCwd) - const message = primaryErr instanceof Error ? primaryErr.message : String(primaryErr) - console.warn( - `[daemon/pty] Primary shell "${args.shellPath}" failed (${message}), fell back to "${attempt.shellPath}"` - ) - return { - process, - shellPath: attempt.shellPath, - spawnCwd: attempt.effectiveCwd, - startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs, - reportsChildExitStatus - } - } catch { - // This fallback shell also failed -- try the next link in the chain. - } - } - throw primaryErr - } -} - /** * Spawns the daemon-owned PTY subprocess for a terminal session. * - * The returned handle records whether the startup command was already embedded - * in Windows shell args so the daemon host does not write it a second time. + * Launch planning stays ahead of validation so Windows validates the effective + * host or WSL cwd selected by the same fallback chain that native spawn uses. + * The handle then owns all event buffering, foreground identity, and teardown. */ export async function createPtySubprocess(opts: PtySubprocessOptions): Promise { const size = normalizePtySize(opts.cols, opts.rows) - const env: Record = { - ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), opts.env), - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - TERM_PROGRAM: 'Orca', - // Why: TUIs feature-gate on TERM_PROGRAM_VERSION; ORCA_APP_VERSION is inherited from the forking main process. - TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev', - // Why: `supports-hyperlinks` gates OSC 8 on a TERM_PROGRAM allowlist excluding Orca; force it since xterm.js parses OSC 8 for clickable links. - FORCE_HYPERLINK: '1' - } as Record - // Why: an older client may not ask a newly upgraded daemon to delete inherited shim state. - stripLegacyTerminalShimEnv(env, process.platform) - composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent) - deleteRequestedDaemonEnvKeys(env, opts.envToDelete) - if (opts.env?.TERM) { - env.TERM = opts.env.TERM - } - // Why: the daemon can inherit the pane identity of the terminal that launched `pn dev`; each PTY must opt into its own. - removeUnspecifiedPaneIdentityEnv(env, opts.env) - // Why: the daemon inherits an exported `fish_history` from the fish pane that - // launched Orca; only a session this spawn asked for may reach the shell, or - // panes write into the launching worktree's history file (STA-4682). - if (opts.env?.fish_history === undefined) { - dropInheritedOrcaFishHistory(env) - } - // Why the same for HISTFILE: it is exported too, so a daemon started from an - // Orca pane hands that worktree's history file to every pane this spawn did - // not scope itself — including panes of other worktrees. - if (opts.env?.HISTFILE === undefined) { - dropInheritedOrcaHistFile(env) - } - // Why ORCA_HISTFILE too: the desktop drops an inherited one on both of its - // branches, and the daemon inherits one from the pane that launched it just - // as readily. Left in place it now both WRAPS a pane the client scoped - // nothing for (shell-startup-features selects `history` on its presence) and - // makes the wrapper re-export another worktree's history path (#11146). - if (opts.env?.ORCA_HISTFILE === undefined) { - delete env.ORCA_HISTFILE - } - removeInheritedDevAgentHookEndpoint(env, opts.env) - removeInheritedElectronRunAsNode(env) - removeAppImageRuntimeEnv(env) - removeInheritedNoColor(env) + const env = createDaemonPtyEnvironment(opts) + const launch = createPtyShellLaunchPlan(opts, env) - env.LANG ??= 'en_US.UTF-8' - // Why: shellOverride must win over env.COMSPEC, or Windows always resolves to cmd.exe/PowerShell regardless of the user's pick. - const resolvedWslContext = resolveWslSessionContext(opts) - // Why: older persisted tabs can carry a PowerShell/cmd shellOverride; ignore it so WSL reconnects still enter the distro. - let shellPath = resolvedWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) - let shellArgs: string[] - let startupCommandDeliveredInShellArgs = false - let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = [] - const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command) - const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex' - // Why: gate on the effective cwd, not raw opts.cwd — an omitted cwd becomes a safe - // default (mirrors LocalPtyProvider). Guarding first treated undefined as root-like (#9578). - const requestedCwd = opts.cwd || getDefaultCwd() - if (opts.command && startupAgentRecognition) { - assertSafeAgentStartupCwd(requestedCwd, opts.command) - } - let spawnCwd = requestedCwd - let validationCwd = spawnCwd - - if (process.platform === 'win32') { - const normalizedShellFamily = pathWin32.basename(shellPath).toLowerCase() - const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellPath) - // Why: normalize concrete PowerShell paths to the family so the resolver can fall back to powershell.exe when pwsh.exe is unavailable. - const resolvedShellFamily: WindowsPowerShellShellFamily = - normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe' - ? normalizedShellFamily - : normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe' - ? normalizedShellFamily - : undefined - const shouldProbePwsh = shouldProbeWindowsPowerShellAvailability({ - shellFamily: resolvedShellFamily, - implementation: opts.terminalWindowsPowerShellImplementation - }) - const shouldResolvePowerShellFamily = - opts.terminalWindowsPowerShellImplementation !== undefined || - pathWin32.basename(shellPath) === shellPath - if (resolvedGitBashPath) { - shellPath = resolvedGitBashPath - } else if (shellPath === WINDOWS_GIT_BASH_SHELL) { - shellPath = 'powershell.exe' - } else { - shellPath = shouldResolvePowerShellFamily - ? (resolveEffectiveWindowsPowerShell({ - shellFamily: resolvedShellFamily, - implementation: opts.terminalWindowsPowerShellImplementation, - pwshAvailable: shouldProbePwsh ? isPwshAvailable() : false - }) ?? shellPath) - : shellPath - } - if ( - pathWin32.basename(shellPath).toLowerCase() === 'cmd.exe' && - env.ORCA_CODEX_LAUNCH_PREFLIGHT - ) { - // Why: node-pty backslash-escapes argv quotes; expand the quote inside cmd.exe instead. - env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"' - } - // Why: a bare `pwsh.exe` resolves to the Store App Execution Alias stub whose launch fails with ERROR_ACCESS_DENIED (5). - windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({ - shellPath, - cwd: spawnCwd, - defaultCwd: getDefaultCwd(), - wslContext: resolvedWslContext, - startupCommand: opts.command - }) - const primaryAttempt = windowsFallbackAttempts[0] - if (primaryAttempt) { - shellPath = primaryAttempt.shellPath - shellArgs = primaryAttempt.shellArgs - spawnCwd = primaryAttempt.effectiveCwd - validationCwd = primaryAttempt.validationCwd - startupCommandDeliveredInShellArgs = primaryAttempt.startupCommandDeliveredInShellArgs - } else { - const resolved = resolveWindowsShellLaunchArgs( - shellPath, - spawnCwd, - getDefaultCwd(), - resolvedWslContext, - opts.command, - env.ORCA_CODEX_LAUNCH_PREFLIGHT - ) - shellArgs = resolved.shellArgs - spawnCwd = resolved.effectiveCwd - validationCwd = resolved.validationCwd - startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true - } - if (isWindowsGitBashShellPath(shellPath)) { - // Why: Git for Windows login startup files otherwise cd to $HOME, ignoring node-pty's cwd. - env.CHERE_INVOKING ??= '1' - } - const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null - if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { - if (codexHomeWslInfo) { - const launchWslDistro = resolvedWslContext?.distro - if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) { - delete env.CODEX_HOME - delete env.ORCA_CODEX_HOME - } else { - env.CODEX_HOME = codexHomeWslInfo.linuxPath - env.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath - // Why: wsl.exe only imports non-default env vars named in WSLENV. - addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) - if (!launchWslDistro) { - const resolved = resolveWindowsShellLaunchArgs( - shellPath, - requestedCwd, - getDefaultCwd(), - { - distro: codexHomeWslInfo.distro - }, - opts.command, - env.ORCA_CODEX_LAUNCH_PREFLIGHT - ) - shellArgs = resolved.shellArgs - spawnCwd = resolved.effectiveCwd - validationCwd = resolved.validationCwd - startupCommandDeliveredInShellArgs = - resolved.startupCommandDeliveredInShellArgs === true - } - } - } else if (isHostCodexHomeForWsl(env.CODEX_HOME)) { - // Why: host-local Codex home is unusable in WSL; let WSL Codex use its Linux-side ~/.codex. - delete env.CODEX_HOME - delete env.ORCA_CODEX_HOME - } else if (env.CODEX_HOME) { - addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) - } - if (env.CLAUDE_CONFIG_DIR) { - // Why: non-default env vars need WSLENV import to cross Windows wsl.exe into the Linux side. - addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) - } - if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { - // Why: wsl.exe drops custom Windows env vars unless named in WSLENV. - addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) - } - } else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) { - // Why: WSL Codex homes are Linux paths; also drop ORCA_CODEX_HOME since shell-ready restores CODEX_HOME from it. - delete env.CODEX_HOME - delete env.ORCA_CODEX_HOME - } - if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { - addOrcaWslInteropEnv(env) - } - } else { - // Why: relay-side launch modes can ask for host defaults to stay scrubbed - // even after environment normalization above. - deleteRequestedDaemonEnvKeys(env, opts.envToDelete) - if (opts.env?.TERM) { - env.TERM = opts.env.TERM - } - // Why: set SHELL after the scrub and before launch-config derivation so shell-ready wrappers target the resolved shell. - const preferredShellPath = shellPath - shellPath = resolveUnixShellPath(shellPath) - if (shellPath !== preferredShellPath) { - env.SHELL = shellPath - console.warn( - `[daemon/pty] Preferred shell "${preferredShellPath}" is unavailable, fell back to "${shellPath}"` - ) - } - // Why: OpenCode/Codex path restoration, OMP's typed-command status wrapper, - // and the worktree HISTFILE repair all need shell-ready code that runs after - // the user's own startup files. - // Why: payload-bearing Codex startup text can be dropped by rc-file noise; plain Codex stays markerless for the startup-speed path. - const waitsForShellReady = - Boolean(opts.command) && - (!isCodexStartupCommand || - shouldUseShellReadyStartupDelivery({ - command: opts.command as string, - startupCommandDelivery: opts.startupCommandDelivery - })) - // Why delete: ORCA_SHELL_FEATURES is Orca-owned, and only the launch config - // below may name features for this shell. - delete env.ORCA_SHELL_FEATURES - const shellLaunch = getShellLaunchConfig( - shellPath, - selectShellStartupFeatures({ - shellPath, - env, - hasStartupCommand: Boolean(opts.command), - waitsForShellReady, - // Why identical: the identity marker exists so the readiness handshake - // can bind output to the right shell PID. - emitsStartupIdentity: waitsForShellReady - }) - ) - Object.assign(env, shellLaunch.env) - shellArgs = shellLaunch.args ?? ['-l'] - } - seedPowerlevel10kWizardEnv(env, { envToDelete: opts.envToDelete }) - if ( - env[POWERLEVEL10K_WIZARD_DISABLE_ENV] !== undefined && - process.platform === 'win32' && - pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' - ) { - addWslEnvKeys(env, [POWERLEVEL10K_WIZARD_DISABLE_ENV]) - } - const requestedEnv = opts.env - expandWindowsPathEnvironmentVariables(env) - // Why: collapse before promoting so the shim lands on the spelling the child actually inherits. - collapseWindowsPathEnvKeys(env, requestedEnv) - const requestedPath = requestedEnv - ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] - : undefined - promoteAgentTeamsShimPath(env, requestedPath) - // Why: raw requested PATH promotion runs after the inherited-env scrub. - stripLegacyTerminalShimEnv(env, process.platform) - // Why after every deletion pass: an envToDelete of CONDA_PREFIX must not leave the sentinel behind. - dropIncoherentCondaActivationEnv(env, process.platform) - - // Why: asar packaging can strip +x from node-pty's spawn-helper; the daemon is a separate forked process from the main-process fix. - ensureNodePtySpawnHelperExecutable() - preflightUnixPtySpawnEnvironment() - try { - await preflightPosixPtySpawnEnvironment(validationCwd, opts.cancelSignal) - await preflightWindowsPtySpawnEnvironment({ - validationCwd, - cwdWasExplicit: opts.cwd !== undefined, - ...(opts.cancelSignal ? { signal: opts.cancelSignal } : {}) - }) - } catch (error) { - // Why: the wire must carry one cancellation identity. Clients key recovery - // off it, and an unrecognized message takes the rollback branch that closes - // a terminal the user still has (#7718). - if (error instanceof WorkingDirectoryValidationAbortedError) { - throw new TerminalAttachCanceledError(opts.sessionId) - } - throw error - } + await preflightPtySpawn({ + validationCwd: launch.validationCwd, + cwdWasExplicit: opts.cwd !== undefined, + sessionId: opts.sessionId, + ...(opts.cancelSignal ? { signal: opts.cancelSignal } : {}) + }) if (opts.isCanceled?.()) { throw new TerminalAttachCanceledError(opts.sessionId) } - let proc: pty.IPty - let reportsChildExitStatus = true + let spawned: SpawnedDaemonPty try { - const spawned = spawnDaemonPtyWithWindowsFallback({ - shellPath, - shellArgs, - spawnCwd, + spawned = spawnNativeDaemonPty({ + shellPath: launch.shellPath, + shellArgs: launch.shellArgs, + spawnCwd: launch.spawnCwd, env, cols: size.cols, rows: size.rows, - windowsFallbackAttempts, + windowsFallbackAttempts: launch.windowsFallbackAttempts, onMacosTccSpawnStrategy: opts.onMacosTccSpawnStrategy }) - proc = spawned.process - // Why: a Windows fallback (e.g. cmd.exe) carries its own argv-embedded startup command; adopt the winning shell's identity + delivery flag. - shellPath = spawned.shellPath - spawnCwd = spawned.spawnCwd - reportsChildExitStatus = spawned.reportsChildExitStatus - if (spawned.startupCommandDeliveredInShellArgs !== undefined) { - startupCommandDeliveredInShellArgs = spawned.startupCommandDeliveredInShellArgs - } - } catch (err) { + } catch (error) { if (process.platform === 'win32') { - throw formatPtySpawnError(err, shellPath, spawnCwd) + throw formatPtySpawnError(error, launch.shellPath, launch.spawnCwd) } - throw err + throw error } - let onDataCb: ((data: string) => void) | null = null - let onExitCb: ((code: number, cause?: TerminalExitCause) => void) | null = null - let pendingPreListenerData: string[] = [] - let pendingPreListenerDataChars = 0 - let pendingPreListenerExitCode: number | null = null - let pendingPreListenerExitCause: TerminalExitCause | null = null - - const bufferPreListenerData = (data: string): void => { - // Why: Windows shell-arg startup commands can print before Session wires this subprocess in; preserve that spawn-time race window. - pendingPreListenerData.push(data) - pendingPreListenerDataChars += data.length - while (pendingPreListenerDataChars > PENDING_PRE_LISTENER_DATA_MAX_CHARS) { - const removed = pendingPreListenerData.shift() - if (removed === undefined) { - pendingPreListenerDataChars = 0 - return - } - pendingPreListenerDataChars -= removed.length - } - } - - const flushPreListenerData = (): void => { - if (!onDataCb || pendingPreListenerData.length === 0) { - return - } - const pending = pendingPreListenerData - pendingPreListenerData = [] - pendingPreListenerDataChars = 0 - for (const data of pending) { - onDataCb(data) - } - } - - let lastOutputAt = 0 - proc.onData((data) => { - if (data.length > 0) { - lastOutputAt = Date.now() - } - if (onDataCb) { - onDataCb(data) - } else { - bufferPreListenerData(data) - } + return createDaemonPtySubprocessHandle({ + process: spawned.process, + shellPath: spawned.shellPath, + spawnCwd: spawned.spawnCwd, + env, + startupCommandDeliveredInShellArgs: + spawned.startupCommandDeliveredInShellArgs ?? launch.startupCommandDeliveredInShellArgs, + reportsChildExitStatus: spawned.reportsChildExitStatus, + requestedCwd: opts.cwd, + sessionId: opts.sessionId, + startupAgentRecognition: launch.startupAgentRecognition }) - proc.onExit(({ exitCode, signal }) => { - // Why: node-pty reports a signalled death as {exitCode: 0, signal: N}, and a - // wrapper spawn reports the wrapper's status — so build the cause here, - // where both facts are still in hand, rather than shipping a bare number. - const cause = resolveProcessExitCause({ - exitCode, - signal, - hostReportsChildExitStatus: reportsChildExitStatus - }) - if (onExitCb) { - flushPreListenerData() - onExitCb(exitCode, cause) - } else { - pendingPreListenerExitCode = exitCode - pendingPreListenerExitCause = cause - } - }) - - // Why: node-pty throws Napi::Error if write/resize/kill hit a closed fd (child-exit vs onExit race); uncaught it std::terminates the daemon. - let dead = false - let disposed = false - let nodePtyKillIssued = false - let cachedAgentForeground: { processName: string; refreshedAt: number } | null = null - const agentForegroundContextPaths = getAgentForegroundContextPaths({ - cwd: opts.cwd, - worktreeId: parsePtySessionId(opts.sessionId).worktreeId - }) - let startupAgentForeground: { processName: string; expiresAt: number } | null = - startupAgentRecognition - ? { - processName: startupAgentRecognition.processName, - expiresAt: Date.now() + STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS - } - : null - let foregroundRefreshInFlight = false - let lastForegroundRefreshStartedAt = 0 - const getFallbackForegroundProcess = (): string | null => - resolveFallbackForegroundProcess(proc.process, shellPath) - const getActiveStartupAgentForeground = ( - now = Date.now() - ): { processName: string; expiresAt: number } | null => { - if (!startupAgentForeground) { - return null - } - if (now > startupAgentForeground.expiresAt) { - startupAgentForeground = null - return null - } - return startupAgentForeground - } - const shouldInspectFallbackForegroundProcess = (fallbackProcess: string | null): boolean => - fallbackProcess !== null && - (isShellProcess(fallbackProcess) || - isAgentForegroundWrapperProcess(fallbackProcess) || - shouldInspectOuterWrapperFallback(fallbackProcess) || - // Why: agent-spawned helpers can become the PTY foreground child, but the Unix process tree still identifies the parent agent. - process.platform !== 'win32') - const scheduleAgentForegroundRefresh = (fallbackProcess: string | null): void => { - if (dead || !proc.pid) { - return - } - const fallbackIsShell = fallbackProcess !== null && isShellProcess(fallbackProcess) - const fallbackRecognition = recognizeAgentProcess(fallbackProcess) - if ( - !fallbackProcess || - (fallbackRecognition !== null && - !shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) || - !shouldInspectFallbackForegroundProcess(fallbackProcess) - ) { - return - } - const now = Date.now() - const idleNoEvidenceShell = - fallbackIsShell && !getActiveStartupAgentForeground(now) && !cachedAgentForeground - // Why: on Windows each refresh is a whole-table CIM scan, so only shells with no agent evidence and no recent output relax the retry. - const retryMs = !idleNoEvidenceShell - ? FOREGROUND_AGENT_CACHE_TTL_MS - : process.platform === 'win32' && now - lastOutputAt > SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS - ? WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS - : SHELL_FOREGROUND_REFRESH_RETRY_MS - if (foregroundRefreshInFlight || now - lastForegroundRefreshStartedAt < retryMs) { - return - } - foregroundRefreshInFlight = true - lastForegroundRefreshStartedAt = now - // Why: daemon foreground reads are sync on the IPC hot path; refresh derived identities in the background and serve from a short cache. - // Why: Windows may need an async membership check before this shell/wrapper retirement policy is safe. - const retireStaleForegroundIdentity = (): void => { - const currentFallbackProcess = getFallbackForegroundProcess() - if ( - fallbackIsShell && - !getActiveStartupAgentForeground() && - currentFallbackProcess !== null && - isShellProcess(currentFallbackProcess) - ) { - cachedAgentForeground = null - startupAgentForeground = null - } else if ( - cachedAgentForeground !== null && - Date.now() - cachedAgentForeground.refreshedAt > FOREGROUND_AGENT_CACHE_TTL_MS && - currentFallbackProcess !== null && - isAgentForegroundWrapperProcess(currentFallbackProcess) - ) { - // Why: an expired wrapper identity must not transfer to an unrelated wrapper (e.g. npm after an agent exit); fresh ones survive scan hiccups. - cachedAgentForeground = null - } - } - void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, { - contextPaths: agentForegroundContextPaths - }) - .then(({ processName, available }) => { - if (dead) { - return - } - // Why: a degraded scan isn't exit evidence — retiring would fire false completion while the agent works under CIM load. - if (!available) { - return - } - if (!processName || !recognizeAgentProcess(processName)) { - // Why: a Windows snapshot can omit a live agent; only verified shell-only membership may retire cached identity. - if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) { - return readWindowsConptyProcessIds(proc.pid).then((consoleProcessIds) => { - if (dead || consoleProcessIds === null || consoleProcessIds.size > 1) { - return - } - retireStaleForegroundIdentity() - }) - } - retireStaleForegroundIdentity() - return - } - cachedAgentForeground = { processName, refreshedAt: Date.now() } - startupAgentForeground = null - return processName - }) - .catch(() => { - // Best-effort only: foreground enrichment must never affect PTY health. - }) - .finally(() => { - foregroundRefreshInFlight = false - }) - } - proc.onExit(() => { - dead = true - cachedAgentForeground = null - startupAgentForeground = null - // Why: neutralize proc.kill synchronously in onExit so UnixTerminal's async socket-close SIGHUP can't land on a recycled pid. - // Windows excluded: WindowsTerminal.destroy needs kill() to close the ConPTY agent. - if (process.platform !== 'win32') { - ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} - } - }) - - const slavePath = readPtySlavePath(proc) - return { - pid: proc.pid, - shellPath, - shellCwd: spawnCwd, - shellPathEnv: env.PATH, - ...(slavePath ? { slavePath } : {}), - ...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), - getForegroundProcess: () => { - // Why: node-pty's `.process` reports the live foreground name but reads a recycled pid on a reaped pty, so bail when dead. - if (dead) { - return null - } - try { - const fallbackProcess = getFallbackForegroundProcess() - const fallbackRecognition = recognizeAgentProcess(fallbackProcess) - const inspectOuterWrapper = - fallbackRecognition !== null && - shouldInspectOuterWrapperForegroundProcess(fallbackRecognition) - if (fallbackProcess && fallbackRecognition && !inspectOuterWrapper) { - cachedAgentForeground = { processName: fallbackProcess, refreshedAt: Date.now() } - startupAgentForeground = null - return fallbackProcess - } - scheduleAgentForegroundRefresh(fallbackProcess) - const now = Date.now() - if ( - cachedAgentForeground && - now - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS - ) { - return cachedAgentForeground.processName - } - // Why: a wrapper (node/python) can't self-identify and readers poll slower than the cache TTL, so serve the last resolved agent. - // On Windows a shell fallback is an unreliable exit signal under ConPTY lag; trust the cache until a console-presence read confirms exit. - if ( - cachedAgentForeground && - fallbackProcess !== null && - (isAgentForegroundWrapperProcess(fallbackProcess) || - inspectOuterWrapper || - (process.platform === 'win32' && isShellProcess(fallbackProcess))) - ) { - return cachedAgentForeground.processName - } - const activeStartupAgentForeground = getActiveStartupAgentForeground(now) - if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) { - return activeStartupAgentForeground.processName - } - return fallbackProcess - } catch { - return null - } - }, - confirmForegroundProcess: async () => { - if (dead || !proc.pid) { - return null - } - try { - const fallbackProcess = getFallbackForegroundProcess() - const fallbackRecognition = recognizeAgentProcess(fallbackProcess) - if ( - !fallbackProcess || - (fallbackRecognition !== null && - process.platform !== 'win32' && - !shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) || - (process.platform !== 'win32' && !shouldInspectFallbackForegroundProcess(fallbackProcess)) - ) { - return fallbackProcess - } - // Why: cached/in-flight scans may predate the OSC command boundary; confirmation needs a snapshot started afterward. - const resolution = await resolveAgentForegroundProcessWithAvailability( - proc.pid, - fallbackProcess, - { - contextPaths: agentForegroundContextPaths, - fresh: true, - ...(process.platform === 'win32' - ? { - forceProcessScan: true, - readWindowsConptyProcessIds: () => readWindowsConptyProcessIds(proc.pid) - } - : {}) - } - ) - if (dead || !resolution.available) { - return null - } - const recognized = recognizeAgentProcess(resolution.processName) - if (recognized) { - cachedAgentForeground = { - processName: recognized.processName, - refreshedAt: Date.now() - } - startupAgentForeground = null - return recognized.processName - } - // Why: a post-boundary scan resolving no agent is the authority that retires stale cached/startup identity. - cachedAgentForeground = null - startupAgentForeground = null - return resolution.processName - } catch { - return null - } - }, - write: (data) => { - if (dead) { - return - } - try { - proc.write(data) - } catch { - dead = true - } - }, - resize: (cols, rows) => { - if (dead) { - return - } - if (!isValidPtySize(cols, rows)) { - return - } - try { - proc.resize(cols, rows) - } catch { - dead = true - } - }, - // Why pause/resume work on Windows too: WindowsTerminal wires _socket to the ConPTY conout pipe, so pausing backpressures the child. - pause: () => { - if (dead) { - return - } - try { - proc.pause() - } catch { - /* native handle already torn down — flow control is best-effort */ - } - }, - resume: () => { - if (dead) { - return - } - try { - proc.resume() - } catch { - /* native handle already torn down — flow control is best-effort */ - } - }, - clear: () => { - if (dead) { - return - } - try { - proc.clear() - } catch { - // Best-effort: a clear on a just-exited PTY must not kill the handle. - } - }, - kill: () => { - if (dead) { - return - } - nodePtyKillIssued = true - try { - proc.kill() - } catch (error) { - // Why: a rejected native kill isn't proof of exit — keep the wrapper live so Session can retry the owner. - nodePtyKillIssued = false - throw error - } - }, - terminateOwnedTree: () => terminatePtyJob(proc), - forceKill: () => { - // Why: after reap/dispose proc.pid is a recycled pid, so SIGKILL would hit an unrelated process (forceKill only signals a live child). - if (dead) { - return - } - // Why: Windows node-pty kill already closed ConPTY; forcing again can double-close the native handle. - // That left forceKill a permanent no-op after any kill(), so a wedged ConPTY -- which - // never fires onExit -- had no escalation at all (#9854). The job is that escalation: - // it terminates the tree without touching the shell handle node-pty owns. - if (process.platform === 'win32' && nodePtyKillIssued) { - terminatePtyJob(proc) - return - } - try { - forceKillPosixPtyProcessGroups(proc.pid, () => { - process.kill(proc.pid, 'SIGKILL') - }) - } catch (signalError) { - try { - proc.kill() - nodePtyKillIssued = true - } catch { - nodePtyKillIssued = false - // Keep the original OS failure so callers can retry the same owner. - throw signalError - } - } - }, - signal: (sig) => { - // Why: same recycled-pid hazard as forceKill — once dead, dropping avoids signalling an unrelated process. - if (dead) { - return - } - const signalRootPid = (): void => { - try { - process.kill(proc.pid, sig) - } catch { - // Process may already be dead - } - } - // Why only SIGWINCH: the kernel delivers a real resize to the tty's foreground - // group, and proc.pid is never in it (the shell setpgid's away, and on macOS - // proc.pid is login(1), which drops SIGWINCH). Destructive signals keep the - // narrower root-pid target. - if (sig === 'SIGWINCH') { - signalPosixPtyForegroundGroup(proc.pid, readPtsName(proc), sig, signalRootPid) - return - } - signalRootPid() - }, - onData: (cb) => { - onDataCb = cb - flushPreListenerData() - }, - onExit: (cb) => { - onExitCb = cb - if (pendingPreListenerExitCode !== null) { - const code = pendingPreListenerExitCode - const cause = pendingPreListenerExitCause ?? resolveProcessExitCause({ exitCode: code }) - pendingPreListenerExitCode = null - pendingPreListenerExitCause = null - flushPreListenerData() - cb(code, cause) - } - }, - dispose: () => { - if (disposed) { - return - } - disposed = true - dead = true - onDataCb = null - onExitCb = null - pendingPreListenerData = [] - pendingPreListenerDataChars = 0 - pendingPreListenerExitCode = null - pendingPreListenerExitCause = null - // Why: UnixTerminal.destroy()'s async socket-close SIGHUP can land on a recycled pid, hitting an unrelated user process; neutralize kill on POSIX. - // Windows destroy() uses kill() to close the ConPTY agent, so the guard is POSIX-only. - if (process.platform !== 'win32') { - ;(proc as unknown as { kill: (sig?: string) => void }).kill = () => {} - } else if (nodePtyKillIssued) { - // Why: WindowsTerminal.destroy() calls kill(); destroying after node-pty's kill() double-closes the ConPTY handle (heap corruption). - return - } - try { - ;(proc as unknown as { destroy?: () => void }).destroy?.() - } catch { - /* swallow — already torn down, or native-side error we can't recover from */ - } - } - } } diff --git a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts new file mode 100644 index 00000000000..ff905e0cdfb --- /dev/null +++ b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts @@ -0,0 +1,275 @@ +import type * as pty from 'node-pty' +import { win32 as pathWin32 } from 'node:path' +import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths' +import { resolveAgentForegroundProcessWithAvailability } from '../../providers/agent-foreground-process' +import { readWindowsConptyProcessIds } from '../../providers/windows-conpty-process-membership' +import { + isAgentForegroundWrapperProcess, + recognizeAgentProcess, + type RecognizedAgentProcess +} from '../../../shared/agent-process-recognition' +import { shouldInspectOuterWrapperForegroundProcess } from '../../../shared/foreground-wrapper-agent' +import { isShellProcess } from '../../../shared/shell-process-detection' +import { parsePtySessionId } from '../pty-session-id' + +const FOREGROUND_AGENT_CACHE_TTL_MS = 1000 +const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000 +const WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS = 15_000 +const SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS = 10_000 +const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000 + +function normalizeForegroundProcessName(processName: string | null | undefined): string | null { + const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' + if (!trimmed || trimmed === 'xterm-256color') { + return null + } + return trimmed.split(/[\\/]/).pop() || null +} + +function resolveFallbackForegroundProcess( + processName: string | null | undefined, + shellPath: string +): string | null { + const normalized = normalizeForegroundProcessName(processName) + if (normalized || process.platform !== 'win32') { + return normalized + } + return normalizeForegroundProcessName(pathWin32.basename(shellPath)) +} + +function shouldInspectOuterWrapperFallback(processName: string | null): boolean { + const recognized = recognizeAgentProcess(processName) + return recognized !== null && shouldInspectOuterWrapperForegroundProcess(recognized) +} + +export type PtyForegroundProcessTracker = { + recordOutput(data: string): void + markDead(): void + getForegroundProcess(): string | null + confirmForegroundProcess(): Promise +} + +export function createPtyForegroundProcessTracker(args: { + process: pty.IPty + shellPath: string + cwd?: string + sessionId: string + startupAgentRecognition: RecognizedAgentProcess | null + isDead: () => boolean +}): PtyForegroundProcessTracker { + const proc = args.process + let lastOutputAt = 0 + let cachedAgentForeground: { processName: string; refreshedAt: number } | null = null + const contextPaths = getAgentForegroundContextPaths({ + cwd: args.cwd, + worktreeId: parsePtySessionId(args.sessionId).worktreeId + }) + let startupAgentForeground: { processName: string; expiresAt: number } | null = + args.startupAgentRecognition + ? { + processName: args.startupAgentRecognition.processName, + expiresAt: Date.now() + STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS + } + : null + let foregroundRefreshInFlight = false + let lastForegroundRefreshStartedAt = 0 + const getFallbackProcess = (): string | null => + resolveFallbackForegroundProcess(proc.process, args.shellPath) + const getActiveStartupAgent = ( + now = Date.now() + ): { processName: string; expiresAt: number } | null => { + if (!startupAgentForeground) { + return null + } + if (now > startupAgentForeground.expiresAt) { + startupAgentForeground = null + return null + } + return startupAgentForeground + } + const shouldInspectFallback = (fallbackProcess: string | null): boolean => + fallbackProcess !== null && + (isShellProcess(fallbackProcess) || + isAgentForegroundWrapperProcess(fallbackProcess) || + shouldInspectOuterWrapperFallback(fallbackProcess) || + process.platform !== 'win32') + + const scheduleRefresh = (fallbackProcess: string | null): void => { + if (args.isDead() || !proc.pid) { + return + } + const fallbackIsShell = fallbackProcess !== null && isShellProcess(fallbackProcess) + const fallbackRecognition = recognizeAgentProcess(fallbackProcess) + if ( + !fallbackProcess || + (fallbackRecognition !== null && + !shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) || + !shouldInspectFallback(fallbackProcess) + ) { + return + } + const now = Date.now() + const idleNoEvidenceShell = + fallbackIsShell && !getActiveStartupAgent(now) && !cachedAgentForeground + const retryMs = !idleNoEvidenceShell + ? FOREGROUND_AGENT_CACHE_TTL_MS + : process.platform === 'win32' && now - lastOutputAt > SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS + ? WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS + : SHELL_FOREGROUND_REFRESH_RETRY_MS + if (foregroundRefreshInFlight || now - lastForegroundRefreshStartedAt < retryMs) { + return + } + foregroundRefreshInFlight = true + lastForegroundRefreshStartedAt = now + const retireStaleForegroundIdentity = (): void => { + const currentFallbackProcess = getFallbackProcess() + if ( + fallbackIsShell && + !getActiveStartupAgent() && + currentFallbackProcess !== null && + isShellProcess(currentFallbackProcess) + ) { + cachedAgentForeground = null + startupAgentForeground = null + } else if ( + cachedAgentForeground !== null && + Date.now() - cachedAgentForeground.refreshedAt > FOREGROUND_AGENT_CACHE_TTL_MS && + currentFallbackProcess !== null && + isAgentForegroundWrapperProcess(currentFallbackProcess) + ) { + cachedAgentForeground = null + } + } + void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, { + contextPaths + }) + .then(({ processName, available }) => { + if (args.isDead() || !available) { + return + } + if (!processName || !recognizeAgentProcess(processName)) { + if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) { + return readWindowsConptyProcessIds(proc.pid).then((consoleProcessIds) => { + if (args.isDead() || consoleProcessIds === null || consoleProcessIds.size > 1) { + return + } + retireStaleForegroundIdentity() + }) + } + retireStaleForegroundIdentity() + return + } + cachedAgentForeground = { processName, refreshedAt: Date.now() } + startupAgentForeground = null + return processName + }) + .catch(() => { + // Best-effort only: foreground enrichment must never affect PTY health. + }) + .finally(() => { + foregroundRefreshInFlight = false + }) + } + + return { + recordOutput: (data) => { + if (data.length > 0) { + lastOutputAt = Date.now() + } + }, + markDead: () => { + cachedAgentForeground = null + startupAgentForeground = null + }, + getForegroundProcess: () => { + if (args.isDead()) { + return null + } + try { + const fallbackProcess = getFallbackProcess() + const fallbackRecognition = recognizeAgentProcess(fallbackProcess) + const inspectOuterWrapper = + fallbackRecognition !== null && + shouldInspectOuterWrapperForegroundProcess(fallbackRecognition) + if (fallbackProcess && fallbackRecognition && !inspectOuterWrapper) { + cachedAgentForeground = { processName: fallbackProcess, refreshedAt: Date.now() } + startupAgentForeground = null + return fallbackProcess + } + scheduleRefresh(fallbackProcess) + const now = Date.now() + if ( + cachedAgentForeground && + now - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS + ) { + return cachedAgentForeground.processName + } + if ( + cachedAgentForeground && + fallbackProcess !== null && + (isAgentForegroundWrapperProcess(fallbackProcess) || + inspectOuterWrapper || + (process.platform === 'win32' && isShellProcess(fallbackProcess))) + ) { + return cachedAgentForeground.processName + } + const activeStartupAgentForeground = getActiveStartupAgent(now) + if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) { + return activeStartupAgentForeground.processName + } + return fallbackProcess + } catch { + return null + } + }, + confirmForegroundProcess: async () => { + if (args.isDead() || !proc.pid) { + return null + } + try { + const fallbackProcess = getFallbackProcess() + const fallbackRecognition = recognizeAgentProcess(fallbackProcess) + if ( + !fallbackProcess || + (fallbackRecognition !== null && + process.platform !== 'win32' && + !shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) || + (process.platform !== 'win32' && !shouldInspectFallback(fallbackProcess)) + ) { + return fallbackProcess + } + const resolution = await resolveAgentForegroundProcessWithAvailability( + proc.pid, + fallbackProcess, + { + contextPaths, + fresh: true, + ...(process.platform === 'win32' + ? { + forceProcessScan: true, + readWindowsConptyProcessIds: () => readWindowsConptyProcessIds(proc.pid) + } + : {}) + } + ) + if (args.isDead() || !resolution.available) { + return null + } + const recognized = recognizeAgentProcess(resolution.processName) + if (recognized) { + cachedAgentForeground = { + processName: recognized.processName, + refreshedAt: Date.now() + } + startupAgentForeground = null + return recognized.processName + } + cachedAgentForeground = null + startupAgentForeground = null + return resolution.processName + } catch { + return null + } + } + } +} diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn.ts b/src/main/daemon/pty-subprocess/native-pty-spawn.ts new file mode 100644 index 00000000000..e0332ba9921 --- /dev/null +++ b/src/main/daemon/pty-subprocess/native-pty-spawn.ts @@ -0,0 +1,82 @@ +import * as pty from 'node-pty' +import { + hostReportsChildExitStatus, + wrapShellSpawnForMacosTccAttribution +} from '../../providers/macos-tcc-login-shell' +import type { WindowsShellSpawnAttempt } from '../../providers/windows-shell-fallback-chain' +import { assignHostProcessToKillOnCloseJob } from '../../windows/windows-pty-job' + +export type SpawnedDaemonPty = { + process: pty.IPty + shellPath: string + spawnCwd: string + startupCommandDeliveredInShellArgs?: boolean + /** False when a wrapper owns the reported status, so no exit code may be read from it. */ + reportsChildExitStatus: boolean +} + +/** Walks the Windows PowerShell -> cmd.exe fallback chain when ConPTY rejects the primary shell. */ +export function spawnNativeDaemonPty(args: { + shellPath: string + shellArgs: string[] + spawnCwd: string + env: Record + cols: number + rows: number + windowsFallbackAttempts: WindowsShellSpawnAttempt[] + onMacosTccSpawnStrategy?: (strategy: 'wrapped' | 'direct') => void +}): SpawnedDaemonPty { + let reportsChildExitStatus = true + const spawnAt = (shellPath: string, shellArgs: string[], cwd: string): pty.IPty => { + const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env) + // Why: children inherit job membership, so the host job must exist before the first Windows PTY. + if (process.platform === 'win32') { + assignHostProcessToKillOnCloseJob() + } + const proc = pty.spawn(wrapped.file, wrapped.args, { + name: args.env.TERM ?? 'xterm-256color', + cols: args.cols, + rows: args.rows, + cwd, + env: args.env, + // Why: bundled ConPTY has the wrap-marker behavior xterm expects. + ...(process.platform === 'win32' ? { useConptyDll: true } : {}) + }) + reportsChildExitStatus = hostReportsChildExitStatus(wrapped.file) + args.onMacosTccSpawnStrategy?.(wrapped.file === shellPath ? 'direct' : 'wrapped') + return proc + } + + try { + const process_ = spawnAt(args.shellPath, args.shellArgs, args.spawnCwd) + return { + process: process_, + shellPath: args.shellPath, + spawnCwd: args.spawnCwd, + reportsChildExitStatus + } + } catch (primaryErr) { + if (process.platform !== 'win32') { + throw primaryErr + } + for (const attempt of args.windowsFallbackAttempts.slice(1)) { + try { + const process = spawnAt(attempt.shellPath, attempt.shellArgs, attempt.effectiveCwd) + const message = primaryErr instanceof Error ? primaryErr.message : String(primaryErr) + console.warn( + `[daemon/pty] Primary shell "${args.shellPath}" failed (${message}), fell back to "${attempt.shellPath}"` + ) + return { + process, + shellPath: attempt.shellPath, + spawnCwd: attempt.effectiveCwd, + startupCommandDeliveredInShellArgs: attempt.startupCommandDeliveredInShellArgs, + reportsChildExitStatus + } + } catch { + // This fallback shell also failed -- try the next link in the chain. + } + } + throw primaryErr + } +} diff --git a/src/main/daemon/pty-subprocess/pre-listener-events.ts b/src/main/daemon/pty-subprocess/pre-listener-events.ts new file mode 100644 index 00000000000..f7eb7216e58 --- /dev/null +++ b/src/main/daemon/pty-subprocess/pre-listener-events.ts @@ -0,0 +1,90 @@ +import { + resolveProcessExitCause, + type TerminalExitCause +} from '../../../shared/terminal-exit-cause' + +const PENDING_PRE_LISTENER_DATA_MAX_CHARS = 512 * 1024 + +type DataListener = (data: string) => void +type ExitListener = (code: number, cause?: TerminalExitCause) => void + +/** Preserves spawn-time output and exit ordering until Session installs its listeners. */ +export class PtyPreListenerEvents { + private onDataCb: DataListener | null = null + private onExitCb: ExitListener | null = null + private pendingData: string[] = [] + private pendingDataChars = 0 + private pendingExitCode: number | null = null + private pendingExitCause: TerminalExitCause | null = null + + acceptData(data: string): void { + if (this.onDataCb) { + this.onDataCb(data) + return + } + this.pendingData.push(data) + this.pendingDataChars += data.length + while (this.pendingDataChars > PENDING_PRE_LISTENER_DATA_MAX_CHARS) { + const removed = this.pendingData.shift() + if (removed === undefined) { + this.pendingDataChars = 0 + return + } + this.pendingDataChars -= removed.length + } + } + + acceptExit(args: { + exitCode: number + signal?: number + hostReportsChildExitStatus: boolean + }): void { + const cause = resolveProcessExitCause(args) + if (this.onExitCb) { + this.flushData() + this.onExitCb(args.exitCode, cause) + } else { + this.pendingExitCode = args.exitCode + this.pendingExitCause = cause + } + } + + onData(cb: DataListener): void { + this.onDataCb = cb + this.flushData() + } + + onExit(cb: ExitListener): void { + this.onExitCb = cb + if (this.pendingExitCode === null) { + return + } + const code = this.pendingExitCode + const cause = this.pendingExitCause ?? resolveProcessExitCause({ exitCode: code }) + this.pendingExitCode = null + this.pendingExitCause = null + this.flushData() + cb(code, cause) + } + + clear(): void { + this.onDataCb = null + this.onExitCb = null + this.pendingData = [] + this.pendingDataChars = 0 + this.pendingExitCode = null + this.pendingExitCause = null + } + + private flushData(): void { + if (!this.onDataCb || this.pendingData.length === 0) { + return + } + const pending = this.pendingData + this.pendingData = [] + this.pendingDataChars = 0 + for (const data of pending) { + this.onDataCb(data) + } + } +} diff --git a/src/main/daemon/pty-subprocess/shell-launch-plan.ts b/src/main/daemon/pty-subprocess/shell-launch-plan.ts new file mode 100644 index 00000000000..ed8cd5e6a22 --- /dev/null +++ b/src/main/daemon/pty-subprocess/shell-launch-plan.ts @@ -0,0 +1,234 @@ +import { win32 as pathWin32 } from 'node:path' +import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../../git-bash' +import { isPwshAvailable } from '../../pwsh' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../../pty/codex-home-wsl-env' +import { addOrcaWslInteropEnv } from '../../pty/wsl-orca-env' +import { + POWERLEVEL10K_WIZARD_DISABLE_ENV, + seedPowerlevel10kWizardEnv +} from '../../pty/powerlevel10k-wizard-env' +import { + resolveSafePtyDefaultCwd, + assertSafeAgentStartupCwd +} from '../../providers/pty-default-cwd' +import { + resolveEffectiveWindowsPowerShell, + shouldProbeWindowsPowerShellAvailability, + type WindowsPowerShellShellFamily +} from '../../providers/windows-powershell' +import { + buildWindowsPowerShellSpawnAttempts, + type WindowsShellSpawnAttempt +} from '../../providers/windows-shell-fallback-chain' +import { + ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV, + resolveWindowsShellLaunchArgs +} from '../../providers/windows-shell-args' +import { resolveUnixShellPath } from '../../providers/local-pty-utils' +import { selectShellStartupFeatures } from '../../shell-startup-features' +import { parseWslPath } from '../../wsl' +import { addWslEnvKeys } from '../../wsl-env' +import { + recognizeAgentProcessFromCommandLine, + type RecognizedAgentProcess +} from '../../../shared/agent-process-recognition' +import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery' +import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../../shared/hermes-startup-query' +import { WINDOWS_GIT_BASH_SHELL } from '../../../shared/windows-terminal-shell' +import { getShellLaunchConfig, resolvePtyShellPath } from '../shell-ready' +import { resolveWslSessionContext } from '../wsl-session-context' +import { finalizeDaemonPtyEnvironment, rescrubDaemonPtyEnvironment } from './spawn-environment' +import type { PtySubprocessOptions } from '../pty-subprocess' + +export type PtyShellLaunchPlan = { + shellPath: string + shellArgs: string[] + spawnCwd: string + validationCwd: string + startupCommandDeliveredInShellArgs: boolean + windowsFallbackAttempts: WindowsShellSpawnAttempt[] + startupAgentRecognition: RecognizedAgentProcess | null +} + +export function createPtyShellLaunchPlan( + opts: PtySubprocessOptions, + env: Record +): PtyShellLaunchPlan { + const resolvedWslContext = resolveWslSessionContext(opts) + let shellPath = resolvedWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) + let shellArgs: string[] + let startupCommandDeliveredInShellArgs = false + let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = [] + const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command) + const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex' + const requestedCwd = opts.cwd || resolveSafePtyDefaultCwd() + if (opts.command && startupAgentRecognition) { + assertSafeAgentStartupCwd(requestedCwd, opts.command) + } + let spawnCwd = requestedCwd + let validationCwd = spawnCwd + + if (process.platform === 'win32') { + const normalizedShellFamily = pathWin32.basename(shellPath).toLowerCase() + const resolvedGitBashPath = resolveWindowsGitBashShellPath(shellPath) + const resolvedShellFamily: WindowsPowerShellShellFamily = + normalizedShellFamily === 'powershell.exe' || normalizedShellFamily === 'pwsh.exe' + ? normalizedShellFamily + : normalizedShellFamily === 'cmd.exe' || normalizedShellFamily === 'wsl.exe' + ? normalizedShellFamily + : undefined + const shouldProbePwsh = shouldProbeWindowsPowerShellAvailability({ + shellFamily: resolvedShellFamily, + implementation: opts.terminalWindowsPowerShellImplementation + }) + const shouldResolvePowerShellFamily = + opts.terminalWindowsPowerShellImplementation !== undefined || + pathWin32.basename(shellPath) === shellPath + if (resolvedGitBashPath) { + shellPath = resolvedGitBashPath + } else if (shellPath === WINDOWS_GIT_BASH_SHELL) { + shellPath = 'powershell.exe' + } else { + shellPath = shouldResolvePowerShellFamily + ? (resolveEffectiveWindowsPowerShell({ + shellFamily: resolvedShellFamily, + implementation: opts.terminalWindowsPowerShellImplementation, + pwshAvailable: shouldProbePwsh ? isPwshAvailable() : false + }) ?? shellPath) + : shellPath + } + if ( + pathWin32.basename(shellPath).toLowerCase() === 'cmd.exe' && + env.ORCA_CODEX_LAUNCH_PREFLIGHT + ) { + env[ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV] = '"' + } + windowsFallbackAttempts = buildWindowsPowerShellSpawnAttempts({ + shellPath, + cwd: spawnCwd, + defaultCwd: resolveSafePtyDefaultCwd(), + wslContext: resolvedWslContext, + startupCommand: opts.command + }) + const primaryAttempt = windowsFallbackAttempts[0] + if (primaryAttempt) { + shellPath = primaryAttempt.shellPath + shellArgs = primaryAttempt.shellArgs + spawnCwd = primaryAttempt.effectiveCwd + validationCwd = primaryAttempt.validationCwd + startupCommandDeliveredInShellArgs = primaryAttempt.startupCommandDeliveredInShellArgs + } else { + const resolved = resolveWindowsShellLaunchArgs( + shellPath, + spawnCwd, + resolveSafePtyDefaultCwd(), + resolvedWslContext, + opts.command, + env.ORCA_CODEX_LAUNCH_PREFLIGHT + ) + shellArgs = resolved.shellArgs + spawnCwd = resolved.effectiveCwd + validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true + } + if (isWindowsGitBashShellPath(shellPath)) { + env.CHERE_INVOKING ??= '1' + } + const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null + if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { + if (codexHomeWslInfo) { + const launchWslDistro = resolvedWslContext?.distro + if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) { + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } else { + env.CODEX_HOME = codexHomeWslInfo.linuxPath + env.ORCA_CODEX_HOME = codexHomeWslInfo.linuxPath + addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + if (!launchWslDistro) { + const resolved = resolveWindowsShellLaunchArgs( + shellPath, + requestedCwd, + resolveSafePtyDefaultCwd(), + { distro: codexHomeWslInfo.distro }, + opts.command, + env.ORCA_CODEX_LAUNCH_PREFLIGHT + ) + shellArgs = resolved.shellArgs + spawnCwd = resolved.effectiveCwd + validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = + resolved.startupCommandDeliveredInShellArgs === true + } + } + } else if (isHostCodexHomeForWsl(env.CODEX_HOME)) { + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } else if (env.CODEX_HOME) { + addWslEnvKeys(env, ['CODEX_HOME', 'ORCA_CODEX_HOME']) + } + if (env.CLAUDE_CONFIG_DIR) { + addWslEnvKeys(env, ['CLAUDE_CONFIG_DIR']) + } + if (env[ORCA_HERMES_STARTUP_QUERY_ENV] !== undefined) { + addWslEnvKeys(env, [ORCA_HERMES_STARTUP_QUERY_ENV]) + } + } else if (codexHomeWslInfo || isWslCodexHomeForHost(env.CODEX_HOME)) { + delete env.CODEX_HOME + delete env.ORCA_CODEX_HOME + } + if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { + addOrcaWslInteropEnv(env) + } + } else { + rescrubDaemonPtyEnvironment(env, opts) + const preferredShellPath = shellPath + shellPath = resolveUnixShellPath(shellPath) + if (shellPath !== preferredShellPath) { + env.SHELL = shellPath + console.warn( + `[daemon/pty] Preferred shell "${preferredShellPath}" is unavailable, fell back to "${shellPath}"` + ) + } + const waitsForShellReady = + Boolean(opts.command) && + (!isCodexStartupCommand || + shouldUseShellReadyStartupDelivery({ + command: opts.command as string, + startupCommandDelivery: opts.startupCommandDelivery + })) + delete env.ORCA_SHELL_FEATURES + const shellLaunch = getShellLaunchConfig( + shellPath, + selectShellStartupFeatures({ + shellPath, + env, + hasStartupCommand: Boolean(opts.command), + waitsForShellReady, + emitsStartupIdentity: waitsForShellReady + }) + ) + Object.assign(env, shellLaunch.env) + shellArgs = shellLaunch.args ?? ['-l'] + } + + seedPowerlevel10kWizardEnv(env, { envToDelete: opts.envToDelete }) + if ( + env[POWERLEVEL10K_WIZARD_DISABLE_ENV] !== undefined && + process.platform === 'win32' && + pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' + ) { + addWslEnvKeys(env, [POWERLEVEL10K_WIZARD_DISABLE_ENV]) + } + finalizeDaemonPtyEnvironment(env, opts.env) + + return { + shellPath, + shellArgs, + spawnCwd, + validationCwd, + startupCommandDeliveredInShellArgs, + windowsFallbackAttempts, + startupAgentRecognition + } +} diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts new file mode 100644 index 00000000000..cbace940f46 --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -0,0 +1,189 @@ +import { delimiter } from 'node:path' +import { dropInheritedOrcaFishHistory } from '../../fish-history-session' +import { removeAppImageRuntimeEnv } from '../../pty/appimage-terminal-env' +import { stripInheritedBuildModeEnv } from '../../pty/build-mode-env' +import { dropIncoherentCondaActivationEnv } from '../../pty/conda-activation-env' +import { stripLegacyTerminalShimEnv } from '../../pty/legacy-terminal-shim-dir' +import { removeInheritedNoColor } from '../../pty/terminal-color-env' +import { resolvePathEnvKey } from '../../pty/windows-environment-path' +import { dropInheritedOrcaHistFile } from '../../worktree-history-file-path' +import { + gitCredentialPromptGuardEnv, + mergeGitConfigEnvProtocol +} from '../../../shared/git-credential-prompt-env' +import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../../shared/terminal-git-credential-guard' +import { + expandWindowsEnvironmentVariables, + expandWindowsPathEnvironmentVariables +} from '../../../shared/windows-environment-expansion' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { PtySubprocessOptions } from '../pty-subprocess' + +const PANE_IDENTITY_ENV_KEYS = [ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_LAUNCH_TOKEN' +] as const +const WINDOWS_PATH_ENV_KEY_RE = /^path$/i + +function composeGuardedDaemonGitConfigEnv( + env: Record, + explicitEnv: Record | undefined, + launchAgent: TuiAgent | undefined +): void { + const policy = explicitEnv?.[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV] + delete env[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV] + if (policy !== 'guard' && launchAgent === undefined) { + return + } + // Why: the daemon can outlive Electron, so its process.env is the authoritative inherited config; append only the guard. + Object.assign(env, gitCredentialPromptGuardEnv(env, process.platform)) +} + +function deleteRequestedDaemonEnvKeys( + env: Record, + keys: readonly string[] | undefined +): void { + // Why: persistent daemon state can differ from Electron; delete CODEX_HOME only when its Orca overlay owns it. + const deleteOrcaOwnedCodexHome = + keys?.includes('ORCA_CODEX_HOME') === true && + env.ORCA_CODEX_HOME !== undefined && + env.CODEX_HOME === env.ORCA_CODEX_HOME + for (const key of keys ?? []) { + delete env[key] + } + if (deleteOrcaOwnedCodexHome) { + delete env.CODEX_HOME + } +} + +function removeUnspecifiedPaneIdentityEnv( + env: Record, + explicitEnv: Record | undefined +): void { + for (const key of PANE_IDENTITY_ENV_KEYS) { + if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) { + delete env[key] + } + } +} + +/** Removes the second PATH key only when the daemon's env merge created it. */ +function collapseWindowsPathEnvKeys( + env: Record, + requestedEnv: Record | undefined +): void { + if (process.platform !== 'win32') { + return + } + const pathKeys = Object.keys(env).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key)) + if (pathKeys.length < 2) { + return + } + // Why: a one-key main patch is authoritative; zero or two keys came from inherited state. + const requestedKeys = requestedEnv + ? Object.keys(requestedEnv).filter((key) => WINDOWS_PATH_ENV_KEY_RE.test(key)) + : [] + if (requestedKeys.length !== 1) { + return + } + const survivingKey = requestedKeys[0] + if (!survivingKey || env[survivingKey] === undefined) { + return + } + for (const key of pathKeys) { + if (key !== survivingKey) { + delete env[key] + } + } +} + +/** Promotes the agent-teams shim path ahead of inherited PATH entries. */ +function promoteAgentTeamsShimPath( + env: Record, + requestedPath: string | undefined +): void { + if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) { + return + } + const normalizedRequestedPath = + process.platform === 'win32' + ? expandWindowsEnvironmentVariables(requestedPath, env) + : requestedPath + const pathDelimiter = process.platform === 'win32' ? ';' : delimiter + const shimDir = normalizedRequestedPath.split(pathDelimiter)[0] + if (!shimDir) { + return + } + const pathKey = resolvePathEnvKey(env, process.platform) + const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? [] + env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter) +} + +function removeInheritedDevAgentHookEndpoint( + env: Record, + explicitEnv: Record | undefined +): void { + if (explicitEnv?.ORCA_AGENT_HOOK_ENV === 'development' && !explicitEnv.ORCA_AGENT_HOOK_ENDPOINT) { + // Why: strip only stale inherited endpoints; a fresh explicit one is needed by hooks that scrub token-like env vars before exec. + delete env.ORCA_AGENT_HOOK_ENDPOINT + } +} + +export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record { + const env: Record = { + ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), opts.env), + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + TERM_PROGRAM: 'Orca', + TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev', + FORCE_HYPERLINK: '1' + } as Record + stripLegacyTerminalShimEnv(env, process.platform) + composeGuardedDaemonGitConfigEnv(env, opts.env, opts.launchAgent) + deleteRequestedDaemonEnvKeys(env, opts.envToDelete) + if (opts.env?.TERM) { + env.TERM = opts.env.TERM + } + removeUnspecifiedPaneIdentityEnv(env, opts.env) + if (opts.env?.fish_history === undefined) { + dropInheritedOrcaFishHistory(env) + } + if (opts.env?.HISTFILE === undefined) { + dropInheritedOrcaHistFile(env) + } + if (opts.env?.ORCA_HISTFILE === undefined) { + delete env.ORCA_HISTFILE + } + removeInheritedDevAgentHookEndpoint(env, opts.env) + delete env.ELECTRON_RUN_AS_NODE + removeAppImageRuntimeEnv(env) + removeInheritedNoColor(env) + env.LANG ??= 'en_US.UTF-8' + return env +} + +export function rescrubDaemonPtyEnvironment( + env: Record, + opts: PtySubprocessOptions +): void { + deleteRequestedDaemonEnvKeys(env, opts.envToDelete) + if (opts.env?.TERM) { + env.TERM = opts.env.TERM + } +} + +export function finalizeDaemonPtyEnvironment( + env: Record, + requestedEnv: Record | undefined +): void { + expandWindowsPathEnvironmentVariables(env) + collapseWindowsPathEnvKeys(env, requestedEnv) + const requestedPath = requestedEnv + ? requestedEnv[resolvePathEnvKey(requestedEnv, process.platform)] + : undefined + promoteAgentTeamsShimPath(env, requestedPath) + stripLegacyTerminalShimEnv(env, process.platform) + dropIncoherentCondaActivationEnv(env, process.platform) +} diff --git a/src/main/daemon/pty-subprocess/spawn-preflight.ts b/src/main/daemon/pty-subprocess/spawn-preflight.ts new file mode 100644 index 00000000000..facaf0a69bd --- /dev/null +++ b/src/main/daemon/pty-subprocess/spawn-preflight.ts @@ -0,0 +1,221 @@ +import * as pty from 'node-pty' +import { statSync } from 'node:fs' +import { release } from 'node:os' +import { + ensureNodePtySpawnHelperExecutable, + getNodePtySpawnHelperCandidates, + validateWorkingDirectoryAsync, + WorkingDirectoryValidationAbortedError +} from '../../providers/local-pty-utils' +import { resolveSafePtyDefaultCwd } from '../../providers/pty-default-cwd' +import { TerminalAttachCanceledError } from '../daemon-errors' +import { DaemonProtocolError } from '../types' + +const PTY_SPAWN_HEALTH_TIMEOUT_MS = 4_000 + +function daemonEnvironmentDiagSuffix(): string { + const orca = process.env.ORCA_APP_VERSION?.trim() || '0.0.0-dev' + const systemVersion = + (process as NodeJS.Process & { getSystemVersion?: () => string }).getSystemVersion?.() || + release() + return ` (orca: ${orca}, arch: ${process.arch}, platform: ${process.platform} ${systemVersion})` +} + +function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError { + const detailName = kind === 'helper' ? 'helper' : 'cwd' + const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd' + const missingTarget = kind === 'helper' ? 'node-pty install' : 'working directory' + return new DaemonProtocolError( + `Daemon's ${missingTarget} is gone (worktree deleted?). Restart Orca. node-pty: ${step} failed: ENOENT (errno 2, No such file or directory) - ${detailName}='${path}'${daemonEnvironmentDiagSuffix()}` + ) +} + +function isExistingDirectory(path: string | undefined): path is string { + if (!path) { + return false + } + try { + return statSync(path).isDirectory() + } catch { + return false + } +} + +function repairDaemonCwd(): string | null { + const candidates = [process.env.ORCA_USER_DATA_PATH] + try { + candidates.push(resolveSafePtyDefaultCwd()) + } catch { + // Keep daemon cwd repair best-effort even when no user terminal cwd is safe. + } + candidates.push(process.platform === 'win32' ? 'C:\\' : '/') + for (const candidate of candidates) { + if (isExistingDirectory(candidate)) { + try { + process.chdir(candidate) + return candidate + } catch { + // Try the next stable cwd candidate. + } + } + } + return null +} + +function preflightDaemonCwd(): void { + let daemonCwd = '' + try { + daemonCwd = process.cwd() + if (isExistingDirectory(daemonCwd)) { + return + } + } catch { + // Recover below; process.cwd() throws after the original cwd is deleted. + } + if (repairDaemonCwd()) { + return + } + throw formatMissingDaemonPathError('cwd', daemonCwd) +} + +function preflightMacNodePtySpawnEnvironment(): void { + if (process.platform !== 'darwin') { + return + } + let candidates: string[] + try { + candidates = getNodePtySpawnHelperCandidates() + } catch { + throw formatMissingDaemonPathError('helper', '') + } + for (const candidate of candidates) { + try { + if (statSync(candidate).isFile()) { + return + } + } catch { + // Try the next node-pty native location. + } + } + throw formatMissingDaemonPathError('helper', candidates[0] ?? '') +} + +function preflightUnixPtySpawnEnvironment(): void { + if (process.platform === 'win32') { + return + } + // Why: detached daemons can outlive their launch cwd; repair before every spawn. + preflightDaemonCwd() + preflightMacNodePtySpawnEnvironment() +} + +function isNativeWindowsPath(path: string): boolean { + return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\') +} + +export async function preflightPtySpawn(args: { + validationCwd: string + cwdWasExplicit: boolean + sessionId: string + signal?: AbortSignal +}): Promise { + ensureNodePtySpawnHelperExecutable() + preflightUnixPtySpawnEnvironment() + try { + if (process.platform === 'win32') { + if (args.cwdWasExplicit && isNativeWindowsPath(args.validationCwd)) { + await validateWorkingDirectoryAsync( + args.validationCwd, + args.signal ? { signal: args.signal } : {} + ) + } + } else { + await validateWorkingDirectoryAsync( + args.validationCwd, + args.signal ? { signal: args.signal } : {} + ) + } + } catch (error) { + if (error instanceof WorkingDirectoryValidationAbortedError) { + throw new TerminalAttachCanceledError(args.sessionId) + } + throw error + } +} + +export function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): Error { + const message = err instanceof Error ? err.message : String(err) + const formatted = new DaemonProtocolError( + `Daemon failed to spawn shell "${shellPath}" with cwd "${spawnCwd}": ${message}${daemonEnvironmentDiagSuffix()}` + ) + if (err instanceof Error && err.stack) { + formatted.stack = err.stack + } + return formatted +} + +export function runPtySpawnHealthProbe(): Promise { + const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH) + ? process.env.ORCA_USER_DATA_PATH + : resolveSafePtyDefaultCwd() + let proc: pty.IPty + try { + proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], { + name: 'xterm-256color', + cols: 2, + rows: 1, + cwd, + env: { ...process.env, TERM: 'xterm-256color' } + }) + } catch (err) { + throw formatPtySpawnError(err, '/bin/sh', cwd) + } + + return new Promise((resolve, reject) => { + let settled = false + let exitDisposable: { dispose(): void } | undefined + const finish = (error?: Error, opts?: { kill?: boolean }): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + exitDisposable?.dispose() + if (opts?.kill) { + try { + proc.kill() + } catch { + // Best-effort cleanup for a short-lived health probe. + } + } + if (error) { + reject(error) + } else { + resolve() + } + } + const timer = setTimeout(() => { + finish(new Error(`PTY spawn health check timed out after ${PTY_SPAWN_HEALTH_TIMEOUT_MS}ms`), { + kill: true + }) + }, PTY_SPAWN_HEALTH_TIMEOUT_MS) + exitDisposable = proc.onExit(({ exitCode }) => { + if (exitCode === 0) { + finish() + } else { + finish(new Error(`PTY spawn health check exited with code ${exitCode}`)) + } + }) + }) +} + +export function preflightPtySpawnHealth(): boolean { + if (process.platform === 'win32') { + return false + } + if (process.platform === 'darwin') { + ensureNodePtySpawnHelperExecutable() + } + preflightUnixPtySpawnEnvironment() + return true +} diff --git a/src/main/daemon/pty-subprocess/subprocess-handle.ts b/src/main/daemon/pty-subprocess/subprocess-handle.ts new file mode 100644 index 00000000000..a56d0fc3ae2 --- /dev/null +++ b/src/main/daemon/pty-subprocess/subprocess-handle.ts @@ -0,0 +1,202 @@ +import type * as pty from 'node-pty' +import type { RecognizedAgentProcess } from '../../../shared/agent-process-recognition' +import { readPtySlavePath } from '../../../shared/pty-slave-line-discipline-echo' +import { forceKillPosixPtyProcessGroups } from '../../pty/posix-pty-process-groups' +import { signalPosixPtyForegroundGroup } from '../../pty/posix-pty-foreground-group' +import { readPtsName } from '../../pty/node-pty-pts-name' +import { terminatePtyJob } from '../../windows/windows-pty-job' +import { isValidPtySize } from '../daemon-pty-size' +import type { SubprocessHandle } from '../session-subprocess-handle' +import { createPtyForegroundProcessTracker } from './foreground-process-tracker' +import { PtyPreListenerEvents } from './pre-listener-events' + +type DisposableNativePty = pty.IPty & { destroy?: () => void } + +export function createDaemonPtySubprocessHandle(args: { + process: pty.IPty + shellPath: string + spawnCwd: string + env: Record + startupCommandDeliveredInShellArgs: boolean + reportsChildExitStatus: boolean + requestedCwd?: string + sessionId: string + startupAgentRecognition: RecognizedAgentProcess | null +}): SubprocessHandle { + const proc = args.process + // node-pty exposes destroy at runtime but omits it from IPty. + const nativeProc = proc as DisposableNativePty + const events = new PtyPreListenerEvents() + let dead = false + let disposed = false + let nodePtyKillIssued = false + const foreground = createPtyForegroundProcessTracker({ + process: proc, + shellPath: args.shellPath, + cwd: args.requestedCwd, + sessionId: args.sessionId, + startupAgentRecognition: args.startupAgentRecognition, + isDead: () => dead + }) + + proc.onData((data) => { + foreground.recordOutput(data) + events.acceptData(data) + }) + proc.onExit(({ exitCode, signal }) => { + events.acceptExit({ + exitCode, + signal, + hostReportsChildExitStatus: args.reportsChildExitStatus + }) + }) + proc.onExit(() => { + dead = true + foreground.markDead() + // Why: neutralize kill synchronously so a later async socket-close SIGHUP cannot hit a recycled pid. + if (process.platform !== 'win32') { + nativeProc.kill = () => {} + } + }) + + const slavePath = readPtySlavePath(proc) + return { + pid: proc.pid, + shellPath: args.shellPath, + shellCwd: args.spawnCwd, + shellPathEnv: args.env.PATH, + ...(slavePath ? { slavePath } : {}), + ...(args.startupCommandDeliveredInShellArgs + ? { startupCommandDeliveredInShellArgs: true } + : {}), + getForegroundProcess: foreground.getForegroundProcess, + confirmForegroundProcess: foreground.confirmForegroundProcess, + write: (data) => { + if (dead) { + return + } + try { + proc.write(data) + } catch { + dead = true + } + }, + resize: (cols, rows) => { + if (dead || !isValidPtySize(cols, rows)) { + return + } + try { + proc.resize(cols, rows) + } catch { + dead = true + } + }, + // WindowsTerminal also wires _socket to the ConPTY conout pipe, so pausing backpressures the child. + pause: () => { + if (dead) { + return + } + try { + proc.pause() + } catch { + // Native handle already torn down; flow control is best-effort. + } + }, + resume: () => { + if (dead) { + return + } + try { + proc.resume() + } catch { + // Native handle already torn down; flow control is best-effort. + } + }, + clear: () => { + if (dead) { + return + } + try { + proc.clear() + } catch { + // A clear on a just-exited PTY is best-effort. + } + }, + kill: () => { + if (dead) { + return + } + nodePtyKillIssued = true + try { + proc.kill() + } catch (error) { + // A rejected native kill is not proof of exit; keep the wrapper live for a retry. + nodePtyKillIssued = false + throw error + } + }, + terminateOwnedTree: () => terminatePtyJob(proc), + forceKill: () => { + if (dead) { + return + } + // Escalate a ConPTY kill through the job without double-closing node-pty's shell handle. + if (process.platform === 'win32' && nodePtyKillIssued) { + terminatePtyJob(proc) + return + } + try { + forceKillPosixPtyProcessGroups(proc.pid, () => { + process.kill(proc.pid, 'SIGKILL') + }) + } catch (signalError) { + try { + proc.kill() + nodePtyKillIssued = true + } catch { + nodePtyKillIssued = false + throw signalError + } + } + }, + signal: (sig) => { + if (dead) { + return + } + const signalRootPid = (): void => { + try { + process.kill(proc.pid, sig) + } catch { + // Process may already be dead. + } + } + // SIGWINCH belongs to the tty foreground group; destructive signals keep the root-pid target. + if (sig === 'SIGWINCH') { + signalPosixPtyForegroundGroup(proc.pid, readPtsName(proc), sig, signalRootPid) + return + } + signalRootPid() + }, + onData: (cb) => events.onData(cb), + onExit: (cb) => events.onExit(cb), + dispose: () => { + if (disposed) { + return + } + disposed = true + dead = true + events.clear() + // POSIX destroy() can asynchronously signal a recycled pid; Windows needs kill() to close ConPTY. + if (process.platform !== 'win32') { + nativeProc.kill = () => {} + } else if (nodePtyKillIssued) { + return + } + try { + nativeProc.destroy?.() + } catch { + // Native handle was already torn down. + } + } + } +} diff --git a/src/main/git/repo-api-parity.test.ts b/src/main/git/repo-api-parity.test.ts new file mode 100644 index 00000000000..9143b7bf65f --- /dev/null +++ b/src/main/git/repo-api-parity.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import * as repo from './repo' + +describe('repo public API parity', () => { + it('keeps the historical runtime export surface', () => { + expect(Object.keys(repo).sort()).toEqual( + [ + 'DEFAULT_BASE_REF_PROBES', + 'buildSearchBaseRefsArgv', + 'getBaseRefDefault', + 'getBranchConflictKind', + 'getDefaultBaseRef', + 'getDefaultRemote', + 'getGitRepoRoot', + 'getLinkedWorktreeMainRepoRoot', + 'getRecentDriftSubjects', + 'getRemoteCommitUrl', + 'getRemoteCount', + 'getRemoteDrift', + 'getRemoteFileUrl', + 'getRemoteUrl', + 'getRepoName', + 'isForEachRefExcludeUnsupportedError', + 'isGitRepo', + 'mergeBaseRefSearchResultGroups', + 'normalizeGitRepoRootForInputPath', + 'normalizeRefSearchQuery', + 'parseAndFilterSearchRefDetails', + 'parseRemoteCount', + 'resolveDefaultBaseRefViaExec', + 'resolveDefaultBaseRefWithLocalGit', + 'searchBaseRefDetails', + 'searchBaseRefs' + ].sort() + ) + }) +}) diff --git a/src/main/git/repo-base-ref-search.ts b/src/main/git/repo-base-ref-search.ts new file mode 100644 index 00000000000..5ea01a7ec1c --- /dev/null +++ b/src/main/git/repo-base-ref-search.ts @@ -0,0 +1,240 @@ +import type { BaseRefSearchResult } from '../../shared/repo-types' +import { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' +import { getLocalGitCapabilityCache } from './git-capability-state' +import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-ref' +import { gitExecFileAsync } from './runner' + +const REF_SEARCH_CANDIDATE_MULTIPLIER = 4 +const REF_SEARCH_LEGACY_HEADROOM = 100 + +type RefSearchPatternGroup = 'all' | 'segmented' | 'branchRoot' + +function getRefSearchTokens(normalizedQuery: string): string[] { + return normalizedQuery.split('/').filter((token) => token.length > 0) +} + +function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): number { + if (!Number.isInteger(limit) || limit <= 0) { + throw new Error('invalid_limit') + } + const baseCount = limit * REF_SEARCH_CANDIDATE_MULTIPLIER + return excludesRemoteHead ? baseCount : baseCount + REF_SEARCH_LEGACY_HEADROOM +} + +/** Build the bounded `for-each-ref` argv shared by local and remote searches. */ +export function buildSearchBaseRefsArgv( + normalizedQuery: string, + limit: number, + options: { + excludeRemoteHead?: boolean + remoteNames?: readonly string[] + patternGroup?: RefSearchPatternGroup + } = {} +): string[] { + const excludeRemoteHead = options.excludeRemoteHead ?? true + const candidateCount = getRefSearchCandidateCount(limit, excludeRemoteHead) + const base = [ + 'for-each-ref', + '--format=%(refname)%00%(refname:short)', + '--sort=-committerdate', + ...(excludeRemoteHead ? ['--exclude=refs/remotes/**/HEAD'] : []), + `--count=${candidateCount}` + ] + const tokens = getRefSearchTokens(normalizedQuery) + if (tokens.length <= 1) { + const query = tokens[0] ?? '' + return [ + ...base, + `refs/heads/**/*${query}*`, + `refs/heads/**/*${query}*/**`, + `refs/remotes/**/*${query}*`, + `refs/remotes/**/*${query}*/**` + ] + } + + const segmented = tokens.map((token) => `*${token}*`).join('/') + const substringQuery = tokens.join('/') + const remoteBranchRootPatterns = + options.remoteNames && options.remoteNames.length > 0 + ? options.remoteNames.flatMap((remote) => [ + `refs/remotes/${remote}/${substringQuery}*`, + `refs/remotes/${remote}/${substringQuery}*/**` + ]) + : [`refs/remotes/*/${substringQuery}*`, `refs/remotes/*/${substringQuery}*/**`] + const segmentedPatterns = [`refs/remotes/${segmented}`, `refs/heads/${segmented}`] + const branchRootPatterns = [ + `refs/heads/${substringQuery}*`, + `refs/heads/${substringQuery}*/**`, + ...remoteBranchRootPatterns + ] + const patterns = + options.patternGroup === 'segmented' + ? segmentedPatterns + : options.patternGroup === 'branchRoot' + ? branchRootPatterns + : [...segmentedPatterns, ...branchRootPatterns] + return [...base, ...patterns] +} + +async function runSearchBaseRefsGit( + path: string, + normalizedQuery: string, + limit: number, + options: { remoteNames: readonly string[]; patternGroup?: RefSearchPatternGroup } +): Promise<{ stdout: string }> { + return getLocalGitCapabilityCache({ cwd: path }).runWithFallback( + 'for-each-ref-exclude', + () => + gitExecFileAsync( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + remoteNames: options.remoteNames, + patternGroup: options.patternGroup + }), + { cwd: path } + ), + () => + gitExecFileAsync( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + excludeRemoteHead: false, + remoteNames: options.remoteNames, + patternGroup: options.patternGroup + }), + { cwd: path } + ), + isForEachRefExcludeUnsupportedError + ) +} + +export function mergeBaseRefSearchResultGroups( + groups: readonly BaseRefSearchResult[][], + limit: number +): BaseRefSearchResult[] { + const seen = new Set() + const merged: BaseRefSearchResult[] = [] + const maxLength = Math.max(0, ...groups.map((group) => group.length)) + for (let index = 0; index < maxLength && merged.length < limit; index += 1) { + for (const group of groups) { + const entry = group[index] + if (!entry || seen.has(entry.refName)) { + continue + } + seen.add(entry.refName) + merged.push(entry) + if (merged.length >= limit) { + break + } + } + } + return merged +} + +export async function searchBaseRefs(path: string, query: string, limit = 25): Promise { + return (await searchBaseRefDetails(path, query, limit)).map((entry) => entry.refName) +} + +export async function searchBaseRefDetails( + path: string, + query: string, + limit = 25 +): Promise { + if (!Number.isInteger(limit) || limit <= 0) { + return [] + } + const normalizedQuery = normalizeRefSearchQuery(query) + + try { + const remotes = await listRemoteNames(path) + const tokens = getRefSearchTokens(normalizedQuery) + if (tokens.length > 1) { + const results = await Promise.all([ + runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes, + patternGroup: 'segmented' + }), + runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes, + patternGroup: 'branchRoot' + }) + ]) + return mergeBaseRefSearchResultGroups( + results.map((entry) => parseAndFilterSearchRefDetails(entry.stdout, limit, remotes)), + limit + ) + } + + const result = await runSearchBaseRefsGit(path, normalizedQuery, limit, { + remoteNames: remotes + }) + return parseAndFilterSearchRefDetails(result.stdout, limit, remotes) + } catch (err) { + console.warn('[searchBaseRefs] for-each-ref failed', { path, err }) + return [] + } +} + +export async function listRemoteNames( + path: string, + options: LocalGitExecOptions = {} +): Promise { + try { + const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options)) + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + } catch { + return [] + } +} + +export function parseAndFilterSearchRefDetails( + stdout: string, + limit: number, + remotes: string[] = [] +): BaseRefSearchResult[] { + const seen = new Set() + const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length) + return stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const nul = line.indexOf('\0') + return nul === -1 ? null : { full: line.slice(0, nul), short: line.slice(nul + 1) } + }) + .filter((entry): entry is { full: string; short: string } => entry !== null) + .filter(({ full }) => !/^refs\/remotes\/.+\/HEAD$/.test(full)) + .filter(({ short }) => { + if (seen.has(short)) { + return false + } + seen.add(short) + return true + }) + .map(({ full, short }) => ({ + refName: short, + localBranchName: resolveLocalBranchName(full, short, sortedRemotes) + })) + .slice(0, Math.max(0, limit)) +} + +export function resolveLocalBranchName( + fullRef: string, + shortRef: string, + remotes: string[] +): string { + const remoteRefPrefix = 'refs/remotes/' + if (!fullRef.startsWith(remoteRefPrefix)) { + return shortRef + } + const remoteAndBranch = fullRef.slice(remoteRefPrefix.length) + const remote = remotes.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`)) + if (remote) { + return remoteAndBranch.slice(remote.length + 1) + } + return remoteAndBranch.split('/').slice(1).join('/') || shortRef +} + +export function normalizeRefSearchQuery(query: string): string { + return query.trim().replace(/[*?[\]\\]/g, '') +} diff --git a/src/main/git/repo-branch-conflict.ts b/src/main/git/repo-branch-conflict.ts new file mode 100644 index 00000000000..dbadf254d11 --- /dev/null +++ b/src/main/git/repo-branch-conflict.ts @@ -0,0 +1,59 @@ +import { listRemoteNames, resolveLocalBranchName } from './repo-base-ref-search' +import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-ref' +import { gitExecFileAsync } from './runner' + +export type BranchConflictKind = 'local' | 'remote' + +async function hasGitRefAsync( + path: string, + ref: string, + options: LocalGitExecOptions = {} +): Promise { + try { + await gitExecFileAsync(['rev-parse', '--verify', ref], gitExecOptions(path, options)) + return true + } catch { + return false + } +} + +export async function getBranchConflictKind( + path: string, + branchName: string, + allowedBaseRef?: string, + options: LocalGitExecOptions = {} +): Promise { + if (await hasGitRefAsync(path, `refs/heads/${branchName}`, options)) { + return 'local' + } + + try { + const remoteNames = (await listRemoteNames(path, options)).sort((a, b) => b.length - a.length) + const { stdout } = await gitExecFileAsync( + ['for-each-ref', '--format=%(refname)', 'refs/remotes'], + gitExecOptions(path, options) + ) + const hasRemoteConflict = stdout.split('\n').some((ref) => { + const trimmed = ref.trim() + if (isAllowedRemoteBaseRef(trimmed, allowedBaseRef)) { + return false + } + const shortRef = trimmed.replace(/^refs\/remotes\//, '') + return resolveLocalBranchName(trimmed, shortRef, remoteNames) === branchName + }) + + return hasRemoteConflict ? 'remote' : null + } catch { + return null + } +} + +function isAllowedRemoteBaseRef(refName: string, allowedBaseRef: string | undefined): boolean { + if (!allowedBaseRef) { + return false + } + const normalizedAllowedRef = allowedBaseRef.startsWith('refs/remotes/') + ? allowedBaseRef + : `refs/remotes/${allowedBaseRef}` + return refName === normalizedAllowedRef +} diff --git a/src/main/git/repo-default-base-ref.ts b/src/main/git/repo-default-base-ref.ts new file mode 100644 index 00000000000..59adaeed512 --- /dev/null +++ b/src/main/git/repo-default-base-ref.ts @@ -0,0 +1,133 @@ +import { gitExecFileAsync, gitExecFileSync } from './runner' + +export type LocalGitExecOptions = { + wslDistro?: string +} + +export type LocalDefaultBaseRefGitOptions = { + cwd: string + wslDistro?: string +} + +export const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000 + +export function gitExecOptions( + cwd: string, + options: LocalGitExecOptions = {} +): { cwd: string; wslDistro?: string } { + return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } +} + +export const DEFAULT_BASE_REF_PROBES: readonly { ref: string; returnAs: string }[] = [ + { ref: 'refs/remotes/origin/main', returnAs: 'origin/main' }, + { ref: 'refs/remotes/origin/master', returnAs: 'origin/master' }, + { ref: 'refs/heads/main', returnAs: 'main' }, + { ref: 'refs/heads/master', returnAs: 'master' } +] + +async function resolveDefaultBaseRefFromProbes( + hasRef: (ref: string) => Promise +): Promise { + for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { + if (await hasRef(ref)) { + return returnAs + } + } + return null +} + +function hasGitRef(path: string, ref: string): boolean { + try { + gitExecFileSync(['rev-parse', '--verify', ref], { cwd: path }) + return true + } catch { + return false + } +} + +function gitRefToDefaultBaseRef(ref: string): string { + return ref.replace(/^refs\/remotes\//, '') +} + +function getVerifiedOriginHeadBaseRef(path: string): string | null { + try { + const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { + cwd: path + }).trim() + return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null + } catch { + return null + } +} + +/** Resolve the default base ref without inventing a fallback branch. */ +export function getDefaultBaseRef(path: string): string | null { + const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path) + if (originHeadBaseRef) { + return originHeadBaseRef + } + for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { + if (hasGitRef(path, ref)) { + return returnAs + } + } + return null +} + +export async function getBaseRefDefault( + path: string, + options: LocalGitExecOptions = {} +): Promise { + return getDefaultBaseRefAsync(path, options) +} + +export type GitExec = (argv: string[]) => Promise<{ stdout: string }> + +async function hasGitRefViaExec(exec: GitExec, ref: string): Promise { + try { + await exec(['rev-parse', '--verify', '--quiet', ref]) + return true + } catch { + return false + } +} + +async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise { + try { + const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) + const ref = stdout.trim() + if (!ref || !(await hasGitRefViaExec(exec, ref))) { + return null + } + return gitRefToDefaultBaseRef(ref) + } catch { + return null + } +} + +/** Resolve the same default-base ordering through a host-owned Git executor. */ +export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise { + const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec) + if (originHeadBaseRef) { + return originHeadBaseRef + } + return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref)) +} + +export function resolveDefaultBaseRefWithLocalGit( + options: LocalDefaultBaseRefGitOptions +): Promise { + return resolveDefaultBaseRefViaExec((argv) => + gitExecFileAsync(argv, { + ...options, + timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS + }) + ) +} + +export function getDefaultBaseRefAsync( + path: string, + options: LocalGitExecOptions = {} +): Promise { + return resolveDefaultBaseRefWithLocalGit(gitExecOptions(path, options)) +} diff --git a/src/main/git/repo-default-remote.test.ts b/src/main/git/repo-default-remote.test.ts new file mode 100644 index 00000000000..a0764598fb8 --- /dev/null +++ b/src/main/git/repo-default-remote.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileSync: vi.fn() +})) + +import { getDefaultRemote } from './repo' + +describe('getDefaultRemote', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('prefers the configured remote for the resolved default branch', async () => { + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'symbolic-ref') { + return { stdout: 'refs/remotes/origin/main\n' } + } + if (argv[0] === 'rev-parse') { + return { stdout: 'abc123\n' } + } + if (argv[0] === 'config') { + return { stdout: 'upstream\n' } + } + throw new Error('unexpected command') + }) + + await expect(getDefaultRemote('/repo')).resolves.toBe('upstream') + expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( + ['config', '--get', 'branch.main.remote'], + { cwd: '/repo' } + ) + }) + + it.each([ + ['origin\nupstream\n', 'origin'], + ['company\n', 'company'] + ])('falls back from unresolved defaults for remotes %j', async (stdout, expected) => { + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'remote') { + return { stdout } + } + throw new Error('missing ref') + }) + + await expect(getDefaultRemote('/repo')).resolves.toBe(expected) + }) + + it.each([ + ['', 'Repo has no configured git remotes.'], + [ + 'upstream\nfork\n', + 'Repo has multiple remotes (upstream, fork) and no default is configured. Set branch..remote.' + ] + ])('preserves the exact ambiguous-remote error for %j', async (stdout, message) => { + gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => { + if (argv[0] === 'remote') { + return { stdout } + } + throw new Error('missing ref') + }) + + await expect(getDefaultRemote('/repo')).rejects.toThrow(message) + }) + + it('normalizes a non-Error remote rejection', async () => { + for (let probe = 0; probe < 5; probe += 1) { + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('missing ref')) + } + gitExecFileAsyncMock.mockRejectedValueOnce('transport failed') + + await expect(getDefaultRemote('/repo')).rejects.toThrow( + 'Failed to resolve default remote for repo.' + ) + }) +}) diff --git a/src/main/git/repo-detection.ts b/src/main/git/repo-detection.ts new file mode 100644 index 00000000000..2f67005a4e7 --- /dev/null +++ b/src/main/git/repo-detection.ts @@ -0,0 +1,141 @@ +import { existsSync, statSync } from 'node:fs' +import { basename, dirname, resolve } from 'node:path' +import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' +import { scanGitMarkerSync, resolveRealPathSync } from './repo-git-marker-scan' +import { gitExecFileSync } from './runner' + +type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' + +let warnedMarkerFallbackThisSession = false + +/** Check if a path is a valid git repository (regular or bare). */ +export function isGitRepo(path: string): boolean { + try { + if (!existsSync(path) || !statSync(path).isDirectory()) { + return false + } + } catch { + return false + } + + const gitProbeResult = probeGitRepo(path) + if (gitProbeResult === 'repo') { + return true + } + if (gitProbeResult === 'not-repo') { + return false + } + + const markerScan = scanGitMarkerSync(path) + if (markerScan.status === 'valid' && !warnedMarkerFallbackThisSession) { + warnedMarkerFallbackThisSession = true + console.warn('[isGitRepo] git rev-parse could not confirm repo; accepted via .git marker', { + path + }) + } + return markerScan.status === 'valid' +} + +/** Only a clean pair of negative Git answers is a definitive non-repo. */ +function probeGitRepo(path: string): GitRepoProbeResult { + let sawFailure = false + + try { + const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { + cwd: path + }).trim() + if (insideWorkTree === 'true') { + return 'repo' + } + if (insideWorkTree !== 'false') { + return 'indeterminate' + } + } catch { + sawFailure = true + } + + try { + const bareRepo = gitExecFileSync(['rev-parse', '--is-bare-repository'], { + cwd: path + }).trim() + if (bareRepo === 'true') { + return 'repo' + } + if (bareRepo !== 'false') { + return 'indeterminate' + } + } catch { + sawFailure = true + } + + return sawFailure ? 'indeterminate' : 'not-repo' +} + +export function getGitRepoRoot(path: string): string { + try { + if (!existsSync(path) || !statSync(path).isDirectory()) { + return path + } + const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { + cwd: path + }).trim() + if (insideWorkTree === 'true') { + const root = gitExecFileSync(['rev-parse', '--show-toplevel'], { + cwd: path + }).trim() + return normalizeGitRepoRootForInputPath(path, root) + } + } catch { + // Fall through to preserving the original path. + } + const markerScan = scanGitMarkerSync(path) + if (markerScan.status === 'valid') { + return normalizeGitRepoRootForInputPath(path, markerScan.rootPath) + } + return path +} + +function canonicalizeGitDirPath(path: string): string { + return resolveRealPathSync(path) ?? path +} + +/** Return the main-checkout path only when `path` is a linked worktree. */ +export function getLinkedWorktreeMainRepoRoot(path: string): string | null { + try { + if (!existsSync(path) || !statSync(path).isDirectory()) { + return null + } + if (gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { cwd: path }).trim() !== 'true') { + return null + } + const [gitDir, commonDir] = gitExecFileSync(['rev-parse', '--git-dir', '--git-common-dir'], { + cwd: path + }) + .split('\n') + .map((line) => line.trim()) + if (!gitDir || !commonDir) { + return null + } + const absoluteCommonDir = canonicalizeGitDirPath(resolve(path, commonDir)) + if (canonicalizeGitDirPath(resolve(path, gitDir)) === absoluteCommonDir) { + return null + } + if (basename(absoluteCommonDir) !== '.git') { + return null + } + return getGitRepoRoot(dirname(absoluteCommonDir)) + } catch { + return null + } +} + +export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: string): string { + const inputWsl = parseWslUncPath(inputPath) + if (inputWsl && rootPath.startsWith('/')) { + // Why: persist the UNC root so later Git calls keep routing through the WSL runner. + return toWindowsWslPath(rootPath, inputWsl.distro) + } + return normalizeRuntimePathSeparators(rootPath) +} diff --git a/src/main/git/repo-git-marker-scan.ts b/src/main/git/repo-git-marker-scan.ts new file mode 100644 index 00000000000..5442d74d391 --- /dev/null +++ b/src/main/git/repo-git-marker-scan.ts @@ -0,0 +1,251 @@ +import { readFileSync, realpathSync, statSync } from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' + +export type GitMarkerScanResult = + | { status: 'valid'; rootPath: string } + | { status: 'absent' | 'invalid' } + +/** Filesystem fallback for genuine Git metadata when git cannot answer cleanly. */ +export function scanGitMarkerSync(path: string): GitMarkerScanResult { + const realPath = resolveRealPathSync(path) + if (realPath && realPath !== path) { + const lexicalScan = scanGitMarkerAncestorsSync(path) + const realPathScan = scanGitMarkerAncestorsSync(realPath) + if ( + lexicalScan.status === 'valid' && + realPathScan.status === 'valid' && + pathsReferToSameEntry(lexicalScan.rootPath, realPathScan.rootPath) + ) { + // Why: preserve lexical spellings, but let a cross-repo symlink bind to its real target. + return lexicalScan + } + return realPathScan + } + return scanGitMarkerAncestorsSync(path) +} + +export function resolveRealPathSync(path: string): string | null { + try { + return realpathSync.native(path) + } catch { + try { + return realpathSync(path) + } catch { + return null + } + } +} + +function scanGitMarkerAncestorsSync(path: string): GitMarkerScanResult { + for (const candidate of ancestorDirectories(path)) { + if (!isInsideDotGitMarker(candidate, path)) { + const worktreeMarker = scanWorktreeMarkerSync(candidate) + if (worktreeMarker.status !== 'absent') { + return worktreeMarker + } + } + if (hasValidBareRepoMarkerSync(candidate)) { + return { status: 'valid', rootPath: candidate } + } + } + return { status: 'absent' } +} + +function ancestorDirectories(path: string): string[] { + const directories: string[] = [] + let current = path + while (true) { + directories.push(current) + const parent = dirname(current) + if (parent === current) { + return directories + } + current = parent + } +} + +function isInsideDotGitMarker(rootPath: string, targetPath: string): boolean { + const relativePath = relative(rootPath, targetPath) + if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) { + return false + } + const firstSegment = relativePath.split(/[\\/]+/)[0] + if (firstSegment === '.git') { + return true + } + if (firstSegment.toLowerCase() !== '.git') { + return false + } + return pathsReferToSameEntry(join(rootPath, firstSegment), join(rootPath, '.git')) +} + +function pathsReferToSameEntry(leftPath: string, rightPath: string): boolean { + try { + const leftStat = statSync(leftPath) + const rightStat = statSync(rightPath) + if (leftStat.ino !== 0 && leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino) { + return true + } + const leftRealPath = normalizeRuntimePathSeparators(realpathSync.native(leftPath)) + const rightRealPath = normalizeRuntimePathSeparators(realpathSync.native(rightPath)) + return process.platform === 'win32' + ? leftRealPath.toLowerCase() === rightRealPath.toLowerCase() + : leftRealPath === rightRealPath + } catch { + return false + } +} + +function scanWorktreeMarkerSync(worktreePath: string): GitMarkerScanResult { + const dotGit = join(worktreePath, '.git') + let marker: ReturnType + try { + marker = statSync(dotGit) + } catch { + return { status: 'absent' } + } + + if (marker.isDirectory()) { + return hasValidGitDirectorySync(dotGit) + ? { status: 'valid', rootPath: worktreePath } + : { status: 'invalid' } + } + if (marker.isFile()) { + let gitDir: string | null + try { + gitDir = parseGitdirFile(worktreePath, readFileSync(dotGit, 'utf8')) + } catch { + return { status: 'invalid' } + } + return gitDir !== null && hasValidGitDirectorySync(gitDir) + ? { status: 'valid', rootPath: worktreePath } + : { status: 'invalid' } + } + return { status: 'invalid' } +} + +function parseGitdirFile(basePath: string, content: string): string | null { + const firstLine = content.split(/\r?\n/, 1)[0] ?? '' + const match = firstLine.match(/^gitdir:\s*(.+?)\s*$/i) + if (!match) { + return null + } + return resolveGitMetadataPath(basePath, match[1]) +} + +function resolveGitMetadataPath(basePath: string, rawPath: string): string | null { + const value = rawPath.trim() + if (!value) { + return null + } + const baseWsl = parseWslUncPath(basePath) + if (baseWsl && value.startsWith('/')) { + return toWindowsWslPath(value, baseWsl.distro) + } + return isAbsolute(value) ? value : resolve(basePath, value) +} + +function hasValidGitDirectorySync(gitDir: string): boolean { + return hasValidCommonGitDirectorySync(gitDir) || hasValidLinkedWorktreeGitDirectorySync(gitDir) +} + +function hasValidCommonGitDirectorySync(gitDir: string): boolean { + try { + return ( + statSync(join(gitDir, 'HEAD')).isFile() && + statSync(join(gitDir, 'objects')).isDirectory() && + statSync(join(gitDir, 'refs')).isDirectory() + ) + } catch { + return false + } +} + +function hasValidLinkedWorktreeGitDirectorySync(gitDir: string): boolean { + try { + if (!statSync(join(gitDir, 'HEAD')).isFile() || !statSync(join(gitDir, 'commondir')).isFile()) { + return false + } + const commonDir = resolveGitMetadataPath( + gitDir, + readFileSync(join(gitDir, 'commondir'), 'utf8') + ) + return commonDir !== null && hasValidCommonGitDirectorySync(commonDir) + } catch { + return false + } +} + +function hasValidBareRepoMarkerSync(path: string): boolean { + return hasValidCommonGitDirectorySync(path) && !gitConfigDeclaresNonBare(path) +} + +function gitConfigDeclaresNonBare(gitDir: string): boolean { + try { + const config = readFileSync(join(gitDir, 'config'), 'utf8') + let inCoreSection = false + for (const line of config.split(/\r?\n/)) { + const section = line.match(/^\s*\[([^\]]+)\]/) + if (section) { + inCoreSection = section[1].trim().toLowerCase() === 'core' + continue + } + const bare = line.match(/^\s*bare\s*=\s*(.*?)\s*$/i) + if (inCoreSection && bare) { + return isGitBooleanFalse(normalizeGitConfigValue(bare[1])) + } + } + return false + } catch { + return false + } +} + +function normalizeGitConfigValue(value: string): string { + const unescaped = stripGitConfigInlineComment(value).trim().replace(/\\"/g, '"') + if ( + unescaped.length >= 2 && + ((unescaped.startsWith('"') && unescaped.endsWith('"')) || + (unescaped.startsWith("'") && unescaped.endsWith("'"))) + ) { + return unescaped.slice(1, -1) + } + return unescaped +} + +function stripGitConfigInlineComment(value: string): string { + let quote: '"' | "'" | null = null + let escaped = false + for (let i = 0; i < value.length; i++) { + const char = value[i] + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (quote) { + if (char === quote) { + quote = null + } + continue + } + if (char === '"' || char === "'") { + quote = char + continue + } + if (char === '#' || char === ';') { + return value.slice(0, i) + } + } + return value +} + +function isGitBooleanFalse(value: string): boolean { + return ['', 'false', 'no', 'off', '0'].includes(value.toLowerCase()) +} diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index 50d8a278be2..fcf5167f907 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -1,542 +1,57 @@ -/* oxlint-disable max-lines */ -import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' -import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' -import { gitExecFileSync, gitExecFileAsync } from './runner' -import type { BaseRefSearchResult } from '../../shared/repo-types' +import { basename } from 'node:path' import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' -import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' -import { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' -import { parseWslUncPath } from '../../shared/wsl-paths' -import { toWindowsWslPath } from '../wsl' import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url' -import { getLocalGitCapabilityCache } from './git-capability-state' +import { + DEFAULT_BASE_REF_PROBE_TIMEOUT_MS, + getDefaultBaseRef, + getDefaultBaseRefAsync, + gitExecOptions, + type LocalGitExecOptions +} from './repo-default-base-ref' +import { gitExecFileAsync, gitExecFileSync } from './runner' -type LocalGitExecOptions = { - wslDistro?: string -} - -type LocalDefaultBaseRefGitOptions = { - cwd: string - wslDistro?: string -} - -const DEFAULT_BASE_REF_PROBE_TIMEOUT_MS = 15_000 - -type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' -type GitMarkerScanResult = { status: 'valid'; rootPath: string } | { status: 'absent' | 'invalid' } - -function gitExecOptions( - cwd: string, - options: LocalGitExecOptions = {} -): { cwd: string; wslDistro?: string } { - return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } -} - -/** - * Ordered probe list for a repo's default base ref when no origin/HEAD symbolic-ref is set. - * `returnAs` is the short-name format the UI expects (as `for-each-ref --format=%(refname:short)` renders it). - * Shared local/SSH so both resolve identical defaults. - */ -export const DEFAULT_BASE_REF_PROBES: readonly { ref: string; returnAs: string }[] = [ - { ref: 'refs/remotes/origin/main', returnAs: 'origin/main' }, - { ref: 'refs/remotes/origin/master', returnAs: 'origin/master' }, - { ref: 'refs/heads/main', returnAs: 'main' }, - { ref: 'refs/heads/master', returnAs: 'master' } -] - -/** - * Walk DEFAULT_BASE_REF_PROBES in order, returning the first ref `hasRef` confirms, or null. - * Abstracts the existence test so local and SSH paths share one authoritative probe ordering. - */ -async function resolveDefaultBaseRefFromProbes( - hasRef: (ref: string) => Promise -): Promise { - for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { - if (await hasRef(ref)) { - return returnAs - } - } - return null -} - -/** Check if a path is a valid git repository (regular or bare). */ -export function isGitRepo(path: string): boolean { - try { - if (!existsSync(path) || !statSync(path).isDirectory()) { - return false - } - } catch { - return false - } - - // Ask git directly first — authoritative for work trees, linked worktrees, submodules, and bare repos. - const gitProbeResult = probeGitRepo(path) - if (gitProbeResult === 'repo') { - return true - } - if (gitProbeResult === 'not-repo') { - return false - } - - // Why: rev-parse can fail for reasons unrelated to repo-ness (spawn hiccup, config error); fall back to a - // validated `.git` marker instead of downgrading a real repo to a plain folder (the spurious "Open as Folder" bug). - const markerScan = scanGitMarkerSync(path) - if (markerScan.status === 'valid' && !warnedMarkerFallbackThisSession) { - // Why: warn once per session; the folder scanner calls isGitRepo for many paths and would otherwise flood logs. - warnedMarkerFallbackThisSession = true - console.warn('[isGitRepo] git rev-parse could not confirm repo; accepted via .git marker', { - path - }) - } - return markerScan.status === 'valid' -} - -let warnedMarkerFallbackThisSession = false - -/** - * Tri-state git probe: only a clean pair of negative answers is a definitive - * non-repo. Spawn/config failures stay indeterminate so marker fallback can run. - */ -function probeGitRepo(path: string): GitRepoProbeResult { - let sawFailure = false - - try { - const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { - cwd: path - }).trim() - if (insideWorkTree === 'true') { - return 'repo' - } - if (insideWorkTree !== 'false') { - return 'indeterminate' - } - } catch { - sawFailure = true - } - - try { - const bareRepo = gitExecFileSync(['rev-parse', '--is-bare-repository'], { - cwd: path - }).trim() - if (bareRepo === 'true') { - return 'repo' - } - if (bareRepo !== 'false') { - return 'indeterminate' - } - } catch { - sawFailure = true - } - - return sawFailure ? 'indeterminate' : 'not-repo' -} - -export function getGitRepoRoot(path: string): string { - try { - if (!existsSync(path) || !statSync(path).isDirectory()) { - return path - } - const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { - cwd: path - }).trim() - if (insideWorkTree === 'true') { - const root = gitExecFileSync(['rev-parse', '--show-toplevel'], { - cwd: path - }).trim() - return normalizeGitRepoRootForInputPath(path, root) - } - } catch { - // Fall through to preserving the original path. - } - const markerScan = scanGitMarkerSync(path) - if (markerScan.status === 'valid') { - return normalizeGitRepoRootForInputPath(path, markerScan.rootPath) - } - return path -} - -function canonicalizeGitDirPath(path: string): string { - return resolveRealPathSync(path) ?? path -} - -/** - * Main-checkout path when `path` is a *linked* worktree, else null (main worktree, bare repo, - * non-repo, or any git failure). A linked worktree's `--git-dir` is `/worktrees/` - * while the main worktree's equals `--git-common-dir`; comparing the two from one invocation is - * git's own canonical test and avoids symlink-canonicalization mismatches. Baseline-safe: both - * flags long predate Git 2.25, and a relative answer resolves against `path` as old Git reports it. - */ -export function getLinkedWorktreeMainRepoRoot(path: string): string | null { - try { - if (!existsSync(path) || !statSync(path).isDirectory()) { - return null - } - if (gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { cwd: path }).trim() !== 'true') { - return null - } - const [gitDir, commonDir] = gitExecFileSync(['rev-parse', '--git-dir', '--git-common-dir'], { - cwd: path - }) - .split('\n') - .map((line) => line.trim()) - if (!gitDir || !commonDir) { - return null - } - // Why realpath both: git answers one flag absolutely (already symlink-resolved) and the other - // relative to cwd, so a repo under a symlinked root (macOS /var -> /private/var) compares - // unequal on raw strings and a main checkout gets misread as a linked worktree. - const absoluteCommonDir = canonicalizeGitDirPath(resolve(path, commonDir)) - if (canonicalizeGitDirPath(resolve(path, gitDir)) === absoluteCommonDir) { - return null - } - // A bare/separate git dir has no adjacent working checkout to point at. - if (basename(absoluteCommonDir) !== '.git') { - return null - } - // Re-resolve through getGitRepoRoot so the returned path matches the canonical form - // add-project stores for the main checkout (symlinks resolved the way git reports them). - return getGitRepoRoot(dirname(absoluteCommonDir)) - } catch { - return null - } -} - -export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: string): string { - const inputWsl = parseWslUncPath(inputPath) - if (inputWsl && rootPath.startsWith('/')) { - // Why: WSL git reports Linux-native roots; persist the UNC path so later git calls keep routing through the WSL runner. - return toWindowsWslPath(rootPath, inputWsl.distro) - } - return normalizeRuntimePathSeparators(rootPath) -} - -/** - * Filesystem-only fallback check for genuine Git metadata when git can't answer cleanly. Strict enough to - * reject a garbage `.git` file (validation from 18ed7b27d): accepts a `.git` dir/file with real gitdir shape - * or a bare-repo root (HEAD + objects/ + refs/, not a worktree admin dir). - */ -function scanGitMarkerSync(path: string): GitMarkerScanResult { - const realPath = resolveRealPathSync(path) - if (realPath && realPath !== path) { - const lexicalScan = scanGitMarkerAncestorsSync(path) - const realPathScan = scanGitMarkerAncestorsSync(realPath) - if ( - lexicalScan.status === 'valid' && - realPathScan.status === 'valid' && - pathsReferToSameEntry(lexicalScan.rootPath, realPathScan.rootPath) - ) { - // Why: preserve lexical spellings (/var vs /private/var), but let a cross-repo symlink bind to the real target like git. - return lexicalScan - } - return realPathScan - } - return scanGitMarkerAncestorsSync(path) -} - -function resolveRealPathSync(path: string): string | null { - try { - return realpathSync.native(path) - } catch { - try { - return realpathSync(path) - } catch { - return null - } - } -} - -function scanGitMarkerAncestorsSync(path: string): GitMarkerScanResult { - for (const candidate of ancestorDirectories(path)) { - if (!isInsideDotGitMarker(candidate, path)) { - const worktreeMarker = scanWorktreeMarkerSync(candidate) - if (worktreeMarker.status !== 'absent') { - return worktreeMarker - } - } - if (hasValidBareRepoMarkerSync(candidate)) { - return { status: 'valid', rootPath: candidate } - } - } - return { status: 'absent' } -} - -function ancestorDirectories(path: string): string[] { - const directories: string[] = [] - let current = path - while (true) { - directories.push(current) - const parent = dirname(current) - if (parent === current) { - return directories - } - current = parent - } -} - -function isInsideDotGitMarker(rootPath: string, targetPath: string): boolean { - const relativePath = relative(rootPath, targetPath) - if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) { - return false - } - const firstSegment = relativePath.split(/[\\/]+/)[0] - if (firstSegment === '.git') { - return true - } - if (firstSegment.toLowerCase() !== '.git') { - return false - } - return pathsReferToSameEntry(join(rootPath, firstSegment), join(rootPath, '.git')) -} - -function pathsReferToSameEntry(leftPath: string, rightPath: string): boolean { - try { - const leftStat = statSync(leftPath) - const rightStat = statSync(rightPath) - if (leftStat.ino !== 0 && leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino) { - return true - } - const leftRealPath = normalizeRuntimePathSeparators(realpathSync.native(leftPath)) - const rightRealPath = normalizeRuntimePathSeparators(realpathSync.native(rightPath)) - return process.platform === 'win32' - ? leftRealPath.toLowerCase() === rightRealPath.toLowerCase() - : leftRealPath === rightRealPath - } catch { - return false - } -} - -function scanWorktreeMarkerSync(worktreePath: string): GitMarkerScanResult { - const dotGit = join(worktreePath, '.git') - let marker: ReturnType - try { - marker = statSync(dotGit) - } catch { - return { status: 'absent' } - } - - if (marker.isDirectory()) { - return hasValidGitDirectorySync(dotGit) - ? { status: 'valid', rootPath: worktreePath } - : { status: 'invalid' } - } - if (marker.isFile()) { - let gitDir: string | null - try { - gitDir = parseGitdirFile(worktreePath, readFileSync(dotGit, 'utf8')) - } catch { - return { status: 'invalid' } - } - return gitDir !== null && hasValidGitDirectorySync(gitDir) - ? { status: 'valid', rootPath: worktreePath } - : { status: 'invalid' } - } - return { status: 'invalid' } -} - -function parseGitdirFile(basePath: string, content: string): string | null { - const firstLine = content.split(/\r?\n/, 1)[0] ?? '' - const match = firstLine.match(/^gitdir:\s*(.+?)\s*$/i) - if (!match) { - return null - } - return resolveGitMetadataPath(basePath, match[1]) -} - -function resolveGitMetadataPath(basePath: string, rawPath: string): string | null { - const value = rawPath.trim() - if (!value) { - return null - } - const baseWsl = parseWslUncPath(basePath) - if (baseWsl && value.startsWith('/')) { - return toWindowsWslPath(value, baseWsl.distro) - } - return isAbsolute(value) ? value : resolve(basePath, value) -} - -function hasValidGitDirectorySync(gitDir: string): boolean { - return hasValidCommonGitDirectorySync(gitDir) || hasValidLinkedWorktreeGitDirectorySync(gitDir) -} - -function hasValidCommonGitDirectorySync(gitDir: string): boolean { - try { - return ( - statSync(join(gitDir, 'HEAD')).isFile() && - statSync(join(gitDir, 'objects')).isDirectory() && - statSync(join(gitDir, 'refs')).isDirectory() - ) - } catch { - return false - } -} - -function hasValidLinkedWorktreeGitDirectorySync(gitDir: string): boolean { - try { - if (!statSync(join(gitDir, 'HEAD')).isFile() || !statSync(join(gitDir, 'commondir')).isFile()) { - return false - } - const commonDir = resolveGitMetadataPath( - gitDir, - readFileSync(join(gitDir, 'commondir'), 'utf8') - ) - return commonDir !== null && hasValidCommonGitDirectorySync(commonDir) - } catch { - return false - } -} - -function hasValidBareRepoMarkerSync(path: string): boolean { - return hasValidCommonGitDirectorySync(path) && !gitConfigDeclaresNonBare(path) -} - -function gitConfigDeclaresNonBare(gitDir: string): boolean { - try { - const config = readFileSync(join(gitDir, 'config'), 'utf8') - let inCoreSection = false - for (const line of config.split(/\r?\n/)) { - const section = line.match(/^\s*\[([^\]]+)\]/) - if (section) { - inCoreSection = section[1].trim().toLowerCase() === 'core' - continue - } - const bare = line.match(/^\s*bare\s*=\s*(.*?)\s*$/i) - if (inCoreSection && bare) { - return isGitBooleanFalse(normalizeGitConfigValue(bare[1])) - } - } - return false - } catch { - return false - } -} - -function normalizeGitConfigValue(value: string): string { - const unescaped = stripGitConfigInlineComment(value).trim().replace(/\\"/g, '"') - if ( - unescaped.length >= 2 && - ((unescaped.startsWith('"') && unescaped.endsWith('"')) || - (unescaped.startsWith("'") && unescaped.endsWith("'"))) - ) { - return unescaped.slice(1, -1) - } - return unescaped -} - -function stripGitConfigInlineComment(value: string): string { - let quote: '"' | "'" | null = null - let escaped = false - for (let i = 0; i < value.length; i++) { - const char = value[i] - if (escaped) { - escaped = false - continue - } - if (char === '\\') { - escaped = true - continue - } - if (quote) { - if (char === quote) { - quote = null - } - continue - } - if (char === '"' || char === "'") { - quote = char - continue - } - if (char === '#' || char === ';') { - return value.slice(0, i) - } - } - return value -} - -function isGitBooleanFalse(value: string): boolean { - return ['', 'false', 'no', 'off', '0'].includes(value.toLowerCase()) -} +export { + isGitRepo, + getGitRepoRoot, + getLinkedWorktreeMainRepoRoot, + normalizeGitRepoRootForInputPath +} from './repo-detection' +export { + DEFAULT_BASE_REF_PROBES, + getDefaultBaseRef, + getBaseRefDefault, + resolveDefaultBaseRefViaExec, + resolveDefaultBaseRefWithLocalGit +} from './repo-default-base-ref' +export type { GitExec } from './repo-default-base-ref' +export { + buildSearchBaseRefsArgv, + mergeBaseRefSearchResultGroups, + searchBaseRefs, + searchBaseRefDetails, + parseAndFilterSearchRefDetails, + normalizeRefSearchQuery +} from './repo-base-ref-search' +export { getBranchConflictKind } from './repo-branch-conflict' +export type { BranchConflictKind } from './repo-branch-conflict' +export { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' /** Get a human-readable name for the repo from its path. */ export function getRepoName(path: string): string { const name = basename(path) - // Strip .git suffix from bare repos return name.endsWith('.git') ? name.slice(0, -4) : name } /** Get the remote origin URL, or null if not set. */ export function getRemoteUrl(path: string): string | null { try { - return getRemoteUrlByName(path, 'origin') + return gitExecFileSync(['remote', 'get-url', 'origin'], { cwd: path }).trim() } catch { return null } } -function getRemoteUrlByName(path: string, remote: string): string { - return gitExecFileSync(['remote', 'get-url', remote], { - cwd: path - }).trim() -} - -function hasGitRef(path: string, ref: string): boolean { - try { - gitExecFileSync(['rev-parse', '--verify', ref], { - cwd: path - }) - return true - } catch { - return false - } -} - -function gitRefToDefaultBaseRef(ref: string): string { - return ref.replace(/^refs\/remotes\//, '') -} - -function getVerifiedOriginHeadBaseRef(path: string): string | null { - try { - const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { - cwd: path - }).trim() - - // Why: origin/HEAD may survive a default-branch rename pointing at a deleted ref; verify before trusting it. - return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null - } catch { - return null - } -} - -/** - * Resolve the default base ref for new worktrees, preferring the remote primary over a stale local branch. - * Returns null when nothing resolves (rather than a hardcoded `origin/main`) so callers fail loudly or degrade. - */ -export function getDefaultBaseRef(path: string): string | null { - const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path) - if (originHeadBaseRef) { - return originHeadBaseRef - } - - // Why: walk the shared DEFAULT_BASE_REF_PROBES so sync and async/SSH paths can't drift on ref order. - for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) { - if (hasGitRef(path, ref)) { - return returnAs - } - } - return null -} - -export async function getBaseRefDefault( - path: string, - options: LocalGitExecOptions = {} -): Promise { - return getDefaultBaseRefAsync(path, options) -} - -/** - * Return { ahead, behind } (merge-base-symmetric delta) for localRef vs remoteRef, or null on failure. - * ahead = commits on localRef not in remoteRef; behind = the reverse. Used by the stale-base dispatch guard (§3.1). - * Async because this runs before every orchestration dispatch on Electron main. - */ +/** Return the symmetric commit delta, or null when Git cannot prove it. */ export async function getRemoteDrift( repoPath: string, localRef: string, @@ -561,10 +76,7 @@ export async function getRemoteDrift( } } -/** - * Up to `limit` commit subjects on remoteRef but not localRef, recency order; [] on git failure. - * Powers the preamble drift section (§3.2) so a worker sees whether stale-base drift touches its area. - */ +/** Return recent subjects on the remote side of the local/remote delta. */ export async function getRecentDriftSubjects( repoPath: string, localRef: string, @@ -580,238 +92,34 @@ export async function getRecentDriftSubjects( timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS } ) - return stdout.split('\n').filter((s) => s.trim().length > 0) + return stdout.split('\n').filter((subject) => subject.trim().length > 0) } catch { return [] } } -/** Parse `git remote` stdout into a remote count. Shared local/SSH so count semantics can't drift. */ +/** Parse `git remote` stdout into a remote count. */ export function parseRemoteCount(stdout: string): number { return stdout.split('\n').filter((line) => line.trim().length > 0).length } -/** Count configured remotes via `git remote`; returns 0 on error (callers read 0 as "unknown / no hint"). */ +/** Count configured remotes; zero means either none or unavailable. */ export async function getRemoteCount(path: string): Promise { try { const { stdout } = await gitExecFileAsync(['remote'], { cwd: path }) return parseRemoteCount(stdout) } catch (err) { - // Why: log so a missing multi-remote hint is debuggable; callers still treat 0 as "unknown". console.warn('[getRemoteCount] git remote failed', { path, err }) return 0 } } -/** Callback shape for a git exec function that yields stdout. */ -export type GitExec = (argv: string[]) => Promise<{ stdout: string }> - -async function hasGitRefViaExec(exec: GitExec, ref: string): Promise { - try { - await exec(['rev-parse', '--verify', '--quiet', ref]) - return true - } catch { - return false - } -} - -async function resolveVerifiedOriginHeadBaseRefViaExec(exec: GitExec): Promise { - try { - const { stdout } = await exec(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']) - const ref = stdout.trim() - if (!ref || !(await hasGitRefViaExec(exec, ref))) { - return null - } - return gitRefToDefaultBaseRef(ref) - } catch { - return null - } -} - -/** - * Resolve the default base ref via a git exec callback: prefer origin/HEAD's symbolic-ref target, - * else fall back to DEFAULT_BASE_REF_PROBES. Shared local/SSH so both transports agree. - * - * Why swallow symbolic-ref's error: a non-zero exit is the expected "origin/HEAD unset" signal, not a failure. - */ -export async function resolveDefaultBaseRefViaExec(exec: GitExec): Promise { - const originHeadBaseRef = await resolveVerifiedOriginHeadBaseRefViaExec(exec) - if (originHeadBaseRef) { - return originHeadBaseRef - } - return resolveDefaultBaseRefFromProbes((ref) => hasGitRefViaExec(exec, ref)) -} - -export function resolveDefaultBaseRefWithLocalGit( - options: LocalDefaultBaseRefGitOptions -): Promise { - return resolveDefaultBaseRefViaExec((argv) => - gitExecFileAsync(argv, { - ...options, - // Why: async avoids main-thread stalls, but dead local/WSL filesystems still need a bound. - timeout: DEFAULT_BASE_REF_PROBE_TIMEOUT_MS - }) - ) -} - -async function getDefaultBaseRefAsync( - path: string, - options: LocalGitExecOptions = {} -): Promise { - return resolveDefaultBaseRefWithLocalGit(gitExecOptions(path, options)) -} - -/** - * Build the argv for `git for-each-ref` used by ref search, given an already-normalized query. - * - * Why: glob every remote (`refs/remotes/*\/*`), not just origin, so fork workflows can find branches like - * `upstream/main` — see docs/upstream-base-ref-design.md. Shared with the SSH relay path so argv can't diverge. - */ -const REF_SEARCH_CANDIDATE_MULTIPLIER = 4 -const REF_SEARCH_LEGACY_HEADROOM = 100 - -type RefSearchPatternGroup = 'all' | 'segmented' | 'branchRoot' - -function getRefSearchTokens(normalizedQuery: string): string[] { - return normalizedQuery.split('/').filter((t) => t.length > 0) -} - -function getRefSearchCandidateCount(limit: number, excludesRemoteHead: boolean): number { - if (!Number.isInteger(limit) || limit <= 0) { - throw new Error('invalid_limit') - } - const baseCount = limit * REF_SEARCH_CANDIDATE_MULTIPLIER - return excludesRemoteHead ? baseCount : baseCount + REF_SEARCH_LEGACY_HEADROOM -} - -export function buildSearchBaseRefsArgv( - normalizedQuery: string, - limit: number, - options: { - excludeRemoteHead?: boolean - remoteNames?: readonly string[] - patternGroup?: RefSearchPatternGroup - } = {} -): string[] { - const excludeRemoteHead = options.excludeRemoteHead ?? true - const candidateCount = getRefSearchCandidateCount(limit, excludeRemoteHead) - const base = [ - 'for-each-ref', - '--format=%(refname)%00%(refname:short)', - '--sort=-committerdate', - ...(excludeRemoteHead - ? [ - // Why: exclude remote HEAD pseudo-refs before --count so the candidate window holds displayable refs. - '--exclude=refs/remotes/**/HEAD' - ] - : []), - // Why: cap git output so broad globs don't overflow execFile/SSH buffers in very large repos. - `--count=${candidateCount}` - ] - // Why: split on `/` so display-format queries route each token to one ref segment; filter empties from stray slashes. - const tokens = getRefSearchTokens(normalizedQuery) - if (tokens.length <= 1) { - const q = tokens[0] ?? '' - // Why `**` not `*`: fnmatch `*` can't cross `/`, so match slash-named branches at both leaf and ancestor segments. - return [ - ...base, - `refs/heads/**/*${q}*`, - `refs/heads/**/*${q}*/**`, - `refs/remotes/**/*${q}*`, - `refs/remotes/**/*${q}*/**` - ] - } - // Why: one `*token*` per ref segment because fnmatch `*` can't cross `/`; lets a retyped `/` result match. - const segmented = tokens.map((token) => `*${token}*`).join('/') - const substringQuery = tokens.join('/') - const remoteBranchRootPatterns = - options.remoteNames && options.remoteNames.length > 0 - ? options.remoteNames.flatMap((remote) => [ - `refs/remotes/${remote}/${substringQuery}*`, - `refs/remotes/${remote}/${substringQuery}*/**` - ]) - : [`refs/remotes/*/${substringQuery}*`, `refs/remotes/*/${substringQuery}*/**`] - const segmentedPatterns = [`refs/remotes/${segmented}`, `refs/heads/${segmented}`] - const branchRootPatterns = [ - // Why: branch names often contain slashes (plan/docs); these root patterns also match a local branch beneath any remote. - `refs/heads/${substringQuery}*`, - `refs/heads/${substringQuery}*/**`, - ...remoteBranchRootPatterns - ] - const patterns = - options.patternGroup === 'segmented' - ? segmentedPatterns - : options.patternGroup === 'branchRoot' - ? branchRootPatterns - : [...segmentedPatterns, ...branchRootPatterns] - return [...base, ...patterns] -} - -async function runSearchBaseRefsGit( - path: string, - normalizedQuery: string, - limit: number, - options: { remoteNames: readonly string[]; patternGroup?: RefSearchPatternGroup } -): Promise<{ stdout: string }> { - return getLocalGitCapabilityCache({ cwd: path }).runWithFallback( - 'for-each-ref-exclude', - () => - gitExecFileAsync( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - remoteNames: options.remoteNames, - patternGroup: options.patternGroup - }), - { cwd: path } - ), - () => - gitExecFileAsync( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - excludeRemoteHead: false, - remoteNames: options.remoteNames, - patternGroup: options.patternGroup - }), - { cwd: path } - ), - isForEachRefExcludeUnsupportedError - ) -} - -export function mergeBaseRefSearchResultGroups( - groups: readonly BaseRefSearchResult[][], - limit: number -): BaseRefSearchResult[] { - const seen = new Set() - const merged: BaseRefSearchResult[] = [] - const maxLength = Math.max(0, ...groups.map((group) => group.length)) - for (let index = 0; index < maxLength && merged.length < limit; index += 1) { - for (const group of groups) { - const entry = group[index] - if (!entry || seen.has(entry.refName)) { - continue - } - seen.add(entry.refName) - merged.push(entry) - if (merged.length >= limit) { - break - } - } - } - return merged -} - -export { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' - -/** - * Resolve the default push remote for a repo. - * Order: remote configured on the current default branch → origin → the single - * remote when the repo has exactly one → error. - */ +/** Resolve the configured push remote without assuming a provider. */ export async function getDefaultRemote( path: string, options: LocalGitExecOptions = {} ): Promise { const defaultRef = await getDefaultBaseRefAsync(path, options) - // Why: getDefaultBaseRefAsync returns null when no default branch exists; guard so .includes() can't crash. const defaultBranch = defaultRef ? defaultRef.includes('/') ? defaultRef.split('/').slice(1).join('/') @@ -859,179 +167,7 @@ export async function getDefaultRemote( } } -export async function searchBaseRefs(path: string, query: string, limit = 25): Promise { - return (await searchBaseRefDetails(path, query, limit)).map((entry) => entry.refName) -} - -export async function searchBaseRefDetails( - path: string, - query: string, - limit = 25 -): Promise { - if (!Number.isInteger(limit) || limit <= 0) { - return [] - } - const normalizedQuery = normalizeRefSearchQuery(query) - - try { - // Why: argv lives in buildSearchBaseRefsArgv so the SSH sibling cannot drift. - const remotes = await listRemoteNames(path) - const tokens = getRefSearchTokens(normalizedQuery) - if (tokens.length > 1) { - // Why: slash queries need both display-format and local-branch matches; merge before the limit so neither starves. - const results = await Promise.all([ - runSearchBaseRefsGit(path, normalizedQuery, limit, { - remoteNames: remotes, - patternGroup: 'segmented' - }), - runSearchBaseRefsGit(path, normalizedQuery, limit, { - remoteNames: remotes, - patternGroup: 'branchRoot' - }) - ]) - return mergeBaseRefSearchResultGroups( - results.map((entry) => parseAndFilterSearchRefDetails(entry.stdout, limit, remotes)), - limit - ) - } - - const result = await runSearchBaseRefsGit(path, normalizedQuery, limit, { - remoteNames: remotes - }) - return parseAndFilterSearchRefDetails(result.stdout, limit, remotes) - } catch (err) { - // Why: log so a missing result set is debuggable; callers still treat [] as "no matches". - console.warn('[searchBaseRefs] for-each-ref failed', { path, err }) - return [] - } -} - -async function listRemoteNames(path: string, options: LocalGitExecOptions = {}): Promise { - try { - const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options)) - return stdout - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - } catch { - return [] - } -} - -export function parseAndFilterSearchRefDetails( - stdout: string, - limit: number, - remotes: string[] = [] -): BaseRefSearchResult[] { - const seen = new Set() - const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length) - return ( - stdout - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => { - const nul = line.indexOf('\0') - if (nul === -1) { - // Why: no NUL means an unexpected %(refname) format; drop it rather than hand callers an unusable "short" ref. - return null - } - return { full: line.slice(0, nul), short: line.slice(nul + 1) } - }) - .filter((entry): entry is { full: string; short: string } => entry !== null) - // Why: drop `/HEAD` pseudo-refs; `.+` (not `[^/]+`) since git allows slashes in remote names. - .filter(({ full }) => !/^refs\/remotes\/.+\/HEAD$/.test(full)) - .filter(({ short }) => { - if (seen.has(short)) { - return false - } - seen.add(short) - return true - }) - .map(({ full, short }) => ({ - refName: short, - localBranchName: resolveLocalBranchName(full, short, sortedRemotes) - })) - // Why: Math.max(0, limit) so pathological limit <= 0 yields zero results, not one. - .slice(0, Math.max(0, limit)) - ) -} - -function resolveLocalBranchName(fullRef: string, shortRef: string, remotes: string[]): string { - const remoteRefPrefix = 'refs/remotes/' - if (!fullRef.startsWith(remoteRefPrefix)) { - return shortRef - } - const remoteAndBranch = fullRef.slice(remoteRefPrefix.length) - const remote = remotes.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`)) - if (remote) { - return remoteAndBranch.slice(remote.length + 1) - } - return remoteAndBranch.split('/').slice(1).join('/') || shortRef -} - -export function normalizeRefSearchQuery(query: string): string { - return query.trim().replace(/[*?[\]\\]/g, '') -} - -async function hasGitRefAsync( - path: string, - ref: string, - options: LocalGitExecOptions = {} -): Promise { - try { - await gitExecFileAsync(['rev-parse', '--verify', ref], gitExecOptions(path, options)) - return true - } catch { - return false - } -} - -export type BranchConflictKind = 'local' | 'remote' - -export async function getBranchConflictKind( - path: string, - branchName: string, - allowedBaseRef?: string, - options: LocalGitExecOptions = {} -): Promise { - if (await hasGitRefAsync(path, `refs/heads/${branchName}`, options)) { - return 'local' - } - - try { - const remoteNames = (await listRemoteNames(path, options)).sort((a, b) => b.length - a.length) - const { stdout } = await gitExecFileAsync( - ['for-each-ref', '--format=%(refname)', 'refs/remotes'], - gitExecOptions(path, options) - ) - const hasRemoteConflict = stdout.split('\n').some((ref) => { - const trimmed = ref.trim() - if (isAllowedRemoteBaseRef(trimmed, allowedBaseRef)) { - return false - } - const shortRef = trimmed.replace(/^refs\/remotes\//, '') - // Why: git allows slashes in remote names; use the configured list so foo/bar/feature resolves to branch "feature". - return resolveLocalBranchName(trimmed, shortRef, remoteNames) === branchName - }) - - return hasRemoteConflict ? 'remote' : null - } catch { - return null - } -} - -function isAllowedRemoteBaseRef(refName: string, allowedBaseRef: string | undefined): boolean { - if (!allowedBaseRef) { - return false - } - const normalizedAllowedRef = allowedBaseRef.startsWith('refs/remotes/') - ? allowedBaseRef - : `refs/remotes/${allowedBaseRef}` - return refName === normalizedAllowedRef -} - -/** Build a hosted URL (GitHub/GitLab/Bitbucket) for a file+line; null when the remote isn't a recognized host. */ +/** Build a hosted file URL when the origin belongs to a supported provider. */ export function getRemoteFileUrl( repoPath: string, relativePath: string, @@ -1041,21 +177,20 @@ export function getRemoteFileUrl( if (!remoteUrl) { return null } - const defaultBaseRef = getDefaultBaseRef(repoPath) if (!defaultBaseRef) { return null } - const defaultBranch = defaultBaseRef.replace(/^origin\//, '') - - return buildHostedRemoteFileUrl(remoteUrl, relativePath, defaultBranch, line) + return buildHostedRemoteFileUrl( + remoteUrl, + relativePath, + defaultBaseRef.replace(/^origin\//, ''), + line + ) } -/** Build a hosted URL (GitHub/GitLab/Bitbucket) for a commit; null when origin isn't a recognized host. */ +/** Build a hosted commit URL when the origin belongs to a supported provider. */ export function getRemoteCommitUrl(repoPath: string, sha: string): string | null { const remoteUrl = getRemoteUrl(repoPath) - if (!remoteUrl) { - return null - } - return buildHostedRemoteCommitUrl(remoteUrl, sha) + return remoteUrl ? buildHostedRemoteCommitUrl(remoteUrl, sha) : null } diff --git a/src/main/github/issue-timeline.ts b/src/main/github/issue-timeline.ts new file mode 100644 index 00000000000..3cc1f36eaac --- /dev/null +++ b/src/main/github/issue-timeline.ts @@ -0,0 +1,189 @@ +import type { + GitHubIssueTimelineItem, + GitHubIssueTimelineTarget +} from '../../shared/github/comment-types' +import { ghExecFileAsync } from './gh-utils' +import type { GitHubApiRepository, GitHubRepoExecOptions } from './github-api-repository' +import { githubHostExecOptions } from './github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const MAX_ISSUE_TIMELINE_ITEMS = 300 +const GITHUB_REST_PAGE_SIZE = 100 + +type RestTimelineUser = { + login?: string | null + avatar_url?: string | null +} + +type RestTimelineIssue = { + number?: number | null + title?: string | null + html_url?: string | null + repository?: { + name?: string | null + owner?: { login?: string | null } | null + } | null + pull_request?: unknown +} + +type RestTimelineEvent = { + id?: number | string | null + node_id?: string | null + event?: string | null + actor?: RestTimelineUser | null + user?: RestTimelineUser | null + assignee?: RestTimelineUser | null + created_at?: string | null + source?: { issue?: RestTimelineIssue | null } | null + closer?: RestTimelineIssue | null + state_reason?: string | null + project_card?: { + column_name?: string | null + previous_column_name?: string | null + project_url?: string | null + } | null + project?: { name?: string | null } | null + project_column_name?: string | null + previous_column_name?: string | null +} + +function isSupportedTimelineEvent( + eventName: string | null | undefined +): eventName is GitHubIssueTimelineItem['event'] { + return ( + eventName === 'assigned' || + eventName === 'unassigned' || + eventName === 'mentioned' || + eventName === 'cross-referenced' || + eventName === 'closed' || + eventName === 'reopened' || + eventName === 'moved_columns_in_project' + ) +} + +function mapTimelineTarget( + issue: RestTimelineIssue | null | undefined +): GitHubIssueTimelineTarget | undefined { + if (!issue || typeof issue.number !== 'number' || !issue.html_url) { + return undefined + } + const owner = issue.repository?.owner?.login + const repo = issue.repository?.name + return { + type: issue.pull_request ? 'pr' : 'issue', + number: issue.number, + title: issue.title ?? '', + url: issue.html_url, + repository: owner && repo ? `${owner}/${repo}` : undefined + } +} + +function getTimelineActor(event: RestTimelineEvent): { login: string; avatarUrl: string } { + const actor = event.actor ?? event.user + return { + login: actor?.login ?? 'ghost', + avatarUrl: actor?.avatar_url ?? '' + } +} + +function mapRestTimelineEvent(event: RestTimelineEvent): GitHubIssueTimelineItem | null { + const eventName = event.event + if (!isSupportedTimelineEvent(eventName) || !event.created_at) { + return null + } + const actor = getTimelineActor(event) + const id = String(event.node_id ?? event.id ?? `${eventName}:${event.created_at}`) + const base = { + id, + event: eventName, + actor: actor.login, + actorAvatarUrl: actor.avatarUrl, + createdAt: event.created_at + } + if (eventName === 'assigned' || eventName === 'unassigned') { + return { ...base, assignee: event.assignee?.login ?? undefined } + } + if (eventName === 'mentioned' || eventName === 'cross-referenced') { + return { ...base, source: mapTimelineTarget(event.source?.issue) } + } + if (eventName === 'closed') { + return { + ...base, + stateReason: event.state_reason ?? null, + closer: mapTimelineTarget(event.closer ?? event.source?.issue) + } + } + if (eventName === 'moved_columns_in_project') { + return { + ...base, + previousColumnName: + event.previous_column_name ?? event.project_card?.previous_column_name ?? null, + columnName: event.project_column_name ?? event.project_card?.column_name ?? null, + projectName: event.project?.name ?? null + } + } + return base +} + +function parseRestTimelineEventLines(stdout: string): RestTimelineEvent[] { + const events: RestTimelineEvent[] = [] + for (const line of stdout.split('\n')) { + const trimmed = line.trim() + if (!trimmed) { + continue + } + try { + const parsed = JSON.parse(trimmed) as unknown + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + events.push(parsed) + } + } catch { + // Timeline activity is auxiliary to issue details. + } + } + return events +} + +export async function getIssueTimelineItems( + repository: GitHubApiRepository, + issueNumber: number, + ghOptions: GitHubRepoExecOptions +): Promise { + try { + const items: GitHubIssueTimelineItem[] = [] + for (let page = 1; items.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) { + if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) { + return items + } + noteRepositoryRateLimitSpend(repository, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${repository.owner}/${repository.repo}/issues/${issueNumber}/timeline?per_page=${GITHUB_REST_PAGE_SIZE}&page=${page}`, + '--jq', + '.[] | @json' + ], + { ...ghOptions, ...githubHostExecOptions(repository) } + ) + const pageEvents = parseRestTimelineEventLines(stdout) + for (const event of pageEvents) { + const item = mapRestTimelineEvent(event) + if (!item) { + continue + } + items.push(item) + if (items.length === MAX_ISSUE_TIMELINE_ITEMS) { + break + } + } + if (pageEvents.length < GITHUB_REST_PAGE_SIZE) { + break + } + } + return items + } catch { + return [] + } +} diff --git a/src/main/github/issue-work-item-details.ts b/src/main/github/issue-work-item-details.ts new file mode 100644 index 00000000000..d58491abd87 --- /dev/null +++ b/src/main/github/issue-work-item-details.ts @@ -0,0 +1,260 @@ +import type { GitHubIssueTimelineItem, PRComment } from '../../shared/github/comment-types' +import type { GitHubAssignableUser } from '../../shared/github/pull-request-types' +import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from './gh-utils' +import type { LocalGitExecOptions } from './gh-utils' +import { githubHostExecOptions, type GitHubApiRepository } from './github-api-repository' +import { getIssueTimelineItems } from './issue-timeline' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const ISSUE_DETAILS_QUERY = `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + body + assignees(first: 50) { nodes { login avatarUrl(size: 48) ... on User { name } } } + participants(first: 100) { + nodes { login avatarUrl(size: 48) ... on User { name } } + } + comments(first: 100) { + nodes { + databaseId + body + createdAt + url + author { + login + avatarUrl(size: 48) + ... on Bot { __typename } + } + } + } + } + } +}` + +type GraphQLIssueDetailsResponse = { + data?: { + repository?: { + issue?: { + body?: string | null + assignees?: { nodes?: { login?: string; avatarUrl?: string; name?: string | null }[] } + participants?: { nodes?: GitHubAssignableUser[] } + comments?: { + nodes?: { + databaseId?: number | null + body?: string | null + createdAt?: string | null + url?: string | null + author?: { + login?: string | null + avatarUrl?: string | null + __typename?: string + } | null + }[] + } + } | null + } | null + } + errors?: { message?: string }[] +} + +export type CollapsedIssueDetails = { + body: string + comments: PRComment[] + assignees: string[] + assigneeUsers: GitHubAssignableUser[] + participants: GitHubAssignableUser[] + timelineItems: GitHubIssueTimelineItem[] +} + +export async function getIssueDetailsViaGraphQL( + repoPath: string, + issueNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (!repository) { + return null + } + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + return null + } + try { + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${ISSUE_DETAILS_QUERY}`, + '-f', + `owner=${repository.owner}`, + '-f', + `repo=${repository.repo}`, + '-F', + `number=${issueNumber}` + ], + ghOptions + ) + const parsed = JSON.parse(stdout) as GraphQLIssueDetailsResponse + if (parsed.errors && parsed.errors.length > 0) { + return null + } + const issue = parsed.data?.repository?.issue + if (!issue) { + return null + } + const comments: PRComment[] = (issue.comments?.nodes ?? []) + .filter((comment) => typeof comment.databaseId === 'number') + .map((comment) => ({ + id: comment.databaseId as number, + author: comment.author?.login ?? 'ghost', + authorAvatarUrl: comment.author?.avatarUrl ?? '', + body: comment.body ?? '', + createdAt: comment.createdAt ?? '', + url: comment.url ?? '', + isBot: comment.author?.__typename === 'Bot' + })) + const assigneeUsers: GitHubAssignableUser[] = (issue.assignees?.nodes ?? []) + .filter((assignee): assignee is { login: string; avatarUrl?: string; name?: string | null } => + Boolean(assignee.login) + ) + .map((assignee) => ({ + login: assignee.login, + name: assignee.name ?? null, + avatarUrl: assignee.avatarUrl ?? '' + })) + const participants: GitHubAssignableUser[] = (issue.participants?.nodes ?? []) + .filter((user) => Boolean(user.login)) + .map((user) => ({ + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + })) + return { + body: issue.body ?? '', + comments, + assignees: assigneeUsers.map((assignee) => assignee.login), + assigneeUsers, + participants, + timelineItems: await getIssueTimelineItems(repository, issueNumber, ghOptions) + } + } catch { + return null + } +} + +export async function getIssueBodyAndComments( + repoPath: string, + issueNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ + body: string + comments: PRComment[] + assignees: string[] + timelineItems: GitHubIssueTimelineItem[] +}> { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + try { + if (repository) { + if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) { + return { body: '', comments: [], assignees: [], timelineItems: [] } + } + noteRepositoryRateLimitSpend(repository, 'core', 2, ghOptions) + const [issueResult, commentsResult, timelineItems] = await Promise.all([ + ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${repository.owner}/${repository.repo}/issues/${issueNumber}` + ], + ghOptions + ), + ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${repository.owner}/${repository.repo}/issues/${issueNumber}/comments?per_page=100` + ], + ghOptions + ), + getIssueTimelineItems(repository, issueNumber, ghOptions) + ]) + const issue = JSON.parse(issueResult.stdout) as { + body?: string | null + assignees?: { login: string }[] + } + type RESTComment = { + id: number + user: { login: string; avatar_url: string; type?: string } | null + body: string + created_at: string + html_url: string + } + const comments = (JSON.parse(commentsResult.stdout) as RESTComment[]).map( + (comment): PRComment => ({ + id: comment.id, + author: comment.user?.login ?? 'ghost', + authorAvatarUrl: comment.user?.avatar_url ?? '', + body: comment.body ?? '', + createdAt: comment.created_at, + url: comment.html_url, + isBot: comment.user?.type === 'Bot' + }) + ) + return { + body: issue.body ?? '', + comments, + assignees: (issue.assignees ?? []).map((assignee) => assignee.login), + timelineItems + } + } + if (connectionId) { + // A connection-backed lookup must not use ambient local GH_REPO/GH_HOST. + return { body: '', comments: [], assignees: [], timelineItems: [] } + } + const { stdout } = await ghExecFileAsync( + ['issue', 'view', String(issueNumber), '--json', 'body,comments,assignees'], + ghOptions + ) + const data = JSON.parse(stdout) as { + body?: string + comments?: { + author: { login: string } + body: string + createdAt: string + url: string + }[] + assignees?: { login: string }[] + } + const comments = (data.comments ?? []).map( + (comment, index): PRComment => ({ + id: index, + author: comment.author?.login ?? 'ghost', + authorAvatarUrl: '', + body: comment.body ?? '', + createdAt: comment.createdAt, + url: comment.url ?? '' + }) + ) + return { + body: data.body ?? '', + comments, + assignees: (data.assignees ?? []).map((assignee) => assignee.login), + timelineItems: [] + } + } catch { + return { body: '', comments: [], assignees: [], timelineItems: [] } + } +} diff --git a/src/main/github/pr-refresh-candidate-policy.ts b/src/main/github/pr-refresh-candidate-policy.ts new file mode 100644 index 00000000000..e2868edf051 --- /dev/null +++ b/src/main/github/pr-refresh-candidate-policy.ts @@ -0,0 +1,183 @@ +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + GitHubPRRefreshReason, + GitHubPRRefreshSkippedReason, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import type { GitHubPRBranchLookupOptions } from './client' +import { NO_REVIEW_REFRESH_INTERVAL_MS } from '../source-control/hosted-review-refresh-pacing' + +export const MANUAL_MERGEABILITY_PENDING_REFRESH_MS = 2_500 +export const POST_PUSH_DELAY_MS = 2_500 + +type PRBranchLookupCandidate = Pick< + GitHubPRRefreshCandidate, + 'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource' | 'currentHeadOid' +> + +function shouldAcceptMergedFallbackPR(candidate: PRBranchLookupCandidate): boolean { + return ( + candidate.linkedPRNumber == null && + candidate.fallbackPRNumber != null && + candidate.fallbackPRSource != null + ) +} + +export function hostedReviewOptionArgs( + candidate: PRBranchLookupCandidate +): [] | [GitHubPRBranchLookupOptions] { + const options: GitHubPRBranchLookupOptions = {} + if (candidate.localGitOptions?.wslDistro) { + options.localGitExecOptions = { wslDistro: candidate.localGitOptions.wslDistro } + } + if (shouldAcceptMergedFallbackPR(candidate)) { + options.acceptMergedFallbackPR = true + } + if (typeof candidate.currentHeadOid === 'string' && candidate.currentHeadOid.trim().length > 0) { + options.currentHeadOid = candidate.currentHeadOid.trim() + } + return Object.keys(options).length > 0 ? [options] : [] +} + +export function refreshKey(candidate: GitHubPRRefreshCandidate): string { + const connectionScope = candidate.connectionId ?? 'local' + const runtimeScope = candidate.connectionId + ? 'remote' + : `runtime:${candidate.localGitOptions?.wslDistro ? `wsl:${candidate.localGitOptions.wslDistro}` : 'host'}` + if (typeof candidate.linkedPRNumber === 'number') { + return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::pr::${candidate.linkedPRNumber}` + } + return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::branch::${candidate.branch}` +} + +export function validateCandidate( + candidate: GitHubPRRefreshCandidate +): GitHubPRRefreshSkippedReason | null { + if (candidate.repoKind !== 'git') { + return 'not-git' + } + if (candidate.isBare) { + return 'bare' + } + if (candidate.isArchived) { + return 'archived' + } + if (candidate.connectionId && candidate.connectionState === 'disconnected') { + return 'disconnected' + } + if (!candidate.branch && typeof candidate.linkedPRNumber !== 'number') { + return 'fresh' + } + return null +} + +export function bypassesFreshnessDelay(reason: GitHubPRRefreshReason): boolean { + return reason === 'manual' || reason === 'active' || reason === 'post-push' +} + +export function isBackground(reason: GitHubPRRefreshReason): boolean { + return reason !== 'manual' +} + +export function isBudgetedBackground(reason: GitHubPRRefreshReason): boolean { + return reason === 'visible' || reason === 'swr' +} + +export function shouldBroadcastQueued(reason: GitHubPRRefreshReason, dueAt: number): boolean { + if (isBudgetedBackground(reason)) { + return false + } + const delay = dueAt - Date.now() + return delay > 0 && delay <= 5_000 +} + +export function shouldSkipFresh( + candidate: GitHubPRRefreshCandidate, + reason: GitHubPRRefreshReason +): boolean { + if (bypassesFreshnessDelay(reason) || candidate.cachedFetchedAt == null) { + return false + } + return Date.now() - candidate.cachedFetchedAt < refreshIntervalForCandidate(candidate) +} + +export function freshRetryAt(candidate: GitHubPRRefreshCandidate): number | null { + return candidate.cachedFetchedAt == null + ? null + : candidate.cachedFetchedAt + refreshIntervalForCandidate(candidate) +} + +export function aliasFromCandidate(candidate: GitHubPRRefreshCandidate): GitHubPRRefreshAlias { + return { + cacheKey: candidate.cacheKey, + repoId: candidate.repoId, + repoPath: candidate.repoPath, + branch: candidate.branch, + worktreeId: candidate.worktreeId, + connectionId: candidate.connectionId ?? null, + currentHeadOid: candidate.currentHeadOid ?? null, + linkedPRNumber: candidate.linkedPRNumber ?? null, + fallbackPRNumber: + candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, + fallbackPRSource: candidate.linkedPRNumber == null ? (candidate.fallbackPRSource ?? null) : null + } +} + +export function visibleCandidateAfterOutcome( + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome +): GitHubPRRefreshCandidate { + if (outcome.kind === 'upstream-error') { + return candidate + } + return { + ...candidate, + cachedFetchedAt: outcome.fetchedAt, + cachedHasPR: outcome.kind === 'found', + cachedPRState: outcome.kind === 'found' ? outcome.pr.state : null, + cachedChecksStatus: outcome.kind === 'found' ? outcome.pr.checksStatus : null, + cachedMergeable: outcome.kind === 'found' ? outcome.pr.mergeable : null, + cachedMergeStateStatus: outcome.kind === 'found' ? (outcome.pr.mergeStateStatus ?? null) : null + } +} + +function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): number { + if (candidate.cachedPRState === 'closed' || candidate.cachedPRState === 'merged') { + return 30 * 60_000 + } + if (candidate.cachedHasPR === false) { + return NO_REVIEW_REFRESH_INTERVAL_MS + } + if ( + candidate.cachedHasPR === true && + candidate.cachedPRState === 'open' && + candidate.cachedMergeable === 'UNKNOWN' && + !hasResolvedMergeStateStatus(candidate.cachedMergeStateStatus) + ) { + return 10_000 + } + if (candidate.cachedChecksStatus === 'success') { + return 10 * 60_000 + } + if (candidate.cachedChecksStatus === 'failure') { + return 3 * 60_000 + } + if (candidate.cachedChecksStatus === 'pending') { + return 90_000 + } + return 60_000 +} + +function hasResolvedMergeStateStatus(status: string | null | undefined): boolean { + return status === 'CLEAN' || status === 'BEHIND' || status === 'BLOCKED' +} + +export function isMergeabilityPendingOutcome(outcome: PRRefreshOutcome): boolean { + return ( + outcome.kind === 'found' && + outcome.pr.state === 'open' && + outcome.pr.mergeable === 'UNKNOWN' && + !hasResolvedMergeStateStatus(outcome.pr.mergeStateStatus) + ) +} diff --git a/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts b/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts index 8d81faeb0f7..29b5309d26b 100644 --- a/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts +++ b/src/main/github/pr-refresh-coordinator-rate-limit-budget.test.ts @@ -164,4 +164,58 @@ describe('pr-refresh-coordinator', () => { 'feature/0' ]) }) + + it.each([ + { primaryResetAt: 4, retryDisabledUntil: 5_000, expectedGateUntil: 5_000 }, + { primaryResetAt: 6, retryDisabledUntil: 5_000, expectedGateUntil: 6_000 } + ])( + 'uses the later manual gate when the primary reset is $primaryResetAt seconds', + async ({ primaryResetAt, retryDisabledUntil, expectedGateUntil }) => { + const { refreshPRNow, reportVisiblePRRefreshCandidates } = + await import('./pr-refresh-coordinator') + const candidate = makeCandidate() + getPRForBranchOutcomeMock.mockResolvedValueOnce({ + kind: 'upstream-error', + errorType: 'rate_limited', + message: 'limited', + fetchedAt: Date.now(), + retryDisabledUntil + }) + + reportVisiblePRRefreshCandidates([candidate], 1, 1) + await refreshPRNow(candidate) + getPRForBranchOutcomeMock.mockClear() + repositoryRateLimitGuardMock.mockImplementation((_repository, bucket) => + bucket === 'core' + ? { blocked: true, remaining: 0, limit: 5_000, resetAt: primaryResetAt } + : { blocked: false } + ) + + const outcome = await refreshPRNow(candidate) + + expect(outcome).toEqual({ + kind: 'upstream-error', + errorType: 'rate_limited', + message: 'GitHub is temporarily limiting requests. Try again after the limit resets.', + fetchedAt: 1_000, + nextAutoRetryAt: expectedGateUntil, + retryDisabledUntil: expectedGateUntil + }) + expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled() + expect(sendMock.mock.calls.map(([, event]) => event).at(-1)).toEqual( + expect.objectContaining({ + reason: 'manual', + status: 'paused', + pausedUntil: expectedGateUntil, + skippedReason: 'rate-limit' + }) + ) + + getPRForBranchOutcomeMock.mockResolvedValue({ kind: 'no-pr', fetchedAt: expectedGateUntil }) + await vi.advanceTimersByTimeAsync(expectedGateUntil - Date.now() - 1) + expect(getPRForBranchOutcomeMock).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledOnce() + } + ) }) diff --git a/src/main/github/pr-refresh-coordinator-refresh-events.test.ts b/src/main/github/pr-refresh-coordinator-refresh-events.test.ts index 9d8d0745645..45a026dabbd 100644 --- a/src/main/github/pr-refresh-coordinator-refresh-events.test.ts +++ b/src/main/github/pr-refresh-coordinator-refresh-events.test.ts @@ -27,6 +27,43 @@ describe('pr-refresh-coordinator', () => { vi.useRealTimers() }) + it('preserves the coordinator public module API', async () => { + const coordinator = await import('./pr-refresh-coordinator') + + expect(Object.keys(coordinator).sort()).toEqual([ + '_getPRRefreshAliasCountForTests', + '_getPRRefreshErrorBackoffCountForTests', + '_getPRRefreshQueueSizeForTests', + '_getVisiblePRRefreshWindowCountForTests', + 'clearVisiblePRRefreshWindow', + 'enqueuePRRefresh', + 'pruneWorktreePRRefreshAliases', + 'refreshPRNow', + 'reportVisiblePRRefreshCandidates', + 'setPRRefreshOutcomeObserver' + ]) + }) + + it('ignores a stale visibility generation before it can replace queued work', async () => { + const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock.mockResolvedValue({ kind: 'no-pr', fetchedAt: Date.now() }) + + reportVisiblePRRefreshCandidates( + [makeCandidate({ branch: 'feature/current', cacheKey: '/repo::feature/current' })], + 2, + 1 + ) + reportVisiblePRRefreshCandidates( + [makeCandidate({ branch: 'feature/stale', cacheKey: '/repo::feature/stale' })], + 1, + 1 + ) + await vi.advanceTimersByTimeAsync(0) + + expect(getPRForBranchOutcomeMock).toHaveBeenCalledOnce() + expect(getPRForBranchOutcomeMock.mock.calls[0]?.[1]).toBe('feature/current') + }) + it('sends each refresh event once without broadcasting to 100 browser guests', async () => { const guestSends = Array.from({ length: 100 }, () => vi.fn()) getAllWebContentsMock.mockReturnValue( @@ -109,6 +146,46 @@ describe('pr-refresh-coordinator', () => { expect(outcome?.sequence).toBe(inFlight?.sequence) }) + it('keeps each manual outcome on its request sequence when completions race', async () => { + const { refreshPRNow } = await import('./pr-refresh-coordinator') + let resolveFirst!: (outcome: ReturnType) => void + let resolveSecond!: (outcome: ReturnType) => void + const first = new Promise>((resolve) => { + resolveFirst = resolve + }) + const second = new Promise>((resolve) => { + resolveSecond = resolve + }) + getPRForBranchOutcomeMock + .mockImplementationOnce(async () => ({ + kind: 'found', + pr: await first, + fetchedAt: Date.now() + })) + .mockImplementationOnce(async () => ({ + kind: 'found', + pr: await second, + fetchedAt: Date.now() + })) + + const firstRefresh = refreshPRNow(makeCandidate()) + const secondRefresh = refreshPRNow(makeCandidate()) + await vi.advanceTimersByTimeAsync(0) + resolveSecond(makePR({ number: 22 })) + await secondRefresh + resolveFirst(makePR({ number: 11 })) + await firstRefresh + + const events = sendMock.mock.calls.map(([, event]) => event) + const inFlight = events.filter((event) => event.status === 'in-flight') + const outcomes = events.filter((event) => event.outcome) + const firstOutcome = outcomes.find((event) => event.outcome.pr?.number === 11) + const secondOutcome = outcomes.find((event) => event.outcome.pr?.number === 22) + expect(firstOutcome?.sequence).toBe(inFlight[0]?.sequence) + expect(secondOutcome?.sequence).toBe(inFlight[1]?.sequence) + expect(firstOutcome?.sequence).toBeLessThan(secondOutcome?.sequence) + }) + it('accepts merged fallback PRs for visible fallback refreshes', async () => { const { refreshPRNow } = await import('./pr-refresh-coordinator') getPRForBranchOutcomeMock.mockResolvedValueOnce({ diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 2ff9d0e410b..22846cbe17c 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -1,817 +1,61 @@ -/* eslint-disable max-lines -- Why: queueing, pacing, and broadcast rules stay together so freshness and rate-limit invariants are reviewable in one place. */ -import { webContents } from 'electron' import type { - GitHubPRRefreshAlias, GitHubPRRefreshCandidate, - GitHubPRRefreshEvent, GitHubPRRefreshReason, - GitHubPRRefreshSkippedReason, PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types' -import { getPRForBranchOutcome, type GitHubPRBranchLookupOptions } from './client' -import { getOriginGitHubApiRepository } from './github-api-repository' -import { ghRepoExecOptions, githubRepoContext } from './gh-utils' -import { getRateLimit, repositoryRateLimitGuard, spendsSharedGitHubComQuota } from './rate-limit' +import { getPRForBranchOutcome } from './client' import { - lookupBackoffDelayMs, - NO_REVIEW_REFRESH_INTERVAL_MS -} from '../source-control/hosted-review-refresh-pacing' -import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' -import { sendToTrustedUIRenderer } from '../ipc/ui' + aliasFromCandidate, + hostedReviewOptionArgs, + MANUAL_MERGEABILITY_PENDING_REFRESH_MS, + refreshKey, + shouldBroadcastQueued, + validateCandidate +} from './pr-refresh-candidate-policy' +import { + PRRefreshEventPublisher, + type PRRefreshOutcomeObserver +} from './pr-refresh-event-publisher' +import { PRRefreshPacing } from './pr-refresh-pacing' +import { PRRefreshQueue } from './pr-refresh-queue' +import { PRRefreshQueueDrainer } from './pr-refresh-queue-drainer' +import { prRefreshRateLimitPausedUntil } from './pr-refresh-rate-limit-gate' +import { PRRefreshRetryState } from './pr-refresh-retry-state' +import { PRRefreshVisibility } from './pr-refresh-visibility' -type QueueEntry = { - key: string - candidate: GitHubPRRefreshCandidate - aliases: Map - reason: GitHubPRRefreshReason - priority: number - dueAt: number - queuedAt: number - bypassBackgroundBudget?: boolean - activeDelayNotified?: boolean - windowId?: number -} - -type PRRefreshOutcomeObserver = ( - candidate: GitHubPRRefreshCandidate, - outcome: PRRefreshOutcome -) => void - -type PRBranchLookupCandidate = Pick< - GitHubPRRefreshCandidate, - 'localGitOptions' | 'linkedPRNumber' | 'fallbackPRNumber' | 'fallbackPRSource' | 'currentHeadOid' -> - -function shouldAcceptMergedFallbackPR(candidate: PRBranchLookupCandidate): boolean { - return ( - candidate.linkedPRNumber == null && - candidate.fallbackPRNumber != null && - candidate.fallbackPRSource != null - ) -} - -function hostedReviewOptionArgs( - candidate: PRBranchLookupCandidate -): [] | [GitHubPRBranchLookupOptions] { - const options: GitHubPRBranchLookupOptions = {} - if (candidate.localGitOptions?.wslDistro) { - options.localGitExecOptions = { wslDistro: candidate.localGitOptions.wslDistro } - } - if (shouldAcceptMergedFallbackPR(candidate)) { - options.acceptMergedFallbackPR = true - } - if (typeof candidate.currentHeadOid === 'string' && candidate.currentHeadOid.trim().length > 0) { - options.currentHeadOid = candidate.currentHeadOid.trim() - } - return Object.keys(options).length > 0 ? [options] : [] -} - -const MIN_BACKGROUND_REFRESH_AGE_MS = 60_000 -const MERGEABILITY_PENDING_REFRESH_MS = 10_000 -const MANUAL_MERGEABILITY_PENDING_REFRESH_MS = 2_500 -const BACKGROUND_BUDGET_WINDOW_MS = 5 * 60_000 -const MIN_BACKGROUND_SPACING_MS = 10_000 -const BACKGROUND_BUDGET_MAX = 20 -const POST_PUSH_DELAY_MS = 2_500 -const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000 -const ACTIVE_BURST_WINDOW_MS = 30_000 -const ACTIVE_BURST_MAX = 3 - -let sequence = 0 -let queueOrder = 0 -let draining = false -let drainTimer: ReturnType | null = null -const queue = new Map() -const backgroundStarts: number[] = [] -const activeStartsByScope = new Map() -const errorBackoff = new Map() -// Per-key manual-retry cooldown (secondary rate-limit Retry-After); refreshPRNow refuses manual retry until this time so a stale renderer can't bypass it. -const manualRetryGates = new Map() -let lastBackgroundStartAt = 0 - -/** - * Track (or clear) the manual-retry cooldown for a key from a broadcast outcome. - * Only a rate-limit outcome carrying `retryDisabledUntil` sets a gate; any other settled outcome clears it. - */ -function noteManualRetryGate(key: string, outcome: PRRefreshOutcome): void { - if (outcome.kind === 'upstream-error' && outcome.retryDisabledUntil !== undefined) { - manualRetryGates.set(key, outcome.retryDisabledUntil) - } else { - manualRetryGates.delete(key) - } -} - -/** Reset all per-key retry state (backoff + manual cooldown) when a key resets. */ -function resetKeyRetryState(key: string): void { - errorBackoff.delete(key) - manualRetryGates.delete(key) -} -const visibleByWindow = new Map }>() -let outcomeObserver: PRRefreshOutcomeObserver | null = null -const diagnosticsCounters = { - enqueued: 0, - coalesced: 0, - skipped: 0, - backgroundPauses: 0 -} +const retry = new PRRefreshRetryState() +const queue = new PRRefreshQueue((key) => retry.reset(key)) +const pacing = new PRRefreshPacing() +const visibility = new PRRefreshVisibility() +const events = new PRRefreshEventPublisher() +const drainer = new PRRefreshQueueDrainer(queue, pacing, visibility, retry, events) export function setPRRefreshOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void { - outcomeObserver = observer + events.setOutcomeObserver(observer) } function removeInvisibleVisibleRefreshes(): void { - for (const [key, entry] of queue) { - if (entry.reason === 'visible' && !isVisibleKey(key)) { - queue.delete(key) - resetKeyRetryState(key) - broadcast({ - aliases: Array.from(entry.aliases.values()), - reason: 'visible', - status: 'skipped', - skippedReason: 'fresh' - }) - } - } -} - -function recordPRRefreshQueueDiagnostic( - event: 'enqueued' | 'coalesced' | 'skipped' | 'background-pause', - reason: GitHubPRRefreshReason, - skippedReason?: GitHubPRRefreshSkippedReason -): void { - recordCoalescedCrashBreadcrumb({ - name: 'pr_refresh_queue', - coalesceKey: `pr-refresh-queue:${event}:${reason}:${skippedReason ?? ''}`, - minIntervalMs: DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS, - data: { - event, - reason, - ...(skippedReason ? { skippedReason } : {}), - enqueued: diagnosticsCounters.enqueued, - coalesced: diagnosticsCounters.coalesced, - skipped: diagnosticsCounters.skipped, - backgroundPauses: diagnosticsCounters.backgroundPauses - } - }) -} - -function clearActiveBurstWindow(windowId: number): void { - const windowPrefix = `${windowId}::` - for (const scope of Array.from(activeStartsByScope.keys())) { - if (scope.startsWith(windowPrefix)) { - activeStartsByScope.delete(scope) - } + const removed = queue.removeInvisibleVisibleEntries((key) => visibility.has(key)) + for (const entry of removed) { + events.broadcast({ + aliases: Array.from(entry.aliases.values()), + reason: 'visible', + status: 'skipped', + skippedReason: 'fresh' + }) } } export function clearVisiblePRRefreshWindow(windowId: number): void { - const hadVisibleRefreshes = visibleByWindow.delete(windowId) - clearActiveBurstWindow(windowId) + const hadVisibleRefreshes = visibility.clearWindow(windowId) + pacing.clearActiveBurstWindow(windowId) if (hadVisibleRefreshes) { - // Why: visible follow-ups are owned by the reporting renderer; if its WebContents is destroyed, no later visibility report arrives. removeInvisibleVisibleRefreshes() } } -/** - * Drop a removed worktree's aliases from every queue entry. - * - * Why: aliases are otherwise only pruned when a candidate is re-enqueued as invalid, so churning worktrees grow these maps unbounded (OOM creep). - */ export function pruneWorktreePRRefreshAliases(worktreeId: string): void { - for (const [key, entry] of queue) { - let removed = false - for (const [cacheKey, alias] of entry.aliases) { - if (alias.worktreeId === worktreeId) { - entry.aliases.delete(cacheKey) - removed = true - } - } - if (!removed) { - continue - } - // No aliases left means no worktree still cares about this refresh. - if (entry.aliases.size === 0) { - queue.delete(key) - resetKeyRetryState(key) - continue - } - // Keep the entry alive but replace its representative so it isn't a dangling reference to the removed worktree. - if (entry.candidate.worktreeId === worktreeId) { - const replacementAlias = entry.aliases.values().next().value - if (replacementAlias) { - entry.candidate = { - ...entry.candidate, - cacheKey: replacementAlias.cacheKey, - branch: replacementAlias.branch, - worktreeId: replacementAlias.worktreeId, - // Why: the probe now represents the replacement worktree, so use its head — else the removed worktree's link never clears. - currentHeadOid: replacementAlias.currentHeadOid ?? null - } - } - } - } -} - -function nextSequence(): number { - sequence += 1 - return sequence -} - -function nextQueueOrder(): number { - queueOrder += 1 - return queueOrder -} - -function broadcast(event: Omit, sequenceOverride?: number): void { - const payload = { ...event, sequence: sequenceOverride ?? nextSequence() } as GitHubPRRefreshEvent - sendToTrustedUIRenderer('gh:prRefreshEvent', payload) -} - -function refreshKey(candidate: GitHubPRRefreshCandidate): string { - const connectionScope = candidate.connectionId ?? 'local' - const runtimeScope = candidate.connectionId - ? 'remote' - : `runtime:${candidate.localGitOptions?.wslDistro ? `wsl:${candidate.localGitOptions.wslDistro}` : 'host'}` - if (typeof candidate.linkedPRNumber === 'number') { - return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::pr::${candidate.linkedPRNumber}` - } - return `${connectionScope}::${runtimeScope}::${candidate.repoPath}::branch::${candidate.branch}` -} - -function isVisibleKey(key: string): boolean { - const liveWindowIds = new Set( - webContents - .getAllWebContents() - .filter((wc) => !wc.isDestroyed()) - .map((wc) => wc.id) - ) - for (const windowId of Array.from(visibleByWindow.keys())) { - if (!liveWindowIds.has(windowId)) { - visibleByWindow.delete(windowId) - } - } - for (const visible of visibleByWindow.values()) { - if (visible.keys.has(key)) { - return true - } - } - return false -} - -function isManual(reason: GitHubPRRefreshReason): boolean { - return reason === 'manual' -} - -function bypassesFreshnessDelay(reason: GitHubPRRefreshReason): boolean { - return reason === 'manual' || reason === 'active' || reason === 'post-push' -} - -function isBackground(reason: GitHubPRRefreshReason): boolean { - return reason !== 'manual' -} - -function isBudgetedBackground(reason: GitHubPRRefreshReason): boolean { - return reason === 'visible' || reason === 'swr' -} - -function isBudgetedQueueEntry(entry: QueueEntry): boolean { - return isBudgetedBackground(entry.reason) && entry.bypassBackgroundBudget !== true -} - -function validateCandidate( - candidate: GitHubPRRefreshCandidate -): GitHubPRRefreshSkippedReason | null { - if (candidate.repoKind !== 'git') { - return 'not-git' - } - if (candidate.isBare) { - return 'bare' - } - if (candidate.isArchived) { - return 'archived' - } - if (candidate.connectionId && candidate.connectionState === 'disconnected') { - return 'disconnected' - } - if (!candidate.branch && typeof candidate.linkedPRNumber !== 'number') { - return 'fresh' - } - return null -} - -function shouldSkipFresh( - candidate: GitHubPRRefreshCandidate, - reason: GitHubPRRefreshReason -): boolean { - if (bypassesFreshnessDelay(reason) || candidate.cachedFetchedAt == null) { - return false - } - return Date.now() - candidate.cachedFetchedAt < refreshIntervalForCandidate(candidate) -} - -function shouldBroadcastQueued(reason: GitHubPRRefreshReason, dueAt: number): boolean { - if (isBudgetedBackground(reason)) { - return false - } - const delay = dueAt - Date.now() - if (delay <= 0) { - return false - } - return delay <= 5_000 -} - -function freshRetryAt(candidate: GitHubPRRefreshCandidate): number | null { - return candidate.cachedFetchedAt == null - ? null - : candidate.cachedFetchedAt + refreshIntervalForCandidate(candidate) -} - -function aliasFromCandidate(candidate: GitHubPRRefreshCandidate): GitHubPRRefreshAlias { - return { - cacheKey: candidate.cacheKey, - repoId: candidate.repoId, - repoPath: candidate.repoPath, - branch: candidate.branch, - worktreeId: candidate.worktreeId, - connectionId: candidate.connectionId ?? null, - currentHeadOid: candidate.currentHeadOid ?? null, - linkedPRNumber: candidate.linkedPRNumber ?? null, - fallbackPRNumber: - candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, - fallbackPRSource: candidate.linkedPRNumber == null ? (candidate.fallbackPRSource ?? null) : null - } -} - -function visibleCandidateAfterOutcome( - candidate: GitHubPRRefreshCandidate, - outcome: PRRefreshOutcome -): GitHubPRRefreshCandidate { - if (outcome.kind === 'upstream-error') { - return candidate - } - return { - ...candidate, - cachedFetchedAt: outcome.fetchedAt, - cachedHasPR: outcome.kind === 'found', - cachedPRState: outcome.kind === 'found' ? outcome.pr.state : null, - cachedChecksStatus: outcome.kind === 'found' ? outcome.pr.checksStatus : null, - cachedMergeable: outcome.kind === 'found' ? outcome.pr.mergeable : null, - cachedMergeStateStatus: outcome.kind === 'found' ? (outcome.pr.mergeStateStatus ?? null) : null - } -} - -function setVisibleFollowUp(entry: QueueEntry): void { - const existing = queue.get(entry.key) - if (!existing) { - queue.set(entry.key, entry) - return - } - - for (const alias of entry.aliases.values()) { - existing.aliases.set(alias.cacheKey, alias) - } - - // Why: a user activation can arrive while a background refresh awaits gh; the follow-up must not overwrite that pending active/manual work. - if ( - bypassesFreshnessDelay(existing.reason) || - existing.priority > entry.priority || - existing.dueAt <= entry.dueAt - ) { - return - } - - queue.set(entry.key, { - ...entry, - aliases: existing.aliases - }) -} - -function removeQueuedAliasForInvalidCandidate(key: string, alias: GitHubPRRefreshAlias): void { - const existing = queue.get(key) - if (!existing) { - return - } - - existing.aliases.delete(alias.cacheKey) - const replacementAlias = existing.aliases.values().next().value - if (!replacementAlias) { - queue.delete(key) - resetKeyRetryState(key) - return - } - - if (existing.candidate.cacheKey === alias.cacheKey) { - existing.candidate = { - ...existing.candidate, - cacheKey: replacementAlias.cacheKey, - branch: replacementAlias.branch, - worktreeId: replacementAlias.worktreeId, - // Why: the probe now represents the replacement worktree, so use its head — else the survivor's link never clears. - currentHeadOid: replacementAlias.currentHeadOid ?? null, - isArchived: false, - isBare: false - } - } -} - -/** - * Advances the visible-key error backoff and returns the earliest retry time. - * Why: only visible keys auto-retry, so callers must gate on `isVisibleKey` first. - */ -function nextVisibleErrorRetryAt(key: string): number { - const failures = (errorBackoff.get(key)?.failures ?? 0) + 1 - const retryAt = Date.now() + lookupBackoffDelayMs(failures) - errorBackoff.set(key, { failures, retryAt }) - return retryAt -} - -/** - * Stamps the auto-retry time onto an error outcome before broadcast. - * Why: sets only `nextAutoRetryAt`, never `retryDisabledUntil` — that disables *manual* Retry and is reserved for a real rate-limit gate. - */ -function withErrorSchedule(outcome: PRRefreshOutcome, retryAt: number): PRRefreshOutcome { - if (outcome.kind !== 'upstream-error') { - return outcome - } - // Why: honor a real Retry-After cooldown the client stamped (`retryDisabledUntil`) so we don't auto-retry into an active secondary rate limit. - const cooldownUntil = outcome.retryDisabledUntil - return { - ...outcome, - nextAutoRetryAt: cooldownUntil !== undefined ? Math.max(retryAt, cooldownUntil) : retryAt - } -} - -function scheduleVisibleFollowUp( - key: string, - candidate: GitHubPRRefreshCandidate, - outcome: PRRefreshOutcome, - priority: number, - aliases: GitHubPRRefreshAlias[], - windowId?: number, - options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number } -): void { - if (!isVisibleKey(key)) { - // Why: manual/active refreshes can remove the queued visible retry after its owner window is gone, orphaning the backoff. - resetKeyRetryState(key) - return - } - if (outcome.kind === 'upstream-error') { - // Why: reuse the retry time already computed for the broadcast so the same failure isn't counted twice against the backoff. - const retryAt = options?.plannedRetryAt ?? nextVisibleErrorRetryAt(key) - setVisibleFollowUp({ - key, - candidate, - aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), - reason: 'visible', - priority, - dueAt: retryAt, - queuedAt: nextQueueOrder(), - windowId - }) - // Why: this is a delayed retry, not active work; a spinner would make visible worktrees look stuck until backoff expires. - scheduleDrain(retryAt - Date.now()) - return - } - resetKeyRetryState(key) - const followUpCandidate = visibleCandidateAfterOutcome(candidate, outcome) - const regularDueAt = freshRetryAt(followUpCandidate) ?? Date.now() - const pendingMergeabilityDueAt = - options?.pendingMergeabilityDelayMs !== undefined && isMergeabilityPendingOutcome(outcome) - ? outcome.fetchedAt + options.pendingMergeabilityDelayMs - : null - const dueAt = - pendingMergeabilityDueAt === null - ? regularDueAt - : Math.min(regularDueAt, pendingMergeabilityDueAt) - // Why: a coalesced linked-PR refresh may represent several branches; preserve every alias so all cache entries keep getting updates. - setVisibleFollowUp({ - key, - candidate: followUpCandidate, - aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), - reason: 'visible', - priority, - dueAt, - queuedAt: nextQueueOrder(), - // Why: this manual one-shot fixes GitHub's transient UNKNOWN state; visible spacing would delay it past the prompt retry window. - bypassBackgroundBudget: pendingMergeabilityDueAt !== null, - windowId - }) - scheduleDrain(Math.max(0, dueAt - Date.now())) -} - -function refreshIntervalForCandidate(candidate: GitHubPRRefreshCandidate): number { - if (candidate.cachedPRState === 'closed' || candidate.cachedPRState === 'merged') { - return 30 * 60_000 - } - if (candidate.cachedHasPR === false) { - return NO_REVIEW_REFRESH_INTERVAL_MS - } - if ( - candidate.cachedHasPR === true && - candidate.cachedPRState === 'open' && - candidate.cachedMergeable === 'UNKNOWN' && - !hasResolvedMergeStateStatus(candidate.cachedMergeStateStatus) - ) { - // Why: GitHub returns transient UNKNOWN mergeability while computing the test merge; visible merge buttons need a prompt follow-up. - return MERGEABILITY_PENDING_REFRESH_MS - } - if (candidate.cachedChecksStatus === 'success') { - return 10 * 60_000 - } - if (candidate.cachedChecksStatus === 'failure') { - return 3 * 60_000 - } - if (candidate.cachedChecksStatus === 'pending') { - return 90_000 - } - return MIN_BACKGROUND_REFRESH_AGE_MS -} - -function hasResolvedMergeStateStatus(status: string | null | undefined): boolean { - return status === 'CLEAN' || status === 'BEHIND' || status === 'BLOCKED' -} - -function isMergeabilityPendingOutcome(outcome: PRRefreshOutcome): boolean { - return ( - outcome.kind === 'found' && - outcome.pr.state === 'open' && - outcome.pr.mergeable === 'UNKNOWN' && - !hasResolvedMergeStateStatus(outcome.pr.mergeStateStatus) - ) -} - -function backgroundRefreshBuckets(): ('core' | 'graphql')[] { - // Why: branch refreshes prefer REST but can fall back to `gh pr list`, so guard both buckets until the client exposes a per-lookup cost plan. - return ['core', 'graphql'] -} - -function noteBackgroundStart(): void { - const now = Date.now() - lastBackgroundStartAt = now - backgroundStarts.push(now) - while (backgroundStarts.length > 0 && now - backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS) { - backgroundStarts.shift() - } -} - -function nextBudgetDelay(): number { - const now = Date.now() - while (backgroundStarts.length > 0 && now - backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS) { - backgroundStarts.shift() - } - const spacingDelay = - lastBackgroundStartAt > 0 - ? Math.max(0, MIN_BACKGROUND_SPACING_MS - (now - lastBackgroundStartAt)) - : 0 - const windowDelay = - backgroundStarts.length < BACKGROUND_BUDGET_MAX - ? 0 - : Math.max(1_000, BACKGROUND_BUDGET_WINDOW_MS - (now - backgroundStarts[0])) - return Math.max(spacingDelay, windowDelay) -} - -function activeBurstScope(entry: QueueEntry): string { - const runtimeScope = entry.candidate.connectionId - ? `ssh:${entry.candidate.connectionId}` - : `local:${entry.candidate.localGitOptions?.wslDistro ?? 'host'}` - return `${entry.windowId ?? 'global'}::${runtimeScope}` -} - -function pruneActiveStarts(scope: string, now: number): number[] { - const activeStarts = activeStartsByScope.get(scope) ?? [] - while (activeStarts.length > 0 && now - activeStarts[0] >= ACTIVE_BURST_WINDOW_MS) { - activeStarts.shift() - } - if (activeStarts.length === 0) { - activeStartsByScope.delete(scope) - } else { - activeStartsByScope.set(scope, activeStarts) - } - return activeStarts -} - -function nextActiveBurstDelay(entry: QueueEntry): number { - const now = Date.now() - const activeStarts = pruneActiveStarts(activeBurstScope(entry), now) - if (activeStarts.length < ACTIVE_BURST_MAX) { - return 0 - } - return Math.max(1, ACTIVE_BURST_WINDOW_MS - (now - activeStarts[0])) -} - -function noteActiveStart(entry: QueueEntry): void { - const now = Date.now() - const scope = activeBurstScope(entry) - const activeStarts = pruneActiveStarts(scope, now) - activeStarts.push(now) - activeStartsByScope.set(scope, activeStarts) -} - -function activeOrder(a: QueueEntry, b: QueueEntry): number { - if (a.reason !== 'active' || b.reason !== 'active') { - return 0 - } - if (activeBurstScope(a) !== activeBurstScope(b)) { - return 0 - } - // Why: refresh the worktree the user lands on before stale transient selections in the same window/runtime scope. - return b.queuedAt - a.queuedAt -} - -function entryDelay(entry: QueueEntry): number { - const activeBurstDelay = entry.reason === 'active' ? nextActiveBurstDelay(entry) : 0 - if (activeBurstDelay > 0) { - return activeBurstDelay - } - return isBudgetedQueueEntry(entry) ? nextBudgetDelay() : 0 -} - -function isActiveBurstDelayed(entry: QueueEntry): boolean { - return entry.reason === 'active' && nextActiveBurstDelay(entry) > 0 -} - -function nextQueuedWakeDelay(excludedKey: string): number | null { - const now = Date.now() - let nextDelay = Number.POSITIVE_INFINITY - for (const entry of queue.values()) { - if (entry.key === excludedKey) { - continue - } - const delay = entry.dueAt > now ? entry.dueAt - now : entryDelay(entry) - nextDelay = Math.min(nextDelay, delay) - } - return Number.isFinite(nextDelay) ? Math.max(0, nextDelay) : null -} - -function scheduleDrain(delay = 0): void { - if (drainTimer) { - clearTimeout(drainTimer) - } - drainTimer = setTimeout(() => { - drainTimer = null - void drainQueue() - }, delay) -} - -function queuedEntriesByPriority(): QueueEntry[] { - const now = Date.now() - return Array.from(queue.values()).sort((a, b) => { - const aReady = a.dueAt <= now - const bReady = b.dueAt <= now - if (aReady && bReady) { - return b.priority - a.priority || activeOrder(a, b) || a.dueAt - b.dueAt - } - if (aReady !== bReady) { - return aReady ? -1 : 1 - } - return a.dueAt - b.dueAt || b.priority - a.priority - }) -} - -async function drainQueue(): Promise { - if (draining) { - return - } - draining = true - try { - while (queue.size > 0) { - let next = queuedEntriesByPriority()[0] - const waitMs = next.dueAt - Date.now() - if (waitMs > 0) { - scheduleDrain(waitMs) - return - } - - let delay = entryDelay(next) - if (delay > 0) { - const runnable = queuedEntriesByPriority().find( - (entry) => entry.dueAt <= Date.now() && entryDelay(entry) === 0 - ) - if (runnable && runnable.key !== next.key) { - next = runnable - delay = 0 - } else { - if (isActiveBurstDelayed(next) && !next.activeDelayNotified) { - next.activeDelayNotified = true - broadcast({ - aliases: Array.from(next.aliases.values()), - reason: next.reason, - status: 'queued' - }) - } - if (isBudgetedQueueEntry(next) && nextBudgetDelay() > 0) { - diagnosticsCounters.backgroundPauses += 1 - recordPRRefreshQueueDiagnostic('background-pause', next.reason) - } - scheduleDrain(Math.min(delay, nextQueuedWakeDelay(next.key) ?? delay)) - return - } - } - - queue.delete(next.key) - const aliases = Array.from(next.aliases.values()) - const skippedReason = validateCandidate(next.candidate) - if (skippedReason) { - diagnosticsCounters.skipped += 1 - recordPRRefreshQueueDiagnostic('skipped', next.reason, skippedReason) - broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason }) - continue - } - if (next.reason === 'visible' && !isVisibleKey(next.key)) { - resetKeyRetryState(next.key) - broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason: 'fresh' }) - continue - } - const requestSequence = nextSequence() - const requestStartedAt = Date.now() - broadcast( - { aliases, reason: next.reason, status: 'in-flight', requestStartedAt }, - requestSequence - ) - - if (isBackground(next.reason)) { - const executionOptions = ghRepoExecOptions( - githubRepoContext( - next.candidate.repoPath, - next.candidate.connectionId, - next.candidate.localGitOptions - ) - ) - const repository = await getOriginGitHubApiRepository( - next.candidate.repoPath, - next.candidate.connectionId, - executionOptions - ) - // Why: only native github.com uses the singleton snapshot; scoped breakers protect GHES and WSL. - if (spendsSharedGitHubComQuota(repository, executionOptions)) { - // Why: the probe only warms the cache, so failures must fail open (#7553). - await getRateLimit() - } - const buckets = backgroundRefreshBuckets() - const blockedGuard = buckets - .map((bucket) => repositoryRateLimitGuard(repository, bucket, executionOptions)) - .find((guard) => guard.blocked) - if (blockedGuard?.blocked) { - const retryAt = blockedGuard.resetAt * 1000 - queue.set(next.key, { ...next, dueAt: retryAt }) - broadcast({ - aliases, - reason: next.reason, - status: 'paused', - pausedUntil: retryAt, - skippedReason: 'rate-limit' - }) - scheduleDrain(Math.max(1_000, retryAt - Date.now())) - continue - } - if (isBudgetedQueueEntry(next)) { - noteBackgroundStart() - } - if (next.reason === 'active') { - // Why: tab/worktree churn can enqueue many distinct active refreshes that each probe local Git. - noteActiveStart(next) - } - // Why (#11532): the lookup itself now debits the snapshot, so every - // caller is accounted for; debiting here too would double-count. - } - - const outcome = await getPRForBranchOutcome( - next.candidate.repoPath, - next.candidate.branch, - next.candidate.linkedPRNumber ?? null, - next.candidate.connectionId ?? null, - next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null, - ...hostedReviewOptionArgs(next.candidate) - ) - // Why: compute the retry schedule before broadcasting so the outcome and its follow-up share one timing; only visible keys auto-retry. - let plannedRetryAt: number | undefined - let broadcastOutcome = outcome - if (outcome.kind === 'upstream-error' && isVisibleKey(next.key)) { - plannedRetryAt = nextVisibleErrorRetryAt(next.key) - broadcastOutcome = withErrorSchedule(outcome, plannedRetryAt) - } - outcomeObserver?.(next.candidate, outcome) - noteManualRetryGate(next.key, broadcastOutcome) - broadcast( - { aliases, reason: next.reason, outcome: broadcastOutcome, requestStartedAt }, - requestSequence - ) - scheduleVisibleFollowUp( - next.key, - next.candidate, - outcome, - next.priority, - aliases, - next.windowId, - { - plannedRetryAt - } - ) - } - } finally { - draining = false - } + queue.pruneWorktreeAliases(worktreeId) } export function enqueuePRRefresh( @@ -824,66 +68,18 @@ export function enqueuePRRefresh( const key = refreshKey(candidate) const skippedReason = validateCandidate(candidate) if (skippedReason) { - removeQueuedAliasForInvalidCandidate(key, alias) - diagnosticsCounters.skipped += 1 - recordPRRefreshQueueDiagnostic('skipped', reason, skippedReason) - broadcast({ - aliases: [alias], - reason, - status: 'skipped', - skippedReason - }) + queue.removeInvalidAlias(key, alias) + events.record('skipped', reason, skippedReason) + events.broadcast({ aliases: [alias], reason, status: 'skipped', skippedReason }) return } - const existing = queue.get(key) - const freshDueAt = shouldSkipFresh(candidate, reason) ? freshRetryAt(candidate) : null - const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0) - if (existing) { - existing.aliases.set(alias.cacheKey, alias) - diagnosticsCounters.coalesced += 1 - recordPRRefreshQueueDiagnostic('coalesced', reason) - const shouldPromoteExisting = - priority > existing.priority || - isManual(reason) || - (reason === 'active' && existing.reason === 'active') || - (priority >= existing.priority && dueAt < existing.dueAt && bypassesFreshnessDelay(reason)) - if (shouldPromoteExisting) { - existing.priority = priority - existing.reason = reason - existing.dueAt = Math.min(existing.dueAt, dueAt) - existing.queuedAt = nextQueueOrder() - existing.activeDelayNotified = false - existing.candidate = candidate - existing.windowId = windowId ?? existing.windowId - } else if (existing.candidate.worktreeId === candidate.worktreeId) { - // Why: the representative drives the probe head; refresh probe inputs when its worktree moved head/branch, else a merged linked PR lingers after a switch. - existing.candidate = { - ...existing.candidate, - cacheKey: candidate.cacheKey, - branch: candidate.branch, - currentHeadOid: candidate.currentHeadOid ?? null - } - } - } else { - diagnosticsCounters.enqueued += 1 - recordPRRefreshQueueDiagnostic('enqueued', reason) - queue.set(key, { - key, - candidate, - aliases: new Map([[alias.cacheKey, alias]]), - reason, - priority, - dueAt, - queuedAt: nextQueueOrder(), - windowId - }) + const enqueued = queue.enqueue(candidate, reason, priority, windowId) + events.record(enqueued.coalesced ? 'coalesced' : 'enqueued', reason) + if (shouldBroadcastQueued(reason, enqueued.dueAt)) { + events.broadcast({ aliases: [enqueued.alias], reason, status: 'queued' }) } - // Why: visible/SWR are background maintenance behind the budget queue; only user/action-driven queueing should surface in UI. - if (shouldBroadcastQueued(reason, dueAt)) { - broadcast({ aliases: [alias], reason, status: 'queued' }) - } - scheduleDrain() + drainer.schedule() } export function reportVisiblePRRefreshCandidates( @@ -891,11 +87,9 @@ export function reportVisiblePRRefreshCandidates( generation: number, windowId: number ): void { - const existingVisible = visibleByWindow.get(windowId) - if (existingVisible && generation < existingVisible.generation) { + if (!visibility.report(candidates, generation, windowId)) { return } - visibleByWindow.set(windowId, { generation, keys: new Set(candidates.map(refreshKey)) }) removeInvisibleVisibleRefreshes() for (const candidate of candidates) { enqueuePRRefresh(candidate, 'visible', 40, windowId) @@ -903,11 +97,11 @@ export function reportVisiblePRRefreshCandidates( } export function _getVisiblePRRefreshWindowCountForTests(): number { - return visibleByWindow.size + return visibility.windowCount } export function _getPRRefreshErrorBackoffCountForTests(): number { - return errorBackoff.size + return retry.errorBackoffCount } export function _getPRRefreshQueueSizeForTests(): number { @@ -915,7 +109,7 @@ export function _getPRRefreshQueueSizeForTests(): number { } export function _getPRRefreshAliasCountForTests(key: string): number { - return queue.get(key)?.aliases.size ?? 0 + return queue.aliasCount(key) } export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise { @@ -927,68 +121,54 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise const aliases = Array.from(aliasMap.values()) const skippedReason = validateCandidate(candidate) if (skippedReason) { - removeQueuedAliasForInvalidCandidate(key, alias) + queue.removeInvalidAlias(key, alias) const outcome: PRRefreshOutcome = { kind: 'upstream-error', errorType: 'unknown', message: `Cannot refresh PR for this worktree: ${skippedReason}`, fetchedAt: Date.now() } - broadcast({ aliases: [alias], reason: 'manual', status: 'skipped', skippedReason }) + events.broadcast({ aliases: [alias], reason: 'manual', status: 'skipped', skippedReason }) return outcome } - // Why: enforce the rate-limit gate so a stale renderer can't bypass it; refuse without spending quota until the later of the two cooldowns. - const manualExecutionOptions = ghRepoExecOptions( - githubRepoContext(candidate.repoPath, candidate.connectionId, candidate.localGitOptions) - ) - const manualRepository = await getOriginGitHubApiRepository( - candidate.repoPath, - candidate.connectionId, - manualExecutionOptions - ) - const manualBlockedGuard = backgroundRefreshBuckets() - .map((bucket) => repositoryRateLimitGuard(manualRepository, bucket, manualExecutionOptions)) - .find((guard) => guard.blocked) - const secondaryGateUntil = manualRetryGates.get(key) - const gateUntil = Math.max( - manualBlockedGuard?.blocked ? manualBlockedGuard.resetAt * 1000 : 0, - secondaryGateUntil !== undefined ? secondaryGateUntil : 0 - ) + const primaryGateUntil = await prRefreshRateLimitPausedUntil(candidate, false) + const gateUntil = Math.max(primaryGateUntil ?? 0, retry.manualGateUntil(key)) if (gateUntil > Date.now()) { - const retryAt = gateUntil - // Why: paused maps `pausedUntil` into the renderer's auto-retry, so requeue at reset (finding 12) — don't advertise an unscheduled retry. queue.set(key, { key, candidate, aliases: aliasMap, reason: 'manual', priority: 40, - dueAt: retryAt, - queuedAt: nextQueueOrder() + dueAt: gateUntil, + queuedAt: queue.nextOrder() }) - broadcast({ + events.broadcast({ aliases, reason: 'manual', status: 'paused', - pausedUntil: retryAt, + pausedUntil: gateUntil, skippedReason: 'rate-limit' }) - scheduleDrain(Math.max(1_000, retryAt - Date.now())) + drainer.schedule(Math.max(1_000, gateUntil - Date.now())) return { kind: 'upstream-error', errorType: 'rate_limited', message: 'GitHub is temporarily limiting requests. Try again after the limit resets.', fetchedAt: Date.now(), - nextAutoRetryAt: retryAt, - retryDisabledUntil: retryAt + nextAutoRetryAt: gateUntil, + retryDisabledUntil: gateUntil } } queue.delete(key) - const requestSequence = nextSequence() + const requestSequence = events.nextSequence() const requestStartedAt = Date.now() - broadcast({ aliases, reason: 'manual', status: 'in-flight', requestStartedAt }, requestSequence) + events.broadcast( + { aliases, reason: 'manual', status: 'in-flight', requestStartedAt }, + requestSequence + ) const outcome = await getPRForBranchOutcome( candidate.repoPath, candidate.branch, @@ -999,19 +179,18 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise ) let plannedRetryAt: number | undefined let broadcastOutcome = outcome - if (outcome.kind === 'upstream-error' && isVisibleKey(key)) { - plannedRetryAt = nextVisibleErrorRetryAt(key) - broadcastOutcome = withErrorSchedule(outcome, plannedRetryAt) + if (outcome.kind === 'upstream-error' && visibility.has(key)) { + plannedRetryAt = retry.nextVisibleErrorRetryAt(key) + broadcastOutcome = retry.withErrorSchedule(outcome, plannedRetryAt) } - outcomeObserver?.(candidate, outcome) - noteManualRetryGate(key, broadcastOutcome) - broadcast( + events.observe(candidate, outcome) + retry.noteManualGate(key, broadcastOutcome) + events.broadcast( { aliases, reason: 'manual', outcome: broadcastOutcome, requestStartedAt }, requestSequence ) - scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, { + drainer.scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, { plannedRetryAt, - // Why: GitHub reports UNKNOWN right after `gh pr reopen`; one prompt visible retry replaces the transient label. pendingMergeabilityDelayMs: MANUAL_MERGEABILITY_PENDING_REFRESH_MS }) return broadcastOutcome diff --git a/src/main/github/pr-refresh-event-publisher.ts b/src/main/github/pr-refresh-event-publisher.ts new file mode 100644 index 00000000000..b028729f1f5 --- /dev/null +++ b/src/main/github/pr-refresh-event-publisher.ts @@ -0,0 +1,69 @@ +import type { + GitHubPRRefreshCandidate, + GitHubPRRefreshEvent, + GitHubPRRefreshReason, + GitHubPRRefreshSkippedReason, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import { recordCoalescedCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { sendToTrustedUIRenderer } from '../ipc/ui' + +export type PRRefreshOutcomeObserver = ( + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome +) => void + +const DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS = 30_000 + +export class PRRefreshEventPublisher { + private sequence = 0 + private observer: PRRefreshOutcomeObserver | null = null + private readonly counters = { enqueued: 0, coalesced: 0, skipped: 0, backgroundPauses: 0 } + + setOutcomeObserver(observer: PRRefreshOutcomeObserver | null): void { + this.observer = observer + } + + observe(candidate: GitHubPRRefreshCandidate, outcome: PRRefreshOutcome): void { + this.observer?.(candidate, outcome) + } + + nextSequence(): number { + this.sequence += 1 + return this.sequence + } + + broadcast(event: Omit, sequenceOverride?: number): void { + const payload = { + ...event, + sequence: sequenceOverride ?? this.nextSequence() + } as GitHubPRRefreshEvent + sendToTrustedUIRenderer('gh:prRefreshEvent', payload) + } + + record( + event: 'enqueued' | 'coalesced' | 'skipped' | 'background-pause', + reason: GitHubPRRefreshReason, + skippedReason?: GitHubPRRefreshSkippedReason + ): void { + if (event === 'background-pause') { + this.counters.backgroundPauses += 1 + } else { + this.counters[event] += 1 + } + recordCoalescedCrashBreadcrumb({ + name: 'pr_refresh_queue', + coalesceKey: `pr-refresh-queue:${event}:${reason}:${skippedReason ?? ''}`, + minIntervalMs: DIAGNOSTIC_BREADCRUMB_MIN_INTERVAL_MS, + data: { + event, + reason, + ...(skippedReason ? { skippedReason } : {}), + enqueued: this.counters.enqueued, + coalesced: this.counters.coalesced, + skipped: this.counters.skipped, + backgroundPauses: this.counters.backgroundPauses + } + }) + } +} diff --git a/src/main/github/pr-refresh-pacing.ts b/src/main/github/pr-refresh-pacing.ts new file mode 100644 index 00000000000..7a88bd74a0a --- /dev/null +++ b/src/main/github/pr-refresh-pacing.ts @@ -0,0 +1,114 @@ +import type { PRRefreshQueueEntry } from './pr-refresh-queue' + +const BACKGROUND_BUDGET_WINDOW_MS = 5 * 60_000 +const MIN_BACKGROUND_SPACING_MS = 10_000 +const BACKGROUND_BUDGET_MAX = 20 +const ACTIVE_BURST_WINDOW_MS = 30_000 +const ACTIVE_BURST_MAX = 3 + +export class PRRefreshPacing { + private readonly backgroundStarts: number[] = [] + private readonly activeStartsByScope = new Map() + private lastBackgroundStartAt = 0 + + clearActiveBurstWindow(windowId: number): void { + const windowPrefix = `${windowId}::` + for (const scope of Array.from(this.activeStartsByScope.keys())) { + if (scope.startsWith(windowPrefix)) { + this.activeStartsByScope.delete(scope) + } + } + } + + noteBackgroundStart(): void { + const now = Date.now() + this.lastBackgroundStartAt = now + this.backgroundStarts.push(now) + this.pruneBackgroundStarts(now) + } + + nextBudgetDelay(): number { + const now = Date.now() + this.pruneBackgroundStarts(now) + const spacingDelay = + this.lastBackgroundStartAt > 0 + ? Math.max(0, MIN_BACKGROUND_SPACING_MS - (now - this.lastBackgroundStartAt)) + : 0 + const windowDelay = + this.backgroundStarts.length < BACKGROUND_BUDGET_MAX + ? 0 + : Math.max(1_000, BACKGROUND_BUDGET_WINDOW_MS - (now - this.backgroundStarts[0])) + return Math.max(spacingDelay, windowDelay) + } + + activeOrder(a: PRRefreshQueueEntry, b: PRRefreshQueueEntry): number { + if (a.reason !== 'active' || b.reason !== 'active') { + return 0 + } + if (this.activeBurstScope(a) !== this.activeBurstScope(b)) { + return 0 + } + return b.queuedAt - a.queuedAt + } + + entryDelay(entry: PRRefreshQueueEntry): number { + const activeDelay = entry.reason === 'active' ? this.nextActiveBurstDelay(entry) : 0 + if (activeDelay > 0) { + return activeDelay + } + return entry.bypassBackgroundBudget !== true && + (entry.reason === 'visible' || entry.reason === 'swr') + ? this.nextBudgetDelay() + : 0 + } + + isActiveBurstDelayed(entry: PRRefreshQueueEntry): boolean { + return entry.reason === 'active' && this.nextActiveBurstDelay(entry) > 0 + } + + noteActiveStart(entry: PRRefreshQueueEntry): void { + const now = Date.now() + const scope = this.activeBurstScope(entry) + const activeStarts = this.pruneActiveStarts(scope, now) + activeStarts.push(now) + this.activeStartsByScope.set(scope, activeStarts) + } + + private pruneBackgroundStarts(now: number): void { + while ( + this.backgroundStarts.length > 0 && + now - this.backgroundStarts[0] > BACKGROUND_BUDGET_WINDOW_MS + ) { + this.backgroundStarts.shift() + } + } + + private activeBurstScope(entry: PRRefreshQueueEntry): string { + const runtimeScope = entry.candidate.connectionId + ? `ssh:${entry.candidate.connectionId}` + : `local:${entry.candidate.localGitOptions?.wslDistro ?? 'host'}` + return `${entry.windowId ?? 'global'}::${runtimeScope}` + } + + private pruneActiveStarts(scope: string, now: number): number[] { + const activeStarts = this.activeStartsByScope.get(scope) ?? [] + while (activeStarts.length > 0 && now - activeStarts[0] >= ACTIVE_BURST_WINDOW_MS) { + activeStarts.shift() + } + if (activeStarts.length === 0) { + this.activeStartsByScope.delete(scope) + } else { + this.activeStartsByScope.set(scope, activeStarts) + } + return activeStarts + } + + private nextActiveBurstDelay(entry: PRRefreshQueueEntry): number { + const now = Date.now() + const activeStarts = this.pruneActiveStarts(this.activeBurstScope(entry), now) + if (activeStarts.length < ACTIVE_BURST_MAX) { + return 0 + } + return Math.max(1, ACTIVE_BURST_WINDOW_MS - (now - activeStarts[0])) + } +} diff --git a/src/main/github/pr-refresh-queue-drainer.ts b/src/main/github/pr-refresh-queue-drainer.ts new file mode 100644 index 00000000000..d1e37492e32 --- /dev/null +++ b/src/main/github/pr-refresh-queue-drainer.ts @@ -0,0 +1,242 @@ +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import { getPRForBranchOutcome } from './client' +import { + freshRetryAt, + hostedReviewOptionArgs, + isBackground, + isMergeabilityPendingOutcome, + validateCandidate, + visibleCandidateAfterOutcome +} from './pr-refresh-candidate-policy' +import type { PRRefreshEventPublisher } from './pr-refresh-event-publisher' +import type { PRRefreshPacing } from './pr-refresh-pacing' +import type { PRRefreshQueue, PRRefreshQueueEntry } from './pr-refresh-queue' +import { prRefreshRateLimitPausedUntil } from './pr-refresh-rate-limit-gate' +import type { PRRefreshRetryState } from './pr-refresh-retry-state' +import type { PRRefreshVisibility } from './pr-refresh-visibility' + +export class PRRefreshQueueDrainer { + private draining = false + private timer: ReturnType | null = null + + constructor( + private readonly queue: PRRefreshQueue, + private readonly pacing: PRRefreshPacing, + private readonly visibility: PRRefreshVisibility, + private readonly retry: PRRefreshRetryState, + private readonly events: PRRefreshEventPublisher + ) {} + + schedule(delay = 0): void { + if (this.timer) { + clearTimeout(this.timer) + } + this.timer = setTimeout(() => { + this.timer = null + void this.drain() + }, delay) + } + + scheduleVisibleFollowUp( + key: string, + candidate: GitHubPRRefreshCandidate, + outcome: PRRefreshOutcome, + priority: number, + aliases: GitHubPRRefreshAlias[], + windowId?: number, + options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number } + ): void { + if (!this.visibility.has(key)) { + this.retry.reset(key) + return + } + if (outcome.kind === 'upstream-error') { + const retryAt = options?.plannedRetryAt ?? this.retry.nextVisibleErrorRetryAt(key) + this.queue.setVisibleFollowUp({ + key, + candidate, + aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), + reason: 'visible', + priority, + dueAt: retryAt, + queuedAt: this.queue.nextOrder(), + windowId + }) + this.schedule(retryAt - Date.now()) + return + } + this.retry.reset(key) + const followUpCandidate = visibleCandidateAfterOutcome(candidate, outcome) + const regularDueAt = freshRetryAt(followUpCandidate) ?? Date.now() + const pendingDueAt = + options?.pendingMergeabilityDelayMs !== undefined && isMergeabilityPendingOutcome(outcome) + ? outcome.fetchedAt + options.pendingMergeabilityDelayMs + : null + const dueAt = pendingDueAt === null ? regularDueAt : Math.min(regularDueAt, pendingDueAt) + this.queue.setVisibleFollowUp({ + key, + candidate: followUpCandidate, + aliases: new Map(aliases.map((alias) => [alias.cacheKey, alias])), + reason: 'visible', + priority, + dueAt, + queuedAt: this.queue.nextOrder(), + bypassBackgroundBudget: pendingDueAt !== null, + windowId + }) + this.schedule(Math.max(0, dueAt - Date.now())) + } + + private ordered(): PRRefreshQueueEntry[] { + return this.queue.ordered((a, b) => this.pacing.activeOrder(a, b)) + } + + private nextQueuedWakeDelay(excludedKey: string): number | null { + const now = Date.now() + let nextDelay = Number.POSITIVE_INFINITY + for (const entry of this.queue.values()) { + if (entry.key === excludedKey) { + continue + } + const delay = entry.dueAt > now ? entry.dueAt - now : this.pacing.entryDelay(entry) + nextDelay = Math.min(nextDelay, delay) + } + return Number.isFinite(nextDelay) ? Math.max(0, nextDelay) : null + } + + private async drain(): Promise { + if (this.draining) { + return + } + this.draining = true + try { + while (this.queue.size > 0) { + let next = this.ordered()[0] + const waitMs = next.dueAt - Date.now() + if (waitMs > 0) { + this.schedule(waitMs) + return + } + + let delay = this.pacing.entryDelay(next) + if (delay > 0) { + const runnable = this.ordered().find( + (entry) => entry.dueAt <= Date.now() && this.pacing.entryDelay(entry) === 0 + ) + if (runnable && runnable.key !== next.key) { + next = runnable + delay = 0 + } else { + this.notePacingDelay(next) + this.schedule(Math.min(delay, this.nextQueuedWakeDelay(next.key) ?? delay)) + return + } + } + + this.queue.delete(next.key) + const aliases = Array.from(next.aliases.values()) + const skippedReason = validateCandidate(next.candidate) + if (skippedReason) { + this.events.record('skipped', next.reason, skippedReason) + this.events.broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason }) + continue + } + if (next.reason === 'visible' && !this.visibility.has(next.key)) { + this.retry.reset(next.key) + this.events.broadcast({ + aliases, + reason: next.reason, + status: 'skipped', + skippedReason: 'fresh' + }) + continue + } + const requestSequence = this.events.nextSequence() + const requestStartedAt = Date.now() + this.events.broadcast( + { aliases, reason: next.reason, status: 'in-flight', requestStartedAt }, + requestSequence + ) + + if (isBackground(next.reason)) { + const pausedUntil = await prRefreshRateLimitPausedUntil(next.candidate, true) + if (pausedUntil !== null) { + this.queue.set(next.key, { ...next, dueAt: pausedUntil }) + this.events.broadcast({ + aliases, + reason: next.reason, + status: 'paused', + pausedUntil, + skippedReason: 'rate-limit' + }) + this.schedule(Math.max(1_000, pausedUntil - Date.now())) + continue + } + if ( + next.bypassBackgroundBudget !== true && + (next.reason === 'visible' || next.reason === 'swr') + ) { + this.pacing.noteBackgroundStart() + } + if (next.reason === 'active') { + this.pacing.noteActiveStart(next) + } + } + + const outcome = await getPRForBranchOutcome( + next.candidate.repoPath, + next.candidate.branch, + next.candidate.linkedPRNumber ?? null, + next.candidate.connectionId ?? null, + next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null, + ...hostedReviewOptionArgs(next.candidate) + ) + let plannedRetryAt: number | undefined + let broadcastOutcome = outcome + if (outcome.kind === 'upstream-error' && this.visibility.has(next.key)) { + plannedRetryAt = this.retry.nextVisibleErrorRetryAt(next.key) + broadcastOutcome = this.retry.withErrorSchedule(outcome, plannedRetryAt) + } + this.events.observe(next.candidate, outcome) + this.retry.noteManualGate(next.key, broadcastOutcome) + this.events.broadcast( + { aliases, reason: next.reason, outcome: broadcastOutcome, requestStartedAt }, + requestSequence + ) + this.scheduleVisibleFollowUp( + next.key, + next.candidate, + outcome, + next.priority, + aliases, + next.windowId, + { plannedRetryAt } + ) + } + } finally { + this.draining = false + } + } + + private notePacingDelay(entry: PRRefreshQueueEntry): void { + if (this.pacing.isActiveBurstDelayed(entry) && !entry.activeDelayNotified) { + entry.activeDelayNotified = true + this.events.broadcast({ + aliases: Array.from(entry.aliases.values()), + reason: entry.reason, + status: 'queued' + }) + } + if ( + entry.bypassBackgroundBudget !== true && + (entry.reason === 'visible' || entry.reason === 'swr') && + this.pacing.nextBudgetDelay() > 0 + ) { + this.events.record('background-pause', entry.reason) + } + } +} diff --git a/src/main/github/pr-refresh-queue.ts b/src/main/github/pr-refresh-queue.ts new file mode 100644 index 00000000000..2cc185535fa --- /dev/null +++ b/src/main/github/pr-refresh-queue.ts @@ -0,0 +1,225 @@ +import type { + GitHubPRRefreshAlias, + GitHubPRRefreshCandidate, + GitHubPRRefreshReason +} from '../../shared/github/pull-request-refresh-types' +import { + aliasFromCandidate, + bypassesFreshnessDelay, + freshRetryAt, + POST_PUSH_DELAY_MS, + refreshKey, + shouldSkipFresh +} from './pr-refresh-candidate-policy' + +export type PRRefreshQueueEntry = { + key: string + candidate: GitHubPRRefreshCandidate + aliases: Map + reason: GitHubPRRefreshReason + priority: number + dueAt: number + queuedAt: number + bypassBackgroundBudget?: boolean + activeDelayNotified?: boolean + windowId?: number +} + +export type PRRefreshEnqueue = { + alias: GitHubPRRefreshAlias + key: string + dueAt: number + coalesced: boolean +} + +export class PRRefreshQueue { + private readonly entries = new Map() + private order = 0 + + constructor(private readonly resetRetryState: (key: string) => void) {} + + get size(): number { + return this.entries.size + } + + get(key: string): PRRefreshQueueEntry | undefined { + return this.entries.get(key) + } + + set(key: string, entry: PRRefreshQueueEntry): void { + this.entries.set(key, entry) + } + + delete(key: string): void { + this.entries.delete(key) + } + + values(): IterableIterator { + return this.entries.values() + } + + nextOrder(): number { + this.order += 1 + return this.order + } + + aliasCount(key: string): number { + return this.entries.get(key)?.aliases.size ?? 0 + } + + enqueue( + candidate: GitHubPRRefreshCandidate, + reason: GitHubPRRefreshReason, + priority: number, + windowId?: number + ): PRRefreshEnqueue { + const alias = aliasFromCandidate(candidate) + const key = refreshKey(candidate) + const existing = this.entries.get(key) + const freshDueAt = shouldSkipFresh(candidate, reason) ? freshRetryAt(candidate) : null + const dueAt = freshDueAt ?? Date.now() + (reason === 'post-push' ? POST_PUSH_DELAY_MS : 0) + if (!existing) { + this.entries.set(key, { + key, + candidate, + aliases: new Map([[alias.cacheKey, alias]]), + reason, + priority, + dueAt, + queuedAt: this.nextOrder(), + windowId + }) + return { alias, key, dueAt, coalesced: false } + } + + existing.aliases.set(alias.cacheKey, alias) + const shouldPromote = + priority > existing.priority || + reason === 'manual' || + (reason === 'active' && existing.reason === 'active') || + (priority >= existing.priority && dueAt < existing.dueAt && bypassesFreshnessDelay(reason)) + if (shouldPromote) { + existing.priority = priority + existing.reason = reason + existing.dueAt = Math.min(existing.dueAt, dueAt) + existing.queuedAt = this.nextOrder() + existing.activeDelayNotified = false + existing.candidate = candidate + existing.windowId = windowId ?? existing.windowId + } else if (existing.candidate.worktreeId === candidate.worktreeId) { + existing.candidate = { + ...existing.candidate, + cacheKey: candidate.cacheKey, + branch: candidate.branch, + currentHeadOid: candidate.currentHeadOid ?? null + } + } + return { alias, key, dueAt, coalesced: true } + } + + removeInvalidAlias(key: string, alias: GitHubPRRefreshAlias): void { + const existing = this.entries.get(key) + if (!existing) { + return + } + existing.aliases.delete(alias.cacheKey) + const replacement = existing.aliases.values().next().value + if (!replacement) { + this.entries.delete(key) + this.resetRetryState(key) + return + } + if (existing.candidate.cacheKey === alias.cacheKey) { + existing.candidate = { + ...existing.candidate, + cacheKey: replacement.cacheKey, + branch: replacement.branch, + worktreeId: replacement.worktreeId, + currentHeadOid: replacement.currentHeadOid ?? null, + isArchived: false, + isBare: false + } + } + } + + pruneWorktreeAliases(worktreeId: string): void { + for (const [key, entry] of this.entries) { + let removed = false + for (const [cacheKey, alias] of entry.aliases) { + if (alias.worktreeId === worktreeId) { + entry.aliases.delete(cacheKey) + removed = true + } + } + if (!removed) { + continue + } + if (entry.aliases.size === 0) { + this.entries.delete(key) + this.resetRetryState(key) + continue + } + if (entry.candidate.worktreeId === worktreeId) { + const replacement = entry.aliases.values().next().value + if (replacement) { + entry.candidate = { + ...entry.candidate, + cacheKey: replacement.cacheKey, + branch: replacement.branch, + worktreeId: replacement.worktreeId, + currentHeadOid: replacement.currentHeadOid ?? null + } + } + } + } + } + + removeInvisibleVisibleEntries(isVisible: (key: string) => boolean): PRRefreshQueueEntry[] { + const removed: PRRefreshQueueEntry[] = [] + for (const [key, entry] of this.entries) { + if (entry.reason !== 'visible' || isVisible(key)) { + continue + } + this.entries.delete(key) + this.resetRetryState(key) + removed.push(entry) + } + return removed + } + + setVisibleFollowUp(entry: PRRefreshQueueEntry): void { + const existing = this.entries.get(entry.key) + if (!existing) { + this.entries.set(entry.key, entry) + return + } + for (const alias of entry.aliases.values()) { + existing.aliases.set(alias.cacheKey, alias) + } + if ( + bypassesFreshnessDelay(existing.reason) || + existing.priority > entry.priority || + existing.dueAt <= entry.dueAt + ) { + return + } + this.entries.set(entry.key, { ...entry, aliases: existing.aliases }) + } + + ordered( + activeOrder: (a: PRRefreshQueueEntry, b: PRRefreshQueueEntry) => number + ): PRRefreshQueueEntry[] { + const now = Date.now() + return Array.from(this.entries.values()).sort((a, b) => { + const aReady = a.dueAt <= now + const bReady = b.dueAt <= now + if (aReady && bReady) { + return b.priority - a.priority || activeOrder(a, b) || a.dueAt - b.dueAt + } + if (aReady !== bReady) { + return aReady ? -1 : 1 + } + return a.dueAt - b.dueAt || b.priority - a.priority + }) + } +} diff --git a/src/main/github/pr-refresh-rate-limit-gate.ts b/src/main/github/pr-refresh-rate-limit-gate.ts new file mode 100644 index 00000000000..19cee2e215d --- /dev/null +++ b/src/main/github/pr-refresh-rate-limit-gate.ts @@ -0,0 +1,27 @@ +import type { GitHubPRRefreshCandidate } from '../../shared/github/pull-request-refresh-types' +import { getOriginGitHubApiRepository } from './github-api-repository' +import { ghRepoExecOptions, githubRepoContext } from './gh-utils' +import { getRateLimit, repositoryRateLimitGuard, spendsSharedGitHubComQuota } from './rate-limit' + +const BUCKETS = ['core', 'graphql'] as const + +export async function prRefreshRateLimitPausedUntil( + candidate: GitHubPRRefreshCandidate, + warmSharedSnapshot: boolean +): Promise { + const executionOptions = ghRepoExecOptions( + githubRepoContext(candidate.repoPath, candidate.connectionId, candidate.localGitOptions) + ) + const repository = await getOriginGitHubApiRepository( + candidate.repoPath, + candidate.connectionId, + executionOptions + ) + if (warmSharedSnapshot && spendsSharedGitHubComQuota(repository, executionOptions)) { + await getRateLimit() + } + const blockedGuard = BUCKETS.map((bucket) => + repositoryRateLimitGuard(repository, bucket, executionOptions) + ).find((guard) => guard.blocked) + return blockedGuard?.blocked ? blockedGuard.resetAt * 1000 : null +} diff --git a/src/main/github/pr-refresh-retry-state.ts b/src/main/github/pr-refresh-retry-state.ts new file mode 100644 index 00000000000..fa66bf021a4 --- /dev/null +++ b/src/main/github/pr-refresh-retry-state.ts @@ -0,0 +1,46 @@ +import type { PRRefreshOutcome } from '../../shared/github/pull-request-refresh-types' +import { lookupBackoffDelayMs } from '../source-control/hosted-review-refresh-pacing' + +export class PRRefreshRetryState { + private readonly errorBackoff = new Map() + private readonly manualRetryGates = new Map() + + get errorBackoffCount(): number { + return this.errorBackoff.size + } + + reset(key: string): void { + this.errorBackoff.delete(key) + this.manualRetryGates.delete(key) + } + + noteManualGate(key: string, outcome: PRRefreshOutcome): void { + if (outcome.kind === 'upstream-error' && outcome.retryDisabledUntil !== undefined) { + this.manualRetryGates.set(key, outcome.retryDisabledUntil) + } else { + this.manualRetryGates.delete(key) + } + } + + manualGateUntil(key: string): number { + return this.manualRetryGates.get(key) ?? 0 + } + + nextVisibleErrorRetryAt(key: string): number { + const failures = (this.errorBackoff.get(key)?.failures ?? 0) + 1 + const retryAt = Date.now() + lookupBackoffDelayMs(failures) + this.errorBackoff.set(key, { failures, retryAt }) + return retryAt + } + + withErrorSchedule(outcome: PRRefreshOutcome, retryAt: number): PRRefreshOutcome { + if (outcome.kind !== 'upstream-error') { + return outcome + } + const cooldownUntil = outcome.retryDisabledUntil + return { + ...outcome, + nextAutoRetryAt: cooldownUntil !== undefined ? Math.max(retryAt, cooldownUntil) : retryAt + } + } +} diff --git a/src/main/github/pr-refresh-visibility.ts b/src/main/github/pr-refresh-visibility.ts new file mode 100644 index 00000000000..9673252b6fe --- /dev/null +++ b/src/main/github/pr-refresh-visibility.ts @@ -0,0 +1,44 @@ +import { webContents } from 'electron' +import type { GitHubPRRefreshCandidate } from '../../shared/github/pull-request-refresh-types' +import { refreshKey } from './pr-refresh-candidate-policy' + +export class PRRefreshVisibility { + private readonly visibleByWindow = new Map }>() + + get windowCount(): number { + return this.visibleByWindow.size + } + + clearWindow(windowId: number): boolean { + return this.visibleByWindow.delete(windowId) + } + + report(candidates: GitHubPRRefreshCandidate[], generation: number, windowId: number): boolean { + const existing = this.visibleByWindow.get(windowId) + if (existing && generation < existing.generation) { + return false + } + this.visibleByWindow.set(windowId, { generation, keys: new Set(candidates.map(refreshKey)) }) + return true + } + + has(key: string): boolean { + const liveWindowIds = new Set( + webContents + .getAllWebContents() + .filter((contents) => !contents.isDestroyed()) + .map((contents) => contents.id) + ) + for (const windowId of Array.from(this.visibleByWindow.keys())) { + if (!liveWindowIds.has(windowId)) { + this.visibleByWindow.delete(windowId) + } + } + for (const visible of this.visibleByWindow.values()) { + if (visible.keys.has(key)) { + return true + } + } + return false + } +} diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index f9320a3b10e..9a0d9e97f78 100644 --- a/src/main/github/project-view.ts +++ b/src/main/github/project-view.ts @@ -60,20 +60,24 @@ import { githubProjectHost } from '../../shared/github/project-identity' // Re-export the public API so existing `./project-view` call sites keep working; the split is internal-only. export { isValidOwnerSlug, isValidRepoSlug } from './project-view/internals' export { classifyProjectError } from './project-view/project-error-classification' +export { updateIssueBySlug } from './project-view/mutations' export { updateProjectItemFieldValue, - clearProjectItemFieldValue, - updateIssueBySlug, - updatePullRequestBySlug, + clearProjectItemFieldValue +} from './project-view/project-field-mutations' +export { updatePullRequestBySlug } from './project-view/pull-request-mutation' +export { addIssueCommentBySlug, updateIssueCommentBySlug, - deleteIssueCommentBySlug, + deleteIssueCommentBySlug +} from './project-view/issue-comment-mutations' +export { listLabelsBySlug, listAssignableUsersBySlug, - listIssueTypesBySlug, - updateIssueTypeBySlug, - getWorkItemDetailsBySlug -} from './project-view/mutations' + listIssueTypesBySlug +} from './project-view/repository-field-options' +export { updateIssueTypeBySlug } from './project-view/issue-type-mutation' +export { getWorkItemDetailsBySlug } from './project-view/work-item-details' // ─── Constants ───────────────────────────────────────────────────────── diff --git a/src/main/github/project-view/issue-comment-mutations.ts b/src/main/github/project-view/issue-comment-mutations.ts new file mode 100644 index 00000000000..fcf7e056fb6 --- /dev/null +++ b/src/main/github/project-view/issue-comment-mutations.ts @@ -0,0 +1,111 @@ +import { assertPositiveInt, projectGhExecOptions, runRest, validateSlugArgs } from './internals' +import type { PRComment } from '../../../shared/github/comment-types' +import type { + GitHubProjectCommentMutationResult, + GitHubProjectMutationResult +} from '../../../shared/github/project-result-types' +import type { + AddIssueCommentBySlugArgs, + DeleteIssueCommentBySlugArgs, + UpdateIssueCommentBySlugArgs +} from '../../../shared/github/project-request-types' + +type RawIssueCommentResponse = { + id?: number + user?: { login?: string; avatar_url?: string; type?: string } | null + body?: string + created_at?: string + html_url?: string +} + +function mapIssueComment(data: RawIssueCommentResponse, fallbackBody: string): PRComment { + return { + id: data.id ?? Date.now(), + author: data.user?.login ?? 'You', + authorAvatarUrl: data.user?.avatar_url ?? '', + body: data.body ?? fallbackBody, + createdAt: data.created_at ?? new Date().toISOString(), + url: data.html_url ?? '', + isBot: data.user?.type === 'Bot' + } +} + +export async function addIssueCommentBySlug( + args: AddIssueCommentBySlugArgs +): Promise { + const slug = validateSlugArgs(args.owner, args.repo) + if (!slug.ok) { + return slug + } + const number = assertPositiveInt(args.number, 'number') + if (!number.ok) { + return { ok: false, error: number.error } + } + if (typeof args.body !== 'string' || !args.body.trim()) { + return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } + } + const result = await runRest( + [ + '-X', + 'POST', + `repos/${args.owner}/${args.repo}/issues/${args.number}/comments`, + '--raw-field', + `body=${args.body}` + ], + undefined, + 'core', + projectGhExecOptions(args.host) + ) + return result.ok + ? { ok: true, comment: mapIssueComment(result.data, args.body) } + : { ok: false, error: result.error } +} + +export async function updateIssueCommentBySlug( + args: UpdateIssueCommentBySlugArgs +): Promise { + const slug = validateSlugArgs(args.owner, args.repo) + if (!slug.ok) { + return slug + } + const number = assertPositiveInt(args.commentId, 'commentId') + if (!number.ok) { + return { ok: false, error: number.error } + } + if (typeof args.body !== 'string' || !args.body.trim()) { + return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } + } + const result = await runRest( + [ + '-X', + 'PATCH', + `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`, + '--raw-field', + `body=${args.body}` + ], + undefined, + 'core', + projectGhExecOptions(args.host) + ) + return result.ok ? { ok: true } : { ok: false, error: result.error } +} + +export async function deleteIssueCommentBySlug( + args: DeleteIssueCommentBySlugArgs +): Promise { + const slug = validateSlugArgs(args.owner, args.repo) + if (!slug.ok) { + return slug + } + const number = assertPositiveInt(args.commentId, 'commentId') + if (!number.ok) { + return { ok: false, error: number.error } + } + const result = await runRest( + ['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`], + undefined, + 'core', + { expectEmpty: true, ...projectGhExecOptions(args.host) } + ) + return result.ok ? { ok: true } : { ok: false, error: result.error } +} diff --git a/src/main/github/project-view/issue-type-mutation.ts b/src/main/github/project-view/issue-type-mutation.ts new file mode 100644 index 00000000000..d36cf26955a --- /dev/null +++ b/src/main/github/project-view/issue-type-mutation.ts @@ -0,0 +1,54 @@ +import { + assertPositiveInt, + projectGhExecOptions, + runGraphql, + validateSlugArgs, + type GraphqlVars +} from './internals' +import type { GitHubProjectMutationResult } from '../../../shared/github/project-result-types' +import type { UpdateIssueTypeBySlugArgs } from '../../../shared/github/project-request-types' + +export async function updateIssueTypeBySlug( + args: UpdateIssueTypeBySlugArgs +): Promise { + const validation = validateSlugArgs(args.owner, args.repo) + if (!validation.ok) { + return validation + } + const number = assertPositiveInt(args.number, 'number') + if (!number.ok) { + return { ok: false, error: number.error } + } + const lookup = await runGraphql<{ + repository?: { issue?: { id?: string } | null } | null + }>( + `query($owner:String!, $repo:String!, $num:Int!) { + repository(owner:$owner, name:$repo) { issue(number:$num) { id } } + }`, + { owner: args.owner, repo: args.repo, num: args.number }, + projectGhExecOptions(args.host) + ) + if (!lookup.ok) { + return { ok: false, error: lookup.error } + } + const issueId = lookup.data.repository?.issue?.id + if (!issueId) { + return { ok: false, error: { type: 'not_found', message: 'Issue not found.' } } + } + const query = args.issueTypeId + ? `mutation($issueId:ID!, $issueTypeId:ID!) { + updateIssueIssueType(input: { issueId: $issueId, issueTypeId: $issueTypeId }) { + issue { id } + } + }` + : `mutation($issueId:ID!) { + updateIssueIssueType(input: { issueId: $issueId, issueTypeId: null }) { + issue { id } + } + }` + const vars: GraphqlVars = args.issueTypeId + ? { issueId, issueTypeId: args.issueTypeId } + : { issueId } + const result = await runGraphql(query, vars, projectGhExecOptions(args.host)) + return result.ok ? { ok: true } : { ok: false, error: result.error } +} diff --git a/src/main/github/project-view/mutations.test.ts b/src/main/github/project-view/mutations.test.ts index 30a00a0f110..4876f6da14b 100644 --- a/src/main/github/project-view/mutations.test.ts +++ b/src/main/github/project-view/mutations.test.ts @@ -33,7 +33,7 @@ vi.mock('./internals', () => ({ repositoryRateLimitGuard: repositoryRateLimitGuardMock, noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, projectHostAuthenticationError: projectHostAuthenticationErrorMock, - projectGhExecOptions: (host?: string) => (host ? { host } : {}), + projectGhExecOptions: (host?: string) => ({ host: host ?? 'github.com' }), classifyProjectError: (stderr: string) => ({ type: 'unknown', message: stderr }), rateLimitedError: () => ({ type: 'rate_limited', message: 'rate limited' }), runGraphql: runGraphqlMock, @@ -46,7 +46,8 @@ vi.mock('./internals', () => ({ : { ok: false, error: { type: 'validation_error', message: `${name} invalid` } } })) -import { getWorkItemDetailsBySlug, updateIssueBySlug } from './mutations' +import { updateIssueBySlug } from './mutations' +import { getWorkItemDetailsBySlug } from './work-item-details' describe('updateIssueBySlug', () => { beforeEach(() => { diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts index 8e06899b512..65d7eb32ec9 100644 --- a/src/main/github/project-view/mutations.ts +++ b/src/main/github/project-view/mutations.ts @@ -1,202 +1,75 @@ -/* eslint-disable max-lines -- Why: slug-addressed mutations + work-item -details share the validate/runRest/runGraphql plumbing with the read path. -Keeping them together preserves a single review surface for the write side. */ import { acquire, - release, + assertPositiveInt, extractExecError, ghExecFileAsync, - repositoryRateLimitGuard, noteRepositoryRateLimitSpend, - runGraphql, - runRest, - validateSlugArgs, - assertPositiveInt, - projectHostAuthenticationError, projectGhExecOptions, - type GraphqlVars + projectHostAuthenticationError, + release, + repositoryRateLimitGuard, + runRest, + validateSlugArgs } from './internals' import { classifyProjectError, rateLimitedError } from './project-error-classification' -import { githubProjectHost } from '../../../shared/github/project-identity' -import type { PRComment } from '../../../shared/github/comment-types' -import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' -import type { GitHubWorkItemDetails } from '../../../shared/github/work-item-types' -import type { GitHubProjectFieldMutationValue } from '../../../shared/github/project-types' -import type { - GitHubProjectCommentMutationResult, - GitHubProjectMutationResult, - ListAssignableUsersBySlugResult, - ListIssueTypesBySlugResult, - ListLabelsBySlugResult, - ProjectWorkItemDetailsBySlugResult -} from '../../../shared/github/project-result-types' -import type { - AddIssueCommentBySlugArgs, - ClearProjectItemFieldArgs, - DeleteIssueCommentBySlugArgs, - ListAssignableUsersBySlugArgs, - ListIssueTypesBySlugArgs, - ListLabelsBySlugArgs, - ProjectWorkItemDetailsBySlugArgs, - UpdateIssueBySlugArgs, - UpdateIssueCommentBySlugArgs, - UpdateIssueTypeBySlugArgs, - UpdatePullRequestBySlugArgs, - UpdateProjectItemFieldArgs -} from '../../../shared/github/project-request-types' - -function githubHostExecOptions(args: { host?: string }): { host: string } { - return { host: githubProjectHost(args.host) } -} - -// ─── Project field mutations ────────────────────────────────────────── - -class UnknownFieldMutationKindError extends Error { - constructor(kind: string) { - super(`Unknown project field mutation kind: ${kind}`) - } -} - -function graphqlValueForFieldMutation(value: GitHubProjectFieldMutationValue): string { - // Serialize the value fragment for the GraphQL mutation. We use GraphQL - // variables for every dynamic piece, so here we only pick the variable name - // to reference per value kind. - switch (value.kind) { - case 'single-select': - return 'singleSelectOptionId: $value' - case 'iteration': - return 'iterationId: $value' - case 'text': - return 'text: $value' - case 'number': - return 'number: $value' - case 'date': - return 'date: $value' - } - // Why: keep a runtime guard for malformed IPC payloads while lint enforces - // that every typed mutation kind is handled above. - throw new UnknownFieldMutationKindError((value as { kind: string }).kind) -} - -function mutationValueVar(value: GitHubProjectFieldMutationValue): { - type: string - val: string | number -} { - switch (value.kind) { - case 'single-select': - return { type: 'String!', val: value.optionId } - case 'iteration': - return { type: 'String!', val: value.iterationId } - case 'text': - return { type: 'String!', val: value.text } - case 'number': - return { type: 'Float!', val: value.number } - case 'date': - return { type: 'Date!', val: value.date } - } - // Why: see graphqlValueForFieldMutation — surface unknown kinds loudly - // instead of returning undefined and dispatching an invalid mutation. - throw new UnknownFieldMutationKindError((value as { kind: string }).kind) -} - -export async function updateProjectItemFieldValue( - args: UpdateProjectItemFieldArgs -): Promise { - if (!args.projectId || !args.itemId || !args.fieldId) { - return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } - } - let valFrag: string - let valVar: { type: string; val: string | number } - try { - valFrag = graphqlValueForFieldMutation(args.value) - valVar = mutationValueVar(args.value) - } catch (err) { - if (err instanceof UnknownFieldMutationKindError) { - return { ok: false, error: { type: 'validation_error', message: err.message } } - } - throw err - } - const query = ` - mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $value:${valVar.type}) { - updateProjectV2ItemFieldValue(input: { - projectId: $projectId - itemId: $itemId - fieldId: $fieldId - value: { ${valFrag} } - }) { projectV2Item { id } } - } - ` - const vars: GraphqlVars = { - projectId: args.projectId, - itemId: args.itemId, - fieldId: args.fieldId, - value: valVar.val - } - const res = await runGraphql(query, vars, projectGhExecOptions(args.host)) - if (!res.ok) { - return { ok: false, error: res.error } - } - return { ok: true } -} - -export async function clearProjectItemFieldValue( - args: ClearProjectItemFieldArgs -): Promise { - if (!args.projectId || !args.itemId || !args.fieldId) { - return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } - } - const query = ` - mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!) { - clearProjectV2ItemFieldValue(input: { - projectId: $projectId - itemId: $itemId - fieldId: $fieldId - }) { projectV2Item { id } } - } - ` - const res = await runGraphql( - query, - { - projectId: args.projectId, - itemId: args.itemId, - fieldId: args.fieldId - }, - projectGhExecOptions(args.host) - ) - if (!res.ok) { - return { ok: false, error: res.error } - } - return { ok: true } -} - -// ─── Slug-addressed issue/PR mutations ──────────────────────────────── +import type { GitHubProjectMutationResult } from '../../../shared/github/project-result-types' +import type { UpdateIssueBySlugArgs } from '../../../shared/github/project-request-types' export async function updateIssueBySlug( args: UpdateIssueBySlugArgs ): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v + const validation = validateSlugArgs(args.owner, args.repo) + if (!validation.ok) { + return validation } - const n = assertPositiveInt(args.number, 'number') - if (!n.ok) { - return { ok: false, error: n.error } + const number = assertPositiveInt(args.number, 'number') + if (!number.ok) { + return { ok: false, error: number.error } } if (!args.updates || typeof args.updates !== 'object') { return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } } } - const { - title, - body, - state, - stateReason, - duplicateOf, - addLabels, - removeLabels, - addAssignees, - removeAssignees - } = args.updates + const duplicateError = validateDuplicateUpdate(args) + if (duplicateError) { + return duplicateError + } + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError } + } + const base = `repos/${args.owner}/${args.repo}/issues/${args.number}` + const stateResult = await updateIssueState(args) + if (!stateResult.ok) { + return stateResult + } + const { title, body } = args.updates + if (title !== undefined || body !== undefined) { + const patchArgs = ['-X', 'PATCH', base] + if (title !== undefined) { + patchArgs.push('--raw-field', `title=${title}`) + } + if (body !== undefined) { + patchArgs.push('--raw-field', `body=${body}`) + } + const result = await runRest( + patchArgs, + undefined, + 'core', + projectGhExecOptions(args.host) + ) + if (!result.ok) { + return { ok: false, error: result.error } + } + } + const labelResult = await updateIssueLabels(args, base) + if (!labelResult.ok) { + return labelResult + } + return updateIssueAssignees(args, base) +} +function validateDuplicateUpdate(args: UpdateIssueBySlugArgs): GitHubProjectMutationResult | null { + const { duplicateOf, state, stateReason } = args.updates if (duplicateOf !== undefined && (state !== 'closed' || stateReason !== 'duplicate')) { return { ok: false, @@ -221,726 +94,137 @@ export async function updateIssueBySlug( return { ok: false, error: duplicate.error } } } - const authError = await projectHostAuthenticationError(args.host) - if (authError) { - return { ok: false, error: authError } + return null +} + +async function updateIssueState(args: UpdateIssueBySlugArgs): Promise { + const { state, stateReason, duplicateOf } = args.updates + if (state === undefined) { + return { ok: true } } - - // Title/body go through PATCH /repos/{owner}/{repo}/issues/{n}. - // State uses gh issue close/reopen so duplicate closes can record a target. - // Labels/assignees go through their dedicated endpoints. - const base = `repos/${args.owner}/${args.repo}/issues/${args.number}` - - if (state !== undefined) { - const guard = repositoryRateLimitGuard(args, 'core') - if (guard.blocked) { - return { ok: false, error: rateLimitedError(guard) } - } - const stateArgs = - state === 'closed' - ? ['issue', 'close', String(args.number), '--repo', `${args.owner}/${args.repo}`] - : ['issue', 'reopen', String(args.number), '--repo', `${args.owner}/${args.repo}`] - if (state === 'closed') { - if (stateReason === 'completed') { - stateArgs.push('--reason', 'completed') - } else if (stateReason === 'not_planned') { - stateArgs.push('--reason', 'not planned') - } else if (stateReason === 'duplicate') { - stateArgs.push('--duplicate-of', String(duplicateOf)) - } - } - await acquire() - noteRepositoryRateLimitSpend(args, 'core') - try { - await ghExecFileAsync(stateArgs, { encoding: 'utf-8', ...githubHostExecOptions(args) }) - } catch (err) { - const { stderr, stdout } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, stdout, args.host) } - } finally { - release() + const guard = repositoryRateLimitGuard(args, 'core') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } + const stateArgs = + state === 'closed' + ? ['issue', 'close', String(args.number), '--repo', `${args.owner}/${args.repo}`] + : ['issue', 'reopen', String(args.number), '--repo', `${args.owner}/${args.repo}`] + if (state === 'closed') { + if (stateReason === 'completed') { + stateArgs.push('--reason', 'completed') + } else if (stateReason === 'not_planned') { + stateArgs.push('--reason', 'not planned') + } else if (stateReason === 'duplicate') { + stateArgs.push('--duplicate-of', String(duplicateOf)) } } - - // 1) PATCH body - if (title !== undefined || body !== undefined) { - const patchArgs: string[] = ['-X', 'PATCH', base] - if (title !== undefined) { - patchArgs.push('--raw-field', `title=${title}`) - } - if (body !== undefined) { - patchArgs.push('--raw-field', `body=${body}`) - } - const r = await runRest(patchArgs, undefined, 'core', githubHostExecOptions(args)) - if (!r.ok) { - return { ok: false, error: r.error } - } + await acquire() + noteRepositoryRateLimitSpend(args, 'core') + try { + await ghExecFileAsync(stateArgs, { + encoding: 'utf-8', + ...projectGhExecOptions(args.host) + }) + return { ok: true } + } catch (error) { + const { stderr, stdout } = extractExecError(error) + return { ok: false, error: classifyProjectError(stderr, stdout, args.host) } + } finally { + release() } +} - // 2) Labels — collapse multi-delete fan-out into a single PUT when removing - // >1 label. PUT /labels replaces the entire label set, so we fetch the - // current labels first and compute the resulting set client-side. This - // turns an N-delete + 1-add (=N+1 calls) into 1-fetch + 1-PUT (=2 calls) - // once removeLabels has more than one entry, capping the cost at 2 even - // for a "remove all 20 labels" mutation. - const removeCount = removeLabels?.length ?? 0 - const addCount = addLabels?.length ?? 0 - if (removeCount > 1) { - type RawLabelResp = { name?: string }[] - const fetched = await runRest( +async function updateIssueLabels( + args: UpdateIssueBySlugArgs, + base: string +): Promise { + const { addLabels = [], removeLabels = [] } = args.updates + const options = projectGhExecOptions(args.host) + if (removeLabels.length > 1) { + const fetched = await runRest<{ name?: string }[]>( ['-X', 'GET', `${base}/labels`], undefined, 'core', - githubHostExecOptions(args) + options ) if (!fetched.ok) { return { ok: false, error: fetched.error } } - const currentNames = new Set( - fetched.data.map((l) => l.name).filter((n): n is string => typeof n === 'string') + const names = new Set( + fetched.data.map((label) => label.name).filter((name): name is string => Boolean(name)) ) - for (const l of removeLabels ?? []) { - currentNames.delete(l) + for (const label of removeLabels) { + names.delete(label) } - for (const l of addLabels ?? []) { - currentNames.add(l) + for (const label of addLabels) { + names.add(label) } - if (currentNames.size === 0) { - // Why: `gh api -X PUT` with no `--raw-field` arguments sends an empty - // body — GitHub does NOT interpret that as "clear labels". The - // dedicated DELETE endpoint is the documented way to remove all - // labels in a single call. - const r = await runRest(['-X', 'DELETE', `${base}/labels`], undefined, 'core', { + if (names.size === 0) { + const result = await runRest(['-X', 'DELETE', `${base}/labels`], undefined, 'core', { expectEmpty: true, - ...githubHostExecOptions(args) + ...options }) - if (!r.ok && r.error.type !== 'not_found') { - return { ok: false, error: r.error } - } - } else { - const putArgs = ['-X', 'PUT', `${base}/labels`] - for (const name of currentNames) { - putArgs.push('--raw-field', `labels[]=${name}`) - } - const r = await runRest(putArgs, undefined, 'core', githubHostExecOptions(args)) - if (!r.ok) { - return { ok: false, error: r.error } - } + return result.ok || result.error.type === 'not_found' + ? { ok: true } + : { ok: false, error: result.error } } - } else { - if (addCount > 0) { - const restArgs = ['-X', 'POST', `${base}/labels`] - for (const l of addLabels ?? []) { - restArgs.push('--raw-field', `labels[]=${l}`) - } - const r = await runRest(restArgs, undefined, 'core', githubHostExecOptions(args)) - if (!r.ok) { - return { ok: false, error: r.error } - } + const putArgs = ['-X', 'PUT', `${base}/labels`] + for (const name of names) { + putArgs.push('--raw-field', `labels[]=${name}`) } - if (removeCount === 1) { - const r = await runRest( - ['-X', 'DELETE', `${base}/labels/${encodeURIComponent(removeLabels![0])}`], - undefined, - 'core', - { expectEmpty: true, ...githubHostExecOptions(args) } - ) - if (!r.ok && r.error.type !== 'not_found') { - return { ok: false, error: r.error } - } + const result = await runRest(putArgs, undefined, 'core', options) + return result.ok ? { ok: true } : { ok: false, error: result.error } + } + if (addLabels.length > 0) { + const postArgs = ['-X', 'POST', `${base}/labels`] + for (const label of addLabels) { + postArgs.push('--raw-field', `labels[]=${label}`) + } + const result = await runRest(postArgs, undefined, 'core', options) + if (!result.ok) { + return { ok: false, error: result.error } } } - - // 3) Assignees — POST and DELETE both accept arrays in a single call, so - // add/remove are at most 2 calls regardless of array size. - if (addAssignees && addAssignees.length > 0) { - const restArgs = ['-X', 'POST', `${base}/assignees`] - for (const u of addAssignees) { - restArgs.push('--raw-field', `assignees[]=${u}`) - } - const r = await runRest(restArgs, undefined, 'core', githubHostExecOptions(args)) - if (!r.ok) { - return { ok: false, error: r.error } - } - } - if (removeAssignees && removeAssignees.length > 0) { - const restArgs = ['-X', 'DELETE', `${base}/assignees`] - for (const u of removeAssignees) { - restArgs.push('--raw-field', `assignees[]=${u}`) - } - const r = await runRest(restArgs, undefined, 'core', githubHostExecOptions(args)) - if (!r.ok) { - return { ok: false, error: r.error } - } - } - return { ok: true } -} - -export async function updatePullRequestBySlug( - args: UpdatePullRequestBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.number, 'number') - if (!n.ok) { - return { ok: false, error: n.error } - } - if (!args.updates || typeof args.updates !== 'object') { - return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } } - } - const patchArgs: string[] = [ - '-X', - 'PATCH', - `repos/${args.owner}/${args.repo}/pulls/${args.number}` - ] - // Why: count fields explicitly rather than inferring from patchArgs.length — - // adding a future header/flag arg silently breaks an array-length check. - let fieldCount = 0 - if (args.updates.title !== undefined) { - patchArgs.push('--raw-field', `title=${args.updates.title}`) - fieldCount++ - } - if (args.updates.body !== undefined) { - patchArgs.push('--raw-field', `body=${args.updates.body}`) - fieldCount++ - } - if (args.updates.state !== undefined) { - patchArgs.push('--raw-field', `state=${args.updates.state}`) - fieldCount++ - } - if (fieldCount === 0) { - // No fields to update — nothing to do. - return { ok: true } - } - const r = await runRest(patchArgs, undefined, 'core', githubHostExecOptions(args)) - if (!r.ok) { - return { ok: false, error: r.error } - } - return { ok: true } -} - -type RawIssueCommentResponse = { - id?: number - user?: { login?: string; avatar_url?: string; type?: string } | null - body?: string - created_at?: string - html_url?: string -} - -function mapIssueComment(data: RawIssueCommentResponse, fallbackBody: string): PRComment { - return { - id: data.id ?? Date.now(), - author: data.user?.login ?? 'You', - authorAvatarUrl: data.user?.avatar_url ?? '', - body: data.body ?? fallbackBody, - createdAt: data.created_at ?? new Date().toISOString(), - url: data.html_url ?? '', - isBot: data.user?.type === 'Bot' - } -} - -export async function addIssueCommentBySlug( - args: AddIssueCommentBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.number, 'number') - if (!n.ok) { - return { ok: false, error: n.error } - } - if (typeof args.body !== 'string' || !args.body.trim()) { - return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } - } - const r = await runRest( - [ - '-X', - 'POST', - `repos/${args.owner}/${args.repo}/issues/${args.number}/comments`, - '--raw-field', - `body=${args.body}` - ], - undefined, - 'core', - githubHostExecOptions(args) - ) - if (!r.ok) { - return { ok: false, error: r.error } - } - return { ok: true, comment: mapIssueComment(r.data, args.body) } -} - -export async function updateIssueCommentBySlug( - args: UpdateIssueCommentBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.commentId, 'commentId') - if (!n.ok) { - return { ok: false, error: n.error } - } - if (typeof args.body !== 'string' || !args.body.trim()) { - return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } - } - const r = await runRest( - [ - '-X', - 'PATCH', - `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`, - '--raw-field', - `body=${args.body}` - ], - undefined, - 'core', - githubHostExecOptions(args) - ) - if (!r.ok) { - return { ok: false, error: r.error } - } - return { ok: true } -} - -export async function deleteIssueCommentBySlug( - args: DeleteIssueCommentBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.commentId, 'commentId') - if (!n.ok) { - return { ok: false, error: n.error } - } - const r = await runRest( - ['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`], - undefined, - 'core', - { expectEmpty: true, ...githubHostExecOptions(args) } - ) - if (!r.ok) { - return { ok: false, error: r.error } - } - return { ok: true } -} - -// ─── Slug-addressed picker sources ──────────────────────────────────── - -export async function listLabelsBySlug( - args: ListLabelsBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const authError = await projectHostAuthenticationError(args.host) - if (authError) { - return { ok: false, error: authError } - } - const guard = repositoryRateLimitGuard(args, 'core') - if (guard.blocked) { - return { ok: false, error: rateLimitedError(guard) } - } - await acquire() - // Why: `--paginate` may fan out to multiple pages; we can only reasonably - // estimate a 1-call spend up front. The next probe will reconcile. - noteRepositoryRateLimitSpend(args, 'core') - try { - const { stdout } = await ghExecFileAsync( - ['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'], - { encoding: 'utf-8', ...githubHostExecOptions(args) } + if (removeLabels.length === 1) { + const result = await runRest( + ['-X', 'DELETE', `${base}/labels/${encodeURIComponent(removeLabels[0])}`], + undefined, + 'core', + { expectEmpty: true, ...options } ) - return { - ok: true, - labels: stdout - .trim() - .split('\n') - .filter((l) => l.length > 0) + if (!result.ok && result.error.type !== 'not_found') { + return { ok: false, error: result.error } } - } catch (err) { - const { stderr, stdout: maybeStdout } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, maybeStdout, args.host) } - } finally { - release() - } -} - -export async function listAssignableUsersBySlug( - args: ListAssignableUsersBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const authError = await projectHostAuthenticationError(args.host) - if (authError) { - return { ok: false, error: authError } - } - // Seed logins merge after the fetch so callers can include currently-visible - // assignees even if the repo participant search is sparse. - const result: GitHubAssignableUser[] = [] - const guard = repositoryRateLimitGuard(args, 'core') - if (guard.blocked) { - return { ok: false, error: rateLimitedError(guard) } - } - await acquire() - noteRepositoryRateLimitSpend(args, 'core') - try { - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--paginate', - `repos/${args.owner}/${args.repo}/assignees`, - '--jq', - '.[] | {login: .login, name: null, avatarUrl: .avatar_url}' - ], - { encoding: 'utf-8', ...githubHostExecOptions(args) } - ) - for (const line of stdout - .trim() - .split('\n') - .filter((l) => l.length > 0)) { - try { - const u = JSON.parse(line) as { login?: string; avatarUrl?: string; name?: string | null } - if (typeof u.login === 'string') { - result.push({ login: u.login, name: u.name ?? null, avatarUrl: u.avatarUrl ?? '' }) - } - } catch { - // skip malformed jq line - } - } - } catch (err) { - const { stderr } = extractExecError(err) - return { ok: false, error: classifyProjectError(stderr, '', args.host) } - } finally { - release() - } - if (args.seedLogins) { - const seen = new Set(result.map((u) => u.login)) - for (const login of args.seedLogins) { - if (typeof login === 'string' && !seen.has(login)) { - result.push({ login, name: null, avatarUrl: '' }) - seen.add(login) - } - } - } - return { ok: true, users: result } -} - -// Why: Issue Types are a repo-level taxonomy (Bug/Feature/Task/etc) only -// available on repos opted into typed-issues. Empty list (or schema_drift on -// older GitHub deployments) is the legitimate "this repo doesn't use issue -// types" signal — callers should treat it as "no editor". -export async function listIssueTypesBySlug( - args: ListIssueTypesBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const query = ` - query($owner:String!, $repo:String!) { - repository(owner:$owner, name:$repo) { - issueTypes(first:50) { - nodes { id name color description } - } - } - } - ` - const res = await runGraphql<{ - repository?: { - issueTypes?: { - nodes?: ({ - id?: string - name?: string - color?: string | null - description?: string | null - } | null)[] - } | null - } | null - }>(query, { owner: args.owner, repo: args.repo }, githubHostExecOptions(args)) - if (!res.ok) { - // Why: repos without issue types respond with a GraphQL error claiming the - // `issueTypes` field is unknown. Map that to an empty list so the UI shows - // "no editor" instead of an angry banner. - if (res.error.type === 'schema_drift' || res.error.type === 'validation_error') { - return { ok: true, types: [] } - } - return { ok: false, error: res.error } - } - const nodes = res.data.repository?.issueTypes?.nodes ?? [] - const types = nodes - .filter( - (n): n is NonNullable => - n !== null && typeof n.id === 'string' && typeof n.name === 'string' - ) - .map((n) => ({ - id: n.id as string, - name: n.name as string, - color: typeof n.color === 'string' ? n.color : null, - description: typeof n.description === 'string' ? n.description : null - })) - return { ok: true, types } -} - -export async function updateIssueTypeBySlug( - args: UpdateIssueTypeBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.number, 'number') - if (!n.ok) { - return { ok: false, error: n.error } - } - // Why: `updateIssueIssueType` is the dedicated mutation; passing null for - // `issueTypeId` clears the type. We resolve the issue id via a lightweight - // GraphQL lookup because the REST endpoint doesn't accept issue types. - const lookup = await runGraphql<{ - repository?: { issue?: { id?: string } | null } | null - }>( - `query($owner:String!, $repo:String!, $num:Int!) { - repository(owner:$owner, name:$repo) { issue(number:$num) { id } } - }`, - { owner: args.owner, repo: args.repo, num: args.number }, - githubHostExecOptions(args) - ) - if (!lookup.ok) { - return { ok: false, error: lookup.error } - } - const issueId = lookup.data.repository?.issue?.id - if (!issueId) { - return { ok: false, error: { type: 'not_found', message: 'Issue not found.' } } - } - // Why: build the mutation conditionally so a null clear doesn't have to - // smuggle a null GraphQL variable through `gh api graphql -f`. The - // mutation accepts a literal `null` in the input object directly. - const query = args.issueTypeId - ? ` - mutation($issueId:ID!, $issueTypeId:ID!) { - updateIssueIssueType(input: { issueId: $issueId, issueTypeId: $issueTypeId }) { - issue { id } - } - } - ` - : ` - mutation($issueId:ID!) { - updateIssueIssueType(input: { issueId: $issueId, issueTypeId: null }) { - issue { id } - } - } - ` - const vars: GraphqlVars = args.issueTypeId - ? { issueId, issueTypeId: args.issueTypeId } - : { issueId } - const res = await runGraphql(query, vars, githubHostExecOptions(args)) - if (!res.ok) { - return { ok: false, error: res.error } } return { ok: true } } -// ─── Slug-addressed work-item details ───────────────────────────────── - -type RawUser = { login?: string; name?: string | null; avatarUrl?: string | null } -type RawLabel = { name?: string; color?: string } -type RawWorkItemContent = { - id?: string - number?: number - title?: string - url?: string - state?: string - stateReason?: string | null - isDraft?: boolean - labels?: { nodes?: RawLabel[] } - assignees?: { nodes?: RawUser[] } -} - -export async function getWorkItemDetailsBySlug( - args: ProjectWorkItemDetailsBySlugArgs -): Promise { - const v = validateSlugArgs(args.owner, args.repo) - if (!v.ok) { - return v - } - const n = assertPositiveInt(args.number, 'number') - if (!n.ok) { - return { ok: false, error: n.error } - } - if (args.type !== 'issue' && args.type !== 'pr') { - return { ok: false, error: { type: 'validation_error', message: 'Invalid type.' } } - } - - // Single GraphQL round-trip to fetch the issue/PR summary + comments + labels + assignees. - const contentFrag = - args.type === 'issue' - ? ` - issue(number:$num) { - id number title url state stateReason updatedAt - body - author { login } - labels(first:50) { nodes { name } } - assignees(first:50) { nodes { login } } - participants(first:50) { nodes { login name avatarUrl } } - comments(first:100) { - nodes { - databaseId - author { login avatarUrl __typename } - body createdAt url - } - } - } - ` - : ` - pullRequest(number:$num) { - id number title url state isDraft updatedAt headRefName baseRefName - body - author { login } - labels(first:50) { nodes { name } } - assignees(first:50) { nodes { login } } - participants(first:50) { nodes { login name avatarUrl } } - comments(first:100) { - nodes { - databaseId - author { login avatarUrl __typename } - body createdAt url - } - } - } - ` - const query = ` - query($owner:String!, $repo:String!, $num:Int!) { - repository(owner:$owner, name:$repo) { - ${contentFrag} - } +async function updateIssueAssignees( + args: UpdateIssueBySlugArgs, + base: string +): Promise { + const { addAssignees = [], removeAssignees = [] } = args.updates + const options = projectGhExecOptions(args.host) + if (addAssignees.length > 0) { + const postArgs = ['-X', 'POST', `${base}/assignees`] + for (const login of addAssignees) { + postArgs.push('--raw-field', `assignees[]=${login}`) } - ` - const res = await runGraphql<{ - repository?: { - issue?: - | (RawWorkItemContent & { - updatedAt?: string - body?: string - author?: { login?: string } | null - participants?: { nodes?: RawUser[] } - comments?: { - nodes?: ({ - databaseId?: number - author?: { login?: string; avatarUrl?: string; __typename?: string } | null - body?: string - createdAt?: string - url?: string - } | null)[] - } - }) - | null - pullRequest?: - | (RawWorkItemContent & { - updatedAt?: string - body?: string - headRefName?: string - baseRefName?: string - author?: { login?: string } | null - participants?: { nodes?: RawUser[] } - comments?: { - nodes?: ({ - databaseId?: number - author?: { login?: string; avatarUrl?: string; __typename?: string } | null - body?: string - createdAt?: string - url?: string - } | null)[] - } - }) - | null - } | null - }>(query, { owner: args.owner, repo: args.repo, num: args.number }, githubHostExecOptions(args)) - if (!res.ok) { - return { ok: false, error: res.error } - } - const raw = args.type === 'issue' ? res.data.repository?.issue : res.data.repository?.pullRequest - if (!raw) { - return { ok: false, error: { type: 'not_found', message: 'Item not found.' } } - } - - const labels = (raw.labels?.nodes ?? []) - .map((l) => l?.name) - .filter((n): n is string => typeof n === 'string') - const assignees = (raw.assignees?.nodes ?? []) - .map((a) => a?.login) - .filter((l): l is string => typeof l === 'string') - const comments: PRComment[] = [] - for (const c of raw.comments?.nodes ?? []) { - if (!c || typeof c.body !== 'string') { - continue + const result = await runRest(postArgs, undefined, 'core', options) + if (!result.ok) { + return { ok: false, error: result.error } } - comments.push({ - id: typeof c.databaseId === 'number' ? c.databaseId : Date.now(), - author: c.author?.login ?? '', - authorAvatarUrl: c.author?.avatarUrl ?? '', - body: c.body, - createdAt: typeof c.createdAt === 'string' ? c.createdAt : '', - url: typeof c.url === 'string' ? c.url : '', - isBot: c.author?.__typename === 'Bot' - }) } - const participants: GitHubAssignableUser[] = [] - for (const p of raw.participants?.nodes ?? []) { - if (p && typeof p.login === 'string') { - participants.push({ login: p.login, name: p.name ?? null, avatarUrl: p.avatarUrl ?? '' }) + if (removeAssignees.length > 0) { + const deleteArgs = ['-X', 'DELETE', `${base}/assignees`] + for (const login of removeAssignees) { + deleteArgs.push('--raw-field', `assignees[]=${login}`) + } + const result = await runRest(deleteArgs, undefined, 'core', options) + if (!result.ok) { + return { ok: false, error: result.error } } - } - - const state: 'open' | 'closed' | 'merged' | 'draft' = - args.type === 'pr' - ? raw.isDraft - ? 'draft' - : raw.state === 'MERGED' - ? 'merged' - : raw.state === 'CLOSED' - ? 'closed' - : 'open' - : raw.state === 'CLOSED' - ? 'closed' - : 'open' - - const details: GitHubWorkItemDetails = { - item: { - id: typeof raw.id === 'string' ? raw.id : '', - type: args.type, - number: typeof raw.number === 'number' ? raw.number : args.number, - title: typeof raw.title === 'string' ? raw.title : '', - state, - url: typeof raw.url === 'string' ? raw.url : '', - labels, - updatedAt: - typeof (raw as { updatedAt?: string }).updatedAt === 'string' - ? (raw as { updatedAt: string }).updatedAt - : '', - author: - typeof (raw as { author?: { login?: string } | null }).author?.login === 'string' - ? ((raw as { author: { login: string } }).author.login as string) - : null, - branchName: - args.type === 'pr' && typeof (raw as { headRefName?: string }).headRefName === 'string' - ? ((raw as { headRefName: string }).headRefName as string) - : undefined, - baseRefName: - args.type === 'pr' && typeof (raw as { baseRefName?: string }).baseRefName === 'string' - ? ((raw as { baseRefName: string }).baseRefName as string) - : undefined - }, - body: typeof raw.body === 'string' ? raw.body : '', - comments, - participants, - // Why: PR files/checks/review-thread tabs depend on a local repo path and - // are out of Project-mode slug scope for v1. Omit them here; the dialog - // branches on their absence and hides those tabs. - assignees } - return { ok: true, details } + return { ok: true } } diff --git a/src/main/github/project-view/project-field-mutations.test.ts b/src/main/github/project-view/project-field-mutations.test.ts new file mode 100644 index 00000000000..3242e947a2a --- /dev/null +++ b/src/main/github/project-view/project-field-mutations.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { runGraphqlMock } = vi.hoisted(() => ({ runGraphqlMock: vi.fn() })) + +vi.mock('./internals', () => ({ + projectGhExecOptions: (host?: string) => ({ host: host ?? 'github.com' }), + runGraphql: runGraphqlMock +})) + +import { clearProjectItemFieldValue, updateProjectItemFieldValue } from './project-field-mutations' + +describe('project field mutations', () => { + beforeEach(() => { + runGraphqlMock.mockReset().mockResolvedValue({ ok: true, data: {} }) + }) + + it('maps each field value kind to a host-pinned GraphQL variable', async () => { + await updateProjectItemFieldValue({ + projectId: 'PVT_1', + itemId: 'PVTI_1', + fieldId: 'PVTF_1', + value: { kind: 'number', number: 12.5 }, + host: 'github.corp.example' + }) + + expect(runGraphqlMock).toHaveBeenCalledWith( + expect.stringContaining('$value:Float!'), + { projectId: 'PVT_1', itemId: 'PVTI_1', fieldId: 'PVTF_1', value: 12.5 }, + { host: 'github.corp.example' } + ) + }) + + it('rejects malformed field kinds without dispatching GraphQL', async () => { + const result = await updateProjectItemFieldValue({ + projectId: 'PVT_1', + itemId: 'PVTI_1', + fieldId: 'PVTF_1', + value: { kind: 'future-kind' } as never + }) + + expect(result).toMatchObject({ ok: false, error: { type: 'validation_error' } }) + expect(runGraphqlMock).not.toHaveBeenCalled() + }) + + it('keeps clear mutations host-scoped', async () => { + await clearProjectItemFieldValue({ + projectId: 'PVT_1', + itemId: 'PVTI_1', + fieldId: 'PVTF_1', + host: 'github.corp.example' + }) + + expect(runGraphqlMock).toHaveBeenCalledWith( + expect.stringContaining('clearProjectV2ItemFieldValue'), + { projectId: 'PVT_1', itemId: 'PVTI_1', fieldId: 'PVTF_1' }, + { host: 'github.corp.example' } + ) + }) +}) diff --git a/src/main/github/project-view/project-field-mutations.ts b/src/main/github/project-view/project-field-mutations.ts new file mode 100644 index 00000000000..5fc5edb1985 --- /dev/null +++ b/src/main/github/project-view/project-field-mutations.ts @@ -0,0 +1,103 @@ +import { projectGhExecOptions, runGraphql, type GraphqlVars } from './internals' +import type { GitHubProjectFieldMutationValue } from '../../../shared/github/project-types' +import type { GitHubProjectMutationResult } from '../../../shared/github/project-result-types' +import type { + ClearProjectItemFieldArgs, + UpdateProjectItemFieldArgs +} from '../../../shared/github/project-request-types' + +class UnknownFieldMutationKindError extends Error { + constructor(kind: string) { + super(`Unknown project field mutation kind: ${kind}`) + } +} + +function graphqlValueForFieldMutation(value: GitHubProjectFieldMutationValue): string { + switch (value.kind) { + case 'single-select': + return 'singleSelectOptionId: $value' + case 'iteration': + return 'iterationId: $value' + case 'text': + return 'text: $value' + case 'number': + return 'number: $value' + case 'date': + return 'date: $value' + } + throw new UnknownFieldMutationKindError((value as { kind: string }).kind) +} + +function mutationValueVar(value: GitHubProjectFieldMutationValue): { + type: string + val: string | number +} { + switch (value.kind) { + case 'single-select': + return { type: 'String!', val: value.optionId } + case 'iteration': + return { type: 'String!', val: value.iterationId } + case 'text': + return { type: 'String!', val: value.text } + case 'number': + return { type: 'Float!', val: value.number } + case 'date': + return { type: 'Date!', val: value.date } + } + throw new UnknownFieldMutationKindError((value as { kind: string }).kind) +} + +export async function updateProjectItemFieldValue( + args: UpdateProjectItemFieldArgs +): Promise { + if (!args.projectId || !args.itemId || !args.fieldId) { + return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } + } + let valueFragment: string + let valueVariable: { type: string; val: string | number } + try { + valueFragment = graphqlValueForFieldMutation(args.value) + valueVariable = mutationValueVar(args.value) + } catch (error) { + if (error instanceof UnknownFieldMutationKindError) { + return { ok: false, error: { type: 'validation_error', message: error.message } } + } + throw error + } + const query = ` + mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $value:${valueVariable.type}) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { ${valueFragment} } + }) { projectV2Item { id } } + } + ` + const vars: GraphqlVars = { + projectId: args.projectId, + itemId: args.itemId, + fieldId: args.fieldId, + value: valueVariable.val + } + const result = await runGraphql(query, vars, projectGhExecOptions(args.host)) + return result.ok ? { ok: true } : { ok: false, error: result.error } +} + +export async function clearProjectItemFieldValue( + args: ClearProjectItemFieldArgs +): Promise { + if (!args.projectId || !args.itemId || !args.fieldId) { + return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } + } + const result = await runGraphql( + `mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!) { + clearProjectV2ItemFieldValue(input: { + projectId: $projectId itemId: $itemId fieldId: $fieldId + }) { projectV2Item { id } } + }`, + { projectId: args.projectId, itemId: args.itemId, fieldId: args.fieldId }, + projectGhExecOptions(args.host) + ) + return result.ok ? { ok: true } : { ok: false, error: result.error } +} diff --git a/src/main/github/project-view/pull-request-mutation.ts b/src/main/github/project-view/pull-request-mutation.ts new file mode 100644 index 00000000000..7a4d056dab2 --- /dev/null +++ b/src/main/github/project-view/pull-request-mutation.ts @@ -0,0 +1,47 @@ +import { assertPositiveInt, projectGhExecOptions, runRest, validateSlugArgs } from './internals' +import type { GitHubProjectMutationResult } from '../../../shared/github/project-result-types' +import type { UpdatePullRequestBySlugArgs } from '../../../shared/github/project-request-types' + +export async function updatePullRequestBySlug( + args: UpdatePullRequestBySlugArgs +): Promise { + const slug = validateSlugArgs(args.owner, args.repo) + if (!slug.ok) { + return slug + } + const number = assertPositiveInt(args.number, 'number') + if (!number.ok) { + return { ok: false, error: number.error } + } + if (!args.updates || typeof args.updates !== 'object') { + return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } } + } + const patchArgs: string[] = [ + '-X', + 'PATCH', + `repos/${args.owner}/${args.repo}/pulls/${args.number}` + ] + let fieldCount = 0 + if (args.updates.title !== undefined) { + patchArgs.push('--raw-field', `title=${args.updates.title}`) + fieldCount++ + } + if (args.updates.body !== undefined) { + patchArgs.push('--raw-field', `body=${args.updates.body}`) + fieldCount++ + } + if (args.updates.state !== undefined) { + patchArgs.push('--raw-field', `state=${args.updates.state}`) + fieldCount++ + } + if (fieldCount === 0) { + return { ok: true } + } + const result = await runRest( + patchArgs, + undefined, + 'core', + projectGhExecOptions(args.host) + ) + return result.ok ? { ok: true } : { ok: false, error: result.error } +} diff --git a/src/main/github/project-view/repository-field-options.test.ts b/src/main/github/project-view/repository-field-options.test.ts new file mode 100644 index 00000000000..91123388b49 --- /dev/null +++ b/src/main/github/project-view/repository-field-options.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { acquireMock, ghExecFileAsyncMock, noteRepositoryRateLimitSpendMock, releaseMock } = + vi.hoisted(() => ({ + acquireMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + noteRepositoryRateLimitSpendMock: vi.fn(), + releaseMock: vi.fn() + })) + +vi.mock('./internals', () => ({ + acquire: acquireMock, + release: releaseMock, + extractExecError: vi.fn(), + ghExecFileAsync: ghExecFileAsyncMock, + noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock, + projectGhExecOptions: (host?: string) => ({ host: host ?? 'github.com' }), + projectHostAuthenticationError: vi.fn().mockResolvedValue(null), + repositoryRateLimitGuard: vi.fn().mockReturnValue({ blocked: false }), + runGraphql: vi.fn(), + validateSlugArgs: () => ({ ok: true }) +})) + +vi.mock('./project-error-classification', () => ({ + classifyProjectError: vi.fn(), + rateLimitedError: vi.fn() +})) + +import { listAssignableUsersBySlug, listLabelsBySlug } from './repository-field-options' + +describe('repository field options', () => { + beforeEach(() => { + acquireMock.mockReset().mockResolvedValue(undefined) + releaseMock.mockReset() + noteRepositoryRateLimitSpendMock.mockReset() + ghExecFileAsyncMock.mockReset() + }) + + it('keeps label discovery paginated and host-scoped', async () => { + ghExecFileAsyncMock.mockResolvedValue({ stdout: 'bug\nenhancement\n', stderr: '' }) + + await expect( + listLabelsBySlug({ owner: 'acme', repo: 'widgets', host: 'github.corp.example' }) + ).resolves.toEqual({ ok: true, labels: ['bug', 'enhancement'] }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['api', '--paginate', 'repos/acme/widgets/labels', '--jq', '.[].name'], + { encoding: 'utf-8', host: 'github.corp.example' } + ) + expect(releaseMock).toHaveBeenCalledOnce() + }) + + it('merges seed assignees after paginated provider results', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: '{"login":"octo","name":null,"avatarUrl":"octo.png"}\n', + stderr: '' + }) + + const result = await listAssignableUsersBySlug({ + owner: 'acme', + repo: 'widgets', + seedLogins: ['octo', 'hubot'] + }) + + expect(result).toEqual({ + ok: true, + users: [ + { login: 'octo', name: null, avatarUrl: 'octo.png' }, + { login: 'hubot', name: null, avatarUrl: '' } + ] + }) + }) +}) diff --git a/src/main/github/project-view/repository-field-options.ts b/src/main/github/project-view/repository-field-options.ts new file mode 100644 index 00000000000..ce1d80cfbf6 --- /dev/null +++ b/src/main/github/project-view/repository-field-options.ts @@ -0,0 +1,165 @@ +import { + acquire, + extractExecError, + ghExecFileAsync, + noteRepositoryRateLimitSpend, + projectGhExecOptions, + projectHostAuthenticationError, + release, + repositoryRateLimitGuard, + runGraphql, + validateSlugArgs +} from './internals' +import { classifyProjectError, rateLimitedError } from './project-error-classification' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +import type { + ListAssignableUsersBySlugResult, + ListIssueTypesBySlugResult, + ListLabelsBySlugResult +} from '../../../shared/github/project-result-types' +import type { + ListAssignableUsersBySlugArgs, + ListIssueTypesBySlugArgs, + ListLabelsBySlugArgs +} from '../../../shared/github/project-request-types' + +export async function listLabelsBySlug( + args: ListLabelsBySlugArgs +): Promise { + const validation = validateSlugArgs(args.owner, args.repo) + if (!validation.ok) { + return validation + } + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError } + } + const guard = repositoryRateLimitGuard(args, 'core') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } + await acquire() + noteRepositoryRateLimitSpend(args, 'core') + try { + const { stdout } = await ghExecFileAsync( + ['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'], + { encoding: 'utf-8', ...projectGhExecOptions(args.host) } + ) + return { ok: true, labels: stdout.trim().split('\n').filter(Boolean) } + } catch (error) { + const { stderr, stdout } = extractExecError(error) + return { ok: false, error: classifyProjectError(stderr, stdout, args.host) } + } finally { + release() + } +} + +export async function listAssignableUsersBySlug( + args: ListAssignableUsersBySlugArgs +): Promise { + const validation = validateSlugArgs(args.owner, args.repo) + if (!validation.ok) { + return validation + } + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError } + } + const guard = repositoryRateLimitGuard(args, 'core') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } + const users: GitHubAssignableUser[] = [] + await acquire() + noteRepositoryRateLimitSpend(args, 'core') + try { + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--paginate', + `repos/${args.owner}/${args.repo}/assignees`, + '--jq', + '.[] | {login: .login, name: null, avatarUrl: .avatar_url}' + ], + { encoding: 'utf-8', ...projectGhExecOptions(args.host) } + ) + for (const line of stdout.trim().split('\n').filter(Boolean)) { + try { + const user = JSON.parse(line) as { + login?: string + avatarUrl?: string + name?: string | null + } + if (typeof user.login === 'string') { + users.push({ + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + }) + } + } catch { + // Skip malformed jq output. + } + } + } catch (error) { + const { stderr } = extractExecError(error) + return { ok: false, error: classifyProjectError(stderr, '', args.host) } + } finally { + release() + } + const seen = new Set(users.map((user) => user.login)) + for (const login of args.seedLogins ?? []) { + if (typeof login === 'string' && !seen.has(login)) { + users.push({ login, name: null, avatarUrl: '' }) + seen.add(login) + } + } + return { ok: true, users } +} + +export async function listIssueTypesBySlug( + args: ListIssueTypesBySlugArgs +): Promise { + const validation = validateSlugArgs(args.owner, args.repo) + if (!validation.ok) { + return validation + } + const result = await runGraphql<{ + repository?: { + issueTypes?: { + nodes?: ({ + id?: string + name?: string + color?: string | null + description?: string | null + } | null)[] + } | null + } | null + }>( + `query($owner:String!, $repo:String!) { + repository(owner:$owner, name:$repo) { + issueTypes(first:50) { nodes { id name color description } } + } + }`, + { owner: args.owner, repo: args.repo }, + projectGhExecOptions(args.host) + ) + if (!result.ok) { + if (result.error.type === 'schema_drift' || result.error.type === 'validation_error') { + return { ok: true, types: [] } + } + return { ok: false, error: result.error } + } + const types = (result.data.repository?.issueTypes?.nodes ?? []) + .filter( + (node): node is NonNullable => + node !== null && typeof node.id === 'string' && typeof node.name === 'string' + ) + .map((node) => ({ + id: node.id as string, + name: node.name as string, + color: typeof node.color === 'string' ? node.color : null, + description: typeof node.description === 'string' ? node.description : null + })) + return { ok: true, types } +} diff --git a/src/main/github/project-view/work-item-details.ts b/src/main/github/project-view/work-item-details.ts new file mode 100644 index 00000000000..ad6d122f707 --- /dev/null +++ b/src/main/github/project-view/work-item-details.ts @@ -0,0 +1,162 @@ +import { assertPositiveInt, projectGhExecOptions, runGraphql, validateSlugArgs } from './internals' +import type { PRComment } from '../../../shared/github/comment-types' +import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' +import type { GitHubWorkItemDetails } from '../../../shared/github/work-item-types' +import type { ProjectWorkItemDetailsBySlugResult } from '../../../shared/github/project-result-types' +import type { ProjectWorkItemDetailsBySlugArgs } from '../../../shared/github/project-request-types' + +type RawUser = { login?: string; name?: string | null; avatarUrl?: string | null } +type RawLabel = { name?: string; color?: string } +type RawComment = { + databaseId?: number + author?: { login?: string; avatarUrl?: string; __typename?: string } | null + body?: string + createdAt?: string + url?: string +} +type RawWorkItemContent = { + id?: string + number?: number + title?: string + url?: string + state?: string + stateReason?: string | null + isDraft?: boolean + updatedAt?: string + body?: string + headRefName?: string + baseRefName?: string + author?: { login?: string } | null + labels?: { nodes?: RawLabel[] } + assignees?: { nodes?: RawUser[] } + participants?: { nodes?: RawUser[] } + comments?: { nodes?: (RawComment | null)[] } +} + +export async function getWorkItemDetailsBySlug( + args: ProjectWorkItemDetailsBySlugArgs +): Promise { + const validation = validateSlugArgs(args.owner, args.repo) + if (!validation.ok) { + return validation + } + const number = assertPositiveInt(args.number, 'number') + if (!number.ok) { + return { ok: false, error: number.error } + } + if (args.type !== 'issue' && args.type !== 'pr') { + return { ok: false, error: { type: 'validation_error', message: 'Invalid type.' } } + } + const contentFragment = args.type === 'issue' ? ISSUE_FRAGMENT : PULL_REQUEST_FRAGMENT + const result = await runGraphql<{ + repository?: { + issue?: RawWorkItemContent | null + pullRequest?: RawWorkItemContent | null + } | null + }>( + `query($owner:String!, $repo:String!, $num:Int!) { + repository(owner:$owner, name:$repo) { ${contentFragment} } + }`, + { owner: args.owner, repo: args.repo, num: args.number }, + projectGhExecOptions(args.host) + ) + if (!result.ok) { + return { ok: false, error: result.error } + } + const raw = + args.type === 'issue' ? result.data.repository?.issue : result.data.repository?.pullRequest + if (!raw) { + return { ok: false, error: { type: 'not_found', message: 'Item not found.' } } + } + return { ok: true, details: mapWorkItemDetails(raw, args) } +} + +const ISSUE_FRAGMENT = `issue(number:$num) { + id number title url state stateReason updatedAt body author { login } + labels(first:50) { nodes { name } } + assignees(first:50) { nodes { login } } + participants(first:50) { nodes { login name avatarUrl } } + comments(first:100) { + nodes { databaseId author { login avatarUrl __typename } body createdAt url } + } +}` + +const PULL_REQUEST_FRAGMENT = `pullRequest(number:$num) { + id number title url state isDraft updatedAt headRefName baseRefName body author { login } + labels(first:50) { nodes { name } } + assignees(first:50) { nodes { login } } + participants(first:50) { nodes { login name avatarUrl } } + comments(first:100) { + nodes { databaseId author { login avatarUrl __typename } body createdAt url } + } +}` + +function mapWorkItemDetails( + raw: RawWorkItemContent, + args: ProjectWorkItemDetailsBySlugArgs +): GitHubWorkItemDetails { + const labels = (raw.labels?.nodes ?? []) + .map((label) => label?.name) + .filter((name): name is string => typeof name === 'string') + const assignees = (raw.assignees?.nodes ?? []) + .map((assignee) => assignee?.login) + .filter((login): login is string => typeof login === 'string') + const comments: PRComment[] = [] + for (const comment of raw.comments?.nodes ?? []) { + if (!comment || typeof comment.body !== 'string') { + continue + } + comments.push({ + id: typeof comment.databaseId === 'number' ? comment.databaseId : Date.now(), + author: comment.author?.login ?? '', + authorAvatarUrl: comment.author?.avatarUrl ?? '', + body: comment.body, + createdAt: typeof comment.createdAt === 'string' ? comment.createdAt : '', + url: typeof comment.url === 'string' ? comment.url : '', + isBot: comment.author?.__typename === 'Bot' + }) + } + const participants: GitHubAssignableUser[] = [] + for (const participant of raw.participants?.nodes ?? []) { + if (participant && typeof participant.login === 'string') { + participants.push({ + login: participant.login, + name: participant.name ?? null, + avatarUrl: participant.avatarUrl ?? '' + }) + } + } + const state: 'open' | 'closed' | 'merged' | 'draft' = + args.type === 'pr' + ? raw.isDraft + ? 'draft' + : raw.state === 'MERGED' + ? 'merged' + : raw.state === 'CLOSED' + ? 'closed' + : 'open' + : raw.state === 'CLOSED' + ? 'closed' + : 'open' + return { + item: { + id: typeof raw.id === 'string' ? raw.id : '', + type: args.type, + number: typeof raw.number === 'number' ? raw.number : args.number, + title: typeof raw.title === 'string' ? raw.title : '', + state, + url: typeof raw.url === 'string' ? raw.url : '', + labels, + updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : '', + author: typeof raw.author?.login === 'string' ? raw.author.login : null, + branchName: + args.type === 'pr' && typeof raw.headRefName === 'string' ? raw.headRefName : undefined, + baseRefName: + args.type === 'pr' && typeof raw.baseRefName === 'string' ? raw.baseRefName : undefined + }, + body: typeof raw.body === 'string' ? raw.body : '', + comments, + participants, + assignees + } +} diff --git a/src/main/github/pull-request-file-contents.ts b/src/main/github/pull-request-file-contents.ts new file mode 100644 index 00000000000..fa367e1c68b --- /dev/null +++ b/src/main/github/pull-request-file-contents.ts @@ -0,0 +1,132 @@ +import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types' +import { acquire, ghExecFileAsync, ghRepoExecOptions, githubRepoContext, release } from './gh-utils' +import type { LocalGitExecOptions } from './gh-utils' +import { + githubHostExecOptions, + resolveGitHubRepoExecution, + type GitHubApiRepository +} from './github-api-repository' +import { isMaxBufferOverflowError } from '../git/max-buffer-overflow' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES = 8 * 1024 * 1024 + +function encodeGitHubContentPath(path: string): string { + return path.split('/').map(encodeURIComponent).join('/') +} + +async function fetchContentAtRef(args: { + repoPath: string + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + ownerRepo: GitHubApiRepository + path: string + ref: string +}): Promise<{ content: string; isBinary: boolean; tooLarge?: boolean }> { + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions)), + ...githubHostExecOptions(args.ownerRepo), + maxBuffer: GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES + } + if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { + return { content: '', isBinary: false } + } + try { + noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '300s', + '-H', + 'Accept: application/vnd.github.raw', + `repos/${args.ownerRepo.owner}/${args.ownerRepo.repo}/contents/${encodeGitHubContentPath(args.path)}?ref=${encodeURIComponent(args.ref)}` + ], + ghOptions + ) + const sample = stdout.slice(0, 2048) + if (sample.includes('\u0000')) { + return { content: '', isBinary: true } + } + return { content: stdout, isBinary: false } + } catch (error) { + if (isMaxBufferOverflowError(error)) { + return { content: '', isBinary: false, tooLarge: true } + } + return { content: '', isBinary: false } + } +} + +export async function getPRFileContents(args: { + repoPath: string + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + prRepo?: GitHubApiRepository | null + prNumber: number + path: string + oldPath?: string + status: GitHubPRFile['status'] + headSha: string + baseSha: string +}): Promise { + const { ownerRepo } = await resolveGitHubRepoExecution( + args.repoPath, + args.prRepo, + args.connectionId, + args.localGitOptions + ) + if (!ownerRepo) { + return { + original: '', + modified: '', + originalIsBinary: false, + modifiedIsBinary: false + } + } + + await acquire() + try { + // Added and removed files have no content on one side of the comparison. + const needsOriginal = args.status !== 'added' + const needsModified = args.status !== 'removed' + const originalPath = args.oldPath ?? args.path + const [original, modified] = await Promise.all([ + needsOriginal + ? fetchContentAtRef({ + repoPath: args.repoPath, + connectionId: args.connectionId, + localGitOptions: args.localGitOptions, + ownerRepo, + path: originalPath, + ref: args.baseSha + }) + : Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({ + content: '', + isBinary: false + }), + needsModified + ? fetchContentAtRef({ + repoPath: args.repoPath, + connectionId: args.connectionId, + localGitOptions: args.localGitOptions, + ownerRepo, + path: args.path, + ref: args.headSha + }) + : Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({ + content: '', + isBinary: false + }) + ]) + return { + original: original.content, + modified: modified.content, + originalIsBinary: original.isBinary, + modifiedIsBinary: modified.isBinary, + originalTooLarge: original.tooLarge, + modifiedTooLarge: modified.tooLarge + } + } finally { + release() + } +} diff --git a/src/main/github/pull-request-file-data.ts b/src/main/github/pull-request-file-data.ts new file mode 100644 index 00000000000..62622e4583f --- /dev/null +++ b/src/main/github/pull-request-file-data.ts @@ -0,0 +1,247 @@ +import type { GitHubPRFile, GitHubPRFileViewedState } from '../../shared/github/pull-request-types' +import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from './gh-utils' +import type { LocalGitExecOptions } from './gh-utils' +import { githubHostExecOptions, type GitHubApiRepository } from './github-api-repository' +import { getPRReviewCommentLineNumbersFromPatch } from './pr-review-comment-lines' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const MAX_PR_FILES = 300 + +const PR_FILE_VIEWED_STATES_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + id + files(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + path + viewerViewedState + } + } + } + } +}` + +type RESTPRFile = { + filename: string + previous_filename?: string + status: string + additions: number + deletions: number + changes: number + patch?: string +} + +export type PRFileViewedStatesResult = { + pullRequestId: string + viewedStates: Map +} + +function mapFileStatus(raw: string): GitHubPRFile['status'] { + switch (raw) { + case 'added': + return 'added' + case 'removed': + return 'removed' + case 'modified': + return 'modified' + case 'renamed': + return 'renamed' + case 'copied': + return 'copied' + case 'changed': + return 'changed' + case 'unchanged': + return 'unchanged' + default: + return 'modified' + } +} + +function isBinaryHint(file: RESTPRFile): boolean { + return file.patch === undefined && file.changes > 0 +} + +export async function getPRMetadata( + repoPath: string, + prNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ body: string; headSha?: string; baseSha?: string }> { + if (!repository) { + return { body: '' } + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) { + return { body: '' } + } + try { + noteRepositoryRateLimitSpend(repository, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + ['api', '--cache', '60s', `repos/${repository.owner}/${repository.repo}/pulls/${prNumber}`], + ghOptions + ) + const data = JSON.parse(stdout) as { + body?: string | null + head?: { sha?: string } + base?: { sha?: string } + } + return { + body: data.body ?? '', + ...(data.head?.sha ? { headSha: data.head.sha } : {}), + ...(data.base?.sha ? { baseSha: data.base.sha } : {}) + } + } catch { + return { body: '' } + } +} + +// null means unavailable; [] means a successfully fetched empty PR. +export async function getPRFiles( + repoPath: string, + prNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (!repository) { + return null + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + try { + const data: RESTPRFile[] = [] + for (let page = 1; data.length < MAX_PR_FILES; page += 1) { + if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) { + return null + } + const pageSuffix = page === 1 ? '' : `&page=${page}` + noteRepositoryRateLimitSpend(repository, 'core', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${repository.owner}/${repository.repo}/pulls/${prNumber}/files?per_page=100${pageSuffix}` + ], + ghOptions + ) + const pageData = JSON.parse(stdout) as RESTPRFile[] + data.push(...pageData.slice(0, MAX_PR_FILES - data.length)) + if (pageData.length < 100) { + break + } + } + return data.map((file) => ({ + path: file.filename, + oldPath: file.previous_filename, + status: mapFileStatus(file.status), + additions: file.additions, + deletions: file.deletions, + isBinary: isBinaryHint(file), + reviewCommentLineNumbers: getPRReviewCommentLineNumbersFromPatch(file.patch) + })) + } catch { + return null + } +} + +export async function getPRFileViewedStates( + repoPath: string, + prNumber: number, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (!repository) { + return null + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + return null + } + const viewedStates = new Map() + let pullRequestId: string | null = null + let after: string | null = null + + try { + for (let fetched = 0; fetched < MAX_PR_FILES; fetched += 100) { + const args = [ + 'api', + 'graphql', + '-f', + `query=${PR_FILE_VIEWED_STATES_QUERY}`, + '-f', + `owner=${repository.owner}`, + '-f', + `repo=${repository.repo}`, + '-F', + `number=${prNumber}` + ] + if (after) { + args.push('-f', `after=${after}`) + } + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync(args, ghOptions) + const parsed = JSON.parse(stdout) as { + data?: { + repository?: { + pullRequest?: { + id?: string + files?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: { + path?: string | null + viewerViewedState?: GitHubPRFileViewedState | null + }[] + } + } | null + } | null + } + errors?: { message?: string }[] + } + if (parsed.errors && parsed.errors.length > 0) { + return null + } + const pullRequest = parsed.data?.repository?.pullRequest + if (!pullRequest?.id) { + return null + } + pullRequestId = pullRequest.id + for (const file of pullRequest.files?.nodes ?? []) { + if (file.path && file.viewerViewedState) { + viewedStates.set(file.path, file.viewerViewedState) + } + } + if (!pullRequest.files?.pageInfo?.hasNextPage || !pullRequest.files.pageInfo.endCursor) { + break + } + after = pullRequest.files.pageInfo.endCursor + } + } catch { + return null + } + return pullRequestId ? { pullRequestId, viewedStates } : null +} + +export function mergePRFileViewedStates( + files: GitHubPRFile[], + viewedStates: PRFileViewedStatesResult | null +): GitHubPRFile[] { + if (!viewedStates) { + return files + } + return files.map((file) => ({ + ...file, + viewerViewedState: viewedStates.viewedStates.get(file.path) ?? 'UNVIEWED' + })) +} diff --git a/src/main/github/work-item-details-api-parity.test.ts b/src/main/github/work-item-details-api-parity.test.ts new file mode 100644 index 00000000000..187680f64cd --- /dev/null +++ b/src/main/github/work-item-details-api-parity.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' +import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types' +import type { GitHubWorkItemDetails } from '../../shared/github/work-item-types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import type { LocalGitExecOptions } from './gh-utils' +import type { GitHubApiRepository } from './github-api-repository' +import * as workItemDetails from './work-item-details' + +type GetWorkItemDetails = ( + repoPath: string, + number: number, + type?: 'issue' | 'pr', + connectionId?: string | null, + localGitOptions?: LocalGitExecOptions, + preference?: IssueSourcePreference +) => Promise + +type GetPRFileContents = (args: { + repoPath: string + connectionId?: string | null + localGitOptions?: LocalGitExecOptions + prRepo?: GitHubApiRepository | null + prNumber: number + path: string + oldPath?: string + status: GitHubPRFile['status'] + headSha: string + baseSha: string +}) => Promise + +describe('work-item-details public API parity', () => { + it('retains only the established runtime exports and call signatures', () => { + expect(Object.keys(workItemDetails).sort()).toEqual(['getPRFileContents', 'getWorkItemDetails']) + expectTypeOf(workItemDetails.getWorkItemDetails).toEqualTypeOf() + expectTypeOf(workItemDetails.getPRFileContents).toEqualTypeOf() + }) +}) diff --git a/src/main/github/work-item-details-pr-files.test.ts b/src/main/github/work-item-details-pr-files.test.ts index cac3c33eaf4..d0205332bd5 100644 --- a/src/main/github/work-item-details-pr-files.test.ts +++ b/src/main/github/work-item-details-pr-files.test.ts @@ -333,6 +333,36 @@ describe('getWorkItemDetails PR file listing', () => { ).toHaveLength(2) }) + it('skips an added file base read and preserves a head max-buffer overflow', async () => { + ghExecFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('stdout maxBuffer length exceeded'), { code: 'ENOBUFS' }) + ) + + const contents = await getPRFileContents({ + repoPath: '/repo-root', + prRepo: { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }, + prNumber: 7, + path: 'src/large.ts', + status: 'added', + headSha: 'head-sha', + baseSha: 'base-sha' + }) + + expect(contents).toEqual({ + original: '', + modified: '', + originalIsBinary: false, + modifiedIsBinary: false, + originalTooLarge: undefined, + modifiedTooLarge: true + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledOnce() + expect(ghExecFileAsyncMock.mock.calls[0][0]).toContain( + 'repos/team/orca/contents/src/large.ts?ref=head-sha' + ) + expect(noteRepositoryRateLimitSpendMock).toHaveBeenCalledOnce() + }) + it('does not fetch raw PR file contents while the repository core budget is blocked', async () => { repositoryRateLimitGuardMock.mockReturnValue({ blocked: true, diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 276ee9d33eb..dafc4f514d6 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -1,1012 +1,41 @@ -/* eslint-disable max-lines -- Why: groups the PR/Issue fetch paths and file-contents resolver so caching/rate-limit strategy lives in one place. */ import type { PRCheckDetail } from '../../shared/github/check-types' -import type { - GitHubIssueTimelineItem, - GitHubIssueTimelineTarget, - PRComment -} from '../../shared/github/comment-types' -import type { - GitHubAssignableUser, - GitHubPRFile, - GitHubPRFileContents, - GitHubPRFileViewedState -} from '../../shared/github/pull-request-types' +import type { GitHubPRFile, GitHubPRFileContents } from '../../shared/github/pull-request-types' import type { GitHubWorkItem, GitHubWorkItemDetails } from '../../shared/github/work-item-types' import type { IssueSourcePreference } from '../../shared/repo-types' -import { - ghExecFileAsync, - acquire, - release, - ghRepoExecOptions, - githubRepoContext, - type LocalGitExecOptions -} from './gh-utils' -import { getWorkItem, getPRChecks, getPRComments } from './client' +import { getPRChecks, getPRComments, getWorkItem } from './client' +import { acquire, release, type LocalGitExecOptions } from './gh-utils' import { getIssueGitHubApiRepository, - githubHostExecOptions, resolveGitHubRepoExecution, type GitHubApiRepository } from './github-api-repository' -import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' -import { getPRReviewCommentLineNumbersFromPatch } from './pr-review-comment-lines' -import { isMaxBufferOverflowError } from '../git/max-buffer-overflow' - -// Why: cap total PR files so a massive PR can't starve the gh semaphore while paging (100/page). -const MAX_PR_FILES = 300 -// Why: bound noisy issue timelines so one huge issue can't monopolize gh/API time. -const MAX_ISSUE_TIMELINE_ITEMS = 300 -const GITHUB_REST_PAGE_SIZE = 100 -// Why: raw-fetch buffer must exceed the renderer's large-diff threshold, else the UI shows an empty diff instead of the fallback. -const GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES = 8 * 1024 * 1024 +import { getIssueBodyAndComments, getIssueDetailsViaGraphQL } from './issue-work-item-details' +import { + getPRFiles, + getPRFileViewedStates, + getPRMetadata, + mergePRFileViewedStates +} from './pull-request-file-data' +import { getPRFileContents as loadPRFileContents } from './pull-request-file-contents' +import { + enrichItemDisplayAvatars, + getMentionParticipants, + getWorkItemParticipants +} from './work-item-participants' function localGitOptionArgs(options: LocalGitExecOptions = {}): [] | [LocalGitExecOptions] { return Object.keys(options).length > 0 ? [options] : [] } -function encodeGitHubContentPath(path: string): string { - return path.split('/').map(encodeURIComponent).join('/') -} - -const PR_FILE_VIEWED_STATES_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $after: String) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - id - files(first: 100, after: $after) { - pageInfo { hasNextPage endCursor } - nodes { - path - viewerViewedState - } - } - } - } -}` - -const WORK_ITEM_PARTICIPANTS_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $isPr: Boolean!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) @include(if: $isPr) { - participants(first: 100) { - nodes { login avatarUrl(size: 48) ... on User { name } } - } - } - issue(number: $number) @skip(if: $isPr) { - participants(first: 100) { - nodes { login avatarUrl(size: 48) ... on User { name } } - } - } - } -}` - -// Why: one GraphQL round-trip replaces the 3 serial gh subprocesses (REST issue + comments + participants); falls back to the legacy path on failure. -const ISSUE_DETAILS_QUERY = `query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $number) { - body - assignees(first: 50) { nodes { login avatarUrl(size: 48) ... on User { name } } } - participants(first: 100) { - nodes { login avatarUrl(size: 48) ... on User { name } } - } - comments(first: 100) { - nodes { - databaseId - body - createdAt - url - author { - login - avatarUrl(size: 48) - ... on Bot { __typename } - } - } - } - } - } -}` - -type GraphQLIssueDetailsResponse = { - data?: { - repository?: { - issue?: { - body?: string | null - assignees?: { nodes?: { login?: string; avatarUrl?: string; name?: string | null }[] } - participants?: { nodes?: GitHubAssignableUser[] } - comments?: { - nodes?: { - databaseId?: number | null - body?: string | null - createdAt?: string | null - url?: string | null - author?: { - login?: string | null - avatarUrl?: string | null - __typename?: string - } | null - }[] - } - } | null - } | null - } - errors?: { message?: string }[] -} - -type RestTimelineUser = { - login?: string | null - avatar_url?: string | null -} - -type RestTimelineIssue = { - number?: number | null - title?: string | null - html_url?: string | null - repository?: { - name?: string | null - owner?: { login?: string | null } | null - } | null - pull_request?: unknown -} - -type RestTimelineEvent = { - id?: number | string | null - node_id?: string | null - event?: string | null - actor?: RestTimelineUser | null - user?: RestTimelineUser | null - assignee?: RestTimelineUser | null - created_at?: string | null - source?: { - issue?: RestTimelineIssue | null - } | null - closer?: RestTimelineIssue | null - state_reason?: string | null - project_card?: { - column_name?: string | null - previous_column_name?: string | null - project_url?: string | null - } | null - project?: { - name?: string | null - } | null - project_column_name?: string | null - previous_column_name?: string | null -} - -function isSupportedTimelineEvent( - eventName: string | null | undefined -): eventName is GitHubIssueTimelineItem['event'] { - return ( - eventName === 'assigned' || - eventName === 'unassigned' || - eventName === 'mentioned' || - eventName === 'cross-referenced' || - eventName === 'closed' || - eventName === 'reopened' || - eventName === 'moved_columns_in_project' - ) -} - -function mapTimelineTarget( - issue: RestTimelineIssue | null | undefined -): GitHubIssueTimelineTarget | undefined { - if (!issue || typeof issue.number !== 'number' || !issue.html_url) { - return undefined - } - const owner = issue.repository?.owner?.login - const repo = issue.repository?.name - return { - type: issue.pull_request ? 'pr' : 'issue', - number: issue.number, - title: issue.title ?? '', - url: issue.html_url, - repository: owner && repo ? `${owner}/${repo}` : undefined - } -} - -function getTimelineActor(event: RestTimelineEvent): { login: string; avatarUrl: string } { - const actor = event.actor ?? event.user - return { - login: actor?.login ?? 'ghost', - avatarUrl: actor?.avatar_url ?? '' - } -} - -function mapRestTimelineEvent(event: RestTimelineEvent): GitHubIssueTimelineItem | null { - const eventName = event.event - if (!isSupportedTimelineEvent(eventName)) { - return null - } - if (!event.created_at) { - return null - } - const actor = getTimelineActor(event) - const id = String(event.node_id ?? event.id ?? `${eventName}:${event.created_at}`) - const base = { - id, - event: eventName, - actor: actor.login, - actorAvatarUrl: actor.avatarUrl, - createdAt: event.created_at - } - if (eventName === 'assigned' || eventName === 'unassigned') { - return { - ...base, - assignee: event.assignee?.login ?? undefined - } - } - if (eventName === 'mentioned' || eventName === 'cross-referenced') { - return { - ...base, - source: mapTimelineTarget(event.source?.issue) - } - } - if (eventName === 'closed') { - return { - ...base, - stateReason: event.state_reason ?? null, - closer: mapTimelineTarget(event.closer ?? event.source?.issue) - } - } - if (eventName === 'moved_columns_in_project') { - return { - ...base, - previousColumnName: - event.previous_column_name ?? event.project_card?.previous_column_name ?? null, - columnName: event.project_column_name ?? event.project_card?.column_name ?? null, - projectName: event.project?.name ?? null - } - } - return base -} - -function parseRestTimelineEventLines(stdout: string): RestTimelineEvent[] { - const events: RestTimelineEvent[] = [] - for (const line of stdout.split('\n')) { - const trimmed = line.trim() - if (!trimmed) { - continue - } - try { - const parsed = JSON.parse(trimmed) as unknown - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - events.push(parsed) - } - } catch { - // Skip malformed jq lines; timeline activity is auxiliary to issue details. - } - } - return events -} - -async function getIssueTimelineItems( - ownerRepo: GitHubApiRepository, - issueNumber: number, - ghOptions: ReturnType -): Promise { +async function withWorkItemDetailsPermit(operation: () => Promise): Promise { + await acquire() try { - const items: GitHubIssueTimelineItem[] = [] - for (let page = 1; items.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) { - if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { - return items - } - noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/timeline?per_page=${GITHUB_REST_PAGE_SIZE}&page=${page}`, - '--jq', - '.[] | @json' - ], - { ...ghOptions, ...githubHostExecOptions(ownerRepo) } - ) - // Why: --jq emits NDJSON and explicit paging lets us stop once we hit the drawer cap. - const pageEvents = parseRestTimelineEventLines(stdout) - for (const event of pageEvents) { - const item = mapRestTimelineEvent(event) - if (!item) { - continue - } - items.push(item) - if (items.length === MAX_ISSUE_TIMELINE_ITEMS) { - break - } - } - if (pageEvents.length < GITHUB_REST_PAGE_SIZE) { - break - } - } - return items - } catch { - return [] + return await operation() + } finally { + release() } } -/** - * Fetch an issue's body, comments, assignees, participants, and timeline in one GraphQL round-trip. - * Returns null on any partial error so the caller falls back to the strict REST path. - * Avatars are resolved here so GHE users don't render blank. - */ -async function getIssueDetailsViaGraphQL( - repoPath: string, - issueNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ - body: string - comments: PRComment[] - assignees: string[] - // Avatar-bearing assignees, kept separate from the login-only `assignees`; enriches avatars `gh` leaves blank (GHE). - assigneeUsers: GitHubAssignableUser[] - participants: GitHubAssignableUser[] - timelineItems: GitHubIssueTimelineItem[] -} | null> { - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (!ownerRepo) { - return null - } - if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { - return null - } - try { - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - 'graphql', - '-f', - `query=${ISSUE_DETAILS_QUERY}`, - '-f', - `owner=${ownerRepo.owner}`, - '-f', - `repo=${ownerRepo.repo}`, - '-F', - `number=${issueNumber}` - ], - ghOptions - ) - const parsed = JSON.parse(stdout) as GraphQLIssueDetailsResponse - if (parsed.errors && parsed.errors.length > 0) { - // Why: any partial GraphQL error forces the strict REST fallback so the drawer never paints a half-built shell. - return null - } - const issue = parsed.data?.repository?.issue - if (!issue) { - return null - } - const comments: PRComment[] = (issue.comments?.nodes ?? []) - .filter((c) => typeof c.databaseId === 'number') - .map((c) => ({ - id: c.databaseId as number, - author: c.author?.login ?? 'ghost', - authorAvatarUrl: c.author?.avatarUrl ?? '', - body: c.body ?? '', - createdAt: c.createdAt ?? '', - url: c.url ?? '', - isBot: c.author?.__typename === 'Bot' - })) - const assigneeUsers: GitHubAssignableUser[] = (issue.assignees?.nodes ?? []) - .filter((a): a is { login: string; avatarUrl?: string; name?: string | null } => - Boolean(a.login) - ) - .map((a) => ({ - login: a.login, - name: a.name ?? null, - avatarUrl: a.avatarUrl ?? '' - })) - const assignees = assigneeUsers.map((a) => a.login) - const participants: GitHubAssignableUser[] = (issue.participants?.nodes ?? []) - .filter((u) => Boolean(u.login)) - .map((u) => ({ - login: u.login, - name: u.name ?? null, - avatarUrl: u.avatarUrl ?? '' - })) - const timelineItems = await getIssueTimelineItems(ownerRepo, issueNumber, ghOptions) - return { - body: issue.body ?? '', - comments, - assignees, - assigneeUsers, - participants, - timelineItems - } - } catch { - return null - } -} - -function mergeGitHubUsers(users: GitHubAssignableUser[]): GitHubAssignableUser[] { - const byLogin = new Map() - for (const user of users) { - if (!user.login) { - continue - } - const key = user.login.toLowerCase() - const existing = byLogin.get(key) - if (existing) { - // Why: return a new merged record instead of mutating caller-provided objects. - byLogin.set(key, { - login: existing.login, - name: existing.name ?? user.name ?? null, - avatarUrl: existing.avatarUrl || user.avatarUrl || '' - }) - continue - } - byLogin.set(key, { - login: user.login, - name: user.name ?? null, - avatarUrl: user.avatarUrl ?? '' - }) - } - return Array.from(byLogin.values()) -} - -type RESTPRFile = { - filename: string - previous_filename?: string - status: string - additions: number - deletions: number - changes: number - /** Raw patch text when available; absent for binary files or patches over GitHub's size cap. */ - patch?: string -} - -function mapFileStatus(raw: string): GitHubPRFile['status'] { - switch (raw) { - case 'added': - return 'added' - case 'removed': - return 'removed' - case 'modified': - return 'modified' - case 'renamed': - return 'renamed' - case 'copied': - return 'copied' - case 'changed': - return 'changed' - case 'unchanged': - return 'unchanged' - default: - return 'modified' - } -} - -// Why: REST doesn't flag binaries but omits `patch` for them; treat "changes but no patch" as binary so the diff tab shows a placeholder. -function isBinaryHint(file: RESTPRFile): boolean { - if (file.status === 'removed' || file.status === 'added') { - // Added/removed file with changes but no patch is almost always binary (images, oversized lockfiles). - return file.patch === undefined && file.changes > 0 - } - return file.patch === undefined && file.changes > 0 -} - -async function getPRMetadata( - repoPath: string, - prNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ body: string; headSha?: string; baseSha?: string }> { - if (!ownerRepo) { - // Why: a bare `gh pr view` can honor ambient GH_HOST/GH_REPO after hosted - // repository resolution fails, returning metadata for the wrong PR. - return { body: '' } - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { - return { body: '' } - } - try { - noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - ['api', '--cache', '60s', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`], - ghOptions - ) - const data = JSON.parse(stdout) as { - body?: string | null - head?: { sha?: string } - base?: { sha?: string } - } - return { - body: data.body ?? '', - ...(data.head?.sha ? { headSha: data.head.sha } : {}), - ...(data.base?.sha ? { baseSha: data.base.sha } : {}) - } - } catch { - return { body: '' } - } -} - -// Why: null = failed/blocked fetch (Files tab shows retry, not "No files changed."); [] = genuinely empty PR. -async function getPRFiles( - repoPath: string, - prNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - if (!ownerRepo) { - return null - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - try { - const data: RESTPRFile[] = [] - for (let page = 1; data.length < MAX_PR_FILES; page += 1) { - if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { - return null - } - const pageSuffix = page === 1 ? '' : `&page=${page}` - noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}/files?per_page=100${pageSuffix}` - ], - ghOptions - ) - const pageData = JSON.parse(stdout) as RESTPRFile[] - data.push(...pageData.slice(0, MAX_PR_FILES - data.length)) - if (pageData.length < 100) { - break - } - } - return data.map((file) => ({ - path: file.filename, - oldPath: file.previous_filename, - status: mapFileStatus(file.status), - additions: file.additions, - deletions: file.deletions, - isBinary: isBinaryHint(file), - reviewCommentLineNumbers: getPRReviewCommentLineNumbersFromPatch(file.patch) - })) - } catch { - return null - } -} - -type PRFileViewedStatesResult = { - pullRequestId: string - viewedStates: Map -} - -async function getPRFileViewedStates( - repoPath: string, - prNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - if (!ownerRepo) { - return null - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { - return null - } - const viewedStates = new Map() - let pullRequestId: string | null = null - let after: string | null = null - - try { - for (let fetched = 0; fetched < MAX_PR_FILES; fetched += 100) { - const args = [ - 'api', - 'graphql', - '-f', - `query=${PR_FILE_VIEWED_STATES_QUERY}`, - '-f', - `owner=${ownerRepo.owner}`, - '-f', - `repo=${ownerRepo.repo}`, - '-F', - `number=${prNumber}` - ] - if (after) { - args.push('-f', `after=${after}`) - } - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - const { stdout } = await ghExecFileAsync(args, ghOptions) - const parsed = JSON.parse(stdout) as { - data?: { - repository?: { - pullRequest?: { - id?: string - files?: { - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: { - path?: string | null - viewerViewedState?: GitHubPRFileViewedState | null - }[] - } - } | null - } | null - } - errors?: { message?: string }[] - } - if (parsed.errors && parsed.errors.length > 0) { - return null - } - const pullRequest = parsed.data?.repository?.pullRequest - if (!pullRequest?.id) { - return null - } - pullRequestId = pullRequest.id - for (const file of pullRequest.files?.nodes ?? []) { - if (file.path && file.viewerViewedState) { - viewedStates.set(file.path, file.viewerViewedState) - } - } - if (!pullRequest.files?.pageInfo?.hasNextPage || !pullRequest.files.pageInfo.endCursor) { - break - } - after = pullRequest.files.pageInfo.endCursor - } - } catch { - return null - } - - return pullRequestId ? { pullRequestId, viewedStates } : null -} - -function mergePRFileViewedStates( - files: GitHubPRFile[], - viewedStates: PRFileViewedStatesResult | null -): GitHubPRFile[] { - if (!viewedStates) { - return files - } - return files.map((file) => ({ - ...file, - viewerViewedState: viewedStates.viewedStates.get(file.path) ?? 'UNVIEWED' - })) -} - -async function getIssueBodyAndComments( - repoPath: string, - issueNumber: number, - ownerRepo: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ - body: string - comments: PRComment[] - assignees: string[] - timelineItems: GitHubIssueTimelineItem[] -}> { - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - try { - if (ownerRepo) { - if (repositoryRateLimitGuard(ownerRepo, 'core', ghOptions).blocked) { - return { body: '', comments: [], assignees: [], timelineItems: [] } - } - // Why: the fallback starts the issue and comments reads together; debit - // both before spawning so a failed response cannot leave quota overstated. - noteRepositoryRateLimitSpend(ownerRepo, 'core', 2, ghOptions) - const [issueResult, commentsResult, timelineItems] = await Promise.all([ - ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}` - ], - ghOptions - ), - ghExecFileAsync( - [ - 'api', - '--cache', - '60s', - `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/comments?per_page=100` - ], - ghOptions - ), - getIssueTimelineItems(ownerRepo, issueNumber, ghOptions) - ]) - const issue = JSON.parse(issueResult.stdout) as { - body?: string | null - assignees?: { login: string }[] - } - type RESTComment = { - id: number - user: { login: string; avatar_url: string; type?: string } | null - body: string - created_at: string - html_url: string - } - const comments = (JSON.parse(commentsResult.stdout) as RESTComment[]).map( - (c): PRComment => ({ - id: c.id, - author: c.user?.login ?? 'ghost', - authorAvatarUrl: c.user?.avatar_url ?? '', - body: c.body ?? '', - createdAt: c.created_at, - url: c.html_url, - isBot: c.user?.type === 'Bot' - }) - ) - const assignees = (issue.assignees ?? []).map((a) => a.login) - return { body: issue.body ?? '', comments, assignees, timelineItems } - } - if (connectionId) { - // Why: connection-backed gh has no cwd. A bare issue lookup could honor - // process GH_REPO/GH_HOST and return an unrelated repository's issue. - return { body: '', comments: [], assignees: [], timelineItems: [] } - } - // Fallback: non-GitHub remote - const { stdout } = await ghExecFileAsync( - ['issue', 'view', String(issueNumber), '--json', 'body,comments,assignees'], - ghOptions - ) - const data = JSON.parse(stdout) as { - body?: string - comments?: { - author: { login: string } - body: string - createdAt: string - url: string - }[] - assignees?: { login: string }[] - } - const comments = (data.comments ?? []).map( - (c, i): PRComment => ({ - id: i, - author: c.author?.login ?? 'ghost', - authorAvatarUrl: '', - body: c.body ?? '', - createdAt: c.createdAt, - url: c.url ?? '' - }) - ) - const fallbackAssignees = (data.assignees ?? []).map((a) => a.login) - return { body: data.body ?? '', comments, assignees: fallbackAssignees, timelineItems: [] } - } catch { - return { body: '', comments: [], assignees: [], timelineItems: [] } - } -} - -async function getWorkItemParticipants( - repoPath: string, - item: Pick, - resolvedRepository: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - // Why: reuse the fan-out's repository identity so details cannot drift across hosts. - const ownerRepo = resolvedRepository - if (!ownerRepo) { - return [] - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(ownerRepo) - } - if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) { - return [] - } - try { - noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - 'graphql', - '-f', - `query=${WORK_ITEM_PARTICIPANTS_QUERY}`, - '-f', - `owner=${ownerRepo.owner}`, - '-f', - `repo=${ownerRepo.repo}`, - '-F', - `number=${item.number}`, - '-F', - `isPr=${item.type === 'pr'}` - ], - ghOptions - ) - const data = JSON.parse(stdout) as { - data?: { - repository?: { - pullRequest?: { - participants?: { nodes?: GitHubAssignableUser[] } - } | null - issue?: { - participants?: { nodes?: GitHubAssignableUser[] } - } | null - } - } - } - const nodes = - data.data?.repository?.pullRequest?.participants?.nodes ?? - data.data?.repository?.issue?.participants?.nodes ?? - [] - return nodes - .map((user) => ({ - login: user.login, - name: user.name ?? null, - avatarUrl: user.avatarUrl ?? '' - })) - .filter((user) => user.login) - } catch { - return [] - } -} - -async function getGitHubUsersByLogin( - repoPath: string, - logins: string[], - repository: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - if (!repository) { - return [] - } - const uniqueLogins = Array.from( - new Set(logins.filter((login) => login && login !== 'ghost').map((login) => login.trim())) - ).slice(0, 40) - if (uniqueLogins.length === 0) { - return [] - } - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), - ...githubHostExecOptions(repository) - } - const guard = repositoryRateLimitGuard(repository, 'graphql', ghOptions) - if (guard.blocked) { - // Why: log the skip — callers degrade silently to blank GHE avatars, so it's otherwise untraceable. - console.warn( - `getGitHubUsersByLogin skipped: GraphQL rate-limit budget exhausted (${uniqueLogins.length} logins unresolved)` - ) - return [] - } - const fields = uniqueLogins - .map( - (login, index) => - `u${index}: user(login: ${JSON.stringify(login)}) { login name avatarUrl(size: 48) }` - ) - .join('\n') - try { - noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - ['api', 'graphql', '-f', `query=query { ${fields} }`], - ghOptions - ) - const data = JSON.parse(stdout) as { - data?: Record< - string, - { - login?: string - name?: string | null - avatarUrl?: string | null - } | null - > - } - return Object.values(data.data ?? {}) - .filter( - ( - user - ): user is { - login: string - name?: string | null - avatarUrl?: string | null - } => Boolean(user?.login) - ) - .map((user) => ({ - login: user.login, - name: user.name ?? null, - avatarUrl: user.avatarUrl ?? '' - })) - } catch { - return [] - } -} - -/** - * Stamp GraphQL-resolved avatars onto a work item's author, reviewers, review requests, latest reviews, and assignees. - * - * Why: `gh pr view` omits avatar_url, so login-based avatars 404 on GHE; knownUsers carries the GraphQL-resolved ones. See #8784. - */ -function enrichItemDisplayAvatars( - item: Omit, - knownUsers: GitHubAssignableUser[] -): Omit { - const avatarByLogin = new Map() - for (const user of knownUsers) { - if (user.login && user.avatarUrl) { - avatarByLogin.set(user.login.toLowerCase(), user.avatarUrl) - } - } - if (avatarByLogin.size === 0) { - return item - } - // Why: prefer the GraphQL-resolved avatar — `gh pr view` returns empty/`u/0` placeholders for enterprise users; fall back to the original only when the lookup is empty. - const avatarFor = (login: string): string | undefined => avatarByLogin.get(login.toLowerCase()) - const resolvedAvatar = (login: string, existing?: string | null): string | undefined => - avatarFor(login) || existing || undefined - // Callers coalesce a missing result to each field's "no avatar" sentinel ('' or null); GitHubUserAvatar falls back to login URL then initials. - const authorAvatarUrl = (item.author ? avatarFor(item.author) : undefined) || item.authorAvatarUrl - return { - ...item, - ...(authorAvatarUrl ? { authorAvatarUrl } : {}), - ...(item.reviewRequests - ? { - reviewRequests: item.reviewRequests.map((user) => ({ - ...user, - avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' - })) - } - : {}), - ...(item.latestReviews - ? { - latestReviews: item.latestReviews.map((review) => ({ - ...review, - avatarUrl: resolvedAvatar(review.login, review.avatarUrl) ?? null - })) - } - : {}), - ...(item.assignees - ? { - assignees: item.assignees.map((user) => ({ - ...user, - avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' - })) - } - : {}) - } -} - -async function getMentionParticipants( - repoPath: string, - item: Pick< - GitHubWorkItem, - 'author' | 'number' | 'type' | 'reviewRequests' | 'latestReviews' | 'assignees' - >, - comments: PRComment[], - participants: GitHubAssignableUser[], - repository: GitHubApiRepository | null, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - // Why: resolve mention authors + display users (reviewers/assignees) in one aliased trip so avatars reuse it without a second rate-limited lookup (#8784). - // Why: order display users before comment authors so the 40-login cap in getGitHubUsersByLogin can't drop a reviewer/assignee avatar. - const visibleLogins = [ - item.author ?? '', - ...(item.reviewRequests ?? []).map((user) => user.login), - ...(item.latestReviews ?? []).map((review) => review.login), - ...(item.assignees ?? []).map((user) => user.login), - ...comments.map((comment) => comment.author) - ] - const graphQlUsers = await getGitHubUsersByLogin( - repoPath, - visibleLogins, - repository, - connectionId, - localGitOptions - ) - return mergeGitHubUsers([...participants, ...graphQlUsers]) -} - async function getPRChecksForDetails( repoPath: string, prNumber: number, @@ -1028,22 +57,12 @@ async function getPRChecksForDetails( connectionId, ...localGitOptionArgs(localGitOptions) ) - } catch (err) { - // Why: checks are auxiliary — a gh failure must not block opening the PR drawer. - console.warn('getWorkItemDetails PR checks failed:', err) + } catch (error) { + console.warn('getWorkItemDetails PR checks failed:', error) return [] } } -async function withWorkItemDetailsPermit(operation: () => Promise): Promise { - await acquire() - try { - return await operation() - } finally { - release() - } -} - export async function getWorkItemDetails( repoPath: string, number: number, @@ -1072,7 +91,6 @@ export async function getWorkItemDetails( if (item.type === 'issue') { return withWorkItemDetailsPermit(async () => { - // Why: one GraphQL trip returns all issue details; fall back to the legacy fan-out on failure. const collapsed = await getIssueDetailsViaGraphQL( repoPath, item.number, @@ -1082,7 +100,6 @@ export async function getWorkItemDetails( ) if (collapsed) { return { - // Include assigneeUsers: non-participating assignees are absent from participants and keep a blank avatar (GHE). item: enrichItemDisplayAvatars(item, [ ...collapsed.participants, ...collapsed.assigneeUsers @@ -1094,7 +111,6 @@ export async function getWorkItemDetails( timelineItems: collapsed.timelineItems } } - // Fallback: fetch body/comments and participants in parallel. const [{ body, comments, assignees, timelineItems }, participants] = await Promise.all([ getIssueBodyAndComments( repoPath, @@ -1125,8 +141,7 @@ export async function getWorkItemDetails( }) } - // Why: getPRComments and getPRChecks own their semaphore permits. Keeping an - // outer permit while awaiting either can deadlock four concurrent detail loads. + // Nested client reads own their semaphore permits, so they stay outside these permits. const [[metadata, files, viewedStates, participants], comments] = await Promise.all([ Promise.all([ withWorkItemDetailsPermit(() => @@ -1159,7 +174,6 @@ export async function getWorkItemDetails( : Promise.resolve([]) ]) - // Why: mention hydration spawns gh directly while checks owns a permit; bound them without nesting. const [mentionParticipants, checks] = await Promise.all([ withWorkItemDetailsPermit(() => getMentionParticipants( @@ -1193,57 +207,12 @@ export async function getWorkItemDetails( baseSha: metadata.baseSha, pullRequestId: viewedStates?.pullRequestId, checks, - // Why: null (failed fetch) vs empty PR — Files tab shows retry, not "No files changed." files: files === null ? undefined : mergePRFileViewedStates(files, viewedStates), filesUnavailable: files === null, participants: mentionParticipants } } -// Why: Monaco DiffViewer needs original/modified text (not patches); --cache bounds rate-limit spend on rapid file-expands. -async function fetchContentAtRef(args: { - repoPath: string - connectionId?: string | null - localGitOptions?: LocalGitExecOptions - ownerRepo: GitHubApiRepository - path: string - ref: string -}): Promise<{ content: string; isBinary: boolean; tooLarge?: boolean }> { - const ghOptions = { - ...ghRepoExecOptions(githubRepoContext(args.repoPath, args.connectionId, args.localGitOptions)), - ...githubHostExecOptions(args.ownerRepo), - maxBuffer: GITHUB_RAW_CONTENT_MAX_BUFFER_BYTES - } - if (repositoryRateLimitGuard(args.ownerRepo, 'core', ghOptions).blocked) { - return { content: '', isBinary: false } - } - try { - noteRepositoryRateLimitSpend(args.ownerRepo, 'core', 1, ghOptions) - const { stdout } = await ghExecFileAsync( - [ - 'api', - '--cache', - '300s', - '-H', - 'Accept: application/vnd.github.raw', - `repos/${args.ownerRepo.owner}/${args.ownerRepo.repo}/contents/${encodeGitHubContentPath(args.path)}?ref=${encodeURIComponent(args.ref)}` - ], - ghOptions - ) - // Heuristic: a NUL byte in the first 2KB means binary (execFile decodes as lossy utf-8). - const sample = stdout.slice(0, 2048) - if (sample.includes('\u0000')) { - return { content: '', isBinary: true } - } - return { content: stdout, isBinary: false } - } catch (error) { - if (isMaxBufferOverflowError(error)) { - return { content: '', isBinary: false, tooLarge: true } - } - return { content: '', isBinary: false } - } -} - export async function getPRFileContents(args: { repoPath: string connectionId?: string | null @@ -1256,67 +225,5 @@ export async function getPRFileContents(args: { headSha: string baseSha: string }): Promise { - const { ownerRepo } = await resolveGitHubRepoExecution( - args.repoPath, - args.prRepo, - args.connectionId, - args.localGitOptions - ) - if (!ownerRepo) { - return { - original: '', - modified: '', - originalIsBinary: false, - modifiedIsBinary: false - } - } - - await acquire() - try { - // Why: added files have no base-ref original, removed files no head-ref modified; skip those to avoid spurious 404s. - const needsOriginal = args.status !== 'added' - const needsModified = args.status !== 'removed' - const originalRef = args.baseSha - const originalPath = args.oldPath ?? args.path - - const [original, modified] = await Promise.all([ - needsOriginal - ? fetchContentAtRef({ - repoPath: args.repoPath, - connectionId: args.connectionId, - localGitOptions: args.localGitOptions, - ownerRepo, - path: originalPath, - ref: originalRef - }) - : Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({ - content: '', - isBinary: false - }), - needsModified - ? fetchContentAtRef({ - repoPath: args.repoPath, - connectionId: args.connectionId, - localGitOptions: args.localGitOptions, - ownerRepo, - path: args.path, - ref: args.headSha - }) - : Promise.resolve<{ content: string; isBinary: boolean; tooLarge?: boolean }>({ - content: '', - isBinary: false - }) - ]) - - return { - original: original.content, - modified: modified.content, - originalIsBinary: original.isBinary, - modifiedIsBinary: modified.isBinary, - originalTooLarge: original.tooLarge, - modifiedTooLarge: modified.tooLarge - } - } finally { - release() - } + return loadPRFileContents(args) } diff --git a/src/main/github/work-item-participants.ts b/src/main/github/work-item-participants.ts new file mode 100644 index 00000000000..0ac793b4812 --- /dev/null +++ b/src/main/github/work-item-participants.ts @@ -0,0 +1,241 @@ +import type { PRComment } from '../../shared/github/comment-types' +import type { GitHubAssignableUser } from '../../shared/github/pull-request-types' +import type { GitHubWorkItem } from '../../shared/github/work-item-types' +import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from './gh-utils' +import type { LocalGitExecOptions } from './gh-utils' +import { githubHostExecOptions, type GitHubApiRepository } from './github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const WORK_ITEM_PARTICIPANTS_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $isPr: Boolean!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) @include(if: $isPr) { + participants(first: 100) { + nodes { login avatarUrl(size: 48) ... on User { name } } + } + } + issue(number: $number) @skip(if: $isPr) { + participants(first: 100) { + nodes { login avatarUrl(size: 48) ... on User { name } } + } + } + } +}` + +function mergeGitHubUsers(users: GitHubAssignableUser[]): GitHubAssignableUser[] { + const byLogin = new Map() + for (const user of users) { + if (!user.login) { + continue + } + const key = user.login.toLowerCase() + const existing = byLogin.get(key) + if (existing) { + byLogin.set(key, { + login: existing.login, + name: existing.name ?? user.name ?? null, + avatarUrl: existing.avatarUrl || user.avatarUrl || '' + }) + continue + } + byLogin.set(key, { + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + }) + } + return Array.from(byLogin.values()) +} + +export async function getWorkItemParticipants( + repoPath: string, + item: Pick, + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (!repository) { + return [] + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + return [] + } + try { + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${WORK_ITEM_PARTICIPANTS_QUERY}`, + '-f', + `owner=${repository.owner}`, + '-f', + `repo=${repository.repo}`, + '-F', + `number=${item.number}`, + '-F', + `isPr=${item.type === 'pr'}` + ], + ghOptions + ) + const data = JSON.parse(stdout) as { + data?: { + repository?: { + pullRequest?: { participants?: { nodes?: GitHubAssignableUser[] } } | null + issue?: { participants?: { nodes?: GitHubAssignableUser[] } } | null + } + } + } + const nodes = + data.data?.repository?.pullRequest?.participants?.nodes ?? + data.data?.repository?.issue?.participants?.nodes ?? + [] + return nodes + .map((user) => ({ + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + })) + .filter((user) => user.login) + } catch { + return [] + } +} + +async function getGitHubUsersByLogin( + repoPath: string, + logins: string[], + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (!repository) { + return [] + } + const uniqueLogins = Array.from( + new Set(logins.filter((login) => login && login !== 'ghost').map((login) => login.trim())) + ).slice(0, 40) + if (uniqueLogins.length === 0) { + return [] + } + const ghOptions = { + ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)), + ...githubHostExecOptions(repository) + } + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + console.warn( + `getGitHubUsersByLogin skipped: GraphQL rate-limit budget exhausted (${uniqueLogins.length} logins unresolved)` + ) + return [] + } + const fields = uniqueLogins + .map( + (login, index) => + `u${index}: user(login: ${JSON.stringify(login)}) { login name avatarUrl(size: 48) }` + ) + .join('\n') + try { + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + ['api', 'graphql', '-f', `query=query { ${fields} }`], + ghOptions + ) + const data = JSON.parse(stdout) as { + data?: Record< + string, + { login?: string; name?: string | null; avatarUrl?: string | null } | null + > + } + return Object.values(data.data ?? {}) + .filter((user): user is { login: string; name?: string | null; avatarUrl?: string | null } => + Boolean(user?.login) + ) + .map((user) => ({ + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatarUrl ?? '' + })) + } catch { + return [] + } +} + +export async function getMentionParticipants( + repoPath: string, + item: Pick< + GitHubWorkItem, + 'author' | 'number' | 'type' | 'reviewRequests' | 'latestReviews' | 'assignees' + >, + comments: PRComment[], + participants: GitHubAssignableUser[], + repository: GitHubApiRepository | null, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const visibleLogins = [ + item.author ?? '', + ...(item.reviewRequests ?? []).map((user) => user.login), + ...(item.latestReviews ?? []).map((review) => review.login), + ...(item.assignees ?? []).map((user) => user.login), + ...comments.map((comment) => comment.author) + ] + const graphQlUsers = await getGitHubUsersByLogin( + repoPath, + visibleLogins, + repository, + connectionId, + localGitOptions + ) + return mergeGitHubUsers([...participants, ...graphQlUsers]) +} + +export function enrichItemDisplayAvatars( + item: Omit, + knownUsers: GitHubAssignableUser[] +): Omit { + const avatarByLogin = new Map() + for (const user of knownUsers) { + if (user.login && user.avatarUrl) { + avatarByLogin.set(user.login.toLowerCase(), user.avatarUrl) + } + } + if (avatarByLogin.size === 0) { + return item + } + const avatarFor = (login: string): string | undefined => avatarByLogin.get(login.toLowerCase()) + const resolvedAvatar = (login: string, existing?: string | null): string | undefined => + avatarFor(login) || existing || undefined + const authorAvatarUrl = (item.author ? avatarFor(item.author) : undefined) || item.authorAvatarUrl + return { + ...item, + ...(authorAvatarUrl ? { authorAvatarUrl } : {}), + ...(item.reviewRequests + ? { + reviewRequests: item.reviewRequests.map((user) => ({ + ...user, + avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' + })) + } + : {}), + ...(item.latestReviews + ? { + latestReviews: item.latestReviews.map((review) => ({ + ...review, + avatarUrl: resolvedAvatar(review.login, review.avatarUrl) ?? null + })) + } + : {}), + ...(item.assignees + ? { + assignees: item.assignees.map((user) => ({ + ...user, + avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' + })) + } + : {}) + } +} diff --git a/src/main/gitlab/client.test.ts b/src/main/gitlab/client.test.ts index 038083def0e..1d932d5c5fa 100644 --- a/src/main/gitlab/client.test.ts +++ b/src/main/gitlab/client.test.ts @@ -50,6 +50,9 @@ describe('gitlab client — viewer & paste-URL lookup', () => { username: 'alice', email: 'alice@example.com' }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['api', 'user']) + expect(acquireMock).toHaveBeenCalledOnce() + expect(releaseMock).toHaveBeenCalledOnce() }) it('coerces a missing email to null', async () => { @@ -65,6 +68,7 @@ describe('gitlab client — viewer & paste-URL lookup', () => { it('returns null when glab fails', async () => { glabExecFileAsyncMock.mockRejectedValueOnce(new Error('not authenticated')) await expect(getAuthenticatedViewer()).resolves.toBeNull() + expect(releaseMock).toHaveBeenCalledOnce() }) it('returns null when username is empty', async () => { diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index 50c541c0213..9f50ebbaf5d 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -1,1378 +1,33 @@ -/* eslint-disable max-lines -- co-locates GitLab MR/issue/work-item operations sharing one acquire/release pattern. */ -import type { ClassifiedError } from '../../shared/classified-error' -import type { - GetGitLabRateLimitResult, - GitLabAssignableUser, - GitLabAuthDiagnostic, - GitLabDiscussionResolveResult, - GitLabJobTraceResult, - GitLabMRInlineCommentInput, - GitLabMRReviewersUpdateResult, - GitLabPagedResult, - GitLabPipelineJob, - GitLabRateLimitSnapshot, - GitLabRetryJobResult, - GitLabTodo, - GitLabViewer, - GitLabWorkItem, - ListMergeRequestsResult, - MRComment, - MRInfo, - MRListState -} from '../../shared/gitlab-types' -import type { IssueSourcePreference } from '../../shared/repo-types' -import { derivePipelineStatus, mapIssueToWorkItem, mapMRInfo, mapMRToWorkItem } from './mappers' -import { - acquire, - classifyGlabError, - classifyJobLogError, - classifyListFetchError, - isMissingJobLogError, - getGlabKnownHosts, - getProjectRef, - getProjectRefForRemote, - glabHostnameArgs, - glabRepoExecOptions, - glabApiWithHeaders, - glabExecFileAsync, - parseGlabAuthStatusHosts, - parseGlabJsonList, - release, - resolveIssueSource, - type LocalGitExecOptions, - type ProjectRef -} from './gl-utils' -import { rememberGlabKnownHosts } from './gitlab-known-host-probe' -import type { IssueListState } from './issues' -import { - hasHostedReviewLocalGitOptions, - getHostedReviewLocalGitOptions, - type HostedReviewExecutionOptions -} from '../source-control/hosted-review-git-options' -import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch' - -// Why: glab REST addresses projects by URL-encoded path; escapes slashes for nested groups. -function encodedProject(projectPath: string): string { - return encodeURIComponent(projectPath) -} - -const GITLAB_RATE_LIMIT_CACHE_TTL_MS = 30_000 -const GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES = 64 -const gitLabRateLimitCache = new Map() - -type HostedReviewLocalGitOptions = ReturnType - -function hostedReviewLocalGitOptionArgs( - options: HostedReviewExecutionOptions = {} -): [] | [HostedReviewLocalGitOptions] { - return hasHostedReviewLocalGitOptions(options) ? [getHostedReviewLocalGitOptions(options)] : [] -} - -/** - * Get the authenticated GitLab viewer. - * Returns null when glab is unavailable, unauthenticated, or the lookup fails. - */ -export async function getAuthenticatedViewer(): Promise { - await acquire() - try { - const { stdout } = await glabExecFileAsync(['api', 'user']) - const viewer = JSON.parse(stdout) as { username?: string; email?: string | null } - if (!viewer.username?.trim()) { - return null - } - return { - username: viewer.username.trim(), - email: viewer.email?.trim() || null - } - } catch { - return null - } finally { - release() - } -} - -export async function diagnoseAuth(): Promise { - const envTokenInProcess = process.env.GITLAB_TOKEN - ? 'GITLAB_TOKEN' - : process.env.GLAB_TOKEN - ? 'GLAB_TOKEN' - : null - try { - // Why: a host-global diagnostic must not wake an unrelated default WSL distro. - const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { - allowDefaultWslFallback: false - }) - const output = `${stdout}\n${stderr}` - const hosts = parseGlabAuthStatusHosts(output) - // Why: refreshing auth must advance the provider cache key past a stale null result. - rememberGlabKnownHosts(hosts) - return { - glabAvailable: true, - authenticated: - /logged in|authenticated|token/i.test(output) && !/not logged in/i.test(output), - hosts, - activeHost: hosts[0] ?? null, - envTokenInProcess, - error: null - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { - glabAvailable: !/ENOENT|not found|spawn/i.test(message), - authenticated: false, - hosts: [], - activeHost: null, - envTokenInProcess, - error: message - } - } -} - -function parseRateLimitHeader( - headers: Record, - keys: readonly string[] -): number | null { - for (const key of keys) { - const parsed = Number.parseInt(headers[key], 10) - if (Number.isFinite(parsed)) { - return parsed - } - } - return null -} - -function parseRateLimitResetAt(headers: Record): number | null { - const numeric = parseRateLimitHeader(headers, ['ratelimit-reset', 'x-ratelimit-reset']) - if (numeric !== null) { - return numeric - } - const resetTime = headers['ratelimit-resettime'] ?? headers['x-ratelimit-resettime'] - if (!resetTime) { - return null - } - const millis = Date.parse(resetTime) - return Number.isFinite(millis) ? Math.floor(millis / 1000) : null -} - -function parseGitLabRateLimitSnapshot( - headers: Record, - host: string | null -): GitLabRateLimitSnapshot { - const limit = parseRateLimitHeader(headers, ['ratelimit-limit', 'x-ratelimit-limit']) - const remaining = parseRateLimitHeader(headers, ['ratelimit-remaining', 'x-ratelimit-remaining']) - const resetAt = parseRateLimitResetAt(headers) - return { - host, - fetchedAt: Date.now(), - rest: - limit === null && remaining === null && resetAt === null - ? null - : { - limit: limit ?? 0, - remaining: remaining ?? 0, - resetAt - } - } -} - -/** @internal — test-only */ -export function _resetGitLabRateLimitCache(): void { - gitLabRateLimitCache.clear() -} - -/** @internal — test-only */ -export function _getGitLabRateLimitCacheSize(): number { - return gitLabRateLimitCache.size -} - -function pruneGitLabRateLimitCache(now = Date.now()): void { - for (const [cacheKey, snapshot] of gitLabRateLimitCache) { - if (now - snapshot.fetchedAt >= GITLAB_RATE_LIMIT_CACHE_TTL_MS) { - gitLabRateLimitCache.delete(cacheKey) - } - } - while (gitLabRateLimitCache.size > GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES) { - const oldestKey = gitLabRateLimitCache.keys().next().value - if (oldestKey === undefined) { - break - } - gitLabRateLimitCache.delete(oldestKey) - } -} - -function rememberGitLabRateLimitSnapshot( - cacheKey: string, - snapshot: GitLabRateLimitSnapshot -): void { - pruneGitLabRateLimitCache() - // Why: self-managed hostnames come from repo config; keep this cache bounded across many transient hosts. - gitLabRateLimitCache.delete(cacheKey) - gitLabRateLimitCache.set(cacheKey, snapshot) - pruneGitLabRateLimitCache() -} - -export async function getRateLimit(options?: { - force?: boolean - host?: string | null -}): Promise { - const host = options?.host?.trim() || null - const cacheKey = host ?? 'default' - pruneGitLabRateLimitCache() - const cached = gitLabRateLimitCache.get(cacheKey) - if (!options?.force && cached && Date.now() - cached.fetchedAt < GITLAB_RATE_LIMIT_CACHE_TTL_MS) { - return { ok: true, snapshot: cached } - } - - await acquire() - try { - // Why: GitLab exposes REST budget headers inconsistently; a null bucket means this host omitted them. - const args = host ? ['--hostname', host, 'user'] : ['user'] - const { headers } = await glabApiWithHeaders(args) - const snapshot = parseGitLabRateLimitSnapshot(headers, host) - rememberGitLabRateLimitSnapshot(cacheKey, snapshot) - return { ok: true, snapshot } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - return { ok: false, error: message } - } finally { - release() - } -} - -/** Resolve a project's full GitLab project ref (host + path); null for non-GitLab remotes. */ -export async function getProjectSlug( - repoPath: string, - connectionId?: string | null, - options: HostedReviewExecutionOptions = {} -): Promise { - const localGitArgs = hostedReviewLocalGitOptionArgs(options) - const knownHosts = await getGlabKnownHosts(connectionId, localGitArgs[0]) - return getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) -} - -/** - * Fetch a single merge request with pipeline status rolled up. - * Returns null when the MR doesn't exist or glab fails. - */ -export async function getMergeRequest( - repoPath: string, - iid: number, - connectionId?: string | null, - options: HostedReviewExecutionOptions = {} -): Promise { - const localGitArgs = hostedReviewLocalGitOptionArgs(options) - const localGitOptions = localGitArgs[0] ?? {} - const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) - const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) - await acquire() - try { - const args = projectRef - ? [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}` - ] - : ['mr', 'view', String(iid), '--output', 'json'] - const { stdout } = await glabExecFileAsync( - args, - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) as Parameters[0] & { - head_pipeline?: { status?: string } | null - pipeline?: { status?: string } | null - } - // Why: older GitLab instances expose `pipeline` instead of `head_pipeline`; try both. - const pipelineStatus = derivePipelineStatus(data.head_pipeline ?? data.pipeline ?? null) - return mapMRInfo(data, pipelineStatus) - } catch { - return null - } finally { - release() - } -} - -/** - * Find the merge request whose source branch matches the given name. - * Returns the most recently updated MR for the branch, or null when none exists. - */ -export async function getMergeRequestForBranch( - repoPath: string, - branch: string, - linkedMRIid?: number | null, - connectionId?: string | null, - options: HostedReviewExecutionOptions = {}, - // Why: when true, a failed lookup throws instead of returning null, so callers never report a false not_found. - throwOnFailure = false -): Promise { - const branchName = branch.replace(/^refs\/heads\//, '') - if (!branchName && linkedMRIid == null) { - return null - } - const localGitArgs = hostedReviewLocalGitOptionArgs(options) - const localGitOptions = localGitArgs[0] ?? {} - const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) - const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) - if (!projectRef) { - return null - } - await acquire() - try { - if (branchName) { - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - // Why: GitLab does not proactively recompute merge status on list endpoints, so this row - // can sit at `unchecked` forever — and the sidebar merge button gates on MERGEABLE. Ask - // for the async recalculation (best-effort; ignored for non-Developers when - // `restrict_merge_status_recheck` is on) so polling converges instead of stalling. - `projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1&with_merge_status_recheck=true` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) as (Parameters[0] & { - head_pipeline?: { status?: string } | null - pipeline?: { status?: string } | null - })[] - if (Array.isArray(data) && data.length > 0) { - const raw = data[0] - // Why: older GitLab list payloads expose `pipeline` instead of `head_pipeline`. - const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) - const info = mapMRInfo(raw, pipelineStatus) - // Why (#9171): discard a non-open implicit branch match on the repo - // default branch and fall through to the linked-iid fallback below. - const hideOnDefaultBranch = await shouldHideNonOpenReviewOnDefaultBranch({ - state: info.state, - reviewNumber: info.number, - linkedReviewNumber: linkedMRIid, - branchName, - repoPath, - connectionId, - localGitOptions - }) - if (!hideOnDefaultBranch) { - return info - } - } - } - if (typeof linkedMRIid !== 'number') { - return null - } - // Why: create-from-MR worktrees may rename the branch; fall back to the durable linked iid. - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const raw = JSON.parse(stdout) as Parameters[0] & { - head_pipeline?: { status?: string } | null - pipeline?: { status?: string } | null - } - const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) - return mapMRInfo(raw, pipelineStatus) - } catch (error) { - if (throwOnFailure) { - throw error - } - return null - } finally { - release() - } -} - -/** - * Like getMergeRequestForBranch but throws glab failures instead of returning null, so callers report 'unavailable' not a false "not found". - */ -export function getMergeRequestForBranchOrThrow( - repoPath: string, - branch: string, - linkedMRIid?: number | null, - connectionId?: string | null, - options: HostedReviewExecutionOptions = {} -): Promise { - return getMergeRequestForBranch(repoPath, branch, linkedMRIid, connectionId, options, true) -} - -function mrListStateFlags(state: MRListState): string[] { - switch (state) { - case 'opened': - return [] - case 'merged': - return ['--merged'] - case 'closed': - return ['--closed'] - case 'all': - return ['--all'] - } -} - -/** List merge requests for a project via glab CLI, which handles self-hosted auth and project selection. */ -export async function listMergeRequests( - repoPath: string, - state: MRListState = 'opened', - page = 1, - perPage = 20, - preference?: IssueSourcePreference, - query?: string, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) - // Why: MRs live on the user's fork (origin); route through the preference resolver so fork workflows share plumbing. - const { source: projectRef } = await resolveIssueSource( - repoPath, - preference, - knownHosts, - connectionId, - localGitOptions - ) - if (!projectRef) { - if (connectionId) { - // Why: SSH-backed repos have no local cwd; a cwd-less glab could resolve an unrelated project. - return { - items: [], - page, - perPage, - totalCount: 0, - totalPages: 0, - error: { - type: 'not_found', - message: 'No GitLab project found for this repository.' - } - } - } - // Why: fallback — let glab infer the project from cwd when the host isn't in getGlabKnownHosts. - const stateFlag = mrListStateFlags(state) - // Why: apply the same search as the API path, else queries are silently ignored on cwd-inferred repos (#6263). - const searchFlag = query?.trim() ? ['--search', query.trim()] : [] - await acquire() - try { - const { stdout } = await glabExecFileAsync( - [ - 'mr', - 'list', - '--output', - 'json', - '--per-page', - String(perPage), - '--page', - String(page), - '--order', - 'updated_at', - '--sort', - 'desc', - ...stateFlag, - ...searchFlag - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = parseGlabJsonList[0]>(stdout) - return { - items: data.map((d) => mapMRToWorkItem(d, 'unknown')), - page, - perPage, - // Why: the CLI omits x-total headers, so totals are approximate. - totalCount: data.length, - totalPages: data.length < perPage ? page : page + 1 - } - } catch (err) { - return { - items: [], - page, - perPage, - totalCount: 0, - totalPages: 0, - error: classifyListFetchError(err) - } - } finally { - release() - } - } - // Why: GitLab's API uses an absent state param to mean "any"; drop it for 'all'. - const stateParam = state === 'all' ? '' : `&state=${state}` - const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' - const path = - `projects/${encodedProject(projectRef.path)}/merge_requests?` + - `page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc&with_merge_status_recheck=false${stateParam}${searchParam}` - const repoId = projectRef.path - - await acquire() - try { - const { body, headers } = await glabApiWithHeaders( - [...glabHostnameArgs(projectRef, connectionId), path], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = parseGlabJsonList[0]>(body) - return { - items: data.map((d) => mapMRToWorkItem(d, repoId, projectRef)), - page, - perPage, - totalCount: parseHeaderInt(headers['x-total'], 0), - // Why: GitLab may omit x-total-pages for 'all' or large per_page; fall back to ceil(total/perPage). - totalPages: - parseHeaderInt(headers['x-total-pages'], 0) || - Math.max(1, Math.ceil(parseHeaderInt(headers['x-total'], 0) / perPage)) - } - } catch (err) { - return { - items: [], - page, - perPage, - totalCount: 0, - totalPages: 0, - error: classifyListFetchError(err) - } - } finally { - release() - } -} - -function parseHeaderInt(value: string | undefined, fallback: number): number { - if (!value) { - return fallback - } - const parsed = Number.parseInt(value, 10) - return Number.isFinite(parsed) ? parsed : fallback -} - -/** - * Fetch a work item (MR or issue) by explicit project ref + iid + type. - * Used by the paste-URL flow, where the URL determines the project directly. - */ -export async function getWorkItemByProjectRef( - repoPath: string, - projectRef: ProjectRef, - iid: number, - type: 'issue' | 'mr', - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - await acquire() - try { - const resource = type === 'mr' ? 'merge_requests' : 'issues' - const { stdout } = await glabExecFileAsync( - [ - 'api', - // Why: preserve the pasted URL's explicit host so cwd remotes can't redirect the lookup. - ...(projectRef.host ? ['--hostname', projectRef.host] : []), - `projects/${encodedProject(projectRef.path)}/${resource}/${iid}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) - if (type === 'mr') { - return mapMRToWorkItem(data, projectRef.path, projectRef) - } - return mapIssueToWorkItem(data, projectRef.path, projectRef) - } catch { - return null - } finally { - release() - } -} - -function mrStateToIssueState(state: MRListState): IssueListState | null { - // Why: issues have no 'merged' state; return null so the issues fetch is skipped, not mis-mapped. - switch (state) { - case 'opened': - return 'opened' - case 'closed': - return 'closed' - case 'all': - return 'all' - case 'merged': - return null - } -} - -export async function listWorkItems( - repoPath: string, - state: MRListState = 'opened', - page = 1, - perPage = 20, - preference?: IssueSourcePreference, - query?: string, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise> { - const issueState = mrStateToIssueState(state) - const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) - const { source: projectRef } = await resolveIssueSource( - repoPath, - preference, - knownHosts, - connectionId, - localGitOptions - ) - if (!projectRef) { - return { - items: [], - page, - perPage, - totalCount: 0, - totalPages: 0, - error: { - type: 'not_found', - message: 'No GitLab project found for this repository.' - } - } - } - // Why: fan out both reads so latency is the slower of the two, not the sum. - // Why read the raw issues API (not listIssues): IssueInfo strips updated_at, which the combined sort needs. - const [mrs, issues] = await Promise.all([ - listMergeRequests( - repoPath, - state, - page, - perPage, - preference, - query, - connectionId, - localGitOptions - ), - issueState === null - ? Promise.resolve({ - items: [] as GitLabWorkItem[], - error: undefined as ClassifiedError | undefined - }) - : fetchIssuesAsWorkItems( - repoPath, - projectRef, - issueState, - page, - perPage, - query, - connectionId, - localGitOptions - ) - ]) - const merged = [...mrs.items, ...issues.items].sort((a, b) => - (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') - ) - // Why: MR-side error wins — issues can be disabled per project, making its error less informative. - const error: ClassifiedError | undefined = mrs.error ?? issues.error - return { - items: merged, - page, - perPage, - // Why: approximate totals — GitLab can't paginate across MRs+issues as one set, so MR totals stand in. - totalCount: mrs.totalCount, - totalPages: mrs.totalPages, - ...(error ? { error } : {}) - } -} - -export async function fetchIssuesAsWorkItems( - repoPath: string, - projectRef: ProjectRef, - state: IssueListState, - page: number, - perPage: number, - query?: string, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ items: GitLabWorkItem[]; error: ClassifiedError | undefined }> { - await acquire() - try { - const stateParam = state === 'all' ? '' : `&state=${state}` - const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/issues?page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}${searchParam}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = parseGlabJsonList[0]>(stdout) - return { - items: data.map((d) => mapIssueToWorkItem(d, projectRef.path, projectRef)), - error: undefined - } - } catch (err) { - return { - items: [], - error: classifyListFetchError(err) - } - } finally { - release() - } -} - -/** - * List the authenticated user's GitLab todos (gitlab.com/dashboard/todos). - * User-scoped, so cwd is irrelevant; repoPath only satisfies the IPC handler's path-validation guard. - */ -export async function listTodos( - repoPath: string, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const projectRef = await getProjectRef( - repoPath, - await getGlabKnownHosts(connectionId, localGitOptions), - connectionId, - localGitOptions - ) - if (connectionId && !projectRef) { - return [] - } - await acquire() - try { - // Why: per_page=50 keeps this cross-project view cheap; the UI only shows top-priority todos. - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...(projectRef ? glabHostnameArgs(projectRef, connectionId) : []), - 'todos?state=pending&per_page=50' - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - type RESTTodo = { - id?: number - action_name?: string - target_type?: string - target?: { - iid?: number - title?: string - web_url?: string - } | null - target_url?: string - author?: { username?: string | null; avatar_url?: string | null } | null - project?: { path_with_namespace?: string } | null - updated_at?: string - state?: string - } - const data = JSON.parse(stdout) as RESTTodo[] - return data.map((t) => ({ - id: t.id ?? 0, - actionName: t.action_name ?? '', - targetType: t.target_type ?? '', - targetIid: typeof t.target?.iid === 'number' ? t.target.iid : null, - targetTitle: t.target?.title ?? '', - targetUrl: t.target_url ?? t.target?.web_url ?? '', - projectPath: t.project?.path_with_namespace ?? '', - authorUsername: t.author?.username ?? '', - authorAvatarUrl: t.author?.avatar_url ?? '', - updatedAt: t.updated_at ?? '', - state: t.state === 'done' ? 'done' : 'pending' - })) - } catch { - // Why: silent empty-list on auth/network failure matches the read-side surface; caller UI signals connectivity. - return [] - } finally { - release() - } -} - -// ── MR mutations ────────────────────────────────────────────────── - -async function withProjectRef( - repoPath: string, - preference: IssueSourcePreference | undefined, - connectionId: string | null | undefined, - explicitProjectRef: ProjectRef | null | undefined, - fn: (projectRef: ProjectRef, repoFlag: string) => Promise, - fallback: T, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const projectRef = - explicitProjectRef ?? - ( - await resolveIssueSource( - repoPath, - preference, - await getGlabKnownHosts(connectionId, localGitOptions), - connectionId, - localGitOptions - ) - ).source - if (!projectRef) { - return fallback - } - return fn(projectRef, projectRef.path) -} - -export async function closeMR( - repoPath: string, - iid: number, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - return withProjectRef<{ ok: true } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef, repoFlag) => { - await acquire() - try { - await glabExecFileAsync( - [ - 'mr', - 'close', - String(iid), - '-R', - repoFlag, - ...glabHostnameArgs(projectRef, connectionId) - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - // Why: glab exits non-zero when the MR is already closed — treat as success. - if (msg.toLowerCase().includes('already')) { - return { ok: true } - } - return { ok: false, error: msg } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function reopenMR( - repoPath: string, - iid: number, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - return withProjectRef<{ ok: true } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef, repoFlag) => { - await acquire() - try { - await glabExecFileAsync( - [ - 'mr', - 'reopen', - String(iid), - '-R', - repoFlag, - ...glabHostnameArgs(projectRef, connectionId) - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - if (msg.toLowerCase().includes('already')) { - return { ok: true } - } - return { ok: false, error: msg } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function mergeMR( - repoPath: string, - iid: number, - method: 'merge' | 'squash' | 'rebase' = 'merge', - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - return withProjectRef<{ ok: true } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef, repoFlag) => { - await acquire() - try { - // Why: omitting both --squash and --rebase yields a regular merge commit. - const methodFlag = - method === 'squash' ? ['--squash'] : method === 'rebase' ? ['--rebase'] : [] - await glabExecFileAsync( - [ - 'mr', - 'merge', - String(iid), - '-R', - repoFlag, - '--yes', - ...methodFlag, - ...glabHostnameArgs(projectRef, connectionId) - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true } - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function addMRComment( - repoPath: string, - iid: number, - body: string, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { - return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - await acquire() - try { - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'POST', - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/notes`, - '-f', - `body=${body}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) as { - id?: number - author?: { username?: string; avatar_url?: string; state?: string } | null - body?: string - created_at?: string - } - return { - ok: true, - comment: { - id: data.id ?? Date.now(), - author: data.author?.username ?? 'You', - authorAvatarUrl: data.author?.avatar_url ?? '', - body: data.body ?? body, - createdAt: data.created_at ?? new Date().toISOString(), - url: '', - isBot: data.author?.state === 'bot' - } - } - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function addMRInlineComment( - repoPath: string, - iid: number, - input: GitLabMRInlineCommentInput, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { - return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - const body = input.body.trim() - if (!body) { - return { ok: false, error: 'Comment body is required' } - } - await acquire() - try { - const oldPath = input.oldPath ?? input.path - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'POST', - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/discussions`, - '-f', - `body=${body}`, - '-f', - 'position[position_type]=text', - '-f', - `position[base_sha]=${input.baseSha}`, - '-f', - `position[start_sha]=${input.startSha}`, - '-f', - `position[head_sha]=${input.headSha}`, - '-f', - `position[old_path]=${oldPath}`, - '-f', - `position[new_path]=${input.path}`, - '-f', - `position[new_line]=${input.line}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) as { - id?: string - notes?: { - id?: number - author?: { username?: string; avatar_url?: string; state?: string } | null - body?: string - created_at?: string - position?: { new_path?: string; new_line?: number } | null - }[] - } - const note = data.notes?.[0] - return { - ok: true, - comment: { - id: note?.id ?? Date.now(), - author: note?.author?.username ?? 'You', - authorAvatarUrl: note?.author?.avatar_url ?? '', - body: note?.body ?? body, - createdAt: note?.created_at ?? new Date().toISOString(), - url: '', - threadId: data.id, - isResolved: false, - isBot: note?.author?.state === 'bot', - path: note?.position?.new_path ?? input.path, - line: note?.position?.new_line ?? input.line - } - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGlabError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function resolveMRDiscussion( - repoPath: string, - iid: number, - discussionId: string, - resolved: boolean, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - return withProjectRef( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - const trimmedDiscussionId = discussionId.trim() - if (!trimmedDiscussionId) { - return { ok: false, error: 'Discussion id is required' } - } - await acquire() - try { - // Why: GitLab resolves/reopens the whole discussion thread, not a single note. - await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'PUT', - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/discussions/${encodeURIComponent(trimmedDiscussionId)}`, - '-f', - `resolved=${resolved ? 'true' : 'false'}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGlabError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -function mapRetriedPipelineJob( - data: { - id?: number - pipeline?: { id?: number | null } | null - name?: string - stage?: string - status?: string - web_url?: string - duration?: number | null - }, - fallbackJobId: number -): GitLabPipelineJob { - return { - id: data.id ?? fallbackJobId, - ...(typeof data.pipeline?.id === 'number' ? { pipelineId: data.pipeline.id } : {}), - name: data.name ?? '', - stage: data.stage ?? '', - status: data.status ?? '', - webUrl: data.web_url ?? '', - duration: typeof data.duration === 'number' ? data.duration : null - } -} - -function mapGitLabReviewer(raw: { - id?: number - username?: string | null - name?: string | null - avatar_url?: string | null - state?: string | null -}): GitLabAssignableUser | null { - if (!raw.username) { - return null - } - return { - ...(typeof raw.id === 'number' ? { id: raw.id } : {}), - username: raw.username, - name: raw.name ?? null, - avatarUrl: raw.avatar_url ?? '', - ...(raw.state !== undefined ? { state: raw.state } : {}) - } -} - -export async function updateMRReviewers( - repoPath: string, - iid: number, - reviewerIds: number[], - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - return withProjectRef( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - await acquire() - try { - const fields = - reviewerIds.length > 0 - ? reviewerIds.flatMap((id) => ['-f', `reviewer_ids[]=${id}`]) - : ['-f', 'reviewer_ids='] - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'PUT', - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, - ...fields - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const data = JSON.parse(stdout) as { reviewers?: Parameters[0][] } - return { - ok: true, - reviewers: (data.reviewers ?? []) - .map(mapGitLabReviewer) - .filter((u): u is GitLabAssignableUser => !!u) - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGlabError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function getJobTrace( - repoPath: string, - jobId: number, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - return withProjectRef( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - await acquire() - try { - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/trace` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true, trace: stdout } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - // A job with no log is an empty log, not a failure: surfacing the 404 would - // pin an error on the Checks row for a job canceled before it started. - if (isMissingJobLogError(msg)) { - return { ok: true, trace: '' } - } - return { ok: false, error: classifyJobLogError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function retryJob( - repoPath: string, - jobId: number, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - return withProjectRef( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - await acquire() - try { - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'POST', - `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/retry` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const trimmed = stdout.trim() - return { - ok: true, - ...(trimmed ? { job: mapRetriedPipelineJob(JSON.parse(trimmed), jobId) } : {}) - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGlabError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -export async function updateMR( - repoPath: string, - iid: number, - updates: { - title?: string - body?: string - addLabels?: string[] - removeLabels?: string[] - }, - preference?: IssueSourcePreference, - connectionId?: string | null, - projectRef?: ProjectRef | null, - localGitOptions: LocalGitExecOptions = {} -): Promise<{ ok: true } | { ok: false; error: string }> { - return withProjectRef<{ ok: true } | { ok: false; error: string }>( - repoPath, - preference, - connectionId, - projectRef, - async (projectRef) => { - const fields: string[] = [] - const title = updates.title?.trim() - if (updates.title !== undefined) { - if (!title) { - return { ok: false, error: 'Title is required' } - } - fields.push(`title=${title}`) - } - if (updates.body !== undefined) { - fields.push(`description=${updates.body}`) - } - const addLabels = (updates.addLabels ?? []).filter((label) => label.trim().length > 0) - const removeLabels = (updates.removeLabels ?? []).filter((label) => label.trim().length > 0) - if (addLabels.length > 0) { - fields.push(`add_labels=${addLabels.join(',')}`) - } - if (removeLabels.length > 0) { - fields.push(`remove_labels=${removeLabels.join(',')}`) - } - if (fields.length === 0) { - return { ok: true } - } - - await acquire() - try { - await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - '-X', - 'PUT', - `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, - ...fields.flatMap((field) => ['-f', field]) - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - return { ok: true } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - return { ok: false, error: classifyGlabError(msg).message } - } finally { - release() - } - }, - { ok: false, error: 'Could not resolve GitLab project for this repository' }, - localGitOptions - ) -} - -/** Re-export so callers don't need to know the gl-utils module split. */ -export { _resetProjectRefCache } from './gl-utils' +export { + _getGitLabRateLimitCacheSize, + _resetGitLabRateLimitCache, + diagnoseAuth, + getAuthenticatedViewer, + getRateLimit +} from './gitlab-auth-and-rate-limit' +export { + getMergeRequest, + getMergeRequestForBranch, + getMergeRequestForBranchOrThrow, + getProjectSlug +} from './merge-request-lookup' +export { listMergeRequests } from './merge-request-list' +export { + fetchIssuesAsWorkItems, + getWorkItemByProjectRef, + listTodos, + listWorkItems +} from './work-item-queries' +export { closeMR, mergeMR, reopenMR } from './merge-request-state-mutations' +export { + addMRComment, + addMRInlineComment, + resolveMRDiscussion, + updateMRReviewers +} from './merge-request-review-mutations' +export { getJobTrace, retryJob } from './pipeline-job-mutations' +export { updateMR } from './merge-request-update' +export { _resetProjectRefCache, getProjectRefForRemote } from './gl-utils' export { addIssueComment, createIssue, getIssue, listIssues } from './issues' export { updateIssue } from './issue-update' export { listAssignableUsers, listLabels } from './project-label-and-member-lookup' - -// Re-exported so paste-URL call sites don't import getProjectRefForRemote from gl-utils directly. -export { getProjectRefForRemote } diff --git a/src/main/gitlab/gitlab-auth-and-rate-limit.ts b/src/main/gitlab/gitlab-auth-and-rate-limit.ts new file mode 100644 index 00000000000..58947d4b307 --- /dev/null +++ b/src/main/gitlab/gitlab-auth-and-rate-limit.ts @@ -0,0 +1,187 @@ +import type { + GetGitLabRateLimitResult, + GitLabAuthDiagnostic, + GitLabRateLimitSnapshot, + GitLabViewer +} from '../../shared/gitlab-types' +import { + acquire, + glabApiWithHeaders, + glabExecFileAsync, + parseGlabAuthStatusHosts, + release +} from './gl-utils' +import { rememberGlabKnownHosts } from './gitlab-known-host-probe' + +const GITLAB_RATE_LIMIT_CACHE_TTL_MS = 30_000 +const GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES = 64 +const gitLabRateLimitCache = new Map() + +export async function getAuthenticatedViewer(): Promise { + await acquire() + try { + const { stdout } = await glabExecFileAsync(['api', 'user']) + const viewer = JSON.parse(stdout) as { username?: string; email?: string | null } + if (!viewer.username?.trim()) { + return null + } + return { + username: viewer.username.trim(), + email: viewer.email?.trim() || null + } + } catch { + return null + } finally { + release() + } +} + +export async function diagnoseAuth(): Promise { + const envTokenInProcess = process.env.GITLAB_TOKEN + ? 'GITLAB_TOKEN' + : process.env.GLAB_TOKEN + ? 'GLAB_TOKEN' + : null + try { + // Why: a host-global diagnostic must not wake an unrelated default WSL distro. + const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { + allowDefaultWslFallback: false + }) + const output = `${stdout}\n${stderr}` + const hosts = parseGlabAuthStatusHosts(output) + // Why: refreshing auth must advance the provider cache key past a stale null result. + rememberGlabKnownHosts(hosts) + return { + glabAvailable: true, + authenticated: + /logged in|authenticated|token/i.test(output) && !/not logged in/i.test(output), + hosts, + activeHost: hosts[0] ?? null, + envTokenInProcess, + error: null + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + glabAvailable: !/ENOENT|not found|spawn/i.test(message), + authenticated: false, + hosts: [], + activeHost: null, + envTokenInProcess, + error: message + } + } +} + +function parseRateLimitHeader( + headers: Record, + keys: readonly string[] +): number | null { + for (const key of keys) { + const parsed = Number.parseInt(headers[key], 10) + if (Number.isFinite(parsed)) { + return parsed + } + } + return null +} + +function parseRateLimitResetAt(headers: Record): number | null { + const numeric = parseRateLimitHeader(headers, ['ratelimit-reset', 'x-ratelimit-reset']) + if (numeric !== null) { + return numeric + } + const resetTime = headers['ratelimit-resettime'] ?? headers['x-ratelimit-resettime'] + if (!resetTime) { + return null + } + const millis = Date.parse(resetTime) + return Number.isFinite(millis) ? Math.floor(millis / 1000) : null +} + +function parseGitLabRateLimitSnapshot( + headers: Record, + host: string | null +): GitLabRateLimitSnapshot { + const limit = parseRateLimitHeader(headers, ['ratelimit-limit', 'x-ratelimit-limit']) + const remaining = parseRateLimitHeader(headers, ['ratelimit-remaining', 'x-ratelimit-remaining']) + const resetAt = parseRateLimitResetAt(headers) + return { + host, + fetchedAt: Date.now(), + rest: + limit === null && remaining === null && resetAt === null + ? null + : { + limit: limit ?? 0, + remaining: remaining ?? 0, + resetAt + } + } +} + +/** @internal — test-only */ +export function _resetGitLabRateLimitCache(): void { + gitLabRateLimitCache.clear() +} + +/** @internal — test-only */ +export function _getGitLabRateLimitCacheSize(): number { + return gitLabRateLimitCache.size +} + +function pruneGitLabRateLimitCache(now = Date.now()): void { + for (const [cacheKey, snapshot] of gitLabRateLimitCache) { + if (now - snapshot.fetchedAt >= GITLAB_RATE_LIMIT_CACHE_TTL_MS) { + gitLabRateLimitCache.delete(cacheKey) + } + } + while (gitLabRateLimitCache.size > GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES) { + const oldestKey = gitLabRateLimitCache.keys().next().value + if (oldestKey === undefined) { + break + } + gitLabRateLimitCache.delete(oldestKey) + } +} + +function rememberGitLabRateLimitSnapshot( + cacheKey: string, + snapshot: GitLabRateLimitSnapshot +): void { + pruneGitLabRateLimitCache() + // Why: self-managed hostnames come from repo config; keep this cache bounded across many transient hosts. + gitLabRateLimitCache.delete(cacheKey) + gitLabRateLimitCache.set(cacheKey, snapshot) + pruneGitLabRateLimitCache() +} + +export async function getRateLimit(options?: { + force?: boolean + host?: string | null +}): Promise { + const host = options?.host?.trim() || null + const cacheKey = host ?? 'default' + pruneGitLabRateLimitCache() + const cached = gitLabRateLimitCache.get(cacheKey) + if (!options?.force && cached && Date.now() - cached.fetchedAt < GITLAB_RATE_LIMIT_CACHE_TTL_MS) { + return { ok: true, snapshot: cached } + } + + await acquire() + try { + // Why: GitLab exposes REST budget headers inconsistently; a null bucket means this host omitted them. + const args = host ? ['--hostname', host, 'user'] : ['user'] + const { headers } = await glabApiWithHeaders(args) + const snapshot = parseGitLabRateLimitSnapshot(headers, host) + rememberGitLabRateLimitSnapshot(cacheKey, snapshot) + return { ok: true, snapshot } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } finally { + release() + } +} + +/** Resolve a project's full GitLab project ref (host + path); null for non-GitLab remotes. */ diff --git a/src/main/gitlab/merge-request-list.ts b/src/main/gitlab/merge-request-list.ts new file mode 100644 index 00000000000..b8a2e7148bf --- /dev/null +++ b/src/main/gitlab/merge-request-list.ts @@ -0,0 +1,164 @@ +import type { ListMergeRequestsResult, MRListState } from '../../shared/gitlab-types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import { mapMRToWorkItem } from './mappers' +import { + acquire, + classifyListFetchError, + getGlabKnownHosts, + glabApiWithHeaders, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + parseGlabJsonList, + release, + resolveIssueSource, + type LocalGitExecOptions +} from './gl-utils' +import { encodedProject } from './project-path-encoding' + +function mrListStateFlags(state: MRListState): string[] { + switch (state) { + case 'opened': + return [] + case 'merged': + return ['--merged'] + case 'closed': + return ['--closed'] + case 'all': + return ['--all'] + } +} + +/** List merge requests for a project via glab CLI, which handles self-hosted auth and project selection. */ +export async function listMergeRequests( + repoPath: string, + state: MRListState = 'opened', + page = 1, + perPage = 20, + preference?: IssueSourcePreference, + query?: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) + // Why: MRs live on the user's fork (origin); route through the preference resolver so fork workflows share plumbing. + const { source: projectRef } = await resolveIssueSource( + repoPath, + preference, + knownHosts, + connectionId, + localGitOptions + ) + if (!projectRef) { + if (connectionId) { + // Why: SSH-backed repos have no local cwd; a cwd-less glab could resolve an unrelated project. + return { + items: [], + page, + perPage, + totalCount: 0, + totalPages: 0, + error: { + type: 'not_found', + message: 'No GitLab project found for this repository.' + } + } + } + // Why: fallback — let glab infer the project from cwd when the host isn't in getGlabKnownHosts. + const stateFlag = mrListStateFlags(state) + // Why: apply the same search as the API path, else queries are silently ignored on cwd-inferred repos (#6263). + const searchFlag = query?.trim() ? ['--search', query.trim()] : [] + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'mr', + 'list', + '--output', + 'json', + '--per-page', + String(perPage), + '--page', + String(page), + '--order', + 'updated_at', + '--sort', + 'desc', + ...stateFlag, + ...searchFlag + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = parseGlabJsonList[0]>(stdout) + return { + items: data.map((d) => mapMRToWorkItem(d, 'unknown')), + page, + perPage, + // Why: the CLI omits x-total headers, so totals are approximate. + totalCount: data.length, + totalPages: data.length < perPage ? page : page + 1 + } + } catch (err) { + return { + items: [], + page, + perPage, + totalCount: 0, + totalPages: 0, + error: classifyListFetchError(err) + } + } finally { + release() + } + } + // Why: GitLab's API uses an absent state param to mean "any"; drop it for 'all'. + const stateParam = state === 'all' ? '' : `&state=${state}` + const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' + const path = + `projects/${encodedProject(projectRef.path)}/merge_requests?` + + `page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc&with_merge_status_recheck=false${stateParam}${searchParam}` + const repoId = projectRef.path + + await acquire() + try { + const { body, headers } = await glabApiWithHeaders( + [...glabHostnameArgs(projectRef, connectionId), path], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = parseGlabJsonList[0]>(body) + return { + items: data.map((d) => mapMRToWorkItem(d, repoId, projectRef)), + page, + perPage, + totalCount: parseHeaderInt(headers['x-total'], 0), + // Why: GitLab may omit x-total-pages for 'all' or large per_page; fall back to ceil(total/perPage). + totalPages: + parseHeaderInt(headers['x-total-pages'], 0) || + Math.max(1, Math.ceil(parseHeaderInt(headers['x-total'], 0) / perPage)) + } + } catch (err) { + return { + items: [], + page, + perPage, + totalCount: 0, + totalPages: 0, + error: classifyListFetchError(err) + } + } finally { + release() + } +} + +function parseHeaderInt(value: string | undefined, fallback: number): number { + if (!value) { + return fallback + } + const parsed = Number.parseInt(value, 10) + return Number.isFinite(parsed) ? parsed : fallback +} + +/** + * Fetch a work item (MR or issue) by explicit project ref + iid + type. + * Used by the paste-URL flow, where the URL determines the project directly. + */ diff --git a/src/main/gitlab/merge-request-lookup.ts b/src/main/gitlab/merge-request-lookup.ts new file mode 100644 index 00000000000..fa2909d3aff --- /dev/null +++ b/src/main/gitlab/merge-request-lookup.ts @@ -0,0 +1,183 @@ +import type { MRInfo } from '../../shared/gitlab-types' +import { derivePipelineStatus, mapMRInfo } from './mappers' +import { + acquire, + getGlabKnownHosts, + getProjectRef, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + release, + type ProjectRef +} from './gl-utils' +import { encodedProject } from './project-path-encoding' +import { + hasHostedReviewLocalGitOptions, + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../source-control/hosted-review-git-options' +import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-default-branch' + +type HostedReviewLocalGitOptions = ReturnType + +function hostedReviewLocalGitOptionArgs( + options: HostedReviewExecutionOptions = {} +): [] | [HostedReviewLocalGitOptions] { + return hasHostedReviewLocalGitOptions(options) ? [getHostedReviewLocalGitOptions(options)] : [] +} + +export async function getProjectSlug( + repoPath: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const localGitArgs = hostedReviewLocalGitOptionArgs(options) + const knownHosts = await getGlabKnownHosts(connectionId, localGitArgs[0]) + return getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) +} + +/** + * Fetch a single merge request with pipeline status rolled up. + * Returns null when the MR doesn't exist or glab fails. + */ +export async function getMergeRequest( + repoPath: string, + iid: number, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const localGitArgs = hostedReviewLocalGitOptionArgs(options) + const localGitOptions = localGitArgs[0] ?? {} + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) + const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) + await acquire() + try { + const args = projectRef + ? [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}` + ] + : ['mr', 'view', String(iid), '--output', 'json'] + const { stdout } = await glabExecFileAsync( + args, + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) as Parameters[0] & { + head_pipeline?: { status?: string } | null + pipeline?: { status?: string } | null + } + // Why: older GitLab instances expose `pipeline` instead of `head_pipeline`; try both. + const pipelineStatus = derivePipelineStatus(data.head_pipeline ?? data.pipeline ?? null) + return mapMRInfo(data, pipelineStatus) + } catch { + return null + } finally { + release() + } +} + +/** + * Find the merge request whose source branch matches the given name. + * Returns the most recently updated MR for the branch, or null when none exists. + */ +export async function getMergeRequestForBranch( + repoPath: string, + branch: string, + linkedMRIid?: number | null, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {}, + // Why: when true, a failed lookup throws instead of returning null, so callers never report a false not_found. + throwOnFailure = false +): Promise { + const branchName = branch.replace(/^refs\/heads\//, '') + if (!branchName && linkedMRIid == null) { + return null + } + const localGitArgs = hostedReviewLocalGitOptionArgs(options) + const localGitOptions = localGitArgs[0] ?? {} + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) + const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) + if (!projectRef) { + return null + } + await acquire() + try { + if (branchName) { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + // Why: GitLab does not proactively recompute merge status on list endpoints, so this row + // can sit at `unchecked` forever — and the sidebar merge button gates on MERGEABLE. Ask + // for the async recalculation (best-effort; ignored for non-Developers when + // `restrict_merge_status_recheck` is on) so polling converges instead of stalling. + `projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1&with_merge_status_recheck=true` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) as (Parameters[0] & { + head_pipeline?: { status?: string } | null + pipeline?: { status?: string } | null + })[] + if (Array.isArray(data) && data.length > 0) { + const raw = data[0] + // Why: older GitLab list payloads expose `pipeline` instead of `head_pipeline`. + const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) + const info = mapMRInfo(raw, pipelineStatus) + // Why (#9171): discard a non-open implicit branch match on the repo + // default branch and fall through to the linked-iid fallback below. + const hideOnDefaultBranch = await shouldHideNonOpenReviewOnDefaultBranch({ + state: info.state, + reviewNumber: info.number, + linkedReviewNumber: linkedMRIid, + branchName, + repoPath, + connectionId, + localGitOptions + }) + if (!hideOnDefaultBranch) { + return info + } + } + } + if (typeof linkedMRIid !== 'number') { + return null + } + // Why: create-from-MR worktrees may rename the branch; fall back to the durable linked iid. + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const raw = JSON.parse(stdout) as Parameters[0] & { + head_pipeline?: { status?: string } | null + pipeline?: { status?: string } | null + } + const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) + return mapMRInfo(raw, pipelineStatus) + } catch (error) { + if (throwOnFailure) { + throw error + } + return null + } finally { + release() + } +} + +/** + * Like getMergeRequestForBranch but throws glab failures instead of returning null, so callers report 'unavailable' not a false "not found". + */ +export function getMergeRequestForBranchOrThrow( + repoPath: string, + branch: string, + linkedMRIid?: number | null, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + return getMergeRequestForBranch(repoPath, branch, linkedMRIid, connectionId, options, true) +} diff --git a/src/main/gitlab/merge-request-project-resolution.ts b/src/main/gitlab/merge-request-project-resolution.ts new file mode 100644 index 00000000000..4a28a3644ed --- /dev/null +++ b/src/main/gitlab/merge-request-project-resolution.ts @@ -0,0 +1,33 @@ +import type { IssueSourcePreference } from '../../shared/repo-types' +import { + getGlabKnownHosts, + resolveIssueSource, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' + +export async function withProjectRef( + repoPath: string, + preference: IssueSourcePreference | undefined, + connectionId: string | null | undefined, + explicitProjectRef: ProjectRef | null | undefined, + fn: (projectRef: ProjectRef, repoFlag: string) => Promise, + fallback: T, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const projectRef = + explicitProjectRef ?? + ( + await resolveIssueSource( + repoPath, + preference, + await getGlabKnownHosts(connectionId, localGitOptions), + connectionId, + localGitOptions + ) + ).source + if (!projectRef) { + return fallback + } + return fn(projectRef, projectRef.path) +} diff --git a/src/main/gitlab/merge-request-review-mutations.ts b/src/main/gitlab/merge-request-review-mutations.ts new file mode 100644 index 00000000000..0b27e0026c4 --- /dev/null +++ b/src/main/gitlab/merge-request-review-mutations.ts @@ -0,0 +1,283 @@ +import type { + GitLabAssignableUser, + GitLabDiscussionResolveResult, + GitLabMRInlineCommentInput, + GitLabMRReviewersUpdateResult, + MRComment +} from '../../shared/gitlab-types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import { + acquire, + classifyGlabError, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + release, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' +import { encodedProject } from './project-path-encoding' +import { withProjectRef } from './merge-request-project-resolution' + +export async function addMRComment( + repoPath: string, + iid: number, + body: string, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { + return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'POST', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/notes`, + '-f', + `body=${body}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) as { + id?: number + author?: { username?: string; avatar_url?: string; state?: string } | null + body?: string + created_at?: string + } + return { + ok: true, + comment: { + id: data.id ?? Date.now(), + author: data.author?.username ?? 'You', + authorAvatarUrl: data.author?.avatar_url ?? '', + body: data.body ?? body, + createdAt: data.created_at ?? new Date().toISOString(), + url: '', + isBot: data.author?.state === 'bot' + } + } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +export async function addMRInlineComment( + repoPath: string, + iid: number, + input: GitLabMRInlineCommentInput, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> { + return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + const body = input.body.trim() + if (!body) { + return { ok: false, error: 'Comment body is required' } + } + await acquire() + try { + const oldPath = input.oldPath ?? input.path + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'POST', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/discussions`, + '-f', + `body=${body}`, + '-f', + 'position[position_type]=text', + '-f', + `position[base_sha]=${input.baseSha}`, + '-f', + `position[start_sha]=${input.startSha}`, + '-f', + `position[head_sha]=${input.headSha}`, + '-f', + `position[old_path]=${oldPath}`, + '-f', + `position[new_path]=${input.path}`, + '-f', + `position[new_line]=${input.line}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) as { + id?: string + notes?: { + id?: number + author?: { username?: string; avatar_url?: string; state?: string } | null + body?: string + created_at?: string + position?: { new_path?: string; new_line?: number } | null + }[] + } + const note = data.notes?.[0] + return { + ok: true, + comment: { + id: note?.id ?? Date.now(), + author: note?.author?.username ?? 'You', + authorAvatarUrl: note?.author?.avatar_url ?? '', + body: note?.body ?? body, + createdAt: note?.created_at ?? new Date().toISOString(), + url: '', + threadId: data.id, + isResolved: false, + isBot: note?.author?.state === 'bot', + path: note?.position?.new_path ?? input.path, + line: note?.position?.new_line ?? input.line + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +export async function resolveMRDiscussion( + repoPath: string, + iid: number, + discussionId: string, + resolved: boolean, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + return withProjectRef( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + const trimmedDiscussionId = discussionId.trim() + if (!trimmedDiscussionId) { + return { ok: false, error: 'Discussion id is required' } + } + await acquire() + try { + // Why: GitLab resolves/reopens the whole discussion thread, not a single note. + await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'PUT', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/discussions/${encodeURIComponent(trimmedDiscussionId)}`, + '-f', + `resolved=${resolved ? 'true' : 'false'}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +function mapGitLabReviewer(raw: { + id?: number + username?: string | null + name?: string | null + avatar_url?: string | null + state?: string | null +}): GitLabAssignableUser | null { + if (!raw.username) { + return null + } + return { + ...(typeof raw.id === 'number' ? { id: raw.id } : {}), + username: raw.username, + name: raw.name ?? null, + avatarUrl: raw.avatar_url ?? '', + ...(raw.state !== undefined ? { state: raw.state } : {}) + } +} + +export async function updateMRReviewers( + repoPath: string, + iid: number, + reviewerIds: number[], + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + return withProjectRef( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + await acquire() + try { + const fields = + reviewerIds.length > 0 + ? reviewerIds.flatMap((id) => ['-f', `reviewer_ids[]=${id}`]) + : ['-f', 'reviewer_ids='] + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'PUT', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, + ...fields + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) as { reviewers?: Parameters[0][] } + return { + ok: true, + reviewers: (data.reviewers ?? []) + .map(mapGitLabReviewer) + .filter((u): u is GitLabAssignableUser => !!u) + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} diff --git a/src/main/gitlab/merge-request-state-mutations.ts b/src/main/gitlab/merge-request-state-mutations.ts new file mode 100644 index 00000000000..fb5d33bb670 --- /dev/null +++ b/src/main/gitlab/merge-request-state-mutations.ts @@ -0,0 +1,143 @@ +import type { IssueSourcePreference } from '../../shared/repo-types' +import { + acquire, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + release, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' +import { withProjectRef } from './merge-request-project-resolution' + +export async function closeMR( + repoPath: string, + iid: number, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + return withProjectRef<{ ok: true } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef, repoFlag) => { + await acquire() + try { + await glabExecFileAsync( + [ + 'mr', + 'close', + String(iid), + '-R', + repoFlag, + ...glabHostnameArgs(projectRef, connectionId) + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + // Why: glab exits non-zero when the MR is already closed — treat as success. + if (msg.toLowerCase().includes('already')) { + return { ok: true } + } + return { ok: false, error: msg } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +export async function reopenMR( + repoPath: string, + iid: number, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + return withProjectRef<{ ok: true } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef, repoFlag) => { + await acquire() + try { + await glabExecFileAsync( + [ + 'mr', + 'reopen', + String(iid), + '-R', + repoFlag, + ...glabHostnameArgs(projectRef, connectionId) + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (msg.toLowerCase().includes('already')) { + return { ok: true } + } + return { ok: false, error: msg } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +export async function mergeMR( + repoPath: string, + iid: number, + method: 'merge' | 'squash' | 'rebase' = 'merge', + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + return withProjectRef<{ ok: true } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef, repoFlag) => { + await acquire() + try { + // Why: omitting both --squash and --rebase yields a regular merge commit. + const methodFlag = + method === 'squash' ? ['--squash'] : method === 'rebase' ? ['--rebase'] : [] + await glabExecFileAsync( + [ + 'mr', + 'merge', + String(iid), + '-R', + repoFlag, + '--yes', + ...methodFlag, + ...glabHostnameArgs(projectRef, connectionId) + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} diff --git a/src/main/gitlab/merge-request-update.ts b/src/main/gitlab/merge-request-update.ts new file mode 100644 index 00000000000..0c82cfcf8ca --- /dev/null +++ b/src/main/gitlab/merge-request-update.ts @@ -0,0 +1,84 @@ +import type { IssueSourcePreference } from '../../shared/repo-types' +import { + acquire, + classifyGlabError, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + release, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' +import { encodedProject } from './project-path-encoding' +import { withProjectRef } from './merge-request-project-resolution' + +export async function updateMR( + repoPath: string, + iid: number, + updates: { + title?: string + body?: string + addLabels?: string[] + removeLabels?: string[] + }, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ ok: true } | { ok: false; error: string }> { + return withProjectRef<{ ok: true } | { ok: false; error: string }>( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + const fields: string[] = [] + const title = updates.title?.trim() + if (updates.title !== undefined) { + if (!title) { + return { ok: false, error: 'Title is required' } + } + fields.push(`title=${title}`) + } + if (updates.body !== undefined) { + fields.push(`description=${updates.body}`) + } + const addLabels = (updates.addLabels ?? []).filter((label) => label.trim().length > 0) + const removeLabels = (updates.removeLabels ?? []).filter((label) => label.trim().length > 0) + if (addLabels.length > 0) { + fields.push(`add_labels=${addLabels.join(',')}`) + } + if (removeLabels.length > 0) { + fields.push(`remove_labels=${removeLabels.join(',')}`) + } + if (fields.length === 0) { + return { ok: true } + } + + await acquire() + try { + await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'PUT', + `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`, + ...fields.flatMap((field) => ['-f', field]) + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +/** Re-export so callers don't need to know the gl-utils module split. */ diff --git a/src/main/gitlab/pipeline-job-mutations.ts b/src/main/gitlab/pipeline-job-mutations.ts new file mode 100644 index 00000000000..48cd2b2e6c5 --- /dev/null +++ b/src/main/gitlab/pipeline-job-mutations.ts @@ -0,0 +1,128 @@ +import type { + GitLabJobTraceResult, + GitLabPipelineJob, + GitLabRetryJobResult +} from '../../shared/gitlab-types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import { + acquire, + classifyGlabError, + classifyJobLogError, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + isMissingJobLogError, + release, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' +import { encodedProject } from './project-path-encoding' +import { withProjectRef } from './merge-request-project-resolution' + +function mapRetriedPipelineJob( + data: { + id?: number + pipeline?: { id?: number | null } | null + name?: string + stage?: string + status?: string + web_url?: string + duration?: number | null + }, + fallbackJobId: number +): GitLabPipelineJob { + return { + id: data.id ?? fallbackJobId, + ...(typeof data.pipeline?.id === 'number' ? { pipelineId: data.pipeline.id } : {}), + name: data.name ?? '', + stage: data.stage ?? '', + status: data.status ?? '', + webUrl: data.web_url ?? '', + duration: typeof data.duration === 'number' ? data.duration : null + } +} + +export async function getJobTrace( + repoPath: string, + jobId: number, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + return withProjectRef( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/trace` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + return { ok: true, trace: stdout } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + // A job with no log is an empty log, not a failure: surfacing the 404 would + // pin an error on the Checks row for a job canceled before it started. + if (isMissingJobLogError(msg)) { + return { ok: true, trace: '' } + } + return { ok: false, error: classifyJobLogError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} + +export async function retryJob( + repoPath: string, + jobId: number, + preference?: IssueSourcePreference, + connectionId?: string | null, + projectRef?: ProjectRef | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + return withProjectRef( + repoPath, + preference, + connectionId, + projectRef, + async (projectRef) => { + await acquire() + try { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + '-X', + 'POST', + `projects/${encodedProject(projectRef.path)}/jobs/${jobId}/retry` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const trimmed = stdout.trim() + return { + ok: true, + ...(trimmed ? { job: mapRetriedPipelineJob(JSON.parse(trimmed), jobId) } : {}) + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, error: classifyGlabError(msg).message } + } finally { + release() + } + }, + { ok: false, error: 'Could not resolve GitLab project for this repository' }, + localGitOptions + ) +} diff --git a/src/main/gitlab/work-item-queries.ts b/src/main/gitlab/work-item-queries.ts new file mode 100644 index 00000000000..4e6e3c3fc53 --- /dev/null +++ b/src/main/gitlab/work-item-queries.ts @@ -0,0 +1,252 @@ +import type { ClassifiedError } from '../../shared/classified-error' +import type { + GitLabPagedResult, + GitLabTodo, + GitLabWorkItem, + MRListState +} from '../../shared/gitlab-types' +import type { IssueSourcePreference } from '../../shared/repo-types' +import { mapIssueToWorkItem, mapMRToWorkItem } from './mappers' +import { + acquire, + classifyListFetchError, + getGlabKnownHosts, + getProjectRef, + glabHostnameArgs, + glabRepoExecOptions, + glabExecFileAsync, + parseGlabJsonList, + release, + resolveIssueSource, + type LocalGitExecOptions, + type ProjectRef +} from './gl-utils' +import { encodedProject } from './project-path-encoding' +import type { IssueListState } from './issues' +import { listMergeRequests } from './merge-request-list' + +export async function getWorkItemByProjectRef( + repoPath: string, + projectRef: ProjectRef, + iid: number, + type: 'issue' | 'mr', + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + await acquire() + try { + const resource = type === 'mr' ? 'merge_requests' : 'issues' + const { stdout } = await glabExecFileAsync( + [ + 'api', + // Why: preserve the pasted URL's explicit host so cwd remotes can't redirect the lookup. + ...(projectRef.host ? ['--hostname', projectRef.host] : []), + `projects/${encodedProject(projectRef.path)}/${resource}/${iid}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = JSON.parse(stdout) + if (type === 'mr') { + return mapMRToWorkItem(data, projectRef.path, projectRef) + } + return mapIssueToWorkItem(data, projectRef.path, projectRef) + } catch { + return null + } finally { + release() + } +} + +function mrStateToIssueState(state: MRListState): IssueListState | null { + // Why: issues have no 'merged' state; return null so the issues fetch is skipped, not mis-mapped. + switch (state) { + case 'opened': + return 'opened' + case 'closed': + return 'closed' + case 'all': + return 'all' + case 'merged': + return null + } +} + +export async function listWorkItems( + repoPath: string, + state: MRListState = 'opened', + page = 1, + perPage = 20, + preference?: IssueSourcePreference, + query?: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise> { + const issueState = mrStateToIssueState(state) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) + const { source: projectRef } = await resolveIssueSource( + repoPath, + preference, + knownHosts, + connectionId, + localGitOptions + ) + if (!projectRef) { + return { + items: [], + page, + perPage, + totalCount: 0, + totalPages: 0, + error: { + type: 'not_found', + message: 'No GitLab project found for this repository.' + } + } + } + // Why: fan out both reads so latency is the slower of the two, not the sum. + // Why read the raw issues API (not listIssues): IssueInfo strips updated_at, which the combined sort needs. + const [mrs, issues] = await Promise.all([ + listMergeRequests( + repoPath, + state, + page, + perPage, + preference, + query, + connectionId, + localGitOptions + ), + issueState === null + ? Promise.resolve({ + items: [] as GitLabWorkItem[], + error: undefined as ClassifiedError | undefined + }) + : fetchIssuesAsWorkItems( + repoPath, + projectRef, + issueState, + page, + perPage, + query, + connectionId, + localGitOptions + ) + ]) + const merged = [...mrs.items, ...issues.items].sort((a, b) => + (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '') + ) + // Why: MR-side error wins — issues can be disabled per project, making its error less informative. + const error: ClassifiedError | undefined = mrs.error ?? issues.error + return { + items: merged, + page, + perPage, + // Why: approximate totals — GitLab can't paginate across MRs+issues as one set, so MR totals stand in. + totalCount: mrs.totalCount, + totalPages: mrs.totalPages, + ...(error ? { error } : {}) + } +} + +export async function fetchIssuesAsWorkItems( + repoPath: string, + projectRef: ProjectRef, + state: IssueListState, + page: number, + perPage: number, + query?: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise<{ items: GitLabWorkItem[]; error: ClassifiedError | undefined }> { + await acquire() + try { + const stateParam = state === 'all' ? '' : `&state=${state}` + const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : '' + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/issues?page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}${searchParam}` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const data = parseGlabJsonList[0]>(stdout) + return { + items: data.map((d) => mapIssueToWorkItem(d, projectRef.path, projectRef)), + error: undefined + } + } catch (err) { + return { + items: [], + error: classifyListFetchError(err) + } + } finally { + release() + } +} + +/** + * List the authenticated user's GitLab todos (gitlab.com/dashboard/todos). + * User-scoped, so cwd is irrelevant; repoPath only satisfies the IPC handler's path-validation guard. + */ +export async function listTodos( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const projectRef = await getProjectRef( + repoPath, + await getGlabKnownHosts(connectionId, localGitOptions), + connectionId, + localGitOptions + ) + if (connectionId && !projectRef) { + return [] + } + await acquire() + try { + // Why: per_page=50 keeps this cross-project view cheap; the UI only shows top-priority todos. + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...(projectRef ? glabHostnameArgs(projectRef, connectionId) : []), + 'todos?state=pending&per_page=50' + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + type RESTTodo = { + id?: number + action_name?: string + target_type?: string + target?: { + iid?: number + title?: string + web_url?: string + } | null + target_url?: string + author?: { username?: string | null; avatar_url?: string | null } | null + project?: { path_with_namespace?: string } | null + updated_at?: string + state?: string + } + const data = JSON.parse(stdout) as RESTTodo[] + return data.map((t) => ({ + id: t.id ?? 0, + actionName: t.action_name ?? '', + targetType: t.target_type ?? '', + targetIid: typeof t.target?.iid === 'number' ? t.target.iid : null, + targetTitle: t.target?.title ?? '', + targetUrl: t.target_url ?? t.target?.web_url ?? '', + projectPath: t.project?.path_with_namespace ?? '', + authorUsername: t.author?.username ?? '', + authorAvatarUrl: t.author?.avatar_url ?? '', + updatedAt: t.updated_at ?? '', + state: t.state === 'done' ? 'done' : 'pending' + })) + } catch { + // Why: silent empty-list on auth/network failure matches the read-side surface; caller UI signals connectivity. + return [] + } finally { + release() + } +} diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index cd6d3c67ca9..cc7696dc796 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -35,7 +35,7 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ // fetch appears only inside injected-page script source strings, not as a // call this process makes ['main/amp/agent-status-plugin-source.ts', 1], - ['main/opencode/hook-service.ts', 1], + ['main/opencode/status-plugin-post-source.ts', 1], ['main/pi/agent-status-extension-source.ts', 1], // local identifiers named `fetch` (git fetch), not HTTP ['main/ipc/worktree-remote.ts', 2], diff --git a/src/main/index.ts b/src/main/index.ts index 84d400b3a0b..bda3c293987 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -699,7 +699,14 @@ if (app.isPackaged && process.platform !== 'win32') { void hydrateShellPath().then((result) => { if (result.ok) { mergePathSegments(result.segments) + return } + // Why: on failure the seeded fallbacks stay in front, which for an nvm user means + // the newest install rather than their `default`. Name the reason so that shows up + // in a log bundle instead of as an unexplained missing CLI. + console.warn( + `[shell-path] login-shell probe failed (${result.failureReason}); using seeded PATH` + ) }) } configureDevUserDataPath(is.dev) @@ -1905,7 +1912,8 @@ function recordProcessGoneCrash( reason, exitCode, expectedTeardown: getExpectedTeardownScope(webContentsId), - details + details, + ...(webContentsId !== undefined ? { webContentsId } : {}) }) } diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts index f291580db40..41c108a5cbc 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts @@ -76,8 +76,11 @@ function registerHandlersWithStubStore(): void { } as never) } -function emitRendererBreadcrumb(args: unknown): void { - listeners.get('crashReports:recordBreadcrumb')?.(null, args) +function emitRendererBreadcrumb(args: unknown, senderId?: number): void { + listeners.get('crashReports:recordBreadcrumb')?.( + senderId === undefined ? null : { sender: { id: senderId } }, + args + ) } describe('renderer breadcrumb IPC routing', () => { @@ -121,6 +124,17 @@ describe('renderer breadcrumb IPC routing', () => { expect(spanEndMock).toHaveBeenCalledTimes(1) }) + it('routes the trusted sender identity into renderer breadcrumb attribution', () => { + emitRendererBreadcrumb({ name: 'renderer_error', data: { message: 'boom' } }, 42) + + expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledWith( + expect.objectContaining({ + origin: 'renderer:42', + coalesceKey: expect.stringContaining('renderer:42') + }) + ) + }) + it('coalesces renderer rejection breadcrumbs by reason message', () => { emitRendererBreadcrumb({ name: 'renderer_unhandled_rejection', diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts index b6c8f5c51ed..2b2129436fb 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.ts @@ -125,10 +125,10 @@ function rendererBreadcrumbCoalesceKey( return JSON.stringify([name, message, ...sourceIdentity]) } -export function recordRendererBreadcrumbFromRenderer(args?: { - name?: unknown - data?: unknown -}): void { +export function recordRendererBreadcrumbFromRenderer( + args?: { name?: unknown; data?: unknown }, + origin?: string +): void { if (!args || typeof args.name !== 'string') { return } @@ -136,15 +136,20 @@ export function recordRendererBreadcrumbFromRenderer(args?: { if (COALESCED_RENDERER_BREADCRUMB_NAMES.has(args.name)) { const coalesceKey = rendererBreadcrumbCoalesceKey(args.name, data) if (!coalesceKey) { - recordCrashBreadcrumb(args.name, data) + if (origin) { + recordCrashBreadcrumb(args.name, data, origin) + } else { + recordCrashBreadcrumb(args.name, data) + } recordRendererBreadcrumbTrace(args.name, data) return } const coalesceResult = recordCoalescedCrashBreadcrumb({ name: args.name, data, - coalesceKey, - minIntervalMs: RENDERER_BREADCRUMB_COALESCE_MS + coalesceKey: origin ? `${origin}\u0000${coalesceKey}` : coalesceKey, + minIntervalMs: RENDERER_BREADCRUMB_COALESCE_MS, + ...(origin ? { origin } : {}) }) // Why: tracing every suppressed duplicate would preserve the same // serialization and disk churn that breadcrumb coalescing removes. @@ -157,7 +162,11 @@ export function recordRendererBreadcrumbFromRenderer(args?: { ) } } else { - recordCrashBreadcrumb(args.name, data) + if (origin) { + recordCrashBreadcrumb(args.name, data, origin) + } else { + recordCrashBreadcrumb(args.name, data) + } recordRendererBreadcrumbTrace(args.name, data) } } diff --git a/src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts b/src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts index 5592ca7ef5d..03a272a64a1 100644 --- a/src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts +++ b/src/main/ipc/crash-reporting-renderer-error-report-attribution.test.ts @@ -97,4 +97,15 @@ describe('renderer error report attribution', () => { expect(text).toContain('boundary_id') expect(text.indexOf('Attribution: unreliable')).toBeLessThan(text.indexOf('Details:')) }) + + it('does not dedupe identical errors from separate renderer windows', async () => { + const first = makeStore() + const second = makeStore() + + const firstResult = await recordRendererErrorReport(first.store, baseArgs(), 11) + const secondResult = await recordRendererErrorReport(second.store, baseArgs(), 22) + + expect(firstResult).toMatchObject({ ok: true, deduped: false }) + expect(secondResult).toMatchObject({ ok: true, deduped: false }) + }) }) diff --git a/src/main/ipc/crash-reporting-renderer-error-report.ts b/src/main/ipc/crash-reporting-renderer-error-report.ts index 3fac1f73302..581cb62d8fb 100644 --- a/src/main/ipc/crash-reporting-renderer-error-report.ts +++ b/src/main/ipc/crash-reporting-renderer-error-report.ts @@ -11,6 +11,7 @@ import { UNRELIABLE_BOUNDARY_ATTRIBUTION_NOTE } from '../../shared/react-update-depth-attribution' import type { CrashReportStore } from '../crash-reporting/crash-report-store' +import { rendererCrashBreadcrumbOrigin } from '../../shared/crash-breadcrumb-origin' import { getCrashBreadcrumbSnapshot } from '../crash-reporting/crash-breadcrumb-store' export const recentRendererErrorReportKeys = new Map() @@ -119,8 +120,12 @@ function pruneRendererErrorReportKeys(now: number): void { } } -function getRendererErrorReportKey(args: ReactErrorBoundaryReportArgs): string { +function getRendererErrorReportKey( + args: ReactErrorBoundaryReportArgs, + webContentsId?: number +): string { return JSON.stringify({ + webContentsId, boundaryId: args.boundaryId, surface: args.surface, errorName: args.errorName, @@ -131,7 +136,8 @@ function getRendererErrorReportKey(args: ReactErrorBoundaryReportArgs): string { export async function recordRendererErrorReport( store: CrashReportStore, - args: unknown + args: unknown, + webContentsId?: number ): Promise { const normalized = normalizeRendererErrorReportArgs(args) if (!normalized) { @@ -140,7 +146,7 @@ export async function recordRendererErrorReport( const now = Date.now() pruneRendererErrorReportKeys(now) - const key = getRendererErrorReportKey(normalized) + const key = getRendererErrorReportKey(normalized, webContentsId) if (now - (recentRendererErrorReportKeys.get(key) ?? 0) < RENDERER_ERROR_DEDUPE_MS) { return { ok: true, report: null, deduped: true } } @@ -186,7 +192,9 @@ export async function recordRendererErrorReport( }, // Why: React render failures are recoverable only because a boundary // caught them; persist the same recent app breadcrumbs as native crashes. - breadcrumbs: getCrashBreadcrumbSnapshot() + breadcrumbs: getCrashBreadcrumbSnapshot( + webContentsId === undefined ? undefined : rendererCrashBreadcrumbOrigin(webContentsId) + ) }) return { ok: true, report, deduped: false } diff --git a/src/main/ipc/crash-reporting.test.ts b/src/main/ipc/crash-reporting.test.ts index 86867659302..c7495634db1 100644 --- a/src/main/ipc/crash-reporting.test.ts +++ b/src/main/ipc/crash-reporting.test.ts @@ -138,7 +138,7 @@ describe('registerCrashReportingHandlers', () => { _resetRendererErrorReportDedupeForTests() }) - it('copies the latest pending diagnostic text to the clipboard', async () => { + it('copies the requested captured report to the clipboard', async () => { const latest = report() registerCrashReportingHandlers({ getById: vi.fn(async () => latest), @@ -151,6 +151,7 @@ describe('registerCrashReportingHandlers', () => { } as never) const result = await handlers.get('crashReports:copyLatestDiagnostics')?.(null, { + reportId: latest.id, notes: 'extra /Users/alice/project' }) diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index 95939a5f139..9b2d11cad7c 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -5,6 +5,7 @@ import { formatCrashReportText } from '../../shared/crash-reporting' import type { CrashReportStore } from '../crash-reporting/crash-report-store' +import { rendererCrashBreadcrumbOrigin } from '../../shared/crash-breadcrumb-origin' import { assertClipboardTextWriteWithinLimit, isClipboardTextWriteTooLargeError @@ -64,8 +65,12 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { ipcMain.removeAllListeners('crashReports:recordBreadcrumb') ipcMain.on( 'crashReports:recordBreadcrumb', - (_event, args?: { name?: unknown; data?: unknown }) => { - recordRendererBreadcrumbFromRenderer(args) + (event, args?: { name?: unknown; data?: unknown }) => { + const senderId = event?.sender?.id + recordRendererBreadcrumbFromRenderer( + args, + typeof senderId === 'number' ? rendererCrashBreadcrumbOrigin(senderId) : undefined + ) } ) @@ -94,9 +99,9 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { ) ipcMain.removeHandler('crashReports:recordRendererError') - ipcMain.handle('crashReports:recordRendererError', async (_event, args: unknown) => { + ipcMain.handle('crashReports:recordRendererError', async (event, args: unknown) => { try { - return await recordRendererErrorReport(store, args) + return await recordRendererErrorReport(store, args, event?.sender?.id) } catch (error) { console.error('[crash-reporting] Failed to record renderer error report:', error) return { ok: false, error: 'Failed to record renderer error report.' } diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 72c0198fb45..fcfe2948baf 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -99,6 +99,19 @@ function imageIconSrc(bodyBytes: number, withWhitespace = false): string { describe('dashboard payload validation', () => { it('accepts a complete dashboard snapshot', () => { expect(isDashboardSnapshot(SNAPSHOT)).toBe(true) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [ + { + ...SNAPSHOT.cards[0], + bucket: 'working', + dotState: 'working', + workingMode: 'monitoring' + } + ] + }) + ).toBe(true) }) it('rejects malformed or unbounded snapshot fields', () => { @@ -109,6 +122,18 @@ describe('dashboard payload validation', () => { cards: [{ ...SNAPSHOT.cards[0], bucket: 'unexpected' }] }) ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], dotState: 'monitoring' }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], dotState: 'done', workingMode: 'monitoring' }] + }) + ).toBe(false) expect( isDashboardSnapshot({ ...SNAPSHOT, diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index e8f8560aa51..8463be64bba 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -257,6 +257,8 @@ function isDashboardCard(value: unknown): boolean { DASHBOARD_BUCKETS.has(card.bucket) && typeof card.dotState === 'string' && DASHBOARD_DOT_STATES.has(card.dotState) && + (card.workingMode === undefined || + (card.dotState === 'working' && card.workingMode === 'monitoring')) && isBoundedString(card.task, AGENT_STATUS_MAX_FIELD_LENGTH, true) && isOptionalBoundedString(card.lastUserMessage, AGENT_STATUS_MAX_FIELD_LENGTH) && isOptionalBoundedString(card.lastAgentMessage, AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH) && diff --git a/src/main/ipc/github-account-handlers.ts b/src/main/ipc/github-account-handlers.ts new file mode 100644 index 00000000000..b5d91b6757a --- /dev/null +++ b/src/main/ipc/github-account-handlers.ts @@ -0,0 +1,31 @@ +import { ipcMain } from 'electron' +import { appStarSourceSchema } from '../../shared/gh-star-source' +import { diagnoseGhAuth } from '../github/auth-diagnose' +import { checkOrcaStarred, getAuthenticatedViewer, starOrca } from '../github/client' +import { getRateLimit } from '../github/rate-limit' +import { getCohortAtEmit } from '../telemetry/cohort-classifier' +import { track } from '../telemetry/client' + +export function registerGitHubAccountHandlers(): void { + ipcMain.handle('gh:viewer', () => getAuthenticatedViewer()) + ipcMain.handle('gh:checkOrcaStarred', () => checkOrcaStarred()) + ipcMain.handle('gh:starOrca', async (_event, source: unknown) => { + const sourceParse = appStarSourceSchema.safeParse(source) + const starred = await starOrca() + if (starred && sourceParse.success) { + track('app_starred_orca', { + source: sourceParse.data, + ...getCohortAtEmit() + }) + } + return starred + }) + + ipcMain.handle('gh:rateLimit', (_event, args?: { force?: boolean }) => + getRateLimit(args?.force ? { force: true } : undefined) + ) + + ipcMain.handle('gh:diagnoseAuth', (_event, args?: { host?: string }) => + diagnoseGhAuth(args?.host) + ) +} diff --git a/src/main/ipc/github-ipc-channel-parity.test.ts b/src/main/ipc/github-ipc-channel-parity.test.ts new file mode 100644 index 00000000000..a54b7cfbf19 --- /dev/null +++ b/src/main/ipc/github-ipc-channel-parity.test.ts @@ -0,0 +1,100 @@ +import { readFileSync } from 'node:fs' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = await vi.hoisted(async () => { + const { createGitHubIpcMocks } = await import('./github-ipc-module-mocks') + return createGitHubIpcMocks() +}) + +vi.mock('electron', () => mocks.electron) +vi.mock('../github/client', () => mocks.client) +vi.mock('../github/work-item-details', () => mocks.workItemDetails) +vi.mock('../github/pr-refresh-coordinator', () => mocks.prRefresh) +vi.mock('../telemetry/client', () => mocks.telemetry) +vi.mock('../telemetry/cohort-classifier', () => mocks.cohort) +vi.mock('./ui', () => mocks.ui) + +import * as github from './github' +import { createGitHubIpcHarness } from './github-ipc-test-harness' + +const EXPECTED_GITHUB_IPC_CHANNELS = [ + 'gh:prForBranch', + 'gh:refreshPRNow', + 'gh:enqueuePRRefresh', + 'gh:reportVisiblePRRefreshCandidates', + 'gh:issue', + 'gh:listIssues', + 'gh:createIssue', + 'gh:listWorkItems', + 'gh:countWorkItems', + 'gh:workItem', + 'gh:workItemByOwnerRepo', + 'gh:workItemDetails', + 'gh:notifyWorkItemMutated', + 'gh:prFileContents', + 'gh:repoSlug', + 'gh:repoUpstream', + 'gh:prChecks', + 'gh:prCheckDetails', + 'gh:prComments', + 'gh:setPRCommentReaction', + 'gh:resolveReviewThread', + 'gh:setPRFileViewed', + 'gh:addPRReviewCommentReply', + 'gh:addPRReviewComment', + 'gh:updatePRTitle', + 'gh:mergePR', + 'gh:setPRAutoMerge', + 'gh:updatePRState', + 'gh:rerunPRChecks', + 'gh:requestPRReviewers', + 'gh:removePRReviewers', + 'gh:updateIssue', + 'gh:addIssueComment', + 'gh:listLabels', + 'gh:listAssignableUsers', + 'gh:viewer', + 'gh:checkOrcaStarred', + 'gh:starOrca', + 'gh:rateLimit', + 'gh:diagnoseAuth', + 'gh:listAccessibleProjects', + 'gh:resolveProjectRef', + 'gh:listProjectViews', + 'gh:getProjectViewTable', + 'gh:projectWorkItemDetailsBySlug', + 'gh:updateProjectItemField', + 'gh:clearProjectItemField', + 'gh:updateIssueBySlug', + 'gh:updatePullRequestBySlug', + 'gh:addIssueCommentBySlug', + 'gh:updateIssueCommentBySlug', + 'gh:deleteIssueCommentBySlug', + 'gh:listLabelsBySlug', + 'gh:listAssignableUsersBySlug', + 'gh:listIssueTypesBySlug', + 'gh:updateIssueTypeBySlug' +] as const + +describe('GitHub IPC channel parity', () => { + const harness = createGitHubIpcHarness(mocks) + + beforeEach(harness.reset) + + it('preserves the facade and fixed IPC channel contract', () => { + github.registerGitHubHandlers(harness.store as never, harness.stats as never) + + const preloadSource = readFileSync(new URL('../../preload/index.ts', import.meta.url), 'utf8') + const exposedChannels = [...preloadSource.matchAll(/ipcRenderer\.invoke\('(gh:[^']+)'/g)].map( + (match) => match[1] + ) + const registeredChannels = mocks.electron.ipcMain.handle.mock.calls.map( + ([channel]) => channel as string + ) + + expect(Object.keys(github)).toEqual(['registerGitHubHandlers']) + expect(new Set(registeredChannels).size).toBe(registeredChannels.length) + expect(registeredChannels).toEqual(EXPECTED_GITHUB_IPC_CHANNELS) + expect(registeredChannels.toSorted()).toEqual([...new Set(exposedChannels)].toSorted()) + }) +}) diff --git a/src/main/ipc/github-issue-mutation-handlers.ts b/src/main/ipc/github-issue-mutation-handlers.ts new file mode 100644 index 00000000000..992f0c6e11f --- /dev/null +++ b/src/main/ipc/github-issue-mutation-handlers.ts @@ -0,0 +1,106 @@ +import { ipcMain } from 'electron' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { GitHubIssueUpdate } from '../../shared/issue-mutation-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { addIssueComment, listAssignableUsers, listLabels, updateIssue } from '../github/client' +import type { Store } from '../persistence' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId, + type GitHubRepoScopedArgs +} from './github-repo-routing' +import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-events' + +export function registerGitHubIssueMutationHandlers(store: Store): void { + ipcMain.handle( + 'gh:updateIssue', + async (event, args: GitHubRepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => { + const repo = assertRegisteredGitHubRepo(args, store) + if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { + return { ok: false, error: 'Invalid issue number' } + } + if (!args.updates || typeof args.updates !== 'object') { + return { ok: false, error: 'Updates object is required' } + } + const result = await updateIssue( + repo.path, + args.number, + args.updates, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + if (result.ok) { + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: 'issue', number: args.number }, + event.sender.id + ) + } + return result + } + ) + + ipcMain.handle( + 'gh:addIssueComment', + async ( + event, + args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + number: number + body: string + type?: 'issue' | 'pr' + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { + return { ok: false, error: 'Invalid issue number' } + } + if (!args.body?.trim()) { + return { ok: false, error: 'Comment body required' } + } + const result = await addIssueComment( + repo.path, + args.number, + args.body.trim(), + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + if (result.ok) { + broadcastGitHubWorkItemMutation( + { + repoPath: repo.path, + repoId: repo.id, + type: args.type ?? 'issue', + number: args.number + }, + event.sender.id + ) + } + return result + } + ) + + ipcMain.handle('gh:listLabels', (_event, args: GitHubRepoScopedArgs) => { + const repo = assertRegisteredGitHubRepo(args, store) + return listLabels( + repo.path, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + }) + + ipcMain.handle('gh:listAssignableUsers', (_event, args: GitHubRepoScopedArgs) => { + const repo = assertRegisteredGitHubRepo(args, store) + return listAssignableUsers( + repo.path, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + }) +} diff --git a/src/main/ipc/github-pr-mutation-handlers.ts b/src/main/ipc/github-pr-mutation-handlers.ts new file mode 100644 index 00000000000..1d3aecc38e3 --- /dev/null +++ b/src/main/ipc/github-pr-mutation-handlers.ts @@ -0,0 +1,212 @@ +import { ipcMain } from 'electron' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { GitHubPullRequestStateUpdate } from '../../shared/issue-mutation-types' +import { + mergePR, + removePRReviewers, + requestPRReviewers, + rerunPRChecks, + setPRAutoMerge, + updatePRState, + updatePRTitle +} from '../github/client' +import type { Store } from '../persistence' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId, + type GitHubRepoScopedArgs +} from './github-repo-routing' +import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-events' + +function broadcastSuccessfulPRMutation( + ok: boolean, + repoPath: string, + repoId: string, + prNumber: number, + senderId: number +): void { + if (ok) { + broadcastGitHubWorkItemMutation({ repoPath, repoId, type: 'pr', number: prNumber }, senderId) + } +} + +export function registerGitHubPRMutationHandlers(store: Store): void { + ipcMain.handle( + 'gh:updatePRTitle', + async ( + event, + args: { repoPath: string; prNumber: number; title: string; prRepo?: GitHubOwnerRepo | null } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const ok = await updatePRTitle( + repo.path, + args.prNumber, + args.title, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(ok, repo.path, repo.id, args.prNumber, event.sender.id) + return ok + } + ) + + ipcMain.handle( + 'gh:mergePR', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + method?: 'merge' | 'squash' | 'rebase' + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const result = await mergePR( + repo.path, + args.prNumber, + args.method, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) + + ipcMain.handle( + 'gh:setPRAutoMerge', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + enabled: boolean + method?: 'merge' | 'squash' | 'rebase' + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const result = await setPRAutoMerge( + repo.path, + args.prNumber, + args.enabled, + args.method, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) + + ipcMain.handle( + 'gh:updatePRState', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + updates: GitHubPullRequestStateUpdate + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid pull request number' } + } + const result = await updatePRState( + repo.path, + args.prNumber, + args.updates, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) + + ipcMain.handle( + 'gh:rerunPRChecks', + async ( + _event, + args: GitHubRepoScopedArgs & { + prNumber: number + headSha?: string + failedOnly?: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid pull request number' } + } + return rerunPRChecks( + repo.path, + args.prNumber, + { headSha: args.headSha, failedOnly: args.failedOnly, prRepo: args.prRepo ?? null }, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:requestPRReviewers', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const result = await requestPRReviewers( + repo.path, + args.prNumber, + args.reviewers, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) + + ipcMain.handle( + 'gh:removePRReviewers', + async ( + event, + args: GitHubRepoScopedArgs & { + prNumber: number + reviewers: string[] + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const result = await removePRReviewers( + repo.path, + args.prNumber, + args.reviewers, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + broadcastSuccessfulPRMutation(result.ok, repo.path, repo.id, args.prNumber, event.sender.id) + return result + } + ) +} diff --git a/src/main/ipc/github-pr-read-handlers.ts b/src/main/ipc/github-pr-read-handlers.ts new file mode 100644 index 00000000000..1c200218f91 --- /dev/null +++ b/src/main/ipc/github-pr-read-handlers.ts @@ -0,0 +1,178 @@ +import { ipcMain } from 'electron' +import type { GitHubReactionContent } from '../../shared/github/comment-types' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { + getPRCheckDetails, + getPRChecks, + getPRComments, + getRepoSlug, + getRepoUpstream, + resolveReviewThread, + setPRCommentReaction +} from '../github/client' +import type { Store } from '../persistence' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId +} from './github-repo-routing' + +export function registerGitHubPRReadHandlers(store: Store): void { + ipcMain.handle('gh:repoSlug', (_event, args: { repoPath: string }) => { + const repo = assertRegisteredGitHubRepo(args, store) + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] + return localGitOptions + ? getRepoSlug(repo.path, getGitHubRepoConnectionId(repo), { + localGitExecOptions: localGitOptions + }) + : getRepoSlug(repo.path, getGitHubRepoConnectionId(repo)) + }) + + ipcMain.handle('gh:repoUpstream', (_event, args: { repoPath: string }) => { + const repo = assertRegisteredGitHubRepo(args, store) + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] + return localGitOptions + ? getRepoUpstream(repo.path, getGitHubRepoConnectionId(repo), { + localGitExecOptions: localGitOptions + }) + : getRepoUpstream(repo.path, getGitHubRepoConnectionId(repo)) + }) + + ipcMain.handle( + 'gh:prChecks', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + headSha?: string + prRepo?: GitHubOwnerRepo | null + noCache?: boolean + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getPRChecks( + repo.path, + args.prNumber, + args.headSha, + args.prRepo ?? null, + { noCache: args.noCache }, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:prCheckDetails', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + checkRunId?: number + workflowRunId?: number + checkName?: string + url?: string | null + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getPRCheckDetails( + repo.path, + { + checkRunId: args.checkRunId, + workflowRunId: args.workflowRunId, + checkName: args.checkName, + url: args.url, + prRepo: args.prRepo ?? null + }, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:prComments', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + prNumber: number + prRepo?: GitHubOwnerRepo | null + noCache?: boolean + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getPRComments( + repo.path, + args.prNumber, + { noCache: args.noCache, prRepo: args.prRepo ?? null }, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:setPRCommentReaction', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + reactionSubjectId: string + content: GitHubReactionContent + reacted: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if (!args.reactionSubjectId?.trim()) { + return false + } + return setPRCommentReaction( + repo.path, + args.reactionSubjectId.trim(), + args.content, + args.reacted, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:resolveReviewThread', + async ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + threadId: string + resolve: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return resolveReviewThread( + repo.path, + args.threadId, + args.resolve, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) +} diff --git a/src/main/ipc/github-pr-refresh-handlers.ts b/src/main/ipc/github-pr-refresh-handlers.ts new file mode 100644 index 00000000000..ab4dc74ec7d --- /dev/null +++ b/src/main/ipc/github-pr-refresh-handlers.ts @@ -0,0 +1,161 @@ +import { ipcMain } from 'electron' +import { resolve } from 'node:path' +import type { + GitHubPRRefreshCandidate, + GitHubPRRefreshEnqueueResult, + GitHubPRRefreshReason, + PRRefreshOutcome +} from '../../shared/github/pull-request-refresh-types' +import type { Repo } from '../../shared/repo-types' +import { getPRForBranch, type GitHubPRBranchLookupOptions } from '../github/client' +import { + clearVisiblePRRefreshWindow, + enqueuePRRefresh, + refreshPRNow, + reportVisiblePRRefreshCandidates, + setPRRefreshOutcomeObserver +} from '../github/pr-refresh-coordinator' +import type { Store } from '../persistence' +import type { StatsCollector } from '../stats/collector' +import { + applyRegisteredRepoToPRRefreshCandidate, + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId, + validateAutomaticPRRefreshCandidate +} from './github-repo-routing' + +const visibilityCleanupRegistered = new Set() + +export function registerGitHubPRRefreshHandlers(store: Store, stats: StatsCollector): void { + function recordPRIfNeeded(repo: Repo, outcome: PRRefreshOutcome): void { + if (outcome.kind === 'found' && !stats.hasCountedPR(outcome.pr.url)) { + stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: outcome.pr.number, prUrl: outcome.pr.url } + }) + } + } + + setPRRefreshOutcomeObserver((candidate, outcome) => { + const repo = + store.getRepos().find((entry) => entry.id === candidate.repoId) ?? + store.getRepos().find((entry) => resolve(entry.path) === resolve(candidate.repoPath)) + if (repo) { + recordPRIfNeeded(repo, outcome) + } + }) + + ipcMain.handle( + 'gh:prForBranch', + async ( + _event, + args: { + repoPath: string + branch: string + linkedPRNumber?: number | null + fallbackPRNumber?: number | null + acceptMergedFallbackPR?: boolean + currentHeadOid?: string | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] + const hostedReviewOptions = localGitOptions + ? { localGitExecOptions: localGitOptions } + : undefined + const currentHeadOid = + typeof args.currentHeadOid === 'string' && args.currentHeadOid.trim().length > 0 + ? args.currentHeadOid.trim() + : null + const lookupOptions: GitHubPRBranchLookupOptions | undefined = hostedReviewOptions + ? { ...hostedReviewOptions } + : args.acceptMergedFallbackPR === true || currentHeadOid !== null + ? {} + : undefined + if (lookupOptions && args.acceptMergedFallbackPR === true) { + lookupOptions.acceptMergedFallbackPR = true + } + if (lookupOptions && currentHeadOid !== null) { + lookupOptions.currentHeadOid = currentHeadOid + } + const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = lookupOptions + ? [lookupOptions] + : [] + const pr = await getPRForBranch( + repo.path, + args.branch, + args.linkedPRNumber ?? null, + getGitHubRepoConnectionId(repo), + args.linkedPRNumber == null ? (args.fallbackPRNumber ?? null) : null, + ...lookupOptionArgs + ) + if (pr && !stats.hasCountedPR(pr.url)) { + stats.record({ + type: 'pr_created', + at: Date.now(), + repoId: repo.id, + meta: { prNumber: pr.number, prUrl: pr.url } + }) + } + return pr + } + ) + + ipcMain.handle( + 'gh:refreshPRNow', + async (_event, args: { candidate: GitHubPRRefreshCandidate }) => { + const repo = assertRegisteredGitHubRepo(args.candidate, store) + const outcome = await refreshPRNow( + applyRegisteredRepoToPRRefreshCandidate(store, repo, args.candidate) + ) + recordPRIfNeeded(repo, outcome) + return outcome + } + ) + + ipcMain.handle( + 'gh:enqueuePRRefresh', + ( + event, + args: { + candidate: GitHubPRRefreshCandidate + reason: GitHubPRRefreshReason + priority?: number + } + ): GitHubPRRefreshEnqueueResult => { + const validation = validateAutomaticPRRefreshCandidate(args.candidate, store) + if (validation.kind === 'skipped') { + return validation.result + } + enqueuePRRefresh(validation.candidate, args.reason, args.priority ?? 0, event?.sender?.id) + return { kind: 'queued' } + } + ) + + ipcMain.handle( + 'gh:reportVisiblePRRefreshCandidates', + (event, args: { candidates: GitHubPRRefreshCandidate[]; generation: number }) => { + const senderId = event.sender.id + if (!visibilityCleanupRegistered.has(senderId)) { + visibilityCleanupRegistered.add(senderId) + event.sender.once('destroyed', () => { + visibilityCleanupRegistered.delete(senderId) + clearVisiblePRRefreshWindow(senderId) + }) + } + const candidates: GitHubPRRefreshCandidate[] = [] + const repos = store.getRepos() + for (const candidate of args.candidates) { + const validation = validateAutomaticPRRefreshCandidate(candidate, store, repos) + if (validation.kind === 'ok') { + candidates.push(validation.candidate) + } + } + reportVisiblePRRefreshCandidates(candidates, args.generation, senderId) + return true + } + ) +} diff --git a/src/main/ipc/github-pr-review-handlers.ts b/src/main/ipc/github-pr-review-handlers.ts new file mode 100644 index 00000000000..637eb74c1de --- /dev/null +++ b/src/main/ipc/github-pr-review-handlers.ts @@ -0,0 +1,180 @@ +import { ipcMain } from 'electron' +import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { addPRReviewComment, addPRReviewCommentReply, setPRFileViewed } from '../github/client' +import type { Store } from '../persistence' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId +} from './github-repo-routing' +import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-events' + +export function registerGitHubPRReviewHandlers(store: Store): void { + ipcMain.handle( + 'gh:setPRFileViewed', + async ( + event, + args: { + repoPath: string + repoId?: string + prNumber: number + prRepo?: GitHubOwnerRepo | null + pullRequestId: string + path: string + viewed: boolean + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return false + } + if (!args.pullRequestId?.trim() || !args.path?.trim()) { + return false + } + const ok = await setPRFileViewed({ + repoPath: repo.path, + connectionId: getGitHubRepoConnectionId(repo), + localGitOptions: getGitHubLocalGitOptionArgs(store, repo)[0], + prRepo: args.prRepo ?? null, + pullRequestId: args.pullRequestId.trim(), + path: args.path, + viewed: Boolean(args.viewed) + }) + if (ok) { + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return ok + } + ) + + ipcMain.handle( + 'gh:addPRReviewCommentReply', + async ( + event, + args: { + repoPath: string + repoId?: string + sourceContext?: TaskSourceContext | null + prNumber: number + commentId: number + body: string + threadId?: string + path?: string + line?: number + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid PR number' } + } + if ( + typeof args.commentId !== 'number' || + !Number.isInteger(args.commentId) || + args.commentId < 1 + ) { + return { ok: false, error: 'Invalid comment ID' } + } + if (!args.body?.trim()) { + return { ok: false, error: 'Comment body required' } + } + const result = await addPRReviewCommentReply( + repo.path, + args.prNumber, + args.commentId, + args.body.trim(), + args.threadId, + args.path, + args.line, + getGitHubRepoConnectionId(repo), + args.prRepo ?? null, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + if (result.ok) { + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return result + } + ) + + ipcMain.handle( + 'gh:addPRReviewComment', + async ( + event, + args: { + repoPath: string + prNumber: number + prRepo?: GitHubOwnerRepo | null + commitId: string + path: string + line: number + startLine?: number + body: string + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + if ( + typeof args.prNumber !== 'number' || + !Number.isInteger(args.prNumber) || + args.prNumber < 1 + ) { + return { ok: false, error: 'Invalid PR number' } + } + if (typeof args.line !== 'number' || !Number.isInteger(args.line) || args.line < 1) { + return { ok: false, error: 'Invalid line number' } + } + if ( + args.startLine !== undefined && + (typeof args.startLine !== 'number' || + !Number.isInteger(args.startLine) || + args.startLine < 1 || + args.startLine > args.line) + ) { + return { ok: false, error: 'Invalid start line' } + } + if (!args.commitId?.trim()) { + return { ok: false, error: 'Missing PR head SHA' } + } + if (!args.path?.trim()) { + return { ok: false, error: 'File path required' } + } + if (!args.body?.trim()) { + return { ok: false, error: 'Comment body required' } + } + const result = await addPRReviewComment({ + repoPath: repo.path, + prRepo: args.prRepo ?? null, + prNumber: args.prNumber, + commitId: args.commitId.trim(), + path: args.path, + line: args.line, + startLine: args.startLine, + body: args.body.trim(), + connectionId: getGitHubRepoConnectionId(repo), + localGitOptions: getGitHubLocalGitOptionArgs(store, repo)[0] + }) + if (result.ok) { + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return result + } + ) +} diff --git a/src/main/ipc/github-project-view-handlers.ts b/src/main/ipc/github-project-view-handlers.ts new file mode 100644 index 00000000000..0106bd80bc7 --- /dev/null +++ b/src/main/ipc/github-project-view-handlers.ts @@ -0,0 +1,89 @@ +import { ipcMain } from 'electron' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GetProjectViewTableArgs, + ListAccessibleProjectsArgs, + ListAssignableUsersBySlugArgs, + ListIssueTypesBySlugArgs, + ListLabelsBySlugArgs, + ListProjectViewsArgs, + ProjectWorkItemDetailsBySlugArgs, + ResolveProjectRefArgs, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdateProjectItemFieldArgs, + UpdatePullRequestBySlugArgs +} from '../../shared/github/project-request-types' +import { + addIssueCommentBySlug, + clearProjectItemFieldValue, + deleteIssueCommentBySlug, + getProjectViewTable, + getWorkItemDetailsBySlug, + listAccessibleProjects, + listAssignableUsersBySlug, + listIssueTypesBySlug, + listLabelsBySlug, + listProjectViews, + resolveProjectRef, + updateIssueBySlug, + updateIssueCommentBySlug, + updateIssueTypeBySlug, + updateProjectItemFieldValue, + updatePullRequestBySlug +} from '../github/project-view' + +export function registerGitHubProjectViewHandlers(): void { + ipcMain.handle('gh:listAccessibleProjects', (_event, args?: ListAccessibleProjectsArgs) => + listAccessibleProjects(args) + ) + ipcMain.handle('gh:resolveProjectRef', (_event, args: ResolveProjectRefArgs) => + resolveProjectRef(args) + ) + ipcMain.handle('gh:listProjectViews', (_event, args: ListProjectViewsArgs) => + listProjectViews(args) + ) + ipcMain.handle('gh:getProjectViewTable', (_event, args: GetProjectViewTableArgs) => + getProjectViewTable(args) + ) + ipcMain.handle( + 'gh:projectWorkItemDetailsBySlug', + (_event, args: ProjectWorkItemDetailsBySlugArgs) => getWorkItemDetailsBySlug(args) + ) + ipcMain.handle('gh:updateProjectItemField', (_event, args: UpdateProjectItemFieldArgs) => + updateProjectItemFieldValue(args) + ) + ipcMain.handle('gh:clearProjectItemField', (_event, args: ClearProjectItemFieldArgs) => + clearProjectItemFieldValue(args) + ) + ipcMain.handle('gh:updateIssueBySlug', (_event, args: UpdateIssueBySlugArgs) => + updateIssueBySlug(args) + ) + ipcMain.handle('gh:updatePullRequestBySlug', (_event, args: UpdatePullRequestBySlugArgs) => + updatePullRequestBySlug(args) + ) + ipcMain.handle('gh:addIssueCommentBySlug', (_event, args: AddIssueCommentBySlugArgs) => + addIssueCommentBySlug(args) + ) + ipcMain.handle('gh:updateIssueCommentBySlug', (_event, args: UpdateIssueCommentBySlugArgs) => + updateIssueCommentBySlug(args) + ) + ipcMain.handle('gh:deleteIssueCommentBySlug', (_event, args: DeleteIssueCommentBySlugArgs) => + deleteIssueCommentBySlug(args) + ) + ipcMain.handle('gh:listLabelsBySlug', (_event, args: ListLabelsBySlugArgs) => + listLabelsBySlug(args) + ) + ipcMain.handle('gh:listAssignableUsersBySlug', (_event, args: ListAssignableUsersBySlugArgs) => + listAssignableUsersBySlug(args) + ) + ipcMain.handle('gh:listIssueTypesBySlug', (_event, args: ListIssueTypesBySlugArgs) => + listIssueTypesBySlug(args) + ) + ipcMain.handle('gh:updateIssueTypeBySlug', (_event, args: UpdateIssueTypeBySlugArgs) => + updateIssueTypeBySlug(args) + ) +} diff --git a/src/main/ipc/github-repo-routing.ts b/src/main/ipc/github-repo-routing.ts new file mode 100644 index 00000000000..a7f93c2b44f --- /dev/null +++ b/src/main/ipc/github-repo-routing.ts @@ -0,0 +1,130 @@ +import { resolve } from 'node:path' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import type { + GitHubPRRefreshCandidate, + GitHubPRRefreshEnqueueResult +} from '../../shared/github/pull-request-refresh-types' +import type { Repo } from '../../shared/repo-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { + notePRRefreshValidationDenial, + type PRRefreshValidationDenialReason +} from '../github/pr-refresh-validation-backoff' +import type { Store } from '../persistence' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' + +export type GitHubRepoScopedArgs = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} + +type RegisteredRepoValidationResult = + | { kind: 'ok'; repo: Repo } + | { kind: 'denied'; reason: PRRefreshValidationDenialReason; message: string } + +function validateRegisteredRepo( + args: string | GitHubRepoScopedArgs, + store: Store, + repos = store.getRepos() +): RegisteredRepoValidationResult { + const repoPath = typeof args === 'string' ? args : args.repoPath + const repoId = typeof args === 'string' ? undefined : args.repoId + const resolvedRepoPath = resolve(repoPath) + const repo = repos.find((candidate) => + repoId ? candidate.id === repoId : resolve(candidate.path) === resolvedRepoPath + ) + if (!repo) { + return { + kind: 'denied', + reason: 'unknown-repo', + message: 'Access denied: unknown repository path' + } + } + if (repoId && resolve(repo.path) !== resolvedRepoPath) { + return { + kind: 'denied', + reason: 'repo-path-mismatch', + message: 'Access denied: repository path does not match repo id' + } + } + if ( + typeof args !== 'string' && + args.sourceContext?.provider === 'github' && + args.sourceContext.hostId !== getRepoExecutionHostId(repo) + ) { + return { + kind: 'denied', + reason: 'host-mismatch', + message: 'Access denied: GitHub source host does not match repository host' + } + } + return { kind: 'ok', repo } +} + +export function assertRegisteredGitHubRepo( + args: string | GitHubRepoScopedArgs, + store: Store +): Repo { + const result = validateRegisteredRepo(args, store) + if (result.kind === 'denied') { + throw new Error(result.message) + } + return result.repo +} + +export function getGitHubRepoConnectionId(repo: Repo): string | null { + return repo.connectionId ?? null +} + +export function getGitHubLocalGitOptionArgs( + store: Store, + repo: Repo +): [] | [{ wslDistro?: string }] { + const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] +} + +export function applyRegisteredRepoToPRRefreshCandidate( + store: Store, + repo: Repo, + candidate: GitHubPRRefreshCandidate +): GitHubPRRefreshCandidate { + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] + const appliedCandidate = { ...candidate } + delete appliedCandidate.localGitOptions + delete appliedCandidate.connectionId + delete appliedCandidate.executionHostId + delete appliedCandidate.connectionState + return { + ...appliedCandidate, + repoPath: repo.path, + repoId: repo.id, + ...(localGitOptions ? { localGitOptions } : {}), + connectionId: getGitHubRepoConnectionId(repo), + executionHostId: repo.executionHostId ?? null, + connectionState: repo.connectionId ? 'connected' : 'unknown' + } +} + +export function validateAutomaticPRRefreshCandidate( + candidate: GitHubPRRefreshCandidate, + store: Store, + repos = store.getRepos() +): + | { kind: 'ok'; candidate: GitHubPRRefreshCandidate } + | { kind: 'skipped'; result: Extract } { + const result = validateRegisteredRepo(candidate, store, repos) + if (result.kind === 'denied') { + const skippedReason = notePRRefreshValidationDenial({ + repoId: candidate.repoId, + repoPath: candidate.repoPath, + reason: result.reason + }) + return { kind: 'skipped', result: { kind: 'skipped', skippedReason } } + } + return { + kind: 'ok', + candidate: applyRegisteredRepoToPRRefreshCandidate(store, result.repo, candidate) + } +} diff --git a/src/main/ipc/github-work-item-handlers.ts b/src/main/ipc/github-work-item-handlers.ts new file mode 100644 index 00000000000..26c21b3db81 --- /dev/null +++ b/src/main/ipc/github-work-item-handlers.ts @@ -0,0 +1,214 @@ +import { ipcMain } from 'electron' +import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-request-types' +import type { GitHubCreateIssueFields } from '../../shared/issue-mutation-types' +import type { TaskSourceContext } from '../../shared/task-source-context' +import { + countWorkItems, + createIssue, + getIssue, + getWorkItem, + getWorkItemByOwnerRepo, + listIssues, + listWorkItems +} from '../github/client' +import { getPRFileContents, getWorkItemDetails } from '../github/work-item-details' +import type { Store } from '../persistence' +import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' +import { + assertRegisteredGitHubRepo, + getGitHubLocalGitOptionArgs, + getGitHubRepoConnectionId, + type GitHubRepoScopedArgs +} from './github-repo-routing' +import { broadcastGitHubWorkItemMutation } from './github-work-item-mutation-events' + +export function registerGitHubWorkItemHandlers(store: Store): void { + ipcMain.handle( + 'gh:issue', + ( + _event, + args: { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null + number: number + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getIssue( + repo.path, + args.number, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle('gh:listIssues', (_event, args: { repoPath: string; limit?: number }) => { + const repo = assertRegisteredGitHubRepo(args, store) + return listIssues( + repo.path, + args.limit, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ).then((result) => result.items) + }) + + ipcMain.handle( + 'gh:createIssue', + ( + _event, + args: GitHubRepoScopedArgs & { title: string; body: string } & GitHubCreateIssueFields + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + const fields = + args.labels !== undefined || args.assignees !== undefined + ? { labels: args.labels, assignees: args.assignees } + : undefined + return createIssue( + repo.path, + args.title, + args.body, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + fields, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle( + 'gh:listWorkItems', + ( + _event, + args: { + repoPath: string + repoId?: string + limit?: number + query?: string + page?: number + noCache?: boolean + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return listWorkItems( + repo.path, + args.limit, + args.query, + args.page, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + args.noCache, + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle('gh:countWorkItems', (_event, args: { repoPath: string; query?: string }) => { + const repo = assertRegisteredGitHubRepo(args, store) + return countWorkItems( + repo.path, + args.query, + repo.issueSourcePreference, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + }) + + ipcMain.handle('gh:workItem', (_event, args: WorkItemArgs) => { + const repo = assertRegisteredGitHubRepo(args, store) + return dispatchWorkItem(args, repo, getWorkItem, getGitHubLocalGitOptionArgs(store, repo)[0]) + }) + + ipcMain.handle( + 'gh:workItemByOwnerRepo', + ( + _event, + args: { + repoPath: string + owner: string + repo: string + host?: string + number: number + type: 'issue' | 'pr' + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getWorkItemByOwnerRepo( + repo.path, + { owner: args.owner, repo: args.repo, ...(args.host ? { host: args.host } : {}) }, + args.number, + args.type, + getGitHubRepoConnectionId(repo), + ...getGitHubLocalGitOptionArgs(store, repo) + ) + } + ) + + ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => { + const repo = assertRegisteredGitHubRepo(args, store) + return dispatchWorkItem( + args, + repo, + getWorkItemDetails, + getGitHubLocalGitOptionArgs(store, repo)[0] + ) + }) + + ipcMain.handle( + 'gh:notifyWorkItemMutated', + (event, args: { repoPath: string; repoId?: string; type: 'issue' | 'pr'; number: number }) => { + const repo = args.repoId + ? store.getRepos().find((candidate) => candidate.id === args.repoId) + : assertRegisteredGitHubRepo(args, store) + if (!repo) { + return false + } + if ( + (args.type !== 'issue' && args.type !== 'pr') || + typeof args.number !== 'number' || + !Number.isInteger(args.number) || + args.number < 1 + ) { + return false + } + broadcastGitHubWorkItemMutation( + { repoPath: repo.path, repoId: repo.id, type: args.type, number: args.number }, + event.sender.id + ) + return true + } + ) + + ipcMain.handle( + 'gh:prFileContents', + ( + _event, + args: { + repoPath: string + prNumber: number + prRepo?: GitHubOwnerRepo | null + path: string + oldPath?: string + status: GitHubPRFile['status'] + headSha: string + baseSha: string + } + ) => { + const repo = assertRegisteredGitHubRepo(args, store) + return getPRFileContents({ + repoPath: repo.path, + connectionId: getGitHubRepoConnectionId(repo), + localGitOptions: getGitHubLocalGitOptionArgs(store, repo)[0], + prRepo: args.prRepo ?? null, + prNumber: args.prNumber, + path: args.path, + oldPath: args.oldPath, + status: args.status, + headSha: args.headSha, + baseSha: args.baseSha + }) + } + ) +} diff --git a/src/main/ipc/github-work-item-mutation-events.ts b/src/main/ipc/github-work-item-mutation-events.ts new file mode 100644 index 00000000000..5466dd8bdc4 --- /dev/null +++ b/src/main/ipc/github-work-item-mutation-events.ts @@ -0,0 +1,15 @@ +import { sendToTrustedUIRenderer } from './ui' + +export type GitHubWorkItemMutation = { + repoPath: string + repoId?: string + type: 'issue' | 'pr' + number: number +} + +export function broadcastGitHubWorkItemMutation( + payload: GitHubWorkItemMutation, + senderId?: number +): void { + sendToTrustedUIRenderer('gh:workItemMutated', payload, senderId) +} diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 7673aff06ce..668d9fdca79 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -1,1312 +1,21 @@ -/* eslint-disable max-lines -- Why: all GitHub IPC handlers stay co-located so -the repo-path validation, preference-threading, and stats wiring patterns are -reviewable as one surface. Splitting by feature area would risk drifting -validation/gate conventions across handler files. */ -import { ipcMain } from 'electron' -import { resolve } from 'node:path' -import type { GitHubReactionContent } from '../../shared/github/comment-types' -import type { - GitHubPRRefreshCandidate, - GitHubPRRefreshEnqueueResult, - GitHubPRRefreshReason, - PRRefreshOutcome -} from '../../shared/github/pull-request-refresh-types' -import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-request-types' -import type { - GitHubCreateIssueFields, - GitHubIssueUpdate, - GitHubPullRequestStateUpdate -} from '../../shared/issue-mutation-types' -import type { Repo } from '../../shared/repo-types' -import { getRepoExecutionHostId } from '../../shared/execution-host' -import type { TaskSourceContext } from '../../shared/task-source-context' import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' -import { - getPRForBranch, - getIssue, - getRepoSlug, - getRepoUpstream, - listIssues, - listWorkItems, - countWorkItems, - getWorkItem, - getWorkItemByOwnerRepo, - createIssue, - updateIssue, - addIssueComment, - listLabels, - listAssignableUsers, - getAuthenticatedViewer, - getPRChecks, - getPRCheckDetails, - getPRComments, - setPRCommentReaction, - resolveReviewThread, - setPRFileViewed, - addPRReviewComment, - addPRReviewCommentReply, - updatePRTitle, - mergePR, - setPRAutoMerge, - updatePRState, - rerunPRChecks, - requestPRReviewers, - removePRReviewers, - checkOrcaStarred, - starOrca -} from '../github/client' -import type { GitHubPRBranchLookupOptions } from '../github/client' -import { - clearVisiblePRRefreshWindow, - enqueuePRRefresh, - refreshPRNow, - reportVisiblePRRefreshCandidates, - setPRRefreshOutcomeObserver -} from '../github/pr-refresh-coordinator' -import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' -import { getRateLimit } from '../github/rate-limit' -import { diagnoseGhAuth } from '../github/auth-diagnose' -import { - notePRRefreshValidationDenial, - type PRRefreshValidationDenialReason -} from '../github/pr-refresh-validation-backoff' -import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' -import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' -import { - getProjectViewTable, - listAccessibleProjects, - resolveProjectRef, - listProjectViews, - getWorkItemDetailsBySlug, - updateProjectItemFieldValue, - clearProjectItemFieldValue, - updateIssueBySlug, - updatePullRequestBySlug, - addIssueCommentBySlug, - updateIssueCommentBySlug, - deleteIssueCommentBySlug, - listLabelsBySlug, - listAssignableUsersBySlug, - listIssueTypesBySlug, - updateIssueTypeBySlug -} from '../github/project-view' -import type { - AddIssueCommentBySlugArgs, - ClearProjectItemFieldArgs, - DeleteIssueCommentBySlugArgs, - GetProjectViewTableArgs, - ListAccessibleProjectsArgs, - ListAssignableUsersBySlugArgs, - ListIssueTypesBySlugArgs, - ListLabelsBySlugArgs, - ListProjectViewsArgs, - ProjectWorkItemDetailsBySlugArgs, - ResolveProjectRefArgs, - UpdateIssueBySlugArgs, - UpdateIssueCommentBySlugArgs, - UpdateIssueTypeBySlugArgs, - UpdateProjectItemFieldArgs, - UpdatePullRequestBySlugArgs -} from '../../shared/github/project-request-types' -import { appStarSourceSchema } from '../../shared/gh-star-source' -import { track } from '../telemetry/client' -import { getCohortAtEmit } from '../telemetry/cohort-classifier' -import { sendToTrustedUIRenderer } from './ui' - -const prRefreshVisibilityCleanupRegistered = new Set() - -// Why: the app renderer owns the SWR cache; browser guests cannot consume this -// event. Skip the origin because it already updated its cache optimistically. -function broadcastWorkItemMutated( - payload: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number - }, - senderId?: number -): void { - sendToTrustedUIRenderer('gh:workItemMutated', payload, senderId) -} - -// Why: returns the full Repo object instead of just the path string so that -// callers have access to repo.id for stat tracking and other context. -type RepoScopedArgs = { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null -} - -type RegisteredRepoValidationResult = - | { kind: 'ok'; repo: Repo } - | { kind: 'denied'; reason: PRRefreshValidationDenialReason; message: string } - -function validateRegisteredRepo( - args: string | RepoScopedArgs, - store: Store, - repos = store.getRepos() -): RegisteredRepoValidationResult { - const repoPath = typeof args === 'string' ? args : args.repoPath - const repoId = typeof args === 'string' ? undefined : args.repoId - const resolvedRepoPath = resolve(repoPath) - const repo = repos.find((r) => (repoId ? r.id === repoId : resolve(r.path) === resolvedRepoPath)) - if (!repo) { - return { - kind: 'denied', - reason: 'unknown-repo', - message: 'Access denied: unknown repository path' - } - } - if (repoId && resolve(repo.path) !== resolvedRepoPath) { - return { - kind: 'denied', - reason: 'repo-path-mismatch', - message: 'Access denied: repository path does not match repo id' - } - } - if ( - typeof args !== 'string' && - args.sourceContext?.provider === 'github' && - args.sourceContext.hostId !== getRepoExecutionHostId(repo) - ) { - return { - kind: 'denied', - reason: 'host-mismatch', - message: 'Access denied: GitHub source host does not match repository host' - } - } - return { kind: 'ok', repo } -} - -function assertRegisteredRepo(args: string | RepoScopedArgs, store: Store): Repo { - const result = validateRegisteredRepo(args, store) - if (result.kind === 'denied') { - throw new Error(result.message) - } - return result.repo -} - -function repoConnectionId(repo: Repo): string | null { - return repo.connectionId ?? null -} - -function localGitOptionArgs(store: Store, repo: Repo): [] | [{ wslDistro?: string }] { - const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) - return Object.keys(localGitOptions).length > 0 ? [localGitOptions] : [] -} - -function applyRepoToPRRefreshCandidate( - store: Store, - repo: Repo, - candidate: GitHubPRRefreshCandidate -): GitHubPRRefreshCandidate { - const localGitOptions = localGitOptionArgs(store, repo)[0] - const appliedCandidate = { ...candidate } - delete appliedCandidate.localGitOptions - delete appliedCandidate.connectionId - delete appliedCandidate.executionHostId - delete appliedCandidate.connectionState - return { - ...appliedCandidate, - repoPath: repo.path, - repoId: repo.id, - ...(localGitOptions ? { localGitOptions } : {}), - connectionId: repoConnectionId(repo), - executionHostId: repo.executionHostId ?? null, - connectionState: repo.connectionId ? 'connected' : 'unknown' - } -} - -function validateAutomaticPRRefreshCandidate( - candidate: GitHubPRRefreshCandidate, - store: Store, - repos = store.getRepos() -): - | { kind: 'ok'; candidate: GitHubPRRefreshCandidate } - | { - kind: 'skipped' - result: Extract - } { - const result = validateRegisteredRepo(candidate, store, repos) - if (result.kind === 'denied') { - const skippedReason = notePRRefreshValidationDenial({ - repoId: candidate.repoId, - repoPath: candidate.repoPath, - reason: result.reason - }) - return { kind: 'skipped', result: { kind: 'skipped', skippedReason } } - } - return { kind: 'ok', candidate: applyRepoToPRRefreshCandidate(store, result.repo, candidate) } -} +import { registerGitHubAccountHandlers } from './github-account-handlers' +import { registerGitHubIssueMutationHandlers } from './github-issue-mutation-handlers' +import { registerGitHubPRMutationHandlers } from './github-pr-mutation-handlers' +import { registerGitHubPRReadHandlers } from './github-pr-read-handlers' +import { registerGitHubPRRefreshHandlers } from './github-pr-refresh-handlers' +import { registerGitHubPRReviewHandlers } from './github-pr-review-handlers' +import { registerGitHubProjectViewHandlers } from './github-project-view-handlers' +import { registerGitHubWorkItemHandlers } from './github-work-item-handlers' export function registerGitHubHandlers(store: Store, stats: StatsCollector): void { - function recordPRIfNeeded(repo: Repo, outcome: PRRefreshOutcome): void { - if (outcome.kind === 'found' && !stats.hasCountedPR(outcome.pr.url)) { - stats.record({ - type: 'pr_created', - at: Date.now(), - repoId: repo.id, - meta: { prNumber: outcome.pr.number, prUrl: outcome.pr.url } - }) - } - } - - setPRRefreshOutcomeObserver((candidate, outcome) => { - const repo = - store.getRepos().find((r) => r.id === candidate.repoId) ?? - store.getRepos().find((r) => resolve(r.path) === resolve(candidate.repoPath)) - if (repo) { - recordPRIfNeeded(repo, outcome) - } - }) - - ipcMain.handle( - 'gh:prForBranch', - async ( - _event, - args: { - repoPath: string - branch: string - linkedPRNumber?: number | null - fallbackPRNumber?: number | null - acceptMergedFallbackPR?: boolean - currentHeadOid?: string | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - const hostedReviewOptionArgs: [] | [{ localGitExecOptions: { wslDistro?: string } }] = - localGitOptions ? [{ localGitExecOptions: localGitOptions }] : [] - const currentHeadOid = - typeof args.currentHeadOid === 'string' && args.currentHeadOid.trim().length > 0 - ? args.currentHeadOid.trim() - : null - const lookupOptions: GitHubPRBranchLookupOptions | undefined = hostedReviewOptionArgs[0] - ? { ...hostedReviewOptionArgs[0] } - : args.acceptMergedFallbackPR === true || currentHeadOid !== null - ? {} - : undefined - if (lookupOptions && args.acceptMergedFallbackPR === true) { - lookupOptions.acceptMergedFallbackPR = true - } - if (lookupOptions && currentHeadOid !== null) { - lookupOptions.currentHeadOid = currentHeadOid - } - const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = lookupOptions - ? [lookupOptions] - : [] - const pr = await getPRForBranch( - repo.path, - args.branch, - args.linkedPRNumber ?? null, - repoConnectionId(repo), - args.linkedPRNumber == null ? (args.fallbackPRNumber ?? null) : null, - ...lookupOptionArgs - ) - // Emit pr_created when a PR is first detected for a branch. - // Why here: the renderer polls gh:prForBranch to check PR status per worktree. - // This captures PRs opened from any workflow (Orca UI, gh CLI, github.com). - if (pr && !stats.hasCountedPR(pr.url)) { - stats.record({ - type: 'pr_created', - at: Date.now(), - repoId: repo.id, - meta: { prNumber: pr.number, prUrl: pr.url } - }) - } - return pr - } - ) - - ipcMain.handle( - 'gh:refreshPRNow', - async (_event, args: { candidate: GitHubPRRefreshCandidate }) => { - const repo = assertRegisteredRepo(args.candidate, store) - const outcome = await refreshPRNow(applyRepoToPRRefreshCandidate(store, repo, args.candidate)) - recordPRIfNeeded(repo, outcome) - return outcome - } - ) - - ipcMain.handle( - 'gh:enqueuePRRefresh', - ( - event, - args: { - candidate: GitHubPRRefreshCandidate - reason: GitHubPRRefreshReason - priority?: number - } - ): GitHubPRRefreshEnqueueResult => { - const validation = validateAutomaticPRRefreshCandidate(args.candidate, store) - if (validation.kind === 'skipped') { - return validation.result - } - const senderWindowId = event?.sender?.id - enqueuePRRefresh(validation.candidate, args.reason, args.priority ?? 0, senderWindowId) - return { kind: 'queued' } - } - ) - - ipcMain.handle( - 'gh:reportVisiblePRRefreshCandidates', - (event, args: { candidates: GitHubPRRefreshCandidate[]; generation: number }) => { - const senderId = event.sender.id - if (!prRefreshVisibilityCleanupRegistered.has(senderId)) { - prRefreshVisibilityCleanupRegistered.add(senderId) - event.sender.once('destroyed', () => { - prRefreshVisibilityCleanupRegistered.delete(senderId) - clearVisiblePRRefreshWindow(senderId) - }) - } - const candidates: GitHubPRRefreshCandidate[] = [] - const repos = store.getRepos() - for (const candidate of args.candidates) { - const validation = validateAutomaticPRRefreshCandidate(candidate, store, repos) - if (validation.kind === 'ok') { - candidates.push(validation.candidate) - } - } - reportVisiblePRRefreshCandidates(candidates, args.generation, senderId) - return true - } - ) - - ipcMain.handle( - 'gh:issue', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - number: number - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getIssue( - repo.path, - args.number, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle('gh:listIssues', (_event, args: { repoPath: string; limit?: number }) => { - const repo = assertRegisteredRepo(args, store) - // Why: listIssues now returns { items, error? }. The IPC handler unwraps to - // the items array for the existing contract; feature 1's UI consumes the - // richer envelope through `gh:listWorkItems` instead. - return listIssues( - repo.path, - args.limit, - repo.issueSourcePreference, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ).then((r) => r.items) - }) - - ipcMain.handle( - 'gh:createIssue', - (_event, args: RepoScopedArgs & { title: string; body: string } & GitHubCreateIssueFields) => { - const repo = assertRegisteredRepo(args, store) - const fields = - args.labels !== undefined || args.assignees !== undefined - ? { labels: args.labels, assignees: args.assignees } - : undefined - return createIssue( - repo.path, - args.title, - args.body, - repo.issueSourcePreference, - repoConnectionId(repo), - fields, - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:listWorkItems', - ( - _event, - args: { - repoPath: string - repoId?: string - limit?: number - query?: string - page?: number - noCache?: boolean - } - ) => { - const repo = assertRegisteredRepo(args, store) - return listWorkItems( - repo.path, - args.limit, - args.query, - args.page, - repo.issueSourcePreference, - repoConnectionId(repo), - args.noCache, - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle('gh:countWorkItems', (_event, args: { repoPath: string; query?: string }) => { - const repo = assertRegisteredRepo(args, store) - return countWorkItems( - repo.path, - args.query, - repo.issueSourcePreference, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - }) - - ipcMain.handle('gh:workItem', (_event, args: WorkItemArgs) => { - const repo = assertRegisteredRepo(args, store) - return dispatchWorkItem(args, repo, getWorkItem, localGitOptionArgs(store, repo)[0]) - }) - ipcMain.handle( - 'gh:workItemByOwnerRepo', - ( - _event, - args: { - repoPath: string - owner: string - repo: string - host?: string - number: number - type: 'issue' | 'pr' - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getWorkItemByOwnerRepo( - repo.path, - // Why: Enterprise host identity must survive the IPC boundary or the - // lookup falls back to gh's default host for a same-named repo. - { owner: args.owner, repo: args.repo, ...(args.host ? { host: args.host } : {}) }, - args.number, - args.type, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - ipcMain.handle('gh:workItemDetails', (_event, args: WorkItemArgs) => { - const repo = assertRegisteredRepo(args, store) - return dispatchWorkItem(args, repo, getWorkItemDetails, localGitOptionArgs(store, repo)[0]) - }) - - ipcMain.handle( - 'gh:notifyWorkItemMutated', - ( - event, - args: { - repoPath: string - repoId?: string - type: 'issue' | 'pr' - number: number - } - ) => { - const repo = args.repoId - ? store.getRepos().find((candidate) => candidate.id === args.repoId) - : assertRegisteredRepo(args, store) - if (!repo) { - return false - } - if ( - (args.type !== 'issue' && args.type !== 'pr') || - typeof args.number !== 'number' || - !Number.isInteger(args.number) || - args.number < 1 - ) { - return false - } - broadcastWorkItemMutated( - { - repoPath: repo.path, - repoId: repo.id, - type: args.type, - number: args.number - }, - event.sender.id - ) - return true - } - ) - - ipcMain.handle( - 'gh:prFileContents', - ( - _event, - args: { - repoPath: string - prNumber: number - prRepo?: GitHubOwnerRepo | null - path: string - oldPath?: string - status: GitHubPRFile['status'] - headSha: string - baseSha: string - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getPRFileContents({ - repoPath: repo.path, - connectionId: repoConnectionId(repo), - localGitOptions: localGitOptionArgs(store, repo)[0], - prRepo: args.prRepo ?? null, - prNumber: args.prNumber, - path: args.path, - oldPath: args.oldPath, - status: args.status, - headSha: args.headSha, - baseSha: args.baseSha - }) - } - ) - - ipcMain.handle('gh:repoSlug', (_event, args: { repoPath: string }) => { - const repo = assertRegisteredRepo(args, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - return localGitOptions - ? getRepoSlug(repo.path, repoConnectionId(repo), { localGitExecOptions: localGitOptions }) - : getRepoSlug(repo.path, repoConnectionId(repo)) - }) - - ipcMain.handle('gh:repoUpstream', (_event, args: { repoPath: string }) => { - const repo = assertRegisteredRepo(args, store) - const localGitOptions = localGitOptionArgs(store, repo)[0] - return localGitOptions - ? getRepoUpstream(repo.path, repoConnectionId(repo), { localGitExecOptions: localGitOptions }) - : getRepoUpstream(repo.path, repoConnectionId(repo)) - }) - - ipcMain.handle( - 'gh:prChecks', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - prNumber: number - headSha?: string - prRepo?: GitHubOwnerRepo | null - noCache?: boolean - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getPRChecks( - repo.path, - args.prNumber, - args.headSha, - args.prRepo ?? null, - { - noCache: args.noCache - }, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:prCheckDetails', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - checkRunId?: number - workflowRunId?: number - checkName?: string - url?: string | null - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getPRCheckDetails( - repo.path, - { - checkRunId: args.checkRunId, - workflowRunId: args.workflowRunId, - checkName: args.checkName, - url: args.url, - prRepo: args.prRepo ?? null - }, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:prComments', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - prNumber: number - prRepo?: GitHubOwnerRepo | null - noCache?: boolean - } - ) => { - const repo = assertRegisteredRepo(args, store) - return getPRComments( - repo.path, - args.prNumber, - { noCache: args.noCache, prRepo: args.prRepo ?? null }, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:setPRCommentReaction', - ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - reactionSubjectId: string - content: GitHubReactionContent - reacted: boolean - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if (!args.reactionSubjectId?.trim()) { - return false - } - return setPRCommentReaction( - repo.path, - args.reactionSubjectId.trim(), - args.content, - args.reacted, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:resolveReviewThread', - async ( - _event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - threadId: string - resolve: boolean - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - // Why: thread resolve doesn't carry the PR number, so we cannot target - // a specific cache entry. The renderer cache stores per-(repo, type, number) - // entries — emitting a path-wide invalidation here would require a new - // event shape; instead, the drawer's existing thread-resolve UI updates - // its local state immediately and the next reopen pays one fresh fetch. - return resolveReviewThread( - repo.path, - args.threadId, - args.resolve, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:setPRFileViewed', - async ( - event, - args: { - repoPath: string - repoId?: string - prNumber: number - prRepo?: GitHubOwnerRepo | null - pullRequestId: string - path: string - viewed: boolean - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return false - } - if (!args.pullRequestId?.trim() || !args.path?.trim()) { - return false - } - const ok = await setPRFileViewed({ - repoPath: repo.path, - connectionId: repoConnectionId(repo), - localGitOptions: localGitOptionArgs(store, repo)[0], - prRepo: args.prRepo ?? null, - pullRequestId: args.pullRequestId.trim(), - path: args.path, - viewed: Boolean(args.viewed) - }) - if (ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return ok - } - ) - - ipcMain.handle( - 'gh:addPRReviewCommentReply', - async ( - event, - args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - prNumber: number - commentId: number - body: string - threadId?: string - path?: string - line?: number - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return { ok: false, error: 'Invalid PR number' } - } - if ( - typeof args.commentId !== 'number' || - !Number.isInteger(args.commentId) || - args.commentId < 1 - ) { - return { ok: false, error: 'Invalid comment ID' } - } - if (!args.body?.trim()) { - return { ok: false, error: 'Comment body required' } - } - const result = await addPRReviewCommentReply( - repo.path, - args.prNumber, - args.commentId, - args.body.trim(), - args.threadId, - args.path, - args.line, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:addPRReviewComment', - async ( - event, - args: { - repoPath: string - prNumber: number - prRepo?: GitHubOwnerRepo | null - commitId: string - path: string - line: number - startLine?: number - body: string - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return { ok: false, error: 'Invalid PR number' } - } - if (typeof args.line !== 'number' || !Number.isInteger(args.line) || args.line < 1) { - return { ok: false, error: 'Invalid line number' } - } - if ( - args.startLine !== undefined && - (typeof args.startLine !== 'number' || - !Number.isInteger(args.startLine) || - args.startLine < 1 || - args.startLine > args.line) - ) { - return { ok: false, error: 'Invalid start line' } - } - if (!args.commitId?.trim()) { - return { ok: false, error: 'Missing PR head SHA' } - } - if (!args.path?.trim()) { - return { ok: false, error: 'File path required' } - } - if (!args.body?.trim()) { - return { ok: false, error: 'Comment body required' } - } - const result = await addPRReviewComment({ - repoPath: repo.path, - prRepo: args.prRepo ?? null, - prNumber: args.prNumber, - commitId: args.commitId.trim(), - path: args.path, - line: args.line, - startLine: args.startLine, - body: args.body.trim(), - connectionId: repoConnectionId(repo), - localGitOptions: localGitOptionArgs(store, repo)[0] - }) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:updatePRTitle', - async ( - event, - args: { repoPath: string; prNumber: number; title: string; prRepo?: GitHubOwnerRepo | null } - ) => { - const repo = assertRegisteredRepo(args, store) - const ok = await updatePRTitle( - repo.path, - args.prNumber, - args.title, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return ok - } - ) - - ipcMain.handle( - 'gh:mergePR', - async ( - event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - prNumber: number - method?: 'merge' | 'squash' | 'rebase' - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const result = await mergePR( - repo.path, - args.prNumber, - args.method, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:setPRAutoMerge', - async ( - event, - args: { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null - prNumber: number - enabled: boolean - method?: 'merge' | 'squash' | 'rebase' - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const result = await setPRAutoMerge( - repo.path, - args.prNumber, - args.enabled, - args.method, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:updatePRState', - async ( - event, - args: RepoScopedArgs & { - prNumber: number - updates: GitHubPullRequestStateUpdate - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return { ok: false, error: 'Invalid pull request number' } - } - const result = await updatePRState( - repo.path, - args.prNumber, - args.updates, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:rerunPRChecks', - async ( - _event, - args: RepoScopedArgs & { - prNumber: number - headSha?: string - failedOnly?: boolean - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if ( - typeof args.prNumber !== 'number' || - !Number.isInteger(args.prNumber) || - args.prNumber < 1 - ) { - return { ok: false, error: 'Invalid pull request number' } - } - return rerunPRChecks( - repo.path, - args.prNumber, - { headSha: args.headSha, failedOnly: args.failedOnly, prRepo: args.prRepo ?? null }, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - } - ) - - ipcMain.handle( - 'gh:requestPRReviewers', - async ( - event, - args: RepoScopedArgs & { - prNumber: number - reviewers: string[] - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const result = await requestPRReviewers( - repo.path, - args.prNumber, - args.reviewers, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:removePRReviewers', - async ( - event, - args: RepoScopedArgs & { - prNumber: number - reviewers: string[] - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - const result = await removePRReviewers( - repo.path, - args.prNumber, - args.reviewers, - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:updateIssue', - async (event, args: RepoScopedArgs & { number: number; updates: GitHubIssueUpdate }) => { - const repo = assertRegisteredRepo(args, store) - if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { - return { ok: false, error: 'Invalid issue number' } - } - if (!args.updates || typeof args.updates !== 'object') { - return { ok: false, error: 'Updates object is required' } - } - const result = await updateIssue( - repo.path, - args.number, - args.updates, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: 'issue', number: args.number }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle( - 'gh:addIssueComment', - async ( - event, - args: { - repoPath: string - repoId?: string - sourceContext?: TaskSourceContext | null - number: number - body: string - type?: 'issue' | 'pr' - prRepo?: GitHubOwnerRepo | null - } - ) => { - const repo = assertRegisteredRepo(args, store) - if (typeof args.number !== 'number' || !Number.isInteger(args.number) || args.number < 1) { - return { ok: false, error: 'Invalid issue number' } - } - if (!args.body?.trim()) { - return { ok: false, error: 'Comment body required' } - } - const result = await addIssueComment( - repo.path, - args.number, - args.body.trim(), - repoConnectionId(repo), - args.prRepo ?? null, - ...localGitOptionArgs(store, repo) - ) - if (result.ok) { - // Why: PR conversation comments hit `/issues/N/comments` too, but the - // drawer's cache key uses type='pr'. The caller passes through which - // drawer they're posting from so we only invalidate the matching key - // — broadcasting both would evict an unrelated PR/issue that happens - // to share the number. - broadcastWorkItemMutated( - { repoPath: repo.path, repoId: repo.id, type: args.type ?? 'issue', number: args.number }, - event.sender.id - ) - } - return result - } - ) - - ipcMain.handle('gh:listLabels', (_event, args: RepoScopedArgs) => { - const repo = assertRegisteredRepo(args, store) - return listLabels( - repo.path, - repo.issueSourcePreference, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - }) - - ipcMain.handle('gh:listAssignableUsers', (_event, args: RepoScopedArgs) => { - const repo = assertRegisteredRepo(args, store) - return listAssignableUsers( - repo.path, - repo.issueSourcePreference, - repoConnectionId(repo), - ...localGitOptionArgs(store, repo) - ) - }) - - // Star operations target the Orca repo itself — no repoPath validation needed - ipcMain.handle('gh:viewer', () => getAuthenticatedViewer()) - ipcMain.handle('gh:checkOrcaStarred', () => checkOrcaStarred()) - ipcMain.handle('gh:starOrca', async (_event, source: unknown) => { - const sourceParse = appStarSourceSchema.safeParse(source) - const starred = await starOrca() - if (starred && sourceParse.success) { - // Why: this main-owned event bypasses renderer telemetry IPC, so cohort - // context must be attached here on the successful star path. - track('app_starred_orca', { - source: sourceParse.data, - ...getCohortAtEmit() - }) - } - return starred - }) - - // Why: `rate_limit` is exempt from GitHub's rate-limit accounting, so - // polling is cheap. A 30s in-process cache still avoids the gh subprocess - // cost on every render — see getRateLimit for the ttl rationale. Force - // parameter lets the renderer bust the cache after a known-expensive op - // (e.g. post-ProjectPicker discovery) without waiting out the ttl. - ipcMain.handle('gh:rateLimit', (_event, args?: { force?: boolean }) => - getRateLimit(args?.force ? { force: true } : undefined) - ) - - ipcMain.handle('gh:diagnoseAuth', (_event, args?: { host?: string }) => - diagnoseGhAuth(args?.host) - ) - - // ── GitHub ProjectV2 view handlers ───────────────────────────────── - // Why: registered unconditionally so enabling the experimental flag at - // runtime takes effect without a restart. The renderer gates entry points. - // Handlers never throw across IPC — every failure mode resolves through the - // GitHubProjectViewError envelope. - - ipcMain.handle('gh:listAccessibleProjects', (_event, args?: ListAccessibleProjectsArgs) => - listAccessibleProjects(args) - ) - - ipcMain.handle('gh:resolveProjectRef', (_event, args: ResolveProjectRefArgs) => - resolveProjectRef(args) - ) - - ipcMain.handle('gh:listProjectViews', (_event, args: ListProjectViewsArgs) => - listProjectViews(args) - ) - - ipcMain.handle('gh:getProjectViewTable', (_event, args: GetProjectViewTableArgs) => - getProjectViewTable(args) - ) - - ipcMain.handle( - 'gh:projectWorkItemDetailsBySlug', - (_event, args: ProjectWorkItemDetailsBySlugArgs) => getWorkItemDetailsBySlug(args) - ) - - ipcMain.handle('gh:updateProjectItemField', (_event, args: UpdateProjectItemFieldArgs) => - updateProjectItemFieldValue(args) - ) - - ipcMain.handle('gh:clearProjectItemField', (_event, args: ClearProjectItemFieldArgs) => - clearProjectItemFieldValue(args) - ) - - ipcMain.handle('gh:updateIssueBySlug', (_event, args: UpdateIssueBySlugArgs) => - updateIssueBySlug(args) - ) - - ipcMain.handle('gh:updatePullRequestBySlug', (_event, args: UpdatePullRequestBySlugArgs) => - updatePullRequestBySlug(args) - ) - - ipcMain.handle('gh:addIssueCommentBySlug', (_event, args: AddIssueCommentBySlugArgs) => - addIssueCommentBySlug(args) - ) - - ipcMain.handle('gh:updateIssueCommentBySlug', (_event, args: UpdateIssueCommentBySlugArgs) => - updateIssueCommentBySlug(args) - ) - - ipcMain.handle('gh:deleteIssueCommentBySlug', (_event, args: DeleteIssueCommentBySlugArgs) => - deleteIssueCommentBySlug(args) - ) - - ipcMain.handle('gh:listLabelsBySlug', (_event, args: ListLabelsBySlugArgs) => - listLabelsBySlug(args) - ) - - ipcMain.handle('gh:listAssignableUsersBySlug', (_event, args: ListAssignableUsersBySlugArgs) => - listAssignableUsersBySlug(args) - ) - - ipcMain.handle('gh:listIssueTypesBySlug', (_event, args: ListIssueTypesBySlugArgs) => - listIssueTypesBySlug(args) - ) - - ipcMain.handle('gh:updateIssueTypeBySlug', (_event, args: UpdateIssueTypeBySlugArgs) => - updateIssueTypeBySlug(args) - ) - - // Why: issue-source preference writes go through the generic `repos:update` - // IPC (extended in this PR to accept `issueSourcePreference`). Routing - // through the same channel keeps a single write path, guarantees the - // `repos:changed` broadcast is emitted, and avoids two channels racing to - // persist the same field with different validation and eviction semantics. - // Reads piggyback on the `Repo` record already delivered by `repos:list`. + registerGitHubPRRefreshHandlers(store, stats) + registerGitHubWorkItemHandlers(store) + registerGitHubPRReadHandlers(store) + registerGitHubPRReviewHandlers(store) + registerGitHubPRMutationHandlers(store) + registerGitHubIssueMutationHandlers(store) + registerGitHubAccountHandlers() + registerGitHubProjectViewHandlers() } diff --git a/src/main/ipc/hooks/register-worktree-hook-check-handler.ts b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts new file mode 100644 index 00000000000..6b64dbe6a6f --- /dev/null +++ b/src/main/ipc/hooks/register-worktree-hook-check-handler.ts @@ -0,0 +1,67 @@ +import { ipcMain } from 'electron' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { isFolderRepo } from '../../../shared/repo-kind' +import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths' +import { parseOrcaYaml, hasHooksFile, loadHooks, hasUnrecognizedOrcaYamlKeys } from '../../hooks' +import { isENOENT } from '../filesystem-path-containment' +import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' +import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' + +export function registerWorktreeHookCheckHandler(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle( + 'hooks:check', + async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { + const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) + if (!repo) { + const repoIdExists = store.getRepos().some((candidate) => candidate.id === args.repoId) + // Why: callers treat inspection errors as "skip", so a requested/ambiguous host must report error (fail closed), not hook-free. + return { + status: args.hostId || repoIdExists ? 'error' : 'ok', + hasHooks: false, + hooks: null, + mayNeedUpdate: false + } + } + if (isFolderRepo(repo)) { + return { status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false } + } + + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false } + } + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + return { + status: 'ok', + hasHooks: !result.isBinary, + hooks: result.isBinary ? null : parseOrcaYaml(result.content), + mayNeedUpdate: false + } + } catch (error) { + return { + status: isENOENT(error) ? 'ok' : 'error', + hasHooks: false, + hooks: null, + mayNeedUpdate: false + } + } + } + + const has = hasHooksFile(repo.path) + const hooks = has ? loadHooks(repo.path) : null + // Why: unrecognised top-level keys mean the file is well-formed but from a newer Orca; suggest updating rather than "could not be parsed". + const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) + return { + status: 'ok', + hasHooks: has, + hooks, + mayNeedUpdate + } + } + ) +} diff --git a/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts b/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts new file mode 100644 index 00000000000..881a5e3bd0a --- /dev/null +++ b/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts @@ -0,0 +1,126 @@ +import { ipcMain } from 'electron' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { isFolderRepo } from '../../../shared/repo-kind' +import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths' +import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { isENOENT } from '../filesystem-path-containment' +import { parseOrcaYaml } from '../../hooks' +import { readIssueCommand, writeIssueCommand } from '../../issue-command-file' +import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' +import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' + +export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle( + 'hooks:readIssueCommand', + async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { + const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) + if (!repo || isFolderRepo(repo)) { + return { + status: 'ok', + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: '', + source: 'none' as const + } + } + if (repo.connectionId) { + const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return { + status: 'error', + localContent: null, + sharedContent: null, + effectiveContent: null, + localFilePath: issueCommandPath, + source: 'none' as const + } + } + + let status: 'ok' | 'error' = 'ok' + let localContent: string | null = null + let sharedContent: string | null = null + try { + const result = await fsProvider.readFile(issueCommandPath) + localContent = result.isBinary ? null : result.content.trim() || null + } catch (error) { + if (!isENOENT(error)) { + status = 'error' + } + } + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + sharedContent = result.isBinary + ? null + : parseOrcaYaml(result.content)?.issueCommand?.trim() || null + } catch (error) { + if (!isENOENT(error)) { + status = 'error' + } + } + const effectiveContent = localContent ?? sharedContent + return { + status: localContent ? 'ok' : status, + localContent, + sharedContent, + effectiveContent, + localFilePath: issueCommandPath, + source: localContent + ? ('local' as const) + : sharedContent + ? ('shared' as const) + : ('none' as const) + } + } + return readIssueCommand(repo.path) + } + ) + + ipcMain.handle( + 'hooks:writeIssueCommand', + async (_event, args: { repoId: string; content: string; hostId?: ExecutionHostId }) => { + const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) + if (!repo || isFolderRepo(repo)) { + return + } + if (repo.connectionId) { + const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + throw new Error( + 'Remote filesystem unavailable. Reconnect the SSH target before retrying.' + ) + } + const trimmed = args.content.trim() + if (!trimmed) { + await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => { + if (!isENOENT(error)) { + throw error + } + }) + return + } + await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) + const gitignorePath = joinWorktreeRelativePath(repo.path, '.gitignore') + try { + const result = await fsProvider.readFile(gitignorePath) + if (!result.isBinary && !/^\.orca\/?$/m.test(result.content)) { + const separator = result.content.endsWith('\n') ? '' : '\n' + await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`) + } + } catch (error) { + if (!isENOENT(error)) { + throw error + } + await fsProvider.writeFile(gitignorePath, '.orca\n') + } + await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) + return + } + writeIssueCommand(repo.path, args.content) + } + ) +} diff --git a/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts b/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts new file mode 100644 index 00000000000..36dcfbc1288 --- /dev/null +++ b/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts @@ -0,0 +1,78 @@ +import { ipcMain } from 'electron' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { isFolderRepo } from '../../../shared/repo-kind' +import { inspectSetupScriptImportCandidates } from '../../../shared/setup-script-imports' +import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths' +import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { readFile, stat } from 'node:fs/promises' +import { isENOENT } from '../filesystem-path-containment' +import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership' +import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' + +export function registerWorktreeHookInspectionHandler(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle( + 'hooks:inspectSetupScriptImports', + async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { + const repo = resolveRepoForExecutionHost(store, args.repoId, args.hostId) + if (!repo || isFolderRepo(repo)) { + return [] + } + + return inspectSetupScriptImportCandidates( + async (relativePath) => { + const filePath = joinWorktreeRelativePath(repo.path, relativePath) + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return null + } + try { + const result = await fsProvider.readFile(filePath) + return result.isBinary ? null : result.content + } catch { + return null + } + } + + try { + return await readFile(filePath, 'utf-8') + } catch (error) { + if (!isENOENT(error)) { + console.warn('[hooks] Failed to inspect setup script import candidate:', error) + } + return null + } + }, + { + fileExists: async (relativePath) => { + const filePath = joinWorktreeRelativePath(repo.path, relativePath) + if (repo.connectionId) { + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return false + } + try { + const fileStat = await fsProvider.stat(filePath) + return fileStat.type !== 'directory' + } catch { + return false + } + } + + try { + const fileStat = await stat(filePath) + return !fileStat.isDirectory() + } catch (error) { + if (!isENOENT(error)) { + console.warn('[hooks] Failed to stat setup script import candidate:', error) + } + return false + } + } + } + ) + } + ) +} diff --git a/src/main/ipc/hooks/register-worktree-hook-runner-handler.ts b/src/main/ipc/hooks/register-worktree-hook-runner-handler.ts new file mode 100644 index 00000000000..b6386616bf2 --- /dev/null +++ b/src/main/ipc/hooks/register-worktree-hook-runner-handler.ts @@ -0,0 +1,29 @@ +import { ipcMain } from 'electron' +import { + createIssueCommandRunnerScript, + resolveSetupRunnerShell +} from '../../worktree-runner-script' +import { getLocalProjectWorktreeGitOptions } from '../../project-runtime-git-options' +import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context' + +export function registerWorktreeHookRunnerHandler(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle( + 'hooks:createIssueCommandRunner', + (_event, args: { repoId: string; worktreePath: string; command: string }) => { + const repo = store.getRepo(args.repoId) + if (!repo) { + throw new Error(`Repo not found: ${args.repoId}`) + } + + return createIssueCommandRunnerScript( + repo, + args.worktreePath, + args.command, + getLocalProjectWorktreeGitOptions(store, repo), + resolveSetupRunnerShell(store.getSettings()) + ) + } + ) +} diff --git a/src/main/ipc/parcel-watcher-entry-path.test.ts b/src/main/ipc/parcel-watcher-entry-path.test.ts index 2b29c6c9016..1331cb2b7a9 100644 --- a/src/main/ipc/parcel-watcher-entry-path.test.ts +++ b/src/main/ipc/parcel-watcher-entry-path.test.ts @@ -41,6 +41,25 @@ describe('resolveWatcherProcessEntryPath', () => { ) }) + it('uses the adjacent entry for a packaged host whose app root is not an asar', () => { + // orcad: a packaged Node bundle that ships this child beside orcad.js. Gating the + // adjacent probe on isPackaged sent it to a desktop out/main that never exists here. + const orcadRoot = path.join(path.sep, 'opt', 'orca') + const adjacentEntry = path.join(orcadRoot, 'parcel-watcher-process-entry.js') + + expect( + resolveWatcherProcessEntryPath(orcadRoot, true, (candidate) => candidate === adjacentEntry) + ).toBe(adjacentEntry) + }) + + it('falls back to the nested entry when a non-asar packaged host ships no adjacent child', () => { + const orcadRoot = path.join(path.sep, 'opt', 'orca') + + expect(resolveWatcherProcessEntryPath(orcadRoot, true, () => false)).toBe( + path.join(orcadRoot, 'out', 'main', 'parcel-watcher-process-entry.js') + ) + }) + it('uses resourcesPath for packaged Electron-as-Node serve processes', () => { const resourcesPath = path.join('Applications', 'Orca.app', 'Contents', 'Resources') const packagedEntry = path.join( diff --git a/src/main/ipc/parcel-watcher-entry-path.ts b/src/main/ipc/parcel-watcher-entry-path.ts index a61d240f2bb..229dcdc65e2 100644 --- a/src/main/ipc/parcel-watcher-entry-path.ts +++ b/src/main/ipc/parcel-watcher-entry-path.ts @@ -18,11 +18,15 @@ export function resolveWatcherProcessEntryPath( ): string { // Why: ELECTRON_RUN_AS_NODE bypasses Electron's asar integration, so the // packaged entry must be forked from app.asar.unpacked. - const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + const usesAsarArchive = isPackaged && appPath.includes('app.asar') + const basePath = usesAsarArchive ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath const adjacentBuildEntry = join(basePath, 'parcel-watcher-process-entry.js') // Why: electron-vite's unpackaged appPath is already out/main. Appending // out/main again silently disables crash isolation in dev and E2E builds. - if (!isPackaged && pathExists(adjacentBuildEntry)) { + // Why asar and not isPackaged: orcad is a packaged non-Electron host whose app root + // holds orcad.js and this child side by side. Only the asar layout nests it under + // out/main, and only there is the adjacent probe guaranteed to miss. + if (!usesAsarArchive && pathExists(adjacentBuildEntry)) { return adjacentBuildEntry } return join(basePath, 'out', 'main', 'parcel-watcher-process-entry.js') diff --git a/src/main/ipc/pty-applied-size-reporting.test.ts b/src/main/ipc/pty-applied-size-reporting.test.ts index 5607c7cab61..87b8c5e086f 100644 --- a/src/main/ipc/pty-applied-size-reporting.test.ts +++ b/src/main/ipc/pty-applied-size-reporting.test.ts @@ -75,6 +75,30 @@ describe('registerPtyHandlers', () => { return write } + function setupProviderWithTransientAttachFailure(earlyExit = false): ReturnType { + const spawn = vi.fn(async (opts: { sessionId?: string }) => { + if (spawn.mock.calls.length > 1) { + if (earlyExit) { + return { id: opts.sessionId ?? 'daemon-pty', exitedBeforeSpawnReply: true as const } + } + throw new Error('transport unavailable') + } + return { id: opts.sessionId ?? 'daemon-pty' } + }) + setLocalPtyProvider({ + spawn, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + return spawn + } + const resizeListener = (): ((event: unknown, args: unknown) => void) => { const call = onMock.mock.calls.find((entry: unknown[]) => entry[0] === 'pty:resize') if (!call) { @@ -150,6 +174,63 @@ describe('registerPtyHandlers', () => { const reported = await handlers.get('pty:getSize')!(null, { id }) expect(reported).toEqual({ cols: 100, rows: 30 }) }) + it('preserves cached geometry after a transient renderer attach failure', async () => { + setupProviderWithTransientAttachFailure() + handlers.clear() + registerPtyHandlers(mainWindow as never) + const request = { sessionId: 'daemon-existing-size', env: {} } + await handlers.get('pty:spawn')!(null, { ...request, cols: 200, rows: 50 }) + + await expect( + handlers.get('pty:spawn')!(null, { ...request, cols: 80, rows: 24 }) + ).rejects.toThrow('transport unavailable') + + await expect(handlers.get('pty:getSize')!(null, { id: request.sessionId })).resolves.toEqual({ + cols: 200, + rows: 50 + }) + }) + it('preserves cached geometry after a transient runtime attach failure', async () => { + setupProviderWithTransientAttachFailure() + let controller: + | { + spawn: (args: Record) => Promise + getSize: (id: string) => { cols: number; rows: number } | null + } + | undefined + const runtime = { + setPtyController: vi.fn((next) => { + controller = next + }), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const request = { sessionId: 'runtime-existing-size' } + await controller!.spawn({ ...request, cols: 200, rows: 50 }) + + await expect(controller!.spawn({ ...request, cols: 80, rows: 24 })).rejects.toThrow( + 'transport unavailable' + ) + + expect(controller!.getSize(request.sessionId)).toEqual({ cols: 200, rows: 50 }) + }) + it('clears cached geometry when the provider proves the PTY exited before reply', async () => { + setupProviderWithTransientAttachFailure(true) + handlers.clear() + registerPtyHandlers(mainWindow as never) + const request = { sessionId: 'daemon-exited-before-reply', env: {} } + await handlers.get('pty:spawn')!(null, { ...request, cols: 200, rows: 50 }) + + await expect( + handlers.get('pty:spawn')!(null, { ...request, cols: 80, rows: 24 }) + ).rejects.toThrow('agent_session_exited_during_start') + + await expect( + handlers.get('pty:getSize')!(null, { id: request.sessionId }) + ).resolves.toBeNull() + }) it('fans out accepted desktop resizes to the runtime after provider resize', async () => { const resize = vi.fn() setupProviderWithAppliedSize({ applied: { cols: 120, rows: 30 }, resize }) diff --git a/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts b/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts index 04ca18f997f..e08e6aa2090 100644 --- a/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts +++ b/src/main/ipc/pty-buffer-snapshot-dispatch.test.ts @@ -48,8 +48,13 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () => ) describe('registerPtyHandlers', () => { - const { handlers, mainWindow, createMockProc, installObservableDaemonTestProvider } = - setupPtyIpcSuite() + const { + handlers, + mainWindow, + mainWindowIpcEvent, + createMockProc, + installObservableDaemonTestProvider + } = setupPtyIpcSuite() describe('agent_started telemetry', () => { // Why: telemetry-plan.md§Agent launch semantics — agent_started fires only after provider.spawn resolves; a malformed payload must not emit a silent event. @@ -190,7 +195,7 @@ describe('registerPtyHandlers', () => { expect(responseChannelRegistrations.length).toBe(1) // Drain the in-flight requests so the test doesn't leak timers. for (const requestId of getSentRequestIds()) { - listener(null, { requestId, snapshot: null }) + listener(mainWindowIpcEvent, { requestId, snapshot: null }) } await Promise.all(inflight) }) @@ -202,11 +207,11 @@ describe('registerPtyHandlers', () => { const requestIdA = ids[0] const requestIdB = ids[1] - listener(null, { + listener(mainWindowIpcEvent, { requestId: requestIdB, snapshot: { data: 'B-data', cols: 80, rows: 24 } }) - listener(null, { + listener(mainWindowIpcEvent, { requestId: requestIdA, snapshot: { data: 'A-data', cols: 100, rows: 30, lastTitle: 'A-title' } }) @@ -224,11 +229,11 @@ describe('registerPtyHandlers', () => { const pending = controller.serializeBuffer('pty-1') const realRequestId = getSentRequestIds()[0] - listener(null, { + listener(mainWindowIpcEvent, { requestId: 'not-a-real-id', snapshot: { data: 'irrelevant', cols: 1, rows: 1 } }) - listener(null, { requestId: undefined, snapshot: null }) + listener(mainWindowIpcEvent, { requestId: undefined, snapshot: null }) let resolved = false void pending.then(() => { @@ -237,7 +242,10 @@ describe('registerPtyHandlers', () => { await new Promise((r) => setTimeout(r, 0)) expect(resolved).toBe(false) - listener(null, { requestId: realRequestId, snapshot: { data: 'ok', cols: 80, rows: 24 } }) + listener(mainWindowIpcEvent, { + requestId: realRequestId, + snapshot: { data: 'ok', cols: 80, rows: 24 } + }) await expect(pending).resolves.toEqual({ data: 'ok', cols: 80, rows: 24 }) }) it('resolves to null and removes the entry when the 750ms timeout fires', async () => { @@ -255,7 +263,7 @@ describe('registerPtyHandlers', () => { const { listener, controller } = setup() const pending = controller.serializeBuffer('pty-bad') const requestId = getSentRequestIds()[0] - listener(null, { requestId, snapshot: { data: 'ok', cols: 'not-a-number' } }) + listener(mainWindowIpcEvent, { requestId, snapshot: { data: 'ok', cols: 'not-a-number' } }) await expect(pending).resolves.toBeNull() }) }) diff --git a/src/main/ipc/pty-codex-account-attribution.test.ts b/src/main/ipc/pty-codex-account-attribution.test.ts index 9b06723e726..353be0f5909 100644 --- a/src/main/ipc/pty-codex-account-attribution.test.ts +++ b/src/main/ipc/pty-codex-account-attribution.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' import { readFileSyncMock, recordCodexPaneAccountMock, forgetCodexPaneAccountMock } from './pty-ipc-mock-registry' -import { TEST_CODEX_HOME, TEST_CODEX_AUTH_JSON } from './pty-ipc-test-constants' +import { TEST_CODEX_HOME, TEST_CODEX_AUTH_JSON, TEST_MANAGED_ROOT } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { registerPtyHandlers, setLocalPtyProvider } from './pty' @@ -52,6 +53,12 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () => import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) ) +const MANAGED_ORIGIN_HOME = join(TEST_MANAGED_ROOT, 'origin', 'home') +const MANAGED_CURRENT_HOME = join(TEST_MANAGED_ROOT, 'current', 'home') +const MANAGED_SHARED_HOME = join(TEST_MANAGED_ROOT, 'shared-mirror', 'home') +const ORIGIN_ROLLOUT = join(MANAGED_ORIGIN_HOME, 'sessions', '2026', '07', '20', 'rollout-a.jsonl') +const SHARED_ROLLOUT = join(MANAGED_SHARED_HOME, 'sessions', '2026', '07', '20', 'rollout-a.jsonl') + describe('registerPtyHandlers', () => { const { handlers, mainWindow } = setupPtyIpcSuite() @@ -145,15 +152,15 @@ describe('registerPtyHandlers', () => { } return '' }) - const resolveHome = vi.fn(() => '/managed/current/home') + const resolveHome = vi.fn(() => MANAGED_CURRENT_HOME) registerPtyHandlers( mainWindow as never, undefined, resolveHome, (() => ({ codexManagedAccounts: [ - { id: 'account-a', managedHomePath: '/managed/origin/home' }, - { id: 'account-b', managedHomePath: '/managed/current/home' } + { id: 'account-a', managedHomePath: MANAGED_ORIGIN_HOME }, + { id: 'account-b', managedHomePath: MANAGED_CURRENT_HOME } ] })) as never, undefined, @@ -161,7 +168,7 @@ describe('registerPtyHandlers', () => { { prepareCodexSessionResume: async () => ({ outcome: 'resume' as const, - codexHomePath: '/managed/origin/home' + codexHomePath: MANAGED_ORIGIN_HOME }) } ) @@ -175,7 +182,7 @@ describe('registerPtyHandlers', () => { resumeProviderSession: { key: 'session_id', id: 'session-a', - transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl' + transcriptPath: ORIGIN_ROLLOUT } }) const rejection = expect(launch).rejects.toThrow( @@ -203,21 +210,21 @@ describe('registerPtyHandlers', () => { const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: 'account-b', codexManagedAccounts: [ - { id: 'account-a', managedHomePath: '/managed/origin/home' }, - { id: 'account-b', managedHomePath: '/managed/current/home' } + { id: 'account-a', managedHomePath: MANAGED_ORIGIN_HOME }, + { id: 'account-b', managedHomePath: MANAGED_CURRENT_HOME } ] }) registerPtyHandlers( mainWindow as never, undefined, - vi.fn(() => '/managed/current/home'), + vi.fn(() => MANAGED_CURRENT_HOME), getSettings as never, undefined, undefined, { prepareCodexSessionResume: async () => ({ outcome: 'resume' as const, - codexHomePath: '/managed/origin/home' + codexHomePath: MANAGED_ORIGIN_HOME }) } ) @@ -231,7 +238,7 @@ describe('registerPtyHandlers', () => { resumeProviderSession: { key: 'session_id', id: 'session-a', - transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl' + transcriptPath: ORIGIN_ROLLOUT } }) @@ -240,7 +247,7 @@ describe('registerPtyHandlers', () => { expect(recordCodexPaneAccountMock.mock.calls).toEqual([ ['pty-resumed', { selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' }] ]) - expect(readFileSyncMock).toHaveBeenCalledWith('/managed/origin/home/auth.json', 'utf8') + expect(readFileSyncMock).toHaveBeenCalledWith(join(MANAGED_ORIGIN_HOME, 'auth.json'), 'utf8') expect(forgetCodexPaneAccountMock).not.toHaveBeenCalled() }) it('leaves a resumed Codex pane unattributed when no account owns its home', async () => { @@ -257,19 +264,19 @@ describe('registerPtyHandlers', () => { } as never) const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: 'account-b', - codexManagedAccounts: [{ id: 'account-b', managedHomePath: '/managed/current/home' }] + codexManagedAccounts: [{ id: 'account-b', managedHomePath: MANAGED_CURRENT_HOME }] }) registerPtyHandlers( mainWindow as never, undefined, - vi.fn(() => '/managed/current/home'), + vi.fn(() => MANAGED_CURRENT_HOME), getSettings as never, undefined, undefined, { prepareCodexSessionResume: async () => ({ outcome: 'resume' as const, - codexHomePath: '/managed/shared-mirror/home' + codexHomePath: MANAGED_SHARED_HOME }) } ) @@ -282,7 +289,7 @@ describe('registerPtyHandlers', () => { resumeProviderSession: { key: 'session_id', id: 'session-a', - transcriptPath: '/managed/shared-mirror/home/sessions/2026/07/20/rollout-a.jsonl' + transcriptPath: SHARED_ROLLOUT } }) @@ -320,22 +327,22 @@ describe('registerPtyHandlers', () => { const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: 'account-b', codexManagedAccounts: [ - { id: 'account-a', managedHomePath: '/managed/origin/home' }, - { id: 'account-b', managedHomePath: '/managed/current/home' } + { id: 'account-a', managedHomePath: MANAGED_ORIGIN_HOME }, + { id: 'account-b', managedHomePath: MANAGED_CURRENT_HOME } ] }) handlers.clear() registerPtyHandlers( mainWindow as never, runtime as never, - vi.fn(() => '/managed/current/home'), + vi.fn(() => MANAGED_CURRENT_HOME), getSettings as never, undefined, undefined, { prepareCodexSessionResume: async () => ({ outcome: 'resume' as const, - codexHomePath: '/managed/origin/home' + codexHomePath: MANAGED_ORIGIN_HOME }) } ) @@ -351,7 +358,7 @@ describe('registerPtyHandlers', () => { resumeProviderSession: { key: 'session_id', id: 'session-a', - transcriptPath: '/managed/origin/home/sessions/2026/07/20/rollout-a.jsonl' + transcriptPath: ORIGIN_ROLLOUT } }) @@ -361,7 +368,7 @@ describe('registerPtyHandlers', () => { { selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' } ] ]) - expect(readFileSyncMock).toHaveBeenCalledWith('/managed/origin/home/auth.json', 'utf8') + expect(readFileSyncMock).toHaveBeenCalledWith(join(MANAGED_ORIGIN_HOME, 'auth.json'), 'utf8') expect(forgetCodexPaneAccountMock).not.toHaveBeenCalled() }) it('leaves a runtime-controller resumed Codex pane unattributed when no account owns its home', async () => { @@ -389,10 +396,10 @@ describe('registerPtyHandlers', () => { } const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: 'account-b', - codexManagedAccounts: [{ id: 'account-b', managedHomePath: '/managed/current/home' }] + codexManagedAccounts: [{ id: 'account-b', managedHomePath: MANAGED_CURRENT_HOME }] }) handlers.clear() - const resolveHome = vi.fn(() => '/managed/shared-mirror/home') + const resolveHome = vi.fn(() => MANAGED_SHARED_HOME) registerPtyHandlers( mainWindow as never, runtime as never, @@ -403,7 +410,7 @@ describe('registerPtyHandlers', () => { { prepareCodexSessionResume: async () => ({ outcome: 'resume' as const, - codexHomePath: '/managed/shared-mirror/home', + codexHomePath: MANAGED_SHARED_HOME, reconcileSharedRuntimeAuth: true }) } @@ -419,7 +426,7 @@ describe('registerPtyHandlers', () => { resumeProviderSession: { key: 'session_id', id: 'session-a', - transcriptPath: '/managed/shared-mirror/home/sessions/2026/07/20/rollout-a.jsonl' + transcriptPath: SHARED_ROLLOUT } }) diff --git a/src/main/ipc/pty-controller-spawn-admission.test.ts b/src/main/ipc/pty-controller-spawn-admission.test.ts index ae4af67434c..65f5ba565c9 100644 --- a/src/main/ipc/pty-controller-spawn-admission.test.ts +++ b/src/main/ipc/pty-controller-spawn-admission.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it, vi } from 'vitest' +import { + openCodeBuildPtyEnvMock, + openCodeClearPtyMock, + piClearPtyMock +} from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { OrcaRuntimeService } from '../runtime/orca-runtime' @@ -227,6 +232,74 @@ describe('registerPtyHandlers', () => { expect.objectContaining({ sessionId: 'serve-stable-session', isNewSession: true }) ) }) + it('clears a fresh runtime session when host-env setup fails', async () => { + const provider = createAgentClaimProvider({ spawn: vi.fn() }) + setLocalPtyProvider(provider as never) + openCodeBuildPtyEnvMock.mockImplementationOnce(() => { + throw new Error('host env failed') + }) + const controller = registerAgentClaimController() + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + cwd: '/tmp/runtime-host-env-failure', + worktreeId: 'repo::/tmp/runtime-host-env-failure' + }) + ).rejects.toThrow('host env failed') + + expect(provider.spawn).not.toHaveBeenCalled() + expect(openCodeClearPtyMock).toHaveBeenCalledOnce() + expect(piClearPtyMock).toHaveBeenCalledOnce() + }) + it('preserves caller-owned runtime state when host-env setup fails', async () => { + const provider = createAgentClaimProvider({ spawn: vi.fn() }) + setLocalPtyProvider(provider as never) + openCodeBuildPtyEnvMock.mockImplementationOnce(() => { + throw new Error('host env failed') + }) + const controller = registerAgentClaimController() + + await expect( + controller.spawn({ + cols: 80, + rows: 24, + sessionId: 'caller-owned-session', + isNewSession: true + }) + ).rejects.toThrow('host env failed') + + expect(provider.spawn).not.toHaveBeenCalled() + expect(openCodeClearPtyMock).not.toHaveBeenCalled() + expect(piClearPtyMock).not.toHaveBeenCalled() + }) + it('preserves operation-owned runtime state when retry host-env setup fails', async () => { + const physicalSpawn = vi.fn(async (options: { sessionId?: string }) => ({ + id: options.sessionId ?? 'unexpected-spawn' + })) + const provider = createAgentClaimProvider({ spawn: physicalSpawn }) + setLocalPtyProvider(provider as never) + const controller = registerAgentClaimController() + const request = { + cols: 80, + rows: 24, + worktreeId: 'repo::/tmp/operation-retry', + agentSessionCreateOperationId: 'a'.repeat(43) + } + await controller.spawn(request) + openCodeClearPtyMock.mockClear() + piClearPtyMock.mockClear() + openCodeBuildPtyEnvMock.mockImplementationOnce(() => { + throw new Error('host env failed') + }) + + await expect(controller.spawn(request)).rejects.toThrow('host env failed') + + expect(physicalSpawn).toHaveBeenCalledOnce() + expect(openCodeClearPtyMock).not.toHaveBeenCalled() + expect(piClearPtyMock).not.toHaveBeenCalled() + }) it('adopts a daemon owner recovered from provider listing before claimed ensure', async () => { const owner: AgentSessionOwnerBinding = { claim: recoveredAgentClaim, diff --git a/src/main/ipc/pty-daemon-controller-teardown.test.ts b/src/main/ipc/pty-daemon-controller-teardown.test.ts index 73ae897b8b3..63786b361fb 100644 --- a/src/main/ipc/pty-daemon-controller-teardown.test.ts +++ b/src/main/ipc/pty-daemon-controller-teardown.test.ts @@ -153,6 +153,62 @@ describe('registerPtyHandlers', () => { }) expect(runtime.onPtyExit).not.toHaveBeenCalled() }) + it('bounds keep-history inventory polls by the settlement deadline', async () => { + vi.useFakeTimers() + try { + const listProcesses = vi.fn(async (_opts?: { deadlineMs?: number }) => []) + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(async () => undefined), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses, + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + const runtime = { + setPtyController: vi.fn(), + onPtyExit: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + stopAndWait: ( + ptyId: string, + opts?: { keepHistory?: boolean; deadlineMs?: number } + ) => Promise + } + const callerDeadlineMs = Date.now() + 5_000 + const stopPromise = controller.stopAndWait('local-pty', { + keepHistory: true, + deadlineMs: callerDeadlineMs + }) + + await vi.advanceTimersByTimeAsync(1_000) + await expect(stopPromise).resolves.toBe(true) + + expect(listProcesses.mock.calls[0]?.[0]).toEqual({ deadlineMs: callerDeadlineMs }) + const settlementDeadlines = listProcesses.mock.calls + .slice(1) + .map(([opts]) => opts?.deadlineMs) + expect(settlementDeadlines.length).toBeGreaterThan(0) + expect(new Set(settlementDeadlines)).toEqual(new Set([callerDeadlineMs - 4_000])) + } finally { + vi.useRealTimers() + } + }) it('uses inventory, not an incarnation-less exit, to confirm the current PTY stopped', async () => { const exitListeners = new Set< (payload: { id: string; code: number; incarnationId?: string }) => void diff --git a/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts b/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts index f323039191f..bfacfc96935 100644 --- a/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts +++ b/src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts @@ -198,7 +198,7 @@ describe('registerPtyHandlers', () => { }) expect(warnSpy).toHaveBeenCalledWith( '[pty] hidden-delivery gate is dropping bytes for a visible/active pty', - expect.objectContaining({ id: result.id, visible: true }) + expect.objectContaining({ id: redactPtyIdForDiagnostics(result.id), visible: true }) ) // Unhiding resolves the contradiction. diff --git a/src/main/ipc/pty-ipc-producer-flow-control.test.ts b/src/main/ipc/pty-ipc-producer-flow-control.test.ts index bfc65403daa..8b97751dba7 100644 --- a/src/main/ipc/pty-ipc-producer-flow-control.test.ts +++ b/src/main/ipc/pty-ipc-producer-flow-control.test.ts @@ -206,6 +206,24 @@ describe('registerPtyHandlers', () => { vi.useRealTimers() } }) + it('releases a paused producer when handlers re-register for a replacement window', () => { + vi.useFakeTimers() + try { + const provider = installObservableDaemonTestProvider() + registerPtyHandlers(mainWindow as never) + mainWindow.webContents.send.mockClear() + + provider.emitData('flood-pty', 'x'.repeat(320 * 1024)) + expect(provider.pauseProducer).toHaveBeenCalledTimes(1) + + // Re-registration hands delivery to a new window; the outgoing session must run its real + // lifecycle reset before the bridge is neutralized or this shell stays paused forever. + registerPtyHandlers(mainWindow as never) + expect(provider.resumeProducer).toHaveBeenCalledWith('flood-pty') + } finally { + vi.useRealTimers() + } + }) it('fences synchronous producer data and duplicate exit while releasing an exiting PTY', () => { vi.useFakeTimers() try { diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts index 5e5a9c819f7..95c7dfe3eea 100644 --- a/src/main/ipc/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty-ipc-suite-environment.ts @@ -54,7 +54,15 @@ import { __resetShellStartupEnvCache } from '../pty/shell-startup-env' import { _resetWslCachesForTests } from '../wsl' /** The mocked webContents each suite asserts sends against. */ -export type PtyIpcTestWebContents = { on: Mock; send: Mock; removeListener: Mock } +export type PtyIpcTestWebContents = { + on: Mock + send: Mock + removeListener: Mock + // Why real Electron has this: webContents can be destroyed a beat before its BrowserWindow + // during close, so renderer-liveness guards check both. Omitting it here let those guards + // pass vacuously in every suite (STA-2373 / STA-5373). + isDestroyed: Mock +} /** The mocked BrowserWindow handed to registerPtyHandlers. */ export type PtyIpcTestMainWindow = { @@ -83,12 +91,18 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { webContents: { on: vi.fn(), send: vi.fn(), - removeListener: vi.fn() + removeListener: vi.fn(), + isDestroyed: vi.fn(() => false) } } const mainWindowIpcEvent = { sender: mainWindow.webContents } const foreignWindowIpcEvent = { - sender: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() } + sender: { + on: vi.fn(), + send: vi.fn(), + removeListener: vi.fn(), + isDestroyed: vi.fn(() => false) + } } const envScope = createPtyIpcProcessEnvScope() @@ -155,6 +169,10 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() mainWindow.webContents.removeListener.mockReset() + // Why re-stub, not just reset: a bare mockReset returns undefined, which reads as "alive" + // by accident rather than by intent, and hides a test that forgot to restore liveness. + mainWindow.webContents.isDestroyed.mockReset() + mainWindow.webContents.isDestroyed.mockReturnValue(false) // Why: hidden-delivery gate state is module-level (PTY-keyed), so tests must not leak hidden bits across cases. _resetHiddenRendererPtyDeliveryGateForTest() __resetShellStartupEnvCache() diff --git a/src/main/ipc/pty-ipc-test-constants.ts b/src/main/ipc/pty-ipc-test-constants.ts index 003b5165be0..e3eae546ca6 100644 --- a/src/main/ipc/pty-ipc-test-constants.ts +++ b/src/main/ipc/pty-ipc-test-constants.ts @@ -13,6 +13,7 @@ export type PlatformGatedTest = ( export const isWindowsHost = process.platform === 'win32' export const posixOnlyIt: PlatformGatedTest = isWindowsHost ? it.skip : it +export const TEST_MANAGED_ROOT = isWindowsHost ? 'C:\\managed' : '/managed' export const BUNDLED_RESOURCES_PATH = join('/tmp', 'orca-bundled-resources') // Why: this suite forces darwin before every test, including on Linux CI. export const BUNDLED_CLI_PATH = getBundledLauncherPath('darwin', BUNDLED_RESOURCES_PATH) as string diff --git a/src/main/ipc/pty-pane-claim-arbitration.test.ts b/src/main/ipc/pty-pane-claim-arbitration.test.ts index 8ab7beca71c..f4f57fc6b0b 100644 --- a/src/main/ipc/pty-pane-claim-arbitration.test.ts +++ b/src/main/ipc/pty-pane-claim-arbitration.test.ts @@ -179,12 +179,19 @@ describe('registerPtyHandlers', () => { } }): Promise<{ id: string }> } + let exitCallback: ((event: { exitCode: number }) => void) | undefined + const killSpy = vi.fn(() => { + exitCallback?.({ exitCode: 0 }) + }) const proc = { onData: vi.fn(), - onExit: vi.fn(), + onExit: vi.fn((cb) => { + exitCallback = cb + return { dispose: () => {} } + }), write: vi.fn(), resize: vi.fn(), - kill: vi.fn(), + kill: killSpy, process: 'zsh', pid: 12345 } @@ -239,7 +246,7 @@ describe('registerPtyHandlers', () => { expect(store.persistPtyBinding).toHaveBeenCalledWith( expect.objectContaining({ expectedSourceBinding }) ) - expect(proc.kill).toHaveBeenCalledOnce() + expect(killSpy).toHaveBeenCalledOnce() }) it('reports lower-owner commit before rejecting an early-exited runtime incarnation', async () => { const persistPtyBinding = vi.fn() @@ -498,9 +505,7 @@ describe('registerPtyHandlers', () => { ORCA_WORKTREE_ID: 'repo-1::/tmp' } }) as Promise<{ id: string }> - await Promise.resolve() - - expect(providerSpawn).toHaveBeenCalledTimes(1) + await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledTimes(1)) resolveSpawn({ id: 'pty-shared' }) await expect(Promise.all([runtimeSpawn, rendererSpawn])).resolves.toEqual([ { id: 'pty-shared' }, diff --git a/src/main/ipc/pty-pane-materialization-race.test.ts b/src/main/ipc/pty-pane-materialization-race.test.ts index bce7b448d3c..ee1bc16c75b 100644 --- a/src/main/ipc/pty-pane-materialization-race.test.ts +++ b/src/main/ipc/pty-pane-materialization-race.test.ts @@ -131,6 +131,124 @@ describe('registerPtyHandlers', () => { await expect(mounted).resolves.toMatchObject({ id: 'pty-after-runtime-claim' }) expect(providerSpawn).toHaveBeenCalledOnce() }) + it('adopts a runtime reservation created during renderer preflight', async () => { + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + command?: string + sessionId?: string + tabId: string + leafId: string + env: Record + }): Promise<{ id: string }> + } + let resolveProviderSpawn!: (result: { id: string }) => void + const providerSpawn = vi.fn( + () => + new Promise<{ id: string }>((resolve) => { + resolveProviderSpawn = resolve + }) + ) + setLocalPtyProvider({ + spawn: providerSpawn, + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + let controller: RuntimeSpawnController | null = null + const runtime = { + setPtyController: vi.fn((value) => { + controller = value + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-concurrent'), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + let releaseAuth!: (value: { + configDir: string + envPatch: Record + stripAuthEnv: false + provenance: string + }) => void + const prepareClaudeAuth = vi.fn( + () => + new Promise<{ + configDir: string + envPatch: Record + stripAuthEnv: false + provenance: string + }>((resolve) => { + releaseAuth = resolve + }) + ) + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + prepareClaudeAuth + ) + const tabId = 'tab-mid-preflight-runtime' + const leafId = '55555555-5555-4555-8555-555555555555' + const paneKey = makePaneKey(tabId, leafId) + const paneArgs = { + cols: 80, + rows: 24, + sessionId: 'pty-runtime-reservation', + tabId, + leafId, + env: { ORCA_PANE_KEY: paneKey } + } + + const rendererSpawn = handlers.get('pty:spawn')!(null, { + ...paneArgs, + command: 'claude' + }) + await vi.waitFor(() => expect(prepareClaudeAuth).toHaveBeenCalledOnce()) + + const runtimeSpawn = (controller as unknown as RuntimeSpawnController).spawn({ + ...paneArgs, + command: 'printf runtime' + }) + await vi.waitFor(() => expect(providerSpawn).toHaveBeenCalledOnce()) + releaseAuth({ + configDir: '/tmp/claude', + envPatch: {}, + stripAuthEnv: false, + provenance: 'managed:test' + }) + await new Promise((resolve) => setImmediate(resolve)) + resolveProviderSpawn({ id: 'pty-runtime-reservation' }) + + await expect(runtimeSpawn).resolves.toEqual({ id: 'pty-runtime-reservation' }) + await expect(rendererSpawn).resolves.toMatchObject({ + id: 'pty-runtime-reservation', + isReattach: true + }) + expect(providerSpawn).toHaveBeenCalledOnce() + await expect( + handlers.get('pty:getSize')!(null, { id: 'pty-runtime-reservation' }) + ).resolves.toEqual({ cols: 80, rows: 24 }) + }) it('reuses renderer spawn when runtime materialization starts for the same pane', async () => { type RuntimeSpawnController = { spawn(args: { diff --git a/src/main/ipc/pty-renderer-liveness-guard.test.ts b/src/main/ipc/pty-renderer-liveness-guard.test.ts new file mode 100644 index 00000000000..08a925067d8 --- /dev/null +++ b/src/main/ipc/pty-renderer-liveness-guard.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from 'vitest' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' +import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../shared/terminal-input' +import { registerPtyHandlers } from './pty' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +/** + * A destroyed WebContents under a still-alive BrowserWindow is the STA-2373 state: + * `webContents` can die a beat before its window during close (see createMainWindow.ts + * forceRepaint), and `webContents.send` throws `Object has been destroyed` there. An + * uncaught throw in the main process is fatal — the app exits with every terminal in it, + * clean exit code and no crash report — so both `pty:writeUnavailable` senders must check + * the WebContents, not just the window. #15927 briefly reduced these to a window-only + * check; these tests fail against that regression (STA-5373). + */ +describe('pty:writeUnavailable renderer-liveness guard', () => { + const { + handlers, + mainWindow, + mainWindowIpcEvent, + installDaemonTestProvider, + getPtyWriteListener + } = setupPtyIpcSuite() + + async function spawnDaemonPty(): Promise { + const result = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { + id: string + } + mainWindow.webContents.send.mockClear() + return result.id + } + + describe('the per-write sender', () => { + it('asks the renderer to remount while the WebContents is alive', async () => { + installDaemonTestProvider({ + write: vi.fn(() => { + throw new PtyWriteUnavailableError('daemon generation lost') + }) + }) + registerPtyHandlers(mainWindow as never) + const id = await spawnDaemonPty() + + getPtyWriteListener()(mainWindowIpcEvent, { id, data: 'x' }) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:writeUnavailable', { id }) + }) + + // Why a chunked write: the single-chunk path is already fenced by + // isPtyWriteEventFromMainWindow, which rejects the event outright once the WebContents + // is gone. A multi-chunk write awaits a macrotask between chunks, so the sender check + // passes while alive and the renderer dies mid-write — the only way this sender is + // reached with a destroyed WebContents, and what makes its own guard load-bearing. + it('stays silent when the WebContents dies mid-write under a live window', async () => { + let writeCount = 0 + installDaemonTestProvider({ + write: vi.fn(() => { + writeCount += 1 + if (writeCount < 2) { + return + } + mainWindow.webContents.isDestroyed.mockReturnValue(true) + throw new PtyWriteUnavailableError('daemon generation lost') + }) + }) + registerPtyHandlers(mainWindow as never) + const id = await spawnDaemonPty() + + getPtyWriteListener()(mainWindowIpcEvent, { + id, + data: 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 3) + }) + // The chunk loop yields a macrotask between chunks; wait for the failing one. + await vi.waitFor(() => expect(writeCount).toBeGreaterThan(1)) + + expect(mainWindow.webContents.isDestroyed).toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:writeUnavailable', + expect.anything() + ) + }) + }) + + describe('the daemon-death fan-out', () => { + /** Registers handlers and returns the fan-out callback the provider hands the session. */ + function installProviderCapturingFanout(): (payload: { id: string }) => void { + let fanout: ((payload: { id: string }) => void) | null = null + installDaemonTestProvider({ + onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => { + fanout = callback + return () => {} + }) + }) + registerPtyHandlers(mainWindow as never) + if (!fanout) { + throw new Error('registerPtyHandlers did not subscribe to onWriteUnavailable') + } + return fanout + } + + it('remounts every signalled pane while the WebContents is alive', () => { + const fanout = installProviderCapturingFanout() + + fanout({ id: 'pane-1' }) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:writeUnavailable', { + id: 'pane-1' + }) + }) + + it('stays silent when the WebContents is destroyed under a live window', () => { + const fanout = installProviderCapturingFanout() + mainWindow.webContents.isDestroyed.mockReturnValue(true) + + fanout({ id: 'pane-1' }) + + expect(mainWindow.webContents.isDestroyed).toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:writeUnavailable', + expect.anything() + ) + }) + }) +}) diff --git a/src/main/ipc/pty-renderer-surface.ts b/src/main/ipc/pty-renderer-surface.ts deleted file mode 100644 index 09fff75e681..00000000000 --- a/src/main/ipc/pty-renderer-surface.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { BrowserWindow, WebContents } from 'electron' - -/** - * The renderer surface the PTY handlers talk to, which may not exist. - * - * Why: `orca serve` — and a future Node-only backend — run the same PTY handlers - * with no window. That used to be faked: `registerHeadlessPtyRuntime` built a - * `BrowserWindow` whose `isDestroyed()` returned true and whose `webContents.send` - * was a no-op, purely to satisfy the type — the "looks fine, silently lies" shape - * this codebase rejects elsewhere, and what forced an `electron` value import into a - * path that needs none. It now passes `null`. - * - * An absent renderer is semantically identical to a destroyed one — every call site - * already guards on `isDestroyed()` and skips — so model it as `null` and say so. - */ - -/** True when there is no renderer, or it is gone. Callers already treat these the same. */ -export function isRendererGone(window: BrowserWindow | null): boolean { - return window === null || window.isDestroyed() -} - -/** Send to the renderer if one is listening. Absent renderer drops the message, as a destroyed one does. */ -export function sendToRenderer( - window: BrowserWindow | null, - channel: string, - payload?: unknown -): void { - if (isRendererGone(window)) { - return - } - window!.webContents.send(channel, payload) -} - -/** The renderer's WebContents, or null. Used for identity checks and listener registration. */ -export function rendererWebContents(window: BrowserWindow | null): WebContents | null { - return isRendererGone(window) ? null : window!.webContents -} diff --git a/src/main/ipc/pty-serializer-settlement-mapping.test.ts b/src/main/ipc/pty-serializer-settlement-mapping.test.ts index a203af2743e..7e526ea317e 100644 --- a/src/main/ipc/pty-serializer-settlement-mapping.test.ts +++ b/src/main/ipc/pty-serializer-settlement-mapping.test.ts @@ -408,6 +408,7 @@ describe('registerPtyHandlers', () => { const destroyedListeners: (() => void)[] = [] const sender = { id: 42, + isDestroyed: () => false, once: vi.fn((event: string, listener: () => void) => { if (event === 'destroyed') { destroyedListeners.push(listener) @@ -422,6 +423,20 @@ describe('registerPtyHandlers', () => { destroyedListeners[0]() expect(hasPendingRendererSerializerForPaneKey(paneKey)).toBe(false) }) + it('does not retain a serializer declaration from an already-destroyed renderer', async () => { + registerPtyHandlers(mainWindow as never) + const paneKey = makePaneKey('tab-dead', '77777777-7777-4777-8777-777777777777') + const sender = { + id: 43, + isDestroyed: () => true, + once: vi.fn() + } + + await handlers.get('pty:declarePendingPaneSerializer')!({ sender }, { paneKey }) + + expect(hasPendingRendererSerializerForPaneKey(paneKey)).toBe(false) + expect(sender.once).not.toHaveBeenCalled() + }) it('ignores renderer-provided ORCA_TERMINAL_HANDLE for local PTY spawns', async () => { const runtime = { setPtyController: vi.fn(), diff --git a/src/main/ipc/pty-session-liveness-and-ownership.test.ts b/src/main/ipc/pty-session-liveness-and-ownership.test.ts index e7377dbe927..97c0e614933 100644 --- a/src/main/ipc/pty-session-liveness-and-ownership.test.ts +++ b/src/main/ipc/pty-session-liveness-and-ownership.test.ts @@ -91,6 +91,21 @@ describe('registerPtyHandlers', () => { ).resolves.toBe(null) expect(hasPty).not.toHaveBeenCalled() }) + it.each(['remote:env-1@@terminal-1', 'ssh:missing-host@@pty-1'])( + 'never routes process inspection for %s to the local provider', + async (id) => { + const inspectProcess = vi.fn() + registerPtyHandlers(mainWindow as never) + setLocalPtyProvider({ inspectProcess } as never) + + await expect(handlers.get('pty:inspectProcess')!(null, { id })).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: false, + unavailable: true + }) + expect(inspectProcess).not.toHaveBeenCalled() + } + ) it('lists duplicate SSH relay session ids as distinct app sessions', async () => { registerPtyHandlers(mainWindow as never) const shutdownA = vi.fn(async () => undefined) diff --git a/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts b/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts index 8dd16f4a63c..56169b208c2 100644 --- a/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts +++ b/src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' import { spawnMock } from './pty-ipc-mock-registry' -import { posixOnlyIt } from './pty-ipc-test-constants' +import { posixOnlyIt, TEST_MANAGED_ROOT } from './pty-ipc-test-constants' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { prepareCodexSessionResume } from '../codex/codex-session-resume-preparation' import { registerPtyHandlers, setLocalPtyProvider, type PrepareCodexSessionResume } from './pty' @@ -55,9 +56,16 @@ describe('registerPtyHandlers', () => { describe('spawn environment', () => { describe('unverifiable Codex resume provenance', () => { const RESUME_SESSION_ID = '019f81b9-19a9-7651-a8d1-352d9420bd11' - const ORIGIN_HOME = '/managed/origin/home' - const OTHER_HOME = '/managed/other/home' - const ORIGIN_ROLLOUT = `${ORIGIN_HOME}/sessions/2026/07/20/rollout-2026-07-20T12-00-00-${RESUME_SESSION_ID}.jsonl` + const ORIGIN_HOME = join(TEST_MANAGED_ROOT, 'origin', 'home') + const OTHER_HOME = join(TEST_MANAGED_ROOT, 'other', 'home') + const ORIGIN_ROLLOUT = join( + ORIGIN_HOME, + 'sessions', + '2026', + '07', + '20', + `rollout-2026-07-20T12-00-00-${RESUME_SESSION_ID}.jsonl` + ) // Why: main's real provenance rule via the same prepareCodexSessionResume that // index.ts calls, so the outcome wiring is exercised rather than restated. This diff --git a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts index 219b3f94d9d..6e5e57f3ad6 100644 --- a/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts +++ b/src/main/ipc/pty-spawn-runtime-handle-binding.test.ts @@ -5,6 +5,12 @@ import { createHash } from 'node:crypto' import { delimiter } from 'node:path' import { makePaneKey } from '../../shared/stable-pane-id' import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { ClaudeAgentTeamsService } from '../runtime/claude-agent-teams-service' +import { + clearPaneSpawnReservation, + makePaneSpawnReservationKey, + reservePaneSpawn +} from './pty/pane/spawn-reservation' import { registerPtyHandlers, registerSshPtyProvider } from './pty' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -500,4 +506,121 @@ describe('registerPtyHandlers', () => { id: spawned.id }) }) + it('releases the Agent Teams leader of a spawn that loses the pane mid-preflight', async () => { + const leafId = '22222222-2222-4222-8222-222222222222' + const teams = new ClaudeAgentTeamsService() + let handleSeq = 0 + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => `term_agent_teams_${++handleSeq}`), + prepareClaudeAgentTeamsLeaderForHandle: vi.fn( + async (args: { handle: string; baseEnv?: Record }) => + teams.createLaunchEnv({ + leaderHandle: args.handle, + baseEnv: args.baseEnv ?? {}, + shimDir: '/tmp/orca-agent-teams-shim', + shimBin: null + }) + ), + releaseClaudeAgentTeamsLeaderForHandle: vi.fn((handle: string) => { + teams.removeTeamForLeaderHandle(handle) + }), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + registerPtyHandlers(mainWindow as never, runtime as never) + // Why: no worktreeId, so the reservation key only resolves after env assembly — the + // window where a concurrent winner's reservation appears mid-preflight. + const spawnArgs = { + cols: 80, + rows: 24, + cwd: '/repo', + command: 'claude --teammate-mode auto', + tabId: 'tab-1', + leafId, + env: { ORCA_PANE_KEY: `tab-1:${leafId}`, ORCA_TAB_ID: 'tab-1' }, + launchConfig: { agentCommand: 'claude --teammate-mode auto', agentArgs: '', agentEnv: {} }, + launchAgent: 'claude' + } + const winner = (await handlers.get('pty:spawn')!(mainWindowIpcEvent, spawnArgs)) as { + id: string + } + expect(teams.getActiveTeamCount()).toBe(1) + + const reservationKey = makePaneSpawnReservationKey( + undefined, + undefined, + makePaneKey('tab-1', leafId) + )! + const winnerReservation = reservePaneSpawn(reservationKey) + winnerReservation.resolve({ id: winner.id }) + try { + const loser = await handlers.get('pty:spawn')!(mainWindowIpcEvent, spawnArgs) + expect(loser).toMatchObject({ id: winner.id, isReattach: true }) + } finally { + clearPaneSpawnReservation(reservationKey, winnerReservation) + } + + const loserHandle = runtime.prepareClaudeAgentTeamsLeaderForHandle.mock.calls[1]?.[0].handle + expect(runtime.prepareClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledTimes(2) + expect(runtime.releaseClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledWith(loserHandle) + // The losing request must not grow the team count it can never bind to a PTY. + expect(teams.getActiveTeamCount()).toBe(1) + }) + it('releases the Agent Teams leader when its provider spawn fails', async () => { + const leafId = '33333333-3333-4333-8333-333333333333' + const teams = new ClaudeAgentTeamsService() + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => 'term_agent_teams_failure'), + prepareClaudeAgentTeamsLeaderForHandle: vi.fn( + async (args: { handle: string; baseEnv?: Record }) => + teams.createLaunchEnv({ + leaderHandle: args.handle, + baseEnv: args.baseEnv ?? {}, + shimDir: '/tmp/orca-agent-teams-shim', + shimBin: null + }) + ), + releaseClaudeAgentTeamsLeaderForHandle: vi.fn((handle: string) => { + teams.removeTeamForLeaderHandle(handle) + }), + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + getDriver: vi.fn(() => ({ kind: 'host' })), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + spawnMock.mockImplementation(() => { + throw new Error('provider spawn failed') + }) + + registerPtyHandlers(mainWindow as never, runtime as never) + + await expect( + handlers.get('pty:spawn')!(mainWindowIpcEvent, { + cols: 80, + rows: 24, + cwd: '/repo', + command: 'claude --teammate-mode auto', + tabId: 'tab-1', + leafId, + worktreeId: 'wt-1', + env: { ORCA_PANE_KEY: `tab-1:${leafId}`, ORCA_TAB_ID: 'tab-1' }, + launchConfig: { agentCommand: 'claude --teammate-mode auto', agentArgs: '', agentEnv: {} }, + launchAgent: 'claude' + }) + ).rejects.toThrow() + + expect(runtime.releaseClaudeAgentTeamsLeaderForHandle).toHaveBeenCalledWith( + 'term_agent_teams_failure' + ) + expect(teams.getActiveTeamCount()).toBe(0) + }) }) diff --git a/src/main/ipc/pty-startup-barrier-ordering.test.ts b/src/main/ipc/pty-startup-barrier-ordering.test.ts index 95dafaaab8a..dcf8a5d4c44 100644 --- a/src/main/ipc/pty-startup-barrier-ordering.test.ts +++ b/src/main/ipc/pty-startup-barrier-ordering.test.ts @@ -2,26 +2,27 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +function readRepoSource(relPath: string): string { + return readFileSync(join(process.cwd(), relPath), 'utf8').replace(/\r\n?/g, '\n') +} + describe('PTY startup barrier ordering', () => { it('waits for local startup before resolving the provider for runtime and renderer spawns', () => { - const source = readFileSync(join(process.cwd(), 'src/main/ipc/pty.ts'), 'utf8').replace( - /\r\n?/g, - '\n' - ) - const runtimeSpawnStart = source.indexOf('spawn: async (args) => {') - const runtimeSpawnEnd = source.indexOf(' write:', runtimeSpawnStart) - const runtimeSpawn = source.slice(runtimeSpawnStart, runtimeSpawnEnd) - // Why `ipc.` and not `ipcMain.`: the module registers against an injected surface now - // (pty-host-bindings) so it can run without electron. The ordering this asserts is unchanged. - const rendererSpawnStart = source.indexOf("ipc.handle(\n 'pty:spawn'") - const rendererSpawnEnd = source.indexOf("ipc.handle(\n 'pty:kill'", rendererSpawnStart) - const rendererSpawn = source.slice(rendererSpawnStart, rendererSpawnEnd) + const runtimeSpawn = + readRepoSource('src/main/ipc/pty/runtime/spawn.ts') + + readRepoSource('src/main/ipc/pty/runtime/spawn-early.ts') + + readRepoSource('src/main/ipc/pty/runtime/spawn-preflight.ts') + + const rendererSource = + readRepoSource('src/main/ipc/pty/ipc/spawn.ts') + + readRepoSource('src/main/ipc/pty/ipc/spawn-begin.ts') + + readRepoSource('src/main/ipc/pty/ipc/spawn-preflight.ts') + const rendererSpawnStart = rendererSource.indexOf("ipcMain.handle('pty:spawn'") + const rendererSpawn = rendererSource.slice(rendererSpawnStart) for (const spawnBlock of [runtimeSpawn, rendererSpawn]) { - const barrierIndex = spawnBlock.indexOf( - 'const startupPromise = getLocalPtyStartupPromise(args.connectionId)' - ) - const providerIndex = spawnBlock.indexOf('const provider = getProvider(args.connectionId)') + const barrierIndex = spawnBlock.indexOf('getLocalPtyStartupPromise(args.connectionId)') + const providerIndex = spawnBlock.indexOf('getProvider(args.connectionId)') expect(barrierIndex).toBeGreaterThanOrEqual(0) expect(providerIndex).toBeGreaterThanOrEqual(0) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 3adcdb56376..7499cf10a01 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1,8035 +1,39 @@ -/* eslint-disable max-lines -- Why: PTY IPC is centralized in one main-process module so spawn env scoping, lifecycle cleanup, process inspection, and renderer IPC stay behind one audited boundary. */ -import { join, delimiter } from 'node:path' -import { getAppEnvironment } from '../../shared/app-environment' -import { getPtyIpc, getPtyPower, type PtyPowerSurface } from './pty-host-bindings' -import { isRendererGone, rendererWebContents, sendToRenderer } from './pty-renderer-surface' -import { randomUUID } from 'node:crypto' -import { statSync } from 'node:fs' -import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready-bash-rcfile' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import type { PtyBindingSourceExpectation, Store } from '../persistence' -import { retireTerminalSurfaceFromPersistence } from '../runtime/mobile-session-terminal-persistence-retirement' -import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../shared/pty-liveness-verdict' -import type { GlobalSettings } from '../../shared/global-settings-types' -import type { TuiAgent } from '../../shared/tui-agent' -import { - LOCAL_EXECUTION_HOST_ID, - toSshExecutionHostId, - type ExecutionHostId -} from '../../shared/execution-host' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy' -import type { - PtyDeliveryWriteOff, - PtyRendererDeliveryHealthReply, - PtyRendererDeliveryStateReport -} from '../../shared/pty-renderer-delivery-health' -import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' -import { - INITIAL_MODE_2031_REPLY_SCAN_STATE, - scanMode2031ReplyDecision, - type Mode2031ReplyScanState -} from '../../shared/terminal-color-scheme-protocol' -import { - type PtyMainDeliveryDiagnostics, - type PtyPerPtyDeliveryDiagnostics, - EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, - createPtyDeliveryBreadcrumbRing, - redactPtyIdForDiagnostics -} from '../../shared/pty-delivery-diagnostics' -import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' -import { isTuiAgent } from '../../shared/tui-agent-config' -import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' -import { - normalizeAgentProviderSession, - type AgentProviderSessionMetadata, - type SleepingAgentLaunchConfig -} from '../../shared/agent-session-resume' -import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' -import { - isWslShellName, - resolveLocalWindowsTerminalRuntimeOptions -} from '../../shared/local-windows-terminal-runtime' -import { applyTerminalGitCredentialPromptGuard } from './terminal-git-credential-guard' -import { openCodeHookService } from '../opencode/hook-service' -import { mimoCodeHookService } from '../mimo/hook-service' -import { - getCommandTokenPathBasename, - getFirstCommandToken -} from '../../shared/command-token-scanner' -import { agentHookServer } from '../agent-hooks/server' -import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' -import { ensureWslHookRelayForReattach } from '../agent-hooks/wsl-hook-relay-reattach' -import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' -import { piTitlebarExtensionService } from '../pi/titlebar-extension-service' -import { - detectExplicitPiAgentKindFromCommand, - isPiCompatibleAgentType, - PRIMARY_AGENT_DIR_ENV_BY_KIND, - SOURCE_AGENT_DIR_ENV_BY_KIND, - type PiAgentKind -} from '../../shared/pi-agent-kind' -import { isPwshAvailableAsync } from '../pwsh' -import { LocalPtyProvider } from '../providers/local-pty-provider' -import type { PtyProcessInfo } from '../providers/pty-process-info' -import { normalizeWindowsTerminalCwd } from '../providers/windows-shell-args' -import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' -import { isPtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' -import { - inspectPtyProviderProcess, - inspectPtyProviderProcessForRenderer -} from '../providers/pty-process-inspection' -import { - PtyProcessListAdmission, - visitPtyProcessListingsInBatches -} from '../providers/pty-process-list-admission' -import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' -import { - SSH_SESSION_EXPIRED_ERROR, - isSshPtyIdentityMismatchError, - isSshPtyNotFoundError -} from '../providers/ssh-pty-errors' -import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' -import { createPtySpawnTiming } from './pty-spawn-timing' -import { - isSafePtySessionId, - mintPtySessionId, - ptySessionIdForAgentCreateOperation -} from '../daemon/pty-session-id' -import { resolveWslSessionContext } from '../daemon/wsl-session-context' -import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' -import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe' -import { - isDaemonEndpointGoneError, - TerminalHostGoneError, - TerminalSessionOwnerUnverifiedError -} from '../daemon/daemon-errors' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' -import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' -import { - isClaudeAuthSwitchInProgress, - markClaudePtyExited, - markClaudePtySpawned -} from '../claude-accounts/live-pty-gate' -import { ensureLinuxTerminalOrcaCliShimDir } from '../cli/linux-terminal-orca-cli-shim' -import { - isLegacyTerminalShimPathEntry, - LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS, - stripLegacyTerminalShimEnv -} from '../pty/legacy-terminal-shim-dir' -import { registerPty, unregisterPty } from '../memory/pty-registry' -import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' -import { track } from '../telemetry/client' -import { classifyError } from '../telemetry/classify-error' -import { getCohortAtEmit } from '../telemetry/cohort-classifier' -import { - agentKindSchema, - launchSourceSchema, - requestKindSchema -} from '../../shared/telemetry-events' -import { - isTerminalInputTooLargeWithDeferredMeasurement, - iterateTerminalInputChunks -} from '../../shared/terminal-input' -import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' -import { createTerminalSessionStateSaveFailureMessage } from '../../shared/terminal-session-state-save-failure' -import { RendererTerminalSerializerReadiness } from './renderer-terminal-serializer-readiness' -import { readSessionShellStartupEnvVar } from '../pty/shell-startup-env' -import { - isTerminalLeafId, - makePaneKey, - parseLegacyNumericPaneKey, - parsePaneKey -} from '../../shared/stable-pane-id' -import { isValidTerminalTabId } from '../../shared/terminal-tab-id' -import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags' -import { - resolveTerminalStartupCwdForWorkspace, - type TerminalStartupCwdMissingDirFallback -} from '../../shared/terminal-startup-cwd' -import { isWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' -import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' -import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' -import { - agentSessionOwnerBindingsEqual, - ClaimedAgentPtyOwnerRegistry -} from '../../shared/claimed-agent-pty-owner' -import { - clearMigrationUnsupportedPty, - clearMigrationUnsupportedPtysForPaneKey -} from '../agent-hooks/migration-unsupported-pty-state' -import { parseWslPath, wslUncDirectoryExistsAsync } from '../wsl' -import { mergePersistedWindowsPath, resolvePathEnvKey } from '../pty/windows-environment-path' -import { addOrcaWslInteropEnv, stampWslOrchestrationCompatibilityHost } from '../pty/wsl-orca-env' -import { resolveCodexShellLaunchPreflightCommand } from '../pty/codex-shell-launch-preflight' -import { PtyProducerFlowController } from './pty-producer-flow-control' -import { beginTerminalInstall } from './watcher-removal-gate' -import { - clearHiddenRendererPtyDeliveryState, - getHiddenRendererPtyDeliveryDebug, - getHiddenRendererPtyIds, - isHiddenPtyDeliveryGateEnabled, - isHiddenRendererPty, - markHiddenRendererPty, - recordHiddenRendererPtyDataDrop, - resetHiddenRendererPtyDeliveryDebugCounters, - resetRendererScopedHiddenPtyDeliveryState, - setRendererPtyDeliveryInterest, - shouldDropHiddenRendererPtyData, - unmarkHiddenRendererPty -} from './pty-hidden-delivery-gate' -import { PtyPendingDataDrainQueue, type PendingPtyData } from './pty-pending-data-drain-queue' -import { - appendPendingProjectionAdmission, - compactPendingProjectionAdmissions, - propagatePendingProjectionRemainder, - type PendingProjectionAdmissions -} from './pty-pending-projection-admissions' -import { SshPtyOutputIntake } from './ssh-pty-output-intake' -import { - cancelSshPtySourceDelivery, - installSshPtyOutputIntake, - publishSshPtySourceAck -} from './ssh-pty-output-intake-registry' -import type { LegacySshProjectionSemantics } from './ssh-pty-legacy-projection' -import { - clearNativeWindowsConptyPty, - isNativeWindowsLocalPtySpawn, - markNativeWindowsConptyPty -} from '../runtime/terminal-model-query-authority' -import { setTerminalViewAttributes } from '../runtime/terminal-view-attribute-store' -import { validateTerminalViewAttributes } from '../../shared/terminal-view-attributes' -import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker' -import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' -import { - isCodexHomeAuthReadyForLaunch, - waitForManagedCodexAuthReady -} from '../codex-accounts/managed-codex-auth-readiness' -import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' -import { - forgetCodexPaneAccount, - getCodexPaneAccount, - recordCodexPaneAccount, - type CodexPaneHomeRoute -} from '../codex/codex-pane-account-registry' -import { resolveCodexPaneLaunchAccount } from '../codex/codex-pane-launch-account' -import { getSystemCodexHomePath } from '../codex/codex-home-paths' -import { ensureCodexStateDbBackfillRecoveryStarted } from '../codex/codex-state-db-backfill-recovery' -import { - environmentCodexHomeOverrideContextsEqual, - getCustomCodexHomeOverrideForLaunch, - shellStartupCodexHomeOverrideContextsEqual -} from '../codex/codex-real-home-path' -import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home' -import { dropUnverifiedCodexResumeArgv } from '../codex/codex-unverified-resume-launch' -import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' -import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' -import { - resolveSetupAgentSequenceLaunchCommand, - SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV -} from '../../shared/setup-agent-sequencing' -import { dropAgentResumeArgvFromCommand } from '../../shared/agent-resume-argv-drop' -import { parseWorkspaceKey } from '../../shared/workspace-scope' -import { getStartupTerminalColorQueryReplyColors } from './terminal-startup-color-query-replies' -import { - assertFolderWorkspacePathUsable, - getFolderWorkspacePathStatus -} from '../project-groups/folder-workspace-path-status' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' -import { isPtyIncarnationId } from '../../shared/pty-incarnation' -import type { PtyListedSession } from '../../shared/pty-listed-session' - -// ─── Provider Registry ────────────────────────────────────────────── -// Routes PTY operations by connectionId (null = local provider). - -let localProvider: IPtyProvider = new LocalPtyProvider() -const sshProviders = new Map() -const sshProvidersByGeneration = new Map() - -type RegisteredPtyProvider = { - provider: IPtyProvider - connectionId: string | null -} - -function registeredPtyProviders(): RegisteredPtyProvider[] { - return [ - { provider: localProvider, connectionId: null }, - ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) - ] -} - -// Why: settling each provider separately only bounds a relay that *rejects*. An -// unanswered relay list runs to the mux's own 30s default — far past the caller's -// budget — so the aggregate still expired and the runtime lost the inventory it -// needs to retire exited PTYs, freezing every retained pane as "active" (STA-517). -// Forward the caller's deadline so a silent relay fails fast and lands in the -// unavailable branch below: unknown, not empty. -async function listRegisteredPtyProcessesWithHostScope( - onSshInventoryUnavailable?: (connectionId: string, error: unknown) => void, - opts?: { deadlineMs?: number } -): Promise<{ - processes: PtyProcessInfo[] - hostIds: ExecutionHostId[] -}> { - const providers = registeredPtyProviders() - const providerSessions = await Promise.all( - providers.map(async ({ provider, connectionId }) => { - try { - const hostId: ExecutionHostId = connectionId - ? toSshExecutionHostId(connectionId) - : LOCAL_EXECUTION_HOST_ID - return { - // Why: the deadline only applies to relay round-trips; the local provider answers in-process. - processes: await (connectionId ? provider.listProcesses(opts) : provider.listProcesses()), - hostId - } - } catch (error) { - if (!connectionId) { - throw error - } - onSshInventoryUnavailable?.(connectionId, error) - return null - } - }) - ) - const respondingSessions = providerSessions.filter((session) => session !== null) - return { - processes: respondingSessions.flatMap((session) => session.processes), - hostIds: respondingSessions.map((session) => session.hostId) - } -} - -const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 -// Why: kill switch — flip to disable producer flow control (pause/resume) without untangling the wiring. -const PRODUCER_FLOW_CONTROL_ENABLED = true -// Why: post-spawn write/resize/kill calls carry only the PTY ID; map it to its connectionId so ops route to the right provider. -const ptyOwnership = new Map() -const ptyIncarnationById = new Map() - -export function isCurrentPtyExit(payload: { id: string; incarnationId?: string }): boolean { - const current = ptyIncarnationById.get(payload.id) - return !current || payload.incarnationId === current -} -// Why: mobile clients must mirror desktop PTY geometry even before the renderer can provide an xterm snapshot (e.g. right after tab creation). -const ptySizes = new Map() -// Why: the "recent user input" signal is PTY-scoped and must be cleared by every teardown path, incl. SSH/daemon shutdowns that skip the local exit listener. -const lastInputAtByPty = new Map() -const interactiveOutputCharsByPty = new Map() -const activeRendererPtys = new Set() -const visibleRendererPtys = new Set() -const rendererVisibilityKnownPtys = new Set() -let invalidatePendingPtyDrainPriority = (_id?: string, _schedule?: boolean): void => {} -let invalidatePendingPtyDrainPolicy = (_id?: string, _schedule?: boolean): void => {} -const pendingHiddenRendererResizeOutputPtys = new Set() -const deliveredHiddenRendererResizeOutputPtys = new Set() -const KEEP_HISTORY_STOP_SETTLE_MS = 1_000 -const KEEP_HISTORY_STOP_POLL_MS = 100 -// Why: track spawn-time paneKey so teardown can clear the agent-hooks server's per-paneKey cache, which otherwise grows unbounded as panes come and go. -const ptyPaneKey = new Map() -// Why: reverse of ptyPaneKey — callers with a paneKey from outside the PTY lifecycle (e.g. agent-hook status routing) need the ptyId; kept in lock-step via the same sites. -const paneKeyPtyId = new Map() - -const AGENT_HOOK_RUNTIME_ENV_KEYS = [ - 'ORCA_AGENT_HOOK_PORT', - 'ORCA_AGENT_HOOK_TOKEN', - 'ORCA_AGENT_HOOK_ENV', - 'ORCA_AGENT_HOOK_VERSION', - 'ORCA_AGENT_HOOK_ENDPOINT', - // Why: PR 2778 briefly exported this path; keep deleting stale inherited values so older PTYs can't leak the reverted path. - 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' -] as const - -// Why: Orca never sets these, so an inherited value means a pty host launched from inside a Claude session — Claude reads it as a nested child and silently stops persisting the transcript. -const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ - 'CLAUDE_CODE_CHILD_SESSION', - 'CLAUDE_CODE_SESSION_ID', - 'CLAUDE_CODE_BRIDGE_SESSION_ID' -] as const - -export function getPtyIdForPaneKey(paneKey: string): string | undefined { - return paneKeyPtyId.get(paneKey) -} - -// Why: let consumers tear down paneKey-scoped state on PTY exit so their timers can't leak; a callback registry keeps the cross-module dependency narrow. -type PaneKeyTeardownListener = (paneKey: string) => void -const paneKeyTeardownListeners = new Set() - -export function registerPaneKeyTeardownListener(listener: PaneKeyTeardownListener): () => void { - paneKeyTeardownListeners.add(listener) - return () => paneKeyTeardownListeners.delete(listener) -} - -// Why: renderer pre-declares serializer ownership before pty:spawn to suppress the daemon-snapshot seed; gen tokens prevent paneKey-reuse races on teardown. See docs/mobile-prefer-renderer-scrollback.md. -let pendingSerializerGenSeq = 0 -const pendingByPaneKey = new Map() -const pendingPaneSerializerCleanupRegistered = new Set() -type PaneSpawnReservation = { - promise: Promise - resolve: (result: PaneSpawnReservationResult) => void - reject: (error: unknown) => void -} -type PaneSpawnReservationResult = { - id: string - launchConfig?: SleepingAgentLaunchConfig - stablePaneOwner?: { - handle: string - tabId: string - leafId: string - } -} & Partial -// Why: identical pane coordinates on different worktrees or hosts are independent owners. -const paneSpawnReservationsByOwnerKey = new Map() -type PendingRuntimePaneCreate = { - count: number - promise: Promise - resolve: () => void -} -const pendingRuntimePaneCreatesByOwnerKey = new Map() -// Why: one main process can route the same remote provider namespace through -// multiple SSH relays; coordinate claims above every provider boundary too. -const agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() -let agentSessionOwnerReconciliation: Promise | null = null - -// Why: restore payloads (reattach snapshot / cold-restore scrollback / relay -// replay + lastTitle) ride spawn RPC results, never onPtyData, so EVERY spawn -// choke point — renderer pty:spawn and the runtime controller — must seed the -// terminal list/read records or headless/CLI-created reattaches stay blank. -// The runtime's empty-record guard makes a second seed for the same session a -// no-op, so overlapping paths cannot double-apply history. -function seedTerminalRestoreRecordsFromSpawnResult( - runtime: OrcaRuntimeService | undefined, - result: PtySpawnResult -): void { - const text = - typeof result.snapshot === 'string' && result.snapshot.length > 0 - ? result.snapshot - : typeof result.coldRestore?.scrollback === 'string' && - result.coldRestore.scrollback.length > 0 - ? result.coldRestore.scrollback - : typeof result.replay === 'string' && result.replay.length > 0 - ? result.replay - : undefined - const lastTitle = - typeof result.lastTitle === 'string' && result.lastTitle.length > 0 - ? result.lastTitle - : typeof result.coldRestore?.lastTitle === 'string' && result.coldRestore.lastTitle.length > 0 - ? result.coldRestore.lastTitle - : undefined - if (text !== undefined || lastTitle !== undefined) { - runtime?.seedTerminalRestoreTail?.(result.id, { - ...(text !== undefined ? { text } : {}), - ...(lastTitle !== undefined ? { lastTitle } : {}) - }) - } -} - -function assertSpawnReplyWasLive(result: PtySpawnResult): void { - if (!result.exitedBeforeSpawnReply) { - return - } - // Why: lower owners can resolve a different canonical id, so controller-local pending ids cannot prove this exit. - throw Object.assign(new Error('agent_session_exited_during_start'), { - agentSessionOperationOutcome: 'unknown' as const - }) -} - -async function reconcileAgentSessionOwnerListings(): Promise { - if (agentSessionOwnerReconciliation) { - return await agentSessionOwnerReconciliation - } - const reconciliation = (async () => { - const providers: { provider: IPtyProvider; connectionId: string | null }[] = [ - { provider: localProvider, connectionId: null }, - ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) - ] - const listings = await Promise.all( - providers.map(async ({ provider, connectionId }) => ({ - connectionId, - sessions: await provider.listProcesses() - })) - ) - const advertisedOwners: AgentSessionOwnerBinding[] = [] - const advertisedOwnerSessions: { - id: string - connectionId: string | null - incarnationId: string - }[] = [] - for (const { connectionId, sessions } of listings) { - for (const session of sessions) { - const incarnationId = session.incarnationId - let hasAdvertisedOwner = false - for (const owner of session.agentSessionOwners ?? []) { - if (owner.ptyId !== session.id || !isPtyIncarnationId(incarnationId)) { - // Why: a recovered claim without process-incarnation proof cannot safely reject a delayed exit. - throw new Error('agent_session_ownership_unknown') - } - advertisedOwners.push(owner) - hasAdvertisedOwner = true - } - if (hasAdvertisedOwner && isPtyIncarnationId(incarnationId)) { - advertisedOwnerSessions.push({ id: session.id, connectionId, incarnationId }) - } - } - } - agentSessionOwners.reconcileAuthoritative(advertisedOwners, { - // Why: an unregistered relay can still own a live PTY during reconnect; - // only providers that serialize claims may make listing absence authoritative. - isInAuthoritativeScope: (owner) => { - const provider = tryGetProviderForAgentSessionOwner(owner.ptyId) - return provider?.providesAgentSessionOwnerListings?.(owner.ptyId) === true - } - }) - for (const session of advertisedOwnerSessions) { - ptyOwnership.set(session.id, session.connectionId) - ptyIncarnationById.set(session.id, session.incarnationId) - } - })() - agentSessionOwnerReconciliation = reconciliation - try { - await reconciliation - } finally { - if (agentSessionOwnerReconciliation === reconciliation) { - agentSessionOwnerReconciliation = null - } - } -} -// Why: bind the declaration generation directly to its spawn result. PTY ids -// are reusable and teardown callbacks carry no incarnation token, so teardown -// must never guess which pending renderer generation it owns. -const pendingPtyIdBySerializerGeneration = new Map() -// Why: hasRendererSerializer probe needs a ptyId-keyed signal; a later spawn starts a fresh incarnation, subscription abort owns waiter cleanup. -const rendererSerializerReadiness = new RendererTerminalSerializerReadiness() - -function parseValidPaneKey(paneKey: unknown): ReturnType { - if (typeof paneKey !== 'string' || paneKey.length > 256) { - return null - } - return parsePaneKey(paneKey) -} - -function isValidPaneKey(paneKey: unknown): paneKey is string { - return parseValidPaneKey(paneKey) !== null -} - -const AGENT_LAUNCH_TOKEN_MAX_LENGTH = 128 - -function admitRendererAgentLaunchAuthority(args: { - launchToken: unknown - spawnEnv: Record | undefined - launchAgent: unknown - launchConfig: SleepingAgentLaunchConfig | undefined - isReattach: boolean - hasStablePaneOwner: boolean - incarnationId: unknown -}): { launchToken: string; launchAgent: TuiAgent } | null { - if ( - args.isReattach || - args.hasStablePaneOwner || - !args.launchConfig || - !isTuiAgent(args.launchAgent) || - !isPtyIncarnationId(args.incarnationId) || - typeof args.launchToken !== 'string' || - args.launchToken.length === 0 || - args.launchToken.length > AGENT_LAUNCH_TOKEN_MAX_LENGTH || - args.spawnEnv?.ORCA_AGENT_LAUNCH_TOKEN !== args.launchToken - ) { - return null - } - return { launchToken: args.launchToken, launchAgent: args.launchAgent } -} - -function shouldRefreshNativeClaudeAgentTeamsEnv(args: { - command?: string - launchConfig?: SleepingAgentLaunchConfig -}): boolean { - const capturedCommand = args.launchConfig?.agentCommand?.trim() || args.command?.trim() || '' - const capturedArgs = args.launchConfig?.agentArgs?.trim() ?? '' - const capturedLaunch = `${capturedCommand} ${capturedArgs}`.trim() - return /(^|\s)--teammate-mode(?:=|\s+)auto(?:\s|$)/.test(capturedLaunch) -} - -function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null { - const normalizedPaneKey = typeof paneKey === 'string' ? paneKey.trim() : '' - if (!isValidPaneKey(normalizedPaneKey)) { - return null - } - ptyPaneKey.set(ptyId, normalizedPaneKey) - paneKeyPtyId.set(normalizedPaneKey, ptyId) - return normalizedPaneKey -} - -function cleanupPendingPaneSerializersForSender(ownerWebContentsId: number): void { - pendingPaneSerializerCleanupRegistered.delete(ownerWebContentsId) - for (const [paneKey, pending] of pendingByPaneKey) { - if (pending.ownerWebContentsId === ownerWebContentsId) { - pendingByPaneKey.delete(paneKey) - pendingPtyIdBySerializerGeneration.delete(pending.gen) - } - } -} - -function registerPendingPaneSerializerCleanup(sender: WebContents | undefined): void { - if (!sender || pendingPaneSerializerCleanupRegistered.has(sender.id)) { - return - } - pendingPaneSerializerCleanupRegistered.add(sender.id) - sender.once('destroyed', () => cleanupPendingPaneSerializersForSender(sender.id)) -} - -function declarePendingPaneSerializer(paneKey: string, sender: WebContents | undefined): number { - const gen = ++pendingSerializerGenSeq - registerPendingPaneSerializerCleanup(sender) - const replaced = pendingByPaneKey.get(paneKey) - if (replaced) { - pendingPtyIdBySerializerGeneration.delete(replaced.gen) - } - pendingByPaneKey.set(paneKey, { gen, ownerWebContentsId: sender?.id ?? null }) - const existingPtyId = paneKeyPtyId.get(paneKey) - if (existingPtyId) { - pendingPtyIdBySerializerGeneration.set(gen, existingPtyId) - } - return gen -} - -function reservePaneSpawn(paneKey: string): PaneSpawnReservation { - let resolve!: (result: PaneSpawnReservationResult) => void - let reject!: (error: unknown) => void - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve - reject = promiseReject - }) - promise.catch(() => {}) - const reservation = { promise, resolve, reject } - paneSpawnReservationsByOwnerKey.set(paneKey, reservation) - return reservation -} - -function clearPaneSpawnReservation(paneKey: string, reservation: PaneSpawnReservation): void { - if (paneSpawnReservationsByOwnerKey.get(paneKey) === reservation) { - paneSpawnReservationsByOwnerKey.delete(paneKey) - } -} - -function makePaneSpawnReservationKey( - worktreeId: string | undefined, - connectionId: string | null | undefined, - paneKey: string | null | undefined -): string | null { - return paneKey ? JSON.stringify([connectionId ?? null, worktreeId ?? null, paneKey]) : null -} - -function claimRuntimePaneCreate(ownerKey: string): () => void { - const existing = pendingRuntimePaneCreatesByOwnerKey.get(ownerKey) - if (existing) { - existing.count += 1 - return () => releaseRuntimePaneCreate(ownerKey, existing) - } - let resolve!: () => void - const claim = { - count: 1, - promise: new Promise((done) => { - resolve = done - }), - resolve: () => resolve() - } - pendingRuntimePaneCreatesByOwnerKey.set(ownerKey, claim) - return () => releaseRuntimePaneCreate(ownerKey, claim) -} - -function releaseRuntimePaneCreate(ownerKey: string, claim: PendingRuntimePaneCreate): void { - if (pendingRuntimePaneCreatesByOwnerKey.get(ownerKey) !== claim) { - return - } - claim.count -= 1 - if (claim.count > 0) { - return - } - pendingRuntimePaneCreatesByOwnerKey.delete(ownerKey) - claim.resolve() -} - -function rejectPaneSpawnReservation( - paneKey: string | null | undefined, - reservation: PaneSpawnReservation | null | undefined, - error: unknown -): void { - if (!reservation) { - return - } - reservation.reject(error) - if (paneKey) { - clearPaneSpawnReservation(paneKey, reservation) - } -} - -function resolvePaneSpawnReservation( - paneKey: string | null | undefined, - reservation: PaneSpawnReservation | null | undefined, - response: T -): T { - if (!reservation) { - return response - } - reservation.resolve(response) - if (paneKey) { - clearPaneSpawnReservation(paneKey, reservation) - } - return response -} - -type StablePaneOwner = { - handle?: string - tabId: string - leafId: string - ptyId: string - incarnationId?: string - hasPersistedBinding?: true - persistedIncarnationId?: string - runtimeIncarnationId?: string -} -type StablePaneAdoption = { - result: PtySpawnResult - owner: StablePaneOwner - materialized?: true -} | null -const stablePaneAdoptionsByOwnerKey = new Map>() - -function resolvePersistedStablePaneOwner( - store: Store | undefined, - paneKey: string, - worktreeId: string, - connectionId: string | null | undefined -): Pick | null { - if (!store || typeof store.getWorkspaceSession !== 'function') { - return null - } - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return null - } - const session = store.getWorkspaceSession( - connectionId ? toSshExecutionHostId(connectionId) : undefined - ) - const tab = session.tabsByWorktree?.[worktreeId]?.find( - (candidate) => candidate.id === parsed.tabId && candidate.worktreeId === worktreeId - ) - const ptyId = session.terminalLayoutsByTabId?.[parsed.tabId]?.ptyIdsByLeafId?.[parsed.leafId] - if (!tab || typeof ptyId !== 'string' || ptyId.length === 0) { - return null - } - const incarnationId = session.terminalPtyIncarnationsByPaneKey?.[paneKey] - return { - tabId: parsed.tabId, - leafId: parsed.leafId, - ptyId, - ...(incarnationId ? { incarnationId } : {}) - } -} - -function resolveStablePaneOwner( - runtime: OrcaRuntimeService | undefined, - store: Store | undefined, - paneKey: string | null | undefined, - worktreeId: string | undefined, - connectionId: string | null | undefined -): StablePaneOwner | null { - if (!paneKey || !worktreeId) { - return null - } - let resolved: ReturnType | null = null - let resolvedHandleCandidate: ReturnType | null = null - if (runtime && typeof runtime.resolveTerminalPane === 'function') { - try { - const candidate = runtime.resolveTerminalPane(paneKey, worktreeId) - resolvedHandleCandidate = candidate - resolved = candidate.connected === false ? null : candidate - } catch (error) { - if (!(error instanceof Error && error.message === 'terminal_not_found')) { - throw error - } - } - } - const persisted = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) - if (resolved?.ptyId && persisted && resolved.ptyId !== persisted.ptyId) { - throw new Error('terminal_pane_owner_conflict') - } - const ptyId = resolved?.ptyId ?? persisted?.ptyId - if (!ptyId) { - return null - } - const registeredConnectionId = ptyOwnership.get(ptyId) - const parsedSshId = registeredConnectionId === undefined ? parseAppSshPtyId(ptyId) : null - const ownerConnectionId = registeredConnectionId ?? parsedSshId?.connectionId ?? null - if (ownerConnectionId !== (connectionId ?? null)) { - throw new Error('terminal_pane_owner_host_mismatch') - } - const runtimeIncarnationId = ptyIncarnationById.get(ptyId) - const parsed = parsePaneKey(paneKey) - if (!parsed) { - return null - } - return { - ...(resolvedHandleCandidate?.ptyId === ptyId ? { handle: resolvedHandleCandidate.handle } : {}), - tabId: resolved?.tabId || persisted?.tabId || parsed.tabId, - leafId: resolved?.leafId || persisted?.leafId || parsed.leafId, - ptyId, - ...(runtimeIncarnationId || persisted?.incarnationId - ? { incarnationId: runtimeIncarnationId ?? persisted?.incarnationId } - : {}), - ...(persisted ? { hasPersistedBinding: true as const } : {}), - ...(persisted?.incarnationId ? { persistedIncarnationId: persisted.incarnationId } : {}), - ...(runtimeIncarnationId ? { runtimeIncarnationId } : {}) - } -} - -function retirePersistedStablePaneOwner( - store: Store | undefined, - owner: StablePaneOwner, - worktreeId: string, - connectionId: string | null | undefined -): boolean { - if (!store) { - return false - } - const paneKey = makePaneKey(owner.tabId, owner.leafId) - const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined - const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) - if (!current) { - // Why: persistence already dropped this pane binding (an earlier stop retired it while the - // runtime kept history), so there is nothing left to clear — that is a completed retirement, - // not a competing owner. Reporting failure here strands the pane after its PTY is proven dead. - return true - } - if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { - return false - } - const session = store.getWorkspaceSession(hostId) - const retired = retireTerminalSurfaceFromPersistence(session, { - worktreeId, - parentTabId: owner.tabId, - leafId: owner.leafId, - ptyId: owner.ptyId, - ...(current.incarnationId ? { incarnationId: current.incarnationId } : {}) - }) - if (retired === session) { - return false - } - store.setWorkspaceSession(retired, hostId) - store.flushOrThrow() - return true -} - -type StablePaneSpawnContext = { - runtime: OrcaRuntimeService | undefined - store?: Store - provider: IPtyProvider - spawnOptions: PtySpawnOptions - owner: StablePaneOwner | null - worktreeId?: string - connectionId?: string | null - resolveOwner?: () => StablePaneOwner | null - onFreshSpawn?: (result: PtySpawnResult) => void -} - -function stablePanePersistenceFence( - owner: StablePaneOwner | null -): { ptyId: string; incarnationId?: string } | undefined { - return owner?.hasPersistedBinding - ? { - ptyId: owner.ptyId, - ...(owner.persistedIncarnationId ? { incarnationId: owner.persistedIncarnationId } : {}) - } - : undefined -} - -function persistAdmittedStablePaneBinding(args: { - store: Store | undefined - owner: StablePaneOwner | null - result: PtySpawnResult - worktreeId: string | undefined - startupCwd: string | undefined - connectionId: string | null | undefined -}): boolean { - const expectedBinding = stablePanePersistenceFence(args.owner) - if (!args.store || !args.owner || !args.worktreeId || !expectedBinding) { - return false - } - const persisted = args.store.persistPtyBinding( - { - worktreeId: args.worktreeId, - tabId: args.owner.tabId, - leafId: args.owner.leafId, - ptyId: args.result.id, - ...(args.result.incarnationId ? { incarnationId: args.result.incarnationId } : {}), - ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), - expectedBinding - }, - args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined - ) - if (persisted === false) { - throw new Error('terminal_pane_owner_changed') - } - return true -} - -async function attachStablePaneOwner( - args: StablePaneSpawnContext & { owner: StablePaneOwner } -): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> { - const { owner, provider, runtime, spawnOptions } = args - let result: PtySpawnResult - try { - result = await provider.spawn({ - ...spawnOptions, - sessionId: owner.ptyId, - attachOnly: true, - expectedIncarnationId: owner.runtimeIncarnationId ?? owner.persistedIncarnationId, - expectedIncarnationIsAuthoritative: owner.runtimeIncarnationId !== undefined, - isNewSession: undefined, - command: undefined, - commandDelivery: undefined, - startupCommandDelivery: undefined, - launchAgent: undefined, - startupIngress: undefined, - agentSessionEnsure: undefined, - agentSessionCreateOperationId: undefined, - onPtySpawnCommitted: undefined - }) - } catch (error) { - if (error instanceof TerminalSessionOwnerUnverifiedError) { - throw new Error('terminal_pane_owner_unverified') - } - // Why: translate before paired-runtime RPC strips the socket error's code and syscall. - if (isDaemonEndpointGoneError(error)) { - throw new TerminalHostGoneError() - } - if (!isPtyAlreadyGoneError(error)) { - throw error - } - const ownerBeforeRetire = args.resolveOwner?.() - if ( - ownerBeforeRetire && - (ownerBeforeRetire.ptyId !== owner.ptyId || - ownerBeforeRetire.runtimeIncarnationId !== owner.runtimeIncarnationId || - ownerBeforeRetire.hasPersistedBinding !== owner.hasPersistedBinding || - ownerBeforeRetire.persistedIncarnationId !== owner.persistedIncarnationId) - ) { - throw new Error('terminal_pane_owner_changed') - } - runtime?.onPtyExit(owner.ptyId, 0, owner.incarnationId) - clearProviderPtyState(owner.ptyId) - ptyOwnership.delete(owner.ptyId) - if ( - args.worktreeId && - !retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId) - ) { - throw new Error('terminal_pane_owner_changed') - } - if (args.resolveOwner?.()) { - throw new Error('terminal_pane_owner_changed') - } - return null - } - if ( - result.id !== owner.ptyId || - result.isReattach !== true || - (owner.runtimeIncarnationId !== undefined && - result.incarnationId !== owner.runtimeIncarnationId) || - (result.incarnationId === undefined && owner.incarnationId !== undefined) - ) { - throw new Error('terminal_pane_owner_changed') - } - return { result, owner } -} - -async function spawnForStablePane( - args: StablePaneSpawnContext -): Promise<{ result: PtySpawnResult; owner: StablePaneOwner | null }> { - if (args.owner) { - const attached = await attachStablePaneOwner({ ...args, owner: args.owner }) - if (attached) { - return attached - } - } - const result = await args.provider.spawn(args.spawnOptions) - args.onFreshSpawn?.(result) - return { result, owner: null } -} - -function settlePendingPaneSerializer(paneKey: string, gen: number): boolean { - if (pendingByPaneKey.get(paneKey)?.gen !== gen) { - return false - } - pendingByPaneKey.delete(paneKey) - return true -} - -export function hasPendingRendererSerializerForPaneKey(paneKey: string): boolean { - return isValidPaneKey(paneKey) && pendingByPaneKey.has(paneKey) -} - -function getProvider(connectionId: string | null | undefined): IPtyProvider { - if (!connectionId) { - return localProvider - } - const provider = sshProviders.get(connectionId) - if (!provider) { - throw new Error(`No PTY provider for connection "${connectionId}"`) - } - return provider -} - -function getProviderForPty(ptyId: string): IPtyProvider { - const connectionId = ptyOwnership.get(ptyId) - if (connectionId === undefined) { - const parsedSshId = parseAppSshPtyId(ptyId) - if (parsedSshId) { - // Why: disconnected SSH PTYs retain their encoded owner and must never fall through to the HUB-local provider. - return getProvider(parsedSshId.connectionId) - } - return localProvider - } - return getProvider(connectionId) -} - -function hasPtyProviderForInspection(ptyId: string): boolean { - // Why: process inspection is background polling; disconnected SSH hosts should read as idle, not raise repeated IPC errors. - const connectionId = ptyOwnership.get(ptyId) - return connectionId == null || sshProviders.has(connectionId) -} - -function getAppPtyId(connectionId: string | null | undefined, ptyId: string): string { - return connectionId ? toAppSshPtyId(connectionId, ptyId) : ptyId -} - -function getRelayPtyId(connectionId: string | null | undefined, ptyId: string): string { - return connectionId ? toRelaySshPtyId(connectionId, ptyId) : ptyId -} - -function stripRemotePaneEnvWhenHooksDisabled( - connectionId: string | null | undefined, - env: Record | undefined -): Record | undefined { - if (!connectionId || isRemoteAgentHooksEnabled()) { - return env - } - if ( - !env || - (!('ORCA_PANE_KEY' in env) && - !('ORCA_TAB_ID' in env) && - !('ORCA_WORKTREE_ID' in env) && - !('ORCA_AGENT_LAUNCH_TOKEN' in env)) - ) { - return env - } - const stripped = { ...env } - delete stripped.ORCA_PANE_KEY - delete stripped.ORCA_TAB_ID - delete stripped.ORCA_WORKTREE_ID - delete stripped.ORCA_AGENT_LAUNCH_TOKEN - return stripped -} - -function tryGetProviderForPty(ptyId: string): IPtyProvider | undefined { - try { - return getProviderForPty(ptyId) - } catch { - return undefined - } -} - -function closeStartupQueryAuthorityForPty(ptyId: string): void { - try { - void Promise.resolve(tryGetProviderForPty(ptyId)?.closeStartupQueryAuthority?.(ptyId)).catch( - () => {} - ) - } catch { - /* Best-effort handoff; the bounded source deadline remains the fallback. */ - } -} - -function tryGetProviderForAgentSessionOwner(ptyId: string): IPtyProvider | undefined { - const ownedConnectionId = ptyOwnership.get(ptyId) - const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(ptyId) : null - try { - return getProvider(parsedSshId?.connectionId ?? ownedConnectionId) - } catch { - return undefined - } -} - -function normalizeNodePtySpawnError(err: unknown): Error { - const rawMessage = err instanceof Error ? err.message : String(err) - const hintedMessage = addNodePtyRecoveryHint(rawMessage) - if (hintedMessage === rawMessage && err instanceof Error) { - return err - } - if (err instanceof Error) { - // Why: preserve the original stack/name/custom fields while adding the same recovery hint as the pty:spawn path. - err.message = hintedMessage - return err - } - return new Error(hintedMessage) -} - -function isPtyAlreadyGoneError(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err) - return isSshPtyNotFoundError(err) || /Session not found/i.test(message) -} - -function delay(ms: number): Promise { - return new Promise((resolve) => { - const timer = setTimeout(resolve, ms) - if (typeof timer.unref === 'function') { - timer.unref() - } - }) -} - -async function isProviderPtyLive( - provider: IPtyProvider, - ptyId: string, - deadlineMs?: number -): Promise { - // Why: bound the liveness list RPC by the teardown deadline so a wedged daemon - // fails fast; undefined keeps the provider default for all other callers. - return (await provider.listProcesses(deadlineMs !== undefined ? { deadlineMs } : undefined)).some( - (session) => session.id === ptyId - ) -} - -async function isProviderAgentSessionOwnerLive( - provider: IPtyProvider, - owner: AgentSessionOwnerBinding -): Promise { - const session = (await provider.listProcesses()).find((candidate) => candidate.id === owner.ptyId) - if (!session) { - return false - } - if (provider.providesAgentSessionOwnerListings?.(owner.ptyId) !== true) { - // Why: in-process local owners cannot serialize the controller claim; exact incarnation - // liveness keeps that claim authoritative until the normal PTY exit releases it. - const expectedIncarnation = ptyIncarnationById.get(owner.ptyId) - return expectedIncarnation !== undefined && session.incarnationId === expectedIncarnation - } - return Boolean( - session.agentSessionOwners?.some((candidate) => - agentSessionOwnerBindingsEqual(candidate, owner) - ) - ) -} - -async function verifyPtyStopped( - provider: IPtyProvider, - ptyId: string, - opts: { keepHistory?: boolean; deadlineMs?: number } | undefined -): Promise { - if (await isProviderPtyLive(provider, ptyId, opts?.deadlineMs)) { - return false - } - if (!opts?.keepHistory) { - return true - } - const deadline = Date.now() + KEEP_HISTORY_STOP_SETTLE_MS - while (Date.now() < deadline) { - await delay(KEEP_HISTORY_STOP_POLL_MS) - if (await isProviderPtyLive(provider, ptyId, opts?.deadlineMs)) { - return false - } - } - return true -} - -function finishPtyShutdown( - id: string, - connectionId: string | null | undefined, - store: Store | undefined -): string | undefined { - const incarnationId = ptyIncarnationById.get(id) - clearProviderPtyState(id) - if (connectionId) { - store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated') - } - ptyOwnership.delete(id) - markClaudePtyExited(id) - return incarnationId -} - -// ─── Host PTY env assembly ────────────────────────────────────────── -// Why: centralize host-local env injections so both spawn paths (local + daemon) get them; implemented twice they drifted, silently breaking daemon PTYs. - -export type BuildPtyHostEnvOptions = { - isPackaged: boolean - resourcesPath?: string - userDataPath: string - selectedCodexHomePath: string | null - skipCodexHomeEnv?: boolean - /** System-default real-home routing (flag ON): inject no managed CODEX_HOME, - * and strip only an inherited Orca-owned override so nested Orca panes do not - * leak the parent's managed home. A user-set CODEX_HOME is preserved. */ - stripInheritedOrcaCodexHome?: boolean - /** Launch command the renderer chose (e.g. 'pi', 'omp', 'claude'); resolves the per-agent - * extension target for Pi/OMP. Undefined for bare shells → defaults to Pi. NEVER infer from - * disk presence (cross-agent shadowing when both dirs exist). */ - launchCommand?: string - /** Trusted agent identity for wrapped commands that cannot be recognized from text. */ - launchAgent?: TuiAgent - isWsl?: boolean - /** Distro for WSL spawns (null = Windows default distro); drives the WSL hook relay + endpoint repoint. Only read when isWsl. */ - wslDistro?: string | null - agentStatusHooksEnabled: boolean - codexStatusHooksEnabled?: boolean - networkProxySettings?: NetworkProxySettings - /** Keep indexed Git config off the sparse daemon wire; the daemon appends guard entries after merging its inherited env. */ - deferGitConfigGuardToDaemon?: boolean -} - -function readInheritedPath(baseEnv: Record): string { - const pathKey = resolvePathEnvKey(baseEnv, process.platform) - return baseEnv[pathKey] ?? process.env[pathKey] ?? '' -} - -function firstPathEntry(pathValue: string | undefined): string | null { - const first = pathValue?.split(delimiter).find((entry) => entry.trim().length > 0) - return first ?? null -} - -function promoteAgentTeamsShimPath( - env: Record | undefined, - requestedPath: string | undefined -): void { - if (!env?.ORCA_AGENT_TEAMS_TEAM_ID) { - return - } - const shimPath = firstPathEntry(requestedPath) - // Why: requestedPath is captured before buildPtyHostEnv scrubs, so a legacy entry that - // reached the front would be re-prepended here and outlive the scrub. - if (!shimPath || isLegacyTerminalShimPathEntry(shimPath)) { - return - } - const currentPathKey = env.PATH !== undefined || env.Path === undefined ? 'PATH' : 'Path' - const currentPath = env[currentPathKey] ?? '' - const remaining = currentPath - .split(delimiter) - .filter((entry) => entry.length > 0 && entry !== shimPath) - // Why: host env injection prepends Orca's shims; Claude Agent Teams must still resolve our fake tmux before any real tmux. - env[currentPathKey] = [shimPath, ...remaining].join(delimiter) -} - -function deleteRequestedEnvKeys( - env: Record | undefined, - keys: string[] | undefined -): void { - if (!env || !keys) { - return - } - for (const key of keys) { - delete env[key] - } -} - -function shouldSkipCodexHomeEnvForWindowsShell( - shellPath: string | undefined, - cwd: string | undefined -): boolean { - return isWslShellName(shellPath) || (typeof cwd === 'string' && parseWslPath(cwd) !== null) -} - -function isCodexStatusHooksEnabled(settings: GlobalSettings | undefined): boolean { - return ( - isAgentStatusHooksEnabled(settings) && isTuiAgentEnabled('codex', settings?.disabledTuiAgents) - ) -} - -// Why: with the real-home flag ON, a host system-default launch resolves to a -// null managed home. Signal the env builder to strip a nested-Orca-inherited -// override instead of injecting one, so Codex runs on the user's own ~/.codex. -function shouldStripInheritedOrcaCodexHome(args: { - target: CodexAccountSelectionTarget - selectedCodexHomePath: string | null - skipCodexHomeEnv: boolean - settings: GlobalSettings | undefined -}): boolean { - return ( - args.target.runtime === 'host' && args.selectedCodexHomePath === null && !args.skipCodexHomeEnv - ) -} - -const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const - -// Why: system-default real-home routing runs Codex on the user's own ~/.codex. -// Nested Orca panes inherit the parent's Orca-owned override; strip only that -// (CODEX_HOME matching Orca's private ORCA_CODEX_HOME marker), and always drop -// the marker so a shell-ready wrapper cannot restore the managed home. A -// user-set CODEX_HOME with no Orca marker is preserved untouched (see #8606). -function stripInheritedOrcaCodexHomeOverride(baseEnv: Record): void { - for (const key of getLocalOrcaCodexHomeEnvKeysToDelete(baseEnv)) { - delete baseEnv[key] - } -} - -// Why: in-process spawns share main's inherited environment, so equality with -// the private marker is authoritative here. Persistent daemons compare locally. -function getLocalOrcaCodexHomeEnvKeysToDelete(env: Record): string[] { - const inheritedOrcaOverride = env.ORCA_CODEX_HOME ?? process.env.ORCA_CODEX_HOME - const inheritedCodexHome = env.CODEX_HOME ?? process.env.CODEX_HOME - const keysToDelete = ['ORCA_CODEX_HOME'] - if (inheritedOrcaOverride && inheritedCodexHome === inheritedOrcaOverride) { - keysToDelete.push('CODEX_HOME') - } - return keysToDelete -} - -export type CodexHomeLaunchContext = { - workspacePath?: string - launchAgent?: TuiAgent - unavailableManagedHomePath?: string -} - -export type GetSelectedCodexHomePath = ( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv, - launchContext?: CodexHomeLaunchContext -) => string | null -export type PrepareCodexSessionResume = (args: { - providerSession: AgentProviderSessionMetadata - target: CodexAccountSelectionTarget - launchEnv?: NodeJS.ProcessEnv - workspacePath?: string -}) => Promise - -export type CodexHomePtySpawnedLifecycleArgs = { - id: string - codexHomePath: string | null - reattached?: boolean - reattachedHomeRoute?: CodexPaneHomeRoute | null - launchEnv?: NodeJS.ProcessEnv - startedAt?: Date - startedSequence?: number -} - -let ptyLifecycleSequence = 0 - -type PrepareClaudeAuth = ( - target?: ClaudeAccountSelectionTarget -) => Promise - -function getCodexSelectionTargetForPty( - shellPath: string | undefined, - cwd: string | undefined, - wslDistro?: string | null -): CodexAccountSelectionTarget { - const wslPath = typeof cwd === 'string' ? parseWslPath(cwd) : null - if (isWslShellName(shellPath) || wslPath) { - return { runtime: 'wsl', wslDistro: wslPath?.distro ?? wslDistro ?? null } - } - return { runtime: 'host' } -} - -function getCompatibleSelectedCodexHomePath( - target: CodexAccountSelectionTarget, - selectedCodexHomePath: string | null -): string | null { - if (!selectedCodexHomePath) { - return null - } - const wslInfo = parseWslPath(selectedCodexHomePath) - if (target.runtime === 'wsl') { - return wslInfo || !isHostCodexHomeForWsl(selectedCodexHomePath) ? selectedCodexHomePath : null - } - return wslInfo || (process.platform === 'win32' && isWslCodexHomeForHost(selectedCodexHomePath)) - ? null - : selectedCodexHomePath -} - -const MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE = - 'The selected Codex account credentials are temporarily unavailable. Try opening the terminal again.' -const CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE = - 'The Codex account credentials for this session are temporarily unavailable. Try opening the terminal again.' -const MANAGED_CODEX_HOME_UNAVAILABLE_MESSAGE = - 'Codex account files are temporarily locked. Retry in a moment.' - -/** - * Why: launch prep refuses an unreadable managed home by throwing instead of - * returning `null`, because `null` already means "launch the system default". - * Turn that refusal into the same shape as the auth-unavailable refusal above — - * a user-facing spawn rejection — so the pane never starts on another account's - * credentials (#STA-4422). - */ -function resolveSelectedCodexHomeOrRefuseSpawn(resolve: () => string | null): string | null { - try { - return resolve() - } catch (error) { - if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { - throw new Error(MANAGED_CODEX_HOME_UNAVAILABLE_MESSAGE, { cause: error }) - } - throw error - } -} - -type ManagedCodexAuthResolutionArgs = { - selectedCodexHomePath: string | null - getSettings: () => GlobalSettings | undefined - requiredCodexHomePath?: string - target: CodexAccountSelectionTarget - resolveCurrent: () => string | null - resolveAfterUnavailable: (unavailableManagedHomePath: string) => string | null -} - -export function resolveCodexHomeAfterManagedAuthReadiness( - args: ManagedCodexAuthResolutionArgs -): string | null | Promise { - const selectedCodexHomePath = args.selectedCodexHomePath - if ( - args.requiredCodexHomePath && - !codexHomePathsEqual(selectedCodexHomePath, args.requiredCodexHomePath) - ) { - throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) - } - const readiness = waitForManagedCodexAuthReady({ - codexHomePath: selectedCodexHomePath, - settings: args.getSettings(), - target: args.target - }) - return readiness - ? continueCodexHomeAfterManagedAuthWait(args, selectedCodexHomePath, readiness) - : selectedCodexHomePath -} - -async function continueCodexHomeAfterManagedAuthWait( - args: ManagedCodexAuthResolutionArgs, - initialCodexHomePath: string | null, - initialReadiness: Promise -): Promise { - let selectedCodexHomePath = initialCodexHomePath - let readiness = initialReadiness - for (let attempt = 0; attempt < 2; attempt += 1) { - if (await readiness) { - if (args.requiredCodexHomePath) { - return selectedCodexHomePath - } - const currentCodexHomePath = args.resolveCurrent() - if (codexHomeSelectionsEqual(selectedCodexHomePath, currentCodexHomePath)) { - return selectedCodexHomePath - } - selectedCodexHomePath = currentCodexHomePath - if (attempt === 1) { - break - } - const nextReadiness = waitForManagedCodexAuthReady({ - codexHomePath: selectedCodexHomePath, - settings: args.getSettings(), - target: args.target - }) - if (!nextReadiness) { - return selectedCodexHomePath - } - readiness = nextReadiness - continue - } - if (args.requiredCodexHomePath) { - throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) - } - selectedCodexHomePath = args.resolveAfterUnavailable(selectedCodexHomePath!) - if (attempt === 1) { - break - } - const nextReadiness = waitForManagedCodexAuthReady({ - codexHomePath: selectedCodexHomePath, - settings: args.getSettings(), - target: args.target - }) - if (!nextReadiness) { - return selectedCodexHomePath - } - readiness = nextReadiness - } - if ( - isCodexHomeAuthReadyForLaunch({ - codexHomePath: selectedCodexHomePath, - settings: args.getSettings(), - target: args.target - }) - ) { - return selectedCodexHomePath - } - throw new Error(MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE) -} - -function codexHomeSelectionsEqual(left: string | null, right: string | null): boolean { - return ( - left === right || - (left !== null && - right !== null && - normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right)) - ) -} - -function codexHomePathsEqual(left: string | null, right: string): boolean { - return codexHomeSelectionsEqual(left, right) -} - -// Why: CODEX_HOME is fixed in a shell's environment at spawn and the daemon -// keeps that shell alive across app restarts, so the launch account is the only -// way to tell later that a pane still runs Codex as the previously selected -// account. A reattach inherits that baked environment rather than choosing one, -// so re-recording it under the current selection would erase the very evidence -// that the pane is stale. -function recordCodexPaneAccountForSpawn(args: { - ptyId: string | undefined - isDaemonHostSpawn: boolean - isReattach: boolean - pinnedByResume: boolean - launchCodexHomePath: string | null - launchEnv?: NodeJS.ProcessEnv - target: CodexAccountSelectionTarget - settings: GlobalSettings | undefined -}): void { - if (!args.ptyId || !args.isDaemonHostSpawn || args.isReattach) { - return - } - const customHomeOverride = getCustomCodexHomeOverrideForLaunch(args.launchEnv) - const processHomeOverride = customHomeOverride ? getCustomCodexHomeOverrideForLaunch() : null - const recheckableEnvironmentOverride = - customHomeOverride?.source === 'environment' && - processHomeOverride?.source === 'environment' && - environmentCodexHomeOverrideContextsEqual( - customHomeOverride.context, - processHomeOverride.context - ) - ? customHomeOverride.context - : undefined - const recheckableShellStartupOverride = - customHomeOverride?.source === 'shell-startup' && - processHomeOverride?.source === 'shell-startup' && - shellStartupCodexHomeOverrideContextsEqual( - customHomeOverride.context, - processHomeOverride.context - ) - ? customHomeOverride.context - : undefined - const record = args.settings - ? resolveCodexPaneLaunchAccount({ - pinnedByResume: args.pinnedByResume, - launchCodexHomePath: args.launchCodexHomePath, - // Why: pane-local overrides cannot be re-derived when a restart builds - // a fresh launch env, so route prompts would guess and block valid input. - recordComparableHomeRoute: - args.pinnedByResume || - ((customHomeOverride?.source !== 'environment' || - recheckableEnvironmentOverride !== undefined) && - (customHomeOverride?.source !== 'shell-startup' || - recheckableShellStartupOverride !== undefined)), - shellStartupHomeOverride: args.pinnedByResume ? undefined : recheckableShellStartupOverride, - environmentHomeOverride: args.pinnedByResume ? undefined : recheckableEnvironmentOverride, - systemCodexHomePath: getSystemCodexHomePath(), - settings: args.settings, - target: args.target - }) - : null - if (!record) { - forgetCodexPaneAccount(args.ptyId) - return - } - recordCodexPaneAccount(args.ptyId, record) -} - -function snapshotCodexPaneHomeRoutes( - ptyIds: readonly (string | null | undefined)[] -): ReadonlyMap { - const routes = new Map() - for (const ptyId of ptyIds) { - if (!ptyId || routes.has(ptyId)) { - continue - } - routes.set(ptyId, getCodexPaneAccount(ptyId)?.homeRoute ?? null) - } - return routes -} - -function codexReattachedHomeRouteField( - routes: ReadonlyMap, - ptyId: string, - reattached: boolean -): { reattachedHomeRoute?: CodexPaneHomeRoute | null } { - if (!reattached || !routes.has(ptyId)) { - return {} - } - return { reattachedHomeRoute: routes.get(ptyId) ?? null } -} - -function readEnvWithProcessFallback( - baseEnv: Record, - key: string -): string | undefined { - return baseEnv[key] ?? process.env[key] -} - -function resolvePiAgentSourceDir( - baseEnv: Record, - kind: PiAgentKind -): string | undefined { - const sourceKey = SOURCE_AGENT_DIR_ENV_BY_KIND[kind] - const primaryKey = PRIMARY_AGENT_DIR_ENV_BY_KIND[kind] - - const sourceDir = readEnvWithProcessFallback(baseEnv, sourceKey) - if (sourceDir) { - return sourceDir - } - - if (kind === 'prime-agent') { - return ( - readEnvWithProcessFallback(baseEnv, primaryKey) ?? - readSessionShellStartupEnvVar(primaryKey, baseEnv) - ) - } - - const overlayKey = kind === 'omp' ? 'ORCA_OMP_CODING_AGENT_DIR' : 'ORCA_PI_CODING_AGENT_DIR' - const otherOverlayKey = kind === 'omp' ? 'ORCA_PI_CODING_AGENT_DIR' : 'ORCA_OMP_CODING_AGENT_DIR' - - const publicDir = readEnvWithProcessFallback(baseEnv, primaryKey) - const ownOverlayDir = readEnvWithProcessFallback(baseEnv, overlayKey) - const otherOverlayDir = readEnvWithProcessFallback(baseEnv, otherOverlayKey) - // Why: if PI_CODING_AGENT_DIR is a restored Orca overlay with no source shadow, remirroring leaks another agent's overlay tree; fall through to defaults. - if (publicDir && publicDir !== ownOverlayDir && publicDir !== otherOverlayDir) { - return publicDir - } - - return readSessionShellStartupEnvVar(primaryKey, baseEnv) -} - -function resolveScopedPiAgentSourceDir( - baseEnv: Record, - kind: PiAgentKind -): string | undefined { - return readEnvWithProcessFallback(baseEnv, SOURCE_AGENT_DIR_ENV_BY_KIND[kind]) -} - -function clearPiAgentShadowEnv(baseEnv: Record, kind: PiAgentKind): void { - if (kind === 'omp') { - delete baseEnv.ORCA_OMP_CODING_AGENT_DIR - delete baseEnv.ORCA_OMP_SOURCE_AGENT_DIR - delete baseEnv.ORCA_OMP_STATUS_EXTENSION - return - } - if (kind === 'prime-agent') { - delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR - delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION - return - } - delete baseEnv.ORCA_PI_CODING_AGENT_DIR - delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR -} - -function exposePiManagedExtensionEnv( - baseEnv: Record, - kind: PiAgentKind, - managedEnv: Record -): void { - if (kind === 'omp') { - delete baseEnv.ORCA_OMP_CODING_AGENT_DIR - if (managedEnv.ORCA_OMP_SOURCE_AGENT_DIR) { - baseEnv.ORCA_OMP_SOURCE_AGENT_DIR = managedEnv.ORCA_OMP_SOURCE_AGENT_DIR - } else { - delete baseEnv.ORCA_OMP_SOURCE_AGENT_DIR - } - if (managedEnv.ORCA_OMP_STATUS_EXTENSION) { - baseEnv.ORCA_OMP_STATUS_EXTENSION = managedEnv.ORCA_OMP_STATUS_EXTENSION - } else { - delete baseEnv.ORCA_OMP_STATUS_EXTENSION - } - return - } - if (kind === 'prime-agent') { - if (managedEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR) { - baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR = managedEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR - } else { - delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR - } - return - } - delete baseEnv.ORCA_PI_CODING_AGENT_DIR - if (managedEnv.ORCA_PI_SOURCE_AGENT_DIR) { - baseEnv.ORCA_PI_SOURCE_AGENT_DIR = managedEnv.ORCA_PI_SOURCE_AGENT_DIR - } else { - delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR - } -} - -// Why: variadic because a nested call per source made intermediate `string[] | undefined` collide with the parameter type. -function mergePtyEnvDeletions( - existingKeys: string[] | undefined, - ...additionalKeyGroups: readonly (readonly string[])[] -): string[] | undefined { - if (!existingKeys && additionalKeyGroups.every((keys) => keys.length === 0)) { - return undefined - } - return Array.from(new Set([...(existingKeys ?? []), ...additionalKeyGroups.flat()])) -} - -function removeCodexHomeDeletionRequests(keys: string[] | undefined): string[] | undefined { - // Why: resume provenance is launch-authoritative; late deletions must not fall back to the current account. - const filtered = keys?.filter((key) => key !== 'CODEX_HOME' && key !== 'ORCA_CODEX_HOME') - return filtered?.length ? filtered : undefined -} - -function getInheritedAgentHookEnvKeysToDelete( - spawnEnv: Record | undefined -): string[] { - const env = spawnEnv ?? {} - // Why: providers merge process.env after cleanup; delete stale hook keys without dropping fresh coordinates buildPtyHostEnv set. - return AGENT_HOOK_RUNTIME_ENV_KEYS.filter((key) => env[key] === undefined) -} - -function getInheritedClaudeSessionStampEnvKeysToDelete( - spawnEnv: Record | undefined -): string[] { - const env = spawnEnv ?? {} - // Why: strip only values inherited from the pty host; a caller that explicitly - // provides a stamp (deliberately spawning a nested Claude child) keeps it. - return CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined) -} - -// Why: a nested terminal can inherit prior OpenCode/Pi/OMP overlay env; restore the user's recorded source dir, else strip only Orca-owned values. -function restoreOrStripOverlayEnv( - baseEnv: Record, - keys: { - primary: string - overlay: string - source: string - } -): void { - const sourceValue = baseEnv[keys.source] ?? process.env[keys.source] - const overlayValue = baseEnv[keys.overlay] ?? process.env[keys.overlay] - if (sourceValue) { - baseEnv[keys.primary] = sourceValue - } else if (overlayValue && baseEnv[keys.primary] === overlayValue) { - delete baseEnv[keys.primary] - } - delete baseEnv[keys.overlay] - delete baseEnv[keys.source] -} - -function isMimoLaunchCommand(launchCommand: string | undefined): boolean { - const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCommand ?? '')) - .toLowerCase() - .replace(/\.(?:cmd|exe|sh)$/, '') - return binary === 'mimo' -} - -function resolveMimocodeSourceHome(baseEnv: Record): string | undefined { - const sourceHome = baseEnv.ORCA_MIMOCODE_SOURCE_HOME ?? process.env.ORCA_MIMOCODE_SOURCE_HOME - if (sourceHome) { - return sourceHome - } - const configHome = baseEnv.MIMOCODE_HOME ?? process.env.MIMOCODE_HOME - const orcaHome = baseEnv.ORCA_MIMOCODE_HOME ?? process.env.ORCA_MIMOCODE_HOME - if (configHome && orcaHome && configHome === orcaHome) { - return undefined - } - return configHome -} - -function resolveOpenCodeSourceConfigDir(baseEnv: Record): string | undefined { - const sourceDir = - baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR - if (sourceDir) { - return sourceDir - } - - const configDir = baseEnv.OPENCODE_CONFIG_DIR ?? process.env.OPENCODE_CONFIG_DIR - const orcaConfigDir = baseEnv.ORCA_OPENCODE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_CONFIG_DIR - // Why: with no recorded source dir, an inherited OPENCODE_CONFIG_DIR is Orca-owned, not user config; treating it as user config makes child Orcas mirror the hook dir. - if (configDir && orcaConfigDir && configDir === orcaConfigDir) { - return undefined - } - - return configDir ?? readSessionShellStartupEnvVar('OPENCODE_CONFIG_DIR', baseEnv) -} - -/** - * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. - * - * Do NOT call when `args.connectionId` is set (SSH): every injection is host-loopback - * or references local filesystem paths meaningless to a remote shell. - */ -export function buildPtyHostEnv( - id: string, - baseEnv: Record, - opts: BuildPtyHostEnvOptions -): Record { - mergePersistedWindowsPath(baseEnv) - Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings)) - - // Why: local path's baseEnv includes process.env but the daemon path doesn't (fork inheritance, not IPC); check both sources so guards stay in lock-step across spawn paths. - const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv) - const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) - const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent) - ? opts.launchAgent - : opts.launchAgent === undefined - ? detectExplicitPiAgentKindFromCommand(launchCommandHint) - : null - const piAgentKind = explicitPiAgentKind ?? 'pi' - const hasLaunchCommand = - typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 - - // Why: unattended agents must fail instead of looping on OS credential prompts; user terminals keep normal Git behavior. - applyTerminalGitCredentialPromptGuard(baseEnv, { - launchCommand: launchCommandHint, - isUnattended: opts.launchAgent !== undefined, - deferGitConfigGuardToHost: opts.deferGitConfigGuardToDaemon - }) - - const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand - // Why: source shadows are agent-scoped; trusting the other kind's source reintroduces Pi/OMP extension-state shadowing. - const preexistingPiAgentDir = resolvePiAgentSourceDir(baseEnv, 'pi') - const preexistingOmpAgentDir = - piAgentKind === 'omp' - ? resolvePiAgentSourceDir(baseEnv, 'omp') - : resolveScopedPiAgentSourceDir(baseEnv, 'omp') - const preexistingPrimeAgentDir = - piAgentKind === 'prime-agent' - ? resolvePiAgentSourceDir(baseEnv, 'prime-agent') - : resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent') - - if (opts.agentStatusHooksEnabled) { - // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md. - Object.assign(baseEnv, openCodeHookService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) - if (baseEnv.OPENCODE_CONFIG_DIR) { - // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value. - baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR - if (preexistingOpenCodeConfigDir) { - // Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays. - baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir - } else { - delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR - } - } - if (isMimoLaunchCommand(launchCommandHint)) { - const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) - Object.assign(baseEnv, mimoCodeHookService.buildPtyEnv(id, preexistingMimocodeHome)) - if (baseEnv.MIMOCODE_HOME) { - baseEnv.ORCA_MIMOCODE_HOME = baseEnv.MIMOCODE_HOME - if (preexistingMimocodeHome) { - baseEnv.ORCA_MIMOCODE_SOURCE_HOME = preexistingMimocodeHome - } else { - delete baseEnv.ORCA_MIMOCODE_SOURCE_HOME - } - } - } - } else { - restoreOrStripOverlayEnv(baseEnv, { - primary: 'OPENCODE_CONFIG_DIR', - overlay: 'ORCA_OPENCODE_CONFIG_DIR', - source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' - }) - restoreOrStripOverlayEnv(baseEnv, { - primary: 'MIMOCODE_HOME', - overlay: 'ORCA_MIMOCODE_HOME', - source: 'ORCA_MIMOCODE_SOURCE_HOME' - }) - } - - // Why: strip inherited hook coordinates before injecting this PTY's fresh loopback receiver, so nested-terminal callbacks route to the owning pane. - for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) { - delete baseEnv[key] - } - if (opts.agentStatusHooksEnabled) { - Object.assign(baseEnv, agentHookServer.buildPtyEnv()) - if (opts.isWsl === true) { - // Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one. - const distro = opts.wslDistro ?? null - wslHookRelayManager.ensureForDistro(distro) - const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro) - if (guestEndpoint) { - baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint - } - // Why: OpenCode loads its status plugin from a guest config overlay, so point OPENCODE_CONFIG_DIR at the guest dir the relay materialized. - const opencodeOverlayDir = wslHookRelayManager.getOpenCodeOverlayDir(distro) - if (opencodeOverlayDir) { - baseEnv.OPENCODE_CONFIG_DIR = opencodeOverlayDir - baseEnv.ORCA_OPENCODE_CONFIG_DIR = opencodeOverlayDir - delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR - } else { - // Why: relay not connected yet (or older guest bundle) — never cross the Windows overlay path into WSL; drop it so in-guest OpenCode uses its own config (pre-fix behavior, no status but no regression). - delete baseEnv.OPENCODE_CONFIG_DIR - delete baseEnv.ORCA_OPENCODE_CONFIG_DIR - delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR - } - } - } - - // Why: PI_CODING_AGENT_DIR is the user's config/session root; install only Orca-owned extension files, don't override it. - if (opts.agentStatusHooksEnabled) { - clearPiAgentShadowEnv(baseEnv, 'pi') - clearPiAgentShadowEnv(baseEnv, 'omp') - clearPiAgentShadowEnv(baseEnv, 'prime-agent') - // Why: bare shells historically defaulted to Pi + OMP shadow prep and - // created ~/./agent even when the user never launches those agents - // (#10196). Only create default homes on an explicit Pi/OMP launch; - // otherwise install only into an existing agent dir (or userData for OMP - // status so a typed `omp` still gets the shell wrapper extension). - if (piAgentKind === 'pi') { - const piEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir, 'pi', { - materializeDefaultHome: explicitPiAgentKind === 'pi' - }) - Object.assign(baseEnv, piEnv) - exposePiManagedExtensionEnv(baseEnv, 'pi', piEnv) - } - - if (shouldPrepareOmpShadow) { - const ompEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingOmpAgentDir, 'omp', { - materializeDefaultHome: explicitPiAgentKind === 'omp' - }) - Object.assign(baseEnv, ompEnv) - exposePiManagedExtensionEnv(baseEnv, 'omp', ompEnv) - } - - if (piAgentKind === 'prime-agent' && !opts.isWsl) { - const primeEnv = piTitlebarExtensionService.buildPtyEnv( - id, - preexistingPrimeAgentDir, - 'prime-agent', - { materializeDefaultHome: explicitPiAgentKind === 'prime-agent' } - ) - Object.assign(baseEnv, primeEnv) - exposePiManagedExtensionEnv(baseEnv, 'prime-agent', primeEnv) - } - } else { - // Why: nested PTYs must not inherit stale source or overlay state from another agent. - restoreOrStripOverlayEnv(baseEnv, { - primary: 'PI_CODING_AGENT_DIR', - overlay: 'ORCA_PI_CODING_AGENT_DIR', - source: 'ORCA_PI_SOURCE_AGENT_DIR' - }) - restoreOrStripOverlayEnv(baseEnv, { - primary: 'PI_CODING_AGENT_DIR', - overlay: 'ORCA_OMP_CODING_AGENT_DIR', - source: 'ORCA_OMP_SOURCE_AGENT_DIR' - }) - delete baseEnv.ORCA_OMP_STATUS_EXTENSION - delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR - delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION - } - - // Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex. - if (opts.skipCodexHomeEnv) { - delete baseEnv.CODEX_HOME - delete baseEnv.ORCA_CODEX_HOME - delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT - } else if (opts.selectedCodexHomePath) { - baseEnv.CODEX_HOME = opts.selectedCodexHomePath - // Why: user startup files may re-export CODEX_HOME; shell-ready wrappers restore this runtime home before Codex launches. - baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath - const preflightCommand = resolveCodexShellLaunchPreflightCommand({ - hooksEnabled: opts.codexStatusHooksEnabled ?? opts.agentStatusHooksEnabled, - isPackaged: opts.isPackaged, - isWsl: opts.isWsl, - managedHomePath: opts.selectedCodexHomePath, - userDataPath: opts.userDataPath, - resourcesPath: opts.resourcesPath - }) - if (preflightCommand) { - baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT = preflightCommand - } else { - delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT - } - } else if (opts.stripInheritedOrcaCodexHome) { - stripInheritedOrcaCodexHomeOverride(baseEnv) - delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT - } else { - delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT - } - - // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. - if (opts.isWsl) { - baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath - // Why: managed WSL registration uses `orca-ide`; exposing that literal scopes agent guidance to WSL without a bare-orca shim. - baseEnv.ORCA_CLI_COMMAND = opts.isPackaged ? 'orca-ide' : 'orca-dev' - } else { - if (!opts.isPackaged) { - baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath - } - delete baseEnv.ORCA_CLI_COMMAND - } - // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca. - if (!opts.isPackaged) { - const devCliBin = join(opts.userDataPath, 'cli', 'bin') - const inheritedPath = readInheritedPath(baseEnv) - // Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter. - baseEnv[resolvePathEnvKey(baseEnv, process.platform)] = inheritedPath - ? `${devCliBin}${delimiter}${inheritedPath}` - : devCliBin - } else if (process.platform === 'linux') { - // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904). - const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath }) - if (shimDir) { - const inheritedEntries = readInheritedPath(baseEnv) - .split(delimiter) - .filter((entry) => entry.length > 0 && entry !== shimDir) - baseEnv.PATH = [shimDir, ...inheritedEntries].join(delimiter) - } - } else if ( - opts.resourcesPath && - (process.platform === 'darwin' || process.platform === 'win32') - ) { - // Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI. - const bundledCliBin = join(opts.resourcesPath, 'bin') - const inheritedPath = readInheritedPath(baseEnv) - baseEnv[resolvePathEnvKey(baseEnv, process.platform)] = inheritedPath - ? `${bundledCliBin}${delimiter}${inheritedPath}` - : bundledCliBin - } - - // Why: must run after the prepends above — they re-read PATH from the unscrubbed - // process.env when baseEnv carries none, which is the daemon path's normal shape. - stripLegacyTerminalShimEnv(baseEnv, process.platform) - - return baseEnv -} - -function isClaudeLaunchCommand(command: string | undefined): boolean { - if (!command) { - return false - } - return /(^|[\s;&|('"`])(?:[^\s;&|('"`]*[\\/])?claude(?:\.cmd|\.exe)?($|[\s;&|)'"`])/i.test( - command - ) -} - -function routesFreshSpawnsToLocalProvider(provider: IPtyProvider): boolean { - return provider.routesFreshSpawnsToLocalProvider === true -} - -function recoverFreshSpawnProviderRouting( - provider: IPtyProvider, - connectionId: string | null | undefined, - sessionId: string | undefined, - isNewSession = sessionId === undefined -): Promise | undefined { - if (connectionId || (!isNewSession && sessionId) || !routesFreshSpawnsToLocalProvider(provider)) { - return - } - return provider.recoverFreshSpawnRouting?.() -} - -function beginPtySpawnForWorktree( - worktreeId: string | undefined, - cwd: string | undefined, - connectionId: string | null | undefined -): () => void { - const worktreePath = worktreeId - ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath - : undefined - const installPaths = new Map() - for (const candidate of [worktreePath, cwd]) { - if (candidate) { - installPaths.set(normalizeRuntimePathForComparison(candidate), candidate) - } - } - const finishes: (() => void)[] = [] - try { - for (const candidate of installPaths.values()) { - finishes.push(beginTerminalInstall(candidate, connectionId ?? undefined)) - } - } catch (error) { - // Why: worktree ID and cwd can be different roots; release earlier admissions before rejecting. - finishes.toReversed().forEach((finish) => finish()) - throw error - } - return () => finishes.toReversed().forEach((finish) => finish()) -} - -/** Register an SSH PTY provider for a connection. */ -export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void { - sshProviders.set(connectionId, provider) - const generation = (provider as { providerGeneration?: number }).providerGeneration - if (Number.isSafeInteger(generation) && generation! > 0) { - sshProvidersByGeneration.set(generation!, provider) - } -} - -/** Remove an SSH PTY provider when a connection is closed. */ -export function unregisterSshPtyProvider(connectionId: string): void { - const provider = sshProviders.get(connectionId) - const generation = (provider as { providerGeneration?: number } | undefined)?.providerGeneration - if (generation !== undefined && sshProvidersByGeneration.get(generation) === provider) { - sshProvidersByGeneration.delete(generation) - } - sshProviders.delete(connectionId) -} - -/** Get the SSH PTY provider for a connection (for dispose on cleanup). */ -export function getSshPtyProvider(connectionId: string): IPtyProvider | undefined { - return sshProviders.get(connectionId) -} - -/** Get the installed PTY provider (for direct access in tests/runtime). - * After daemon init this may be a DaemonPtyAdapter/DaemonPtyRouter, not LocalPtyProvider; - * callers needing LocalPtyProvider-specific methods must type-narrow or import the class. */ -export function getLocalPtyProvider(): IPtyProvider { - return localProvider -} - -/** Replace the local PTY provider with a daemon-backed one. - * Call before registerPtyHandlers so the IPC layer routes through the daemon. */ -export function setLocalPtyProvider(provider: IPtyProvider): void { - localProvider = provider -} - -/** Get all PTY IDs owned by a given connectionId (for reconnection reattach). */ -export function getPtyIdsForConnection(connectionId: string): string[] { - const ids: string[] = [] - for (const [ptyId, connId] of ptyOwnership) { - if (connId === connectionId) { - ids.push(ptyId) - } - } - return ids -} - -/** - * Remove transient PTY routing entries for a disconnected connection. - * Claimed agent owners remain fenced because the relay process may survive and - * prove the exact same generation after reconnect. - */ -export function clearPtyOwnershipForConnection(connectionId: string): void { - for (const [ptyId, connId] of ptyOwnership) { - if (connId === connectionId) { - // Why: pane-scoped caches cannot route while disconnected, but claimed - // ownership must survive until reconnect makes absence authoritative. - clearProviderPtyState(ptyId, { preserveAgentSessionOwners: true }) - ptyOwnership.delete(ptyId) - } - } -} - -// ─── Provider-scoped PTY state cleanup ────────────────────────────── - -export function clearProviderPtyState( - id: string, - opts: { preserveAgentSessionOwners?: boolean } = {} -): void { - if (!opts.preserveAgentSessionOwners) { - agentSessionOwners.release(id) - // Why: the launch-account record outlives the app, so only a real teardown - // may drop it — a disconnect that can reconnect is not a death, and a reused - // id must never inherit a dead pane's Codex account. - forgetCodexPaneAccount(id) - } - // Why: OpenCode and Pi both allocate PTY-scoped runtime state outside the - // node-pty process table. Centralizing provider cleanup avoids drift where a - // new teardown path forgets to remove one provider's overlay/hook state. - openCodeHookService.clearPty(id) - piTitlebarExtensionService.clearPty(id) - // Why: SSH exit/teardown paths bypass pty.ts's local onExit but still must release Claude account-switch guards. - markClaudePtyExited(id) - ptySizes.delete(id) - ptyIncarnationById.delete(id) - lastInputAtByPty.delete(id) - interactiveOutputCharsByPty.delete(id) - const activeChanged = activeRendererPtys.delete(id) - visibleRendererPtys.delete(id) - rendererVisibilityKnownPtys.delete(id) - pendingHiddenRendererResizeOutputPtys.delete(id) - deliveredHiddenRendererResizeOutputPtys.delete(id) - // Why: every teardown path funnels through here — hidden/interest gate bits must not outlive the PTY or a reused map entry could silently gate a new one. - const deliveryPolicyChanged = isHiddenRendererPty(id) - clearHiddenRendererPtyDeliveryState(id) - if (activeChanged) { - invalidatePendingPtyDrainPriority(id, false) - } - if (deliveryPolicyChanged) { - invalidatePendingPtyDrainPolicy(id, false) - } - clearBackgroundedDeliverySyncForPty(id) - providerSnapshotRequiredPtys.delete(id) - // Why: the Phase-5 ConPTY DA1 spawn record must not leak onto a reused id. - clearNativeWindowsConptyPty(id) - const paneKey = ptyPaneKey.get(id) - const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false - // Why: drop the memory-collector registration so a dead PTY doesn't resolve its dead pid on every snapshot; no-op for never-registered (SSH-owned) PTYs. - unregisterPty(id) - // Why: cover paths that bypass runtime.onPtyExit (SSH reattach/shutdown, daemon spawn-failure) — else the watcher's per-PTY buffer and worktree binding outlive the PTY. - advertisedUrlWatcher.unbindPty(id) - clearMigrationUnsupportedPty(id) - agentHookServer.clearPaneKeyAliasesForPty(id, { - shouldClearStablePaneKey: (stablePaneKey) => { - // Why: when this PTY never rebuilt ptyPaneKey after restart, alias ownership is our only proof — don't erase a newer PTY that now owns the same stable paneKey. - const stablePaneOwner = paneKeyPtyId.get(stablePaneKey) - if (stablePaneOwner && stablePaneOwner !== id) { - return false - } - return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey) - } - }) - // Why: clear the hook server's per-paneKey caches (via the spawn-time paneKey mapping, its only ptyId→paneKey correlation) so dead panes don't accumulate over process lifetime. - if (paneKey) { - if (stillOwnsPaneKey) { - agentHookServer.clearPaneState(paneKey) - paneKeyPtyId.delete(paneKey) - } - ptyPaneKey.delete(id) - if (stillOwnsPaneKey) { - // Why: notify AFTER dropping the paneKey↔ptyId entries so a listener re-reading the map sees post-teardown state; wrap each so one throw can't block the rest. - for (const listener of paneKeyTeardownListeners) { - try { - listener(paneKey) - } catch (err) { - console.error('[pty] paneKey teardown listener threw', err) - } - } - } - } -} - -export function deletePtyOwnership(id: string): void { - ptyOwnership.delete(id) -} - -export function setPtyOwnership(id: string, connectionId: string | null): void { - ptyOwnership.set(id, connectionId) -} - -export function restorePtyIncarnation(id: string, incarnationId: string): void { - if (!isPtyIncarnationId(incarnationId)) { - throw new Error('Invalid PTY incarnation') - } - ptyIncarnationById.set(id, incarnationId) -} - -// Why: localProvider.onData/onExit return unsubscribe functions. Without -// storing and calling these on re-registration, macOS app re-activation -// creates a new BrowserWindow and re-calls registerPtyHandlers, leaking -// duplicate listeners that forward every event twice. -let localDataUnsub: (() => void) | null = null -let localExitUnsub: (() => void) | null = null -let localBackgroundStreamUnsub: (() => void) | null = null -let localWriteUnavailableUnsub: (() => void) | null = null -let didFinishLoadHandler: (() => void) | null = null -let didFinishLoadWebContents: WebContents | null = null -let rendererLifecycleResetWebContents: WebContents | null = null -let rendererLifecycleResetHandler: (() => void) | null = null -// Why: the hidden-delivery gate registries mirror renderer state; a reload/crash destroys owners without unregistering, so they reset when the renderer is replaced (drop memory preserved). -let rendererGateResetLoadHandler: (() => void) | null = null -let rendererGateResetGoneHandler: (() => void) | null = null -let rendererGateResetWebContents: WebContents | null = null -// Why: the backgrounded-delivery dedupe map lives in the registerPtyHandlers closure but teardown funnels through module-scope clearProviderPtyState. -let clearBackgroundedDeliverySyncForPty: (id: string) => void = () => {} -// Why: after daemon keep-tail thinning main's mirror holds only the kept tail, so recovery must keep consulting the daemon's complete model until exit. -const providerSnapshotRequiredPtys = new Set() -type RendererNavigationDetails = { - isMainFrame: boolean - isSameDocument: boolean -} - -// Why: navigation details identify the triggering frame; querying aggregate load state can misclassify an overlapping subframe load. -let rendererDidStartNavigationHandler: ((details: RendererNavigationDetails) => void) | null = null - -// Why: Restart daemon must re-bind provider→renderer listeners after replaceDaemonProvider swaps localProvider, else subscribers stay bound to the disposed adapter and new PTY data silently drops. -let rebindProviderListeners: (() => void) | null = null -let sshOutputIntakeCleanup: (() => void) | null = null - -export function rebindLocalProviderListeners(): void { - rebindProviderListeners?.() -} - -export type PtyRendererDeliveryDebugSnapshot = { - pendingPtyCount: number - pendingChars: number - maxPendingCharsByPty: number - rendererInFlightPtyCount: number - rendererInFlightChars: number - maxRendererInFlightCharsByPty: number - activeRendererPtyCount: number - flushScheduled: boolean - peakPendingChars: number - peakMaxPendingCharsByPty: number - peakRendererInFlightChars: number - peakMaxRendererInFlightCharsByPty: number - ackGatedFlushSkipCount: number - hiddenDeliveryGatedPtyCount: number - /** Hidden-gated ptys the renderer ALSO reports visible/active — a contradiction that should be zero (v1.4.124-rc.2.perf field lead). */ - hiddenDeliveryGatedVisiblePtyCount: number - hiddenDeliveryGatedActivePtyCount: number - deliveryInterestPtyCount: number - hiddenDeliveryDroppedChars: number - hiddenDeliveryDroppedChunks: number - pendingDroppedChars: number - /** One-paste freeze diagnostics: per-pty delivery table + event history. */ - diagnostics: PtyMainDeliveryDiagnostics - // Why: a nonzero lastLifecycleResetClearedChars is the exact signature of the leaked-accounting freeze this reset fixes. - rendererLifecycleResetCount: number - lastLifecycleResetClearedChars: number - // Why: the boot-window hold early-returns before ackGatedFlushSkipCount++, so these expose an otherwise-invisible held gate; forcedCount > 0 flags a watchdog self-heal. - rendererPtyDispatcherReady: boolean - rendererDispatcherReadyForcedCount: number -} - -// Why module scope: breadcrumb writers live both inside registerPtyHandlers and outside it (renderer lifecycle resets). -const mainDeliveryBreadcrumbs = createPtyDeliveryBreadcrumbRing() -let lastPowerSuspendAtMs: number | null = null -let lastPowerResumeAtMs: number | null = null -let powerSignalBreadcrumbsInstalled = false - -// Why: both field freeze variants correlate with display sleep; suspend/resume timestamps let breadcrumbs line up against the wake. -function installPowerSignalBreadcrumbs(power: PtyPowerSurface): void { - if (powerSignalBreadcrumbsInstalled) { - return - } - powerSignalBreadcrumbsInstalled = true - power.on('suspend', () => { - lastPowerSuspendAtMs = Date.now() - mainDeliveryBreadcrumbs.record('power-suspend') - }) - power.on('resume', () => { - lastPowerResumeAtMs = Date.now() - mainDeliveryBreadcrumbs.record('power-resume') - }) -} - -const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapshot = { - pendingPtyCount: 0, - pendingChars: 0, - maxPendingCharsByPty: 0, - rendererInFlightPtyCount: 0, - rendererInFlightChars: 0, - maxRendererInFlightCharsByPty: 0, - activeRendererPtyCount: 0, - flushScheduled: false, - peakPendingChars: 0, - peakMaxPendingCharsByPty: 0, - peakRendererInFlightChars: 0, - peakMaxRendererInFlightCharsByPty: 0, - ackGatedFlushSkipCount: 0, - hiddenDeliveryGatedPtyCount: 0, - hiddenDeliveryGatedVisiblePtyCount: 0, - hiddenDeliveryGatedActivePtyCount: 0, - deliveryInterestPtyCount: 0, - hiddenDeliveryDroppedChars: 0, - hiddenDeliveryDroppedChunks: 0, - pendingDroppedChars: 0, - diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, - rendererLifecycleResetCount: 0, - lastLifecycleResetClearedChars: 0, - rendererPtyDispatcherReady: false, - rendererDispatcherReadyForcedCount: 0 -} - -let readPtyRendererDeliveryDebugSnapshot = (): PtyRendererDeliveryDebugSnapshot => ({ - ...EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT -}) -let resetPtyRendererDeliveryDebugSnapshot = (): void => {} -// Bridged into the registerPtyHandlers closure so the module-scope lifecycle-reset handler can zero closure-owned delivery accounting on renderer reload/crash. -let resetRendererDeliveryAccountingForLifecycleReset = (): void => {} -// Bridged so a re-registration can cancel the prior closure's dispatcher-ready watchdog before wiring its own. -let clearRendererDispatcherReadyWatchdog = (): void => {} - -export function getPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { - return readPtyRendererDeliveryDebugSnapshot() -} - -export function resetPtyRendererDeliveryDebug(): void { - resetPtyRendererDeliveryDebugSnapshot() -} - -function clearDidFinishLoadHandler(): void { - if (didFinishLoadHandler && didFinishLoadWebContents) { - didFinishLoadWebContents.removeListener('did-finish-load', didFinishLoadHandler) - } - didFinishLoadHandler = null - didFinishLoadWebContents = null -} - -function markRendererPtysHiddenForRendererLifecycleReset(): void { - // A reload/crash in the breadcrumb history is load-bearing context for any freeze report. - mainDeliveryBreadcrumbs.record('renderer-lifecycle-reset') - // Why: renderer-owned hints die with the page; clear visibility so surviving daemon/SSH PTYs fail closed until the new renderer reports. - const activePriorityChanged = activeRendererPtys.size > 0 - activeRendererPtys.clear() - visibleRendererPtys.clear() - // Why: the dead page never ACKs its in-flight bytes, so leaked accounting would delivery-gate surviving PTYs forever after a reload/crash. - resetRendererDeliveryAccountingForLifecycleReset() - if (activePriorityChanged) { - invalidatePendingPtyDrainPriority() - } -} - -function clearRendererLifecycleResetHandlers(): void { - if (!rendererLifecycleResetWebContents) { - return - } - if (rendererDidStartNavigationHandler) { - rendererLifecycleResetWebContents.removeListener( - 'did-start-navigation', - rendererDidStartNavigationHandler - ) - } - if (rendererLifecycleResetHandler) { - rendererLifecycleResetWebContents.removeListener( - 'render-process-gone', - rendererLifecycleResetHandler - ) - rendererLifecycleResetWebContents.removeListener('destroyed', rendererLifecycleResetHandler) - } - rendererLifecycleResetWebContents = null - rendererLifecycleResetHandler = null - rendererDidStartNavigationHandler = null -} - -function registerRendererLifecycleResetHandlers(webContents: WebContents): void { - clearRendererLifecycleResetHandlers() - markRendererPtysHiddenForRendererLifecycleReset() - rendererLifecycleResetWebContents = webContents - rendererLifecycleResetHandler = markRendererPtysHiddenForRendererLifecycleReset - rendererDidStartNavigationHandler = (details) => { - if (!details.isMainFrame || details.isSameDocument) { - return - } - markRendererPtysHiddenForRendererLifecycleReset() - } - webContents.on('did-start-navigation', rendererDidStartNavigationHandler) - webContents.on('render-process-gone', rendererLifecycleResetHandler) - webContents.on('destroyed', rendererLifecycleResetHandler) -} - -function clearRendererGateResetHandlers(): void { - if (rendererGateResetWebContents) { - if (rendererGateResetLoadHandler) { - rendererGateResetWebContents.removeListener('did-finish-load', rendererGateResetLoadHandler) - } - if (rendererGateResetGoneHandler) { - rendererGateResetWebContents.removeListener( - 'render-process-gone', - rendererGateResetGoneHandler - ) - } - } - rendererGateResetLoadHandler = null - rendererGateResetGoneHandler = null - rendererGateResetWebContents = null -} - -// Why: Restart daemon must detach listeners AFTER synthetic pty:exit events fan out but BEFORE replaceDaemonProvider swaps the adapter; this export narrows that window to the caller. -export function unbindLocalProviderListeners(): void { - localDataUnsub?.() - localExitUnsub?.() - localBackgroundStreamUnsub?.() - localWriteUnavailableUnsub?.() - localDataUnsub = null - localExitUnsub = null - localBackgroundStreamUnsub = null - localWriteUnavailableUnsub = null -} - -// ─── IPC Registration ─────────────────────────────────────────────── - -export function registerPtyHandlers( - mainWindow: BrowserWindow | null, - runtime?: OrcaRuntimeService, - getSelectedCodexHomePath?: GetSelectedCodexHomePath, - getSettings?: () => GlobalSettings, - prepareClaudeAuth?: PrepareClaudeAuth, - store?: Store, - options?: { - prepareCodexSessionResume?: PrepareCodexSessionResume - awaitLocalPtyStartup?: () => Promise - awaitLocalPtyProviderStartup?: () => Promise - // Why: returns true once for the crash-recovery reload so its did-finish-load skips the orphan sweep and keeps live PTYs (#5787). - isRecoveryReloadInFlight?: (webContentsId: number) => boolean - onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void - onPtyExit?: (id: string, exitSequence: number) => void - } -): void { - const ipc = getPtyIpc() - // Why: a re-registration means a new window owns delivery — cancel the prior closure's watchdog and neutralize its bridged reset so mark-hidden below can't arm a timer against the dead closure. - clearRendererDispatcherReadyWatchdog() - resetRendererDeliveryAccountingForLifecycleReset = () => {} - invalidatePendingPtyDrainPriority = () => {} - invalidatePendingPtyDrainPolicy = () => {} - const rendererContents = rendererWebContents(mainWindow) - if (rendererContents) { - registerRendererLifecycleResetHandlers(rendererContents) - } - - const getLocalPtyStartupPromise = (connectionId?: string | null): Promise | undefined => { - if (connectionId) { - return undefined - } - // Why: during cold start the daemon provider swap overlaps first paint, so local spawns must wait; SSH/headless don't use the desktop daemon. - return options?.awaitLocalPtyStartup?.() - } - - const getLocalPtyProviderStartupPromise = ( - connectionId?: string | null - ): Promise | undefined => { - if (connectionId) { - return undefined - } - return options?.awaitLocalPtyProviderStartup?.() ?? options?.awaitLocalPtyStartup?.() - } - - // Remove prior handlers so re-registration (e.g. macOS re-activate creating a new window) doesn't double-register. - ipc.removeHandler('pty:spawn') - ipc.removeHandler('pty:kill') - ipc.removeHandler('pty:listSessions') - ipc.removeHandler('pty:hasPty') - ipc.removeHandler('pty:hasChildProcesses') - ipc.removeHandler('pty:getForegroundProcess') - ipc.removeHandler('pty:inspectProcess') - ipc.removeHandler('pty:confirmForegroundProcess') - ipc.removeHandler('pty:getCwd') - ipc.removeHandler('pty:getSize') - ipc.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities') - ipc.removeHandler('pty:declarePendingPaneSerializer') - ipc.removeHandler('pty:settlePaneSerializer') - ipc.removeHandler('pty:clearPendingPaneSerializer') - ipc.removeHandler('pty:reportRendererSerializerReady') - ipc.removeHandler('pty:getMainBufferSnapshot') - ipc.removeHandler('pty:sideEffectSnapshot') - ipc.removeHandler('pty:getRendererDeliveryDebugSnapshot') - ipc.removeHandler('pty:resetRendererDeliveryDebug') - ipc.removeHandler('pty:reportRendererDeliveryState') - ipc.removeHandler('pty:writeAccepted') - ipc.removeAllListeners('pty:write') - ipc.removeAllListeners('pty:ackColdRestore') - ipc.removeAllListeners('pty:ackData') - ipc.removeAllListeners('pty:deliveryResyncResponse') - ipc.removeAllListeners('pty:serializeBuffer:response') - - // Why: only LocalPtyProvider needs main-process hook injection; daemon-backed providers spawn subprocesses internally. - if (localProvider instanceof LocalPtyProvider) { - localProvider.configure({ - isHistoryEnabled: () => getSettings?.()?.terminalScopeHistoryByWorktree ?? true, - getWindowsShell: () => getSettings?.()?.terminalWindowsShell, - getWindowsPowerShellImplementation: () => - getSettings - ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') - : undefined, - pwshAvailable: () => isPwshAvailableAsync(), - buildSpawnEnv: (id, baseEnv, ctx) => { - const codexSelectionTarget: CodexAccountSelectionTarget = - ctx?.isWsl === true - ? { runtime: 'wsl', wslDistro: ctx.wslDistro ?? null } - : { runtime: 'host' } - const selectedCodexHomePath = getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - ctx?.codexHomePathOverride - ? ctx.codexHomePathOverride.value - : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: ctx?.cwd, - launchAgent: ctx?.launchAgent - }) ?? null) - ) - const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath - const ptySettings = getSettings?.() - const env = buildPtyHostEnv(id, baseEnv, { - isPackaged: getAppEnvironment().isPackaged(), - resourcesPath: process.resourcesPath, - userDataPath: getAppEnvironment().getPath('userData'), - selectedCodexHomePath, - skipCodexHomeEnv, - stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({ - target: codexSelectionTarget, - selectedCodexHomePath, - skipCodexHomeEnv, - settings: ptySettings - }), - launchCommand: ctx?.command, - launchAgent: ctx?.launchAgent, - isWsl: ctx?.isWsl, - wslDistro: ctx?.wslDistro ?? null, - agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), - codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), - networkProxySettings: ptySettings - }) - // Why: agents need their terminal handle at process start to self-identify in orchestration messages without an extra RPC. - const requestedHandle = baseEnv.ORCA_TERMINAL_HANDLE - const preAllocatedHandle = - requestedHandle && trustedTerminalHandleEnv.has(requestedHandle) - ? requestedHandle - : runtime?.preAllocateHandleForPty(id) - if (requestedHandle && requestedHandle !== preAllocatedHandle) { - delete env.ORCA_TERMINAL_HANDLE - } - if (preAllocatedHandle) { - env.ORCA_TERMINAL_HANDLE = preAllocatedHandle - } - stampWslOrchestrationCompatibilityHost( - env, - runtime?.getOrchestrationCompatibilityHostId?.(), - ctx?.isWsl === true ? ctx.wslDistro : null - ) - if (ctx?.isWsl === true) { - addOrcaWslInteropEnv(env) - } - return env - }, - onSpawned: (id, incarnationId) => runtime?.onPtySpawned(id, incarnationId), - onExit: (id, code, incarnationId, cause) => { - if (!isCurrentPtyExit({ id, incarnationId })) { - return - } - clearProviderPtyState(id) - ptyOwnership.delete(id) - markClaudePtyExited(id) - // Why: the provider's physical exit callback is a death certificate even when node-pty - // reports the code as -1; only the synthesized failed/unverified stop calls omit this. - runtime?.onPtyExit(id, code, incarnationId, { - providerExitObserved: true, - ...(cause ? { cause } : {}) - }) - }, - onData: (id, data, timestamp, sequenceChars, transformed) => - runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed) - }) - } - - type PtyDataPayload = { - id: string - data: string - seq?: number - rawLength?: number - transformed?: boolean - background?: boolean - droppedOutput?: boolean - } - - // Why: bounded batch windows amortize renderer IPC; keystroke echo/redraws bypass them below. - const pendingData = new PtyPendingDataDrainQueue( - (id) => { - const runnableLane = activeRendererPtys.has(id) ? 'active' : 'background' - // Why first: hidden bytes are dropped from main's pending queue even when renderer credit is exhausted. - if (shouldDropHiddenRendererPtyData(id, getSettings?.())) { - return runnableLane - } - if ( - !rendererPtyDispatcherReady || - !canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) }) - ) { - return 'blocked' - } - return runnableLane - }, - () => isHiddenPtyDeliveryGateEnabled(getSettings?.()) - ) - let sshOutputIntake: SshPtyOutputIntake | null = null - // Why: resuming a paused producer during exit can synchronously emit; those bytes must not queue behind pty:exit. - const rendererExitingPtyIds = new Set() - const rendererCreditBeforeExitByPty = new Map() - const rendererDeliveryRestoreNeededPtys = new Set() - - function transitionHiddenRendererPtyDeliveryState(id: string, hidden: boolean) { - const settings = getSettings?.() - const wasDroppable = shouldDropHiddenRendererPtyData(id, settings) - let droppedWhileHidden = false - if (hidden) { - markHiddenRendererPty(id) - } else { - droppedWhileHidden = unmarkHiddenRendererPty(id).droppedWhileHidden - } - const droppable = shouldDropHiddenRendererPtyData(id, settings) - return { droppable, droppedWhileHidden, policyChanged: wasDroppable !== droppable } - } - - function transitionSpawnHiddenRendererPtyDeliveryState(id: string, hidden: boolean): void { - const transition = transitionHiddenRendererPtyDeliveryState(id, hidden) - if (transition.policyChanged) { - invalidatePendingPtyDrainPolicy(id) - } - } - // Why: one restore marker per overflow episode — cleared on full drain so a later overflow re-marks exactly once. - const pendingOverflowMarkedPtys = new Set() - // Why: TCP-style cumulative accounting — monotonic sent/acked totals self-heal on any later ACK, where relative in-flight counters would make each lost ACK a permanent debt. - type RendererPtyDeliveryAccounting = { - sentChars: number - ackedChars: number - lastSendAtMs: number - lastAckAtMs: number | null - } - const rendererDeliveryAccountingByPty = new Map() - const trustedTerminalHandleEnv = new Set() - let flushTimer: ReturnType | null = null - let pendingDataFlushActive = false - let pendingDataCreditReleasedDuringFlush = false - let rendererInFlightTotalChars = 0 - let pendingDroppedChars = 0 - let deliveryResyncRequestSerial = 0 - let deliveryResyncOutstandingRequestId: number | null = null - let deliveryResyncTimer: ReturnType | null = null - let deliveryResyncUnansweredWarnLogged = false - let lastAckReceivedAtMs: number | null = null - // Why 2ms: pairs with the daemon stream batcher (daemon-stream-data-batcher.ts); keeps flood coalescing at negligible IPC overhead while cutting the pipeline's latency tax. - const PTY_BATCH_INTERVAL_MS = 2 - const PTY_BATCH_DRAIN_CONTINUE_MS = 1 - const PTY_BATCH_FLUSH_CHUNK_CHARS = 16 * 1024 - const PTY_BATCH_FLUSH_MAX_WRITES = 2 - const PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS = 512 * 1024 - const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 8 * 1024 * 1024 - // Why: cap unbounded pendingData growth when the renderer can't receive (frozen/reloading); beyond it bytes drop and the pane heals from the main buffer snapshot via the droppedOutput sentinel (#7630). - // Why read settings live: the cap scales with the user's scrollback so power users don't lose lines their scrollback would have retained. - const pendingDataCapChars = (): number => - terminalOutputBacklogCapChars(getSettings?.().terminalScrollbackRows) - // Why: self-heal bound — if a reloaded page never sends pty:rendererDispatcherReady, force sends on after this window so a lost handshake can't become a permanent hold. - const PTY_DISPATCHER_READY_WATCHDOG_MS = 10_000 - const PTY_RENDERER_INTERACTIVE_RESERVE_CHARS = 256 * 1024 - // Why: reserve a bounded lane so an active pane's keystroke redraw reaches the renderer ahead of hidden bulk output's ACKs. - const PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS = 512 * 1024 - // Why: request/response hygiene only (never mutates delivery state) — clears the outstanding-probe flag so a later arrival can re-probe, logs once per silent streak. - const PTY_DELIVERY_RESYNC_TIMEOUT_MS = 5_000 - // Why: a heal write-off destroys delivery accounting; require this much main-side ACK silence (independent of renderer evidence) before declaring the channel dead. - const PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS = 10_000 - // Why: keep the immediate path to keystroke-sized TUI redraws; large/non-interactive output must use the batcher. - const INTERACTIVE_OUTPUT_WINDOW_MS = 100 - const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 - const INTERACTIVE_REDRAW_MAX_CHARS = PTY_BATCH_FLUSH_CHUNK_CHARS - const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 - let peakPendingChars = 0 - let peakMaxPendingCharsByPty = 0 - let peakRendererInFlightChars = 0 - let peakMaxRendererInFlightCharsByPty = 0 - let ackGatedFlushSkipCount = 0 - let rendererLifecycleResetCount = 0 - let lastLifecycleResetClearedChars = 0 - // Why: count of watchdog gate force-opens (no handshake arrived); nonzero flags a dropped-handshake self-heal. - let rendererDispatcherReadyForcedCount = 0 - // Why: gate sends until the page's pty:data listener exists; else webContents.send drops bytes but still counts them in-flight, permanently pinning the gate. - let rendererPtyDispatcherReady = false - let dispatcherReadyWatchdogTimer: ReturnType | null = null - - // Why: watermark producer pause/resume keyed on per-PTY pendingData (in-flight is already ACK-bounded); providers without pauseProducer no-op, memory still bounded by the pending cap. - const producerFlowControl = new PtyProducerFlowController({ - pauseProducer: (id) => tryGetProviderForPty(id)?.pauseProducer?.(id), - resumeProducer: (id) => tryGetProviderForPty(id)?.resumeProducer?.(id) - }) - const sourceCreditPendingPtys = new Set() - - function updateProducerFlowControl(id: string): void { - if (!PRODUCER_FLOW_CONTROL_ENABLED) { - return - } - if (sourceCreditPendingPtys.has(id)) { - if (pendingData.get(id)) { - return - } - sourceCreditPendingPtys.delete(id) - } - producerFlowControl.update(id, pendingData.get(id)?.data.length ?? 0) - } - - // Why: hidden ptys are exempt from pendingData flow control, so background agents can run 100MB+ ahead in the daemon stream buffer; this sync tells the provider transport which ptys to keep-tail thin. - // Why keyed on the visibility registry (not gate marks): thinning asks "does any visible view show this PTY?"; remote-view subscribers consume raw bytes, so their presence vetoes thinning. - const backgroundedDeliverySyncByPty = new Map() - function syncPtyBackgroundedDelivery(id: string, caller: string): void { - const background = - rendererPtyIsKnownHidden(id) && !(runtime?.hasRawTerminalViewSubscriber?.(id) ?? false) - if (backgroundedDeliverySyncByPty.get(id) === background) { - return - } - const provider = tryGetProviderForPty(id) - if (!provider?.setPtyBackgrounded) { - return - } - recordDaemonStreamBacklogEvent('mainBackgroundSync', { - sessionIdSuffix: id.slice(-10), - background, - caller, - known: rendererVisibilityKnownPtys.has(id), - visible: visibleRendererPtys.has(id) - }) - backgroundedDeliverySyncByPty.set(id, background) - provider.setPtyBackgrounded(id, background) - } - clearBackgroundedDeliverySyncForPty = (id: string) => { - backgroundedDeliverySyncByPty.delete(id) - } - if (runtime) { - runtime.onRemoteTerminalViewPresenceChanged = (id) => - syncPtyBackgroundedDelivery(id, 'remote-view') - } - function resyncBackgroundedDeliveriesAfterGateReset(): void { - for (const id of backgroundedDeliverySyncByPty.keys()) { - syncPtyBackgroundedDelivery(id, 'gate-reset') - } - } - - function getRendererInFlightCharsForPty(id: string): number { - const accounting = rendererDeliveryAccountingByPty.get(id) - return accounting ? accounting.sentChars - accounting.ackedChars : 0 - } - - // Why touched PTY only: pressure peaks are monotonic between explicit resets. - function recordPtyRendererDeliveryPressure(id: string): void { - peakPendingChars = Math.max(peakPendingChars, pendingData.totalPendingChars) - peakMaxPendingCharsByPty = Math.max( - peakMaxPendingCharsByPty, - pendingData.get(id)?.data.length ?? 0 - ) - peakRendererInFlightChars = Math.max(peakRendererInFlightChars, rendererInFlightTotalChars) - peakMaxRendererInFlightCharsByPty = Math.max( - peakMaxRendererInFlightCharsByPty, - getRendererInFlightCharsForPty(id) - ) - } - - function setPendingPtyData(id: string, pending: PendingPtyData): void { - pendingData.set(id, pending) - recordPtyRendererDeliveryPressure(id) - } - - function deletePendingPtyData(id: string): void { - pendingData.delete(id) - } - - function clearPendingPtyData(): void { - for (const pending of pendingData.values()) { - if (pending.projectionAdmissionIds) { - sshOutputIntake?.transferProjections( - pending.projectionAdmissionIds, - 'renderer-lifecycle-reset' - ) - } - } - pendingData.clear() - sourceCreditPendingPtys.clear() - } - - function readCurrentPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { - let pendingChars = 0 - let maxPendingCharsByPty = 0 - for (const pending of pendingData.values()) { - const chars = pending.data.length - pendingChars += chars - maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) - } - const hiddenDeliveryDebug = getHiddenRendererPtyDeliveryDebug() - let rendererInFlightPtyCount = 0 - let maxRendererInFlightCharsByPty = 0 - for (const accounting of rendererDeliveryAccountingByPty.values()) { - const inFlight = accounting.sentChars - accounting.ackedChars - if (inFlight > 0) { - rendererInFlightPtyCount++ - } - maxRendererInFlightCharsByPty = Math.max(maxRendererInFlightCharsByPty, inFlight) - } - // Why: a pty both hidden-gated and reported visible means main is starving a visible pane (v1.4.124-rc.2.perf field lead). - let hiddenDeliveryGatedVisiblePtyCount = 0 - for (const id of visibleRendererPtys) { - if (isHiddenRendererPty(id)) { - hiddenDeliveryGatedVisiblePtyCount++ - } - } - let hiddenDeliveryGatedActivePtyCount = 0 - for (const id of activeRendererPtys) { - if (isHiddenRendererPty(id)) { - hiddenDeliveryGatedActivePtyCount++ - } - } - return { - pendingPtyCount: pendingData.size, - pendingChars, - maxPendingCharsByPty, - rendererInFlightPtyCount, - rendererInFlightChars: rendererInFlightTotalChars, - maxRendererInFlightCharsByPty, - activeRendererPtyCount: activeRendererPtys.size, - flushScheduled: flushTimer !== null, - peakPendingChars, - peakMaxPendingCharsByPty, - peakRendererInFlightChars, - peakMaxRendererInFlightCharsByPty, - ackGatedFlushSkipCount, - ...hiddenDeliveryDebug, - hiddenDeliveryGatedVisiblePtyCount, - hiddenDeliveryGatedActivePtyCount, - pendingDroppedChars, - diagnostics: buildMainDeliveryDiagnostics(), - rendererLifecycleResetCount, - lastLifecycleResetClearedChars, - rendererPtyDispatcherReady, - rendererDispatcherReadyForcedCount - } - } - - const DELIVERY_DIAGNOSTICS_MAX_PTYS = 30 - - // Built only when the debug snapshot is read (never on the data path): the per-pty table + breadcrumb history says WHICH pty is wedged and WHEN, unlike aggregate counters. - function buildMainDeliveryDiagnostics(): PtyMainDeliveryDiagnostics { - const now = Date.now() - // Include hidden/visible/active members even without an accounting entry: a pty gated before its first byte is exactly the wedge case to surface. - const ids = new Set([ - ...rendererDeliveryAccountingByPty.keys(), - ...pendingData.keys(), - ...getHiddenRendererPtyIds(), - ...visibleRendererPtys, - ...activeRendererPtys - ]) - const perPty: PtyPerPtyDeliveryDiagnostics[] = [] - for (const id of ids) { - const accounting = rendererDeliveryAccountingByPty.get(id) - perPty.push({ - id: redactPtyIdForDiagnostics(id), - sentChars: accounting?.sentChars ?? 0, - ackedChars: accounting?.ackedChars ?? 0, - inFlightChars: accounting ? accounting.sentChars - accounting.ackedChars : 0, - pendingChars: pendingData.get(id)?.data.length ?? 0, - hidden: isHiddenRendererPty(id), - visible: visibleRendererPtys.has(id), - active: activeRendererPtys.has(id), - msSinceLastSend: accounting ? now - accounting.lastSendAtMs : null, - msSinceLastAck: accounting?.lastAckAtMs == null ? null : now - accounting.lastAckAtMs - }) - } - perPty.sort((a, b) => b.inFlightChars + b.pendingChars - (a.inFlightChars + a.pendingChars)) - const windowAlive = !isRendererGone(mainWindow) - return { - appVersion: getAppEnvironment().getVersion(), - mainUptimeMs: Math.round(process.uptime() * 1000), - windowFocused: windowAlive ? mainWindow!.isFocused() : null, - windowVisible: windowAlive ? mainWindow!.isVisible() : null, - windowMinimized: windowAlive ? mainWindow!.isMinimized() : null, - msSinceLastPowerSuspend: lastPowerSuspendAtMs === null ? null : now - lastPowerSuspendAtMs, - msSinceLastPowerResume: lastPowerResumeAtMs === null ? null : now - lastPowerResumeAtMs, - perPty: perPty.slice(0, DELIVERY_DIAGNOSTICS_MAX_PTYS), - breadcrumbs: mainDeliveryBreadcrumbs.snapshot() - } - } - - // Why rate-limited: the contradiction persists chunk after chunk while latched; one line per minute keeps field logs readable but present. - let lastHiddenDropContradictionWarnAtMs = 0 - function warnIfDroppingHiddenBytesForVisiblePty(id: string, droppedChars: number): void { - if (!visibleRendererPtys.has(id) && !activeRendererPtys.has(id)) { - return - } - // Recorded before the warn rate limit: the ring coalesces repeats, and the contradiction must appear in the freeze report either way. - mainDeliveryBreadcrumbs.record('hidden-drop-visible', { - id: redactPtyIdForDiagnostics(id), - droppedChars - }) - const now = Date.now() - if (now - lastHiddenDropContradictionWarnAtMs < 60_000) { - return - } - lastHiddenDropContradictionWarnAtMs = now - console.warn('[pty] hidden-delivery gate is dropping bytes for a visible/active pty', { - id, - droppedChars, - visible: visibleRendererPtys.has(id), - active: activeRendererPtys.has(id), - ...readCurrentPtyRendererDeliveryDebugSnapshot() - }) - } - - function seedPtyRendererDeliveryPeaksFromCurrentState(): void { - let pendingChars = 0 - let maxPendingCharsByPty = 0 - for (const pending of pendingData.values()) { - const chars = pending.data.length - pendingChars += chars - maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) - } - peakPendingChars = pendingChars - peakMaxPendingCharsByPty = maxPendingCharsByPty - peakRendererInFlightChars = rendererInFlightTotalChars - let maxRendererInFlightCharsByPty = 0 - for (const accounting of rendererDeliveryAccountingByPty.values()) { - maxRendererInFlightCharsByPty = Math.max( - maxRendererInFlightCharsByPty, - accounting.sentChars - accounting.ackedChars - ) - } - peakMaxRendererInFlightCharsByPty = maxRendererInFlightCharsByPty - } - - readPtyRendererDeliveryDebugSnapshot = readCurrentPtyRendererDeliveryDebugSnapshot - resetPtyRendererDeliveryDebugSnapshot = () => { - peakPendingChars = 0 - peakMaxPendingCharsByPty = 0 - peakRendererInFlightChars = 0 - peakMaxRendererInFlightCharsByPty = 0 - ackGatedFlushSkipCount = 0 - pendingDroppedChars = 0 - resetHiddenRendererPtyDeliveryDebugCounters() - seedPtyRendererDeliveryPeaksFromCurrentState() - } - resetRendererDeliveryAccountingForLifecycleReset = () => { - // Why lossless: pendingData bytes were bound for the dead page; the replacement repaints from main's authoritative sources, which superset it. - lastLifecycleResetClearedChars = rendererInFlightTotalChars - rendererLifecycleResetCount += 1 - // Why release before clearing: pending bytes and credits belonged to the dead page; releasing producer pauses first keeps no shell wedged. - producerFlowControl.releaseAll() - clearDeliveryResyncProbe() - deliveryResyncUnansweredWarnLogged = false - for (const id of rendererDeliveryAccountingByPty.keys()) { - sshOutputIntake?.transferPtyProjections(id, 'renderer-lifecycle-reset') - } - rendererDeliveryAccountingByPty.clear() - rendererInFlightTotalChars = 0 - clearPendingPtyData() - pendingOverflowMarkedPtys.clear() - rendererDeliveryRestoreNeededPtys.clear() - // Why hold sends: the reloading page's pty:data listener is gone until it re-registers/handshakes, so bytes would drop into a listener-less page and re-pin the gate. - rendererPtyDispatcherReady = false - // Why: arm the self-heal watchdog so a never-arriving handshake can't hold the gate forever; the real handshake cancels it. - armDispatcherReadyWatchdog() - } - // Why the bridge: let a later re-registration cancel this closure's watchdog (armed via a hoisted fn, so this assignment can precede its definition). - clearRendererDispatcherReadyWatchdog = clearDispatcherReadyWatchdog - - function isLikelyInteractiveRedraw(data: string): boolean { - if (data.length <= INTERACTIVE_OUTPUT_MAX_CHARS) { - return true - } - // Why the ANSI check: Codex-style TUIs repaint >1 KB per keypress (latency-sensitive), while plain command output should stay on the throughput batch path. - return data.length <= INTERACTIVE_REDRAW_MAX_CHARS && data.includes('\x1b[') - } - - function shouldSendInteractiveOutputNow(id: string, data: string, now: number): boolean { - const lastInputAt = lastInputAtByPty.get(id) - if (lastInputAt === undefined || now - lastInputAt > INTERACTIVE_OUTPUT_WINDOW_MS) { - interactiveOutputCharsByPty.delete(id) - return false - } - if (!isLikelyInteractiveRedraw(data)) { - interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) - return false - } - const usedChars = interactiveOutputCharsByPty.get(id) ?? 0 - if (usedChars + data.length > INTERACTIVE_OUTPUT_BUDGET_CHARS) { - interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) - return false - } - interactiveOutputCharsByPty.set(id, usedChars + data.length) - return true - } - - function makePtyDataPayload( - id: string, - data: string, - startSeq: number | undefined, - containsBackgroundOutput: boolean | undefined, - rawLength = data.length, - transformed = false - ): PtyDataPayload { - const payload: PtyDataPayload = { id, data } - if (typeof startSeq === 'number') { - payload.seq = startSeq + rawLength - } - if (typeof startSeq === 'number' || rawLength !== data.length || transformed) { - payload.rawLength = rawLength - } - if (transformed) { - payload.transformed = true - } - if (containsBackgroundOutput === true) { - payload.background = true - } - return payload - } - - function getPtyPayloadCharCount(payload: { data: string; rawLength?: number }): number { - return Math.max(0, payload.rawLength ?? payload.data.length) - } - - function canSendPtyDataToRenderer(id: string, options: { interactive?: boolean } = {}): boolean { - const totalLimit = - PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS + - (options.interactive === true ? PTY_RENDERER_INTERACTIVE_RESERVE_CHARS : 0) - // Why per-PTY (not global) reserve: keep one active pane responsive without letting every background pane burst past the cap. - const ptyLimit = - PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS + - (options.interactive === true ? PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS : 0) - return getRendererInFlightCharsForPty(id) < ptyLimit && rendererInFlightTotalChars < totalLimit - } - - // Why max-merge cumulative totals: idempotent and reorder-tolerant — replayed/out-of-order ACKs can't double-credit and a lost ACK self-heals. Returns the newly acknowledged delta. - function applyCumulativeAck(id: string, processedChars: number): number { - const accounting = rendererDeliveryAccountingByPty.get(id) - if (!accounting) { - return 0 - } - // Clamped to sentChars so a corrupt payload cannot drive in-flight negative. - const nextAckedChars = Math.min( - accounting.sentChars, - Math.max(accounting.ackedChars, processedChars) - ) - const acknowledged = nextAckedChars - accounting.ackedChars - accounting.ackedChars = nextAckedChars - if (acknowledged > 0) { - accounting.lastAckAtMs = Date.now() - } - rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged) - if (acknowledged > 0) { - sshOutputIntake?.settleProjectionPrefix(id, acknowledged) - } - return acknowledged - } - - function schedulePendingDataAfterCreditReport(creditedAny: boolean): void { - if (creditedAny) { - pendingData.reactivateBlocked() - } - if (pendingData.size > 0 && !flushTimer) { - schedulePendingDataFlush(0) - } - } - - function clearDeliveryResyncProbe(): void { - deliveryResyncOutstandingRequestId = null - if (deliveryResyncTimer) { - clearTimeout(deliveryResyncTimer) - deliveryResyncTimer = null - } - } - - // Why: data for a fully gated PTY signals delivery may be stuck on lost ACKs (e.g. dropped across suspend); ask the renderer for authoritative totals instead of a wall-clock guess. - function requestDeliveryResyncForGatedPty(): void { - if (deliveryResyncOutstandingRequestId !== null || isRendererGone(mainWindow)) { - return - } - deliveryResyncRequestSerial += 1 - const requestId = deliveryResyncRequestSerial - deliveryResyncOutstandingRequestId = requestId - deliveryResyncTimer = setTimeout(() => { - if (deliveryResyncOutstandingRequestId !== requestId) { - return - } - clearDeliveryResyncProbe() - // Why no mutation on timeout: unanswered means dead IPC that only a reload cures; log once per silent streak to avoid spamming every probe. - if (deliveryResyncUnansweredWarnLogged) { - return - } - deliveryResyncUnansweredWarnLogged = true - console.warn('[pty] delivery resync probe unanswered — renderer IPC unresponsive', { - msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, - ...readCurrentPtyRendererDeliveryDebugSnapshot() - }) - }, PTY_DELIVERY_RESYNC_TIMEOUT_MS) - deliveryResyncTimer.unref?.() - sendToRenderer(mainWindow, 'pty:requestDeliveryResync', { requestId }) - } - - // Why write off: bytes sent but never received after a confirmed wedge are gone (no ACK can repay them); hand back restore markers so panes repaint from the snapshot. - function writeOffLostRendererDelivery( - report: PtyRendererDeliveryStateReport - ): PtyDeliveryWriteOff[] { - const writtenOff: PtyDeliveryWriteOff[] = [] - for (const [id, accounting] of rendererDeliveryAccountingByPty) { - if (accounting.sentChars - accounting.ackedChars <= 0) { - continue - } - const received = report.receivedCharsByPty?.[id] - const receivedChars = - typeof received === 'number' && Number.isFinite(received) ? Math.max(0, received) : 0 - // Why skip: received-but-unparsed bytes are alive in the renderer write queue; their deferred ACK still repays this debt. - if (receivedChars > accounting.ackedChars) { - continue - } - const acknowledged = applyCumulativeAck(id, accounting.sentChars) - if (acknowledged <= 0) { - continue - } - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) - // Why drop pending: everything at/before markerSeq comes from the snapshot, so flushing pre-marker bytes would double-paint the restore. - const pending = pendingData.get(id) - if (pending) { - if (pending.projectionAdmissionIds) { - sshOutputIntake?.transferProjections( - pending.projectionAdmissionIds, - 'renderer-delivery-writeoff' - ) - } - pendingDroppedChars += pending.data.length - deletePendingPtyData(id) - pendingOverflowMarkedPtys.delete(id) - updateProducerFlowControl(id) - } - const markerSeq = runtime?.getPtyOutputSequence(id) - writtenOff.push({ - id, - ...(typeof markerSeq === 'number' ? { markerSeq } : {}), - writtenOffChars: acknowledged - }) - } - if (writtenOff.length > 0) { - clearDeliveryResyncProbe() - deliveryResyncUnansweredWarnLogged = false - mainDeliveryBreadcrumbs.record('delivery-heal-writeoff', { - writtenOffPtyCount: writtenOff.length, - writtenOffChars: writtenOff.reduce((sum, { writtenOffChars }) => sum + writtenOffChars, 0) - }) - console.warn('[pty] delivery heal: wrote off renderer-bound bytes lost in push channel', { - rendererPtyDataListenerCount: report.rendererPtyDataListenerCount ?? null, - msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, - writtenOffByPty: writtenOff.map(({ id, writtenOffChars }) => ({ id, writtenOffChars })), - ...readCurrentPtyRendererDeliveryDebugSnapshot() - }) - } - return writtenOff - } - - function sendPtyDataToRenderer( - id: string, - payload: PtyDataPayload, - projectionAdmissionIds?: readonly string[] - ): { sent: boolean; projectionsTransferred: boolean } { - const charCount = getPtyPayloadCharCount(payload) - const accounting = rendererDeliveryAccountingByPty.get(id) - const hadAccounting = accounting !== undefined - if (accounting) { - accounting.sentChars += charCount - accounting.lastSendAtMs = Date.now() - } else { - rendererDeliveryAccountingByPty.set(id, { - sentChars: charCount, - ackedChars: 0, - lastSendAtMs: Date.now(), - lastAckAtMs: null - }) - } - rendererInFlightTotalChars += charCount - recordPtyRendererDeliveryPressure(id) - try { - sendToRenderer(mainWindow, 'pty:data', payload) - } catch (error) { - const current = rendererDeliveryAccountingByPty.get(id) - if (current) { - const inFlightBeforeRollback = current.sentChars - current.ackedChars - current.sentChars = Math.max(0, current.sentChars - charCount) - current.ackedChars = Math.min(current.ackedChars, current.sentChars) - const inFlightAfterRollback = current.sentChars - current.ackedChars - rendererInFlightTotalChars = Math.max( - 0, - rendererInFlightTotalChars - (inFlightBeforeRollback - inFlightAfterRollback) - ) - if (!hadAccounting && current.sentChars === 0) { - rendererDeliveryAccountingByPty.delete(id) - } - } - rendererDeliveryRestoreNeededPtys.add(id) - if (projectionAdmissionIds) { - sshOutputIntake?.transferProjections(projectionAdmissionIds, 'renderer-send-failed') - } - mainDeliveryBreadcrumbs.record('pty-data-send-failed', { - id: redactPtyIdForDiagnostics(id), - chars: charCount - }) - console.error('[pty] renderer data send failed; payload will not be retried', error) - return { sent: false, projectionsTransferred: projectionAdmissionIds !== undefined } - } - let projectionsTransferred = false - if (projectionAdmissionIds) { - try { - sshOutputIntake?.publishProjectionPrefix( - projectionAdmissionIds, - payload.data.length, - charCount - ) - } catch { - sshOutputIntake?.transferProjections(projectionAdmissionIds, 'projection-publish-failed') - projectionsTransferred = true - } - } - if (rendererDeliveryRestoreNeededPtys.has(id)) { - try { - sendModelRestoreNeededMarker(id, 'delivery-heal', runtime?.getPtyOutputSequence(id)) - rendererDeliveryRestoreNeededPtys.delete(id) - } catch (error) { - console.error( - '[pty] renderer delivery-heal marker send failed; restore remains pending', - error - ) - } - } - return { sent: true, projectionsTransferred } - } - - function rendererPtyIsKnownHidden(id: string): boolean { - return rendererVisibilityKnownPtys.has(id) && !visibleRendererPtys.has(id) - } - - function ptyHasHiddenRendererResizeOutput(id: string): boolean { - return ( - pendingHiddenRendererResizeOutputPtys.has(id) || - deliveredHiddenRendererResizeOutputPtys.has(id) - ) - } - - function markHiddenRendererResizeOutputDelivered(id: string): void { - if (!pendingHiddenRendererResizeOutputPtys.delete(id)) { - return - } - deliveredHiddenRendererResizeOutputPtys.add(id) - } - - function clearDeliveredHiddenRendererResizeOutput(id: string): void { - deliveredHiddenRendererResizeOutputPtys.delete(id) - } - - function clearHiddenRendererResizeOutput(id: string): void { - pendingHiddenRendererResizeOutputPtys.delete(id) - deliveredHiddenRendererResizeOutputPtys.delete(id) - } - - // Why out-of-band (not pty:data): an in-band empty chunk is indistinguishable from one fully consumed by renderer-side OSC-9999 stripping, which spuriously restored visible panes. - function sendModelRestoreNeededMarker( - id: string, - reason: PtyModelRestoreReason, - markerSeq: number | undefined - ): void { - if (isRendererGone(mainWindow)) { - return - } - sendToRenderer(mainWindow, 'pty:modelRestoreNeeded', { - id, - reason, - ...(typeof markerSeq === 'number' ? { markerSeq } : {}) - }) - } - - const pendingDataDropWarnedPtys = new Set() - - // Why capped: keeps O(1) memory per PTY; salvaged query bytes are tiny, so past the cap a pathological stream can degrade to the plain sentinel. - const DROPPED_QUERY_SALVAGE_MAX_CHARS = 4096 - - // Why carve out queries: a bulk drop must not swallow reply-eliciting probes (DSR/CPR, DA1/DA2, DECRQM, OSC 10/11) the program blocks on; snapshot heals content so replies can't double-fire. - function extractDroppedPtyQueryBytes(data: string): string { - if (!data.includes('\x1b')) { - return '' - } - const extracted = extractHiddenStartupRendererQueryData(data, '') - return extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData - } - - function scanDroppedMode2031Data( - data: string, - previous: Mode2031ReplyScanState - ): { data: string; state: Mode2031ReplyScanState } { - const result = scanMode2031ReplyDecision(previous, data) - const decisionData = - result.decision === 'subscribed' - ? '\x1b[?2031h' - : result.decision === 'unsubscribed' - ? '\x1b[?2031l' - : '' - return { data: decisionData, state: result.state } - } - - function getDroppedMode2031RendererData(pending: PendingPtyData): string { - const state = pending.droppedMode2031ScanState - if (!state) { - return pending.droppedMode2031Data ?? '' - } - const pendingSubscribe = state.pendingSubscribe ? '\x1b[?2031h' : '' - return (pending.droppedMode2031Data ?? '') + pendingSubscribe + state.tail - } - - function dropOversizedPendingPtyData(id: string, pending: PendingPtyData): PendingPtyData { - const capChars = pendingDataCapChars() - if (pending.droppedOutput === true || pending.data.length <= capChars) { - return pending - } - if (!pendingDataDropWarnedPtys.has(id)) { - pendingDataDropWarnedPtys.add(id) - console.error( - `[pty] dropped ${pending.data.length} buffered chars for ${id}: renderer not receiving and per-PTY pending cap exceeded; pane will restore from the main-owned snapshot` - ) - // Why: field visibility for cap tuning (issue #2836 / #7017); no pty id since session ids can embed workspace paths. - recordCrashBreadcrumb('terminal_pending_output_dropped', { - droppedChars: pending.data.length, - capChars - }) - } - if (isHiddenPtyDeliveryGateEnabled(getSettings?.()) && !pendingOverflowMarkedPtys.has(id)) { - pendingOverflowMarkedPtys.add(id) - } - pendingDroppedChars += pending.data.length - if (pending.projectionAdmissionIds) { - sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'pending-cap') - } - const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE) - // Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along). - return { - data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS), - droppedOutput: true, - droppedMode2031Data: mode2031.data, - droppedMode2031ScanState: mode2031.state - } - } - - function updatePendingProjectionAdmissions( - pending: PendingPtyData, - state: PendingProjectionAdmissions - ): void { - delete pending.projectionAdmissionIds - delete pending.projectionAdmissionsTransferred - if (state.projectionAdmissionIds) { - pending.projectionAdmissionIds = state.projectionAdmissionIds - } - if (state.projectionAdmissionsTransferred) { - pending.projectionAdmissionsTransferred = true - } - } - - function compactPendingProjectionState( - pending: PendingProjectionAdmissions, - projectionSemanticsId?: string - ): PendingProjectionAdmissions { - const options = pendingProjectionAdmissionOptions() - const compacted = compactPendingProjectionAdmissions(pending, options) - return projectionSemanticsId - ? appendPendingProjectionAdmission(compacted, projectionSemanticsId, options) - : compacted - } - - function pendingProjectionAdmissionOptions() { - return { - isPending: (id: string) => sshOutputIntake?.hasUnpublishedProjection(id) ?? false, - transfer: (ids: readonly string[], reason: string) => - sshOutputIntake?.transferProjections(ids, reason) - } - } - - function appendPendingPtyData( - id: string, - existing: PendingPtyData | undefined, - data: string, - startSeq: number | undefined, - preservesSeq: boolean, - containsBackgroundOutput: boolean, - rawLength = data.length, - transformed = false, - projectionSemanticsId?: string - ): PendingPtyData { - // Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode. - if (existing?.droppedOutput === true) { - if (projectionSemanticsId) { - sshOutputIntake?.transferProjections([projectionSemanticsId], 'pending-cap') - } - const mode2031 = scanDroppedMode2031Data( - data, - existing.droppedMode2031ScanState ?? INITIAL_MODE_2031_REPLY_SCAN_STATE - ) - const remainingQueryCapacity = Math.max( - 0, - DROPPED_QUERY_SALVAGE_MAX_CHARS - existing.data.length - ) - const salvaged = extractDroppedPtyQueryBytes(data).slice(0, remainingQueryCapacity) - return { - ...existing, - data: existing.data + salvaged, - droppedMode2031Data: mode2031.data || existing.droppedMode2031Data, - droppedMode2031ScanState: mode2031.state - } - } - const projectionState = compactPendingProjectionState(existing ?? {}, projectionSemanticsId) - const nextContainsBackgroundOutput = - existing?.containsBackgroundOutput === true || containsBackgroundOutput - if (!existing) { - const pending: PendingPtyData = { - data, - ...(typeof startSeq === 'number' ? { startSeq } : {}), - ...(rawLength !== data.length ? { rawLength } : {}), - ...(transformed ? { transformed: true } : {}), - ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) - } - updatePendingProjectionAdmissions(pending, projectionState) - return dropOversizedPendingPtyData(id, pending) - } - const existingRawLength = existing.rawLength ?? existing.data.length - const next: PendingPtyData = { - data: existing.data + data, - ...(!preservesSeq || existing.transformed || transformed - ? { rawLength: existingRawLength + rawLength, transformed: true as const } - : {}), - ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) - } - updatePendingProjectionAdmissions(next, projectionState) - if (typeof existing.startSeq === 'number') { - next.startSeq = existing.startSeq - } - return dropOversizedPendingPtyData(id, next) - } - - function schedulePendingDataFlush(delayMs: number): void { - if (flushTimer) { - return - } - flushTimer = setTimeout(flushPendingData, delayMs) - } - - function invalidatePendingPtyDrainClassification(id?: string, schedule = true): void { - const invalidated = - typeof id === 'string' ? pendingData.invalidate(id) : pendingData.invalidateAll() - if (invalidated && schedule && !flushTimer) { - schedulePendingDataFlush(0) - } - } - invalidatePendingPtyDrainPriority = invalidatePendingPtyDrainClassification - invalidatePendingPtyDrainPolicy = invalidatePendingPtyDrainClassification - - function clearDispatcherReadyWatchdog(): void { - if (dispatcherReadyWatchdogTimer) { - clearTimeout(dispatcherReadyWatchdogTimer) - dispatcherReadyWatchdogTimer = null - } - } - - function armDispatcherReadyWatchdog(): void { - clearDispatcherReadyWatchdog() - if (isRendererGone(mainWindow)) { - return - } - // Why: one-shot self-heal — force the gate open if the reloaded page never signals ready, so a dropped handshake can't hold it forever. Unref'd so it can't keep the process alive. - dispatcherReadyWatchdogTimer = setTimeout(() => { - dispatcherReadyWatchdogTimer = null - if (rendererPtyDispatcherReady || isRendererGone(mainWindow)) { - return - } - rendererPtyDispatcherReady = true - rendererDispatcherReadyForcedCount += 1 - pendingData.reactivateBlocked() - schedulePendingDataFlush(0) - }, PTY_DISPATCHER_READY_WATCHDOG_MS) - dispatcherReadyWatchdogTimer.unref?.() - } - - function flushPendingData(): void { - flushTimer = null - if (isRendererGone(mainWindow)) { - // Why release now: bookkeeping is being wiped, so no future drain can resume these producers — local shells would wedge. - producerFlowControl.releaseAll() - clearDeliveryResyncProbe() - clearPendingPtyData() - pendingOverflowMarkedPtys.clear() - rendererDeliveryAccountingByPty.clear() - rendererInFlightTotalChars = 0 - clearDispatcherReadyWatchdog() - return - } - // Ordinary boot-window data is blocked in the queue; hidden-droppable entries still retire before renderer readiness. - const settings = getSettings?.() - let writes = 0 - let sendFailed = false - const round = pendingData.beginRound() - let creditReleasedDuringFlush = false - pendingDataFlushActive = true - pendingDataCreditReleasedDuringFlush = false - try { - while (writes < PTY_BATCH_FLUSH_MAX_WRITES) { - const selection = pendingData.takeNext(round) - if (!selection) { - break - } - const { id, pending } = selection - // Why drop, never re-queue: the model already ingested hidden-gated bytes; reveal restores from the snapshot+seq machinery. - if (shouldDropHiddenRendererPtyData(id, settings)) { - pendingData.remove(selection) - pendingOverflowMarkedPtys.delete(id) - updateProducerFlowControl(id) - const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length) - if (pending.projectionAdmissionIds) { - sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'hidden-drop') - } - warnIfDroppingHiddenBytesForVisiblePty(id, pending.data.length) - if (drop.shouldEmitRestoreMarker) { - sendModelRestoreNeededMarker(id, 'hidden-drop', runtime?.getPtyOutputSequence(id)) - } - continue - } - if (!canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) { - pendingData.block(selection) - continue - } - if (pending.droppedOutput === true) { - pendingData.remove(selection) - updateProducerFlowControl(id) - // Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only). - if ( - !sendPtyDataToRenderer( - id, - { - id, - data: pending.data + getDroppedMode2031RendererData(pending), - droppedOutput: true - }, - pending.projectionAdmissionIds - ).sent - ) { - sendFailed = true - break - } - writes++ - continue - } - const { data } = pending - const indivisible = pending.transformed === true - const chunk = indivisible ? data : data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS) - const remaining = indivisible ? '' : data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS) - let nextPending: PendingPtyData | undefined - if (remaining) { - nextPending = { data: remaining } - if (typeof pending.startSeq === 'number') { - nextPending.startSeq = pending.startSeq + chunk.length - } - if (pending.containsBackgroundOutput === true) { - nextPending.containsBackgroundOutput = true - } - if (pending.projectionAdmissionIds) { - nextPending.projectionAdmissionIds = pending.projectionAdmissionIds - } - if (pending.projectionAdmissionsTransferred) { - nextPending.projectionAdmissionsTransferred = true - } - pendingData.replaceWithRemainder(selection, nextPending) - } else { - pendingData.remove(selection) - pendingOverflowMarkedPtys.delete(id) - } - updateProducerFlowControl(id) - const delivery = sendPtyDataToRenderer( - id, - makePtyDataPayload( - id, - chunk, - pending.startSeq, - pending.containsBackgroundOutput, - pending.rawLength, - pending.transformed - ), - pending.projectionAdmissionIds - ) - if (nextPending) { - updatePendingProjectionAdmissions( - nextPending, - propagatePendingProjectionRemainder( - nextPending, - delivery, - pendingProjectionAdmissionOptions() - ) - ) - } - if (!delivery.sent) { - sendFailed = true - break - } - writes++ - } - } finally { - pendingDataFlushActive = false - creditReleasedDuringFlush = pendingDataCreditReleasedDuringFlush - pendingDataCreditReleasedDuringFlush = false - pendingData.endRound(round) - } - if (rendererPtyDispatcherReady && pendingData.size > 0 && writes === 0 && !sendFailed) { - ackGatedFlushSkipCount++ - } - if (sendFailed && pendingData.size > 0) { - if (flushTimer) { - clearTimeout(flushTimer) - flushTimer = null - } - schedulePendingDataFlush(PTY_BATCH_DRAIN_CONTINUE_MS) - return - } - if (pendingData.size > 0 && (writes > 0 || creditReleasedDuringFlush)) { - // Why yield between slices: a background terminal can dump megabytes at once, and keystroke writes must not stall behind one flush. - schedulePendingDataFlush(writes > 0 ? PTY_BATCH_DRAIN_CONTINUE_MS : 0) - } - } - - const clearFlushTimerIfIdle = (): void => { - if (pendingData.size > 0 || flushTimer === null) { - return - } - clearTimeout(flushTimer) - flushTimer = null - } - - const syntheticKillExitPtyIds = new Map() - const reversibleStopOwnersByPtyId = new Map() - - function rememberSyntheticKillExit(id: string): void { - const existing = syntheticKillExitPtyIds.get(id) - if (existing) { - clearTimeout(existing) - } - // Why a timed window: providers may report the real exit after kill completes; skip only that late duplicate, not a future reused id forever. - const cleanupTimer = setTimeout(() => { - syntheticKillExitPtyIds.delete(id) - }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) - cleanupTimer.unref?.() - syntheticKillExitPtyIds.set(id, cleanupTimer) - } - - // Why: a rejected split retires its PTY while kill's shutdown is still in flight; kill's late - // synthetic exit must not land afterwards, or an SSH pane's code -1 would re-preserve the - // surface the retirement just removed. Timed like the duplicate window so a reused id is free. - const retiredRejectedPtyIds = new Map() - - function rememberRetiredRejectedPty(id: string): void { - const existing = retiredRejectedPtyIds.get(id) - if (existing) { - clearTimeout(existing) - } - const cleanupTimer = setTimeout(() => { - retiredRejectedPtyIds.delete(id) - }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) - cleanupTimer.unref?.() - retiredRejectedPtyIds.set(id, cleanupTimer) - } - - function consumeSyntheticKillExit(id: string): boolean { - const cleanupTimer = syntheticKillExitPtyIds.get(id) - if (!cleanupTimer) { - return false - } - clearTimeout(cleanupTimer) - syntheticKillExitPtyIds.delete(id) - return true - } - - function preparePtyExitForRenderer(payload: { id: string; code: number }): (() => void) | null { - if (isRendererGone(mainWindow)) { - sshOutputIntake?.transferPtyProjections(payload.id, 'renderer-destroyed') - return () => {} - } - if (rendererExitingPtyIds.has(payload.id)) { - return null - } - rendererExitingPtyIds.add(payload.id) - let released = false - const release = (): void => { - if (released) { - return - } - released = true - rendererExitingPtyIds.delete(payload.id) - } - try { - if (!rendererCreditBeforeExitByPty.has(payload.id)) { - rendererCreditBeforeExitByPty.set( - payload.id, - getRendererInFlightCharsForPty(payload.id) > 0 - ) - } - // Why flush before exit: the renderer tears down the terminal on pty:exit, so any batched output not yet flushed would be silently lost. - const remaining = pendingData.delete(payload.id) - clearFlushTimerIfIdle() - if (remaining) { - if (remaining.droppedOutput === true) { - // Sentinel entry: only salvaged query bytes remain; keep the flag so the renderer knows the span was dropped. - sendPtyDataToRenderer( - payload.id, - { - id: payload.id, - data: remaining.data, - droppedOutput: true - }, - remaining.projectionAdmissionIds - ) - } else { - sendPtyDataToRenderer( - payload.id, - makePtyDataPayload( - payload.id, - remaining.data, - remaining.startSeq, - remaining.containsBackgroundOutput, - remaining.rawLength, - remaining.transformed - ), - remaining.projectionAdmissionIds - ) - } - } - return release - } catch (error) { - release() - throw error - } - } - - function finalizePtyExitForRenderer(payload: { id: string; code: number }): void { - if (isRendererGone(mainWindow)) { - rendererCreditBeforeExitByPty.delete(payload.id) - return - } - const hadReleasableRendererCredit = - rendererCreditBeforeExitByPty.get(payload.id) ?? - getRendererInFlightCharsForPty(payload.id) > 0 - rendererCreditBeforeExitByPty.delete(payload.id) - // Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id. - producerFlowControl.release(payload.id) - sourceCreditPendingPtys.delete(payload.id) - pendingOverflowMarkedPtys.delete(payload.id) - rendererDeliveryRestoreNeededPtys.delete(payload.id) - lastInputAtByPty.delete(payload.id) - interactiveOutputCharsByPty.delete(payload.id) - const releasedRendererCredit = getRendererInFlightCharsForPty(payload.id) - rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - releasedRendererCredit) - // Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides. - rendererDeliveryAccountingByPty.delete(payload.id) - if (hadReleasableRendererCredit) { - if (pendingDataFlushActive) { - // Why: let the open round coalesce this wake into its one post-round continuation. - const reactivatedBlocked = pendingData.reactivateBlocked() - pendingDataCreditReleasedDuringFlush ||= reactivatedBlocked - } else { - schedulePendingDataAfterCreditReport(true) - } - } - sendToRenderer(mainWindow, 'pty:exit', { - ...payload, - ...(reversibleStopOwnersByPtyId.has(payload.id) ? { preserveRendererBinding: true } : {}) - }) - } - - function sendPtyExitToRenderer(payload: { id: string; code: number }): void { - options?.onPtyExit?.(payload.id, ++ptyLifecycleSequence) - const release = preparePtyExitForRenderer(payload) - if (!release) { - return - } - try { - sshOutputIntake?.transferPtyProjections(payload.id, 'legacy-pty-exit') - finalizePtyExitForRenderer(payload) - } finally { - release() - } - } - - function sendPtySpawnedToRenderer(id: string): void { - if (!isRendererGone(mainWindow)) { - sendToRenderer(mainWindow, 'pty:spawned', { id }) - } - } - - function acceptPtyDataForRenderer( - payload: { - id: string - data: string - sequenceChars?: number - transformed?: boolean - }, - outputSeq: number | undefined, - projection?: LegacySshProjectionSemantics - ): void { - const rawLength = payload.sequenceChars ?? payload.data.length - const preservesSeq = !payload.transformed && rawLength === payload.data.length - const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined - const projectionId = projection?.identity.projectionSemanticsId - if (isRendererGone(mainWindow)) { - if (projectionId) { - sshOutputIntake?.transferProjections([projectionId], 'renderer-destroyed') - } - if (flushTimer) { - clearTimeout(flushTimer) - flushTimer = null - } - producerFlowControl.releaseAll() - clearDeliveryResyncProbe() - clearPendingPtyData() - pendingOverflowMarkedPtys.clear() - rendererDeliveryAccountingByPty.clear() - rendererInFlightTotalChars = 0 - clearDispatcherReadyWatchdog() - return - } - if (rendererExitingPtyIds.has(payload.id)) { - if (projectionId) { - sshOutputIntake?.transferProjections([projectionId], 'pty-exiting') - } - return - } - if (shouldDropHiddenRendererPtyData(payload.id, getSettings?.())) { - if (projectionId) { - sshOutputIntake?.transferProjections([projectionId], 'hidden-drop') - } - const droppedChars = projection ? rawLength : payload.data.length - const drop = recordHiddenRendererPtyDataDrop(payload.id, droppedChars) - warnIfDroppingHiddenBytesForVisiblePty(payload.id, droppedChars) - if (drop.shouldEmitRestoreMarker) { - sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq) - } - return - } - if (payload.data.length === 0 && !payload.transformed) { - if (projectionId) { - sshOutputIntake?.transferProjections([projectionId], 'empty-projection') - } - return - } - const containsBackgroundOutput = - rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id) - if (containsBackgroundOutput) { - markHiddenRendererResizeOutputDelivered(payload.id) - } - const overflowMarkedBeforeAppend = pendingOverflowMarkedPtys.has(payload.id) - if (projection?.desktopSpan) { - sourceCreditPendingPtys.add(payload.id) - } - const pending = appendPendingPtyData( - payload.id, - pendingData.get(payload.id), - payload.data, - startSeq, - preservesSeq, - containsBackgroundOutput, - rawLength, - payload.transformed === true, - projectionId - ) - const shouldEmitPendingCapRestoreMarker = - pending.droppedOutput === true && - !overflowMarkedBeforeAppend && - pendingOverflowMarkedPtys.has(payload.id) - const nextData = pending.data + getDroppedMode2031RendererData(pending) - const isInteractiveOutput = shouldSendInteractiveOutputNow( - payload.id, - nextData, - performance.now() - ) - if (isInteractiveOutput && rendererPtyDispatcherReady) { - if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) { - setPendingPtyData(payload.id, pending) - if (shouldEmitPendingCapRestoreMarker) { - sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq) - } - updateProducerFlowControl(payload.id) - requestDeliveryResyncForGatedPty() - return - } - deletePendingPtyData(payload.id) - clearFlushTimerIfIdle() - if (shouldEmitPendingCapRestoreMarker) { - sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq) - } - pendingOverflowMarkedPtys.delete(payload.id) - try { - sendPtyDataToRenderer( - payload.id, - { - id: payload.id, - data: nextData, - ...(typeof pending.startSeq === 'number' - ? { - seq: pending.startSeq + (pending.rawLength ?? nextData.length), - rawLength: pending.rawLength ?? nextData.length - } - : {}), - ...(pending.transformed ? { transformed: true } : {}), - ...(pending.containsBackgroundOutput === true ? { background: true } : {}), - ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) - }, - pending.projectionAdmissionIds - ) - } finally { - updateProducerFlowControl(payload.id) - } - return - } - setPendingPtyData(payload.id, pending) - if (shouldEmitPendingCapRestoreMarker) { - sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq) - } - updateProducerFlowControl(payload.id) - if ( - !canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) }) - ) { - requestDeliveryResyncForGatedPty() - } - if (!flushTimer) { - schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) - } - } - - sshOutputIntakeCleanup?.() - sshOutputIntake = new SshPtyOutputIntake({ - getModelSequence: (id) => runtime?.getPtyOutputSequence(id) ?? 0, - acceptModel: (event, projection) => { - if (!runtime) { - throw new Error('SSH PTY output requires the main terminal model') - } - return runtime.acceptPtyDataBounded( - event.id, - event.data, - Date.now(), - event.rawLength, - event.transformed, - projection.desktopSpan ? [projection.desktopSpan] : undefined - ) - }, - project: (event, projection) => - acceptPtyDataForRenderer( - { - id: event.id, - data: event.data, - sequenceChars: event.rawLength, - transformed: event.transformed - }, - projection.identity.sequenceEnd, - projection - ), - prepareExit: (event) => { - const release = preparePtyExitForRenderer(event) - if (!release) { - throw new Error('pty_renderer_exit_in_progress') - } - return release - }, - finalizeExit: (event) => { - runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation, { - hostExitConfirmed: true - }) - finalizePtyExitForRenderer(event) - }, - pauseProvider: (generation, id) => { - const provider = sshProvidersByGeneration.get(generation) as - | (IPtyProvider & { hasPtyDeliveryPauseAdapter?: () => boolean }) - | undefined - if (!provider?.hasPtyDeliveryPauseAdapter?.()) { - return false - } - provider.pauseProducer?.(id) - return true - }, - resumeProvider: (generation, id) => - sshProvidersByGeneration.get(generation)?.resumeProducer?.(id), - closeProvider: (generation, reason) => { - const provider = sshProvidersByGeneration.get(generation) - ;( - provider as (IPtyProvider & { closeOutputIntake?: (reason: string) => void }) | undefined - )?.closeOutputIntake?.(reason) - }, - resetModelForMigration: (_generation, id) => runtime?.resetPtyModelAfterMigrationFailure(id), - onGenerationClosed: (providerGeneration) => { - for (const id of pendingData.keys()) { - const pending = pendingData.get(id) - if ( - pending?.projectionAdmissionIds && - sshOutputIntake?.hasProjectionFromGeneration( - pending.projectionAdmissionIds, - providerGeneration - ) - ) { - pendingData.delete(id) - updateProducerFlowControl(id) - pendingOverflowMarkedPtys.delete(id) - } - } - sshProvidersByGeneration.delete(providerGeneration) - }, - publishSourceAck: publishSshPtySourceAck, - cancelSourceDelivery: cancelSshPtySourceDelivery - }) - runtime?.setRemoteTerminalSourceRangeConsumerHooks?.( - sshOutputIntake.getRemoteSourceRangeConsumerHooks() - ) - const cleanupSshOutputIntakeRegistry = installSshPtyOutputIntake(sshOutputIntake) - sshOutputIntakeCleanup = () => { - runtime?.setRemoteTerminalSourceRangeConsumerHooks?.(null) - cleanupSshOutputIntakeRegistry() - } - - async function shutdownProviderAndDetectExit( - provider: IPtyProvider, - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise { - let providerExitObserved = false - const expectedIncarnationId = ptyIncarnationById.get(id) - const unsubscribe = provider.onExit((payload) => { - if ( - payload.id === id && - (!expectedIncarnationId || payload.incarnationId === expectedIncarnationId) - ) { - providerExitObserved = true - } - }) - try { - await provider.shutdown(id, opts) - } finally { - unsubscribe() - } - return providerExitObserved - } - - // Why extracted: the "Restart daemon" flow rebinds against the fresh adapter after replaceDaemonProvider, sharing this code path with startup registration. - const bindProviderListeners = (): void => { - localDataUnsub?.() - localExitUnsub?.() - localBackgroundStreamUnsub?.() - localWriteUnavailableUnsub?.() - - // Why: a daemon death takes down every session at once. The provider signals - // each affected pane here so background panes remount + re-attach too, not - // just the pane whose write happened to detect the dead endpoint (STA-2373). - localWriteUnavailableUnsub = - localProvider.onWriteUnavailable?.((payload) => { - if (isRendererGone(mainWindow)) { - return - } - sendToRenderer(mainWindow, 'pty:writeUnavailable', { id: payload.id }) - }) ?? null - - // Daemon keep-tail thinning facts, in byte order with onData: markers flip transient-fact scan authority; a gap forces renderer restore from the snapshot. - localBackgroundStreamUnsub = - localProvider.onBackgroundStreamEvent?.((payload) => { - if (payload.kind === 'backgroundMarker') { - runtime?.setPtyTransientFactDelegation( - payload.id, - payload.background, - payload.scanSeedAnsi, - payload.mode2031PendingSubscribe - ) - return - } - if (payload.kind === 'dataGap') { - providerSnapshotRequiredPtys.add(payload.id) - runtime?.notePtyDataGap(payload.id, payload.sequenceChars ?? payload.droppedChars) - sendModelRestoreNeededMarker( - payload.id, - 'hidden-drop', - runtime?.getPtyOutputSequence(payload.id) - ) - return - } - runtime?.emitDaemonPtyTransientFact(payload.id, payload.fact) - }) ?? null - - // Why: daemon providers lack configure().onData, so feed the runtime here or their tail buffer (terminal.read, agent-detection, mobile stream) stays empty. - const isLocalProvider = localProvider instanceof LocalPtyProvider - - localDataUnsub = localProvider.onData((payload) => { - const rawLength = payload.sequenceChars ?? payload.data.length - const outputSeq = isLocalProvider - ? runtime?.getPtyOutputSequence(payload.id) - : runtime?.onPtyData(payload.id, payload.data, Date.now(), rawLength, payload.transformed) - acceptPtyDataForRenderer(payload, outputSeq) - }) - localExitUnsub = localProvider.onExit((payload) => { - if (!isCurrentPtyExit(payload)) { - return - } - if (consumeSyntheticKillExit(payload.id)) { - return - } - if (!isLocalProvider) { - clearProviderPtyState(payload.id) - ptyOwnership.delete(payload.id) - markClaudePtyExited(payload.id) - // Why: same physical-exit certificate as the LocalPtyProvider branch above — this is the - // daemon/WSL callback, and on macOS it is the branch that actually runs. - runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId, { - providerExitObserved: true, - ...(payload.cause ? { cause: payload.cause } : {}) - }) - } - // Why not the whole payload: the exit cause is a main-process fact for the - // runtime's records; the renderer's pty:exit contract stays as it was. - sendPtyExitToRenderer({ - id: payload.id, - code: payload.code, - ...(payload.incarnationId ? { incarnationId: payload.incarnationId } : {}) - }) - }) - } - - bindProviderListeners() - rebindProviderListeners = bindProviderListeners - - // Why: one persistent listener with a request-ID dispatch table instead of one per call, so concurrent serialize requests don't trip Node's MaxListeners=10 warning. - type SerializeResult = { - data: string - cols: number - rows: number - seq?: number - lastTitle?: string - kittyKeyboardFlags?: number - } | null - const pendingSerializeRequests = new Map< - string, - { resolve: (result: SerializeResult) => void; timeout: NodeJS.Timeout } - >() - - function settleSerializeRequest(requestId: string, result: SerializeResult): void { - const pending = pendingSerializeRequests.get(requestId) - if (!pending) { - return - } - clearTimeout(pending.timeout) - pendingSerializeRequests.delete(requestId) - pending.resolve(result) - } - - ipc.on( - 'pty:serializeBuffer:response', - ( - _event, - args: { - requestId?: string - snapshot?: { - data?: unknown - cols?: unknown - rows?: unknown - seq?: unknown - lastTitle?: unknown - kittyKeyboardFlags?: unknown - } | null - } - ) => { - if (typeof args?.requestId !== 'string') { - return - } - const snapshot = args.snapshot - if ( - snapshot && - typeof snapshot.data === 'string' && - typeof snapshot.cols === 'number' && - typeof snapshot.rows === 'number' - ) { - const result: { - data: string - cols: number - rows: number - seq?: number - lastTitle?: string - kittyKeyboardFlags?: number - } = { - data: snapshot.data, - cols: snapshot.cols, - rows: snapshot.rows - } - if (typeof snapshot.seq === 'number' && Number.isFinite(snapshot.seq)) { - result.seq = snapshot.seq - } - if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) { - result.lastTitle = snapshot.lastTitle - } - // Why gated on seq: without a boundary the flags cannot be reconciled - // against live bytes, so they prove nothing. - const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.kittyKeyboardFlags) - if (result.seq !== undefined && kittyKeyboardFlags !== undefined) { - result.kittyKeyboardFlags = kittyKeyboardFlags - } - settleSerializeRequest(args.requestId, result) - } else { - settleSerializeRequest(args.requestId, null) - } - } - ) - - function requestSerializedBuffer( - ptyId: string, - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - ): Promise { - if (isRendererGone(mainWindow)) { - return Promise.resolve(null) - } - - const requestId = randomUUID() - return new Promise((resolve) => { - const timeout = setTimeout(() => { - settleSerializeRequest(requestId, null) - }, 750) - pendingSerializeRequests.set(requestId, { resolve, timeout }) - const payload: { - requestId: string - ptyId: string - opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } - } = { requestId, ptyId } - if (opts) { - payload.opts = opts - } - sendToRenderer(mainWindow, 'pty:serializeBuffer:request', payload) - }) - } - - // Why: reload/crash orphans delivery-interest holds and hidden marks; reset so surviving PTYs aren't stuck force-fed or gated — each pane's first sync re-marks. - clearRendererGateResetHandlers() - const resetRendererPtyDeliveryGateState = (): void => { - const gateDebug = getHiddenRendererPtyDeliveryDebug() - resetRendererScopedHiddenPtyDeliveryState() - if (gateDebug.hiddenDeliveryGatedPtyCount > 0 || gateDebug.deliveryInterestPtyCount > 0) { - invalidatePendingPtyDrainPolicy() - } - // Why: the daemon pacer must not keep throttling ptys whose hidden marks died with the renderer; the fresh renderer's sync re-marks the still-hidden ones. - resyncBackgroundedDeliveriesAfterGateReset() - } - rendererGateResetLoadHandler = resetRendererPtyDeliveryGateState - rendererGateResetGoneHandler = resetRendererPtyDeliveryGateState - // Why guarded: with no renderer there is nothing to reset a gate for, and nothing - // that will ever emit these. Registering against a fake window was the old workaround. - if (rendererContents) { - rendererGateResetWebContents = rendererContents - rendererContents.on('did-finish-load', rendererGateResetLoadHandler) - rendererContents.on('render-process-gone', rendererGateResetGoneHandler) - } - - // Why: only LocalPtyProvider PTYs (main-process) can be orphaned on reload; daemon sessions survive by design and cleanup would kill them. - clearDidFinishLoadHandler() - if (localProvider instanceof LocalPtyProvider) { - const lp = localProvider - didFinishLoadHandler = () => { - // Why: always advance to keep the generation monotonic, but skip the sweep on crash/freeze-recovery reload — it would kill live local PTYs before session restore (#5787). - const generation = lp.advanceGeneration() - if (rendererContents && options?.isRecoveryReloadInFlight?.(rendererContents.id)) { - return - } - // Why: the retained provider onExit callback is the only physical-exit proof; it clears ownership after the OS reaps it. - lp.killOrphanedPtys(generation - 1) - } - if (rendererContents) { - didFinishLoadWebContents = rendererContents - rendererContents.on('did-finish-load', didFinishLoadHandler) - } - } - - const assertFolderWorkspacePtyPathUsable = async ( - worktreeId: string | undefined - ): Promise => { - const workspaceScope = typeof worktreeId === 'string' ? parseWorkspaceKey(worktreeId) : null - if (!store || workspaceScope?.type !== 'folder') { - return - } - const status = await getFolderWorkspacePathStatus( - store, - { scope: 'folder-workspace', folderWorkspaceId: workspaceScope.folderWorkspaceId }, - { getSshFilesystemProvider } - ) - assertFolderWorkspacePathUsable(status) - } - - const resolvePtySpawnStartupCwd = ( - worktreeId: string | undefined, - cwd: string | undefined, - missingDirFallback?: TerminalStartupCwdMissingDirFallback - ): string | undefined => - resolveTerminalStartupCwdForWorkspace({ - workspaceId: worktreeId, - requestedCwd: cwd, - missingDirFallback, - resolveFolderWorkspacePath: (folderWorkspaceId) => - store?.getFolderWorkspace(folderWorkspaceId)?.folderPath - }) - - const localStartupCwdDirectoryExists = (path: string): boolean => { - // Why: Win32 statSync on \\wsl.localhost 9P shares can falsely report ENOENT; defer to the provider's WSL-aware validation. - if (isWslUncPath(path)) { - return true - } - try { - return statSync(path).isDirectory() - } catch { - return false - } - } - - const prepareCodexResumeHome = (args: { - connectionId?: string | null - launchAgent?: TuiAgent - providerSession?: AgentProviderSessionMetadata - target: CodexAccountSelectionTarget - launchEnv?: NodeJS.ProcessEnv - workspacePath?: string - }): { - providerSession: AgentProviderSessionMetadata - preparation: Promise - } | null => { - if (args.connectionId || args.launchAgent !== 'codex' || !options?.prepareCodexSessionResume) { - return null - } - const providerSession = normalizeAgentProviderSession(args.providerSession) - if (!providerSession) { - return null - } - return { - providerSession, - preparation: options.prepareCodexSessionResume({ - providerSession, - target: args.target, - launchEnv: args.launchEnv, - workspacePath: args.workspacePath - }) - } - } - - type CodexResumeLaunch = { - codexResumeHome: Extract | null - command: string | undefined - notifyResumeUnavailable: boolean - droppedResumeArgv: boolean - providerSession: AgentProviderSessionMetadata | null - } - - /** Kept separate from resolveCodexResumeLaunch so non-Codex spawns never await: - * an extra tick reorders the pane-spawn reservation races this handler arbitrates. */ - const noCodexResumeLaunch = (command: string | undefined): CodexResumeLaunch => ({ - codexResumeHome: null, - command, - notifyResumeUnavailable: false, - droppedResumeArgv: false, - providerSession: null - }) - - /** The command a Codex launch actually runs: unchanged when provenance is verified, - * stripped of `resume ` when it is not. */ - const resolveCodexResumeLaunch = ( - command: string | undefined, - preparation: NonNullable> - ): Promise => - preparation.preparation.then((prepared) => { - const providerSession = preparation.providerSession - if (prepared?.outcome !== 'fresh') { - return { - codexResumeHome: prepared ?? null, - command, - notifyResumeUnavailable: false, - droppedResumeArgv: false, - providerSession - } - } - const dropped = dropUnverifiedCodexResumeArgv({ - command, - providerSession, - claimedCodexProvenance: prepared.claimedCodexProvenance - }) - return { - codexResumeHome: null, - command: dropped.command, - // Why: staying silent only makes sense for metadata that positively belongs to - // another agent; a resume with no transcript path at all still owes the user a notice. - notifyResumeUnavailable: - dropped.droppedResumeArgv && - (prepared.claimedCodexProvenance || !providerSession.transcriptPath), - droppedResumeArgv: dropped.droppedResumeArgv, - providerSession - } - }) - - const reconcileSharedRuntimeResumeHome = ( - resumeHome: Extract, - resolveCurrentHome: () => string | null - ): string => { - if (!resumeHome.reconcileSharedRuntimeAuth) { - return resumeHome.codexHomePath - } - const currentHome = resolveCurrentHome() - if (!codexHomePathsEqual(currentHome, resumeHome.codexHomePath)) { - throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) - } - return resumeHome.codexHomePath - } - - /** Why: buildPtyHostEnv prefers ORCA_SEQUENCED_STARTUP_COMMAND over the launch command - * and the sequenced wrapper `eval`s it, so a dropped resume argv has to go there too. */ - const stripSequencedStartupResumeArgv = | undefined>( - env: T, - launch: CodexResumeLaunch - ): T => { - const sequenced = env?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV] - if (!env || !sequenced || !launch.droppedResumeArgv || !launch.providerSession) { - return env - } - const drop = dropAgentResumeArgvFromCommand({ - command: sequenced, - agent: 'codex', - providerSession: launch.providerSession - }) - return drop.status === 'dropped' - ? { ...env, [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: drop.command } - : env - } - - const adoptStablePane = async (args: { - cols: number - rows: number - cwd?: string - connectionId?: string | null - worktreeId: string - preAllocatedHandle?: string - tabId: string - leafId: string - ownsPaneSpawnReservation?: true - }) => { - const paneKey = makePaneKey(args.tabId, args.leafId) - const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) - const pendingAdoption = ownerKey ? stablePaneAdoptionsByOwnerKey.get(ownerKey) : undefined - if (pendingAdoption) { - return await pendingAdoption - } - const activePaneSpawn = - ownerKey && !args.ownsPaneSpawnReservation - ? paneSpawnReservationsByOwnerKey.get(ownerKey) - : undefined - if (activePaneSpawn) { - const result = await activePaneSpawn.promise - const owner = resolveStablePaneOwner( - runtime, - store, - paneKey, - args.worktreeId, - args.connectionId - ) - if ( - !owner || - owner.ptyId !== result.id || - (owner.incarnationId !== undefined && - result.incarnationId !== undefined && - owner.incarnationId !== result.incarnationId) - ) { - throw new Error('terminal_pane_owner_changed') - } - return { - result: { - ...result, - isReattach: true, - ...(owner.incarnationId ? { incarnationId: owner.incarnationId } : {}) - }, - owner, - materialized: true as const - } - } - const owner = resolveStablePaneOwner( - runtime, - store, - paneKey, - args.worktreeId, - args.connectionId - ) - if (!owner) { - return null - } - const adoption = attachStablePaneOwner({ - runtime, - store, - provider: getProvider(args.connectionId), - spawnOptions: { - cols: args.cols, - rows: args.rows, - cwd: args.cwd - }, - owner, - worktreeId: args.worktreeId, - connectionId: args.connectionId, - resolveOwner: () => - resolveStablePaneOwner(runtime, store, paneKey, args.worktreeId, args.connectionId) - }) - if (!ownerKey) { - return await adoption - } - stablePaneAdoptionsByOwnerKey.set(ownerKey, adoption) - try { - return await adoption - } finally { - if (stablePaneAdoptionsByOwnerKey.get(ownerKey) === adoption) { - stablePaneAdoptionsByOwnerKey.delete(ownerKey) - } - } - } - - const markSshInventoryUnverifiable = (connectionId: string, error: unknown): void => { - const reason = error instanceof Error ? error.message : String(error) - for (const [ptyId, ownerConnectionId] of ptyOwnership) { - if (ownerConnectionId === connectionId) { - runtime?.markPtyLivenessUnverifiable?.(ptyId, reason) - } - } - } - - // Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them. - runtime?.setPtyController({ - claimStablePaneCreate: (args) => { - const paneKey = makePaneKey(args.tabId, args.leafId) - const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) - return ownerKey ? claimRuntimePaneCreate(ownerKey) : () => {} - }, - adoptStablePane, - spawn: async (args) => { - const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined - const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined - const preAdoptedStablePane = args.adoptedStablePane ?? null - const reattachedCodexHomeRoutes = !args.connectionId - ? snapshotCodexPaneHomeRoutes([ - preAdoptedStablePane?.result.id, - args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined - ]) - : new Map() - const startupPromise = getLocalPtyStartupPromise(args.connectionId) - if (startupPromise) { - await startupPromise - } - if (preAdoptedStablePane?.materialized) { - const handle = preAdoptedStablePane.owner.handle ?? args.preAllocatedHandle - if (!handle) { - throw new Error('terminal_pane_owner_unknown') - } - const result = { - id: preAdoptedStablePane.result.id, - ...(preAdoptedStablePane.result.incarnationId - ? { incarnationId: preAdoptedStablePane.result.incarnationId } - : {}), - ...(typeof preAdoptedStablePane.result.wslDistro === 'string' - ? { wslDistro: preAdoptedStablePane.result.wslDistro } - : {}), - stablePaneOwner: { - handle, - tabId: preAdoptedStablePane.owner.tabId, - leafId: preAdoptedStablePane.owner.leafId - } - } - ensureWslHookRelayForReattach( - { isReattach: true, wslDistro: preAdoptedStablePane.result.wslDistro }, - args.connectionId - ) - if (!args.connectionId) { - options?.onCodexHomePtySpawned?.({ - id: result.id, - codexHomePath: null, - reattached: true, - startedAt: codexHomeLaunchStartedAt, - startedSequence: codexHomeLaunchStartedSequence, - ...codexReattachedHomeRouteField(reattachedCodexHomeRoutes, result.id, true) - }) - } - return result - } - if (!preAdoptedStablePane) { - await assertFolderWorkspacePtyPathUsable(args.worktreeId) - } - const cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) - const provider = getProvider(args.connectionId) - const freshSpawnRecovery = preAdoptedStablePane - ? undefined - : recoverFreshSpawnProviderRouting( - provider, - args.connectionId, - args.sessionId, - args.isNewSession - ) - if (freshSpawnRecovery) { - await freshSpawnRecovery - } - const isClaudeLaunch = - !preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) - if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') - } - // Why: runtime-created terminals carry no renderer-computed projectRuntime; resolve from worktreeId to honor the project's Windows runtime. - const terminalRuntimeOptions = - process.platform === 'win32' && !args.connectionId - ? resolveLocalWindowsTerminalRuntimeOptions({ - requestedShellOverride: undefined, - settings: getSettings?.(), - projectRuntime: resolveLocalProjectRuntimeForWorktreeId(store, args.worktreeId), - fallbackHostShell: process.env.COMSPEC || 'powershell.exe' - }) - : { shellOverride: undefined, terminalWindowsWslDistro: null } - const daemonShellOverride = terminalRuntimeOptions.shellOverride - const isDaemonHostSpawn = - !args.connectionId && - !(provider instanceof LocalPtyProvider) && - !routesFreshSpawnsToLocalProvider(provider) - const callerRequestedSessionId = args.sessionId?.trim() - const requestedSessionId = - callerRequestedSessionId ?? - (isDaemonHostSpawn && args.agentSessionCreateOperationId - ? ptySessionIdForAgentCreateOperation(args.worktreeId, args.agentSessionCreateOperationId) - : undefined) - const sessionId = - requestedSessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) - const effectiveSessionRelayId = - sessionId !== undefined ? getRelayPtyId(args.connectionId, sessionId) : undefined - const effectiveSessionAppId = - sessionId !== undefined ? getAppPtyId(args.connectionId, sessionId) : undefined - const isNewDaemonSession = - !preAdoptedStablePane && - isDaemonHostSpawn && - (callerRequestedSessionId === undefined || args.isNewSession === true) - const expectedWslDistro = !args.connectionId - ? (resolveWslSessionContext({ - cwd, - sessionId, - shellOverride: terminalRuntimeOptions.shellOverride, - terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro - })?.distro ?? null) - : null - const codexSelectionTarget = getCodexSelectionTargetForPty( - daemonShellOverride, - cwd, - expectedWslDistro - ) - const codexResumePreparation = preAdoptedStablePane - ? null - : prepareCodexResumeHome({ - connectionId: args.connectionId, - launchAgent: args.launchAgent, - providerSession: args.resumeProviderSession, - target: codexSelectionTarget, - launchEnv: args.env, - workspacePath: cwd - }) - const codexResumeLaunch = codexResumePreparation - ? await resolveCodexResumeLaunch(args.command, codexResumePreparation) - : noCodexResumeLaunch(preAdoptedStablePane ? undefined : args.command) - const codexResumeHome = codexResumeLaunch.codexResumeHome - // Why: the drop still applies here, but this controller's result has no field for - // notifyResumeUnavailable — runtime/relay panes start fresh without the notice. - const launchCommand = codexResumeLaunch.command - const claudeAuth = - isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(codexSelectionTarget) : null - if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') - } - if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { - throw new Error( - 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' - ) - } - - const shouldPersistHostSessionBinding = args.persistHostSessionBinding === true - let hostSessionBinding: { - store: NonNullable - worktreeId: string - tabId: string - leafId: string - expectedSourceBinding?: PtyBindingSourceExpectation - } | null = null - if (shouldPersistHostSessionBinding) { - if ( - !store || - typeof args.worktreeId !== 'string' || - typeof args.tabId !== 'string' || - !isValidTerminalTabId(args.tabId) || - typeof args.leafId !== 'string' || - !isTerminalLeafId(args.leafId) - ) { - throw new Error( - 'Cannot persist runtime PTY binding without worktreeId, tabId, and leafId' - ) - } - hostSessionBinding = { - store, - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: args.leafId, - ...(args.expectedSourceBinding - ? { expectedSourceBinding: args.expectedSourceBinding } - : {}) - } - } - const sshScopedEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env) - let env: Record | undefined = claudeAuth - ? { ...sshScopedEnv, ...claudeAuth.envPatch } - : sshScopedEnv - const requestedAgentTeamsPath = env?.ORCA_AGENT_TEAMS_TEAM_ID - ? env[resolvePathEnvKey(env, process.platform)] - : undefined - env = stripSequencedStartupResumeArgv(env, codexResumeLaunch) - if (args.preAllocatedHandle) { - env = { ...env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } - } - let selectedCodexHomePath = resolveSelectedCodexHomeOrRefuseSpawn(() => - !preAdoptedStablePane && !args.connectionId - ? getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - codexResumeHome - ? reconcileSharedRuntimeResumeHome(codexResumeHome, () => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null - ) - ) - : (getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null) - ) - : null - ) - if ( - !preAdoptedStablePane && - args.launchAgent === 'codex' && - callerRequestedSessionId === undefined - ) { - const resolution = resolveCodexHomeAfterManagedAuthReadiness({ - selectedCodexHomePath, - getSettings: () => getSettings?.(), - requiredCodexHomePath: codexResumeHome?.codexHomePath, - target: codexSelectionTarget, - resolveCurrent: () => - resolveSelectedCodexHomeOrRefuseSpawn(() => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: 'codex' - }) ?? null - ) - ), - resolveAfterUnavailable: (unavailableManagedHomePath) => - resolveSelectedCodexHomeOrRefuseSpawn(() => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, env, { - workspacePath: cwd, - launchAgent: 'codex', - unavailableManagedHomePath - }) ?? null - ) - ) - }) - selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution - } - if (args.launchAgent === 'codex' && selectedCodexHomePath) { - await ensureCodexStateDbBackfillRecoveryStarted(selectedCodexHomePath) - } - const codexResumeHomeSelected = Boolean( - codexResumeHome && codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath) - ) - const skipCodexHomeEnv = - isDaemonHostSpawn && - shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) && - !selectedCodexHomePath - const ptySettings = isDaemonHostSpawn ? getSettings?.() : undefined - const stripInheritedOrcaCodexHome = - isDaemonHostSpawn && - shouldStripInheritedOrcaCodexHome({ - target: codexSelectionTarget, - selectedCodexHomePath, - skipCodexHomeEnv, - settings: ptySettings - }) - if (isDaemonHostSpawn && sessionId && !preAdoptedStablePane) { - if (!isSafePtySessionId(sessionId, getAppEnvironment().getPath('userData'))) { - throw new Error('Invalid PTY session id') - } - env = buildPtyHostEnv(sessionId, env ?? {}, { - isPackaged: getAppEnvironment().isPackaged(), - resourcesPath: process.resourcesPath, - userDataPath: getAppEnvironment().getPath('userData'), - selectedCodexHomePath, - skipCodexHomeEnv, - stripInheritedOrcaCodexHome, - launchCommand, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, - isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd), - wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null, - agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), - codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), - networkProxySettings: ptySettings, - deferGitConfigGuardToDaemon: provider.supportsGitCredentialGuardHost?.(sessionId) === true - }) - stampWslOrchestrationCompatibilityHost( - env, - runtime?.getOrchestrationCompatibilityHostId?.(), - codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null - ) - promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) - } - - const authEnvToDelete = claudeAuth?.stripAuthEnv - ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] - : undefined - const spawnOptions: PtySpawnOptions = { - cols: args.cols, - rows: args.rows, - cwd, - env, - ...(isNewDaemonSession ? { isNewSession: true } : {}), - historyIsolationEnabled: getSettings?.()?.terminalScopeHistoryByWorktree ?? true - } - if (!args.connectionId && !isDaemonHostSpawn) { - spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath } - } - const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) - if (startupTerminalColorQueryReplyColors) { - spawnOptions.startupIngress = { - colors: startupTerminalColorQueryReplyColors, - deadlineMs: 5_000 - } - } - let ptySpawnCommitReported = false - const reportPtySpawnCommitted = (): void => { - if (ptySpawnCommitReported) { - return - } - ptySpawnCommitReported = true - args.onPtySpawnCommitted?.() - } - spawnOptions.envToDelete = mergePtyEnvDeletions( - authEnvToDelete, - args.envToDelete ?? [], - // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. - isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], - isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [], - // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. - getInheritedClaudeSessionStampEnvKeysToDelete(env) - ) - if (skipCodexHomeEnv) { - spawnOptions.envToDelete = mergePtyEnvDeletions( - spawnOptions.envToDelete, - CODEX_HOME_ENV_KEYS - ) - } else if (stripInheritedOrcaCodexHome) { - // Why: the daemon owns a persistent inherited environment that may - // differ from main. ORCA_CODEX_HOME asks it to compare/delete the pair. - spawnOptions.envToDelete = mergePtyEnvDeletions(spawnOptions.envToDelete, [ - 'ORCA_CODEX_HOME' - ]) - } - if (codexResumeHomeSelected) { - spawnOptions.envToDelete = removeCodexHomeDeletionRequests(spawnOptions.envToDelete) - } - deleteRequestedEnvKeys(env, spawnOptions.envToDelete) - promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) - if (launchCommand !== undefined) { - spawnOptions.command = launchCommand - } - if (args.commandDelivery !== undefined) { - spawnOptions.commandDelivery = args.commandDelivery - } - if (args.startupCommandDelivery !== undefined) { - spawnOptions.startupCommandDelivery = args.startupCommandDelivery - } - if (isTuiAgent(args.launchAgent)) { - spawnOptions.launchAgent = args.launchAgent - } - if (args.worktreeId !== undefined) { - spawnOptions.worktreeId = args.worktreeId - } - const hadSessionSizeBeforeAttach = - effectiveSessionAppId !== undefined ? ptySizes.has(effectiveSessionAppId) : false - const sessionSizeBeforeAttach = - effectiveSessionAppId !== undefined ? ptySizes.get(effectiveSessionAppId) : undefined - if (sessionId !== undefined) { - spawnOptions.sessionId = sessionId - ptySizes.set(effectiveSessionAppId ?? sessionId, { cols: args.cols, rows: args.rows }) - } - const materializedPaneKey = hostSessionBinding - ? makePaneKey(hostSessionBinding.tabId, hostSessionBinding.leafId) - : null - const metadataLeafId = - typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null - const metadataPaneKey = - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - metadataLeafId - ? makePaneKey(args.tabId, metadataLeafId) - : null - const spawnIdentityPaneKey = materializedPaneKey ?? metadataPaneKey - if (spawnIdentityPaneKey) { - spawnOptions.paneKey = spawnIdentityPaneKey - } - if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) { - spawnOptions.tabId = args.tabId - } - if (process.platform === 'win32' && !args.connectionId) { - spawnOptions.shellOverride = terminalRuntimeOptions.shellOverride - spawnOptions.terminalWindowsWslDistro = expectedWslDistro - spawnOptions.terminalWindowsPowerShellImplementation = getSettings - ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') - : undefined - } - if ( - !preAdoptedStablePane && - args.agentSessionEnsure && - (await (provider as IPtyProvider).supportsAgentSessionClaims?.()) === false - ) { - // Why: runtime routing must select legacy before dispatch; never downgrade here after it began. - throw new Error('agent_session_claim_unavailable') - } - if ( - !preAdoptedStablePane && - args.agentSessionCreateOperationId && - (await (provider as IPtyProvider).supportsAgentSessionCreateOperations?.()) === false - ) { - throw new Error('execution_owner_unavailable') - } - if (!preAdoptedStablePane && args.agentSessionEnsure) { - spawnOptions.agentSessionEnsure = args.agentSessionEnsure - } - if (!preAdoptedStablePane && args.agentSessionCreateOperationId) { - spawnOptions.agentSessionCreateOperationId = args.agentSessionCreateOperationId - } - if (args.signal) { - spawnOptions.signal = args.signal - } - if ( - args.onPtySpawnCommitted && - (provider instanceof LocalPtyProvider || routesFreshSpawnsToLocalProvider(provider)) - ) { - // Why: local fallback has no lower operation ledger, so commit must be reported at native spawn. - spawnOptions.onPtySpawnCommitted = reportPtySpawnCommitted - } - - const paneSpawnReservationKey = makePaneSpawnReservationKey( - args.worktreeId, - args.connectionId, - spawnIdentityPaneKey - ) - const existingPaneSpawn = paneSpawnReservationKey - ? paneSpawnReservationsByOwnerKey.get(paneSpawnReservationKey) - : undefined - if (existingPaneSpawn) { - const concurrentResult = await existingPaneSpawn.promise - const concurrentOwner = resolveStablePaneOwner( - runtime, - store, - spawnIdentityPaneKey, - args.worktreeId, - args.connectionId - ) - if ( - !concurrentOwner?.handle || - concurrentOwner.ptyId !== concurrentResult.id || - (concurrentOwner.incarnationId !== undefined && - concurrentResult.incarnationId !== undefined && - concurrentOwner.incarnationId !== concurrentResult.incarnationId) - ) { - throw new Error('terminal_pane_owner_unknown') - } - return { - id: concurrentOwner.ptyId, - ...(concurrentOwner.incarnationId - ? { incarnationId: concurrentOwner.incarnationId } - : {}), - stablePaneOwner: { - handle: concurrentOwner.handle, - tabId: concurrentOwner.tabId, - leafId: concurrentOwner.leafId - } - } - } - const finishTerminalInstall = beginPtySpawnForWorktree( - args.worktreeId, - cwd, - args.connectionId - ) - const paneSpawnReservation = paneSpawnReservationKey - ? reservePaneSpawn(paneSpawnReservationKey) - : null - let result: PtySpawnResult - let stablePaneOwner: StablePaneOwner | null = null - let stablePaneBindingPersisted = false - let rejectedRegistrationCandidate: PtySpawnResult | null = null - let pendingRegistrationPtyId: string | null = null - // Why hoisted to the reply scope: main reconciles the provider sequence - // deep inside the spawn path, but the pane needs that renderer-domain - // boundary beside the daemon snapshot's kitty flags. - let reconciledSnapshotSeq: number | null = null - // False when bytes crossed the data socket during the spawn RPC: the - // reconciled boundary covers them, but the daemon proved its kitty flags - // before they existed, so the claim must not erase what the pane may - // have scanned from those bytes live. - let snapshotKittyFlagsCoverReconciledSeq = true - let preparedProvisionalExecutionContext = false - let releaseWorktreeSpawn: (() => void) | undefined - try { - releaseWorktreeSpawn = await runtime?.acquireWorktreeTerminalSpawn?.(args.worktreeId) - try { - if (args.preAllocatedHandle) { - trustedTerminalHandleEnv.add(args.preAllocatedHandle) - } - const stablePaneOwnerCandidate = preAdoptedStablePane - ? preAdoptedStablePane.owner - : args.agentSessionEnsure - ? null - : resolveStablePaneOwner( - runtime, - store, - spawnIdentityPaneKey, - args.worktreeId, - args.connectionId - ) - const expectedPtyId = - stablePaneOwnerCandidate?.ptyId ?? effectiveSessionAppId ?? sessionId - if (expectedPtyId) { - runtime?.beginPtyRegistration?.(expectedPtyId) - pendingRegistrationPtyId = expectedPtyId - } - if (isDaemonHostSpawn && expectedPtyId) { - preparedProvisionalExecutionContext = - runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, { - resetIncarnation: isNewDaemonSession && !stablePaneOwnerCandidate, - preserveExisting: !isNewDaemonSession || Boolean(stablePaneOwnerCandidate) - }) ?? false - } - const sequenceBeforeProviderSpawn = expectedPtyId - ? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) - : 0 - const assertClientStillConnected = (): void => { - if (args.signal?.aborted) { - throw new Error('client_disconnected') - } - } - if (args.agentSessionEnsure && !preAdoptedStablePane) { - // Why: daemon-backed claims can outlive this controller; import all - // proven owners before deciding that an identity is absent. - await reconcileAgentSessionOwnerListings() - const recoveredOwner = agentSessionOwners.find(args.agentSessionEnsure.claim) - if (recoveredOwner && pendingRegistrationPtyId !== recoveredOwner.ptyId) { - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId) - } - runtime?.beginPtyRegistration?.( - recoveredOwner.ptyId, - ptyIncarnationById.get(recoveredOwner.ptyId) - ) - pendingRegistrationPtyId = recoveredOwner.ptyId - } - let providerResult: PtySpawnResult | null = null - const ensured = await agentSessionOwners.ensure({ - claim: args.agentSessionEnsure.claim, - surface: args.agentSessionEnsure.surface, - spawn: async () => { - assertClientStillConnected() - providerResult = await provider.spawn(spawnOptions) - rejectedRegistrationCandidate = providerResult - // Why: a successful lower-owner return proves physical work committed even if admission sees an early exit. - reportPtySpawnCommitted() - assertSpawnReplyWasLive(providerResult) - runtime?.assertPtyRegistrationAllowed?.( - providerResult.id, - providerResult.incarnationId - ) - if (providerResult.incarnationId) { - // Why: local providers cannot serialize controller claims, so liveness proof - // needs the exact incarnation before the registry promotes the new owner. - ptyIncarnationById.set(providerResult.id, providerResult.incarnationId) - } - const providerEnsure = providerResult.agentSessionEnsure - return { - ptyId: providerResult.id, - ...(providerEnsure - ? { - owner: providerEnsure.owner, - disposition: providerEnsure.disposition - } - : {}) - } - }, - isLive: async (owner) => { - const ownerProvider = tryGetProviderForAgentSessionOwner(owner.ptyId) - if (!ownerProvider) { - // Why: a disconnected relay may keep its PTY alive during the - // grace window; missing transport is unknown, never absence. - throw new Error('execution_owner_unavailable') - } - return await isProviderAgentSessionOwnerLive(ownerProvider, owner) - } - }) - result = providerResult ?? { - id: ensured.owner.ptyId, - isReattach: true, - // Why: adoption from an authoritative listing must preserve the - // incarnation proof used to reject a delayed exit from an older process. - incarnationId: ptyIncarnationById.get(ensured.owner.ptyId) - } - result.agentSessionEnsure = ensured - } else { - assertClientStillConnected() - const stablePaneSpawn = preAdoptedStablePane - ? preAdoptedStablePane - : await spawnForStablePane({ - runtime, - store, - provider, - spawnOptions, - owner: stablePaneOwnerCandidate, - worktreeId: args.worktreeId, - connectionId: args.connectionId, - resolveOwner: () => - resolveStablePaneOwner( - runtime, - store, - spawnIdentityPaneKey, - args.worktreeId, - args.connectionId - ), - onFreshSpawn: reportPtySpawnCommitted - }) - result = stablePaneSpawn.result - stablePaneOwner = stablePaneSpawn.owner - if ( - stablePaneOwner && - isNewDaemonSession && - effectiveSessionAppId && - effectiveSessionAppId !== result.id - ) { - clearProviderPtyState(effectiveSessionAppId) - } - rejectedRegistrationCandidate = result - assertSpawnReplyWasLive(result) - } - rejectedRegistrationCandidate ??= result - if (pendingRegistrationPtyId !== result.id) { - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId) - } - runtime?.beginPtyRegistration?.(result.id, result.incarnationId) - pendingRegistrationPtyId = result.id - } - // Why: admission precedes sequence/context state and every durable publication below. - runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId) - if (result.providerSequence) { - const runtimeSequenceBeforeReconcile = runtime?.getPtyOutputSequence?.(result.id) ?? 0 - // Why kept: this is the reattach boundary in the RENDERER's sequence - // domain, and the daemon snapshot's kitty flags mean nothing without - // the boundary they were proven at. - reconciledSnapshotSeq = - runtime?.synchronizePtyOutputSequenceFromProvider?.( - result.id, - result.providerSequence, - sequenceBeforeProviderSpawn - ) ?? null - if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) { - snapshotKittyFlagsCoverReconciledSeq = false - } - } - ensureWslHookRelayForReattach(result, args.connectionId) - runtime?.preparePtyExecutionContext?.( - result.id, - args.connectionId - ? null - : result.wslDistro === undefined - ? expectedWslDistro - : result.wslDistro - ) - } catch (err) { - if ( - (isNewDaemonSession || preparedProvisionalExecutionContext) && - effectiveSessionAppId - ) { - runtime?.preparePtyExecutionContext?.(effectiveSessionAppId, null, { - resetIncarnation: true - }) - } - const rawMessage = err instanceof Error ? err.message : String(err) - if (rawMessage === 'agent_session_exited_during_start' && rejectedRegistrationCandidate) { - runtime?.releaseRejectedPtyRegistrationFence?.( - rejectedRegistrationCandidate.id, - rejectedRegistrationCandidate.incarnationId - ) - } - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.( - pendingRegistrationPtyId, - rejectedRegistrationCandidate?.incarnationId - ) - pendingRegistrationPtyId = null - } - const spawnError = normalizeNodePtySpawnError(err) - const isIdentityMismatch = - isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage) - if (effectiveSessionAppId !== undefined) { - if (isIdentityMismatch && hadSessionSizeBeforeAttach && sessionSizeBeforeAttach) { - ptySizes.set(effectiveSessionAppId, sessionSizeBeforeAttach) - } else { - ptySizes.delete(effectiveSessionAppId) - } - } - if ( - args.connectionId && - effectiveSessionRelayId !== undefined && - (spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) || - rawMessage.includes(SSH_SESSION_EXPIRED_ERROR)) - ) { - if (effectiveSessionAppId !== undefined && !isIdentityMismatch) { - clearProviderPtyState(effectiveSessionAppId) - deletePtyOwnership(effectiveSessionAppId) - } - if (!isIdentityMismatch) { - store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired') - } - } - if (isNewDaemonSession && sessionId !== undefined) { - clearProviderPtyState(sessionId) - } - throw spawnError - } finally { - if (args.preAllocatedHandle) { - trustedTerminalHandleEnv.delete(args.preAllocatedHandle) - } - } - try { - stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ - store: hostSessionBinding?.store, - owner: stablePaneOwner, - result, - worktreeId: hostSessionBinding?.worktreeId, - startupCwd: cwd, - connectionId: args.connectionId - }) - } catch (error) { - if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { - throw error - } - console.error('[pty] failed to persist runtime PTY binding after attach:', error) - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { - agentSessionOperationOutcome: 'unknown' as const - }) - } - if (result.agentSessionEnsure?.disposition === 'adopted') { - const owner = result.agentSessionEnsure.owner - ptyOwnership.set(result.id, args.connectionId ?? ptyOwnership.get(result.id) ?? null) - runtime?.registerPreAllocatedHandleForPty(result.id, owner.surface.terminalHandle) - if (result.incarnationId) { - ptyIncarnationById.set(result.id, result.incarnationId) - } - runtime?.registerPty(result.id, owner.surface.worktreeId, args.connectionId ?? null, { - tabId: owner.surface.tabId, - leafId: owner.surface.leafId, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) - }) - if (!args.connectionId) { - options?.onCodexHomePtySpawned?.({ - id: result.id, - codexHomePath: selectedCodexHomePath, - reattached: true, - startedAt: codexHomeLaunchStartedAt, - startedSequence: codexHomeLaunchStartedSequence, - ...codexReattachedHomeRouteField(reattachedCodexHomeRoutes, result.id, true), - ...(env ? { launchEnv: env } : {}) - }) - } - return { - id: result.id, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - agentSessionEnsure: result.agentSessionEnsure - } - } - ptyOwnership.set(result.id, args.connectionId ?? null) - if (result.incarnationId) { - ptyIncarnationById.set(result.id, result.incarnationId) - } - // Why: record the native-Windows-local-PTY determination before any byte reaches the emulator, so its ConPTY DA1 override exists from byte zero. - if ( - isNativeWindowsLocalPtySpawn({ - connectionId: args.connectionId, - cwd: args.cwd, - shellOverride: daemonShellOverride - }) - ) { - markNativeWindowsConptyPty(result.id) - } - const relayResultId = getRelayPtyId(args.connectionId, result.id) - const persistSshLease = (): void => { - if (!store || !args.connectionId) { - return - } - // Why: SSH leases keep relay ids for remote reconciliation, while session bindings keep app-facing ids for hydration. - store.upsertSshRemotePtyLease({ - targetId: args.connectionId, - ptyId: relayResultId, - ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), - ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), - ...(typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) - ? { leafId: args.leafId } - : {}), - state: 'attached', - lastAttachedAt: Date.now() - }) - } - if (!hostSessionBinding) { - persistSshLease() - } - ptySizes.set(result.id, { cols: args.cols, rows: args.rows }) - if (effectiveSessionAppId !== undefined && effectiveSessionAppId !== result.id) { - ptySizes.delete(effectiveSessionAppId) - } - recordCodexPaneAccountForSpawn({ - ptyId: result.id, - isDaemonHostSpawn, - isReattach: result.isReattach === true, - pinnedByResume: codexResumeHomeSelected, - launchCodexHomePath: selectedCodexHomePath, - launchEnv: args.env, - target: codexSelectionTarget, - settings: getSettings?.() - }) - if (hostSessionBinding && !stablePaneBindingPersisted) { - try { - const binding = { - worktreeId: hostSessionBinding.worktreeId, - tabId: hostSessionBinding.tabId, - leafId: hostSessionBinding.leafId, - ptyId: result.id, - hostAdmittedMembership: true, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(cwd ? { startupCwd: cwd } : {}), - ...(hostSessionBinding.expectedSourceBinding - ? { expectedSourceBinding: hostSessionBinding.expectedSourceBinding } - : {}) - } - const persisted = args.connectionId - ? hostSessionBinding.store.persistPtyBinding( - binding, - toSshExecutionHostId(args.connectionId) - ) - : hostSessionBinding.store.persistPtyBinding(binding) - if (persisted === false) { - throw new Error('terminal_split_source_not_found') - } - } catch (err) { - console.error('[pty] failed to persist runtime PTY binding after spawn:', err) - if (!result.isReattach) { - deletePtyOwnership(result.id) - try { - await provider.shutdown(result.id, { immediate: true }) - } catch (shutdownErr) { - console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) - } - clearProviderPtyState(result.id) - } - if (err instanceof Error && err.message === 'terminal_split_source_not_found') { - throw err - } - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { - agentSessionOperationOutcome: 'unknown' as const - }) - } - persistSshLease() - } - if (args.preAllocatedHandle && !stablePaneOwner?.handle) { - runtime?.registerPreAllocatedHandleForPty(result.id, args.preAllocatedHandle) - } - if (args.worktreeId) { - runtime?.registerPty( - result.id, - args.worktreeId, - args.connectionId ?? null, - // Why: thread validated pane identity so main can back a pending mobile create even if graph-sync stalls (#7587). - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - metadataLeafId !== null - ? { - tabId: args.tabId, - leafId: metadataLeafId, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) - } - : undefined, - !args.connectionId - ? shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd) - : undefined - ) - } else { - // Why: non-worktree PTYs have no later surface-registration phase to clear admission intent. - runtime?.cancelPendingPtyRegistration?.(result.id, result.incarnationId) - } - // Why: runtime-controller creates (headless serve, CLI, splits) adopt surviving daemon sessions too; without this seed their records stay blank. - seedTerminalRestoreRecordsFromSpawnResult(runtime, result) - // Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity). - if (!stablePaneOwner) { - runtime?.noteTerminalSpawnCommand?.(result.id, launchCommand ?? null) - } - if (isClaudeLaunch && !stablePaneOwner) { - markClaudePtySpawned(result.id) - } - if (args.telemetry && !stablePaneOwner) { - const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) - const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) - const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) - if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { - track('agent_started', { - agent_kind: agentKindParse.data, - launch_source: launchSourceParse.data, - request_kind: requestKindParse.data, - ...getCohortAtEmit() - }) - } - } - // Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; record paneKey here too since hook titles and cache cleanup need this reverse lookup. - const paneKey = rememberPaneKeyForPty(result.id, env?.ORCA_PANE_KEY) - const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined - const inheritRendererReadiness = - result.isReattach === true && - !pendingSerializer && - rendererSerializerReadiness.has(result.id) - rendererSerializerReadiness.beginIncarnation(result.id, inheritRendererReadiness) - if (paneKey && pendingSerializer) { - pendingPtyIdBySerializerGeneration.set(pendingSerializer.gen, result.id) - } - if (!args.connectionId) { - registerPty({ - ptyId: result.id, - worktreeId: args.worktreeId ?? null, - sessionId: sessionId ?? null, - paneKey, - pid: - typeof result.pid === 'number' && Number.isFinite(result.pid) && result.pid > 0 - ? result.pid - : null - }) - } - // Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal. - sendPtySpawnedToRenderer(result.id) - if (!args.connectionId) { - options?.onCodexHomePtySpawned?.({ - id: result.id, - codexHomePath: selectedCodexHomePath, - startedAt: codexHomeLaunchStartedAt, - startedSequence: codexHomeLaunchStartedSequence, - ...codexReattachedHomeRouteField( - reattachedCodexHomeRoutes, - result.id, - result.isReattach === true - ), - ...(result.isReattach === true ? { reattached: true } : env ? { launchEnv: env } : {}) - }) - } - const response = { - id: result.id, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(stablePaneOwner && (stablePaneOwner.handle || args.preAllocatedHandle) - ? { - stablePaneOwner: { - handle: stablePaneOwner.handle ?? args.preAllocatedHandle!, - tabId: stablePaneOwner.tabId, - leafId: stablePaneOwner.leafId - } - } - : {}), - ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) - } - resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, { - ...result, - ...(typeof result.snapshotKittyKeyboardFlags === 'number' && - reconciledSnapshotSeq !== null && - snapshotKittyFlagsCoverReconciledSeq - ? { snapshotSeq: reconciledSnapshotSeq } - : { snapshotKittyKeyboardFlags: undefined }), - isReattach: true - }) - return response - } catch (err) { - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.( - pendingRegistrationPtyId, - rejectedRegistrationCandidate?.incarnationId - ) - pendingRegistrationPtyId = null - } - // Why: once the reservation is created, any later throw — spawn - // failure, persist failure, or a post-spawn helper such as - // registerPty/rememberPaneKeyForPty/track — must settle it. Otherwise - // it lingers in paneSpawnReservationsByOwnerKey and every future spawn - // for this pane awaits a promise that never resolves. reject is a - // no-op once the reservation has already resolved. - rejectPaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, err) - throw err - } finally { - releaseWorktreeSpawn?.() - finishTerminalInstall() - } - }, - write: (ptyId, data) => { - try { - return getProviderForPty(ptyId).write(ptyId, data) !== false - } catch { - return false - } - }, - writeWithSettlement: async (ptyId, data) => { - try { - const provider = getProviderForPty(ptyId) - return provider.writeWithSettlement - ? await provider.writeWithSettlement(ptyId, data) - : provider.write(ptyId, data) !== false - } catch { - return false - } - }, - probePtyLiveness: async (ptyId) => { - try { - // Why: no locally routed provider can authoritatively answer for a - // remote host's PTY, so remote-scoped ids stay unknown, never absent. - if (ptyId.startsWith('remote:')) { - return null - } - const connectionId = ptyOwnership.get(ptyId) ?? parseAppSshPtyId(ptyId)?.connectionId - // Why: during cold start the daemon swap is in flight; the pre-swap - // fallback would answer absent for every daemon-owned id. - const startupPromise = getLocalPtyProviderStartupPromise(connectionId) - if (startupPromise) { - await startupPromise - } - const provider = getProviderForPty(ptyId) - if (provider.probePtyLiveness) { - return await provider.probePtyLiveness(ptyId) - } - // Why: the in-process provider is its own sole owner (#12393), so its - // refusal is authoritative; every other probe-less provider is doubt. - if (provider instanceof LocalPtyProvider) { - return provider.hasPty(ptyId) - } - return null - } catch { - return null - } - }, - // Why: subscriber-driven ingestion for daemon sessions no renderer pane - // ever attached. Local daemon sessions only — SSH panes have their own - // lease machinery, and the in-process local provider streams without - // attach. Attach-only and false-on-doubt: never creates or resizes. - attach: async (ptyId) => { - if (ptyOwnership.get(ptyId) != null || parseAppSshPtyId(ptyId)) { - return false - } - let provider: IPtyProvider - try { - provider = getProviderForPty(ptyId) - } catch { - return false - } - if (provider !== localProvider || provider instanceof LocalPtyProvider) { - return false - } - try { - const sequenceBeforeProviderAttach = runtime?.getPtyOutputSequence?.(ptyId) ?? 0 - const attachResult = await provider.attach(ptyId) - if (attachResult?.providerSequence) { - runtime?.synchronizePtyOutputSequenceFromProvider?.( - ptyId, - attachResult.providerSequence, - sequenceBeforeProviderAttach - ) - } - return true - } catch { - return false - } - }, - kill: (ptyId) => { - runtime?.markPtyStopRequested?.(ptyId) - let connectionId: string | null | undefined = ptyOwnership.get(ptyId) - const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null - connectionId ??= parsedSshId?.connectionId - const killWithCurrentProvider = (): boolean => { - let provider: IPtyProvider - try { - provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId) - } catch { - if (connectionId) { - // Why: runtime/CLI close can target a detached SSH PTY after its - // provider was unregistered. Tombstone the lease so reconnect does - // not revive a terminal the user explicitly closed — but a detached - // relay PTY outlives its provider, so report an unconfirmed stop - // rather than a kill nobody performed. - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) - return false - } - return false - } - // Why: controller is synchronous, but keep ownership until async shutdown proves whether the provider emitted an exit. - void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false }) - .then((providerExitObserved) => { - const retired = retiredRejectedPtyIds.has(ptyId) - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - if (!providerExitObserved && !retired) { - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - } - }) - .catch((err) => { - const retired = retiredRejectedPtyIds.has(ptyId) - if (isPtyAlreadyGoneError(err)) { - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - if (!retired) { - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - } - return - } - console.warn( - `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` - ) - // Why: close runtime tails without clearing provider ownership, so - // a retry can still target a PTY that survived the failed shutdown. - if (!retired) { - if (connectionId) { - runtime?.markPtyLivenessUnverifiable?.( - ptyId, - err instanceof Error ? err.message : String(err) - ) - } - runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) - } - }) - return true - } - const startupPromise = getLocalPtyProviderStartupPromise(connectionId) - if (startupPromise) { - // Why: select the provider after the daemon swap; the fallback first can report success while orphaning a daemon PTY. - void startupPromise.then(killWithCurrentProvider).catch((err) => { - console.warn( - `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` - ) - if (!retiredRejectedPtyIds.has(ptyId)) { - if (connectionId) { - runtime?.markPtyLivenessUnverifiable?.( - ptyId, - err instanceof Error ? err.message : String(err) - ) - } - runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) - } - }) - return true - } - return killWithCurrentProvider() - }, - retireRejectedPty: (ptyId, stopConfirmed) => { - rememberRetiredRejectedPty(ptyId) - if (!stopConfirmed) { - runtime?.markPtyLivenessUnverifiable?.( - ptyId, - 'a follow-up stop was issued but its outcome could not be verified' - ) - if (!ptyOwnership.has(ptyId)) { - return - } - runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - return - } - // Why: a completed stop already cleared provider state, tombstoned the lease and told the - // renderer; repeating that double-fires the exit IPC. - if (!ptyOwnership.has(ptyId)) { - runtime?.onPtyExit(ptyId, 0, ptyIncarnationById.get(ptyId)) - return - } - let connectionId: string | null | undefined = ptyOwnership.get(ptyId) - const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null - connectionId ??= parsedSshId?.connectionId - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, 0, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: 0 }) - }, - markReversibleStops: (ptyIds) => { - for (const ptyId of ptyIds) { - reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1) - } - let released = false - return () => { - if (released) { - return - } - released = true - for (const ptyId of ptyIds) { - const owners = (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) - 1 - if (owners > 0) { - reversibleStopOwnersByPtyId.set(ptyId, owners) - } else { - reversibleStopOwnersByPtyId.delete(ptyId) - } - } - } - }, - stopAndWait: async (ptyId, opts) => { - runtime?.markPtyStopRequested?.(ptyId) - let connectionId: string | null | undefined = ptyOwnership.get(ptyId) - const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null - connectionId ??= parsedSshId?.connectionId - // Why: destructive teardown threads one absolute deadline through every await - // below; each RPC leaf converts it to the remaining time when it issues, so - // sequential RPCs share the budget and cannot overrun the sweep deadline. - const deadlineMs = opts?.deadlineMs - const startupPromise = getLocalPtyProviderStartupPromise(connectionId) - if (startupPromise) { - // Why: exact-stop must resolve the provider after daemon startup just - // like renderer kills, or the fallback can falsely confirm teardown. - if (deadlineMs !== undefined) { - // Why: bound the cold-start await by the teardown deadline instead of the - // 60s startup fail-open cap; fail closed so the sweep records the miss. - const won = await Promise.race([ - // Why: () => false on rejection both fails closed on a startup error and - // keeps the losing branch's rejection from surfacing as unhandled. - startupPromise.then( - () => true, - () => false - ), - delay(Math.max(1, deadlineMs - Date.now())).then(() => false) - ]) - if (!won) { - return false - } - } else { - await startupPromise - } - } - let provider: IPtyProvider - try { - provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId) - } catch { - if (connectionId) { - // Why: the relay lease must still be tombstoned, but an absent SSH - // provider is lost contact — the remote PTY is designed to survive it, - // so nothing here observed an exit to report as a confirmed stop. - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - runtime?.onPtyExit(ptyId, -1, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) - runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) - } - return false - } - let providerExitObserved = false - try { - providerExitObserved = await shutdownProviderAndDetectExit(provider, ptyId, { - immediate: true, - keepHistory: opts?.keepHistory ?? false, - deadlineMs - }) - } catch (err) { - if (!isPtyAlreadyGoneError(err)) { - if (connectionId) { - runtime?.markPtyLivenessUnverifiable?.( - ptyId, - err instanceof Error ? err.message : String(err) - ) - } - console.warn( - `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` - ) - return false - } - } - try { - if (!(await verifyPtyStopped(provider, ptyId, opts))) { - runtime?.markPtyLivenessLive?.(ptyId) - return false - } - } catch (err) { - if (connectionId) { - runtime?.markPtyLivenessUnverifiable?.( - ptyId, - err instanceof Error ? err.message : String(err) - ) - } - console.warn( - `[pty] Failed to verify PTY ${ptyId} stopped: ${ - err instanceof Error ? err.message : String(err) - }` - ) - return false - } - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) - if (!providerExitObserved) { - // The owning provider's fresh inventory observed absence, so this is a - // death certificate even when its exit event was missed. - runtime?.onPtyExit(ptyId, 0, incarnationId) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: 0 }) - } - return true - }, - getForegroundProcess: async (ptyId) => { - try { - return await getProviderForPty(ptyId).getForegroundProcess(ptyId) - } catch { - return null - } - }, - inspectProcess: async (ptyId) => inspectPtyProviderProcess(getProviderForPty(ptyId), ptyId), - confirmForegroundProcess: async (ptyId) => { - try { - const provider = getProviderForPty(ptyId) - // Why: cached foreground evidence cannot resolve a fresh shell conflict. - return (await provider.confirmForegroundProcess?.(ptyId)) ?? null - } catch { - return null - } - }, - getCwd: async (ptyId) => { - try { - const cwd = await getProviderForPty(ptyId).getCwd(ptyId) - return cwd || null - } catch { - return null - } - }, - hasChildProcesses: async (ptyId) => { - try { - return await getProviderForPty(ptyId).hasChildProcesses(ptyId) - } catch { - return false - } - }, - clearBuffer: async (ptyId) => { - // Why: desktop xterm and daemon/SSH providers hold separate buffers; clear both so mobile resubscribe can't resurrect cleared history. - sendToRenderer(mainWindow, 'pty:clearBuffer:request', { ptyId }) - try { - await getProviderForPty(ptyId).clearBuffer(ptyId) - } catch { - /* best effort: renderer clear still handles local PTYs */ - } - }, - hasPty: (ptyId) => { - try { - return getProviderForPty(ptyId).hasPty?.(ptyId) ?? null - } catch { - return null - } - }, - listProcesses: async (connectionId, opts) => { - if (connectionId === null) { - return localProvider.listProcesses() - } - if (connectionId !== undefined) { - try { - return await getProvider(connectionId).listProcesses(opts) - } catch (error) { - markSshInventoryUnverifiable(connectionId, error) - throw error - } - } - return (await listRegisteredPtyProcessesWithHostScope(markSshInventoryUnverifiable, opts)) - .processes - }, - listProcessesWithHostScope: (opts) => - listRegisteredPtyProcessesWithHostScope(markSshInventoryUnverifiable, opts), - serializeBuffer: (ptyId, opts) => { - // Why: mobile xterm must start from the desktop's exact screen state/dimensions before live TUI chunks render correctly. - return requestSerializedBuffer(ptyId, opts) - }, - serializeProviderBuffer: async (ptyId, opts) => { - try { - // Why: restored daemon PTYs can be live while their desktop pane is unmounted; query the provider model so phone-local navigation works. - return (await getProviderForPty(ptyId).getBufferSnapshot?.(ptyId, opts)) ?? null - } catch { - return null - } - }, - hasRendererSerializer: (ptyId) => { - // Why: a synchronous probe lets the runtime decide whether to skip the daemon-snapshot seed (renderer will hydrate) or run it (no renderer authoritative). - return rendererSerializerReadiness.has(ptyId) - }, - getRendererSerializerGeneration: (ptyId) => { - return rendererSerializerReadiness.generation(ptyId) - }, - waitForRendererSerializer: (ptyId, afterGeneration, timeoutMs, signal) => { - return rendererSerializerReadiness.wait(ptyId, afterGeneration, timeoutMs, signal) - }, - getSize: (ptyId) => ptySizes.get(ptyId) ?? null, - resize: (ptyId, cols, rows) => { - try { - getProviderForPty(ptyId).resize(ptyId, cols, rows) - ptySizes.set(ptyId, { cols, rows }) - return true - } catch { - return false - } - } - }) - - // ─── IPC Handlers (thin dispatch layer) ───────────────────────── - - function normalizeSnapshotScrollbackRows(value: unknown): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return undefined - } - return Math.max(0, Math.min(50_000, Math.floor(value))) - } - - ipc.handle( - 'pty:getMainBufferSnapshot', - async ( - _event, - args: { id?: unknown; opts?: { scrollbackRows?: unknown } } - ): Promise<{ - data: string - frameRestoreAnsi?: string - cols: number - rows: number - cwd?: string | null - lastTitle?: string - seq?: number - pendingDeliveryStartSeq?: number - source?: 'headless' | 'renderer' - alternateScreen?: boolean - scrollbackAnsi?: string - pendingEscapeTailAnsi?: string - kittyKeyboardFlags?: number - } | null> => { - if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { - return null - } - const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows) - try { - const runtimeSeqBeforeSnapshot = runtime.getPtyOutputSequence(args.id) - const providerSnapshotRequired = providerSnapshotRequiredPtys.has(args.id) - const providerSnapshot = providerSnapshotRequired - ? await tryGetProviderForPty(args.id)?.getBufferSnapshot?.(args.id, { - scrollbackRows - }) - : null - // Why: after a data gap main holds only the retained tail; returning it as a full snapshot would erase older scrollback. - if (providerSnapshotRequired && !providerSnapshot) { - return null - } - const snapshot = - providerSnapshot ?? - (await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { - scrollbackRows - })) - if (!snapshot || typeof snapshot.seq !== 'number') { - return snapshot - } - // Why: the renderer's post-restore dedupe needs this pending-queue bound, or a stale baseline swallows new chunks whose seq sits below the snapshot counter. - const pending = pendingData.get(args.id) - if (pending && typeof pending.startSeq !== 'number') { - // Why: a seq-less backlog cannot be bounded — stay conservative. - return snapshot - } - return { - ...snapshot, - pendingDeliveryStartSeq: Math.min( - pending?.startSeq ?? (providerSnapshot ? runtimeSeqBeforeSnapshot : snapshot.seq), - snapshot.seq - ) - } - } catch { - return null - } - } - ) - - // Why: main owns side effects, so this replay restores title state only — never historical bells/completions (no-attention-replay rule, terminal-side-effect-authority.md). - ipc.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => { - if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { - return null - } - return runtime.getTerminalSideEffectSnapshot(args.id) - }) - - installPowerSignalBreadcrumbs(getPtyPower()) - ipc.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => { - return getPtyRendererDeliveryDebugSnapshot() - }) - ipc.handle('pty:resetRendererDeliveryDebug', (): void => { - resetPtyRendererDeliveryDebug() - }) - - ipc.handle( - 'pty:spawn', - async ( - _event, - args: { - cols: number - rows: number - cwd?: string - // Why: fresh local spawns opt into recovering a saved cwd whose dir was deleted (#7239); reattach/remote need exact cwd, so the flag alone isn't sufficient. - cwdFallback?: 'worktree' - env?: Record - envToDelete?: string[] - command?: string - commandDelivery?: 'renderer' | 'provider' - launchConfig?: SleepingAgentLaunchConfig - resumeProviderSession?: AgentProviderSessionMetadata - launchToken?: unknown - launchAgent?: TuiAgent - startupCommandDelivery?: StartupCommandDelivery - connectionId?: string | null - worktreeId?: string - sessionId?: string - shellOverride?: string - projectRuntime?: ProjectExecutionRuntimeResolution - terminalColorQueryReplies?: { - foreground?: unknown - background?: unknown - } - // Why: hidden-at-spawn declaration (terminal-query-authority.md §races) — main marks hidden before byte zero so the gate owns spawn-time queries. - initiallyHidden?: boolean - // Why: closes the SIGKILL race (INVESTIGATION.md) by letting main sync-flush the binding before pty:spawn returns; only the Ctrl+T daemon-host path threads these. - tabId?: string - leafId?: string - // Why: renderer-threaded launch telemetry (telemetry-plan.md§Agent launch semantics); loosely typed because the main-side schema validator is the single enforcement point. - telemetry?: { - agent_kind?: unknown - launch_source?: unknown - request_kind?: unknown - } - } - ) => { - const codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined - const codexHomeLaunchStartedSequence = !args.connectionId ? ++ptyLifecycleSequence : undefined - const initialLeafId = - typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null - const initialPaneKey = - typeof args.worktreeId === 'string' && - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - initialLeafId - ? makePaneKey(args.tabId, initialLeafId) - : null - const initialStablePanePtyId = (() => { - try { - return !args.connectionId && initialPaneKey - ? resolveStablePaneOwner( - runtime, - store, - initialPaneKey, - args.worktreeId, - args.connectionId - )?.ptyId - : undefined - } catch { - return undefined - } - })() - const reattachedCodexHomeRoutes = !args.connectionId - ? snapshotCodexPaneHomeRoutes([ - initialStablePanePtyId, - args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined - ]) - : new Map() - const spawnTiming = createPtySpawnTiming() - const startupPromise = getLocalPtyStartupPromise(args.connectionId) - if (startupPromise) { - await startupPromise - } - let cwd = resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) - let prevalidatedCwd: string | undefined - let startupCwdFallback: { kind: 'worktree'; cwd: string } | undefined - const earlyLeafId = - typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null - const earlyPaneKey = - typeof args.worktreeId === 'string' && - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - earlyLeafId - ? makePaneKey(args.tabId, earlyLeafId) - : null - const earlyReservationKey = makePaneSpawnReservationKey( - args.worktreeId, - args.connectionId, - earlyPaneKey - ) - const pendingRuntimeCreate = earlyReservationKey - ? pendingRuntimePaneCreatesByOwnerKey.get(earlyReservationKey) - : undefined - if (pendingRuntimeCreate) { - await pendingRuntimeCreate.promise - } - const existingPaneSpawn = earlyReservationKey - ? paneSpawnReservationsByOwnerKey.get(earlyReservationKey) - : undefined - if (existingPaneSpawn) { - return { ...(await existingPaneSpawn.promise), isReattach: true } - } - const earlyStablePaneOwner = - earlyPaneKey && args.worktreeId - ? resolveStablePaneOwner(runtime, store, earlyPaneKey, args.worktreeId, args.connectionId) - : null - const earlyWorktreeId = args.worktreeId - let paneSpawnReservationKey = - earlyStablePaneOwner && earlyReservationKey ? earlyReservationKey : null - let paneSpawnReservation = paneSpawnReservationKey - ? reservePaneSpawn(paneSpawnReservationKey) - : null - let finishTerminalInstall = (): void => {} - let result: PtySpawnResult - let stablePaneOwner: StablePaneOwner | null = null - let stablePaneBindingPersisted = false - let rejectedRegistrationCandidate: PtySpawnResult | null = null - let pendingRegistrationPtyId: string | null = null - // Why hoisted to the reply scope: main reconciles the provider sequence - // deep inside the spawn path, but the pane needs that renderer-domain - // boundary beside the daemon snapshot's kitty flags. - let reconciledSnapshotSeq: number | null = null - // False when bytes crossed the data socket during the spawn RPC: the - // reconciled boundary covers them, but the daemon proved its kitty flags - // before they existed, so the claim must not erase what the pane may - // have scanned from those bytes live. - let snapshotKittyFlagsCoverReconciledSeq = true - let preparedProvisionalExecutionContext = false - let releaseWorktreeSpawn: (() => void) | undefined - try { - if (!earlyStablePaneOwner) { - await assertFolderWorkspacePtyPathUsable(args.worktreeId) - } - const provider = getProvider(args.connectionId) - spawnTiming.mark('stable_adoption_setup') - const preAdoptedStablePane = - earlyStablePaneOwner && earlyWorktreeId - ? await adoptStablePane({ - cols: args.cols, - rows: args.rows, - cwd, - connectionId: args.connectionId, - worktreeId: earlyWorktreeId, - tabId: earlyStablePaneOwner.tabId, - leafId: earlyStablePaneOwner.leafId, - ownsPaneSpawnReservation: true - }) - : null - spawnTiming.mark('stable_adoption') - if (earlyStablePaneOwner && !preAdoptedStablePane) { - await assertFolderWorkspacePtyPathUsable(args.worktreeId) - } - if (!preAdoptedStablePane) { - // Why: reattach needs exact cwd, SSH cannot probe locally, and successful stable-pane adoption needs no launch preflight. - const requestedMissingCwdFallback = - !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' - const isPosixStartupCwd = args.cwd?.startsWith('/') === true - const startupWorkspaceCwd = - requestedMissingCwdFallback && isPosixStartupCwd - ? resolvePtySpawnStartupCwd(args.worktreeId, '.') - : undefined - const initiallyResolvedStartupCwd = - requestedMissingCwdFallback && isPosixStartupCwd ? cwd : undefined - const startupTerminalRuntimeOptions = - requestedMissingCwdFallback && process.platform === 'win32' - ? resolveLocalWindowsTerminalRuntimeOptions({ - requestedShellOverride: args.shellOverride, - settings: getSettings?.(), - projectRuntime: args.projectRuntime, - fallbackHostShell: process.env.COMSPEC || 'powershell.exe' - }) - : undefined - const wslRuntimeOwnsStartupCwd = - requestedMissingCwdFallback && - isPosixStartupCwd && - (isWslShellName(startupTerminalRuntimeOptions?.shellOverride) || - isWslUncPath(startupWorkspaceCwd ?? '')) - const startupWslContext = wslRuntimeOwnsStartupCwd - ? resolveWslSessionContext({ - cwd: startupWorkspaceCwd, - shellOverride: startupTerminalRuntimeOptions?.shellOverride, - terminalWindowsWslDistro: startupTerminalRuntimeOptions?.terminalWindowsWslDistro - }) - : undefined - let wslStartupCwdExists: boolean | null = null - let wslWorkspaceCwdExists: boolean | null = null - if (startupWslContext && initiallyResolvedStartupCwd) { - const validationCwd = toWindowsWslPath( - initiallyResolvedStartupCwd, - startupWslContext.distro - ) - wslStartupCwdExists = isWslUncPath(validationCwd) - ? await wslUncDirectoryExistsAsync(validationCwd) - : localStartupCwdDirectoryExists(validationCwd) - if (wslStartupCwdExists === true) { - prevalidatedCwd = validationCwd - } - if (wslStartupCwdExists === false && startupWorkspaceCwd) { - wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd) - ? await wslUncDirectoryExistsAsync(startupWorkspaceCwd) - : localStartupCwdDirectoryExists(startupWorkspaceCwd) - if (wslWorkspaceCwdExists === true) { - prevalidatedCwd = startupWorkspaceCwd - } - } - } - const allowMissingCwdFallback = - requestedMissingCwdFallback && - (!wslRuntimeOwnsStartupCwd || - (wslStartupCwdExists === false && wslWorkspaceCwdExists === true)) - let didFallbackToWorkspaceRootCwd = false - cwd = resolvePtySpawnStartupCwd( - args.worktreeId, - args.cwd, - allowMissingCwdFallback - ? { - directoryExists: (path) => - startupWslContext && - wslStartupCwdExists === false && - path === initiallyResolvedStartupCwd - ? false - : startupWslContext && path === startupWorkspaceCwd - ? wslWorkspaceCwdExists === true - : localStartupCwdDirectoryExists( - process.platform === 'win32' ? normalizeWindowsTerminalCwd(path) : path - ), - onFallbackToWorkspaceRoot: () => { - didFallbackToWorkspaceRootCwd = true - } - } - : undefined - ) - if (didFallbackToWorkspaceRootCwd && wslWorkspaceCwdExists === true && cwd) { - prevalidatedCwd = cwd - } - startupCwdFallback = - didFallbackToWorkspaceRootCwd && cwd ? { kind: 'worktree', cwd } : undefined - } - spawnTiming.mark('preflight') - const freshSpawnRecovery = preAdoptedStablePane - ? undefined - : recoverFreshSpawnProviderRouting(provider, args.connectionId, args.sessionId) - if (freshSpawnRecovery) { - await freshSpawnRecovery - } - const isClaudeLaunch = - !preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) - if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') - } - const terminalRuntimeOptions = - process.platform === 'win32' && !args.connectionId - ? resolveLocalWindowsTerminalRuntimeOptions({ - requestedShellOverride: args.shellOverride, - settings: getSettings?.(), - projectRuntime: args.projectRuntime, - fallbackHostShell: process.env.COMSPEC || 'powershell.exe' - }) - : { shellOverride: args.shellOverride, terminalWindowsWslDistro: null } - const initialShellOverride = terminalRuntimeOptions.shellOverride - const isDaemonHostSpawn = - !args.connectionId && - !(provider instanceof LocalPtyProvider) && - !routesFreshSpawnsToLocalProvider(provider) - // Why: daemon host-env setup needs a stable id BEFORE provider.spawn so buildPtyHostEnv hooks/Pi cleanup can run; daemon still honors opts.sessionId ?? mint(). - // Note: sessionId is STABLE across daemon restarts by design — do NOT simplify to a fresh UUID per spawn; that orphans reconnectable state. - // Why: only clear ids minted in THIS request on failure — a caller-supplied args.sessionId may name an existing PTY we must not clobber. - const isMintedSessionId = args.sessionId === undefined && isDaemonHostSpawn - const effectiveSessionId = - args.sessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) - const effectiveSessionAppId = - effectiveSessionId !== undefined - ? getAppPtyId(args.connectionId, effectiveSessionId) - : undefined - const effectiveSessionRelayId = - effectiveSessionId !== undefined - ? getRelayPtyId(args.connectionId, effectiveSessionId) - : undefined - const expectedWslDistro = !args.connectionId - ? (resolveWslSessionContext({ - cwd, - sessionId: effectiveSessionId, - shellOverride: terminalRuntimeOptions.shellOverride, - terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro - })?.distro ?? null) - : null - const initialSelectionTarget = getCodexSelectionTargetForPty( - initialShellOverride, - cwd, - expectedWslDistro - ) - const claudeAuth = - isClaudeLaunch && prepareClaudeAuth - ? await prepareClaudeAuth(initialSelectionTarget) - : null - spawnTiming.mark('auth') - if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) { - throw new Error('A Claude account switch is in progress. Try again after it finishes.') - } - if (claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { - throw new Error( - 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' - ) - } - // Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity. - // Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host. - const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) - // Why: forward pane env to SSH only when the relay hook path is enabled, or a newer relay could emit statuses this build can't route. - const sshSourceEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env) - const baseEnvWithAuth = claudeAuth - ? { ...sshSourceEnv, ...claudeAuth.envPatch } - : sshSourceEnv - const spawnPaneKey = baseEnvWithAuth?.ORCA_PANE_KEY - const parsedSpawnPaneKey = parseValidPaneKey(spawnPaneKey) - const verifiedPaneKey = - parsedSpawnPaneKey && - typeof args.tabId === 'string' && - args.tabId === parsedSpawnPaneKey.tabId && - args.leafId === parsedSpawnPaneKey.leafId - ? makePaneKey(parsedSpawnPaneKey.tabId, parsedSpawnPaneKey.leafId) - : null - const verifiedLeafId = - verifiedPaneKey && parsedSpawnPaneKey ? parsedSpawnPaneKey.leafId : null - const metadataLeafId = - typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null - const metadataPaneKey = - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - metadataLeafId - ? makePaneKey(args.tabId, metadataLeafId) - : null - const legacySpawnPaneKey = verifiedPaneKey ? null : parseLegacyNumericPaneKey(spawnPaneKey) - const migrationUnsupportedPaneKey = - legacySpawnPaneKey && - typeof args.tabId === 'string' && - args.tabId === legacySpawnPaneKey.tabId && - typeof args.leafId === 'string' && - isTerminalLeafId(args.leafId) - ? makePaneKey(args.tabId, args.leafId) - : null - const stablePaneKey = verifiedPaneKey ?? migrationUnsupportedPaneKey - let baseEnv = baseEnvWithAuth ? { ...baseEnvWithAuth } : undefined - const shouldRefreshAgentTeamsEnv = - !preAdoptedStablePane && - !args.connectionId && - runtime !== undefined && - stablePaneKey !== null && - shouldRefreshNativeClaudeAgentTeamsEnv({ - command: args.command, - launchConfig: args.launchConfig - }) - let effectiveLaunchConfig = args.launchConfig - const shouldPreAllocateTerminalHandle = - runtime !== undefined && - ((!(provider instanceof LocalPtyProvider) && - !routesFreshSpawnsToLocalProvider(provider)) || - shouldRefreshAgentTeamsEnv) - const preAllocatedHandle = shouldPreAllocateTerminalHandle - ? (preAdoptedStablePane?.owner.handle ?? runtime.createPreAllocatedTerminalHandle()) - : null - if (shouldRefreshAgentTeamsEnv && preAllocatedHandle) { - // Why: Agent Teams ids/tokens are process-local, so the team env must be regenerated for the new leader PTY. - const prepared = await runtime.prepareClaudeAgentTeamsLeaderForHandle({ - handle: preAllocatedHandle, - baseEnv: baseEnv ?? {} - }) - baseEnv = { - ...baseEnv, - ...prepared.env - } - if (args.launchConfig) { - effectiveLaunchConfig = { - ...args.launchConfig, - agentEnv: { - ...args.launchConfig.agentEnv, - ...prepared.env - } - } - } - } - const requestedAgentTeamsPath = baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID - ? baseEnv[resolvePathEnvKey(baseEnv, process.platform)] - : undefined - const agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv ? ['TERM_PROGRAM'] : undefined - if (baseEnv && stablePaneKey) { - baseEnv.ORCA_PANE_KEY = stablePaneKey - if (typeof args.tabId === 'string') { - baseEnv.ORCA_TAB_ID = args.tabId - } else if (!args.connectionId) { - delete baseEnv.ORCA_TAB_ID - } - if (typeof args.worktreeId === 'string') { - baseEnv.ORCA_WORKTREE_ID = args.worktreeId - } else if (!args.connectionId) { - delete baseEnv.ORCA_WORKTREE_ID - } - } else if (baseEnv) { - // Why: ORCA_PANE_KEY crosses into shells/hook registries; only a key proven to match this spawn's tab+leaf may cross the IPC boundary. - delete baseEnv.ORCA_PANE_KEY - delete baseEnv.ORCA_TAB_ID - delete baseEnv.ORCA_WORKTREE_ID - delete baseEnv.ORCA_AGENT_LAUNCH_TOKEN - } - const validatedPaneKey = stablePaneKey - // Why: SSH can strip ORCA_PANE_KEY when remote hooks are off; IPC tab/leaf metadata still names the pane. - const reservationPaneKey = metadataPaneKey ?? validatedPaneKey - const validatedLeafId = verifiedLeafId ?? metadataLeafId - const effectiveShellOverride = terminalRuntimeOptions.shellOverride - const nativeWindowsConptySpawn = isNativeWindowsLocalPtySpawn({ - connectionId: args.connectionId, - cwd: args.cwd, - shellOverride: effectiveShellOverride - }) - const codexSelectionTarget = getCodexSelectionTargetForPty( - effectiveShellOverride, - cwd, - expectedWslDistro - ) - const codexResumePreparation = preAdoptedStablePane - ? null - : prepareCodexResumeHome({ - connectionId: args.connectionId, - launchAgent: args.launchAgent, - providerSession: args.resumeProviderSession, - target: codexSelectionTarget, - launchEnv: baseEnv, - workspacePath: cwd - }) - const codexResumeLaunch = codexResumePreparation - ? await resolveCodexResumeLaunch(args.command, codexResumePreparation) - : noCodexResumeLaunch(preAdoptedStablePane ? undefined : args.command) - const codexResumeHome = codexResumeLaunch.codexResumeHome - const launchCommand = codexResumeLaunch.command - baseEnv = stripSequencedStartupResumeArgv(baseEnv, codexResumeLaunch) - // Why: declared after the strip so a local-provider spawn cannot capture the - // pre-strip env — only the daemon branch below re-derives this from baseEnv. - let env: Record | undefined = baseEnv - let selectedCodexHomePath = resolveSelectedCodexHomeOrRefuseSpawn(() => - !preAdoptedStablePane && !args.connectionId - ? getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - codexResumeHome - ? reconcileSharedRuntimeResumeHome(codexResumeHome, () => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null - ) - ) - : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined - }) ?? null) - ) - : null - ) - if (!preAdoptedStablePane && args.launchAgent === 'codex' && args.sessionId === undefined) { - const resolution = resolveCodexHomeAfterManagedAuthReadiness({ - selectedCodexHomePath, - getSettings: () => getSettings?.(), - requiredCodexHomePath: codexResumeHome?.codexHomePath, - target: codexSelectionTarget, - resolveCurrent: () => - resolveSelectedCodexHomeOrRefuseSpawn(() => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: 'codex' - }) ?? null - ) - ), - resolveAfterUnavailable: (unavailableManagedHomePath) => - resolveSelectedCodexHomeOrRefuseSpawn(() => - getCompatibleSelectedCodexHomePath( - codexSelectionTarget, - getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { - workspacePath: cwd, - launchAgent: 'codex', - unavailableManagedHomePath - }) ?? null - ) - ) - }) - selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution - } - if (args.launchAgent === 'codex' && selectedCodexHomePath) { - await ensureCodexStateDbBackfillRecoveryStarted(selectedCodexHomePath) - } - const codexResumeHomeSelected = Boolean( - codexResumeHome && - codexHomePathsEqual(selectedCodexHomePath, codexResumeHome.codexHomePath) - ) - const skipCodexHomeEnv = - isDaemonHostSpawn && - shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) && - !selectedCodexHomePath - const ptySettings = isDaemonHostSpawn ? getSettings?.() : undefined - const stripInheritedOrcaCodexHome = - isDaemonHostSpawn && - shouldStripInheritedOrcaCodexHome({ - target: codexSelectionTarget, - selectedCodexHomePath, - skipCodexHomeEnv, - settings: ptySettings - }) - if (isDaemonHostSpawn && !preAdoptedStablePane) { - if (effectiveSessionId === undefined) { - // Should be unreachable: effectiveSessionId is a string when isDaemonHostSpawn; defense-in-depth. - throw new Error('Invariant violation: daemon spawn without sessionId') - } - const sessionIdForEnv = effectiveSessionId - // Why: this id reaches filesystem paths; reject traversal/separators so a crafted IPC payload can't escape the expected roots. - if (!isSafePtySessionId(sessionIdForEnv, getAppEnvironment().getPath('userData'))) { - throw new Error('Invalid PTY session id') - } - // Why: clone before mutating so injections don't leak back into args.env (renderer may reuse it). - env = { ...baseEnv } - try { - buildPtyHostEnv(sessionIdForEnv, env, { - isPackaged: getAppEnvironment().isPackaged(), - resourcesPath: process.resourcesPath, - userDataPath: getAppEnvironment().getPath('userData'), - selectedCodexHomePath, - skipCodexHomeEnv, - stripInheritedOrcaCodexHome, - launchCommand, - launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, - isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd), - wslDistro: codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null, - agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), - codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), - networkProxySettings: ptySettings, - deferGitConfigGuardToDaemon: - provider.supportsGitCredentialGuardHost?.(effectiveSessionId) === true - }) - stampWslOrchestrationCompatibilityHost( - env, - runtime?.getOrchestrationCompatibilityHostId?.(), - codexSelectionTarget.runtime === 'wsl' ? expectedWslDistro : null - ) - promoteAgentTeamsShimPath(env, requestedAgentTeamsPath) - } catch (err) { - // Why: buildPtyHostEnv has fs side-effects (Pi/OMP install); clear per-PTY state on throw, but only minted ids — caller ids may name existing PTYs. - if (isMintedSessionId) { - clearProviderPtyState(sessionIdForEnv) - } - throw err - } - } - spawnTiming.mark('host_env') - const spawnEnv = preAllocatedHandle - ? { ...env, ORCA_TERMINAL_HANDLE: preAllocatedHandle } - : env - const envToDelete = claudeAuth?.stripAuthEnv - ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] - : undefined - let combinedEnvToDelete = mergePtyEnvDeletions( - envToDelete, - args.envToDelete ?? [], - agentTeamsEnvToDelete ?? [], - // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. - isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], - isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [], - getInheritedClaudeSessionStampEnvKeysToDelete(spawnEnv), - skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], - // Why: the persistent daemon compares its own merged CODEX_HOME pair; - // main cannot safely decide ownership for a process it may not parent. - stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] - ) - if (codexResumeHomeSelected) { - combinedEnvToDelete = removeCodexHomeDeletionRequests(combinedEnvToDelete) - } - deleteRequestedEnvKeys(spawnEnv, combinedEnvToDelete) - promoteAgentTeamsShimPath(spawnEnv, requestedAgentTeamsPath) - const spawnOptions: PtySpawnOptions = { - cols: args.cols, - rows: args.rows, - cwd, - ...(prevalidatedCwd && !isDaemonHostSpawn ? { prevalidatedCwd } : {}), - env: spawnEnv, - ...(isMintedSessionId ? { isNewSession: true } : {}), - historyIsolationEnabled: getSettings?.()?.terminalScopeHistoryByWorktree ?? true - } - if (!args.connectionId && !isDaemonHostSpawn) { - spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath } - } - if (combinedEnvToDelete) { - spawnOptions.envToDelete = combinedEnvToDelete - } - if (launchCommand !== undefined) { - spawnOptions.command = launchCommand - } - if (args.commandDelivery !== undefined) { - spawnOptions.commandDelivery = args.commandDelivery - } - if (args.startupCommandDelivery !== undefined) { - spawnOptions.startupCommandDelivery = args.startupCommandDelivery - } - if (isTuiAgent(args.launchAgent)) { - spawnOptions.launchAgent = args.launchAgent - } - if (args.worktreeId !== undefined) { - spawnOptions.worktreeId = args.worktreeId - } - if (reservationPaneKey) { - spawnOptions.paneKey = reservationPaneKey - } - if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) { - spawnOptions.tabId = args.tabId - } - if (effectiveSessionId !== undefined) { - spawnOptions.sessionId = effectiveSessionId - } - // Why: without this, the Windows daemon path ignores the user's Default Shell preference (LocalPtyProvider already honors it via getWindowsShell()). - if (effectiveShellOverride !== undefined) { - spawnOptions.shellOverride = effectiveShellOverride - } - const hadSessionSizeBeforeAttach = - effectiveSessionAppId !== undefined ? ptySizes.has(effectiveSessionAppId) : false - const sessionSizeBeforeAttach = - effectiveSessionAppId !== undefined ? ptySizes.get(effectiveSessionAppId) : undefined - if (effectiveSessionId !== undefined) { - // Why: daemon PTYs can emit before spawn() resolves; set real geometry now or early bytes default to 80x24 and wrap TUIs. - ptySizes.set(effectiveSessionAppId ?? effectiveSessionId, { - cols: args.cols, - rows: args.rows - }) - } - if (process.platform === 'win32' && !args.connectionId) { - // Why: the renderer models PowerShell as one shell family; thread the implementation choice so both PTY paths resolve the same executable. - spawnOptions.terminalWindowsWslDistro = expectedWslDistro - spawnOptions.terminalWindowsPowerShellImplementation = getSettings - ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') - : undefined - } - if (startupTerminalColorQueryReplyColors) { - spawnOptions.startupIngress = { - colors: startupTerminalColorQueryReplyColors, - deadlineMs: 5_000 - } - } - const resolvedPaneSpawnReservationKey = makePaneSpawnReservationKey( - args.worktreeId, - args.connectionId, - reservationPaneKey - ) - if ( - paneSpawnReservationKey && - resolvedPaneSpawnReservationKey !== paneSpawnReservationKey - ) { - throw new Error('terminal_pane_identity_changed') - } - if (!paneSpawnReservationKey) { - paneSpawnReservationKey = resolvedPaneSpawnReservationKey - const pendingRuntimeCreateAfterPreflight = paneSpawnReservationKey - ? pendingRuntimePaneCreatesByOwnerKey.get(paneSpawnReservationKey) - : undefined - if (pendingRuntimeCreateAfterPreflight) { - await pendingRuntimeCreateAfterPreflight.promise - } - const existingPaneSpawnAfterPreflight = paneSpawnReservationKey - ? paneSpawnReservationsByOwnerKey.get(paneSpawnReservationKey) - : undefined - if (existingPaneSpawnAfterPreflight) { - return { ...(await existingPaneSpawnAfterPreflight.promise), isReattach: true } - } - paneSpawnReservation = paneSpawnReservationKey - ? reservePaneSpawn(paneSpawnReservationKey) - : null - } - finishTerminalInstall = beginPtySpawnForWorktree(args.worktreeId, cwd, args.connectionId) - const initiallyHidden = args.initiallyHidden === true - // Why: daemon PTYs can emit before spawn() resolves, so the hidden mark must beat byte zero (terminal-query-authority.md §races); other providers are safe with the post-spawn mark below. - const preSpawnHiddenMarkId = - initiallyHidden && isDaemonHostSpawn && effectiveSessionAppId !== undefined - ? effectiveSessionAppId - : null - if (preSpawnHiddenMarkId !== null) { - transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, true) - } - releaseWorktreeSpawn = await runtime?.acquireWorktreeTerminalSpawn?.(args.worktreeId) - try { - if (preAllocatedHandle) { - trustedTerminalHandleEnv.add(preAllocatedHandle) - } - spawnTiming.mark('options') - const stablePaneOwnerCandidate = resolveStablePaneOwner( - runtime, - store, - reservationPaneKey, - args.worktreeId, - args.connectionId - ) - const expectedPtyId = - stablePaneOwnerCandidate?.ptyId ?? effectiveSessionAppId ?? effectiveSessionId - if (expectedPtyId) { - runtime?.beginPtyRegistration?.(expectedPtyId) - pendingRegistrationPtyId = expectedPtyId - } - if (isDaemonHostSpawn && expectedPtyId) { - preparedProvisionalExecutionContext = - runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, { - resetIncarnation: isMintedSessionId && !stablePaneOwnerCandidate, - preserveExisting: !isMintedSessionId || Boolean(stablePaneOwnerCandidate) - }) ?? false - } - const sequenceBeforeProviderSpawn = expectedPtyId - ? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) - : 0 - const stablePaneSpawn = preAdoptedStablePane - ? preAdoptedStablePane - : await spawnForStablePane({ - runtime, - store, - provider, - spawnOptions, - owner: stablePaneOwnerCandidate, - worktreeId: args.worktreeId, - connectionId: args.connectionId, - resolveOwner: () => - resolveStablePaneOwner( - runtime, - store, - reservationPaneKey, - args.worktreeId, - args.connectionId - ) - }) - result = stablePaneSpawn.result - stablePaneOwner = stablePaneSpawn.owner - if ( - stablePaneOwner && - isMintedSessionId && - effectiveSessionAppId && - effectiveSessionAppId !== result.id - ) { - clearProviderPtyState(effectiveSessionAppId) - } - rejectedRegistrationCandidate = result - if (pendingRegistrationPtyId !== result.id) { - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId) - } - runtime?.beginPtyRegistration?.(result.id, result.incarnationId) - pendingRegistrationPtyId = result.id - } - assertSpawnReplyWasLive(result) - runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId) - if (result.providerSequence) { - const runtimeSequenceBeforeReconcile = runtime?.getPtyOutputSequence?.(result.id) ?? 0 - // Why kept: this is the reattach boundary in the RENDERER's sequence - // domain, and the daemon snapshot's kitty flags mean nothing without - // the boundary they were proven at. - reconciledSnapshotSeq = - runtime?.synchronizePtyOutputSequenceFromProvider?.( - result.id, - result.providerSequence, - sequenceBeforeProviderSpawn - ) ?? null - if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) { - snapshotKittyFlagsCoverReconciledSeq = false - } - } - ensureWslHookRelayForReattach(result, args.connectionId) - runtime?.preparePtyExecutionContext?.( - result.id, - args.connectionId - ? null - : result.wslDistro === undefined - ? expectedWslDistro - : result.wslDistro - ) - spawnTiming.mark('provider_spawn') - } catch (err) { - if ((isMintedSessionId || preparedProvisionalExecutionContext) && effectiveSessionAppId) { - runtime?.preparePtyExecutionContext?.(effectiveSessionAppId, null, { - resetIncarnation: true - }) - } - // Why: a stale hidden mark on this session id would gate a later visible attach that reuses it. - if (preSpawnHiddenMarkId !== null) { - transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, false) - } - const rawMessage = err instanceof Error ? err.message : String(err) - if (rawMessage === 'agent_session_exited_during_start' && rejectedRegistrationCandidate) { - runtime?.releaseRejectedPtyRegistrationFence?.( - rejectedRegistrationCandidate.id, - rejectedRegistrationCandidate.incarnationId - ) - } - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.( - pendingRegistrationPtyId, - rejectedRegistrationCandidate?.incarnationId - ) - pendingRegistrationPtyId = null - } - const spawnError = normalizeNodePtySpawnError(err) - const isIdentityMismatch = - isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage) - if (effectiveSessionAppId !== undefined) { - if (isIdentityMismatch && hadSessionSizeBeforeAttach && sessionSizeBeforeAttach) { - ptySizes.set(effectiveSessionAppId, sessionSizeBeforeAttach) - } else { - ptySizes.delete(effectiveSessionAppId) - } - } - if ( - args.connectionId && - effectiveSessionRelayId !== undefined && - (spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) || - rawMessage.includes(SSH_SESSION_EXPIRED_ERROR)) - ) { - // Why: expired remote reattach = relay already dropped the PTY; clear the lease so writes can't restore the stale binding. - if (effectiveSessionAppId !== undefined && !isIdentityMismatch) { - clearProviderPtyState(effectiveSessionAppId) - deletePtyOwnership(effectiveSessionAppId) - } - if (!isIdentityMismatch) { - store?.markSshRemotePtyLease(args.connectionId, effectiveSessionRelayId, 'expired') - } - } - // Why: provider state buildPtyHostEnv materialized for this minted id leaks if spawn failed. - if (isMintedSessionId && effectiveSessionId !== undefined) { - clearProviderPtyState(effectiveSessionId) - } - // Why: telemetry-plan.md§agent_error — attribute the error to the renderer-threaded agent_kind, else sniff the command for `claude`; raw messages are dropped at the validator boundary. - const rendererAgentKindParse = - args.telemetry?.agent_kind !== undefined - ? agentKindSchema.safeParse(args.telemetry.agent_kind) - : null - const errorAgentKind = rendererAgentKindParse?.success - ? rendererAgentKindParse.data - : isClaudeLaunch - ? ('claude-code' as const) - : null - if (errorAgentKind) { - const classified = classifyError(spawnError) - track('agent_error', { - agent_kind: errorAgentKind, - error_class: classified.error_class, - ...getCohortAtEmit() - }) - } - throw spawnError - } finally { - if (preAllocatedHandle) { - trustedTerminalHandleEnv.delete(preAllocatedHandle) - } - } - try { - stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ - store, - owner: stablePaneOwner, - result, - worktreeId: args.worktreeId, - startupCwd: cwd, - connectionId: args.connectionId - }) - } catch (error) { - if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { - throw error - } - console.error('[pty] failed to persist PTY binding after attach:', error) - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { - agentSessionOperationOutcome: 'unknown' as const - }) - } - spawnTiming.log(result.id, { - daemon: isDaemonHostSpawn, - reattach: result.isReattach ?? false - }) - recordCodexPaneAccountForSpawn({ - ptyId: result.id, - isDaemonHostSpawn, - isReattach: result.isReattach === true, - pinnedByResume: codexResumeHomeSelected, - launchCodexHomePath: selectedCodexHomePath, - launchEnv: baseEnv, - target: codexSelectionTarget, - settings: getSettings?.() - }) - ptyOwnership.set(result.id, args.connectionId ?? null) - if (result.incarnationId) { - ptyIncarnationById.set(result.id, result.incarnationId) - } - if (initiallyHidden) { - // Why marked synchronously here: provider data events dispatch on later tasks, so this still lands ahead of the first byte's delivery decision (idempotent if already marked pre-spawn). - transitionSpawnHiddenRendererPtyDeliveryState(result.id, true) - if (preSpawnHiddenMarkId !== null && preSpawnHiddenMarkId !== result.id) { - // Defense: never strand a mark on an id the provider renamed. - transitionSpawnHiddenRendererPtyDeliveryState(preSpawnHiddenMarkId, false) - } - // Why after ptyOwnership.set: provider lookup routes by ownership, and a hidden-spawned agent should be paceable from its first flood. - syncPtyBackgroundedDelivery(result.id, 'spawn') - closeStartupQueryAuthorityForPty(result.id) - } - // Why: record the native-Windows-ConPTY determination before the headless seed so the emulator's DA1 override exists from byte zero. - if (nativeWindowsConptySpawn) { - markNativeWindowsConptyPty(result.id) - } - const relayResultId = getRelayPtyId(args.connectionId, result.id) - if (store && args.connectionId) { - // Why: remote PTYs live in the SSH relay grace window after Orca detaches; persist IDs immediately so reconnect reattaches instead of spawning a fresh shell. - store.upsertSshRemotePtyLease({ - targetId: args.connectionId, - ptyId: relayResultId, - ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), - ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), - ...(validatedLeafId ? { leafId: validatedLeafId } : {}), - state: 'attached', - lastAttachedAt: Date.now() - }) - } - if (preAllocatedHandle && !stablePaneOwner?.handle) { - runtime?.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) - } - ptySizes.set(result.id, { cols: args.cols, rows: args.rows }) - if (effectiveSessionAppId !== undefined && effectiveSessionAppId !== result.id) { - ptySizes.delete(effectiveSessionAppId) - } - // Why: patch the load-bearing ptyId binding synchronously so a force-quit in the renderer's ~450 ms debounce window can't orphan daemon history or an SSH relay lease (Issue #217). - if ( - store && - typeof args.worktreeId === 'string' && - typeof args.tabId === 'string' && - validatedLeafId !== null && - !stablePaneBindingPersisted - ) { - try { - const binding = { - worktreeId: args.worktreeId, - tabId: args.tabId, - leafId: validatedLeafId, - ptyId: result.id, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(cwd ? { startupCwd: cwd } : {}) - } - if (args.connectionId) { - store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId)) - } else { - store.persistPtyBinding(binding) - } - } catch (err) { - console.error('[pty] failed to persist PTY binding after spawn:', err) - if (!result.isReattach) { - try { - await provider.shutdown(result.id, { immediate: true }) - } catch (shutdownErr) { - console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) - } - clearProviderPtyState(result.id) - deletePtyOwnership(result.id) - } - if (!result.isReattach && args.connectionId && store) { - store.removeSshRemotePtyLease(args.connectionId, relayResultId) - } - throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { - agentSessionOperationOutcome: 'unknown' as const - }) - } - } - // Why: when the renderer has declared it will own the serializer for this paneKey, suppress the daemon-snapshot seed so its hydration path is sole authority (keyed on paneKey since the ptyId isn't known yet). See docs/mobile-prefer-renderer-scrollback.md. - const rendererPreSignaled = validatedPaneKey - ? pendingByPaneKey.has(validatedPaneKey) - : false - const rendererAlreadyRegistered = - result.isReattach === true && - !rendererPreSignaled && - rendererSerializerReadiness.has(result.id) - rendererSerializerReadiness.beginIncarnation(result.id, rendererAlreadyRegistered) - // Why: capture the pending gen at spawn time so this PTY's teardown only settles its own generation, not a remount that replaced the entry. - if (validatedPaneKey && rendererPreSignaled) { - const pending = pendingByPaneKey.get(validatedPaneKey) - if (pending) { - pendingPtyIdBySerializerGeneration.set(pending.gen, result.id) - } - } - - // Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback). - // Skip when the renderer will be authoritative — its xterm buffer is richer than the daemon snapshot. - if (runtime && !rendererPreSignaled && !rendererAlreadyRegistered) { - const snapshotSeedSize = - typeof result.snapshotCols === 'number' && typeof result.snapshotRows === 'number' - ? { cols: result.snapshotCols, rows: result.snapshotRows } - : undefined - if (typeof result.snapshot === 'string' && result.snapshot.length > 0) { - // Why kitty flags ride seed metadata: the snapshot omits them, but the re-seeded emulator must answer hidden `CSI ? u` with the running app's flags (terminal-query-authority.md). - runtime.seedHeadlessTerminal( - result.id, - result.snapshot, - snapshotSeedSize, - typeof result.snapshotKittyKeyboardFlags === 'number' - ? { kittyKeyboardFlags: result.snapshotKittyKeyboardFlags } - : {} - ) - } else if ( - result.coldRestore && - typeof result.coldRestore.scrollback === 'string' && - result.coldRestore.scrollback.length > 0 - ) { - const coldRestoreSeedSize = - typeof result.coldRestore.cols === 'number' && - typeof result.coldRestore.rows === 'number' - ? { cols: result.coldRestore.cols, rows: result.coldRestore.rows } - : undefined - runtime.seedHeadlessTerminal( - result.id, - result.coldRestore.scrollback, - coldRestoreSeedSize, - { - cwd: result.coldRestore.cwd, - oscLinks: result.coldRestore.oscLinks, - preferProviderIfExisting: true - } - ) - } else if (typeof result.replay === 'string' && result.replay.length > 0) { - // Why: the relay reattach replay is the one restore main never ingests, so without - // this seed its model is a mere suffix of what the renderer painted — and a later - // park-reveal would outrank the fuller relay replay with that fragment. - runtime.seedHeadlessTerminal(result.id, result.replay) - } - } - if ( - typeof args.worktreeId === 'string' && - args.worktreeId.length > 0 && - args.worktreeId.length <= 512 - ) { - const agentLaunchAuthority = admitRendererAgentLaunchAuthority({ - launchToken: args.launchToken, - spawnEnv, - launchAgent: args.launchAgent, - launchConfig: effectiveLaunchConfig, - isReattach: result.isReattach === true, - hasStablePaneOwner: stablePaneOwner !== null, - incarnationId: result.incarnationId - }) - runtime?.registerPty( - result.id, - args.worktreeId, - args.connectionId ?? null, - // Why: pass validated pane identity so a throttled mobile create publishes its surface main-side instead of destroying the live PTY (#7587); bound the untrusted tabId. - typeof args.tabId === 'string' && - isValidTerminalTabId(args.tabId) && - args.tabId.length <= 512 && - metadataLeafId !== null - ? { - tabId: args.tabId, - leafId: metadataLeafId, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - ...(agentLaunchAuthority ? { agentLaunchAuthority } : {}) - } - : undefined, - !args.connectionId - ? shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd) - : undefined - ) - pendingRegistrationPtyId = null - } else if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.(pendingRegistrationPtyId, result.incarnationId) - pendingRegistrationPtyId = null - } - // Why: seed after registerPty binds the worktree — including on - // desktop, where the renderer-authority gate above skips the emulator - // seed but the list/read records still live main-side. - seedTerminalRestoreRecordsFromSpawnResult(runtime, result) - // Why: arm main's per-PTY Command Code output detector from the launch command (startupCommand parity); banner detection covers PTYs without one. - if (!stablePaneOwner) { - runtime?.noteTerminalSpawnCommand?.( - result.id, - typeof launchCommand === 'string' ? launchCommand : null - ) - } - if (isClaudeLaunch && !stablePaneOwner) { - markClaudePtySpawned(result.id) - } - // Why: record the paneKey mapping so clearProviderPtyState can clear the agent-hooks server's per-paneKey caches on exit. - // Why: args.env is untrusted IPC JSON (type unenforced); bound the paneKey so malformed/oversized values can't pollute ptyPaneKey or clearPaneState. - const rememberedPaneKey = validatedPaneKey - ? rememberPaneKeyForPty(result.id, validatedPaneKey) - : null - if (legacySpawnPaneKey && migrationUnsupportedPaneKey) { - agentHookServer.registerPaneKeyAlias( - legacySpawnPaneKey.paneKey, - migrationUnsupportedPaneKey, - result.id, - Date.now(), - { authorityVerified: true } - ) - clearMigrationUnsupportedPtysForPaneKey(migrationUnsupportedPaneKey) - } else if (validatedPaneKey) { - if (!result.isReattach) { - clearMigrationUnsupportedPtysForPaneKey(validatedPaneKey) - } - } - // Why: register only local PTYs with the memory collector — SSH PTYs run remotely and their process tree is invisible to our local `ps`. - if (!args.connectionId) { - // Why: record the spawn-result pid once here so the memory module needn't reach back into ipc/pty on a hot path (works for in-process and daemon-hosted PTYs). - const spawnedPid = result.pid ?? null - // Why: args.worktreeId/sessionId arrive as untrusted IPC strings (type unenforced at the boundary); bound them so malformed/oversized values can't pollute registerPty's maps. - registerPty({ - ptyId: result.id, - worktreeId: - typeof args.worktreeId === 'string' && - args.worktreeId.length > 0 && - args.worktreeId.length <= 512 - ? args.worktreeId - : null, - sessionId: - typeof args.sessionId === 'string' && - args.sessionId.length > 0 && - args.sessionId.length <= 256 - ? args.sessionId - : null, - paneKey: rememberedPaneKey, - pid: - typeof spawnedPid === 'number' && Number.isFinite(spawnedPid) && spawnedPid > 0 - ? spawnedPid - : null - }) - } - // Why: telemetry-plan.md§Agent launch semantics — fire agent_started only after spawn resolved; safeParse each field so a spoofed IPC payload can't poison the event (missing required field skips it). - if (args.telemetry && !stablePaneOwner) { - const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) - const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) - const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) - if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { - track('agent_started', { - agent_kind: agentKindParse.data, - launch_source: launchSourceParse.data, - request_kind: requestKindParse.data, - ...getCohortAtEmit() - }) - } - } - const response = { - ...result, - // Why both or neither: a pane can only adopt proven kitty flags together - // with the sequence boundary they describe. - ...(typeof result.snapshotKittyKeyboardFlags === 'number' && - reconciledSnapshotSeq !== null && - snapshotKittyFlagsCoverReconciledSeq - ? { snapshotSeq: reconciledSnapshotSeq } - : { snapshotKittyKeyboardFlags: undefined }), - ...(!result.isReattach && effectiveLaunchConfig - ? { launchConfig: effectiveLaunchConfig } - : {}), - // Why: a daemon-retry race can surface isReattach even for a minted session id, and a reattach must never claim its cwd was remapped. - ...(startupCwdFallback && !result.isReattach ? { startupCwdFallback } : {}), - // Why: the pane asked to resume and got a fresh session instead; only the - // renderer can say so, and a reattach never ran this launch command. - ...(codexResumeLaunch.notifyResumeUnavailable && !result.isReattach - ? { agentResumeUnavailable: true as const } - : {}) - } - // Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory. - sendPtySpawnedToRenderer(result.id) - if (!args.connectionId) { - options?.onCodexHomePtySpawned?.({ - id: result.id, - codexHomePath: selectedCodexHomePath, - startedAt: codexHomeLaunchStartedAt, - startedSequence: codexHomeLaunchStartedSequence, - ...codexReattachedHomeRouteField( - reattachedCodexHomeRoutes, - result.id, - result.isReattach === true - ), - ...(result.isReattach === true - ? { reattached: true } - : baseEnv - ? { launchEnv: baseEnv } - : {}) - }) - } - return resolvePaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, response) - } catch (err) { - if (pendingRegistrationPtyId) { - runtime?.cancelPendingPtyRegistration?.( - pendingRegistrationPtyId, - rejectedRegistrationCandidate?.incarnationId - ) - pendingRegistrationPtyId = null - } - // Why: once the reservation is created, any later throw — - // spawn failure, persist failure, or a post-spawn helper such as - // seedHeadlessTerminal/registerPty/track — must settle it. Otherwise - // it lingers in paneSpawnReservationsByOwnerKey and every future spawn - // for this pane awaits a promise that never resolves. reject is a - // no-op once the reservation has already resolved. - rejectPaneSpawnReservation(paneSpawnReservationKey, paneSpawnReservation, err) - throw err - } finally { - releaseWorktreeSpawn?.() - finishTerminalInstall() - } - } - ) - - const reportUnavailablePtyWrite = (id: string, error: unknown): void => { - if (!isPtyWriteUnavailableError(error) || isRendererGone(mainWindow)) { - return - } - sendToRenderer(mainWindow, 'pty:writeUnavailable', { id }) - } - - const writePtyProviderInputWithinLimit = ( - provider: IPtyProvider, - id: string, - data: string - ): boolean | Promise => { - const chunks = iterateTerminalInputChunks(data) - const first = chunks.next() - if (first.done) { - provider.write(id, data) - return true - } - const second = chunks.next() - if (second.done) { - provider.write(id, first.value) - return true - } - return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value) - } - - const writePtyProviderInput = ( - provider: IPtyProvider, - id: string, - data: string - ): boolean | Promise => { - try { - const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) - if (typeof tooLarge === 'boolean') { - return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data) - } - return tooLarge - .then((result) => (result ? false : writePtyProviderInputWithinLimit(provider, id, data))) - .catch((error) => { - reportUnavailablePtyWrite(id, error) - return false - }) - } catch (error) { - reportUnavailablePtyWrite(id, error) - return false - } - } - - const writePtyProviderInputChunks = async ( - provider: IPtyProvider, - id: string, - chunks: Iterator, - firstChunk: string, - secondChunk: string - ): Promise => { - try { - let chunk: IteratorResult = { done: false, value: firstChunk } - let nextChunk: IteratorResult = { done: false, value: secondChunk } - while (!chunk.done) { - provider.write(id, chunk.value) - if (!nextChunk.done) { - await new Promise((resolve) => setTimeout(resolve, 0)) - } - chunk = nextChunk - nextChunk = chunks.next() - } - return true - } catch (error) { - reportUnavailablePtyWrite(id, error) - return false - } - } - - type PtyWritePayload = { id: string; data: string } - type PtyViewportClaimPayload = { id: string; cols: number; rows: number } - - const isPtyWritePayload = (value: unknown): value is PtyWritePayload => - typeof value === 'object' && - value !== null && - typeof (value as { id?: unknown }).id === 'string' && - (value as { id: string }).id.length > 0 && - typeof (value as { data?: unknown }).data === 'string' - - const isPtyViewportClaimPayload = (value: unknown): value is PtyViewportClaimPayload => - typeof value === 'object' && - value !== null && - typeof (value as { id?: unknown }).id === 'string' && - (value as { id: string }).id.length > 0 && - typeof (value as { cols?: unknown }).cols === 'number' && - Number.isFinite((value as { cols: number }).cols) && - typeof (value as { rows?: unknown }).rows === 'number' && - Number.isFinite((value as { rows: number }).rows) && - (value as { cols: number }).cols > 0 && - (value as { rows: number }).rows > 0 - - // Why null-tolerant: with no renderer there is no sender that can legitimately match, - // so every write is rejected. These handlers cannot fire headless anyway — ipcMain has - // nothing to deliver from — but failing closed is the right answer if that ever changes. - const isPtyWriteEventFromMainWindow = ( - event: IpcMainEvent | IpcMainInvokeEvent, - mainWebContents: WebContents | null - ): boolean => - mainWebContents !== null && - event.sender === mainWebContents && - !isRendererGone(mainWindow) && - !(typeof mainWebContents.isDestroyed === 'function' && mainWebContents.isDestroyed()) - - const writePtyInput = (args: PtyWritePayload): boolean | Promise => { - // Why: mobile-presence-lock defense-in-depth — the renderer's onData guard can let one keystroke slip during the state-flip lag, so catch it server-side. See docs/mobile-presence-lock.md. - if (runtime?.getDriver(args.id).kind === 'mobile') { - return false - } - const provider = ptyOwnership.has(args.id) ? tryGetProviderForPty(args.id) : undefined - if (!provider) { - return false - } - try { - const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) - if (visibleRendererPtys.has(args.id)) { - clearHiddenRendererResizeOutput(args.id) - } - return writePtyProviderInput(provider, args.id, args.data) - } catch { - return false - } - } - - const writePtyInputAccepted = (args: PtyWritePayload): boolean | Promise => { - if (runtime?.getDriver(args.id).kind === 'mobile') { - return false - } - // Why: the ack infers Ctrl+C/Escape reached the local PTY; SSH providers are fire-and-forget relay notifications and can't truthfully acknowledge yet. - if (ptyOwnership.get(args.id) !== null) { - return false - } - const provider = tryGetProviderForPty(args.id) - if (!provider?.hasPty?.(args.id)) { - return false - } - try { - const now = performance.now() - lastInputAtByPty.set(args.id, now) - interactiveOutputCharsByPty.set(args.id, 0) - if (visibleRendererPtys.has(args.id)) { - clearHiddenRendererResizeOutput(args.id) - } - return writePtyProviderInput(provider, args.id, args.data) - } catch { - return false - } - } - - const hostViewportClaimTails = new Map>() - - ipc.on('pty:write', (event, args: unknown) => { - if ( - !isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) || - !isPtyWritePayload(args) - ) { - return - } - const claimTail = hostViewportClaimTails.get(args.id) - if (claimTail) { - void claimTail.then((claimed) => (claimed ? writePtyInput(args) : false)) - return - } - writePtyInput(args) - }) - ipc.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise => { - if ( - !isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) || - !isPtyWritePayload(args) - ) { - return false - } - const claimTail = hostViewportClaimTails.get(args.id) - return claimTail - ? claimTail.then((claimed) => (claimed ? writePtyInputAccepted(args) : false)) - : writePtyInputAccepted(args) - }) - - ipc.removeAllListeners('pty:claimViewport') - ipc.on('pty:claimViewport', (event, args: unknown) => { - if ( - !isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow)) || - !runtime || - !isPtyViewportClaimPayload(args) - ) { - return - } - const prior = hostViewportClaimTails.get(args.id) - // Why: two panes can mirror one PTY — never let a later no-op claim replace the in-flight resize that the following host input must await. - const claim = ( - prior - ? prior.then( - () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows), - () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) - ) - : runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) - ).catch(() => false) - hostViewportClaimTails.set(args.id, claim) - void claim.then(() => { - if (hostViewportClaimTails.get(args.id) === claim) { - hostViewportClaimTails.delete(args.id) - } - }) - }) - - // Why: resize is fire-and-forget — ipc.on (not .handle) halves IPC traffic by skipping the empty acknowledgement reply. - ipc.removeAllListeners('pty:resize') - ipc.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => { - // Why: after a desktop-fit override change the renderer's safeFit cascade re-measures ALL panes (background ones at full width), so suppress every pty:resize in this window to avoid corrupting PTY dimensions. - if (runtime?.isResizeSuppressed()) { - return - } - // Why: presence-lock defense-in-depth — while a phone or remote-desktop viewer drives the width, host-side resizes must not reach the PTY or its alt-screen grid garbles; load-bearing because the renderer mirror lags one IPC hop. See docs/mobile-presence-lock.md. - const mobileOwnsResize = runtime?.getDriver(args.id).kind === 'mobile' - const remoteDesktopOwnsResize = runtime?.isRemoteDesktopResizeDriven?.(args.id) === true - if (mobileOwnsResize || remoteDesktopOwnsResize) { - if (remoteDesktopOwnsResize) { - runtime?.recordRemoteDesktopHostReclaimTarget(args.id, args.cols, args.rows) - } - return - } - const provider = tryGetProviderForPty(args.id) - if (!provider) { - return - } - const markedHiddenResizeOutput = rendererPtyIsKnownHidden(args.id) - if (markedHiddenResizeOutput) { - // Why: alt-screen TUIs repaint on SIGWINCH; a hidden repaint read after switch-back must not masquerade as live output and overwrite the correctly-sized screen. - pendingHiddenRendererResizeOutputPtys.add(args.id) - deliveredHiddenRendererResizeOutputPtys.delete(args.id) - } else if (visibleRendererPtys.has(args.id)) { - // Why: after the stale hidden-resize repaint is observed, the renderer's visible resize pulse owns the next repaint. - clearDeliveredHiddenRendererResizeOutput(args.id) - } - try { - provider.resize(args.id, args.cols, args.rows) - } catch { - if (markedHiddenResizeOutput) { - pendingHiddenRendererResizeOutputPtys.delete(args.id) - } - return - } - ptySizes.set(args.id, { cols: args.cols, rows: args.rows }) - runtime?.onExternalPtyResize(args.id, args.cols, args.rows) - }) - - // Why: pty:reportGeometry is a measurement-only sibling of pty:resize — it refreshes the restore-target cache (never resizes) so mobile-fit hold learns real desktop dims even while resize is blocked. See docs/mobile-fit-hold.md. - ipc.removeAllListeners('pty:reportGeometry') - ipc.on('pty:reportGeometry', (_event, args: { id: string; cols: number; rows: number }) => { - runtime?.recordRendererGeometry(args.id, args.cols, args.rows) - }) - - // Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold-restore cache after the renderer consumed it; no-op for non-daemon providers. - ipc.on('pty:ackColdRestore', (_event, args: { id: string }) => { - const provider = tryGetProviderForPty(args.id) - if (provider && 'ackColdRestore' in provider && typeof provider.ackColdRestore === 'function') { - provider.ackColdRestore(args.id) - } - }) - - // Why: renderer ACKs bound main→renderer delivery without stopping PTY ingestion — agent/status consumers still see every chunk via the provider/runtime path. - ipc.on( - 'pty:ackData', - (_event, args: { id: string; charCount?: number; processedChars?: number }) => { - lastAckReceivedAtMs = Date.now() - // Why: a live ACK channel means a future unanswered probe is a fresh diagnostic event, not a continuation of the last silent streak. - deliveryResyncUnansweredWarnLogged = false - let acknowledged = 0 - if (typeof args.processedChars === 'number' && Number.isFinite(args.processedChars)) { - acknowledged = applyCumulativeAck(args.id, Math.max(0, args.processedChars)) - } else { - // Why: tolerate legacy per-chunk delta payloads — dev hot-reload can pair an old renderer with a new main. - const accounting = rendererDeliveryAccountingByPty.get(args.id) - const delta = Number.isFinite(args.charCount) ? Math.max(0, args.charCount ?? 0) : 0 - acknowledged = accounting ? applyCumulativeAck(args.id, accounting.ackedChars + delta) : 0 - } - tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged) - schedulePendingDataAfterCreditReport(acknowledged > 0) - } - ) - - ipc.on( - 'pty:deliveryResyncResponse', - (_event, args: { requestId: number; processedCharsByPty: Record }) => { - if ( - deliveryResyncOutstandingRequestId === null || - args?.requestId !== deliveryResyncOutstandingRequestId - ) { - return - } - clearDeliveryResyncProbe() - deliveryResyncUnansweredWarnLogged = false - // Why max-merge: the renderer's cumulative totals are authoritative for what it processed, draining exactly the in-flight debt from lost ACKs. - let creditedAny = false - for (const [id, processedChars] of Object.entries(args.processedCharsByPty ?? {})) { - if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { - continue - } - const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars)) - if (acknowledged > 0) { - creditedAny = true - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) - } - } - schedulePendingDataAfterCreditReport(creditedAny) - } - ) - - // Why invoke + renderer-initiated: the field wedge (v1.4.121-rc.0) kills every main→renderer push channel while invoke survives, so the resync rides here plus a write-off lane. - ipc.handle( - 'pty:reportRendererDeliveryState', - (_event, args: PtyRendererDeliveryStateReport): PtyRendererDeliveryHealthReply => { - // Extra repair lane for the lost-ACK variant: identical max-merge to the resync response, so a heal is only reached when merging cannot drain. - let creditedAny = false - for (const [id, processedChars] of Object.entries(args?.processedCharsByPty ?? {})) { - if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { - continue - } - const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars)) - if (acknowledged > 0) { - creditedAny = true - tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) - } - } - let writtenOff: PtyDeliveryWriteOff[] = [] - // Why the main-side ACK-silence check: requiring main to have also seen no ACK stops a buggy/foreign caller from writing off live delivery. - if ( - args?.heal === true && - rendererInFlightTotalChars > 0 && - (lastAckReceivedAtMs === null || - Date.now() - lastAckReceivedAtMs >= PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS) - ) { - writtenOff = writeOffLostRendererDelivery(args) - creditedAny ||= writtenOff.length > 0 - } - schedulePendingDataAfterCreditReport(creditedAny) - let inFlightPtyCount = 0 - for (const accounting of rendererDeliveryAccountingByPty.values()) { - if (accounting.sentChars - accounting.ackedChars > 0) { - inFlightPtyCount++ - } - } - return { - inFlightTotalChars: rendererInFlightTotalChars, - inFlightPtyCount, - msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, - ...(writtenOff.length > 0 ? { writtenOff } : {}) - } - } - ) - - // Why: renderer signals its pty:data listener is live; until then sends are held so boot-window bytes can't drop into a listener-less page and pin the gate. - ipc.removeAllListeners('pty:rendererDispatcherReady') - ipc.on('pty:rendererDispatcherReady', (event) => { - // Why: the reconcile below destructively clears delivery accounting, so a straggler handshake from a dying window must not reset the new window. - if (!isPtyWriteEventFromMainWindow(event, rendererWebContents(mainWindow))) { - return - } - // Why: a handshake while the gate is already open means a page load whose lifecycle reset was missed; clear the dead page's stale accounting so it can't permanently gate survivors. - if (rendererPtyDispatcherReady) { - resetRendererDeliveryAccountingForLifecycleReset() - } - // Why: real handshake landed — cancel the self-heal watchdog so it can't later force-open the gate. - clearDispatcherReadyWatchdog() - rendererPtyDispatcherReady = true - pendingData.reactivateBlocked() - schedulePendingDataFlush(0) - }) - - ipc.removeAllListeners('pty:setActiveRendererPty') - ipc.on('pty:setActiveRendererPty', (_event, args: { id: string; active: boolean }) => { - if (typeof args.id !== 'string' || !args.id) { - return - } - // Why: renderer scheduling hint only — active panes just get first chance at the bounded output reserve; reads/state/notifications continue for inactive terminals. - if (args.active) { - if (activeRendererPtys.has(args.id)) { - return - } - activeRendererPtys.add(args.id) - } else if (!activeRendererPtys.delete(args.id)) { - return - } - invalidatePendingPtyDrainPriority(args.id) - }) - - ipc.removeAllListeners('pty:setRendererPtyVisible') - ipc.on('pty:setRendererPtyVisible', (_event, args: { id: string; visible: boolean }) => { - if (typeof args.id !== 'string' || !args.id) { - return - } - // Why: data produced while no renderer can see this PTY must keep that origin through batching, even if the user switches back before the flush lands. - rendererVisibilityKnownPtys.add(args.id) - if (args.visible) { - visibleRendererPtys.add(args.id) - closeStartupQueryAuthorityForPty(args.id) - } else { - visibleRendererPtys.delete(args.id) - } - syncPtyBackgroundedDelivery(args.id, 'visibility-report') - }) - - ipc.removeAllListeners('pty:setHiddenRendererPty') - ipc.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => { - if (typeof args.id !== 'string' || !args.id) { - return - } - mainDeliveryBreadcrumbs.record(args.hidden === true ? 'gate-mark' : 'gate-unmark', { - id: redactPtyIdForDiagnostics(args.id) - }) - const transition = transitionHiddenRendererPtyDeliveryState(args.id, args.hidden === true) - if (args.hidden === true) { - closeStartupQueryAuthorityForPty(args.id) - // Why: drop bytes queued for a newly hidden PTY instead of holding them under ACK starvation; reveal restores from the snapshot. - const pending = pendingData.get(args.id) - if (pending && transition.droppable) { - pendingData.delete(args.id) - if (pending.projectionAdmissionIds) { - sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'hidden-drop') - } - updateProducerFlowControl(args.id) - pendingOverflowMarkedPtys.delete(args.id) - const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length) - if (drop.shouldEmitRestoreMarker) { - sendModelRestoreNeededMarker( - args.id, - 'hidden-drop', - runtime?.getPtyOutputSequence(args.id) - ) - } - } - if (transition.policyChanged) { - invalidatePendingPtyDrainPolicy(args.id) - } - syncPtyBackgroundedDelivery(args.id, 'gate-mark') - return - } - if (transition.policyChanged) { - invalidatePendingPtyDrainPolicy(args.id) - } - syncPtyBackgroundedDelivery(args.id, 'gate-unmark') - // Why: a reload/remount may have replaced the view that latched restore-needed, so re-emit on unhide; a redundant replay is cheap/idempotent, a missed restore corrupts the pane. - if (transition.droppedWhileHidden) { - sendModelRestoreNeededMarker(args.id, 'unhide', runtime?.getPtyOutputSequence(args.id)) - } - }) - - ipc.removeAllListeners('pty:terminalViewAttributes') - ipc.on('pty:terminalViewAttributes', (_event, args: unknown) => { - // Why validate-or-drop: a malformed palette gives a wrong color reply that breaks TUI theme detection worse than the silent-until-first-push default. - const attributes = validateTerminalViewAttributes(args) - if (attributes) { - setTerminalViewAttributes(attributes) - } - }) - - ipc.removeAllListeners('pty:setPtyDeliveryInterest') - ipc.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { - if (typeof args.id !== 'string' || !args.id) { - return - } - // Why: any delivery interest suppresses the hidden-delivery gate (raw-byte consumers keep receiving while hidden); not synced to the daemon pacer so interest churn can't un-pace a flood. - const settings = getSettings?.() - const wasDroppable = shouldDropHiddenRendererPtyData(args.id, settings) - setRendererPtyDeliveryInterest(args.id, args.interested === true) - if (wasDroppable !== shouldDropHiddenRendererPtyData(args.id, settings)) { - invalidatePendingPtyDrainPolicy(args.id) - } - }) - - ipc.removeAllListeners('pty:signal') - ipc.on('pty:signal', (_event, args: { id: string; signal: string }) => { - tryGetProviderForPty(args.id) - ?.sendSignal(args.id, args.signal) - .catch(() => {}) - }) - - ipc.removeAllListeners('pty:clearBuffer') - ipc.on('pty:clearBuffer', (_event, args: { id: string }) => { - // Why: clear PTY-side state (ConPTY/daemon/SSH buffer) so the next prompt repaint doesn't land at a stale cursor row. - tryGetProviderForPty(args.id) - ?.clearBuffer(args.id) - .catch(() => {}) - runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {}) - }) - - ipc.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => { - if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { - // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. - throw new Error('Invalid PTY provider id') - } - runtime?.markPtyStopRequested?.(args.id) - const ownedConnectionId = ptyOwnership.get(args.id) - const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null - const connectionId = ownedConnectionId ?? parsedSshId?.connectionId - // Why: wait for daemon startup before selecting the local provider, else a fallback shutdown falsely succeeds and orphans a restored daemon PTY (#7742). - const startupPromise = getLocalPtyProviderStartupPromise(connectionId) - if (startupPromise) { - await startupPromise - } - const provider = connectionId ? sshProviders.get(connectionId) : tryGetProviderForPty(args.id) - if (!provider && connectionId) { - // Why: detached SSH PTYs intentionally keep ownership after their - // provider is unregistered; hydrated app-scoped ids can also arrive - // before ownership is rebuilt. Tombstone instead of falling back local. - const incarnationId = finishPtyShutdown(args.id, connectionId, store) - runtime?.markPtyLivenessUnverifiable?.(args.id, SSH_PROVIDER_UNREGISTERED_REASON) - runtime?.onPtyExit(args.id, -1, incarnationId) - rememberSyntheticKillExit(args.id) - sendPtyExitToRenderer({ id: args.id, code: -1 }) - return - } - const shutdownProvider = provider ?? getProviderForPty(args.id) - let providerExitObserved = false - try { - providerExitObserved = await shutdownProviderAndDetectExit(shutdownProvider, args.id, { - immediate: true, - keepHistory: args.keepHistory ?? false - }) - } catch (err) { - if (!isPtyAlreadyGoneError(err)) { - // Why: a failed shutdown can leave the process alive (SSH relay grace window / local daemon); keep ownership/lease state so the user can retry. - throw err - } - /* session already dead — cleanup below handles the rest */ - } - // Why: some shutdown paths do not emit onExit through the provider listener. - // Explicit cleanup is idempotent and covers already-dead PTYs. - const incarnationId = finishPtyShutdown(args.id, connectionId, store) - if (!providerExitObserved) { - runtime?.onPtyExit(args.id, -1, incarnationId) - rememberSyntheticKillExit(args.id) - sendPtyExitToRenderer({ id: args.id, code: -1 }) - } - }) - - ipc.handle('pty:listSessions', async (): Promise => { - const deduped = new Map() - const admission = new PtyProcessListAdmission() - await visitPtyProcessListingsInBatches( - registeredPtyProviders(), - ({ provider, connectionId }) => - connectionId === null ? provider.listProcesses() : provider.listProcesses().catch(() => []), - ({ provider, connectionId }, sessions) => { - for (const rawSession of sessions) { - const session = admission.admit(rawSession) - // Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider. - ptyOwnership.set(session.id, connectionId) - deduped.set(session.id, { - id: session.id, - cwd: session.cwd, - title: session.title, - // Why: the renderer's binding map is empty during restore, so ownership is the only - // liveness evidence it has. Absence is authoritative only from a provider that - // serializes claims — otherwise it is 'unknown', never 'absent' (#8459). - agentOwnership: - (session.agentSessionOwners?.length ?? 0) > 0 - ? 'present' - : provider.providesAgentSessionOwnerListings?.(session.id) === true - ? 'absent' - : 'unknown' - }) - } - } - ) - return Array.from(deduped.values()) - }) - - ipc.handle( - 'pty:getAuthoritativeBufferSnapshotCapabilities', - async (_event, args: { ids?: unknown }) => { - const ids = Array.isArray(args?.ids) ? args.ids.slice(0, 512) : [] - const hasLocalPtyId = ids.some((value) => { - if ( - typeof value !== 'string' || - value.length === 0 || - value.length > 512 || - value.startsWith('remote:') || - parseAppSshPtyId(value) - ) { - return false - } - const ownedConnectionId = ptyOwnership.get(value) - return ownedConnectionId === undefined || ownedConnectionId === null - }) - if (hasLocalPtyId) { - await getLocalPtyProviderStartupPromise() - } - const capabilities: { id: string; authoritative: boolean | null }[] = [] - const seen = new Set() - for (const value of ids) { - if ( - typeof value !== 'string' || - value.length === 0 || - value.length > 512 || - seen.has(value) - ) { - continue - } - seen.add(value) - const provider = tryGetProviderForPty(value) - // Resolved providers without the optional method are definitively non-authoritative; null remains retryable. - capabilities.push({ - id: value, - authoritative: - provider === undefined || provider === null - ? null - : provider.canProvideAuthoritativeBufferSnapshot - ? provider.canProvideAuthoritativeBufferSnapshot(value) - : false - }) - } - return capabilities - } - ) - - ipc.handle('pty:hasPty', async (_event, args: { id: string }): Promise => { - if (typeof args?.id !== 'string' || args.id.startsWith('remote:')) { - // Why: same routing hazard pty:kill guards against — ptyOwnership never holds - // a runtime terminal handle and parseAppSshPtyId ignores it, so the lookup - // falls through to the local provider and its "not in my table" reads as an - // authoritative dead. That is a fabricated answer about another host's PTY. - return null - } - const ownedConnectionId = ptyOwnership.get(args.id) - const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null - const provider = parsedSshId - ? sshProviders.get(parsedSshId.connectionId) - : tryGetProviderForPty(args.id) - if (!provider?.hasPty) { - return null - } - try { - return provider.hasPty(args.id) - } catch { - // Why: liveness is only allowed to close panes on an authoritative false. - return null - } - }) - - ipc.handle('pty:hasChildProcesses', async (_event, args: { id: string }): Promise => { - if (!hasPtyProviderForInspection(args.id)) { - return false - } - return getProviderForPty(args.id).hasChildProcesses(args.id) - }) - - ipc.handle( - 'pty:getForegroundProcess', - async (_event, args: { id: string }): Promise => { - if (!hasPtyProviderForInspection(args.id)) { - return null - } - return getProviderForPty(args.id).getForegroundProcess(args.id) - } - ) - - ipc.handle('pty:inspectProcess', async (_event, args: { id: string }) => - inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id) - ) - - ipc.handle( - 'pty:confirmForegroundProcess', - async (_event, args: { id: string }): Promise => { - if (!hasPtyProviderForInspection(args.id)) { - return null - } - const provider = getProviderForPty(args.id) - // Why: the cached foreground API would turn stale process identity into shell/agent authority at a command boundary. - return provider.confirmForegroundProcess?.(args.id) ?? null - } - ) - - // Why: Cmd+D split needs the live shell cwd so the new pane inherits it (not the worktree root); '' means unknown/unresolvable (Windows) → renderer falls through. - ipc.handle('pty:getCwd', async (_event, args: { id: string }): Promise => { - try { - return await getProviderForPty(args.id).getCwd(args.id) - } catch { - return '' - } - }) - - // Why: prefer the provider's APPLIED size over the requested ptySizes so the renderer's resume drift-check can spot a dropped resize; null means "cannot confirm" → re-forward once. - ipc.handle( - 'pty:getSize', - async (_event, args: { id: string }): Promise<{ cols: number; rows: number } | null> => { - const provider = tryGetProviderForPty(args.id) - try { - if (provider?.getAppliedSize) { - // Why: a provider-owned null means it could not verify the applied - // grid; preserve null so the renderer re-forwards instead of trusting - // the requested-size cache that may describe a dropped resize. - return await provider.getAppliedSize(args.id) - } - } catch { - // Fall through to the requested-size cache so a dead daemon/relay can't throw across the IPC boundary. - } - return ptySizes.get(args.id) ?? null - } - ) - - // Pre-signal handshake handlers (declare→spawn→settle/clear); see docs/mobile-prefer-renderer-scrollback.md and `pendingByPaneKey` above. - ipc.handle( - 'pty:declarePendingPaneSerializer', - async (event, args: { paneKey?: unknown }): Promise => { - if (!isValidPaneKey(args.paneKey)) { - throw new Error('Invalid paneKey') - } - return declarePendingPaneSerializer(args.paneKey, event?.sender) - } - ) - - ipc.handle( - 'pty:settlePaneSerializer', - async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { - if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { - return - } - const ptyId = pendingPtyIdBySerializerGeneration.get(args.gen) - const settledCurrentGeneration = settlePendingPaneSerializer(args.paneKey, args.gen) - // Why: the generation-to-PTY binding survives late teardown of a reused id; paneKey reverse maps may already be gone. - pendingPtyIdBySerializerGeneration.delete(args.gen) - if (settledCurrentGeneration && ptyId) { - rendererSerializerReadiness.markReady(ptyId) - } - } - ) - - ipc.handle( - 'pty:clearPendingPaneSerializer', - async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { - if (!isValidPaneKey(args.paneKey) || typeof args.gen !== 'number') { - return - } - settlePendingPaneSerializer(args.paneKey, args.gen) - pendingPtyIdBySerializerGeneration.delete(args.gen) - } - ) - - ipc.handle( - 'pty:reportRendererSerializerReady', - async (_event, args: { ptyId?: unknown }): Promise => { - if ( - typeof args.ptyId !== 'string' || - !args.ptyId.startsWith('remote:') || - args.ptyId.length > 512 - ) { - return - } - // Why: remote-runtime panes skip the local spawn cooperation gate, so their exact PTY id is the only readiness key. - rendererSerializerReadiness.markReady(args.ptyId) - } - ) -} - -export function registerHeadlessPtyRuntime( - runtime: OrcaRuntimeService, - getSelectedCodexHomePath?: GetSelectedCodexHomePath, - getSettings?: () => GlobalSettings, - prepareClaudeAuth?: PrepareClaudeAuth, - store?: Store, - prepareCodexSessionResume?: PrepareCodexSessionResume, - lifecycle?: { - onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void - onPtyExit?: (id: string, exitSequence: number) => void - } -): void { - // Why null and not a stub window: headless `orca serve` has no renderer but needs the - // same PTY handlers so remote clients can drive terminals. This used to pass a fake - // BrowserWindow whose isDestroyed() returned true — a window-shaped object that lied - // about being a window. `null` says the same thing honestly, and keeps `electron` out - // of this path entirely. - registerPtyHandlers( - null, - runtime, - getSelectedCodexHomePath, - getSettings, - prepareClaudeAuth, - store, - { prepareCodexSessionResume, ...lifecycle } - ) -} - -/** - * Kill in-process local PTYs. Daemon-backed PTYs are preserved by daemon disconnect. - */ -export function killAllPty(): void { - if (localProvider instanceof LocalPtyProvider) { - localProvider.killAll() - } -} +export { + isCurrentPtyExit, + deletePtyOwnership, + setPtyOwnership, + restorePtyIncarnation, + getPtyIdsForConnection +} from './pty/provider/ownership-state' +export { getPtyIdForPaneKey, registerPaneKeyTeardownListener } from './pty/pane/key-state' +export { hasPendingRendererSerializerForPaneKey } from './pty/pane/serializer-state' +export type { + BuildPtyHostEnvOptions, + CodexHomeLaunchContext, + GetSelectedCodexHomePath, + PrepareCodexSessionResume, + CodexHomePtySpawnedLifecycleArgs +} from './pty/host-env/types' +export { resolveCodexHomeAfterManagedAuthReadiness } from './pty/host-env/codex-home' +export { buildPtyHostEnv } from './pty/host-env/assembly' +export { + registerSshPtyProvider, + unregisterSshPtyProvider, + getSshPtyProvider, + getLocalPtyProvider, + setLocalPtyProvider +} from './pty/provider/registry' +export { clearProviderPtyState, clearPtyOwnershipForConnection } from './pty/provider/state-cleanup' +export { + rebindLocalProviderListeners, + unbindLocalProviderListeners +} from './pty/provider/listener-lifecycle' +export type { PtyRendererDeliveryDebugSnapshot } from './pty/delivery/debug' +export { + getPtyRendererDeliveryDebugSnapshot, + resetPtyRendererDeliveryDebug +} from './pty/delivery/debug' +export { registerPtyHandlers } from './pty/register-handlers' +export { registerHeadlessPtyRuntime } from './pty/register-headless-runtime' +export { killAllPty } from './pty/kill-all' diff --git a/src/main/ipc/pty/delivery/accept.ts b/src/main/ipc/pty/delivery/accept.ts new file mode 100644 index 00000000000..91117147dde --- /dev/null +++ b/src/main/ipc/pty/delivery/accept.ts @@ -0,0 +1,190 @@ +import type { LegacySshProjectionSemantics } from '../../ssh-pty-legacy-projection' +import { + recordHiddenRendererPtyDataDrop, + shouldDropHiddenRendererPtyData +} from '../../pty-hidden-delivery-gate' +import { + deliveredHiddenRendererResizeOutputPtys, + pendingHiddenRendererResizeOutputPtys, + rendererVisibilityKnownPtys, + visibleRendererPtys, + activeRendererPtys +} from './visibility-state' +import { PTY_BATCH_INTERVAL_MS } from './constants' +import { appendPendingPtyData, getDroppedMode2031RendererData } from './pending' +import { sendModelRestoreNeededMarker, sendPtyDataToRenderer } from './payload' +import { shouldSendInteractiveOutputNow } from './interactive' +import { requestDeliveryResyncForGatedPty } from './accounting' +import { warnIfDroppingHiddenBytesForVisiblePty } from './debug-snapshot' +import { clearFlushTimerIfIdle } from './flush' +import type { PtyIpcSession } from '../session' + +export function rendererPtyIsKnownHidden(id: string): boolean { + return rendererVisibilityKnownPtys.has(id) && !visibleRendererPtys.has(id) +} + +export function ptyHasHiddenRendererResizeOutput(id: string): boolean { + return ( + pendingHiddenRendererResizeOutputPtys.has(id) || deliveredHiddenRendererResizeOutputPtys.has(id) + ) +} + +export function markHiddenRendererResizeOutputDelivered(id: string): void { + if (!pendingHiddenRendererResizeOutputPtys.delete(id)) { + return + } + deliveredHiddenRendererResizeOutputPtys.add(id) +} + +export function clearDeliveredHiddenRendererResizeOutput(id: string): void { + deliveredHiddenRendererResizeOutputPtys.delete(id) +} + +export function clearHiddenRendererResizeOutput(id: string): void { + pendingHiddenRendererResizeOutputPtys.delete(id) + deliveredHiddenRendererResizeOutputPtys.delete(id) +} + +export function acceptPtyDataForRenderer( + session: PtyIpcSession, + payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + }, + outputSeq: number | undefined, + projection?: LegacySshProjectionSemantics +): void { + const rawLength = payload.sequenceChars ?? payload.data.length + const preservesSeq = !payload.transformed && rawLength === payload.data.length + const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined + const projectionId = projection?.identity.projectionSemanticsId + if (session.mainWindow.isDestroyed()) { + if (projectionId) { + session.sshOutputIntake?.transferProjections([projectionId], 'renderer-destroyed') + } + if (session.flushTimer) { + clearTimeout(session.flushTimer) + session.flushTimer = null + } + session.producerFlowControl.releaseAll() + session.clearDeliveryResyncProbe() + session.clearPendingPtyData() + session.pendingOverflowMarkedPtys.clear() + session.rendererDeliveryAccountingByPty.clear() + session.rendererInFlightTotalChars = 0 + session.clearDispatcherReadyWatchdog() + return + } + if (session.rendererExitingPtyIds.has(payload.id)) { + if (projectionId) { + session.sshOutputIntake?.transferProjections([projectionId], 'pty-exiting') + } + return + } + if (shouldDropHiddenRendererPtyData(payload.id, session.getSettings?.())) { + if (projectionId) { + session.sshOutputIntake?.transferProjections([projectionId], 'hidden-drop') + } + const droppedChars = projection ? rawLength : payload.data.length + const drop = recordHiddenRendererPtyDataDrop(payload.id, droppedChars) + warnIfDroppingHiddenBytesForVisiblePty(session, payload.id, droppedChars) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker(session, payload.id, 'hidden-drop', outputSeq) + } + return + } + if (payload.data.length === 0 && !payload.transformed) { + if (projectionId) { + session.sshOutputIntake?.transferProjections([projectionId], 'empty-projection') + } + return + } + const containsBackgroundOutput = + rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id) + if (containsBackgroundOutput) { + markHiddenRendererResizeOutputDelivered(payload.id) + } + const overflowMarkedBeforeAppend = session.pendingOverflowMarkedPtys.has(payload.id) + if (projection?.desktopSpan) { + session.sourceCreditPendingPtys.add(payload.id) + } + const pending = appendPendingPtyData( + session, + payload.id, + session.pendingData.get(payload.id), + payload.data, + startSeq, + preservesSeq, + containsBackgroundOutput, + rawLength, + payload.transformed === true, + projectionId + ) + const shouldEmitPendingCapRestoreMarker = + pending.droppedOutput === true && + !overflowMarkedBeforeAppend && + session.pendingOverflowMarkedPtys.has(payload.id) + const nextData = pending.data + getDroppedMode2031RendererData(pending) + const isInteractiveOutput = shouldSendInteractiveOutputNow( + payload.id, + nextData, + performance.now() + ) + if (isInteractiveOutput && session.rendererPtyDispatcherReady) { + if (!session.canSendPtyDataToRenderer(payload.id, { interactive: true })) { + session.setPendingPtyData(payload.id, pending) + if (shouldEmitPendingCapRestoreMarker) { + sendModelRestoreNeededMarker(session, payload.id, 'pending-cap', outputSeq) + } + session.updateProducerFlowControl(payload.id) + requestDeliveryResyncForGatedPty(session) + return + } + session.deletePendingPtyData(payload.id) + clearFlushTimerIfIdle(session) + if (shouldEmitPendingCapRestoreMarker) { + sendModelRestoreNeededMarker(session, payload.id, 'pending-cap', outputSeq) + } + session.pendingOverflowMarkedPtys.delete(payload.id) + try { + sendPtyDataToRenderer( + session, + payload.id, + { + id: payload.id, + data: nextData, + ...(typeof pending.startSeq === 'number' + ? { + seq: pending.startSeq + (pending.rawLength ?? nextData.length), + rawLength: pending.rawLength ?? nextData.length + } + : {}), + ...(pending.transformed ? { transformed: true } : {}), + ...(pending.containsBackgroundOutput === true ? { background: true } : {}), + ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) + }, + pending.projectionAdmissionIds + ) + } finally { + session.updateProducerFlowControl(payload.id) + } + return + } + session.setPendingPtyData(payload.id, pending) + if (shouldEmitPendingCapRestoreMarker) { + sendModelRestoreNeededMarker(session, payload.id, 'pending-cap', outputSeq) + } + session.updateProducerFlowControl(payload.id) + if ( + !session.canSendPtyDataToRenderer(payload.id, { + interactive: activeRendererPtys.has(payload.id) + }) + ) { + requestDeliveryResyncForGatedPty(session) + } + if (!session.flushTimer) { + session.schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) + } +} diff --git a/src/main/ipc/pty/delivery/accounting.ts b/src/main/ipc/pty/delivery/accounting.ts new file mode 100644 index 00000000000..eddc49be3f2 --- /dev/null +++ b/src/main/ipc/pty/delivery/accounting.ts @@ -0,0 +1,219 @@ +import type { + PtyDeliveryWriteOff, + PtyRendererDeliveryStateReport +} from '../../../../shared/pty-renderer-delivery-health' +import { tryGetProviderForPty } from '../provider/registry' +import { mainDeliveryBreadcrumbs } from './debug' +import { + PTY_DELIVERY_RESYNC_TIMEOUT_MS, + PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS, + PTY_RENDERER_INTERACTIVE_RESERVE_CHARS, + PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS, + PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS +} from './constants' +import type { PtyIpcSession } from '../session' +import type { PendingPtyData } from '../../pty-pending-data-drain-queue' + +export function getRendererInFlightCharsForPty(session: PtyIpcSession, id: string): number { + const accounting = session.rendererDeliveryAccountingByPty.get(id) + return accounting ? accounting.sentChars - accounting.ackedChars : 0 +} + +export function recordPtyRendererDeliveryPressure(session: PtyIpcSession, id: string): void { + session.peakPendingChars = Math.max( + session.peakPendingChars, + session.pendingData.totalPendingChars + ) + session.peakMaxPendingCharsByPty = Math.max( + session.peakMaxPendingCharsByPty, + session.pendingData.get(id)?.data.length ?? 0 + ) + session.peakRendererInFlightChars = Math.max( + session.peakRendererInFlightChars, + session.rendererInFlightTotalChars + ) + session.peakMaxRendererInFlightCharsByPty = Math.max( + session.peakMaxRendererInFlightCharsByPty, + getRendererInFlightCharsForPty(session, id) + ) +} + +export function setPendingPtyData( + session: PtyIpcSession, + id: string, + pending: PendingPtyData +): void { + session.pendingData.set(id, pending) + recordPtyRendererDeliveryPressure(session, id) +} + +export function deletePendingPtyData(session: PtyIpcSession, id: string): void { + session.pendingData.delete(id) +} + +export function clearPendingPtyData(session: PtyIpcSession): void { + for (const pending of session.pendingData.values()) { + if (pending.projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections( + pending.projectionAdmissionIds, + 'renderer-lifecycle-reset' + ) + } + } + session.pendingData.clear() + session.sourceCreditPendingPtys.clear() +} + +export function canSendPtyDataToRenderer( + session: PtyIpcSession, + id: string, + options: { interactive?: boolean } = {} +): boolean { + const totalLimit = + PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS + + (options.interactive === true ? PTY_RENDERER_INTERACTIVE_RESERVE_CHARS : 0) + // Why per-PTY (not global) reserve: keep one active pane responsive without letting every background pane burst past the cap. + const ptyLimit = + PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS + + (options.interactive === true ? PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS : 0) + return ( + getRendererInFlightCharsForPty(session, id) < ptyLimit && + session.rendererInFlightTotalChars < totalLimit + ) +} + +export function applyCumulativeAck( + session: PtyIpcSession, + id: string, + processedChars: number +): number { + const accounting = session.rendererDeliveryAccountingByPty.get(id) + if (!accounting) { + return 0 + } + // Clamped to sentChars so a corrupt payload cannot drive in-flight negative. + const nextAckedChars = Math.min( + accounting.sentChars, + Math.max(accounting.ackedChars, processedChars) + ) + const acknowledged = nextAckedChars - accounting.ackedChars + accounting.ackedChars = nextAckedChars + if (acknowledged > 0) { + accounting.lastAckAtMs = Date.now() + } + session.rendererInFlightTotalChars = Math.max( + 0, + session.rendererInFlightTotalChars - acknowledged + ) + if (acknowledged > 0) { + session.sshOutputIntake?.settleProjectionPrefix(id, acknowledged) + } + return acknowledged +} + +export function schedulePendingDataAfterCreditReport( + session: PtyIpcSession, + creditedAny: boolean +): void { + if (creditedAny) { + session.pendingData.reactivateBlocked() + } + if (session.pendingData.size > 0 && !session.flushTimer) { + session.schedulePendingDataFlush(0) + } +} + +export function clearDeliveryResyncProbe(session: PtyIpcSession): void { + session.deliveryResyncOutstandingRequestId = null + if (session.deliveryResyncTimer) { + clearTimeout(session.deliveryResyncTimer) + session.deliveryResyncTimer = null + } +} + +export function requestDeliveryResyncForGatedPty(session: PtyIpcSession): void { + if (session.deliveryResyncOutstandingRequestId !== null || session.mainWindow.isDestroyed()) { + return + } + session.deliveryResyncRequestSerial += 1 + const requestId = session.deliveryResyncRequestSerial + session.deliveryResyncOutstandingRequestId = requestId + session.deliveryResyncTimer = setTimeout(() => { + if (session.deliveryResyncOutstandingRequestId !== requestId) { + return + } + clearDeliveryResyncProbe(session) + // Why no mutation on timeout: unanswered means dead IPC that only a reload cures; log once per silent streak to avoid spamming every probe. + if (session.deliveryResyncUnansweredWarnLogged) { + return + } + session.deliveryResyncUnansweredWarnLogged = true + console.warn('[pty] delivery resync probe unanswered — renderer IPC unresponsive', { + msSinceLastAck: + session.lastAckReceivedAtMs === null ? null : Date.now() - session.lastAckReceivedAtMs, + ...session.readCurrentPtyRendererDeliveryDebugSnapshot() + }) + }, PTY_DELIVERY_RESYNC_TIMEOUT_MS) + session.deliveryResyncTimer.unref?.() + session.mainWindow.webContents.send('pty:requestDeliveryResync', { requestId }) +} + +export function writeOffLostRendererDelivery( + session: PtyIpcSession, + report: PtyRendererDeliveryStateReport +): PtyDeliveryWriteOff[] { + const writtenOff: PtyDeliveryWriteOff[] = [] + for (const [id, accounting] of session.rendererDeliveryAccountingByPty) { + if (accounting.sentChars - accounting.ackedChars <= 0) { + continue + } + const received = report.receivedCharsByPty?.[id] + const receivedChars = + typeof received === 'number' && Number.isFinite(received) ? Math.max(0, received) : 0 + // Why skip: received-but-unparsed bytes are alive in the renderer write queue; their deferred ACK still repays this debt. + if (receivedChars > accounting.ackedChars) { + continue + } + const acknowledged = applyCumulativeAck(session, id, accounting.sentChars) + if (acknowledged <= 0) { + continue + } + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + // Why drop pending: everything at/before markerSeq comes from the snapshot, so flushing pre-marker bytes would double-paint the restore. + const pending = session.pendingData.get(id) + if (pending) { + if (pending.projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections( + pending.projectionAdmissionIds, + 'renderer-delivery-writeoff' + ) + } + session.pendingDroppedChars += pending.data.length + deletePendingPtyData(session, id) + session.pendingOverflowMarkedPtys.delete(id) + session.updateProducerFlowControl(id) + } + const markerSeq = session.runtime?.getPtyOutputSequence(id) + writtenOff.push({ + id, + ...(typeof markerSeq === 'number' ? { markerSeq } : {}), + writtenOffChars: acknowledged + }) + } + if (writtenOff.length > 0) { + clearDeliveryResyncProbe(session) + session.deliveryResyncUnansweredWarnLogged = false + mainDeliveryBreadcrumbs.record('delivery-heal-writeoff', { + writtenOffPtyCount: writtenOff.length, + writtenOffChars: writtenOff.reduce((sum, { writtenOffChars }) => sum + writtenOffChars, 0) + }) + console.warn('[pty] delivery heal: wrote off renderer-bound bytes lost in push channel', { + rendererPtyDataListenerCount: report.rendererPtyDataListenerCount ?? null, + msSinceLastAck: + session.lastAckReceivedAtMs === null ? null : Date.now() - session.lastAckReceivedAtMs, + writtenOffByPty: writtenOff.map(({ id, writtenOffChars }) => ({ id, writtenOffChars })), + ...session.readCurrentPtyRendererDeliveryDebugSnapshot() + }) + } + return writtenOff +} diff --git a/src/main/ipc/pty/delivery/constants.ts b/src/main/ipc/pty/delivery/constants.ts new file mode 100644 index 00000000000..b9b3bb3670c --- /dev/null +++ b/src/main/ipc/pty/delivery/constants.ts @@ -0,0 +1,17 @@ +export const PTY_BATCH_INTERVAL_MS = 2 +export const PTY_BATCH_DRAIN_CONTINUE_MS = 1 +export const PTY_BATCH_FLUSH_CHUNK_CHARS = 16 * 1024 +export const PTY_BATCH_FLUSH_MAX_WRITES = 2 +export const PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS = 512 * 1024 +export const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 8 * 1024 * 1024 +export const PTY_DISPATCHER_READY_WATCHDOG_MS = 10_000 +export const PTY_RENDERER_INTERACTIVE_RESERVE_CHARS = 256 * 1024 +export const PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS = 512 * 1024 +export const PTY_DELIVERY_RESYNC_TIMEOUT_MS = 5_000 +export const PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS = 10_000 +export const INTERACTIVE_OUTPUT_WINDOW_MS = 100 +export const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 +export const INTERACTIVE_REDRAW_MAX_CHARS = PTY_BATCH_FLUSH_CHUNK_CHARS +export const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 +export const DELIVERY_DIAGNOSTICS_MAX_PTYS = 30 +export const DROPPED_QUERY_SALVAGE_MAX_CHARS = 4096 diff --git a/src/main/ipc/pty/delivery/debug-snapshot.ts b/src/main/ipc/pty/delivery/debug-snapshot.ts new file mode 100644 index 00000000000..facc04e4e74 --- /dev/null +++ b/src/main/ipc/pty/delivery/debug-snapshot.ts @@ -0,0 +1,168 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import { + type PtyMainDeliveryDiagnostics, + type PtyPerPtyDeliveryDiagnostics, + redactPtyIdForDiagnostics +} from '../../../../shared/pty-delivery-diagnostics' +import { + getHiddenRendererPtyDeliveryDebug, + getHiddenRendererPtyIds, + isHiddenRendererPty +} from '../../pty-hidden-delivery-gate' +import { + lastPowerResumeAtMs, + lastPowerSuspendAtMs, + mainDeliveryBreadcrumbs, + type PtyRendererDeliveryDebugSnapshot +} from './debug' +import { activeRendererPtys, visibleRendererPtys } from './visibility-state' +import { DELIVERY_DIAGNOSTICS_MAX_PTYS } from './constants' +import type { PtyIpcSession } from '../session' + +export function buildMainDeliveryDiagnostics(session: PtyIpcSession): PtyMainDeliveryDiagnostics { + const now = Date.now() + // Include hidden/visible/active members even without an accounting entry: a pty gated before its first byte is exactly the wedge case to surface. + const ids = new Set([ + ...session.rendererDeliveryAccountingByPty.keys(), + ...session.pendingData.keys(), + ...getHiddenRendererPtyIds(), + ...visibleRendererPtys, + ...activeRendererPtys + ]) + const perPty: PtyPerPtyDeliveryDiagnostics[] = [] + for (const id of ids) { + const accounting = session.rendererDeliveryAccountingByPty.get(id) + perPty.push({ + id: redactPtyIdForDiagnostics(id), + sentChars: accounting?.sentChars ?? 0, + ackedChars: accounting?.ackedChars ?? 0, + inFlightChars: accounting ? accounting.sentChars - accounting.ackedChars : 0, + pendingChars: session.pendingData.get(id)?.data.length ?? 0, + hidden: isHiddenRendererPty(id), + visible: visibleRendererPtys.has(id), + active: activeRendererPtys.has(id), + msSinceLastSend: accounting ? now - accounting.lastSendAtMs : null, + msSinceLastAck: accounting?.lastAckAtMs == null ? null : now - accounting.lastAckAtMs + }) + } + perPty.sort((a, b) => b.inFlightChars + b.pendingChars - (a.inFlightChars + a.pendingChars)) + const windowAlive = !session.mainWindow.isDestroyed() + return { + appVersion: getAppEnvironment().getVersion(), + mainUptimeMs: Math.round(process.uptime() * 1000), + windowFocused: windowAlive ? session.mainWindow.isFocused() : null, + windowVisible: windowAlive ? session.mainWindow.isVisible() : null, + windowMinimized: windowAlive ? session.mainWindow.isMinimized() : null, + msSinceLastPowerSuspend: lastPowerSuspendAtMs === null ? null : now - lastPowerSuspendAtMs, + msSinceLastPowerResume: lastPowerResumeAtMs === null ? null : now - lastPowerResumeAtMs, + perPty: perPty.slice(0, DELIVERY_DIAGNOSTICS_MAX_PTYS), + breadcrumbs: mainDeliveryBreadcrumbs.snapshot() + } +} + +export function readCurrentPtyRendererDeliveryDebugSnapshot( + session: PtyIpcSession +): PtyRendererDeliveryDebugSnapshot { + let pendingChars = 0 + let maxPendingCharsByPty = 0 + for (const pending of session.pendingData.values()) { + const chars = pending.data.length + pendingChars += chars + maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) + } + const hiddenDeliveryDebug = getHiddenRendererPtyDeliveryDebug() + let rendererInFlightPtyCount = 0 + let maxRendererInFlightCharsByPty = 0 + for (const accounting of session.rendererDeliveryAccountingByPty.values()) { + const inFlight = accounting.sentChars - accounting.ackedChars + if (inFlight > 0) { + rendererInFlightPtyCount++ + } + maxRendererInFlightCharsByPty = Math.max(maxRendererInFlightCharsByPty, inFlight) + } + // Why: a pty both hidden-gated and reported visible means main is starving a visible pane (v1.4.124-rc.2.perf field lead). + let hiddenDeliveryGatedVisiblePtyCount = 0 + for (const id of visibleRendererPtys) { + if (isHiddenRendererPty(id)) { + hiddenDeliveryGatedVisiblePtyCount++ + } + } + let hiddenDeliveryGatedActivePtyCount = 0 + for (const id of activeRendererPtys) { + if (isHiddenRendererPty(id)) { + hiddenDeliveryGatedActivePtyCount++ + } + } + return { + pendingPtyCount: session.pendingData.size, + pendingChars, + maxPendingCharsByPty, + rendererInFlightPtyCount, + rendererInFlightChars: session.rendererInFlightTotalChars, + maxRendererInFlightCharsByPty, + activeRendererPtyCount: activeRendererPtys.size, + flushScheduled: session.flushTimer !== null, + peakPendingChars: session.peakPendingChars, + peakMaxPendingCharsByPty: session.peakMaxPendingCharsByPty, + peakRendererInFlightChars: session.peakRendererInFlightChars, + peakMaxRendererInFlightCharsByPty: session.peakMaxRendererInFlightCharsByPty, + ackGatedFlushSkipCount: session.ackGatedFlushSkipCount, + ...hiddenDeliveryDebug, + hiddenDeliveryGatedVisiblePtyCount, + hiddenDeliveryGatedActivePtyCount, + pendingDroppedChars: session.pendingDroppedChars, + diagnostics: buildMainDeliveryDiagnostics(session), + rendererLifecycleResetCount: session.rendererLifecycleResetCount, + lastLifecycleResetClearedChars: session.lastLifecycleResetClearedChars, + rendererPtyDispatcherReady: session.rendererPtyDispatcherReady, + rendererDispatcherReadyForcedCount: session.rendererDispatcherReadyForcedCount + } +} + +export function seedPtyRendererDeliveryPeaksFromCurrentState(session: PtyIpcSession): void { + let pendingChars = 0 + let maxPendingCharsByPty = 0 + for (const pending of session.pendingData.values()) { + const chars = pending.data.length + pendingChars += chars + maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) + } + session.peakPendingChars = pendingChars + session.peakMaxPendingCharsByPty = maxPendingCharsByPty + session.peakRendererInFlightChars = session.rendererInFlightTotalChars + let maxRendererInFlightCharsByPty = 0 + for (const accounting of session.rendererDeliveryAccountingByPty.values()) { + maxRendererInFlightCharsByPty = Math.max( + maxRendererInFlightCharsByPty, + accounting.sentChars - accounting.ackedChars + ) + } + session.peakMaxRendererInFlightCharsByPty = maxRendererInFlightCharsByPty +} + +export function warnIfDroppingHiddenBytesForVisiblePty( + session: PtyIpcSession, + id: string, + droppedChars: number +): void { + if (!visibleRendererPtys.has(id) && !activeRendererPtys.has(id)) { + return + } + // Recorded before the warn rate limit: the ring coalesces repeats, and the contradiction must appear in the freeze report either way. + mainDeliveryBreadcrumbs.record('hidden-drop-visible', { + id: redactPtyIdForDiagnostics(id), + droppedChars + }) + const now = Date.now() + if (now - session.lastHiddenDropContradictionWarnAtMs < 60_000) { + return + } + session.lastHiddenDropContradictionWarnAtMs = now + console.warn('[pty] hidden-delivery gate is dropping bytes for a visible/active pty', { + id: redactPtyIdForDiagnostics(id), + droppedChars, + visible: visibleRendererPtys.has(id), + active: activeRendererPtys.has(id), + ...readCurrentPtyRendererDeliveryDebugSnapshot(session) + }) +} diff --git a/src/main/ipc/pty/delivery/debug.ts b/src/main/ipc/pty/delivery/debug.ts new file mode 100644 index 00000000000..99dae69488d --- /dev/null +++ b/src/main/ipc/pty/delivery/debug.ts @@ -0,0 +1,124 @@ +import { getPtyPower } from '../../pty-host-bindings' +import { + type PtyMainDeliveryDiagnostics, + EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, + createPtyDeliveryBreadcrumbRing +} from '../../../../shared/pty-delivery-diagnostics' + +export type PtyRendererDeliveryDebugSnapshot = { + pendingPtyCount: number + pendingChars: number + maxPendingCharsByPty: number + rendererInFlightPtyCount: number + rendererInFlightChars: number + maxRendererInFlightCharsByPty: number + activeRendererPtyCount: number + flushScheduled: boolean + peakPendingChars: number + peakMaxPendingCharsByPty: number + peakRendererInFlightChars: number + peakMaxRendererInFlightCharsByPty: number + ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + /** Hidden-gated ptys the renderer ALSO reports visible/active — a contradiction that should be zero (v1.4.124-rc.2.perf field lead). */ + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryGatedActivePtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number + /** One-paste freeze diagnostics: per-pty delivery table + event history. */ + diagnostics: PtyMainDeliveryDiagnostics + // Why: a nonzero lastLifecycleResetClearedChars is the exact signature of the leaked-accounting freeze this reset fixes. + rendererLifecycleResetCount: number + lastLifecycleResetClearedChars: number + // Why: the boot-window hold early-returns before ackGatedFlushSkipCount++, so these expose an otherwise-invisible held gate; forcedCount > 0 flags a watchdog self-heal. + rendererPtyDispatcherReady: boolean + rendererDispatcherReadyForcedCount: number +} + +// Why module scope: breadcrumb writers live both inside registerPtyHandlers and outside it (renderer lifecycle resets). +export const mainDeliveryBreadcrumbs = createPtyDeliveryBreadcrumbRing() +export let lastPowerSuspendAtMs: number | null = null +export let lastPowerResumeAtMs: number | null = null +let powerSignalBreadcrumbsInstalled = false + +// Why: both field freeze variants correlate with display sleep; suspend/resume timestamps let breadcrumbs line up against the wake. +export function installPowerSignalBreadcrumbs(): void { + if (powerSignalBreadcrumbsInstalled) { + return + } + powerSignalBreadcrumbsInstalled = true + const powerMonitor = getPtyPower() + powerMonitor.on('suspend', () => { + lastPowerSuspendAtMs = Date.now() + mainDeliveryBreadcrumbs.record('power-suspend') + }) + powerMonitor.on('resume', () => { + lastPowerResumeAtMs = Date.now() + mainDeliveryBreadcrumbs.record('power-resume') + }) +} + +export const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapshot = { + pendingPtyCount: 0, + pendingChars: 0, + maxPendingCharsByPty: 0, + rendererInFlightPtyCount: 0, + rendererInFlightChars: 0, + maxRendererInFlightCharsByPty: 0, + activeRendererPtyCount: 0, + flushScheduled: false, + peakPendingChars: 0, + peakMaxPendingCharsByPty: 0, + peakRendererInFlightChars: 0, + peakMaxRendererInFlightCharsByPty: 0, + ackGatedFlushSkipCount: 0, + hiddenDeliveryGatedPtyCount: 0, + hiddenDeliveryGatedVisiblePtyCount: 0, + hiddenDeliveryGatedActivePtyCount: 0, + deliveryInterestPtyCount: 0, + hiddenDeliveryDroppedChars: 0, + hiddenDeliveryDroppedChunks: 0, + pendingDroppedChars: 0, + diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, + rendererLifecycleResetCount: 0, + lastLifecycleResetClearedChars: 0, + rendererPtyDispatcherReady: false, + rendererDispatcherReadyForcedCount: 0 +} + +export let readPtyRendererDeliveryDebugSnapshot = (): PtyRendererDeliveryDebugSnapshot => ({ + ...EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT +}) +export let resetPtyRendererDeliveryDebugSnapshot = (): void => {} +// Bridged into the registerPtyHandlers closure so the module-scope lifecycle-reset handler can zero closure-owned delivery accounting on renderer reload/crash. +export let resetRendererDeliveryAccountingForLifecycleReset = (): void => {} +// Bridged so a re-registration can cancel the prior closure's dispatcher-ready watchdog before wiring its own. +export let clearRendererDispatcherReadyWatchdog = (): void => {} + +export function setReadPtyRendererDeliveryDebugSnapshot( + fn: () => PtyRendererDeliveryDebugSnapshot +): void { + readPtyRendererDeliveryDebugSnapshot = fn +} + +export function setResetPtyRendererDeliveryDebugSnapshot(fn: () => void): void { + resetPtyRendererDeliveryDebugSnapshot = fn +} + +export function setResetRendererDeliveryAccountingForLifecycleReset(fn: () => void): void { + resetRendererDeliveryAccountingForLifecycleReset = fn +} + +export function setClearRendererDispatcherReadyWatchdog(fn: () => void): void { + clearRendererDispatcherReadyWatchdog = fn +} + +export function getPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { + return readPtyRendererDeliveryDebugSnapshot() +} + +export function resetPtyRendererDeliveryDebug(): void { + resetPtyRendererDeliveryDebugSnapshot() +} diff --git a/src/main/ipc/pty/delivery/exit.ts b/src/main/ipc/pty/delivery/exit.ts new file mode 100644 index 00000000000..3825be51192 --- /dev/null +++ b/src/main/ipc/pty/delivery/exit.ts @@ -0,0 +1,177 @@ +import { + interactiveOutputCharsByPty, + lastInputAtByPty, + SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS +} from './visibility-state' +import { allocatePtyLifecycleSequence } from '../host-env/types' +import { makePtyDataPayload, sendPtyDataToRenderer } from './payload' +import { getRendererInFlightCharsForPty } from './accounting' +import { clearFlushTimerIfIdle } from './flush' +import type { PtyIpcSession } from '../session' + +export function rememberSyntheticKillExit(session: PtyIpcSession, id: string): void { + const existing = session.syntheticKillExitPtyIds.get(id) + if (existing) { + clearTimeout(existing) + } + // Why a timed window: providers may report the real exit after kill completes; skip only that late duplicate, not a future reused id forever. + const cleanupTimer = setTimeout(() => { + session.syntheticKillExitPtyIds.delete(id) + }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) + cleanupTimer.unref?.() + session.syntheticKillExitPtyIds.set(id, cleanupTimer) +} + +export function rememberRetiredRejectedPty(session: PtyIpcSession, id: string): void { + const existing = session.retiredRejectedPtyIds.get(id) + if (existing) { + clearTimeout(existing) + } + const cleanupTimer = setTimeout(() => { + session.retiredRejectedPtyIds.delete(id) + }, SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS) + cleanupTimer.unref?.() + session.retiredRejectedPtyIds.set(id, cleanupTimer) +} + +export function consumeSyntheticKillExit(session: PtyIpcSession, id: string): boolean { + const cleanupTimer = session.syntheticKillExitPtyIds.get(id) + if (!cleanupTimer) { + return false + } + clearTimeout(cleanupTimer) + session.syntheticKillExitPtyIds.delete(id) + return true +} + +export function preparePtyExitForRenderer( + session: PtyIpcSession, + payload: { id: string; code: number; incarnationId?: string } +): (() => void) | null { + if (session.mainWindow.isDestroyed()) { + session.sshOutputIntake?.transferPtyProjections(payload.id, 'renderer-destroyed') + return () => {} + } + if (session.rendererExitingPtyIds.has(payload.id)) { + return null + } + session.rendererExitingPtyIds.add(payload.id) + let released = false + const release = (): void => { + if (released) { + return + } + released = true + session.rendererExitingPtyIds.delete(payload.id) + } + try { + if (!session.rendererCreditBeforeExitByPty.has(payload.id)) { + session.rendererCreditBeforeExitByPty.set( + payload.id, + getRendererInFlightCharsForPty(session, payload.id) > 0 + ) + } + // Why flush before exit: the renderer tears down the terminal on pty:exit, so any batched output not yet flushed would be silently lost. + const remaining = session.pendingData.delete(payload.id) + clearFlushTimerIfIdle(session) + if (remaining) { + if (remaining.droppedOutput === true) { + // Sentinel entry: only salvaged query bytes remain; keep the flag so the renderer knows the span was dropped. + sendPtyDataToRenderer( + session, + payload.id, + { + id: payload.id, + data: remaining.data, + droppedOutput: true + }, + remaining.projectionAdmissionIds + ) + } else { + sendPtyDataToRenderer( + session, + payload.id, + makePtyDataPayload( + payload.id, + remaining.data, + remaining.startSeq, + remaining.containsBackgroundOutput, + remaining.rawLength, + remaining.transformed + ), + remaining.projectionAdmissionIds + ) + } + } + return release + } catch (error) { + release() + throw error + } +} + +export function finalizePtyExitForRenderer( + session: PtyIpcSession, + payload: { id: string; code: number; incarnationId?: string } +): void { + if (session.mainWindow.isDestroyed()) { + session.rendererCreditBeforeExitByPty.delete(payload.id) + return + } + const hadReleasableRendererCredit = + session.rendererCreditBeforeExitByPty.get(payload.id) ?? + getRendererInFlightCharsForPty(session, payload.id) > 0 + session.rendererCreditBeforeExitByPty.delete(payload.id) + // Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id. + session.producerFlowControl.release(payload.id) + session.sourceCreditPendingPtys.delete(payload.id) + session.pendingOverflowMarkedPtys.delete(payload.id) + session.rendererDeliveryRestoreNeededPtys.delete(payload.id) + lastInputAtByPty.delete(payload.id) + interactiveOutputCharsByPty.delete(payload.id) + const releasedRendererCredit = getRendererInFlightCharsForPty(session, payload.id) + session.rendererInFlightTotalChars = Math.max( + 0, + session.rendererInFlightTotalChars - releasedRendererCredit + ) + // Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides. + session.rendererDeliveryAccountingByPty.delete(payload.id) + if (hadReleasableRendererCredit) { + if (session.pendingDataFlushActive) { + // Why: let the open round coalesce this wake into its one post-round continuation. + const reactivatedBlocked = session.pendingData.reactivateBlocked() + session.pendingDataCreditReleasedDuringFlush ||= reactivatedBlocked + } else { + session.schedulePendingDataAfterCreditReport(true) + } + } + session.mainWindow.webContents.send('pty:exit', { + ...payload, + ...(session.reversibleStopOwnersByPtyId.has(payload.id) + ? { preserveRendererBinding: true } + : {}) + }) +} + +export function sendPtyExitToRenderer( + session: PtyIpcSession, + payload: { id: string; code: number; incarnationId?: string } +): void { + session.options?.onPtyExit?.(payload.id, allocatePtyLifecycleSequence()) + const release = preparePtyExitForRenderer(session, payload) + if (!release) { + return + } + try { + session.sshOutputIntake?.transferPtyProjections(payload.id, 'legacy-pty-exit') + finalizePtyExitForRenderer(session, payload) + } finally { + release() + } +} + +export function sendPtySpawnedToRenderer(session: PtyIpcSession, id: string): void { + if (!session.mainWindow.isDestroyed()) { + session.mainWindow.webContents.send('pty:spawned', { id }) + } +} diff --git a/src/main/ipc/pty/delivery/flush.ts b/src/main/ipc/pty/delivery/flush.ts new file mode 100644 index 00000000000..038d955a932 --- /dev/null +++ b/src/main/ipc/pty/delivery/flush.ts @@ -0,0 +1,235 @@ +import { + recordHiddenRendererPtyDataDrop, + shouldDropHiddenRendererPtyData +} from '../../pty-hidden-delivery-gate' +import { propagatePendingProjectionRemainder } from '../../pty-pending-projection-admissions' +import type { PendingPtyData } from '../../pty-pending-data-drain-queue' +import { activeRendererPtys } from './visibility-state' +import { + PTY_BATCH_DRAIN_CONTINUE_MS, + PTY_BATCH_FLUSH_CHUNK_CHARS, + PTY_BATCH_FLUSH_MAX_WRITES, + PTY_DISPATCHER_READY_WATCHDOG_MS +} from './constants' +import { + getDroppedMode2031RendererData, + pendingProjectionAdmissionOptions, + updatePendingProjectionAdmissions +} from './pending' +import { makePtyDataPayload, sendModelRestoreNeededMarker, sendPtyDataToRenderer } from './payload' +import { warnIfDroppingHiddenBytesForVisiblePty } from './debug-snapshot' +import type { PtyIpcSession } from '../session' + +export function schedulePendingDataFlush(session: PtyIpcSession, delayMs: number): void { + if (session.flushTimer) { + return + } + session.flushTimer = setTimeout(() => session.flushPendingData(), delayMs) +} + +export function invalidatePendingPtyDrainClassification( + session: PtyIpcSession, + id?: string, + schedule = true +): void { + const invalidated = + typeof id === 'string' + ? session.pendingData.invalidate(id) + : session.pendingData.invalidateAll() + if (invalidated && schedule && !session.flushTimer) { + schedulePendingDataFlush(session, 0) + } +} + +export function clearDispatcherReadyWatchdog(session: PtyIpcSession): void { + if (session.dispatcherReadyWatchdogTimer) { + clearTimeout(session.dispatcherReadyWatchdogTimer) + session.dispatcherReadyWatchdogTimer = null + } +} + +export function armDispatcherReadyWatchdog(session: PtyIpcSession): void { + clearDispatcherReadyWatchdog(session) + if (session.mainWindow.isDestroyed()) { + return + } + // Why: one-shot self-heal — force the gate open if the reloaded page never signals ready, so a dropped handshake can't hold it forever. Unref'd so it can't keep the process alive. + session.dispatcherReadyWatchdogTimer = setTimeout(() => { + session.dispatcherReadyWatchdogTimer = null + if (session.rendererPtyDispatcherReady || session.mainWindow.isDestroyed()) { + return + } + session.rendererPtyDispatcherReady = true + session.rendererDispatcherReadyForcedCount += 1 + session.pendingData.reactivateBlocked() + schedulePendingDataFlush(session, 0) + }, PTY_DISPATCHER_READY_WATCHDOG_MS) + session.dispatcherReadyWatchdogTimer.unref?.() +} + +export function clearFlushTimerIfIdle(session: PtyIpcSession): void { + if (session.pendingData.size > 0 || session.flushTimer === null) { + return + } + clearTimeout(session.flushTimer) + session.flushTimer = null +} + +export function flushPendingData(session: PtyIpcSession): void { + session.flushTimer = null + if (session.mainWindow.isDestroyed()) { + // Why release now: bookkeeping is being wiped, so no future drain can resume these producers — local shells would wedge. + session.producerFlowControl.releaseAll() + session.clearDeliveryResyncProbe() + session.clearPendingPtyData() + session.pendingOverflowMarkedPtys.clear() + session.rendererDeliveryAccountingByPty.clear() + session.rendererInFlightTotalChars = 0 + session.clearDispatcherReadyWatchdog() + return + } + // Ordinary boot-window data is blocked in the queue; hidden-droppable entries still retire before renderer readiness. + const settings = session.getSettings?.() + let writes = 0 + let sendFailed = false + const round = session.pendingData.beginRound() + let creditReleasedDuringFlush = false + session.pendingDataFlushActive = true + session.pendingDataCreditReleasedDuringFlush = false + try { + while (writes < PTY_BATCH_FLUSH_MAX_WRITES) { + const selection = session.pendingData.takeNext(round) + if (!selection) { + break + } + const { id, pending } = selection + // Why drop, never re-queue: the model already ingested hidden-gated bytes; reveal restores from the snapshot+seq machinery. + if (shouldDropHiddenRendererPtyData(id, settings)) { + session.pendingData.remove(selection) + session.pendingOverflowMarkedPtys.delete(id) + session.updateProducerFlowControl(id) + const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length) + if (pending.projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections( + pending.projectionAdmissionIds, + 'hidden-drop' + ) + } + warnIfDroppingHiddenBytesForVisiblePty(session, id, pending.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker( + session, + id, + 'hidden-drop', + session.runtime?.getPtyOutputSequence(id) + ) + } + continue + } + if (!session.canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) { + session.pendingData.block(selection) + continue + } + if (pending.droppedOutput === true) { + session.pendingData.remove(selection) + session.updateProducerFlowControl(id) + // Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only). + if ( + !sendPtyDataToRenderer( + session, + id, + { + id, + data: pending.data + getDroppedMode2031RendererData(pending), + droppedOutput: true + }, + pending.projectionAdmissionIds + ).sent + ) { + sendFailed = true + break + } + writes++ + continue + } + const { data } = pending + const indivisible = pending.transformed === true + const chunk = indivisible ? data : data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS) + const remaining = indivisible ? '' : data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS) + let nextPending: PendingPtyData | undefined + if (remaining) { + nextPending = { data: remaining } + if (typeof pending.startSeq === 'number') { + nextPending.startSeq = pending.startSeq + chunk.length + } + if (pending.containsBackgroundOutput === true) { + nextPending.containsBackgroundOutput = true + } + if (pending.projectionAdmissionIds) { + nextPending.projectionAdmissionIds = pending.projectionAdmissionIds + } + if (pending.projectionAdmissionsTransferred) { + nextPending.projectionAdmissionsTransferred = true + } + session.pendingData.replaceWithRemainder(selection, nextPending) + } else { + session.pendingData.remove(selection) + session.pendingOverflowMarkedPtys.delete(id) + } + session.updateProducerFlowControl(id) + const delivery = sendPtyDataToRenderer( + session, + id, + makePtyDataPayload( + id, + chunk, + pending.startSeq, + pending.containsBackgroundOutput, + pending.rawLength, + pending.transformed + ), + pending.projectionAdmissionIds + ) + if (nextPending) { + updatePendingProjectionAdmissions( + nextPending, + propagatePendingProjectionRemainder( + nextPending, + delivery, + pendingProjectionAdmissionOptions(session) + ) + ) + } + if (!delivery.sent) { + sendFailed = true + break + } + writes++ + } + } finally { + session.pendingDataFlushActive = false + creditReleasedDuringFlush = session.pendingDataCreditReleasedDuringFlush + session.pendingDataCreditReleasedDuringFlush = false + session.pendingData.endRound(round) + } + if ( + session.rendererPtyDispatcherReady && + session.pendingData.size > 0 && + writes === 0 && + !sendFailed + ) { + session.ackGatedFlushSkipCount++ + } + if (sendFailed && session.pendingData.size > 0) { + if (session.flushTimer) { + clearTimeout(session.flushTimer) + session.flushTimer = null + } + schedulePendingDataFlush(session, PTY_BATCH_DRAIN_CONTINUE_MS) + return + } + if (session.pendingData.size > 0 && (writes > 0 || creditReleasedDuringFlush)) { + // Why yield between slices: a background terminal can dump megabytes at once, and keystroke writes must not stall behind one flush. + schedulePendingDataFlush(session, writes > 0 ? PTY_BATCH_DRAIN_CONTINUE_MS : 0) + } +} diff --git a/src/main/ipc/pty/delivery/hidden-transition.ts b/src/main/ipc/pty/delivery/hidden-transition.ts new file mode 100644 index 00000000000..a6ca1204875 --- /dev/null +++ b/src/main/ipc/pty/delivery/hidden-transition.ts @@ -0,0 +1,35 @@ +import { + markHiddenRendererPty, + shouldDropHiddenRendererPtyData, + unmarkHiddenRendererPty +} from '../../pty-hidden-delivery-gate' +import { invalidatePendingPtyDrainPolicy } from './visibility-state' +import type { PtyIpcSession } from '../session' + +export function transitionHiddenRendererPtyDeliveryState( + session: PtyIpcSession, + id: string, + hidden: boolean +): { droppable: boolean; droppedWhileHidden: boolean; policyChanged: boolean } { + const settings = session.getSettings?.() + const wasDroppable = shouldDropHiddenRendererPtyData(id, settings) + let droppedWhileHidden = false + if (hidden) { + markHiddenRendererPty(id) + } else { + droppedWhileHidden = unmarkHiddenRendererPty(id).droppedWhileHidden + } + const droppable = shouldDropHiddenRendererPtyData(id, settings) + return { droppable, droppedWhileHidden, policyChanged: wasDroppable !== droppable } +} + +export function transitionSpawnHiddenRendererPtyDeliveryState( + session: PtyIpcSession, + id: string, + hidden: boolean +): void { + const transition = transitionHiddenRendererPtyDeliveryState(session, id, hidden) + if (transition.policyChanged) { + invalidatePendingPtyDrainPolicy(id) + } +} diff --git a/src/main/ipc/pty/delivery/interactive.ts b/src/main/ipc/pty/delivery/interactive.ts new file mode 100644 index 00000000000..6a69af426f8 --- /dev/null +++ b/src/main/ipc/pty/delivery/interactive.ts @@ -0,0 +1,34 @@ +import { interactiveOutputCharsByPty, lastInputAtByPty } from './visibility-state' +import { + INTERACTIVE_OUTPUT_BUDGET_CHARS, + INTERACTIVE_OUTPUT_MAX_CHARS, + INTERACTIVE_OUTPUT_WINDOW_MS, + INTERACTIVE_REDRAW_MAX_CHARS +} from './constants' + +export function isLikelyInteractiveRedraw(data: string): boolean { + if (data.length <= INTERACTIVE_OUTPUT_MAX_CHARS) { + return true + } + // Why the ANSI check: Codex-style TUIs repaint >1 KB per keypress (latency-sensitive), while plain command output should stay on the throughput batch path. + return data.length <= INTERACTIVE_REDRAW_MAX_CHARS && data.includes('\x1b[') +} + +export function shouldSendInteractiveOutputNow(id: string, data: string, now: number): boolean { + const lastInputAt = lastInputAtByPty.get(id) + if (lastInputAt === undefined || now - lastInputAt > INTERACTIVE_OUTPUT_WINDOW_MS) { + interactiveOutputCharsByPty.delete(id) + return false + } + if (!isLikelyInteractiveRedraw(data)) { + interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) + return false + } + const usedChars = interactiveOutputCharsByPty.get(id) ?? 0 + if (usedChars + data.length > INTERACTIVE_OUTPUT_BUDGET_CHARS) { + interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) + return false + } + interactiveOutputCharsByPty.set(id, usedChars + data.length) + return true +} diff --git a/src/main/ipc/pty/delivery/lifecycle-reset.ts b/src/main/ipc/pty/delivery/lifecycle-reset.ts new file mode 100644 index 00000000000..961c09188a1 --- /dev/null +++ b/src/main/ipc/pty/delivery/lifecycle-reset.ts @@ -0,0 +1,96 @@ +import type { WebContents } from 'electron' +import { + didFinishLoadHandler, + didFinishLoadWebContents, + rendererDidStartNavigationHandler, + rendererGateResetGoneHandler, + rendererGateResetLoadHandler, + rendererGateResetWebContents, + rendererLifecycleResetHandler, + rendererLifecycleResetWebContents, + setDidFinishLoadHandler, + setRendererGateResetState, + setRendererLifecycleResetState +} from '../provider/listener-lifecycle' +import { mainDeliveryBreadcrumbs, resetRendererDeliveryAccountingForLifecycleReset } from './debug' +import { + activeRendererPtys, + invalidatePendingPtyDrainPriority, + visibleRendererPtys +} from './visibility-state' + +export function clearDidFinishLoadHandler(): void { + if (didFinishLoadHandler && didFinishLoadWebContents) { + didFinishLoadWebContents.removeListener('did-finish-load', didFinishLoadHandler) + } + setDidFinishLoadHandler(null, null) +} + +export function markRendererPtysHiddenForRendererLifecycleReset(): void { + // A reload/crash in the breadcrumb history is load-bearing context for any freeze report. + mainDeliveryBreadcrumbs.record('renderer-lifecycle-reset') + // Why: renderer-owned hints die with the page; clear visibility so surviving daemon/SSH PTYs fail closed until the new renderer reports. + const activePriorityChanged = activeRendererPtys.size > 0 + activeRendererPtys.clear() + visibleRendererPtys.clear() + // Why: the dead page never ACKs its in-flight bytes, so leaked accounting would delivery-gate surviving PTYs forever after a reload/crash. + resetRendererDeliveryAccountingForLifecycleReset() + if (activePriorityChanged) { + invalidatePendingPtyDrainPriority() + } +} + +export function clearRendererLifecycleResetHandlers(): void { + if (!rendererLifecycleResetWebContents) { + return + } + if (rendererDidStartNavigationHandler) { + rendererLifecycleResetWebContents.removeListener( + 'did-start-navigation', + rendererDidStartNavigationHandler + ) + } + if (rendererLifecycleResetHandler) { + rendererLifecycleResetWebContents.removeListener( + 'render-process-gone', + rendererLifecycleResetHandler + ) + rendererLifecycleResetWebContents.removeListener('destroyed', rendererLifecycleResetHandler) + } + setRendererLifecycleResetState({ contents: null, handler: null, navigation: null }) +} + +export function registerRendererLifecycleResetHandlers(webContents: WebContents): void { + clearRendererLifecycleResetHandlers() + markRendererPtysHiddenForRendererLifecycleReset() + const handler = markRendererPtysHiddenForRendererLifecycleReset + const navigationHandler = (details: { isMainFrame: boolean; isSameDocument: boolean }) => { + if (!details.isMainFrame || details.isSameDocument) { + return + } + markRendererPtysHiddenForRendererLifecycleReset() + } + setRendererLifecycleResetState({ + contents: webContents, + handler, + navigation: navigationHandler + }) + webContents.on('did-start-navigation', navigationHandler) + webContents.on('render-process-gone', handler) + webContents.on('destroyed', handler) +} + +export function clearRendererGateResetHandlers(): void { + if (rendererGateResetWebContents) { + if (rendererGateResetLoadHandler) { + rendererGateResetWebContents.removeListener('did-finish-load', rendererGateResetLoadHandler) + } + if (rendererGateResetGoneHandler) { + rendererGateResetWebContents.removeListener( + 'render-process-gone', + rendererGateResetGoneHandler + ) + } + } + setRendererGateResetState({ contents: null, load: null, gone: null }) +} diff --git a/src/main/ipc/pty/delivery/payload.ts b/src/main/ipc/pty/delivery/payload.ts new file mode 100644 index 00000000000..119bb173ed7 --- /dev/null +++ b/src/main/ipc/pty/delivery/payload.ts @@ -0,0 +1,137 @@ +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' +import type { PtyModelRestoreReason } from '../../../../shared/pty-model-restore-marker' +import { mainDeliveryBreadcrumbs } from './debug' +import { recordPtyRendererDeliveryPressure } from './accounting' +import type { PtyDataPayload, PtyIpcSession } from '../session' + +export function makePtyDataPayload( + id: string, + data: string, + startSeq: number | undefined, + containsBackgroundOutput: boolean | undefined, + rawLength = data.length, + transformed = false +): PtyDataPayload { + const payload: PtyDataPayload = { id, data } + if (typeof startSeq === 'number') { + payload.seq = startSeq + rawLength + } + if (typeof startSeq === 'number' || rawLength !== data.length || transformed) { + payload.rawLength = rawLength + } + if (transformed) { + payload.transformed = true + } + if (containsBackgroundOutput === true) { + payload.background = true + } + return payload +} + +export function getPtyPayloadCharCount(payload: { data: string; rawLength?: number }): number { + return Math.max(0, payload.rawLength ?? payload.data.length) +} + +export function sendModelRestoreNeededMarker( + session: PtyIpcSession, + id: string, + reason: PtyModelRestoreReason, + markerSeq: number | undefined +): boolean { + if (session.mainWindow.isDestroyed()) { + return false + } + try { + session.mainWindow.webContents.send('pty:modelRestoreNeeded', { + id, + reason, + ...(typeof markerSeq === 'number' ? { markerSeq } : {}) + }) + } catch (error) { + // Why: a disposed render frame throws synchronously here, and this rides the data path. + console.error('[pty] renderer model-restore marker send failed', error) + return false + } + return true +} + +export function sendPtyDataToRenderer( + session: PtyIpcSession, + id: string, + payload: PtyDataPayload, + projectionAdmissionIds?: readonly string[] +): { sent: boolean; projectionsTransferred: boolean } { + const charCount = getPtyPayloadCharCount(payload) + const accounting = session.rendererDeliveryAccountingByPty.get(id) + const hadAccounting = accounting !== undefined + if (accounting) { + accounting.sentChars += charCount + accounting.lastSendAtMs = Date.now() + } else { + session.rendererDeliveryAccountingByPty.set(id, { + sentChars: charCount, + ackedChars: 0, + lastSendAtMs: Date.now(), + lastAckAtMs: null + }) + } + session.rendererInFlightTotalChars += charCount + recordPtyRendererDeliveryPressure(session, id) + try { + session.mainWindow.webContents.send('pty:data', payload) + } catch (error) { + const current = session.rendererDeliveryAccountingByPty.get(id) + if (current) { + const inFlightBeforeRollback = current.sentChars - current.ackedChars + current.sentChars = Math.max(0, current.sentChars - charCount) + current.ackedChars = Math.min(current.ackedChars, current.sentChars) + const inFlightAfterRollback = current.sentChars - current.ackedChars + session.rendererInFlightTotalChars = Math.max( + 0, + session.rendererInFlightTotalChars - (inFlightBeforeRollback - inFlightAfterRollback) + ) + if (!hadAccounting && current.sentChars === 0) { + session.rendererDeliveryAccountingByPty.delete(id) + } + } + session.rendererDeliveryRestoreNeededPtys.add(id) + if (projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections(projectionAdmissionIds, 'renderer-send-failed') + } + mainDeliveryBreadcrumbs.record('pty-data-send-failed', { + id: redactPtyIdForDiagnostics(id), + chars: charCount + }) + console.error('[pty] renderer data send failed; payload will not be retried', error) + return { sent: false, projectionsTransferred: projectionAdmissionIds !== undefined } + } + let projectionsTransferred = false + if (projectionAdmissionIds) { + try { + session.sshOutputIntake?.publishProjectionPrefix( + projectionAdmissionIds, + payload.data.length, + charCount + ) + } catch { + session.sshOutputIntake?.transferProjections( + projectionAdmissionIds, + 'projection-publish-failed' + ) + projectionsTransferred = true + } + } + if ( + session.rendererDeliveryRestoreNeededPtys.has(id) && + sendModelRestoreNeededMarker( + session, + id, + 'delivery-heal', + session.runtime?.getPtyOutputSequence(id) + ) + ) { + // Why cleared only on a successful send: an unsent marker leaves the restore pending. + session.rendererDeliveryRestoreNeededPtys.delete(id) + } + return { sent: true, projectionsTransferred } +} diff --git a/src/main/ipc/pty/delivery/pending.ts b/src/main/ipc/pty/delivery/pending.ts new file mode 100644 index 00000000000..ff14ccc8c52 --- /dev/null +++ b/src/main/ipc/pty/delivery/pending.ts @@ -0,0 +1,193 @@ +import { extractHiddenStartupRendererQueryData } from '../../../../shared/terminal-reply-query-extraction' +import { + INITIAL_MODE_2031_REPLY_SCAN_STATE, + scanMode2031ReplyDecision, + type Mode2031ReplyScanState +} from '../../../../shared/terminal-color-scheme-protocol' +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' +import { recordCrashBreadcrumb } from '../../../crash-reporting/crash-breadcrumb-store' +import { terminalOutputBacklogCapChars } from '../../../../shared/terminal-scrollback-policy' +import { isHiddenPtyDeliveryGateEnabled } from '../../pty-hidden-delivery-gate' +import { + appendPendingProjectionAdmission, + compactPendingProjectionAdmissions, + type PendingProjectionAdmissions +} from '../../pty-pending-projection-admissions' +import type { PendingPtyData } from '../../pty-pending-data-drain-queue' +import { DROPPED_QUERY_SALVAGE_MAX_CHARS } from './constants' +import type { PtyIpcSession } from '../session' + +export function pendingDataCapChars(session: PtyIpcSession): number { + return terminalOutputBacklogCapChars(session.getSettings?.().terminalScrollbackRows) +} + +export function extractDroppedPtyQueryBytes(data: string): string { + if (!data.includes('\x1b')) { + return '' + } + const extracted = extractHiddenStartupRendererQueryData(data, '') + return extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData +} + +export function scanDroppedMode2031Data( + data: string, + previous: Mode2031ReplyScanState +): { data: string; state: Mode2031ReplyScanState } { + const result = scanMode2031ReplyDecision(previous, data) + const decisionData = + result.decision === 'subscribed' + ? '\x1b[?2031h' + : result.decision === 'unsubscribed' + ? '\x1b[?2031l' + : '' + return { data: decisionData, state: result.state } +} + +export function getDroppedMode2031RendererData(pending: PendingPtyData): string { + const state = pending.droppedMode2031ScanState + if (!state) { + return pending.droppedMode2031Data ?? '' + } + const pendingSubscribe = state.pendingSubscribe ? '\x1b[?2031h' : '' + return (pending.droppedMode2031Data ?? '') + pendingSubscribe + state.tail +} + +export function pendingProjectionAdmissionOptions(session: PtyIpcSession) { + return { + isPending: (id: string) => session.sshOutputIntake?.hasUnpublishedProjection(id) ?? false, + transfer: (ids: readonly string[], reason: string) => + session.sshOutputIntake?.transferProjections(ids, reason) + } +} + +export function updatePendingProjectionAdmissions( + pending: PendingPtyData, + state: PendingProjectionAdmissions +): void { + delete pending.projectionAdmissionIds + delete pending.projectionAdmissionsTransferred + if (state.projectionAdmissionIds) { + pending.projectionAdmissionIds = state.projectionAdmissionIds + } + if (state.projectionAdmissionsTransferred) { + pending.projectionAdmissionsTransferred = true + } +} + +export function compactPendingProjectionState( + session: PtyIpcSession, + pending: PendingProjectionAdmissions, + projectionSemanticsId?: string +): PendingProjectionAdmissions { + const options = pendingProjectionAdmissionOptions(session) + const compacted = compactPendingProjectionAdmissions(pending, options) + return projectionSemanticsId + ? appendPendingProjectionAdmission(compacted, projectionSemanticsId, options) + : compacted +} + +export function dropOversizedPendingPtyData( + session: PtyIpcSession, + id: string, + pending: PendingPtyData +): PendingPtyData { + const capChars = pendingDataCapChars(session) + if (pending.droppedOutput === true || pending.data.length <= capChars) { + return pending + } + if (!session.pendingDataDropWarnedPtys.has(id)) { + session.pendingDataDropWarnedPtys.add(id) + console.error( + `[pty] dropped ${pending.data.length} buffered chars for ${redactPtyIdForDiagnostics(id)}: renderer not receiving and per-PTY pending cap exceeded; pane will restore from the main-owned snapshot` + ) + // Why: field visibility for cap tuning (issue #2836 / #7017); no pty id since session ids can embed workspace paths. + recordCrashBreadcrumb('terminal_pending_output_dropped', { + droppedChars: pending.data.length, + capChars + }) + } + if ( + isHiddenPtyDeliveryGateEnabled(session.getSettings?.()) && + !session.pendingOverflowMarkedPtys.has(id) + ) { + session.pendingOverflowMarkedPtys.add(id) + } + session.pendingDroppedChars += pending.data.length + if (pending.projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'pending-cap') + } + const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE) + // Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along). + return { + data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS), + droppedOutput: true, + droppedMode2031Data: mode2031.data, + droppedMode2031ScanState: mode2031.state + } +} + +export function appendPendingPtyData( + session: PtyIpcSession, + id: string, + existing: PendingPtyData | undefined, + data: string, + startSeq: number | undefined, + preservesSeq: boolean, + containsBackgroundOutput: boolean, + rawLength = data.length, + transformed = false, + projectionSemanticsId?: string +): PendingPtyData { + // Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode. + if (existing?.droppedOutput === true) { + if (projectionSemanticsId) { + session.sshOutputIntake?.transferProjections([projectionSemanticsId], 'pending-cap') + } + const mode2031 = scanDroppedMode2031Data( + data, + existing.droppedMode2031ScanState ?? INITIAL_MODE_2031_REPLY_SCAN_STATE + ) + const remainingQueryCapacity = Math.max( + 0, + DROPPED_QUERY_SALVAGE_MAX_CHARS - existing.data.length + ) + const salvaged = extractDroppedPtyQueryBytes(data).slice(0, remainingQueryCapacity) + return { + ...existing, + data: existing.data + salvaged, + droppedMode2031Data: mode2031.data || existing.droppedMode2031Data, + droppedMode2031ScanState: mode2031.state + } + } + const projectionState = compactPendingProjectionState( + session, + existing ?? {}, + projectionSemanticsId + ) + const nextContainsBackgroundOutput = + existing?.containsBackgroundOutput === true || containsBackgroundOutput + if (!existing) { + const pending: PendingPtyData = { + data, + ...(typeof startSeq === 'number' ? { startSeq } : {}), + ...(rawLength !== data.length ? { rawLength } : {}), + ...(transformed ? { transformed: true } : {}), + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + } + updatePendingProjectionAdmissions(pending, projectionState) + return dropOversizedPendingPtyData(session, id, pending) + } + const existingRawLength = existing.rawLength ?? existing.data.length + const next: PendingPtyData = { + data: existing.data + data, + ...(!preservesSeq || existing.transformed || transformed + ? { rawLength: existingRawLength + rawLength, transformed: true as const } + : {}), + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + } + updatePendingProjectionAdmissions(next, projectionState) + if (typeof existing.startSeq === 'number') { + next.startSeq = existing.startSeq + } + return dropOversizedPendingPtyData(session, id, next) +} diff --git a/src/main/ipc/pty/delivery/producer-sync.ts b/src/main/ipc/pty/delivery/producer-sync.ts new file mode 100644 index 00000000000..b718e3cebe7 --- /dev/null +++ b/src/main/ipc/pty/delivery/producer-sync.ts @@ -0,0 +1,64 @@ +import { recordDaemonStreamBacklogEvent } from '../../../daemon/daemon-stream-backlog-probe' +import { tryGetProviderForPty } from '../provider/registry' +import { + PRODUCER_FLOW_CONTROL_ENABLED, + rendererVisibilityKnownPtys, + visibleRendererPtys +} from './visibility-state' +import type { PtyIpcSession } from '../session' + +export function updateProducerFlowControl(session: PtyIpcSession, id: string): void { + if (!PRODUCER_FLOW_CONTROL_ENABLED) { + return + } + if (session.sourceCreditPendingPtys.has(id)) { + if (session.pendingData.get(id)) { + return + } + session.sourceCreditPendingPtys.delete(id) + } + session.producerFlowControl.update(id, session.pendingData.get(id)?.data.length ?? 0) +} + +export function syncPtyBackgroundedDelivery( + session: PtyIpcSession, + id: string, + caller: string +): void { + const background = + session.rendererPtyIsKnownHidden(id) && + !(session.runtime?.hasRawTerminalViewSubscriber?.(id) ?? false) + if (session.backgroundedDeliverySyncByPty.get(id) === background) { + return + } + const provider = tryGetProviderForPty(id) + if (!provider?.setPtyBackgrounded) { + return + } + recordDaemonStreamBacklogEvent('mainBackgroundSync', { + sessionIdSuffix: id.slice(-10), + background, + caller, + known: rendererVisibilityKnownPtys.has(id), + visible: visibleRendererPtys.has(id) + }) + const previous = session.backgroundedDeliverySyncByPty.get(id) + session.backgroundedDeliverySyncByPty.set(id, background) + try { + provider.setPtyBackgrounded(id, background) + } catch (error) { + // Why: a recorded state the provider never took would short-circuit every later sync for this PTY. + if (previous === undefined) { + session.backgroundedDeliverySyncByPty.delete(id) + } else { + session.backgroundedDeliverySyncByPty.set(id, previous) + } + console.error('[pty] setPtyBackgrounded failed; delivery sync state rolled back', error) + } +} + +export function resyncBackgroundedDeliveriesAfterGateReset(session: PtyIpcSession): void { + for (const id of session.backgroundedDeliverySyncByPty.keys()) { + syncPtyBackgroundedDelivery(session, id, 'gate-reset') + } +} diff --git a/src/main/ipc/pty/delivery/ssh-intake.ts b/src/main/ipc/pty/delivery/ssh-intake.ts new file mode 100644 index 00000000000..2f86608fb82 --- /dev/null +++ b/src/main/ipc/pty/delivery/ssh-intake.ts @@ -0,0 +1,101 @@ +import type { IPtyProvider } from '../../../providers/types' +import { SshPtyOutputIntake } from '../../ssh-pty-output-intake' +import { + cancelSshPtySourceDelivery, + installSshPtyOutputIntake, + publishSshPtySourceAck +} from '../../ssh-pty-output-intake-registry' +import { sshProvidersByGeneration } from '../provider/registry' +import { setSshOutputIntakeCleanup, sshOutputIntakeCleanup } from '../provider/listener-lifecycle' +import type { PtyIpcSession } from '../session' + +export function installSessionSshOutputIntake(session: PtyIpcSession): void { + sshOutputIntakeCleanup?.() + session.sshOutputIntake = new SshPtyOutputIntake({ + getModelSequence: (id) => session.runtime?.getPtyOutputSequence(id) ?? 0, + acceptModel: (event, projection) => { + if (!session.runtime) { + throw new Error('SSH PTY output requires the main terminal model') + } + return session.runtime.acceptPtyDataBounded( + event.id, + event.data, + Date.now(), + event.rawLength, + event.transformed, + projection.desktopSpan ? [projection.desktopSpan] : undefined + ) + }, + project: (event, projection) => + session.acceptPtyDataForRenderer( + { + id: event.id, + data: event.data, + sequenceChars: event.rawLength, + transformed: event.transformed + }, + projection.identity.sequenceEnd, + projection + ), + prepareExit: (event) => { + const release = session.preparePtyExitForRenderer(event) + if (!release) { + throw new Error('pty_renderer_exit_in_progress') + } + return release + }, + finalizeExit: (event) => { + session.runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation, { + hostExitConfirmed: true + }) + session.finalizePtyExitForRenderer(event) + }, + pauseProvider: (generation, id) => { + const provider = sshProvidersByGeneration.get(generation) as + | (IPtyProvider & { hasPtyDeliveryPauseAdapter?: () => boolean }) + | undefined + if (!provider?.hasPtyDeliveryPauseAdapter?.()) { + return false + } + provider.pauseProducer?.(id) + return true + }, + resumeProvider: (generation, id) => + sshProvidersByGeneration.get(generation)?.resumeProducer?.(id), + closeProvider: (generation, reason) => { + const provider = sshProvidersByGeneration.get(generation) + ;( + provider as (IPtyProvider & { closeOutputIntake?: (reason: string) => void }) | undefined + )?.closeOutputIntake?.(reason) + }, + resetModelForMigration: (_generation, id) => + session.runtime?.resetPtyModelAfterMigrationFailure(id), + onGenerationClosed: (providerGeneration) => { + for (const id of session.pendingData.keys()) { + const pending = session.pendingData.get(id) + if ( + pending?.projectionAdmissionIds && + session.sshOutputIntake?.hasProjectionFromGeneration( + pending.projectionAdmissionIds, + providerGeneration + ) + ) { + session.pendingData.delete(id) + session.updateProducerFlowControl(id) + session.pendingOverflowMarkedPtys.delete(id) + } + } + sshProvidersByGeneration.delete(providerGeneration) + }, + publishSourceAck: publishSshPtySourceAck, + cancelSourceDelivery: cancelSshPtySourceDelivery + }) + session.runtime?.setRemoteTerminalSourceRangeConsumerHooks?.( + session.sshOutputIntake.getRemoteSourceRangeConsumerHooks() + ) + const cleanupSshOutputIntakeRegistry = installSshPtyOutputIntake(session.sshOutputIntake) + setSshOutputIntakeCleanup(() => { + session.runtime?.setRemoteTerminalSourceRangeConsumerHooks?.(null) + cleanupSshOutputIntakeRegistry() + }) +} diff --git a/src/main/ipc/pty/delivery/visibility-state.ts b/src/main/ipc/pty/delivery/visibility-state.ts new file mode 100644 index 00000000000..dbe9526ae2e --- /dev/null +++ b/src/main/ipc/pty/delivery/visibility-state.ts @@ -0,0 +1,31 @@ +export const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 +// Why: kill switch — flip to disable producer flow control (pause/resume) without untangling the wiring. +export const PRODUCER_FLOW_CONTROL_ENABLED = true +// Why: mobile clients must mirror desktop PTY geometry even before the renderer can provide an xterm snapshot (e.g. right after tab creation). +export const ptySizes = new Map() +// Why: the "recent user input" signal is PTY-scoped and must be cleared by every teardown path, incl. SSH/daemon shutdowns that skip the local exit listener. +export const lastInputAtByPty = new Map() +export const interactiveOutputCharsByPty = new Map() +export const activeRendererPtys = new Set() +export const visibleRendererPtys = new Set() +export const rendererVisibilityKnownPtys = new Set() +export let invalidatePendingPtyDrainPriority = (_id?: string, _schedule?: boolean): void => {} +export let invalidatePendingPtyDrainPolicy = (_id?: string, _schedule?: boolean): void => {} +export const pendingHiddenRendererResizeOutputPtys = new Set() +export const deliveredHiddenRendererResizeOutputPtys = new Set() +export const KEEP_HISTORY_STOP_SETTLE_MS = 1_000 +export const KEEP_HISTORY_STOP_POLL_MS = 100 +// Why: after daemon keep-tail thinning main's mirror holds only the kept tail, so recovery must keep consulting the daemon's complete model until exit. +export const providerSnapshotRequiredPtys = new Set() + +export function setInvalidatePendingPtyDrainPriority( + fn: (id?: string, schedule?: boolean) => void +): void { + invalidatePendingPtyDrainPriority = fn +} + +export function setInvalidatePendingPtyDrainPolicy( + fn: (id?: string, schedule?: boolean) => void +): void { + invalidatePendingPtyDrainPolicy = fn +} diff --git a/src/main/ipc/pty/delivery/wire-session.ts b/src/main/ipc/pty/delivery/wire-session.ts new file mode 100644 index 00000000000..5425acb9f10 --- /dev/null +++ b/src/main/ipc/pty/delivery/wire-session.ts @@ -0,0 +1,159 @@ +import { resetHiddenRendererPtyDeliveryDebugCounters } from '../../pty-hidden-delivery-gate' +import { + setReadPtyRendererDeliveryDebugSnapshot, + setResetPtyRendererDeliveryDebugSnapshot, + setResetRendererDeliveryAccountingForLifecycleReset, + setClearRendererDispatcherReadyWatchdog +} from './debug' +import { + setInvalidatePendingPtyDrainPolicy, + setInvalidatePendingPtyDrainPriority +} from './visibility-state' +import { setClearBackgroundedDeliverySyncForPty } from '../provider/listener-lifecycle' +import { + applyCumulativeAck, + canSendPtyDataToRenderer, + clearDeliveryResyncProbe, + clearPendingPtyData, + deletePendingPtyData, + getRendererInFlightCharsForPty, + schedulePendingDataAfterCreditReport, + setPendingPtyData, + writeOffLostRendererDelivery +} from './accounting' +import { + readCurrentPtyRendererDeliveryDebugSnapshot, + seedPtyRendererDeliveryPeaksFromCurrentState +} from './debug-snapshot' +import { + armDispatcherReadyWatchdog, + clearDispatcherReadyWatchdog, + flushPendingData, + invalidatePendingPtyDrainClassification, + schedulePendingDataFlush +} from './flush' +import { sendModelRestoreNeededMarker, sendPtyDataToRenderer } from './payload' +import { + resyncBackgroundedDeliveriesAfterGateReset, + syncPtyBackgroundedDelivery, + updateProducerFlowControl +} from './producer-sync' +import { + acceptPtyDataForRenderer, + clearDeliveredHiddenRendererResizeOutput, + clearHiddenRendererResizeOutput, + rendererPtyIsKnownHidden +} from './accept' +import { + consumeSyntheticKillExit, + finalizePtyExitForRenderer, + preparePtyExitForRenderer, + rememberRetiredRejectedPty, + rememberSyntheticKillExit, + sendPtyExitToRenderer, + sendPtySpawnedToRenderer +} from './exit' +import { + transitionHiddenRendererPtyDeliveryState, + transitionSpawnHiddenRendererPtyDeliveryState +} from './hidden-transition' +import { requestSerializedBuffer } from '../ipc/serialize-buffer' +import { shutdownProviderAndDetectExit } from '../provider/shutdown-detect' +import type { PtyIpcSession } from '../session' + +export function wirePtyIpcSession(session: PtyIpcSession): void { + session.canSendPtyDataToRenderer = (id, options) => canSendPtyDataToRenderer(session, id, options) + session.schedulePendingDataFlush = (delayMs) => schedulePendingDataFlush(session, delayMs) + session.flushPendingData = () => flushPendingData(session) + session.sendPtyDataToRenderer = (id, payload, projectionAdmissionIds) => + sendPtyDataToRenderer(session, id, payload, projectionAdmissionIds) + session.sendModelRestoreNeededMarker = (id, reason, markerSeq) => + sendModelRestoreNeededMarker(session, id, reason, markerSeq) + session.updateProducerFlowControl = (id) => updateProducerFlowControl(session, id) + session.applyCumulativeAck = (id, processedChars) => + applyCumulativeAck(session, id, processedChars) + session.readCurrentPtyRendererDeliveryDebugSnapshot = () => + readCurrentPtyRendererDeliveryDebugSnapshot(session) + session.clearDeliveryResyncProbe = () => clearDeliveryResyncProbe(session) + session.clearPendingPtyData = () => clearPendingPtyData(session) + session.deletePendingPtyData = (id) => deletePendingPtyData(session, id) + session.setPendingPtyData = (id, pending) => setPendingPtyData(session, id, pending) + session.clearDispatcherReadyWatchdog = () => clearDispatcherReadyWatchdog(session) + session.armDispatcherReadyWatchdog = () => armDispatcherReadyWatchdog(session) + session.acceptPtyDataForRenderer = (payload, outputSeq, projection) => + acceptPtyDataForRenderer(session, payload, outputSeq, projection) + session.preparePtyExitForRenderer = (payload) => preparePtyExitForRenderer(session, payload) + session.finalizePtyExitForRenderer = (payload) => finalizePtyExitForRenderer(session, payload) + session.sendPtyExitToRenderer = (payload) => sendPtyExitToRenderer(session, payload) + session.sendPtySpawnedToRenderer = (id) => sendPtySpawnedToRenderer(session, id) + session.requestSerializedBuffer = (ptyId, opts) => requestSerializedBuffer(session, ptyId, opts) + session.shutdownProviderAndDetectExit = (provider, id, opts) => + shutdownProviderAndDetectExit(provider, id, opts) + session.rememberSyntheticKillExit = (id) => rememberSyntheticKillExit(session, id) + session.rememberRetiredRejectedPty = (id) => rememberRetiredRejectedPty(session, id) + session.consumeSyntheticKillExit = (id) => consumeSyntheticKillExit(session, id) + session.syncPtyBackgroundedDelivery = (id, caller) => + syncPtyBackgroundedDelivery(session, id, caller) + session.resyncBackgroundedDeliveriesAfterGateReset = () => + resyncBackgroundedDeliveriesAfterGateReset(session) + session.transitionHiddenRendererPtyDeliveryState = (id, hidden) => + transitionHiddenRendererPtyDeliveryState(session, id, hidden) + session.transitionSpawnHiddenRendererPtyDeliveryState = (id, hidden) => + transitionSpawnHiddenRendererPtyDeliveryState(session, id, hidden) + session.rendererPtyIsKnownHidden = rendererPtyIsKnownHidden + session.clearHiddenRendererResizeOutput = clearHiddenRendererResizeOutput + session.clearDeliveredHiddenRendererResizeOutput = clearDeliveredHiddenRendererResizeOutput + session.schedulePendingDataAfterCreditReport = (creditedAny) => + schedulePendingDataAfterCreditReport(session, creditedAny) + session.writeOffLostRendererDelivery = (report) => writeOffLostRendererDelivery(session, report) + session.getRendererInFlightCharsForPty = (id) => getRendererInFlightCharsForPty(session, id) + + setClearBackgroundedDeliverySyncForPty((id: string) => { + session.backgroundedDeliverySyncByPty.delete(id) + }) + if (session.runtime) { + session.runtime.onRemoteTerminalViewPresenceChanged = (id) => + session.syncPtyBackgroundedDelivery(id, 'remote-view') + } + + setReadPtyRendererDeliveryDebugSnapshot(session.readCurrentPtyRendererDeliveryDebugSnapshot) + setResetPtyRendererDeliveryDebugSnapshot(() => { + session.peakPendingChars = 0 + session.peakMaxPendingCharsByPty = 0 + session.peakRendererInFlightChars = 0 + session.peakMaxRendererInFlightCharsByPty = 0 + session.ackGatedFlushSkipCount = 0 + session.pendingDroppedChars = 0 + resetHiddenRendererPtyDeliveryDebugCounters() + seedPtyRendererDeliveryPeaksFromCurrentState(session) + }) + setResetRendererDeliveryAccountingForLifecycleReset(() => { + // Why lossless: pendingData bytes were bound for the dead page; the replacement repaints from main's authoritative sources, which superset it. + session.lastLifecycleResetClearedChars = session.rendererInFlightTotalChars + session.rendererLifecycleResetCount += 1 + // Why release before clearing: pending bytes and credits belonged to the dead page; releasing producer pauses first keeps no shell wedged. + session.producerFlowControl.releaseAll() + session.clearDeliveryResyncProbe() + session.deliveryResyncUnansweredWarnLogged = false + for (const id of session.rendererDeliveryAccountingByPty.keys()) { + session.sshOutputIntake?.transferPtyProjections(id, 'renderer-lifecycle-reset') + } + session.rendererDeliveryAccountingByPty.clear() + session.rendererInFlightTotalChars = 0 + session.clearPendingPtyData() + session.pendingOverflowMarkedPtys.clear() + session.rendererDeliveryRestoreNeededPtys.clear() + // Why hold sends: the reloading page's pty:data listener is gone until it re-registers/handshakes, so bytes would drop into a listener-less page and re-pin the gate. + session.rendererPtyDispatcherReady = false + // Why: arm the self-heal watchdog so a never-arriving handshake can't hold the gate forever; the real handshake cancels it. + session.armDispatcherReadyWatchdog() + }) + // Why the bridge: let a later re-registration cancel this closure's watchdog (armed via a hoisted fn, so this assignment can precede its definition). + setClearRendererDispatcherReadyWatchdog(session.clearDispatcherReadyWatchdog) + setInvalidatePendingPtyDrainPriority((id, schedule) => + invalidatePendingPtyDrainClassification(session, id, schedule) + ) + setInvalidatePendingPtyDrainPolicy((id, schedule) => + invalidatePendingPtyDrainClassification(session, id, schedule) + ) +} diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts new file mode 100644 index 00000000000..569b40052df --- /dev/null +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -0,0 +1,272 @@ +import { join, delimiter } from 'node:path' +import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' +import { + detectExplicitPiAgentKindFromCommand, + isPiCompatibleAgentType +} from '../../../../shared/pi-agent-kind' +import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' +import { openCodeHookService } from '../../../opencode/hook-service' +import { mimoCodeHookService } from '../../../mimo/hook-service' +import { agentHookServer } from '../../../agent-hooks/server' +import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager' +import { piTitlebarExtensionService } from '../../../pi/titlebar-extension-service' +import { ensureLinuxTerminalOrcaCliShimDir } from '../../../cli/linux-terminal-orca-cli-shim' +import { stripLegacyTerminalShimEnv } from '../../../pty/legacy-terminal-shim-dir' +import { resolvePathEnvKey, mergePersistedWindowsPath } from '../../../pty/windows-environment-path' +import { resolveCodexShellLaunchPreflightCommand } from '../../../pty/codex-shell-launch-preflight' +import { buildConfiguredProxyEnv } from '../../../../shared/network-proxy' +import type { BuildPtyHostEnvOptions } from './types' +import { readInheritedPath } from './path' +import { stripInheritedOrcaCodexHomeOverride } from './codex-home' +import { + clearPiAgentShadowEnv, + exposePiManagedExtensionEnv, + isMimoLaunchCommand, + resolveMimocodeSourceHome, + resolveOpenCodeSourceConfigDir, + resolvePiAgentSourceDir, + resolveScopedPiAgentSourceDir, + restoreOrStripOverlayEnv +} from './pi-agent' +import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys' + +/** + * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. + * + * Do NOT call when `args.connectionId` is set (SSH): every injection is host-loopback + * or references local filesystem paths meaningless to a remote shell. + */ +export function buildPtyHostEnv( + id: string, + baseEnv: Record, + opts: BuildPtyHostEnvOptions +): Record { + mergePersistedWindowsPath(baseEnv) + Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings)) + + // Why: local path's baseEnv includes process.env but the daemon path doesn't (fork inheritance, not IPC); check both sources so guards stay in lock-step across spawn paths. + const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv) + const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) + const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent) + ? opts.launchAgent + : opts.launchAgent === undefined + ? detectExplicitPiAgentKindFromCommand(launchCommandHint) + : null + const piAgentKind = explicitPiAgentKind ?? 'pi' + const hasLaunchCommand = + typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 + + // Why: unattended agents must fail instead of looping on OS credential prompts; user terminals keep normal Git behavior. + applyTerminalGitCredentialPromptGuard(baseEnv, { + launchCommand: launchCommandHint, + isUnattended: opts.launchAgent !== undefined, + deferGitConfigGuardToHost: opts.deferGitConfigGuardToDaemon + }) + + const shouldPrepareOmpShadow = piAgentKind === 'omp' || !hasLaunchCommand + // Why: source shadows are agent-scoped; trusting the other kind's source reintroduces Pi/OMP extension-state shadowing. + const preexistingPiAgentDir = resolvePiAgentSourceDir(baseEnv, 'pi') + const preexistingOmpAgentDir = + piAgentKind === 'omp' + ? resolvePiAgentSourceDir(baseEnv, 'omp') + : resolveScopedPiAgentSourceDir(baseEnv, 'omp') + const preexistingPrimeAgentDir = + piAgentKind === 'prime-agent' + ? resolvePiAgentSourceDir(baseEnv, 'prime-agent') + : resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent') + + if (opts.agentStatusHooksEnabled) { + // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md. + Object.assign(baseEnv, openCodeHookService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) + if (baseEnv.OPENCODE_CONFIG_DIR) { + // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value. + baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR + if (preexistingOpenCodeConfigDir) { + // Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays. + baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir + } else { + delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } + } + if (isMimoLaunchCommand(launchCommandHint)) { + const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) + Object.assign(baseEnv, mimoCodeHookService.buildPtyEnv(id, preexistingMimocodeHome)) + if (baseEnv.MIMOCODE_HOME) { + baseEnv.ORCA_MIMOCODE_HOME = baseEnv.MIMOCODE_HOME + if (preexistingMimocodeHome) { + baseEnv.ORCA_MIMOCODE_SOURCE_HOME = preexistingMimocodeHome + } else { + delete baseEnv.ORCA_MIMOCODE_SOURCE_HOME + } + } + } + } else { + restoreOrStripOverlayEnv(baseEnv, { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + }) + restoreOrStripOverlayEnv(baseEnv, { + primary: 'MIMOCODE_HOME', + overlay: 'ORCA_MIMOCODE_HOME', + source: 'ORCA_MIMOCODE_SOURCE_HOME' + }) + } + + // Why: strip inherited hook coordinates before injecting this PTY's fresh loopback receiver, so nested-terminal callbacks route to the owning pane. + for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) { + delete baseEnv[key] + } + if (opts.agentStatusHooksEnabled) { + Object.assign(baseEnv, agentHookServer.buildPtyEnv()) + if (opts.isWsl === true) { + // Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one. + const distro = opts.wslDistro ?? null + wslHookRelayManager.ensureForDistro(distro) + const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro) + if (guestEndpoint) { + baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint + } + // Why: OpenCode loads its status plugin from a guest config overlay, so point OPENCODE_CONFIG_DIR at the guest dir the relay materialized. + const opencodeOverlayDir = wslHookRelayManager.getOpenCodeOverlayDir(distro) + if (opencodeOverlayDir) { + baseEnv.OPENCODE_CONFIG_DIR = opencodeOverlayDir + baseEnv.ORCA_OPENCODE_CONFIG_DIR = opencodeOverlayDir + delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } else { + // Why: relay not connected yet (or older guest bundle) — never cross the Windows overlay path into WSL; drop it so in-guest OpenCode uses its own config (pre-fix behavior, no status but no regression). + delete baseEnv.OPENCODE_CONFIG_DIR + delete baseEnv.ORCA_OPENCODE_CONFIG_DIR + delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } + } + } + + // Why: PI_CODING_AGENT_DIR is the user's config/session root; install only Orca-owned extension files, don't override it. + if (opts.agentStatusHooksEnabled) { + clearPiAgentShadowEnv(baseEnv, 'pi') + clearPiAgentShadowEnv(baseEnv, 'omp') + clearPiAgentShadowEnv(baseEnv, 'prime-agent') + // Why: bare shells historically defaulted to Pi + OMP shadow prep and + // created ~/./agent even when the user never launches those agents + // (#10196). Only create default homes on an explicit Pi/OMP launch; + // otherwise install only into an existing agent dir (or userData for OMP + // status so a typed `omp` still gets the shell wrapper extension). + if (piAgentKind === 'pi') { + const piEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir, 'pi', { + materializeDefaultHome: explicitPiAgentKind === 'pi' + }) + Object.assign(baseEnv, piEnv) + exposePiManagedExtensionEnv(baseEnv, 'pi', piEnv) + } + + if (shouldPrepareOmpShadow) { + const ompEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingOmpAgentDir, 'omp', { + materializeDefaultHome: explicitPiAgentKind === 'omp' + }) + Object.assign(baseEnv, ompEnv) + exposePiManagedExtensionEnv(baseEnv, 'omp', ompEnv) + } + + if (piAgentKind === 'prime-agent' && !opts.isWsl) { + const primeEnv = piTitlebarExtensionService.buildPtyEnv( + id, + preexistingPrimeAgentDir, + 'prime-agent', + { materializeDefaultHome: explicitPiAgentKind === 'prime-agent' } + ) + Object.assign(baseEnv, primeEnv) + exposePiManagedExtensionEnv(baseEnv, 'prime-agent', primeEnv) + } + } else { + // Why: nested PTYs must not inherit stale source or overlay state from another agent. + restoreOrStripOverlayEnv(baseEnv, { + primary: 'PI_CODING_AGENT_DIR', + overlay: 'ORCA_PI_CODING_AGENT_DIR', + source: 'ORCA_PI_SOURCE_AGENT_DIR' + }) + restoreOrStripOverlayEnv(baseEnv, { + primary: 'PI_CODING_AGENT_DIR', + overlay: 'ORCA_OMP_CODING_AGENT_DIR', + source: 'ORCA_OMP_SOURCE_AGENT_DIR' + }) + delete baseEnv.ORCA_OMP_STATUS_EXTENSION + delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION + } + + // Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex. + if (opts.skipCodexHomeEnv) { + delete baseEnv.CODEX_HOME + delete baseEnv.ORCA_CODEX_HOME + delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT + } else if (opts.selectedCodexHomePath) { + baseEnv.CODEX_HOME = opts.selectedCodexHomePath + // Why: user startup files may re-export CODEX_HOME; shell-ready wrappers restore this runtime home before Codex launches. + baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath + const preflightCommand = resolveCodexShellLaunchPreflightCommand({ + hooksEnabled: opts.codexStatusHooksEnabled ?? opts.agentStatusHooksEnabled, + isPackaged: opts.isPackaged, + isWsl: opts.isWsl, + managedHomePath: opts.selectedCodexHomePath, + userDataPath: opts.userDataPath, + resourcesPath: opts.resourcesPath + }) + if (preflightCommand) { + baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT = preflightCommand + } else { + delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT + } + } else if (opts.stripInheritedOrcaCodexHome) { + stripInheritedOrcaCodexHomeOverride(baseEnv) + delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT + } else { + delete baseEnv.ORCA_CODEX_LAUNCH_PREFLIGHT + } + + // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. + if (opts.isWsl) { + baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath + // Why: managed WSL registration uses `orca-ide`; exposing that literal scopes agent guidance to WSL without a bare-orca shim. + baseEnv.ORCA_CLI_COMMAND = opts.isPackaged ? 'orca-ide' : 'orca-dev' + } else { + if (!opts.isPackaged) { + baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath + } + delete baseEnv.ORCA_CLI_COMMAND + } + // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca. + if (!opts.isPackaged) { + const devCliBin = join(opts.userDataPath, 'cli', 'bin') + const inheritedPath = readInheritedPath(baseEnv) + // Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter. + baseEnv[resolvePathEnvKey(baseEnv, process.platform)] = inheritedPath + ? `${devCliBin}${delimiter}${inheritedPath}` + : devCliBin + } else if (process.platform === 'linux') { + // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904). + const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath }) + if (shimDir) { + const inheritedEntries = readInheritedPath(baseEnv) + .split(delimiter) + .filter((entry) => entry.length > 0 && entry !== shimDir) + baseEnv.PATH = [shimDir, ...inheritedEntries].join(delimiter) + } + } else if ( + opts.resourcesPath && + (process.platform === 'darwin' || process.platform === 'win32') + ) { + // Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI. + const bundledCliBin = join(opts.resourcesPath, 'bin') + const inheritedPath = readInheritedPath(baseEnv) + baseEnv[resolvePathEnvKey(baseEnv, process.platform)] = inheritedPath + ? `${bundledCliBin}${delimiter}${inheritedPath}` + : bundledCliBin + } + + // Why: must run after the prepends above — they re-read PATH from the unscrubbed + // process.env when baseEnv carries none, which is the daemon path's normal shape. + stripLegacyTerminalShimEnv(baseEnv, process.platform) + + return baseEnv +} diff --git a/src/main/ipc/pty/host-env/codex-home.ts b/src/main/ipc/pty/host-env/codex-home.ts new file mode 100644 index 00000000000..ae825e31e78 --- /dev/null +++ b/src/main/ipc/pty/host-env/codex-home.ts @@ -0,0 +1,301 @@ +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' +import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' +import { + isCodexHomeAuthReadyForLaunch, + waitForManagedCodexAuthReady +} from '../../../codex-accounts/managed-codex-auth-readiness' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import { + forgetCodexPaneAccount, + getCodexPaneAccount, + recordCodexPaneAccount, + type CodexPaneHomeRoute +} from '../../../codex/codex-pane-account-registry' +import { resolveCodexPaneLaunchAccount } from '../../../codex/codex-pane-launch-account' +import { getSystemCodexHomePath } from '../../../codex/codex-home-paths' +import { + environmentCodexHomeOverrideContextsEqual, + getCustomCodexHomeOverrideForLaunch, + shellStartupCodexHomeOverrideContextsEqual +} from '../../../codex/codex-real-home-path' +import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../../../pty/codex-home-wsl-env' +import { isWslShellName } from '../../../../shared/local-windows-terminal-runtime' +import { parseWslPath } from '../../../wsl' + +export function shouldSkipCodexHomeEnvForWindowsShell( + shellPath: string | undefined, + cwd: string | undefined +): boolean { + return isWslShellName(shellPath) || (typeof cwd === 'string' && parseWslPath(cwd) !== null) +} + +export function isCodexStatusHooksEnabled(settings: GlobalSettings | undefined): boolean { + return ( + isAgentStatusHooksEnabled(settings) && isTuiAgentEnabled('codex', settings?.disabledTuiAgents) + ) +} + +// Why: with the real-home flag ON, a host system-default launch resolves to a +// null managed home. Signal the env builder to strip a nested-Orca-inherited +// override instead of injecting one, so Codex runs on the user's own ~/.codex. +export function shouldStripInheritedOrcaCodexHome(args: { + target: CodexAccountSelectionTarget + selectedCodexHomePath: string | null + skipCodexHomeEnv: boolean + settings: GlobalSettings | undefined +}): boolean { + return ( + args.target.runtime === 'host' && args.selectedCodexHomePath === null && !args.skipCodexHomeEnv + ) +} + +export const CODEX_HOME_ENV_KEYS = ['CODEX_HOME', 'ORCA_CODEX_HOME'] as const + +// Why: system-default real-home routing runs Codex on the user's own ~/.codex. +// Nested Orca panes inherit the parent's Orca-owned override; strip only that +// (CODEX_HOME matching Orca's private ORCA_CODEX_HOME marker), and always drop +// the marker so a shell-ready wrapper cannot restore the managed home. A +// user-set CODEX_HOME with no Orca marker is preserved untouched (see #8606). +export function stripInheritedOrcaCodexHomeOverride(baseEnv: Record): void { + for (const key of getLocalOrcaCodexHomeEnvKeysToDelete(baseEnv)) { + delete baseEnv[key] + } +} + +// Why: in-process spawns share main's inherited environment, so equality with +// the private marker is authoritative here. Persistent daemons compare locally. +export function getLocalOrcaCodexHomeEnvKeysToDelete(env: Record): string[] { + const inheritedOrcaOverride = env.ORCA_CODEX_HOME ?? process.env.ORCA_CODEX_HOME + const inheritedCodexHome = env.CODEX_HOME ?? process.env.CODEX_HOME + const keysToDelete = ['ORCA_CODEX_HOME'] + if (inheritedOrcaOverride && inheritedCodexHome === inheritedOrcaOverride) { + keysToDelete.push('CODEX_HOME') + } + return keysToDelete +} + +export function getCodexSelectionTargetForPty( + shellPath: string | undefined, + cwd: string | undefined, + wslDistro?: string | null +): CodexAccountSelectionTarget { + const wslPath = typeof cwd === 'string' ? parseWslPath(cwd) : null + if (isWslShellName(shellPath) || wslPath) { + return { runtime: 'wsl', wslDistro: wslPath?.distro ?? wslDistro ?? null } + } + return { runtime: 'host' } +} + +export function getCompatibleSelectedCodexHomePath( + target: CodexAccountSelectionTarget, + selectedCodexHomePath: string | null +): string | null { + if (!selectedCodexHomePath) { + return null + } + const wslInfo = parseWslPath(selectedCodexHomePath) + if (target.runtime === 'wsl') { + return wslInfo || !isHostCodexHomeForWsl(selectedCodexHomePath) ? selectedCodexHomePath : null + } + return wslInfo || (process.platform === 'win32' && isWslCodexHomeForHost(selectedCodexHomePath)) + ? null + : selectedCodexHomePath +} + +export const MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE = + 'The selected Codex account credentials are temporarily unavailable. Try opening the terminal again.' +export const CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE = + 'The Codex account credentials for this session are temporarily unavailable. Try opening the terminal again.' + +type ManagedCodexAuthResolutionArgs = { + selectedCodexHomePath: string | null + getSettings: () => GlobalSettings | undefined + requiredCodexHomePath?: string + target: CodexAccountSelectionTarget + resolveCurrent: () => string | null + resolveAfterUnavailable: (unavailableManagedHomePath: string) => string | null +} + +export function resolveCodexHomeAfterManagedAuthReadiness( + args: ManagedCodexAuthResolutionArgs +): string | null | Promise { + const selectedCodexHomePath = args.selectedCodexHomePath + if ( + args.requiredCodexHomePath && + !codexHomePathsEqual(selectedCodexHomePath, args.requiredCodexHomePath) + ) { + throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) + } + const readiness = waitForManagedCodexAuthReady({ + codexHomePath: selectedCodexHomePath, + settings: args.getSettings(), + target: args.target + }) + return readiness + ? continueCodexHomeAfterManagedAuthWait(args, selectedCodexHomePath, readiness) + : selectedCodexHomePath +} + +async function continueCodexHomeAfterManagedAuthWait( + args: ManagedCodexAuthResolutionArgs, + initialCodexHomePath: string | null, + initialReadiness: Promise +): Promise { + let selectedCodexHomePath = initialCodexHomePath + let readiness = initialReadiness + for (let attempt = 0; attempt < 2; attempt += 1) { + if (await readiness) { + if (args.requiredCodexHomePath) { + return selectedCodexHomePath + } + const currentCodexHomePath = args.resolveCurrent() + if (codexHomeSelectionsEqual(selectedCodexHomePath, currentCodexHomePath)) { + return selectedCodexHomePath + } + selectedCodexHomePath = currentCodexHomePath + if (attempt === 1) { + break + } + const nextReadiness = waitForManagedCodexAuthReady({ + codexHomePath: selectedCodexHomePath, + settings: args.getSettings(), + target: args.target + }) + if (!nextReadiness) { + return selectedCodexHomePath + } + readiness = nextReadiness + continue + } + if (args.requiredCodexHomePath) { + throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) + } + selectedCodexHomePath = args.resolveAfterUnavailable(selectedCodexHomePath!) + if (attempt === 1) { + break + } + const nextReadiness = waitForManagedCodexAuthReady({ + codexHomePath: selectedCodexHomePath, + settings: args.getSettings(), + target: args.target + }) + if (!nextReadiness) { + return selectedCodexHomePath + } + readiness = nextReadiness + } + if ( + isCodexHomeAuthReadyForLaunch({ + codexHomePath: selectedCodexHomePath, + settings: args.getSettings(), + target: args.target + }) + ) { + return selectedCodexHomePath + } + throw new Error(MANAGED_CODEX_AUTH_UNAVAILABLE_MESSAGE) +} + +export function codexHomeSelectionsEqual(left: string | null, right: string | null): boolean { + return ( + left === right || + (left !== null && + right !== null && + normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right)) + ) +} + +export function codexHomePathsEqual(left: string | null, right: string): boolean { + return codexHomeSelectionsEqual(left, right) +} + +// Why: CODEX_HOME is fixed in a shell's environment at spawn and the daemon +// keeps that shell alive across app restarts, so the launch account is the only +// way to tell later that a pane still runs Codex as the previously selected +// account. A reattach inherits that baked environment rather than choosing one, +// so re-recording it under the current selection would erase the very evidence +// that the pane is stale. +export function recordCodexPaneAccountForSpawn(args: { + ptyId: string | undefined + isDaemonHostSpawn: boolean + isReattach: boolean + pinnedByResume: boolean + launchCodexHomePath: string | null + launchEnv?: NodeJS.ProcessEnv + target: CodexAccountSelectionTarget + settings: GlobalSettings | undefined +}): void { + if (!args.ptyId || !args.isDaemonHostSpawn || args.isReattach) { + return + } + const customHomeOverride = getCustomCodexHomeOverrideForLaunch(args.launchEnv) + const processHomeOverride = customHomeOverride ? getCustomCodexHomeOverrideForLaunch() : null + const recheckableEnvironmentOverride = + customHomeOverride?.source === 'environment' && + processHomeOverride?.source === 'environment' && + environmentCodexHomeOverrideContextsEqual( + customHomeOverride.context, + processHomeOverride.context + ) + ? customHomeOverride.context + : undefined + const recheckableShellStartupOverride = + customHomeOverride?.source === 'shell-startup' && + processHomeOverride?.source === 'shell-startup' && + shellStartupCodexHomeOverrideContextsEqual( + customHomeOverride.context, + processHomeOverride.context + ) + ? customHomeOverride.context + : undefined + const record = args.settings + ? resolveCodexPaneLaunchAccount({ + pinnedByResume: args.pinnedByResume, + launchCodexHomePath: args.launchCodexHomePath, + // Why: pane-local overrides cannot be re-derived when a restart builds + // a fresh launch env, so route prompts would guess and block valid input. + recordComparableHomeRoute: + args.pinnedByResume || + ((customHomeOverride?.source !== 'environment' || + recheckableEnvironmentOverride !== undefined) && + (customHomeOverride?.source !== 'shell-startup' || + recheckableShellStartupOverride !== undefined)), + shellStartupHomeOverride: args.pinnedByResume ? undefined : recheckableShellStartupOverride, + environmentHomeOverride: args.pinnedByResume ? undefined : recheckableEnvironmentOverride, + systemCodexHomePath: getSystemCodexHomePath(), + settings: args.settings, + target: args.target + }) + : null + if (!record) { + forgetCodexPaneAccount(args.ptyId) + return + } + recordCodexPaneAccount(args.ptyId, record) +} + +export function snapshotCodexPaneHomeRoutes( + ptyIds: readonly (string | null | undefined)[] +): ReadonlyMap { + const routes = new Map() + for (const ptyId of ptyIds) { + if (!ptyId || routes.has(ptyId)) { + continue + } + routes.set(ptyId, getCodexPaneAccount(ptyId)?.homeRoute ?? null) + } + return routes +} + +export function codexReattachedHomeRouteField( + routes: ReadonlyMap, + ptyId: string, + reattached: boolean +): { reattachedHomeRoute?: CodexPaneHomeRoute | null } { + if (!reattached || !routes.has(ptyId)) { + return {} + } + return { reattachedHomeRoute: routes.get(ptyId) ?? null } +} diff --git a/src/main/ipc/pty/host-env/codex-resume.ts b/src/main/ipc/pty/host-env/codex-resume.ts new file mode 100644 index 00000000000..a32aefe9057 --- /dev/null +++ b/src/main/ipc/pty/host-env/codex-resume.ts @@ -0,0 +1,138 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import { + normalizeAgentProviderSession, + type AgentProviderSessionMetadata +} from '../../../../shared/agent-session-resume' +import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../../../shared/setup-agent-sequencing' +import { dropAgentResumeArgvFromCommand } from '../../../../shared/agent-resume-argv-drop' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import { dropUnverifiedCodexResumeArgv } from '../../../codex/codex-unverified-resume-launch' +import type { CodexSessionResumePreparation } from '../../../codex/codex-session-resume-home' +import { CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE, codexHomePathsEqual } from './codex-home' +import type { PrepareCodexSessionResume } from './types' + +export type CodexResumeLaunch = { + codexResumeHome: Extract | null + command: string | undefined + notifyResumeUnavailable: boolean + droppedResumeArgv: boolean + providerSession: AgentProviderSessionMetadata | null +} + +export type PreparedCodexResumeHome = { + providerSession: AgentProviderSessionMetadata + preparation: Promise +} + +export type PrepareCodexResumeHomeArgs = { + connectionId?: string | null + launchAgent?: TuiAgent + providerSession?: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string +} + +export function prepareCodexResumeHome( + prepareCodexSessionResume: PrepareCodexSessionResume | undefined, + args: PrepareCodexResumeHomeArgs +): PreparedCodexResumeHome | null { + if (args.connectionId || args.launchAgent !== 'codex' || !prepareCodexSessionResume) { + return null + } + const providerSession = normalizeAgentProviderSession(args.providerSession) + if (!providerSession) { + return null + } + return { + providerSession, + preparation: prepareCodexSessionResume({ + providerSession, + target: args.target, + launchEnv: args.launchEnv, + workspacePath: args.workspacePath + }) + } +} + +/** Kept separate from resolveCodexResumeLaunch so non-Codex spawns never await: + * an extra tick reorders the pane-spawn reservation races this handler arbitrates. */ +export function noCodexResumeLaunch(command: string | undefined): CodexResumeLaunch { + return { + codexResumeHome: null, + command, + notifyResumeUnavailable: false, + droppedResumeArgv: false, + providerSession: null + } +} + +/** The command a Codex launch actually runs: unchanged when provenance is verified, + * stripped of `resume ` when it is not. */ +export function resolveCodexResumeLaunch( + command: string | undefined, + preparation: PreparedCodexResumeHome +): Promise { + return preparation.preparation.then((prepared) => { + const providerSession = preparation.providerSession + if (prepared?.outcome !== 'fresh') { + return { + codexResumeHome: prepared ?? null, + command, + notifyResumeUnavailable: false, + droppedResumeArgv: false, + providerSession + } + } + const dropped = dropUnverifiedCodexResumeArgv({ + command, + providerSession, + claimedCodexProvenance: prepared.claimedCodexProvenance + }) + return { + codexResumeHome: null, + command: dropped.command, + // Why: staying silent only makes sense for metadata that positively belongs to + // another agent; a resume with no transcript path at all still owes the user a notice. + notifyResumeUnavailable: + dropped.droppedResumeArgv && + (prepared.claimedCodexProvenance || !providerSession.transcriptPath), + droppedResumeArgv: dropped.droppedResumeArgv, + providerSession + } + }) +} + +export function reconcileSharedRuntimeResumeHome( + resumeHome: Extract, + resolveCurrentHome: () => string | null +): string { + if (!resumeHome.reconcileSharedRuntimeAuth) { + return resumeHome.codexHomePath + } + const currentHome = resolveCurrentHome() + if (!codexHomePathsEqual(currentHome, resumeHome.codexHomePath)) { + throw new Error(CODEX_RESUME_AUTH_UNAVAILABLE_MESSAGE) + } + return resumeHome.codexHomePath +} + +/** Why: buildPtyHostEnv prefers ORCA_SEQUENCED_STARTUP_COMMAND over the launch command + * and the sequenced wrapper `eval`s it, so a dropped resume argv has to go there too. */ +export function stripSequencedStartupResumeArgv | undefined>( + env: T, + launch: CodexResumeLaunch +): T { + const sequenced = env?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV] + if (!env || !sequenced || !launch.droppedResumeArgv || !launch.providerSession) { + return env + } + const drop = dropAgentResumeArgvFromCommand({ + command: sequenced, + agent: 'codex', + providerSession: launch.providerSession + }) + return drop.status === 'dropped' + ? { ...env, [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: drop.command } + : env +} diff --git a/src/main/ipc/pty/host-env/fresh-spawn-routing.ts b/src/main/ipc/pty/host-env/fresh-spawn-routing.ts new file mode 100644 index 00000000000..64034d544c2 --- /dev/null +++ b/src/main/ipc/pty/host-env/fresh-spawn-routing.ts @@ -0,0 +1,56 @@ +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' +import type { IPtyProvider } from '../../../providers/types' +import { beginTerminalInstall } from '../../watcher-removal-gate' + +export function isClaudeLaunchCommand(command: string | undefined): boolean { + if (!command) { + return false + } + return /(^|[\s;&|('"`])(?:[^\s;&|('"`]*[\\/])?claude(?:\.cmd|\.exe)?($|[\s;&|)'"`])/i.test( + command + ) +} + +export function routesFreshSpawnsToLocalProvider(provider: IPtyProvider): boolean { + return provider.routesFreshSpawnsToLocalProvider === true +} + +export function recoverFreshSpawnProviderRouting( + provider: IPtyProvider, + connectionId: string | null | undefined, + sessionId: string | undefined, + isNewSession = sessionId === undefined +): Promise | undefined { + if (connectionId || (!isNewSession && sessionId) || !routesFreshSpawnsToLocalProvider(provider)) { + return + } + return provider.recoverFreshSpawnRouting?.() +} + +export function beginPtySpawnForWorktree( + worktreeId: string | undefined, + cwd: string | undefined, + connectionId: string | null | undefined +): () => void { + const worktreePath = worktreeId + ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + : undefined + const installPaths = new Map() + for (const candidate of [worktreePath, cwd]) { + if (candidate) { + installPaths.set(normalizeRuntimePathForComparison(candidate), candidate) + } + } + const finishes: (() => void)[] = [] + try { + for (const candidate of installPaths.values()) { + finishes.push(beginTerminalInstall(candidate, connectionId ?? undefined)) + } + } catch (error) { + // Why: worktree ID and cwd can be different roots; release earlier admissions before rejecting. + finishes.toReversed().forEach((finish) => finish()) + throw error + } + return () => finishes.toReversed().forEach((finish) => finish()) +} diff --git a/src/main/ipc/pty/host-env/path.ts b/src/main/ipc/pty/host-env/path.ts new file mode 100644 index 00000000000..226ad8691fe --- /dev/null +++ b/src/main/ipc/pty/host-env/path.ts @@ -0,0 +1,47 @@ +import { delimiter } from 'node:path' +import { isLegacyTerminalShimPathEntry } from '../../../pty/legacy-terminal-shim-dir' +import { resolvePathEnvKey } from '../../../pty/windows-environment-path' + +export function readInheritedPath(baseEnv: Record): string { + const pathKey = resolvePathEnvKey(baseEnv, process.platform) + return baseEnv[pathKey] ?? process.env[pathKey] ?? '' +} + +export function firstPathEntry(pathValue: string | undefined): string | null { + const first = pathValue?.split(delimiter).find((entry) => entry.trim().length > 0) + return first ?? null +} + +export function promoteAgentTeamsShimPath( + env: Record | undefined, + requestedPath: string | undefined +): void { + if (!env?.ORCA_AGENT_TEAMS_TEAM_ID) { + return + } + const shimPath = firstPathEntry(requestedPath) + // Why: requestedPath is captured before buildPtyHostEnv scrubs, so a legacy entry that + // reached the front would be re-prepended here and outlive the scrub. + if (!shimPath || isLegacyTerminalShimPathEntry(shimPath)) { + return + } + const currentPathKey = env.PATH !== undefined || env.Path === undefined ? 'PATH' : 'Path' + const currentPath = env[currentPathKey] ?? '' + const remaining = currentPath + .split(delimiter) + .filter((entry) => entry.length > 0 && entry !== shimPath) + // Why: host env injection prepends Orca's shims; Claude Agent Teams must still resolve our fake tmux before any real tmux. + env[currentPathKey] = [shimPath, ...remaining].join(delimiter) +} + +export function deleteRequestedEnvKeys( + env: Record | undefined, + keys: string[] | undefined +): void { + if (!env || !keys) { + return + } + for (const key of keys) { + delete env[key] + } +} diff --git a/src/main/ipc/pty/host-env/pi-agent.ts b/src/main/ipc/pty/host-env/pi-agent.ts new file mode 100644 index 00000000000..545d04d063d --- /dev/null +++ b/src/main/ipc/pty/host-env/pi-agent.ts @@ -0,0 +1,202 @@ +import { + getCommandTokenPathBasename, + getFirstCommandToken +} from '../../../../shared/command-token-scanner' +import { + PRIMARY_AGENT_DIR_ENV_BY_KIND, + SOURCE_AGENT_DIR_ENV_BY_KIND, + type PiAgentKind +} from '../../../../shared/pi-agent-kind' +import { readSessionShellStartupEnvVar } from '../../../pty/shell-startup-env' +import { AGENT_HOOK_RUNTIME_ENV_KEYS, CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS } from './spawn-env-keys' + +export function readEnvWithProcessFallback( + baseEnv: Record, + key: string +): string | undefined { + return baseEnv[key] ?? process.env[key] +} + +export function resolvePiAgentSourceDir( + baseEnv: Record, + kind: PiAgentKind +): string | undefined { + const sourceKey = SOURCE_AGENT_DIR_ENV_BY_KIND[kind] + const primaryKey = PRIMARY_AGENT_DIR_ENV_BY_KIND[kind] + + const sourceDir = readEnvWithProcessFallback(baseEnv, sourceKey) + if (sourceDir) { + return sourceDir + } + + if (kind === 'prime-agent') { + return ( + readEnvWithProcessFallback(baseEnv, primaryKey) ?? + readSessionShellStartupEnvVar(primaryKey, baseEnv) + ) + } + + const overlayKey = kind === 'omp' ? 'ORCA_OMP_CODING_AGENT_DIR' : 'ORCA_PI_CODING_AGENT_DIR' + const otherOverlayKey = kind === 'omp' ? 'ORCA_PI_CODING_AGENT_DIR' : 'ORCA_OMP_CODING_AGENT_DIR' + + const publicDir = readEnvWithProcessFallback(baseEnv, primaryKey) + const ownOverlayDir = readEnvWithProcessFallback(baseEnv, overlayKey) + const otherOverlayDir = readEnvWithProcessFallback(baseEnv, otherOverlayKey) + // Why: if PI_CODING_AGENT_DIR is a restored Orca overlay with no source shadow, remirroring leaks another agent's overlay tree; fall through to defaults. + if (publicDir && publicDir !== ownOverlayDir && publicDir !== otherOverlayDir) { + return publicDir + } + + return readSessionShellStartupEnvVar(primaryKey, baseEnv) +} + +export function resolveScopedPiAgentSourceDir( + baseEnv: Record, + kind: PiAgentKind +): string | undefined { + return readEnvWithProcessFallback(baseEnv, SOURCE_AGENT_DIR_ENV_BY_KIND[kind]) +} + +export function clearPiAgentShadowEnv(baseEnv: Record, kind: PiAgentKind): void { + if (kind === 'omp') { + delete baseEnv.ORCA_OMP_CODING_AGENT_DIR + delete baseEnv.ORCA_OMP_SOURCE_AGENT_DIR + delete baseEnv.ORCA_OMP_STATUS_EXTENSION + return + } + if (kind === 'prime-agent') { + delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION + return + } + delete baseEnv.ORCA_PI_CODING_AGENT_DIR + delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR +} + +export function exposePiManagedExtensionEnv( + baseEnv: Record, + kind: PiAgentKind, + managedEnv: Record +): void { + if (kind === 'omp') { + delete baseEnv.ORCA_OMP_CODING_AGENT_DIR + if (managedEnv.ORCA_OMP_SOURCE_AGENT_DIR) { + baseEnv.ORCA_OMP_SOURCE_AGENT_DIR = managedEnv.ORCA_OMP_SOURCE_AGENT_DIR + } else { + delete baseEnv.ORCA_OMP_SOURCE_AGENT_DIR + } + if (managedEnv.ORCA_OMP_STATUS_EXTENSION) { + baseEnv.ORCA_OMP_STATUS_EXTENSION = managedEnv.ORCA_OMP_STATUS_EXTENSION + } else { + delete baseEnv.ORCA_OMP_STATUS_EXTENSION + } + return + } + if (kind === 'prime-agent') { + if (managedEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR) { + baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR = managedEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + } else { + delete baseEnv.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR + } + return + } + delete baseEnv.ORCA_PI_CODING_AGENT_DIR + if (managedEnv.ORCA_PI_SOURCE_AGENT_DIR) { + baseEnv.ORCA_PI_SOURCE_AGENT_DIR = managedEnv.ORCA_PI_SOURCE_AGENT_DIR + } else { + delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR + } +} + +// Why: variadic because a nested call per source made intermediate `string[] | undefined` collide with the parameter type. +export function mergePtyEnvDeletions( + existingKeys: string[] | undefined, + ...additionalKeyGroups: readonly (readonly string[])[] +): string[] | undefined { + if (!existingKeys && additionalKeyGroups.every((keys) => keys.length === 0)) { + return undefined + } + return Array.from(new Set([...(existingKeys ?? []), ...additionalKeyGroups.flat()])) +} + +export function removeCodexHomeDeletionRequests(keys: string[] | undefined): string[] | undefined { + // Why: resume provenance is launch-authoritative; late deletions must not fall back to the current account. + const filtered = keys?.filter((key) => key !== 'CODEX_HOME' && key !== 'ORCA_CODEX_HOME') + return filtered?.length ? filtered : undefined +} + +export function getInheritedAgentHookEnvKeysToDelete( + spawnEnv: Record | undefined +): string[] { + const env = spawnEnv ?? {} + // Why: providers merge process.env after cleanup; delete stale hook keys without dropping fresh coordinates buildPtyHostEnv set. + return AGENT_HOOK_RUNTIME_ENV_KEYS.filter((key) => env[key] === undefined) +} + +export function getInheritedClaudeSessionStampEnvKeysToDelete( + spawnEnv: Record | undefined +): string[] { + const env = spawnEnv ?? {} + // Why: strip only values inherited from the pty host; a caller that explicitly + // provides a stamp (deliberately spawning a nested Claude child) keeps it. + return CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined) +} + +// Why: a nested terminal can inherit prior OpenCode/Pi/OMP overlay env; restore the user's recorded source dir, else strip only Orca-owned values. +export function restoreOrStripOverlayEnv( + baseEnv: Record, + keys: { + primary: string + overlay: string + source: string + } +): void { + const sourceValue = baseEnv[keys.source] ?? process.env[keys.source] + const overlayValue = baseEnv[keys.overlay] ?? process.env[keys.overlay] + if (sourceValue) { + baseEnv[keys.primary] = sourceValue + } else if (overlayValue && baseEnv[keys.primary] === overlayValue) { + delete baseEnv[keys.primary] + } + delete baseEnv[keys.overlay] + delete baseEnv[keys.source] +} + +export function isMimoLaunchCommand(launchCommand: string | undefined): boolean { + const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCommand ?? '')) + .toLowerCase() + .replace(/\.(?:cmd|exe|sh)$/, '') + return binary === 'mimo' +} + +export function resolveMimocodeSourceHome(baseEnv: Record): string | undefined { + const sourceHome = baseEnv.ORCA_MIMOCODE_SOURCE_HOME ?? process.env.ORCA_MIMOCODE_SOURCE_HOME + if (sourceHome) { + return sourceHome + } + const configHome = baseEnv.MIMOCODE_HOME ?? process.env.MIMOCODE_HOME + const orcaHome = baseEnv.ORCA_MIMOCODE_HOME ?? process.env.ORCA_MIMOCODE_HOME + if (configHome && orcaHome && configHome === orcaHome) { + return undefined + } + return configHome +} + +export function resolveOpenCodeSourceConfigDir( + baseEnv: Record +): string | undefined { + const sourceDir = + baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR + if (sourceDir) { + return sourceDir + } + + const configDir = baseEnv.OPENCODE_CONFIG_DIR ?? process.env.OPENCODE_CONFIG_DIR + const orcaConfigDir = baseEnv.ORCA_OPENCODE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_CONFIG_DIR + // Why: with no recorded source dir, an inherited OPENCODE_CONFIG_DIR is Orca-owned, not user config; treating it as user config makes child Orcas mirror the hook dir. + if (configDir && orcaConfigDir && configDir === orcaConfigDir) { + return undefined + } + + return configDir ?? readSessionShellStartupEnvVar('OPENCODE_CONFIG_DIR', baseEnv) +} diff --git a/src/main/ipc/pty/host-env/spawn-cwd.ts b/src/main/ipc/pty/host-env/spawn-cwd.ts new file mode 100644 index 00000000000..03bac6611be --- /dev/null +++ b/src/main/ipc/pty/host-env/spawn-cwd.ts @@ -0,0 +1,55 @@ +import { statSync } from 'node:fs' +import type { Store } from '../../../persistence' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { isWslUncPath } from '../../../../shared/wsl-paths' +import { + resolveTerminalStartupCwdForWorkspace, + type TerminalStartupCwdMissingDirFallback +} from '../../../../shared/terminal-startup-cwd' +import { + assertFolderWorkspacePathUsable, + getFolderWorkspacePathStatus +} from '../../../project-groups/folder-workspace-path-status' +import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' + +export function assertFolderWorkspacePtyPathUsable( + store: Store | undefined, + worktreeId: string | undefined +): Promise | void { + const workspaceScope = typeof worktreeId === 'string' ? parseWorkspaceKey(worktreeId) : null + if (!store || workspaceScope?.type !== 'folder') { + return + } + return getFolderWorkspacePathStatus( + store, + { scope: 'folder-workspace', folderWorkspaceId: workspaceScope.folderWorkspaceId }, + { getSshFilesystemProvider } + ).then(assertFolderWorkspacePathUsable) +} + +export function resolvePtySpawnStartupCwd( + store: Store | undefined, + worktreeId: string | undefined, + cwd: string | undefined, + missingDirFallback?: TerminalStartupCwdMissingDirFallback +): string | undefined { + return resolveTerminalStartupCwdForWorkspace({ + workspaceId: worktreeId, + requestedCwd: cwd, + missingDirFallback, + resolveFolderWorkspacePath: (folderWorkspaceId) => + store?.getFolderWorkspace(folderWorkspaceId)?.folderPath + }) +} + +export function localStartupCwdDirectoryExists(path: string): boolean { + // Why: Win32 statSync on \\wsl.localhost 9P shares can falsely report ENOENT; defer to the provider's WSL-aware validation. + if (isWslUncPath(path)) { + return true + } + try { + return statSync(path).isDirectory() + } catch { + return false + } +} diff --git a/src/main/ipc/pty/host-env/spawn-env-keys.ts b/src/main/ipc/pty/host-env/spawn-env-keys.ts new file mode 100644 index 00000000000..0cd0b183729 --- /dev/null +++ b/src/main/ipc/pty/host-env/spawn-env-keys.ts @@ -0,0 +1,16 @@ +export const AGENT_HOOK_RUNTIME_ENV_KEYS = [ + 'ORCA_AGENT_HOOK_PORT', + 'ORCA_AGENT_HOOK_TOKEN', + 'ORCA_AGENT_HOOK_ENV', + 'ORCA_AGENT_HOOK_VERSION', + 'ORCA_AGENT_HOOK_ENDPOINT', + // Why: PR 2778 briefly exported this path; keep deleting stale inherited values so older PTYs can't leak the reverted path. + 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' +] as const + +// Why: Orca never sets these, so an inherited value means a pty host launched from inside a Claude session — Claude reads it as a nested child and silently stops persisting the transcript. +export const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' +] as const diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts new file mode 100644 index 00000000000..61ff335b43d --- /dev/null +++ b/src/main/ipc/pty/host-env/types.ts @@ -0,0 +1,73 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import type { NetworkProxySettings } from '../../../../shared/network-proxy' +import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' +import type { ClaudeAccountSelectionTarget } from '../../../claude-accounts/runtime-selection' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' +import type { CodexSessionResumePreparation } from '../../../codex/codex-session-resume-home' + +export type BuildPtyHostEnvOptions = { + isPackaged: boolean + resourcesPath?: string + userDataPath: string + selectedCodexHomePath: string | null + skipCodexHomeEnv?: boolean + /** System-default real-home routing (flag ON): inject no managed CODEX_HOME, + * and strip only an inherited Orca-owned override so nested Orca panes do not + * leak the parent's managed home. A user-set CODEX_HOME is preserved. */ + stripInheritedOrcaCodexHome?: boolean + /** Launch command the renderer chose (e.g. 'pi', 'omp', 'claude'); resolves the per-agent + * extension target for Pi/OMP. Undefined for bare shells → defaults to Pi. NEVER infer from + * disk presence (cross-agent shadowing when both dirs exist). */ + launchCommand?: string + /** Trusted agent identity for wrapped commands that cannot be recognized from text. */ + launchAgent?: TuiAgent + isWsl?: boolean + /** Distro for WSL spawns (null = Windows default distro); drives the WSL hook relay + endpoint repoint. Only read when isWsl. */ + wslDistro?: string | null + agentStatusHooksEnabled: boolean + codexStatusHooksEnabled?: boolean + networkProxySettings?: NetworkProxySettings + /** Keep indexed Git config off the sparse daemon wire; the daemon appends guard entries after merging its inherited env. */ + deferGitConfigGuardToDaemon?: boolean +} + +export type CodexHomeLaunchContext = { + workspacePath?: string + launchAgent?: TuiAgent + unavailableManagedHomePath?: string +} + +export type GetSelectedCodexHomePath = ( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + launchContext?: CodexHomeLaunchContext +) => string | null + +export type PrepareCodexSessionResume = (args: { + providerSession: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string +}) => Promise + +export type CodexHomePtySpawnedLifecycleArgs = { + id: string + codexHomePath: string | null + reattached?: boolean + reattachedHomeRoute?: CodexPaneHomeRoute | null + launchEnv?: NodeJS.ProcessEnv + startedAt?: Date + startedSequence?: number +} + +let ptyLifecycleSequence = 0 + +export function allocatePtyLifecycleSequence(): number { + return ++ptyLifecycleSequence +} + +export type PrepareClaudeAuth = ( + target?: ClaudeAccountSelectionTarget +) => Promise diff --git a/src/main/ipc/pty/ipc/inspect.ts b/src/main/ipc/pty/ipc/inspect.ts new file mode 100644 index 00000000000..2bb1b6a07e1 --- /dev/null +++ b/src/main/ipc/pty/ipc/inspect.ts @@ -0,0 +1,340 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import type { Store } from '../../../persistence' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { IPtyProvider } from '../../../providers/types' +import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' +import { inspectPtyProviderProcessForRenderer } from '../../../providers/pty-process-inspection' +import { + PtyProcessListAdmission, + visitPtyProcessListingsInBatches +} from '../../../providers/pty-process-list-admission' +import type { PtyListedSession } from '../../../../shared/pty-listed-session' +import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict' +import { ptyOwnership } from '../provider/ownership-state' +import { + getProviderForPty, + hasPtyProviderForInspection, + registeredPtyProviders, + sshProviders, + tryGetProviderForPty +} from '../provider/registry' +import { finishPtyShutdown, isPtyAlreadyGoneError } from '../provider/liveness' +import { ptySizes } from '../delivery/visibility-state' +import { isValidPaneKey } from '../pane/key-state' +import { + declarePendingPaneSerializer, + pendingPtyIdBySerializerGeneration, + rendererSerializerReadiness, + settlePendingPaneSerializer +} from '../pane/serializer-state' + +export function installPtyInspectIpcHandlers(deps: { + store?: Store + runtime?: OrcaRuntimeService + getLocalPtyProviderStartupPromise: (connectionId?: string | null) => Promise | undefined + shutdownProviderAndDetectExit: ( + provider: IPtyProvider, + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ) => Promise + rememberSyntheticKillExit: (id: string) => void + sendPtyExitToRenderer: (payload: { id: string; code: number; incarnationId?: string }) => void +}): void { + const ipcMain = getPtyIpc() + const { + store, + runtime, + getLocalPtyProviderStartupPromise, + shutdownProviderAndDetectExit, + rememberSyntheticKillExit, + sendPtyExitToRenderer + } = deps + + ipcMain.handle('pty:kill', async (_event, args: { id: string; keepHistory?: boolean }) => { + if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { + // Why: runtime terminal handles belong to terminal.close; unowned PTY routing could target the local provider. + throw new Error('Invalid PTY provider id') + } + runtime?.markPtyStopRequested?.(args.id) + const ownedConnectionId = ptyOwnership.get(args.id) + const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null + const connectionId = ownedConnectionId ?? parsedSshId?.connectionId + // Why: wait for daemon startup before selecting the local provider, else a fallback shutdown falsely succeeds and orphans a restored daemon PTY (#7742). + const startupPromise = getLocalPtyProviderStartupPromise(connectionId) + if (startupPromise) { + await startupPromise + } + const provider = connectionId ? sshProviders.get(connectionId) : tryGetProviderForPty(args.id) + if (!provider && connectionId) { + // Why: detached SSH PTYs intentionally keep ownership after their + // provider is unregistered; hydrated app-scoped ids can also arrive + // before ownership is rebuilt. Tombstone instead of falling back local. + const incarnationId = finishPtyShutdown(args.id, connectionId, store) + runtime?.markPtyLivenessUnverifiable?.(args.id, SSH_PROVIDER_UNREGISTERED_REASON) + runtime?.onPtyExit(args.id, -1, incarnationId) + rememberSyntheticKillExit(args.id) + sendPtyExitToRenderer({ + id: args.id, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + return + } + const shutdownProvider = provider ?? getProviderForPty(args.id) + let providerExitObserved = false + try { + providerExitObserved = await shutdownProviderAndDetectExit(shutdownProvider, args.id, { + immediate: true, + keepHistory: args.keepHistory ?? false + }) + } catch (err) { + if (!isPtyAlreadyGoneError(err)) { + // Why: a failed shutdown can leave the process alive (SSH relay grace window / local daemon); keep ownership/lease state so the user can retry. + throw err + } + /* session already dead — cleanup below handles the rest */ + } + // Why: some shutdown paths do not emit onExit through the provider listener. + // Explicit cleanup is idempotent and covers already-dead PTYs. + const incarnationId = finishPtyShutdown(args.id, connectionId, store) + if (!providerExitObserved) { + runtime?.onPtyExit(args.id, -1, incarnationId) + rememberSyntheticKillExit(args.id) + sendPtyExitToRenderer({ + id: args.id, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + } + }) + + ipcMain.handle('pty:listSessions', async (): Promise => { + const deduped = new Map() + const admission = new PtyProcessListAdmission() + await visitPtyProcessListingsInBatches( + registeredPtyProviders(), + ({ provider, connectionId }) => + connectionId === null ? provider.listProcesses() : provider.listProcesses().catch(() => []), + ({ provider, connectionId }, sessions) => { + for (const rawSession of sessions) { + const session = admission.admit(rawSession) + // Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider. + ptyOwnership.set(session.id, connectionId) + deduped.set(session.id, { + id: session.id, + cwd: session.cwd, + title: session.title, + // Why: the renderer's binding map is empty during restore, so ownership is the only + // liveness evidence it has. Absence is authoritative only from a provider that + // serializes claims — otherwise it is 'unknown', never 'absent' (#8459). + agentOwnership: + (session.agentSessionOwners?.length ?? 0) > 0 + ? 'present' + : provider.providesAgentSessionOwnerListings?.(session.id) === true + ? 'absent' + : 'unknown' + }) + } + } + ) + return Array.from(deduped.values()) + }) + + ipcMain.handle( + 'pty:getAuthoritativeBufferSnapshotCapabilities', + async (_event, args: { ids?: unknown }) => { + const ids = Array.isArray(args?.ids) ? args.ids.slice(0, 512) : [] + const hasLocalPtyId = ids.some((value) => { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 512 || + value.startsWith('remote:') || + parseAppSshPtyId(value) + ) { + return false + } + const ownedConnectionId = ptyOwnership.get(value) + return ownedConnectionId === undefined || ownedConnectionId === null + }) + if (hasLocalPtyId) { + await getLocalPtyProviderStartupPromise() + } + const capabilities: { id: string; authoritative: boolean | null }[] = [] + const seen = new Set() + for (const value of ids) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 512 || + seen.has(value) + ) { + continue + } + seen.add(value) + const provider = tryGetProviderForPty(value) + // Resolved providers without the optional method are definitively non-authoritative; null remains retryable. + capabilities.push({ + id: value, + authoritative: + provider === undefined || provider === null + ? null + : provider.canProvideAuthoritativeBufferSnapshot + ? provider.canProvideAuthoritativeBufferSnapshot(value) + : false + }) + } + return capabilities + } + ) + + ipcMain.handle('pty:hasPty', async (_event, args: { id: string }): Promise => { + if (typeof args?.id !== 'string' || args.id.startsWith('remote:')) { + // Why: same routing hazard pty:kill guards against — ptyOwnership never holds + // a runtime terminal handle and parseAppSshPtyId ignores it, so the lookup + // falls through to the local provider and its "not in my table" reads as an + // authoritative dead. That is a fabricated answer about another host's PTY. + return null + } + const ownedConnectionId = ptyOwnership.get(args.id) + const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null + const provider = parsedSshId + ? sshProviders.get(parsedSshId.connectionId) + : tryGetProviderForPty(args.id) + if (!provider?.hasPty) { + return null + } + try { + return provider.hasPty(args.id) + } catch { + // Why: liveness is only allowed to close panes on an authoritative false. + return null + } + }) + + ipcMain.handle( + 'pty:hasChildProcesses', + async (_event, args: { id: string }): Promise => { + if (typeof args?.id !== 'string' || !hasPtyProviderForInspection(args.id)) { + return false + } + return getProviderForPty(args.id).hasChildProcesses(args.id) + } + ) + + ipcMain.handle( + 'pty:getForegroundProcess', + async (_event, args: { id: string }): Promise => { + if (typeof args?.id !== 'string' || !hasPtyProviderForInspection(args.id)) { + return null + } + return getProviderForPty(args.id).getForegroundProcess(args.id) + } + ) + + ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) => { + // Why: same routing hazard as pty:hasPty — an unroutable id must read as unavailable, not as a local-provider answer or a raised IPC error. + if ( + typeof args?.id !== 'string' || + !args.id || + args.id.startsWith('remote:') || + !hasPtyProviderForInspection(args.id) + ) { + return { foregroundProcess: null, hasChildProcesses: false, unavailable: true as const } + } + return inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id) + }) + + ipcMain.handle( + 'pty:confirmForegroundProcess', + async (_event, args: { id: string }): Promise => { + if (typeof args?.id !== 'string' || !hasPtyProviderForInspection(args.id)) { + return null + } + const provider = getProviderForPty(args.id) + // Why: the cached foreground API would turn stale process identity into shell/agent authority at a command boundary. + return provider.confirmForegroundProcess?.(args.id) ?? null + } + ) + + // Why: Cmd+D split needs the live shell cwd so the new pane inherits it (not the worktree root); '' means unknown/unresolvable (Windows) → renderer falls through. + ipcMain.handle('pty:getCwd', async (_event, args: { id: string }): Promise => { + try { + return await getProviderForPty(args?.id).getCwd(args.id) + } catch { + return '' + } + }) + + // Why: prefer the provider's APPLIED size over the requested ptySizes so the renderer's resume drift-check can spot a dropped resize; null means "cannot confirm" → re-forward once. + ipcMain.handle( + 'pty:getSize', + async (_event, args: { id: string }): Promise<{ cols: number; rows: number } | null> => { + const provider = tryGetProviderForPty(args?.id) + try { + if (provider?.getAppliedSize) { + // Why: a provider-owned null means it could not verify the applied + // grid; preserve null so the renderer re-forwards instead of trusting + // the requested-size cache that may describe a dropped resize. + return await provider.getAppliedSize(args.id) + } + } catch { + // Fall through to the requested-size cache so a dead daemon/relay can't throw across the IPC boundary. + } + return ptySizes.get(args?.id) ?? null + } + ) + + // Pre-signal handshake handlers (declare→spawn→settle/clear); see docs/mobile-prefer-renderer-scrollback.md and `pendingByPaneKey` above. + ipcMain.handle( + 'pty:declarePendingPaneSerializer', + async (event, args: { paneKey?: unknown }): Promise => { + if (!isValidPaneKey(args?.paneKey)) { + throw new Error('Invalid paneKey') + } + return declarePendingPaneSerializer(args.paneKey, event?.sender) + } + ) + + ipcMain.handle( + 'pty:settlePaneSerializer', + async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { + if (!isValidPaneKey(args?.paneKey) || typeof args.gen !== 'number') { + return + } + const ptyId = pendingPtyIdBySerializerGeneration.get(args.gen) + const settledCurrentGeneration = settlePendingPaneSerializer(args.paneKey, args.gen) + // Why: the generation-to-PTY binding survives late teardown of a reused id; paneKey reverse maps may already be gone. + pendingPtyIdBySerializerGeneration.delete(args.gen) + if (settledCurrentGeneration && ptyId) { + rendererSerializerReadiness.markReady(ptyId) + } + } + ) + + ipcMain.handle( + 'pty:clearPendingPaneSerializer', + async (_event, args: { paneKey?: unknown; gen?: unknown }): Promise => { + if (!isValidPaneKey(args?.paneKey) || typeof args.gen !== 'number') { + return + } + settlePendingPaneSerializer(args.paneKey, args.gen) + pendingPtyIdBySerializerGeneration.delete(args.gen) + } + ) + + ipcMain.handle( + 'pty:reportRendererSerializerReady', + async (_event, args: { ptyId?: unknown }): Promise => { + if ( + typeof args?.ptyId !== 'string' || + !args.ptyId.startsWith('remote:') || + args.ptyId.length > 512 + ) { + return + } + // Why: remote-runtime panes skip the local spawn cooperation gate, so their exact PTY id is the only readiness key. + rendererSerializerReadiness.markReady(args.ptyId) + } + ) +} diff --git a/src/main/ipc/pty/ipc/resize-visibility.ts b/src/main/ipc/pty/ipc/resize-visibility.ts new file mode 100644 index 00000000000..d0c376fabc8 --- /dev/null +++ b/src/main/ipc/pty/ipc/resize-visibility.ts @@ -0,0 +1,339 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import type { + PtyDeliveryWriteOff, + PtyRendererDeliveryHealthReply, + PtyRendererDeliveryStateReport +} from '../../../../shared/pty-renderer-delivery-health' +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' +import { setTerminalViewAttributes } from '../../../runtime/terminal-view-attribute-store' +import { validateTerminalViewAttributes } from '../../../../shared/terminal-view-attributes' +import { + recordHiddenRendererPtyDataDrop, + setRendererPtyDeliveryInterest, + shouldDropHiddenRendererPtyData +} from '../../pty-hidden-delivery-gate' +import { tryGetProviderForPty, closeStartupQueryAuthorityForPty } from '../provider/registry' +import { + activeRendererPtys, + deliveredHiddenRendererResizeOutputPtys, + invalidatePendingPtyDrainPolicy, + invalidatePendingPtyDrainPriority, + pendingHiddenRendererResizeOutputPtys, + ptySizes, + rendererVisibilityKnownPtys, + visibleRendererPtys +} from '../delivery/visibility-state' +import { + mainDeliveryBreadcrumbs, + resetRendererDeliveryAccountingForLifecycleReset +} from '../delivery/debug' +import { PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS } from '../delivery/constants' +import { applyCumulativeAck } from '../delivery/accounting' +import { sendModelRestoreNeededMarker } from '../delivery/payload' +import { isMainWindowPtyIpcEvent } from './write-input' +import type { PtyIpcSession } from '../session' + +export function installPtyResizeVisibilityIpc(session: PtyIpcSession): void { + const ipcMain = getPtyIpc() + const { runtime, mainWindow } = session + + // Why: resize is fire-and-forget — ipcMain.on (not .handle) halves IPC traffic by skipping the empty acknowledgement reply. + ipcMain.removeAllListeners('pty:resize') + ipcMain.on('pty:resize', (_event, args: { id: string; cols: number; rows: number }) => { + // Why: after a desktop-fit override change the renderer's safeFit cascade re-measures ALL panes (background ones at full width), so suppress every pty:resize in this window to avoid corrupting PTY dimensions. + if (runtime?.isResizeSuppressed()) { + return + } + // Why: presence-lock defense-in-depth — while a phone or remote-desktop viewer drives the width, host-side resizes must not reach the PTY or its alt-screen grid garbles; load-bearing because the renderer mirror lags one IPC hop. See docs/mobile-presence-lock.md. + const mobileOwnsResize = runtime?.getDriver(args.id).kind === 'mobile' + const remoteDesktopOwnsResize = runtime?.isRemoteDesktopResizeDriven?.(args.id) === true + if (mobileOwnsResize || remoteDesktopOwnsResize) { + if (remoteDesktopOwnsResize) { + runtime?.recordRemoteDesktopHostReclaimTarget(args.id, args.cols, args.rows) + } + return + } + const provider = tryGetProviderForPty(args.id) + if (!provider) { + return + } + const markedHiddenResizeOutput = session.rendererPtyIsKnownHidden(args.id) + if (markedHiddenResizeOutput) { + // Why: alt-screen TUIs repaint on SIGWINCH; a hidden repaint read after switch-back must not masquerade as live output and overwrite the correctly-sized screen. + pendingHiddenRendererResizeOutputPtys.add(args.id) + deliveredHiddenRendererResizeOutputPtys.delete(args.id) + } else if (visibleRendererPtys.has(args.id)) { + // Why: after the stale hidden-resize repaint is observed, the renderer's visible resize pulse owns the next repaint. + session.clearDeliveredHiddenRendererResizeOutput(args.id) + } + try { + provider.resize(args.id, args.cols, args.rows) + } catch { + if (markedHiddenResizeOutput) { + pendingHiddenRendererResizeOutputPtys.delete(args.id) + } + return + } + ptySizes.set(args.id, { cols: args.cols, rows: args.rows }) + runtime?.onExternalPtyResize(args.id, args.cols, args.rows) + }) + + // Why: pty:reportGeometry is a measurement-only sibling of pty:resize — it refreshes the restore-target cache (never resizes) so mobile-fit hold learns real desktop dims even while resize is blocked. See docs/mobile-fit-hold.md. + ipcMain.removeAllListeners('pty:reportGeometry') + ipcMain.on('pty:reportGeometry', (_event, args: { id: string; cols: number; rows: number }) => { + runtime?.recordRendererGeometry(args.id, args.cols, args.rows) + }) + + // Why: fire-and-forget — clears the DaemonPtyAdapter's sticky cold-restore cache after the renderer consumed it; no-op for non-daemon providers. + ipcMain.removeAllListeners('pty:ackColdRestore') + ipcMain.on('pty:ackColdRestore', (_event, args: { id: string }) => { + const provider = tryGetProviderForPty(args.id) + if (provider && 'ackColdRestore' in provider && typeof provider.ackColdRestore === 'function') { + provider.ackColdRestore(args.id) + } + }) + + // Why: renderer ACKs bound main→renderer delivery without stopping PTY ingestion — agent/status consumers still see every chunk via the provider/runtime path. + ipcMain.removeAllListeners('pty:ackData') + ipcMain.on( + 'pty:ackData', + (_event, args: { id: string; charCount?: number; processedChars?: number }) => { + session.lastAckReceivedAtMs = Date.now() + // Why: a live ACK channel means a future unanswered probe is a fresh diagnostic event, not a continuation of the last silent streak. + session.deliveryResyncUnansweredWarnLogged = false + let acknowledged = 0 + if (typeof args.processedChars === 'number' && Number.isFinite(args.processedChars)) { + acknowledged = applyCumulativeAck(session, args.id, Math.max(0, args.processedChars)) + } else { + // Why: tolerate legacy per-chunk delta payloads — dev hot-reload can pair an old renderer with a new main. + const accounting = session.rendererDeliveryAccountingByPty.get(args.id) + const delta = Number.isFinite(args.charCount) ? Math.max(0, args.charCount ?? 0) : 0 + acknowledged = accounting + ? applyCumulativeAck(session, args.id, accounting.ackedChars + delta) + : 0 + } + tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged) + session.schedulePendingDataAfterCreditReport(acknowledged > 0) + } + ) + + ipcMain.removeAllListeners('pty:deliveryResyncResponse') + ipcMain.on( + 'pty:deliveryResyncResponse', + (_event, args: { requestId: number; processedCharsByPty: Record }) => { + if ( + session.deliveryResyncOutstandingRequestId === null || + args?.requestId !== session.deliveryResyncOutstandingRequestId + ) { + return + } + session.clearDeliveryResyncProbe() + session.deliveryResyncUnansweredWarnLogged = false + // Why max-merge: the renderer's cumulative totals are authoritative for what it processed, draining exactly the in-flight debt from lost ACKs. + let creditedAny = false + for (const [id, processedChars] of Object.entries(args.processedCharsByPty ?? {})) { + if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { + continue + } + const acknowledged = applyCumulativeAck(session, id, Math.max(0, processedChars)) + if (acknowledged > 0) { + creditedAny = true + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + } + } + session.schedulePendingDataAfterCreditReport(creditedAny) + } + ) + + // Why invoke + renderer-initiated: the field wedge (v1.4.121-rc.0) kills every main→renderer push channel while invoke survives, so the resync rides here plus a write-off lane. + ipcMain.removeHandler('pty:reportRendererDeliveryState') + ipcMain.handle( + 'pty:reportRendererDeliveryState', + (_event, args: PtyRendererDeliveryStateReport): PtyRendererDeliveryHealthReply => { + // Extra repair lane for the lost-ACK variant: identical max-merge to the resync response, so a heal is only reached when merging cannot drain. + let creditedAny = false + for (const [id, processedChars] of Object.entries(args?.processedCharsByPty ?? {})) { + if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { + continue + } + const acknowledged = applyCumulativeAck(session, id, Math.max(0, processedChars)) + if (acknowledged > 0) { + creditedAny = true + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + } + } + let writtenOff: PtyDeliveryWriteOff[] = [] + // Why the main-side ACK-silence check: requiring main to have also seen no ACK stops a buggy/foreign caller from writing off live delivery. + if ( + args?.heal === true && + session.rendererInFlightTotalChars > 0 && + (session.lastAckReceivedAtMs === null || + Date.now() - session.lastAckReceivedAtMs >= PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS) + ) { + writtenOff = session.writeOffLostRendererDelivery(args) + creditedAny ||= writtenOff.length > 0 + } + session.schedulePendingDataAfterCreditReport(creditedAny) + let inFlightPtyCount = 0 + for (const accounting of session.rendererDeliveryAccountingByPty.values()) { + if (accounting.sentChars - accounting.ackedChars > 0) { + inFlightPtyCount++ + } + } + return { + inFlightTotalChars: session.rendererInFlightTotalChars, + inFlightPtyCount, + msSinceLastAck: + session.lastAckReceivedAtMs === null ? null : Date.now() - session.lastAckReceivedAtMs, + ...(writtenOff.length > 0 ? { writtenOff } : {}) + } + } + ) + + // Why: renderer signals its pty:data listener is live; until then sends are held so boot-window bytes can't drop into a listener-less page and pin the gate. + ipcMain.removeAllListeners('pty:rendererDispatcherReady') + ipcMain.on('pty:rendererDispatcherReady', (event) => { + // Why: the reconcile below destructively clears delivery accounting, so a straggler handshake from a dying window must not reset the new window. + if (!isMainWindowPtyIpcEvent(event, mainWindow, mainWindow.webContents)) { + return + } + // Why: a handshake while the gate is already open means a page load whose lifecycle reset was missed; clear the dead page's stale accounting so it can't permanently gate survivors. + if (session.rendererPtyDispatcherReady) { + resetRendererDeliveryAccountingForLifecycleReset() + } + // Why: real handshake landed — cancel the self-heal watchdog so it can't later force-open the gate. + session.clearDispatcherReadyWatchdog() + session.rendererPtyDispatcherReady = true + session.pendingData.reactivateBlocked() + session.schedulePendingDataFlush(0) + }) + + ipcMain.removeAllListeners('pty:setActiveRendererPty') + ipcMain.on('pty:setActiveRendererPty', (_event, args: { id: string; active: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + // Why: renderer scheduling hint only — active panes just get first chance at the bounded output reserve; reads/state/notifications continue for inactive terminals. + if (args.active) { + if (activeRendererPtys.has(args.id)) { + return + } + activeRendererPtys.add(args.id) + } else if (!activeRendererPtys.delete(args.id)) { + return + } + invalidatePendingPtyDrainPriority(args.id) + }) + + ipcMain.removeAllListeners('pty:setRendererPtyVisible') + ipcMain.on('pty:setRendererPtyVisible', (_event, args: { id: string; visible: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + // Why: data produced while no renderer can see this PTY must keep that origin through batching, even if the user switches back before the flush lands. + rendererVisibilityKnownPtys.add(args.id) + if (args.visible) { + visibleRendererPtys.add(args.id) + closeStartupQueryAuthorityForPty(args.id) + } else { + visibleRendererPtys.delete(args.id) + } + session.syncPtyBackgroundedDelivery(args.id, 'visibility-report') + }) + + ipcMain.removeAllListeners('pty:setHiddenRendererPty') + ipcMain.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + mainDeliveryBreadcrumbs.record(args.hidden === true ? 'gate-mark' : 'gate-unmark', { + id: redactPtyIdForDiagnostics(args.id) + }) + const transition = session.transitionHiddenRendererPtyDeliveryState( + args.id, + args.hidden === true + ) + if (args.hidden === true) { + closeStartupQueryAuthorityForPty(args.id) + // Why: drop bytes queued for a newly hidden PTY instead of holding them under ACK starvation; reveal restores from the snapshot. + const pending = session.pendingData.get(args.id) + if (pending && transition.droppable) { + session.pendingData.delete(args.id) + if (pending.projectionAdmissionIds) { + session.sshOutputIntake?.transferProjections( + pending.projectionAdmissionIds, + 'hidden-drop' + ) + } + session.updateProducerFlowControl(args.id) + session.pendingOverflowMarkedPtys.delete(args.id) + const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker( + session, + args.id, + 'hidden-drop', + runtime?.getPtyOutputSequence(args.id) + ) + } + } + if (transition.policyChanged) { + invalidatePendingPtyDrainPolicy(args.id) + } + session.syncPtyBackgroundedDelivery(args.id, 'gate-mark') + return + } + if (transition.policyChanged) { + invalidatePendingPtyDrainPolicy(args.id) + } + session.syncPtyBackgroundedDelivery(args.id, 'gate-unmark') + // Why: a reload/remount may have replaced the view that latched restore-needed, so re-emit on unhide; a redundant replay is cheap/idempotent, a missed restore corrupts the pane. + if (transition.droppedWhileHidden) { + sendModelRestoreNeededMarker( + session, + args.id, + 'unhide', + runtime?.getPtyOutputSequence(args.id) + ) + } + }) + + ipcMain.removeAllListeners('pty:terminalViewAttributes') + ipcMain.on('pty:terminalViewAttributes', (_event, args: unknown) => { + // Why validate-or-drop: a malformed palette gives a wrong color reply that breaks TUI theme detection worse than the silent-until-first-push default. + const attributes = validateTerminalViewAttributes(args) + if (attributes) { + setTerminalViewAttributes(attributes) + } + }) + + ipcMain.removeAllListeners('pty:setPtyDeliveryInterest') + ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + // Why: any delivery interest suppresses the hidden-delivery gate (raw-byte consumers keep receiving while hidden); not synced to the daemon pacer so interest churn can't un-pace a flood. + const settings = session.getSettings?.() + const wasDroppable = shouldDropHiddenRendererPtyData(args.id, settings) + setRendererPtyDeliveryInterest(args.id, args.interested === true) + if (wasDroppable !== shouldDropHiddenRendererPtyData(args.id, settings)) { + invalidatePendingPtyDrainPolicy(args.id) + } + }) + + ipcMain.removeAllListeners('pty:signal') + ipcMain.on('pty:signal', (_event, args: { id: string; signal: string }) => { + tryGetProviderForPty(args.id) + ?.sendSignal(args.id, args.signal) + .catch(() => {}) + }) + + ipcMain.removeAllListeners('pty:clearBuffer') + ipcMain.on('pty:clearBuffer', (_event, args: { id: string }) => { + // Why: clear PTY-side state (ConPTY/daemon/SSH buffer) so the next prompt repaint doesn't land at a stale cursor row. + tryGetProviderForPty(args.id) + ?.clearBuffer(args.id) + .catch(() => {}) + runtime?.clearHeadlessTerminalBuffer(args.id).catch(() => {}) + }) +} diff --git a/src/main/ipc/pty/ipc/serialize-buffer.ts b/src/main/ipc/pty/ipc/serialize-buffer.ts new file mode 100644 index 00000000000..10011c19aae --- /dev/null +++ b/src/main/ipc/pty/ipc/serialize-buffer.ts @@ -0,0 +1,111 @@ +import { randomUUID } from 'node:crypto' +import { getPtyIpc } from '../../pty-host-bindings' +import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal-kitty-keyboard-flags' +import { isMainWindowPtyIpcEvent } from './write-input' +import type { PtyIpcSession, SerializeResult } from '../session' + +export function settleSerializeRequest( + session: PtyIpcSession, + requestId: string, + result: SerializeResult +): void { + const pending = session.pendingSerializeRequests.get(requestId) + if (!pending) { + return + } + clearTimeout(pending.timeout) + session.pendingSerializeRequests.delete(requestId) + pending.resolve(result) +} + +export function installPtySerializeBufferIpc(session: PtyIpcSession): void { + const ipcMain = getPtyIpc() + // Why: one persistent listener with a request-ID dispatch table instead of one per call, so concurrent serialize requests don't trip Node's MaxListeners=10 warning. + ipcMain.on( + 'pty:serializeBuffer:response', + ( + event, + args: { + requestId?: string + snapshot?: { + data?: unknown + cols?: unknown + rows?: unknown + seq?: unknown + lastTitle?: unknown + kittyKeyboardFlags?: unknown + } | null + } + ) => { + // Why: the snapshot seeds terminal restore state, so only the main window may settle it. + if ( + !isMainWindowPtyIpcEvent(event, session.mainWindow, session.mainWindow.webContents) || + typeof args?.requestId !== 'string' + ) { + return + } + const snapshot = args.snapshot + if ( + snapshot && + typeof snapshot.data === 'string' && + typeof snapshot.cols === 'number' && + typeof snapshot.rows === 'number' + ) { + const result: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + kittyKeyboardFlags?: number + } = { + data: snapshot.data, + cols: snapshot.cols, + rows: snapshot.rows + } + if (typeof snapshot.seq === 'number' && Number.isFinite(snapshot.seq)) { + result.seq = snapshot.seq + } + if (typeof snapshot.lastTitle === 'string' && snapshot.lastTitle.length > 0) { + result.lastTitle = snapshot.lastTitle + } + // Why gated on seq: without a boundary the flags cannot be reconciled + // against live bytes, so they prove nothing. + const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.kittyKeyboardFlags) + if (result.seq !== undefined && kittyKeyboardFlags !== undefined) { + result.kittyKeyboardFlags = kittyKeyboardFlags + } + settleSerializeRequest(session, args.requestId, result) + } else { + settleSerializeRequest(session, args.requestId, null) + } + } + ) +} + +export function requestSerializedBuffer( + session: PtyIpcSession, + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } +): Promise { + if (session.mainWindow.isDestroyed()) { + return Promise.resolve(null) + } + + const requestId = randomUUID() + return new Promise((resolve) => { + const timeout = setTimeout(() => { + settleSerializeRequest(session, requestId, null) + }, 750) + session.pendingSerializeRequests.set(requestId, { resolve, timeout }) + const payload: { + requestId: string + ptyId: string + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + } = { requestId, ptyId } + if (opts) { + payload.opts = opts + } + session.mainWindow.webContents.send('pty:serializeBuffer:request', payload) + }) +} diff --git a/src/main/ipc/pty/ipc/snapshot.ts b/src/main/ipc/pty/ipc/snapshot.ts new file mode 100644 index 00000000000..607b3bfe781 --- /dev/null +++ b/src/main/ipc/pty/ipc/snapshot.ts @@ -0,0 +1,105 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { tryGetProviderForPty } from '../provider/registry' +import { providerSnapshotRequiredPtys } from '../delivery/visibility-state' +import type { PtyPendingDataDrainQueue } from '../../pty-pending-data-drain-queue' +import { + getPtyRendererDeliveryDebugSnapshot, + installPowerSignalBreadcrumbs, + resetPtyRendererDeliveryDebug, + type PtyRendererDeliveryDebugSnapshot +} from '../delivery/debug' + +function normalizeSnapshotScrollbackRows(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined + } + return Math.max(0, Math.min(50_000, Math.floor(value))) +} + +export function installPtySnapshotIpcHandlers(deps: { + runtime?: OrcaRuntimeService + pendingData: PtyPendingDataDrainQueue +}): void { + const ipcMain = getPtyIpc() + const { runtime, pendingData } = deps + + ipcMain.handle( + 'pty:getMainBufferSnapshot', + async ( + _event, + args: { id?: unknown; opts?: { scrollbackRows?: unknown } } + ): Promise<{ + data: string + frameRestoreAnsi?: string + cols: number + rows: number + cwd?: string | null + lastTitle?: string + seq?: number + pendingDeliveryStartSeq?: number + source?: 'headless' | 'renderer' + alternateScreen?: boolean + scrollbackAnsi?: string + pendingEscapeTailAnsi?: string + kittyKeyboardFlags?: number + } | null> => { + if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { + return null + } + const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows) + try { + const runtimeSeqBeforeSnapshot = runtime.getPtyOutputSequence(args.id) + const providerSnapshotRequired = providerSnapshotRequiredPtys.has(args.id) + const providerSnapshot = providerSnapshotRequired + ? await tryGetProviderForPty(args.id)?.getBufferSnapshot?.(args.id, { + scrollbackRows + }) + : null + // Why: after a data gap main holds only the retained tail; returning it as a full snapshot would erase older scrollback. + if (providerSnapshotRequired && !providerSnapshot) { + return null + } + const snapshot = + providerSnapshot ?? + (await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { + scrollbackRows + })) + if (!snapshot || typeof snapshot.seq !== 'number') { + return snapshot + } + // Why: the renderer's post-restore dedupe needs this pending-queue bound, or a stale baseline swallows new chunks whose seq sits below the snapshot counter. + const pending = pendingData.get(args.id) + if (pending && typeof pending.startSeq !== 'number') { + // Why: a seq-less backlog cannot be bounded — stay conservative. + return snapshot + } + return { + ...snapshot, + pendingDeliveryStartSeq: Math.min( + pending?.startSeq ?? (providerSnapshot ? runtimeSeqBeforeSnapshot : snapshot.seq), + snapshot.seq + ) + } + } catch { + return null + } + } + ) + + // Why: main owns side effects, so this replay restores title state only — never historical bells/completions (no-attention-replay rule, terminal-side-effect-authority.md). + ipcMain.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => { + if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { + return null + } + return runtime.getTerminalSideEffectSnapshot(args.id) + }) + + installPowerSignalBreadcrumbs() + ipcMain.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => { + return getPtyRendererDeliveryDebugSnapshot() + }) + ipcMain.handle('pty:resetRendererDeliveryDebug', (): void => { + resetPtyRendererDeliveryDebug() + }) +} diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts new file mode 100644 index 00000000000..0b59d347e4d --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -0,0 +1,122 @@ +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import type { PtySpawnResult } from '../../../providers/types' +import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' +import { createPtySpawnTiming } from '../../pty-spawn-timing' +import { allocatePtyLifecycleSequence } from '../host-env/types' +import { snapshotCodexPaneHomeRoutes } from '../host-env/codex-home' +import { getAppPtyId } from '../provider/registry' +import { + makePaneSpawnReservationKey, + reservePaneSpawn, + paneSpawnReservationsByOwnerKey, + pendingRuntimePaneCreatesByOwnerKey +} from '../pane/spawn-reservation' +import { resolveStablePaneOwner } from '../pane/stable-owner' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function beginPtyIpcSpawn( + ctx: PtyIpcSpawnState +): Promise { + const args = ctx.args + ctx.codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined + ctx.codexHomeLaunchStartedSequence = !args.connectionId + ? allocatePtyLifecycleSequence() + : undefined + const initialLeafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + const initialPaneKey = + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + initialLeafId + ? makePaneKey(args.tabId, initialLeafId) + : null + const initialStablePanePtyId = (() => { + try { + return !args.connectionId && initialPaneKey + ? resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + initialPaneKey, + args.worktreeId, + args.connectionId + )?.ptyId + : undefined + } catch { + return undefined + } + })() + ctx.reattachedCodexHomeRoutes = !args.connectionId + ? new Map( + snapshotCodexPaneHomeRoutes([ + initialStablePanePtyId, + args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined + ]) + ) + : new Map() + ctx.spawnTiming = createPtySpawnTiming() + ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) + + const earlyLeafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + const earlyPaneKey = + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + earlyLeafId + ? makePaneKey(args.tabId, earlyLeafId) + : null + const earlyReservationKey = makePaneSpawnReservationKey( + args.worktreeId, + args.connectionId, + earlyPaneKey + ) + const pendingRuntimeCreate = earlyReservationKey + ? pendingRuntimePaneCreatesByOwnerKey.get(earlyReservationKey) + : undefined + if (pendingRuntimeCreate) { + await pendingRuntimeCreate.promise + } + const existingPaneSpawn = earlyReservationKey + ? paneSpawnReservationsByOwnerKey.get(earlyReservationKey) + : undefined + if (existingPaneSpawn) { + return { ...(await existingPaneSpawn.promise), isReattach: true } + } + ctx.earlyStablePaneOwner = + earlyPaneKey && args.worktreeId + ? resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + earlyPaneKey, + args.worktreeId, + args.connectionId + ) + : null + ctx.earlyWorktreeId = args.worktreeId + // Reserve early so renderer/runtime materialization cannot start duplicate provider spawns. + ctx.paneSpawnReservationKey = earlyReservationKey + ctx.paneSpawnReservation = ctx.paneSpawnReservationKey + ? reservePaneSpawn(ctx.paneSpawnReservationKey) + : null + ctx.finishTerminalInstall = (): void => {} + ctx.stablePaneOwner = null + ctx.stablePaneBindingPersisted = false + ctx.rejectedRegistrationCandidate = null + ctx.pendingRegistrationPtyId = null + // Why hoisted to the reply scope: main reconciles the provider sequence + // deep inside the spawn path, but the pane needs that renderer-domain + // boundary beside the daemon snapshot's kitty flags. + ctx.reconciledSnapshotSeq = null + // False when bytes crossed the data socket during the spawn RPC: the + // reconciled boundary covers them, but the daemon proved its kitty flags + // before they existed, so the claim must not erase what the pane may + // have scanned from those bytes live. + ctx.snapshotKittyFlagsCoverReconciledSeq = true + ctx.preparedProvisionalExecutionContext = false + + return null +} diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts new file mode 100644 index 00000000000..be9cb31394a --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -0,0 +1,154 @@ +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' +import { closeStartupQueryAuthorityForPty, getRelayPtyId } from '../provider/registry' +import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' +import { recordCodexPaneAccountForSpawn } from '../host-env/codex-home' +import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' +import { + pendingByPaneKey, + pendingPtyIdBySerializerGeneration, + rendererSerializerReadiness +} from '../pane/serializer-state' +import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' +import { ptySizes } from '../delivery/visibility-state' +import { clearProviderPtyState } from '../provider/state-cleanup' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ + rendererPreSignaled: boolean + rendererAlreadyRegistered: boolean +}> { + const args = ctx.args + try { + ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + store: ctx.deps.store, + owner: ctx.stablePaneOwner, + result: ctx.result, + worktreeId: args.worktreeId, + startupCwd: ctx.cwd, + connectionId: args.connectionId + }) + } catch (error) { + if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { + throw error + } + console.error('[pty] failed to persist PTY binding after attach:', error) + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } + ctx.spawnTiming.log(ctx.result.id, { + daemon: ctx.isDaemonHostSpawn, + reattach: ctx.result.isReattach ?? false + }) + recordCodexPaneAccountForSpawn({ + ptyId: ctx.result.id, + isDaemonHostSpawn: ctx.isDaemonHostSpawn, + isReattach: ctx.result.isReattach === true, + pinnedByResume: ctx.codexResumeHomeSelected, + launchCodexHomePath: ctx.selectedCodexHomePath, + launchEnv: ctx.baseEnv, + target: ctx.codexSelectionTarget, + settings: ctx.deps.getSettings?.() + }) + ptyOwnership.set(ctx.result.id, args.connectionId ?? null) + if (ctx.result.incarnationId) { + ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) + } + if (ctx.initiallyHidden) { + // Why marked synchronously here: provider data events dispatch on later tasks, so this still lands ahead of the first byte's delivery decision (idempotent if already marked pre-spawn). + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.result.id, true) + if (ctx.preSpawnHiddenMarkId !== null && ctx.preSpawnHiddenMarkId !== ctx.result.id) { + // Defense: never strand a mark on an id the provider renamed. + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) + } + // Why after ptyOwnership.set: provider lookup routes by ownership, and a hidden-spawned agent should be paceable from its first flood. + ctx.deps.syncPtyBackgroundedDelivery(ctx.result.id, 'spawn') + closeStartupQueryAuthorityForPty(ctx.result.id) + } + // Why: record the native-Windows-ConPTY determination before the headless seed so the emulator's DA1 override exists from byte zero. + if (ctx.nativeWindowsConptySpawn) { + markNativeWindowsConptyPty(ctx.result.id) + } + const relayResultId = getRelayPtyId(args.connectionId, ctx.result.id) + if (ctx.deps.store && args.connectionId) { + // Why: remote PTYs live in the SSH relay grace window after Orca detaches; persist IDs immediately so reconnect reattaches instead of spawning a fresh shell. + ctx.deps.store.upsertSshRemotePtyLease({ + targetId: args.connectionId, + ptyId: relayResultId, + ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), + ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), + ...(ctx.validatedLeafId ? { leafId: ctx.validatedLeafId } : {}), + state: 'attached', + lastAttachedAt: Date.now() + }) + } + if (ctx.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { + if (ctx.deps.runtime?.registerPreAllocatedHandleForPty) { + ctx.deps.runtime.registerPreAllocatedHandleForPty(ctx.result.id, ctx.preAllocatedHandle) + ctx.agentTeamsLeaderHandle = null + } + } + ptySizes.set(ctx.result.id, { cols: args.cols, rows: args.rows }) + if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { + ptySizes.delete(ctx.effectiveSessionAppId) + } + // Why: patch the load-bearing ptyId binding synchronously so a force-quit in the renderer's ~450 ms debounce window can't orphan daemon history or an SSH relay lease (Issue #217). + if ( + ctx.deps.store && + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + ctx.validatedLeafId !== null && + !ctx.stablePaneBindingPersisted + ) { + try { + const binding = { + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: ctx.validatedLeafId, + ptyId: ctx.result.id, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}) + } + if (args.connectionId) { + ctx.deps.store.persistPtyBinding(binding, toSshExecutionHostId(args.connectionId)) + } else { + ctx.deps.store.persistPtyBinding(binding) + } + } catch (err) { + console.error('[pty] failed to persist PTY binding after spawn:', err) + if (!ctx.result.isReattach) { + try { + await ctx.provider.shutdown(ctx.result.id, { immediate: true }) + } catch (shutdownErr) { + console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) + } + clearProviderPtyState(ctx.result.id) + deletePtyOwnership(ctx.result.id) + } + if (!ctx.result.isReattach && args.connectionId && ctx.deps.store) { + ctx.deps.store.removeSshRemotePtyLease(args.connectionId, relayResultId) + } + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } + } + // Why: when the renderer has declared it will own the serializer for this paneKey, suppress the daemon-snapshot seed so its hydration path is sole authority (keyed on paneKey since the ptyId isn't known yet). See docs/mobile-prefer-renderer-scrollback.md. + const rendererPreSignaled = ctx.validatedPaneKey + ? pendingByPaneKey.has(ctx.validatedPaneKey) + : false + const rendererAlreadyRegistered = + ctx.result.isReattach === true && + !rendererPreSignaled && + rendererSerializerReadiness.has(ctx.result.id) + rendererSerializerReadiness.beginIncarnation(ctx.result.id, rendererAlreadyRegistered) + // Why: capture the pending gen at spawn time so this PTY's teardown only settles its own generation, not a remount that replaced the entry. + if (ctx.validatedPaneKey && rendererPreSignaled) { + const pending = pendingByPaneKey.get(ctx.validatedPaneKey) + if (pending) { + pendingPtyIdBySerializerGeneration.set(pending.gen, ctx.result.id) + } + } + return { rendererPreSignaled, rendererAlreadyRegistered } +} diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts new file mode 100644 index 00000000000..7e277c7c3f5 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -0,0 +1,233 @@ +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { agentHookServer } from '../../../agent-hooks/server' +import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' +import { registerPty } from '../../../memory/pty-registry' +import type { PtySpawnResult } from '../../../providers/types' +import { clearMigrationUnsupportedPtysForPaneKey } from '../../../agent-hooks/migration-unsupported-pty-state' +import { track } from '../../../telemetry/client' +import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' +import { + agentKindSchema, + launchSourceSchema, + requestKindSchema +} from '../../../../shared/telemetry-events' +import { + shouldSkipCodexHomeEnvForWindowsShell, + codexReattachedHomeRouteField +} from '../host-env/codex-home' +import { rememberPaneKeyForPty } from '../pane/key-state' +import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' +import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' +import { admitRendererAgentLaunchAuthority } from '../pane/launch-authority' +import type { PtyIpcSpawnState } from './spawn-state' +import { persistPtyIpcSpawnCommit } from './spawn-commit-persist' + +export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + const args = ctx.args + const { rendererPreSignaled, rendererAlreadyRegistered } = await persistPtyIpcSpawnCommit(ctx) + + // Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback). + // Skip when the renderer will be authoritative — its xterm buffer is richer than the daemon snapshot. + if (ctx.deps.runtime && !rendererPreSignaled && !rendererAlreadyRegistered) { + const snapshotSeedSize = + typeof ctx.result.snapshotCols === 'number' && typeof ctx.result.snapshotRows === 'number' + ? { cols: ctx.result.snapshotCols, rows: ctx.result.snapshotRows } + : undefined + if (typeof ctx.result.snapshot === 'string' && ctx.result.snapshot.length > 0) { + // Why kitty flags ride seed metadata: the snapshot omits them, but the re-seeded emulator must answer hidden `CSI ? u` with the running app's flags (terminal-query-authority.md). + ctx.deps.runtime.seedHeadlessTerminal( + ctx.result.id, + ctx.result.snapshot, + snapshotSeedSize, + typeof ctx.result.snapshotKittyKeyboardFlags === 'number' + ? { kittyKeyboardFlags: ctx.result.snapshotKittyKeyboardFlags } + : {} + ) + } else if ( + ctx.result.coldRestore && + typeof ctx.result.coldRestore.scrollback === 'string' && + ctx.result.coldRestore.scrollback.length > 0 + ) { + const coldRestoreSeedSize = + typeof ctx.result.coldRestore.cols === 'number' && + typeof ctx.result.coldRestore.rows === 'number' + ? { cols: ctx.result.coldRestore.cols, rows: ctx.result.coldRestore.rows } + : undefined + ctx.deps.runtime.seedHeadlessTerminal( + ctx.result.id, + ctx.result.coldRestore.scrollback, + coldRestoreSeedSize, + { + cwd: ctx.result.coldRestore.cwd, + oscLinks: ctx.result.coldRestore.oscLinks, + preferProviderIfExisting: true + } + ) + } else if (typeof ctx.result.replay === 'string' && ctx.result.replay.length > 0) { + // Why: relay reattach replay is the only restore main never ingests; skip this seed and park-reveal would replace it with a suffix fragment. + ctx.deps.runtime.seedHeadlessTerminal(ctx.result.id, ctx.result.replay) + } + } + if ( + typeof args.worktreeId === 'string' && + args.worktreeId.length > 0 && + args.worktreeId.length <= 512 + ) { + const agentLaunchAuthority = admitRendererAgentLaunchAuthority({ + launchToken: args.launchToken, + spawnEnv: ctx.spawnEnv, + launchAgent: args.launchAgent, + launchConfig: ctx.effectiveLaunchConfig, + isReattach: ctx.result.isReattach === true, + hasStablePaneOwner: ctx.stablePaneOwner !== null, + incarnationId: ctx.result.incarnationId + }) + ctx.deps.runtime?.registerPty( + ctx.result.id, + args.worktreeId, + args.connectionId ?? null, + // Why: pass validated pane identity so a throttled mobile create publishes its surface main-side instead of destroying the live PTY (#7587); bound the untrusted tabId. + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + ctx.metadataLeafId !== null + ? { + tabId: args.tabId, + leafId: ctx.metadataLeafId, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(agentLaunchAuthority ? { agentLaunchAuthority } : {}) + } + : undefined, + !args.connectionId + ? shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd) + : undefined + ) + ctx.pendingRegistrationPtyId = null + } else if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.( + ctx.pendingRegistrationPtyId, + ctx.result.incarnationId + ) + ctx.pendingRegistrationPtyId = null + } + // Why: seed after registerPty binds the worktree — including on + // desktop, where the renderer-authority gate above skips the emulator + // seed but the list/read records still live main-side. + seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) + // Why: arm main's per-PTY Command Code output detector from the launch command (startupCommand parity); banner detection covers PTYs without one. + if (!ctx.stablePaneOwner) { + ctx.deps.runtime?.noteTerminalSpawnCommand?.( + ctx.result.id, + typeof ctx.launchCommand === 'string' ? ctx.launchCommand : null + ) + } + if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { + markClaudePtySpawned(ctx.result.id) + } + // Why: record the paneKey mapping so clearProviderPtyState can clear the agent-hooks server's per-paneKey caches on exit. + // Why: args.env is untrusted IPC JSON (type unenforced); bound the paneKey so malformed/oversized values can't pollute ptyPaneKey or clearPaneState. + const rememberedPaneKey = ctx.validatedPaneKey + ? rememberPaneKeyForPty(ctx.result.id, ctx.validatedPaneKey) + : null + if (ctx.legacySpawnPaneKey && ctx.migrationUnsupportedPaneKey) { + agentHookServer.registerPaneKeyAlias( + ctx.legacySpawnPaneKey.paneKey, + ctx.migrationUnsupportedPaneKey, + ctx.result.id, + Date.now(), + { authorityVerified: true } + ) + clearMigrationUnsupportedPtysForPaneKey(ctx.migrationUnsupportedPaneKey) + } else if (ctx.validatedPaneKey) { + if (!ctx.result.isReattach) { + clearMigrationUnsupportedPtysForPaneKey(ctx.validatedPaneKey) + } + } + // Why: register only local PTYs with the memory collector — SSH PTYs run remotely and their process tree is invisible to our local `ps`. + if (!args.connectionId) { + // Why: record the spawn-result pid once here so the memory module needn't reach back into ipc/pty on a hot path (works for in-process and daemon-hosted PTYs). + const spawnedPid = ctx.result.pid ?? null + // Why: args.worktreeId/sessionId arrive as untrusted IPC strings (type unenforced at the boundary); bound them so malformed/oversized values can't pollute registerPty's maps. + registerPty({ + ptyId: ctx.result.id, + worktreeId: + typeof args.worktreeId === 'string' && + args.worktreeId.length > 0 && + args.worktreeId.length <= 512 + ? args.worktreeId + : null, + sessionId: + typeof args.sessionId === 'string' && + args.sessionId.length > 0 && + args.sessionId.length <= 256 + ? args.sessionId + : null, + paneKey: rememberedPaneKey, + pid: + typeof spawnedPid === 'number' && Number.isFinite(spawnedPid) && spawnedPid > 0 + ? spawnedPid + : null + }) + } + // Why: telemetry-plan.md§Agent launch semantics — fire agent_started only after spawn resolved; safeParse each field so a spoofed IPC payload can't poison the event (missing required field skips it). + if (args.telemetry && !ctx.stablePaneOwner) { + const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) + const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) + const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) + if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { + track('agent_started', { + agent_kind: agentKindParse.data, + launch_source: launchSourceParse.data, + request_kind: requestKindParse.data, + ...getCohortAtEmit() + }) + } + } + const response = { + ...ctx.result, + // Why both or neither: a pane can only adopt proven kitty flags together + // with the sequence boundary they describe. + ...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' && + ctx.reconciledSnapshotSeq !== null && + ctx.snapshotKittyFlagsCoverReconciledSeq + ? { snapshotSeq: ctx.reconciledSnapshotSeq } + : { snapshotKittyKeyboardFlags: undefined }), + ...(!ctx.result.isReattach && ctx.effectiveLaunchConfig + ? { launchConfig: ctx.effectiveLaunchConfig } + : {}), + // Why: a daemon-retry race can surface isReattach even for a minted session id, and a reattach must never claim its cwd was remapped. + ...(ctx.startupCwdFallback && !ctx.result.isReattach + ? { startupCwdFallback: ctx.startupCwdFallback } + : {}), + // Why: the pane asked to resume and got a fresh session instead; only the + // renderer can say so, and a reattach never ran this launch command. + ...(ctx.codexResumeLaunch.notifyResumeUnavailable && !ctx.result.isReattach + ? { agentResumeUnavailable: true as const } + : {}) + } + // Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory. + ctx.deps.sendPtySpawnedToRenderer(ctx.result.id) + if (!args.connectionId) { + ctx.deps.options?.onCodexHomePtySpawned?.({ + id: ctx.result.id, + codexHomePath: ctx.selectedCodexHomePath, + startedAt: ctx.codexHomeLaunchStartedAt, + startedSequence: ctx.codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField( + ctx.reattachedCodexHomeRoutes, + ctx.result.id, + ctx.result.isReattach === true + ), + ...(ctx.result.isReattach === true + ? { reattached: true } + : ctx.baseEnv + ? { launchEnv: ctx.baseEnv } + : {}) + }) + } + return resolvePaneSpawnReservation( + ctx.paneSpawnReservationKey, + ctx.paneSpawnReservation, + response + ) +} diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts new file mode 100644 index 00000000000..655a848e980 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -0,0 +1,164 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' +import { isSafePtySessionId } from '../../../daemon/pty-session-id' +import { isNativeWindowsLocalPtySpawn } from '../../../runtime/terminal-model-query-authority' +import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' +import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery' +import { buildPtyHostEnv } from '../host-env/assembly' +import { + getCompatibleSelectedCodexHomePath, + getCodexSelectionTargetForPty, + resolveCodexHomeAfterManagedAuthReadiness, + shouldSkipCodexHomeEnvForWindowsShell, + shouldStripInheritedOrcaCodexHome, + isCodexStatusHooksEnabled, + codexHomePathsEqual +} from '../host-env/codex-home' +import { promoteAgentTeamsShimPath } from '../host-env/path' +import { clearProviderPtyState } from '../provider/state-cleanup' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promise { + const args = ctx.args + ctx.effectiveShellOverride = ctx.terminalRuntimeOptions.shellOverride + ctx.nativeWindowsConptySpawn = isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: ctx.effectiveShellOverride + }) + ctx.codexSelectionTarget = getCodexSelectionTargetForPty( + ctx.effectiveShellOverride, + ctx.cwd, + ctx.expectedWslDistro + ) + const codexResumePreparation = ctx.preAdoptedStablePane + ? null + : ctx.deps.prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: ctx.codexSelectionTarget, + launchEnv: ctx.baseEnv, + workspacePath: ctx.cwd + }) + ctx.codexResumeLaunch = codexResumePreparation + ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) + : ctx.deps.noCodexResumeLaunch(ctx.preAdoptedStablePane ? undefined : args.command) + const codexResumeHome = ctx.codexResumeLaunch.codexResumeHome + ctx.launchCommand = ctx.codexResumeLaunch.command + ctx.baseEnv = ctx.deps.stripSequencedStartupResumeArgv(ctx.baseEnv, ctx.codexResumeLaunch) + // Why: declared after the strip so a local-provider spawn cannot capture the + // pre-strip env — only the daemon branch below re-derives this from baseEnv. + ctx.env = ctx.baseEnv + ctx.selectedCodexHomePath = + !ctx.preAdoptedStablePane && !args.connectionId + ? getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + codexResumeHome + ? ctx.deps.reconcileSharedRuntimeResumeHome(codexResumeHome, () => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { + workspacePath: ctx.cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null + ) + ) + : (ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { + workspacePath: ctx.cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null) + ) + : null + if (!ctx.preAdoptedStablePane && args.launchAgent === 'codex' && args.sessionId === undefined) { + const resolution = resolveCodexHomeAfterManagedAuthReadiness({ + selectedCodexHomePath: ctx.selectedCodexHomePath, + getSettings: () => ctx.deps.getSettings?.(), + requiredCodexHomePath: codexResumeHome?.codexHomePath, + target: ctx.codexSelectionTarget, + resolveCurrent: () => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { + workspacePath: ctx.cwd, + launchAgent: 'codex' + }) ?? null + ), + resolveAfterUnavailable: (unavailableManagedHomePath) => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.baseEnv, { + workspacePath: ctx.cwd, + launchAgent: 'codex', + unavailableManagedHomePath + }) ?? null + ) + }) + ctx.selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution + } + if (args.launchAgent === 'codex' && ctx.selectedCodexHomePath) { + await ensureCodexStateDbBackfillRecoveryStarted(ctx.selectedCodexHomePath) + } + ctx.codexResumeHomeSelected = Boolean( + codexResumeHome && codexHomePathsEqual(ctx.selectedCodexHomePath, codexResumeHome.codexHomePath) + ) + ctx.skipCodexHomeEnv = + ctx.isDaemonHostSpawn && + shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd) && + !ctx.selectedCodexHomePath + const ptySettings = ctx.isDaemonHostSpawn ? ctx.deps.getSettings?.() : undefined + ctx.stripInheritedOrcaCodexHome = + ctx.isDaemonHostSpawn && + shouldStripInheritedOrcaCodexHome({ + target: ctx.codexSelectionTarget, + selectedCodexHomePath: ctx.selectedCodexHomePath, + skipCodexHomeEnv: ctx.skipCodexHomeEnv, + settings: ptySettings + }) + if (ctx.isDaemonHostSpawn && !ctx.preAdoptedStablePane) { + if (ctx.effectiveSessionId === undefined) { + // Should be unreachable: effectiveSessionId is a string when isDaemonHostSpawn; defense-in-depth. + throw new Error('Invariant violation: daemon spawn without sessionId') + } + const sessionIdForEnv = ctx.effectiveSessionId + // Why: this id reaches filesystem paths; reject traversal/separators so a crafted IPC payload can't escape the expected roots. + if (!isSafePtySessionId(sessionIdForEnv, getAppEnvironment().getPath('userData'))) { + throw new Error('Invalid PTY session id') + } + // Why: clone before mutating so injections don't leak back into args.env (renderer may reuse it). + ctx.env = { ...ctx.baseEnv } + try { + buildPtyHostEnv(sessionIdForEnv, ctx.env, { + isPackaged: getAppEnvironment().isPackaged(), + resourcesPath: process.resourcesPath, + userDataPath: getAppEnvironment().getPath('userData'), + selectedCodexHomePath: ctx.selectedCodexHomePath, + skipCodexHomeEnv: ctx.skipCodexHomeEnv, + stripInheritedOrcaCodexHome: ctx.stripInheritedOrcaCodexHome, + launchCommand: ctx.launchCommand, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd), + wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), + codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), + networkProxySettings: ptySettings, + deferGitConfigGuardToDaemon: + ctx.provider.supportsGitCredentialGuardHost?.(ctx.effectiveSessionId) === true + }) + stampWslOrchestrationCompatibilityHost( + ctx.env, + ctx.deps.runtime?.getOrchestrationCompatibilityHostId?.(), + ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null + ) + promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) + } catch (err) { + // Why: buildPtyHostEnv has fs side-effects (Pi/OMP install); clear per-PTY state on throw, but only minted ids — caller ids may name existing PTYs. + if (ctx.isMintedSessionId) { + clearProviderPtyState(sessionIdForEnv) + } + throw err + } + } + ctx.spawnTiming.mark('host_env') +} diff --git a/src/main/ipc/pty/ipc/spawn-env.ts b/src/main/ipc/pty/ipc/spawn-env.ts new file mode 100644 index 00000000000..2fd4c51ac04 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-env.ts @@ -0,0 +1,135 @@ +import { + isTerminalLeafId, + makePaneKey, + parseLegacyNumericPaneKey +} from '../../../../shared/stable-pane-id' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' +import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { resolvePathEnvKey } from '../../../pty/windows-environment-path' +import { routesFreshSpawnsToLocalProvider } from '../host-env/fresh-spawn-routing' +import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness' +import { parseValidPaneKey } from '../pane/key-state' +import { shouldRefreshNativeClaudeAgentTeamsEnv } from '../pane/launch-authority' +import type { PtyIpcSpawnState } from './spawn-state' +import { assemblePtyIpcSpawnCodexEnv } from './spawn-env-codex' + +export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise { + const args = ctx.args + if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { + throw new Error( + 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' + ) + } + // Why: the daemon-backed provider skips LocalPtyProvider's buildSpawnEnv, so assemble the same host-local env here for parity. + // Safety: skip entirely for SSH — every injection is a loopback secret or a local path that leaks or misleads on the remote host. + // Why: forward pane env to SSH only when the relay hook path is enabled, or a newer relay could emit statuses this build can't route. + const sshSourceEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env) + const baseEnvWithAuth = ctx.claudeAuth + ? { ...sshSourceEnv, ...ctx.claudeAuth.envPatch } + : sshSourceEnv + const spawnPaneKey = baseEnvWithAuth?.ORCA_PANE_KEY + const parsedSpawnPaneKey = parseValidPaneKey(spawnPaneKey) + const verifiedPaneKey = + parsedSpawnPaneKey && + typeof args.tabId === 'string' && + args.tabId === parsedSpawnPaneKey.tabId && + args.leafId === parsedSpawnPaneKey.leafId + ? makePaneKey(parsedSpawnPaneKey.tabId, parsedSpawnPaneKey.leafId) + : null + ctx.verifiedLeafId = verifiedPaneKey && parsedSpawnPaneKey ? parsedSpawnPaneKey.leafId : null + ctx.metadataLeafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + ctx.metadataPaneKey = + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + ctx.metadataLeafId + ? makePaneKey(args.tabId, ctx.metadataLeafId) + : null + ctx.legacySpawnPaneKey = verifiedPaneKey ? null : parseLegacyNumericPaneKey(spawnPaneKey) + ctx.migrationUnsupportedPaneKey = + ctx.legacySpawnPaneKey && + typeof args.tabId === 'string' && + args.tabId === ctx.legacySpawnPaneKey.tabId && + typeof args.leafId === 'string' && + isTerminalLeafId(args.leafId) + ? makePaneKey(args.tabId, args.leafId) + : null + ctx.stablePaneKey = verifiedPaneKey ?? ctx.migrationUnsupportedPaneKey + ctx.baseEnv = baseEnvWithAuth ? { ...baseEnvWithAuth } : undefined + const shouldRefreshAgentTeamsEnv = + !ctx.preAdoptedStablePane && + !args.connectionId && + ctx.deps.runtime !== undefined && + ctx.stablePaneKey !== null && + shouldRefreshNativeClaudeAgentTeamsEnv({ + command: args.command, + launchConfig: args.launchConfig + }) + ctx.effectiveLaunchConfig = args.launchConfig + const shouldPreAllocateTerminalHandle = + ctx.deps.runtime !== undefined && + ((!(ctx.provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(ctx.provider)) || + shouldRefreshAgentTeamsEnv) + const runtime = ctx.deps.runtime + ctx.preAllocatedHandle = shouldPreAllocateTerminalHandle + ? (ctx.preAdoptedStablePane?.owner.handle ?? + runtime?.createPreAllocatedTerminalHandle() ?? + null) + : null + if (shouldRefreshAgentTeamsEnv && ctx.preAllocatedHandle && runtime) { + // Why: Agent Teams ids/tokens are process-local, so the team env must be regenerated for the new leader PTY. + const prepared = await runtime.prepareClaudeAgentTeamsLeaderForHandle({ + handle: ctx.preAllocatedHandle, + baseEnv: ctx.baseEnv ?? {} + }) + ctx.agentTeamsLeaderHandle = ctx.preAllocatedHandle + ctx.baseEnv = { + ...ctx.baseEnv, + ...prepared.env + } + if (args.launchConfig) { + ctx.effectiveLaunchConfig = { + ...args.launchConfig, + agentEnv: { + ...args.launchConfig.agentEnv, + ...prepared.env + } + } + } + } + ctx.requestedAgentTeamsPath = ctx.baseEnv?.ORCA_AGENT_TEAMS_TEAM_ID + ? ctx.baseEnv[resolvePathEnvKey(ctx.baseEnv, process.platform)] + : undefined + ctx.agentTeamsEnvToDelete = shouldRefreshAgentTeamsEnv ? ['TERM_PROGRAM'] : undefined + if (ctx.baseEnv && ctx.stablePaneKey) { + ctx.baseEnv.ORCA_PANE_KEY = ctx.stablePaneKey + if (typeof args.tabId === 'string') { + ctx.baseEnv.ORCA_TAB_ID = args.tabId + } else if (!args.connectionId) { + delete ctx.baseEnv.ORCA_TAB_ID + } + if (typeof args.worktreeId === 'string') { + ctx.baseEnv.ORCA_WORKTREE_ID = args.worktreeId + } else if (!args.connectionId) { + delete ctx.baseEnv.ORCA_WORKTREE_ID + } + } else if (ctx.baseEnv) { + // Why: ORCA_PANE_KEY crosses into shells/hook registries; only a key proven to match this spawn's tab+leaf may cross the IPC boundary. + delete ctx.baseEnv.ORCA_PANE_KEY + delete ctx.baseEnv.ORCA_TAB_ID + delete ctx.baseEnv.ORCA_WORKTREE_ID + delete ctx.baseEnv.ORCA_AGENT_LAUNCH_TOKEN + } + ctx.validatedPaneKey = ctx.stablePaneKey + // Why: SSH can strip ORCA_PANE_KEY when remote hooks are off; IPC tab/leaf metadata still names the pane. + ctx.reservationPaneKey = ctx.metadataPaneKey ?? ctx.validatedPaneKey + ctx.validatedLeafId = ctx.verifiedLeafId ?? ctx.metadataLeafId + await assemblePtyIpcSpawnCodexEnv(ctx) +} diff --git a/src/main/ipc/pty/ipc/spawn-execute.ts b/src/main/ipc/pty/ipc/spawn-execute.ts new file mode 100644 index 00000000000..58525e162aa --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-execute.ts @@ -0,0 +1,202 @@ +import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' +import { + SSH_SESSION_EXPIRED_ERROR, + isSshPtyIdentityMismatchError +} from '../../../providers/ssh-pty-errors' +import { classifyError } from '../../../telemetry/classify-error' +import { track } from '../../../telemetry/client' +import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' +import { agentKindSchema } from '../../../../shared/telemetry-events' +import { normalizeNodePtySpawnError } from '../provider/liveness' +import { resolveStablePaneOwner, spawnForStablePane } from '../pane/stable-owner' +import { assertSpawnReplyWasLive } from '../pane/agent-session-owners' +import { deletePtyOwnership } from '../provider/ownership-state' +import { ptySizes } from '../delivery/visibility-state' +import { clearProviderPtyState } from '../provider/state-cleanup' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + const args = ctx.args + try { + if (ctx.preAllocatedHandle) { + ctx.deps.trustedTerminalHandleEnv.add(ctx.preAllocatedHandle) + } + ctx.spawnTiming.mark('options') + const stablePaneOwnerCandidate = resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.reservationPaneKey, + args.worktreeId, + args.connectionId + ) + const expectedPtyId = + stablePaneOwnerCandidate?.ptyId ?? ctx.effectiveSessionAppId ?? ctx.effectiveSessionId + if (expectedPtyId) { + ctx.deps.runtime?.beginPtyRegistration?.(expectedPtyId) + ctx.pendingRegistrationPtyId = expectedPtyId + } + if (ctx.isDaemonHostSpawn && expectedPtyId) { + ctx.preparedProvisionalExecutionContext = + ctx.deps.runtime?.preparePtyExecutionContext?.(expectedPtyId, ctx.expectedWslDistro, { + resetIncarnation: ctx.isMintedSessionId && !stablePaneOwnerCandidate, + preserveExisting: !ctx.isMintedSessionId || Boolean(stablePaneOwnerCandidate) + }) ?? false + } + const sequenceBeforeProviderSpawn = expectedPtyId + ? (ctx.deps.runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) + : 0 + const stablePaneSpawn = ctx.preAdoptedStablePane + ? ctx.preAdoptedStablePane + : await spawnForStablePane({ + runtime: ctx.deps.runtime, + store: ctx.deps.store, + provider: ctx.provider, + spawnOptions: ctx.spawnOptions, + owner: stablePaneOwnerCandidate, + worktreeId: args.worktreeId, + connectionId: args.connectionId, + resolveOwner: () => + resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.reservationPaneKey, + args.worktreeId, + args.connectionId + ) + }) + ctx.result = stablePaneSpawn.result + ctx.stablePaneOwner = stablePaneSpawn.owner + if ( + ctx.stablePaneOwner && + ctx.isMintedSessionId && + ctx.effectiveSessionAppId && + ctx.effectiveSessionAppId !== ctx.result.id + ) { + clearProviderPtyState(ctx.effectiveSessionAppId) + } + ctx.rejectedRegistrationCandidate = ctx.result + if (ctx.pendingRegistrationPtyId !== ctx.result.id) { + if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.pendingRegistrationPtyId) + } + ctx.deps.runtime?.beginPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) + ctx.pendingRegistrationPtyId = ctx.result.id + } + assertSpawnReplyWasLive(ctx.result) + ctx.deps.runtime?.assertPtyRegistrationAllowed?.(ctx.result.id, ctx.result.incarnationId) + if (ctx.result.providerSequence) { + const runtimeSequenceBeforeReconcile = + ctx.deps.runtime?.getPtyOutputSequence?.(ctx.result.id) ?? 0 + // Why kept: this is the reattach boundary in the RENDERER's sequence + // domain, and the daemon snapshot's kitty flags mean nothing without + // the boundary they were proven at. + ctx.reconciledSnapshotSeq = + ctx.deps.runtime?.synchronizePtyOutputSequenceFromProvider?.( + ctx.result.id, + ctx.result.providerSequence, + sequenceBeforeProviderSpawn + ) ?? null + if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) { + ctx.snapshotKittyFlagsCoverReconciledSeq = false + } + } + ensureWslHookRelayForReattach(ctx.result, args.connectionId) + ctx.deps.runtime?.preparePtyExecutionContext?.( + ctx.result.id, + args.connectionId + ? null + : ctx.result.wslDistro === undefined + ? ctx.expectedWslDistro + : ctx.result.wslDistro + ) + ctx.spawnTiming.mark('provider_spawn') + } catch (err) { + if ( + (ctx.isMintedSessionId || ctx.preparedProvisionalExecutionContext) && + ctx.effectiveSessionAppId + ) { + ctx.deps.runtime?.preparePtyExecutionContext?.(ctx.effectiveSessionAppId, null, { + resetIncarnation: true + }) + } + // Why: a stale hidden mark on this session id would gate a later visible attach that reuses it. + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) + } + const rawMessage = err instanceof Error ? err.message : String(err) + if (rawMessage === 'agent_session_exited_during_start' && ctx.rejectedRegistrationCandidate) { + ctx.deps.runtime?.releaseRejectedPtyRegistrationFence?.( + ctx.rejectedRegistrationCandidate.id, + ctx.rejectedRegistrationCandidate.incarnationId + ) + } + if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.( + ctx.pendingRegistrationPtyId, + ctx.rejectedRegistrationCandidate?.incarnationId + ) + ctx.pendingRegistrationPtyId = null + } + const spawnError = normalizeNodePtySpawnError(err) + const isIdentityMismatch = + isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage) + const isExpiredSshSession = + Boolean(args.connectionId) && + (spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) || + rawMessage.includes(SSH_SESSION_EXPIRED_ERROR)) + const exitedBeforeSpawnReply = + ctx.rejectedRegistrationCandidate?.exitedBeforeSpawnReply === true + if (ctx.effectiveSessionAppId !== undefined) { + if ( + ctx.hadSessionSizeBeforeAttach && + ctx.sessionSizeBeforeAttach && + (isIdentityMismatch || (!isExpiredSshSession && !exitedBeforeSpawnReply)) + ) { + ptySizes.set(ctx.effectiveSessionAppId, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(ctx.effectiveSessionAppId) + } + } + if (args.connectionId && ctx.effectiveSessionRelayId !== undefined && isExpiredSshSession) { + // Why: expired remote reattach = relay already dropped the PTY; clear the lease so writes can't restore the stale binding. + if (ctx.effectiveSessionAppId !== undefined && !isIdentityMismatch) { + clearProviderPtyState(ctx.effectiveSessionAppId) + deletePtyOwnership(ctx.effectiveSessionAppId) + } + if (!isIdentityMismatch) { + ctx.deps.store?.markSshRemotePtyLease( + args.connectionId, + ctx.effectiveSessionRelayId, + 'expired' + ) + } + } + // Why: provider state buildPtyHostEnv materialized for this minted id leaks if spawn failed. + if (ctx.isMintedSessionId && ctx.effectiveSessionId !== undefined) { + clearProviderPtyState(ctx.effectiveSessionId) + } + // Why: telemetry-plan.md§agent_error — attribute the error to the renderer-threaded agent_kind, else sniff the command for `claude`; raw messages are dropped at the validator boundary. + const rendererAgentKindParse = + args.telemetry?.agent_kind !== undefined + ? agentKindSchema.safeParse(args.telemetry.agent_kind) + : null + const errorAgentKind = rendererAgentKindParse?.success + ? rendererAgentKindParse.data + : ctx.isClaudeLaunch + ? ('claude-code' as const) + : null + if (errorAgentKind) { + const classified = classifyError(spawnError) + track('agent_error', { + agent_kind: errorAgentKind, + error_class: classified.error_class, + ...getCohortAtEmit() + }) + } + throw spawnError + } finally { + if (ctx.preAllocatedHandle) { + ctx.deps.trustedTerminalHandleEnv.delete(ctx.preAllocatedHandle) + } + } +} diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts new file mode 100644 index 00000000000..ed75b989f6d --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -0,0 +1,181 @@ +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' +import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' +import { CODEX_HOME_ENV_KEYS } from '../host-env/codex-home' +import { + mergePtyEnvDeletions, + removeCodexHomeDeletionRequests, + getInheritedAgentHookEnvKeysToDelete, + getInheritedClaudeSessionStampEnvKeysToDelete +} from '../host-env/pi-agent' +import { promoteAgentTeamsShimPath, deleteRequestedEnvKeys } from '../host-env/path' +import { beginPtySpawnForWorktree } from '../host-env/fresh-spawn-routing' +import { + makePaneSpawnReservationKey, + reservePaneSpawn, + paneSpawnReservationsByOwnerKey, + pendingRuntimePaneCreatesByOwnerKey +} from '../pane/spawn-reservation' +import { ptySizes } from '../delivery/visibility-state' +import { getStartupTerminalColorQueryReplyColors } from '../../terminal-startup-color-query-replies' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function buildPtyIpcSpawnOptions( + ctx: PtyIpcSpawnState +): Promise<{ isReattach: true } | null> { + const args = ctx.args + ctx.spawnEnv = ctx.preAllocatedHandle + ? { ...ctx.env, ORCA_TERMINAL_HANDLE: ctx.preAllocatedHandle } + : ctx.env + const envToDelete = ctx.claudeAuth?.stripAuthEnv + ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] + : undefined + ctx.combinedEnvToDelete = mergePtyEnvDeletions( + envToDelete, + args.envToDelete ?? [], + ctx.agentTeamsEnvToDelete ?? [], + // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. + ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], + ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.spawnEnv) : [], + getInheritedClaudeSessionStampEnvKeysToDelete(ctx.spawnEnv), + ctx.skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], + // Why: the persistent daemon compares its own merged CODEX_HOME pair; + // main cannot safely decide ownership for a process it may not parent. + ctx.stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] + ) + if (ctx.codexResumeHomeSelected) { + ctx.combinedEnvToDelete = removeCodexHomeDeletionRequests(ctx.combinedEnvToDelete) + } + deleteRequestedEnvKeys(ctx.spawnEnv, ctx.combinedEnvToDelete) + promoteAgentTeamsShimPath(ctx.spawnEnv, ctx.requestedAgentTeamsPath) + ctx.spawnOptions = { + cols: args.cols, + rows: args.rows, + cwd: ctx.cwd, + ...(ctx.prevalidatedCwd && !ctx.isDaemonHostSpawn + ? { prevalidatedCwd: ctx.prevalidatedCwd } + : {}), + env: ctx.spawnEnv, + historyIsolationEnabled: ctx.deps.getSettings?.()?.terminalScopeHistoryByWorktree ?? true, + ...(ctx.isMintedSessionId ? { isNewSession: true } : {}) + } + if (!args.connectionId && !ctx.isDaemonHostSpawn) { + ctx.spawnOptions.codexHomePathOverride = { value: ctx.selectedCodexHomePath } + } + if (ctx.combinedEnvToDelete) { + ctx.spawnOptions.envToDelete = ctx.combinedEnvToDelete + } + if (ctx.launchCommand !== undefined) { + ctx.spawnOptions.command = ctx.launchCommand + } + if (args.commandDelivery !== undefined) { + ctx.spawnOptions.commandDelivery = args.commandDelivery + } + if (args.startupCommandDelivery !== undefined) { + ctx.spawnOptions.startupCommandDelivery = args.startupCommandDelivery + } + if (isTuiAgent(args.launchAgent)) { + ctx.spawnOptions.launchAgent = args.launchAgent + } + if (args.worktreeId !== undefined) { + ctx.spawnOptions.worktreeId = args.worktreeId + } + if (ctx.reservationPaneKey) { + ctx.spawnOptions.paneKey = ctx.reservationPaneKey + } + if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) { + ctx.spawnOptions.tabId = args.tabId + } + if (ctx.effectiveSessionId !== undefined) { + ctx.spawnOptions.sessionId = ctx.effectiveSessionId + } + // Why: without this, the Windows daemon path ignores the user's Default Shell preference (LocalPtyProvider already honors it via getWindowsShell()). + if (ctx.effectiveShellOverride !== undefined) { + ctx.spawnOptions.shellOverride = ctx.effectiveShellOverride + } + ctx.hadSessionSizeBeforeAttach = + ctx.effectiveSessionAppId !== undefined ? ptySizes.has(ctx.effectiveSessionAppId) : false + ctx.sessionSizeBeforeAttach = + ctx.effectiveSessionAppId !== undefined ? ptySizes.get(ctx.effectiveSessionAppId) : undefined + if (ctx.effectiveSessionId !== undefined) { + // Why: daemon PTYs can emit before spawn() resolves; set real geometry now or early bytes default to 80x24 and wrap TUIs. + ptySizes.set(ctx.effectiveSessionAppId ?? ctx.effectiveSessionId, { + cols: args.cols, + rows: args.rows + }) + } + if (process.platform === 'win32' && !args.connectionId) { + // Why: the renderer models PowerShell as one shell family; thread the implementation choice so both PTY paths resolve the same executable. + ctx.spawnOptions.terminalWindowsWslDistro = ctx.expectedWslDistro + ctx.spawnOptions.terminalWindowsPowerShellImplementation = ctx.deps.getSettings + ? (ctx.deps.getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') + : undefined + } + const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) + if (startupTerminalColorQueryReplyColors) { + ctx.spawnOptions.startupIngress = { + colors: startupTerminalColorQueryReplyColors, + deadlineMs: 5_000 + } + } + const resolvedPaneSpawnReservationKey = makePaneSpawnReservationKey( + args.worktreeId, + args.connectionId, + ctx.reservationPaneKey + ) + if ( + ctx.paneSpawnReservationKey && + resolvedPaneSpawnReservationKey !== ctx.paneSpawnReservationKey + ) { + throw new Error('terminal_pane_identity_changed') + } + if (!ctx.paneSpawnReservationKey) { + ctx.paneSpawnReservationKey = resolvedPaneSpawnReservationKey + const pendingRuntimeCreateAfterPreflight = ctx.paneSpawnReservationKey + ? pendingRuntimePaneCreatesByOwnerKey.get(ctx.paneSpawnReservationKey) + : undefined + if (pendingRuntimeCreateAfterPreflight) { + await pendingRuntimeCreateAfterPreflight.promise + } + const existingPaneSpawnAfterPreflight = ctx.paneSpawnReservationKey + ? paneSpawnReservationsByOwnerKey.get(ctx.paneSpawnReservationKey) + : undefined + if (existingPaneSpawnAfterPreflight) { + const reattach = { + ...(await existingPaneSpawnAfterPreflight.promise), + isReattach: true as const + } + // Why: discard only a distinct provisional id; the winner may have committed this id's real size. + if (ctx.effectiveSessionId !== undefined) { + const provisionalSizeKey = ctx.effectiveSessionAppId ?? ctx.effectiveSessionId + if (provisionalSizeKey !== reattach.id) { + if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) { + ptySizes.set(provisionalSizeKey, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(provisionalSizeKey) + } + } + } + return reattach + } + ctx.paneSpawnReservation = ctx.paneSpawnReservationKey + ? reservePaneSpawn(ctx.paneSpawnReservationKey) + : null + } + ctx.finishTerminalInstall = beginPtySpawnForWorktree(args.worktreeId, ctx.cwd, args.connectionId) + ctx.initiallyHidden = args.initiallyHidden === true + // Why: daemon PTYs can emit before spawn() resolves, so the hidden mark must beat byte zero (terminal-query-authority.md §races); other providers are safe with the post-spawn mark below. + ctx.preSpawnHiddenMarkId = + ctx.initiallyHidden && ctx.isDaemonHostSpawn && ctx.effectiveSessionAppId !== undefined + ? ctx.effectiveSessionAppId + : null + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true) + } + const runtime = ctx.deps.runtime + const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn + ctx.releaseWorktreeSpawn = acquireWorktreeSpawn + ? await acquireWorktreeSpawn.call(runtime, args.worktreeId) + : undefined + return null +} diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts new file mode 100644 index 00000000000..f40b46e5dd5 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -0,0 +1,229 @@ +import { + isWslShellName, + resolveLocalWindowsTerminalRuntimeOptions +} from '../../../../shared/local-windows-terminal-runtime' +import { isWslUncPath, toWindowsWslPath } from '../../../../shared/wsl-paths' +import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' +import { mintPtySessionId } from '../../../daemon/pty-session-id' +import { resolveWslSessionContext } from '../../../daemon/wsl-session-context' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { normalizeWindowsTerminalCwd } from '../../../providers/windows-shell-args' +import { wslUncDirectoryExistsAsync } from '../../../wsl' +import { getCodexSelectionTargetForPty } from '../host-env/codex-home' +import { + isClaudeLaunchCommand, + recoverFreshSpawnProviderRouting, + routesFreshSpawnsToLocalProvider +} from '../host-env/fresh-spawn-routing' +import { getAppPtyId, getProvider, getRelayPtyId } from '../provider/registry' +import type { PtyIpcSpawnState } from './spawn-state' + +export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promise { + const args = ctx.args + // Establish daemon identity before the first await so hidden delivery is gated before byte zero. + ctx.provider = getProvider(args.connectionId) + ctx.isDaemonHostSpawn = + !args.connectionId && + !(ctx.provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(ctx.provider) + ctx.isMintedSessionId = args.sessionId === undefined && ctx.isDaemonHostSpawn + ctx.effectiveSessionId = + args.sessionId ?? (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) + ctx.effectiveSessionAppId = + ctx.effectiveSessionId !== undefined + ? getAppPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.effectiveSessionRelayId = + ctx.effectiveSessionId !== undefined + ? getRelayPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.initiallyHidden = args.initiallyHidden === true + ctx.preSpawnHiddenMarkId = + ctx.initiallyHidden && ctx.isDaemonHostSpawn && ctx.effectiveSessionAppId !== undefined + ? ctx.effectiveSessionAppId + : null + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true) + } + if (!ctx.earlyStablePaneOwner) { + const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + if (pathUsable) { + await pathUsable + } + } + ctx.spawnTiming.mark('stable_adoption_setup') + ctx.preAdoptedStablePane = + ctx.earlyStablePaneOwner && ctx.earlyWorktreeId + ? await ctx.deps.adoptStablePane({ + cols: args.cols, + rows: args.rows, + cwd: ctx.cwd, + connectionId: args.connectionId, + worktreeId: ctx.earlyWorktreeId, + tabId: ctx.earlyStablePaneOwner.tabId, + leafId: ctx.earlyStablePaneOwner.leafId, + ownsPaneSpawnReservation: true + }) + : null + ctx.spawnTiming.mark('stable_adoption') + if (ctx.earlyStablePaneOwner && !ctx.preAdoptedStablePane) { + const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + if (pathUsable) { + await pathUsable + } + } + if (!ctx.preAdoptedStablePane) { + // Why: reattach needs exact cwd, SSH cannot probe locally, and successful stable-pane adoption needs no launch preflight. + const requestedMissingCwdFallback = + !args.connectionId && !args.sessionId && args.cwdFallback === 'worktree' + const isPosixStartupCwd = args.cwd?.startsWith('/') === true + const startupWorkspaceCwd = + requestedMissingCwdFallback && isPosixStartupCwd + ? ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, '.') + : undefined + const initiallyResolvedStartupCwd = + requestedMissingCwdFallback && isPosixStartupCwd ? ctx.cwd : undefined + const startupTerminalRuntimeOptions = + requestedMissingCwdFallback && process.platform === 'win32' + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.shellOverride, + settings: ctx.deps.getSettings?.(), + projectRuntime: args.projectRuntime, + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : undefined + const wslRuntimeOwnsStartupCwd = + requestedMissingCwdFallback && + isPosixStartupCwd && + (isWslShellName(startupTerminalRuntimeOptions?.shellOverride) || + isWslUncPath(startupWorkspaceCwd ?? '')) + const startupWslContext = wslRuntimeOwnsStartupCwd + ? resolveWslSessionContext({ + cwd: startupWorkspaceCwd, + shellOverride: startupTerminalRuntimeOptions?.shellOverride, + terminalWindowsWslDistro: startupTerminalRuntimeOptions?.terminalWindowsWslDistro + }) + : undefined + let wslStartupCwdExists: boolean | null = null + let wslWorkspaceCwdExists: boolean | null = null + if (startupWslContext && initiallyResolvedStartupCwd) { + const validationCwd = toWindowsWslPath(initiallyResolvedStartupCwd, startupWslContext.distro) + wslStartupCwdExists = isWslUncPath(validationCwd) + ? await wslUncDirectoryExistsAsync(validationCwd) + : ctx.deps.localStartupCwdDirectoryExists(validationCwd) + if (wslStartupCwdExists === true) { + ctx.prevalidatedCwd = validationCwd + } + if (wslStartupCwdExists === false && startupWorkspaceCwd) { + wslWorkspaceCwdExists = isWslUncPath(startupWorkspaceCwd) + ? await wslUncDirectoryExistsAsync(startupWorkspaceCwd) + : ctx.deps.localStartupCwdDirectoryExists(startupWorkspaceCwd) + if (wslWorkspaceCwdExists === true) { + ctx.prevalidatedCwd = startupWorkspaceCwd + } + } + } + const allowMissingCwdFallback = + requestedMissingCwdFallback && + (!wslRuntimeOwnsStartupCwd || + (wslStartupCwdExists === false && wslWorkspaceCwdExists === true)) + let didFallbackToWorkspaceRootCwd = false + ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd( + args.worktreeId, + args.cwd, + allowMissingCwdFallback + ? { + directoryExists: (path) => + startupWslContext && + wslStartupCwdExists === false && + path === initiallyResolvedStartupCwd + ? false + : startupWslContext && path === startupWorkspaceCwd + ? wslWorkspaceCwdExists === true + : ctx.deps.localStartupCwdDirectoryExists( + process.platform === 'win32' ? normalizeWindowsTerminalCwd(path) : path + ), + onFallbackToWorkspaceRoot: () => { + didFallbackToWorkspaceRootCwd = true + } + } + : undefined + ) + if (didFallbackToWorkspaceRootCwd && wslWorkspaceCwdExists === true && ctx.cwd) { + ctx.prevalidatedCwd = ctx.cwd + } + ctx.startupCwdFallback = + didFallbackToWorkspaceRootCwd && ctx.cwd ? { kind: 'worktree', cwd: ctx.cwd } : undefined + } + ctx.spawnTiming.mark('preflight') + const freshSpawnRecovery = ctx.preAdoptedStablePane + ? undefined + : recoverFreshSpawnProviderRouting(ctx.provider, args.connectionId, args.sessionId) + if (freshSpawnRecovery) { + await freshSpawnRecovery + const previousHiddenMarkId = ctx.preSpawnHiddenMarkId + ctx.isDaemonHostSpawn = + !args.connectionId && + !(ctx.provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(ctx.provider) + ctx.isMintedSessionId = args.sessionId === undefined && ctx.isDaemonHostSpawn + ctx.effectiveSessionId = + args.sessionId ?? (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) + ctx.effectiveSessionAppId = + ctx.effectiveSessionId !== undefined + ? getAppPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.effectiveSessionRelayId = + ctx.effectiveSessionId !== undefined + ? getRelayPtyId(args.connectionId, ctx.effectiveSessionId) + : undefined + ctx.preSpawnHiddenMarkId = + ctx.initiallyHidden && ctx.isDaemonHostSpawn && ctx.effectiveSessionAppId !== undefined + ? ctx.effectiveSessionAppId + : null + if (previousHiddenMarkId !== ctx.preSpawnHiddenMarkId) { + if (previousHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(previousHiddenMarkId, false) + } + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, true) + } + } + } + ctx.isClaudeLaunch = + !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) + if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + ctx.terminalRuntimeOptions = + process.platform === 'win32' && !args.connectionId + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: args.shellOverride, + settings: ctx.deps.getSettings?.(), + projectRuntime: args.projectRuntime, + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : { shellOverride: args.shellOverride, terminalWindowsWslDistro: null } + const initialShellOverride = ctx.terminalRuntimeOptions.shellOverride + // Why: daemon host-env setup needs a stable id BEFORE provider.spawn so buildPtyHostEnv hooks/Pi cleanup can run; daemon still honors opts.sessionId ?? mint(). + // Note: sessionId is STABLE across daemon restarts by design — do NOT simplify to a fresh UUID per spawn; that orphans reconnectable state. + // Why: only clear ids minted in THIS request on failure — a caller-supplied args.sessionId may name an existing PTY we must not clobber. + ctx.expectedWslDistro = !args.connectionId + ? (resolveWslSessionContext({ + cwd: ctx.cwd, + sessionId: ctx.effectiveSessionId, + shellOverride: ctx.terminalRuntimeOptions.shellOverride, + terminalWindowsWslDistro: ctx.terminalRuntimeOptions.terminalWindowsWslDistro + })?.distro ?? null) + : null + const initialSelectionTarget = getCodexSelectionTargetForPty( + initialShellOverride, + ctx.cwd, + ctx.expectedWslDistro + ) + ctx.claudeAuth = + ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth + ? await ctx.deps.prepareClaudeAuth(initialSelectionTarget) + : null + ctx.spawnTiming.mark('auth') +} diff --git a/src/main/ipc/pty/ipc/spawn-run.ts b/src/main/ipc/pty/ipc/spawn-run.ts new file mode 100644 index 00000000000..748eb5d8f62 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-run.ts @@ -0,0 +1,79 @@ +import { rejectPaneSpawnReservation } from '../pane/spawn-reservation' +import { ptySizes } from '../delivery/visibility-state' +import { beginPtyIpcSpawn } from './spawn-begin' +import { preparePtyIpcSpawnPreflight } from './spawn-preflight' +import { assemblePtyIpcSpawnEnv } from './spawn-env' +import { buildPtyIpcSpawnOptions } from './spawn-options' +import { executePtyIpcSpawn } from './spawn-execute' +import { commitPtyIpcSpawn } from './spawn-commit' +import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +function releaseAbandonedAgentTeamsLeader(ctx: PtyIpcSpawnState): void { + if (!ctx.agentTeamsLeaderHandle) { + return + } + ctx.deps.runtime?.releaseClaudeAgentTeamsLeaderForHandle?.(ctx.agentTeamsLeaderHandle) + ctx.agentTeamsLeaderHandle = null +} + +function restoreProvisionalPtySize(ctx: PtyIpcSpawnState): void { + if (ctx.effectiveSessionId === undefined) { + return + } + const key = ctx.effectiveSessionAppId ?? ctx.effectiveSessionId + if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) { + ptySizes.set(key, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(key) + } +} + +export async function runPtyIpcSpawn(deps: PtySpawnIpcDeps, args: PtySpawnIpcArgs) { + const ctx = createPtyIpcSpawnState(deps, args) + const early = await beginPtyIpcSpawn(ctx) + if (early) { + return early + } + try { + await preparePtyIpcSpawnPreflight(ctx) + await assemblePtyIpcSpawnEnv(ctx) + const earlyReserved = await buildPtyIpcSpawnOptions(ctx).catch((error: unknown) => { + restoreProvisionalPtySize(ctx) + throw error + }) + if (earlyReserved) { + // Why: this request lost the pane to the reservation winner, so its + // pre-allocated leader handle never binds to a PTY. Nothing else can + // evict the team env assembly created for it — exit/close cleanup keys + // off handleByPtyId — so every lost race would leak one team forever. + releaseAbandonedAgentTeamsLeader(ctx) + return earlyReserved + } + await executePtyIpcSpawn(ctx) + return await commitPtyIpcSpawn(ctx) + } catch (err) { + releaseAbandonedAgentTeamsLeader(ctx) + if (ctx.preSpawnHiddenMarkId !== null) { + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState(ctx.preSpawnHiddenMarkId, false) + } + if (ctx.pendingRegistrationPtyId) { + deps.runtime?.cancelPendingPtyRegistration?.( + ctx.pendingRegistrationPtyId, + ctx.rejectedRegistrationCandidate?.incarnationId + ) + ctx.pendingRegistrationPtyId = null + } + // Why: once the reservation is created, any later throw — + // spawn failure, persist failure, or a post-spawn helper such as + // seedHeadlessTerminal/registerPty/track — must settle it. Otherwise + // it lingers in paneSpawnReservationsByOwnerKey and every future spawn + // for this pane awaits a promise that never resolves. reject is a + // no-op once the reservation has already resolved. + rejectPaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, err) + throw err + } finally { + ctx.releaseWorktreeSpawn?.() + ctx.finishTerminalInstall() + } +} diff --git a/src/main/ipc/pty/ipc/spawn-state.ts b/src/main/ipc/pty/ipc/spawn-state.ts new file mode 100644 index 00000000000..ee9f5c33cc7 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-state.ts @@ -0,0 +1,159 @@ +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types' +import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' +import type { PtySpawnTiming } from '../../pty-spawn-timing' +import { createPtySpawnTiming } from '../../pty-spawn-timing' +import { noCodexResumeLaunch, type CodexResumeLaunch } from '../host-env/codex-resume' +import type { StablePaneOwner } from '../pane/stable-owner' +import type { PaneSpawnReservation } from '../pane/spawn-reservation' +import { localProvider } from '../provider/registry' +import type { AdoptStablePaneResult, PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +export type PtyIpcSpawnState = { + deps: PtySpawnIpcDeps + args: PtySpawnIpcArgs + spawnTiming: PtySpawnTiming + codexHomeLaunchStartedAt: Date | undefined + codexHomeLaunchStartedSequence: number | undefined + reattachedCodexHomeRoutes: Map + cwd: string | undefined + prevalidatedCwd: string | undefined + startupCwdFallback: { kind: 'worktree'; cwd: string } | undefined + earlyStablePaneOwner: StablePaneOwner | null + earlyWorktreeId: string | undefined + paneSpawnReservationKey: string | null + paneSpawnReservation: PaneSpawnReservation | null + finishTerminalInstall: () => void + result: PtySpawnResult + stablePaneOwner: StablePaneOwner | null + stablePaneBindingPersisted: boolean + rejectedRegistrationCandidate: PtySpawnResult | null + pendingRegistrationPtyId: string | null + reconciledSnapshotSeq: number | null + snapshotKittyFlagsCoverReconciledSeq: boolean + preparedProvisionalExecutionContext: boolean + releaseWorktreeSpawn: (() => void) | undefined + provider: IPtyProvider + preAdoptedStablePane: AdoptStablePaneResult | null + isClaudeLaunch: boolean + claudeAuth: ClaudeRuntimeAuthPreparation | null + terminalRuntimeOptions: { + shellOverride?: string + terminalWindowsWslDistro?: string | null + } + isDaemonHostSpawn: boolean + isMintedSessionId: boolean + effectiveSessionId: string | undefined + effectiveSessionAppId: string | undefined + effectiveSessionRelayId: string | undefined + expectedWslDistro: string | null + baseEnv: Record | undefined + effectiveLaunchConfig: SleepingAgentLaunchConfig | undefined + preAllocatedHandle: string | null + /** Leader handle this spawn allocated Agent Teams state for; must be released if the spawn is abandoned. */ + agentTeamsLeaderHandle: string | null + requestedAgentTeamsPath: string | undefined + agentTeamsEnvToDelete: string[] | undefined + stablePaneKey: string | null + verifiedLeafId: string | null + metadataLeafId: string | null + metadataPaneKey: string | null + legacySpawnPaneKey: { tabId: string; numericPaneId: string; paneKey: string } | null + migrationUnsupportedPaneKey: string | null + reservationPaneKey: string | null + validatedPaneKey: string | null + validatedLeafId: string | null + effectiveShellOverride: string | undefined + nativeWindowsConptySpawn: boolean + codexSelectionTarget: CodexAccountSelectionTarget + codexResumeLaunch: CodexResumeLaunch + launchCommand: string | undefined + env: Record | undefined + selectedCodexHomePath: string | null + spawnEnv: Record | undefined + spawnOptions: PtySpawnOptions + combinedEnvToDelete: string[] | undefined + skipCodexHomeEnv: boolean + stripInheritedOrcaCodexHome: boolean + codexResumeHomeSelected: boolean + hadSessionSizeBeforeAttach: boolean + sessionSizeBeforeAttach: { cols: number; rows: number } | undefined + initiallyHidden: boolean + preSpawnHiddenMarkId: string | null +} + +export function createPtyIpcSpawnState( + deps: PtySpawnIpcDeps, + args: PtySpawnIpcArgs +): PtyIpcSpawnState { + return { + deps, + args, + spawnTiming: createPtySpawnTiming(), + codexHomeLaunchStartedAt: undefined, + codexHomeLaunchStartedSequence: undefined, + reattachedCodexHomeRoutes: new Map(), + cwd: undefined, + prevalidatedCwd: undefined, + startupCwdFallback: undefined, + earlyStablePaneOwner: null, + earlyWorktreeId: undefined, + paneSpawnReservationKey: null, + paneSpawnReservation: null, + finishTerminalInstall: () => {}, + result: { id: '' }, + stablePaneOwner: null, + stablePaneBindingPersisted: false, + rejectedRegistrationCandidate: null, + pendingRegistrationPtyId: null, + reconciledSnapshotSeq: null, + snapshotKittyFlagsCoverReconciledSeq: true, + preparedProvisionalExecutionContext: false, + releaseWorktreeSpawn: undefined, + provider: localProvider, + preAdoptedStablePane: null, + isClaudeLaunch: false, + claudeAuth: null, + terminalRuntimeOptions: {}, + isDaemonHostSpawn: false, + isMintedSessionId: false, + effectiveSessionId: undefined, + effectiveSessionAppId: undefined, + effectiveSessionRelayId: undefined, + expectedWslDistro: null, + baseEnv: undefined, + effectiveLaunchConfig: undefined, + preAllocatedHandle: null, + agentTeamsLeaderHandle: null, + requestedAgentTeamsPath: undefined, + agentTeamsEnvToDelete: undefined, + stablePaneKey: null, + verifiedLeafId: null, + metadataLeafId: null, + metadataPaneKey: null, + legacySpawnPaneKey: null, + migrationUnsupportedPaneKey: null, + reservationPaneKey: null, + validatedPaneKey: null, + validatedLeafId: null, + effectiveShellOverride: undefined, + nativeWindowsConptySpawn: false, + codexSelectionTarget: { runtime: 'host' }, + codexResumeLaunch: noCodexResumeLaunch(undefined), + launchCommand: undefined, + env: undefined, + selectedCodexHomePath: null, + spawnEnv: undefined, + spawnOptions: { cols: args.cols, rows: args.rows }, + combinedEnvToDelete: undefined, + skipCodexHomeEnv: false, + stripInheritedOrcaCodexHome: false, + codexResumeHomeSelected: false, + hadSessionSizeBeforeAttach: false, + sessionSizeBeforeAttach: undefined, + initiallyHidden: false, + preSpawnHiddenMarkId: null + } +} diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts new file mode 100644 index 00000000000..68e1cc62227 --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -0,0 +1,123 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { + AgentProviderSessionMetadata, + SleepingAgentLaunchConfig +} from '../../../../shared/agent-session-resume' +import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' +import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TerminalStartupCwdMissingDirFallback } from '../../../../shared/terminal-startup-cwd' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { Store } from '../../../persistence' +import type { PtySpawnResult } from '../../../providers/types' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import type { CodexSessionResumePreparation } from '../../../codex/codex-session-resume-home' +import type { + CodexHomePtySpawnedLifecycleArgs, + GetSelectedCodexHomePath, + PrepareClaudeAuth, + PrepareCodexSessionResume +} from '../host-env/types' +import type { CodexResumeLaunch, PreparedCodexResumeHome } from '../host-env/codex-resume' +import type { StablePaneOwner } from '../pane/stable-owner' + +export type PtySpawnIpcArgs = { + cols: number + rows: number + cwd?: string + // Why: fresh local spawns opt into recovering a saved cwd whose dir was deleted (#7239); reattach/remote need exact cwd, so the flag alone isn't sufficient. + cwdFallback?: 'worktree' + env?: Record + envToDelete?: string[] + command?: string + commandDelivery?: 'renderer' | 'provider' + launchConfig?: SleepingAgentLaunchConfig + resumeProviderSession?: AgentProviderSessionMetadata + launchToken?: unknown + launchAgent?: TuiAgent + startupCommandDelivery?: StartupCommandDelivery + connectionId?: string | null + worktreeId?: string + sessionId?: string + shellOverride?: string + projectRuntime?: ProjectExecutionRuntimeResolution + terminalColorQueryReplies?: { + foreground?: unknown + background?: unknown + } + // Why: hidden-at-spawn declaration (terminal-query-authority.md §races) — main marks hidden before byte zero so the gate owns spawn-time queries. + initiallyHidden?: boolean + // Why: closes the SIGKILL race (INVESTIGATION.md) by letting main sync-flush the binding before pty:spawn returns; only the Ctrl+T daemon-host path threads these. + tabId?: string + leafId?: string + // Why: renderer-threaded launch telemetry (telemetry-plan.md§Agent launch semantics); loosely typed because the main-side schema validator is the single enforcement point. + telemetry?: { + agent_kind?: unknown + launch_source?: unknown + request_kind?: unknown + } +} + +export type AdoptStablePaneArgs = { + cols: number + rows: number + cwd?: string + connectionId?: string | null + worktreeId: string + preAllocatedHandle?: string + tabId: string + leafId: string + ownsPaneSpawnReservation?: true +} + +export type AdoptStablePaneResult = { + result: PtySpawnResult + owner: StablePaneOwner + materialized?: true +} + +export type PtySpawnIpcDeps = { + runtime?: OrcaRuntimeService + store?: Store + getSettings?: () => GlobalSettings + getSelectedCodexHomePath?: GetSelectedCodexHomePath + prepareClaudeAuth?: PrepareClaudeAuth + options?: { + prepareCodexSessionResume?: PrepareCodexSessionResume + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + } + getLocalPtyStartupPromise: (connectionId?: string | null) => Promise | undefined + adoptStablePane: (args: AdoptStablePaneArgs) => Promise + assertFolderWorkspacePtyPathUsable: (worktreeId: string | undefined) => Promise | void + resolvePtySpawnStartupCwd: ( + worktreeId: string | undefined, + cwd: string | undefined, + missingDirFallback?: TerminalStartupCwdMissingDirFallback + ) => string | undefined + localStartupCwdDirectoryExists: (path: string) => boolean + prepareCodexResumeHome: (args: { + connectionId?: string | null + launchAgent?: TuiAgent + providerSession?: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string + }) => PreparedCodexResumeHome | null + noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch + resolveCodexResumeLaunch: ( + command: string | undefined, + prepared: PreparedCodexResumeHome + ) => Promise + reconcileSharedRuntimeResumeHome: ( + resumeHome: Extract, + resolveCurrent: () => string | null + ) => string + stripSequencedStartupResumeArgv: | undefined>( + env: T, + launch: CodexResumeLaunch + ) => T + transitionSpawnHiddenRendererPtyDeliveryState: (id: string, hidden: boolean) => void + trustedTerminalHandleEnv: Set + sendPtySpawnedToRenderer: (id: string) => void + syncPtyBackgroundedDelivery: (id: string, caller: string) => void +} diff --git a/src/main/ipc/pty/ipc/spawn.ts b/src/main/ipc/pty/ipc/spawn.ts new file mode 100644 index 00000000000..6fd5183a36d --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn.ts @@ -0,0 +1,16 @@ +import { getPtyIpc } from '../../pty-host-bindings' +import { runPtyIpcSpawn } from './spawn-run' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +export function installPtySpawnIpcHandler(deps: PtySpawnIpcDeps): void { + const ipcMain = getPtyIpc() + const { getLocalPtyStartupPromise } = deps + + ipcMain.handle('pty:spawn', async (_event, args: PtySpawnIpcArgs) => { + const startupPromise = getLocalPtyStartupPromise(args.connectionId) + if (startupPromise) { + await startupPromise + } + return runPtyIpcSpawn(deps, args) + }) +} diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts new file mode 100644 index 00000000000..fbfc09d1d4d --- /dev/null +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -0,0 +1,204 @@ +import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { IPtyProvider } from '../../../providers/types' +import { isPtyWriteUnavailableError } from '../../../providers/pty-write-unavailable-error' +import { + isTerminalInputTooLargeWithDeferredMeasurement, + iterateTerminalInputChunks +} from '../../../../shared/terminal-input' +import { ptyOwnership } from '../provider/ownership-state' +import { tryGetProviderForPty } from '../provider/registry' +import { + interactiveOutputCharsByPty, + lastInputAtByPty, + visibleRendererPtys +} from '../delivery/visibility-state' + +export function isMainWindowPtyIpcEvent( + event: IpcMainEvent | IpcMainInvokeEvent, + mainWindow: BrowserWindow, + mainWebContents: WebContents +): boolean { + return ( + event.sender === mainWebContents && + !mainWindow.isDestroyed() && + !(typeof mainWebContents.isDestroyed === 'function' && mainWebContents.isDestroyed()) + ) +} + +export type PtyWritePayload = { id: string; data: string } +export type PtyViewportClaimPayload = { id: string; cols: number; rows: number } + +export function createPtyWriteInput(deps: { + mainWindow: BrowserWindow + runtime?: OrcaRuntimeService + clearHiddenRendererResizeOutput: (id: string) => void +}): { + writePtyInput: (args: PtyWritePayload) => boolean | Promise + writePtyInputAccepted: (args: PtyWritePayload) => boolean | Promise + isPtyWritePayload: (value: unknown) => value is PtyWritePayload + isPtyViewportClaimPayload: (value: unknown) => value is PtyViewportClaimPayload + isPtyWriteEventFromMainWindow: ( + event: IpcMainEvent | IpcMainInvokeEvent, + mainWebContents: WebContents + ) => boolean +} { + const { mainWindow, runtime, clearHiddenRendererResizeOutput } = deps + + const reportUnavailablePtyWrite = (id: string, error: unknown): void => { + if ( + !isPtyWriteUnavailableError(error) || + mainWindow.isDestroyed() || + (typeof mainWindow.webContents.isDestroyed === 'function' && + mainWindow.webContents.isDestroyed()) + ) { + return + } + mainWindow.webContents.send('pty:writeUnavailable', { id }) + } + + const writePtyProviderInputWithinLimit = ( + provider: IPtyProvider, + id: string, + data: string + ): boolean | Promise => { + const chunks = iterateTerminalInputChunks(data) + const first = chunks.next() + if (first.done) { + provider.write(id, data) + return true + } + const second = chunks.next() + if (second.done) { + provider.write(id, first.value) + return true + } + return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value) + } + + const writePtyProviderInput = ( + provider: IPtyProvider, + id: string, + data: string + ): boolean | Promise => { + try { + const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) + if (typeof tooLarge === 'boolean') { + return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data) + } + return tooLarge + .then((result) => (result ? false : writePtyProviderInputWithinLimit(provider, id, data))) + .catch((error) => { + reportUnavailablePtyWrite(id, error) + return false + }) + } catch (error) { + reportUnavailablePtyWrite(id, error) + return false + } + } + + const writePtyProviderInputChunks = async ( + provider: IPtyProvider, + id: string, + chunks: Iterator, + firstChunk: string, + secondChunk: string + ): Promise => { + try { + let chunk: IteratorResult = { done: false, value: firstChunk } + let nextChunk: IteratorResult = { done: false, value: secondChunk } + while (!chunk.done) { + provider.write(id, chunk.value) + if (!nextChunk.done) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + chunk = nextChunk + nextChunk = chunks.next() + } + return true + } catch (error) { + reportUnavailablePtyWrite(id, error) + return false + } + } + + const isPtyWritePayload = (value: unknown): value is PtyWritePayload => + typeof value === 'object' && + value !== null && + typeof (value as { id?: unknown }).id === 'string' && + (value as { id: string }).id.length > 0 && + typeof (value as { data?: unknown }).data === 'string' + + const isPtyViewportClaimPayload = (value: unknown): value is PtyViewportClaimPayload => + typeof value === 'object' && + value !== null && + typeof (value as { id?: unknown }).id === 'string' && + (value as { id: string }).id.length > 0 && + typeof (value as { cols?: unknown }).cols === 'number' && + Number.isFinite((value as { cols: number }).cols) && + typeof (value as { rows?: unknown }).rows === 'number' && + Number.isFinite((value as { rows: number }).rows) && + (value as { cols: number }).cols > 0 && + (value as { rows: number }).rows > 0 + + const isPtyWriteEventFromMainWindow = ( + event: IpcMainEvent | IpcMainInvokeEvent, + mainWebContents: WebContents + ): boolean => isMainWindowPtyIpcEvent(event, mainWindow, mainWebContents) + + const writePtyInput = (args: PtyWritePayload): boolean | Promise => { + // Why: mobile-presence-lock defense-in-depth — the renderer's onData guard can let one keystroke slip during the state-flip lag, so catch it server-side. See docs/mobile-presence-lock.md. + if (runtime?.getDriver(args.id).kind === 'mobile') { + return false + } + const provider = ptyOwnership.has(args.id) ? tryGetProviderForPty(args.id) : undefined + if (!provider) { + return false + } + try { + const now = performance.now() + lastInputAtByPty.set(args.id, now) + interactiveOutputCharsByPty.set(args.id, 0) + if (visibleRendererPtys.has(args.id)) { + clearHiddenRendererResizeOutput(args.id) + } + return writePtyProviderInput(provider, args.id, args.data) + } catch { + return false + } + } + + const writePtyInputAccepted = (args: PtyWritePayload): boolean | Promise => { + if (runtime?.getDriver(args.id).kind === 'mobile') { + return false + } + // Why: the ack infers Ctrl+C/Escape reached the local PTY; SSH providers are fire-and-forget relay notifications and can't truthfully acknowledge yet. + if (ptyOwnership.get(args.id) !== null) { + return false + } + const provider = tryGetProviderForPty(args.id) + if (!provider?.hasPty?.(args.id)) { + return false + } + try { + const now = performance.now() + lastInputAtByPty.set(args.id, now) + interactiveOutputCharsByPty.set(args.id, 0) + if (visibleRendererPtys.has(args.id)) { + clearHiddenRendererResizeOutput(args.id) + } + return writePtyProviderInput(provider, args.id, args.data) + } catch { + return false + } + } + + return { + writePtyInput, + writePtyInputAccepted, + isPtyWritePayload, + isPtyViewportClaimPayload, + isPtyWriteEventFromMainWindow + } +} diff --git a/src/main/ipc/pty/ipc/write.ts b/src/main/ipc/pty/ipc/write.ts new file mode 100644 index 00000000000..417f2daed70 --- /dev/null +++ b/src/main/ipc/pty/ipc/write.ts @@ -0,0 +1,74 @@ +import type { BrowserWindow } from 'electron' +import { getPtyIpc } from '../../pty-host-bindings' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { createPtyWriteInput } from './write-input' + +export function installPtyWriteIpcHandlers(deps: { + mainWindow: BrowserWindow + runtime?: OrcaRuntimeService + clearHiddenRendererResizeOutput: (id: string) => void +}): void { + const ipcMain = getPtyIpc() + const { mainWindow, runtime } = deps + const { + writePtyInput, + writePtyInputAccepted, + isPtyWritePayload, + isPtyViewportClaimPayload, + isPtyWriteEventFromMainWindow + } = createPtyWriteInput(deps) + + const hostViewportClaimTails = new Map>() + + ipcMain.on('pty:write', (event, args: unknown) => { + if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { + return + } + const claimTail = hostViewportClaimTails.get(args.id) + if (claimTail) { + void claimTail.then((claimed) => (claimed ? writePtyInput(args) : false)) + return + } + writePtyInput(args) + }) + ipcMain.handle('pty:writeAccepted', (event, args: unknown): boolean | Promise => { + if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || !isPtyWritePayload(args)) { + return false + } + const claimTail = hostViewportClaimTails.get(args.id) + return claimTail + ? claimTail.then((claimed) => (claimed ? writePtyInputAccepted(args) : false)) + : writePtyInputAccepted(args) + }) + + ipcMain.removeAllListeners('pty:claimViewport') + ipcMain.on('pty:claimViewport', (event, args: unknown) => { + if ( + !isPtyWriteEventFromMainWindow(event, mainWindow.webContents) || + !runtime || + !isPtyViewportClaimPayload(args) + ) { + return + } + const prior = hostViewportClaimTails.get(args.id) + // Why: two panes can mirror one PTY — never let a later no-op claim replace the in-flight resize that the following host input must await. + const claim = ( + prior + ? prior.then( + () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows), + () => runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) + ) + : runtime.claimRemoteDesktopHost(args.id, args.cols, args.rows) + ).catch((error) => { + // Why: a failed claim silently discards every gated keystroke for this pane. + console.error('[pty] remote desktop host claim failed; gated input will be discarded', error) + return false + }) + hostViewportClaimTails.set(args.id, claim) + void claim.then(() => { + if (hostViewportClaimTails.get(args.id) === claim) { + hostViewportClaimTails.delete(args.id) + } + }) + }) +} diff --git a/src/main/ipc/pty/kill-all.ts b/src/main/ipc/pty/kill-all.ts new file mode 100644 index 00000000000..307bfaeabb2 --- /dev/null +++ b/src/main/ipc/pty/kill-all.ts @@ -0,0 +1,11 @@ +import { LocalPtyProvider } from '../../providers/local-pty-provider' +import { localProvider } from './provider/registry' + +/** + * Kill in-process local PTYs. Daemon-backed PTYs are preserved by daemon disconnect. + */ +export function killAllPty(): void { + if (localProvider instanceof LocalPtyProvider) { + localProvider.killAll() + } +} diff --git a/src/main/ipc/pty/pane/adopt-stable.ts b/src/main/ipc/pty/pane/adopt-stable.ts new file mode 100644 index 00000000000..7c1a7d5c818 --- /dev/null +++ b/src/main/ipc/pty/pane/adopt-stable.ts @@ -0,0 +1,86 @@ +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { Store } from '../../../persistence' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { getProvider } from '../provider/registry' +import { makePaneSpawnReservationKey, paneSpawnReservationsByOwnerKey } from './spawn-reservation' +import { + attachStablePaneOwner, + resolveStablePaneOwner, + stablePaneAdoptionsByOwnerKey +} from './stable-owner' +import type { AdoptStablePaneArgs, AdoptStablePaneResult } from '../ipc/spawn-types' + +export async function adoptStablePane( + runtime: OrcaRuntimeService | undefined, + store: Store | undefined, + args: AdoptStablePaneArgs +): Promise { + const paneKey = makePaneKey(args.tabId, args.leafId) + const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) + const pendingAdoption = ownerKey ? stablePaneAdoptionsByOwnerKey.get(ownerKey) : undefined + if (pendingAdoption) { + return await pendingAdoption + } + const activePaneSpawn = + ownerKey && !args.ownsPaneSpawnReservation + ? paneSpawnReservationsByOwnerKey.get(ownerKey) + : undefined + if (activePaneSpawn) { + const result = await activePaneSpawn.promise + const owner = resolveStablePaneOwner( + runtime, + store, + paneKey, + args.worktreeId, + args.connectionId + ) + if ( + !owner || + owner.ptyId !== result.id || + (owner.incarnationId !== undefined && + result.incarnationId !== undefined && + owner.incarnationId !== result.incarnationId) + ) { + throw new Error('terminal_pane_owner_changed') + } + return { + result: { + ...result, + isReattach: true, + ...(owner.incarnationId ? { incarnationId: owner.incarnationId } : {}) + }, + owner, + materialized: true as const + } + } + const owner = resolveStablePaneOwner(runtime, store, paneKey, args.worktreeId, args.connectionId) + if (!owner) { + return null + } + const adoption = attachStablePaneOwner({ + runtime, + store, + provider: getProvider(args.connectionId), + spawnOptions: { + cols: args.cols, + rows: args.rows, + cwd: args.cwd + }, + owner, + worktreeId: args.worktreeId, + connectionId: args.connectionId, + resolveOwner: () => + resolveStablePaneOwner(runtime, store, paneKey, args.worktreeId, args.connectionId) + }) + if (!ownerKey) { + return await adoption + } + stablePaneAdoptionsByOwnerKey.set(ownerKey, adoption) + try { + return await adoption + } finally { + if (stablePaneAdoptionsByOwnerKey.get(ownerKey) === adoption) { + stablePaneAdoptionsByOwnerKey.delete(ownerKey) + } + } +} diff --git a/src/main/ipc/pty/pane/agent-session-owners.ts b/src/main/ipc/pty/pane/agent-session-owners.ts new file mode 100644 index 00000000000..291eaab6462 --- /dev/null +++ b/src/main/ipc/pty/pane/agent-session-owners.ts @@ -0,0 +1,126 @@ +import type { AgentSessionOwnerBinding } from '../../../../shared/agent-session-host-authority' +import { ClaimedAgentPtyOwnerRegistry } from '../../../../shared/claimed-agent-pty-owner' +import { isPtyIncarnationId } from '../../../../shared/pty-incarnation' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { IPtyProvider, PtySpawnResult } from '../../../providers/types' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' +import { + localProvider, + sshProviders, + tryGetProviderForAgentSessionOwner +} from '../provider/registry' + +// Why: one main process can route the same remote provider namespace through +// multiple SSH relays; coordinate claims above every provider boundary too. +export const agentSessionOwners = new ClaimedAgentPtyOwnerRegistry() +let agentSessionOwnerReconciliation: Promise | null = null +// Why: this reconcile gates spawn; a wedged relay listing must fail closed (rejecting +// with unknown ownership) instead of blocking the spawn path indefinitely. +const OWNER_LISTING_DEADLINE_MS = 5_000 + +// Why: restore payloads (reattach snapshot / cold-restore scrollback / relay +// replay + lastTitle) ride spawn RPC results, never onPtyData, so EVERY spawn +// choke point — renderer pty:spawn and the runtime controller — must seed the +// terminal list/read records or headless/CLI-created reattaches stay blank. +// The runtime's empty-record guard makes a second seed for the same session a +// no-op, so overlapping paths cannot double-apply history. +export function seedTerminalRestoreRecordsFromSpawnResult( + runtime: OrcaRuntimeService | undefined, + result: PtySpawnResult +): void { + const text = + typeof result.snapshot === 'string' && result.snapshot.length > 0 + ? result.snapshot + : typeof result.coldRestore?.scrollback === 'string' && + result.coldRestore.scrollback.length > 0 + ? result.coldRestore.scrollback + : typeof result.replay === 'string' && result.replay.length > 0 + ? result.replay + : undefined + const lastTitle = + typeof result.lastTitle === 'string' && result.lastTitle.length > 0 + ? result.lastTitle + : typeof result.coldRestore?.lastTitle === 'string' && result.coldRestore.lastTitle.length > 0 + ? result.coldRestore.lastTitle + : undefined + if (text !== undefined || lastTitle !== undefined) { + runtime?.seedTerminalRestoreTail?.(result.id, { + ...(text !== undefined ? { text } : {}), + ...(lastTitle !== undefined ? { lastTitle } : {}) + }) + } +} + +export function assertSpawnReplyWasLive(result: PtySpawnResult): void { + if (!result.exitedBeforeSpawnReply) { + return + } + // Why: lower owners can resolve a different canonical id, so controller-local pending ids cannot prove this exit. + throw Object.assign(new Error('agent_session_exited_during_start'), { + agentSessionOperationOutcome: 'unknown' as const + }) +} + +export async function reconcileAgentSessionOwnerListings(): Promise { + if (agentSessionOwnerReconciliation) { + return await agentSessionOwnerReconciliation + } + const reconciliation = (async () => { + const providers: { provider: IPtyProvider; connectionId: string | null }[] = [ + { provider: localProvider, connectionId: null }, + ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) + ] + const deadlineMs = Date.now() + OWNER_LISTING_DEADLINE_MS + const listings = await Promise.all( + providers.map(async ({ provider, connectionId }) => ({ + connectionId, + sessions: await provider.listProcesses({ deadlineMs }).catch(() => { + throw new Error('agent_session_ownership_unknown') + }) + })) + ) + const advertisedOwners: AgentSessionOwnerBinding[] = [] + const advertisedOwnerSessions: { + id: string + connectionId: string | null + incarnationId: string + }[] = [] + for (const { connectionId, sessions } of listings) { + for (const session of sessions) { + const incarnationId = session.incarnationId + let hasAdvertisedOwner = false + for (const owner of session.agentSessionOwners ?? []) { + if (owner.ptyId !== session.id || !isPtyIncarnationId(incarnationId)) { + // Why: a recovered claim without process-incarnation proof cannot safely reject a delayed exit. + throw new Error('agent_session_ownership_unknown') + } + advertisedOwners.push(owner) + hasAdvertisedOwner = true + } + if (hasAdvertisedOwner && isPtyIncarnationId(incarnationId)) { + advertisedOwnerSessions.push({ id: session.id, connectionId, incarnationId }) + } + } + } + agentSessionOwners.reconcileAuthoritative(advertisedOwners, { + // Why: an unregistered relay can still own a live PTY during reconnect; + // only providers that serialize claims may make listing absence authoritative. + isInAuthoritativeScope: (owner) => { + const provider = tryGetProviderForAgentSessionOwner(owner.ptyId) + return provider?.providesAgentSessionOwnerListings?.(owner.ptyId) === true + } + }) + for (const session of advertisedOwnerSessions) { + ptyOwnership.set(session.id, session.connectionId) + ptyIncarnationById.set(session.id, session.incarnationId) + } + })() + agentSessionOwnerReconciliation = reconciliation + try { + await reconciliation + } finally { + if (agentSessionOwnerReconciliation === reconciliation) { + agentSessionOwnerReconciliation = null + } + } +} diff --git a/src/main/ipc/pty/pane/key-state.ts b/src/main/ipc/pty/pane/key-state.ts new file mode 100644 index 00000000000..0a7881140b6 --- /dev/null +++ b/src/main/ipc/pty/pane/key-state.ts @@ -0,0 +1,47 @@ +import { parsePaneKey } from '../../../../shared/stable-pane-id' + +// Why: track spawn-time paneKey so teardown can clear the agent-hooks server's per-paneKey cache, which otherwise grows unbounded as panes come and go. +export const ptyPaneKey = new Map() +// Why: reverse of ptyPaneKey — callers with a paneKey from outside the PTY lifecycle (e.g. agent-hook status routing) need the ptyId; kept in lock-step via the same sites. +export const paneKeyPtyId = new Map() + +export function getPtyIdForPaneKey(paneKey: string): string | undefined { + return paneKeyPtyId.get(paneKey) +} + +// Why: let consumers tear down paneKey-scoped state on PTY exit so their timers can't leak; a callback registry keeps the cross-module dependency narrow. +export type PaneKeyTeardownListener = (paneKey: string) => void +export const paneKeyTeardownListeners = new Set() + +export function registerPaneKeyTeardownListener(listener: PaneKeyTeardownListener): () => void { + paneKeyTeardownListeners.add(listener) + return () => paneKeyTeardownListeners.delete(listener) +} + +export function parseValidPaneKey(paneKey: unknown): ReturnType { + if (typeof paneKey !== 'string' || paneKey.length > 256) { + return null + } + return parsePaneKey(paneKey) +} + +export function isValidPaneKey(paneKey: unknown): paneKey is string { + return parseValidPaneKey(paneKey) !== null +} + +export function rememberPaneKeyForPty(ptyId: string, paneKey: unknown): string | null { + const normalizedPaneKey = typeof paneKey === 'string' ? paneKey.trim() : '' + if (!isValidPaneKey(normalizedPaneKey)) { + return null + } + // Why: a re-registered ptyId would otherwise leave its old paneKey pointing here. + const previousPaneKey = ptyPaneKey.get(ptyId) + if (previousPaneKey && previousPaneKey !== normalizedPaneKey) { + if (paneKeyPtyId.get(previousPaneKey) === ptyId) { + paneKeyPtyId.delete(previousPaneKey) + } + } + ptyPaneKey.set(ptyId, normalizedPaneKey) + paneKeyPtyId.set(normalizedPaneKey, ptyId) + return normalizedPaneKey +} diff --git a/src/main/ipc/pty/pane/launch-authority.ts b/src/main/ipc/pty/pane/launch-authority.ts new file mode 100644 index 00000000000..93f3061f0b3 --- /dev/null +++ b/src/main/ipc/pty/pane/launch-authority.ts @@ -0,0 +1,41 @@ +import type { TuiAgent } from '../../../../shared/tui-agent' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import { isPtyIncarnationId } from '../../../../shared/pty-incarnation' + +const AGENT_LAUNCH_TOKEN_MAX_LENGTH = 128 + +export function admitRendererAgentLaunchAuthority(args: { + launchToken: unknown + spawnEnv: Record | undefined + launchAgent: unknown + launchConfig: SleepingAgentLaunchConfig | undefined + isReattach: boolean + hasStablePaneOwner: boolean + incarnationId: unknown +}): { launchToken: string; launchAgent: TuiAgent } | null { + if ( + args.isReattach || + args.hasStablePaneOwner || + !args.launchConfig || + !isTuiAgent(args.launchAgent) || + !isPtyIncarnationId(args.incarnationId) || + typeof args.launchToken !== 'string' || + args.launchToken.length === 0 || + args.launchToken.length > AGENT_LAUNCH_TOKEN_MAX_LENGTH || + args.spawnEnv?.ORCA_AGENT_LAUNCH_TOKEN !== args.launchToken + ) { + return null + } + return { launchToken: args.launchToken, launchAgent: args.launchAgent } +} + +export function shouldRefreshNativeClaudeAgentTeamsEnv(args: { + command?: string + launchConfig?: SleepingAgentLaunchConfig +}): boolean { + const capturedCommand = args.launchConfig?.agentCommand?.trim() || args.command?.trim() || '' + const capturedArgs = args.launchConfig?.agentArgs?.trim() ?? '' + const capturedLaunch = `${capturedCommand} ${capturedArgs}`.trim() + return /(^|\s)--teammate-mode(?:=|\s+)auto(?:\s|$)/.test(capturedLaunch) +} diff --git a/src/main/ipc/pty/pane/serializer-state.ts b/src/main/ipc/pty/pane/serializer-state.ts new file mode 100644 index 00000000000..ec3f8dacbc4 --- /dev/null +++ b/src/main/ipc/pty/pane/serializer-state.ts @@ -0,0 +1,71 @@ +import type { WebContents } from 'electron' +import { RendererTerminalSerializerReadiness } from '../../renderer-terminal-serializer-readiness' +import { isValidPaneKey, paneKeyPtyId } from './key-state' + +// Why: renderer pre-declares serializer ownership before pty:spawn to suppress the daemon-snapshot seed; gen tokens prevent paneKey-reuse races on teardown. See docs/mobile-prefer-renderer-scrollback.md. +export let pendingSerializerGenSeq = 0 +export const pendingByPaneKey = new Map< + string, + { gen: number; ownerWebContentsId: number | null } +>() +export const pendingPaneSerializerCleanupRegistered = new Set() +// Why: bind the declaration generation directly to its spawn result. PTY ids +// are reusable and teardown callbacks carry no incarnation token, so teardown +// must never guess which pending renderer generation it owns. +export const pendingPtyIdBySerializerGeneration = new Map() +// Why: hasRendererSerializer probe needs a ptyId-keyed signal; a later spawn starts a fresh incarnation, subscription abort owns waiter cleanup. +export const rendererSerializerReadiness = new RendererTerminalSerializerReadiness() + +export function cleanupPendingPaneSerializersForSender(ownerWebContentsId: number): void { + pendingPaneSerializerCleanupRegistered.delete(ownerWebContentsId) + for (const [paneKey, pending] of pendingByPaneKey) { + if (pending.ownerWebContentsId === ownerWebContentsId) { + pendingByPaneKey.delete(paneKey) + pendingPtyIdBySerializerGeneration.delete(pending.gen) + } + } +} + +export function registerPendingPaneSerializerCleanup(sender: WebContents | undefined): void { + if (!sender || pendingPaneSerializerCleanupRegistered.has(sender.id)) { + return + } + pendingPaneSerializerCleanupRegistered.add(sender.id) + sender.once('destroyed', () => cleanupPendingPaneSerializersForSender(sender.id)) +} + +export function declarePendingPaneSerializer( + paneKey: string, + sender: WebContents | undefined +): number { + const gen = ++pendingSerializerGenSeq + // Why: a sender destroyed before the declaration never fires 'destroyed' again, + // so its entry would strand in pendingByPaneKey and suppress the daemon seed forever. + if (sender?.isDestroyed()) { + cleanupPendingPaneSerializersForSender(sender.id) + return gen + } + registerPendingPaneSerializerCleanup(sender) + const replaced = pendingByPaneKey.get(paneKey) + if (replaced) { + pendingPtyIdBySerializerGeneration.delete(replaced.gen) + } + pendingByPaneKey.set(paneKey, { gen, ownerWebContentsId: sender?.id ?? null }) + const existingPtyId = paneKeyPtyId.get(paneKey) + if (existingPtyId) { + pendingPtyIdBySerializerGeneration.set(gen, existingPtyId) + } + return gen +} + +export function settlePendingPaneSerializer(paneKey: string, gen: number): boolean { + if (pendingByPaneKey.get(paneKey)?.gen !== gen) { + return false + } + pendingByPaneKey.delete(paneKey) + return true +} + +export function hasPendingRendererSerializerForPaneKey(paneKey: string): boolean { + return isValidPaneKey(paneKey) && pendingByPaneKey.has(paneKey) +} diff --git a/src/main/ipc/pty/pane/spawn-reservation.ts b/src/main/ipc/pty/pane/spawn-reservation.ts new file mode 100644 index 00000000000..47c968b9b5a --- /dev/null +++ b/src/main/ipc/pty/pane/spawn-reservation.ts @@ -0,0 +1,115 @@ +import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { PtySpawnResult } from '../../../providers/types' + +export type PaneSpawnReservation = { + promise: Promise + resolve: (result: PaneSpawnReservationResult) => void + reject: (error: unknown) => void +} +export type PaneSpawnReservationResult = { + id: string + launchConfig?: SleepingAgentLaunchConfig + stablePaneOwner?: { + handle: string + tabId: string + leafId: string + } +} & Partial + +// Why: identical pane coordinates on different worktrees or hosts are independent owners. +export const paneSpawnReservationsByOwnerKey = new Map() +export type PendingRuntimePaneCreate = { + count: number + promise: Promise + resolve: () => void +} +export const pendingRuntimePaneCreatesByOwnerKey = new Map() + +export function reservePaneSpawn(paneKey: string): PaneSpawnReservation { + let resolve!: (result: PaneSpawnReservationResult) => void + let reject!: (error: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + promise.catch(() => {}) + const reservation = { promise, resolve, reject } + paneSpawnReservationsByOwnerKey.set(paneKey, reservation) + return reservation +} + +export function clearPaneSpawnReservation( + paneKey: string, + reservation: PaneSpawnReservation +): void { + if (paneSpawnReservationsByOwnerKey.get(paneKey) === reservation) { + paneSpawnReservationsByOwnerKey.delete(paneKey) + } +} + +export function makePaneSpawnReservationKey( + worktreeId: string | undefined, + connectionId: string | null | undefined, + paneKey: string | null | undefined +): string | null { + return paneKey ? JSON.stringify([connectionId ?? null, worktreeId ?? null, paneKey]) : null +} + +export function claimRuntimePaneCreate(ownerKey: string): () => void { + const existing = pendingRuntimePaneCreatesByOwnerKey.get(ownerKey) + if (existing) { + existing.count += 1 + return () => releaseRuntimePaneCreate(ownerKey, existing) + } + let resolve!: () => void + const claim = { + count: 1, + promise: new Promise((done) => { + resolve = done + }), + resolve: () => resolve() + } + pendingRuntimePaneCreatesByOwnerKey.set(ownerKey, claim) + return () => releaseRuntimePaneCreate(ownerKey, claim) +} + +export function releaseRuntimePaneCreate(ownerKey: string, claim: PendingRuntimePaneCreate): void { + if (pendingRuntimePaneCreatesByOwnerKey.get(ownerKey) !== claim) { + return + } + claim.count -= 1 + if (claim.count > 0) { + return + } + pendingRuntimePaneCreatesByOwnerKey.delete(ownerKey) + claim.resolve() +} + +export function rejectPaneSpawnReservation( + paneKey: string | null | undefined, + reservation: PaneSpawnReservation | null | undefined, + error: unknown +): void { + if (!reservation) { + return + } + reservation.reject(error) + if (paneKey) { + clearPaneSpawnReservation(paneKey, reservation) + } +} + +export function resolvePaneSpawnReservation( + paneKey: string | null | undefined, + reservation: PaneSpawnReservation | null | undefined, + response: T +): T { + if (!reservation) { + return response + } + reservation.resolve(response) + if (paneKey) { + clearPaneSpawnReservation(paneKey, reservation) + } + return response +} diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts new file mode 100644 index 00000000000..9442e914e1f --- /dev/null +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -0,0 +1,293 @@ +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import type { Store } from '../../../persistence' +import { retireTerminalSurfaceFromPersistence } from '../../../runtime/mobile-session-terminal-persistence-retirement' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types' +import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' +import { + isDaemonEndpointGoneError, + TerminalHostGoneError, + TerminalSessionOwnerUnverifiedError +} from '../../../daemon/daemon-errors' +import { ptyIncarnationById, ptyOwnership } from '../provider/ownership-state' +import { isPtyAlreadyGoneError } from '../provider/liveness' +import { clearProviderPtyState } from '../provider/state-cleanup' + +export type StablePaneOwner = { + handle?: string + tabId: string + leafId: string + ptyId: string + incarnationId?: string + hasPersistedBinding?: true + persistedIncarnationId?: string + runtimeIncarnationId?: string +} +export type StablePaneAdoption = { + result: PtySpawnResult + owner: StablePaneOwner + materialized?: true +} | null +export const stablePaneAdoptionsByOwnerKey = new Map>() + +export function resolvePersistedStablePaneOwner( + store: Store | undefined, + paneKey: string, + worktreeId: string, + connectionId: string | null | undefined +): Pick | null { + if (!store || typeof store.getWorkspaceSession !== 'function') { + return null + } + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return null + } + const session = store.getWorkspaceSession( + connectionId ? toSshExecutionHostId(connectionId) : undefined + ) + const tab = session.tabsByWorktree?.[worktreeId]?.find( + (candidate) => candidate.id === parsed.tabId && candidate.worktreeId === worktreeId + ) + const ptyId = session.terminalLayoutsByTabId?.[parsed.tabId]?.ptyIdsByLeafId?.[parsed.leafId] + if (!tab || typeof ptyId !== 'string' || ptyId.length === 0) { + return null + } + const incarnationId = session.terminalPtyIncarnationsByPaneKey?.[paneKey] + return { + tabId: parsed.tabId, + leafId: parsed.leafId, + ptyId, + ...(incarnationId ? { incarnationId } : {}) + } +} + +export function resolveStablePaneOwner( + runtime: OrcaRuntimeService | undefined, + store: Store | undefined, + paneKey: string | null | undefined, + worktreeId: string | undefined, + connectionId: string | null | undefined +): StablePaneOwner | null { + if (!paneKey || !worktreeId) { + return null + } + let resolved: ReturnType | null = null + let resolvedHandleCandidate: ReturnType | null = null + if (runtime && typeof runtime.resolveTerminalPane === 'function') { + try { + const candidate = runtime.resolveTerminalPane(paneKey, worktreeId) + resolvedHandleCandidate = candidate + resolved = candidate.connected === false ? null : candidate + } catch (error) { + if (!(error instanceof Error && error.message === 'terminal_not_found')) { + throw error + } + } + } + const persisted = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) + if (resolved?.ptyId && persisted && resolved.ptyId !== persisted.ptyId) { + throw new Error('terminal_pane_owner_conflict') + } + const ptyId = resolved?.ptyId ?? persisted?.ptyId + if (!ptyId) { + return null + } + const registeredConnectionId = ptyOwnership.get(ptyId) + const parsedSshId = registeredConnectionId === undefined ? parseAppSshPtyId(ptyId) : null + const ownerConnectionId = registeredConnectionId ?? parsedSshId?.connectionId ?? null + if (ownerConnectionId !== (connectionId ?? null)) { + throw new Error('terminal_pane_owner_host_mismatch') + } + const runtimeIncarnationId = ptyIncarnationById.get(ptyId) + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return null + } + return { + ...(resolvedHandleCandidate?.ptyId === ptyId ? { handle: resolvedHandleCandidate.handle } : {}), + tabId: resolved?.tabId || persisted?.tabId || parsed.tabId, + leafId: resolved?.leafId || persisted?.leafId || parsed.leafId, + ptyId, + ...(runtimeIncarnationId || persisted?.incarnationId + ? { incarnationId: runtimeIncarnationId ?? persisted?.incarnationId } + : {}), + ...(persisted ? { hasPersistedBinding: true as const } : {}), + ...(persisted?.incarnationId ? { persistedIncarnationId: persisted.incarnationId } : {}), + ...(runtimeIncarnationId ? { runtimeIncarnationId } : {}) + } +} + +export function retirePersistedStablePaneOwner( + store: Store | undefined, + owner: StablePaneOwner, + worktreeId: string, + connectionId: string | null | undefined +): boolean { + if (!store) { + return false + } + const paneKey = makePaneKey(owner.tabId, owner.leafId) + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + const current = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId) + if (!current) { + // Why: persistence already dropped this pane binding (an earlier stop retired it while the + // runtime kept history), so there is nothing left to clear — that is a completed retirement, + // not a competing owner. Reporting failure here strands the pane after its PTY is proven dead. + return true + } + if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { + return false + } + const session = store.getWorkspaceSession(hostId) + const retired = retireTerminalSurfaceFromPersistence(session, { + worktreeId, + parentTabId: owner.tabId, + leafId: owner.leafId, + ptyId: owner.ptyId, + ...(current.incarnationId ? { incarnationId: current.incarnationId } : {}) + }) + if (retired === session) { + return false + } + store.setWorkspaceSession(retired, hostId) + store.flushOrThrow() + return true +} + +export type StablePaneSpawnContext = { + runtime: OrcaRuntimeService | undefined + store?: Store + provider: IPtyProvider + spawnOptions: PtySpawnOptions + owner: StablePaneOwner | null + worktreeId?: string + connectionId?: string | null + resolveOwner?: () => StablePaneOwner | null + onFreshSpawn?: (result: PtySpawnResult) => void +} + +export function stablePanePersistenceFence( + owner: StablePaneOwner | null +): { ptyId: string; incarnationId?: string } | undefined { + return owner?.hasPersistedBinding + ? { + ptyId: owner.ptyId, + ...(owner.persistedIncarnationId ? { incarnationId: owner.persistedIncarnationId } : {}) + } + : undefined +} + +export function persistAdmittedStablePaneBinding(args: { + store: Store | undefined + owner: StablePaneOwner | null + result: PtySpawnResult + worktreeId: string | undefined + startupCwd: string | undefined + connectionId: string | null | undefined +}): boolean { + const expectedBinding = stablePanePersistenceFence(args.owner) + if (!args.store || !args.owner || !args.worktreeId || !expectedBinding) { + return false + } + const persisted = args.store.persistPtyBinding( + { + worktreeId: args.worktreeId, + tabId: args.owner.tabId, + leafId: args.owner.leafId, + ptyId: args.result.id, + ...(args.result.incarnationId ? { incarnationId: args.result.incarnationId } : {}), + ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), + expectedBinding + }, + args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined + ) + if (persisted === false) { + throw new Error('terminal_pane_owner_changed') + } + return true +} + +export async function attachStablePaneOwner( + args: StablePaneSpawnContext & { owner: StablePaneOwner } +): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> { + const { owner, provider, runtime, spawnOptions } = args + let result: PtySpawnResult + try { + result = await provider.spawn({ + ...spawnOptions, + sessionId: owner.ptyId, + attachOnly: true, + expectedIncarnationId: owner.runtimeIncarnationId ?? owner.persistedIncarnationId, + expectedIncarnationIsAuthoritative: owner.runtimeIncarnationId !== undefined, + isNewSession: undefined, + command: undefined, + commandDelivery: undefined, + startupCommandDelivery: undefined, + launchAgent: undefined, + startupIngress: undefined, + agentSessionEnsure: undefined, + agentSessionCreateOperationId: undefined, + onPtySpawnCommitted: undefined + }) + } catch (error) { + if (error instanceof TerminalSessionOwnerUnverifiedError) { + throw new Error('terminal_pane_owner_unverified') + } + // Why: translate before paired-runtime RPC strips the socket error's code and syscall. + if (isDaemonEndpointGoneError(error)) { + throw new TerminalHostGoneError() + } + if (!isPtyAlreadyGoneError(error)) { + throw error + } + const ownerBeforeRetire = args.resolveOwner?.() + if ( + ownerBeforeRetire && + (ownerBeforeRetire.ptyId !== owner.ptyId || + ownerBeforeRetire.runtimeIncarnationId !== owner.runtimeIncarnationId || + ownerBeforeRetire.hasPersistedBinding !== owner.hasPersistedBinding || + ownerBeforeRetire.persistedIncarnationId !== owner.persistedIncarnationId) + ) { + throw new Error('terminal_pane_owner_changed') + } + runtime?.onPtyExit(owner.ptyId, 0, owner.incarnationId) + clearProviderPtyState(owner.ptyId) + ptyOwnership.delete(owner.ptyId) + if ( + args.worktreeId && + !retirePersistedStablePaneOwner(args.store, owner, args.worktreeId, args.connectionId) + ) { + throw new Error('terminal_pane_owner_changed') + } + if (args.resolveOwner?.()) { + throw new Error('terminal_pane_owner_changed') + } + return null + } + if ( + result.id !== owner.ptyId || + result.isReattach !== true || + (owner.runtimeIncarnationId !== undefined && + result.incarnationId !== owner.runtimeIncarnationId) || + (result.incarnationId === undefined && owner.incarnationId !== undefined) + ) { + throw new Error('terminal_pane_owner_changed') + } + return { result, owner } +} + +export async function spawnForStablePane( + args: StablePaneSpawnContext +): Promise<{ result: PtySpawnResult; owner: StablePaneOwner | null }> { + if (args.owner) { + const attached = await attachStablePaneOwner({ ...args, owner: args.owner }) + if (attached) { + return attached + } + } + const result = await args.provider.spawn(args.spawnOptions) + args.onFreshSpawn?.(result) + return { result, owner: null } +} diff --git a/src/main/ipc/pty/provider/bind-listeners.ts b/src/main/ipc/pty/provider/bind-listeners.ts new file mode 100644 index 00000000000..8febd1e3534 --- /dev/null +++ b/src/main/ipc/pty/provider/bind-listeners.ts @@ -0,0 +1,111 @@ +import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { isCurrentPtyExit, ptyOwnership } from './ownership-state' +import { + localBackgroundStreamUnsub, + localDataUnsub, + localExitUnsub, + localWriteUnavailableUnsub, + setLocalBackgroundStreamUnsub, + setLocalDataUnsub, + setLocalExitUnsub, + setLocalWriteUnavailableUnsub +} from './listener-lifecycle' +import { localProvider } from './registry' +import { clearProviderPtyState } from './state-cleanup' +import { providerSnapshotRequiredPtys } from '../delivery/visibility-state' +import type { PtyIpcSession } from '../session' + +export function bindProviderListeners(session: PtyIpcSession): void { + localDataUnsub?.() + localExitUnsub?.() + localBackgroundStreamUnsub?.() + localWriteUnavailableUnsub?.() + + // Why: a daemon death takes down every session at once. The provider signals + // each affected pane here so background panes remount + re-attach too, not + // just the pane whose write happened to detect the dead endpoint (STA-2373). + setLocalWriteUnavailableUnsub( + localProvider.onWriteUnavailable?.((payload) => { + if ( + session.mainWindow.isDestroyed() || + (typeof session.mainWindow.webContents.isDestroyed === 'function' && + session.mainWindow.webContents.isDestroyed()) + ) { + return + } + session.mainWindow.webContents.send('pty:writeUnavailable', { id: payload.id }) + }) ?? null + ) + + // Daemon keep-tail thinning facts, in byte order with onData: markers flip transient-fact scan authority; a gap forces renderer restore from the snapshot. + setLocalBackgroundStreamUnsub( + localProvider.onBackgroundStreamEvent?.((payload) => { + if (payload.kind === 'backgroundMarker') { + session.runtime?.setPtyTransientFactDelegation( + payload.id, + payload.background, + payload.scanSeedAnsi, + payload.mode2031PendingSubscribe + ) + return + } + if (payload.kind === 'dataGap') { + providerSnapshotRequiredPtys.add(payload.id) + session.runtime?.notePtyDataGap(payload.id, payload.sequenceChars ?? payload.droppedChars) + session.sendModelRestoreNeededMarker( + payload.id, + 'hidden-drop', + session.runtime?.getPtyOutputSequence(payload.id) + ) + return + } + session.runtime?.emitDaemonPtyTransientFact(payload.id, payload.fact) + }) ?? null + ) + + // Why: daemon providers lack configure().onData, so feed the runtime here or their tail buffer (terminal.read, agent-detection, mobile stream) stays empty. + const isLocalProvider = localProvider instanceof LocalPtyProvider + + setLocalDataUnsub( + localProvider.onData((payload) => { + const rawLength = payload.sequenceChars ?? payload.data.length + const outputSeq = isLocalProvider + ? session.runtime?.getPtyOutputSequence(payload.id) + : session.runtime?.onPtyData( + payload.id, + payload.data, + Date.now(), + rawLength, + payload.transformed + ) + session.acceptPtyDataForRenderer(payload, outputSeq) + }) + ) + setLocalExitUnsub( + localProvider.onExit((payload) => { + if (!isCurrentPtyExit(payload)) { + return + } + if (session.consumeSyntheticKillExit(payload.id)) { + return + } + if (!isLocalProvider) { + clearProviderPtyState(payload.id) + ptyOwnership.delete(payload.id) + markClaudePtyExited(payload.id) + session.runtime?.onPtyExit(payload.id, payload.code, payload.incarnationId, { + providerExitObserved: true, + ...(payload.cause ? { cause: payload.cause } : {}) + }) + } + // Why not the whole payload: the exit cause is a main-process fact for the + // runtime's records; the renderer's pty:exit contract stays as it was. + session.sendPtyExitToRenderer({ + id: payload.id, + code: payload.code, + ...(payload.incarnationId ? { incarnationId: payload.incarnationId } : {}) + }) + }) + ) +} diff --git a/src/main/ipc/pty/provider/listener-lifecycle.ts b/src/main/ipc/pty/provider/listener-lifecycle.ts new file mode 100644 index 00000000000..d6cb383c9a3 --- /dev/null +++ b/src/main/ipc/pty/provider/listener-lifecycle.ts @@ -0,0 +1,106 @@ +import type { WebContents } from 'electron' + +// Why: localProvider.onData/onExit return unsubscribe functions. Without +// storing and calling these on re-registration, macOS app re-activation +// creates a new BrowserWindow and re-calls registerPtyHandlers, leaking +// duplicate listeners that forward every event twice. +export let localDataUnsub: (() => void) | null = null +export let localExitUnsub: (() => void) | null = null +export let localBackgroundStreamUnsub: (() => void) | null = null +export let localWriteUnavailableUnsub: (() => void) | null = null +export let didFinishLoadHandler: (() => void) | null = null +export let didFinishLoadWebContents: WebContents | null = null +export let rendererLifecycleResetWebContents: WebContents | null = null +export let rendererLifecycleResetHandler: (() => void) | null = null +// Why: the hidden-delivery gate registries mirror renderer state; a reload/crash destroys owners without unregistering, so they reset when the renderer is replaced (drop memory preserved). +export let rendererGateResetLoadHandler: (() => void) | null = null +export let rendererGateResetGoneHandler: (() => void) | null = null +export let rendererGateResetWebContents: WebContents | null = null +// Why: the backgrounded-delivery dedupe map lives in the registerPtyHandlers closure but teardown funnels through module-scope clearProviderPtyState. +export let clearBackgroundedDeliverySyncForPty: (id: string) => void = () => {} + +export type RendererNavigationDetails = { + isMainFrame: boolean + isSameDocument: boolean +} + +// Why: navigation details identify the triggering frame; querying aggregate load state can misclassify an overlapping subframe load. +export let rendererDidStartNavigationHandler: + | ((details: RendererNavigationDetails) => void) + | null = null + +// Why: Restart daemon must re-bind provider→renderer listeners after replaceDaemonProvider swaps localProvider, else subscribers stay bound to the disposed adapter and new PTY data silently drops. +export let rebindProviderListeners: (() => void) | null = null +export let sshOutputIntakeCleanup: (() => void) | null = null + +export function rebindLocalProviderListeners(): void { + rebindProviderListeners?.() +} + +export function setRebindProviderListeners(fn: (() => void) | null): void { + rebindProviderListeners = fn +} + +export function setClearBackgroundedDeliverySyncForPty(fn: (id: string) => void): void { + clearBackgroundedDeliverySyncForPty = fn +} + +export function setSshOutputIntakeCleanup(fn: (() => void) | null): void { + sshOutputIntakeCleanup = fn +} + +export function setLocalDataUnsub(fn: (() => void) | null): void { + localDataUnsub = fn +} + +export function setLocalExitUnsub(fn: (() => void) | null): void { + localExitUnsub = fn +} + +export function setLocalBackgroundStreamUnsub(fn: (() => void) | null): void { + localBackgroundStreamUnsub = fn +} + +export function setLocalWriteUnavailableUnsub(fn: (() => void) | null): void { + localWriteUnavailableUnsub = fn +} + +export function setDidFinishLoadHandler( + handler: (() => void) | null, + contents: WebContents | null +): void { + didFinishLoadHandler = handler + didFinishLoadWebContents = contents +} + +export function setRendererLifecycleResetState(args: { + contents: WebContents | null + handler: (() => void) | null + navigation: ((details: RendererNavigationDetails) => void) | null +}): void { + rendererLifecycleResetWebContents = args.contents + rendererLifecycleResetHandler = args.handler + rendererDidStartNavigationHandler = args.navigation +} + +export function setRendererGateResetState(args: { + contents: WebContents | null + load: (() => void) | null + gone: (() => void) | null +}): void { + rendererGateResetWebContents = args.contents + rendererGateResetLoadHandler = args.load + rendererGateResetGoneHandler = args.gone +} + +// Why: Restart daemon must detach listeners AFTER synthetic pty:exit events fan out but BEFORE replaceDaemonProvider swaps the adapter; this export narrows that window to the caller. +export function unbindLocalProviderListeners(): void { + localDataUnsub?.() + localExitUnsub?.() + localBackgroundStreamUnsub?.() + localWriteUnavailableUnsub?.() + localDataUnsub = null + localExitUnsub = null + localBackgroundStreamUnsub = null + localWriteUnavailableUnsub = null +} diff --git a/src/main/ipc/pty/provider/liveness.ts b/src/main/ipc/pty/provider/liveness.ts new file mode 100644 index 00000000000..a8c005cce06 --- /dev/null +++ b/src/main/ipc/pty/provider/liveness.ts @@ -0,0 +1,142 @@ +import { isRemoteAgentHooksEnabled } from '../../../../shared/agent-hook-relay' +import type { AgentSessionOwnerBinding } from '../../../../shared/agent-session-host-authority' +import { agentSessionOwnerBindingsEqual } from '../../../../shared/claimed-agent-pty-owner' +import { addNodePtyRecoveryHint } from '../../../daemon/node-pty-error-hints' +import type { Store } from '../../../persistence' +import { isSshPtyNotFoundError } from '../../../providers/ssh-pty-errors' +import type { IPtyProvider } from '../../../providers/types' +import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' +import { ptyIncarnationById, ptyOwnership } from './ownership-state' +import { getRelayPtyId } from './registry' +import { + KEEP_HISTORY_STOP_POLL_MS, + KEEP_HISTORY_STOP_SETTLE_MS +} from '../delivery/visibility-state' +import { clearProviderPtyState } from './state-cleanup' + +export function stripRemotePaneEnvWhenHooksDisabled( + connectionId: string | null | undefined, + env: Record | undefined +): Record | undefined { + if (!connectionId || isRemoteAgentHooksEnabled()) { + return env + } + if ( + !env || + (!('ORCA_PANE_KEY' in env) && + !('ORCA_TAB_ID' in env) && + !('ORCA_WORKTREE_ID' in env) && + !('ORCA_AGENT_LAUNCH_TOKEN' in env)) + ) { + return env + } + const stripped = { ...env } + delete stripped.ORCA_PANE_KEY + delete stripped.ORCA_TAB_ID + delete stripped.ORCA_WORKTREE_ID + delete stripped.ORCA_AGENT_LAUNCH_TOKEN + return stripped +} + +export function normalizeNodePtySpawnError(err: unknown): Error { + const rawMessage = err instanceof Error ? err.message : String(err) + const hintedMessage = addNodePtyRecoveryHint(rawMessage) + if (hintedMessage === rawMessage && err instanceof Error) { + return err + } + if (err instanceof Error) { + // Why: preserve the original stack/name/custom fields while adding the same recovery hint as the pty:spawn path. + err.message = hintedMessage + return err + } + return new Error(hintedMessage) +} + +export function isPtyAlreadyGoneError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err) + return isSshPtyNotFoundError(err) || /Session not found/i.test(message) +} + +export function delay(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) + if (typeof timer.unref === 'function') { + timer.unref() + } + }) +} + +export async function isProviderPtyLive( + provider: IPtyProvider, + ptyId: string, + deadlineMs?: number +): Promise { + // Why: bound the liveness list RPC by the teardown deadline so a wedged daemon + // fails fast; undefined keeps the provider default for all other callers. + return (await provider.listProcesses(deadlineMs !== undefined ? { deadlineMs } : undefined)).some( + (session) => session.id === ptyId + ) +} + +export async function isProviderAgentSessionOwnerLive( + provider: IPtyProvider, + owner: AgentSessionOwnerBinding +): Promise { + const session = (await provider.listProcesses()).find((candidate) => candidate.id === owner.ptyId) + if (!session) { + return false + } + if (provider.providesAgentSessionOwnerListings?.(owner.ptyId) !== true) { + // Why: in-process local owners cannot serialize the controller claim; exact incarnation + // liveness keeps that claim authoritative until the normal PTY exit releases it. + const expectedIncarnation = ptyIncarnationById.get(owner.ptyId) + return expectedIncarnation !== undefined && session.incarnationId === expectedIncarnation + } + return Boolean( + session.agentSessionOwners?.some((candidate) => + agentSessionOwnerBindingsEqual(candidate, owner) + ) + ) +} + +export async function verifyPtyStopped( + provider: IPtyProvider, + ptyId: string, + opts: { keepHistory?: boolean; deadlineMs?: number } | undefined +): Promise { + if (await isProviderPtyLive(provider, ptyId, opts?.deadlineMs)) { + return false + } + if (!opts?.keepHistory) { + return true + } + const settleDeadline = Date.now() + KEEP_HISTORY_STOP_SETTLE_MS + // Why: deadlineMs is absolute, so the settle poll must not outlive the caller's teardown budget. + const deadline = + opts.deadlineMs !== undefined ? Math.min(settleDeadline, opts.deadlineMs) : settleDeadline + while (Date.now() < deadline) { + await delay(Math.min(KEEP_HISTORY_STOP_POLL_MS, deadline - Date.now())) + if (Date.now() >= deadline) { + break + } + if (await isProviderPtyLive(provider, ptyId, deadline)) { + return false + } + } + return true +} + +export function finishPtyShutdown( + id: string, + connectionId: string | null | undefined, + store: Store | undefined +): string | undefined { + const incarnationId = ptyIncarnationById.get(id) + clearProviderPtyState(id) + if (connectionId) { + store?.markSshRemotePtyLease(connectionId, getRelayPtyId(connectionId, id), 'terminated') + } + ptyOwnership.delete(id) + markClaudePtyExited(id) + return incarnationId +} diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts new file mode 100644 index 00000000000..2051d937c94 --- /dev/null +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -0,0 +1,115 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' +import { isPwshAvailableAsync } from '../../../pwsh' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { + addOrcaWslInteropEnv, + stampWslOrchestrationCompatibilityHost +} from '../../../pty/wsl-orca-env' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' +import { buildPtyHostEnv } from '../host-env/assembly' +import { + getCompatibleSelectedCodexHomePath, + isCodexStatusHooksEnabled, + shouldStripInheritedOrcaCodexHome +} from '../host-env/codex-home' +import type { GetSelectedCodexHomePath } from '../host-env/types' +import { isCurrentPtyExit, ptyOwnership } from './ownership-state' +import { localProvider } from './registry' +import { clearProviderPtyState } from './state-cleanup' + +export function configureLocalPtyProvider(args: { + runtime?: OrcaRuntimeService + getSettings?: () => GlobalSettings + getSelectedCodexHomePath?: GetSelectedCodexHomePath + trustedTerminalHandleEnv: Set +}): void { + // Why: only LocalPtyProvider needs main-process hook injection; daemon-backed providers spawn subprocesses internally. + if (!(localProvider instanceof LocalPtyProvider)) { + return + } + const { runtime, getSettings, getSelectedCodexHomePath, trustedTerminalHandleEnv } = args + localProvider.configure({ + isHistoryEnabled: () => getSettings?.()?.terminalScopeHistoryByWorktree ?? true, + getWindowsShell: () => getSettings?.()?.terminalWindowsShell, + getWindowsPowerShellImplementation: () => + getSettings ? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') : undefined, + pwshAvailable: () => isPwshAvailableAsync(), + buildSpawnEnv: (id, baseEnv, ctx) => { + const codexSelectionTarget: CodexAccountSelectionTarget = + ctx?.isWsl === true + ? { runtime: 'wsl', wslDistro: ctx.wslDistro ?? null } + : { runtime: 'host' } + const selectedCodexHomePath = getCompatibleSelectedCodexHomePath( + codexSelectionTarget, + ctx?.codexHomePathOverride + ? ctx.codexHomePathOverride.value + : (getSelectedCodexHomePath?.(codexSelectionTarget, baseEnv, { + workspacePath: ctx?.cwd, + launchAgent: ctx?.launchAgent + }) ?? null) + ) + const skipCodexHomeEnv = ctx?.isWsl === true && !selectedCodexHomePath + const ptySettings = getSettings?.() + const env = buildPtyHostEnv(id, baseEnv, { + isPackaged: getAppEnvironment().isPackaged(), + resourcesPath: process.resourcesPath, + userDataPath: getAppEnvironment().getPath('userData'), + selectedCodexHomePath, + skipCodexHomeEnv, + stripInheritedOrcaCodexHome: shouldStripInheritedOrcaCodexHome({ + target: codexSelectionTarget, + selectedCodexHomePath, + skipCodexHomeEnv, + settings: ptySettings + }), + launchCommand: ctx?.command, + launchAgent: ctx?.launchAgent, + isWsl: ctx?.isWsl, + wslDistro: ctx?.wslDistro ?? null, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), + codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), + networkProxySettings: ptySettings + }) + // Why: agents need their terminal handle at process start to self-identify in orchestration messages without an extra RPC. + const requestedHandle = baseEnv.ORCA_TERMINAL_HANDLE + const preAllocatedHandle = + requestedHandle && trustedTerminalHandleEnv.has(requestedHandle) + ? requestedHandle + : runtime?.preAllocateHandleForPty(id) + if (requestedHandle && requestedHandle !== preAllocatedHandle) { + delete env.ORCA_TERMINAL_HANDLE + } + if (preAllocatedHandle) { + env.ORCA_TERMINAL_HANDLE = preAllocatedHandle + } + stampWslOrchestrationCompatibilityHost( + env, + runtime?.getOrchestrationCompatibilityHostId?.(), + ctx?.isWsl === true ? ctx.wslDistro : null + ) + if (ctx?.isWsl === true) { + addOrcaWslInteropEnv(env) + } + return env + }, + onSpawned: (id, incarnationId) => runtime?.onPtySpawned(id, incarnationId), + onExit: (id, code, incarnationId, cause) => { + if (!isCurrentPtyExit({ id, incarnationId })) { + return + } + clearProviderPtyState(id) + ptyOwnership.delete(id) + markClaudePtyExited(id) + runtime?.onPtyExit(id, code, incarnationId, { + providerExitObserved: true, + ...(cause ? { cause } : {}) + }) + }, + onData: (id, data, timestamp, sequenceChars, transformed) => + runtime?.onPtyData(id, data, timestamp, sequenceChars ?? data.length, transformed) + }) +} diff --git a/src/main/ipc/pty/provider/ownership-state.ts b/src/main/ipc/pty/provider/ownership-state.ts new file mode 100644 index 00000000000..7d5ef3dcbb7 --- /dev/null +++ b/src/main/ipc/pty/provider/ownership-state.ts @@ -0,0 +1,35 @@ +import { isPtyIncarnationId } from '../../../../shared/pty-incarnation' + +// Why: post-spawn write/resize/kill calls carry only the PTY ID; map it to its connectionId so ops route to the right provider. +export const ptyOwnership = new Map() +export const ptyIncarnationById = new Map() + +export function isCurrentPtyExit(payload: { id: string; incarnationId?: string }): boolean { + const current = ptyIncarnationById.get(payload.id) + return !current || payload.incarnationId === current +} + +export function deletePtyOwnership(id: string): void { + ptyOwnership.delete(id) +} + +export function setPtyOwnership(id: string, connectionId: string | null): void { + ptyOwnership.set(id, connectionId) +} + +export function restorePtyIncarnation(id: string, incarnationId: string): void { + if (!isPtyIncarnationId(incarnationId)) { + throw new Error('Invalid PTY incarnation') + } + ptyIncarnationById.set(id, incarnationId) +} + +export function getPtyIdsForConnection(connectionId: string): string[] { + const ids: string[] = [] + for (const [ptyId, connId] of ptyOwnership) { + if (connId === connectionId) { + ids.push(ptyId) + } + } + return ids +} diff --git a/src/main/ipc/pty/provider/registry.ts b/src/main/ipc/pty/provider/registry.ts new file mode 100644 index 00000000000..85c8a3514db --- /dev/null +++ b/src/main/ipc/pty/provider/registry.ts @@ -0,0 +1,131 @@ +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import type { IPtyProvider } from '../../../providers/types' +import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../../../providers/ssh-pty-id' +import { ptyOwnership } from './ownership-state' + +// ─── Provider Registry ────────────────────────────────────────────── +// Routes PTY operations by connectionId (null = local provider). + +export let localProvider: IPtyProvider = new LocalPtyProvider() +export const sshProviders = new Map() +export const sshProvidersByGeneration = new Map() + +export type RegisteredPtyProvider = { + provider: IPtyProvider + connectionId: string | null +} + +export function registeredPtyProviders(): RegisteredPtyProvider[] { + return [ + { provider: localProvider, connectionId: null }, + ...Array.from(sshProviders, ([connectionId, provider]) => ({ provider, connectionId })) + ] +} + +export function getProvider(connectionId: string | null | undefined): IPtyProvider { + if (!connectionId) { + return localProvider + } + const provider = sshProviders.get(connectionId) + if (!provider) { + throw new Error(`No PTY provider for connection "${connectionId}"`) + } + return provider +} + +export function getProviderForPty(ptyId: string): IPtyProvider { + const connectionId = ptyOwnership.get(ptyId) + if (connectionId === undefined) { + const parsedSshId = parseAppSshPtyId(ptyId) + if (parsedSshId) { + // Why: disconnected SSH PTYs retain their encoded owner and must never fall through to the HUB-local provider. + return getProvider(parsedSshId.connectionId) + } + return localProvider + } + return getProvider(connectionId) +} + +export function hasPtyProviderForInspection(ptyId: string): boolean { + // Why: process inspection is background polling; disconnected SSH hosts should read as idle, not raise repeated IPC errors. + const connectionId = ptyOwnership.get(ptyId) + if (connectionId === undefined) { + // Why: mirror getProviderForPty — an unowned id still routes by its encoded SSH owner. + const parsedSshId = parseAppSshPtyId(ptyId) + return !parsedSshId || sshProviders.has(parsedSshId.connectionId) + } + return connectionId === null || sshProviders.has(connectionId) +} + +export function getAppPtyId(connectionId: string | null | undefined, ptyId: string): string { + return connectionId ? toAppSshPtyId(connectionId, ptyId) : ptyId +} + +export function getRelayPtyId(connectionId: string | null | undefined, ptyId: string): string { + return connectionId ? toRelaySshPtyId(connectionId, ptyId) : ptyId +} + +export function tryGetProviderForPty(ptyId: string): IPtyProvider | undefined { + try { + return getProviderForPty(ptyId) + } catch { + return undefined + } +} + +export function closeStartupQueryAuthorityForPty(ptyId: string): void { + try { + void Promise.resolve(tryGetProviderForPty(ptyId)?.closeStartupQueryAuthority?.(ptyId)).catch( + () => {} + ) + } catch { + /* Best-effort handoff; the bounded source deadline remains the fallback. */ + } +} + +export function tryGetProviderForAgentSessionOwner(ptyId: string): IPtyProvider | undefined { + const ownedConnectionId = ptyOwnership.get(ptyId) + const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(ptyId) : null + try { + return getProvider(parsedSshId?.connectionId ?? ownedConnectionId) + } catch { + return undefined + } +} + +/** Register an SSH PTY provider for a connection. */ +export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void { + sshProviders.set(connectionId, provider) + const generation = (provider as { providerGeneration?: number }).providerGeneration + if (Number.isSafeInteger(generation) && generation! > 0) { + sshProvidersByGeneration.set(generation!, provider) + } +} + +/** Remove an SSH PTY provider when a connection is closed. */ +export function unregisterSshPtyProvider(connectionId: string): void { + const provider = sshProviders.get(connectionId) + const generation = (provider as { providerGeneration?: number } | undefined)?.providerGeneration + if (generation !== undefined && sshProvidersByGeneration.get(generation) === provider) { + sshProvidersByGeneration.delete(generation) + } + sshProviders.delete(connectionId) +} + +/** Get the SSH PTY provider for a connection (for dispose on cleanup). */ +export function getSshPtyProvider(connectionId: string): IPtyProvider | undefined { + return sshProviders.get(connectionId) +} + +/** Get the installed PTY provider (for direct access in tests/runtime). + * After daemon init this may be a DaemonPtyAdapter/DaemonPtyRouter, not LocalPtyProvider; + * callers needing LocalPtyProvider-specific methods must type-narrow or import the class. */ +export function getLocalPtyProvider(): IPtyProvider { + return localProvider +} + +/** Replace the local PTY provider with a daemon-backed one. + * Call before registerPtyHandlers so the IPC layer routes through the daemon. */ +export function setLocalPtyProvider(provider: IPtyProvider): void { + localProvider = provider +} diff --git a/src/main/ipc/pty/provider/shutdown-detect.ts b/src/main/ipc/pty/provider/shutdown-detect.ts new file mode 100644 index 00000000000..3972f94880e --- /dev/null +++ b/src/main/ipc/pty/provider/shutdown-detect.ts @@ -0,0 +1,25 @@ +import type { IPtyProvider } from '../../../providers/types' +import { ptyIncarnationById } from './ownership-state' + +export async function shutdownProviderAndDetectExit( + provider: IPtyProvider, + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } +): Promise { + let providerExitObserved = false + const expectedIncarnationId = ptyIncarnationById.get(id) + const unsubscribe = provider.onExit((payload) => { + if ( + payload.id === id && + (!expectedIncarnationId || payload.incarnationId === expectedIncarnationId) + ) { + providerExitObserved = true + } + }) + try { + await provider.shutdown(id, opts) + } finally { + unsubscribe() + } + return providerExitObserved +} diff --git a/src/main/ipc/pty/provider/state-cleanup.ts b/src/main/ipc/pty/provider/state-cleanup.ts new file mode 100644 index 00000000000..72286eb0a4d --- /dev/null +++ b/src/main/ipc/pty/provider/state-cleanup.ts @@ -0,0 +1,124 @@ +import { advertisedUrlWatcher } from '../../../ports/advertised-url-watcher' +import { unregisterPty } from '../../../memory/pty-registry' +import { markClaudePtyExited } from '../../../claude-accounts/live-pty-gate' +import { forgetCodexPaneAccount } from '../../../codex/codex-pane-account-registry' +import { openCodeHookService } from '../../../opencode/hook-service' +import { piTitlebarExtensionService } from '../../../pi/titlebar-extension-service' +import { agentHookServer } from '../../../agent-hooks/server' +import { clearMigrationUnsupportedPty } from '../../../agent-hooks/migration-unsupported-pty-state' +import { clearNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' +import { + clearHiddenRendererPtyDeliveryState, + isHiddenRendererPty +} from '../../pty-hidden-delivery-gate' +import { agentSessionOwners } from '../pane/agent-session-owners' +import { paneKeyPtyId, paneKeyTeardownListeners, ptyPaneKey } from '../pane/key-state' +import { ptyIncarnationById, ptyOwnership } from './ownership-state' +import { clearBackgroundedDeliverySyncForPty } from './listener-lifecycle' +import { + activeRendererPtys, + deliveredHiddenRendererResizeOutputPtys, + interactiveOutputCharsByPty, + invalidatePendingPtyDrainPolicy, + invalidatePendingPtyDrainPriority, + lastInputAtByPty, + pendingHiddenRendererResizeOutputPtys, + providerSnapshotRequiredPtys, + ptySizes, + rendererVisibilityKnownPtys, + visibleRendererPtys +} from '../delivery/visibility-state' + +/** + * Tear down per-PTY provider-scoped state. + * + * Claimed agent owners remain fenced because the relay process may survive and + * prove the exact same generation after reconnect. + */ +export function clearProviderPtyState( + id: string, + opts: { preserveAgentSessionOwners?: boolean } = {} +): void { + if (!opts.preserveAgentSessionOwners) { + agentSessionOwners.release(id) + // Why: the launch-account record outlives the app, so only a real teardown + // may drop it — a disconnect that can reconnect is not a death, and a reused + // id must never inherit a dead pane's Codex account. + forgetCodexPaneAccount(id) + } + // Why: OpenCode and Pi both allocate PTY-scoped runtime state outside the + // node-pty process table. Centralizing provider cleanup avoids drift where a + // new teardown path forgets to remove one provider's overlay/hook state. + openCodeHookService.clearPty(id) + piTitlebarExtensionService.clearPty(id) + // Why: SSH exit/teardown paths bypass pty.ts's local onExit but still must release Claude account-switch guards. + markClaudePtyExited(id) + ptySizes.delete(id) + ptyIncarnationById.delete(id) + lastInputAtByPty.delete(id) + interactiveOutputCharsByPty.delete(id) + const activeChanged = activeRendererPtys.delete(id) + visibleRendererPtys.delete(id) + rendererVisibilityKnownPtys.delete(id) + pendingHiddenRendererResizeOutputPtys.delete(id) + deliveredHiddenRendererResizeOutputPtys.delete(id) + // Why: every teardown path funnels through here — hidden/interest gate bits must not outlive the PTY or a reused map entry could silently gate a new one. + const deliveryPolicyChanged = isHiddenRendererPty(id) + clearHiddenRendererPtyDeliveryState(id) + if (activeChanged) { + invalidatePendingPtyDrainPriority(id, false) + } + if (deliveryPolicyChanged) { + invalidatePendingPtyDrainPolicy(id, false) + } + clearBackgroundedDeliverySyncForPty(id) + providerSnapshotRequiredPtys.delete(id) + // Why: the Phase-5 ConPTY DA1 spawn record must not leak onto a reused id. + clearNativeWindowsConptyPty(id) + const paneKey = ptyPaneKey.get(id) + const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false + // Why: drop the memory-collector registration so a dead PTY doesn't resolve its dead pid on every snapshot; no-op for never-registered (SSH-owned) PTYs. + unregisterPty(id) + // Why: cover paths that bypass runtime.onPtyExit (SSH reattach/shutdown, daemon spawn-failure) — else the watcher's per-PTY buffer and worktree binding outlive the PTY. + advertisedUrlWatcher.unbindPty(id) + clearMigrationUnsupportedPty(id) + agentHookServer.clearPaneKeyAliasesForPty(id, { + shouldClearStablePaneKey: (stablePaneKey) => { + // Why: when this PTY never rebuilt ptyPaneKey after restart, alias ownership is our only proof — don't erase a newer PTY that now owns the same stable paneKey. + const stablePaneOwner = paneKeyPtyId.get(stablePaneKey) + if (stablePaneOwner && stablePaneOwner !== id) { + return false + } + return !paneKey || (stillOwnsPaneKey && stablePaneKey === paneKey) + } + }) + // Why: clear the hook server's per-paneKey caches (via the spawn-time paneKey mapping, its only ptyId→paneKey correlation) so dead panes don't accumulate over process lifetime. + if (paneKey) { + if (stillOwnsPaneKey) { + agentHookServer.clearPaneState(paneKey) + paneKeyPtyId.delete(paneKey) + } + ptyPaneKey.delete(id) + if (stillOwnsPaneKey) { + // Why: notify AFTER dropping the paneKey↔ptyId entries so a listener re-reading the map sees post-teardown state; wrap each so one throw can't block the rest. + for (const listener of paneKeyTeardownListeners) { + try { + listener(paneKey) + } catch (err) { + console.error('[pty] paneKey teardown listener threw', err) + } + } + } + } +} + +export function clearPtyOwnershipForConnection(connectionId: string): void { + for (const [ptyId, connId] of ptyOwnership) { + if (connId === connectionId) { + // Why: pane-scoped caches cannot route while disconnected, but claimed + // ownership must survive until reconnect makes absence authoritative. + clearProviderPtyState(ptyId, { preserveAgentSessionOwners: true }) + ptyOwnership.delete(ptyId) + } + } +} diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts new file mode 100644 index 00000000000..82bd55bc854 --- /dev/null +++ b/src/main/ipc/pty/register-handlers.ts @@ -0,0 +1,267 @@ +import type { BrowserWindow } from 'electron' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { Store } from '../../persistence' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { LocalPtyProvider } from '../../providers/local-pty-provider' +import type { TerminalStartupCwdMissingDirFallback } from '../../../shared/terminal-startup-cwd' +import { + getHiddenRendererPtyDeliveryDebug, + resetRendererScopedHiddenPtyDeliveryState +} from '../pty-hidden-delivery-gate' +import { localProvider } from './provider/registry' +import { finishPtyShutdown } from './provider/liveness' +import type { GetSelectedCodexHomePath, PrepareClaudeAuth } from './host-env/types' +import { installPtyInspectIpcHandlers } from './ipc/inspect' +import { installPtyWriteIpcHandlers } from './ipc/write' +import { installPtySpawnIpcHandler } from './ipc/spawn' +import { installPtyRuntimeController } from './runtime/controller' +import { installPtySnapshotIpcHandlers } from './ipc/snapshot' +import { + assertFolderWorkspacePtyPathUsable as assertFolderWorkspacePtyPathUsableImpl, + localStartupCwdDirectoryExists, + resolvePtySpawnStartupCwd as resolvePtySpawnStartupCwdImpl +} from './host-env/spawn-cwd' +import { + setInvalidatePendingPtyDrainPolicy, + setInvalidatePendingPtyDrainPriority, + invalidatePendingPtyDrainPolicy +} from './delivery/visibility-state' +import { + setRebindProviderListeners, + setDidFinishLoadHandler, + setRendererGateResetState +} from './provider/listener-lifecycle' +import { + resetRendererDeliveryAccountingForLifecycleReset, + setResetRendererDeliveryAccountingForLifecycleReset, + clearRendererDispatcherReadyWatchdog +} from './delivery/debug' +import { + registerRendererLifecycleResetHandlers, + clearRendererGateResetHandlers, + clearDidFinishLoadHandler +} from './delivery/lifecycle-reset' +import { createPtyIpcSession, type PtyIpcSessionOptions } from './session' +import { wirePtyIpcSession } from './delivery/wire-session' +import { configureLocalPtyProvider } from './provider/local-configure' +import { bindProviderListeners } from './provider/bind-listeners' +import { installSessionSshOutputIntake } from './delivery/ssh-intake' +import { installPtySerializeBufferIpc } from './ipc/serialize-buffer' +import { installPtyResizeVisibilityIpc } from './ipc/resize-visibility' +import { adoptStablePane } from './pane/adopt-stable' +import { getPtyIpc } from '../pty-host-bindings' +import { + noCodexResumeLaunch, + prepareCodexResumeHome, + reconcileSharedRuntimeResumeHome, + resolveCodexResumeLaunch, + stripSequencedStartupResumeArgv +} from './host-env/codex-resume' + +export function registerPtyHandlers( + mainWindow: BrowserWindow, + runtime?: OrcaRuntimeService, + getSelectedCodexHomePath?: GetSelectedCodexHomePath, + getSettings?: () => GlobalSettings, + prepareClaudeAuth?: PrepareClaudeAuth, + store?: Store, + options?: PtyIpcSessionOptions +): void { + const ipcMain = getPtyIpc() + // Why first: the outgoing session owns the producer pauses, so its real reset must run + // before the bridge is neutralized or a PTY paused during re-registration stays paused. + resetRendererDeliveryAccountingForLifecycleReset() + // Why: a re-registration means a new window owns delivery — cancel the prior closure's watchdog and neutralize its bridged reset so mark-hidden below can't arm a timer against the dead closure. + clearRendererDispatcherReadyWatchdog() + setResetRendererDeliveryAccountingForLifecycleReset(() => {}) + setInvalidatePendingPtyDrainPriority(() => {}) + setInvalidatePendingPtyDrainPolicy(() => {}) + // Why: neutralize rebind at the same moment as drain so a daemon replace in this window cannot attach the old accept/exit closures. + setRebindProviderListeners(() => {}) + registerRendererLifecycleResetHandlers(mainWindow.webContents) + + const getLocalPtyStartupPromise = (connectionId?: string | null): Promise | undefined => { + if (connectionId) { + return undefined + } + // Why: during cold start the daemon provider swap overlaps first paint, so local spawns must wait; SSH/headless don't use the desktop daemon. + return options?.awaitLocalPtyStartup?.() + } + + const getLocalPtyProviderStartupPromise = ( + connectionId?: string | null + ): Promise | undefined => { + if (connectionId) { + return undefined + } + return options?.awaitLocalPtyProviderStartup?.() ?? options?.awaitLocalPtyStartup?.() + } + + // Remove prior handlers so re-registration (e.g. macOS re-activate creating a new window) doesn't double-register. + ipcMain.removeHandler('pty:spawn') + ipcMain.removeHandler('pty:kill') + ipcMain.removeHandler('pty:listSessions') + ipcMain.removeHandler('pty:hasPty') + ipcMain.removeHandler('pty:hasChildProcesses') + ipcMain.removeHandler('pty:getForegroundProcess') + ipcMain.removeHandler('pty:inspectProcess') + ipcMain.removeHandler('pty:confirmForegroundProcess') + ipcMain.removeHandler('pty:getCwd') + ipcMain.removeHandler('pty:getSize') + ipcMain.removeHandler('pty:getAuthoritativeBufferSnapshotCapabilities') + ipcMain.removeHandler('pty:declarePendingPaneSerializer') + ipcMain.removeHandler('pty:settlePaneSerializer') + ipcMain.removeHandler('pty:clearPendingPaneSerializer') + ipcMain.removeHandler('pty:reportRendererSerializerReady') + ipcMain.removeHandler('pty:getMainBufferSnapshot') + ipcMain.removeHandler('pty:sideEffectSnapshot') + ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot') + ipcMain.removeHandler('pty:resetRendererDeliveryDebug') + ipcMain.removeHandler('pty:reportRendererDeliveryState') + ipcMain.removeHandler('pty:writeAccepted') + ipcMain.removeAllListeners('pty:write') + ipcMain.removeAllListeners('pty:ackColdRestore') + ipcMain.removeAllListeners('pty:ackData') + ipcMain.removeAllListeners('pty:deliveryResyncResponse') + ipcMain.removeAllListeners('pty:serializeBuffer:response') + + const session = createPtyIpcSession({ + mainWindow, + runtime, + store, + getSettings, + options + }) + wirePtyIpcSession(session) + configureLocalPtyProvider({ + runtime, + getSettings, + getSelectedCodexHomePath, + trustedTerminalHandleEnv: session.trustedTerminalHandleEnv + }) + installSessionSshOutputIntake(session) + bindProviderListeners(session) + setRebindProviderListeners(() => bindProviderListeners(session)) + installPtySerializeBufferIpc(session) + + // Why: reload/crash orphans delivery-interest holds and hidden marks; reset so surviving PTYs aren't stuck force-fed or gated — each pane's first sync re-marks. + clearRendererGateResetHandlers() + const resetRendererPtyDeliveryGateState = (): void => { + const gateDebug = getHiddenRendererPtyDeliveryDebug() + resetRendererScopedHiddenPtyDeliveryState() + if (gateDebug.hiddenDeliveryGatedPtyCount > 0 || gateDebug.deliveryInterestPtyCount > 0) { + invalidatePendingPtyDrainPolicy() + } + // Why: the daemon pacer must not keep throttling ptys whose hidden marks died with the renderer; the fresh renderer's sync re-marks the still-hidden ones. + session.resyncBackgroundedDeliveriesAfterGateReset() + } + setRendererGateResetState({ + contents: mainWindow.webContents, + load: resetRendererPtyDeliveryGateState, + gone: resetRendererPtyDeliveryGateState + }) + mainWindow.webContents.on('did-finish-load', resetRendererPtyDeliveryGateState) + mainWindow.webContents.on('render-process-gone', resetRendererPtyDeliveryGateState) + + // Why: only LocalPtyProvider PTYs (main-process) can be orphaned on reload; daemon sessions survive by design and cleanup would kill them. + clearDidFinishLoadHandler() + if (localProvider instanceof LocalPtyProvider) { + const lp = localProvider + const finishLoadHandler = () => { + // Why: always advance to keep the generation monotonic, but skip the sweep on crash/freeze-recovery reload — it would kill live local PTYs before session restore (#5787). + const generation = lp.advanceGeneration() + if (options?.isRecoveryReloadInFlight?.(mainWindow.webContents.id)) { + return + } + // Why: the retained provider onExit callback is the only physical-exit proof; it clears ownership after the OS reaps it. + lp.killOrphanedPtys(generation - 1) + } + setDidFinishLoadHandler(finishLoadHandler, mainWindow.webContents) + mainWindow.webContents.on('did-finish-load', finishLoadHandler) + } + + const assertFolderWorkspacePtyPathUsable = ( + worktreeId: string | undefined + ): Promise | void => assertFolderWorkspacePtyPathUsableImpl(store, worktreeId) + const resolvePtySpawnStartupCwd = ( + worktreeId: string | undefined, + cwd: string | undefined, + missingDirFallback?: TerminalStartupCwdMissingDirFallback + ): string | undefined => resolvePtySpawnStartupCwdImpl(store, worktreeId, cwd, missingDirFallback) + const prepareCodexResumeHomeBound = ( + args: Parameters[1] + ): ReturnType => + prepareCodexResumeHome(options?.prepareCodexSessionResume, args) + const adoptStablePaneBound = (args: Parameters[2]) => + adoptStablePane(runtime, store, args) + + // Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them. + installPtyRuntimeController({ + runtime, + store, + adoptStablePane: adoptStablePaneBound, + getLocalPtyStartupPromise, + getLocalPtyProviderStartupPromise, + prepareCodexResumeHome: prepareCodexResumeHomeBound, + resolveCodexResumeLaunch, + noCodexResumeLaunch, + reconcileSharedRuntimeResumeHome, + stripSequencedStartupResumeArgv, + assertFolderWorkspacePtyPathUsable, + resolvePtySpawnStartupCwd, + requestSerializedBuffer: session.requestSerializedBuffer, + shutdownProviderAndDetectExit: session.shutdownProviderAndDetectExit, + rememberSyntheticKillExit: session.rememberSyntheticKillExit, + rememberRetiredRejectedPty: session.rememberRetiredRejectedPty, + sendPtyExitToRenderer: session.sendPtyExitToRenderer, + sendPtySpawnedToRenderer: session.sendPtySpawnedToRenderer, + finishPtyShutdown, + getSettings, + getSelectedCodexHomePath, + prepareClaudeAuth, + options, + trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, + retiredRejectedPtyIds: session.retiredRejectedPtyIds, + reversibleStopOwnersByPtyId: session.reversibleStopOwnersByPtyId, + mainWindow + }) + + installPtySnapshotIpcHandlers({ runtime, pendingData: session.pendingData }) + installPtySpawnIpcHandler({ + runtime, + store, + getSettings, + getSelectedCodexHomePath, + prepareClaudeAuth, + options, + getLocalPtyStartupPromise, + adoptStablePane: adoptStablePaneBound, + assertFolderWorkspacePtyPathUsable, + resolvePtySpawnStartupCwd, + localStartupCwdDirectoryExists, + prepareCodexResumeHome: prepareCodexResumeHomeBound, + noCodexResumeLaunch, + resolveCodexResumeLaunch, + reconcileSharedRuntimeResumeHome, + stripSequencedStartupResumeArgv, + transitionSpawnHiddenRendererPtyDeliveryState: + session.transitionSpawnHiddenRendererPtyDeliveryState, + trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, + sendPtySpawnedToRenderer: session.sendPtySpawnedToRenderer, + syncPtyBackgroundedDelivery: session.syncPtyBackgroundedDelivery + }) + installPtyWriteIpcHandlers({ + mainWindow, + runtime, + clearHiddenRendererResizeOutput: session.clearHiddenRendererResizeOutput + }) + installPtyResizeVisibilityIpc(session) + installPtyInspectIpcHandlers({ + store, + runtime, + getLocalPtyProviderStartupPromise, + shutdownProviderAndDetectExit: session.shutdownProviderAndDetectExit, + rememberSyntheticKillExit: session.rememberSyntheticKillExit, + sendPtyExitToRenderer: session.sendPtyExitToRenderer + }) +} diff --git a/src/main/ipc/pty/register-headless-runtime.ts b/src/main/ipc/pty/register-headless-runtime.ts new file mode 100644 index 00000000000..f380482a079 --- /dev/null +++ b/src/main/ipc/pty/register-headless-runtime.ts @@ -0,0 +1,47 @@ +import type { BrowserWindow } from 'electron' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { Store } from '../../persistence' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { + CodexHomePtySpawnedLifecycleArgs, + GetSelectedCodexHomePath, + PrepareClaudeAuth, + PrepareCodexSessionResume +} from './host-env/types' +import { registerPtyHandlers } from './register-handlers' + +export function registerHeadlessPtyRuntime( + runtime: OrcaRuntimeService, + getSelectedCodexHomePath?: GetSelectedCodexHomePath, + getSettings?: () => GlobalSettings, + prepareClaudeAuth?: PrepareClaudeAuth, + store?: Store, + prepareCodexSessionResume?: PrepareCodexSessionResume, + lifecycle?: { + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + onPtyExit?: (id: string, exitSequence: number) => void + } +): void { + // Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals. + // Why a fake rather than null: `registerPtyHandlers` takes a non-null BrowserWindow. `isDestroyed: () => true` + // is what makes that safe — every renderer-liveness guard reads it and skips, so no send is ever attempted. + // Keep `webContents.isDestroyed` in step with it: guards check both, and a missing method reads as "alive". + const headlessWindow = { + isDestroyed: () => true, + webContents: { + isDestroyed: () => true, + send: () => {}, + on: () => {}, + removeListener: () => {} + } + } as unknown as BrowserWindow + registerPtyHandlers( + headlessWindow, + runtime, + getSelectedCodexHomePath, + getSettings, + prepareClaudeAuth, + store, + { prepareCodexSessionResume, ...lifecycle } + ) +} diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts new file mode 100644 index 00000000000..4abebc3b28c --- /dev/null +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -0,0 +1,89 @@ +import type { BrowserWindow } from 'electron' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { Store } from '../../../persistence' +import type { IPtyProvider } from '../../../providers/types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import type { TerminalStartupCwdMissingDirFallback } from '../../../../shared/terminal-startup-cwd' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import type { CodexSessionResumePreparation } from '../../../codex/codex-session-resume-home' +import type { + CodexHomePtySpawnedLifecycleArgs, + GetSelectedCodexHomePath, + PrepareClaudeAuth, + PrepareCodexSessionResume +} from '../host-env/types' +import type { CodexResumeLaunch, PreparedCodexResumeHome } from '../host-env/codex-resume' +import type { StablePaneOwner } from '../pane/stable-owner' +import type { AdoptStablePaneArgs, AdoptStablePaneResult } from '../ipc/spawn-types' +import type { finishPtyShutdown } from '../provider/liveness' + +export type PtyRuntimeControllerDeps = { + runtime?: OrcaRuntimeService + store?: Store + adoptStablePane: (args: AdoptStablePaneArgs) => Promise + getLocalPtyStartupPromise: (connectionId?: string | null) => Promise | undefined + getLocalPtyProviderStartupPromise: (connectionId?: string | null) => Promise | undefined + prepareCodexResumeHome: (args: { + connectionId?: string | null + launchAgent?: TuiAgent + providerSession?: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string + }) => PreparedCodexResumeHome | null + resolveCodexResumeLaunch: ( + command: string | undefined, + prepared: PreparedCodexResumeHome + ) => Promise + noCodexResumeLaunch: (command: string | undefined) => CodexResumeLaunch + reconcileSharedRuntimeResumeHome: ( + resumeHome: Extract, + resolveCurrent: () => string | null + ) => string + stripSequencedStartupResumeArgv: | undefined>( + env: T, + launch: CodexResumeLaunch + ) => T + assertFolderWorkspacePtyPathUsable: (worktreeId: string | undefined) => Promise | void + resolvePtySpawnStartupCwd: ( + worktreeId: string | undefined, + cwd: string | undefined, + missingDirFallback?: TerminalStartupCwdMissingDirFallback + ) => string | undefined + requestSerializedBuffer: ( + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + ) => Promise<{ + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + kittyKeyboardFlags?: number + } | null> + shutdownProviderAndDetectExit: ( + provider: IPtyProvider, + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ) => Promise + rememberSyntheticKillExit: (id: string) => void + rememberRetiredRejectedPty: (id: string) => void + sendPtyExitToRenderer: (payload: { id: string; code: number; incarnationId?: string }) => void + sendPtySpawnedToRenderer: (id: string) => void + finishPtyShutdown: typeof finishPtyShutdown + getSettings?: () => GlobalSettings | undefined + getSelectedCodexHomePath?: GetSelectedCodexHomePath + prepareClaudeAuth?: PrepareClaudeAuth + options?: { + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + prepareCodexSessionResume?: PrepareCodexSessionResume + } + trustedTerminalHandleEnv: Set + retiredRejectedPtyIds: Map + reversibleStopOwnersByPtyId: Map + mainWindow: BrowserWindow +} + +export type { StablePaneOwner } diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts new file mode 100644 index 00000000000..4fce4853bc4 --- /dev/null +++ b/src/main/ipc/pty/runtime/controller.ts @@ -0,0 +1,80 @@ +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { claimRuntimePaneCreate, makePaneSpawnReservationKey } from '../pane/spawn-reservation' +import type { PtyRuntimeControllerDeps } from './controller-deps' +import { spawnPtyFromRuntimeController } from './spawn' +import { + killPtyFromRuntimeController, + markReversibleStopsFromRuntimeController, + retireRejectedPtyFromRuntimeController, + stopAndWaitPtyFromRuntimeController +} from './kill' +import { + attachPtyFromRuntimeController, + clearBufferFromRuntimeController, + confirmForegroundProcessFromRuntimeController, + getCwdFromRuntimeController, + getForegroundProcessFromRuntimeController, + getRendererSerializerGenerationFromRuntimeController, + getSizeFromRuntimeController, + hasChildProcessesFromRuntimeController, + hasPtyFromRuntimeController, + hasRendererSerializerFromRuntimeController, + inspectProcessFromRuntimeController, + listProcessesFromRuntimeController, + listProcessesWithHostScopeFromRuntimeController, + probePtyLivenessFromRuntimeController, + resizePtyFromRuntimeController, + serializeProviderBufferFromRuntimeController, + waitForRendererSerializerFromRuntimeController, + writePtyFromRuntimeController +} from './operations' + +export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): void { + const { runtime, adoptStablePane, requestSerializedBuffer } = deps + + runtime?.setPtyController({ + claimStablePaneCreate: (args) => { + const paneKey = makePaneKey(args.tabId, args.leafId) + const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) + return ownerKey ? claimRuntimePaneCreate(ownerKey) : () => {} + }, + adoptStablePane, + spawn: async (args) => spawnPtyFromRuntimeController(deps, args), + write: (ptyId, data) => writePtyFromRuntimeController(ptyId, data), + probePtyLiveness: (ptyId) => probePtyLivenessFromRuntimeController(deps, ptyId), + // Why: subscriber-driven ingestion for daemon sessions no renderer pane + // ever attached. Local daemon sessions only — SSH panes have their own + // lease machinery, and the in-process local provider streams without + // attach. Attach-only and false-on-doubt: never creates or resizes. + attach: (ptyId) => attachPtyFromRuntimeController(deps, ptyId), + kill: (ptyId) => killPtyFromRuntimeController(deps, ptyId), + retireRejectedPty: (ptyId, stopConfirmed) => + retireRejectedPtyFromRuntimeController(deps, ptyId, stopConfirmed), + markReversibleStops: (ptyIds) => markReversibleStopsFromRuntimeController(deps, ptyIds), + stopAndWait: (ptyId, opts) => stopAndWaitPtyFromRuntimeController(deps, ptyId, opts), + getForegroundProcess: (ptyId) => getForegroundProcessFromRuntimeController(ptyId), + inspectProcess: (ptyId) => inspectProcessFromRuntimeController(ptyId), + confirmForegroundProcess: (ptyId) => confirmForegroundProcessFromRuntimeController(ptyId), + getCwd: (ptyId) => getCwdFromRuntimeController(ptyId), + hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId), + clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId), + hasPty: (ptyId) => hasPtyFromRuntimeController(ptyId), + listProcesses: (connectionId, opts) => + listProcessesFromRuntimeController(deps, connectionId, opts), + listProcessesWithHostScope: (opts) => + listProcessesWithHostScopeFromRuntimeController(deps, opts), + serializeBuffer: (ptyId, opts) => { + // Why: mobile xterm must start from the desktop's exact screen state/dimensions before live TUI chunks render correctly. + return requestSerializedBuffer(ptyId, opts) + }, + serializeProviderBuffer: (ptyId, opts) => + serializeProviderBufferFromRuntimeController(ptyId, opts), + hasRendererSerializer: (ptyId) => hasRendererSerializerFromRuntimeController(ptyId), + getRendererSerializerGeneration: (ptyId) => + getRendererSerializerGenerationFromRuntimeController(ptyId), + waitForRendererSerializer: (ptyId, afterGeneration, timeoutMs, signal) => + waitForRendererSerializerFromRuntimeController(ptyId, afterGeneration, timeoutMs, signal), + getSize: (ptyId) => getSizeFromRuntimeController(ptyId), + resize: (ptyId, cols, rows) => resizePtyFromRuntimeController(ptyId, cols, rows) + }) +} diff --git a/src/main/ipc/pty/runtime/kill.ts b/src/main/ipc/pty/runtime/kill.ts new file mode 100644 index 00000000000..3f38ac339dd --- /dev/null +++ b/src/main/ipc/pty/runtime/kill.ts @@ -0,0 +1,311 @@ +import type { IPtyProvider } from '../../../providers/types' +import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../../../shared/pty-liveness-verdict' +import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' +import { ptyOwnership, ptyIncarnationById } from '../provider/ownership-state' +import { getProvider, getProviderForPty } from '../provider/registry' +import { isPtyAlreadyGoneError, delay, verifyPtyStopped } from '../provider/liveness' +import type { PtyRuntimeControllerDeps } from './controller-deps' + +export function killPtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string +): boolean { + const { + runtime, + store, + getLocalPtyProviderStartupPromise, + shutdownProviderAndDetectExit, + rememberSyntheticKillExit, + sendPtyExitToRenderer, + finishPtyShutdown, + retiredRejectedPtyIds + } = deps + runtime?.markPtyStopRequested?.(ptyId) + let connectionId: string | null | undefined = ptyOwnership.get(ptyId) + const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null + connectionId ??= parsedSshId?.connectionId + const killWithCurrentProvider = (): boolean => { + let provider: IPtyProvider + try { + provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId) + } catch { + if (connectionId) { + // Why: runtime/CLI close can target a detached SSH PTY after its + // provider was unregistered. Tombstone the lease so reconnect does + // not revive a terminal the user explicitly closed. + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + runtime?.onPtyExit(ptyId, -1, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) + return false + } + return false + } + // Why: controller is synchronous, but keep ownership until async shutdown proves whether the provider emitted an exit. + void shutdownProviderAndDetectExit(provider, ptyId, { immediate: false }) + .then((providerExitObserved) => { + const retired = retiredRejectedPtyIds.has(ptyId) + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + if (!providerExitObserved && !retired) { + runtime?.onPtyExit(ptyId, -1, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + } + }) + .catch((err) => { + const retired = retiredRejectedPtyIds.has(ptyId) + if (isPtyAlreadyGoneError(err)) { + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + if (!retired) { + runtime?.onPtyExit(ptyId, -1, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + } + return + } + console.warn( + `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` + ) + // Why: close runtime tails without clearing provider ownership, so + // a retry can still target a PTY that survived the failed shutdown. + if (!retired) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } + runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) + } + }) + return true + } + const startupPromise = getLocalPtyProviderStartupPromise(connectionId) + if (startupPromise) { + // Why: select the provider after the daemon swap; the fallback first can report success while orphaning a daemon PTY. + void startupPromise.then(killWithCurrentProvider).catch((err) => { + console.warn( + `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` + ) + if (!retiredRejectedPtyIds.has(ptyId)) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } + runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) + } + }) + return true + } + return killWithCurrentProvider() +} + +export function retireRejectedPtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string, + stopConfirmed: boolean +): void { + const { + runtime, + store, + rememberRetiredRejectedPty, + rememberSyntheticKillExit, + sendPtyExitToRenderer, + finishPtyShutdown + } = deps + rememberRetiredRejectedPty(ptyId) + if (!stopConfirmed) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + 'a follow-up stop was issued but its outcome could not be verified' + ) + if (!ptyOwnership.has(ptyId)) { + return + } + runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: -1, + ...(ptyIncarnationById.get(ptyId) ? { incarnationId: ptyIncarnationById.get(ptyId) } : {}) + }) + return + } + // Why: a completed stop already cleared provider state, tombstoned the lease and told the + // renderer; repeating that double-fires the exit IPC. The runtime still needs code 0 so an + // SSH pane retires for good instead of staying preserved by the stop's negative exit. + if (!ptyOwnership.has(ptyId)) { + runtime?.onPtyExit(ptyId, 0, ptyIncarnationById.get(ptyId)) + return + } + let connectionId: string | null | undefined = ptyOwnership.get(ptyId) + const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null + connectionId ??= parsedSshId?.connectionId + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + runtime?.onPtyExit(ptyId, 0, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: 0, + ...(incarnationId ? { incarnationId } : {}) + }) +} + +export function markReversibleStopsFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyIds: readonly string[] +): () => void { + const { reversibleStopOwnersByPtyId } = deps + for (const ptyId of ptyIds) { + reversibleStopOwnersByPtyId.set(ptyId, (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) + 1) + } + let released = false + return () => { + if (released) { + return + } + released = true + for (const ptyId of ptyIds) { + const owners = (reversibleStopOwnersByPtyId.get(ptyId) ?? 0) - 1 + if (owners > 0) { + reversibleStopOwnersByPtyId.set(ptyId, owners) + } else { + reversibleStopOwnersByPtyId.delete(ptyId) + } + } + } +} + +export async function stopAndWaitPtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string, + opts?: { keepHistory?: boolean; deadlineMs?: number } +): Promise { + const { + runtime, + store, + getLocalPtyProviderStartupPromise, + shutdownProviderAndDetectExit, + rememberSyntheticKillExit, + sendPtyExitToRenderer, + finishPtyShutdown + } = deps + runtime?.markPtyStopRequested?.(ptyId) + let connectionId: string | null | undefined = ptyOwnership.get(ptyId) + const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null + connectionId ??= parsedSshId?.connectionId + // Why: destructive teardown threads one absolute deadline through every await + // below; each RPC leaf converts it to the remaining time when it issues, so + // sequential RPCs share the budget and cannot overrun the sweep deadline. + const deadlineMs = opts?.deadlineMs + const startupPromise = getLocalPtyProviderStartupPromise(connectionId) + if (startupPromise) { + // Why: exact-stop must resolve the provider after daemon startup just + // like renderer kills, or the fallback can falsely confirm teardown. + if (deadlineMs !== undefined) { + // Why: bound the cold-start await by the teardown deadline instead of the + // 60s startup fail-open cap; fail closed so the sweep records the miss. + const won = await Promise.race([ + // Why: () => false on rejection both fails closed on a startup error and + // keeps the losing branch's rejection from surfacing as unhandled. + startupPromise.then( + () => true, + () => false + ), + delay(Math.max(1, deadlineMs - Date.now())).then(() => false) + ]) + if (!won) { + return false + } + } else { + await startupPromise + } + } + let provider: IPtyProvider + try { + provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId) + } catch { + if (connectionId) { + // Why: an absent SSH provider means there is no live target left to + // await, but the relay lease must still be tombstoned. + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + runtime?.onPtyExit(ptyId, -1, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: -1, + ...(incarnationId ? { incarnationId } : {}) + }) + runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON) + } + return false + } + let providerExitObserved = false + try { + providerExitObserved = await shutdownProviderAndDetectExit(provider, ptyId, { + immediate: true, + keepHistory: opts?.keepHistory ?? false, + deadlineMs + }) + } catch (err) { + if (!isPtyAlreadyGoneError(err)) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } + console.warn( + `[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}` + ) + return false + } + } + try { + if (!(await verifyPtyStopped(provider, ptyId, opts))) { + runtime?.markPtyLivenessLive?.(ptyId) + return false + } + } catch (err) { + if (connectionId) { + runtime?.markPtyLivenessUnverifiable?.( + ptyId, + err instanceof Error ? err.message : String(err) + ) + } + console.warn( + `[pty] Failed to verify PTY ${ptyId} stopped: ${ + err instanceof Error ? err.message : String(err) + }` + ) + return false + } + const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + if (!providerExitObserved) { + // The owning provider's fresh inventory observed absence, so this is a + // death certificate even when its exit event was missed. + runtime?.onPtyExit(ptyId, 0, incarnationId) + rememberSyntheticKillExit(ptyId) + sendPtyExitToRenderer({ + id: ptyId, + code: 0, + ...(incarnationId ? { incarnationId } : {}) + }) + } + return true +} diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts new file mode 100644 index 00000000000..745f225312a --- /dev/null +++ b/src/main/ipc/pty/runtime/operations.ts @@ -0,0 +1,259 @@ +import type { IPtyProvider } from '../../../providers/types' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import type { PtyProcessInfo } from '../../../providers/pty-process-info' +import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' +import { + LOCAL_EXECUTION_HOST_ID, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { ptyOwnership } from '../provider/ownership-state' +import { ptySizes } from '../delivery/visibility-state' +import { rendererSerializerReadiness } from '../pane/serializer-state' +import { + getProvider, + getProviderForPty, + localProvider, + registeredPtyProviders +} from '../provider/registry' +import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspection' +import type { PtyRuntimeControllerDeps } from './controller-deps' + +export function writePtyFromRuntimeController(ptyId: string, data: string): boolean { + try { + getProviderForPty(ptyId).write(ptyId, data) + return true + } catch { + return false + } +} + +export async function probePtyLivenessFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string +): Promise { + try { + // Why: no locally routed provider can authoritatively answer for a + // remote host's PTY, so remote-scoped ids stay unknown, never absent. + if (ptyId.startsWith('remote:')) { + return null + } + const connectionId = ptyOwnership.get(ptyId) ?? parseAppSshPtyId(ptyId)?.connectionId + // Why: during cold start the daemon swap is in flight; the pre-swap + // fallback would answer absent for every daemon-owned id. + const startupPromise = deps.getLocalPtyProviderStartupPromise(connectionId) + if (startupPromise) { + await startupPromise + } + const provider = getProviderForPty(ptyId) + if (provider.probePtyLiveness) { + return await provider.probePtyLiveness(ptyId) + } + // Why: the in-process provider is its own sole owner (#12393), so its + // refusal is authoritative; every other probe-less provider is doubt. + if (provider instanceof LocalPtyProvider) { + return provider.hasPty(ptyId) + } + return null + } catch { + return null + } +} + +export async function attachPtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string +): Promise { + if (ptyOwnership.get(ptyId) != null || parseAppSshPtyId(ptyId)) { + return false + } + let provider: IPtyProvider + try { + provider = getProviderForPty(ptyId) + } catch { + return false + } + if (provider !== localProvider || provider instanceof LocalPtyProvider) { + return false + } + try { + const sequenceBeforeProviderAttach = deps.runtime?.getPtyOutputSequence?.(ptyId) ?? 0 + const attachResult = await provider.attach(ptyId) + if (attachResult?.providerSequence) { + deps.runtime?.synchronizePtyOutputSequenceFromProvider?.( + ptyId, + attachResult.providerSequence, + sequenceBeforeProviderAttach + ) + } + return true + } catch { + return false + } +} + +export async function getForegroundProcessFromRuntimeController(ptyId: string) { + try { + return await getProviderForPty(ptyId).getForegroundProcess(ptyId) + } catch { + return null + } +} + +export async function inspectProcessFromRuntimeController(ptyId: string) { + return inspectPtyProviderProcess(getProviderForPty(ptyId), ptyId) +} + +export async function confirmForegroundProcessFromRuntimeController(ptyId: string) { + try { + const provider = getProviderForPty(ptyId) + // Why: cached foreground evidence cannot resolve a fresh shell conflict. + return (await provider.confirmForegroundProcess?.(ptyId)) ?? null + } catch { + return null + } +} + +export async function getCwdFromRuntimeController(ptyId: string) { + try { + const cwd = await getProviderForPty(ptyId).getCwd(ptyId) + return cwd || null + } catch { + return null + } +} + +export async function hasChildProcessesFromRuntimeController(ptyId: string) { + try { + return await getProviderForPty(ptyId).hasChildProcesses(ptyId) + } catch { + return false + } +} + +export async function clearBufferFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string +): Promise { + // Why: desktop xterm and daemon/SSH providers hold separate buffers; clear both so mobile resubscribe can't resurrect cleared history. + deps.mainWindow.webContents.send('pty:clearBuffer:request', { ptyId }) + try { + await getProviderForPty(ptyId).clearBuffer(ptyId) + } catch { + /* best effort: renderer clear still handles local PTYs */ + } +} + +export function hasPtyFromRuntimeController(ptyId: string): boolean | null { + try { + return getProviderForPty(ptyId).hasPty?.(ptyId) ?? null + } catch { + return null + } +} + +function markSshInventoryUnverifiable( + runtime: PtyRuntimeControllerDeps['runtime'], + connectionId: string, + error: unknown +): void { + const reason = error instanceof Error ? error.message : String(error) + for (const [ptyId, ownerConnectionId] of ptyOwnership) { + if (ownerConnectionId === connectionId) { + runtime?.markPtyLivenessUnverifiable?.(ptyId, reason) + } + } +} + +export async function listProcessesWithHostScopeFromRuntimeController( + deps: PtyRuntimeControllerDeps, + opts?: { deadlineMs?: number } +): Promise<{ processes: PtyProcessInfo[]; hostIds: ExecutionHostId[] }> { + const providerSessions = await Promise.all( + registeredPtyProviders().map(async ({ provider, connectionId }) => { + const hostId: ExecutionHostId = connectionId + ? toSshExecutionHostId(connectionId) + : LOCAL_EXECUTION_HOST_ID + try { + return { + processes: await (connectionId ? provider.listProcesses(opts) : provider.listProcesses()), + hostId + } + } catch (error) { + if (!connectionId) { + throw error + } + markSshInventoryUnverifiable(deps.runtime, connectionId, error) + return null + } + }) + ) + const respondingSessions = providerSessions.filter((session) => session !== null) + return { + processes: respondingSessions.flatMap((session) => session.processes), + hostIds: respondingSessions.map((session) => session.hostId) + } +} + +export async function listProcessesFromRuntimeController( + deps: PtyRuntimeControllerDeps, + connectionId?: string | null, + opts?: { deadlineMs?: number } +) { + if (connectionId === null) { + return localProvider.listProcesses() + } + if (connectionId !== undefined) { + try { + return await getProvider(connectionId).listProcesses(opts) + } catch (error) { + markSshInventoryUnverifiable(deps.runtime, connectionId, error) + throw error + } + } + return (await listProcessesWithHostScopeFromRuntimeController(deps, opts)).processes +} + +export function resizePtyFromRuntimeController(ptyId: string, cols: number, rows: number): boolean { + try { + getProviderForPty(ptyId).resize(ptyId, cols, rows) + ptySizes.set(ptyId, { cols, rows }) + return true + } catch { + return false + } +} + +export function hasRendererSerializerFromRuntimeController(ptyId: string): boolean { + // Why: a synchronous probe lets the runtime decide whether to skip the daemon-snapshot seed (renderer will hydrate) or run it (no renderer authoritative). + return rendererSerializerReadiness.has(ptyId) +} + +export function getRendererSerializerGenerationFromRuntimeController(ptyId: string) { + return rendererSerializerReadiness.generation(ptyId) +} + +export function waitForRendererSerializerFromRuntimeController( + ptyId: string, + afterGeneration = 0, + timeoutMs?: number, + signal?: AbortSignal +) { + return rendererSerializerReadiness.wait(ptyId, afterGeneration ?? 0, timeoutMs, signal) +} + +export function getSizeFromRuntimeController(ptyId: string) { + return ptySizes.get(ptyId) ?? null +} + +export async function serializeProviderBufferFromRuntimeController( + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } +) { + try { + // Why: restored daemon PTYs can be live while their desktop pane is unmounted; query the provider model so phone-local navigation works. + return (await getProviderForPty(ptyId).getBufferSnapshot?.(ptyId, opts)) ?? null + } catch { + return null + } +} diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts new file mode 100644 index 00000000000..ecf4f4ebcd4 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -0,0 +1,307 @@ +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' +import { ptySizes } from '../delivery/visibility-state' +import { getRelayPtyId } from '../provider/registry' +import { + shouldSkipCodexHomeEnvForWindowsShell, + recordCodexPaneAccountForSpawn, + codexReattachedHomeRouteField +} from '../host-env/codex-home' +import { markClaudePtySpawned } from '../../../claude-accounts/live-pty-gate' +import { registerPty } from '../../../memory/pty-registry' +import { rememberPaneKeyForPty } from '../pane/key-state' +import { + pendingByPaneKey, + pendingPtyIdBySerializerGeneration, + rendererSerializerReadiness +} from '../pane/serializer-state' +import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' +import { track } from '../../../telemetry/client' +import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' +import { + agentKindSchema, + launchSourceSchema, + requestKindSchema +} from '../../../../shared/telemetry-events' +import { persistAdmittedStablePaneBinding } from '../pane/stable-owner' +import { + isNativeWindowsLocalPtySpawn, + markNativeWindowsConptyPty +} from '../../../runtime/terminal-model-query-authority' +import { toSshExecutionHostId } from '../../../../shared/execution-host' +import { createTerminalSessionStateSaveFailureMessage } from '../../../../shared/terminal-session-state-save-failure' +import { clearProviderPtyState } from '../provider/state-cleanup' +import { resolvePaneSpawnReservation } from '../pane/spawn-reservation' +import type { RuntimePtySpawnState } from './spawn-state' + +export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { + const args = ctx.args + try { + ctx.stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + store: ctx.hostSessionBinding?.store, + owner: ctx.stablePaneOwner, + result: ctx.result, + worktreeId: ctx.hostSessionBinding?.worktreeId, + startupCwd: ctx.cwd, + connectionId: args.connectionId + }) + } catch (error) { + if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { + throw error + } + console.error('[pty] failed to persist runtime PTY binding after attach:', error) + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } + if (ctx.result.agentSessionEnsure?.disposition === 'adopted') { + const owner = ctx.result.agentSessionEnsure.owner + ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null) + ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle) + if (ctx.result.incarnationId) { + ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) + } + ctx.deps.runtime?.registerPty( + ctx.result.id, + owner.surface.worktreeId, + args.connectionId ?? null, + { + tabId: owner.surface.tabId, + leafId: owner.surface.leafId, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}) + } + ) + if (!args.connectionId) { + ctx.deps.options?.onCodexHomePtySpawned?.({ + id: ctx.result.id, + codexHomePath: ctx.selectedCodexHomePath, + reattached: true, + startedAt: ctx.codexHomeLaunchStartedAt, + startedSequence: ctx.codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField(ctx.reattachedCodexHomeRoutes, ctx.result.id, true), + ...(ctx.env ? { launchEnv: ctx.env } : {}) + }) + } + // Why: the adopted branch returns before the normal settle site, so the + // reservation must be resolved here or every later spawn for this pane + // awaits a promise that never settles. + resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, { + ...ctx.result, + isReattach: true + }) + return { + id: ctx.result.id, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + agentSessionEnsure: ctx.result.agentSessionEnsure + } + } + ptyOwnership.set(ctx.result.id, args.connectionId ?? null) + if (ctx.result.incarnationId) { + ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) + } + // Why: record the native-Windows-local-PTY determination before any byte reaches the emulator, so its ConPTY DA1 override exists from byte zero. + if ( + isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: ctx.daemonShellOverride + }) + ) { + markNativeWindowsConptyPty(ctx.result.id) + } + const relayResultId = getRelayPtyId(args.connectionId, ctx.result.id) + const persistSshLease = (): void => { + if (!ctx.deps.store || !args.connectionId) { + return + } + // Why: SSH leases keep relay ids for remote reconciliation, while session bindings keep app-facing ids for hydration. + ctx.deps.store.upsertSshRemotePtyLease({ + targetId: args.connectionId, + ptyId: relayResultId, + ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}), + ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}), + ...(typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) + ? { leafId: args.leafId } + : {}), + state: 'attached', + lastAttachedAt: Date.now() + }) + } + if (!ctx.hostSessionBinding) { + persistSshLease() + } + ptySizes.set(ctx.result.id, { cols: args.cols, rows: args.rows }) + if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { + ptySizes.delete(ctx.effectiveSessionAppId) + } + recordCodexPaneAccountForSpawn({ + ptyId: ctx.result.id, + isDaemonHostSpawn: ctx.isDaemonHostSpawn, + isReattach: ctx.result.isReattach === true, + pinnedByResume: ctx.codexResumeHomeSelected, + launchCodexHomePath: ctx.selectedCodexHomePath, + launchEnv: args.env, + target: ctx.codexSelectionTarget, + settings: ctx.deps.getSettings?.() + }) + if (ctx.hostSessionBinding && !ctx.stablePaneBindingPersisted) { + try { + const binding = { + worktreeId: ctx.hostSessionBinding.worktreeId, + tabId: ctx.hostSessionBinding.tabId, + leafId: ctx.hostSessionBinding.leafId, + ptyId: ctx.result.id, + hostAdmittedMembership: true, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(ctx.cwd ? { startupCwd: ctx.cwd } : {}), + ...(ctx.hostSessionBinding.expectedSourceBinding + ? { expectedSourceBinding: ctx.hostSessionBinding.expectedSourceBinding } + : {}) + } + const persisted = args.connectionId + ? ctx.hostSessionBinding.store.persistPtyBinding( + binding, + toSshExecutionHostId(args.connectionId) + ) + : ctx.hostSessionBinding.store.persistPtyBinding(binding) + if (persisted === false) { + throw new Error('terminal_split_source_not_found') + } + } catch (err) { + console.error('[pty] failed to persist runtime PTY binding after spawn:', err) + if (!ctx.result.isReattach) { + deletePtyOwnership(ctx.result.id) + try { + await ctx.provider.shutdown(ctx.result.id, { immediate: true }) + } catch (shutdownErr) { + console.warn('[pty] failed to clean up PTY after persistence failure:', shutdownErr) + } + clearProviderPtyState(ctx.result.id) + } + if (err instanceof Error && err.message === 'terminal_split_source_not_found') { + throw err + } + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } + persistSshLease() + } + if (args.preAllocatedHandle && !ctx.stablePaneOwner?.handle) { + ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, args.preAllocatedHandle) + } + if (args.worktreeId) { + ctx.deps.runtime?.registerPty( + ctx.result.id, + args.worktreeId, + args.connectionId ?? null, + // Why: thread validated pane identity so main can back a pending mobile create even if graph-sync stalls (#7587). + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + ctx.metadataLeafId !== null + ? { + tabId: args.tabId, + leafId: ctx.metadataLeafId, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}) + } + : undefined, + !args.connectionId + ? shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd) + : undefined + ) + } else { + // Why: non-worktree PTYs have no later surface-registration phase to clear admission intent. + ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) + } + // Why: runtime-controller creates (headless serve, CLI, splits) adopt surviving daemon sessions too; without this seed their records stay blank. + seedTerminalRestoreRecordsFromSpawnResult(ctx.deps.runtime, ctx.result) + // Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity). + if (!ctx.stablePaneOwner) { + ctx.deps.runtime?.noteTerminalSpawnCommand?.(ctx.result.id, ctx.launchCommand ?? null) + } + if (ctx.isClaudeLaunch && !ctx.stablePaneOwner) { + markClaudePtySpawned(ctx.result.id) + } + if (args.telemetry && !ctx.stablePaneOwner) { + const agentKindParse = agentKindSchema.safeParse(args.telemetry.agent_kind) + const launchSourceParse = launchSourceSchema.safeParse(args.telemetry.launch_source) + const requestKindParse = requestKindSchema.safeParse(args.telemetry.request_kind) + if (agentKindParse.success && launchSourceParse.success && requestKindParse.success) { + track('agent_started', { + agent_kind: agentKindParse.data, + launch_source: launchSourceParse.data, + request_kind: requestKindParse.data, + ...getCohortAtEmit() + }) + } + } + // Why: runtime-owned CLI PTYs bypass the renderer pty:spawn handler; record paneKey here too since hook titles and cache cleanup need this reverse lookup. + const paneKey = rememberPaneKeyForPty(ctx.result.id, ctx.env?.ORCA_PANE_KEY) + const pendingSerializer = paneKey ? pendingByPaneKey.get(paneKey) : undefined + const inheritRendererReadiness = + ctx.result.isReattach === true && + !pendingSerializer && + rendererSerializerReadiness.has(ctx.result.id) + rendererSerializerReadiness.beginIncarnation(ctx.result.id, inheritRendererReadiness) + if (paneKey && pendingSerializer) { + pendingPtyIdBySerializerGeneration.set(pendingSerializer.gen, ctx.result.id) + } + if (!args.connectionId) { + registerPty({ + ptyId: ctx.result.id, + worktreeId: args.worktreeId ?? null, + sessionId: ctx.sessionId ?? null, + paneKey, + pid: + typeof ctx.result.pid === 'number' && Number.isFinite(ctx.result.pid) && ctx.result.pid > 0 + ? ctx.result.pid + : null + }) + } + // Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal. + ctx.deps.sendPtySpawnedToRenderer(ctx.result.id) + if (!args.connectionId) { + ctx.deps.options?.onCodexHomePtySpawned?.({ + id: ctx.result.id, + codexHomePath: ctx.selectedCodexHomePath, + startedAt: ctx.codexHomeLaunchStartedAt, + startedSequence: ctx.codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField( + ctx.reattachedCodexHomeRoutes, + ctx.result.id, + ctx.result.isReattach === true + ), + ...(ctx.result.isReattach === true + ? { reattached: true } + : ctx.env + ? { launchEnv: ctx.env } + : {}) + }) + } + const response = { + id: ctx.result.id, + ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), + ...(ctx.stablePaneOwner && (ctx.stablePaneOwner.handle || args.preAllocatedHandle) + ? { + stablePaneOwner: { + handle: ctx.stablePaneOwner.handle ?? args.preAllocatedHandle!, + tabId: ctx.stablePaneOwner.tabId, + leafId: ctx.stablePaneOwner.leafId + } + } + : {}), + ...(ctx.result.agentSessionEnsure ? { agentSessionEnsure: ctx.result.agentSessionEnsure } : {}) + } + resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, { + ...ctx.result, + ...(typeof ctx.result.snapshotKittyKeyboardFlags === 'number' && + ctx.reconciledSnapshotSeq !== null && + ctx.snapshotKittyFlagsCoverReconciledSeq + ? { snapshotSeq: ctx.reconciledSnapshotSeq } + : { snapshotKittyKeyboardFlags: undefined }), + isReattach: true + }) + return response +} diff --git a/src/main/ipc/pty/runtime/spawn-early.ts b/src/main/ipc/pty/runtime/spawn-early.ts new file mode 100644 index 00000000000..575f89b22e7 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-early.ts @@ -0,0 +1,71 @@ +import type { PtySpawnResult } from '../../../providers/types' +import { getAppPtyId } from '../provider/registry' +import { allocatePtyLifecycleSequence } from '../host-env/types' +import { snapshotCodexPaneHomeRoutes, codexReattachedHomeRouteField } from '../host-env/codex-home' +import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' +import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' +import type { RuntimePtySpawnState } from './spawn-state' + +export function adoptMaterializedRuntimePtySpawn( + ctx: RuntimePtySpawnState, + startupAlreadyAwaited = false +): Promise | PtySpawnResult | null { + const args = ctx.args + // Why: the re-entry after the startup await must not re-mint the lifecycle + // sequence or re-snapshot routes taken before the barrier. + if (!startupAlreadyAwaited) { + ctx.codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined + ctx.codexHomeLaunchStartedSequence = !args.connectionId + ? allocatePtyLifecycleSequence() + : undefined + ctx.preAdoptedStablePane = args.adoptedStablePane ?? null + ctx.reattachedCodexHomeRoutes = !args.connectionId + ? new Map( + snapshotCodexPaneHomeRoutes([ + ctx.preAdoptedStablePane?.result.id, + args.sessionId ? getAppPtyId(args.connectionId, args.sessionId) : undefined + ]) + ) + : new Map() + } + const startupPromise = ctx.deps.getLocalPtyStartupPromise(args.connectionId) + if (startupPromise && !startupAlreadyAwaited) { + return startupPromise.then(() => adoptMaterializedRuntimePtySpawn(ctx, true)) + } + if (!ctx.preAdoptedStablePane?.materialized) { + return null + } + const handle = ctx.preAdoptedStablePane.owner.handle ?? args.preAllocatedHandle + if (!handle) { + throw new Error('terminal_pane_owner_unknown') + } + ctx.result = { + id: ctx.preAdoptedStablePane.result.id, + ...(ctx.preAdoptedStablePane.result.incarnationId + ? { incarnationId: ctx.preAdoptedStablePane.result.incarnationId } + : {}), + ...(typeof ctx.preAdoptedStablePane.result.wslDistro === 'string' + ? { wslDistro: ctx.preAdoptedStablePane.result.wslDistro } + : {}), + stablePaneOwner: { + handle, + tabId: ctx.preAdoptedStablePane.owner.tabId, + leafId: ctx.preAdoptedStablePane.owner.leafId + } + } + ensureWslHookRelayForReattach( + { isReattach: true, wslDistro: ctx.preAdoptedStablePane.result.wslDistro }, + args.connectionId + ) + if (!args.connectionId) { + ctx.deps.options?.onCodexHomePtySpawned?.({ + id: ctx.result.id, + codexHomePath: null, + reattached: true, + startedAt: ctx.codexHomeLaunchStartedAt, + startedSequence: ctx.codexHomeLaunchStartedSequence, + ...codexReattachedHomeRouteField(ctx.reattachedCodexHomeRoutes, ctx.result.id, true) + }) + } + return ctx.result +} diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts new file mode 100644 index 00000000000..99829f119bb --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -0,0 +1,262 @@ +import type { PtySpawnResult } from '../../../providers/types' +import { ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' +import { ptySizes } from '../delivery/visibility-state' +import { tryGetProviderForAgentSessionOwner } from '../provider/registry' +import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' +import { + agentSessionOwners, + assertSpawnReplyWasLive, + reconcileAgentSessionOwnerListings +} from '../pane/agent-session-owners' +import { spawnForStablePane, resolveStablePaneOwner } from '../pane/stable-owner' +import { clearProviderPtyState } from '../provider/state-cleanup' +import { isProviderAgentSessionOwnerLive, normalizeNodePtySpawnError } from '../provider/liveness' +import { + SSH_SESSION_EXPIRED_ERROR, + isSshPtyIdentityMismatchError +} from '../../../providers/ssh-pty-errors' +import type { RuntimePtySpawnState } from './spawn-state' + +export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise { + const args = ctx.args + const runtime = ctx.deps.runtime + const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn + ctx.releaseWorktreeSpawn = acquireWorktreeSpawn + ? await acquireWorktreeSpawn.call(runtime, args.worktreeId) + : undefined + try { + if (args.preAllocatedHandle) { + ctx.deps.trustedTerminalHandleEnv.add(args.preAllocatedHandle) + } + const stablePaneOwnerCandidate = ctx.preAdoptedStablePane + ? ctx.preAdoptedStablePane.owner + : args.agentSessionEnsure + ? null + : resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ) + const expectedPtyId = + stablePaneOwnerCandidate?.ptyId ?? ctx.effectiveSessionAppId ?? ctx.sessionId + if (expectedPtyId) { + ctx.deps.runtime?.beginPtyRegistration?.(expectedPtyId) + ctx.pendingRegistrationPtyId = expectedPtyId + } + if (ctx.isDaemonHostSpawn && expectedPtyId) { + ctx.preparedProvisionalExecutionContext = + ctx.deps.runtime?.preparePtyExecutionContext?.(expectedPtyId, ctx.expectedWslDistro, { + resetIncarnation: ctx.isNewDaemonSession && !stablePaneOwnerCandidate, + preserveExisting: !ctx.isNewDaemonSession || Boolean(stablePaneOwnerCandidate) + }) ?? false + } + const sequenceBeforeProviderSpawn = expectedPtyId + ? (ctx.deps.runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) + : 0 + const assertClientStillConnected = (): void => { + if (args.signal?.aborted) { + throw new Error('client_disconnected') + } + } + if (args.agentSessionEnsure && !ctx.preAdoptedStablePane) { + // Why: daemon-backed claims can outlive this controller; import all + // proven owners before deciding that an identity is absent. + await reconcileAgentSessionOwnerListings() + const recoveredOwner = agentSessionOwners.find(args.agentSessionEnsure.claim) + if (recoveredOwner && ctx.pendingRegistrationPtyId !== recoveredOwner.ptyId) { + if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.pendingRegistrationPtyId) + } + ctx.deps.runtime?.beginPtyRegistration?.( + recoveredOwner.ptyId, + ptyIncarnationById.get(recoveredOwner.ptyId) + ) + ctx.pendingRegistrationPtyId = recoveredOwner.ptyId + } + let providerResult: PtySpawnResult | null = null + const ensured = await agentSessionOwners.ensure({ + claim: args.agentSessionEnsure.claim, + surface: args.agentSessionEnsure.surface, + spawn: async () => { + assertClientStillConnected() + providerResult = await ctx.provider.spawn(ctx.spawnOptions) + ctx.rejectedRegistrationCandidate = providerResult + // Why: a successful lower-owner return proves physical work committed even if admission sees an early exit. + ctx.reportPtySpawnCommitted() + assertSpawnReplyWasLive(providerResult) + ctx.deps.runtime?.assertPtyRegistrationAllowed?.( + providerResult.id, + providerResult.incarnationId + ) + if (providerResult.incarnationId) { + // Why: local providers cannot serialize controller claims, so liveness proof + // needs the exact incarnation before the registry promotes the new owner. + ptyIncarnationById.set(providerResult.id, providerResult.incarnationId) + } + const providerEnsure = providerResult.agentSessionEnsure + return { + ptyId: providerResult.id, + ...(providerEnsure + ? { + owner: providerEnsure.owner, + disposition: providerEnsure.disposition + } + : {}) + } + }, + isLive: async (owner) => { + const ownerProvider = tryGetProviderForAgentSessionOwner(owner.ptyId) + if (!ownerProvider) { + // Why: a disconnected relay may keep its PTY alive during the + // grace window; missing transport is unknown, never absence. + throw new Error('execution_owner_unavailable') + } + return await isProviderAgentSessionOwnerLive(ownerProvider, owner) + } + }) + ctx.result = providerResult ?? { + id: ensured.owner.ptyId, + isReattach: true, + // Why: adoption from an authoritative listing must preserve the + // incarnation proof used to reject a delayed exit from an older process. + incarnationId: ptyIncarnationById.get(ensured.owner.ptyId) + } + ctx.result.agentSessionEnsure = ensured + } else { + assertClientStillConnected() + const stablePaneSpawn = ctx.preAdoptedStablePane + ? ctx.preAdoptedStablePane + : await spawnForStablePane({ + runtime: ctx.deps.runtime, + store: ctx.deps.store, + provider: ctx.provider, + spawnOptions: ctx.spawnOptions, + owner: stablePaneOwnerCandidate, + worktreeId: args.worktreeId, + connectionId: args.connectionId, + resolveOwner: () => + resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ), + onFreshSpawn: ctx.reportPtySpawnCommitted + }) + ctx.result = stablePaneSpawn.result + ctx.stablePaneOwner = stablePaneSpawn.owner + if ( + ctx.stablePaneOwner && + ctx.isNewDaemonSession && + ctx.effectiveSessionAppId && + ctx.effectiveSessionAppId !== ctx.result.id + ) { + clearProviderPtyState(ctx.effectiveSessionAppId) + } + ctx.rejectedRegistrationCandidate = ctx.result + assertSpawnReplyWasLive(ctx.result) + } + ctx.rejectedRegistrationCandidate ??= ctx.result + if (ctx.pendingRegistrationPtyId !== ctx.result.id) { + if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.(ctx.pendingRegistrationPtyId) + } + ctx.deps.runtime?.beginPtyRegistration?.(ctx.result.id, ctx.result.incarnationId) + ctx.pendingRegistrationPtyId = ctx.result.id + } + // Why: admission precedes sequence/context state and every durable publication below. + ctx.deps.runtime?.assertPtyRegistrationAllowed?.(ctx.result.id, ctx.result.incarnationId) + if (ctx.result.providerSequence) { + const runtimeSequenceBeforeReconcile = + ctx.deps.runtime?.getPtyOutputSequence?.(ctx.result.id) ?? 0 + // Why kept: this is the reattach boundary in the RENDERER's sequence + // domain, and the daemon snapshot's kitty flags mean nothing without + // the boundary they were proven at. + ctx.reconciledSnapshotSeq = + ctx.deps.runtime?.synchronizePtyOutputSequenceFromProvider?.( + ctx.result.id, + ctx.result.providerSequence, + sequenceBeforeProviderSpawn + ) ?? null + if (runtimeSequenceBeforeReconcile > sequenceBeforeProviderSpawn) { + ctx.snapshotKittyFlagsCoverReconciledSeq = false + } + } + ensureWslHookRelayForReattach(ctx.result, args.connectionId) + ctx.deps.runtime?.preparePtyExecutionContext?.( + ctx.result.id, + args.connectionId + ? null + : ctx.result.wslDistro === undefined + ? ctx.expectedWslDistro + : ctx.result.wslDistro + ) + } catch (err) { + if ( + (ctx.isNewDaemonSession || ctx.preparedProvisionalExecutionContext) && + ctx.effectiveSessionAppId + ) { + ctx.deps.runtime?.preparePtyExecutionContext?.(ctx.effectiveSessionAppId, null, { + resetIncarnation: true + }) + } + const rawMessage = err instanceof Error ? err.message : String(err) + if (rawMessage === 'agent_session_exited_during_start' && ctx.rejectedRegistrationCandidate) { + ctx.deps.runtime?.releaseRejectedPtyRegistrationFence?.( + ctx.rejectedRegistrationCandidate.id, + ctx.rejectedRegistrationCandidate.incarnationId + ) + } + if (ctx.pendingRegistrationPtyId) { + ctx.deps.runtime?.cancelPendingPtyRegistration?.( + ctx.pendingRegistrationPtyId, + ctx.rejectedRegistrationCandidate?.incarnationId + ) + ctx.pendingRegistrationPtyId = null + } + const spawnError = normalizeNodePtySpawnError(err) + const isIdentityMismatch = + isSshPtyIdentityMismatchError(spawnError) || isSshPtyIdentityMismatchError(rawMessage) + const isExpiredSshSession = + Boolean(args.connectionId) && + (spawnError.message.includes(SSH_SESSION_EXPIRED_ERROR) || + rawMessage.includes(SSH_SESSION_EXPIRED_ERROR)) + const exitedBeforeSpawnReply = + ctx.rejectedRegistrationCandidate?.exitedBeforeSpawnReply === true + if (ctx.effectiveSessionAppId !== undefined) { + if ( + ctx.hadSessionSizeBeforeAttach && + ctx.sessionSizeBeforeAttach && + (isIdentityMismatch || (!isExpiredSshSession && !exitedBeforeSpawnReply)) + ) { + ptySizes.set(ctx.effectiveSessionAppId, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(ctx.effectiveSessionAppId) + } + } + if (args.connectionId && ctx.effectiveSessionRelayId !== undefined && isExpiredSshSession) { + if (ctx.effectiveSessionAppId !== undefined && !isIdentityMismatch) { + clearProviderPtyState(ctx.effectiveSessionAppId) + deletePtyOwnership(ctx.effectiveSessionAppId) + } + if (!isIdentityMismatch) { + ctx.deps.store?.markSshRemotePtyLease( + args.connectionId, + ctx.effectiveSessionRelayId, + 'expired' + ) + } + } + if (ctx.isNewDaemonSession && ctx.sessionId !== undefined) { + clearProviderPtyState(ctx.sessionId) + } + throw spawnError + } finally { + if (args.preAllocatedHandle) { + ctx.deps.trustedTerminalHandleEnv.delete(args.preAllocatedHandle) + } + } +} diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts new file mode 100644 index 00000000000..fcba7770a39 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -0,0 +1,250 @@ +import type { IPtyProvider, PtySpawnResult } from '../../../providers/types' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { makePaneKey, isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { ptySizes } from '../delivery/visibility-state' +import { CODEX_HOME_ENV_KEYS } from '../host-env/codex-home' +import { + mergePtyEnvDeletions, + removeCodexHomeDeletionRequests, + getInheritedAgentHookEnvKeysToDelete, + getInheritedClaudeSessionStampEnvKeysToDelete +} from '../host-env/pi-agent' +import { promoteAgentTeamsShimPath, deleteRequestedEnvKeys } from '../host-env/path' +import { + routesFreshSpawnsToLocalProvider, + beginPtySpawnForWorktree +} from '../host-env/fresh-spawn-routing' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' +import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' +import { resolveStablePaneOwner } from '../pane/stable-owner' +import { getStartupTerminalColorQueryReplyColors } from '../../terminal-startup-color-query-replies' +import { + makePaneSpawnReservationKey, + reservePaneSpawn, + paneSpawnReservationsByOwnerKey +} from '../pane/spawn-reservation' +import type { RuntimePtySpawnState } from './spawn-state' + +export async function buildRuntimePtySpawnOptions( + ctx: RuntimePtySpawnState +): Promise< + (PtySpawnResult & { stablePaneOwner?: { handle: string; tabId: string; leafId: string } }) | null +> { + const args = ctx.args + + const authEnvToDelete = ctx.claudeAuth?.stripAuthEnv + ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] + : undefined + ctx.spawnOptions = { + cols: args.cols, + rows: args.rows, + cwd: ctx.cwd, + env: ctx.env, + historyIsolationEnabled: ctx.deps.getSettings?.()?.terminalScopeHistoryByWorktree ?? true, + ...(ctx.isNewDaemonSession ? { isNewSession: true } : {}) + } + if (!args.connectionId && !ctx.isDaemonHostSpawn) { + ctx.spawnOptions.codexHomePathOverride = { value: ctx.selectedCodexHomePath } + } + const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) + if (startupTerminalColorQueryReplyColors) { + ctx.spawnOptions.startupIngress = { + colors: startupTerminalColorQueryReplyColors, + deadlineMs: 5_000 + } + } + let ptySpawnCommitReported = false + ctx.reportPtySpawnCommitted = (): void => { + if (ptySpawnCommitReported) { + return + } + ptySpawnCommitReported = true + args.onPtySpawnCommitted?.() + } + ctx.spawnOptions.envToDelete = mergePtyEnvDeletions( + authEnvToDelete, + args.envToDelete ?? [], + // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. + ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], + ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.env) : [], + // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. + getInheritedClaudeSessionStampEnvKeysToDelete(ctx.env) + ) + if (ctx.skipCodexHomeEnv) { + ctx.spawnOptions.envToDelete = mergePtyEnvDeletions( + ctx.spawnOptions.envToDelete, + CODEX_HOME_ENV_KEYS + ) + } else if (ctx.stripInheritedOrcaCodexHome) { + // Why: the daemon owns a persistent inherited environment that may + // differ from main. ORCA_CODEX_HOME asks it to compare/delete the pair. + ctx.spawnOptions.envToDelete = mergePtyEnvDeletions(ctx.spawnOptions.envToDelete, [ + 'ORCA_CODEX_HOME' + ]) + } + if (ctx.codexResumeHomeSelected) { + ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete) + } + deleteRequestedEnvKeys(ctx.env, ctx.spawnOptions.envToDelete) + promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) + if (ctx.launchCommand !== undefined) { + ctx.spawnOptions.command = ctx.launchCommand + } + if (args.commandDelivery !== undefined) { + ctx.spawnOptions.commandDelivery = args.commandDelivery + } + if (args.startupCommandDelivery !== undefined) { + ctx.spawnOptions.startupCommandDelivery = args.startupCommandDelivery + } + if (isTuiAgent(args.launchAgent)) { + ctx.spawnOptions.launchAgent = args.launchAgent + } + if (args.worktreeId !== undefined) { + ctx.spawnOptions.worktreeId = args.worktreeId + } + ctx.hadSessionSizeBeforeAttach = + ctx.effectiveSessionAppId !== undefined ? ptySizes.has(ctx.effectiveSessionAppId) : false + ctx.sessionSizeBeforeAttach = + ctx.effectiveSessionAppId !== undefined ? ptySizes.get(ctx.effectiveSessionAppId) : undefined + if (ctx.sessionId !== undefined) { + ctx.spawnOptions.sessionId = ctx.sessionId + ptySizes.set(ctx.effectiveSessionAppId ?? ctx.sessionId, { cols: args.cols, rows: args.rows }) + } + ctx.materializedPaneKey = ctx.hostSessionBinding + ? makePaneKey(ctx.hostSessionBinding.tabId, ctx.hostSessionBinding.leafId) + : null + ctx.metadataLeafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + ctx.metadataPaneKey = + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + ctx.metadataLeafId + ? makePaneKey(args.tabId, ctx.metadataLeafId) + : null + ctx.spawnIdentityPaneKey = ctx.materializedPaneKey ?? ctx.metadataPaneKey + if (ctx.spawnIdentityPaneKey) { + ctx.spawnOptions.paneKey = ctx.spawnIdentityPaneKey + } + if (typeof args.tabId === 'string' && args.tabId.length > 0 && args.tabId.length <= 512) { + ctx.spawnOptions.tabId = args.tabId + } + if (process.platform === 'win32' && !args.connectionId) { + ctx.spawnOptions.shellOverride = ctx.terminalRuntimeOptions.shellOverride + ctx.spawnOptions.terminalWindowsWslDistro = ctx.expectedWslDistro + ctx.spawnOptions.terminalWindowsPowerShellImplementation = ctx.deps.getSettings + ? (ctx.deps.getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto') + : undefined + } + if ( + !ctx.preAdoptedStablePane && + args.agentSessionEnsure && + (await (ctx.provider as IPtyProvider).supportsAgentSessionClaims?.()) === false + ) { + // Why: runtime routing must select legacy before dispatch; never downgrade here after it began. + throw new Error('agent_session_claim_unavailable') + } + if ( + !ctx.preAdoptedStablePane && + args.agentSessionCreateOperationId && + (await (ctx.provider as IPtyProvider).supportsAgentSessionCreateOperations?.()) === false + ) { + throw new Error('execution_owner_unavailable') + } + if (!ctx.preAdoptedStablePane && args.agentSessionEnsure) { + ctx.spawnOptions.agentSessionEnsure = args.agentSessionEnsure + } + if (!ctx.preAdoptedStablePane && args.agentSessionCreateOperationId) { + ctx.spawnOptions.agentSessionCreateOperationId = args.agentSessionCreateOperationId + } + if (args.signal) { + ctx.spawnOptions.signal = args.signal + } + if ( + args.onPtySpawnCommitted && + (ctx.provider instanceof LocalPtyProvider || routesFreshSpawnsToLocalProvider(ctx.provider)) + ) { + // Why: local fallback has no lower operation ledger, so commit must be reported at native spawn. + ctx.spawnOptions.onPtySpawnCommitted = ctx.reportPtySpawnCommitted + } + + const resolvedPaneSpawnReservationKey = makePaneSpawnReservationKey( + args.worktreeId, + args.connectionId, + ctx.spawnIdentityPaneKey + ) + if ( + ctx.paneSpawnReservationKey && + ctx.paneSpawnReservationKey !== resolvedPaneSpawnReservationKey + ) { + throw new Error('terminal_pane_identity_changed') + } + if (!ctx.paneSpawnReservationKey) { + ctx.paneSpawnReservationKey = resolvedPaneSpawnReservationKey + const existingPaneSpawn = ctx.spawnIdentityPaneKey + ? paneSpawnReservationsByOwnerKey.get(resolvedPaneSpawnReservationKey!) + : undefined + if (existingPaneSpawn) { + const concurrentResult = await existingPaneSpawn.promise + const concurrentOwner = resolveStablePaneOwner( + ctx.deps.runtime, + ctx.deps.store, + ctx.spawnIdentityPaneKey, + args.worktreeId, + args.connectionId + ) + if ( + !concurrentOwner?.handle || + concurrentOwner.ptyId !== concurrentResult.id || + (concurrentOwner.incarnationId !== undefined && + concurrentResult.incarnationId !== undefined && + concurrentOwner.incarnationId !== concurrentResult.incarnationId) + ) { + throw new Error('terminal_pane_owner_unknown') + } + const reattach = { + id: concurrentOwner.ptyId, + ...(concurrentOwner.incarnationId ? { incarnationId: concurrentOwner.incarnationId } : {}), + stablePaneOwner: { + handle: concurrentOwner.handle, + tabId: concurrentOwner.tabId, + leafId: concurrentOwner.leafId + } + } + // Why: the winner owns its id's size; only a distinct losing session id remains provisional. + if (ctx.sessionId !== undefined) { + const provisionalSizeKey = ctx.effectiveSessionAppId ?? ctx.sessionId + if (provisionalSizeKey !== reattach.id) { + if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) { + ptySizes.set(provisionalSizeKey, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(provisionalSizeKey) + } + } + } + return reattach + } + } + ctx.finishTerminalInstall = beginPtySpawnForWorktree(args.worktreeId, ctx.cwd, args.connectionId) + ctx.paneSpawnReservation ??= ctx.paneSpawnReservationKey + ? reservePaneSpawn(ctx.paneSpawnReservationKey) + : null + ctx.stablePaneOwner = null + ctx.stablePaneBindingPersisted = false + ctx.rejectedRegistrationCandidate = null + ctx.pendingRegistrationPtyId = null + // Why hoisted to the reply scope: main reconciles the provider sequence + // deep inside the spawn path, but the pane needs that renderer-domain + // boundary beside the daemon snapshot's kitty flags. + ctx.reconciledSnapshotSeq = null + // False when bytes crossed the data socket during the spawn RPC: the + // reconciled boundary covers them, but the daemon proved its kitty flags + // before they existed, so the claim must not erase what the pane may + // have scanned from those bytes live. + ctx.snapshotKittyFlagsCoverReconciledSeq = true + ctx.preparedProvisionalExecutionContext = false + + return null +} diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts new file mode 100644 index 00000000000..45c3ea1172f --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -0,0 +1,281 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import type { PtySpawnResult } from '../../../providers/types' +import { LocalPtyProvider } from '../../../providers/local-pty-provider' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { getAppPtyId, getProvider, getRelayPtyId } from '../provider/registry' +import { buildPtyHostEnv } from '../host-env/assembly' +import { + getCompatibleSelectedCodexHomePath, + getCodexSelectionTargetForPty, + resolveCodexHomeAfterManagedAuthReadiness, + shouldSkipCodexHomeEnvForWindowsShell, + shouldStripInheritedOrcaCodexHome, + isCodexStatusHooksEnabled, + codexHomePathsEqual +} from '../host-env/codex-home' +import { promoteAgentTeamsShimPath } from '../host-env/path' +import { + isClaudeLaunchCommand, + recoverFreshSpawnProviderRouting, + routesFreshSpawnsToLocalProvider +} from '../host-env/fresh-spawn-routing' +import { stripRemotePaneEnvWhenHooksDisabled } from '../provider/liveness' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isClaudeAuthSwitchInProgress } from '../../../claude-accounts/live-pty-gate' +import { hasClaudeAuthEnvConflict } from '../../../claude-accounts/environment' +import { + isSafePtySessionId, + mintPtySessionId, + ptySessionIdForAgentCreateOperation +} from '../../../daemon/pty-session-id' +import { resolveWslSessionContext } from '../../../daemon/wsl-session-context' +import { isAgentStatusHooksEnabled } from '../../../agent-hooks/managed-agent-hook-controls' +import { resolveLocalWindowsTerminalRuntimeOptions } from '../../../../shared/local-windows-terminal-runtime' +import { resolveLocalProjectRuntimeForWorktreeId } from '../../../local-project-runtime-resolution' +import { resolvePathEnvKey } from '../../../pty/windows-environment-path' +import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env' +import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery' +import { clearProviderPtyState } from '../provider/state-cleanup' +import type { RuntimePtySpawnState } from './spawn-state' + +export async function prepareRuntimePtySpawn( + ctx: RuntimePtySpawnState +): Promise { + const args = ctx.args + if (!ctx.preAdoptedStablePane) { + const pathUsable = ctx.deps.assertFolderWorkspacePtyPathUsable(args.worktreeId) + if (pathUsable) { + await pathUsable + } + } + ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, args.cwd) + ctx.provider = getProvider(args.connectionId) + const freshSpawnRecovery = ctx.preAdoptedStablePane + ? undefined + : recoverFreshSpawnProviderRouting( + ctx.provider, + args.connectionId, + args.sessionId, + args.isNewSession + ) + if (freshSpawnRecovery) { + await freshSpawnRecovery + } + ctx.isClaudeLaunch = + !ctx.preAdoptedStablePane && !args.connectionId && isClaudeLaunchCommand(args.command) + if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + // Why: runtime-created terminals carry no renderer-computed projectRuntime; resolve from worktreeId to honor the project's Windows runtime. + ctx.terminalRuntimeOptions = + process.platform === 'win32' && !args.connectionId + ? resolveLocalWindowsTerminalRuntimeOptions({ + requestedShellOverride: undefined, + settings: ctx.deps.getSettings?.(), + projectRuntime: resolveLocalProjectRuntimeForWorktreeId(ctx.deps.store, args.worktreeId), + fallbackHostShell: process.env.COMSPEC || 'powershell.exe' + }) + : { shellOverride: undefined, terminalWindowsWslDistro: null } + ctx.daemonShellOverride = ctx.terminalRuntimeOptions.shellOverride + ctx.isDaemonHostSpawn = + !args.connectionId && + !(ctx.provider instanceof LocalPtyProvider) && + !routesFreshSpawnsToLocalProvider(ctx.provider) + ctx.callerRequestedSessionId = args.sessionId?.trim() + ctx.requestedSessionId = + ctx.callerRequestedSessionId ?? + (ctx.isDaemonHostSpawn && args.agentSessionCreateOperationId + ? ptySessionIdForAgentCreateOperation(args.worktreeId, args.agentSessionCreateOperationId) + : undefined) + ctx.sessionId = + ctx.requestedSessionId ?? + (ctx.isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) + ctx.effectiveSessionRelayId = + ctx.sessionId !== undefined ? getRelayPtyId(args.connectionId, ctx.sessionId) : undefined + ctx.effectiveSessionAppId = + ctx.sessionId !== undefined ? getAppPtyId(args.connectionId, ctx.sessionId) : undefined + ctx.isNewDaemonSession = + !ctx.preAdoptedStablePane && + ctx.isDaemonHostSpawn && + (ctx.callerRequestedSessionId === undefined || args.isNewSession === true) + ctx.expectedWslDistro = !args.connectionId + ? (resolveWslSessionContext({ + cwd: ctx.cwd, + sessionId: ctx.sessionId, + shellOverride: ctx.terminalRuntimeOptions.shellOverride, + terminalWindowsWslDistro: ctx.terminalRuntimeOptions.terminalWindowsWslDistro + })?.distro ?? null) + : null + ctx.codexSelectionTarget = getCodexSelectionTargetForPty( + ctx.daemonShellOverride, + ctx.cwd, + ctx.expectedWslDistro + ) + const codexResumePreparation = ctx.preAdoptedStablePane + ? null + : ctx.deps.prepareCodexResumeHome({ + connectionId: args.connectionId, + launchAgent: args.launchAgent, + providerSession: args.resumeProviderSession, + target: ctx.codexSelectionTarget, + launchEnv: args.env, + workspacePath: ctx.cwd + }) + const codexResumeLaunch = codexResumePreparation + ? await ctx.deps.resolveCodexResumeLaunch(args.command, codexResumePreparation) + : ctx.deps.noCodexResumeLaunch(ctx.preAdoptedStablePane ? undefined : args.command) + const codexResumeHome = codexResumeLaunch.codexResumeHome + // Why: the drop still applies here, but this controller's result has no field for + // notifyResumeUnavailable — runtime/relay panes start fresh without the notice. + ctx.launchCommand = codexResumeLaunch.command + ctx.claudeAuth = + ctx.isClaudeLaunch && ctx.deps.prepareClaudeAuth + ? await ctx.deps.prepareClaudeAuth(ctx.codexSelectionTarget) + : null + if (ctx.isClaudeLaunch && isClaudeAuthSwitchInProgress()) { + throw new Error('A Claude account switch is in progress. Try again after it finishes.') + } + if (ctx.claudeAuth?.stripAuthEnv && hasClaudeAuthEnvConflict(args.env)) { + throw new Error( + 'This Claude launch defines explicit Anthropic auth environment variables. Remove those overrides before using a managed Claude account.' + ) + } + + ctx.shouldPersistHostSessionBinding = args.persistHostSessionBinding === true + if (ctx.shouldPersistHostSessionBinding) { + if ( + !ctx.deps.store || + typeof args.worktreeId !== 'string' || + typeof args.tabId !== 'string' || + !isValidTerminalTabId(args.tabId) || + typeof args.leafId !== 'string' || + !isTerminalLeafId(args.leafId) + ) { + throw new Error('Cannot persist runtime PTY binding without worktreeId, tabId, and leafId') + } + ctx.hostSessionBinding = { + store: ctx.deps.store, + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: args.leafId, + ...(args.expectedSourceBinding ? { expectedSourceBinding: args.expectedSourceBinding } : {}) + } + } + const sshScopedEnv = stripRemotePaneEnvWhenHooksDisabled(args.connectionId, args.env) + ctx.env = ctx.claudeAuth ? { ...sshScopedEnv, ...ctx.claudeAuth.envPatch } : sshScopedEnv + ctx.requestedAgentTeamsPath = ctx.env?.ORCA_AGENT_TEAMS_TEAM_ID + ? ctx.env[resolvePathEnvKey(ctx.env, process.platform)] + : undefined + ctx.env = ctx.deps.stripSequencedStartupResumeArgv(ctx.env, codexResumeLaunch) + if (args.preAllocatedHandle) { + ctx.env = { ...ctx.env, ORCA_TERMINAL_HANDLE: args.preAllocatedHandle } + } + ctx.selectedCodexHomePath = + !ctx.preAdoptedStablePane && !args.connectionId + ? getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + codexResumeHome + ? ctx.deps.reconcileSharedRuntimeResumeHome(codexResumeHome, () => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { + workspacePath: ctx.cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null + ) + ) + : (ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { + workspacePath: ctx.cwd, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined + }) ?? null) + ) + : null + if ( + !ctx.preAdoptedStablePane && + args.launchAgent === 'codex' && + ctx.callerRequestedSessionId === undefined + ) { + const resolution = resolveCodexHomeAfterManagedAuthReadiness({ + selectedCodexHomePath: ctx.selectedCodexHomePath, + getSettings: () => ctx.deps.getSettings?.(), + requiredCodexHomePath: codexResumeHome?.codexHomePath, + target: ctx.codexSelectionTarget, + resolveCurrent: () => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { + workspacePath: ctx.cwd, + launchAgent: 'codex' + }) ?? null + ), + resolveAfterUnavailable: (unavailableManagedHomePath) => + getCompatibleSelectedCodexHomePath( + ctx.codexSelectionTarget, + ctx.deps.getSelectedCodexHomePath?.(ctx.codexSelectionTarget, ctx.env, { + workspacePath: ctx.cwd, + launchAgent: 'codex', + unavailableManagedHomePath + }) ?? null + ) + }) + ctx.selectedCodexHomePath = resolution instanceof Promise ? await resolution : resolution + } + if (args.launchAgent === 'codex' && ctx.selectedCodexHomePath) { + await ensureCodexStateDbBackfillRecoveryStarted(ctx.selectedCodexHomePath) + } + ctx.codexResumeHomeSelected = Boolean( + codexResumeHome && codexHomePathsEqual(ctx.selectedCodexHomePath, codexResumeHome.codexHomePath) + ) + ctx.skipCodexHomeEnv = + ctx.isDaemonHostSpawn && + shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd) && + !ctx.selectedCodexHomePath + const ptySettings = ctx.isDaemonHostSpawn ? ctx.deps.getSettings?.() : undefined + ctx.stripInheritedOrcaCodexHome = + ctx.isDaemonHostSpawn && + shouldStripInheritedOrcaCodexHome({ + target: ctx.codexSelectionTarget, + selectedCodexHomePath: ctx.selectedCodexHomePath, + skipCodexHomeEnv: ctx.skipCodexHomeEnv, + settings: ptySettings + }) + if (ctx.isDaemonHostSpawn && ctx.sessionId && !ctx.preAdoptedStablePane) { + if (!isSafePtySessionId(ctx.sessionId, getAppEnvironment().getPath('userData'))) { + throw new Error('Invalid PTY session id') + } + try { + ctx.env = buildPtyHostEnv(ctx.sessionId, ctx.env ?? {}, { + isPackaged: getAppEnvironment().isPackaged(), + resourcesPath: process.resourcesPath, + userDataPath: getAppEnvironment().getPath('userData'), + selectedCodexHomePath: ctx.selectedCodexHomePath, + skipCodexHomeEnv: ctx.skipCodexHomeEnv, + stripInheritedOrcaCodexHome: ctx.stripInheritedOrcaCodexHome, + launchCommand: ctx.launchCommand, + launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd), + wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), + codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), + networkProxySettings: ptySettings, + deferGitConfigGuardToDaemon: + ctx.provider.supportsGitCredentialGuardHost?.(ctx.sessionId) === true + }) + stampWslOrchestrationCompatibilityHost( + ctx.env, + ctx.deps.runtime?.getOrchestrationCompatibilityHostId?.(), + ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null + ) + promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) + } catch (error) { + // Why: host-env setup can materialize agent hooks/extensions before failing. + if (ctx.requestedSessionId === undefined) { + clearProviderPtyState(ctx.sessionId) + } + throw error + } + } + + return null +} diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts new file mode 100644 index 00000000000..81878fa029b --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -0,0 +1,185 @@ +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../../../providers/types' +import type { CodexPaneHomeRoute } from '../../../codex/codex-pane-account-registry' +import type { CodexAccountSelectionTarget } from '../../../codex-accounts/runtime-selection' +import type { ClaudeRuntimeAuthPreparation } from '../../../claude-accounts/runtime-auth-service' +import type { StablePaneOwner } from '../pane/stable-owner' +import type { PaneSpawnReservation } from '../pane/spawn-reservation' +import type { AdoptStablePaneResult } from '../ipc/spawn-types' +import type { PtyBindingSourceExpectation } from '../../../persistence' +import type { PtyRuntimeControllerDeps } from './controller-deps' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' +import type { + AgentSessionExecutionClaim, + AgentSessionSurfaceBinding +} from '../../../../shared/agent-session-host-authority' +import { localProvider } from '../provider/registry' + +export type RuntimePtySpawnState = { + deps: PtyRuntimeControllerDeps + args: RuntimePtySpawnArgs + codexHomeLaunchStartedAt: Date | undefined + codexHomeLaunchStartedSequence: number | undefined + preAdoptedStablePane: AdoptStablePaneResult | null + reattachedCodexHomeRoutes: Map + cwd: string | undefined + provider: IPtyProvider + isClaudeLaunch: boolean + terminalRuntimeOptions: { shellOverride?: string; terminalWindowsWslDistro?: string | null } + daemonShellOverride: string | undefined + isDaemonHostSpawn: boolean + callerRequestedSessionId: string | undefined + requestedSessionId: string | undefined + sessionId: string | undefined + effectiveSessionRelayId: string | undefined + effectiveSessionAppId: string | undefined + isNewDaemonSession: boolean + expectedWslDistro: string | null + codexSelectionTarget: CodexAccountSelectionTarget + launchCommand: string | undefined + claudeAuth: ClaudeRuntimeAuthPreparation | null + shouldPersistHostSessionBinding: boolean + hostSessionBinding: + | { + store: NonNullable + worktreeId: string + tabId: string + leafId: string + expectedSourceBinding?: PtyBindingSourceExpectation + } + | undefined + env: Record | undefined + requestedAgentTeamsPath: string | undefined + selectedCodexHomePath: string | null + codexResumeHomeSelected: boolean + skipCodexHomeEnv: boolean + stripInheritedOrcaCodexHome: boolean + spawnOptions: PtySpawnOptions + hadSessionSizeBeforeAttach: boolean + sessionSizeBeforeAttach: { cols: number; rows: number } | undefined + materializedPaneKey: string | null + metadataLeafId: string | null + metadataPaneKey: string | null + spawnIdentityPaneKey: string | null + paneSpawnReservationKey: string | null + paneSpawnReservation: PaneSpawnReservation | null + finishTerminalInstall: () => void + result: PtySpawnResult & { + stablePaneOwner?: { handle: string; tabId: string; leafId: string } + agentSessionEnsure?: unknown + } + stablePaneOwner: StablePaneOwner | null + stablePaneBindingPersisted: boolean + rejectedRegistrationCandidate: PtySpawnResult | null + pendingRegistrationPtyId: string | null + reconciledSnapshotSeq: number | null + snapshotKittyFlagsCoverReconciledSeq: boolean + preparedProvisionalExecutionContext: boolean + releaseWorktreeSpawn: (() => void) | undefined + reportPtySpawnCommitted: () => void +} + +export type RuntimePtySpawnArgs = { + cols: number + rows: number + cwd?: string + command?: string + launchAgent?: TuiAgent + commandDelivery?: 'renderer' | 'provider' + startupCommandDelivery?: StartupCommandDelivery + telemetry?: { + agent_kind?: unknown + launch_source?: unknown + request_kind?: unknown + } + env?: Record + envToDelete?: string[] + resumeProviderSession?: AgentProviderSessionMetadata + connectionId?: string | null + worktreeId?: string + preAllocatedHandle?: string + tabId?: string + leafId?: string + sessionId?: string + isNewSession?: boolean + persistHostSessionBinding?: boolean + expectedSourceBinding?: PtyBindingSourceExpectation + terminalColorQueryReplies?: { foreground?: string; background?: string } + agentSessionEnsure?: { + claim: AgentSessionExecutionClaim + surface: AgentSessionSurfaceBinding + } + agentSessionCreateOperationId?: string + signal?: AbortSignal + onPtySpawnCommitted?: () => void + adoptedStablePane?: { + result: PtySpawnResult + owner: { + handle?: string + tabId: string + leafId: string + ptyId: string + incarnationId?: string + } + materialized?: true + } +} + +export function createRuntimePtySpawnState( + deps: PtyRuntimeControllerDeps, + args: RuntimePtySpawnArgs +): RuntimePtySpawnState { + return { + deps, + args, + codexHomeLaunchStartedAt: undefined, + codexHomeLaunchStartedSequence: undefined, + preAdoptedStablePane: null, + reattachedCodexHomeRoutes: new Map(), + cwd: undefined, + provider: localProvider, + isClaudeLaunch: false, + terminalRuntimeOptions: {}, + daemonShellOverride: undefined, + isDaemonHostSpawn: false, + callerRequestedSessionId: undefined, + requestedSessionId: undefined, + sessionId: undefined, + effectiveSessionRelayId: undefined, + effectiveSessionAppId: undefined, + isNewDaemonSession: false, + expectedWslDistro: null, + codexSelectionTarget: { runtime: 'host' }, + launchCommand: undefined, + claudeAuth: null, + shouldPersistHostSessionBinding: false, + hostSessionBinding: undefined, + env: undefined, + requestedAgentTeamsPath: undefined, + selectedCodexHomePath: null, + codexResumeHomeSelected: false, + skipCodexHomeEnv: false, + stripInheritedOrcaCodexHome: false, + spawnOptions: { cols: args.cols, rows: args.rows }, + hadSessionSizeBeforeAttach: false, + sessionSizeBeforeAttach: undefined, + materializedPaneKey: null, + metadataLeafId: null, + metadataPaneKey: null, + spawnIdentityPaneKey: null, + paneSpawnReservationKey: null, + paneSpawnReservation: null, + finishTerminalInstall: () => {}, + result: { id: '' }, + stablePaneOwner: null, + stablePaneBindingPersisted: false, + rejectedRegistrationCandidate: null, + pendingRegistrationPtyId: null, + reconciledSnapshotSeq: null, + snapshotKittyFlagsCoverReconciledSeq: true, + preparedProvisionalExecutionContext: false, + releaseWorktreeSpawn: undefined, + reportPtySpawnCommitted: () => {} + } +} diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts new file mode 100644 index 00000000000..2af050cd796 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -0,0 +1,119 @@ +import type { AgentSessionClaimedSpawnResult } from '../../../../shared/agent-session-host-authority' +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' +import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' +import { + makePaneSpawnReservationKey, + paneSpawnReservationsByOwnerKey, + rejectPaneSpawnReservation, + reservePaneSpawn +} from '../pane/spawn-reservation' +import { resolveStablePaneOwner } from '../pane/stable-owner' +import { ptySizes } from '../delivery/visibility-state' +import type { PtyRuntimeControllerDeps } from './controller-deps' +import { adoptMaterializedRuntimePtySpawn } from './spawn-early' +import { prepareRuntimePtySpawn } from './spawn-preflight' +import { buildRuntimePtySpawnOptions } from './spawn-options' +import { executeRuntimePtySpawn } from './spawn-execute' +import { commitRuntimePtySpawn } from './spawn-commit' +import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' + +function toRuntimeSpawnReply(result: { + id: string + incarnationId?: string + wslDistro?: string | null + stablePaneOwner?: { handle: string; tabId: string; leafId: string } + agentSessionEnsure?: AgentSessionClaimedSpawnResult +}) { + return { + id: result.id, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), + ...(typeof result.wslDistro === 'string' ? { wslDistro: result.wslDistro } : {}), + ...(result.stablePaneOwner ? { stablePaneOwner: result.stablePaneOwner } : {}), + ...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}) + } +} + +function restoreProvisionalPtySize(ctx: ReturnType): void { + if (ctx.sessionId === undefined) { + return + } + const key = ctx.effectiveSessionAppId ?? ctx.sessionId + if (ctx.hadSessionSizeBeforeAttach && ctx.sessionSizeBeforeAttach) { + ptySizes.set(key, ctx.sessionSizeBeforeAttach) + } else { + ptySizes.delete(key) + } +} + +export async function spawnPtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + args: RuntimePtySpawnArgs +) { + const ctx = createRuntimePtySpawnState(deps, args) + if (!args.adoptedStablePane) { + const leafId = + typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null + const paneKey = + typeof args.worktreeId === 'string' && + typeof args.tabId === 'string' && + isValidTerminalTabId(args.tabId) && + args.tabId.length <= 512 && + leafId + ? makePaneKey(args.tabId, leafId) + : null + const ownerKey = makePaneSpawnReservationKey(args.worktreeId, args.connectionId, paneKey) + const existingOwner = paneKey + ? resolveStablePaneOwner( + deps.runtime, + deps.store, + paneKey, + args.worktreeId, + args.connectionId + ) + : null + if (ownerKey && !existingOwner && !paneSpawnReservationsByOwnerKey.has(ownerKey)) { + ctx.paneSpawnReservationKey = ownerKey + ctx.paneSpawnReservation = reservePaneSpawn(ownerKey) + } + } + try { + const materializedOrPromise = adoptMaterializedRuntimePtySpawn(ctx) + const materialized = + materializedOrPromise instanceof Promise ? await materializedOrPromise : materializedOrPromise + if (materialized) { + return toRuntimeSpawnReply(materialized) + } + const earlyAdopt = await prepareRuntimePtySpawn(ctx) + if (earlyAdopt) { + return toRuntimeSpawnReply(earlyAdopt) + } + const earlyReserved = await buildRuntimePtySpawnOptions(ctx).catch((error: unknown) => { + restoreProvisionalPtySize(ctx) + throw error + }) + if (earlyReserved) { + return toRuntimeSpawnReply(earlyReserved) + } + await executeRuntimePtySpawn(ctx) + return toRuntimeSpawnReply(await commitRuntimePtySpawn(ctx)) + } catch (err) { + if (ctx.pendingRegistrationPtyId) { + deps.runtime?.cancelPendingPtyRegistration?.( + ctx.pendingRegistrationPtyId, + ctx.rejectedRegistrationCandidate?.incarnationId + ) + ctx.pendingRegistrationPtyId = null + } + // Why: once the reservation is created, any later throw — spawn + // failure, persist failure, or a post-spawn helper such as + // registerPty/rememberPaneKeyForPty/track — must settle it. Otherwise + // it lingers in paneSpawnReservationsByOwnerKey and every future spawn + // for this pane awaits a promise that never resolves. reject is a + // no-op once the reservation has already resolved. + rejectPaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, err) + throw err + } finally { + ctx.releaseWorktreeSpawn?.() + ctx.finishTerminalInstall() + } +} diff --git a/src/main/ipc/pty/session.ts b/src/main/ipc/pty/session.ts new file mode 100644 index 00000000000..423ecabbe3d --- /dev/null +++ b/src/main/ipc/pty/session.ts @@ -0,0 +1,289 @@ +import type { BrowserWindow } from 'electron' +import type { OrcaRuntimeService } from '../../runtime/orca-runtime' +import type { Store } from '../../persistence' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { IPtyProvider } from '../../providers/types' +import type { LegacySshProjectionSemantics } from '../ssh-pty-legacy-projection' +import type { PtyModelRestoreReason } from '../../../shared/pty-model-restore-marker' +import type { + PtyDeliveryWriteOff, + PtyRendererDeliveryStateReport +} from '../../../shared/pty-renderer-delivery-health' +import type { PtyRendererDeliveryDebugSnapshot } from './delivery/debug' +import { PtyProducerFlowController } from '../pty-producer-flow-control' +import { PtyPendingDataDrainQueue, type PendingPtyData } from '../pty-pending-data-drain-queue' +import type { SshPtyOutputIntake } from '../ssh-pty-output-intake' +import { activeRendererPtys } from './delivery/visibility-state' +import { + isHiddenPtyDeliveryGateEnabled, + shouldDropHiddenRendererPtyData +} from '../pty-hidden-delivery-gate' +import type { CodexHomePtySpawnedLifecycleArgs, PrepareCodexSessionResume } from './host-env/types' +import { tryGetProviderForPty } from './provider/registry' + +export type PtyDataPayload = { + id: string + data: string + seq?: number + rawLength?: number + transformed?: boolean + background?: boolean + droppedOutput?: boolean +} + +export type RendererPtyDeliveryAccounting = { + sentChars: number + ackedChars: number + lastSendAtMs: number + lastAckAtMs: number | null +} + +export type SerializeResult = { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + kittyKeyboardFlags?: number +} | null + +export type PtyIpcSessionOptions = { + prepareCodexSessionResume?: PrepareCodexSessionResume + awaitLocalPtyStartup?: () => Promise + awaitLocalPtyProviderStartup?: () => Promise + // Why: returns true once for the crash-recovery reload so its did-finish-load skips the orphan sweep and keeps live PTYs (#5787). + isRecoveryReloadInFlight?: (webContentsId: number) => boolean + onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void + onPtyExit?: (id: string, exitSequence: number) => void +} + +export type PtyIpcSession = { + mainWindow: BrowserWindow + runtime?: OrcaRuntimeService + store?: Store + getSettings?: () => GlobalSettings + options?: PtyIpcSessionOptions + pendingData: PtyPendingDataDrainQueue + sshOutputIntake: SshPtyOutputIntake | null + rendererExitingPtyIds: Set + rendererCreditBeforeExitByPty: Map + rendererDeliveryRestoreNeededPtys: Set + pendingOverflowMarkedPtys: Set + rendererDeliveryAccountingByPty: Map + trustedTerminalHandleEnv: Set + flushTimer: ReturnType | null + pendingDataFlushActive: boolean + pendingDataCreditReleasedDuringFlush: boolean + rendererInFlightTotalChars: number + pendingDroppedChars: number + deliveryResyncRequestSerial: number + deliveryResyncOutstandingRequestId: number | null + deliveryResyncTimer: ReturnType | null + deliveryResyncUnansweredWarnLogged: boolean + lastAckReceivedAtMs: number | null + peakPendingChars: number + peakMaxPendingCharsByPty: number + peakRendererInFlightChars: number + peakMaxRendererInFlightCharsByPty: number + ackGatedFlushSkipCount: number + rendererLifecycleResetCount: number + lastLifecycleResetClearedChars: number + rendererDispatcherReadyForcedCount: number + rendererPtyDispatcherReady: boolean + dispatcherReadyWatchdogTimer: ReturnType | null + lastHiddenDropContradictionWarnAtMs: number + pendingDataDropWarnedPtys: Set + producerFlowControl: PtyProducerFlowController + sourceCreditPendingPtys: Set + backgroundedDeliverySyncByPty: Map + syntheticKillExitPtyIds: Map + reversibleStopOwnersByPtyId: Map + retiredRejectedPtyIds: Map + pendingSerializeRequests: Map< + string, + { resolve: (result: SerializeResult) => void; timeout: NodeJS.Timeout } + > + canSendPtyDataToRenderer: (id: string, options?: { interactive?: boolean }) => boolean + schedulePendingDataFlush: (delayMs: number) => void + flushPendingData: () => void + sendPtyDataToRenderer: ( + id: string, + payload: PtyDataPayload, + projectionAdmissionIds?: readonly string[] + ) => { sent: boolean; projectionsTransferred: boolean } + sendModelRestoreNeededMarker: ( + id: string, + reason: PtyModelRestoreReason, + markerSeq: number | undefined + ) => boolean + updateProducerFlowControl: (id: string) => void + applyCumulativeAck: (id: string, processedChars: number) => number + readCurrentPtyRendererDeliveryDebugSnapshot: () => PtyRendererDeliveryDebugSnapshot + clearDeliveryResyncProbe: () => void + clearPendingPtyData: () => void + deletePendingPtyData: (id: string) => void + setPendingPtyData: (id: string, pending: PendingPtyData) => void + clearDispatcherReadyWatchdog: () => void + armDispatcherReadyWatchdog: () => void + acceptPtyDataForRenderer: ( + payload: { + id: string + data: string + sequenceChars?: number + transformed?: boolean + }, + outputSeq: number | undefined, + projection?: LegacySshProjectionSemantics + ) => void + preparePtyExitForRenderer: (payload: { + id: string + code: number + incarnationId?: string + }) => (() => void) | null + finalizePtyExitForRenderer: (payload: { + id: string + code: number + incarnationId?: string + }) => void + sendPtyExitToRenderer: (payload: { id: string; code: number; incarnationId?: string }) => void + sendPtySpawnedToRenderer: (id: string) => void + requestSerializedBuffer: ( + ptyId: string, + opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } + ) => Promise + shutdownProviderAndDetectExit: ( + provider: IPtyProvider, + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ) => Promise + rememberSyntheticKillExit: (id: string) => void + rememberRetiredRejectedPty: (id: string) => void + consumeSyntheticKillExit: (id: string) => boolean + syncPtyBackgroundedDelivery: (id: string, caller: string) => void + resyncBackgroundedDeliveriesAfterGateReset: () => void + transitionHiddenRendererPtyDeliveryState: ( + id: string, + hidden: boolean + ) => { droppable: boolean; droppedWhileHidden: boolean; policyChanged: boolean } + transitionSpawnHiddenRendererPtyDeliveryState: (id: string, hidden: boolean) => void + rendererPtyIsKnownHidden: (id: string) => boolean + clearHiddenRendererResizeOutput: (id: string) => void + clearDeliveredHiddenRendererResizeOutput: (id: string) => void + schedulePendingDataAfterCreditReport: (creditedAny: boolean) => void + writeOffLostRendererDelivery: (report: PtyRendererDeliveryStateReport) => PtyDeliveryWriteOff[] + getRendererInFlightCharsForPty: (id: string) => number +} + +const unsetSessionFn = (): never => { + throw new Error('pty ipc session function used before wiring') +} + +export function createPtyIpcSession(args: { + mainWindow: BrowserWindow + runtime?: OrcaRuntimeService + store?: Store + getSettings?: () => GlobalSettings + options?: PtyIpcSessionOptions +}): PtyIpcSession { + const session: PtyIpcSession = { + mainWindow: args.mainWindow, + runtime: args.runtime, + store: args.store, + getSettings: args.getSettings, + options: args.options, + pendingData: undefined as unknown as PtyPendingDataDrainQueue, + sshOutputIntake: null, + rendererExitingPtyIds: new Set(), + rendererCreditBeforeExitByPty: new Map(), + rendererDeliveryRestoreNeededPtys: new Set(), + pendingOverflowMarkedPtys: new Set(), + rendererDeliveryAccountingByPty: new Map(), + trustedTerminalHandleEnv: new Set(), + flushTimer: null, + pendingDataFlushActive: false, + pendingDataCreditReleasedDuringFlush: false, + rendererInFlightTotalChars: 0, + pendingDroppedChars: 0, + deliveryResyncRequestSerial: 0, + deliveryResyncOutstandingRequestId: null, + deliveryResyncTimer: null, + deliveryResyncUnansweredWarnLogged: false, + lastAckReceivedAtMs: null, + peakPendingChars: 0, + peakMaxPendingCharsByPty: 0, + peakRendererInFlightChars: 0, + peakMaxRendererInFlightCharsByPty: 0, + ackGatedFlushSkipCount: 0, + rendererLifecycleResetCount: 0, + lastLifecycleResetClearedChars: 0, + rendererDispatcherReadyForcedCount: 0, + rendererPtyDispatcherReady: false, + dispatcherReadyWatchdogTimer: null, + lastHiddenDropContradictionWarnAtMs: 0, + pendingDataDropWarnedPtys: new Set(), + producerFlowControl: new PtyProducerFlowController({ + pauseProducer: (id) => tryGetProviderForPty(id)?.pauseProducer?.(id), + resumeProducer: (id) => tryGetProviderForPty(id)?.resumeProducer?.(id) + }), + sourceCreditPendingPtys: new Set(), + backgroundedDeliverySyncByPty: new Map(), + syntheticKillExitPtyIds: new Map(), + reversibleStopOwnersByPtyId: new Map(), + retiredRejectedPtyIds: new Map(), + pendingSerializeRequests: new Map(), + canSendPtyDataToRenderer: unsetSessionFn, + schedulePendingDataFlush: unsetSessionFn, + flushPendingData: unsetSessionFn, + sendPtyDataToRenderer: unsetSessionFn, + sendModelRestoreNeededMarker: unsetSessionFn, + updateProducerFlowControl: unsetSessionFn, + applyCumulativeAck: unsetSessionFn, + readCurrentPtyRendererDeliveryDebugSnapshot: unsetSessionFn, + clearDeliveryResyncProbe: unsetSessionFn, + clearPendingPtyData: unsetSessionFn, + deletePendingPtyData: unsetSessionFn, + setPendingPtyData: unsetSessionFn, + clearDispatcherReadyWatchdog: unsetSessionFn, + armDispatcherReadyWatchdog: unsetSessionFn, + acceptPtyDataForRenderer: unsetSessionFn, + preparePtyExitForRenderer: unsetSessionFn, + finalizePtyExitForRenderer: unsetSessionFn, + sendPtyExitToRenderer: unsetSessionFn, + sendPtySpawnedToRenderer: unsetSessionFn, + requestSerializedBuffer: unsetSessionFn, + shutdownProviderAndDetectExit: unsetSessionFn, + rememberSyntheticKillExit: unsetSessionFn, + rememberRetiredRejectedPty: unsetSessionFn, + consumeSyntheticKillExit: unsetSessionFn, + syncPtyBackgroundedDelivery: unsetSessionFn, + resyncBackgroundedDeliveriesAfterGateReset: unsetSessionFn, + transitionHiddenRendererPtyDeliveryState: unsetSessionFn, + transitionSpawnHiddenRendererPtyDeliveryState: unsetSessionFn, + rendererPtyIsKnownHidden: unsetSessionFn, + clearHiddenRendererResizeOutput: unsetSessionFn, + clearDeliveredHiddenRendererResizeOutput: unsetSessionFn, + schedulePendingDataAfterCreditReport: unsetSessionFn, + writeOffLostRendererDelivery: unsetSessionFn, + getRendererInFlightCharsForPty: unsetSessionFn + } + + session.pendingData = new PtyPendingDataDrainQueue( + (id) => { + const runnableLane = activeRendererPtys.has(id) ? 'active' : 'background' + // Why first: hidden bytes are dropped from main's pending queue even when renderer credit is exhausted. + if (shouldDropHiddenRendererPtyData(id, session.getSettings?.())) { + return runnableLane + } + if ( + !session.rendererPtyDispatcherReady || + !session.canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) }) + ) { + return 'blocked' + } + return runnableLane + }, + () => isHiddenPtyDeliveryGateEnabled(session.getSettings?.()) + ) + + return session +} diff --git a/src/main/ipc/workspace-cleanup-broad-scan.test.ts b/src/main/ipc/workspace-cleanup-broad-scan.test.ts index a5d19d26f7e..070ea461906 100644 --- a/src/main/ipc/workspace-cleanup-broad-scan.test.ts +++ b/src/main/ipc/workspace-cleanup-broad-scan.test.ts @@ -119,8 +119,12 @@ function makeWorktreeMeta(overrides: Partial = {}): WorktreeMeta { const META_BY_WORKTREE_ID: Record = { 'repo-1::/repo': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }), 'repo-1::/repo-old': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }), - 'repo-1::/repo-recent': makeWorktreeMeta({ lastActivityAt: NOW - 2 * DAY_MS }), - 'repo-folder::/folder-workspace': makeWorktreeMeta({ lastActivityAt: NOW - 40 * DAY_MS }) + 'repo-1::/repo-recent': makeWorktreeMeta({ + lastActivityAt: NOW - 2 * DAY_MS + }), + 'repo-folder::/folder-workspace': makeWorktreeMeta({ + lastActivityAt: NOW - 40 * DAY_MS + }) } function makeStore(repos: Repo[] = [REPO], allMeta: Record = {}): Store { @@ -132,16 +136,6 @@ function makeStore(repos: Repo[] = [REPO], allMeta: Record } as unknown as Store } -/** `repo-1::/repo-old` is in the shared fixture map, so pinning it needs a getWorktreeMeta override. */ -function makePinnedStore(): Store { - const pinned = makeWorktreeMeta({ isPinned: true, lastActivityAt: NOW - 40 * DAY_MS }) - return { - ...makeStore(), - getWorktreeMeta: (worktreeId: string) => - worktreeId === 'repo-1::/repo-old' ? pinned : META_BY_WORKTREE_ID[worktreeId] - } as Store -} - describe('workspace cleanup broad scan opt-in', () => { beforeEach(() => { vi.useFakeTimers() @@ -164,7 +158,9 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('emits every worktree when the client opts into the full workspace list', async () => { - const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(makeStore(), { + includeAllWorkspaces: true + }) expect(result.errors).toEqual([]) expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual([ @@ -185,7 +181,9 @@ describe('workspace cleanup broad scan opt-in', () => { worktreeId === 'repo-1::/repo-old' ? runtimeMeta : META_BY_WORKTREE_ID[worktreeId] } as Store - const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(store, { + includeAllWorkspaces: true + }) expect( result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-old') @@ -240,32 +238,54 @@ describe('workspace cleanup broad scan opt-in', () => { ) }) - it('skips git for a pinned workspace on a broad scan, which is the cost saving', async () => { - const store = makePinnedStore() + it('reads git for a pinned target during focused preflight scans', async () => { + const worktreeId = 'repo-1::/repo-old' + const pinnedMeta = makeWorktreeMeta({ + isPinned: true, + lastActivityAt: NOW - 40 * DAY_MS + }) + const store = { + ...makeStore(), + getWorktreeMeta: (id: string) => (id === worktreeId ? pinnedMeta : META_BY_WORKTREE_ID[id]) + } as Store + + const result = await scanWorkspaceCleanup(store, { + worktreeIds: [worktreeId], + refreshActivity: true + }) + + expect(result.candidates[0]?.blockers).toContain('pinned') + expect(getStatusMock).toHaveBeenCalledTimes(1) + }) + + it('continues to skip pinned git reads during broad scans', async () => { + const worktreeId = 'repo-1::/repo-old' + const pinnedMeta = makeWorktreeMeta({ + isPinned: true, + lastActivityAt: NOW - 40 * DAY_MS + }) + const store = { + ...makeStore(), + getWorktreeMeta: (id: string) => (id === worktreeId ? pinnedMeta : META_BY_WORKTREE_ID[id]) + } as Store + listRepoWorktreesMock.mockResolvedValue([GIT_WORKTREES[1]]) await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) - expect(getStatusMock).not.toHaveBeenCalledWith( - expect.stringContaining('/repo-old'), - expect.anything() - ) + expect(getStatusMock).not.toHaveBeenCalled() }) - it('reads git for a pinned workspace on a targeted preflight, which decides force-removal', async () => { - // Why this matters: `pinned` is not a queue blocker, so a pinned idle workspace is - // hand-selectable today, and removal forces whenever git is unknown. Skipping the - // forced read meant it could be force-deleted with no evidence ever obtained. - const worktreeId = 'repo-1::/repo-old' + it('qualifies targeted repo-list failures with the execution host', async () => { + listRepoWorktreesMock.mockRejectedValue(new Error('listing failed')) - const result = await scanWorkspaceCleanup(makePinnedStore(), { worktreeIds: [worktreeId] }) + const result = await scanWorkspaceCleanup(makeStore(), { + worktreeIds: ['repo-1::/repo-old'], + refreshActivity: true + }) - expect(getStatusMock).toHaveBeenCalledWith( - expect.stringContaining('/repo-old'), - expect.anything() - ) - const pinned = result.candidates.find((candidate) => candidate.worktreeId === worktreeId) - expect(pinned?.blockers).toContain('pinned') - expect(pinned?.git.checkedAt).not.toBeNull() + expect(result.errors).toEqual([ + expect.objectContaining({ repoId: 'repo-1', executionHostId: 'local' }) + ]) }) it('keeps the legacy suggestion-only projection when the flag is absent', async () => { @@ -277,7 +297,9 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('reports recent workspaces with no cleanup reasons', async () => { - const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(makeStore(), { + includeAllWorkspaces: true + }) const recent = result.candidates.find( (candidate) => candidate.worktreeId === 'repo-1::/repo-recent' @@ -291,7 +313,9 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('reports the main worktree with a main-worktree blocker', async () => { - const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(makeStore(), { + includeAllWorkspaces: true + }) const main = result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo') expect(main).toMatchObject({ @@ -372,7 +396,9 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('defers git evidence for rows that no cleanup decision can use', async () => { - const result = await scanWorkspaceCleanup(makeStore(), { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(makeStore(), { + includeAllWorkspaces: true + }) // Why: only the inactive row can ever be queued, so it is the only row worth // a git read; the rest stream immediately with empty evidence. @@ -380,7 +406,12 @@ describe('workspace cleanup broad scan opt-in', () => { expect(getStatusMock).toHaveBeenCalledWith('/repo-old', expect.anything()) expect( result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-recent')?.git - ).toEqual({ clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }) + ).toEqual({ + clean: null, + upstreamAhead: null, + upstreamBehind: null, + checkedAt: null + }) expect( result.candidates.find((candidate) => candidate.worktreeId === 'repo-1::/repo-old')?.git ).toMatchObject({ clean: true, checkedAt: expect.any(Number) }) @@ -403,7 +434,9 @@ describe('workspace cleanup broad scan opt-in', () => { getWorktreeMeta: () => recentMeta } as unknown as Store - const result = await scanWorkspaceCleanup(store, { includeAllWorkspaces: true }) + const result = await scanWorkspaceCleanup(store, { + includeAllWorkspaces: true + }) expect(result.candidates).toHaveLength(recentWorktrees.length) expect(readFileMock).not.toHaveBeenCalled() @@ -471,7 +504,9 @@ describe('workspace cleanup broad scan opt-in', () => { it('lists a repo once for a targeted Git-evidence batch', async () => { const targets = ['repo-1::/repo-recent', 'repo-1::/repo-old'] - const result = await scanWorkspaceCleanup(makeStore(), { worktreeIds: targets }) + const result = await scanWorkspaceCleanup(makeStore(), { + worktreeIds: targets + }) expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1) expect(getStatusMock).toHaveBeenCalledTimes(2) @@ -507,7 +542,9 @@ describe('workspace cleanup broad scan opt-in', () => { it('lists disconnected SSH workspaces only on an opt-in scan', async () => { const sshRepo: Repo = { ...REPO, id: 'repo-ssh', connectionId: 'ssh-1' } const allMeta: Record = { - 'repo-ssh::/remote/recent': makeWorktreeMeta({ lastActivityAt: NOW - 2 * DAY_MS }) + 'repo-ssh::/remote/recent': makeWorktreeMeta({ + lastActivityAt: NOW - 2 * DAY_MS + }) } const legacy = await scanWorkspaceCleanup(makeStore([sshRepo], allMeta)) @@ -574,7 +611,10 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('streams every row through scan progress on an opt-in scan', async () => { - const progress: { scannedWorktreeCount: number; totalWorktreeCount: number }[] = [] + const progress: { + scannedWorktreeCount: number + totalWorktreeCount: number + }[] = [] await scanWorkspaceCleanup( makeStore(), diff --git a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts index 5e3cebf260c..c0e136b7b77 100644 --- a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts +++ b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts @@ -74,8 +74,6 @@ function createDisconnectedSshCandidate( displayName: meta.displayName || basename(path), branch: basename(path), path, - tier: 'protected', - selectedByDefault: false, reasons, blockers: ['ssh-disconnected'], lastActivityAt: meta.lastActivityAt, diff --git a/src/main/ipc/workspace-cleanup-scan-primitives.ts b/src/main/ipc/workspace-cleanup-scan-primitives.ts index 93af63562c2..8054d333e0d 100644 --- a/src/main/ipc/workspace-cleanup-scan-primitives.ts +++ b/src/main/ipc/workspace-cleanup-scan-primitives.ts @@ -1,4 +1,5 @@ import { basename } from 'node:path' +import { getRepoExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import type { WorkspaceCleanupScanError } from '../../shared/workspace-cleanup' @@ -91,7 +92,8 @@ export function createWorkspaceCleanupScanError( return { repoId: repo.id, repoName: repo.displayName || basename(repo.path), - message + message, + executionHostId: getRepoExecutionHostId(repo) } } diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index 278c3ae7ee8..b87667f0a63 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -252,8 +252,8 @@ async function scanRepoWorkspaces( worktree: worktreeWithActivity, scannedAt, provider, - // Why: a row with no inactivity reason can never be queued or selected, - // so full-fleet scans stream it now and let a focused scan read git later. + // Why: full-fleet scans defer git for recently active rows; removal preflight + // forces a fresh read before any selected row can be deleted. skipGit: skipGitWorktreeIds.has(worktreeWithActivity.id) || !isInactive, forceGitCheck: Boolean(targetWorktreeIds), signal diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index b35167906c8..6f5d3d74a9b 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -322,6 +322,7 @@ describe('workspace cleanup scan', () => { { repoId: 'repo-1', repoName: 'Repo', + executionHostId: 'local', message: 'Git could not list worktrees.' } ]) @@ -344,6 +345,7 @@ describe('workspace cleanup scan', () => { { repoId: 'repo-1', repoName: 'Repo', + executionHostId: 'local', message: 'Timed out listing worktrees.' } ] diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index 798cc443442..7e2ebac49f5 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { REVIEW_HEAD_FETCH_TIMEOUT_MS } from '../../shared/review-head-tracking-ref' import { + handleMock, removeHandlerMock, listWorktreesMock, getPullRequestPushTargetMock, @@ -14,6 +15,35 @@ import { } from './worktrees-test-fixtures' import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' +const WORKTREE_HANDLER_CHANNELS = [ + 'worktrees:listAll', + 'worktrees:list', + 'worktrees:listRetiredNames', + 'worktrees:listDetected', + 'worktrees:listKnownForExecutionHost', + 'worktrees:forgetRemovedForExecutionHost', + 'worktrees:cancelListDetected', + 'worktrees:create', + 'worktrees:adoptProvisionedRoot', + 'worktrees:prefetchCreateBase', + 'worktrees:resolvePrBase', + 'worktrees:resolveMrBase', + 'worktrees:remove', + 'worktrees:forgetLocal', + 'worktrees:forceDeletePreservedBranch', + 'worktrees:updateMeta', + 'worktrees:listLineage', + 'worktrees:listLineageForHost', + 'worktrees:updateLineage', + 'worktrees:persistSortOrder', + 'worktrees:getBranchRenameFailureOutput', + 'hooks:check', + 'hooks:inspectSetupScriptImports', + 'hooks:createIssueCommandRunner', + 'hooks:readIssueCommand', + 'hooks:writeIssueCommand' +] as const + vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() ) @@ -113,6 +143,20 @@ describe('registerWorktreeHandlers', () => { expect(handlers['worktrees:getBranchRenameFailureOutput']).toBeDefined() }) + it('removes exactly the installed channel set before installing the first handler', () => { + const removedChannels = removeHandlerMock.mock.calls.map(([channel]) => channel) + const installedChannels = handleMock.mock.calls.map(([channel]) => channel) + + expect(new Set(removedChannels)).toEqual(new Set(WORKTREE_HANDLER_CHANNELS)) + expect(new Set(removedChannels)).toEqual(new Set(installedChannels)) + expect(new Set(installedChannels)).toEqual(new Set(WORKTREE_HANDLER_CHANNELS)) + expect(removedChannels).toHaveLength(WORKTREE_HANDLER_CHANNELS.length) + expect(installedChannels).toHaveLength(WORKTREE_HANDLER_CHANNELS.length) + expect(Math.max(...removeHandlerMock.mock.invocationCallOrder)).toBeLessThan( + Math.min(...handleMock.mock.invocationCallOrder) + ) + }) + it('persistSortOrder only reorders existing worktrees and never mints meta for a stale id', () => { const liveId = 'repo-1::/workspace/repo' const staleId = 'removed-repo::/workspace/gone' diff --git a/src/main/ipc/worktrees-detected-scan-cache.test.ts b/src/main/ipc/worktrees-detected-scan-cache.test.ts index 6fb798fad3f..3504fd3a0d6 100644 --- a/src/main/ipc/worktrees-detected-scan-cache.test.ts +++ b/src/main/ipc/worktrees-detected-scan-cache.test.ts @@ -3,7 +3,7 @@ import { resolve } from 'node:path' import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import { notifyWorktreesChanged } from './worktree-remote' import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache' -import { __getDetectedWorktreeScanCacheStatsForTests } from './worktrees' +import { __getDetectedWorktreeScanCacheStatsForTests } from './worktrees/listing/detected-worktree-scan-cache' import { setPlatform, listWorktreesMock } from './worktrees-test-module-mocks' import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' import { makeWorktreeMeta } from './worktrees-test-fixtures' diff --git a/src/main/ipc/worktrees-forget-local.test.ts b/src/main/ipc/worktrees-forget-local.test.ts index b1616f6eecc..9bace029631 100644 --- a/src/main/ipc/worktrees-forget-local.test.ts +++ b/src/main/ipc/worktrees-forget-local.test.ts @@ -156,6 +156,22 @@ describe('registerWorktreeHandlers', () => { expect(removeWorktreeMock).not.toHaveBeenCalled() }) + it('purges metadata after a rejected best-effort PTY sweep', async () => { + const worktreeId = 'repo-1::/workspace/feature-wt' + killAllProcessesForWorktreeMock.mockRejectedValue(new Error('terminal inventory unavailable')) + + await expect(handlers['worktrees:forgetLocal'](null, { worktreeId })).resolves.toEqual({}) + + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-1' + }) + expect(store.removeWorktreeMeta.mock.invocationCallOrder[0]).toBeLessThan( + mainWindow.webContents.send.mock.invocationCallOrder[0] + ) + expect(removeWorktreeMock).not.toHaveBeenCalled() + }) + it('sweeps a connected SSH owner through its PTY provider', async () => { const worktreeId = 'repo-gone::/workspace/feature-wt' const sshProvider = {} as never diff --git a/src/main/ipc/worktrees-lineage-hydration.test.ts b/src/main/ipc/worktrees-lineage-hydration.test.ts index cb12b0095f9..e109a13848f 100644 --- a/src/main/ipc/worktrees-lineage-hydration.test.ts +++ b/src/main/ipc/worktrees-lineage-hydration.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Worktree } from '../../shared/worktree/types' import { toSshExecutionHostId } from '../../shared/execution-host' -import { LINEAGE_HYDRATION_TIMEOUT_MS } from './worktrees' +import { LINEAGE_HYDRATION_TIMEOUT_MS } from './worktrees/metadata/host-lineage-listing' import { getSshProviderAuthority, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' import { listWorktreesMock, getSshGitProviderMock } from './worktrees-test-module-mocks' import { handlers, ipcEvent, setupWorktreeHandlers, store } from './worktrees-test-harness' diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index cc10e1929a1..49dc7b633bd 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -137,6 +137,35 @@ describe('registerWorktreeHandlers', () => { ) }) + it('purges only the selected host when a normal worktree id is owned locally and over SSH', async () => { + const worktreeId = 'repo-1::/workspace/feature-wt' + const localRepo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'local', + badgeColor: '#000', + addedAt: 0 + } + const sshRepo = { + ...localRepo, + displayName: 'ssh', + connectionId: 'conn-1' + } + store.getRepos.mockReturnValue([localRepo, sshRepo]) + store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ hostId: 'local' })) + mockKnownFeatureWorktree() + removeWorktreeMock.mockResolvedValue({}) + + await handlers['worktrees:remove'](null, { worktreeId, hostId: 'local' }) + + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(advertisedUrlWatcherForgetWorktreeMock).not.toHaveBeenCalled() + expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-1' + }) + }) + it('tombstones a cleanup-batch removal without scheduling singular sidecar writes', async () => { mockKnownFeatureWorktree() getEffectiveHooksMock.mockReturnValue(null) @@ -484,6 +513,29 @@ describe('registerWorktreeHandlers', () => { }) }) + it('purges a folder workspace after a rejected best-effort PTY sweep', async () => { + const worktreeId = 'repo-folder::/workspace/folder::workspace:child-1' + store.getRepo.mockReturnValue({ + id: 'repo-folder', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + }) + killAllProcessesForWorktreeMock.mockRejectedValue(new Error('terminal inventory unavailable')) + + await handlers['worktrees:remove'](null, { worktreeId }) + + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-folder' + }) + expect(store.removeWorktreeMeta.mock.invocationCallOrder[0]).toBeLessThan( + mainWindow.webContents.send.mock.invocationCallOrder[0] + ) + }) + // Folder projects can be SSH-backed, and folder workspace ids are `repoId::path::workspace:` // — reusable across hosts — so the sweep must name the owning connection. it('fences an SSH folder workspace PTY sweep to the owning connection', async () => { diff --git a/src/main/ipc/worktrees-ssh-provider-authority.test.ts b/src/main/ipc/worktrees-ssh-provider-authority.test.ts index c801d0498f3..8c2d6b0edd7 100644 --- a/src/main/ipc/worktrees-ssh-provider-authority.test.ts +++ b/src/main/ipc/worktrees-ssh-provider-authority.test.ts @@ -3,7 +3,7 @@ import type { Repo } from '../../shared/repo-types' import type { GitWorktreeInfo } from '../../shared/worktree/types' import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract' import { toSshExecutionHostId } from '../../shared/execution-host' -import { DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS } from './worktrees' +import { DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS } from './worktrees/listing/register-detected-worktree-handlers' import { getSshProviderAuthority, rotateSshProviderAuthority } from '../ssh/ssh-provider-authority' import { getSshGitProviderMock } from './worktrees-test-module-mocks' import { handlers, ipcEvent, setupWorktreeHandlers, store } from './worktrees-test-harness' diff --git a/src/main/ipc/worktrees-test-harness.ts b/src/main/ipc/worktrees-test-harness.ts index de0d3a3ed5d..4e1c524fa67 100644 --- a/src/main/ipc/worktrees-test-harness.ts +++ b/src/main/ipc/worktrees-test-harness.ts @@ -1,6 +1,7 @@ import { __resetSshWorktreeCreateFetchCacheForTests } from './worktree-remote' import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' -import { __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees' +import { registerWorktreeHandlers } from './worktrees' +import { __resetDetectedWorktreeScanCacheForTests } from './worktrees/listing/detected-worktree-scan-cache' import { clearConfiguredWorktreeSharedDirectoriesCacheForTests } from '../git/worktree-shared-directories' import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retirement' import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 4b4d009024f..5e70faa1871 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1,1848 +1,53 @@ -/* oxlint-disable max-lines */ -import { app, ipcMain, type BrowserWindow } from 'electron' -import { readFile, stat } from 'node:fs/promises' -import { randomUUID } from 'node:crypto' -import type { Store } from '../persistence' -import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning' -import { isFolderRepo } from '../../shared/repo-kind' -import { readBranchRenameFailureOutputForDisplay } from '../agent-hooks/branch-rename-failure-output' -import { parseWorkspaceKey } from '../../shared/workspace-scope' -import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' -import { planWorktreeSortOrderUpdates } from '../../shared/worktree/sort-order-update' -import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' -import { TaskSourceContextSchema } from '../../shared/task-source-context-schema' -import { WorkspaceLinkedItemSchema } from '../../shared/workspace-linked-item-schema' -import { isWorkspaceLinkedItemSourceContextMatch } from '../../shared/workspace-linked-item-source-context' -import { getProjectGroupSubtreeIds } from '../../shared/project-groups' -import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' -import { isPathInsideOrEqual, isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' -import { deleteWorktreeHistoryDir } from '../terminal-history-deletion' -import { - pruneWorkspaceCleanupScanSnapshot, - pruneWorkspaceCleanupScanSnapshots -} from '../workspace-cleanup-scan-snapshot' -import { - pruneWorkspaceSpaceAnalysisSnapshot, - pruneWorkspaceSpaceAnalysisSnapshots -} from '../workspace-space-analysis-snapshot' -import { recordWorkspaceCleanupRemovalSnapshotPrune } from '../workspace-cleanup-removal-snapshot-prune' -import type { OrcaHooks } from '../../shared/orca-yaml-hook-types' -import type { Repo } from '../../shared/repo-types' -import type { - AdoptProvisionedRootArgs, - CreateWorktreeArgs, - CreateWorktreeResult, - ForceDeleteWorktreeBranchResult, - RemoveWorktreeResult -} from '../../shared/worktree/create-types' -import type { WorkspaceLineage, WorktreeLineage } from '../../shared/worktree/lineage-types' -import type { WorktreeMeta } from '../../shared/worktree/meta-types' -import type { - AutomationWorkspaceProvenance, - CliWorkspaceProvenance, - DetectedWorktree, - DetectedWorktreeListResult, - GitHubPrStartPoint, - GitPushTarget, - GitWorktreeInfo, - Worktree -} from '../../shared/worktree/types' -import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' -import { - getRepoExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId, - toSshExecutionHostId, - type ExecutionHostId -} from '../../shared/execution-host' -import { - PROVIDER_REQUEST_ID_MAX_UTF8_BYTES, - type DirectSshDetectedWorktreeRequest, - type ForgetRemovedWorktreesForExecutionHostArgs, - type ForgetRemovedWorktreesForExecutionHostResult, - type HostQualifiedKnownWorktreeResult, - type HostQualifiedDetectedWorktreeResult, - type ListKnownWorktreesForExecutionHostArgs, - type ListDetectedWorktreesArgs, - type ProviderRequestId -} from '../../shared/detected-worktree-provider-contract' -import type { - HostLineageSnapshot, - ListDesktopLineageForHostArgs -} from '../../shared/host-lineage-contract' -import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission' -import { - applyMetadataFallbackVisibility, - buildKnownOrcaWorkspaceLayouts, - isLegacyRepoForExternalWorktreeVisibility, - toDetectedWorktree -} from '../../shared/worktree/ownership' -import { - createWorktreeVisibilitySourceMatcher, - resolveCustomWorktreeVisibilitySources -} from '../../shared/worktree/visibility-sources' -import { resolveConfiguredWorktreeBasePaths } from '../../shared/worktree/configured-worktree-base-path' -import { - assertWorktreeCleanForRemoval, - forceDeleteLocalBranch, - listWorktreesStrict as listGitWorktreesStrict, - removeWorktree -} from '../git/worktree' -import { gitExecFileAsync } from '../git/runner' -import { withWorktreeRemoveStageSpan, withWorktreeSpan } from '../observability/instrumentation' -import { resolveGitHubPrStartPoint } from '../github/pr-start-point' -import { - fetchGitHubPullRequestHeadRef, - fetchPrHeadTrackingRef -} from '../github/pr-head-tracking-ref' -import { pruneWorktreePRRefreshAliases } from '../github/pr-refresh-coordinator' -import { resolveGitHubReviewHeadRemote } from '../github/review-head-remote' -import { listRepoWorktrees } from '../repo-worktrees' -import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { - getEffectiveHooks, - loadHooks, - parseOrcaYaml, - runHook, - hasHooksFile, - hasUnrecognizedOrcaYamlKeys -} from '../hooks' -import { createIssueCommandRunnerScript, resolveSetupRunnerShell } from '../worktree-runner-script' -import { getSetupRunnerEnvVars } from '../setup-hook-env-vars' -import { getEffectiveHooksFromConfig } from '../effective-hook-config' -import { readIssueCommand, writeIssueCommand } from '../issue-command-file' -import { - mergeWorktree, - parseWorktreeId, - areWorktreePathsEqual, - formatWorktreeRemovalError, - isOrphanCompatiblePreflightError, - isOrphanedWorktreeError -} from './worktree-logic' -import { getRetiredNameRegistryForRepo } from '../worktree-name-retirement' -import { EMPTY_RETIRED_NAME_REGISTRY } from '../../shared/worktree/retired-name-registry' -import { dedupeWorktreesByPath } from './worktree-path-comparison' -import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' -import { - createLocalWorktree, - createRemoteWorktree, - cleanupUnusedWorktreePushTargetRemote, - cleanupUnusedWorktreePushTargetRemoteSsh, - notifyWorktreesChanged -} from './worktree-remote' -import { registerWorktreeChangeInvalidator } from './worktree-change-invalidators' -import { isENOENT } from './filesystem-path-containment' -import { - invalidateAuthorizedRootsCache, - registerWorktreeRootsForRepo -} from './registered-worktree-roots-cache' +import { ipcMain, type BrowserWindow } from 'electron' +import type { Store } from '../persistence/loading-store/store' import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' -import { killAllProcessesForWorktree } from '../runtime/worktree-teardown' -import { clearProviderPtyState, getLocalPtyProvider, getSshPtyProvider } from './pty' -import { findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths } from './worktree-symlinks' -import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' -import { track } from '../telemetry/client' -import { getCohortAtEmit } from '../telemetry/cohort-classifier' -import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events' -import { - finishAutomationWorkspaceProvenanceRequest, - releaseAutomationWorkspaceProvenanceRequest, - resolveAutomationWorkspaceProvenance -} from '../automations/workspace-provenance' -import { shouldEmitBoundedWarning } from './bounded-warning-dedupe' -import { - getSshProviderAuthority, - isCurrentSshProviderAuthority, - registerSshProviderRequestAbort -} from '../ssh/ssh-provider-authority' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' -import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' -import { adoptProvisionedRootSshCheckout } from '../provisioned-root-ssh-adoption' - -type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { - automationProvenance?: AutomationWorkspaceProvenance - cliProvenance?: CliWorkspaceProvenance -} - -type RemoveWorktreeArgs = { - worktreeId: string - hostId?: ExecutionHostId - force?: boolean - /** Explicit Force Delete only — `force` alone is set by the ordinary confirmation (#11960). */ - allowUnverifiedPtyStop?: boolean - skipArchive?: boolean - snapshotPruneBatchId?: string -} - -type DetectedWorktreeRequestArgs = { repoId: string } | ListDetectedWorktreesArgs - -async function stopPtysForDestructiveWorktreeRemoval( - runtime: OrcaRuntimeService, - worktreeId: string, - options: { connectionId?: string; allowUnverifiedStop?: boolean } = {} -): Promise { - const { connectionId, allowUnverifiedStop } = options - const provider = connectionId ? getSshPtyProvider(connectionId) : getLocalPtyProvider() - if (!provider) { - throw new Error(`PTY provider unavailable for worktree deletion: ${worktreeId}`) - } - const teardownResult = await killAllProcessesForWorktree(worktreeId, { - runtime, - // Why: `repoId::path` ids repeat across hosts, so an unfenced sweep stops a same-id - // workspace's terminals on another connection — and the selector lookup this replaces - // throws `selector_ambiguous` the moment two hosts own the id. - resolvedWorktreeId: worktreeId, - ...(connectionId ? { resolvedConnectionId: connectionId } : {}), - localProvider: provider, - onPtyStopped: clearProviderPtyState, - requirePhysicalStop: true, - // Why (#11960): set only by an explicit Force Delete, never by the ordinary - // confirmation — otherwise the gate would be off on the primary delete path. - ...(allowUnverifiedStop ? { allowUnverifiedStop: true } : {}), - ...(connectionId ? { includeLocalRegistry: false } : {}) - }) - const total = - teardownResult.runtimeStopped + teardownResult.providerStopped + teardownResult.registryStopped - if (total > 0) { - console.info( - `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped}` - ) - } -} - -function getRepoForWorktreeRemoval( - store: Store, - repoId: string, - hostId?: ExecutionHostId -): Repo | undefined { - // Why: deletion must never guess between host owners; legacy unscoped calls work only while the repo id has one unique owner. - const owner = resolveWorktreeRemovalRepoOwner(store, repoId, hostId) - return owner.kind === 'resolved' ? owner.repo : undefined -} -import { - hasWorktreeRemovalRepoOwnerOnOtherHost, - resolveWorktreeRemovalMetadata, - resolveWorktreeRemovalRepoOwner -} from '../worktree-removal-repo-owner' -import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' -import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' -import { localhostWorktreeLabelProxy } from '../localhost-worktree-label-proxy' -import { - assertWorktreeDoesNotContainRegisteredWorktree, - canCleanupUnregisteredOrcaLeftoverDirectory, - canCleanupUnregisteredOrcaWorktreeDirectory, - canSafelyRemoveOrphanedWorktreeDirectory, - findRegisteredDeletableWorktree, - isDangerousWorktreeRemovalPath, - isWorktreePathMissing, - ORPHANED_WORKTREE_DIRECTORY_MESSAGE, - stripOrcaProvenanceMetaUpdates, - UNREGISTERED_MISSING_WORKTREE_MESSAGE -} from '../worktree-removal-safety' -import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' -import { - FOLDER_WORKSPACE_INSTANCE_SEPARATOR, - getRepoIdFromWorktreeId, - getWorktreePathBasenameFromId -} from '../../shared/worktree/id' -import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' -import { - getLocalProjectGitExecOptions, - getLocalProjectWorktreeGitOptions -} from '../project-runtime-git-options' -import { - getLocalWorktreePathAccess, - removeLocalWorktreePath, - toLocalWorktreeRuntimePath -} from '../local-worktree-filesystem' -import { - removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval, - recoverLocalWindowsWorktreeRemoval -} from '../local-worktree-removal-recovery' -import { deleteRemoteWorktreeHistory } from '../remote-worktree-history-cleanup' - -const NullableWorkspaceLinkedItemSchema = WorkspaceLinkedItemSchema.nullable() -const NullableTaskSourceContextSchema = TaskSourceContextSchema.nullable() -const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000 -const WORKTREE_LIST_ALL_CONCURRENCY = 8 - -function normalizeLinkedWorkItemFields< - T extends { - linkedWorkItem?: unknown - linkedTaskSourceContext?: unknown - } ->(input: T): T { - const linkedWorkItem = - input.linkedWorkItem === undefined - ? undefined - : NullableWorkspaceLinkedItemSchema.parse(input.linkedWorkItem) - const linkedTaskSourceContext = - input.linkedTaskSourceContext === undefined - ? undefined - : NullableTaskSourceContextSchema.parse(input.linkedTaskSourceContext) - if ( - linkedWorkItem && - linkedTaskSourceContext && - !isWorkspaceLinkedItemSourceContextMatch(linkedWorkItem, linkedTaskSourceContext) - ) { - throw new Error('Linked work item and source context identities must match') - } - return { - ...input, - ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), - ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}) - } -} - -async function mapWithConcurrency( - items: readonly T[], - limit: number, - fn: (item: T) => Promise -): Promise { - const results: R[] = [] - let nextIndex = 0 - const workerCount = Math.min(limit, items.length) - await Promise.all( - Array.from({ length: workerCount }, async () => { - while (nextIndex < items.length) { - const index = nextIndex - nextIndex += 1 - results[index] = await fn(items[index]) - } - }) - ) - return results -} - -// Why: the worktree's own persisted host outranks the repo fallback; teardown and metadata purge must resolve the same owner -// or the purge lands on the local partition while the SSH/runtime one keeps the workspace's tabs forever. -function resolveWorktreeRemovalOwnerHostId( - store: Store, - worktreeId: string, - repo: Repo | undefined, - fallbackHostId?: ExecutionHostId -): ExecutionHostId | undefined { - return ( - fallbackHostId ?? - (repo ? getRepoExecutionHostId(repo) : store.getWorktreeMeta(worktreeId)?.hostId) - ) -} - -function removeWorktreeMetadataAndTransientState( - store: Store, - worktreeId: string, - hostId?: ExecutionHostId, - snapshotPruneBatchId?: string -): void { - const persistedHostId = store.getWorktreeMeta(worktreeId)?.hostId - const repoId = getRepoIdFromWorktreeId(worktreeId) - const preservesSameIdOwner = Boolean( - hostId && - ((persistedHostId && persistedHostId !== hostId) || - hasWorktreeRemovalRepoOwnerOnOtherHost(store, repoId, hostId)) - ) - // Why: worktree IDs are path-derived and reusable; drop process-local caches before the same ID can map to a new workspace. - if (hostId) { - store.removeWorktreeMeta(worktreeId, hostId) - } else { - store.removeWorktreeMeta(worktreeId) - } - if (!preservesSameIdOwner) { - advertisedUrlWatcher.forgetWorktree(worktreeId) - // Why: drop this worktree's localhost label routes so they don't accumulate in the proxy's route maps all session. - localhostWorktreeLabelProxy.unregisterWorktree(worktreeId) - // Why: schedule async history tree removal — never recursive-rmSync on the delete critical path. - deleteWorktreeHistoryDir(worktreeId) - // Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep). - pruneWorktreePRRefreshAliases(worktreeId) - } - // Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots. - const snapshotDirectory = store.getProfileStorageDirectory() - if (snapshotPruneBatchId) { - recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, { - batchId: snapshotPruneBatchId, - worktreeId, - executionHostId: hostId - }) - return - } - void pruneWorkspaceCleanupScanSnapshot(snapshotDirectory, worktreeId, hostId) - void pruneWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, worktreeId, hostId) -} - -function getProjectHostSetupMetaUpdates( - store: Store, - repo: Repo, - existing?: WorktreeMeta -): Partial> { - const ownership = getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) - const sameSetup = - existing?.projectHostSetupId === undefined || - existing.projectHostSetupId === ownership.projectHostSetupId - return { - // Why: project IDs can upgrade from legacy repo IDs to provider-backed ones; repair ownership on discovery when the host setup matches. - ...(sameSetup && existing?.projectId !== ownership.projectId - ? { projectId: ownership.projectId } - : {}), - ...(sameSetup && existing?.hostId === undefined ? { hostId: ownership.hostId } : {}), - ...(existing?.projectHostSetupId === undefined - ? { projectHostSetupId: ownership.projectHostSetupId } - : {}) - } -} - -// Why: disk-discovered worktrees have no WorktreeMeta, so lastActivityAt=0 sinks them to the bottom of "Recent"; also backfill host-setup ownership here. -function resolveWorktreeMetaWithDiscoveryBackfill( - store: Store, - repo: Repo, - worktreeId: string -): WorktreeMeta { - const existing = store.getWorktreeMeta(worktreeId) - const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing) - if (existing) { - const updates = { - ...(!existing.instanceId ? { instanceId: randomUUID() } : {}), - ...ownershipUpdates - } - if (Object.keys(updates).length > 0) { - // Why: pre-lineage profiles already have WorktreeMeta rows; backfill on discovery so upgraded workspaces get lineage and host routing. - return store.setWorktreeMeta(worktreeId, updates) - } - return existing - } - return store.setWorktreeMeta(worktreeId, { - lastActivityAt: Date.now(), - ...ownershipUpdates - }) -} - -async function isAlreadyRemovedWorktreePath( - repo: Repo, - worktreePath: string, - localWorktreeGitOptions: { wslDistro?: string } = {} -): Promise { - if (!repo.connectionId) { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - return isWorktreePathMissing( - toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), - access.statPath - ) - } - - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return false - } - return isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) -} - -async function isLocalGitRepository( - runtimeWorktreePath: string, - localWorktreeGitOptions: { wslDistro?: string } = {} -): Promise { - try { - await gitExecFileAsync(['status', '--short'], { - cwd: runtimeWorktreePath, - ...localWorktreeGitOptions - }) - return true - } catch (error) { - return !gitStatusErrorMeansNotRepository(error) - } -} - -function gitStatusErrorMeansNotRepository(error: unknown): boolean { - const message = - error instanceof Error - ? error.message - : error && typeof error === 'object' && 'message' in error - ? String((error as { message: unknown }).message) - : typeof error === 'string' - ? error - : '' - const stderr = - error && typeof error === 'object' && 'stderr' in error - ? String((error as { stderr: unknown }).stderr) - : '' - return /not a git repository/i.test(`${message}\n${stderr}`) -} - -function getWorktreeRemovalOptionsKey(args: { - force?: boolean - allowUnverifiedPtyStop?: boolean - skipArchive?: boolean -}): string { - const forceKey = args.force === true ? 'force' : 'normal' - const archiveKey = args.skipArchive === true ? 'skip-archive' : 'run-archive' - // Why: a Force Delete retry must not coalesce onto the in-flight attempt that - // just failed the PTY gate — it would inherit that failure instead of retrying. - const ptyKey = args.allowUnverifiedPtyStop === true ? 'allow-unverified-pty' : 'require-pty-stop' - return `${forceKey}:${archiveKey}:${ptyKey}` -} - -function getWorktreeRemovalInFlightKey(worktreeId: string, hostId?: ExecutionHostId): string { - return `${hostId ?? ''}\0${worktreeId}` -} - -async function getArchiveHooksForRemoval(repo: Repo): Promise { - if (!repo.connectionId) { - return getEffectiveHooks(repo) - } - - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return getEffectiveHooksFromConfig(repo, null) - } - - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - const yamlHooks = result.isBinary ? null : parseOrcaYaml(result.content) - return getEffectiveHooksFromConfig(repo, yamlHooks) - } catch { - return getEffectiveHooksFromConfig(repo, null) - } -} - -async function runRemoteArchiveHook( - repo: Repo, - worktreePath: string, - script: string -): Promise<{ success: boolean; output: string }> { - if (!repo.connectionId) { - return { success: true, output: '' } - } - - const provider = requireSshGitProvider(repo.connectionId) - const env = getSetupRunnerEnvVars(repo, worktreePath) - const isWindowsRemote = isWindowsAbsolutePathLike(worktreePath) - const result = await provider - .execNonInteractive( - isWindowsRemote ? 'cmd.exe' : '/bin/bash', - isWindowsRemote ? ['/d', '/s', '/c', script] : ['-lc', script], - worktreePath, - WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS, - undefined, - env - ) - .catch((error) => ({ - stdout: '', - stderr: '', - exitCode: null, - timedOut: false, - spawnError: error instanceof Error ? error.message : String(error) - })) - const output = [ - result.stdout, - result.stderr, - result.spawnError, - result.timedOut ? 'archive hook timed out' : null, - typeof result.exitCode === 'number' && result.exitCode !== 0 - ? `archive hook exited ${result.exitCode}` - : null - ] - .filter((part): part is string => Boolean(part)) - .join('\n') - .trim() - - return { - success: !result.spawnError && !result.timedOut && result.exitCode === 0, - output - } -} - -type WorktreeRemovalInFlight = { - optionsKey: string - promise: Promise -} - -type PreservedBranchCleanupTarget = { - worktreeId: string - hostId: ExecutionHostId - branchName: string - head: string - pushTarget?: GitPushTarget -} - -const preservedBranchCleanupByScope = new Map() - -function rememberPreservedBranchCleanupTarget( - worktreeId: string, - hostId: ExecutionHostId, - result: RemoveWorktreeResult | undefined, - fallbackHead: string | undefined, - pushTarget: GitPushTarget | undefined -): void { - if (result?.preservedBranch) { - const head = result.preservedBranch.head ?? fallbackHead - if (!head) { - throw new Error( - `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` - ) - } - preservedBranchCleanupByScope.set(preservedBranchCleanupScopeKey({ worktreeId, hostId }), { - worktreeId, - hostId, - branchName: result.preservedBranch.branchName, - head, - ...(pushTarget ? { pushTarget } : {}) - }) - return - } - preservedBranchCleanupByScope.delete(preservedBranchCleanupScopeKey({ worktreeId, hostId })) -} - -function preserveBranchHeadFallback( - result: RemoveWorktreeResult | undefined, - fallbackHead: string | undefined -): RemoveWorktreeResult { - if (!result?.preservedBranch || result.preservedBranch.head || !fallbackHead) { - return result ?? {} - } - return { - ...result, - preservedBranch: { - ...result.preservedBranch, - head: fallbackHead - } - } -} - -function getPreservedBranchCleanupTarget( - worktreeId: string, - branchName: string, - expectedHead: string, - hostId?: ExecutionHostId -): PreservedBranchCleanupTarget { - const exactTarget = hostId - ? preservedBranchCleanupByScope.get(preservedBranchCleanupScopeKey({ worktreeId, hostId })) - : undefined - const legacyMatches = hostId - ? [] - : [...preservedBranchCleanupByScope.values()].filter( - (target) => - target.worktreeId === worktreeId && - target.branchName === branchName && - target.head === expectedHead - ) - const target = exactTarget ?? (legacyMatches.length === 1 ? legacyMatches[0] : undefined) - if (!target || target.branchName !== branchName || target.head !== expectedHead) { - throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) - } - return target -} - -const loggedUnavailableSshGitProviders = new Set() -const loggedWorktreeListFailures = new Set() -const loggedMalformedWorktreeMetaKeys = new Set() -export const DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS = 30_000 -export const LINEAGE_HYDRATION_TIMEOUT_MS = 5_000 -// Why: absorb renderer polling bursts while bounding external worktree-change lag to one short refresh window. -const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000 - -type DetectedWorktreeScanCacheEntry = { - expiresAt: number - worktrees: GitWorktreeInfo[] -} - -type DetectedWorktreeScan = { - invalidated: boolean - promise: Promise -} - -type DetectedWorktreeScanResult = { - gitWorktrees: GitWorktreeInfo[] - fresh: boolean -} - -const detectedWorktreeScanCache = new Map() -const detectedWorktreeScanInFlight = new Map() - -function invalidateDetectedWorktreeScanCache(repoId: string): void { - const keyPrefix = `${repoId}\0` - for (const key of new Set([ - ...detectedWorktreeScanCache.keys(), - ...detectedWorktreeScanInFlight.keys() - ])) { - if (!key.startsWith(keyPrefix)) { - continue - } - detectedWorktreeScanCache.delete(key) - const inFlight = detectedWorktreeScanInFlight.get(key) - if (inFlight) { - // Why: the detached scan keeps this token so later scans settle without making an older result fresh again. - inFlight.invalidated = true - detectedWorktreeScanInFlight.delete(key) - } - } -} - -registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache) - -export function __resetDetectedWorktreeScanCacheForTests(): void { - // Why: pending scans across a test reset must not repopulate the cache and leak state into the next test. - for (const scan of detectedWorktreeScanInFlight.values()) { - scan.invalidated = true - } - detectedWorktreeScanCache.clear() - detectedWorktreeScanInFlight.clear() -} - -export function __getDetectedWorktreeScanCacheStatsForTests(): { - cacheSize: number - inFlightSize: number -} { - return { - cacheSize: detectedWorktreeScanCache.size, - inFlightSize: detectedWorktreeScanInFlight.size - } -} - -async function listDetectedGitWorktrees( - store: Store, - repo: Repo -): Promise { - const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) - if (repo.connectionId || isFolderRepo(repo)) { - return { - gitWorktrees: await listRepoWorktrees(repo, localWorktreeGitOptions), - fresh: true - } - } - - const cacheKey = getDetectedWorktreeScanCacheKey(repo.id, localWorktreeGitOptions) - const cached = detectedWorktreeScanCache.get(cacheKey) - if (cached && cached.expiresAt > Date.now()) { - return { gitWorktrees: cached.worktrees, fresh: false } - } - - const inFlight = detectedWorktreeScanInFlight.get(cacheKey) - if (inFlight) { - return { gitWorktrees: await inFlight.promise, fresh: false } - } - - const scan: DetectedWorktreeScan = { - invalidated: false, - promise: listRepoWorktrees(repo, localWorktreeGitOptions) - } - detectedWorktreeScanInFlight.set(cacheKey, scan) - try { - const gitWorktrees = await scan.promise - // Why: a create/remove notification can invalidate mid-scan; don't let that stale scan repopulate the cache afterward. - if (!scan.invalidated) { - detectedWorktreeScanCache.set(cacheKey, { - worktrees: gitWorktrees, - expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS - }) - } - return { gitWorktrees, fresh: !scan.invalidated } - } finally { - if (detectedWorktreeScanInFlight.get(cacheKey) === scan) { - detectedWorktreeScanInFlight.delete(cacheKey) - } - } -} - -function getDetectedWorktreeScanCacheKey( - repoId: string, - localWorktreeGitOptions: { wslDistro?: string } = {} -): string { - return `${repoId}\0${localWorktreeGitOptions.wslDistro ?? 'host'}` -} - -function warnOnce(keySet: Set, key: string, message: string, error?: unknown): void { - if (!shouldEmitBoundedWarning(keySet, key)) { - return - } - if (error) { - console.warn(message, error) - } else { - console.warn(message) - } -} - -function rememberLocalWorktreeRoots( - store: Store, - repo: Repo, - gitWorktrees: GitWorktreeInfo[] -): void { - if (repo.connectionId) { - return - } - // Why: reuse the `git worktree list` result so later git/file IPC validation skips a second scan that can trigger macOS folder-permission prompts. - registerWorktreeRootsForRepo(store, repo.id, [ - repo.path, - ...gitWorktrees.map((worktree) => worktree.path) - ]) -} - -type SshWorktreeMetaCandidate = { - id: string - path: string - meta: WorktreeMeta -} - -type SshWorktreeMetaIndex = Map - -function createSshWorktreeMetaIndex(entries: [string, WorktreeMeta][]): SshWorktreeMetaIndex { - const index: SshWorktreeMetaIndex = new Map() - for (const [worktreeId, meta] of entries) { - let parsed: { repoId: string; worktreePath: string } - try { - parsed = parseWorktreeId(worktreeId) - } catch (err) { - warnOnce( - loggedMalformedWorktreeMetaKeys, - worktreeId, - `[worktrees] ignoring malformed persisted worktree metadata key "${worktreeId}"`, - err - ) - continue - } - - const candidates = index.get(parsed.repoId) ?? [] - candidates.push({ id: worktreeId, path: parsed.worktreePath, meta }) - index.set(parsed.repoId, candidates) - } - return index -} - -// Why: scopes parseWorktreeId to one repo's keys. The entry list itself is still materialized for the whole -// store, so this is cheaper per call than the unfiltered index, not free. -function createSshWorktreeMetaIndexForRepo( - allMeta: Record, - repoId: string -): SshWorktreeMetaIndex { - return createSshWorktreeMetaIndex( - Object.entries(allMeta).filter(([worktreeId]) => getRepoIdFromWorktreeId(worktreeId) === repoId) - ) -} - -function synthesizeSshGitWorktree(repo: Repo, path: string, meta: WorktreeMeta): GitWorktreeInfo { - return { - path, - head: '', - branch: '', - isBare: false, - isMainWorktree: areWorktreePathsEqual(path, repo.path), - ...(meta.sparseDirectories !== undefined || - meta.sparseBaseRef !== undefined || - meta.sparsePresetId !== undefined - ? { isSparse: true } - : {}) - } -} - -function listDisconnectedSshWorktrees( - store: Store, - repo: Repo, - metaIndex: SshWorktreeMetaIndex -): ReturnType[] { - const byWorktreeId = new Map>() - const expectedHostId = getRepoExecutionHostId(repo) - const repoOwners = store.getRepos().filter((candidate) => candidate.id === repo.id) - for (const candidate of metaIndex.get(repo.id) ?? []) { - if ( - (candidate.meta.hostId && candidate.meta.hostId !== expectedHostId) || - (!candidate.meta.hostId && repoOwners.length > 1) - ) { - continue - } - const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, candidate.meta) - const meta = - Object.keys(ownershipUpdates).length > 0 - ? { ...candidate.meta, ...ownershipUpdates } - : candidate.meta - if (Object.keys(ownershipUpdates).length > 0) { - store.setWorktreeMeta(candidate.id, ownershipUpdates) - } - // Why: synthesized rows carry no branch, so the title would fall through to the DESKTOP's basename() - // applied to a REMOTE path — a Windows remote then renders its whole C:\... path as the name. Rows must - // stay per-directory (repo.displayName would title every row identically), so use the separator-agnostic - // basename instead. - const worktree = mergeWorktree( - repo.id, - synthesizeSshGitWorktree(repo, candidate.path, meta), - meta, - getWorktreePathBasenameFromId(candidate.id) ?? undefined - ) - byWorktreeId.delete(worktree.id) - byWorktreeId.set(worktree.id, worktree) - } - return [...byWorktreeId.values()] -} - -function buildDetectedGitWorktrees( - store: Store, - repo: Repo, - gitWorktrees: GitWorktreeInfo[] -): DetectedWorktree[] { - const settings = store.getSettings() - const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo) - const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) - // Why: a prunable registration has no working directory (issue #8389); only this listing omits it — cleanup flows list separately. - const liveWorktrees = dedupeWorktreesByPath( - gitWorktrees.filter((gitWorktree) => !gitWorktree.prunable) - ) - const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( - [repo.path, ...liveWorktrees.map((worktree) => worktree.path)], - resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), - resolveConfiguredWorktreeBasePaths(repo) - ) - const detected = liveWorktrees.map((gitWorktree) => { - const worktreeId = `${repo.id}::${gitWorktree.path}` - let meta = store.getWorktreeMeta(worktreeId) - const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) - const detected = toDetectedWorktree({ - repo, - worktree, - meta, - settings, - knownOrcaLayouts, - isLegacyRepoForVisibility, - worktreeVisibilitySourceMatcher - }) - if (!detected.visible) { - return detected - } - - meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, worktreeId) - return toDetectedWorktree({ - repo, - worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), - meta, - settings, - knownOrcaLayouts, - isLegacyRepoForVisibility, - worktreeVisibilitySourceMatcher - }) - }) - return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) -} - -function stampAndMergeVisibleDetectedWorktree( - store: Store, - repo: Repo, - detected: DetectedWorktree -) { - const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id) - return mergeWorktree(repo.id, detected, meta, repo.displayName) -} - -function getFolderWorkspaceRootId(repo: Repo): string { - return `${repo.id}::${repo.path}` -} - -function getFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { - return `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` -} - -function getFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string { - const prefix = `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` - return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID() -} - -function isFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { - const rootId = getFolderWorkspaceRootId(repo) - return ( - worktreeId === rootId || - worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`) - ) -} - -function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree { - return { - id: worktreeId, - ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), - repoId: repo.id, - ...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}), - ...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}), - ...(meta.projectHostSetupId !== undefined - ? { projectHostSetupId: meta.projectHostSetupId } - : {}), - path: repo.path, - head: '', - branch: '', - isBare: false, - isMainWorktree: worktreeId === getFolderWorkspaceRootId(repo), - displayName: meta.displayName || repo.displayName, - comment: meta.comment || '', - linkedIssue: meta.linkedIssue ?? null, - linkedPR: meta.linkedPR ?? null, - linkedLinearIssue: meta.linkedLinearIssue ?? null, - linkedLinearIssueWorkspaceId: meta.linkedLinearIssueWorkspaceId ?? null, - linkedLinearIssueOrganizationUrlKey: meta.linkedLinearIssueOrganizationUrlKey ?? null, - linkedGitLabMR: meta.linkedGitLabMR ?? null, - linkedGitLabIssue: meta.linkedGitLabIssue ?? null, - linkedBitbucketPR: meta.linkedBitbucketPR ?? null, - linkedAzureDevOpsPR: meta.linkedAzureDevOpsPR ?? null, - linkedGiteaPR: meta.linkedGiteaPR ?? null, - linkedWorkItem: meta.linkedWorkItem ?? null, - linkedTaskSourceContext: meta.linkedTaskSourceContext ?? null, - isArchived: meta.isArchived ?? false, - isUnread: meta.isUnread ?? false, - isPinned: meta.isPinned ?? false, - sortOrder: meta.sortOrder ?? 0, - ...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), - lastActivityAt: meta.lastActivityAt ?? 0, - ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), - ...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), - ...(meta.automationProvenance !== undefined - ? { automationProvenance: meta.automationProvenance } - : {}), - ...(meta.cliProvenance !== undefined ? { cliProvenance: meta.cliProvenance } : {}), - ...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}), - workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, - diffComments: meta.diffComments, - mobileDiffReview: meta.mobileDiffReview - } -} - -function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] { - const rootId = getFolderWorkspaceRootId(repo) - const allMeta = store.getAllWorktreeMeta() - const ids = Object.keys(allMeta).filter((worktreeId) => - isFolderWorkspaceIdForRepo(repo, worktreeId) - ) - if (!ids.includes(rootId)) { - ids.unshift(rootId) - } - - return ids - .map((worktreeId) => { - const existing = allMeta[worktreeId] - const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing) - const meta = - existing?.instanceId && Object.keys(ownershipUpdates).length === 0 - ? existing - : store.setWorktreeMeta(worktreeId, { - instanceId: - existing?.instanceId ?? getFolderWorkspaceInstanceIdentity(repo, worktreeId), - ...ownershipUpdates, - ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) - }) - return mergeFolderWorkspace(repo, worktreeId, meta) - }) - .sort((a, b) => { - if (a.id === rootId) { - return -1 - } - if (b.id === rootId) { - return 1 - } - return (b.createdAt ?? b.lastActivityAt) - (a.createdAt ?? a.lastActivityAt) - }) -} - -function buildFolderDetectedWorktrees(store: Store, repo: Repo): DetectedWorktree[] { - const settings = store.getSettings() - const worktrees = listFolderWorkspaces(store, repo) - const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( - [repo.path, ...worktrees.map((worktree) => worktree.path)], - resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), - resolveConfiguredWorktreeBasePaths(repo) - ) - return worktrees.map((worktree) => - toDetectedWorktree({ - repo, - worktree, - meta: store.getWorktreeMeta(worktree.id), - settings, - knownOrcaLayouts: [], - isLegacyRepoForVisibility: true, - worktreeVisibilitySourceMatcher - }) - ) -} - -function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] { - return buildFolderDetectedWorktrees(store, repo) - .filter((worktree) => worktree.visible) - .map((worktree) => { - const meta = store.getWorktreeMeta(worktree.id) - const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, meta) - const repairedMeta = - meta && Object.keys(ownershipUpdates).length === 0 - ? meta - : store.setWorktreeMeta(worktree.id, ownershipUpdates) - return mergeFolderWorkspace(repo, worktree.id, repairedMeta) - }) -} - -function createFolderWorkspace( - args: CreateWorktreeArgsWithSystemProvenance, - repo: Repo, - store: Store -): CreateWorktreeResult { - const now = Date.now() - const instanceId = randomUUID() - const worktreeId = getFolderWorkspaceInstanceId(repo, instanceId) - const meta = store.setWorktreeMeta(worktreeId, { - instanceId, - ...(store.getProjectHostSetups - ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) - : {}), - displayName: args.displayName || args.name, - lastActivityAt: now, - createdAt: now, - orcaCreatedAt: now, - orcaCreationSource: 'desktop', - creatorProvenance: { kind: 'host' }, - ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), - ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), - ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), - ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), - ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), - ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), - ...(args.linkedLinearIssueWorkspaceId !== undefined - ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } - : {}), - ...(args.linkedLinearIssueOrganizationUrlKey !== undefined - ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } - : {}), - ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), - ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}), - ...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), - ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), - ...(args.linkedBitbucketPR !== undefined ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}), - ...(args.linkedAzureDevOpsPR !== undefined - ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } - : {}), - ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), - ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), - ...(args.linkedTaskSourceContext !== undefined - ? { linkedTaskSourceContext: args.linkedTaskSourceContext } - : {}) - }) - return { worktree: mergeFolderWorkspace(repo, worktreeId, meta) } -} - -function buildDisconnectedDetectedWorktrees( - store: Store, - repo: Repo, - worktrees: Worktree[] -): DetectedWorktree[] { - const settings = store.getSettings() - const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( - [repo.path, ...worktrees.map((worktree) => worktree.path)], - resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), - resolveConfiguredWorktreeBasePaths(repo) - ) - const detected = worktrees.map((worktree) => { - const meta = store.getWorktreeMeta(worktree.id) - const detected = toDetectedWorktree({ - repo, - worktree, - meta, - settings, - knownOrcaLayouts: [], - isLegacyRepoForVisibility: true, - worktreeVisibilitySourceMatcher - }) - return applyMetadataFallbackVisibility(detected) - }) - return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) -} - -function hasConflictingStoredWorktreeOwner( - store: Store, - repo: Repo, - worktreeIds: readonly string[] -): boolean { - const expectedHostId = getRepoExecutionHostId(repo) - const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length - return worktreeIds.some((worktreeId) => { - const meta = store.getWorktreeMeta(worktreeId) - return !!meta && (meta.hostId ? meta.hostId !== expectedHostId : repoOwnerCount > 1) - }) -} - -type RepoOwnershipEvidence = - | { status: 'owned'; hostId: ExecutionHostId } - | { status: 'malformed' } - | { status: 'contradictory' } - -function resolveRepoOwnershipEvidence(repo: Repo): RepoOwnershipEvidence { - const hasExplicitHost = repo.executionHostId !== null && repo.executionHostId !== undefined - const explicitHost = hasExplicitHost ? parseExecutionHostId(repo.executionHostId) : null - if (hasExplicitHost && !explicitHost) { - return { status: 'malformed' } - } - const hasConnection = repo.connectionId !== null && repo.connectionId !== undefined - const connectionId = hasConnection ? repo.connectionId?.trim() : null - if (hasConnection && !connectionId) { - return { status: 'malformed' } - } - const connectionHostId = connectionId ? toSshExecutionHostId(connectionId) : null - if (explicitHost && connectionHostId && explicitHost.id !== connectionHostId) { - return { status: 'contradictory' } - } - return { - status: 'owned', - hostId: explicitHost?.id ?? connectionHostId ?? LOCAL_EXECUTION_HOST_ID - } -} - -function findExactRepoOwner( - store: Store, - repoId: string, - executionHostId?: ExecutionHostId -): Repo | undefined { - const candidates = store.getRepos().filter((repo) => repo.id === repoId) - const evidence = candidates.map(resolveRepoOwnershipEvidence) - if (evidence.some((owner) => owner.status !== 'owned')) { - return undefined - } - const matches = candidates.filter((_, index) => { - const owner = evidence[index] - return ( - owner?.status === 'owned' && - (executionHostId === undefined || owner.hostId === executionHostId) - ) - }) - return matches.length === 1 ? matches[0] : undefined -} - -function isCapturedRepoCurrent( - store: Store, - repo: Repo, - executionHostId?: ExecutionHostId -): boolean { - const current = findExactRepoOwner(store, repo.id, executionHostId) - return ( - current !== undefined && - current.path === repo.path && - (current.connectionId ?? null) === (repo.connectionId ?? null) && - (current.executionHostId ?? null) === (repo.executionHostId ?? null) - ) -} - -async function listDetectedWorktreesForCapturedRepo( - store: Store, - repo: Repo, - isCurrent: () => boolean, - capturedProvider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, - providerAbort?: { signal: AbortSignal; status: () => 'canceled' | 'timed-out' } -): Promise { - const abortedResult = () => - providerAbort?.signal.aborted - ? ({ providerAbortStatus: providerAbort.status() } as const) - : undefined - const sshWorktreeMetaIndex = repo.connectionId - ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) - : new Map() - - try { - let gitWorktrees: GitWorktreeInfo[] - let freshScan = true - if (isFolderRepo(repo)) { - if (!isCurrent()) { - return null - } - const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => - isFolderWorkspaceIdForRepo(repo, worktreeId) - ) - if (hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds)) { - return { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees: [] - } - } - return { - repoId: repo.id, - authoritative: true, - source: 'git', - worktrees: projectResolvedWorktreeLineage( - buildFolderDetectedWorktrees(store, repo), - store.getAllWorktreeLineage?.() ?? {} - ) - } - } - if (repo.connectionId) { - if (!capturedProvider) { - const aborted = abortedResult() - if (aborted) { - return aborted - } - if (!isCurrent()) { - return null - } - const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - return { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) - } - } - gitWorktrees = await capturedProvider.listWorktrees(repo.path, { - signal: providerAbort?.signal - }) - } else { - const scan = await listDetectedGitWorktrees(store, repo) - gitWorktrees = scan.gitWorktrees - freshScan = scan.fresh - } - const aborted = abortedResult() - if (aborted) { - return aborted - } - if (!isCurrent()) { - return null - } - const listedWorktreeIds = gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`) - if (hasConflictingStoredWorktreeOwner(store, repo, listedWorktreeIds)) { - return { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees: [] - } - } - if (freshScan) { - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) - } - loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) - return { - repoId: repo.id, - authoritative: true, - source: 'git', - worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees) - } - } catch (err) { - const aborted = abortedResult() - if (aborted) { - return aborted - } - if (!isCurrent()) { - return null - } - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list detected worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - if (repo.connectionId) { - const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - return { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) - } - } - return { repoId: repo.id, authoritative: false, source: 'metadata-fallback', worktrees: [] } - } -} - -function hasValidDirectSshAuthority( - args: DirectSshDetectedWorktreeRequest -): args is DirectSshDetectedWorktreeRequest { - return isAdmissibleDirectSshAuthority(args.expectedAuthority) -} - -function hasValidLineageSshAuthority( - args: ListDesktopLineageForHostArgs -): args is Extract { - if (!('expectedAuthority' in args)) { - return false - } - return isAdmissibleDirectSshAuthority(args.expectedAuthority) -} - -type LineageOwner = - | { status: 'owned'; hostId: ExecutionHostId } - | { status: 'ambiguous' | 'contradictory' | 'runtime' } - -type LineageFolder = ReturnType[number] -type LineageGroup = ReturnType[number] - -type LineageResolutionContext = { - store: Store - repos: Repo[] - groups: LineageGroup[] - reposById: Map - foldersById: Map - groupsById: Map - groupSubtreeIdsByRoot: Map> - worktreeOwners: Map - folderOwners: Map - workspaceOwners: Map -} - -function indexLineageEntriesById( - entries: readonly T[] -): Map { - const index = new Map() - for (const entry of entries) { - const matching = index.get(entry.id) ?? [] - matching.push(entry) - index.set(entry.id, matching) - } - return index -} - -function createLineageResolutionContext(store: Store): LineageResolutionContext { - const repos = store.getRepos() - const folders = store.getFolderWorkspaces() - const groups = store.getProjectGroups() - return { - store, - repos, - groups, - reposById: indexLineageEntriesById(repos), - foldersById: indexLineageEntriesById(folders), - groupsById: indexLineageEntriesById(groups), - groupSubtreeIdsByRoot: new Map(), - worktreeOwners: new Map(), - folderOwners: new Map(), - workspaceOwners: new Map() - } -} - -function resolveRepoLineageOwner(repo: Repo): LineageOwner { - const owner = resolveRepoOwnershipEvidence(repo) - if (owner.status === 'malformed') { - return { status: 'ambiguous' } - } - if (owner.status === 'contradictory') { - return { status: 'contradictory' } - } - return parseExecutionHostId(owner.hostId)?.kind === 'runtime' ? { status: 'runtime' } : owner -} - -function resolveWorktreeLineageOwner( - context: LineageResolutionContext, - worktreeId: string -): LineageOwner { - const cached = context.worktreeOwners.get(worktreeId) - if (cached) { - return cached - } - const remember = (owner: LineageOwner): LineageOwner => { - context.worktreeOwners.set(worktreeId, owner) - return owner - } - let repoId: string - try { - repoId = parseWorktreeId(worktreeId).repoId - } catch { - return remember({ status: 'ambiguous' }) - } - const repos = context.reposById.get(repoId) ?? [] - const meta = context.store.getWorktreeMeta(worktreeId) - const runtimeOwnerEnvironmentId = ( - meta as (WorktreeMeta & { runtimeOwnerEnvironmentId?: string }) | undefined - )?.runtimeOwnerEnvironmentId?.trim() - if (runtimeOwnerEnvironmentId) { - return remember({ status: 'runtime' }) - } - if (meta?.hostId) { - const explicitHost = parseExecutionHostId(meta.hostId) - if (!explicitHost) { - return remember({ status: 'ambiguous' }) - } - if (explicitHost.kind === 'runtime') { - return remember({ status: 'runtime' }) - } - const matchingRepos = repos.filter((repo) => { - const owner = resolveRepoLineageOwner(repo) - return owner.status === 'owned' && owner.hostId === explicitHost.id - }) - if (matchingRepos.length === 1) { - return remember({ status: 'owned', hostId: explicitHost.id }) - } - return remember( - matchingRepos.length > 1 - ? { status: 'ambiguous' } - : { status: repos.length > 0 ? 'contradictory' : 'ambiguous' } - ) - } - if (repos.length !== 1) { - return remember({ status: 'ambiguous' }) - } - return remember(resolveRepoLineageOwner(repos[0])) -} - -function getFolderLineageCandidateRepos( - context: LineageResolutionContext, - folder: LineageFolder -): Repo[] { - let groupIds = context.groupSubtreeIdsByRoot.get(folder.projectGroupId) - if (!groupIds) { - groupIds = getProjectGroupSubtreeIds(context.groups, folder.projectGroupId) - context.groupSubtreeIdsByRoot.set(folder.projectGroupId, groupIds) - } - const grouped = context.repos.filter( - (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) - ) - const pathRepos = context.repos.filter( - (repo) => - !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && - isPathInsideOrEqual(folder.folderPath, repo.path) - ) - const group = context.groupsById.get(folder.projectGroupId)?.[0] - const connectionId = folder.connectionId ?? group?.connectionId ?? null - return connectionId - ? [...grouped, ...pathRepos.filter((repo) => (repo.connectionId ?? null) === connectionId)] - : grouped.length > 0 - ? [ - ...grouped, - ...pathRepos.filter((repo) => - new Set(grouped.map((candidate) => candidate.connectionId ?? null)).has( - repo.connectionId ?? null - ) - ) - ] - : pathRepos -} - -function resolveFolderLineageOwner( - context: LineageResolutionContext, - folderWorkspaceId: string -): LineageOwner { - const cached = context.folderOwners.get(folderWorkspaceId) - if (cached) { - return cached - } - const remember = (owner: LineageOwner): LineageOwner => { - context.folderOwners.set(folderWorkspaceId, owner) - return owner - } - const folders = context.foldersById.get(folderWorkspaceId) ?? [] - if (folders.length !== 1) { - return remember({ status: 'ambiguous' }) - } - const folder = folders[0] - const groups = context.groupsById.get(folder.projectGroupId) ?? [] - if (groups.length !== 1) { - return remember({ status: 'ambiguous' }) - } - const group = groups[0] - const hosts = new Set() - if (folder.connectionId) { - hosts.add(`ssh:${encodeURIComponent(folder.connectionId)}`) - } - if (group.connectionId) { - hosts.add(`ssh:${encodeURIComponent(group.connectionId)}`) - } - if (group.executionHostId) { - const parsed = parseExecutionHostId(group.executionHostId) - if (!parsed) { - return remember({ status: 'ambiguous' }) - } - hosts.add(parsed.id) - } - for (const repo of getFolderLineageCandidateRepos(context, folder)) { - const owner = resolveRepoLineageOwner(repo) - if (owner.status !== 'owned') { - return remember(owner) - } - hosts.add(owner.hostId) - } - if (hosts.size > 1) { - return remember({ status: 'contradictory' }) - } - const hostId = [...hosts][0] ?? LOCAL_EXECUTION_HOST_ID - return remember( - parseExecutionHostId(hostId)?.kind === 'runtime' - ? { status: 'runtime' } - : { status: 'owned', hostId } - ) -} - -function resolveWorkspaceLineageOwner( - context: LineageResolutionContext, - workspaceKey: string -): LineageOwner { - const cached = context.workspaceOwners.get(workspaceKey) - if (cached) { - return cached - } - const workspace = parseWorkspaceKey(workspaceKey) - const owner = !workspace - ? { status: 'ambiguous' as const } - : workspace.type === 'worktree' - ? resolveWorktreeLineageOwner(context, workspace.worktreeId) - : resolveFolderLineageOwner(context, workspace.folderWorkspaceId) - context.workspaceOwners.set(workspaceKey, owner) - return owner -} - -function filterLineageForHost( - store: Store, - executionHostId: ExecutionHostId -): { - worktreeLineageById: Record - workspaceLineageByChildKey: Record -} | null { - const context = createLineageResolutionContext(store) - const worktreeLineageById: Record = {} - const workspaceLineageByChildKey: Record = {} - for (const [worktreeId, lineage] of Object.entries(store.getAllWorktreeLineage())) { - const child = resolveWorktreeLineageOwner(context, worktreeId) - const parent = resolveWorktreeLineageOwner(context, lineage.parentWorktreeId) - if (child.status === 'ambiguous' || child.status === 'contradictory') { - return null - } - if (parent.status === 'ambiguous' || parent.status === 'contradictory') { - return null - } - if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId === executionHostId && - parent.hostId === executionHostId - ) { - worktreeLineageById[worktreeId] = structuredClone(lineage) - } else if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId !== parent.hostId - ) { - return null - } - } - for (const [childKey, lineage] of Object.entries(store.getAllWorkspaceLineage())) { - const child = resolveWorkspaceLineageOwner(context, childKey) - const parent = resolveWorkspaceLineageOwner(context, lineage.parentWorkspaceKey) - if (child.status === 'ambiguous' || child.status === 'contradictory') { - return null - } - if (parent.status === 'ambiguous' || parent.status === 'contradictory') { - return null - } - if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId === executionHostId && - parent.hostId === executionHostId - ) { - workspaceLineageByChildKey[childKey] = structuredClone(lineage) - } else if ( - child.status === 'owned' && - parent.status === 'owned' && - child.hostId !== parent.hostId - ) { - return null - } - } - return { worktreeLineageById, workspaceLineageByChildKey } -} - -async function hydrateLineageWithinDeadline(runtime: OrcaRuntimeService): Promise { - let timeout: ReturnType | undefined - const hydration = Promise.resolve() - .then(() => runtime.hydrateInferredWorktreeLineage()) - .then( - () => true, - () => false - ) - const deadline = new Promise((resolve) => { - timeout = setTimeout(() => resolve(false), LINEAGE_HYDRATION_TIMEOUT_MS) - }) - try { - return await Promise.race([hydration, deadline]) - } finally { - if (timeout) { - clearTimeout(timeout) - } - } -} - -async function listDesktopLineageForHost( - store: Store, - runtime: OrcaRuntimeService, - args: ListDesktopLineageForHostArgs -): Promise { - const parsedHost = parseExecutionHostId(args?.executionHostId) - const rejected = ( - reason: Extract['reason'] - ): HostLineageSnapshot => ({ - authoritative: false, - executionHostId: args.executionHostId, - reason - }) - if (!parsedHost || parsedHost.kind === 'runtime') { - return rejected('rejected') - } - let provider: ReturnType | undefined - let authority: - | Extract['expectedAuthority'] - | null = null - if (parsedHost.kind === 'local') { - if ('expectedAuthority' in args) { - return rejected('rejected') - } - } else { - if ( - !hasValidLineageSshAuthority(args) || - args.expectedAuthority.targetId !== parsedHost.targetId - ) { - return rejected('rejected') - } - authority = { ...args.expectedAuthority } - if (!isCurrentSshProviderAuthority(authority)) { - return rejected('stale') - } - provider = getSshGitProvider(parsedHost.targetId) - if (!provider) { - return rejected('unavailable') - } - } - if (!(await hydrateLineageWithinDeadline(runtime))) { - return rejected('unavailable') - } - if ( - parsedHost.kind === 'ssh' && - (!authority || - getSshGitProvider(parsedHost.targetId) !== provider || - !isCurrentSshProviderAuthority(authority)) - ) { - return rejected('stale') - } - const lineage = filterLineageForHost(store, parsedHost.id) - if (!lineage) { - return rejected('ambiguous-owner') - } - if (parsedHost.kind === 'local') { - return { - authoritative: true, - authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, - ...lineage - } - } - if (!authority) { - return rejected('authority-unknown') - } - return { - authoritative: true, - authority: { - kind: 'direct-ssh', - executionHostId: parsedHost.id, - ...authority - }, - ...lineage - } -} - -async function listHostQualifiedDetectedWorktrees( - store: Store, - args: ListDetectedWorktreesArgs, - providerAbort?: { signal: AbortSignal; status: () => 'canceled' | 'timed-out' } -): Promise { - const parsedHost = parseExecutionHostId(args.executionHostId) - const rejected = (status: 'rejected' | 'stale' | 'ambiguous-owner') => ({ - providerRequestId: args.providerRequestId, - executionHostId: args.executionHostId, - status - }) - if ( - typeof args.providerRequestId !== 'string' || - args.providerRequestId.length === 0 || - Buffer.byteLength(args.providerRequestId, 'utf8') > PROVIDER_REQUEST_ID_MAX_UTF8_BYTES || - !parsedHost || - parsedHost.kind === 'runtime' - ) { - return rejected('rejected') - } - let capturedAuthority: DirectSshDetectedWorktreeRequest['expectedAuthority'] | null = null - if (parsedHost.kind === 'ssh') { - const directArgs = args as DirectSshDetectedWorktreeRequest - if ( - !hasValidDirectSshAuthority(directArgs) || - directArgs.expectedAuthority.targetId !== parsedHost.targetId - ) { - return rejected('rejected') - } - capturedAuthority = { ...directArgs.expectedAuthority } - if (!isCurrentSshProviderAuthority(capturedAuthority)) { - return rejected('stale') - } - } - - const repoCandidates = store.getRepos().filter((candidate) => candidate.id === args.repoId) - if ( - repoCandidates.some((candidate) => resolveRepoOwnershipEvidence(candidate).status !== 'owned') - ) { - return rejected('rejected') - } - const repo = findExactRepoOwner(store, args.repoId, args.executionHostId) - if (!repo) { - return rejected('ambiguous-owner') - } - if ( - (parsedHost.kind === 'local' && repo.connectionId) || - (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) - ) { - return rejected('rejected') - } - const provider = parsedHost.kind === 'ssh' ? getSshGitProvider(parsedHost.targetId) : undefined - const isCurrent = (): boolean => { - if (!isCapturedRepoCurrent(store, repo, args.executionHostId)) { - return false - } - if ( - (parsedHost.kind === 'local' && repo.connectionId) || - (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) - ) { - return false - } - if (parsedHost.kind !== 'ssh') { - return true - } - return ( - capturedAuthority !== null && - getSshGitProvider(parsedHost.targetId) === provider && - isCurrentSshProviderAuthority(capturedAuthority) - ) - } - const result = await listDetectedWorktreesForCapturedRepo( - store, - repo, - isCurrent, - provider, - providerAbort - ) - if (!result) { - return rejected('stale') - } - if ('providerAbortStatus' in result) { - return { - providerRequestId: args.providerRequestId, - executionHostId: args.executionHostId, - status: result.providerAbortStatus - } - } - const status = result.authoritative ? 'complete' : 'non-authoritative' - if (parsedHost.kind === 'local') { - return { - status, - providerRequestId: args.providerRequestId, - repoId: repo.id, - authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, - result - } - } - if (!capturedAuthority) { - return rejected('rejected') - } - return { - status, - providerRequestId: args.providerRequestId, - repoId: repo.id, - authority: { - kind: 'direct-ssh', - executionHostId: args.executionHostId as `ssh:${string}`, - ...capturedAuthority - }, - result - } -} +import { registerWorktreeCreateHandlers } from './worktrees/create/register-worktree-create-handlers' +import { registerWorktreePrefetchHandler } from './worktrees/create/register-worktree-prefetch-handler' +import { registerReviewBaseHandlers } from './worktrees/create/register-review-base-handlers' +import { registerWorktreeHookCheckHandler } from './hooks/register-worktree-hook-check-handler' +import { registerWorktreeHookFileHandlers } from './hooks/register-worktree-hook-file-handlers' +import { registerWorktreeHookInspectionHandler } from './hooks/register-worktree-hook-inspection-handler' +import { registerWorktreeHookRunnerHandler } from './hooks/register-worktree-hook-runner-handler' +import { registerDetectedWorktreeHandlers } from './worktrees/listing/register-detected-worktree-handlers' +import { registerHostCatalogHandlers } from './worktrees/listing/register-host-catalog-handlers' +import { registerWorktreeCatalogHandlers } from './worktrees/listing/register-worktree-catalog-handlers' +import { registerDetectedWorktreeScanInvalidation } from './worktrees/listing/register-detected-worktree-scan-invalidation' +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' + +registerDetectedWorktreeScanInvalidation() + +const WORKTREE_HANDLER_CHANNELS = [ + 'worktrees:listAll', + 'worktrees:list', + 'worktrees:listRetiredNames', + 'worktrees:listDetected', + 'worktrees:listKnownForExecutionHost', + 'worktrees:forgetRemovedForExecutionHost', + 'worktrees:cancelListDetected', + 'worktrees:create', + 'worktrees:adoptProvisionedRoot', + 'worktrees:prefetchCreateBase', + 'worktrees:resolvePrBase', + 'worktrees:resolveMrBase', + 'worktrees:remove', + 'worktrees:forgetLocal', + 'worktrees:forceDeletePreservedBranch', + 'worktrees:updateMeta', + 'worktrees:listLineage', + 'worktrees:listLineageForHost', + 'worktrees:updateLineage', + 'worktrees:persistSortOrder', + 'worktrees:getBranchRenameFailureOutput', + 'hooks:check', + 'hooks:inspectSetupScriptImports', + 'hooks:createIssueCommandRunner', + 'hooks:readIssueCommand', + 'hooks:writeIssueCommand' +] as const export function registerWorktreeHandlers( mainWindow: BrowserWindow, @@ -1850,1826 +55,31 @@ export function registerWorktreeHandlers( runtime: OrcaRuntimeService, options?: { onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void } ): void { - const detectedWorktreeCancellations = createSenderScopedRequestCancellations() - // Remove previously registered handlers so re-register works when macOS re-activates and creates a new window. - ipcMain.removeHandler('worktrees:listAll') - ipcMain.removeHandler('worktrees:list') - ipcMain.removeHandler('worktrees:listRetiredNames') - ipcMain.removeHandler('worktrees:listDetected') - ipcMain.removeHandler('worktrees:listKnownForExecutionHost') - ipcMain.removeHandler('worktrees:forgetRemovedForExecutionHost') - ipcMain.removeHandler('worktrees:cancelListDetected') - ipcMain.removeHandler('worktrees:create') - ipcMain.removeHandler('worktrees:adoptProvisionedRoot') - ipcMain.removeHandler('worktrees:prefetchCreateBase') - ipcMain.removeHandler('worktrees:resolvePrBase') - ipcMain.removeHandler('worktrees:resolveMrBase') - ipcMain.removeHandler('worktrees:remove') - ipcMain.removeHandler('worktrees:forgetLocal') - ipcMain.removeHandler('worktrees:forceDeletePreservedBranch') - ipcMain.removeHandler('worktrees:updateMeta') - ipcMain.removeHandler('worktrees:listLineage') - ipcMain.removeHandler('worktrees:listLineageForHost') - ipcMain.removeHandler('worktrees:updateLineage') - ipcMain.removeHandler('worktrees:persistSortOrder') - ipcMain.removeHandler('worktrees:getBranchRenameFailureOutput') - ipcMain.removeHandler('hooks:check') - ipcMain.removeHandler('hooks:inspectSetupScriptImports') - ipcMain.removeHandler('hooks:createIssueCommandRunner') - ipcMain.removeHandler('hooks:readIssueCommand') - ipcMain.removeHandler('hooks:writeIssueCommand') + const context: WorktreeIpcContext = { + mainWindow, + store, + runtime, + ...(options ? { options } : {}), + detectedWorktreeCancellations: createSenderScopedRequestCancellations(), + worktreeRemovalsInFlight: new Map() + } - ipcMain.handle('worktrees:listAll', async () => { - const repos = store.getRepos() - const sshWorktreeMetaIndex = repos.some((repo) => repo.connectionId) - ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) - : new Map() + // Remove all stale registrations before installing any replacement handler. + for (const channel of WORKTREE_HANDLER_CHANNELS) { + ipcMain.removeHandler(channel) + } - // Why: each local repo listing can spawn `git worktree list`; cap fan-out so large fleets don't start unbounded subprocesses. - const results = await mapWithConcurrency(repos, WORKTREE_LIST_ALL_CONCURRENCY, async (repo) => { - try { - let gitWorktrees - let freshScan = true - if (isFolderRepo(repo)) { - return listVisibleFolderWorkspaces(store, repo) - } else if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - warnOnce( - loggedUnavailableSshGitProviders, - `${repo.connectionId}:${repo.id}`, - `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` - ) - return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - } - loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) - try { - gitWorktrees = await provider.listWorktrees(repo.path) - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - } - } else { - const scan = await listDetectedGitWorktrees(store, repo) - gitWorktrees = scan.gitWorktrees - freshScan = scan.fresh - } - if (freshScan) { - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) - } - loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) - return buildDetectedGitWorktrees(store, repo, gitWorktrees) - .filter((worktree) => worktree.visible) - .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree)) - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - // Why: do NOT seed empty success — it flags the repo registered, blocking access to legit linked worktrees until the cache is invalidated. - return [] - } - }) - - return results.flat() - }) - - ipcMain.handle('worktrees:listRetiredNames', async (_event, args: { repoId: string }) => { - const repo = store.getRepo(args.repoId) - if (!repo) { - return EMPTY_RETIRED_NAME_REGISTRY - } - return getRetiredNameRegistryForRepo(store, repo, store.getRepos(), store.getSettings()) - }) - - ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => { - const repo = store.getRepo(args.repoId) - if (!repo) { - return [] - } - const sshWorktreeMetaIndex = repo.connectionId - ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) - : new Map() - - try { - let gitWorktrees - let freshScan = true - if (isFolderRepo(repo)) { - return listVisibleFolderWorkspaces(store, repo) - } else if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - warnOnce( - loggedUnavailableSshGitProviders, - `${repo.connectionId}:${repo.id}`, - `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` - ) - return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - } - loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) - try { - gitWorktrees = await provider.listWorktrees(repo.path) - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) - } - } else { - const scan = await listDetectedGitWorktrees(store, repo) - gitWorktrees = scan.gitWorktrees - freshScan = scan.fresh - } - if (freshScan) { - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) - } - loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) - return buildDetectedGitWorktrees(store, repo, gitWorktrees) - .filter((worktree) => worktree.visible) - .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree)) - } catch (err) { - warnOnce( - loggedWorktreeListFailures, - `${repo.id}:${repo.path}`, - `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, - err - ) - // Why: see worktrees:listAll catch — seeding an empty-success result would poison the auth cache and block linked worktrees. - return [] - } - }) - - ipcMain.handle( - 'worktrees:listKnownForExecutionHost', - (_event, args: ListKnownWorktreesForExecutionHostArgs): HostQualifiedKnownWorktreeResult => { - // Why: a malformed invoke must fail closed as `rejected`, not throw out of the handler. `ssh:` is inert — - // it owns no repo, so every guard below still rejects it. - const requestedRepoId = args?.repoId ?? '' - const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' - const rejected = (): HostQualifiedKnownWorktreeResult => ({ - status: 'rejected', - repoId: requestedRepoId, - executionHostId: requestedExecutionHostId - }) - const parsedHost = parseExecutionHostId(requestedExecutionHostId) - if (parsedHost?.kind !== 'ssh') { - return rejected() - } - // Why: findExactRepoOwner repeats this same all-candidates-owned check, and getRepos() re-hydrates the - // whole catalog, so a separate pass here is pure cost. - const repo = findExactRepoOwner(store, requestedRepoId, requestedExecutionHostId) - if (!repo || repo.connectionId !== parsedHost.targetId) { - return rejected() - } - const complete = (worktrees: DetectedWorktree[]): HostQualifiedKnownWorktreeResult => ({ - status: 'complete', - repoId: repo.id, - executionHostId: requestedExecutionHostId, - result: { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees - } - }) - // Why: folder workspace ids carry an instance suffix the git-worktree synthesizer would read as a directory; build them the way every other listing does. - if (isFolderRepo(repo)) { - const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => - isFolderWorkspaceIdForRepo(repo, worktreeId) - ) - return hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds) - ? rejected() - : complete( - // Why: match the authoritative folder listing; without lineage these rows render flat and then - // reshuffle once the real scan lands. - projectResolvedWorktreeLineage( - buildFolderDetectedWorktrees(store, repo), - store.getAllWorktreeLineage?.() ?? {} - ) - ) - } - const metaIndex = createSshWorktreeMetaIndexForRepo(store.getAllWorktreeMeta(), repo.id) - return complete( - buildDisconnectedDetectedWorktrees( - store, - repo, - listDisconnectedSshWorktrees(store, repo, metaIndex) - ) - ) - } - ) - - // Why: gcStaleWorktreeMeta cannot stat a remote path, so SSH metadata outlives the worktree and the fallback - // above re-lists a worktree deleted outside Orca on every launch. An authoritative scan is the only proof of - // absence, so the renderer reports what it retired here and the row is dropped like a local GC would. - ipcMain.handle( - 'worktrees:forgetRemovedForExecutionHost', - ( - _event, - args: ForgetRemovedWorktreesForExecutionHostArgs - ): ForgetRemovedWorktreesForExecutionHostResult => { - const nothingForgotten: ForgetRemovedWorktreesForExecutionHostResult = { - forgottenWorktreeIds: [] - } - const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' - const worktreeIds = Array.isArray(args?.worktreeIds) ? args.worktreeIds : [] - const parsedHost = parseExecutionHostId(requestedExecutionHostId) - if (parsedHost?.kind !== 'ssh' || worktreeIds.length === 0) { - return nothingForgotten - } - const repo = findExactRepoOwner(store, args?.repoId ?? '', requestedExecutionHostId) - if (!repo || repo.connectionId !== parsedHost.targetId) { - return nothingForgotten - } - // Why: a folder workspace's meta IS the workspace record, not a checkout row — gcStaleWorktreeMeta skips - // those keys for the same reason, and no remote scan can retire one. - if (isFolderRepo(repo)) { - return nothingForgotten - } - const allMeta = store.getAllWorktreeMeta() - const forgottenWorktreeIds: string[] = [] - for (const worktreeId of worktreeIds) { - const meta = typeof worktreeId === 'string' ? allMeta[worktreeId] : undefined - if (!meta || getRepoIdFromWorktreeId(worktreeId) !== repo.id) { - continue - } - // An unhosted meta belongs to this repo's only owner; a foreign hostId needs that host's own scan. - if (meta.hostId && meta.hostId !== requestedExecutionHostId) { - continue - } - store.removeWorktreeMeta(worktreeId, requestedExecutionHostId) - forgottenWorktreeIds.push(worktreeId) - } - if (forgottenWorktreeIds.length > 0) { - const snapshotDirectory = store.getProfileStorageDirectory() - const targets = forgottenWorktreeIds.map((worktreeId) => ({ - worktreeId, - executionHostId: requestedExecutionHostId - })) - void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets) - void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets) - } - return { forgottenWorktreeIds } - } - ) - - ipcMain.handle( - 'worktrees:listDetected', - async ( - event, - args: DetectedWorktreeRequestArgs - ): Promise => { - if ('executionHostId' in args) { - const parsedHost = parseExecutionHostId(args.executionHostId) - const directSshRequest = parsedHost?.kind === 'ssh' - const controller = directSshRequest - ? detectedWorktreeCancellations.begin(event, args.providerRequestId) - : null - const directArgs = args as DirectSshDetectedWorktreeRequest - const removeAuthorityAbort = - controller && - parsedHost?.kind === 'ssh' && - hasValidDirectSshAuthority(directArgs) && - directArgs.expectedAuthority.targetId === parsedHost.targetId - ? registerSshProviderRequestAbort(directArgs.expectedAuthority, controller) - : undefined - let timedOut = false - let removeAbortListener: (() => void) | undefined - const abortedResult = controller - ? new Promise((resolve) => { - const onAbort = (): void => { - resolve({ - providerRequestId: args.providerRequestId, - executionHostId: args.executionHostId, - status: timedOut ? 'timed-out' : 'canceled' - }) - } - controller.signal.addEventListener('abort', onAbort, { once: true }) - removeAbortListener = () => controller.signal.removeEventListener('abort', onAbort) - }) - : undefined - const timeout = controller - ? setTimeout(() => { - timedOut = true - controller.abort() - }, DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS) - : undefined - try { - const providerResult = listHostQualifiedDetectedWorktrees( - store, - args, - controller - ? { - signal: controller.signal, - status: () => (timedOut ? 'timed-out' : 'canceled') - } - : undefined - ) - return abortedResult - ? await Promise.race([providerResult, abortedResult]) - : await providerResult - } finally { - if (timeout) { - clearTimeout(timeout) - } - removeAbortListener?.() - removeAuthorityAbort?.() - detectedWorktreeCancellations.finish(event, args.providerRequestId, controller) - } - } - const repo = findExactRepoOwner(store, args.repoId) - if (!repo) { - return { - repoId: args.repoId, - authoritative: false, - source: 'metadata-fallback', - worktrees: [] - } - } - const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined - const authority = repo.connectionId - ? { ...getSshProviderAuthority(repo.connectionId) } - : undefined - const result = await listDetectedWorktreesForCapturedRepo( - store, - repo, - () => - isCapturedRepoCurrent(store, repo) && - (!repo.connectionId || - (getSshGitProvider(repo.connectionId) === provider && - authority !== undefined && - isCurrentSshProviderAuthority(authority))), - provider - ) - return result && !('providerAbortStatus' in result) - ? result - : { - repoId: repo.id, - authoritative: false, - source: 'metadata-fallback', - worktrees: [] - } - } - ) - ipcMain.handle( - 'worktrees:cancelListDetected', - (event, args: { providerRequestId: ProviderRequestId }): void => { - detectedWorktreeCancellations.cancel(event, args.providerRequestId) - } - ) - - ipcMain.handle( - 'worktrees:prefetchCreateBase', - async (_event, args: { repoId: string; baseBranch?: string }): Promise => { - const repo = store.getRepo(args.repoId) - if (!repo) { - return - } - try { - await prefetchWorktreeCreateBase({ repo, baseBranch: args.baseBranch, runtime }) - } catch { - // Why: optimistic warm-up; the real create path awaits the same refresh and reports failures there. - } - } - ) - - ipcMain.handle( - 'worktrees:create', - 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 () => { - const repo = store.getRepo(args.repoId) - if (!repo) { - throw new Error(`Repo not found: ${args.repoId}`) - } - - const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) - const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' - - const automationProvenance = resolveAutomationWorkspaceProvenance({ - authority: runtime, - repoSelector: args.repoId, - repo, - request: args.automationProvenanceRequest - }) - const createArgs: CreateWorktreeArgsWithSystemProvenance = { - ...args, - automationProvenance - } - - let result: CreateWorktreeResult - try { - // Why: wrap only the helpers; the pre-validation throws above are IPC-shape bugs, not the git/filesystem failures the funnel tracks. - result = isFolderRepo(repo) - ? createFolderWorkspace(createArgs, repo, store) - : repo.connectionId - ? await createRemoteWorktree(createArgs, repo, store, mainWindow) - : await createLocalWorktree(createArgs, repo, store, mainWindow, runtime) - } catch (error) { - releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) - track('workspace_create_failed', { - source, - error_class: classifyWorkspaceCreateError(error), - ...getCohortAtEmit() - }) - throw error - } - finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) - - // 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', { - source, - from_existing_branch: - !isFolderRepo(repo) && - typeof args.baseBranch === 'string' && - args.baseBranch.length > 0, - ...getCohortAtEmit() - }) - - if (isFolderRepo(repo)) { - notifyWorktreesChanged(mainWindow, repo.id) - } - - options?.onWorktreeLifecycle?.({ - kind: 'created', - worktreeId: result.worktree.id, - path: result.worktree.path, - branch: result.worktree.branch - }) - - return result - }) - } - ) - - ipcMain.handle( - 'worktrees:adoptProvisionedRoot', - async (_event, rawArgs: AdoptProvisionedRootArgs): Promise => { - const args = normalizeLinkedWorkItemFields(rawArgs) - return withWorktreeSpan({ stage: 'create' }, async () => { - const repo = findExactRepoOwner(store, args.repoId, args.executionHostId) - if (!repo || isFolderRepo(repo)) { - throw new Error('Provisioned-root repository ownership is missing or ambiguous.') - } - const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) - const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' - const automationProvenance = resolveAutomationWorkspaceProvenance({ - authority: runtime, - repoSelector: args.repoId, - repo, - request: args.automationProvenanceRequest - }) - let result: CreateWorktreeResult - try { - result = await adoptProvisionedRootSshCheckout({ - userDataPath: app.getPath('userData'), - request: { ...args, automationProvenance }, - repo, - store, - isRepoCurrent: () => isCapturedRepoCurrent(store, repo, args.executionHostId) - }) - } catch (error) { - releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) - track('workspace_create_failed', { - source, - error_class: classifyWorkspaceCreateError(error), - ...getCohortAtEmit() - }) - throw error - } - finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) - track('workspace_created', { - source, - from_existing_branch: false, - ...getCohortAtEmit() - }) - notifyWorktreesChanged(mainWindow, repo.id) - options?.onWorktreeLifecycle?.({ - kind: 'created', - worktreeId: result.worktree.id, - path: result.worktree.path, - branch: result.worktree.branch - }) - return result - }) - } - ) - - ipcMain.handle( - 'worktrees:resolvePrBase', - async ( - _event, - args: { - repoId: string - prNumber: number - headRefName?: string - baseRefName?: string - isCrossRepository?: boolean - } - ): Promise => { - const repo = store.getRepo(args.repoId) - if (!repo) { - return { error: 'Repo not found' } - } - if (isFolderRepo(repo)) { - return { error: 'Folder mode does not support creating worktrees.' } - } - const gitExec = async (args: string[]): Promise<{ stdout: string; stderr: string }> => { - if (!repo.connectionId) { - return gitExecFileAsync(args, getLocalProjectGitExecOptions(store, repo)) - } - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - throw new Error( - 'SSH Git provider is not available. Reconnect to this target and try again.' - ) - } - return provider.exec(args, repo.path) - } - // Why: SSH review-head fetches require narrow write-capable RPCs. - const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => - fetchPrHeadTrackingRef( - repo, - repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, - remote, - branch, - { localGitExecOptions: getLocalProjectGitExecOptions(store, repo) } - ) - const fetchPullRequestHeadRef = (remote: string, prNumber: number): Promise => - fetchGitHubPullRequestHeadRef( - repo, - repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, - remote, - prNumber, - { localGitExecOptions: getLocalProjectGitExecOptions(store, repo) } - ) - - return resolveGitHubPrStartPoint({ - repoPath: repo.path, - prNumber: args.prNumber, - headRefName: args.headRefName, - baseRefName: args.baseRefName, - isCrossRepository: args.isCrossRepository, - issueSourcePreference: repo.issueSourcePreference, - connectionId: repo.connectionId ?? null, - localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), - gitExec, - fetchRemoteTrackingRef, - fetchPullRequestHeadRef, - // Why: one resolver keeps source preference and hosting identity aligned - // across local, WSL, and SSH worktree creation. - resolveRemote: () => - resolveGitHubReviewHeadRemote({ - repoPath: repo.path, - issueSourcePreference: repo.issueSourcePreference, - connectionId: repo.connectionId ?? null, - localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), - gitExec - }) - }) - } - ) - - // Why: keep desktop IPC and mobile/runtime RPC on the same MR-base path so SSH repos don't regress differently per surface. - ipcMain.handle( - 'worktrees:resolveMrBase', - async ( - _event, - args: { - repoId: string - mrIid: number - sourceBranch?: string - targetBranch?: string - isCrossRepository?: boolean - } - ): Promise< - | { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } - | { error: string } - > => { - return runtime.resolveManagedMrBase({ - repoSelector: `id:${args.repoId}`, - mrIid: args.mrIid, - sourceBranch: args.sourceBranch, - targetBranch: args.targetBranch, - isCrossRepository: args.isCrossRepository - }) - } - ) - - const worktreeRemovalsInFlight = new Map() - - ipcMain.handle( - 'worktrees:remove', - async (_event, args: RemoveWorktreeArgs): Promise => { - const { repoId, worktreePath } = parseWorktreeId(args.worktreeId) - const repo = getRepoForWorktreeRemoval(store, repoId, args.hostId) - if (!repo) { - throw new Error(`Repo not found: ${repoId}`) - } - // The resolved repo supplies host ownership when legacy callers omit args.hostId. - const removalHostId = getRepoExecutionHostId(repo) - const inFlightKey = getWorktreeRemovalInFlightKey(args.worktreeId, removalHostId) - const optionsKey = getWorktreeRemovalOptionsKey(args) - const inFlightRemoval = worktreeRemovalsInFlight.get(inFlightKey) - if (inFlightRemoval) { - if (inFlightRemoval.optionsKey === optionsKey) { - return inFlightRemoval.promise - } - throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`) - } - - // Why: concurrent stale-toast/double-click/sidebar races can hit the same worktree; share the op so only one path touches Git and disk. - const removal = (async (): Promise => { - // Why: worktree.create is traced; delete freezes were invisible without a matching worktree.remove parent span. - return withWorktreeSpan({ stage: 'remove', path: worktreePath }, async () => { - if (isFolderRepo(repo)) { - if (args.worktreeId === getFolderWorkspaceRootId(repo)) { - throw new Error( - 'Cannot delete the project root workspace. Remove the folder project instead.' - ) - } - const ownerHost = parseExecutionHostId(removalHostId) - const sshPtyProvider = - ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined - // Why: folder workspaces share one root, so there's no Git remove step to close shells; sweep PTYs before dropping metadata. - await withWorktreeRemoveStageSpan('pty_sweep', 'folder', async () => { - // Folder projects can be SSH-backed, so fence the sweep to the owning host exactly - // like the git paths — the local inventory must never reach a remote workspace's id. - // The resolved repo is authoritative here: path-derived metadata is shared by - // same-id host copies and can describe a different owner's workspace. - const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' - await killAllProcessesForWorktree(args.worktreeId, { - runtime, - resolvedWorktreeId: args.worktreeId, - ...(ownerHost?.kind === 'ssh' ? { resolvedConnectionId: ownerHost.targetId } : {}), - ...(ownerHost?.kind === 'runtime' - ? { resolvedRuntimeEnvironmentId: ownerHost.environmentId } - : {}), - localProvider: sshPtyProvider ?? getLocalPtyProvider(), - onPtyStopped: clearProviderPtyState, - ...(externalHost - ? { - includeProviderInventory: - ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider), - includeLocalRegistry: false - } - : {}) - }).catch((err) => { - console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) - }) - }) - await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => { - await deleteRemoteWorktreeHistory(sshPtyProvider, args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - }) - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: removalHostId }) - ) - notifyWorktreesChanged(mainWindow, repoId) - return {} - } - - // Why: renderer-supplied worktreeId embeds a path; re-derive the canonical path from git before any destructive action. - const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null - const localWorktreeGitOptions = repo.connectionId - ? {} - : getLocalProjectWorktreeGitOptions(store, repo) - const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 - const registeredWorktrees = repo.connectionId - ? await provider!.listWorktrees(repo.path) - : hasLocalWorktreeGitOptions - ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) - : await listGitWorktreesStrict(repo.path) - const removedMeta = resolveWorktreeRemovalMetadata( - store, - repoId, - args.worktreeId, - removalHostId - ) - const removedPushTarget = removedMeta?.pushTarget - const registeredWorktree = findRegisteredDeletableWorktree( - repo.path, - worktreePath, - registeredWorktrees - ) - if (!registeredWorktree) { - const fsProvider = repo.connectionId - ? getSshFilesystemProvider(repo.connectionId) - : null - let canCleanOrphanedDirectory = false - if ( - canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: removedMeta - }) - ) { - if (repo.connectionId) { - if (!fsProvider) { - throw new Error('SSH filesystem provider unavailable') - } - if (!fsProvider.lstat) { - throw new Error('SSH filesystem provider lstat unavailable') - } - canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( - worktreePath, - repo.path, - (path) => fsProvider.lstat!(path), - (path) => fsProvider.readFile(path) - ) - } else { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - canCleanOrphanedDirectory = - !isDangerousWorktreeRemovalPath(worktreePath, repo.path) && - (await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), - toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - access.statPath, - access.readPath - )) - } - } - if (canCleanOrphanedDirectory) { - assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, registeredWorktrees) - if (!args.force) { - throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) - } - if (repo.connectionId) { - const removalGate = await runtime.acquireFileWatcherRemoval( - worktreePath, - repo.connectionId - ) - let removalCompleted = false - try { - await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { - connectionId: repo.connectionId, - allowUnverifiedStop: args.allowUnverifiedPtyStop - }) - await fsProvider!.deletePath(worktreePath, true) - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - // Why history first: the worktree is already gone from git and - // disk by here, so a rejecting push-target cleanup must not be - // able to skip history removal and leave the user's commands on - // the remote host. - await deleteRemoteWorktreeHistory( - getSshPtyProvider(repo.connectionId), - args.worktreeId - ) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - args.worktreeId, - removedPushTarget, - store - ) - } else { - const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath) - let removalCompleted = false - try { - await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { - allowUnverifiedStop: args.allowUnverifiedPtyStop - }) - await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - invalidateAuthorizedRootsCache() - } - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ - worktreeId: args.worktreeId, - hostId: removalHostId - }) - ) - notifyWorktreesChanged(mainWindow, repoId) - return {} - } - if (!repo.connectionId) { - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - const runtimeWorktreePath = toLocalWorktreeRuntimePath( - worktreePath, - localWorktreeGitOptions - ) - if ( - await canCleanupUnregisteredOrcaLeftoverDirectory({ - meta: removedMeta, - worktreePath, - runtimeWorktreePath, - repo, - runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - registeredWorktrees, - statPath: access.statPath, - isGitRepository: (path) => isLocalGitRepository(path, localWorktreeGitOptions) - }) - ) { - if (!args.force) { - throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) - } - const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath) - let removalCompleted = false - try { - await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { - allowUnverifiedStop: args.allowUnverifiedPtyStop - }) - await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ - worktreeId: args.worktreeId, - hostId: removalHostId - }) - ) - invalidateAuthorizedRootsCache() - notifyWorktreesChanged(mainWindow, repoId) - return {} - } - } - if (await isAlreadyRemovedWorktreePath(repo, worktreePath, localWorktreeGitOptions)) { - if (!args.force && !removedMeta) { - // Why: without persisted metadata, require the renderer recovery path before deleting Orca-only state for an unregistered path. - throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE) - } - // Why: a manually deleted worktree is already gone; persisted metadata proves it was an Orca-known row, so no force is needed. - if (repo.connectionId) { - // Why history first: the worktree is already gone from git and - // disk by here, so a rejecting push-target cleanup must not be - // able to skip history removal and leave the user's commands on - // the remote host. - await deleteRemoteWorktreeHistory( - getSshPtyProvider(repo.connectionId), - args.worktreeId - ) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - args.worktreeId, - removedPushTarget, - store - ) - } else { - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - invalidateAuthorizedRootsCache() - } - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ - worktreeId: args.worktreeId, - hostId: removalHostId - }) - ) - notifyWorktreesChanged(mainWindow, repoId) - return {} - } - throw new Error(`Refusing to delete unregistered worktree path: ${worktreePath}`) - } - const canonicalWorktreePath = registeredWorktree.path - const deleteBranch = removedMeta?.preserveBranchOnDelete !== true - - // Why: a Git lock must block before archive hooks or linked-path cleanup mutate the workspace; dirty-file force is separate. - try { - assertWorktreeUnlockedForRemoval(registeredWorktree) - } catch (error) { - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) - } - - // Why: a prior forced Windows recovery can delete the dir but leave a stale Git registration; verify before clearing metadata. - if ( - !repo.connectionId && - args.force === true && - process.platform === 'win32' && - (isWindowsAbsolutePathLike(canonicalWorktreePath) || - !!localWorktreeGitOptions.wslDistro) && - removedMeta && - (await isAlreadyRemovedWorktreePath( - repo, - canonicalWorktreePath, - localWorktreeGitOptions - )) - ) { - const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ - canonicalWorktreePath, - repoPath: repo.path, - localWorktreeGitOptions, - registeredWorktree, - deleteBranch - }) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - rememberPreservedBranchCleanupTarget( - args.worktreeId, - removalHostId, - removalResult, - registeredWorktree.head, - removedPushTarget - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - invalidateAuthorizedRootsCache() - notifyWorktreesChanged(mainWindow, repoId) - return removalResult ?? {} - } - - // Run archive hook before removal so teardown scripts still see the worktree directory. - const hooks = await getArchiveHooksForRemoval(repo) - const archiveScript = hooks?.scripts.archive - if (archiveScript && !args.skipArchive) { - // Why the branch on connectionId: this block is shared by both flows, so a hardcoded - // 'remote' would file every local archive hook under the SSH breakdown. - await withWorktreeRemoveStageSpan( - 'archive_hook', - repo.connectionId ? 'remote' : 'local', - async () => { - const result = repo.connectionId - ? await runRemoteArchiveHook(repo, canonicalWorktreePath, archiveScript) - : await runHook( - 'archive', - canonicalWorktreePath, - repo, - undefined, - localWorktreeGitOptions - ) - if (!result.success) { - console.error( - `[hooks] archive hook failed for ${canonicalWorktreePath}:`, - result.output - ) - } - } - ) - } - - const remoteConnectionId = repo.connectionId ?? undefined - if (remoteConnectionId) { - // Why: SSH deletion mirrors the local flow — hooks run while the directory is intact, then the clean check guards removal. - if (!args.force) { - const { clean, stdout } = await provider!.worktreeIsClean(canonicalWorktreePath) - if (!clean) { - const error = new Error('Worktree has uncommitted or untracked changes.') - ;(error as Error & { stdout?: string }).stdout = stdout - throw error - } - } - - const remoteRemoveOptions = !deleteBranch ? { deleteBranch } : {} - const removalGate = await withWorktreeRemoveStageSpan( - 'watcher_gate', - 'remote', - async () => - runtime.acquireFileWatcherRemoval(canonicalWorktreePath, remoteConnectionId) - ) - let rawRemovalResult: RemoveWorktreeResult | undefined - let removalCompleted = false - try { - await withWorktreeRemoveStageSpan('pty_sweep', 'remote', async () => { - await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { - connectionId: remoteConnectionId, - allowUnverifiedStop: args.allowUnverifiedPtyStop - }) - }) - rawRemovalResult = await withWorktreeRemoveStageSpan( - 'git_remove', - 'remote', - async () => - Object.keys(remoteRemoveOptions).length > 0 - ? provider!.removeWorktree( - canonicalWorktreePath, - args.force, - remoteRemoveOptions - ) - : provider!.removeWorktree(canonicalWorktreePath, args.force) - ) - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - const removalResult = preserveBranchHeadFallback( - rawRemovalResult, - registeredWorktree.head - ) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider!, - repo.path, - args.worktreeId, - removedPushTarget, - store - ) - await deleteRemoteWorktreeHistory( - getSshPtyProvider(remoteConnectionId), - args.worktreeId - ) - rememberPreservedBranchCleanupTarget( - args.worktreeId, - removalHostId, - removalResult, - registeredWorktree.head, - removedPushTarget - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - await withWorktreeRemoveStageSpan('metadata_purge', 'remote', async () => { - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - }) - notifyWorktreesChanged(mainWindow, repoId) - return removalResult ?? {} - } - - const refreshedWorktrees = hasLocalWorktreeGitOptions - ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) - : await listGitWorktreesStrict(repo.path) - const refreshedRegisteredWorktree = findRegisteredDeletableWorktree( - repo.path, - canonicalWorktreePath, - refreshedWorktrees - ) - if (!refreshedRegisteredWorktree) { - throw new Error( - `Worktree registration changed during deletion: ${canonicalWorktreePath}. Retry deletion.` - ) - } - try { - // Why: an archive hook can race another Git client that locks the row; recheck before linked-path/watcher/terminal teardown. - assertWorktreeUnlockedForRemoval(refreshedRegisteredWorktree) - } catch (error) { - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) - } - - // Why: `orca.yaml` shared directories are symlinked in too, and a - // directory-only ignore rule leaves those links untracked, so removal must - // tolerate and unlink them exactly like the per-user shared paths. - const linkedPaths = getWorktreeSharedLinkPaths(repo) - const ignoredLinkedPaths = args.force - ? [] - : await findExistingWorktreeSymlinkPaths(canonicalWorktreePath, linkedPaths) - try { - await (hasLocalWorktreeGitOptions - ? assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false, { - ...localWorktreeGitOptions, - ...(ignoredLinkedPaths.length > 0 - ? { ignoredUntrackedPaths: ignoredLinkedPaths } - : {}) - }) - : ignoredLinkedPaths.length > 0 - ? assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false, { - ignoredUntrackedPaths: ignoredLinkedPaths - }) - : assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false)) - } catch (error) { - if (!isOrphanCompatiblePreflightError(error)) { - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) - } - // Why: Git can still classify this as an orphan after preflight; keep strict PTY teardown before any recursive fallback deletion. - } - - let removalResult: RemoveWorktreeResult | undefined - const removalGate = await withWorktreeRemoveStageSpan('watcher_gate', 'local', async () => - runtime.acquireFileWatcherRemoval(canonicalWorktreePath) - ) - let removalCompleted = false - try { - // Why: hold the watcher/terminal gate through Git and any recursive fallback so no late spawn recreates a native handle. - // Linked-path deletion is destructive too, so PTYs must release every handle before Windows or WSL filesystem cleanup starts. - await withWorktreeRemoveStageSpan('pty_sweep', 'local', async () => { - await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { - allowUnverifiedStop: args.allowUnverifiedPtyStop - }) - }) - - // Why: preflight only ignored these paths, not mutated them; keep watcher installs fenced through Git removal. - if (linkedPaths.length > 0) { - await removeWorktreeLinkedPaths(canonicalWorktreePath, linkedPaths) - } - - try { - const removeOptions = { - ...(!deleteBranch ? { deleteBranch } : {}), - // Why: reuse the authoritative worktree list already computed here instead of rescanning siblings on the hot delete path. - knownRemovedWorktree: refreshedRegisteredWorktree, - ...(hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}) - } - removalResult = preserveBranchHeadFallback( - await withWorktreeRemoveStageSpan('git_remove', 'local', async () => - removeWorktree( - repo.path, - canonicalWorktreePath, - args.force ?? false, - removeOptions - ) - ), - refreshedRegisteredWorktree.head - ) - } catch (error) { - // Why: Git for Windows can deregister a clean worktree before its recursive filesystem deletion fails transiently. - const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({ - error, - force: args.force ?? false, - canonicalWorktreePath, - repoPath: repo.path, - localWorktreeGitOptions, - registeredWorktree: refreshedRegisteredWorktree, - deleteBranch, - closeWatcher: (worktreePath) => runtime.closeFileWatchersForRemoval(worktreePath) - }) - if (recoveredRemovalResult) { - removalResult = recoveredRemovalResult - removalCompleted = true - } else if (isOrphanedWorktreeError(error)) { - // If git no longer tracks this worktree, clean up the directory and metadata - console.warn( - `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` - ) - const access = getLocalWorktreePathAccess(localWorktreeGitOptions) - if ( - await canSafelyRemoveOrphanedWorktreeDirectory( - toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), - toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - access.statPath, - access.readPath - ) - ) { - await runtime.closeFileWatchersForRemoval(canonicalWorktreePath) - await removeLocalWorktreePath( - canonicalWorktreePath, - localWorktreeGitOptions - ).catch(() => {}) - } else { - console.warn( - `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` - ) - } - // Why: remove failed so git still tracks it (.git/worktrees/); prune or the stale entry keeps its branch locked. - await gitExecFileAsync(['worktree', 'prune'], { - cwd: repo.path, - ...localWorktreeGitOptions - }).catch(() => {}) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ - worktreeId: args.worktreeId, - hostId: removalHostId - }) - ) - invalidateAuthorizedRootsCache() - notifyWorktreesChanged(mainWindow, repoId) - removalCompleted = true - return {} - } else { - throw new Error( - formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) - ) - } - } - removalCompleted = true - } finally { - await removalGate.finish(removalCompleted) - } - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - removedPushTarget, - store, - localWorktreeGitOptions - ) - rememberPreservedBranchCleanupTarget( - args.worktreeId, - removalHostId, - removalResult, - refreshedRegisteredWorktree.head, - removedPushTarget - ) - runtime.clearOptimisticReconcileToken(args.worktreeId) - await withWorktreeRemoveStageSpan('metadata_purge', 'local', async () => { - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - removalHostId, - args.snapshotPruneBatchId - ) - }) - await withWorktreeRemoveStageSpan('cache_invalidation', 'local', async () => { - invalidateAuthorizedRootsCache() - }) - - notifyWorktreesChanged(mainWindow, repoId) - return removalResult ?? {} - }) - })() - worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: removal }) - try { - const result = await removal - options?.onWorktreeLifecycle?.({ - kind: 'removed', - worktreeId: args.worktreeId, - path: parseWorktreeId(args.worktreeId).worktreePath - }) - return result - } finally { - if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === removal) { - worktreeRemovalsInFlight.delete(inFlightKey) - } - } - } - ) - - // Why: drop a workspace locally with no remote work, so one pinned to a dead SSH target (where worktrees:remove throws) can still be cleared. - ipcMain.handle( - 'worktrees:forgetLocal', - async ( - _event, - args: Pick - ): Promise => { - const { repoId } = parseWorktreeId(args.worktreeId) - const repoOwner = resolveWorktreeRemovalRepoOwner(store, repoId, args.hostId) - if (!args.hostId && repoOwner.kind === 'ambiguous') { - throw new Error( - `Workspace identity is ambiguous across hosts: ${args.worktreeId}. Retry with an explicit host.` - ) - } - const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined - // Repo-first (unlike owner resolution below) so this key matches worktrees:remove's; meta only covers ownerless forgets. - const inFlightKey = getWorktreeRemovalInFlightKey( - args.worktreeId, - repo - ? getRepoExecutionHostId(repo) - : (args.hostId ?? store.getWorktreeMeta(args.worktreeId)?.hostId) - ) - const optionsKey = 'forget-local' - const inFlight = worktreeRemovalsInFlight.get(inFlightKey) - if (inFlight) { - if (inFlight.optionsKey === optionsKey) { - return inFlight.promise - } - throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`) - } - - const forget = (async (): Promise => { - const isFolderRootOf = (candidate: Repo): boolean => - isFolderRepo(candidate) && args.worktreeId === getFolderWorkspaceRootId(candidate) - const fallbackRepos = args.hostId - ? store - .getRepos() - .filter((candidate) => getRepoExecutionHostId(candidate) === args.hostId) - : store.getRepos() - if (repo ? isFolderRootOf(repo) : fallbackRepos.some(isFolderRootOf)) { - throw new Error( - 'Cannot delete the project root workspace. Remove the folder project instead.' - ) - } - - const ownerHostId = resolveWorktreeRemovalOwnerHostId( - store, - args.worktreeId, - repo, - args.hostId - ) - const ownerHost = parseExecutionHostId(ownerHostId) - const sshPtyProvider = - ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined - const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' - // External host inventories must never sweep a same-id local workspace. - await killAllProcessesForWorktree(args.worktreeId, { - runtime, - resolvedWorktreeId: args.worktreeId, - ...(ownerHost?.kind === 'ssh' ? { resolvedConnectionId: ownerHost.targetId } : {}), - ...(ownerHost?.kind === 'runtime' - ? { resolvedRuntimeEnvironmentId: ownerHost.environmentId } - : {}), - localProvider: sshPtyProvider ?? getLocalPtyProvider(), - onPtyStopped: clearProviderPtyState, - ...(externalHost - ? { - includeProviderInventory: ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider), - includeLocalRegistry: false - } - : {}) - }).catch((err) => { - console.warn(`[worktree-teardown] forget-local failed for ${args.worktreeId}:`, err) - }) - - runtime.clearOptimisticReconcileToken(args.worktreeId) - // The resolved owner, not args.hostId: an orphan forget with no hostId still has to purge its SSH/runtime partition. - removeWorktreeMetadataAndTransientState( - store, - args.worktreeId, - ownerHost?.id, - args.snapshotPruneBatchId - ) - // Why: cached roots outlive the forgotten workspace, so an ownerless path stays filesystem-authorized until a rebuild. - invalidateAuthorizedRootsCache() - if (ownerHost?.id) { - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: ownerHost.id }) - ) - } else { - for (const [key, target] of preservedBranchCleanupByScope) { - if (target.worktreeId === args.worktreeId) { - preservedBranchCleanupByScope.delete(key) - } - } - } - notifyWorktreesChanged(mainWindow, repoId) - return {} - })() - worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: forget }) - try { - return await forget - } finally { - if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === forget) { - worktreeRemovalsInFlight.delete(inFlightKey) - } - } - } - ) - - ipcMain.handle( - 'worktrees:forceDeletePreservedBranch', - async ( - _event, - args: { - worktreeId: string - branchName: string - expectedHead: string - hostId?: ExecutionHostId - } - ): Promise => { - const { repoId } = parseWorktreeId(args.worktreeId) - const cleanupTarget = getPreservedBranchCleanupTarget( - args.worktreeId, - args.branchName, - args.expectedHead, - args.hostId - ) - const repo = getRepoForWorktreeRemoval(store, repoId, cleanupTarget.hostId) - if (!repo) { - throw new Error(`Repo not found: ${repoId}`) - } - if (isFolderRepo(repo)) { - throw new Error('Folder workspaces do not have local Git branches.') - } - - if (repo.connectionId) { - const provider = requireSshGitProvider(repo.connectionId) - // Why: SSH needs the write-capable relay RPC; the read-only git.exec allowlist rejects these worktree/update-ref/config writes. - await provider.forceDeletePreservedBranch( - repo.path, - cleanupTarget.branchName, - cleanupTarget.head - ) - await cleanupUnusedWorktreePushTargetRemoteSsh( - provider, - repo.path, - args.worktreeId, - cleanupTarget.pushTarget, - store - ) - } else { - const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) - const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 - await (hasLocalWorktreeGitOptions - ? forceDeleteLocalBranch( - repo.path, - cleanupTarget.branchName, - cleanupTarget.head, - (argv, cwd) => gitExecFileAsync(argv, { cwd, ...localWorktreeGitOptions }) - ) - : forceDeleteLocalBranch(repo.path, cleanupTarget.branchName, cleanupTarget.head)) - await cleanupUnusedWorktreePushTargetRemote( - repo.path, - args.worktreeId, - cleanupTarget.pushTarget, - store, - localWorktreeGitOptions - ) - } - - preservedBranchCleanupByScope.delete( - preservedBranchCleanupScopeKey({ - worktreeId: args.worktreeId, - hostId: cleanupTarget.hostId - }) - ) - return { deleted: true } - } - ) - - ipcMain.handle( - 'worktrees:updateMeta', - (_event, args: { worktreeId: string; updates: Partial }) => { - const validatedUpdates = normalizeLinkedWorkItemFields(args.updates) - const updates = - validatedUpdates.displayName !== undefined - ? { - ...validatedUpdates, - pendingFirstAgentMessageRename: false, - firstAgentMessageRenameError: null - } - : validatedUpdates - const meta = store.setWorktreeMeta(args.worktreeId, stripOrcaProvenanceMetaUpdates(updates)) - // Do NOT notify here: renderer already applied this optimistically; a notification would re-sort the sidebar (bug PR #209). - if (args.updates.displayName !== undefined) { - // Why: remote clients have no optimistic rename and stopped polling titles, so push a remote-only invalidation; gate on displayName so per-click isUnread updates stay event-free. - runtime.notifyWorktreesChangedForRemoteClients(getRepoIdFromWorktreeId(args.worktreeId)) - } - return meta - } - ) - - ipcMain.handle('worktrees:listLineage', async () => { - await runtime.hydrateInferredWorktreeLineage() - return { - lineage: store.getAllWorktreeLineage(), - workspaceLineage: store.getAllWorkspaceLineage() - } - }) - - ipcMain.handle( - 'worktrees:listLineageForHost', - (_event, args: ListDesktopLineageForHostArgs): Promise => - listDesktopLineageForHost(store, runtime, args) - ) - - ipcMain.handle( - 'worktrees:updateLineage', - async (_event, args: { worktreeId: string; parentWorktreeId?: string; noParent?: boolean }) => { - await runtime.updateManagedWorktreeMeta(args.worktreeId, { - lineage: - args.noParent === true - ? { noParent: true } - : args.parentWorktreeId - ? { parentWorktree: `id:${args.parentWorktreeId}` } - : undefined - }) - notifyWorktreesChanged(mainWindow, parseWorktreeId(args.worktreeId).repoId) - return store.getWorktreeLineage(args.worktreeId) ?? null - } - ) - - // Why: snapshot sidebar order for cold-start restore (ephemeral signals gone); one batch call avoids N updateMeta IPCs. - ipcMain.handle('worktrees:persistSortOrder', (_event, args: { orderedIds: string[] }) => { - if (!Array.isArray(args?.orderedIds) || args.orderedIds.length === 0) { - return - } - const updates = planWorktreeSortOrderUpdates( - args.orderedIds, - (worktreeId) => store.getWorktreeMeta(worktreeId), - Date.now() - ) - for (const update of updates) { - store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder }) - } - }) - - // Why: full failure output lives only in main memory (not worktree metadata), so the dialog pulls it on demand. - ipcMain.handle( - 'worktrees:getBranchRenameFailureOutput', - (_event, args: { worktreeId: string }) => { - if (typeof args?.worktreeId !== 'string' || args.worktreeId.length === 0) { - return null - } - return readBranchRenameFailureOutputForDisplay(args.worktreeId) - } - ) - - ipcMain.handle( - 'hooks:check', - async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { - const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId) - if (!repo) { - const repoIdExists = store.getRepos().some((candidate) => candidate.id === args.repoId) - // Why: callers treat inspection errors as "skip", so a requested/ambiguous host must report error (fail closed), not hook-free. - return { - status: args.hostId || repoIdExists ? 'error' : 'ok', - hasHooks: false, - hooks: null, - mayNeedUpdate: false - } - } - if (isFolderRepo(repo)) { - return { status: 'ok', hasHooks: false, hooks: null, mayNeedUpdate: false } - } - - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return { status: 'error', hasHooks: false, hooks: null, mayNeedUpdate: false } - } - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - return { - status: 'ok', - hasHooks: !result.isBinary, - hooks: result.isBinary ? null : parseOrcaYaml(result.content), - mayNeedUpdate: false - } - } catch (error) { - return { - status: isENOENT(error) ? 'ok' : 'error', - hasHooks: false, - hooks: null, - mayNeedUpdate: false - } - } - } - - const has = hasHooksFile(repo.path) - const hooks = has ? loadHooks(repo.path) : null - // Why: unrecognised top-level keys mean the file is well-formed but from a newer Orca; suggest updating rather than "could not be parsed". - const mayNeedUpdate = has && !hooks && hasUnrecognizedOrcaYamlKeys(repo.path) - return { - status: 'ok', - hasHooks: has, - hooks, - mayNeedUpdate - } - } - ) - - ipcMain.handle( - 'hooks:createIssueCommandRunner', - (_event, args: { repoId: string; worktreePath: string; command: string }) => { - const repo = store.getRepo(args.repoId) - if (!repo) { - throw new Error(`Repo not found: ${args.repoId}`) - } - - return createIssueCommandRunnerScript( - repo, - args.worktreePath, - args.command, - getLocalProjectWorktreeGitOptions(store, repo), - resolveSetupRunnerShell(store.getSettings()) - ) - } - ) - - ipcMain.handle( - 'hooks:inspectSetupScriptImports', - async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { - const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId) - if (!repo || isFolderRepo(repo)) { - return [] - } - - return inspectSetupScriptImportCandidates( - async (relativePath) => { - const filePath = joinWorktreeRelativePath(repo.path, relativePath) - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return null - } - try { - const result = await fsProvider.readFile(filePath) - return result.isBinary ? null : result.content - } catch { - return null - } - } - - try { - return await readFile(filePath, 'utf-8') - } catch (error) { - if (!isENOENT(error)) { - console.warn('[hooks] Failed to inspect setup script import candidate:', error) - } - return null - } - }, - { - fileExists: async (relativePath) => { - const filePath = joinWorktreeRelativePath(repo.path, relativePath) - if (repo.connectionId) { - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return false - } - try { - const fileStat = await fsProvider.stat(filePath) - return fileStat.type !== 'directory' - } catch { - return false - } - } - - try { - const fileStat = await stat(filePath) - return !fileStat.isDirectory() - } catch (error) { - if (!isENOENT(error)) { - console.warn('[hooks] Failed to stat setup script import candidate:', error) - } - return false - } - } - } - ) - } - ) - - ipcMain.handle( - 'hooks:readIssueCommand', - async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => { - const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId) - if (!repo || isFolderRepo(repo)) { - return { - status: 'ok', - localContent: null, - sharedContent: null, - effectiveContent: null, - localFilePath: '', - source: 'none' as const - } - } - if (repo.connectionId) { - const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - return { - status: 'error', - localContent: null, - sharedContent: null, - effectiveContent: null, - localFilePath: issueCommandPath, - source: 'none' as const - } - } - - let status: 'ok' | 'error' = 'ok' - let localContent: string | null = null - let sharedContent: string | null = null - try { - const result = await fsProvider.readFile(issueCommandPath) - localContent = result.isBinary ? null : result.content.trim() || null - } catch (error) { - if (!isENOENT(error)) { - status = 'error' - } - } - try { - const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) - sharedContent = result.isBinary - ? null - : parseOrcaYaml(result.content)?.issueCommand?.trim() || null - } catch (error) { - if (!isENOENT(error)) { - status = 'error' - } - } - const effectiveContent = localContent ?? sharedContent - return { - status: localContent ? 'ok' : status, - localContent, - sharedContent, - effectiveContent, - localFilePath: issueCommandPath, - source: localContent - ? ('local' as const) - : sharedContent - ? ('shared' as const) - : ('none' as const) - } - } - return readIssueCommand(repo.path) - } - ) - - ipcMain.handle( - 'hooks:writeIssueCommand', - async (_event, args: { repoId: string; content: string; hostId?: ExecutionHostId }) => { - const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId) - if (!repo || isFolderRepo(repo)) { - return - } - if (repo.connectionId) { - const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command') - const fsProvider = getSshFilesystemProvider(repo.connectionId) - if (!fsProvider) { - throw new Error( - 'Remote filesystem unavailable. Reconnect the SSH target before retrying.' - ) - } - const trimmed = args.content.trim() - if (!trimmed) { - await fsProvider.deletePath(issueCommandPath, false).catch((error: unknown) => { - if (!isENOENT(error)) { - throw error - } - }) - return - } - await fsProvider.createDir(joinWorktreeRelativePath(repo.path, '.orca')) - const gitignorePath = joinWorktreeRelativePath(repo.path, '.gitignore') - try { - const result = await fsProvider.readFile(gitignorePath) - if (!result.isBinary && !/^\.orca\/?$/m.test(result.content)) { - const separator = result.content.endsWith('\n') ? '' : '\n' - await fsProvider.writeFile(gitignorePath, `${result.content}${separator}.orca\n`) - } - } catch (error) { - if (!isENOENT(error)) { - throw error - } - await fsProvider.writeFile(gitignorePath, '.orca\n') - } - await fsProvider.writeFile(issueCommandPath, `${trimmed}\n`) - return - } - writeIssueCommand(repo.path, args.content) - } - ) + registerWorktreeCatalogHandlers(context) + registerHostCatalogHandlers(context) + registerDetectedWorktreeHandlers(context) + registerWorktreePrefetchHandler(context) + registerWorktreeCreateHandlers(context) + registerReviewBaseHandlers(context) + registerWorktreeRemovalHandlers(context) + registerWorktreeForgetHandlers(context) + registerWorktreeMetadataHandlers(context) + registerWorktreeHookCheckHandler(context) + registerWorktreeHookRunnerHandler(context) + registerWorktreeHookInspectionHandler(context) + registerWorktreeHookFileHandlers(context) } diff --git a/src/main/ipc/worktrees/create/folder-workspace-creation.ts b/src/main/ipc/worktrees/create/folder-workspace-creation.ts new file mode 100644 index 00000000000..9a1bc65fc02 --- /dev/null +++ b/src/main/ipc/worktrees/create/folder-workspace-creation.ts @@ -0,0 +1,55 @@ +import { randomUUID } from 'node:crypto' +import type { Repo } from '../../../../shared/repo-types' +import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-projection' +import type { CreateWorktreeResult } from '../../../../shared/worktree/create-types' +import type { Store } from '../../../persistence/loading-store/store' +import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas' +import { getFolderWorkspaceInstanceId, mergeFolderWorkspace } from '../folder-workspace-model' + +export function createFolderWorkspace( + args: CreateWorktreeArgsWithSystemProvenance, + repo: Repo, + store: Store +): CreateWorktreeResult { + const now = Date.now() + const instanceId = randomUUID() + const worktreeId = getFolderWorkspaceInstanceId(repo, instanceId) + const meta = store.setWorktreeMeta(worktreeId, { + instanceId, + ...(store.getProjectHostSetups + ? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) + : {}), + displayName: args.displayName || args.name, + lastActivityAt: now, + createdAt: now, + orcaCreatedAt: now, + orcaCreationSource: 'desktop', + creatorProvenance: { kind: 'host' }, + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), + ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), + ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), + ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), + ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), + ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), + ...(args.linkedLinearIssueWorkspaceId !== undefined + ? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId } + : {}), + ...(args.linkedLinearIssueOrganizationUrlKey !== undefined + ? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey } + : {}), + ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), + ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}), + ...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}), + ...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}), + ...(args.linkedBitbucketPR !== undefined ? { linkedBitbucketPR: args.linkedBitbucketPR } : {}), + ...(args.linkedAzureDevOpsPR !== undefined + ? { linkedAzureDevOpsPR: args.linkedAzureDevOpsPR } + : {}), + ...(args.linkedGiteaPR !== undefined ? { linkedGiteaPR: args.linkedGiteaPR } : {}), + ...(args.linkedWorkItem !== undefined ? { linkedWorkItem: args.linkedWorkItem } : {}), + ...(args.linkedTaskSourceContext !== undefined + ? { linkedTaskSourceContext: args.linkedTaskSourceContext } + : {}) + }) + return { worktree: mergeFolderWorkspace(repo, worktreeId, meta) } +} diff --git a/src/main/ipc/worktrees/create/register-review-base-handlers.ts b/src/main/ipc/worktrees/create/register-review-base-handlers.ts new file mode 100644 index 00000000000..6ebe8f26fbd --- /dev/null +++ b/src/main/ipc/worktrees/create/register-review-base-handlers.ts @@ -0,0 +1,120 @@ +import { ipcMain } from 'electron' +import type { GitHubPrStartPoint, GitPushTarget } from '../../../../shared/worktree/types' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { gitExecFileAsync } from '../../../git/runner' +import { + getLocalProjectGitExecOptions, + getLocalProjectWorktreeGitOptions +} from '../../../project-runtime-git-options' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { + fetchPrHeadTrackingRef, + fetchGitHubPullRequestHeadRef +} from '../../../github/pr-head-tracking-ref' +import { resolveGitHubPrStartPoint } from '../../../github/pr-start-point' +import { resolveGitHubReviewHeadRemote } from '../../../github/review-head-remote' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerReviewBaseHandlers(context: WorktreeIpcContext): void { + const { store, runtime } = context + + ipcMain.handle( + 'worktrees:resolvePrBase', + async ( + _event, + args: { + repoId: string + prNumber: number + headRefName?: string + baseRefName?: string + isCrossRepository?: boolean + } + ): Promise => { + const repo = store.getRepo(args.repoId) + if (!repo) { + return { error: 'Repo not found' } + } + if (isFolderRepo(repo)) { + return { error: 'Folder mode does not support creating worktrees.' } + } + const gitExec = async (args: string[]): Promise<{ stdout: string; stderr: string }> => { + if (!repo.connectionId) { + return gitExecFileAsync(args, getLocalProjectGitExecOptions(store, repo)) + } + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + throw new Error( + 'SSH Git provider is not available. Reconnect to this target and try again.' + ) + } + return provider.exec(args, repo.path) + } + // Why: SSH review-head fetches require narrow write-capable RPCs. + const fetchRemoteTrackingRef = (remote: string, branch: string): Promise => + fetchPrHeadTrackingRef( + repo, + repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, + remote, + branch, + { localGitExecOptions: getLocalProjectGitExecOptions(store, repo) } + ) + const fetchPullRequestHeadRef = (remote: string, prNumber: number): Promise => + fetchGitHubPullRequestHeadRef( + repo, + repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, + remote, + prNumber, + { localGitExecOptions: getLocalProjectGitExecOptions(store, repo) } + ) + + return resolveGitHubPrStartPoint({ + repoPath: repo.path, + prNumber: args.prNumber, + headRefName: args.headRefName, + baseRefName: args.baseRefName, + isCrossRepository: args.isCrossRepository, + issueSourcePreference: repo.issueSourcePreference, + connectionId: repo.connectionId ?? null, + localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), + gitExec, + fetchRemoteTrackingRef, + fetchPullRequestHeadRef, + // Why: one resolver keeps source preference and hosting identity aligned + // across local, WSL, and SSH worktree creation. + resolveRemote: () => + resolveGitHubReviewHeadRemote({ + repoPath: repo.path, + issueSourcePreference: repo.issueSourcePreference, + connectionId: repo.connectionId ?? null, + localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), + gitExec + }) + }) + } + ) + + ipcMain.handle( + 'worktrees:resolveMrBase', + async ( + _event, + args: { + repoId: string + mrIid: number + sourceBranch?: string + targetBranch?: string + isCrossRepository?: boolean + } + ): Promise< + | { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } + | { error: string } + > => { + return runtime.resolveManagedMrBase({ + repoSelector: `id:${args.repoId}`, + mrIid: args.mrIid, + sourceBranch: args.sourceBranch, + targetBranch: args.targetBranch, + isCrossRepository: args.isCrossRepository + }) + } + ) +} diff --git a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts new file mode 100644 index 00000000000..775a0859790 --- /dev/null +++ b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts @@ -0,0 +1,156 @@ +import { ipcMain, app } from 'electron' +import type { + CreateWorktreeArgs, + CreateWorktreeResult, + AdoptProvisionedRootArgs +} from '../../../../shared/worktree/create-types' +import { withWorktreeSpan } from '../../../observability/instrumentation' +import { workspaceSourceSchema } from '../../../../shared/telemetry-events' +import type { WorkspaceSource } from '../../../../shared/telemetry-events' +import { + resolveAutomationWorkspaceProvenance, + releaseAutomationWorkspaceProvenanceRequest, + finishAutomationWorkspaceProvenanceRequest +} from '../../../automations/workspace-provenance' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { + createRemoteWorktree, + createLocalWorktree, + notifyWorktreesChanged +} from '../../worktree-remote' +import { track } from '../../../telemetry/client' +import { classifyWorkspaceCreateError } from '../../workspace-create-error-classifier' +import { getCohortAtEmit } from '../../../telemetry/cohort-classifier' +import { adoptProvisionedRootSshCheckout } from '../../../provisioned-root-ssh-adoption' +import { normalizeLinkedWorkItemFields } from '../ipc-context-schemas' +import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas' +import { createFolderWorkspace } from './folder-workspace-creation' +import { findExactRepoOwner, isCapturedRepoCurrent } from '../listing/worktree-host-ownership' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): void { + const { mainWindow, store, runtime, options } = context + + ipcMain.handle( + 'worktrees:create', + 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 () => { + const repo = store.getRepo(args.repoId) + if (!repo) { + throw new Error(`Repo not found: ${args.repoId}`) + } + + const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) + const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' + + const automationProvenance = resolveAutomationWorkspaceProvenance({ + authority: runtime, + repoSelector: args.repoId, + repo, + request: args.automationProvenanceRequest + }) + const createArgs: CreateWorktreeArgsWithSystemProvenance = { + ...args, + automationProvenance + } + + let result: CreateWorktreeResult + try { + // Why: wrap only the helpers; the pre-validation throws above are IPC-shape bugs, not the git/filesystem failures the funnel tracks. + result = isFolderRepo(repo) + ? createFolderWorkspace(createArgs, repo, store) + : repo.connectionId + ? await createRemoteWorktree(createArgs, repo, store, mainWindow) + : await createLocalWorktree(createArgs, repo, store, mainWindow, runtime) + } catch (error) { + releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + track('workspace_create_failed', { + source, + error_class: classifyWorkspaceCreateError(error), + ...getCohortAtEmit() + }) + throw error + } + finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + + // 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', { + source, + from_existing_branch: + !isFolderRepo(repo) && + typeof args.baseBranch === 'string' && + args.baseBranch.length > 0, + ...getCohortAtEmit() + }) + + if (isFolderRepo(repo)) { + notifyWorktreesChanged(mainWindow, repo.id) + } + + options?.onWorktreeLifecycle?.({ + kind: 'created', + worktreeId: result.worktree.id, + path: result.worktree.path, + branch: result.worktree.branch + }) + + return result + }) + } + ) + + ipcMain.handle( + 'worktrees:adoptProvisionedRoot', + async (_event, rawArgs: AdoptProvisionedRootArgs): Promise => { + const args = normalizeLinkedWorkItemFields(rawArgs) + return withWorktreeSpan({ stage: 'create' }, async () => { + const repo = findExactRepoOwner(store, args.repoId, args.executionHostId) + if (!repo || isFolderRepo(repo)) { + throw new Error('Provisioned-root repository ownership is missing or ambiguous.') + } + const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) + const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' + const automationProvenance = resolveAutomationWorkspaceProvenance({ + authority: runtime, + repoSelector: args.repoId, + repo, + request: args.automationProvenanceRequest + }) + let result: CreateWorktreeResult + try { + result = await adoptProvisionedRootSshCheckout({ + userDataPath: app.getPath('userData'), + request: { ...args, automationProvenance }, + repo, + store, + isRepoCurrent: () => isCapturedRepoCurrent(store, repo, args.executionHostId) + }) + } catch (error) { + releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + track('workspace_create_failed', { + source, + error_class: classifyWorkspaceCreateError(error), + ...getCohortAtEmit() + }) + throw error + } + finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) + track('workspace_created', { + source, + from_existing_branch: false, + ...getCohortAtEmit() + }) + notifyWorktreesChanged(mainWindow, repo.id) + options?.onWorktreeLifecycle?.({ + kind: 'created', + worktreeId: result.worktree.id, + path: result.worktree.path, + branch: result.worktree.branch + }) + return result + }) + } + ) +} diff --git a/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts new file mode 100644 index 00000000000..19b0516f274 --- /dev/null +++ b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts @@ -0,0 +1,22 @@ +import { ipcMain } from 'electron' +import { prefetchWorktreeCreateBase } from '../../../worktree-create-base-prefetch' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): void { + const { store, runtime } = context + + ipcMain.handle( + 'worktrees:prefetchCreateBase', + async (_event, args: { repoId: string; baseBranch?: string }): Promise => { + const repo = store.getRepo(args.repoId) + if (!repo) { + return + } + try { + await prefetchWorktreeCreateBase({ repo, baseBranch: args.baseBranch, runtime }) + } catch { + // Why: optimistic warm-up; the real create path awaits the same refresh and reports failures there. + } + } + ) +} diff --git a/src/main/ipc/worktrees/folder-workspace-model.ts b/src/main/ipc/worktrees/folder-workspace-model.ts new file mode 100644 index 00000000000..8f42175fce4 --- /dev/null +++ b/src/main/ipc/worktrees/folder-workspace-model.ts @@ -0,0 +1,75 @@ +import { randomUUID } from 'node:crypto' +import type { Repo } from '../../../shared/repo-types' +import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../shared/workspace-statuses' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../../shared/worktree/id' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import type { Worktree } from '../../../shared/worktree/types' + +export function getFolderWorkspaceRootId(repo: Repo): string { + return `${repo.id}::${repo.path}` +} + +export function getFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { + return `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` +} + +export function getFolderWorkspaceInstanceIdentity(repo: Repo, worktreeId: string): string { + const prefix = `${getFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` + return worktreeId.startsWith(prefix) ? worktreeId.slice(prefix.length) : randomUUID() +} + +export function isFolderWorkspaceIdForRepo(repo: Repo, worktreeId: string): boolean { + const rootId = getFolderWorkspaceRootId(repo) + return ( + worktreeId === rootId || + worktreeId.startsWith(`${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}`) + ) +} + +export function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta): Worktree { + return { + id: worktreeId, + ...(meta.instanceId !== undefined ? { instanceId: meta.instanceId } : {}), + repoId: repo.id, + ...(meta.projectId !== undefined ? { projectId: meta.projectId } : {}), + ...(meta.hostId !== undefined ? { hostId: meta.hostId } : {}), + ...(meta.projectHostSetupId !== undefined + ? { projectHostSetupId: meta.projectHostSetupId } + : {}), + path: repo.path, + head: '', + branch: '', + isBare: false, + isMainWorktree: worktreeId === getFolderWorkspaceRootId(repo), + displayName: meta.displayName || repo.displayName, + comment: meta.comment || '', + linkedIssue: meta.linkedIssue ?? null, + linkedPR: meta.linkedPR ?? null, + linkedLinearIssue: meta.linkedLinearIssue ?? null, + linkedLinearIssueWorkspaceId: meta.linkedLinearIssueWorkspaceId ?? null, + linkedLinearIssueOrganizationUrlKey: meta.linkedLinearIssueOrganizationUrlKey ?? null, + linkedGitLabMR: meta.linkedGitLabMR ?? null, + linkedGitLabIssue: meta.linkedGitLabIssue ?? null, + linkedBitbucketPR: meta.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: meta.linkedAzureDevOpsPR ?? null, + linkedGiteaPR: meta.linkedGiteaPR ?? null, + linkedWorkItem: meta.linkedWorkItem ?? null, + linkedTaskSourceContext: meta.linkedTaskSourceContext ?? null, + isArchived: meta.isArchived ?? false, + isUnread: meta.isUnread ?? false, + isPinned: meta.isPinned ?? false, + sortOrder: meta.sortOrder ?? 0, + ...(meta.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), + lastActivityAt: meta.lastActivityAt ?? 0, + ...(meta.createdAt !== undefined ? { createdAt: meta.createdAt } : {}), + ...(meta.createdWithAgent !== undefined ? { createdWithAgent: meta.createdWithAgent } : {}), + ...(meta.automationProvenance !== undefined + ? { automationProvenance: meta.automationProvenance } + : {}), + ...(meta.cliProvenance !== undefined ? { cliProvenance: meta.cliProvenance } : {}), + ...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}), + workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, + diffComments: meta.diffComments, + mobileDiffReview: meta.mobileDiffReview + } +} diff --git a/src/main/ipc/worktrees/ipc-context-schemas.ts b/src/main/ipc/worktrees/ipc-context-schemas.ts new file mode 100644 index 00000000000..f29f09b39d4 --- /dev/null +++ b/src/main/ipc/worktrees/ipc-context-schemas.ts @@ -0,0 +1,58 @@ +import type { CreateWorktreeArgs } from '../../../shared/worktree/create-types' +import type { + AutomationWorkspaceProvenance, + CliWorkspaceProvenance +} from '../../../shared/worktree/types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { ListDetectedWorktreesArgs } from '../../../shared/detected-worktree-provider-contract' +import { WorkspaceLinkedItemSchema } from '../../../shared/workspace-linked-item-schema' +import { TaskSourceContextSchema } from '../../../shared/task-source-context-schema' +import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' + +export type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { + automationProvenance?: AutomationWorkspaceProvenance + cliProvenance?: CliWorkspaceProvenance +} + +export type RemoveWorktreeArgs = { + worktreeId: string + hostId?: ExecutionHostId + force?: boolean + /** Explicit Force Delete only — `force` alone is set by the ordinary confirmation (#11960). */ + allowUnverifiedPtyStop?: boolean + skipArchive?: boolean + snapshotPruneBatchId?: string +} + +export type DetectedWorktreeRequestArgs = { repoId: string } | ListDetectedWorktreesArgs + +export const NullableWorkspaceLinkedItemSchema = WorkspaceLinkedItemSchema.nullable() +export const NullableTaskSourceContextSchema = TaskSourceContextSchema.nullable() + +export function normalizeLinkedWorkItemFields< + T extends { + linkedWorkItem?: unknown + linkedTaskSourceContext?: unknown + } +>(input: T): T { + const linkedWorkItem = + input.linkedWorkItem === undefined + ? undefined + : NullableWorkspaceLinkedItemSchema.parse(input.linkedWorkItem) + const linkedTaskSourceContext = + input.linkedTaskSourceContext === undefined + ? undefined + : NullableTaskSourceContextSchema.parse(input.linkedTaskSourceContext) + if ( + linkedWorkItem && + linkedTaskSourceContext && + !isWorkspaceLinkedItemSourceContextMatch(linkedWorkItem, linkedTaskSourceContext) + ) { + throw new Error('Linked work item and source context identities must match') + } + return { + ...input, + ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), + ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}) + } +} diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts new file mode 100644 index 00000000000..c3692d5c926 --- /dev/null +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -0,0 +1,163 @@ +import type { Store } from '../../../persistence/loading-store/store' +import type { Repo } from '../../../../shared/repo-types' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import type { DetectedWorktreeListResult, GitWorktreeInfo } from '../../../../shared/worktree/types' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' +import { pruneLineageForMissingRepoWorktrees } from '../../../worktree-lineage-pruning' +import type { DirectSshDetectedWorktreeRequest } from '../../../../shared/detected-worktree-provider-contract' +import { isAdmissibleDirectSshAuthority } from '../../../../shared/ssh-retained-payload-admission' +import type { ListDesktopLineageForHostArgs } from '../../../../shared/host-lineage-contract' +import { + buildDetectedGitWorktrees, + createSshWorktreeMetaIndex, + listDisconnectedSshWorktrees +} from './ssh-worktree-fallback' +import { + buildDisconnectedDetectedWorktrees, + buildFolderDetectedWorktrees +} from './folder-workspace-catalog' +import { isFolderWorkspaceIdForRepo } from '../folder-workspace-model' +import { hasConflictingStoredWorktreeOwner } from './worktree-host-ownership' +import { + listDetectedGitWorktrees, + rememberLocalWorktreeRoots +} from './detected-worktree-scan-cache' +import { loggedWorktreeListFailures, warnOnce } from './worktree-listing-diagnostics' + +export async function listDetectedWorktreesForCapturedRepo( + store: Store, + repo: Repo, + isCurrent: () => boolean, + capturedProvider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined, + providerAbort?: { signal: AbortSignal; status: () => 'canceled' | 'timed-out' } +): Promise { + const abortedResult = () => + providerAbort?.signal.aborted + ? ({ providerAbortStatus: providerAbort.status() } as const) + : undefined + const sshWorktreeMetaIndex = repo.connectionId + ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) + : new Map() + + try { + let gitWorktrees: GitWorktreeInfo[] + let freshScan = true + if (isFolderRepo(repo)) { + if (!isCurrent()) { + return null + } + const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) + if (hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds)) { + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + return { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees: projectResolvedWorktreeLineage( + buildFolderDetectedWorktrees(store, repo), + store.getAllWorktreeLineage?.() ?? {} + ) + } + } + if (repo.connectionId) { + if (!capturedProvider) { + const aborted = abortedResult() + if (aborted) { + return aborted + } + if (!isCurrent()) { + return null + } + const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) + } + } + gitWorktrees = await capturedProvider.listWorktrees(repo.path, { + signal: providerAbort?.signal + }) + } else { + const scan = await listDetectedGitWorktrees(store, repo) + gitWorktrees = scan.gitWorktrees + freshScan = scan.fresh + } + const aborted = abortedResult() + if (aborted) { + return aborted + } + if (!isCurrent()) { + return null + } + const listedWorktreeIds = gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`) + if (hasConflictingStoredWorktreeOwner(store, repo, listedWorktreeIds)) { + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + if (freshScan) { + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) + } + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) + return { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees: buildDetectedGitWorktrees(store, repo, gitWorktrees) + } + } catch (err) { + const aborted = abortedResult() + if (aborted) { + return aborted + } + if (!isCurrent()) { + return null + } + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list detected worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + if (repo.connectionId) { + const worktrees = listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + return { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) + } + } + return { repoId: repo.id, authoritative: false, source: 'metadata-fallback', worktrees: [] } + } +} + +export function hasValidDirectSshAuthority( + args: DirectSshDetectedWorktreeRequest +): args is DirectSshDetectedWorktreeRequest { + return isAdmissibleDirectSshAuthority(args.expectedAuthority) +} + +export function hasValidLineageSshAuthority( + args: ListDesktopLineageForHostArgs +): args is Extract { + if (!('expectedAuthority' in args)) { + return false + } + return isAdmissibleDirectSshAuthority(args.expectedAuthority) +} diff --git a/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts new file mode 100644 index 00000000000..55b3d75e3ed --- /dev/null +++ b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts @@ -0,0 +1,133 @@ +import type { GitWorktreeInfo } from '../../../../shared/worktree/types' +import type { Store } from '../../../persistence/loading-store/store' +import type { Repo } from '../../../../shared/repo-types' +import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { listRepoWorktrees } from '../../../repo-worktrees' +import { registerWorktreeRootsForRepo } from '../../registered-worktree-roots-cache' + +// Why: absorb renderer polling bursts while bounding external worktree-change lag to one short refresh window. +export const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000 + +export type DetectedWorktreeScanCacheEntry = { + expiresAt: number + worktrees: GitWorktreeInfo[] +} + +export type DetectedWorktreeScan = { + invalidated: boolean + promise: Promise +} + +export type DetectedWorktreeScanResult = { + gitWorktrees: GitWorktreeInfo[] + fresh: boolean +} + +export const detectedWorktreeScanCache = new Map() +export const detectedWorktreeScanInFlight = new Map() + +export function invalidateDetectedWorktreeScanCache(repoId: string): void { + const keyPrefix = `${repoId}\0` + for (const key of new Set([ + ...detectedWorktreeScanCache.keys(), + ...detectedWorktreeScanInFlight.keys() + ])) { + if (!key.startsWith(keyPrefix)) { + continue + } + detectedWorktreeScanCache.delete(key) + const inFlight = detectedWorktreeScanInFlight.get(key) + if (inFlight) { + // Why: the detached scan keeps this token so later scans settle without making an older result fresh again. + inFlight.invalidated = true + detectedWorktreeScanInFlight.delete(key) + } + } +} + +export function __resetDetectedWorktreeScanCacheForTests(): void { + // Why: pending scans across a test reset must not repopulate the cache and leak state into the next test. + for (const scan of detectedWorktreeScanInFlight.values()) { + scan.invalidated = true + } + detectedWorktreeScanCache.clear() + detectedWorktreeScanInFlight.clear() +} + +export function __getDetectedWorktreeScanCacheStatsForTests(): { + cacheSize: number + inFlightSize: number +} { + return { + cacheSize: detectedWorktreeScanCache.size, + inFlightSize: detectedWorktreeScanInFlight.size + } +} + +export async function listDetectedGitWorktrees( + store: Store, + repo: Repo +): Promise { + const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + if (repo.connectionId || isFolderRepo(repo)) { + return { + gitWorktrees: await listRepoWorktrees(repo, localWorktreeGitOptions), + fresh: true + } + } + + const cacheKey = getDetectedWorktreeScanCacheKey(repo.id, localWorktreeGitOptions) + const cached = detectedWorktreeScanCache.get(cacheKey) + if (cached && cached.expiresAt > Date.now()) { + return { gitWorktrees: cached.worktrees, fresh: false } + } + + const inFlight = detectedWorktreeScanInFlight.get(cacheKey) + if (inFlight) { + return { gitWorktrees: await inFlight.promise, fresh: false } + } + + const scan: DetectedWorktreeScan = { + invalidated: false, + promise: listRepoWorktrees(repo, localWorktreeGitOptions) + } + detectedWorktreeScanInFlight.set(cacheKey, scan) + try { + const gitWorktrees = await scan.promise + // Why: a create/remove notification can invalidate mid-scan; don't let that stale scan repopulate the cache afterward. + if (!scan.invalidated) { + detectedWorktreeScanCache.set(cacheKey, { + worktrees: gitWorktrees, + expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS + }) + } + return { gitWorktrees, fresh: !scan.invalidated } + } finally { + if (detectedWorktreeScanInFlight.get(cacheKey) === scan) { + detectedWorktreeScanInFlight.delete(cacheKey) + } + } +} + +export function getDetectedWorktreeScanCacheKey( + repoId: string, + localWorktreeGitOptions: { wslDistro?: string } = {} +): string { + return `${repoId}\0${localWorktreeGitOptions.wslDistro ?? 'host'}` +} + +export function rememberLocalWorktreeRoots( + store: Store, + repo: Repo, + gitWorktrees: GitWorktreeInfo[] +): void { + if (repo.connectionId) { + return + } + // Why: reuse the `git worktree list` result so later git/file IPC validation skips a second scan that can trigger macOS folder-permission prompts. + registerWorktreeRootsForRepo(store, repo.id, [ + repo.path, + ...gitWorktrees.map((worktree) => worktree.path) + ]) +} diff --git a/src/main/ipc/worktrees/listing/folder-workspace-catalog.ts b/src/main/ipc/worktrees/listing/folder-workspace-catalog.ts new file mode 100644 index 00000000000..abaa594bf6e --- /dev/null +++ b/src/main/ipc/worktrees/listing/folder-workspace-catalog.ts @@ -0,0 +1,118 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree, DetectedWorktree } from '../../../../shared/worktree/types' +import type { Store } from '../../../persistence/loading-store/store' +import { + createWorktreeVisibilitySourceMatcher, + resolveCustomWorktreeVisibilitySources +} from '../../../../shared/worktree/visibility-sources' +import { resolveConfiguredWorktreeBasePaths } from '../../../../shared/worktree/configured-worktree-base-path' +import { + toDetectedWorktree, + applyMetadataFallbackVisibility +} from '../../../../shared/worktree/ownership' +import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' +import { getProjectHostSetupMetaUpdates } from './worktree-discovery-metadata' +import { + getFolderWorkspaceInstanceIdentity, + getFolderWorkspaceRootId, + isFolderWorkspaceIdForRepo, + mergeFolderWorkspace +} from '../folder-workspace-model' + +export function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] { + const rootId = getFolderWorkspaceRootId(repo) + const allMeta = store.getAllWorktreeMeta() + const ids = Object.keys(allMeta).filter((worktreeId) => + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) + if (!ids.includes(rootId)) { + ids.unshift(rootId) + } + + return ids + .map((worktreeId) => { + const existing = allMeta[worktreeId] + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing) + const meta = + existing?.instanceId && Object.keys(ownershipUpdates).length === 0 + ? existing + : store.setWorktreeMeta(worktreeId, { + instanceId: + existing?.instanceId ?? getFolderWorkspaceInstanceIdentity(repo, worktreeId), + ...ownershipUpdates, + ...(existing ? {} : { displayName: repo.displayName, lastActivityAt: Date.now() }) + }) + return mergeFolderWorkspace(repo, worktreeId, meta) + }) + .sort((a, b) => { + if (a.id === rootId) { + return -1 + } + if (b.id === rootId) { + return 1 + } + return (b.createdAt ?? b.lastActivityAt) - (a.createdAt ?? a.lastActivityAt) + }) +} + +export function buildFolderDetectedWorktrees(store: Store, repo: Repo): DetectedWorktree[] { + const settings = store.getSettings() + const worktrees = listFolderWorkspaces(store, repo) + const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( + [repo.path, ...worktrees.map((worktree) => worktree.path)], + resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), + resolveConfiguredWorktreeBasePaths(repo) + ) + return worktrees.map((worktree) => + toDetectedWorktree({ + repo, + worktree, + meta: store.getWorktreeMeta(worktree.id), + settings, + knownOrcaLayouts: [], + isLegacyRepoForVisibility: true, + worktreeVisibilitySourceMatcher + }) + ) +} + +export function listVisibleFolderWorkspaces(store: Store, repo: Repo): Worktree[] { + return buildFolderDetectedWorktrees(store, repo) + .filter((worktree) => worktree.visible) + .map((worktree) => { + const meta = store.getWorktreeMeta(worktree.id) + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, meta) + const repairedMeta = + meta && Object.keys(ownershipUpdates).length === 0 + ? meta + : store.setWorktreeMeta(worktree.id, ownershipUpdates) + return mergeFolderWorkspace(repo, worktree.id, repairedMeta) + }) +} + +export function buildDisconnectedDetectedWorktrees( + store: Store, + repo: Repo, + worktrees: Worktree[] +): DetectedWorktree[] { + const settings = store.getSettings() + const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( + [repo.path, ...worktrees.map((worktree) => worktree.path)], + resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), + resolveConfiguredWorktreeBasePaths(repo) + ) + const detected = worktrees.map((worktree) => { + const meta = store.getWorktreeMeta(worktree.id) + const detected = toDetectedWorktree({ + repo, + worktree, + meta, + settings, + knownOrcaLayouts: [], + isLegacyRepoForVisibility: true, + worktreeVisibilitySourceMatcher + }) + return applyMetadataFallbackVisibility(detected) + }) + return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) +} diff --git a/src/main/ipc/worktrees/listing/host-qualified-worktree-listing.ts b/src/main/ipc/worktrees/listing/host-qualified-worktree-listing.ts new file mode 100644 index 00000000000..3026a1b0652 --- /dev/null +++ b/src/main/ipc/worktrees/listing/host-qualified-worktree-listing.ts @@ -0,0 +1,133 @@ +import type { Store } from '../../../persistence/loading-store/store' +import { PROVIDER_REQUEST_ID_MAX_UTF8_BYTES } from '../../../../shared/detected-worktree-provider-contract' +import type { + ListDetectedWorktreesArgs, + HostQualifiedDetectedWorktreeResult, + DirectSshDetectedWorktreeRequest +} from '../../../../shared/detected-worktree-provider-contract' +import { parseExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { isCurrentSshProviderAuthority } from '../../../ssh/ssh-provider-authority' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { + hasValidDirectSshAuthority, + listDetectedWorktreesForCapturedRepo +} from './detected-provider-listing' +import { + findExactRepoOwner, + isCapturedRepoCurrent, + resolveRepoOwnershipEvidence +} from './worktree-host-ownership' + +export async function listHostQualifiedDetectedWorktrees( + store: Store, + args: ListDetectedWorktreesArgs, + providerAbort?: { signal: AbortSignal; status: () => 'canceled' | 'timed-out' } +): Promise { + const parsedHost = parseExecutionHostId(args.executionHostId) + const rejected = (status: 'rejected' | 'stale' | 'ambiguous-owner') => ({ + providerRequestId: args.providerRequestId, + executionHostId: args.executionHostId, + status + }) + if ( + typeof args.providerRequestId !== 'string' || + args.providerRequestId.length === 0 || + Buffer.byteLength(args.providerRequestId, 'utf8') > PROVIDER_REQUEST_ID_MAX_UTF8_BYTES || + !parsedHost || + parsedHost.kind === 'runtime' + ) { + return rejected('rejected') + } + let capturedAuthority: DirectSshDetectedWorktreeRequest['expectedAuthority'] | null = null + if (parsedHost.kind === 'ssh') { + const directArgs = args as DirectSshDetectedWorktreeRequest + if ( + !hasValidDirectSshAuthority(directArgs) || + directArgs.expectedAuthority.targetId !== parsedHost.targetId + ) { + return rejected('rejected') + } + capturedAuthority = { ...directArgs.expectedAuthority } + if (!isCurrentSshProviderAuthority(capturedAuthority)) { + return rejected('stale') + } + } + + const repoCandidates = store.getRepos().filter((candidate) => candidate.id === args.repoId) + if ( + repoCandidates.some((candidate) => resolveRepoOwnershipEvidence(candidate).status !== 'owned') + ) { + return rejected('rejected') + } + const repo = findExactRepoOwner(store, args.repoId, args.executionHostId) + if (!repo) { + return rejected('ambiguous-owner') + } + if ( + (parsedHost.kind === 'local' && repo.connectionId) || + (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) + ) { + return rejected('rejected') + } + const provider = parsedHost.kind === 'ssh' ? getSshGitProvider(parsedHost.targetId) : undefined + const isCurrent = (): boolean => { + if (!isCapturedRepoCurrent(store, repo, args.executionHostId)) { + return false + } + if ( + (parsedHost.kind === 'local' && repo.connectionId) || + (parsedHost.kind === 'ssh' && repo.connectionId !== parsedHost.targetId) + ) { + return false + } + if (parsedHost.kind !== 'ssh') { + return true + } + return ( + capturedAuthority !== null && + getSshGitProvider(parsedHost.targetId) === provider && + isCurrentSshProviderAuthority(capturedAuthority) + ) + } + const result = await listDetectedWorktreesForCapturedRepo( + store, + repo, + isCurrent, + provider, + providerAbort + ) + if (!result) { + return rejected('stale') + } + if ('providerAbortStatus' in result) { + return { + providerRequestId: args.providerRequestId, + executionHostId: args.executionHostId, + status: result.providerAbortStatus + } + } + const status = result.authoritative ? 'complete' : 'non-authoritative' + if (parsedHost.kind === 'local') { + return { + status, + providerRequestId: args.providerRequestId, + repoId: repo.id, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + result + } + } + if (!capturedAuthority) { + return rejected('rejected') + } + return { + status, + providerRequestId: args.providerRequestId, + repoId: repo.id, + authority: { + kind: 'direct-ssh', + executionHostId: args.executionHostId as `ssh:${string}`, + ...capturedAuthority + }, + result + } +} diff --git a/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts new file mode 100644 index 00000000000..66e8e85a4a5 --- /dev/null +++ b/src/main/ipc/worktrees/listing/register-detected-worktree-handlers.ts @@ -0,0 +1,134 @@ +import { ipcMain } from 'electron' +import type { DetectedWorktreeListResult } from '../../../../shared/worktree/types' +import type { + HostQualifiedDetectedWorktreeResult, + DirectSshDetectedWorktreeRequest, + ProviderRequestId +} from '../../../../shared/detected-worktree-provider-contract' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { + registerSshProviderRequestAbort, + getSshProviderAuthority, + isCurrentSshProviderAuthority +} from '../../../ssh/ssh-provider-authority' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import type { DetectedWorktreeRequestArgs } from '../ipc-context-schemas' +import { + hasValidDirectSshAuthority, + listDetectedWorktreesForCapturedRepo +} from './detected-provider-listing' +import { listHostQualifiedDetectedWorktrees } from './host-qualified-worktree-listing' +import { findExactRepoOwner, isCapturedRepoCurrent } from './worktree-host-ownership' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export const DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS = 30_000 + +export function registerDetectedWorktreeHandlers(context: WorktreeIpcContext): void { + const { store, detectedWorktreeCancellations } = context + + ipcMain.handle( + 'worktrees:listDetected', + async ( + event, + args: DetectedWorktreeRequestArgs + ): Promise => { + if ('executionHostId' in args) { + const parsedHost = parseExecutionHostId(args.executionHostId) + const directSshRequest = parsedHost?.kind === 'ssh' + const controller = directSshRequest + ? detectedWorktreeCancellations.begin(event, args.providerRequestId) + : null + const directArgs = args as DirectSshDetectedWorktreeRequest + const removeAuthorityAbort = + controller && + parsedHost?.kind === 'ssh' && + hasValidDirectSshAuthority(directArgs) && + directArgs.expectedAuthority.targetId === parsedHost.targetId + ? registerSshProviderRequestAbort(directArgs.expectedAuthority, controller) + : undefined + let timedOut = false + let removeAbortListener: (() => void) | undefined + const abortedResult = controller + ? new Promise((resolve) => { + const onAbort = (): void => { + resolve({ + providerRequestId: args.providerRequestId, + executionHostId: args.executionHostId, + status: timedOut ? 'timed-out' : 'canceled' + }) + } + controller.signal.addEventListener('abort', onAbort, { once: true }) + removeAbortListener = () => controller.signal.removeEventListener('abort', onAbort) + }) + : undefined + const timeout = controller + ? setTimeout(() => { + timedOut = true + controller.abort() + }, DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS) + : undefined + try { + const providerResult = listHostQualifiedDetectedWorktrees( + store, + args, + controller + ? { + signal: controller.signal, + status: () => (timedOut ? 'timed-out' : 'canceled') + } + : undefined + ) + return abortedResult + ? await Promise.race([providerResult, abortedResult]) + : await providerResult + } finally { + if (timeout) { + clearTimeout(timeout) + } + removeAbortListener?.() + removeAuthorityAbort?.() + detectedWorktreeCancellations.finish(event, args.providerRequestId, controller) + } + } + const repo = findExactRepoOwner(store, args.repoId) + if (!repo) { + return { + repoId: args.repoId, + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : undefined + const authority = repo.connectionId + ? { ...getSshProviderAuthority(repo.connectionId) } + : undefined + const result = await listDetectedWorktreesForCapturedRepo( + store, + repo, + () => + isCapturedRepoCurrent(store, repo) && + (!repo.connectionId || + (getSshGitProvider(repo.connectionId) === provider && + authority !== undefined && + isCurrentSshProviderAuthority(authority))), + provider + ) + return result && !('providerAbortStatus' in result) + ? result + : { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + ) + + ipcMain.handle( + 'worktrees:cancelListDetected', + (event, args: { providerRequestId: ProviderRequestId }): void => { + detectedWorktreeCancellations.cancel(event, args.providerRequestId) + } + ) +} diff --git a/src/main/ipc/worktrees/listing/register-detected-worktree-scan-invalidation.ts b/src/main/ipc/worktrees/listing/register-detected-worktree-scan-invalidation.ts new file mode 100644 index 00000000000..222ae1bf999 --- /dev/null +++ b/src/main/ipc/worktrees/listing/register-detected-worktree-scan-invalidation.ts @@ -0,0 +1,6 @@ +import { registerWorktreeChangeInvalidator } from '../../worktree-change-invalidators' +import { invalidateDetectedWorktreeScanCache } from './detected-worktree-scan-cache' + +export function registerDetectedWorktreeScanInvalidation(): void { + registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache) +} diff --git a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts new file mode 100644 index 00000000000..50ce6082a42 --- /dev/null +++ b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts @@ -0,0 +1,140 @@ +import { ipcMain } from 'electron' +import type { + ListKnownWorktreesForExecutionHostArgs, + HostQualifiedKnownWorktreeResult, + ForgetRemovedWorktreesForExecutionHostArgs, + ForgetRemovedWorktreesForExecutionHostResult +} from '../../../../shared/detected-worktree-provider-contract' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { DetectedWorktree } from '../../../../shared/worktree/types' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { pruneWorkspaceCleanupScanSnapshots } from '../../../workspace-cleanup-scan-snapshot' +import { pruneWorkspaceSpaceAnalysisSnapshots } from '../../../workspace-space-analysis-snapshot' +import { findExactRepoOwner, hasConflictingStoredWorktreeOwner } from './worktree-host-ownership' +import { + buildDisconnectedDetectedWorktrees, + buildFolderDetectedWorktrees +} from './folder-workspace-catalog' +import { isFolderWorkspaceIdForRepo } from '../folder-workspace-model' +import { + createSshWorktreeMetaIndexForRepo, + listDisconnectedSshWorktrees +} from './ssh-worktree-fallback' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerHostCatalogHandlers(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle( + 'worktrees:listKnownForExecutionHost', + (_event, args: ListKnownWorktreesForExecutionHostArgs): HostQualifiedKnownWorktreeResult => { + // Why: a malformed invoke must fail closed as `rejected`, not throw out of the handler. `ssh:` is inert — + // it owns no repo, so every guard below still rejects it. + const requestedRepoId = args?.repoId ?? '' + const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' + const rejected = (): HostQualifiedKnownWorktreeResult => ({ + status: 'rejected', + repoId: requestedRepoId, + executionHostId: requestedExecutionHostId + }) + const parsedHost = parseExecutionHostId(requestedExecutionHostId) + if (parsedHost?.kind !== 'ssh') { + return rejected() + } + // Why: findExactRepoOwner repeats this same all-candidates-owned check, and getRepos() re-hydrates the + // whole catalog, so a separate pass here is pure cost. + const repo = findExactRepoOwner(store, requestedRepoId, requestedExecutionHostId) + if (!repo || repo.connectionId !== parsedHost.targetId) { + return rejected() + } + const complete = (worktrees: DetectedWorktree[]): HostQualifiedKnownWorktreeResult => ({ + status: 'complete', + repoId: repo.id, + executionHostId: requestedExecutionHostId, + result: { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees + } + }) + // Why: folder workspace ids carry an instance suffix the git-worktree synthesizer would read as a directory; build them the way every other listing does. + if (isFolderRepo(repo)) { + const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) + return hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds) + ? rejected() + : complete( + // Why: match the authoritative folder listing; without lineage these rows render flat and then + // reshuffle once the real scan lands. + projectResolvedWorktreeLineage( + buildFolderDetectedWorktrees(store, repo), + store.getAllWorktreeLineage?.() ?? {} + ) + ) + } + const metaIndex = createSshWorktreeMetaIndexForRepo(store.getAllWorktreeMeta(), repo.id) + return complete( + buildDisconnectedDetectedWorktrees( + store, + repo, + listDisconnectedSshWorktrees(store, repo, metaIndex) + ) + ) + } + ) + + ipcMain.handle( + 'worktrees:forgetRemovedForExecutionHost', + ( + _event, + args: ForgetRemovedWorktreesForExecutionHostArgs + ): ForgetRemovedWorktreesForExecutionHostResult => { + const nothingForgotten: ForgetRemovedWorktreesForExecutionHostResult = { + forgottenWorktreeIds: [] + } + const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' + const worktreeIds = Array.isArray(args?.worktreeIds) ? args.worktreeIds : [] + const parsedHost = parseExecutionHostId(requestedExecutionHostId) + if (parsedHost?.kind !== 'ssh' || worktreeIds.length === 0) { + return nothingForgotten + } + const repo = findExactRepoOwner(store, args?.repoId ?? '', requestedExecutionHostId) + if (!repo || repo.connectionId !== parsedHost.targetId) { + return nothingForgotten + } + // Why: a folder workspace's meta IS the workspace record, not a checkout row — gcStaleWorktreeMeta skips + // those keys for the same reason, and no remote scan can retire one. + if (isFolderRepo(repo)) { + return nothingForgotten + } + const allMeta = store.getAllWorktreeMeta() + const forgottenWorktreeIds: string[] = [] + for (const worktreeId of worktreeIds) { + const meta = typeof worktreeId === 'string' ? allMeta[worktreeId] : undefined + if (!meta || getRepoIdFromWorktreeId(worktreeId) !== repo.id) { + continue + } + // An unhosted meta belongs to this repo's only owner; a foreign hostId needs that host's own scan. + if (meta.hostId && meta.hostId !== requestedExecutionHostId) { + continue + } + store.removeWorktreeMeta(worktreeId, requestedExecutionHostId) + forgottenWorktreeIds.push(worktreeId) + } + if (forgottenWorktreeIds.length > 0) { + const snapshotDirectory = store.getProfileStorageDirectory() + const targets = forgottenWorktreeIds.map((worktreeId) => ({ + worktreeId, + executionHostId: requestedExecutionHostId + })) + void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets) + void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets) + } + return { forgottenWorktreeIds } + } + ) +} diff --git a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts new file mode 100644 index 00000000000..64c28c08495 --- /dev/null +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -0,0 +1,181 @@ +import { ipcMain } from 'electron' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { pruneLineageForMissingRepoWorktrees } from '../../../worktree-lineage-pruning' +import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry' +import { getRetiredNameRegistryForRepo } from '../../../worktree-name-retirement' +import { + buildDetectedGitWorktrees, + createSshWorktreeMetaIndex, + listDisconnectedSshWorktrees, + stampAndMergeVisibleDetectedWorktree +} from './ssh-worktree-fallback' +import { listVisibleFolderWorkspaces } from './folder-workspace-catalog' +import { + listDetectedGitWorktrees, + rememberLocalWorktreeRoots +} from './detected-worktree-scan-cache' +import { + loggedUnavailableSshGitProviders, + loggedWorktreeListFailures, + warnOnce +} from './worktree-listing-diagnostics' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +const WORKTREE_LIST_ALL_CONCURRENCY = 8 + +async function mapWithConcurrency( + items: readonly T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const results: R[] = [] + let nextIndex = 0 + const workerCount = Math.min(limit, items.length) + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await fn(items[index]) + } + }) + ) + return results +} + +export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): void { + const { store } = context + + ipcMain.handle('worktrees:listAll', async () => { + const repos = store.getRepos() + const sshWorktreeMetaIndex = repos.some((repo) => repo.connectionId) + ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) + : new Map() + + // Why: each local repo listing can spawn `git worktree list`; cap fan-out so large fleets don't start unbounded subprocesses. + const results = await mapWithConcurrency(repos, WORKTREE_LIST_ALL_CONCURRENCY, async (repo) => { + try { + let gitWorktrees + let freshScan = true + if (isFolderRepo(repo)) { + return listVisibleFolderWorkspaces(store, repo) + } else if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + warnOnce( + loggedUnavailableSshGitProviders, + `${repo.connectionId}:${repo.id}`, + `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` + ) + return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + } + loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) + try { + gitWorktrees = await provider.listWorktrees(repo.path) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + } + } else { + const scan = await listDetectedGitWorktrees(store, repo) + gitWorktrees = scan.gitWorktrees + freshScan = scan.fresh + } + if (freshScan) { + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) + } + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) + return buildDetectedGitWorktrees(store, repo, gitWorktrees) + .filter((worktree) => worktree.visible) + .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree)) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + // Why: do NOT seed empty success — it flags the repo registered, blocking access to legit linked worktrees until the cache is invalidated. + return [] + } + }) + + return results.flat() + }) + + ipcMain.handle('worktrees:listRetiredNames', async (_event, args: { repoId: string }) => { + const repo = store.getRepo(args.repoId) + if (!repo) { + return EMPTY_RETIRED_NAME_REGISTRY + } + return getRetiredNameRegistryForRepo(store, repo, store.getRepos(), store.getSettings()) + }) + + ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => { + const repo = store.getRepo(args.repoId) + if (!repo) { + return [] + } + const sshWorktreeMetaIndex = repo.connectionId + ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) + : new Map() + + try { + let gitWorktrees + let freshScan = true + if (isFolderRepo(repo)) { + return listVisibleFolderWorkspaces(store, repo) + } else if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + warnOnce( + loggedUnavailableSshGitProviders, + `${repo.connectionId}:${repo.id}`, + `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` + ) + return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + } + loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) + try { + gitWorktrees = await provider.listWorktrees(repo.path) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + return listDisconnectedSshWorktrees(store, repo, sshWorktreeMetaIndex) + } + } else { + const scan = await listDetectedGitWorktrees(store, repo) + gitWorktrees = scan.gitWorktrees + freshScan = scan.fresh + } + if (freshScan) { + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) + } + loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) + return buildDetectedGitWorktrees(store, repo, gitWorktrees) + .filter((worktree) => worktree.visible) + .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree)) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + // Why: see worktrees:listAll catch — seeding an empty-success result would poison the auth cache and block linked worktrees. + return [] + } + }) +} diff --git a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts new file mode 100644 index 00000000000..26fb14f2341 --- /dev/null +++ b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts @@ -0,0 +1,186 @@ +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import { parseWorktreeId, areWorktreePathsEqual, mergeWorktree } from '../../worktree-logic' +import { + getRepoIdFromWorktreeId, + getWorktreePathBasenameFromId +} from '../../../../shared/worktree/id' +import type { Repo } from '../../../../shared/repo-types' +import type { GitWorktreeInfo, DetectedWorktree, Worktree } from '../../../../shared/worktree/types' +import type { Store } from '../../../persistence/loading-store/store' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { + buildKnownOrcaWorkspaceLayouts, + isLegacyRepoForExternalWorktreeVisibility, + toDetectedWorktree +} from '../../../../shared/worktree/ownership' +import { dedupeWorktreesByPath } from '../../worktree-path-comparison' +import { + createWorktreeVisibilitySourceMatcher, + resolveCustomWorktreeVisibilitySources +} from '../../../../shared/worktree/visibility-sources' +import { resolveConfiguredWorktreeBasePaths } from '../../../../shared/worktree/configured-worktree-base-path' +import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' +import { loggedMalformedWorktreeMetaKeys, warnOnce } from './worktree-listing-diagnostics' +import { + getProjectHostSetupMetaUpdates, + resolveWorktreeMetaWithDiscoveryBackfill +} from './worktree-discovery-metadata' + +export type SshWorktreeMetaCandidate = { + id: string + path: string + meta: WorktreeMeta +} + +export type SshWorktreeMetaIndex = Map + +export function createSshWorktreeMetaIndex( + entries: [string, WorktreeMeta][] +): SshWorktreeMetaIndex { + const index: SshWorktreeMetaIndex = new Map() + for (const [worktreeId, meta] of entries) { + let parsed: { repoId: string; worktreePath: string } + try { + parsed = parseWorktreeId(worktreeId) + } catch (err) { + warnOnce( + loggedMalformedWorktreeMetaKeys, + worktreeId, + `[worktrees] ignoring malformed persisted worktree metadata key "${worktreeId}"`, + err + ) + continue + } + + const candidates = index.get(parsed.repoId) ?? [] + candidates.push({ id: worktreeId, path: parsed.worktreePath, meta }) + index.set(parsed.repoId, candidates) + } + return index +} + +// Why: scopes parseWorktreeId to one repo's keys. The entry list itself is still materialized for the whole +// store, so this is cheaper per call than the unfiltered index, not free. +export function createSshWorktreeMetaIndexForRepo( + allMeta: Record, + repoId: string +): SshWorktreeMetaIndex { + return createSshWorktreeMetaIndex( + Object.entries(allMeta).filter(([worktreeId]) => getRepoIdFromWorktreeId(worktreeId) === repoId) + ) +} + +export function synthesizeSshGitWorktree( + repo: Repo, + path: string, + meta: WorktreeMeta +): GitWorktreeInfo { + return { + path, + head: '', + branch: '', + isBare: false, + isMainWorktree: areWorktreePathsEqual(path, repo.path), + ...(meta.sparseDirectories !== undefined || + meta.sparseBaseRef !== undefined || + meta.sparsePresetId !== undefined + ? { isSparse: true } + : {}) + } +} + +export function listDisconnectedSshWorktrees( + store: Store, + repo: Repo, + metaIndex: SshWorktreeMetaIndex +): Worktree[] { + const byWorktreeId = new Map() + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwners = store.getRepos().filter((candidate) => candidate.id === repo.id) + for (const candidate of metaIndex.get(repo.id) ?? []) { + if ( + (candidate.meta.hostId && candidate.meta.hostId !== expectedHostId) || + (!candidate.meta.hostId && repoOwners.length > 1) + ) { + continue + } + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, candidate.meta) + const meta = + Object.keys(ownershipUpdates).length > 0 + ? { ...candidate.meta, ...ownershipUpdates } + : candidate.meta + if (Object.keys(ownershipUpdates).length > 0) { + store.setWorktreeMeta(candidate.id, ownershipUpdates) + } + // Why: synthesized rows carry no branch, so the title would fall through to the DESKTOP's basename() + // applied to a REMOTE path — a Windows remote then renders its whole C:\... path as the name. Rows must + // stay per-directory (repo.displayName would title every row identically), so use the separator-agnostic + // basename instead. + const worktree = mergeWorktree( + repo.id, + synthesizeSshGitWorktree(repo, candidate.path, meta), + meta, + getWorktreePathBasenameFromId(candidate.id) ?? undefined + ) + byWorktreeId.delete(worktree.id) + byWorktreeId.set(worktree.id, worktree) + } + return [...byWorktreeId.values()] +} + +export function buildDetectedGitWorktrees( + store: Store, + repo: Repo, + gitWorktrees: GitWorktreeInfo[] +): DetectedWorktree[] { + const settings = store.getSettings() + const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo) + const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) + // Why: a prunable registration has no working directory (issue #8389); only this listing omits it — cleanup flows list separately. + const liveWorktrees = dedupeWorktreesByPath( + gitWorktrees.filter((gitWorktree) => !gitWorktree.prunable) + ) + const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( + [repo.path, ...liveWorktrees.map((worktree) => worktree.path)], + resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults), + resolveConfiguredWorktreeBasePaths(repo) + ) + const detected = liveWorktrees.map((gitWorktree) => { + const worktreeId = `${repo.id}::${gitWorktree.path}` + let meta = store.getWorktreeMeta(worktreeId) + const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + const detected = toDetectedWorktree({ + repo, + worktree, + meta, + settings, + knownOrcaLayouts, + isLegacyRepoForVisibility, + worktreeVisibilitySourceMatcher + }) + if (!detected.visible) { + return detected + } + + meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, worktreeId) + return toDetectedWorktree({ + repo, + worktree: mergeWorktree(repo.id, gitWorktree, meta, repo.displayName), + meta, + settings, + knownOrcaLayouts, + isLegacyRepoForVisibility, + worktreeVisibilitySourceMatcher + }) + }) + return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) +} + +export function stampAndMergeVisibleDetectedWorktree( + store: Store, + repo: Repo, + detected: DetectedWorktree +) { + const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, detected.id) + return mergeWorktree(repo.id, detected, meta, repo.displayName) +} diff --git a/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts b/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts new file mode 100644 index 00000000000..c96e8dc2978 --- /dev/null +++ b/src/main/ipc/worktrees/listing/worktree-discovery-metadata.ts @@ -0,0 +1,51 @@ +import type { Store } from '../../../persistence/loading-store/store' +import type { Repo } from '../../../../shared/repo-types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import { getProjectHostSetupWorktreeMeta } from '../../../../shared/project-host-setup-projection' +import { randomUUID } from 'node:crypto' + +export function getProjectHostSetupMetaUpdates( + store: Store, + repo: Repo, + existing?: WorktreeMeta +): Partial> { + const ownership = getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo) + const sameSetup = + existing?.projectHostSetupId === undefined || + existing.projectHostSetupId === ownership.projectHostSetupId + return { + // Why: project IDs can upgrade from legacy repo IDs to provider-backed ones; repair ownership on discovery when the host setup matches. + ...(sameSetup && existing?.projectId !== ownership.projectId + ? { projectId: ownership.projectId } + : {}), + ...(sameSetup && existing?.hostId === undefined ? { hostId: ownership.hostId } : {}), + ...(existing?.projectHostSetupId === undefined + ? { projectHostSetupId: ownership.projectHostSetupId } + : {}) + } +} + +// Why: disk-discovered worktrees have no WorktreeMeta, so lastActivityAt=0 sinks them to the bottom of "Recent"; also backfill host-setup ownership here. +export function resolveWorktreeMetaWithDiscoveryBackfill( + store: Store, + repo: Repo, + worktreeId: string +): WorktreeMeta { + const existing = store.getWorktreeMeta(worktreeId) + const ownershipUpdates = getProjectHostSetupMetaUpdates(store, repo, existing) + if (existing) { + const updates = { + ...(!existing.instanceId ? { instanceId: randomUUID() } : {}), + ...ownershipUpdates + } + if (Object.keys(updates).length > 0) { + // Why: pre-lineage profiles already have WorktreeMeta rows; backfill on discovery so upgraded workspaces get lineage and host routing. + return store.setWorktreeMeta(worktreeId, updates) + } + return existing + } + return store.setWorktreeMeta(worktreeId, { + lastActivityAt: Date.now(), + ...ownershipUpdates + }) +} diff --git a/src/main/ipc/worktrees/listing/worktree-host-ownership.ts b/src/main/ipc/worktrees/listing/worktree-host-ownership.ts new file mode 100644 index 00000000000..1f9eee701d8 --- /dev/null +++ b/src/main/ipc/worktrees/listing/worktree-host-ownership.ts @@ -0,0 +1,82 @@ +import type { Store } from '../../../persistence/loading-store/store' +import type { Repo } from '../../../../shared/repo-types' +import { + getRepoExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId, + LOCAL_EXECUTION_HOST_ID +} from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' + +export function hasConflictingStoredWorktreeOwner( + store: Store, + repo: Repo, + worktreeIds: readonly string[] +): boolean { + const expectedHostId = getRepoExecutionHostId(repo) + const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length + return worktreeIds.some((worktreeId) => { + const meta = store.getWorktreeMeta(worktreeId) + return !!meta && (meta.hostId ? meta.hostId !== expectedHostId : repoOwnerCount > 1) + }) +} + +export type RepoOwnershipEvidence = + | { status: 'owned'; hostId: ExecutionHostId } + | { status: 'malformed' } + | { status: 'contradictory' } + +export function resolveRepoOwnershipEvidence(repo: Repo): RepoOwnershipEvidence { + const hasExplicitHost = repo.executionHostId !== null && repo.executionHostId !== undefined + const explicitHost = hasExplicitHost ? parseExecutionHostId(repo.executionHostId) : null + if (hasExplicitHost && !explicitHost) { + return { status: 'malformed' } + } + const hasConnection = repo.connectionId !== null && repo.connectionId !== undefined + const connectionId = hasConnection ? repo.connectionId?.trim() : null + if (hasConnection && !connectionId) { + return { status: 'malformed' } + } + const connectionHostId = connectionId ? toSshExecutionHostId(connectionId) : null + if (explicitHost && connectionHostId && explicitHost.id !== connectionHostId) { + return { status: 'contradictory' } + } + return { + status: 'owned', + hostId: explicitHost?.id ?? connectionHostId ?? LOCAL_EXECUTION_HOST_ID + } +} + +export function findExactRepoOwner( + store: Store, + repoId: string, + executionHostId?: ExecutionHostId +): Repo | undefined { + const candidates = store.getRepos().filter((repo) => repo.id === repoId) + const evidence = candidates.map(resolveRepoOwnershipEvidence) + if (evidence.some((owner) => owner.status !== 'owned')) { + return undefined + } + const matches = candidates.filter((_, index) => { + const owner = evidence[index] + return ( + owner?.status === 'owned' && + (executionHostId === undefined || owner.hostId === executionHostId) + ) + }) + return matches.length === 1 ? matches[0] : undefined +} + +export function isCapturedRepoCurrent( + store: Store, + repo: Repo, + executionHostId?: ExecutionHostId +): boolean { + const current = findExactRepoOwner(store, repo.id, executionHostId) + return ( + current !== undefined && + current.path === repo.path && + (current.connectionId ?? null) === (repo.connectionId ?? null) && + (current.executionHostId ?? null) === (repo.executionHostId ?? null) + ) +} diff --git a/src/main/ipc/worktrees/listing/worktree-listing-diagnostics.ts b/src/main/ipc/worktrees/listing/worktree-listing-diagnostics.ts new file mode 100644 index 00000000000..128bb46dd28 --- /dev/null +++ b/src/main/ipc/worktrees/listing/worktree-listing-diagnostics.ts @@ -0,0 +1,16 @@ +import { shouldEmitBoundedWarning } from '../../bounded-warning-dedupe' + +export const loggedUnavailableSshGitProviders = new Set() +export const loggedWorktreeListFailures = new Set() +export const loggedMalformedWorktreeMetaKeys = new Set() + +export function warnOnce(keySet: Set, key: string, message: string, error?: unknown): void { + if (!shouldEmitBoundedWarning(keySet, key)) { + return + } + if (error) { + console.warn(message, error) + } else { + console.warn(message) + } +} diff --git a/src/main/ipc/worktrees/metadata/host-lineage-listing.ts b/src/main/ipc/worktrees/metadata/host-lineage-listing.ts new file mode 100644 index 00000000000..a2ec052fe68 --- /dev/null +++ b/src/main/ipc/worktrees/metadata/host-lineage-listing.ts @@ -0,0 +1,110 @@ +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import type { Store } from '../../../persistence/loading-store/store' +import type { + ListDesktopLineageForHostArgs, + HostLineageSnapshot +} from '../../../../shared/host-lineage-contract' +import { parseExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' +import type { SshGitProvider } from '../../../providers/ssh-git-provider' +import { isCurrentSshProviderAuthority } from '../../../ssh/ssh-provider-authority' +import { hasValidLineageSshAuthority } from '../listing/detected-provider-listing' +import { filterLineageForHost } from './workspace-lineage-filtering' + +export const LINEAGE_HYDRATION_TIMEOUT_MS = 5_000 + +export async function hydrateLineageWithinDeadline(runtime: OrcaRuntimeService): Promise { + let timeout: ReturnType | undefined + const hydration = Promise.resolve() + .then(() => runtime.hydrateInferredWorktreeLineage()) + .then( + () => true, + () => false + ) + const deadline = new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), LINEAGE_HYDRATION_TIMEOUT_MS) + }) + try { + return await Promise.race([hydration, deadline]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +export async function listDesktopLineageForHost( + store: Store, + runtime: OrcaRuntimeService, + args: ListDesktopLineageForHostArgs +): Promise { + const parsedHost = parseExecutionHostId(args?.executionHostId) + const rejected = ( + reason: Extract['reason'] + ): HostLineageSnapshot => ({ + authoritative: false, + executionHostId: args.executionHostId, + reason + }) + if (!parsedHost || parsedHost.kind === 'runtime') { + return rejected('rejected') + } + let provider: SshGitProvider | undefined + let authority: + | Extract['expectedAuthority'] + | null = null + if (parsedHost.kind === 'local') { + if ('expectedAuthority' in args) { + return rejected('rejected') + } + } else { + if ( + !hasValidLineageSshAuthority(args) || + args.expectedAuthority.targetId !== parsedHost.targetId + ) { + return rejected('rejected') + } + authority = { ...args.expectedAuthority } + if (!isCurrentSshProviderAuthority(authority)) { + return rejected('stale') + } + provider = getSshGitProvider(parsedHost.targetId) + if (!provider) { + return rejected('unavailable') + } + } + if (!(await hydrateLineageWithinDeadline(runtime))) { + return rejected('unavailable') + } + if ( + parsedHost.kind === 'ssh' && + (!authority || + getSshGitProvider(parsedHost.targetId) !== provider || + !isCurrentSshProviderAuthority(authority)) + ) { + return rejected('stale') + } + const lineage = filterLineageForHost(store, parsedHost.id) + if (!lineage) { + return rejected('ambiguous-owner') + } + if (parsedHost.kind === 'local') { + return { + authoritative: true, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + ...lineage + } + } + if (!authority) { + return rejected('authority-unknown') + } + return { + authoritative: true, + authority: { + kind: 'direct-ssh', + executionHostId: parsedHost.id, + ...authority + }, + ...lineage + } +} diff --git a/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts b/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts new file mode 100644 index 00000000000..a8a58bf81d1 --- /dev/null +++ b/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts @@ -0,0 +1,123 @@ +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { Store } from '../../../persistence/loading-store/store' +import type { Repo } from '../../../../shared/repo-types' +import { parseWorktreeId } from '../../worktree-logic' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import { resolveRepoOwnershipEvidence } from '../listing/worktree-host-ownership' + +export type LineageOwner = + | { status: 'owned'; hostId: ExecutionHostId } + | { status: 'ambiguous' | 'contradictory' | 'runtime' } + +export type LineageFolder = FolderWorkspace +export type LineageGroup = ProjectGroup + +export type LineageResolutionContext = { + store: Store + repos: Repo[] + groups: LineageGroup[] + reposById: Map + foldersById: Map + groupsById: Map + groupSubtreeIdsByRoot: Map> + worktreeOwners: Map + folderOwners: Map + workspaceOwners: Map +} + +export function indexLineageEntriesById( + entries: readonly T[] +): Map { + const index = new Map() + for (const entry of entries) { + const matching = index.get(entry.id) ?? [] + matching.push(entry) + index.set(entry.id, matching) + } + return index +} + +export function createLineageResolutionContext(store: Store): LineageResolutionContext { + const repos = store.getRepos() + const folders = store.getFolderWorkspaces() + const groups = store.getProjectGroups() + return { + store, + repos, + groups, + reposById: indexLineageEntriesById(repos), + foldersById: indexLineageEntriesById(folders), + groupsById: indexLineageEntriesById(groups), + groupSubtreeIdsByRoot: new Map(), + worktreeOwners: new Map(), + folderOwners: new Map(), + workspaceOwners: new Map() + } +} + +export function resolveRepoLineageOwner(repo: Repo): LineageOwner { + const owner = resolveRepoOwnershipEvidence(repo) + if (owner.status === 'malformed') { + return { status: 'ambiguous' } + } + if (owner.status === 'contradictory') { + return { status: 'contradictory' } + } + return parseExecutionHostId(owner.hostId)?.kind === 'runtime' ? { status: 'runtime' } : owner +} + +export function resolveWorktreeLineageOwner( + context: LineageResolutionContext, + worktreeId: string +): LineageOwner { + const cached = context.worktreeOwners.get(worktreeId) + if (cached) { + return cached + } + const remember = (owner: LineageOwner): LineageOwner => { + context.worktreeOwners.set(worktreeId, owner) + return owner + } + let repoId: string + try { + repoId = parseWorktreeId(worktreeId).repoId + } catch { + return remember({ status: 'ambiguous' }) + } + const repos = context.reposById.get(repoId) ?? [] + const meta = context.store.getWorktreeMeta(worktreeId) + const runtimeOwnerEnvironmentId = ( + meta as (WorktreeMeta & { runtimeOwnerEnvironmentId?: string }) | undefined + )?.runtimeOwnerEnvironmentId?.trim() + if (runtimeOwnerEnvironmentId) { + return remember({ status: 'runtime' }) + } + if (meta?.hostId) { + const explicitHost = parseExecutionHostId(meta.hostId) + if (!explicitHost) { + return remember({ status: 'ambiguous' }) + } + if (explicitHost.kind === 'runtime') { + return remember({ status: 'runtime' }) + } + const matchingRepos = repos.filter((repo) => { + const owner = resolveRepoLineageOwner(repo) + return owner.status === 'owned' && owner.hostId === explicitHost.id + }) + if (matchingRepos.length === 1) { + return remember({ status: 'owned', hostId: explicitHost.id }) + } + return remember( + matchingRepos.length > 1 + ? { status: 'ambiguous' } + : { status: repos.length > 0 ? 'contradictory' : 'ambiguous' } + ) + } + if (repos.length !== 1) { + return remember({ status: 'ambiguous' }) + } + return remember(resolveRepoLineageOwner(repos[0])) +} diff --git a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts new file mode 100644 index 00000000000..97d175a70df --- /dev/null +++ b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts @@ -0,0 +1,95 @@ +import { ipcMain } from 'electron' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import { stripOrcaProvenanceMetaUpdates } from '../../../worktree-removal-safety' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import type { + ListDesktopLineageForHostArgs, + HostLineageSnapshot +} from '../../../../shared/host-lineage-contract' +import { notifyWorktreesChanged } from '../../worktree-remote' +import { parseWorktreeId } from '../../worktree-logic' +import { planWorktreeSortOrderUpdates } from '../../../../shared/worktree/sort-order-update' +import { readBranchRenameFailureOutputForDisplay } from '../../../agent-hooks/branch-rename-failure-output' +import { normalizeLinkedWorkItemFields } from '../ipc-context-schemas' +import { listDesktopLineageForHost } from './host-lineage-listing' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): void { + const { mainWindow, store, runtime } = context + + ipcMain.handle( + 'worktrees:updateMeta', + (_event, args: { worktreeId: string; updates: Partial }) => { + const validatedUpdates = normalizeLinkedWorkItemFields(args.updates) + const updates = + validatedUpdates.displayName !== undefined + ? { + ...validatedUpdates, + pendingFirstAgentMessageRename: false, + firstAgentMessageRenameError: null + } + : validatedUpdates + const meta = store.setWorktreeMeta(args.worktreeId, stripOrcaProvenanceMetaUpdates(updates)) + // Do NOT notify here: renderer already applied this optimistically; a notification would re-sort the sidebar (bug PR #209). + if (args.updates.displayName !== undefined) { + // Why: remote clients have no optimistic rename and stopped polling titles, so push a remote-only invalidation; gate on displayName so per-click isUnread updates stay event-free. + runtime.notifyWorktreesChangedForRemoteClients(getRepoIdFromWorktreeId(args.worktreeId)) + } + return meta + } + ) + + ipcMain.handle('worktrees:listLineage', async () => { + await runtime.hydrateInferredWorktreeLineage() + return { + lineage: store.getAllWorktreeLineage(), + workspaceLineage: store.getAllWorkspaceLineage() + } + }) + + ipcMain.handle( + 'worktrees:listLineageForHost', + (_event, args: ListDesktopLineageForHostArgs): Promise => + listDesktopLineageForHost(store, runtime, args) + ) + + ipcMain.handle( + 'worktrees:updateLineage', + async (_event, args: { worktreeId: string; parentWorktreeId?: string; noParent?: boolean }) => { + await runtime.updateManagedWorktreeMeta(args.worktreeId, { + lineage: + args.noParent === true + ? { noParent: true } + : args.parentWorktreeId + ? { parentWorktree: `id:${args.parentWorktreeId}` } + : undefined + }) + notifyWorktreesChanged(mainWindow, parseWorktreeId(args.worktreeId).repoId) + return store.getWorktreeLineage(args.worktreeId) ?? null + } + ) + + ipcMain.handle('worktrees:persistSortOrder', (_event, args: { orderedIds: string[] }) => { + if (!Array.isArray(args?.orderedIds) || args.orderedIds.length === 0) { + return + } + const updates = planWorktreeSortOrderUpdates( + args.orderedIds, + (worktreeId) => store.getWorktreeMeta(worktreeId), + Date.now() + ) + for (const update of updates) { + store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder }) + } + }) + + ipcMain.handle( + 'worktrees:getBranchRenameFailureOutput', + (_event, args: { worktreeId: string }) => { + if (typeof args?.worktreeId !== 'string' || args.worktreeId.length === 0) { + return null + } + return readBranchRenameFailureOutputForDisplay(args.worktreeId) + } + ) +} diff --git a/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts b/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts new file mode 100644 index 00000000000..3106812418c --- /dev/null +++ b/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts @@ -0,0 +1,184 @@ +import type { Repo } from '../../../../shared/repo-types' +import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' +import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' +import { parseExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import type { Store } from '../../../persistence/loading-store/store' +import type { WorktreeLineage, WorkspaceLineage } from '../../../../shared/worktree/lineage-types' +import { + createLineageResolutionContext, + resolveRepoLineageOwner, + resolveWorktreeLineageOwner +} from './lineage-owner-resolution' +import type { + LineageFolder, + LineageOwner, + LineageResolutionContext +} from './lineage-owner-resolution' + +export function getFolderLineageCandidateRepos( + context: LineageResolutionContext, + folder: LineageFolder +): Repo[] { + let groupIds = context.groupSubtreeIdsByRoot.get(folder.projectGroupId) + if (!groupIds) { + groupIds = getProjectGroupSubtreeIds(context.groups, folder.projectGroupId) + context.groupSubtreeIdsByRoot.set(folder.projectGroupId, groupIds) + } + const grouped = context.repos.filter( + (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) + ) + const pathRepos = context.repos.filter( + (repo) => + !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && + isPathInsideOrEqual(folder.folderPath, repo.path) + ) + const group = context.groupsById.get(folder.projectGroupId)?.[0] + const connectionId = folder.connectionId ?? group?.connectionId ?? null + return connectionId + ? [...grouped, ...pathRepos.filter((repo) => (repo.connectionId ?? null) === connectionId)] + : grouped.length > 0 + ? [ + ...grouped, + ...pathRepos.filter((repo) => + new Set(grouped.map((candidate) => candidate.connectionId ?? null)).has( + repo.connectionId ?? null + ) + ) + ] + : pathRepos +} + +export function resolveFolderLineageOwner( + context: LineageResolutionContext, + folderWorkspaceId: string +): LineageOwner { + const cached = context.folderOwners.get(folderWorkspaceId) + if (cached) { + return cached + } + const remember = (owner: LineageOwner): LineageOwner => { + context.folderOwners.set(folderWorkspaceId, owner) + return owner + } + const folders = context.foldersById.get(folderWorkspaceId) ?? [] + if (folders.length !== 1) { + return remember({ status: 'ambiguous' }) + } + const folder = folders[0] + const groups = context.groupsById.get(folder.projectGroupId) ?? [] + if (groups.length !== 1) { + return remember({ status: 'ambiguous' }) + } + const group = groups[0] + const hosts = new Set() + if (folder.connectionId) { + hosts.add(`ssh:${encodeURIComponent(folder.connectionId)}`) + } + if (group.connectionId) { + hosts.add(`ssh:${encodeURIComponent(group.connectionId)}`) + } + if (group.executionHostId) { + const parsed = parseExecutionHostId(group.executionHostId) + if (!parsed) { + return remember({ status: 'ambiguous' }) + } + hosts.add(parsed.id) + } + for (const repo of getFolderLineageCandidateRepos(context, folder)) { + const owner = resolveRepoLineageOwner(repo) + if (owner.status !== 'owned') { + return remember(owner) + } + hosts.add(owner.hostId) + } + if (hosts.size > 1) { + return remember({ status: 'contradictory' }) + } + const hostId = [...hosts][0] ?? LOCAL_EXECUTION_HOST_ID + return remember( + parseExecutionHostId(hostId)?.kind === 'runtime' + ? { status: 'runtime' } + : { status: 'owned', hostId } + ) +} + +export function resolveWorkspaceLineageOwner( + context: LineageResolutionContext, + workspaceKey: string +): LineageOwner { + const cached = context.workspaceOwners.get(workspaceKey) + if (cached) { + return cached + } + const workspace = parseWorkspaceKey(workspaceKey) + const owner = !workspace + ? { status: 'ambiguous' as const } + : workspace.type === 'worktree' + ? resolveWorktreeLineageOwner(context, workspace.worktreeId) + : resolveFolderLineageOwner(context, workspace.folderWorkspaceId) + context.workspaceOwners.set(workspaceKey, owner) + return owner +} + +export function filterLineageForHost( + store: Store, + executionHostId: ExecutionHostId +): { + worktreeLineageById: Record + workspaceLineageByChildKey: Record +} | null { + const context = createLineageResolutionContext(store) + const worktreeLineageById: Record = {} + const workspaceLineageByChildKey: Record = {} + for (const [worktreeId, lineage] of Object.entries(store.getAllWorktreeLineage())) { + const child = resolveWorktreeLineageOwner(context, worktreeId) + const parent = resolveWorktreeLineageOwner(context, lineage.parentWorktreeId) + if (child.status === 'ambiguous' || child.status === 'contradictory') { + return null + } + if (parent.status === 'ambiguous' || parent.status === 'contradictory') { + return null + } + if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId === executionHostId && + parent.hostId === executionHostId + ) { + worktreeLineageById[worktreeId] = structuredClone(lineage) + } else if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId !== parent.hostId + ) { + return null + } + } + for (const [childKey, lineage] of Object.entries(store.getAllWorkspaceLineage())) { + const child = resolveWorkspaceLineageOwner(context, childKey) + const parent = resolveWorkspaceLineageOwner(context, lineage.parentWorkspaceKey) + if (child.status === 'ambiguous' || child.status === 'contradictory') { + return null + } + if (parent.status === 'ambiguous' || parent.status === 'contradictory') { + return null + } + if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId === executionHostId && + parent.hostId === executionHostId + ) { + workspaceLineageByChildKey[childKey] = structuredClone(lineage) + } else if ( + child.status === 'owned' && + parent.status === 'owned' && + child.hostId !== parent.hostId + ) { + return null + } + } + return { worktreeLineageById, workspaceLineageByChildKey } +} diff --git a/src/main/ipc/worktrees/removal/execute-worktree-removal.ts b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts new file mode 100644 index 00000000000..6f0d91ffef5 --- /dev/null +++ b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts @@ -0,0 +1,181 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { assertWorktreeUnlockedForRemoval } from '../../../../shared/worktree/removal' +import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' +import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' +import { listWorktreesStrict as listGitWorktreesStrict } from '../../../git/worktree' +import { requireSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { resolveWorktreeRemovalMetadata } from '../../../worktree-removal-repo-owner' +import { findRegisteredDeletableWorktree } from '../../../worktree-removal-safety' +import { removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval } from '../../../local-worktree-removal-recovery' +import { runHook } from '../../../hooks' +import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' +import { + cleanupUnusedWorktreePushTargetRemote, + notifyWorktreesChanged +} from '../../worktree-remote' +import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache' +import { formatWorktreeRemovalError } from '../../worktree-logic' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { getArchiveHooksForRemoval, runRemoteArchiveHook } from './worktree-archive-hook' +import { isAlreadyRemovedWorktreePath } from './worktree-removal-filesystem' +import { rememberPreservedBranchCleanupTarget } from './preserved-branch-cleanup' +import { removeWorktreeMetadataAndTransientState } from './worktree-removal-ownership' +import { removeFolderWorkspace } from './remove-folder-workspace' +import { removeUnregisteredWorktree } from './remove-unregistered-worktree' +import { removeRegisteredRemoteWorktree } from './remove-registered-remote-worktree' +import { removeRegisteredLocalWorktree } from './remove-registered-local-worktree' + +export async function executeWorktreeRemoval( + context: WorktreeIpcContext, + args: RemoveWorktreeArgs, + repo: Repo, + repoId: string, + worktreePath: string, + removalHostId: ExecutionHostId +): Promise { + const { mainWindow, store, runtime } = context + if (isFolderRepo(repo)) { + return removeFolderWorkspace(context, args, repo, repoId, removalHostId) + } + const provider = repo.connectionId ? requireSshGitProvider(repo.connectionId) : null + const localWorktreeGitOptions = repo.connectionId + ? {} + : getLocalProjectWorktreeGitOptions(store, repo) + const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 + const registeredWorktrees = repo.connectionId + ? await provider!.listWorktrees(repo.path) + : hasLocalWorktreeGitOptions + ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) + : await listGitWorktreesStrict(repo.path) + const removedMeta = resolveWorktreeRemovalMetadata(store, repoId, args.worktreeId, removalHostId) + const removedPushTarget = removedMeta?.pushTarget + const registeredWorktree = findRegisteredDeletableWorktree( + repo.path, + worktreePath, + registeredWorktrees + ) + if (!registeredWorktree) { + return removeUnregisteredWorktree( + context, + args, + repo, + repoId, + worktreePath, + removalHostId, + registeredWorktrees, + removedMeta, + removedPushTarget, + localWorktreeGitOptions, + provider + ) + } + const canonicalWorktreePath = registeredWorktree.path + + const deleteBranch = removedMeta?.preserveBranchOnDelete !== true + + try { + assertWorktreeUnlockedForRemoval(registeredWorktree) + } catch (error) { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false)) + } + + if ( + !repo.connectionId && + args.force === true && + process.platform === 'win32' && + (isWindowsAbsolutePathLike(canonicalWorktreePath) || !!localWorktreeGitOptions.wslDistro) && + removedMeta && + (await isAlreadyRemovedWorktreePath(repo, canonicalWorktreePath, localWorktreeGitOptions)) + ) { + const removalResult = await removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval({ + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree, + deleteBranch + }) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + rememberPreservedBranchCleanupTarget( + args.worktreeId, + removalHostId, + removalResult, + registeredWorktree.head, + removedPushTarget + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + invalidateAuthorizedRootsCache() + notifyWorktreesChanged(mainWindow, repoId) + return removalResult ?? {} + } + + const hooks = await getArchiveHooksForRemoval(repo) + + const archiveScript = hooks?.scripts.archive + + if (archiveScript && !args.skipArchive) { + // Why the branch on connectionId: this block is shared by both flows, so a hardcoded + // 'remote' would file every local archive hook under the SSH breakdown. + await withWorktreeRemoveStageSpan( + 'archive_hook', + repo.connectionId ? 'remote' : 'local', + async () => { + const result = repo.connectionId + ? await runRemoteArchiveHook(repo, canonicalWorktreePath, archiveScript) + : await runHook( + 'archive', + canonicalWorktreePath, + repo, + undefined, + localWorktreeGitOptions + ) + if (!result.success) { + console.error(`[hooks] archive hook failed for ${canonicalWorktreePath}:`, result.output) + } + } + ) + } + + const remoteConnectionId = repo.connectionId ?? undefined + if (remoteConnectionId) { + return removeRegisteredRemoteWorktree( + context, + args, + repo, + repoId, + canonicalWorktreePath, + removalHostId, + registeredWorktree, + removedPushTarget, + provider!, + deleteBranch + ) + } + return removeRegisteredLocalWorktree( + context, + args, + repo, + repoId, + canonicalWorktreePath, + removalHostId, + removedPushTarget, + localWorktreeGitOptions, + hasLocalWorktreeGitOptions, + deleteBranch + ) +} diff --git a/src/main/ipc/worktrees/removal/preserved-branch-cleanup.ts b/src/main/ipc/worktrees/removal/preserved-branch-cleanup.ts new file mode 100644 index 00000000000..948e997ccd0 --- /dev/null +++ b/src/main/ipc/worktrees/removal/preserved-branch-cleanup.ts @@ -0,0 +1,80 @@ +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' + +export type PreservedBranchCleanupTarget = { + worktreeId: string + hostId: ExecutionHostId + branchName: string + head: string + pushTarget?: GitPushTarget +} + +export const preservedBranchCleanupByScope = new Map() + +export function rememberPreservedBranchCleanupTarget( + worktreeId: string, + hostId: ExecutionHostId, + result: RemoveWorktreeResult | undefined, + fallbackHead: string | undefined, + pushTarget: GitPushTarget | undefined +): void { + if (result?.preservedBranch) { + const head = result.preservedBranch.head ?? fallbackHead + if (!head) { + throw new Error( + `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` + ) + } + preservedBranchCleanupByScope.set(preservedBranchCleanupScopeKey({ worktreeId, hostId }), { + worktreeId, + hostId, + branchName: result.preservedBranch.branchName, + head, + ...(pushTarget ? { pushTarget } : {}) + }) + return + } + preservedBranchCleanupByScope.delete(preservedBranchCleanupScopeKey({ worktreeId, hostId })) +} + +export function preserveBranchHeadFallback( + result: RemoveWorktreeResult | undefined, + fallbackHead: string | undefined +): RemoveWorktreeResult { + if (!result?.preservedBranch || result.preservedBranch.head || !fallbackHead) { + return result ?? {} + } + return { + ...result, + preservedBranch: { + ...result.preservedBranch, + head: fallbackHead + } + } +} + +export function getPreservedBranchCleanupTarget( + worktreeId: string, + branchName: string, + expectedHead: string, + hostId?: ExecutionHostId +): PreservedBranchCleanupTarget { + const exactTarget = hostId + ? preservedBranchCleanupByScope.get(preservedBranchCleanupScopeKey({ worktreeId, hostId })) + : undefined + const legacyMatches = hostId + ? [] + : [...preservedBranchCleanupByScope.values()].filter( + (target) => + target.worktreeId === worktreeId && + target.branchName === branchName && + target.head === expectedHead + ) + const target = exactTarget ?? (legacyMatches.length === 1 ? legacyMatches[0] : undefined) + if (!target || target.branchName !== branchName || target.head !== expectedHead) { + throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) + } + return target +} diff --git a/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts new file mode 100644 index 00000000000..393ccc1a77b --- /dev/null +++ b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts @@ -0,0 +1,221 @@ +import { ipcMain } from 'electron' +import type { + RemoveWorktreeResult, + ForceDeleteWorktreeBranchResult +} from '../../../../shared/worktree/create-types' +import { parseWorktreeId } from '../../worktree-logic' +import { resolveWorktreeRemovalRepoOwner } from '../../../worktree-removal-repo-owner' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { getSshPtyProvider, getLocalPtyProvider, clearProviderPtyState } from '../../pty' +import { killAllProcessesForWorktree } from '../../../runtime/worktree-teardown' +import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache' +import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' +import { + notifyWorktreesChanged, + cleanupUnusedWorktreePushTargetRemoteSsh, + cleanupUnusedWorktreePushTargetRemote +} from '../../worktree-remote' +import { requireSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' +import { forceDeleteLocalBranch } from '../../../git/worktree' +import { gitExecFileAsync } from '../../../git/runner' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import { getWorktreeRemovalInFlightKey } from './worktree-removal-coordinator' +import { getFolderWorkspaceRootId } from '../folder-workspace-model' +import { + removeWorktreeMetadataAndTransientState, + resolveWorktreeRemovalOwnerHostId +} from './worktree-removal-ownership' +import { resolveRepoForExecutionHost } from '../repo-host-ownership' +import { + getPreservedBranchCleanupTarget, + preservedBranchCleanupByScope +} from './preserved-branch-cleanup' +import type { WorktreeIpcContext } from '../worktree-ipc-context' + +export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): void { + const { mainWindow, store, runtime, worktreeRemovalsInFlight } = context + + ipcMain.handle( + 'worktrees:forgetLocal', + async ( + _event, + args: Pick + ): Promise => { + const { repoId } = parseWorktreeId(args.worktreeId) + const repoOwner = resolveWorktreeRemovalRepoOwner(store, repoId, args.hostId) + if (!args.hostId && repoOwner.kind === 'ambiguous') { + throw new Error( + `Workspace identity is ambiguous across hosts: ${args.worktreeId}. Retry with an explicit host.` + ) + } + const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined + // Repo-first (unlike owner resolution below) so this key matches worktrees:remove's; meta only covers ownerless forgets. + const inFlightKey = getWorktreeRemovalInFlightKey( + args.worktreeId, + repo + ? getRepoExecutionHostId(repo) + : (args.hostId ?? store.getWorktreeMeta(args.worktreeId)?.hostId) + ) + const optionsKey = 'forget-local' + const inFlight = worktreeRemovalsInFlight.get(inFlightKey) + if (inFlight) { + if (inFlight.optionsKey === optionsKey) { + return inFlight.promise + } + throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`) + } + + const forget = (async (): Promise => { + const isFolderRootOf = (candidate: Repo): boolean => + isFolderRepo(candidate) && args.worktreeId === getFolderWorkspaceRootId(candidate) + const fallbackRepos = args.hostId + ? store + .getRepos() + .filter((candidate) => getRepoExecutionHostId(candidate) === args.hostId) + : store.getRepos() + if (repo ? isFolderRootOf(repo) : fallbackRepos.some(isFolderRootOf)) { + throw new Error( + 'Cannot delete the project root workspace. Remove the folder project instead.' + ) + } + + const ownerHostId = resolveWorktreeRemovalOwnerHostId( + store, + args.worktreeId, + repo, + args.hostId + ) + const ownerHost = parseExecutionHostId(ownerHostId) + const sshPtyProvider = + ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined + const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' + // External host inventories must never sweep a same-id local workspace. + await killAllProcessesForWorktree(args.worktreeId, { + runtime, + resolvedWorktreeId: args.worktreeId, + ...(ownerHost?.kind === 'ssh' ? { resolvedConnectionId: ownerHost.targetId } : {}), + ...(ownerHost?.kind === 'runtime' + ? { resolvedRuntimeEnvironmentId: ownerHost.environmentId } + : {}), + localProvider: sshPtyProvider ?? getLocalPtyProvider(), + onPtyStopped: clearProviderPtyState, + ...(externalHost + ? { + includeProviderInventory: ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider), + includeLocalRegistry: false + } + : {}) + }).catch((err) => { + console.warn(`[worktree-teardown] forget-local failed for ${args.worktreeId}:`, err) + }) + + runtime.clearOptimisticReconcileToken(args.worktreeId) + // The resolved owner, not args.hostId: an orphan forget with no hostId still has to purge its SSH/runtime partition. + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + ownerHost?.id, + args.snapshotPruneBatchId + ) + // Why: cached roots outlive the forgotten workspace, so an ownerless path stays filesystem-authorized until a rebuild. + invalidateAuthorizedRootsCache() + if (ownerHost?.id) { + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: ownerHost.id }) + ) + } else { + for (const [key, target] of preservedBranchCleanupByScope) { + if (target.worktreeId === args.worktreeId) { + preservedBranchCleanupByScope.delete(key) + } + } + } + notifyWorktreesChanged(mainWindow, repoId) + return {} + })() + worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: forget }) + try { + return await forget + } finally { + if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === forget) { + worktreeRemovalsInFlight.delete(inFlightKey) + } + } + } + ) + + ipcMain.handle( + 'worktrees:forceDeletePreservedBranch', + async ( + _event, + args: { + worktreeId: string + branchName: string + expectedHead: string + hostId?: ExecutionHostId + } + ): Promise => { + const { repoId } = parseWorktreeId(args.worktreeId) + const cleanupTarget = getPreservedBranchCleanupTarget( + args.worktreeId, + args.branchName, + args.expectedHead, + args.hostId + ) + const repo = resolveRepoForExecutionHost(store, repoId, cleanupTarget.hostId) + if (!repo) { + throw new Error(`Repo not found: ${repoId}`) + } + if (isFolderRepo(repo)) { + throw new Error('Folder workspaces do not have local Git branches.') + } + + if (repo.connectionId) { + const provider = requireSshGitProvider(repo.connectionId) + // Why: SSH needs the write-capable relay RPC; the read-only git.exec allowlist rejects these worktree/update-ref/config writes. + await provider.forceDeletePreservedBranch( + repo.path, + cleanupTarget.branchName, + cleanupTarget.head + ) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider, + repo.path, + args.worktreeId, + cleanupTarget.pushTarget, + store + ) + } else { + const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 + await (hasLocalWorktreeGitOptions + ? forceDeleteLocalBranch( + repo.path, + cleanupTarget.branchName, + cleanupTarget.head, + (argv, cwd) => gitExecFileAsync(argv, { cwd, ...localWorktreeGitOptions }) + ) + : forceDeleteLocalBranch(repo.path, cleanupTarget.branchName, cleanupTarget.head)) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + cleanupTarget.pushTarget, + store, + localWorktreeGitOptions + ) + } + + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: cleanupTarget.hostId + }) + ) + return { deleted: true } + } + ) +} diff --git a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts new file mode 100644 index 00000000000..5aff34966c8 --- /dev/null +++ b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts @@ -0,0 +1,58 @@ +import { ipcMain } from 'electron' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { withWorktreeSpan } from '../../../observability/instrumentation' +import { parseWorktreeId } from '../../worktree-logic' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { executeWorktreeRemoval } from './execute-worktree-removal' +import { + getWorktreeRemovalInFlightKey, + getWorktreeRemovalOptionsKey +} from './worktree-removal-coordinator' +import { resolveRepoForExecutionHost } from '../repo-host-ownership' + +export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): void { + const { store, options, worktreeRemovalsInFlight } = context + + ipcMain.handle( + 'worktrees:remove', + async (_event, args: RemoveWorktreeArgs): Promise => { + const { repoId, worktreePath } = parseWorktreeId(args.worktreeId) + const repo = resolveRepoForExecutionHost(store, repoId, args.hostId) + if (!repo) { + throw new Error(`Repo not found: ${repoId}`) + } + // The resolved repo supplies host ownership when legacy callers omit args.hostId. + const removalHostId = getRepoExecutionHostId(repo) + const inFlightKey = getWorktreeRemovalInFlightKey(args.worktreeId, removalHostId) + const optionsKey = getWorktreeRemovalOptionsKey(args) + const inFlightRemoval = worktreeRemovalsInFlight.get(inFlightKey) + if (inFlightRemoval) { + if (inFlightRemoval.optionsKey === optionsKey) { + return inFlightRemoval.promise + } + throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`) + } + + // Why: concurrent stale-toast/double-click/sidebar races can hit the same worktree; share the op so only one path touches Git and disk. + const removal = withWorktreeSpan({ stage: 'remove', path: worktreePath }, () => + executeWorktreeRemoval(context, args, repo, repoId, worktreePath, removalHostId) + ) + worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: removal }) + try { + const result = await removal + options?.onWorktreeLifecycle?.({ + kind: 'removed', + worktreeId: args.worktreeId, + path: parseWorktreeId(args.worktreeId).worktreePath + }) + return result + } finally { + if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === removal) { + worktreeRemovalsInFlight.delete(inFlightKey) + } + } + } + ) +} diff --git a/src/main/ipc/worktrees/removal/remove-folder-workspace.ts b/src/main/ipc/worktrees/removal/remove-folder-workspace.ts new file mode 100644 index 00000000000..c03b1449136 --- /dev/null +++ b/src/main/ipc/worktrees/removal/remove-folder-workspace.ts @@ -0,0 +1,70 @@ +import type { Repo } from '../../../../shared/repo-types' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import { deleteRemoteWorktreeHistory } from '../../../remote-worktree-history-cleanup' +import { killAllProcessesForWorktree } from '../../../runtime/worktree-teardown' +import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' +import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' +import { notifyWorktreesChanged } from '../../worktree-remote' +import { clearProviderPtyState, getLocalPtyProvider, getSshPtyProvider } from '../../pty' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { getFolderWorkspaceRootId } from '../folder-workspace-model' +import { preservedBranchCleanupByScope } from './preserved-branch-cleanup' +import { removeWorktreeMetadataAndTransientState } from './worktree-removal-ownership' + +export async function removeFolderWorkspace( + context: WorktreeIpcContext, + args: RemoveWorktreeArgs, + repo: Repo, + repoId: string, + removalHostId: ExecutionHostId +): Promise { + const { mainWindow, store, runtime } = context + if (args.worktreeId === getFolderWorkspaceRootId(repo)) { + throw new Error('Cannot delete the project root workspace. Remove the folder project instead.') + } + const ownerHost = parseExecutionHostId(removalHostId) + const sshPtyProvider = + ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined + // Why: folder workspaces share one root, so there's no Git remove step to close shells; sweep PTYs before dropping metadata. + await withWorktreeRemoveStageSpan('pty_sweep', 'folder', async () => { + // Folder projects can be SSH-backed, so fence the sweep to the owning host exactly + // like the git paths — the local inventory must never reach a remote workspace's id. + // The resolved repo is authoritative here: path-derived metadata is shared by + // same-id host copies and can describe a different owner's workspace. + const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime' + await killAllProcessesForWorktree(args.worktreeId, { + runtime, + resolvedWorktreeId: args.worktreeId, + ...(ownerHost?.kind === 'ssh' ? { resolvedConnectionId: ownerHost.targetId } : {}), + ...(ownerHost?.kind === 'runtime' + ? { resolvedRuntimeEnvironmentId: ownerHost.environmentId } + : {}), + localProvider: sshPtyProvider ?? getLocalPtyProvider(), + onPtyStopped: clearProviderPtyState, + ...(externalHost + ? { + includeProviderInventory: ownerHost?.kind === 'ssh' && Boolean(sshPtyProvider), + includeLocalRegistry: false + } + : {}) + }).catch((err) => { + console.warn(`[worktree-teardown] failed for ${args.worktreeId}:`, err) + }) + }) + await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => { + await deleteRemoteWorktreeHistory(sshPtyProvider, args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + }) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: removalHostId }) + ) + notifyWorktreesChanged(mainWindow, repoId) + return {} +} diff --git a/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts new file mode 100644 index 00000000000..2b81078b1dd --- /dev/null +++ b/src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts @@ -0,0 +1,248 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import { assertWorktreeUnlockedForRemoval } from '../../../../shared/worktree/removal' +import type { LocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' +import { + assertWorktreeCleanForRemoval, + listWorktreesStrict as listGitWorktreesStrict, + removeWorktree +} from '../../../git/worktree' +import { gitExecFileAsync } from '../../../git/runner' +import { getWorktreeSharedLinkPaths } from '../../../git/worktree-shared-directories' +import { + getLocalWorktreePathAccess, + removeLocalWorktreePath, + toLocalWorktreeRuntimePath +} from '../../../local-worktree-filesystem' +import { recoverLocalWindowsWorktreeRemoval } from '../../../local-worktree-removal-recovery' +import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' +import { + canSafelyRemoveOrphanedWorktreeDirectory, + findRegisteredDeletableWorktree +} from '../../../worktree-removal-safety' +import { + cleanupUnusedWorktreePushTargetRemote, + notifyWorktreesChanged +} from '../../worktree-remote' +import { + findExistingWorktreeSymlinkPaths, + removeWorktreeLinkedPaths +} from '../../worktree-symlinks' +import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache' +import { + formatWorktreeRemovalError, + isOrphanCompatiblePreflightError, + isOrphanedWorktreeError +} from '../../worktree-logic' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { + preserveBranchHeadFallback, + preservedBranchCleanupByScope, + rememberPreservedBranchCleanupTarget +} from './preserved-branch-cleanup' +import { + removeWorktreeMetadataAndTransientState, + stopPtysForDestructiveWorktreeRemoval +} from './worktree-removal-ownership' +import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' + +export async function removeRegisteredLocalWorktree( + context: WorktreeIpcContext, + args: RemoveWorktreeArgs, + repo: Repo, + repoId: string, + canonicalWorktreePath: string, + removalHostId: ExecutionHostId, + removedPushTarget: GitPushTarget | undefined, + localWorktreeGitOptions: LocalProjectWorktreeGitOptions, + hasLocalWorktreeGitOptions: boolean, + deleteBranch: boolean +): Promise { + const { mainWindow, store, runtime } = context + const refreshedWorktrees = hasLocalWorktreeGitOptions + ? await listGitWorktreesStrict(repo.path, localWorktreeGitOptions) + : await listGitWorktreesStrict(repo.path) + const refreshedRegisteredWorktree = findRegisteredDeletableWorktree( + repo.path, + canonicalWorktreePath, + refreshedWorktrees + ) + if (!refreshedRegisteredWorktree) { + throw new Error( + `Worktree registration changed during deletion: ${canonicalWorktreePath}. Retry deletion.` + ) + } + try { + // Why: an archive hook can race another Git client that locks the row; recheck before linked-path/watcher/terminal teardown. + assertWorktreeUnlockedForRemoval(refreshedRegisteredWorktree) + } catch (error) { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false)) + } + + // Why: `orca.yaml` shared directories are symlinked in too, and a + // directory-only ignore rule leaves those links untracked, so removal must + // tolerate and unlink them exactly like the per-user shared paths. + const linkedPaths = getWorktreeSharedLinkPaths(repo) + const ignoredLinkedPaths = args.force + ? [] + : await findExistingWorktreeSymlinkPaths(canonicalWorktreePath, linkedPaths) + try { + await (hasLocalWorktreeGitOptions + ? assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false, { + ...localWorktreeGitOptions, + ...(ignoredLinkedPaths.length > 0 ? { ignoredUntrackedPaths: ignoredLinkedPaths } : {}) + }) + : ignoredLinkedPaths.length > 0 + ? assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false, { + ignoredUntrackedPaths: ignoredLinkedPaths + }) + : assertWorktreeCleanForRemoval(canonicalWorktreePath, args.force ?? false)) + } catch (error) { + if (!isOrphanCompatiblePreflightError(error)) { + throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false)) + } + // Why: Git can still classify this as an orphan after preflight; keep strict PTY teardown before any recursive fallback deletion. + } + + let removalResult: RemoveWorktreeResult | undefined + const removalGate = await withWorktreeRemoveStageSpan('watcher_gate', 'local', async () => + runtime.acquireFileWatcherRemoval(canonicalWorktreePath) + ) + let removalCompleted = false + try { + // Why: hold the watcher/terminal gate through Git and any recursive fallback so no late spawn recreates a native handle. + // Linked-path deletion is destructive too, so PTYs must release every handle before Windows or WSL filesystem cleanup starts. + await withWorktreeRemoveStageSpan('pty_sweep', 'local', async () => { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { + allowUnverifiedStop: args.allowUnverifiedPtyStop + }) + }) + + // Why: preflight only ignored these paths, not mutated them; keep watcher installs fenced through Git removal. + if (linkedPaths.length > 0) { + await removeWorktreeLinkedPaths(canonicalWorktreePath, linkedPaths) + } + + try { + const removeOptions = { + ...(!deleteBranch ? { deleteBranch } : {}), + // Why: reuse the authoritative worktree list already computed here instead of rescanning siblings on the hot delete path. + knownRemovedWorktree: refreshedRegisteredWorktree, + ...(hasLocalWorktreeGitOptions ? localWorktreeGitOptions : {}) + } + removalResult = preserveBranchHeadFallback( + await withWorktreeRemoveStageSpan('git_remove', 'local', async () => + removeWorktree(repo.path, canonicalWorktreePath, args.force ?? false, removeOptions) + ), + refreshedRegisteredWorktree.head + ) + } catch (error) { + // Why: Git for Windows can deregister a clean worktree before its recursive filesystem deletion fails transiently. + const recoveredRemovalResult = await recoverLocalWindowsWorktreeRemoval({ + error, + force: args.force ?? false, + canonicalWorktreePath, + repoPath: repo.path, + localWorktreeGitOptions, + registeredWorktree: refreshedRegisteredWorktree, + deleteBranch, + closeWatcher: (worktreePath) => runtime.closeFileWatchersForRemoval(worktreePath) + }) + if (recoveredRemovalResult) { + removalResult = recoveredRemovalResult + removalCompleted = true + } else if (isOrphanedWorktreeError(error)) { + // If git no longer tracks this worktree, clean up the directory and metadata + console.warn( + `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` + ) + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + if ( + await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(canonicalWorktreePath, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath + ) + ) { + await runtime.closeFileWatchersForRemoval(canonicalWorktreePath) + await removeLocalWorktreePath(canonicalWorktreePath, localWorktreeGitOptions).catch( + () => {} + ) + } else { + console.warn( + `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` + ) + } + // Why: remove failed so git still tracks it (.git/worktrees/); prune or the stale entry keeps its branch locked. + await gitExecFileAsync(['worktree', 'prune'], { + cwd: repo.path, + ...localWorktreeGitOptions + }).catch(() => {}) + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) + invalidateAuthorizedRootsCache() + notifyWorktreesChanged(mainWindow, repoId) + removalCompleted = true + return {} + } else { + throw new Error( + formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) + ) + } + } + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + rememberPreservedBranchCleanupTarget( + args.worktreeId, + removalHostId, + removalResult, + refreshedRegisteredWorktree.head, + removedPushTarget + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + await withWorktreeRemoveStageSpan('metadata_purge', 'local', async () => { + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + }) + await withWorktreeRemoveStageSpan('cache_invalidation', 'local', async () => { + invalidateAuthorizedRootsCache() + }) + + notifyWorktreesChanged(mainWindow, repoId) + return removalResult ?? {} +} diff --git a/src/main/ipc/worktrees/removal/remove-registered-remote-worktree.ts b/src/main/ipc/worktrees/removal/remove-registered-remote-worktree.ts new file mode 100644 index 00000000000..47297eda4d7 --- /dev/null +++ b/src/main/ipc/worktrees/removal/remove-registered-remote-worktree.ts @@ -0,0 +1,97 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import type { GitPushTarget, GitWorktreeInfo } from '../../../../shared/worktree/types' +import type { SshGitProvider } from '../../../providers/ssh-git-provider' +import { deleteRemoteWorktreeHistory } from '../../../remote-worktree-history-cleanup' +import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation' +import { getSshPtyProvider } from '../../pty' +import { + cleanupUnusedWorktreePushTargetRemoteSsh, + notifyWorktreesChanged +} from '../../worktree-remote' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { + preserveBranchHeadFallback, + rememberPreservedBranchCleanupTarget +} from './preserved-branch-cleanup' +import { + removeWorktreeMetadataAndTransientState, + stopPtysForDestructiveWorktreeRemoval +} from './worktree-removal-ownership' + +export async function removeRegisteredRemoteWorktree( + context: WorktreeIpcContext, + args: RemoveWorktreeArgs, + repo: Repo, + repoId: string, + canonicalWorktreePath: string, + removalHostId: ExecutionHostId, + registeredWorktree: GitWorktreeInfo, + removedPushTarget: GitPushTarget | undefined, + provider: SshGitProvider, + deleteBranch: boolean +): Promise { + const { mainWindow, store, runtime } = context + const remoteConnectionId = repo.connectionId! + // Why: SSH deletion mirrors the local flow — hooks run while the directory is intact, then the clean check guards removal. + if (!args.force) { + const { clean, stdout } = await provider!.worktreeIsClean(canonicalWorktreePath) + if (!clean) { + const error = new Error('Worktree has uncommitted or untracked changes.') + ;(error as Error & { stdout?: string }).stdout = stdout + throw error + } + } + + const remoteRemoveOptions = !deleteBranch ? { deleteBranch } : {} + const removalGate = await withWorktreeRemoveStageSpan('watcher_gate', 'remote', async () => + runtime.acquireFileWatcherRemoval(canonicalWorktreePath, remoteConnectionId) + ) + let rawRemovalResult: RemoveWorktreeResult | undefined + let removalCompleted = false + try { + await withWorktreeRemoveStageSpan('pty_sweep', 'remote', async () => { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { + connectionId: remoteConnectionId, + allowUnverifiedStop: args.allowUnverifiedPtyStop + }) + }) + rawRemovalResult = await withWorktreeRemoveStageSpan('git_remove', 'remote', async () => + Object.keys(remoteRemoveOptions).length > 0 + ? provider!.removeWorktree(canonicalWorktreePath, args.force, remoteRemoveOptions) + : provider!.removeWorktree(canonicalWorktreePath, args.force) + ) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + const removalResult = preserveBranchHeadFallback(rawRemovalResult, registeredWorktree.head) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + args.worktreeId, + removedPushTarget, + store + ) + await deleteRemoteWorktreeHistory(getSshPtyProvider(remoteConnectionId), args.worktreeId) + rememberPreservedBranchCleanupTarget( + args.worktreeId, + removalHostId, + removalResult, + registeredWorktree.head, + removedPushTarget + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + await withWorktreeRemoveStageSpan('metadata_purge', 'remote', async () => { + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + }) + notifyWorktreesChanged(mainWindow, repoId) + return removalResult ?? {} +} diff --git a/src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts b/src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts new file mode 100644 index 00000000000..6cacdc6d317 --- /dev/null +++ b/src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts @@ -0,0 +1,254 @@ +import type { Repo } from '../../../../shared/repo-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import type { GitPushTarget, GitWorktreeInfo } from '../../../../shared/worktree/types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { LocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' +import type { SshGitProvider } from '../../../providers/ssh-git-provider' +import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' +import { deleteRemoteWorktreeHistory } from '../../../remote-worktree-history-cleanup' +import { preservedBranchCleanupScopeKey } from '../../../../shared/preserved-branch-cleanup' +import { + assertWorktreeDoesNotContainRegisteredWorktree, + canCleanupUnregisteredOrcaLeftoverDirectory, + canCleanupUnregisteredOrcaWorktreeDirectory, + canSafelyRemoveOrphanedWorktreeDirectory, + isDangerousWorktreeRemovalPath, + ORPHANED_WORKTREE_DIRECTORY_MESSAGE, + UNREGISTERED_MISSING_WORKTREE_MESSAGE +} from '../../../worktree-removal-safety' +import { + getLocalWorktreePathAccess, + removeLocalWorktreePath, + toLocalWorktreeRuntimePath +} from '../../../local-worktree-filesystem' +import { + cleanupUnusedWorktreePushTargetRemote, + cleanupUnusedWorktreePushTargetRemoteSsh, + notifyWorktreesChanged +} from '../../worktree-remote' +import { getSshPtyProvider } from '../../pty' +import { invalidateAuthorizedRootsCache } from '../../registered-worktree-roots-cache' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' +import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { preservedBranchCleanupByScope } from './preserved-branch-cleanup' +import { + removeWorktreeMetadataAndTransientState, + stopPtysForDestructiveWorktreeRemoval +} from './worktree-removal-ownership' +import { isAlreadyRemovedWorktreePath, isLocalGitRepository } from './worktree-removal-filesystem' + +export async function removeUnregisteredWorktree( + context: WorktreeIpcContext, + args: RemoveWorktreeArgs, + repo: Repo, + repoId: string, + worktreePath: string, + removalHostId: ExecutionHostId, + registeredWorktrees: GitWorktreeInfo[], + removedMeta: WorktreeMeta | undefined, + removedPushTarget: GitPushTarget | undefined, + localWorktreeGitOptions: LocalProjectWorktreeGitOptions, + provider: SshGitProvider | null +): Promise { + const { mainWindow, store, runtime } = context + const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null + let canCleanOrphanedDirectory = false + if ( + canCleanupUnregisteredOrcaWorktreeDirectory({ + meta: removedMeta + }) + ) { + if (repo.connectionId) { + if (!fsProvider) { + throw new Error('SSH filesystem provider unavailable') + } + if (!fsProvider.lstat) { + throw new Error('SSH filesystem provider lstat unavailable') + } + canCleanOrphanedDirectory = await canSafelyRemoveOrphanedWorktreeDirectory( + worktreePath, + repo.path, + (path) => fsProvider.lstat!(path), + (path) => fsProvider.readFile(path) + ) + } else { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + canCleanOrphanedDirectory = + !isDangerousWorktreeRemovalPath(worktreePath, repo.path) && + (await canSafelyRemoveOrphanedWorktreeDirectory( + toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), + toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + access.statPath, + access.readPath + )) + } + } + if (canCleanOrphanedDirectory) { + assertWorktreeDoesNotContainRegisteredWorktree(worktreePath, registeredWorktrees) + if (!args.force) { + throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } + if (repo.connectionId) { + const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath, repo.connectionId) + let removalCompleted = false + try { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { + connectionId: repo.connectionId, + allowUnverifiedStop: args.allowUnverifiedPtyStop + }) + await fsProvider!.deletePath(worktreePath, true) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + // Why history first: the worktree is already gone from git and + // disk by here, so a rejecting push-target cleanup must not be + // able to skip history removal and leave the user's commands on + // the remote host. + await deleteRemoteWorktreeHistory(getSshPtyProvider(repo.connectionId), args.worktreeId) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + args.worktreeId, + removedPushTarget, + store + ) + } else { + const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath) + let removalCompleted = false + try { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { + allowUnverifiedStop: args.allowUnverifiedPtyStop + }) + await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + invalidateAuthorizedRootsCache() + } + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) + notifyWorktreesChanged(mainWindow, repoId) + return {} + } + if (!repo.connectionId) { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + const runtimeWorktreePath = toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions) + if ( + await canCleanupUnregisteredOrcaLeftoverDirectory({ + meta: removedMeta, + worktreePath, + runtimeWorktreePath, + repo, + runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), + registeredWorktrees, + statPath: access.statPath, + isGitRepository: (path) => isLocalGitRepository(path, localWorktreeGitOptions) + }) + ) { + if (!args.force) { + throw new Error(ORPHANED_WORKTREE_DIRECTORY_MESSAGE) + } + const removalGate = await runtime.acquireFileWatcherRemoval(worktreePath) + let removalCompleted = false + try { + await stopPtysForDestructiveWorktreeRemoval(runtime, args.worktreeId, { + allowUnverifiedStop: args.allowUnverifiedPtyStop + }) + await removeLocalWorktreePath(worktreePath, localWorktreeGitOptions) + removalCompleted = true + } finally { + await removalGate.finish(removalCompleted) + } + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) + invalidateAuthorizedRootsCache() + notifyWorktreesChanged(mainWindow, repoId) + return {} + } + } + if (await isAlreadyRemovedWorktreePath(repo, worktreePath, localWorktreeGitOptions)) { + if (!args.force && !removedMeta) { + // Why: without persisted metadata, require the renderer recovery path before deleting Orca-only state for an unregistered path. + throw new Error(UNREGISTERED_MISSING_WORKTREE_MESSAGE) + } + // Why: a manually deleted worktree is already gone; persisted metadata proves it was an Orca-known row, so no force is needed. + if (repo.connectionId) { + // Why history first: the worktree is already gone from git and + // disk by here, so a rejecting push-target cleanup must not be + // able to skip history removal and leave the user's commands on + // the remote host. + await deleteRemoteWorktreeHistory(getSshPtyProvider(repo.connectionId), args.worktreeId) + await cleanupUnusedWorktreePushTargetRemoteSsh( + provider!, + repo.path, + args.worktreeId, + removedPushTarget, + store + ) + } else { + await cleanupUnusedWorktreePushTargetRemote( + repo.path, + args.worktreeId, + removedPushTarget, + store, + localWorktreeGitOptions + ) + invalidateAuthorizedRootsCache() + } + runtime.clearOptimisticReconcileToken(args.worktreeId) + removeWorktreeMetadataAndTransientState( + store, + args.worktreeId, + removalHostId, + args.snapshotPruneBatchId + ) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) + notifyWorktreesChanged(mainWindow, repoId) + return {} + } + throw new Error(`Refusing to delete unregistered worktree path: ${worktreePath}`) +} diff --git a/src/main/ipc/worktrees/removal/worktree-archive-hook.ts b/src/main/ipc/worktrees/removal/worktree-archive-hook.ts new file mode 100644 index 00000000000..f59ac4a2ebb --- /dev/null +++ b/src/main/ipc/worktrees/removal/worktree-archive-hook.ts @@ -0,0 +1,77 @@ +import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' +import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' +import type { Repo } from '../../../../shared/repo-types' +import { getEffectiveHooksFromConfig } from '../../../effective-hook-config' +import { getEffectiveHooks, parseOrcaYaml } from '../../../hooks' +import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' +import { requireSshGitProvider } from '../../../providers/ssh-git-dispatch' +import { joinWorktreeRelativePath } from '../../../runtime/runtime-relative-paths' +import { getSetupRunnerEnvVars } from '../../../setup-hook-env-vars' + +const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000 + +export async function getArchiveHooksForRemoval(repo: Repo): Promise { + if (!repo.connectionId) { + return getEffectiveHooks(repo) + } + + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return getEffectiveHooksFromConfig(repo, null) + } + + try { + const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml')) + const yamlHooks = result.isBinary ? null : parseOrcaYaml(result.content) + return getEffectiveHooksFromConfig(repo, yamlHooks) + } catch { + return getEffectiveHooksFromConfig(repo, null) + } +} + +export async function runRemoteArchiveHook( + repo: Repo, + worktreePath: string, + script: string +): Promise<{ success: boolean; output: string }> { + if (!repo.connectionId) { + return { success: true, output: '' } + } + + const provider = requireSshGitProvider(repo.connectionId) + const env = getSetupRunnerEnvVars(repo, worktreePath) + const isWindowsRemote = isWindowsAbsolutePathLike(worktreePath) + const result = await provider + .execNonInteractive( + isWindowsRemote ? 'cmd.exe' : '/bin/bash', + isWindowsRemote ? ['/d', '/s', '/c', script] : ['-lc', script], + worktreePath, + WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS, + undefined, + env + ) + .catch((error) => ({ + stdout: '', + stderr: '', + exitCode: null, + timedOut: false, + spawnError: error instanceof Error ? error.message : String(error) + })) + const output = [ + result.stdout, + result.stderr, + result.spawnError, + result.timedOut ? 'archive hook timed out' : null, + typeof result.exitCode === 'number' && result.exitCode !== 0 + ? `archive hook exited ${result.exitCode}` + : null + ] + .filter((part): part is string => Boolean(part)) + .join('\n') + .trim() + + return { + success: !result.spawnError && !result.timedOut && result.exitCode === 0, + output + } +} diff --git a/src/main/ipc/worktrees/removal/worktree-removal-coordinator.ts b/src/main/ipc/worktrees/removal/worktree-removal-coordinator.ts new file mode 100644 index 00000000000..3c52c4cabae --- /dev/null +++ b/src/main/ipc/worktrees/removal/worktree-removal-coordinator.ts @@ -0,0 +1,26 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' +import type { RemoveWorktreeArgs } from '../ipc-context-schemas' + +export type WorktreeRemovalInFlight = { + optionsKey: string + promise: Promise +} + +export function getWorktreeRemovalOptionsKey( + args: Pick +): string { + const forceKey = args.force === true ? 'force' : 'normal' + const archiveKey = args.skipArchive === true ? 'skip-archive' : 'run-archive' + // Why: a Force Delete retry must not coalesce onto the in-flight attempt that + // just failed the PTY gate — it would inherit that failure instead of retrying. + const ptyKey = args.allowUnverifiedPtyStop === true ? 'allow-unverified-pty' : 'require-pty-stop' + return `${forceKey}:${archiveKey}:${ptyKey}` +} + +export function getWorktreeRemovalInFlightKey( + worktreeId: string, + hostId?: ExecutionHostId +): string { + return `${hostId ?? ''}\0${worktreeId}` +} diff --git a/src/main/ipc/worktrees/removal/worktree-removal-filesystem.ts b/src/main/ipc/worktrees/removal/worktree-removal-filesystem.ts new file mode 100644 index 00000000000..2f071f458c5 --- /dev/null +++ b/src/main/ipc/worktrees/removal/worktree-removal-filesystem.ts @@ -0,0 +1,59 @@ +import type { Repo } from '../../../../shared/repo-types' +import { + getLocalWorktreePathAccess, + toLocalWorktreeRuntimePath +} from '../../../local-worktree-filesystem' +import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-dispatch' +import { gitExecFileAsync } from '../../../git/runner' +import { isWorktreePathMissing } from '../../../worktree-removal-safety' + +export async function isAlreadyRemovedWorktreePath( + repo: Repo, + worktreePath: string, + localWorktreeGitOptions: { wslDistro?: string } = {} +): Promise { + if (!repo.connectionId) { + const access = getLocalWorktreePathAccess(localWorktreeGitOptions) + return isWorktreePathMissing( + toLocalWorktreeRuntimePath(worktreePath, localWorktreeGitOptions), + access.statPath + ) + } + + const fsProvider = getSshFilesystemProvider(repo.connectionId) + if (!fsProvider) { + return false + } + return isWorktreePathMissing(worktreePath, (path) => fsProvider.stat(path)) +} + +export async function isLocalGitRepository( + runtimeWorktreePath: string, + localWorktreeGitOptions: { wslDistro?: string } = {} +): Promise { + try { + await gitExecFileAsync(['status', '--short'], { + cwd: runtimeWorktreePath, + ...localWorktreeGitOptions + }) + return true + } catch (error) { + return !gitStatusErrorMeansNotRepository(error) + } +} + +export function gitStatusErrorMeansNotRepository(error: unknown): boolean { + const message = + error instanceof Error + ? error.message + : error && typeof error === 'object' && 'message' in error + ? String((error as { message: unknown }).message) + : typeof error === 'string' + ? error + : '' + const stderr = + error && typeof error === 'object' && 'stderr' in error + ? String((error as { stderr: unknown }).stderr) + : '' + return /not a git repository/i.test(`${message}\n${stderr}`) +} diff --git a/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts new file mode 100644 index 00000000000..ec2198bcdb0 --- /dev/null +++ b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts @@ -0,0 +1,106 @@ +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { getSshPtyProvider, getLocalPtyProvider, clearProviderPtyState } from '../../pty' +import { killAllProcessesForWorktree } from '../../../runtime/worktree-teardown' +import type { Store } from '../../../persistence/loading-store/store' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../../worktree-removal-repo-owner' +import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { advertisedUrlWatcher } from '../../../ports/advertised-url-watcher' +import { localhostWorktreeLabelProxy } from '../../../localhost-worktree-label-proxy' +import { deleteWorktreeHistoryDir } from '../../../terminal-history-deletion' +import { pruneWorktreePRRefreshAliases } from '../../../github/pr-refresh-coordinator' +import { recordWorkspaceCleanupRemovalSnapshotPrune } from '../../../workspace-cleanup-removal-snapshot-prune' +import { pruneWorkspaceCleanupScanSnapshot } from '../../../workspace-cleanup-scan-snapshot' +import { pruneWorkspaceSpaceAnalysisSnapshot } from '../../../workspace-space-analysis-snapshot' + +export async function stopPtysForDestructiveWorktreeRemoval( + runtime: OrcaRuntimeService, + worktreeId: string, + options: { connectionId?: string; allowUnverifiedStop?: boolean } = {} +): Promise { + const { connectionId, allowUnverifiedStop } = options + const provider = connectionId ? getSshPtyProvider(connectionId) : getLocalPtyProvider() + if (!provider) { + throw new Error(`PTY provider unavailable for worktree deletion: ${worktreeId}`) + } + const teardownResult = await killAllProcessesForWorktree(worktreeId, { + runtime, + // Why: `repoId::path` ids repeat across hosts, so an unfenced sweep stops a same-id + // workspace's terminals on another connection — and the selector lookup this replaces + // throws `selector_ambiguous` the moment two hosts own the id. + resolvedWorktreeId: worktreeId, + ...(connectionId ? { resolvedConnectionId: connectionId } : {}), + localProvider: provider, + onPtyStopped: clearProviderPtyState, + requirePhysicalStop: true, + // Why (#11960): set only by an explicit Force Delete, never by the ordinary + // confirmation — otherwise the gate would be off on the primary delete path. + ...(allowUnverifiedStop ? { allowUnverifiedStop: true } : {}), + ...(connectionId ? { includeLocalRegistry: false } : {}) + }) + const total = + teardownResult.runtimeStopped + teardownResult.providerStopped + teardownResult.registryStopped + if (total > 0) { + console.info( + `[worktree-teardown] ${worktreeId} killed runtime=${teardownResult.runtimeStopped} provider=${teardownResult.providerStopped} registry=${teardownResult.registryStopped}` + ) + } +} + +// Why: the worktree's own persisted host outranks the repo fallback; teardown and metadata purge must resolve the same owner +// or the purge lands on the local partition while the SSH/runtime one keeps the workspace's tabs forever. +export function resolveWorktreeRemovalOwnerHostId( + store: Store, + worktreeId: string, + repo: Repo | undefined, + fallbackHostId?: ExecutionHostId +): ExecutionHostId | undefined { + return ( + fallbackHostId ?? + (repo ? getRepoExecutionHostId(repo) : store.getWorktreeMeta(worktreeId)?.hostId) + ) +} + +export function removeWorktreeMetadataAndTransientState( + store: Store, + worktreeId: string, + hostId?: ExecutionHostId, + snapshotPruneBatchId?: string +): void { + const persistedHostId = store.getWorktreeMeta(worktreeId)?.hostId + const repoId = getRepoIdFromWorktreeId(worktreeId) + const preservesSameIdOwner = Boolean( + hostId && + ((persistedHostId && persistedHostId !== hostId) || + hasWorktreeRemovalRepoOwnerOnOtherHost(store, repoId, hostId)) + ) + // Why: worktree IDs are path-derived and reusable; drop process-local caches before the same ID can map to a new workspace. + if (hostId) { + store.removeWorktreeMeta(worktreeId, hostId) + } else { + store.removeWorktreeMeta(worktreeId) + } + if (!preservesSameIdOwner) { + advertisedUrlWatcher.forgetWorktree(worktreeId) + // Why: drop this worktree's localhost label routes so they don't accumulate in the proxy's route maps all session. + localhostWorktreeLabelProxy.unregisterWorktree(worktreeId) + // Why: schedule async history tree removal — never recursive-rmSync on the delete critical path. + deleteWorktreeHistoryDir(worktreeId) + // Why: release the removed worktree's PR-refresh aliases so coalesced queue entries don't retain it all session (memory creep). + pruneWorktreePRRefreshAliases(worktreeId) + } + // Why: removed workspaces must never resurrect from the persisted cleanup/space scan snapshots. + const snapshotDirectory = store.getProfileStorageDirectory() + if (snapshotPruneBatchId) { + recordWorkspaceCleanupRemovalSnapshotPrune(snapshotDirectory, { + batchId: snapshotPruneBatchId, + worktreeId, + executionHostId: hostId + }) + return + } + void pruneWorkspaceCleanupScanSnapshot(snapshotDirectory, worktreeId, hostId) + void pruneWorkspaceSpaceAnalysisSnapshot(snapshotDirectory, worktreeId, hostId) +} diff --git a/src/main/ipc/worktrees/repo-host-ownership.ts b/src/main/ipc/worktrees/repo-host-ownership.ts new file mode 100644 index 00000000000..89f26e871a5 --- /dev/null +++ b/src/main/ipc/worktrees/repo-host-ownership.ts @@ -0,0 +1,14 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { Repo } from '../../../shared/repo-types' +import type { Store } from '../../persistence/loading-store/store' +import { resolveWorktreeRemovalRepoOwner } from '../../worktree-removal-repo-owner' + +export function resolveRepoForExecutionHost( + store: Store, + repoId: string, + hostId?: ExecutionHostId +): Repo | undefined { + // Why: host-qualified operations must never guess between repo owners; legacy unscoped calls work only for one unique owner. + const owner = resolveWorktreeRemovalRepoOwner(store, repoId, hostId) + return owner.kind === 'resolved' ? owner.repo : undefined +} diff --git a/src/main/ipc/worktrees/worktree-ipc-context.ts b/src/main/ipc/worktrees/worktree-ipc-context.ts new file mode 100644 index 00000000000..5455a71d06e --- /dev/null +++ b/src/main/ipc/worktrees/worktree-ipc-context.ts @@ -0,0 +1,16 @@ +import type { BrowserWindow } from 'electron' +import type { Store } from '../../persistence/loading-store/store' +import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../../runtime/orca-runtime' +import type { SenderScopedRequestCancellations } from '../sender-scoped-request-cancellation' +import type { WorktreeRemovalInFlight } from './removal/worktree-removal-coordinator' + +export type WorktreeIpcContext = { + mainWindow: BrowserWindow + store: Store + runtime: OrcaRuntimeService + options?: { + onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void + } + detectedWorktreeCancellations: SenderScopedRequestCancellations + worktreeRemovalsInFlight: Map +} diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index 2972fa6b531..46c7a430537 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -387,7 +387,7 @@ describe('Jira issue operations', () => { ]) expect(String(jiraRequestMock.mock.calls[0][1])).toContain( - '/rest/api/3/issue/createmeta/10000/issuetypes/10001?' + '/rest/api/3/issue/createmeta/10000/issuetypes/10001?maxResults=100&startAt=0' ) }) diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 976862c0e96..7529c2131da 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -1,1246 +1,13 @@ -/* eslint-disable max-lines -- Why: Jira task reads and mutations share ADF - mapping, multi-site fan-out, and auth-clearing behavior; keeping the API - boundary together avoids subtle drift between operations. */ -import type { - JiraComment, - JiraCreateField, - JiraCreateFieldAllowedValue, - JiraCreateIssueArgs, - JiraCreateIssueResult, - JiraIssue, - JiraIssueFilter, - JiraIssueType, - JiraIssueUpdate, - JiraMutationResult, - JiraPriority, - JiraProject, - JiraProjectStatusOrder, - JiraSite, - JiraSiteSelection, - JiraStatus, - JiraTransition, - JiraUser -} from '../../shared/jira-types' -import { acquire, release } from './request-queue' -import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' -import { clearToken, getClients, isAuthError } from './client' -import { - adfToMarkdownText, - collectAdfMediaAttrs, - textToAdf, - type AdfToMarkdownOptions, - type JiraAdfMediaAttrs -} from './adf-markdown' -import { - extractAttachmentContentIdsFromHtml, - selectPreferredAttachmentIds, - warnIfMediaResolutionIncomplete -} from './attachment-discovery' -import { - createMediaMarkdownResolver, - loadIssueImageAttachments, - type MediaResolutionStats -} from './attachment-images' -import { JiraSummaryLookupError } from '../../shared/jira-summary-lookup' - -const ISSUE_FIELDS = [ - 'summary', - 'description', - 'project', - 'issuetype', - 'status', - 'assignee', - 'reporter', - 'priority', - 'labels', - 'created', - 'updated' -] - -// Why: list/typeahead only need identity + metadata; description ADF parse is the hot-path cost. -const ISSUE_LIST_FIELDS = ISSUE_FIELDS.filter((field) => field !== 'description') - -// Why: detail reads need attachment metadata so inline ADF media can be resolved -// to downloadable image content; list/search omit this for payload size. -const ISSUE_DETAIL_FIELDS = [...ISSUE_FIELDS, 'attachment'] -// `created`/`updated` are required: mapJiraIssue falls back to "now" when they're absent, -// which would silently report the lookup time as the issue's timestamps. -const ISSUE_SUMMARY_FIELDS = ['summary', 'project', 'issuetype', 'status', 'created', 'updated'] -const ISSUE_SUMMARY_TIMEOUT_MS = 30_000 -const ISSUE_SEARCH_TIMEOUT_MS = 30_000 - -type JiraRecord = Record - -type JiraSearchResponse = { - issues?: JiraRecord[] -} - -type JiraPagedResponse = { - startAt?: number - maxResults?: number - total?: number - isLast?: boolean - values?: T[] - issueTypes?: T[] - comments?: T[] - fields?: T[] | Record -} - -type JiraPageItemKey = 'values' | 'issueTypes' | 'comments' - -function clampLimit(limit: number | undefined, fallback = 30): number { - return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100) -} - -type JiraIssueSearchFailure = { - error: unknown - auth: boolean -} - -/** Run against one signal that trips on the caller's abort or the request deadline. */ -async function withJiraDeadline( - signal: AbortSignal | undefined, - timeoutMs: number, - run: (deadlineSignal: AbortSignal) => Promise -): Promise { - const controller = new AbortController() - const abort = (): void => controller.abort() - signal?.addEventListener('abort', abort, { once: true }) - if (signal?.aborted) { - controller.abort() - } - const timer = setTimeout(abort, timeoutMs) - try { - return await run(controller.signal) - } finally { - clearTimeout(timer) - signal?.removeEventListener('abort', abort) - } -} - -function settleJiraSummaryRead(read: Promise, signal: AbortSignal): Promise { - if (signal.aborted) { - return Promise.reject(new Error('Jira summary lookup aborted')) - } - return new Promise((resolve, reject) => { - const handleAbort = (): void => { - cleanup() - reject(new Error('Jira summary lookup aborted')) - } - const cleanup = (): void => signal.removeEventListener('abort', handleAbort) - signal.addEventListener('abort', handleAbort, { once: true }) - void read.then( - (value) => { - cleanup() - resolve(value) - }, - (error: unknown) => { - cleanup() - reject(error) - } - ) - }) -} - -function getErrorStatus(error: unknown): number | null { - if (!error || typeof error !== 'object' || !('status' in error)) { - return null - } - const status = (error as { status?: unknown }).status - return typeof status === 'number' && Number.isFinite(status) ? status : null -} - -function toIssueSearchFailureError(error: unknown): unknown { - const status = getErrorStatus(error) - if ( - status === null || - !(error instanceof Error) || - error.message.startsWith(`Error ${status}:`) - ) { - return error - } - return new Error(`Error ${status}: ${error.message}`) -} - -function shouldSurfaceSiteFailure( - selection: JiraSiteSelection | null | undefined, - entryCount: number -): boolean { - // getClients can resolve an omitted selection to the persisted 'all' choice; - // multi-entry reads need the same resilient fan-out policy as explicit 'all'. - return selection !== 'all' && entryCount <= 1 -} - -function asRecord(value: unknown): JiraRecord { - return value && typeof value === 'object' ? (value as JiraRecord) : {} -} - -function asString(value: unknown, fallback = ''): string { - return typeof value === 'string' ? value : fallback -} - -function asIdentifier(value: unknown): string { - if (typeof value === 'string') { - return value - } - return typeof value === 'number' && Number.isFinite(value) ? String(value) : '' -} - -function asStringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === 'string') - : [] -} - -function asFiniteNumber(value: unknown): number | null { - return typeof value === 'number' && Number.isFinite(value) ? value : null -} - -function getPageItems(response: JiraPagedResponse, key: JiraPageItemKey): T[] { - const keyedItems = response[key] - if (Array.isArray(keyedItems)) { - return keyedItems - } - return response.values ?? [] -} - -function shouldFetchNextPage( - response: JiraPagedResponse, - startAt: number, - items: T[], - requestedMaxResults: number -): boolean { - if (response.isLast === true || items.length === 0) { - return false - } - const total = asFiniteNumber(response.total) - const pageSize = asFiniteNumber(response.maxResults) - if (total !== null) { - return startAt + items.length < total && (pageSize ?? requestedMaxResults) > 0 - } - if (response.isLast === false) { - return (pageSize ?? requestedMaxResults) > 0 - } - return pageSize !== null && items.length >= pageSize -} - -async function fetchPagedRecords( - entry: JiraClientForSite, - key: JiraPageItemKey, - pathForPage: (startAt: number, maxResults: number) => string, - maxResults = 100 -): Promise { - const records: JiraRecord[] = [] - let startAt = 0 - for (let guard = 0; guard < 100; guard += 1) { - const response = await jiraRequest>( - entry, - pathForPage(startAt, maxResults) - ) - const items = getPageItems(response, key) - records.push(...items) - if (!shouldFetchNextPage(response, startAt, items, maxResults)) { - break - } - startAt += asFiniteNumber(response.maxResults) ?? maxResults - } - return records -} - -function avatarUrl(value: unknown): string | undefined { - const avatars = asRecord(value) - return ( - asString(avatars['48x48']) || - asString(avatars['32x32']) || - asString(avatars['24x24']) || - undefined - ) -} - -function mapUser(value: unknown): JiraUser | undefined { - const user = asRecord(value) - // Server/DC users have no accountId; name (login) and key are its stable ids. - const accountId = asString(user.accountId) || asString(user.name) || asString(user.key) - if (!accountId) { - return undefined - } - return { - accountId, - displayName: asString(user.displayName, 'Unknown'), - email: typeof user.emailAddress === 'string' ? user.emailAddress : undefined, - avatarUrl: avatarUrl(user.avatarUrls) - } -} - -function mapProject(value: unknown, site?: JiraSite): JiraProject { - const project = asRecord(value) - return { - id: asString(project.id), - key: asString(project.key), - name: asString(project.name, asString(project.key)), - siteId: site?.id, - siteName: site?.displayName - } -} - -function mapIssueType(value: unknown): JiraIssueType { - const issueType = asRecord(value) - return { - id: asString(issueType.id), - name: asString(issueType.name, 'Issue'), - description: asString(issueType.description) || undefined, - iconUrl: asString(issueType.iconUrl) || undefined, - subtask: typeof issueType.subtask === 'boolean' ? issueType.subtask : undefined - } -} - -function mapCreateFieldAllowedValue(value: unknown): JiraCreateFieldAllowedValue { - const option = asRecord(value) - return { - id: asString(option.id) || undefined, - value: asString(option.value) || undefined, - name: asString(option.name) || undefined - } -} - -function mapCreateField(value: unknown, fallbackKey = ''): JiraCreateField | null { - const field = asRecord(value) - const schema = asRecord(field.schema) - const key = - asString(field.key) || - asString(field.fieldId) || - asString(field.id) || - asString(field.fieldKey) || - fallbackKey - if (!key) { - return null - } - const allowedValues = Array.isArray(field.allowedValues) - ? field.allowedValues.map(mapCreateFieldAllowedValue) - : undefined - return { - key, - name: asString(field.name, key), - required: field.required === true, - schema: { - type: asString(schema.type) || undefined, - items: asString(schema.items) || undefined, - custom: asString(schema.custom) || undefined - }, - allowedValues - } -} - -function getCreateFieldRecords(response: JiraPagedResponse): JiraRecord[] { - if (Array.isArray(response.values)) { - return response.values - } - if (Array.isArray(response.fields)) { - return response.fields - } - if (response.fields && typeof response.fields === 'object') { - return Object.entries(response.fields).map(([key, value]) => ({ - key, - ...asRecord(value) - })) - } - return [] -} - -function mapPriority(value: unknown): JiraPriority | undefined { - const priority = asRecord(value) - const id = asString(priority.id) - if (!id) { - return undefined - } - return { - id, - name: asString(priority.name, 'Priority'), - iconUrl: asString(priority.iconUrl) || undefined - } -} - -function mapStatus(value: unknown): JiraStatus { - const status = asRecord(value) - const category = asRecord(status.statusCategory) - return { - id: asString(status.id), - name: asString(status.name, 'Unknown'), - categoryKey: asString(category.key, 'undefined'), - categoryName: asString(category.name, 'No Category'), - colorName: asString(category.colorName) || undefined - } -} - -function issueUrl(site: JiraSite, key: string): string { - return `${site.siteUrl}/browse/${encodeURIComponent(key)}` -} - -// REST v2 (Server/DC) bodies are plain text; v3 (Cloud) requires ADF documents. -function toBodyText(site: JiraSite, text: string): unknown { - return site.authType === 'server' ? text : textToAdf(text) -} - -export function mapJiraIssue( - site: JiraSite, - raw: JiraRecord, - adfOptions?: AdfToMarkdownOptions -): JiraIssue { - const fields = asRecord(raw.fields) - const key = asString(raw.key) - return { - id: asString(raw.id, key), - key, - siteId: site.id, - siteName: site.displayName, - title: asString(fields.summary, key || 'Untitled issue'), - description: adfToMarkdownText(fields.description, adfOptions), - url: issueUrl(site, key), - project: mapProject(fields.project, site), - issueType: mapIssueType(fields.issuetype), - status: mapStatus(fields.status), - labels: asStringArray(fields.labels), - assignee: mapUser(fields.assignee), - reporter: mapUser(fields.reporter), - priority: mapPriority(fields.priority), - createdAt: asString(fields.created, new Date().toISOString()), - updatedAt: asString(fields.updated, new Date().toISOString()) - } -} - -type MediaRequest = { - attachmentField: unknown - preferredIds: string[] - needCount: number - fallbackRan: boolean - issueKey: string -} - -/** Pooled: HTML/ADF selection only — no binary downloads. */ -function collectIssueMediaRequest(raw: JiraRecord): MediaRequest | undefined { - const fields = asRecord(raw.fields) - const renderedFields = asRecord(raw.renderedFields) - const htmlIds = extractAttachmentContentIdsFromHtml( - asString(renderedFields.description) || undefined - ) - const mediaAttrs = collectAdfMediaAttrs(fields.description) - const selection = selectPreferredAttachmentIds({ - renderedHtmlIds: htmlIds, - attachmentField: fields.attachment, - mediaAttrs - }) - if (selection.needCount === 0 && selection.preferredIds.length === 0) { - return undefined - } - return { - attachmentField: fields.attachment, - preferredIds: selection.preferredIds, - needCount: selection.needCount, - fallbackRan: selection.fallbackRan, - issueKey: asString(raw.key) - } -} - -type PreparedMedia = { - options: AdfToMarkdownOptions - stats: MediaResolutionStats - request: MediaRequest -} - -/** Unpooled: binary downloads + resolver (outside the Jira API semaphore). */ -async function prepareMediaResolver( - client: JiraClientForSite, - request: MediaRequest -): Promise { - if (request.preferredIds.length === 0) { - warnIfMediaResolutionIncomplete({ - siteId: client.site.id, - issueKey: request.issueKey, - needCount: request.needCount, - preferredIdCount: 0, - resolvedCount: 0, - fallbackRan: request.fallbackRan - }) - return undefined - } - const images = await loadIssueImageAttachments( - client, - request.attachmentField, - request.preferredIds - ) - if (images.length === 0) { - warnIfMediaResolutionIncomplete({ - siteId: client.site.id, - issueKey: request.issueKey, - needCount: request.needCount, - preferredIdCount: request.preferredIds.length, - resolvedCount: 0, - fallbackRan: request.fallbackRan - }) - return undefined - } - const stats: MediaResolutionStats = { attachmentResolvedCount: 0 } - const resolveMedia = createMediaMarkdownResolver(images, request.preferredIds, stats) - return { - options: { resolveMedia }, - stats, - request - } -} - -function flushMediaResolutionWarn(client: JiraClientForSite, prepared: PreparedMedia): void { - warnIfMediaResolutionIncomplete({ - siteId: client.site.id, - issueKey: prepared.request.issueKey, - needCount: prepared.request.needCount, - preferredIdCount: prepared.request.preferredIds.length, - resolvedCount: prepared.stats.attachmentResolvedCount, - fallbackRan: prepared.request.fallbackRan - }) -} - -function sortAndLimitIssues(issues: JiraIssue[], limit: number): JiraIssue[] { - return issues - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) - .slice(0, limit) -} - -function filterToJql(filter: JiraIssueFilter): string { - if (filter === 'assigned') { - return 'assignee = currentUser() AND resolution = Unresolved ORDER BY updated DESC' - } - if (filter === 'reported') { - return 'reporter = currentUser() AND resolution = Unresolved ORDER BY updated DESC' - } - if (filter === 'done') { - return 'assignee = currentUser() AND resolution IS NOT EMPTY ORDER BY updated DESC' - } - return 'resolution = Unresolved ORDER BY updated DESC' -} - -async function searchIssuesForClient( - entry: JiraClientForSite, - jql: string, - limit: number, - signal?: AbortSignal -): Promise { - // Server/DC only has the classic /search resource; /search/jql is Cloud-only. - const searchPath = - entry.site.authType === 'server' - ? `${apiBasePath(entry.site)}/search` - : '/rest/api/3/search/jql' - const result = await jiraRequest(entry, searchPath, { - method: 'POST', - body: JSON.stringify({ - jql, - maxResults: limit, - fields: ISSUE_LIST_FIELDS - }), - signal - }) - return (result.issues ?? []).map((issue) => mapJiraIssue(entry.site, issue)) -} - -export async function listIssues( - filter: JiraIssueFilter = 'assigned', - limit = 30, - siteId?: JiraSiteSelection | null -): Promise { - return searchIssues(filterToJql(filter), limit, siteId) -} - -export async function searchIssues( - jql: string, - limit = 30, - siteId?: JiraSiteSelection | null, - signal?: AbortSignal -): Promise { - const entries = getClients(siteId) - if (entries.length === 0 || !jql.trim()) { - return [] - } - const safeLimit = clampLimit(limit) - const failures: (JiraIssueSearchFailure | undefined)[] = Array.from({ length: entries.length }) - const surfaceSiteFailure = shouldSurfaceSiteFailure(siteId, entries.length) - const results = await withJiraDeadline(signal, ISSUE_SEARCH_TIMEOUT_MS, (requestSignal) => - Promise.all( - entries.map(async (entry, index) => { - // Why: queueing on an abandoned search would keep occupying the shared Jira pool. - await acquire(requestSignal) - try { - return await searchIssuesForClient(entry, jql.trim(), safeLimit, requestSignal) - } catch (error) { - if (requestSignal.aborted) { - // Abandoned by the caller: not a site failure, so don't clear tokens or mask a real one. - throw error - } - const authFailure = isAuthError(error) - if (authFailure) { - clearToken(entry.site.id) - } - if (surfaceSiteFailure) { - throw toIssueSearchFailureError(error) - } - console.warn('[jira] searchIssues failed:', error) - failures[index] = { error: toIssueSearchFailureError(error), auth: authFailure } - return [] as JiraIssue[] - } finally { - release() - } - }) - ) - ) - // 'all' fan-out: only surface an error when every connected site failed, so a - // partial success (or a genuinely empty result) is not reported as an error. - const recordedFailures = failures.filter( - (failure): failure is JiraIssueSearchFailure => failure !== undefined - ) - if (recordedFailures.length === entries.length) { - throw (recordedFailures.find((failure) => !failure.auth) ?? recordedFailures[0]).error - } - return entries.length === 1 - ? results.flat().slice(0, safeLimit) - : sortAndLimitIssues(results.flat(), safeLimit) -} - -export async function getIssue( - key: string, - siteId?: JiraSiteSelection | null -): Promise { - const entries = getClients(siteId) - for (const entry of entries) { - let mediaRequest: MediaRequest | undefined - let issue: JiraRecord | undefined - let held = false - try { - await acquire() - held = true - const params = new URLSearchParams({ - fields: ISSUE_DETAIL_FIELDS.join(','), - expand: 'renderedFields' - }) - issue = await jiraRequest( - entry, - `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?${params.toString()}` - ) - // Why: keep only JSON under the pool; binary downloads fan out after release. - mediaRequest = collectIssueMediaRequest(issue) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - if (shouldSurfaceSiteFailure(siteId, entries.length)) { - throw error - } - } else { - console.warn('[jira] getIssue failed:', error) - } - continue - } finally { - if (held) { - held = false - release() - } - } - - try { - if (!issue) { - continue - } - const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined - const mapped = mapJiraIssue(entry.site, issue, prepared?.options) - if (prepared) { - flushMediaResolutionWarn(entry, prepared) - } - return mapped - } catch (error) { - console.warn('[jira] getIssue media load failed:', error) - return mapJiraIssue(entry.site, issue) - } - } - return null -} - -export async function getIssueSummary( - key: string, - siteId: string, - signal?: AbortSignal -): Promise { - let entries: JiraClientForSite[] - try { - entries = getClients(siteId) - } catch (error) { - throw new JiraSummaryLookupError('auth', error) - } - const entry = entries.find((candidate) => candidate.site.id === siteId) - if (!entry) { - throw new JiraSummaryLookupError('disconnected') - } - - return withJiraDeadline(signal, ISSUE_SUMMARY_TIMEOUT_MS, async (requestSignal) => { - await acquire(requestSignal) - try { - const params = new URLSearchParams({ fields: ISSUE_SUMMARY_FIELDS.join(',') }) - const issue = await settleJiraSummaryRead( - jiraRequest( - entry, - `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?${params.toString()}`, - { signal: requestSignal } - ), - requestSignal - ) - return mapJiraIssue(entry.site, issue) - } catch (error) { - if (isAuthError(error)) { - throw new JiraSummaryLookupError('auth', error) - } - if (getErrorStatus(error) === 404) { - throw new JiraSummaryLookupError('not-found', error) - } - throw new JiraSummaryLookupError('read-failed', error) - } finally { - release() - } - }) -} - -export async function createIssue(args: JiraCreateIssueArgs): Promise { - const entry = getClients(args.siteId)[0] - if (!entry) { - return { ok: false, error: 'Not connected to Jira.' } - } - const title = args.title.trim() - if (!title) { - return { ok: false, error: 'Title is required.' } - } - - await acquire() - try { - const fields: JiraRecord = { - project: { id: args.projectId }, - issuetype: { id: args.issueTypeId }, - summary: title - } - if (args.description?.trim()) { - fields.description = toBodyText(entry.site, args.description.trim()) - } - for (const [fieldKey, value] of Object.entries(args.customFields ?? {})) { - if (!fieldKey || value === undefined || value === null || value === '') { - continue - } - fields[fieldKey] = value - } - const created = await jiraRequest<{ id: string; key: string; self: string }>( - entry, - `${apiBasePath(entry.site)}/issue`, - { - method: 'POST', - body: JSON.stringify({ fields }) - } - ) - return { ok: true, id: created.id, key: created.key, url: issueUrl(entry.site, created.key) } - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - return { ok: false, error: error instanceof Error ? error.message : 'Failed to create issue.' } - } finally { - release() - } -} - -export async function updateIssue( - key: string, - updates: JiraIssueUpdate, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return { ok: false, error: 'Not connected to Jira.' } - } - await acquire() - try { - const fields: JiraRecord = {} - if (updates.title !== undefined) { - fields.summary = updates.title - } - if (updates.labels !== undefined) { - fields.labels = updates.labels - } - if (updates.priorityId !== undefined) { - fields.priority = updates.priorityId ? { id: updates.priorityId } : null - } - const issueBase = `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}` - if (Object.keys(fields).length > 0) { - await jiraRequest(entry, issueBase, { - method: 'PUT', - body: JSON.stringify({ fields }) - }) - } - if (updates.assigneeAccountId !== undefined) { - // Server/DC identifies assignees by username (`name`), not accountId; - // mapUser stores the Server username in the accountId slot. - const assigneeBody = - entry.site.authType === 'server' - ? { name: updates.assigneeAccountId } - : { accountId: updates.assigneeAccountId } - await jiraRequest(entry, `${issueBase}/assignee`, { - method: 'PUT', - body: JSON.stringify(assigneeBody) - }) - } - if (updates.transitionId) { - await jiraRequest(entry, `${issueBase}/transitions`, { - method: 'POST', - body: JSON.stringify({ transition: { id: updates.transitionId } }) - }) - } - return { ok: true } - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - return { ok: false, error: error instanceof Error ? error.message : 'Failed to update issue.' } - } finally { - release() - } -} - -export async function addIssueComment( - key: string, - body: string, - siteId?: string | null -): Promise<{ ok: true; id: string } | { ok: false; error: string }> { - const entry = getClients(siteId)[0] - if (!entry) { - return { ok: false, error: 'Not connected to Jira.' } - } - await acquire() - try { - const comment = await jiraRequest<{ id: string }>( - entry, - `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment`, - { - method: 'POST', - body: JSON.stringify({ body: toBodyText(entry.site, body) }) - } - ) - return { ok: true, id: comment.id } - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - return { ok: false, error: error instanceof Error ? error.message : 'Failed to add comment.' } - } finally { - release() - } -} - -function mapComment(raw: JiraRecord, adfOptions?: AdfToMarkdownOptions): JiraComment { - return { - id: asString(raw.id), - body: adfToMarkdownText(raw.body, adfOptions), - createdAt: asString(raw.created, new Date().toISOString()), - updatedAt: asString(raw.updated) || undefined, - user: mapUser(raw.author) - } -} - -/** - * Pooled comment media collect: attachment metadata JSON stays under the semaphore. - * Residual: Server/DC comment bodies are wiki markup, not ADF — this only fixes - * the lookup path; wiki `!filename!` is not rendered as media. - */ -async function collectCommentMediaRequest( - client: JiraClientForSite, - key: string, - comments: JiraRecord[] -): Promise { - const htmlIds: string[] = [] - const seen = new Set() - const mediaAttrs: JiraAdfMediaAttrs[] = [] - for (const comment of comments) { - for (const id of extractAttachmentContentIdsFromHtml(asString(comment.renderedBody))) { - if (!seen.has(id)) { - seen.add(id) - htmlIds.push(id) - } - } - mediaAttrs.push(...collectAdfMediaAttrs(comment.body)) - } - - const needingCount = mediaAttrs.filter( - (attrs) => !(attrs.url && /^https?:\/\//i.test(attrs.url)) - ).length - // Why: selectPreferredAttachmentIds yields nothing without attachment-needing media, so - // HTML ids alone can never produce a download — skip the extra metadata request entirely. - if (needingCount === 0) { - return undefined - } - - // Why: comment media usually references issue-level attachments; pull them once - // for the whole thread. Use apiBasePath so Server/DC does not 404 on /rest/api/3. - let attachmentField: unknown - try { - const issue = await jiraRequest( - client, - `${apiBasePath(client.site)}/issue/${encodeURIComponent(key)}?fields=attachment` - ) - attachmentField = asRecord(issue.fields).attachment - } catch (error) { - console.warn('[jira] comment attachment lookup failed:', error) - return undefined - } - - const selection = selectPreferredAttachmentIds({ - renderedHtmlIds: htmlIds, - attachmentField, - mediaAttrs - }) - if (selection.needCount === 0 && selection.preferredIds.length === 0) { - return undefined - } - return { - attachmentField, - preferredIds: selection.preferredIds, - needCount: selection.needCount, - fallbackRan: selection.fallbackRan, - issueKey: key - } -} - -export async function getIssueComments( - key: string, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - - let comments: JiraRecord[] = [] - let mediaRequest: MediaRequest | undefined - let held = false - try { - await acquire() - held = true - comments = await fetchPagedRecords(entry, 'comments', (startAt, maxResults) => { - const params = new URLSearchParams({ - maxResults: String(maxResults), - orderBy: 'created', - startAt: String(startAt), - expand: 'renderedBody' - }) - return `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment?${params.toString()}` - }) - mediaRequest = await collectCommentMediaRequest(entry, key, comments) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] getIssueComments failed:', error) - return [] - } finally { - if (held) { - held = false - release() - } - } - - try { - const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined - const mapped = comments.map((comment) => mapComment(comment, prepared?.options)) - if (prepared) { - flushMediaResolutionWarn(entry, prepared) - } - return mapped - } catch (error) { - console.warn('[jira] getIssueComments media load failed:', error) - return comments.map((comment) => mapComment(comment)) - } -} - -export async function listProjects(siteId?: JiraSiteSelection | null): Promise { - const entries = getClients(siteId) - if (entries.length === 0) { - return [] - } - const results = await Promise.all( - entries.map(async (entry) => { - await acquire() - try { - // Server/DC has no /project/search resource; /project returns the - // full list as a plain (unpaged) array. - const projects = - entry.site.authType === 'server' - ? await jiraRequest(entry, `${apiBasePath(entry.site)}/project`) - : await fetchPagedRecords(entry, 'values', (startAt, maxResults) => { - const params = new URLSearchParams({ - maxResults: String(maxResults), - startAt: String(startAt) - }) - return `/rest/api/3/project/search?${params.toString()}` - }) - return projects.map((project) => mapProject(project, entry.site)) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - if (shouldSurfaceSiteFailure(siteId, entries.length)) { - throw error - } - } else { - console.warn('[jira] listProjects failed:', error) - } - return [] - } finally { - release() - } - }) - ) - return results.flat().sort((a, b) => a.name.localeCompare(b.name)) -} - -export async function listIssueTypes( - projectIdOrKey: string, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - await acquire() - try { - const issueTypes = await fetchPagedRecords(entry, 'issueTypes', (startAt, maxResults) => { - const params = new URLSearchParams({ - maxResults: String(maxResults), - startAt: String(startAt) - }) - // Per-project createmeta paths exist on Server/DC from Jira 8.4 onward. - return `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( - projectIdOrKey - )}/issuetypes?${params.toString()}` - }) - return issueTypes.map(mapIssueType) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] listIssueTypes failed:', error) - return [] - } finally { - release() - } -} - -export async function listCreateFields( - projectIdOrKey: string, - issueTypeId: string, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - await acquire() - try { - const fields: JiraCreateField[] = [] - let startAt = 0 - const maxResults = 100 - for (let guard = 0; guard < 100; guard += 1) { - const params = new URLSearchParams({ - maxResults: String(maxResults), - startAt: String(startAt) - }) - const response = await jiraRequest>( - entry, - `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( - projectIdOrKey - )}/issuetypes/${encodeURIComponent(issueTypeId)}?${params.toString()}` - ) - const records = getCreateFieldRecords(response) - fields.push( - ...records - .map((record) => mapCreateField(record)) - .filter((field): field is JiraCreateField => field !== null) - ) - if (!shouldFetchNextPage(response, startAt, records, maxResults)) { - break - } - startAt += asFiniteNumber(response.maxResults) ?? maxResults - } - return fields - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] listCreateFields failed:', error) - return [] - } finally { - release() - } -} - -export async function listPriorities(siteId?: string | null): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - await acquire() - try { - const response = await jiraRequest(entry, `${apiBasePath(entry.site)}/priority`) - return response.map(mapPriority).filter((priority): priority is JiraPriority => !!priority) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] listPriorities failed:', error) - return [] - } finally { - release() - } -} - -export async function listAssignableUsers( - key: string, - query?: string, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - const isServer = entry.site.authType === 'server' - const params = new URLSearchParams({ issueKey: key, maxResults: '50' }) - if (query?.trim()) { - // Server/DC filters assignable users by `username`; `query` is Cloud-only. - params.set(isServer ? 'username' : 'query', query.trim()) - } - await acquire() - try { - const response = await jiraRequest( - entry, - `${apiBasePath(entry.site)}/user/assignable/search?${params.toString()}` - ) - return response.map(mapUser).filter((user): user is JiraUser => !!user) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] listAssignableUsers failed:', error) - return [] - } finally { - release() - } -} - -export async function listTransitions( - key: string, - siteId?: string | null -): Promise { - const entry = getClients(siteId)[0] - if (!entry) { - return [] - } - await acquire() - try { - const response = await jiraRequest<{ transitions?: JiraRecord[] }>( - entry, - `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/transitions` - ) - return (response.transitions ?? []).map((transition) => ({ - id: asString(transition.id), - name: asString(transition.name), - to: mapStatus(transition.to) - })) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] listTransitions failed:', error) - return [] - } finally { - release() - } -} - -export async function getProjectStatusOrder( - projectKey: string, - siteId?: string | null -): Promise { - // Why: an omitted site can resolve to the persisted "all" selection; board - // metadata is only truthful when exactly one Jira connection owns the project. - const entries = getClients(siteId) - const entry = entries.length === 1 ? entries[0] : undefined - if (!entry) { - return { statusIdsByColumn: [] } - } - await acquire() - try { - // Why: without an explicit board picker there is no truthful way to choose - // among multiple project boards, so ambiguous projects keep alphabetical order. - const params = new URLSearchParams({ projectKeyOrId: projectKey, maxResults: '2' }) - const boardsResponse = await jiraRequest>( - entry, - `/rest/agile/1.0/board?${params.toString()}` - ) - const boards = boardsResponse.values ?? [] - const boardCount = asFiniteNumber(boardsResponse.total) - const singleBoardIsProven = - boards.length === 1 && - boardsResponse.isLast !== false && - (boardCount === 1 || (boardCount === null && boardsResponse.isLast === true)) - const boardId = singleBoardIsProven ? asIdentifier(asRecord(boards[0]).id) : '' - if (!boardId) { - return { statusIdsByColumn: [] } - } - - const configResponse = await jiraRequest( - entry, - `/rest/agile/1.0/board/${encodeURIComponent(boardId)}/configuration` - ) - const columns = asRecord(asRecord(configResponse).columnConfig).columns - if (!Array.isArray(columns)) { - return { statusIdsByColumn: [] } - } - - // Why: issues already carry status names and IDs, so returning board IDs - // avoids a second metadata request and keeps duplicate names unambiguous. - const seenStatusIds = new Set() - const statusIdsByColumn: string[][] = [] - for (const column of columns) { - const statuses = asRecord(column).statuses - if (!Array.isArray(statuses)) { - continue - } - const columnStatusIds: string[] = [] - for (const status of statuses) { - const statusId = asIdentifier(asRecord(status).id) - if (statusId && !seenStatusIds.has(statusId)) { - seenStatusIds.add(statusId) - columnStatusIds.push(statusId) - } - } - if (columnStatusIds.length > 0) { - statusIdsByColumn.push(columnStatusIds) - } - } - return { statusIdsByColumn } - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.site.id) - throw error - } - console.warn('[jira] getProjectStatusOrder failed:', error) - return { statusIdsByColumn: [] } - } finally { - release() - } -} +export { mapJiraIssue } from './jira-issue-mapping' +export { listIssues, searchIssues } from './jira-issue-search' +export { getIssue, getIssueSummary } from './jira-issue-read' +export { addIssueComment, createIssue, updateIssue } from './jira-issue-mutations' +export { getIssueComments } from './jira-issue-comments' +export { listProjects } from './jira-project-queries' +export { + listAssignableUsers, + listCreateFields, + listIssueTypes, + listPriorities +} from './jira-issue-create-metadata' +export { getProjectStatusOrder, listTransitions } from './jira-transition-queries' diff --git a/src/main/jira/jira-issue-comments.ts b/src/main/jira/jira-issue-comments.ts new file mode 100644 index 00000000000..c81ce300dd4 --- /dev/null +++ b/src/main/jira/jira-issue-comments.ts @@ -0,0 +1,146 @@ +import type { JiraComment } from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { + adfToMarkdownText, + collectAdfMediaAttrs, + type AdfToMarkdownOptions, + type JiraAdfMediaAttrs +} from './adf-markdown' +import { + extractAttachmentContentIdsFromHtml, + selectPreferredAttachmentIds +} from './attachment-discovery' +import { mapUser } from './jira-issue-mapping' +import { + flushMediaResolutionWarn, + prepareMediaResolver, + type MediaRequest +} from './jira-issue-media' +import { asRecord, asString, fetchPagedRecords, type JiraRecord } from './jira-record-pages' + +function mapComment(raw: JiraRecord, adfOptions?: AdfToMarkdownOptions): JiraComment { + return { + id: asString(raw.id), + body: adfToMarkdownText(raw.body, adfOptions), + createdAt: asString(raw.created, new Date().toISOString()), + updatedAt: asString(raw.updated) || undefined, + user: mapUser(raw.author) + } +} + +/** + * Pooled comment media collect: attachment metadata JSON stays under the semaphore. + * Residual: Server/DC comment bodies are wiki markup, not ADF — this only fixes + * the lookup path; wiki `!filename!` is not rendered as media. + */ +async function collectCommentMediaRequest( + client: JiraClientForSite, + key: string, + comments: JiraRecord[] +): Promise { + const htmlIds: string[] = [] + const seen = new Set() + const mediaAttrs: JiraAdfMediaAttrs[] = [] + for (const comment of comments) { + for (const id of extractAttachmentContentIdsFromHtml(asString(comment.renderedBody))) { + if (!seen.has(id)) { + seen.add(id) + htmlIds.push(id) + } + } + mediaAttrs.push(...collectAdfMediaAttrs(comment.body)) + } + + const needingCount = mediaAttrs.filter( + (attrs) => !(attrs.url && /^https?:\/\//i.test(attrs.url)) + ).length + // Why: selectPreferredAttachmentIds yields nothing without attachment-needing media, so + // HTML ids alone can never produce a download — skip the extra metadata request entirely. + if (needingCount === 0) { + return undefined + } + + // Why: comment media usually references issue-level attachments; pull them once + // for the whole thread. Use apiBasePath so Server/DC does not 404 on /rest/api/3. + let attachmentField: unknown + try { + const issue = await jiraRequest( + client, + `${apiBasePath(client.site)}/issue/${encodeURIComponent(key)}?fields=attachment` + ) + attachmentField = asRecord(issue.fields).attachment + } catch (error) { + console.warn('[jira] comment attachment lookup failed:', error) + return undefined + } + + const selection = selectPreferredAttachmentIds({ + renderedHtmlIds: htmlIds, + attachmentField, + mediaAttrs + }) + if (selection.needCount === 0 && selection.preferredIds.length === 0) { + return undefined + } + return { + attachmentField, + preferredIds: selection.preferredIds, + needCount: selection.needCount, + fallbackRan: selection.fallbackRan, + issueKey: key + } +} + +export async function getIssueComments( + key: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + + let comments: JiraRecord[] = [] + let mediaRequest: MediaRequest | undefined + let held = false + try { + await acquire() + held = true + comments = await fetchPagedRecords(entry, 'comments', (startAt, maxResults) => { + const params = new URLSearchParams({ + maxResults: String(maxResults), + orderBy: 'created', + startAt: String(startAt), + expand: 'renderedBody' + }) + return `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment?${params.toString()}` + }) + mediaRequest = await collectCommentMediaRequest(entry, key, comments) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] getIssueComments failed:', error) + return [] + } finally { + if (held) { + held = false + release() + } + } + + try { + const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined + const mapped = comments.map((comment) => mapComment(comment, prepared?.options)) + if (prepared) { + flushMediaResolutionWarn(entry, prepared) + } + return mapped + } catch (error) { + console.warn('[jira] getIssueComments media load failed:', error) + return comments.map((comment) => mapComment(comment)) + } +} diff --git a/src/main/jira/jira-issue-create-metadata.ts b/src/main/jira/jira-issue-create-metadata.ts new file mode 100644 index 00000000000..e3c5338bd49 --- /dev/null +++ b/src/main/jira/jira-issue-create-metadata.ts @@ -0,0 +1,160 @@ +import type { + JiraCreateField, + JiraIssueType, + JiraPriority, + JiraUser +} from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { + getCreateFieldRecords, + mapCreateField, + mapIssueType, + mapPriority, + mapUser +} from './jira-issue-mapping' +import { + asFiniteNumber, + fetchPagedRecords, + shouldFetchNextPage, + type JiraPagedResponse, + type JiraRecord +} from './jira-record-pages' + +export async function listIssueTypes( + projectIdOrKey: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + await acquire() + try { + const issueTypes = await fetchPagedRecords(entry, 'issueTypes', (startAt, maxResults) => { + const params = new URLSearchParams({ + maxResults: String(maxResults), + startAt: String(startAt) + }) + // Per-project createmeta paths exist on Server/DC from Jira 8.4 onward. + return `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( + projectIdOrKey + )}/issuetypes?${params.toString()}` + }) + return issueTypes.map(mapIssueType) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listIssueTypes failed:', error) + return [] + } finally { + release() + } +} + +export async function listCreateFields( + projectIdOrKey: string, + issueTypeId: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + await acquire() + try { + const fields: JiraCreateField[] = [] + let startAt = 0 + const maxResults = 100 + for (let guard = 0; guard < 100; guard += 1) { + const params = new URLSearchParams({ + maxResults: String(maxResults), + startAt: String(startAt) + }) + const response = await jiraRequest>( + entry, + `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent( + projectIdOrKey + )}/issuetypes/${encodeURIComponent(issueTypeId)}?${params.toString()}` + ) + const records = getCreateFieldRecords(response) + fields.push( + ...records + .map((record) => mapCreateField(record)) + .filter((field): field is JiraCreateField => field !== null) + ) + if (!shouldFetchNextPage(response, startAt, records, maxResults)) { + break + } + startAt += asFiniteNumber(response.maxResults) ?? maxResults + } + return fields + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listCreateFields failed:', error) + return [] + } finally { + release() + } +} + +export async function listPriorities(siteId?: string | null): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + await acquire() + try { + const response = await jiraRequest(entry, `${apiBasePath(entry.site)}/priority`) + return response.map(mapPriority).filter((priority): priority is JiraPriority => !!priority) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listPriorities failed:', error) + return [] + } finally { + release() + } +} + +export async function listAssignableUsers( + key: string, + query?: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + const isServer = entry.site.authType === 'server' + const params = new URLSearchParams({ issueKey: key, maxResults: '50' }) + if (query?.trim()) { + // Server/DC filters assignable users by `username`; `query` is Cloud-only. + params.set(isServer ? 'username' : 'query', query.trim()) + } + await acquire() + try { + const response = await jiraRequest( + entry, + `${apiBasePath(entry.site)}/user/assignable/search?${params.toString()}` + ) + return response.map(mapUser).filter((user): user is JiraUser => !!user) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listAssignableUsers failed:', error) + return [] + } finally { + release() + } +} diff --git a/src/main/jira/jira-issue-mapping.ts b/src/main/jira/jira-issue-mapping.ts new file mode 100644 index 00000000000..cc7c9357eaf --- /dev/null +++ b/src/main/jira/jira-issue-mapping.ts @@ -0,0 +1,210 @@ +import type { + JiraCreateField, + JiraCreateFieldAllowedValue, + JiraIssue, + JiraIssueType, + JiraPriority, + JiraProject, + JiraSite, + JiraStatus, + JiraUser +} from '../../shared/jira-types' +import { adfToMarkdownText, textToAdf, type AdfToMarkdownOptions } from './adf-markdown' +import { + asRecord, + asString, + asStringArray, + type JiraPagedResponse, + type JiraRecord +} from './jira-record-pages' + +export const ISSUE_FIELDS = [ + 'summary', + 'description', + 'project', + 'issuetype', + 'status', + 'assignee', + 'reporter', + 'priority', + 'labels', + 'created', + 'updated' +] + +// Why: list/typeahead only need identity + metadata; description ADF parse is the hot-path cost. +export const ISSUE_LIST_FIELDS = ISSUE_FIELDS.filter((field) => field !== 'description') + +// Why: detail reads need attachment metadata so inline ADF media can be resolved +// to downloadable image content; list/search omit this for payload size. +export const ISSUE_DETAIL_FIELDS = [...ISSUE_FIELDS, 'attachment'] +// `created`/`updated` are required: mapJiraIssue falls back to "now" when they're absent, +// which would silently report the lookup time as the issue's timestamps. +export const ISSUE_SUMMARY_FIELDS = [ + 'summary', + 'project', + 'issuetype', + 'status', + 'created', + 'updated' +] +export function avatarUrl(value: unknown): string | undefined { + const avatars = asRecord(value) + return ( + asString(avatars['48x48']) || + asString(avatars['32x32']) || + asString(avatars['24x24']) || + undefined + ) +} + +export function mapUser(value: unknown): JiraUser | undefined { + const user = asRecord(value) + // Server/DC users have no accountId; name (login) and key are its stable ids. + const accountId = asString(user.accountId) || asString(user.name) || asString(user.key) + if (!accountId) { + return undefined + } + return { + accountId, + displayName: asString(user.displayName, 'Unknown'), + email: typeof user.emailAddress === 'string' ? user.emailAddress : undefined, + avatarUrl: avatarUrl(user.avatarUrls) + } +} + +export function mapProject(value: unknown, site?: JiraSite): JiraProject { + const project = asRecord(value) + return { + id: asString(project.id), + key: asString(project.key), + name: asString(project.name, asString(project.key)), + siteId: site?.id, + siteName: site?.displayName + } +} + +export function mapIssueType(value: unknown): JiraIssueType { + const issueType = asRecord(value) + return { + id: asString(issueType.id), + name: asString(issueType.name, 'Issue'), + description: asString(issueType.description) || undefined, + iconUrl: asString(issueType.iconUrl) || undefined, + subtask: typeof issueType.subtask === 'boolean' ? issueType.subtask : undefined + } +} + +export function mapCreateFieldAllowedValue(value: unknown): JiraCreateFieldAllowedValue { + const option = asRecord(value) + return { + id: asString(option.id) || undefined, + value: asString(option.value) || undefined, + name: asString(option.name) || undefined + } +} + +export function mapCreateField(value: unknown, fallbackKey = ''): JiraCreateField | null { + const field = asRecord(value) + const schema = asRecord(field.schema) + const key = + asString(field.key) || + asString(field.fieldId) || + asString(field.id) || + asString(field.fieldKey) || + fallbackKey + if (!key) { + return null + } + const allowedValues = Array.isArray(field.allowedValues) + ? field.allowedValues.map(mapCreateFieldAllowedValue) + : undefined + return { + key, + name: asString(field.name, key), + required: field.required === true, + schema: { + type: asString(schema.type) || undefined, + items: asString(schema.items) || undefined, + custom: asString(schema.custom) || undefined + }, + allowedValues + } +} + +export function getCreateFieldRecords(response: JiraPagedResponse): JiraRecord[] { + if (Array.isArray(response.values)) { + return response.values + } + if (Array.isArray(response.fields)) { + return response.fields + } + if (response.fields && typeof response.fields === 'object') { + return Object.entries(response.fields).map(([key, value]) => ({ + key, + ...asRecord(value) + })) + } + return [] +} + +export function mapPriority(value: unknown): JiraPriority | undefined { + const priority = asRecord(value) + const id = asString(priority.id) + if (!id) { + return undefined + } + return { + id, + name: asString(priority.name, 'Priority'), + iconUrl: asString(priority.iconUrl) || undefined + } +} + +export function mapStatus(value: unknown): JiraStatus { + const status = asRecord(value) + const category = asRecord(status.statusCategory) + return { + id: asString(status.id), + name: asString(status.name, 'Unknown'), + categoryKey: asString(category.key, 'undefined'), + categoryName: asString(category.name, 'No Category'), + colorName: asString(category.colorName) || undefined + } +} + +export function issueUrl(site: JiraSite, key: string): string { + return `${site.siteUrl}/browse/${encodeURIComponent(key)}` +} + +// REST v2 (Server/DC) bodies are plain text; v3 (Cloud) requires ADF documents. +export function toBodyText(site: JiraSite, text: string): unknown { + return site.authType === 'server' ? text : textToAdf(text) +} + +export function mapJiraIssue( + site: JiraSite, + raw: JiraRecord, + adfOptions?: AdfToMarkdownOptions +): JiraIssue { + const fields = asRecord(raw.fields) + const key = asString(raw.key) + return { + id: asString(raw.id, key), + key, + siteId: site.id, + siteName: site.displayName, + title: asString(fields.summary, key || 'Untitled issue'), + description: adfToMarkdownText(fields.description, adfOptions), + url: issueUrl(site, key), + project: mapProject(fields.project, site), + issueType: mapIssueType(fields.issuetype), + status: mapStatus(fields.status), + labels: asStringArray(fields.labels), + assignee: mapUser(fields.assignee), + reporter: mapUser(fields.reporter), + priority: mapPriority(fields.priority), + createdAt: asString(fields.created, new Date().toISOString()), + updatedAt: asString(fields.updated, new Date().toISOString()) + } +} diff --git a/src/main/jira/jira-issue-media.ts b/src/main/jira/jira-issue-media.ts new file mode 100644 index 00000000000..743717a320f --- /dev/null +++ b/src/main/jira/jira-issue-media.ts @@ -0,0 +1,104 @@ +import { collectAdfMediaAttrs, type AdfToMarkdownOptions } from './adf-markdown' +import { + extractAttachmentContentIdsFromHtml, + selectPreferredAttachmentIds, + warnIfMediaResolutionIncomplete +} from './attachment-discovery' +import { + createMediaMarkdownResolver, + loadIssueImageAttachments, + type MediaResolutionStats +} from './attachment-images' +import type { JiraClientForSite } from './authenticated-request' +import { asRecord, asString, type JiraRecord } from './jira-record-pages' + +export type MediaRequest = { + attachmentField: unknown + preferredIds: string[] + needCount: number + fallbackRan: boolean + issueKey: string +} + +/** Pooled: HTML/ADF selection only — no binary downloads. */ +export function collectIssueMediaRequest(raw: JiraRecord): MediaRequest | undefined { + const fields = asRecord(raw.fields) + const renderedFields = asRecord(raw.renderedFields) + const htmlIds = extractAttachmentContentIdsFromHtml( + asString(renderedFields.description) || undefined + ) + const mediaAttrs = collectAdfMediaAttrs(fields.description) + const selection = selectPreferredAttachmentIds({ + renderedHtmlIds: htmlIds, + attachmentField: fields.attachment, + mediaAttrs + }) + if (selection.needCount === 0 && selection.preferredIds.length === 0) { + return undefined + } + return { + attachmentField: fields.attachment, + preferredIds: selection.preferredIds, + needCount: selection.needCount, + fallbackRan: selection.fallbackRan, + issueKey: asString(raw.key) + } +} + +export type PreparedMedia = { + options: AdfToMarkdownOptions + stats: MediaResolutionStats + request: MediaRequest +} + +/** Unpooled: binary downloads + resolver (outside the Jira API semaphore). */ +export async function prepareMediaResolver( + client: JiraClientForSite, + request: MediaRequest +): Promise { + if (request.preferredIds.length === 0) { + warnIfMediaResolutionIncomplete({ + siteId: client.site.id, + issueKey: request.issueKey, + needCount: request.needCount, + preferredIdCount: 0, + resolvedCount: 0, + fallbackRan: request.fallbackRan + }) + return undefined + } + const images = await loadIssueImageAttachments( + client, + request.attachmentField, + request.preferredIds + ) + if (images.length === 0) { + warnIfMediaResolutionIncomplete({ + siteId: client.site.id, + issueKey: request.issueKey, + needCount: request.needCount, + preferredIdCount: request.preferredIds.length, + resolvedCount: 0, + fallbackRan: request.fallbackRan + }) + return undefined + } + const stats: MediaResolutionStats = { attachmentResolvedCount: 0 } + const resolveMedia = createMediaMarkdownResolver(images, request.preferredIds, stats) + return { + options: { resolveMedia }, + stats, + request + } +} + +export function flushMediaResolutionWarn(client: JiraClientForSite, prepared: PreparedMedia): void { + warnIfMediaResolutionIncomplete({ + siteId: client.site.id, + issueKey: prepared.request.issueKey, + needCount: prepared.request.needCount, + preferredIdCount: prepared.request.preferredIds.length, + resolvedCount: prepared.stats.attachmentResolvedCount, + fallbackRan: prepared.request.fallbackRan + }) +} diff --git a/src/main/jira/jira-issue-mutations.ts b/src/main/jira/jira-issue-mutations.ts new file mode 100644 index 00000000000..29b5fd7a679 --- /dev/null +++ b/src/main/jira/jira-issue-mutations.ts @@ -0,0 +1,146 @@ +import type { + JiraCreateIssueArgs, + JiraCreateIssueResult, + JiraIssueUpdate, + JiraMutationResult +} from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { issueUrl, toBodyText } from './jira-issue-mapping' +import type { JiraRecord } from './jira-record-pages' + +export async function createIssue(args: JiraCreateIssueArgs): Promise { + const entry = getClients(args.siteId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Jira.' } + } + const title = args.title.trim() + if (!title) { + return { ok: false, error: 'Title is required.' } + } + + await acquire() + try { + const fields: JiraRecord = { + project: { id: args.projectId }, + issuetype: { id: args.issueTypeId }, + summary: title + } + if (args.description?.trim()) { + fields.description = toBodyText(entry.site, args.description.trim()) + } + for (const [fieldKey, value] of Object.entries(args.customFields ?? {})) { + if (!fieldKey || value === undefined || value === null || value === '') { + continue + } + fields[fieldKey] = value + } + const created = await jiraRequest<{ id: string; key: string; self: string }>( + entry, + `${apiBasePath(entry.site)}/issue`, + { + method: 'POST', + body: JSON.stringify({ fields }) + } + ) + return { ok: true, id: created.id, key: created.key, url: issueUrl(entry.site, created.key) } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + return { ok: false, error: error instanceof Error ? error.message : 'Failed to create issue.' } + } finally { + release() + } +} + +export async function updateIssue( + key: string, + updates: JiraIssueUpdate, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Jira.' } + } + await acquire() + try { + const fields: JiraRecord = {} + if (updates.title !== undefined) { + fields.summary = updates.title + } + if (updates.labels !== undefined) { + fields.labels = updates.labels + } + if (updates.priorityId !== undefined) { + fields.priority = updates.priorityId ? { id: updates.priorityId } : null + } + const issueBase = `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}` + if (Object.keys(fields).length > 0) { + await jiraRequest(entry, issueBase, { + method: 'PUT', + body: JSON.stringify({ fields }) + }) + } + if (updates.assigneeAccountId !== undefined) { + // Server/DC identifies assignees by username (`name`), not accountId; + // mapUser stores the Server username in the accountId slot. + const assigneeBody = + entry.site.authType === 'server' + ? { name: updates.assigneeAccountId } + : { accountId: updates.assigneeAccountId } + await jiraRequest(entry, `${issueBase}/assignee`, { + method: 'PUT', + body: JSON.stringify(assigneeBody) + }) + } + if (updates.transitionId) { + await jiraRequest(entry, `${issueBase}/transitions`, { + method: 'POST', + body: JSON.stringify({ transition: { id: updates.transitionId } }) + }) + } + return { ok: true } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + return { ok: false, error: error instanceof Error ? error.message : 'Failed to update issue.' } + } finally { + release() + } +} + +export async function addIssueComment( + key: string, + body: string, + siteId?: string | null +): Promise<{ ok: true; id: string } | { ok: false; error: string }> { + const entry = getClients(siteId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Jira.' } + } + await acquire() + try { + const comment = await jiraRequest<{ id: string }>( + entry, + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment`, + { + method: 'POST', + body: JSON.stringify({ body: toBodyText(entry.site, body) }) + } + ) + return { ok: true, id: comment.id } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + return { ok: false, error: error instanceof Error ? error.message : 'Failed to add comment.' } + } finally { + release() + } +} diff --git a/src/main/jira/jira-issue-read.ts b/src/main/jira/jira-issue-read.ts new file mode 100644 index 00000000000..16889c50b3b --- /dev/null +++ b/src/main/jira/jira-issue-read.ts @@ -0,0 +1,121 @@ +import type { JiraIssue, JiraSiteSelection } from '../../shared/jira-types' +import { JiraSummaryLookupError } from '../../shared/jira-summary-lookup' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { ISSUE_DETAIL_FIELDS, ISSUE_SUMMARY_FIELDS, mapJiraIssue } from './jira-issue-mapping' +import { + collectIssueMediaRequest, + flushMediaResolutionWarn, + prepareMediaResolver, + type MediaRequest +} from './jira-issue-media' +import type { JiraRecord } from './jira-record-pages' +import { + getErrorStatus, + settleJiraSummaryRead, + shouldSurfaceSiteFailure, + withJiraDeadline +} from './jira-read-failure' + +const ISSUE_SUMMARY_TIMEOUT_MS = 30_000 + +export async function getIssue( + key: string, + siteId?: JiraSiteSelection | null +): Promise { + const entries = getClients(siteId) + for (const entry of entries) { + let mediaRequest: MediaRequest | undefined + let issue: JiraRecord | undefined + let held = false + try { + await acquire() + held = true + const params = new URLSearchParams({ + fields: ISSUE_DETAIL_FIELDS.join(','), + expand: 'renderedFields' + }) + issue = await jiraRequest( + entry, + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?${params.toString()}` + ) + // Why: keep only JSON under the pool; binary downloads fan out after release. + mediaRequest = collectIssueMediaRequest(issue) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + if (shouldSurfaceSiteFailure(siteId, entries.length)) { + throw error + } + } else { + console.warn('[jira] getIssue failed:', error) + } + continue + } finally { + if (held) { + held = false + release() + } + } + + try { + if (!issue) { + continue + } + const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined + const mapped = mapJiraIssue(entry.site, issue, prepared?.options) + if (prepared) { + flushMediaResolutionWarn(entry, prepared) + } + return mapped + } catch (error) { + console.warn('[jira] getIssue media load failed:', error) + return mapJiraIssue(entry.site, issue) + } + } + return null +} + +export async function getIssueSummary( + key: string, + siteId: string, + signal?: AbortSignal +): Promise { + let entries: JiraClientForSite[] + try { + entries = getClients(siteId) + } catch (error) { + throw new JiraSummaryLookupError('auth', error) + } + const entry = entries.find((candidate) => candidate.site.id === siteId) + if (!entry) { + throw new JiraSummaryLookupError('disconnected') + } + + return withJiraDeadline(signal, ISSUE_SUMMARY_TIMEOUT_MS, async (requestSignal) => { + await acquire(requestSignal) + try { + const params = new URLSearchParams({ fields: ISSUE_SUMMARY_FIELDS.join(',') }) + const issue = await settleJiraSummaryRead( + jiraRequest( + entry, + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?${params.toString()}`, + { signal: requestSignal } + ), + requestSignal + ) + return mapJiraIssue(entry.site, issue) + } catch (error) { + if (isAuthError(error)) { + throw new JiraSummaryLookupError('auth', error) + } + if (getErrorStatus(error) === 404) { + throw new JiraSummaryLookupError('not-found', error) + } + throw new JiraSummaryLookupError('read-failed', error) + } finally { + release() + } + }) +} diff --git a/src/main/jira/jira-issue-search.ts b/src/main/jira/jira-issue-search.ts new file mode 100644 index 00000000000..743342f7727 --- /dev/null +++ b/src/main/jira/jira-issue-search.ts @@ -0,0 +1,122 @@ +import type { JiraIssue, JiraIssueFilter, JiraSiteSelection } from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { ISSUE_LIST_FIELDS, mapJiraIssue } from './jira-issue-mapping' +import type { JiraSearchResponse } from './jira-record-pages' +import { + shouldSurfaceSiteFailure, + toIssueSearchFailureError, + withJiraDeadline, + type JiraIssueSearchFailure +} from './jira-read-failure' + +const ISSUE_SEARCH_TIMEOUT_MS = 30_000 + +function clampLimit(limit: number | undefined, fallback = 30): number { + return Math.min(Math.max(1, Number.isFinite(limit) ? Number(limit) : fallback), 100) +} + +function sortAndLimitIssues(issues: JiraIssue[], limit: number): JiraIssue[] { + return issues + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) + .slice(0, limit) +} + +function filterToJql(filter: JiraIssueFilter): string { + if (filter === 'assigned') { + return 'assignee = currentUser() AND resolution = Unresolved ORDER BY updated DESC' + } + if (filter === 'reported') { + return 'reporter = currentUser() AND resolution = Unresolved ORDER BY updated DESC' + } + if (filter === 'done') { + return 'assignee = currentUser() AND resolution IS NOT EMPTY ORDER BY updated DESC' + } + return 'resolution = Unresolved ORDER BY updated DESC' +} + +async function searchIssuesForClient( + entry: JiraClientForSite, + jql: string, + limit: number, + signal?: AbortSignal +): Promise { + // Server/DC only has the classic /search resource; /search/jql is Cloud-only. + const searchPath = + entry.site.authType === 'server' + ? `${apiBasePath(entry.site)}/search` + : '/rest/api/3/search/jql' + const result = await jiraRequest(entry, searchPath, { + method: 'POST', + body: JSON.stringify({ + jql, + maxResults: limit, + fields: ISSUE_LIST_FIELDS + }), + signal + }) + return (result.issues ?? []).map((issue) => mapJiraIssue(entry.site, issue)) +} + +export async function listIssues( + filter: JiraIssueFilter = 'assigned', + limit = 30, + siteId?: JiraSiteSelection | null +): Promise { + return searchIssues(filterToJql(filter), limit, siteId) +} + +export async function searchIssues( + jql: string, + limit = 30, + siteId?: JiraSiteSelection | null, + signal?: AbortSignal +): Promise { + const entries = getClients(siteId) + if (entries.length === 0 || !jql.trim()) { + return [] + } + const safeLimit = clampLimit(limit) + const failures: (JiraIssueSearchFailure | undefined)[] = Array.from({ length: entries.length }) + const surfaceSiteFailure = shouldSurfaceSiteFailure(siteId, entries.length) + const results = await withJiraDeadline(signal, ISSUE_SEARCH_TIMEOUT_MS, (requestSignal) => + Promise.all( + entries.map(async (entry, index) => { + // Why: queueing on an abandoned search would keep occupying the shared Jira pool. + await acquire(requestSignal) + try { + return await searchIssuesForClient(entry, jql.trim(), safeLimit, requestSignal) + } catch (error) { + if (requestSignal.aborted) { + // Abandoned by the caller: not a site failure, so don't clear tokens or mask a real one. + throw error + } + const authFailure = isAuthError(error) + if (authFailure) { + clearToken(entry.site.id) + } + if (surfaceSiteFailure) { + throw toIssueSearchFailureError(error) + } + console.warn('[jira] searchIssues failed:', error) + failures[index] = { error: toIssueSearchFailureError(error), auth: authFailure } + return [] as JiraIssue[] + } finally { + release() + } + }) + ) + ) + // 'all' fan-out: only surface an error when every connected site failed, so a + // partial success (or a genuinely empty result) is not reported as an error. + const recordedFailures = failures.filter( + (failure): failure is JiraIssueSearchFailure => failure !== undefined + ) + if (recordedFailures.length === entries.length) { + throw (recordedFailures.find((failure) => !failure.auth) ?? recordedFailures[0]).error + } + return entries.length === 1 + ? results.flat().slice(0, safeLimit) + : sortAndLimitIssues(results.flat(), safeLimit) +} diff --git a/src/main/jira/jira-project-queries.ts b/src/main/jira/jira-project-queries.ts new file mode 100644 index 00000000000..3c5c6d7b63d --- /dev/null +++ b/src/main/jira/jira-project-queries.ts @@ -0,0 +1,47 @@ +import type { JiraProject, JiraSiteSelection } from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { mapProject } from './jira-issue-mapping' +import { fetchPagedRecords, type JiraRecord } from './jira-record-pages' +import { shouldSurfaceSiteFailure } from './jira-read-failure' + +export async function listProjects(siteId?: JiraSiteSelection | null): Promise { + const entries = getClients(siteId) + if (entries.length === 0) { + return [] + } + const results = await Promise.all( + entries.map(async (entry) => { + await acquire() + try { + // Server/DC has no /project/search resource; /project returns the + // full list as a plain (unpaged) array. + const projects = + entry.site.authType === 'server' + ? await jiraRequest(entry, `${apiBasePath(entry.site)}/project`) + : await fetchPagedRecords(entry, 'values', (startAt, maxResults) => { + const params = new URLSearchParams({ + maxResults: String(maxResults), + startAt: String(startAt) + }) + return `/rest/api/3/project/search?${params.toString()}` + }) + return projects.map((project) => mapProject(project, entry.site)) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + if (shouldSurfaceSiteFailure(siteId, entries.length)) { + throw error + } + } else { + console.warn('[jira] listProjects failed:', error) + } + return [] + } finally { + release() + } + }) + ) + return results.flat().sort((a, b) => a.name.localeCompare(b.name)) +} diff --git a/src/main/jira/jira-read-failure.ts b/src/main/jira/jira-read-failure.ts new file mode 100644 index 00000000000..e87011d12b9 --- /dev/null +++ b/src/main/jira/jira-read-failure.ts @@ -0,0 +1,80 @@ +import type { JiraSiteSelection } from '../../shared/jira-types' + +export type JiraIssueSearchFailure = { + error: unknown + auth: boolean +} + +/** Run against one signal that trips on the caller's abort or the request deadline. */ +export async function withJiraDeadline( + signal: AbortSignal | undefined, + timeoutMs: number, + run: (deadlineSignal: AbortSignal) => Promise +): Promise { + const controller = new AbortController() + const abort = (): void => controller.abort() + signal?.addEventListener('abort', abort, { once: true }) + if (signal?.aborted) { + controller.abort() + } + const timer = setTimeout(abort, timeoutMs) + try { + return await run(controller.signal) + } finally { + clearTimeout(timer) + signal?.removeEventListener('abort', abort) + } +} + +export function settleJiraSummaryRead(read: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.reject(new Error('Jira summary lookup aborted')) + } + return new Promise((resolve, reject) => { + const handleAbort = (): void => { + cleanup() + reject(new Error('Jira summary lookup aborted')) + } + const cleanup = (): void => signal.removeEventListener('abort', handleAbort) + signal.addEventListener('abort', handleAbort, { once: true }) + void read.then( + (value) => { + cleanup() + resolve(value) + }, + (error: unknown) => { + cleanup() + reject(error) + } + ) + }) +} + +export function getErrorStatus(error: unknown): number | null { + if (!error || typeof error !== 'object' || !('status' in error)) { + return null + } + const status = (error as { status?: unknown }).status + return typeof status === 'number' && Number.isFinite(status) ? status : null +} + +export function toIssueSearchFailureError(error: unknown): unknown { + const status = getErrorStatus(error) + if ( + status === null || + !(error instanceof Error) || + error.message.startsWith(`Error ${status}:`) + ) { + return error + } + return new Error(`Error ${status}: ${error.message}`) +} + +export function shouldSurfaceSiteFailure( + selection: JiraSiteSelection | null | undefined, + entryCount: number +): boolean { + // getClients can resolve an omitted selection to the persisted 'all' choice; + // multi-entry reads need the same resilient fan-out policy as explicit 'all'. + return selection !== 'all' && entryCount <= 1 +} diff --git a/src/main/jira/jira-record-pages.ts b/src/main/jira/jira-record-pages.ts new file mode 100644 index 00000000000..c6bcba6ea9f --- /dev/null +++ b/src/main/jira/jira-record-pages.ts @@ -0,0 +1,96 @@ +import { jiraRequest, type JiraClientForSite } from './authenticated-request' + +export type JiraRecord = Record + +export type JiraSearchResponse = { + issues?: JiraRecord[] +} + +export type JiraPagedResponse = { + startAt?: number + maxResults?: number + total?: number + isLast?: boolean + values?: T[] + issueTypes?: T[] + comments?: T[] + fields?: T[] | Record +} + +export type JiraPageItemKey = 'values' | 'issueTypes' | 'comments' + +export function asRecord(value: unknown): JiraRecord { + return value && typeof value === 'object' ? (value as JiraRecord) : {} +} + +export function asString(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback +} + +export function asIdentifier(value: unknown): string { + if (typeof value === 'string') { + return value + } + return typeof value === 'number' && Number.isFinite(value) ? String(value) : '' +} + +export function asStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : [] +} + +export function asFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +export function getPageItems(response: JiraPagedResponse, key: JiraPageItemKey): T[] { + const keyedItems = response[key] + if (Array.isArray(keyedItems)) { + return keyedItems + } + return response.values ?? [] +} + +export function shouldFetchNextPage( + response: JiraPagedResponse, + startAt: number, + items: T[], + requestedMaxResults: number +): boolean { + if (response.isLast === true || items.length === 0) { + return false + } + const total = asFiniteNumber(response.total) + const pageSize = asFiniteNumber(response.maxResults) + if (total !== null) { + return startAt + items.length < total && (pageSize ?? requestedMaxResults) > 0 + } + if (response.isLast === false) { + return (pageSize ?? requestedMaxResults) > 0 + } + return pageSize !== null && items.length >= pageSize +} + +export async function fetchPagedRecords( + entry: JiraClientForSite, + key: JiraPageItemKey, + pathForPage: (startAt: number, maxResults: number) => string, + maxResults = 100 +): Promise { + const records: JiraRecord[] = [] + let startAt = 0 + for (let guard = 0; guard < 100; guard += 1) { + const response = await jiraRequest>( + entry, + pathForPage(startAt, maxResults) + ) + const items = getPageItems(response, key) + records.push(...items) + if (!shouldFetchNextPage(response, startAt, items, maxResults)) { + break + } + startAt += asFiniteNumber(response.maxResults) ?? maxResults + } + return records +} diff --git a/src/main/jira/jira-transition-queries.ts b/src/main/jira/jira-transition-queries.ts new file mode 100644 index 00000000000..2e40a905981 --- /dev/null +++ b/src/main/jira/jira-transition-queries.ts @@ -0,0 +1,118 @@ +import type { JiraProjectStatusOrder, JiraTransition } from '../../shared/jira-types' +import { acquire, release } from './request-queue' +import { apiBasePath, jiraRequest } from './authenticated-request' +import { clearToken, getClients, isAuthError } from './client' +import { mapStatus } from './jira-issue-mapping' +import { + asFiniteNumber, + asIdentifier, + asRecord, + asString, + type JiraPagedResponse, + type JiraRecord +} from './jira-record-pages' + +export async function listTransitions( + key: string, + siteId?: string | null +): Promise { + const entry = getClients(siteId)[0] + if (!entry) { + return [] + } + await acquire() + try { + const response = await jiraRequest<{ transitions?: JiraRecord[] }>( + entry, + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/transitions` + ) + return (response.transitions ?? []).map((transition) => ({ + id: asString(transition.id), + name: asString(transition.name), + to: mapStatus(transition.to) + })) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] listTransitions failed:', error) + return [] + } finally { + release() + } +} + +export async function getProjectStatusOrder( + projectKey: string, + siteId?: string | null +): Promise { + // Why: an omitted site can resolve to the persisted "all" selection; board + // metadata is only truthful when exactly one Jira connection owns the project. + const entries = getClients(siteId) + const entry = entries.length === 1 ? entries[0] : undefined + if (!entry) { + return { statusIdsByColumn: [] } + } + await acquire() + try { + // Why: without an explicit board picker there is no truthful way to choose + // among multiple project boards, so ambiguous projects keep alphabetical order. + const params = new URLSearchParams({ projectKeyOrId: projectKey, maxResults: '2' }) + const boardsResponse = await jiraRequest>( + entry, + `/rest/agile/1.0/board?${params.toString()}` + ) + const boards = boardsResponse.values ?? [] + const boardCount = asFiniteNumber(boardsResponse.total) + const singleBoardIsProven = + boards.length === 1 && + boardsResponse.isLast !== false && + (boardCount === 1 || (boardCount === null && boardsResponse.isLast === true)) + const boardId = singleBoardIsProven ? asIdentifier(asRecord(boards[0]).id) : '' + if (!boardId) { + return { statusIdsByColumn: [] } + } + + const configResponse = await jiraRequest( + entry, + `/rest/agile/1.0/board/${encodeURIComponent(boardId)}/configuration` + ) + const columns = asRecord(asRecord(configResponse).columnConfig).columns + if (!Array.isArray(columns)) { + return { statusIdsByColumn: [] } + } + + // Why: issues already carry status names and IDs, so returning board IDs + // avoids a second metadata request and keeps duplicate names unambiguous. + const seenStatusIds = new Set() + const statusIdsByColumn: string[][] = [] + for (const column of columns) { + const statuses = asRecord(column).statuses + if (!Array.isArray(statuses)) { + continue + } + const columnStatusIds: string[] = [] + for (const status of statuses) { + const statusId = asIdentifier(asRecord(status).id) + if (statusId && !seenStatusIds.has(statusId)) { + seenStatusIds.add(statusId) + columnStatusIds.push(statusId) + } + } + if (columnStatusIds.length > 0) { + statusIdsByColumn.push(columnStatusIds) + } + } + return { statusIdsByColumn } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] getProjectStatusOrder failed:', error) + return { statusIdsByColumn: [] } + } finally { + release() + } +} diff --git a/src/main/linear/linear-custom-view-graphql.ts b/src/main/linear/linear-custom-view-graphql.ts new file mode 100644 index 00000000000..8cdfd96dddd --- /dev/null +++ b/src/main/linear/linear-custom-view-graphql.ts @@ -0,0 +1,114 @@ +import { ORCA_ISSUE_FIELDS, ORCA_PROJECT_FIELDS } from './linear-project-graphql' + +export const CUSTOM_VIEWS_QUERY = ` + query OrcaLinearCustomViews( + $first: Int, + $filter: CustomViewFilter, + $orderBy: PaginationOrderBy + ) { + customViews(first: $first, filter: $filter, orderBy: $orderBy) { + nodes { + id + name + description + modelName + color + icon + shared + slugId + createdAt + updatedAt + team { + id + name + key + } + owner { + id + displayName + avatarUrl + } + creator { + id + displayName + avatarUrl + } + } + pageInfo { + hasNextPage + } + } + } +` + +export const CUSTOM_VIEW_QUERY = ` + query OrcaLinearCustomView($id: String!) { + customView(id: $id) { + id + name + description + modelName + color + icon + shared + slugId + createdAt + updatedAt + team { + id + name + key + } + owner { + id + displayName + avatarUrl + } + creator { + id + displayName + avatarUrl + } + } + } +` + +export const CUSTOM_VIEW_ISSUES_QUERY = ` + query OrcaLinearCustomViewIssues( + $id: String!, + $first: Int, + $after: String, + $orderBy: PaginationOrderBy + ) { + customView(id: $id) { + id + modelName + issues(first: $first, after: $after, orderBy: $orderBy) { + nodes { + ${ORCA_ISSUE_FIELDS} + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +` + +export const CUSTOM_VIEW_PROJECTS_QUERY = ` + query OrcaLinearCustomViewProjects($id: String!, $first: Int, $orderBy: PaginationOrderBy) { + customView(id: $id) { + id + modelName + projects(first: $first, orderBy: $orderBy) { + nodes { + ${ORCA_PROJECT_FIELDS} + } + pageInfo { + hasNextPage + } + } + } + } +` diff --git a/src/main/linear/linear-custom-view-queries.ts b/src/main/linear/linear-custom-view-queries.ts new file mode 100644 index 00000000000..fe225e644d3 --- /dev/null +++ b/src/main/linear/linear-custom-view-queries.ts @@ -0,0 +1,169 @@ +import type { LinearIssue } from '../../shared/linear/issue-types' +import type { + LinearCustomViewModel, + LinearCustomViewSummary, + LinearProjectSummary +} from '../../shared/linear/project-types' +import type { + LinearCollectionResult, + LinearConcreteWorkspaceId, + LinearWorkspaceSelection +} from '../../shared/linear/workspace-types' +import { clampLinearIssueListLimit } from '../../shared/linear/issue-read-limits' +import { acquire, release } from './linear-request-concurrency' +import { clearToken } from './linear-token-store' +import { getClients, isAuthError } from './client' +import { + CUSTOM_VIEW_ISSUES_QUERY, + CUSTOM_VIEW_PROJECTS_QUERY, + CUSTOM_VIEW_QUERY, + CUSTOM_VIEWS_QUERY +} from './linear-custom-view-graphql' +import type { CustomViewConnectionResponse, LinearRawVariables } from './linear-project-nodes' +import { + clampLimit, + coalesce, + mapCustomViewForWorkspace, + mapCustomViewModel, + mapProjectForWorkspace, + normalizeConcreteWorkspaceId +} from './linear-project-models' +import { readCollection, readConcreteCollection } from './linear-project-collection-read' +import { readIssueConnectionPages } from './linear-project-issue-queries' + +export async function listCustomViews( + model: LinearCustomViewModel, + limit = 20, + workspaceId?: LinearWorkspaceSelection | null, + force = false +): Promise> { + const first = clampLimit(limit) + const key = `listCustomViews:${workspaceId ?? 'default'}:${model}:${first}` + const filter = { modelName: { eq: model === 'project' ? 'Project' : 'Issue' } } + return readCollection( + key, + workspaceId, + async (entry) => { + const result = await entry.client.client.rawRequest< + CustomViewConnectionResponse, + LinearRawVariables + >(CUSTOM_VIEWS_QUERY, { first, filter, orderBy: 'updatedAt' }) + const connection = result.data?.customViews + return { + items: (connection?.nodes ?? []) + .map((view) => mapCustomViewForWorkspace(entry, view)) + .filter((view): view is LinearCustomViewSummary => !!view && view.model === model), + hasMore: !!connection?.pageInfo?.hasNextPage + } + }, + force + ) +} + +export async function getCustomView( + viewId: string, + model: LinearCustomViewModel, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise { + const id = viewId.trim() + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + if (!id) { + throw new Error('Custom view ID is required') + } + const key = `getCustomView:${concreteWorkspaceId}:${model}:${id}` + return coalesce( + key, + async () => { + const entries = getClients(concreteWorkspaceId) + const entry = entries[0] + if (!entry) { + return null + } + await acquire() + try { + const result = await entry.client.client.rawRequest< + CustomViewConnectionResponse, + LinearRawVariables + >(CUSTOM_VIEW_QUERY, { id }) + const view = result.data?.customView + const mapped = view ? mapCustomViewForWorkspace(entry, view) : null + return mapped?.model === model ? mapped : null + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } + throw error + } finally { + release() + } + }, + force + ) +} + +export async function listCustomViewIssues( + viewId: string, + limit = 20, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise> { + const id = viewId.trim() + if (!id) { + throw new Error('Custom view ID is required') + } + const first = clampLinearIssueListLimit(limit) + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + return readConcreteCollection( + `listCustomViewIssues:${concreteWorkspaceId}:${id}:${first}`, + concreteWorkspaceId, + async (entry) => { + return readIssueConnectionPages(entry, first, async (page) => { + const result = await entry.client.client.rawRequest< + CustomViewConnectionResponse, + LinearRawVariables + >(CUSTOM_VIEW_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' }) + const view = result.data?.customView + if (mapCustomViewModel(view?.modelName) !== 'issue') { + throw new Error('Custom view does not contain issues') + } + return view?.issues + }) + }, + force + ) +} + +export async function listCustomViewProjects( + viewId: string, + limit = 20, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise> { + const id = viewId.trim() + if (!id) { + throw new Error('Custom view ID is required') + } + const first = clampLimit(limit) + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + return readConcreteCollection( + `listCustomViewProjects:${concreteWorkspaceId}:${id}:${first}`, + concreteWorkspaceId, + async (entry) => { + const result = await entry.client.client.rawRequest< + CustomViewConnectionResponse, + LinearRawVariables + >(CUSTOM_VIEW_PROJECTS_QUERY, { id, first, orderBy: 'updatedAt' }) + const view = result.data?.customView + if (mapCustomViewModel(view?.modelName) !== 'project') { + throw new Error('Custom view does not contain projects') + } + const connection = view?.projects + return { + items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), + hasMore: !!connection?.pageInfo?.hasNextPage + } + }, + force + ) +} diff --git a/src/main/linear/linear-project-collection-read.ts b/src/main/linear/linear-project-collection-read.ts new file mode 100644 index 00000000000..d9924e46346 --- /dev/null +++ b/src/main/linear/linear-project-collection-read.ts @@ -0,0 +1,71 @@ +import type { + LinearCollectionResult, + LinearConcreteWorkspaceId, + LinearWorkspaceSelection +} from '../../shared/linear/workspace-types' +import { acquire, release } from './linear-request-concurrency' +import { clearToken } from './linear-token-store' +import { getClients, isAuthError, type LinearClientForWorkspace } from './client' +import { + coalesce, + normalizeConcreteWorkspaceId, + shouldFailWholeRequest, + workspaceError +} from './linear-project-models' + +export async function readCollection( + key: string, + workspaceId: LinearWorkspaceSelection | null | undefined, + load: (entry: LinearClientForWorkspace) => Promise>, + force = false +): Promise> { + return coalesce( + key, + async () => { + const entries = getClients(workspaceId) + if (entries.length === 0) { + return { items: [] } + } + + const results = await Promise.all( + entries.map(async (entry) => { + await acquire() + try { + return await load(entry) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } else { + console.warn('[linear] project/view read failed:', error) + } + if (shouldFailWholeRequest(workspaceId)) { + throw error + } + return { items: [], errors: [workspaceError(entry, error)] } + } finally { + release() + } + }) + ) + + return { + items: results.flatMap((result) => result.items), + errors: results.flatMap((result) => result.errors ?? []).length + ? results.flatMap((result) => result.errors ?? []) + : undefined, + hasMore: results.some((result) => result.hasMore) + } + }, + force + ) +} + +export async function readConcreteCollection( + key: string, + workspaceId: LinearConcreteWorkspaceId, + load: (entry: LinearClientForWorkspace) => Promise>, + force = false +): Promise> { + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + return readCollection(key, concreteWorkspaceId, load, force) +} diff --git a/src/main/linear/linear-project-graphql.ts b/src/main/linear/linear-project-graphql.ts new file mode 100644 index 00000000000..bd85f70e819 --- /dev/null +++ b/src/main/linear/linear-project-graphql.ts @@ -0,0 +1,207 @@ +export const ORCA_PROJECT_FIELDS = ` + id + slugId + name + description + content + url + color + icon + health + priority + priorityLabel + progress + scope + issueCountHistory + completedIssueCountHistory + startDate + targetDate + createdAt + updatedAt + completedAt + canceledAt + startedAt + status { + id + name + type + color + } + lead { + id + displayName + avatarUrl + } + members(first: 10) { + nodes { + id + displayName + avatarUrl + } + } + teams(first: 10) { + nodes { + id + name + key + } + } + labels(first: 20) { + nodes { + id + name + color + } + } +` + +export const ORCA_PROJECT_DETAIL_FIELDS = ` + ${ORCA_PROJECT_FIELDS} + projectMilestones(first: 20) { + nodes { + id + name + status + targetDate + progress + } + } + externalLinks(first: 20) { + nodes { + id + label + url + } + } + lastUpdate { + id + body + health + url + createdAt + updatedAt + user { + id + displayName + avatarUrl + } + } +` + +export const ORCA_ISSUE_FIELDS = ` + id + identifier + title + description + url + priority + estimate + updatedAt + labelIds + state { + name + type + color + } + team { + id + name + key + } + assignee { + id + displayName + avatarUrl + } + labels(first: 50) { + nodes { + id + name + } + } +` + +export const PROJECTS_QUERY = ` + query OrcaLinearProjects($first: Int, $filter: ProjectFilter, $orderBy: PaginationOrderBy) { + projects(first: $first, filter: $filter, orderBy: $orderBy) { + nodes { + ${ORCA_PROJECT_FIELDS} + } + pageInfo { + hasNextPage + } + } + } +` + +export const SEARCH_PROJECTS_QUERY = ` + query OrcaLinearProjectSearch($term: String!, $first: Int, $after: String) { + searchProjects(term: $term, first: $first, after: $after) { + nodes { + ${ORCA_PROJECT_FIELDS} + } + pageInfo { + hasNextPage + endCursor + } + } + } +` + +export const PROJECT_QUERY = ` + query OrcaLinearProject($id: String!) { + project(id: $id) { + ${ORCA_PROJECT_DETAIL_FIELDS} + } + } +` + +export const CREATE_PROJECT_MUTATION = ` + mutation OrcaLinearProjectCreate($input: ProjectCreateInput!) { + projectCreate(input: $input) { + success + project { + ${ORCA_PROJECT_DETAIL_FIELDS} + } + } + } +` + +export const PROJECT_ISSUES_QUERY = ` + query OrcaLinearProjectIssues( + $id: String!, + $first: Int, + $after: String, + $orderBy: PaginationOrderBy + ) { + project(id: $id) { + issues(first: $first, after: $after, orderBy: $orderBy) { + nodes { + ${ORCA_ISSUE_FIELDS} + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +` + +export const PROJECT_TEAMS_QUERY = ` + query OrcaLinearProjectTeams($id: String!, $first: Int, $after: String) { + project(id: $id) { + teams(first: $first, after: $after) { + nodes { + id + name + key + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +` diff --git a/src/main/linear/linear-project-issue-queries.ts b/src/main/linear/linear-project-issue-queries.ts new file mode 100644 index 00000000000..a2ced4a878b --- /dev/null +++ b/src/main/linear/linear-project-issue-queries.ts @@ -0,0 +1,82 @@ +import type { LinearIssue } from '../../shared/linear/issue-types' +import type { + LinearCollectionResult, + LinearConcreteWorkspaceId +} from '../../shared/linear/workspace-types' +import { + LINEAR_ISSUE_API_PAGE_SIZE_MAX, + clampLinearIssueListLimit +} from '../../shared/linear/issue-read-limits' +import type { LinearClientForWorkspace } from './client' +import { PROJECT_ISSUES_QUERY } from './linear-project-graphql' +import type { + LinearConnection, + LinearIssueNode, + LinearRawVariables, + ProjectIssueConnectionResponse +} from './linear-project-nodes' +import { mapIssueForWorkspace, normalizeConcreteWorkspaceId } from './linear-project-models' +import { readConcreteCollection } from './linear-project-collection-read' + +export async function readIssueConnectionPages( + entry: LinearClientForWorkspace, + limit: number, + loadConnection: (variables: { + first: number + after?: string + }) => Promise | null | undefined> +): Promise> { + const items: LinearIssue[] = [] + let after: string | undefined + let hasMore = false + + while (items.length < limit) { + // Why: Linear returns issue connections in pages of up to 50; expanded + // Orca reads must follow cursors to show more than one backend page. + const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - items.length) + const connection = await loadConnection(after ? { first, after } : { first }) + const nodes = connection?.nodes ?? [] + items.push(...nodes.map((issue) => mapIssueForWorkspace(entry, issue))) + hasMore = Boolean(connection?.pageInfo?.hasNextPage) + + const nextCursor = connection?.pageInfo?.endCursor ?? undefined + if (!hasMore || !nextCursor || nextCursor === after || nodes.length === 0) { + break + } + after = nextCursor + } + + return { items, hasMore } +} + +export async function listProjectIssues( + projectId: string, + limit = 20, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise> { + const id = projectId.trim() + if (!id) { + throw new Error('Project ID is required') + } + const first = clampLinearIssueListLimit(limit) + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + return readConcreteCollection( + `listProjectIssues:${concreteWorkspaceId}:${id}:${first}`, + concreteWorkspaceId, + async (entry) => { + return readIssueConnectionPages(entry, first, async (page) => { + const result = await entry.client.client.rawRequest< + ProjectIssueConnectionResponse, + LinearRawVariables + >(PROJECT_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' }) + const project = result.data?.project + if (!project) { + throw new Error('Project was not found') + } + return project.issues + }) + }, + force + ) +} diff --git a/src/main/linear/linear-project-models.ts b/src/main/linear/linear-project-models.ts new file mode 100644 index 00000000000..38371e70b1f --- /dev/null +++ b/src/main/linear/linear-project-models.ts @@ -0,0 +1,285 @@ +import type { LinearIssue } from '../../shared/linear/issue-types' +import type { + LinearCustomViewModel, + LinearCustomViewSummary, + LinearProjectDetail, + LinearProjectMemberSummary, + LinearProjectSummary +} from '../../shared/linear/project-types' +import type { + LinearConcreteWorkspaceId, + LinearWorkspaceError, + LinearWorkspaceSelection +} from '../../shared/linear/workspace-types' +import { isAuthError, type LinearClientForWorkspace } from './client' +import type { + LinearCustomViewNode, + LinearIssueNode, + LinearProjectNode, + LinearUserNode +} from './linear-project-nodes' + +const inFlight = new Map>() +export const LINEAR_PROJECT_API_PAGE_SIZE_MAX = 50 + +export function clampLimit(limit = 20): number { + return Math.min(Math.max(1, Math.floor(limit)), LINEAR_PROJECT_API_PAGE_SIZE_MAX) +} + +export function coalesce(key: string, load: () => Promise, force = false): Promise { + const existing = inFlight.get(key) as Promise | undefined + if (existing && !force) { + return existing + } + const promise = load().finally(() => { + if (inFlight.get(key) === promise) { + inFlight.delete(key) + } + }) + inFlight.set(key, promise) + return promise +} + +export function normalizeConcreteWorkspaceId(workspaceId: unknown): LinearConcreteWorkspaceId { + if (typeof workspaceId !== 'string' || !workspaceId.trim() || workspaceId === 'all') { + throw new Error('Concrete Linear workspace ID is required') + } + return workspaceId.trim() +} + +export function workspaceError( + entry: LinearClientForWorkspace, + error: unknown +): LinearWorkspaceError { + if (isAuthError(error)) { + return { + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + type: 'auth', + message: 'Linear authentication expired for this workspace.' + } + } + + const record = error as { name?: string; message?: string; status?: number; response?: unknown } + const message = record.message || 'Linear request failed.' + const status = + typeof record.status === 'number' + ? record.status + : typeof (record.response as { status?: unknown } | undefined)?.status === 'number' + ? ((record.response as { status: number }).status as number) + : undefined + const name = record.name ?? '' + + if (status === 429 || /rate/i.test(name)) { + return { + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + type: 'rate_limited', + message + } + } + if ((typeof status === 'number' && status >= 500) || /network/i.test(name)) { + return { + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + type: 'network', + message + } + } + + return { + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + type: 'unknown', + message + } +} + +export function shouldFailWholeRequest( + selection: LinearWorkspaceSelection | null | undefined +): boolean { + return selection !== 'all' +} + +export function lastNumericValue(values?: number[] | null): number | undefined { + const last = values?.at(-1) + return typeof last === 'number' ? last : undefined +} + +export function mapUser(user?: LinearUserNode | null): LinearProjectMemberSummary | undefined { + if (!user?.id) { + return undefined + } + return { + id: user.id, + displayName: user.displayName ?? '', + avatarUrl: user.avatarUrl ?? undefined + } +} + +export function mapProjectForWorkspace( + entry: LinearClientForWorkspace, + project: LinearProjectNode +): LinearProjectSummary { + return { + id: project.id, + slugId: project.slugId ?? undefined, + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + name: project.name, + url: project.url ?? undefined, + color: project.color ?? undefined, + icon: project.icon ?? undefined, + description: project.description ?? undefined, + content: project.content ?? undefined, + status: project.status + ? { + id: project.status.id, + name: project.status.name ?? '', + type: project.status.type ?? undefined, + color: project.status.color ?? undefined + } + : undefined, + health: project.health ?? null, + priority: project.priority ?? null, + priorityLabel: project.priorityLabel ?? null, + lead: mapUser(project.lead), + members: project.members?.nodes + ?.map(mapUser) + .filter((user): user is LinearProjectMemberSummary => !!user), + teams: project.teams?.nodes?.map((team) => ({ + id: team.id, + name: team.name ?? '', + key: team.key ?? undefined + })), + labels: project.labels?.nodes?.map((label) => ({ + id: label.id, + name: label.name ?? '', + color: label.color ?? undefined + })), + startDate: project.startDate ?? null, + targetDate: project.targetDate ?? null, + createdAt: project.createdAt ?? undefined, + updatedAt: project.updatedAt ?? undefined, + completedAt: project.completedAt ?? null, + canceledAt: project.canceledAt ?? null, + startedAt: project.startedAt ?? null, + progress: project.progress ?? null, + scope: project.scope ?? null, + issueCount: lastNumericValue(project.issueCountHistory), + completedIssueCount: lastNumericValue(project.completedIssueCountHistory) + } +} + +export function mapProjectDetailForWorkspace( + entry: LinearClientForWorkspace, + project: LinearProjectNode +): LinearProjectDetail { + return { + ...mapProjectForWorkspace(entry, project), + milestones: project.projectMilestones?.nodes?.map((milestone) => ({ + id: milestone.id, + name: milestone.name ?? '', + status: milestone.status ?? undefined, + targetDate: milestone.targetDate ?? null, + progress: milestone.progress ?? null + })), + resources: project.externalLinks?.nodes + ?.filter((link) => link.url) + .map((link) => ({ + id: link.id, + title: link.label || link.url || 'Link', + url: link.url!, + type: 'link' + })), + latestUpdate: project.lastUpdate + ? { + id: project.lastUpdate.id, + body: project.lastUpdate.body ?? undefined, + health: project.lastUpdate.health ?? null, + url: project.lastUpdate.url ?? undefined, + createdAt: project.lastUpdate.createdAt ?? undefined, + updatedAt: project.lastUpdate.updatedAt ?? undefined, + user: mapUser(project.lastUpdate.user) + } + : undefined + } +} + +export function mapIssueForWorkspace( + entry: LinearClientForWorkspace, + issue: LinearIssueNode +): LinearIssue { + const labelNodes = issue.labels?.nodes ?? [] + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description ?? undefined, + url: issue.url, + state: { + name: issue.state?.name ?? '', + type: issue.state?.type ?? '', + color: issue.state?.color ?? '' + }, + team: { + id: issue.team?.id ?? '', + name: issue.team?.name ?? '', + key: issue.team?.key ?? '' + }, + labels: labelNodes.map((label) => label.name), + labelIds: issue.labelIds ?? labelNodes.map((label) => label.id), + assignee: mapUser(issue.assignee), + estimate: issue.estimate ?? null, + priority: issue.priority, + updatedAt: issue.updatedAt, + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName + } +} + +export function mapCustomViewModel(modelName?: string | null): LinearCustomViewModel | null { + const normalized = modelName?.toLowerCase() + if (normalized === 'issue') { + return 'issue' + } + if (normalized === 'project') { + return 'project' + } + return null +} + +export function mapCustomViewForWorkspace( + entry: LinearClientForWorkspace, + view: LinearCustomViewNode +): LinearCustomViewSummary | null { + const model = mapCustomViewModel(view.modelName) + if (!model) { + return null + } + return { + id: view.id, + workspaceId: entry.workspace.id, + workspaceName: entry.workspace.organizationName, + name: view.name, + description: view.description ?? undefined, + model, + url: view.slugId + ? `https://linear.app/${entry.workspace.organizationUrlKey}/view/${view.slugId}` + : undefined, + color: view.color ?? undefined, + icon: view.icon ?? undefined, + shared: view.shared ?? undefined, + team: view.team + ? { + id: view.team.id, + name: view.team.name ?? undefined, + key: view.team.key ?? undefined + } + : undefined, + owner: mapUser(view.owner), + creator: mapUser(view.creator), + createdAt: view.createdAt ?? undefined, + updatedAt: view.updatedAt ?? undefined + } +} diff --git a/src/main/linear/linear-project-nodes.ts b/src/main/linear/linear-project-nodes.ts new file mode 100644 index 00000000000..2796ca35d94 --- /dev/null +++ b/src/main/linear/linear-project-nodes.ts @@ -0,0 +1,161 @@ +export type LinearRawVariables = Record + +export type PageInfoNode = { + hasNextPage?: boolean | null + endCursor?: string | null +} + +export type LinearConnection = { + nodes?: T[] | null + pageInfo?: PageInfoNode | null +} + +export type LinearUserNode = { + id: string + displayName?: string | null + avatarUrl?: string | null +} + +export type LinearProjectNode = { + id: string + slugId?: string | null + name: string + description?: string | null + content?: string | null + url?: string | null + color?: string | null + icon?: string | null + health?: string | null + priority?: number | null + priorityLabel?: string | null + progress?: number | null + scope?: number | null + issueCountHistory?: number[] | null + completedIssueCountHistory?: number[] | null + startDate?: string | null + targetDate?: string | null + createdAt?: string | null + updatedAt?: string | null + completedAt?: string | null + canceledAt?: string | null + startedAt?: string | null + status?: { + id: string + name?: string | null + type?: string | null + color?: string | null + } | null + lead?: LinearUserNode | null + members?: LinearConnection | null + teams?: LinearConnection<{ id: string; name?: string | null; key?: string | null }> | null + labels?: LinearConnection<{ id: string; name?: string | null; color?: string | null }> | null + projectMilestones?: LinearConnection<{ + id: string + name?: string | null + status?: string | null + targetDate?: string | null + progress?: number | null + }> | null + externalLinks?: LinearConnection<{ + id: string + label?: string | null + url?: string | null + }> | null + lastUpdate?: { + id: string + body?: string | null + health?: string | null + url?: string | null + createdAt?: string | null + updatedAt?: string | null + user?: LinearUserNode | null + } | null +} + +export type LinearIssueNode = { + id: string + identifier: string + title: string + description?: string | null + url: string + estimate?: number | null + priority: number + updatedAt: string + labelIds?: string[] | null + state?: { + name?: string | null + type?: string | null + color?: string | null + } | null + team?: { + id?: string | null + name?: string | null + key?: string | null + } | null + assignee?: LinearUserNode | null + labels?: LinearConnection<{ id: string; name: string }> | null +} + +export type LinearCustomViewNode = { + id: string + name: string + description?: string | null + modelName?: string | null + color?: string | null + icon?: string | null + shared?: boolean | null + slugId?: string | null + createdAt?: string | null + updatedAt?: string | null + team?: { id: string; name?: string | null; key?: string | null } | null + owner?: LinearUserNode | null + creator?: LinearUserNode | null +} + +export type ProjectConnectionResponse = { + projects?: LinearConnection | null + searchProjects?: LinearConnection | null + project?: LinearProjectNode | null +} + +export type ProjectIssueConnectionResponse = { + project?: { + issues?: LinearConnection | null + } | null +} + +export type ProjectTeamsResponse = { + project?: { + teams?: LinearConnection<{ id: string; name?: string | null; key?: string | null }> | null + } | null +} + +export type CustomViewConnectionResponse = { + customViews?: LinearConnection | null + customView?: + | (LinearCustomViewNode & { + issues?: LinearConnection | null + projects?: LinearConnection | null + }) + | null +} + +export type ProjectMutationResponse = { + projectCreate?: { + success?: boolean | null + project?: LinearProjectNode | null + } | null +} + +export type LinearProjectCreateInput = { + name: string + description?: string + content?: string + teamIds: string[] + leadId?: string + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string +} diff --git a/src/main/linear/linear-project-queries.ts b/src/main/linear/linear-project-queries.ts new file mode 100644 index 00000000000..961d2f642ba --- /dev/null +++ b/src/main/linear/linear-project-queries.ts @@ -0,0 +1,196 @@ +import type { LinearProjectDetail, LinearProjectSummary } from '../../shared/linear/project-types' +import type { + LinearCollectionResult, + LinearConcreteWorkspaceId, + LinearWorkspaceSelection +} from '../../shared/linear/workspace-types' +import { acquire, release } from './linear-request-concurrency' +import { clearToken } from './linear-token-store' +import { getClients, isAuthError } from './client' +import { + CREATE_PROJECT_MUTATION, + PROJECT_QUERY, + PROJECTS_QUERY, + SEARCH_PROJECTS_QUERY +} from './linear-project-graphql' +import type { + LinearProjectCreateInput, + LinearRawVariables, + ProjectConnectionResponse, + ProjectMutationResponse +} from './linear-project-nodes' +import { + clampLimit, + coalesce, + LINEAR_PROJECT_API_PAGE_SIZE_MAX, + mapProjectDetailForWorkspace, + mapProjectForWorkspace, + normalizeConcreteWorkspaceId +} from './linear-project-models' +import { readCollection } from './linear-project-collection-read' + +export async function listProjects( + query: string | undefined, + limit = 20, + workspaceId?: LinearWorkspaceSelection | null, + force = false +): Promise> { + const first = clampLimit(limit) + const trimmed = query?.trim() + const key = `listProjects:${workspaceId ?? 'default'}:${trimmed ?? ''}:${first}` + return readCollection( + key, + workspaceId, + async (entry) => { + const variables = trimmed ? { term: trimmed, first } : { first, orderBy: 'updatedAt' } + const result = await entry.client.client.rawRequest< + ProjectConnectionResponse, + LinearRawVariables + >(trimmed ? SEARCH_PROJECTS_QUERY : PROJECTS_QUERY, variables) + const connection = trimmed ? result.data?.searchProjects : result.data?.projects + return { + items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), + hasMore: !!connection?.pageInfo?.hasNextPage + } + }, + force + ) +} + +export async function listProjectsByExactName( + name: string, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise { + const projectName = name.trim() + if (!projectName) { + throw new Error('Project name is required') + } + const normalized = projectName.toLowerCase() + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + const key = `listProjectsByExactName:${concreteWorkspaceId}:${normalized}` + return coalesce( + key, + async () => { + const entries = getClients(concreteWorkspaceId) + const entry = entries[0] + if (!entry) { + return [] + } + await acquire() + try { + const matches: LinearProjectSummary[] = [] + let after: string | undefined + while (true) { + const result = await entry.client.client.rawRequest< + ProjectConnectionResponse, + LinearRawVariables + >(SEARCH_PROJECTS_QUERY, { + term: projectName, + first: LINEAR_PROJECT_API_PAGE_SIZE_MAX, + ...(after ? { after } : {}) + }) + const connection = result.data?.searchProjects + for (const project of connection?.nodes ?? []) { + if (project.name.trim().toLowerCase() === normalized) { + matches.push(mapProjectForWorkspace(entry, project)) + } + } + const nextCursor = connection?.pageInfo?.endCursor ?? undefined + if ( + connection?.pageInfo?.hasNextPage !== true || + !nextCursor || + nextCursor === after || + (connection.nodes ?? []).length === 0 + ) { + break + } + after = nextCursor + } + return matches + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } + throw error + } finally { + release() + } + }, + force + ) +} + +export async function getProject( + id: string, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise { + const projectId = id.trim() + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + if (!projectId) { + throw new Error('Project ID is required') + } + const key = `getProject:${concreteWorkspaceId}:${projectId}` + return coalesce( + key, + async () => { + const entries = getClients(concreteWorkspaceId) + const entry = entries[0] + if (!entry) { + return null + } + await acquire() + try { + const result = await entry.client.client.rawRequest< + ProjectConnectionResponse, + LinearRawVariables + >(PROJECT_QUERY, { id: projectId }) + return result.data?.project + ? mapProjectDetailForWorkspace(entry, result.data.project) + : null + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } + throw error + } finally { + release() + } + }, + force + ) +} + +export async function createProject( + input: LinearProjectCreateInput, + workspaceId?: string | null +): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> { + const entry = getClients(workspaceId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Linear' } + } + + await acquire() + try { + const result = await entry.client.client.rawRequest< + ProjectMutationResponse, + LinearRawVariables + >(CREATE_PROJECT_MUTATION, { input }) + const payload = result.data?.projectCreate + const project = payload?.project + if (!payload?.success || !project) { + return { ok: false, error: 'Linear project create failed' } + } + return { ok: true, project: mapProjectDetailForWorkspace(entry, project) } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + throw error + } + const message = error instanceof Error ? error.message : String(error) + return { ok: false, error: message } + } finally { + release() + } +} diff --git a/src/main/linear/linear-project-team-queries.ts b/src/main/linear/linear-project-team-queries.ts new file mode 100644 index 00000000000..569dd130220 --- /dev/null +++ b/src/main/linear/linear-project-team-queries.ts @@ -0,0 +1,77 @@ +import type { LinearProjectSummary } from '../../shared/linear/project-types' +import type { LinearConcreteWorkspaceId } from '../../shared/linear/workspace-types' +import { acquire, release } from './linear-request-concurrency' +import { clearToken } from './linear-token-store' +import { getClients, isAuthError } from './client' +import { PROJECT_TEAMS_QUERY } from './linear-project-graphql' +import type { LinearRawVariables, ProjectTeamsResponse } from './linear-project-nodes' +import { coalesce, normalizeConcreteWorkspaceId } from './linear-project-models' + +export async function listProjectTeams( + projectId: string, + workspaceId: LinearConcreteWorkspaceId, + force = false +): Promise> { + const id = projectId.trim() + if (!id) { + throw new Error('Project ID is required') + } + const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) + const key = `listProjectTeams:${concreteWorkspaceId}:${id}` + return coalesce( + key, + async () => { + const entry = getClients(concreteWorkspaceId)[0] + if (!entry) { + return [] + } + const teams: NonNullable = [] + let after: string | undefined + await acquire() + try { + while (true) { + const result = await entry.client.client.rawRequest< + ProjectTeamsResponse, + LinearRawVariables + >(PROJECT_TEAMS_QUERY, { + id, + first: 50, + ...(after ? { after } : {}) + }) + const project = result.data?.project + if (!project) { + throw new Error('Project was not found') + } + const connection = project.teams + const nodes = connection?.nodes ?? [] + teams.push( + ...nodes.map((team) => ({ + id: team.id, + name: team.name ?? '', + key: team.key ?? undefined + })) + ) + const nextCursor = connection?.pageInfo?.endCursor ?? undefined + if ( + !connection?.pageInfo?.hasNextPage || + !nextCursor || + nextCursor === after || + nodes.length === 0 + ) { + break + } + after = nextCursor + } + return teams + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + } + throw error + } finally { + release() + } + }, + force + ) +} diff --git a/src/main/linear/projects.test.ts b/src/main/linear/projects.test.ts index c8a5dd6ac09..45655495f64 100644 --- a/src/main/linear/projects.test.ts +++ b/src/main/linear/projects.test.ts @@ -388,6 +388,8 @@ describe('Linear project queries', () => { targetDate: '2026-08-01' } }) + expect(rawRequest.mock.calls[0]?.[0]).toContain('mutation OrcaLinearProjectCreate') + expect(rawRequest.mock.calls[0]?.[0]).toContain('projectCreate(input: $input)') }) it('lets manual custom view list refresh bypass older in-flight reads', async () => { diff --git a/src/main/linear/projects.ts b/src/main/linear/projects.ts index 483b1b032a6..4ea2d13c241 100644 --- a/src/main/linear/projects.ts +++ b/src/main/linear/projects.ts @@ -1,1258 +1,15 @@ -/* eslint-disable max-lines -- Why: Linear project and custom-view reads share - raw GraphQL selection sets, workspace fan-out, and partial-failure mapping. */ -import type { LinearIssue } from '../../shared/linear/issue-types' -import type { - LinearCustomViewModel, - LinearCustomViewSummary, - LinearProjectDetail, - LinearProjectMemberSummary, - LinearProjectSummary -} from '../../shared/linear/project-types' -import type { - LinearCollectionResult, - LinearConcreteWorkspaceId, - LinearWorkspaceError, - LinearWorkspaceSelection -} from '../../shared/linear/workspace-types' -import { - LINEAR_ISSUE_API_PAGE_SIZE_MAX, - clampLinearIssueListLimit -} from '../../shared/linear/issue-read-limits' -import { acquire, release } from './linear-request-concurrency' -import { clearToken } from './linear-token-store' -import { getClients, isAuthError, type LinearClientForWorkspace } from './client' - -type LinearRawVariables = Record - -type PageInfoNode = { - hasNextPage?: boolean | null - endCursor?: string | null -} - -type LinearConnection = { - nodes?: T[] | null - pageInfo?: PageInfoNode | null -} - -type LinearUserNode = { - id: string - displayName?: string | null - avatarUrl?: string | null -} - -type LinearProjectNode = { - id: string - slugId?: string | null - name: string - description?: string | null - content?: string | null - url?: string | null - color?: string | null - icon?: string | null - health?: string | null - priority?: number | null - priorityLabel?: string | null - progress?: number | null - scope?: number | null - issueCountHistory?: number[] | null - completedIssueCountHistory?: number[] | null - startDate?: string | null - targetDate?: string | null - createdAt?: string | null - updatedAt?: string | null - completedAt?: string | null - canceledAt?: string | null - startedAt?: string | null - status?: { - id: string - name?: string | null - type?: string | null - color?: string | null - } | null - lead?: LinearUserNode | null - members?: LinearConnection | null - teams?: LinearConnection<{ id: string; name?: string | null; key?: string | null }> | null - labels?: LinearConnection<{ id: string; name?: string | null; color?: string | null }> | null - projectMilestones?: LinearConnection<{ - id: string - name?: string | null - status?: string | null - targetDate?: string | null - progress?: number | null - }> | null - externalLinks?: LinearConnection<{ - id: string - label?: string | null - url?: string | null - }> | null - lastUpdate?: { - id: string - body?: string | null - health?: string | null - url?: string | null - createdAt?: string | null - updatedAt?: string | null - user?: LinearUserNode | null - } | null -} - -type LinearIssueNode = { - id: string - identifier: string - title: string - description?: string | null - url: string - estimate?: number | null - priority: number - updatedAt: string - labelIds?: string[] | null - state?: { - name?: string | null - type?: string | null - color?: string | null - } | null - team?: { - id?: string | null - name?: string | null - key?: string | null - } | null - assignee?: LinearUserNode | null - labels?: LinearConnection<{ id: string; name: string }> | null -} - -type LinearCustomViewNode = { - id: string - name: string - description?: string | null - modelName?: string | null - color?: string | null - icon?: string | null - shared?: boolean | null - slugId?: string | null - createdAt?: string | null - updatedAt?: string | null - team?: { id: string; name?: string | null; key?: string | null } | null - owner?: LinearUserNode | null - creator?: LinearUserNode | null -} - -type ProjectConnectionResponse = { - projects?: LinearConnection | null - searchProjects?: LinearConnection | null - project?: LinearProjectNode | null -} - -type ProjectIssueConnectionResponse = { - project?: { - issues?: LinearConnection | null - } | null -} - -type ProjectTeamsResponse = { - project?: { - teams?: LinearConnection<{ id: string; name?: string | null; key?: string | null }> | null - } | null -} - -type CustomViewConnectionResponse = { - customViews?: LinearConnection | null - customView?: - | (LinearCustomViewNode & { - issues?: LinearConnection | null - projects?: LinearConnection | null - }) - | null -} - -type ProjectMutationResponse = { - projectCreate?: { - success?: boolean | null - project?: LinearProjectNode | null - } | null -} - -export type LinearProjectCreateInput = { - name: string - description?: string - content?: string - teamIds: string[] - leadId?: string - memberIds?: string[] - labelIds?: string[] - priority?: number - startDate?: string - targetDate?: string -} - -const ORCA_PROJECT_FIELDS = ` - id - slugId - name - description - content - url - color - icon - health - priority - priorityLabel - progress - scope - issueCountHistory - completedIssueCountHistory - startDate - targetDate - createdAt - updatedAt - completedAt - canceledAt - startedAt - status { - id - name - type - color - } - lead { - id - displayName - avatarUrl - } - members(first: 10) { - nodes { - id - displayName - avatarUrl - } - } - teams(first: 10) { - nodes { - id - name - key - } - } - labels(first: 20) { - nodes { - id - name - color - } - } -` - -const ORCA_PROJECT_DETAIL_FIELDS = ` - ${ORCA_PROJECT_FIELDS} - projectMilestones(first: 20) { - nodes { - id - name - status - targetDate - progress - } - } - externalLinks(first: 20) { - nodes { - id - label - url - } - } - lastUpdate { - id - body - health - url - createdAt - updatedAt - user { - id - displayName - avatarUrl - } - } -` - -const ORCA_ISSUE_FIELDS = ` - id - identifier - title - description - url - priority - estimate - updatedAt - labelIds - state { - name - type - color - } - team { - id - name - key - } - assignee { - id - displayName - avatarUrl - } - labels(first: 50) { - nodes { - id - name - } - } -` - -const PROJECTS_QUERY = ` - query OrcaLinearProjects($first: Int, $filter: ProjectFilter, $orderBy: PaginationOrderBy) { - projects(first: $first, filter: $filter, orderBy: $orderBy) { - nodes { - ${ORCA_PROJECT_FIELDS} - } - pageInfo { - hasNextPage - } - } - } -` - -const SEARCH_PROJECTS_QUERY = ` - query OrcaLinearProjectSearch($term: String!, $first: Int, $after: String) { - searchProjects(term: $term, first: $first, after: $after) { - nodes { - ${ORCA_PROJECT_FIELDS} - } - pageInfo { - hasNextPage - endCursor - } - } - } -` - -const PROJECT_QUERY = ` - query OrcaLinearProject($id: String!) { - project(id: $id) { - ${ORCA_PROJECT_DETAIL_FIELDS} - } - } -` - -const CREATE_PROJECT_MUTATION = ` - mutation OrcaLinearProjectCreate($input: ProjectCreateInput!) { - projectCreate(input: $input) { - success - project { - ${ORCA_PROJECT_DETAIL_FIELDS} - } - } - } -` - -const PROJECT_ISSUES_QUERY = ` - query OrcaLinearProjectIssues( - $id: String!, - $first: Int, - $after: String, - $orderBy: PaginationOrderBy - ) { - project(id: $id) { - issues(first: $first, after: $after, orderBy: $orderBy) { - nodes { - ${ORCA_ISSUE_FIELDS} - } - pageInfo { - hasNextPage - endCursor - } - } - } - } -` - -const PROJECT_TEAMS_QUERY = ` - query OrcaLinearProjectTeams($id: String!, $first: Int, $after: String) { - project(id: $id) { - teams(first: $first, after: $after) { - nodes { - id - name - key - } - pageInfo { - hasNextPage - endCursor - } - } - } - } -` - -const CUSTOM_VIEWS_QUERY = ` - query OrcaLinearCustomViews( - $first: Int, - $filter: CustomViewFilter, - $orderBy: PaginationOrderBy - ) { - customViews(first: $first, filter: $filter, orderBy: $orderBy) { - nodes { - id - name - description - modelName - color - icon - shared - slugId - createdAt - updatedAt - team { - id - name - key - } - owner { - id - displayName - avatarUrl - } - creator { - id - displayName - avatarUrl - } - } - pageInfo { - hasNextPage - } - } - } -` - -const CUSTOM_VIEW_QUERY = ` - query OrcaLinearCustomView($id: String!) { - customView(id: $id) { - id - name - description - modelName - color - icon - shared - slugId - createdAt - updatedAt - team { - id - name - key - } - owner { - id - displayName - avatarUrl - } - creator { - id - displayName - avatarUrl - } - } - } -` - -const CUSTOM_VIEW_ISSUES_QUERY = ` - query OrcaLinearCustomViewIssues( - $id: String!, - $first: Int, - $after: String, - $orderBy: PaginationOrderBy - ) { - customView(id: $id) { - id - modelName - issues(first: $first, after: $after, orderBy: $orderBy) { - nodes { - ${ORCA_ISSUE_FIELDS} - } - pageInfo { - hasNextPage - endCursor - } - } - } - } -` - -const CUSTOM_VIEW_PROJECTS_QUERY = ` - query OrcaLinearCustomViewProjects($id: String!, $first: Int, $orderBy: PaginationOrderBy) { - customView(id: $id) { - id - modelName - projects(first: $first, orderBy: $orderBy) { - nodes { - ${ORCA_PROJECT_FIELDS} - } - pageInfo { - hasNextPage - } - } - } - } -` - -const inFlight = new Map>() -const LINEAR_PROJECT_API_PAGE_SIZE_MAX = 50 - -function clampLimit(limit = 20): number { - return Math.min(Math.max(1, Math.floor(limit)), LINEAR_PROJECT_API_PAGE_SIZE_MAX) -} - -function coalesce(key: string, load: () => Promise, force = false): Promise { - const existing = inFlight.get(key) as Promise | undefined - if (existing && !force) { - return existing - } - const promise = load().finally(() => { - if (inFlight.get(key) === promise) { - inFlight.delete(key) - } - }) - inFlight.set(key, promise) - return promise -} - -function normalizeConcreteWorkspaceId(workspaceId: unknown): LinearConcreteWorkspaceId { - if (typeof workspaceId !== 'string' || !workspaceId.trim() || workspaceId === 'all') { - throw new Error('Concrete Linear workspace ID is required') - } - return workspaceId.trim() -} - -function workspaceError(entry: LinearClientForWorkspace, error: unknown): LinearWorkspaceError { - if (isAuthError(error)) { - return { - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - type: 'auth', - message: 'Linear authentication expired for this workspace.' - } - } - - const record = error as { name?: string; message?: string; status?: number; response?: unknown } - const message = record.message || 'Linear request failed.' - const status = - typeof record.status === 'number' - ? record.status - : typeof (record.response as { status?: unknown } | undefined)?.status === 'number' - ? ((record.response as { status: number }).status as number) - : undefined - const name = record.name ?? '' - - if (status === 429 || /rate/i.test(name)) { - return { - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - type: 'rate_limited', - message - } - } - if ((typeof status === 'number' && status >= 500) || /network/i.test(name)) { - return { - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - type: 'network', - message - } - } - - return { - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - type: 'unknown', - message - } -} - -function shouldFailWholeRequest(selection: LinearWorkspaceSelection | null | undefined): boolean { - return selection !== 'all' -} - -function lastNumericValue(values?: number[] | null): number | undefined { - const last = values?.at(-1) - return typeof last === 'number' ? last : undefined -} - -function mapUser(user?: LinearUserNode | null): LinearProjectMemberSummary | undefined { - if (!user?.id) { - return undefined - } - return { - id: user.id, - displayName: user.displayName ?? '', - avatarUrl: user.avatarUrl ?? undefined - } -} - -function mapProjectForWorkspace( - entry: LinearClientForWorkspace, - project: LinearProjectNode -): LinearProjectSummary { - return { - id: project.id, - slugId: project.slugId ?? undefined, - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - name: project.name, - url: project.url ?? undefined, - color: project.color ?? undefined, - icon: project.icon ?? undefined, - description: project.description ?? undefined, - content: project.content ?? undefined, - status: project.status - ? { - id: project.status.id, - name: project.status.name ?? '', - type: project.status.type ?? undefined, - color: project.status.color ?? undefined - } - : undefined, - health: project.health ?? null, - priority: project.priority ?? null, - priorityLabel: project.priorityLabel ?? null, - lead: mapUser(project.lead), - members: project.members?.nodes - ?.map(mapUser) - .filter((user): user is LinearProjectMemberSummary => !!user), - teams: project.teams?.nodes?.map((team) => ({ - id: team.id, - name: team.name ?? '', - key: team.key ?? undefined - })), - labels: project.labels?.nodes?.map((label) => ({ - id: label.id, - name: label.name ?? '', - color: label.color ?? undefined - })), - startDate: project.startDate ?? null, - targetDate: project.targetDate ?? null, - createdAt: project.createdAt ?? undefined, - updatedAt: project.updatedAt ?? undefined, - completedAt: project.completedAt ?? null, - canceledAt: project.canceledAt ?? null, - startedAt: project.startedAt ?? null, - progress: project.progress ?? null, - scope: project.scope ?? null, - issueCount: lastNumericValue(project.issueCountHistory), - completedIssueCount: lastNumericValue(project.completedIssueCountHistory) - } -} - -function mapProjectDetailForWorkspace( - entry: LinearClientForWorkspace, - project: LinearProjectNode -): LinearProjectDetail { - return { - ...mapProjectForWorkspace(entry, project), - milestones: project.projectMilestones?.nodes?.map((milestone) => ({ - id: milestone.id, - name: milestone.name ?? '', - status: milestone.status ?? undefined, - targetDate: milestone.targetDate ?? null, - progress: milestone.progress ?? null - })), - resources: project.externalLinks?.nodes - ?.filter((link) => link.url) - .map((link) => ({ - id: link.id, - title: link.label || link.url || 'Link', - url: link.url!, - type: 'link' - })), - latestUpdate: project.lastUpdate - ? { - id: project.lastUpdate.id, - body: project.lastUpdate.body ?? undefined, - health: project.lastUpdate.health ?? null, - url: project.lastUpdate.url ?? undefined, - createdAt: project.lastUpdate.createdAt ?? undefined, - updatedAt: project.lastUpdate.updatedAt ?? undefined, - user: mapUser(project.lastUpdate.user) - } - : undefined - } -} - -function mapIssueForWorkspace( - entry: LinearClientForWorkspace, - issue: LinearIssueNode -): LinearIssue { - const labelNodes = issue.labels?.nodes ?? [] - return { - id: issue.id, - identifier: issue.identifier, - title: issue.title, - description: issue.description ?? undefined, - url: issue.url, - state: { - name: issue.state?.name ?? '', - type: issue.state?.type ?? '', - color: issue.state?.color ?? '' - }, - team: { - id: issue.team?.id ?? '', - name: issue.team?.name ?? '', - key: issue.team?.key ?? '' - }, - labels: labelNodes.map((label) => label.name), - labelIds: issue.labelIds ?? labelNodes.map((label) => label.id), - assignee: mapUser(issue.assignee), - estimate: issue.estimate ?? null, - priority: issue.priority, - updatedAt: issue.updatedAt, - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName - } -} - -function mapCustomViewModel(modelName?: string | null): LinearCustomViewModel | null { - const normalized = modelName?.toLowerCase() - if (normalized === 'issue') { - return 'issue' - } - if (normalized === 'project') { - return 'project' - } - return null -} - -function mapCustomViewForWorkspace( - entry: LinearClientForWorkspace, - view: LinearCustomViewNode -): LinearCustomViewSummary | null { - const model = mapCustomViewModel(view.modelName) - if (!model) { - return null - } - return { - id: view.id, - workspaceId: entry.workspace.id, - workspaceName: entry.workspace.organizationName, - name: view.name, - description: view.description ?? undefined, - model, - url: view.slugId - ? `https://linear.app/${entry.workspace.organizationUrlKey}/view/${view.slugId}` - : undefined, - color: view.color ?? undefined, - icon: view.icon ?? undefined, - shared: view.shared ?? undefined, - team: view.team - ? { - id: view.team.id, - name: view.team.name ?? undefined, - key: view.team.key ?? undefined - } - : undefined, - owner: mapUser(view.owner), - creator: mapUser(view.creator), - createdAt: view.createdAt ?? undefined, - updatedAt: view.updatedAt ?? undefined - } -} - -async function readIssueConnectionPages( - entry: LinearClientForWorkspace, - limit: number, - loadConnection: (variables: { - first: number - after?: string - }) => Promise | null | undefined> -): Promise> { - const items: LinearIssue[] = [] - let after: string | undefined - let hasMore = false - - while (items.length < limit) { - // Why: Linear returns issue connections in pages of up to 50; expanded - // Orca reads must follow cursors to show more than one backend page. - const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - items.length) - const connection = await loadConnection(after ? { first, after } : { first }) - const nodes = connection?.nodes ?? [] - items.push(...nodes.map((issue) => mapIssueForWorkspace(entry, issue))) - hasMore = Boolean(connection?.pageInfo?.hasNextPage) - - const nextCursor = connection?.pageInfo?.endCursor ?? undefined - if (!hasMore || !nextCursor || nextCursor === after || nodes.length === 0) { - break - } - after = nextCursor - } - - return { items, hasMore } -} - -async function readCollection( - key: string, - workspaceId: LinearWorkspaceSelection | null | undefined, - load: (entry: LinearClientForWorkspace) => Promise>, - force = false -): Promise> { - return coalesce( - key, - async () => { - const entries = getClients(workspaceId) - if (entries.length === 0) { - return { items: [] } - } - - const results = await Promise.all( - entries.map(async (entry) => { - await acquire() - try { - return await load(entry) - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } else { - console.warn('[linear] project/view read failed:', error) - } - if (shouldFailWholeRequest(workspaceId)) { - throw error - } - return { items: [], errors: [workspaceError(entry, error)] } - } finally { - release() - } - }) - ) - - return { - items: results.flatMap((result) => result.items), - errors: results.flatMap((result) => result.errors ?? []).length - ? results.flatMap((result) => result.errors ?? []) - : undefined, - hasMore: results.some((result) => result.hasMore) - } - }, - force - ) -} - -async function readConcreteCollection( - key: string, - workspaceId: LinearConcreteWorkspaceId, - load: (entry: LinearClientForWorkspace) => Promise>, - force = false -): Promise> { - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - return readCollection(key, concreteWorkspaceId, load, force) -} - -export async function listProjects( - query: string | undefined, - limit = 20, - workspaceId?: LinearWorkspaceSelection | null, - force = false -): Promise> { - const first = clampLimit(limit) - const trimmed = query?.trim() - const key = `listProjects:${workspaceId ?? 'default'}:${trimmed ?? ''}:${first}` - return readCollection( - key, - workspaceId, - async (entry) => { - const variables = trimmed ? { term: trimmed, first } : { first, orderBy: 'updatedAt' } - const result = await entry.client.client.rawRequest< - ProjectConnectionResponse, - LinearRawVariables - >(trimmed ? SEARCH_PROJECTS_QUERY : PROJECTS_QUERY, variables) - const connection = trimmed ? result.data?.searchProjects : result.data?.projects - return { - items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), - hasMore: !!connection?.pageInfo?.hasNextPage - } - }, - force - ) -} - -export async function listProjectsByExactName( - name: string, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise { - const projectName = name.trim() - if (!projectName) { - throw new Error('Project name is required') - } - const normalized = projectName.toLowerCase() - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - const key = `listProjectsByExactName:${concreteWorkspaceId}:${normalized}` - return coalesce( - key, - async () => { - const entries = getClients(concreteWorkspaceId) - const entry = entries[0] - if (!entry) { - return [] - } - await acquire() - try { - const matches: LinearProjectSummary[] = [] - let after: string | undefined - while (true) { - const result = await entry.client.client.rawRequest< - ProjectConnectionResponse, - LinearRawVariables - >(SEARCH_PROJECTS_QUERY, { - term: projectName, - first: LINEAR_PROJECT_API_PAGE_SIZE_MAX, - ...(after ? { after } : {}) - }) - const connection = result.data?.searchProjects - for (const project of connection?.nodes ?? []) { - if (project.name.trim().toLowerCase() === normalized) { - matches.push(mapProjectForWorkspace(entry, project)) - } - } - const nextCursor = connection?.pageInfo?.endCursor ?? undefined - if ( - connection?.pageInfo?.hasNextPage !== true || - !nextCursor || - nextCursor === after || - (connection.nodes ?? []).length === 0 - ) { - break - } - after = nextCursor - } - return matches - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } - }, - force - ) -} - -export async function getProject( - id: string, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise { - const projectId = id.trim() - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - if (!projectId) { - throw new Error('Project ID is required') - } - const key = `getProject:${concreteWorkspaceId}:${projectId}` - return coalesce( - key, - async () => { - const entries = getClients(concreteWorkspaceId) - const entry = entries[0] - if (!entry) { - return null - } - await acquire() - try { - const result = await entry.client.client.rawRequest< - ProjectConnectionResponse, - LinearRawVariables - >(PROJECT_QUERY, { id: projectId }) - return result.data?.project - ? mapProjectDetailForWorkspace(entry, result.data.project) - : null - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } - }, - force - ) -} - -export async function createProject( - input: LinearProjectCreateInput, - workspaceId?: string | null -): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> { - const entry = getClients(workspaceId)[0] - if (!entry) { - return { ok: false, error: 'Not connected to Linear' } - } - - await acquire() - try { - const result = await entry.client.client.rawRequest< - ProjectMutationResponse, - LinearRawVariables - >(CREATE_PROJECT_MUTATION, { input }) - const payload = result.data?.projectCreate - const project = payload?.project - if (!payload?.success || !project) { - return { ok: false, error: 'Linear project create failed' } - } - return { ok: true, project: mapProjectDetailForWorkspace(entry, project) } - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - throw error - } - const message = error instanceof Error ? error.message : String(error) - return { ok: false, error: message } - } finally { - release() - } -} - -export async function listProjectIssues( - projectId: string, - limit = 20, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise> { - const id = projectId.trim() - if (!id) { - throw new Error('Project ID is required') - } - const first = clampLinearIssueListLimit(limit) - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - return readConcreteCollection( - `listProjectIssues:${concreteWorkspaceId}:${id}:${first}`, - concreteWorkspaceId, - async (entry) => { - return readIssueConnectionPages(entry, first, async (page) => { - const result = await entry.client.client.rawRequest< - ProjectIssueConnectionResponse, - LinearRawVariables - >(PROJECT_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' }) - const project = result.data?.project - if (!project) { - throw new Error('Project was not found') - } - return project.issues - }) - }, - force - ) -} - -export async function listProjectTeams( - projectId: string, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise> { - const id = projectId.trim() - if (!id) { - throw new Error('Project ID is required') - } - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - const key = `listProjectTeams:${concreteWorkspaceId}:${id}` - return coalesce( - key, - async () => { - const entry = getClients(concreteWorkspaceId)[0] - if (!entry) { - return [] - } - const teams: NonNullable = [] - let after: string | undefined - await acquire() - try { - while (true) { - const result = await entry.client.client.rawRequest< - ProjectTeamsResponse, - LinearRawVariables - >(PROJECT_TEAMS_QUERY, { - id, - first: 50, - ...(after ? { after } : {}) - }) - const project = result.data?.project - if (!project) { - throw new Error('Project was not found') - } - const connection = project.teams - const nodes = connection?.nodes ?? [] - teams.push( - ...nodes.map((team) => ({ - id: team.id, - name: team.name ?? '', - key: team.key ?? undefined - })) - ) - const nextCursor = connection?.pageInfo?.endCursor ?? undefined - if ( - !connection?.pageInfo?.hasNextPage || - !nextCursor || - nextCursor === after || - nodes.length === 0 - ) { - break - } - after = nextCursor - } - return teams - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } - }, - force - ) -} - -export async function listCustomViews( - model: LinearCustomViewModel, - limit = 20, - workspaceId?: LinearWorkspaceSelection | null, - force = false -): Promise> { - const first = clampLimit(limit) - const key = `listCustomViews:${workspaceId ?? 'default'}:${model}:${first}` - const filter = { modelName: { eq: model === 'project' ? 'Project' : 'Issue' } } - return readCollection( - key, - workspaceId, - async (entry) => { - const result = await entry.client.client.rawRequest< - CustomViewConnectionResponse, - LinearRawVariables - >(CUSTOM_VIEWS_QUERY, { first, filter, orderBy: 'updatedAt' }) - const connection = result.data?.customViews - return { - items: (connection?.nodes ?? []) - .map((view) => mapCustomViewForWorkspace(entry, view)) - .filter((view): view is LinearCustomViewSummary => !!view && view.model === model), - hasMore: !!connection?.pageInfo?.hasNextPage - } - }, - force - ) -} - -export async function getCustomView( - viewId: string, - model: LinearCustomViewModel, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise { - const id = viewId.trim() - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - if (!id) { - throw new Error('Custom view ID is required') - } - const key = `getCustomView:${concreteWorkspaceId}:${model}:${id}` - return coalesce( - key, - async () => { - const entries = getClients(concreteWorkspaceId) - const entry = entries[0] - if (!entry) { - return null - } - await acquire() - try { - const result = await entry.client.client.rawRequest< - CustomViewConnectionResponse, - LinearRawVariables - >(CUSTOM_VIEW_QUERY, { id }) - const view = result.data?.customView - const mapped = view ? mapCustomViewForWorkspace(entry, view) : null - return mapped?.model === model ? mapped : null - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - } - throw error - } finally { - release() - } - }, - force - ) -} - -export async function listCustomViewIssues( - viewId: string, - limit = 20, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise> { - const id = viewId.trim() - if (!id) { - throw new Error('Custom view ID is required') - } - const first = clampLinearIssueListLimit(limit) - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - return readConcreteCollection( - `listCustomViewIssues:${concreteWorkspaceId}:${id}:${first}`, - concreteWorkspaceId, - async (entry) => { - return readIssueConnectionPages(entry, first, async (page) => { - const result = await entry.client.client.rawRequest< - CustomViewConnectionResponse, - LinearRawVariables - >(CUSTOM_VIEW_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' }) - const view = result.data?.customView - if (mapCustomViewModel(view?.modelName) !== 'issue') { - throw new Error('Custom view does not contain issues') - } - return view?.issues - }) - }, - force - ) -} - -export async function listCustomViewProjects( - viewId: string, - limit = 20, - workspaceId: LinearConcreteWorkspaceId, - force = false -): Promise> { - const id = viewId.trim() - if (!id) { - throw new Error('Custom view ID is required') - } - const first = clampLimit(limit) - const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) - return readConcreteCollection( - `listCustomViewProjects:${concreteWorkspaceId}:${id}:${first}`, - concreteWorkspaceId, - async (entry) => { - const result = await entry.client.client.rawRequest< - CustomViewConnectionResponse, - LinearRawVariables - >(CUSTOM_VIEW_PROJECTS_QUERY, { id, first, orderBy: 'updatedAt' }) - const view = result.data?.customView - if (mapCustomViewModel(view?.modelName) !== 'project') { - throw new Error('Custom view does not contain projects') - } - const connection = view?.projects - return { - items: (connection?.nodes ?? []).map((project) => mapProjectForWorkspace(entry, project)), - hasMore: !!connection?.pageInfo?.hasNextPage - } - }, - force - ) -} +export type { LinearProjectCreateInput } from './linear-project-nodes' +export { + createProject, + getProject, + listProjects, + listProjectsByExactName +} from './linear-project-queries' +export { listProjectIssues } from './linear-project-issue-queries' +export { listProjectTeams } from './linear-project-team-queries' +export { + getCustomView, + listCustomViewIssues, + listCustomViewProjects, + listCustomViews +} from './linear-custom-view-queries' diff --git a/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts b/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts index 5e87a90991a..9fc69c8c78d 100644 --- a/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts +++ b/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts @@ -147,6 +147,45 @@ describe('OpenCode plugin lifecycle delivery', () => { expect(posts).toEqual([{ hook_event_name: 'SessionStart', sessionID: 'root' }]) }) + it('falls back per coordinate when the endpoint file is partial or malformed', async () => { + const endpointPath = join(tempDir, 'endpoint.env') + writeFileSync( + endpointPath, + 'not-an-assignment\nORCA_AGENT_HOOK_TOKEN=file-token\nBROKEN LINE\n' + ) + process.env.ORCA_AGENT_HOOK_ENDPOINT = endpointPath + + const fetchMock = vi.fn( + async (_url: RequestInfo | URL, _init?: RequestInit) => new Response(null, { status: 204 }) + ) + globalThis.fetch = fetchMock as typeof globalThis.fetch + const handler = await loadHandler() + + await handler({ event: status('busy') }) + + expect(fetchMock).toHaveBeenCalledOnce() + const [url, init] = fetchMock.mock.calls[0]! + expect(String(url)).toBe('http://127.0.0.1:45678/hook/opencode') + expect(new Headers(init?.headers).get('X-Orca-Agent-Hook-Token')).toBe('file-token') + }) + + it('warns once for an unreadable endpoint without exposing hook credentials', async () => { + process.env.ORCA_AGENT_HOOK_ENDPOINT = tempDir + process.env.ORCA_AGENT_HOOK_TOKEN = 'fallback-secret-token' + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + const handler = await loadHandler() + await handler({ event: status('busy') }) + await handler({ event: delta('still working') }) + + expect(warn).toHaveBeenCalledOnce() + expect(warn.mock.calls.flat().join(' ')).not.toContain('fallback-secret-token') + } finally { + warn.mockRestore() + } + }) + it('preserves FIFO lifecycle order while the first session lookup is delayed', async () => { let releaseFirstLookup: (() => void) | undefined const firstLookup = new Promise((resolve) => { diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 10355c8e6d0..f0cd94610cf 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -10,6 +10,7 @@ import { symlinkSync, writeFileSync } from 'node:fs' +import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { join } from 'node:path' import { setAppEnvironment } from '../../shared/app-environment' @@ -18,7 +19,12 @@ const { getPathMock } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>() })) -import { OpenCodeHookService, _internals } from './hook-service' +import { + OpenCodeHookService, + _internals, + getOpenCodeFamilyPluginSource, + getOpenCodePluginSource +} from './hook-service' beforeEach(() => { setAppEnvironment({ @@ -35,6 +41,47 @@ beforeEach(() => { const { isUsableId, toSafeDirName } = _internals describe('OpenCode hook plugin source', () => { + it('preserves the public module surface', async () => { + const module = await import('./hook-service') + + expect(Object.keys(module).sort()).toEqual([ + 'OpenCodeHookService', + '_internals', + 'getOpenCodeFamilyPluginSource', + 'getOpenCodePluginSource', + 'openCodeHookService' + ]) + expect(Object.keys(module._internals).sort()).toEqual([ + 'getOpenCodePluginSource', + 'isUsableId', + 'toSafeDirName' + ]) + }) + + it('keeps family routing and session-start policy separate', () => { + const primarySource = getOpenCodePluginSource() + const familySource = getOpenCodeFamilyPluginSource('/hook/mimo-code', { + emitSessionStart: false + }) + + expect(primarySource).toContain('http://127.0.0.1:${coords.port}/hook/opencode') + expect(primarySource).toContain('post("SessionStart", { sessionID: info.id })') + expect(familySource).toContain('http://127.0.0.1:${coords.port}/hook/mimo-code') + expect(familySource).not.toContain('post("SessionStart", { sessionID: info.id })') + expect(familySource).toContain('export const OrcaOpenCodeStatusPlugin') + }) + + it('keeps generated plugin bytes stable across the module split', () => { + const digest = (source: string): string => createHash('sha256').update(source).digest('hex') + + expect(digest(getOpenCodePluginSource())).toBe( + 'd14859a36c88aefe3a45cd232789503296e0a23438b151c773414bad64ab8eaa' + ) + expect( + digest(getOpenCodeFamilyPluginSource('/hook/mimo-code', { emitSessionStart: false })) + ).toBe('4de14bee0c27ce55f29f70b19aa6ce9967e09b098bba139fb88f0511af7d4fca') + }) + it('filters child sessions via parentID lookup before forwarding events', () => { const source = _internals.getOpenCodePluginSource() diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 09966c7cda3..ee26acb954e 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: holds an inline JS plugin source emitted as one file; splitting across TS modules would scatter tightly coupled string-template logic. */ import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { @@ -13,6 +12,15 @@ import { } from 'node:fs' import { createHash } from 'node:crypto' import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' +import { getStatusPluginEndpointSource } from './status-plugin-endpoint-source' +import { getStatusPluginRuntimeStateSource } from './status-plugin-runtime-state-source' +import { getStatusPluginMessagePreviewSource } from './status-plugin-message-preview-source' +import { getStatusPluginSessionLineageSource } from './status-plugin-session-lineage-source' +import { getStatusPluginPostSource } from './status-plugin-post-source' +import { getStatusPluginDeliverySource } from './status-plugin-delivery-source' +import { getStatusPluginOwnershipSource } from './status-plugin-ownership-source' +import { getStatusPluginLifecycleSource } from './status-plugin-lifecycle-source' +import { getStatusPluginFactorySource } from './status-plugin-factory-source' const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE_LEGACY_HOOKS_DIR = 'opencode-hooks' @@ -45,1030 +53,15 @@ export function getOpenCodeFamilyPluginSource( ): string { // Why: the plugin posts PTY environment data from OpenCode to the shared hooks server. return [ - '// Why: process-lifetime guard so a recurring parse error on a malformed', - "// endpoint file does not spam OpenCode's stderr once per hook post.", - '// This guard lives inside the plugin source because the plugin runs in', - "// OpenCode's Node process (not Orca's) and has no access to server.ts's", - '// equivalent warnedVersions / warnedEnvs Sets.', - 'let warnedBadEndpoint = false;', - '', - '// Why: message.part.updated can fire many times per second during a', - '// streaming assistant reply, and each post() calls resolveHookCoords()', - '// which reads the endpoint file. The file only changes on Orca restart', - '// (rare), so a stat+mtime check is substantially cheaper than a full', - '// readFileSync+parse on every streamed part. On stat error we fall', - '// through to parse so the fail-open behavior is preserved.', - 'let cachedEndpointKey = "";', - 'let cachedEndpointValues = null;', - '', - 'function readEndpointFile() {', - ' const path = process.env.ORCA_AGENT_HOOK_ENDPOINT;', - ' if (!path) return null;', - ' try {', - ' const fs = require("fs");', - ' try {', - ' const stat = fs.statSync(path);', - ' // Why: cache key combines mtime + size + inode. renameSync (used by', - ' // writeEndpointFile on the Orca side) allocates a fresh inode on', - ' // POSIX and a new Windows file ID on NTFS, so ino changes on every', - ' // legitimate rewrite even when mtimeMs resolution is coarse and size', - ' // happens to match.', - ' const cacheKey = stat.mtimeMs + ":" + stat.size + ":" + stat.ino;', - ' if (cacheKey === cachedEndpointKey && cachedEndpointValues) {', - ' return cachedEndpointValues;', - ' }', - ' const contents = fs.readFileSync(path, "utf8");', - ' const out = {};', - ' for (const line of contents.split(/\\r?\\n/)) {', - ' // Why: Windows endpoint.cmd uses `set KEY=VALUE`; Unix endpoint.env', - ' // uses `KEY=VALUE`. Making `set ` optional lets the same parser', - ' // handle both without platform detection in the plugin. Allow', - ' // digits in the key for forward-compat with future ORCA_AGENT_HOOK_*', - ' // names that may contain numerics, and strip a trailing CR so', - ' // mixed-EOL files with lone `\\r` do not leak CR into the value.', - ' const m = line.match(/^(?:set\\s+)?([A-Z0-9_]+)=(.*)$/);', - ' if (m) out[m[1]] = m[2].replace(/\\r$/, "");', - ' }', - ' cachedEndpointKey = cacheKey;', - ' cachedEndpointValues = out;', - ' return out;', - ' } catch (ioErr) {', - ' // Why: any stat or read failure (file yanked mid-read, permission', - ' // race, unlink between stat and readFileSync) must invalidate the', - ' // cache so a transient failure does not lock in a stale parse for', - ' // the remaining process lifetime; rethrow to the outer catch.', - ' cachedEndpointKey = "";', - ' cachedEndpointValues = null;', - ' throw ioErr;', - ' }', - ' } catch (err) {', - ' // Why: warn once per process if the file exists but is unreadable or', - ' // malformed — a persistent, silently-swallowed parse error would', - ' // otherwise leave the plugin falling back to stale process.env on', - ' // every post with no signal. ENOENT / missing env var is the normal', - ' // pre-install case; stay silent for it.', - ' if (err && err.code !== "ENOENT" && !warnedBadEndpoint) {', - ' warnedBadEndpoint = true;', - ' console.warn("[orca-hook] failed to parse endpoint file:", err.message);', - ' }', - ' return null;', - ' }', - '}', - '', - 'function resolveHookCoords() {', - ' // Why: prefer the on-disk endpoint file over process.env because env was', - ' // frozen when OpenCode was fork()ed — stale after an Orca restart. The', - ' // file is rewritten on every Orca start(), so sourcing it per post lets', - ' // a long-running OpenCode session reach the current server. Falls back', - ' // to process.env when the file is absent (first-run / pre-endpoint-file / Orca', - ' // never started writing the file).', - ' const fileEnv = readEndpointFile() || {};', - ' return {', - ' port: fileEnv.ORCA_AGENT_HOOK_PORT || process.env.ORCA_AGENT_HOOK_PORT,', - ' token: fileEnv.ORCA_AGENT_HOOK_TOKEN || process.env.ORCA_AGENT_HOOK_TOKEN,', - ' env: fileEnv.ORCA_AGENT_HOOK_ENV || process.env.ORCA_AGENT_HOOK_ENV || "",', - ' version: fileEnv.ORCA_AGENT_HOOK_VERSION || process.env.ORCA_AGENT_HOOK_VERSION || "",', - ' };', - '}', - '', - 'function hookEndpointKey() {', - ' const coords = resolveHookCoords();', - ' return [coords.port || "", coords.token || "", coords.env, coords.version].join("\\u0000");', - '}', - '', - 'function getStatusType(event) {', - ' return event?.properties?.status?.type ?? event?.status?.type ?? null;', - '}', - '', - 'const HOOK_POST_TIMEOUT_MS = 2000;', - 'const SESSION_LOOKUP_TIMEOUT_MS = 2000;', - 'const MAX_SESSION_ANCESTRY_DEPTH = 32;', - 'const STATUS_RETRY_BASE_MS = 500;', - 'const STATUS_RETRY_MAX_MS = 30000;', - 'let desiredStatus = "idle";', - 'let desiredHookEventName = "SessionIdle";', - 'let desiredStatusKey = "idle:";', - 'let desiredStatusProperties = {};', - 'let desiredFactoryID = null;', - 'let deliveredStatusKey = "idle:";', - 'let deliveredEndpointKey = "";', - 'let statusDeliveryDirty = false;', - 'let statusRevision = 0;', - 'let statusRetryAttempt = 0;', - 'let statusRetryTimer = null;', - 'let lifecycleQueue = Promise.resolve();', - 'let busyRecoveryQueued = false;', - 'let busyRecoveryUsed = false;', - 'let busyRecoveryEndpointKey = "";', - 'let stateArrivalRevision = 0;', - '// Why: OpenCode can create directory-scoped factories and concurrent root', - '// sessions in one pane; module ownership lets waiting/busy aggregate safely.', - 'let nextFactoryID = 0;', - 'const activeFactoryIDs = new Set();', - 'const disposingFactoryIDs = new Set();', - 'const busyRootOwnerBySessionID = new Map();', - '// Why: a matching Idle must retire fail-open Busy even when the SDK client', - '// is unavailable, without granting an unrelated unknown Idle authority.', - 'const provisionalBusyByKey = new Map();', - '// Why: a background child outlives the root turn that spawned it, so the', - '// pane must stay Busy on its behalf until its own exact Idle (or its', - "// factory's disposal) retires it — otherwise the root Idle completes a task", - '// tree that is still working.', - 'const busyChildRootByKey = new Map();', - 'const pendingAttentionByKey = new Map();', - 'const rootSessionById = new Map();', - 'const rootSessionLookupById = new Map();', - '', - '// Why: message.part.updated re-sends the FULL accumulated text of the part', - '// after every streamed append, so posting each event forwards O(n^2) bytes', - '// per turn through Orca (loopback HTTP -> main JSON parse -> status compare', - '// -> IPC -> renderer store update -> React commit). On Windows that flood', - '// saturated both event loops and froze the whole UI a few seconds into a', - '// streaming reply. The dashboard only needs a bounded preview at a human', - '// cadence: cap the text and trailing-edge coalesce assistant parts.', - 'const MESSAGE_PART_THROTTLE_MS = 250;', - 'const MESSAGE_PART_MAX_CHARS = 4000;', - 'let pendingAssistantPart = null;', - 'let assistantPartFlushTimer = null;', - 'let messagePartPostInFlight = null;', - 'let deliveredMessagePartFactoryID = null;', - 'let lastAssistantPartPostAt = 0;', - '', - 'function capMessagePartText(text) {', - ' return text.length > MESSAGE_PART_MAX_CHARS ? text.slice(0, MESSAGE_PART_MAX_CHARS) : text;', - '}', - '', - 'async function postMessagePart(properties, factoryID) {', - ' while (messagePartPostInFlight) await messagePartPostInFlight;', - ' const delivery = post("MessagePart", properties);', - ' messagePartPostInFlight = delivery;', - ' try {', - ' const delivered = await delivery;', - ' if (delivered) deliveredMessagePartFactoryID = factoryID;', - ' } finally {', - ' if (messagePartPostInFlight === delivery) messagePartPostInFlight = null;', - ' }', - '}', - '', - 'async function flushPendingAssistantPart(force = false) {', - ' if (assistantPartFlushTimer) {', - ' clearTimeout(assistantPartFlushTimer);', - ' assistantPartFlushTimer = null;', - ' }', - ' // Why: an idle/waiting transition must wait for every older preview;', - ' // keep one post in flight while later snapshots coalesce in memory.', - ' while (messagePartPostInFlight) await messagePartPostInFlight;', - ' const pending = pendingAssistantPart;', - ' pendingAssistantPart = null;', - ' if (!pending) return;', - ' if (', - ' !activeFactoryIDs.has(pending.factoryID) ||', - ' disposingFactoryIDs.has(pending.factoryID)', - ' ) return;', - ' if (!force && pending.authorityRevision !== stateArrivalRevision) return;', - ' lastAssistantPartPostAt = Date.now();', - ' await postMessagePart({', - ' role: pending.role,', - ' text: capMessagePartText(pending.text),', - ' messageID: pending.messageID,', - ' sessionID: pending.sessionID,', - ' }, pending.factoryID);', - '}', - '', - 'function queueAssistantPart(part) {', - ' // Why: keep only the latest snapshot — each event already contains the', - ' // full accumulated text, so intermediate snapshots are pure waste.', - ' pendingAssistantPart = part;', - ' const sinceLastPost = Date.now() - lastAssistantPartPostAt;', - ' if (sinceLastPost >= MESSAGE_PART_THROTTLE_MS) {', - ' void flushPendingAssistantPart();', - ' return;', - ' }', - ' if (!assistantPartFlushTimer) {', - ' assistantPartFlushTimer = setTimeout(() => {', - ' void flushPendingAssistantPart();', - ' }, MESSAGE_PART_THROTTLE_MS - sinceLastPost);', - ' if (assistantPartFlushTimer.unref) assistantPartFlushTimer.unref();', - ' }', - '}', - '', - '// Why: message.part.updated fires for every Part (text, tool, reasoning)', - '// but does not include the message role — that lives on the parent', - '// message.updated event. Cache the role per messageID so the plugin can', - '// tag a TextPart as user vs assistant when POSTing. Capped at 128 entries', - '// so long-running sessions do not grow this map unboundedly.', - 'const messageRoleById = new Map();', - 'function rememberMessageRole(messageID, role) {', - ' if (!messageID || !role) return;', - ' if (messageRoleById.size >= 128) {', - ' const first = messageRoleById.keys().next().value;', - ' if (first !== undefined) messageRoleById.delete(first);', - ' }', - ' messageRoleById.set(messageID, role);', - '}', - '', - '// Why: oh-my-opencode style tools spawn child sessions that emit their', - '// own session.idle / message events. Those child completions must not', - '// flip the root Orca pane to done or overwrite the parent turn preview.', - '// Resolve the full parentID chain so descendant attention can be attributed', - '// to the root while child completion and previews remain non-authoritative.', - 'async function resolveRootSessionID(client, sessionID) {', - ' if (!sessionID) return null;', - ' if (rootSessionById.has(sessionID)) return rootSessionById.get(sessionID);', - ' if (!client?.session?.get && !client?.session?.list) return null;', - ' if (rootSessionLookupById.has(sessionID)) return rootSessionLookupById.get(sessionID);', - ' const lookup = lookupRootSessionID(client, sessionID);', - ' rootSessionLookupById.set(sessionID, lookup);', - ' try {', - ' return await lookup;', - ' } finally {', - ' if (rootSessionLookupById.get(sessionID) === lookup) {', - ' rootSessionLookupById.delete(sessionID);', - ' }', - ' }', - '}', - '', - 'async function isChildSession(client, sessionID) {', - ' const rootSessionID = await resolveRootSessionID(client, sessionID);', - ' return rootSessionID === null ? null : rootSessionID !== sessionID;', - '}', - '', - 'function rememberSessionRoot(sessionID, rootSessionID) {', - ' if (rootSessionById.size >= 128 && !rootSessionById.has(sessionID)) {', - ' const first = rootSessionById.keys().next().value;', - ' if (first !== undefined) rootSessionById.delete(first);', - ' }', - ' rootSessionById.set(sessionID, rootSessionID);', - '}', - '', - 'async function lookupRootSessionID(client, sessionID) {', - ' const controller = new AbortController();', - ' let timeout;', - ' const deadline = new Promise((_, reject) => {', - ' timeout = setTimeout(() => {', - ' controller.abort();', - ' reject(new Error("session lookup timed out"));', - ' }, SESSION_LOOKUP_TIMEOUT_MS);', - ' if (timeout.unref) timeout.unref();', - ' });', - ' try {', - ' const rootSessionID = await Promise.race([', - ' walkSessionParents(client, sessionID, controller.signal),', - ' deadline,', - ' ]);', - ' return rootSessionID;', - ' } catch {', - ' return null;', - ' } finally {', - ' clearTimeout(timeout);', - ' }', - '}', - '', - 'async function walkSessionParents(client, sessionID, signal) {', - ' const lineage = [];', - ' let currentSessionID = sessionID;', - ' // Why: malformed or unexpectedly deep ancestry must not monopolize the', - ' // lifecycle FIFO even when every individual SDK lookup succeeds.', - ' while (lineage.length < MAX_SESSION_ANCESTRY_DEPTH) {', - ' const cachedRoot = rootSessionById.get(currentSessionID);', - ' if (cachedRoot) {', - ' for (const id of lineage) rememberSessionRoot(id, cachedRoot);', - ' return cachedRoot;', - ' }', - ' if (lineage.includes(currentSessionID)) return null;', - ' lineage.push(currentSessionID);', - ' const sessions = await lookupSessionList(client, currentSessionID, signal);', - ' const list = Array.isArray(sessions?.data) ? sessions.data : [];', - ' const session = list.find((entry) => entry?.id === currentSessionID);', - ' if (!session) return null;', - ' if (!session.parentID) {', - ' for (const id of lineage) rememberSessionRoot(id, currentSessionID);', - ' return currentSessionID;', - ' }', - ' currentSessionID = session.parentID;', - ' }', - ' return null;', - '}', - '', - 'async function lookupSessionList(client, sessionID, signal) {', - ' // Why: point lookup avoids the SDK list page dropping older children;', - ' // current SDKs put AbortSignal in a second options argument, while legacy', - ' // generated clients accept one request-options object.', - ' if (client?.session?.get) {', - ' const calls = client.session.get.length >= 2', - ' ? [', - ' [{ sessionID }, { signal }],', - ' [{ path: { id: sessionID }, signal }],', - ' ]', - ' : [[{ path: { id: sessionID }, signal }]];', - ' for (const args of calls) {', - ' try {', - ' const result = await client.session.get(...args);', - ' if (result?.data?.id === sessionID) return { data: [result.data] };', - ' } catch {', - ' if (signal.aborted) throw new Error("session lookup aborted");', - ' // Try the other supported SDK generation, then list fallback.', - ' }', - ' }', - ' }', - ' if (!client?.session?.list) return { data: [] };', - ' if (client.session.list.length >= 2) {', - ' return client.session.list({}, { signal });', - ' }', - ' return client.session.list({ signal });', - '}', - '', - 'async function post(hookEventName, extraProperties) {', - ' // Why: resolve coords per post — the endpoint file may have been', - ' // rewritten by a newer Orca since the last call. Pane/tab/worktree IDs', - ' // stay on process.env because they are per-PTY (stable for the life of', - ' // the OpenCode process), not per-Orca-instance.', - ' const coords = resolveHookCoords();', - ' const paneKey = process.env.ORCA_PANE_KEY;', - ' if (!coords.port || !coords.token || !paneKey) return false;', - ` const url = \`http://127.0.0.1:\${coords.port}${hookPathname}\`;`, - ' const body = JSON.stringify({', - ' paneKey,', - ' launchToken: process.env.ORCA_AGENT_LAUNCH_TOKEN || "",', - ' tabId: process.env.ORCA_TAB_ID || "",', - ' worktreeId: process.env.ORCA_WORKTREE_ID || "",', - ' env: coords.env,', - ' version: coords.version,', - ' payload: { hook_event_name: hookEventName, ...(extraProperties || {}) },', - ' });', - ' const controller = new AbortController();', - ' const timeout = setTimeout(() => controller.abort(), HOOK_POST_TIMEOUT_MS);', - ' if (timeout.unref) timeout.unref();', - ' try {', - ' const response = await fetch(url, {', - ' method: "POST",', - ' headers: {', - ' "Content-Type": "application/json",', - ' "X-Orca-Agent-Hook-Token": coords.token,', - ' },', - ' body,', - ' signal: controller.signal,', - ' });', - ' return response.ok;', - ' } catch {', - ' // Why: OpenCode session events must never fail the agent run just', - ' // because Orca is unavailable or the local loopback request failed.', - ' return false;', - ' } finally {', - ' clearTimeout(timeout);', - ' }', - '}', - '', - 'function enqueueLifecycle(task) {', - ' // Why: OpenCode intentionally fire-and-forgets hook promises, so async', - ' // lookups and posts need their own FIFO to preserve event order.', - ' const run = lifecycleQueue.then(async () => {', - ' try {', - ' await task();', - ' } catch {', - ' // Hook delivery must never reject into OpenCode.', - ' }', - ' });', - ' lifecycleQueue = run;', - ' return run;', - '}', - '', - 'function clearStatusRetry() {', - ' if (statusRetryTimer) clearTimeout(statusRetryTimer);', - ' statusRetryTimer = null;', - '}', - '', - 'function scheduleStatusRetry(revision) {', - ' if (', - ' statusRetryTimer ||', - ' revision !== statusRevision ||', - ' !statusDeliveryDirty ||', - ' !activeFactoryIDs.has(desiredFactoryID)', - ' ) return;', - ' const delay = Math.min(', - ' STATUS_RETRY_BASE_MS * Math.pow(2, Math.min(statusRetryAttempt, 6)),', - ' STATUS_RETRY_MAX_MS', - ' );', - ' statusRetryAttempt = Math.min(statusRetryAttempt + 1, 7);', - ' statusRetryTimer = setTimeout(() => {', - ' statusRetryTimer = null;', - ' void enqueueLifecycle(async () => {', - ' if (', - ' revision !== statusRevision ||', - ' !statusDeliveryDirty ||', - ' !activeFactoryIDs.has(desiredFactoryID)', - ' ) return;', - ' await publishDesiredStatus(revision);', - ' });', - ' }, delay);', - ' if (statusRetryTimer.unref) statusRetryTimer.unref();', - '}', - '', - 'async function publishDesiredStatus(revision) {', - ' if (revision !== statusRevision) return;', - ' if (!activeFactoryIDs.has(desiredFactoryID)) return;', - ' const endpointKey = hookEndpointKey();', - ' if (', - ' !statusDeliveryDirty &&', - ' deliveredStatusKey === desiredStatusKey &&', - ' deliveredEndpointKey === endpointKey &&', - ' deliveredMessagePartFactoryID === null', - ' ) return;', - ' const delivered = await post(desiredHookEventName, desiredStatusProperties);', - ' if (revision !== statusRevision) return;', - ' if (!delivered) {', - ' statusDeliveryDirty = true;', - ' scheduleStatusRetry(revision);', - ' return;', - ' }', - ' clearStatusRetry();', - ' statusRetryAttempt = 0;', - ' deliveredStatusKey = desiredStatusKey;', - ' deliveredEndpointKey = endpointKey;', - ' deliveredMessagePartFactoryID = null;', - ' statusDeliveryDirty = false;', - '}', - '', - 'async function setDeliveryTarget(', - ' next,', - ' nextKey,', - ' hookEventName,', - ' extraProperties,', - ' factoryID', - ') {', - ' const endpointChanged = deliveredEndpointKey !== hookEndpointKey();', - ' if (', - ' nextKey === desiredStatusKey &&', - ' nextKey === deliveredStatusKey &&', - ' desiredFactoryID === factoryID &&', - ' !statusDeliveryDirty &&', - ' !endpointChanged &&', - ' deliveredMessagePartFactoryID === null', - ' ) return;', - ' const targetChanged = nextKey !== desiredStatusKey || desiredFactoryID !== factoryID;', - ' clearStatusRetry();', - ' if (targetChanged) {', - ' statusRetryAttempt = 0;', - ' busyRecoveryUsed = false;', - ' busyRecoveryEndpointKey = "";', - ' }', - ' desiredStatus = next;', - ' desiredHookEventName = hookEventName;', - ' desiredStatusKey = nextKey;', - ' desiredStatusProperties = extraProperties || {};', - ' desiredFactoryID = factoryID;', - ' statusDeliveryDirty =', - ' statusDeliveryDirty ||', - ' deliveredMessagePartFactoryID !== null ||', - ' deliveredStatusKey !== nextKey ||', - ' endpointChanged;', - ' const revision = ++statusRevision;', - ' await publishDesiredStatus(revision);', - '}', - '', - 'async function setStatus(next, extraProperties, factoryID) {', - ' const nextKey = next + ":" + (extraProperties?.sessionID || "");', - ' const hookEventName = next === "busy" ? "SessionBusy" : "SessionIdle";', - ' await setDeliveryTarget(next, nextKey, hookEventName, extraProperties, factoryID);', - '}', - '', - 'async function setAttention(hookEventName, properties, factoryID, sourceSessionID) {', - ' const requestID = properties?.id || properties?.sessionID || "";', - ' const requestKey = attentionKey(factoryID, hookEventName, requestID, sourceSessionID);', - ' await flushPendingAssistantPart(true);', - ' await setDeliveryTarget(', - ' "waiting",', - ' "waiting:" + requestKey,', - ' hookEventName,', - ' properties,', - ' factoryID', - ' );', - '}', - '', - 'function recoverBusyFromDelta(client, sessionID, factoryID) {', - ' if (busyRecoveryQueued) return lifecycleQueue;', - ' if (', - ' busyRecoveryUsed &&', - ' busyRecoveryEndpointKey === hookEndpointKey()', - ' ) return lifecycleQueue;', - ' busyRecoveryQueued = true;', - ' return enqueueLifecycle(async () => {', - ' try {', - ' if (!activeFactoryIDs.has(factoryID)) return;', - ' if (sessionID && (await isChildSession(client, sessionID)) === true) return;', - ' if (!activeFactoryIDs.has(factoryID)) return;', - ' const endpointKey = hookEndpointKey();', - ' const endpointChanged = deliveredEndpointKey !== endpointKey;', - ' if (desiredStatus !== "busy" || (!statusDeliveryDirty && !endpointChanged)) return;', - ' if (busyRecoveryUsed && !endpointChanged) return;', - ' busyRecoveryUsed = true;', - ' busyRecoveryEndpointKey = endpointKey;', - ' clearStatusRetry();', - ' statusDeliveryDirty = true;', - ' const revision = ++statusRevision;', - ' await publishDesiredStatus(revision);', - ' } finally {', - ' busyRecoveryQueued = false;', - ' }', - ' });', - '}', - '', - 'function currentAttention() {', - ' let latestQuestion = null;', - ' for (const attention of pendingAttentionByKey.values()) {', - ' if (attention.hookEventName === "PermissionRequest") return attention;', - ' latestQuestion = attention;', - ' }', - ' return latestQuestion;', - '}', - '', - 'function attentionKey(factoryID, hookEventName, requestID, sourceSessionID) {', - ' // Why: custom plugins may reuse request IDs across sessions or factories;', - ' // JSON tuple identity prevents one owner from hiding another blocker.', - ' return JSON.stringify([factoryID, hookEventName, requestID, sourceSessionID || ""]);', - '}', - '', - 'function clearAttentionForSession(sessionID, factoryID) {', - ' let rootSessionID = null;', - ' for (const [key, attention] of pendingAttentionByKey) {', - ' if (attention.sourceSessionID === sessionID && attention.factoryID === factoryID) {', - ' pendingAttentionByKey.delete(key);', - ' rootSessionID = attention.properties?.sessionID || sessionID;', - ' }', - ' }', - ' return rootSessionID;', - '}', - '', - 'function clearQuestionForToolPart(part, sessionID, factoryID) {', - ' if (', - ' part?.type !== "tool" ||', - ' part.tool !== "question" ||', - ' (part.state?.status !== "completed" && part.state?.status !== "error")', - ' ) return null;', - ' let rootSessionID = null;', - ' for (const [key, attention] of pendingAttentionByKey) {', - ' const tool = attention.properties?.tool;', - ' if (', - ' attention.hookEventName === "AskUserQuestion" &&', - ' attention.sourceSessionID === sessionID &&', - ' attention.factoryID === factoryID &&', - ' tool?.messageID === part.messageID &&', - ' tool?.callID === part.callID', - ' ) {', - ' pendingAttentionByKey.delete(key);', - ' rootSessionID = attention.properties?.sessionID || sessionID;', - ' }', - ' }', - ' return rootSessionID;', - '}', - '', - 'function clearAttentionForResolution(event, sessionID, factoryID) {', - ' const hookEventName =', - ' event.type === "permission.replied" ? "PermissionRequest" : "AskUserQuestion";', - ' const requestID = event.properties?.requestID || "";', - ' const key = attentionKey(factoryID, hookEventName, requestID, sessionID);', - ' const attention = pendingAttentionByKey.get(key);', - ' if (!attention || attention.sourceSessionID !== sessionID) return null;', - ' pendingAttentionByKey.delete(key);', - ' return attention.properties?.sessionID || sessionID;', - '}', - '', - 'function provisionalBusyKey(factoryID, sessionID) {', - ' return JSON.stringify([factoryID, sessionID || ""]);', - '}', - '', - 'function rememberProvisionalBusy(sessionID, factoryID) {', - ' const key = provisionalBusyKey(factoryID, sessionID);', - ' // Why: active ownership cannot be LRU-evicted without allowing false', - ' // Idle; exact matching Idle or factory disposal lifecycle-bounds it.', - ' provisionalBusyByKey.delete(key);', - ' provisionalBusyByKey.set(key, { sessionID, factoryID });', - '}', - '', - 'function clearProvisionalBusy(sessionID, factoryID) {', - ' return provisionalBusyByKey.delete(provisionalBusyKey(factoryID, sessionID));', - '}', - '', - 'function busyChildKey(factoryID, sessionID) {', - ' return JSON.stringify([factoryID, sessionID || ""]);', - '}', - '', - 'function rememberBusyChild(sessionID, rootSessionID, factoryID) {', - ' const key = busyChildKey(factoryID, sessionID);', - ' // Why: repeated Busy for the same child is not a state change; report one', - ' // only when the pane actually gains a running descendant.', - ' if (busyChildRootByKey.get(key)?.sessionID === rootSessionID) return false;', - ' busyChildRootByKey.delete(key);', - ' busyChildRootByKey.set(key, { sessionID: rootSessionID, factoryID });', - ' return true;', - '}', - '', - 'function clearBusyChild(sessionID, factoryID) {', - ' return busyChildRootByKey.delete(busyChildKey(factoryID, sessionID));', - '}', - '', - 'function clearKnownBusyRoot(sessionID, factoryID) {', - ' if (busyRootOwnerBySessionID.get(sessionID) !== factoryID) return false;', - ' busyRootOwnerBySessionID.delete(sessionID);', - ' return true;', - '}', - '', - 'function latestBusyOwner() {', - ' let latest = null;', - ' // Why: a running descendant is reported under its ROOT id, and ranks', - ' // below a directly busy root so an ongoing turn keeps labelling the pane.', - ' for (const busyChild of busyChildRootByKey.values()) {', - ' latest = busyChild;', - ' }', - ' for (const [sessionID, factoryID] of busyRootOwnerBySessionID) {', - ' latest = { sessionID, factoryID };', - ' }', - ' for (const provisional of provisionalBusyByKey.values()) {', - ' latest = provisional;', - ' }', - ' return latest;', - '}', - '', - 'async function publishAggregateStatus(fallbackFactoryID, preferredSessionID) {', - ' const attention = currentAttention();', - ' if (attention) {', - ' await setAttention(', - ' attention.hookEventName,', - ' attention.properties,', - ' attention.factoryID,', - ' attention.sourceSessionID', - ' );', - ' return;', - ' }', - ' const busyOwner = latestBusyOwner();', - ' if (busyOwner) {', - ' await setStatus("busy", { sessionID: busyOwner.sessionID }, busyOwner.factoryID);', - ' return;', - ' }', - ' await setStatus("idle", { sessionID: preferredSessionID }, fallbackFactoryID);', - '}', - '', - 'async function publishOwnershipChange(fallbackFactoryID, preferredSessionID) {', - ' stateArrivalRevision += 1;', - ' // Why: exact blocker/Busy retirement is authoritative even if ancestry', - ' // lookup failed; every older preview must settle before its replacement.', - ' await flushPendingAssistantPart(true);', - ' await publishAggregateStatus(fallbackFactoryID, preferredSessionID);', - '}', - '', - 'async function handleLifecycleEvent(client, event, factoryID) {', - ' const sessionID = event.properties?.sessionID;', - ' const statusType = getStatusType(event);', - ' const isResolutionEvent =', - ' event.type === "permission.replied" ||', - ' event.type === "question.replied" ||', - ' event.type === "question.rejected";', - ' if (isResolutionEvent) {', - ' // Why: the stored owner already identifies the root, so replies clear', - ' // immediately even when OpenCode session lookup is slow or unavailable.', - ' const rootSessionID = clearAttentionForResolution(event, sessionID, factoryID);', - ' if (rootSessionID) await publishOwnershipChange(factoryID, rootSessionID);', - ' return;', - ' }', - ' const isAttentionEvent =', - ' event.type === "permission.asked" ||', - ' event.type === "question.asked";', - " // Why: attention without OpenCode's required sessionID cannot be", - ' // correlated to a later reply/Idle, so it must not become UI authority.', - ' if (isAttentionEvent && !sessionID) return;', - ' const canFailOpen =', - ' statusType === "busy" || statusType === "retry" || isAttentionEvent;', - ' const rootSessionID = sessionID ? await resolveRootSessionID(client, sessionID) : null;', - ' const childState = rootSessionID === null ? null : rootSessionID !== sessionID;', - ' const isIdleEvent = event.type === "session.idle" || statusType === "idle";', - ' const resolvedProvisionalBusy =', - ' childState === false || (childState === true && isIdleEvent)', - ' ? clearProvisionalBusy(sessionID, factoryID)', - ' : false;', - ' if (resolvedProvisionalBusy && childState === false) {', - ' busyRootOwnerBySessionID.delete(sessionID);', - ' busyRootOwnerBySessionID.set(sessionID, factoryID);', - ' }', - ' // Why: child work rolls up to the pane; ignore its normal lifecycle noise,', - ' // but preserve blockers that still require the pane owner to respond.', - ' if (childState === true && !isAttentionEvent) {', - ' let attentionRootSessionID = null;', - ' let childBusyChanged = false;', - ' if (isIdleEvent) {', - ' attentionRootSessionID = clearAttentionForSession(sessionID, factoryID);', - ' childBusyChanged = clearBusyChild(sessionID, factoryID);', - ' } else if (statusType === "busy" || statusType === "retry") {', - ' childBusyChanged = rememberBusyChild(sessionID, rootSessionID, factoryID);', - ' }', - ' if (resolvedProvisionalBusy || attentionRootSessionID || childBusyChanged) {', - ' await publishOwnershipChange(factoryID, attentionRootSessionID || rootSessionID);', - ' }', - ' return;', - ' }', - ' if (childState === null && !canFailOpen) {', - ' if (isIdleEvent) {', - ' // Why: recorded ownership can safely retire a blocker during an SDK', - ' // outage without granting unknown child Idle authority over root state.', - ' const attentionRootSessionID = clearAttentionForSession(sessionID, factoryID);', - ' const clearedProvisionalBusy = clearProvisionalBusy(sessionID, factoryID);', - ' const clearedKnownBusyRoot = clearKnownBusyRoot(sessionID, factoryID);', - ' // Why: exact-session cleanup, so a child whose finishing Idle lands', - ' // during an SDK outage cannot pin the pane Busy forever.', - ' const clearedBusyChild = clearBusyChild(sessionID, factoryID);', - ' if (', - ' attentionRootSessionID ||', - ' clearedProvisionalBusy ||', - ' clearedKnownBusyRoot ||', - ' clearedBusyChild', - ' ) {', - ' await publishOwnershipChange(factoryID, attentionRootSessionID || sessionID);', - ' }', - ' }', - ' return;', - ' }', - ' if (childState === null && (statusType === "busy" || statusType === "retry")) {', - ' // Unknown lineage may be child work, so keep exact provisional ownership', - ' // only until matching Idle/disposal; other blockers still take priority.', - ' clearAttentionForSession(sessionID, factoryID);', - ' rememberProvisionalBusy(sessionID, factoryID);', - ' await publishAggregateStatus(factoryID, sessionID);', - ' return;', - ' }', - ' if (event.type === "permission.asked" || event.type === "question.asked") {', - ' stateArrivalRevision += 1;', - ' // Why: attention must share the lifecycle FIFO and retry target so a', - ' // delayed Busy post cannot overwrite a newer human blocker.', - ' const hookEventName =', - ' event.type === "permission.asked" ? "PermissionRequest" : "AskUserQuestion";', - ' // Why: show the blocker on the root turn while retaining its real child', - ' // owner for exact reply, tool-completion, and disposal cleanup.', - ' const properties = { ...(event.properties || {}), sessionID: rootSessionID || sessionID };', - ' const requestID = properties.id || sessionID || "";', - ' const key = attentionKey(factoryID, hookEventName, requestID, sessionID);', - ' // Why: unresolved blockers are live UI authority and cannot be evicted;', - ' // reply, exact Idle, tool completion, or factory disposal retires them.', - ' pendingAttentionByKey.set(key, {', - ' hookEventName,', - ' properties,', - ' factoryID,', - ' sourceSessionID: sessionID,', - ' });', - ' await publishAggregateStatus(factoryID, rootSessionID || sessionID);', - ' return;', - ' }', - ' if (isIdleEvent) {', - ' stateArrivalRevision += 1;', - ' const idleKey = "idle:" + (sessionID || "");', - ' // Why: current OpenCode emits canonical idle followed by deprecated', - ' // session.idle; a failed canonical post should keep its backoff.', - ' if (', - ' event.type === "session.idle" &&', - ' desiredStatusKey === idleKey &&', - ' statusDeliveryDirty &&', - ' statusRetryTimer', - ' ) return;', - ' // Why: flush the coalesced final reply snapshot before the idle', - ' // transition so the done-state preview shows the completed message.', - ' await flushPendingAssistantPart(true);', - ' clearAttentionForSession(sessionID, factoryID);', - ' if (busyRootOwnerBySessionID.get(sessionID) === factoryID) {', - ' busyRootOwnerBySessionID.delete(sessionID);', - ' }', - ' await publishAggregateStatus(factoryID, sessionID);', - ' return;', - ' }', - ' // Why: recoverable compaction failures emit session.error and continue;', - ' // canonical session.status is the authority for actual completion.', - ' if (event.type === "session.error") return;', - ' if (statusType === "busy" || statusType === "retry") {', - ' clearAttentionForSession(sessionID, factoryID);', - ' busyRootOwnerBySessionID.delete(sessionID);', - ' busyRootOwnerBySessionID.set(sessionID, factoryID);', - ' await publishAggregateStatus(factoryID, sessionID);', - ' }', - '}', - '', - '// Why: accept the factory argument as an optional opaque parameter instead', - '// of destructuring (`async ({ client }) => …`). OpenCode can invoke the', - '// plugin factory with undefined during startup, which makes the', - '// destructuring form throw synchronously and crash OpenCode with an opaque', - '// UnknownError before any event is ever dispatched.', - 'export const OrcaOpenCodeStatusPlugin = async (_ctx) => {', - ' const client = _ctx?.client;', - ' const factoryID = ++nextFactoryID;', - ' activeFactoryIDs.add(factoryID);', - ' let disposed = false;', - ' return {', - ' event: async ({ event }) => {', - ' if (disposed || !event?.type) return;', - ' const authorityRevision = stateArrivalRevision;', - ' const statusType = getStatusType(event);', - '', - ' // Why: cache the message role BEFORE the async isChildSession check.', - ' // OpenCode fires message.updated (user) and message.part.updated (text)', - ' // back-to-back; if we awaited isChildSession first, the part.updated', - ' // handler could reach messageRoleById.get(...) while the user message.updated', - ' // is still suspended on that await — so the part would see an empty cache', - ' // and drop the user prompt. Caching is a cheap Map.set with bounded size,', - ' // safe to run even for child sessions (the part POST still filters them).', - ' if (event.type === "message.updated") {', - ' const info = event.properties && event.properties.info;', - ' rememberMessageRole(info && info.id, info && info.role);', - ' }', - '', - ' const sessionID = event.properties?.sessionID;', - ' const updatedPart = event.properties?.part;', - ...(options.emitSessionStart - ? [ - ' if (event.type === "session.created") {', - ' const info = event.properties?.info;', - ' if (!info?.id || info.parentID) return;', - ' rememberSessionRoot(info.id, info.id);', - ' await enqueueLifecycle(() =>', - ' disposed ? undefined : post("SessionStart", { sessionID: info.id })', - ' );', - ' return;', - ' }', - '' - ] - : []), - ' if (', - ' event.type === "message.part.updated" &&', - ' updatedPart?.type === "tool" &&', - ' updatedPart.tool === "question" &&', - ' (updatedPart.state?.status === "completed" || updatedPart.state?.status === "error")', - ' ) {', - ' await enqueueLifecycle(async () => {', - ' if (disposed) return;', - ' // Why: stored ownership clears child questions without waiting on', - ' // ancestry lookup even though ordinary child message parts stay hidden.', - ' const rootSessionID = clearQuestionForToolPart(updatedPart, sessionID, factoryID);', - ' if (!rootSessionID) return;', - ' await publishOwnershipChange(factoryID, rootSessionID);', - ' });', - ' return;', - ' }', - '', - ' if (', - ' event.type === "session.status" ||', - ' event.type === "session.idle" ||', - ' event.type === "session.error" ||', - ' event.type === "permission.asked" ||', - ' event.type === "question.asked" ||', - ' event.type === "permission.replied" ||', - ' event.type === "question.replied" ||', - ' event.type === "question.rejected"', - ' ) {', - ' await enqueueLifecycle(() =>', - ' disposed ? undefined : handleLifecycleEvent(client, event, factoryID)', - ' );', - ' return;', - ' }', - '', - ' if (event.type === "message.part.delta") {', - ' const properties = event.properties || {};', - ' if (', - ' properties.field === "text" &&', - ' typeof properties.delta === "string" &&', - ' properties.delta.length > 0', - ' ) {', - ' await recoverBusyFromDelta(client, sessionID, factoryID);', - ' }', - ' return;', - ' }', - '', - ' if (sessionID && (await isChildSession(client, sessionID)) !== false) {', - ' return;', - ' }', - ' if (disposed) return;', - ' if (authorityRevision !== stateArrivalRevision) return;', - ' if (desiredStatus === "waiting") return;', - '', - ' if (event.type === "message.updated") {', - ' // Why: role is already cached above the isChildSession await so the', - ' // back-to-back message.part.updated for the same messageID is not', - ' // racing against this handler. Nothing more to do here — return to', - ' // avoid falling through to the part/session handlers below.', - ' return;', - ' }', - '', - ' if (event.type === "message.part.updated") {', - ' // Why: a TextPart carries the actual user prompt or assistant reply', - ' // text. Skip non-text parts (tool, reasoning, file, …) so we only', - ' // forward what the dashboard renders. Role came from the earlier', - ' // message.updated event; if we never saw one (e.g. plugin loaded', - ' // mid-turn) the role is unknown, and mislabeling the part — a user', - ' // prompt displayed as the assistant reply, or vice versa — is worse', - ' // than silently dropping a single in-flight text chunk. The next', - ' // message.updated event will re-seed the role cache, so subsequent', - ' // parts in the same session flow normally.', - ' const part = event.properties && event.properties.part;', - ' if (!part || part.type !== "text" || !part.text) return;', - ' // Why: OpenCode injects a finished background task back into the', - ' // parent turn as a synthetic `` text part. It is machinery,', - ' // not what the human typed or the agent replied — OpenCode hides it', - ' // from its own prompt too — so it must not replace the pane preview.', - ' if (part.synthetic === true) return;', - ' const role = messageRoleById.get(part.messageID);', - ' if (!role) return;', - ' if (role === "user") {', - ' // Why: user prompts arrive as a single event, not a stream — post', - ' // immediately (still capped) so the throttle slot stays free for', - ' // the assistant reply that follows within the same window.', - ' await postMessagePart(', - ' { role, text: capMessagePartText(part.text), messageID: part.messageID, sessionID },', - ' factoryID', - ' );', - ' return;', - ' }', - ' queueAssistantPart({', - ' role,', - ' text: part.text,', - ' messageID: part.messageID,', - ' sessionID,', - ' authorityRevision,', - ' factoryID,', - ' });', - ' return;', - ' }', - '', - ' },', - ' dispose: async () => {', - ' if (disposed) return;', - ' disposed = true;', - ' disposingFactoryIDs.add(factoryID);', - ' await enqueueLifecycle(async () => {', - ' // An older MessagePart must settle before disposal publishes the', - ' // replacement state, or its late Working update could win.', - ' while (messagePartPostInFlight) await messagePartPostInFlight;', - ' for (const [sessionID, ownerID] of busyRootOwnerBySessionID) {', - ' if (ownerID === factoryID) busyRootOwnerBySessionID.delete(sessionID);', - ' }', - ' for (const [key, provisional] of provisionalBusyByKey) {', - ' if (provisional.factoryID === factoryID) provisionalBusyByKey.delete(key);', - ' }', - ' for (const [key, busyChild] of busyChildRootByKey) {', - ' if (busyChild.factoryID === factoryID) busyChildRootByKey.delete(key);', - ' }', - ' for (const [key, attention] of pendingAttentionByKey) {', - ' if (attention.factoryID === factoryID) pendingAttentionByKey.delete(key);', - ' }', - ' if (pendingAssistantPart?.factoryID === factoryID) {', - ' if (assistantPartFlushTimer) clearTimeout(assistantPartFlushTimer);', - ' assistantPartFlushTimer = null;', - ' pendingAssistantPart = null;', - ' }', - ' const ownsDeliveredMessagePart = deliveredMessagePartFactoryID === factoryID;', - ' if (desiredFactoryID === factoryID || ownsDeliveredMessagePart) {', - ' clearStatusRetry();', - ' statusRevision += 1;', - ' // A MessagePart may have changed the listener to Working after the', - ' // same lifecycle key was delivered; force that key to be reasserted.', - ' statusDeliveryDirty = ownsDeliveredMessagePart;', - ' busyRecoveryUsed = false;', - ' busyRecoveryEndpointKey = "";', - ' const fallbackFactoryID = Array.from(activeFactoryIDs).find(', - ' (id) => id !== factoryID', - ' );', - ' if (fallbackFactoryID !== undefined) {', - ' await publishAggregateStatus(', - ' fallbackFactoryID,', - ' desiredStatusProperties?.sessionID', - ' );', - ' } else {', - ' // Why: Instance disposal can happen while the PTY stays alive;', - ' // publish a final idle so Orca does not retain a dead owner.', - ' if (!deliveredStatusKey.startsWith("idle:") || ownsDeliveredMessagePart) {', - ' await setStatus(', - ' "idle",', - ' { sessionID: desiredStatusProperties?.sessionID },', - ' factoryID', - ' );', - ' }', - ' clearStatusRetry();', - ' desiredStatus = "idle";', - ' desiredHookEventName = "SessionIdle";', - ' desiredStatusKey = "idle:";', - ' desiredStatusProperties = {};', - ' desiredFactoryID = null;', - ' }', - ' }', - ' activeFactoryIDs.delete(factoryID);', - ' disposingFactoryIDs.delete(factoryID);', - ' });', - ' },', - ' };', - '};', - '', - '// Why: OpenCode also resolves plugins through the module default export, and that', - '// loader rejects the module unless the default exposes `server()` ("must default', - '// export an object with server()"). `setup()` does not satisfy it. Keep the named', - '// export so the factory-based loader still finds the same instance.', - 'export default {', - ' id: "orca-opencode-status",', - ' server: OrcaOpenCodeStatusPlugin,', - '};', - '' + ...getStatusPluginEndpointSource(), + ...getStatusPluginRuntimeStateSource(), + ...getStatusPluginMessagePreviewSource(), + ...getStatusPluginSessionLineageSource(), + ...getStatusPluginPostSource(hookPathname), + ...getStatusPluginDeliverySource(), + ...getStatusPluginOwnershipSource(), + ...getStatusPluginLifecycleSource(), + ...getStatusPluginFactorySource(options) ].join('\n') } diff --git a/src/main/opencode/status-plugin-delivery-source.ts b/src/main/opencode/status-plugin-delivery-source.ts new file mode 100644 index 00000000000..57553198c8a --- /dev/null +++ b/src/main/opencode/status-plugin-delivery-source.ts @@ -0,0 +1,158 @@ +export function getStatusPluginDeliverySource(): string[] { + return [ + '', + 'function enqueueLifecycle(task) {', + ' // Why: OpenCode intentionally fire-and-forgets hook promises, so async', + ' // lookups and posts need their own FIFO to preserve event order.', + ' const run = lifecycleQueue.then(async () => {', + ' try {', + ' await task();', + ' } catch {', + ' // Hook delivery must never reject into OpenCode.', + ' }', + ' });', + ' lifecycleQueue = run;', + ' return run;', + '}', + '', + 'function clearStatusRetry() {', + ' if (statusRetryTimer) clearTimeout(statusRetryTimer);', + ' statusRetryTimer = null;', + '}', + '', + 'function scheduleStatusRetry(revision) {', + ' if (', + ' statusRetryTimer ||', + ' revision !== statusRevision ||', + ' !statusDeliveryDirty ||', + ' !activeFactoryIDs.has(desiredFactoryID)', + ' ) return;', + ' const delay = Math.min(', + ' STATUS_RETRY_BASE_MS * Math.pow(2, Math.min(statusRetryAttempt, 6)),', + ' STATUS_RETRY_MAX_MS', + ' );', + ' statusRetryAttempt = Math.min(statusRetryAttempt + 1, 7);', + ' statusRetryTimer = setTimeout(() => {', + ' statusRetryTimer = null;', + ' void enqueueLifecycle(async () => {', + ' if (', + ' revision !== statusRevision ||', + ' !statusDeliveryDirty ||', + ' !activeFactoryIDs.has(desiredFactoryID)', + ' ) return;', + ' await publishDesiredStatus(revision);', + ' });', + ' }, delay);', + ' if (statusRetryTimer.unref) statusRetryTimer.unref();', + '}', + '', + 'async function publishDesiredStatus(revision) {', + ' if (revision !== statusRevision) return;', + ' if (!activeFactoryIDs.has(desiredFactoryID)) return;', + ' const endpointKey = hookEndpointKey();', + ' if (', + ' !statusDeliveryDirty &&', + ' deliveredStatusKey === desiredStatusKey &&', + ' deliveredEndpointKey === endpointKey &&', + ' deliveredMessagePartFactoryID === null', + ' ) return;', + ' const delivered = await post(desiredHookEventName, desiredStatusProperties);', + ' if (revision !== statusRevision) return;', + ' if (!delivered) {', + ' statusDeliveryDirty = true;', + ' scheduleStatusRetry(revision);', + ' return;', + ' }', + ' clearStatusRetry();', + ' statusRetryAttempt = 0;', + ' deliveredStatusKey = desiredStatusKey;', + ' deliveredEndpointKey = endpointKey;', + ' deliveredMessagePartFactoryID = null;', + ' statusDeliveryDirty = false;', + '}', + '', + 'async function setDeliveryTarget(', + ' next,', + ' nextKey,', + ' hookEventName,', + ' extraProperties,', + ' factoryID', + ') {', + ' const endpointChanged = deliveredEndpointKey !== hookEndpointKey();', + ' if (', + ' nextKey === desiredStatusKey &&', + ' nextKey === deliveredStatusKey &&', + ' desiredFactoryID === factoryID &&', + ' !statusDeliveryDirty &&', + ' !endpointChanged &&', + ' deliveredMessagePartFactoryID === null', + ' ) return;', + ' const targetChanged = nextKey !== desiredStatusKey || desiredFactoryID !== factoryID;', + ' clearStatusRetry();', + ' if (targetChanged) {', + ' statusRetryAttempt = 0;', + ' busyRecoveryUsed = false;', + ' busyRecoveryEndpointKey = "";', + ' }', + ' desiredStatus = next;', + ' desiredHookEventName = hookEventName;', + ' desiredStatusKey = nextKey;', + ' desiredStatusProperties = extraProperties || {};', + ' desiredFactoryID = factoryID;', + ' statusDeliveryDirty =', + ' statusDeliveryDirty ||', + ' deliveredMessagePartFactoryID !== null ||', + ' deliveredStatusKey !== nextKey ||', + ' endpointChanged;', + ' const revision = ++statusRevision;', + ' await publishDesiredStatus(revision);', + '}', + '', + 'async function setStatus(next, extraProperties, factoryID) {', + ' const nextKey = next + ":" + (extraProperties?.sessionID || "");', + ' const hookEventName = next === "busy" ? "SessionBusy" : "SessionIdle";', + ' await setDeliveryTarget(next, nextKey, hookEventName, extraProperties, factoryID);', + '}', + '', + 'async function setAttention(hookEventName, properties, factoryID, sourceSessionID) {', + ' const requestID = properties?.id || properties?.sessionID || "";', + ' const requestKey = attentionKey(factoryID, hookEventName, requestID, sourceSessionID);', + ' await flushPendingAssistantPart(true);', + ' await setDeliveryTarget(', + ' "waiting",', + ' "waiting:" + requestKey,', + ' hookEventName,', + ' properties,', + ' factoryID', + ' );', + '}', + '', + 'function recoverBusyFromDelta(client, sessionID, factoryID) {', + ' if (busyRecoveryQueued) return lifecycleQueue;', + ' if (', + ' busyRecoveryUsed &&', + ' busyRecoveryEndpointKey === hookEndpointKey()', + ' ) return lifecycleQueue;', + ' busyRecoveryQueued = true;', + ' return enqueueLifecycle(async () => {', + ' try {', + ' if (!activeFactoryIDs.has(factoryID)) return;', + ' if (sessionID && (await isChildSession(client, sessionID)) === true) return;', + ' if (!activeFactoryIDs.has(factoryID)) return;', + ' const endpointKey = hookEndpointKey();', + ' const endpointChanged = deliveredEndpointKey !== endpointKey;', + ' if (desiredStatus !== "busy" || (!statusDeliveryDirty && !endpointChanged)) return;', + ' if (busyRecoveryUsed && !endpointChanged) return;', + ' busyRecoveryUsed = true;', + ' busyRecoveryEndpointKey = endpointKey;', + ' clearStatusRetry();', + ' statusDeliveryDirty = true;', + ' const revision = ++statusRevision;', + ' await publishDesiredStatus(revision);', + ' } finally {', + ' busyRecoveryQueued = false;', + ' }', + ' });', + '}' + ] +} diff --git a/src/main/opencode/status-plugin-endpoint-source.ts b/src/main/opencode/status-plugin-endpoint-source.ts new file mode 100644 index 00000000000..0af08df9b2c --- /dev/null +++ b/src/main/opencode/status-plugin-endpoint-source.ts @@ -0,0 +1,99 @@ +export function getStatusPluginEndpointSource(): string[] { + return [ + '// Why: process-lifetime guard so a recurring parse error on a malformed', + "// endpoint file does not spam OpenCode's stderr once per hook post.", + '// This guard lives inside the plugin source because the plugin runs in', + "// OpenCode's Node process (not Orca's) and has no access to server.ts's", + '// equivalent warnedVersions / warnedEnvs Sets.', + 'let warnedBadEndpoint = false;', + '', + '// Why: message.part.updated can fire many times per second during a', + '// streaming assistant reply, and each post() calls resolveHookCoords()', + '// which reads the endpoint file. The file only changes on Orca restart', + '// (rare), so a stat+mtime check is substantially cheaper than a full', + '// readFileSync+parse on every streamed part. On stat error we fall', + '// through to parse so the fail-open behavior is preserved.', + 'let cachedEndpointKey = "";', + 'let cachedEndpointValues = null;', + '', + 'function readEndpointFile() {', + ' const path = process.env.ORCA_AGENT_HOOK_ENDPOINT;', + ' if (!path) return null;', + ' try {', + ' const fs = require("fs");', + ' try {', + ' const stat = fs.statSync(path);', + ' // Why: cache key combines mtime + size + inode. renameSync (used by', + ' // writeEndpointFile on the Orca side) allocates a fresh inode on', + ' // POSIX and a new Windows file ID on NTFS, so ino changes on every', + ' // legitimate rewrite even when mtimeMs resolution is coarse and size', + ' // happens to match.', + ' const cacheKey = stat.mtimeMs + ":" + stat.size + ":" + stat.ino;', + ' if (cacheKey === cachedEndpointKey && cachedEndpointValues) {', + ' return cachedEndpointValues;', + ' }', + ' const contents = fs.readFileSync(path, "utf8");', + ' const out = {};', + ' for (const line of contents.split(/\\r?\\n/)) {', + ' // Why: Windows endpoint.cmd uses `set KEY=VALUE`; Unix endpoint.env', + ' // uses `KEY=VALUE`. Making `set ` optional lets the same parser', + ' // handle both without platform detection in the plugin. Allow', + ' // digits in the key for forward-compat with future ORCA_AGENT_HOOK_*', + ' // names that may contain numerics, and strip a trailing CR so', + ' // mixed-EOL files with lone `\\r` do not leak CR into the value.', + ' const m = line.match(/^(?:set\\s+)?([A-Z0-9_]+)=(.*)$/);', + ' if (m) out[m[1]] = m[2].replace(/\\r$/, "");', + ' }', + ' cachedEndpointKey = cacheKey;', + ' cachedEndpointValues = out;', + ' return out;', + ' } catch (ioErr) {', + ' // Why: any stat or read failure (file yanked mid-read, permission', + ' // race, unlink between stat and readFileSync) must invalidate the', + ' // cache so a transient failure does not lock in a stale parse for', + ' // the remaining process lifetime; rethrow to the outer catch.', + ' cachedEndpointKey = "";', + ' cachedEndpointValues = null;', + ' throw ioErr;', + ' }', + ' } catch (err) {', + ' // Why: warn once per process if the file exists but is unreadable or', + ' // malformed — a persistent, silently-swallowed parse error would', + ' // otherwise leave the plugin falling back to stale process.env on', + ' // every post with no signal. ENOENT / missing env var is the normal', + ' // pre-install case; stay silent for it.', + ' if (err && err.code !== "ENOENT" && !warnedBadEndpoint) {', + ' warnedBadEndpoint = true;', + ' console.warn("[orca-hook] failed to parse endpoint file:", err.message);', + ' }', + ' return null;', + ' }', + '}', + '', + 'function resolveHookCoords() {', + ' // Why: prefer the on-disk endpoint file over process.env because env was', + ' // frozen when OpenCode was fork()ed — stale after an Orca restart. The', + ' // file is rewritten on every Orca start(), so sourcing it per post lets', + ' // a long-running OpenCode session reach the current server. Falls back', + ' // to process.env when the file is absent (first-run / pre-endpoint-file / Orca', + ' // never started writing the file).', + ' const fileEnv = readEndpointFile() || {};', + ' return {', + ' port: fileEnv.ORCA_AGENT_HOOK_PORT || process.env.ORCA_AGENT_HOOK_PORT,', + ' token: fileEnv.ORCA_AGENT_HOOK_TOKEN || process.env.ORCA_AGENT_HOOK_TOKEN,', + ' env: fileEnv.ORCA_AGENT_HOOK_ENV || process.env.ORCA_AGENT_HOOK_ENV || "",', + ' version: fileEnv.ORCA_AGENT_HOOK_VERSION || process.env.ORCA_AGENT_HOOK_VERSION || "",', + ' };', + '}', + '', + 'function hookEndpointKey() {', + ' const coords = resolveHookCoords();', + ' return [coords.port || "", coords.token || "", coords.env, coords.version].join("\\u0000");', + '}', + '', + 'function getStatusType(event) {', + ' return event?.properties?.status?.type ?? event?.status?.type ?? null;', + '}', + '' + ] +} diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts new file mode 100644 index 00000000000..9a82bcf8764 --- /dev/null +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -0,0 +1,225 @@ +export function getStatusPluginFactorySource(options: { emitSessionStart: boolean }): string[] { + return [ + '// Why: accept the factory argument as an optional opaque parameter instead', + '// of destructuring (`async ({ client }) => …`). OpenCode can invoke the', + '// plugin factory with undefined during startup, which makes the', + '// destructuring form throw synchronously and crash OpenCode with an opaque', + '// UnknownError before any event is ever dispatched.', + 'export const OrcaOpenCodeStatusPlugin = async (_ctx) => {', + ' const client = _ctx?.client;', + ' const factoryID = ++nextFactoryID;', + ' activeFactoryIDs.add(factoryID);', + ' let disposed = false;', + ' return {', + ' event: async ({ event }) => {', + ' if (disposed || !event?.type) return;', + ' const authorityRevision = stateArrivalRevision;', + ' const statusType = getStatusType(event);', + '', + ' // Why: cache the message role BEFORE the async isChildSession check.', + ' // OpenCode fires message.updated (user) and message.part.updated (text)', + ' // back-to-back; if we awaited isChildSession first, the part.updated', + ' // handler could reach messageRoleById.get(...) while the user message.updated', + ' // is still suspended on that await — so the part would see an empty cache', + ' // and drop the user prompt. Caching is a cheap Map.set with bounded size,', + ' // safe to run even for child sessions (the part POST still filters them).', + ' if (event.type === "message.updated") {', + ' const info = event.properties && event.properties.info;', + ' rememberMessageRole(info && info.id, info && info.role);', + ' }', + '', + ' const sessionID = event.properties?.sessionID;', + ' const updatedPart = event.properties?.part;', + ...(options.emitSessionStart + ? [ + ' if (event.type === "session.created") {', + ' const info = event.properties?.info;', + ' if (!info?.id || info.parentID) return;', + ' rememberSessionRoot(info.id, info.id);', + ' await enqueueLifecycle(() =>', + ' disposed ? undefined : post("SessionStart", { sessionID: info.id })', + ' );', + ' return;', + ' }', + '' + ] + : []), + ' if (', + ' event.type === "message.part.updated" &&', + ' updatedPart?.type === "tool" &&', + ' updatedPart.tool === "question" &&', + ' (updatedPart.state?.status === "completed" || updatedPart.state?.status === "error")', + ' ) {', + ' await enqueueLifecycle(async () => {', + ' if (disposed) return;', + ' // Why: stored ownership clears child questions without waiting on', + ' // ancestry lookup even though ordinary child message parts stay hidden.', + ' const rootSessionID = clearQuestionForToolPart(updatedPart, sessionID, factoryID);', + ' if (!rootSessionID) return;', + ' await publishOwnershipChange(factoryID, rootSessionID);', + ' });', + ' return;', + ' }', + '', + ' if (', + ' event.type === "session.status" ||', + ' event.type === "session.idle" ||', + ' event.type === "session.error" ||', + ' event.type === "permission.asked" ||', + ' event.type === "question.asked" ||', + ' event.type === "permission.replied" ||', + ' event.type === "question.replied" ||', + ' event.type === "question.rejected"', + ' ) {', + ' await enqueueLifecycle(() =>', + ' disposed ? undefined : handleLifecycleEvent(client, event, factoryID)', + ' );', + ' return;', + ' }', + '', + ' if (event.type === "message.part.delta") {', + ' const properties = event.properties || {};', + ' if (', + ' properties.field === "text" &&', + ' typeof properties.delta === "string" &&', + ' properties.delta.length > 0', + ' ) {', + ' await recoverBusyFromDelta(client, sessionID, factoryID);', + ' }', + ' return;', + ' }', + '', + ' if (sessionID && (await isChildSession(client, sessionID)) !== false) {', + ' return;', + ' }', + ' if (disposed) return;', + ' if (authorityRevision !== stateArrivalRevision) return;', + ' if (desiredStatus === "waiting") return;', + '', + ' if (event.type === "message.updated") {', + ' // Why: role is already cached above the isChildSession await so the', + ' // back-to-back message.part.updated for the same messageID is not', + ' // racing against this handler. Nothing more to do here — return to', + ' // avoid falling through to the part/session handlers below.', + ' return;', + ' }', + '', + ' if (event.type === "message.part.updated") {', + ' // Why: a TextPart carries the actual user prompt or assistant reply', + ' // text. Skip non-text parts (tool, reasoning, file, …) so we only', + ' // forward what the dashboard renders. Role came from the earlier', + ' // message.updated event; if we never saw one (e.g. plugin loaded', + ' // mid-turn) the role is unknown, and mislabeling the part — a user', + ' // prompt displayed as the assistant reply, or vice versa — is worse', + ' // than silently dropping a single in-flight text chunk. The next', + ' // message.updated event will re-seed the role cache, so subsequent', + ' // parts in the same session flow normally.', + ' const part = event.properties && event.properties.part;', + ' if (!part || part.type !== "text" || !part.text) return;', + ' // Why: OpenCode injects a finished background task back into the', + ' // parent turn as a synthetic `` text part. It is machinery,', + ' // not what the human typed or the agent replied — OpenCode hides it', + ' // from its own prompt too — so it must not replace the pane preview.', + ' if (part.synthetic === true) return;', + ' const role = messageRoleById.get(part.messageID);', + ' if (!role) return;', + ' if (role === "user") {', + ' // Why: user prompts arrive as a single event, not a stream — post', + ' // immediately (still capped) so the throttle slot stays free for', + ' // the assistant reply that follows within the same window.', + ' await postMessagePart(', + ' { role, text: capMessagePartText(part.text), messageID: part.messageID, sessionID },', + ' factoryID', + ' );', + ' return;', + ' }', + ' queueAssistantPart({', + ' role,', + ' text: part.text,', + ' messageID: part.messageID,', + ' sessionID,', + ' authorityRevision,', + ' factoryID,', + ' });', + ' return;', + ' }', + '', + ' },', + ' dispose: async () => {', + ' if (disposed) return;', + ' disposed = true;', + ' disposingFactoryIDs.add(factoryID);', + ' await enqueueLifecycle(async () => {', + ' // An older MessagePart must settle before disposal publishes the', + ' // replacement state, or its late Working update could win.', + ' while (messagePartPostInFlight) await messagePartPostInFlight;', + ' for (const [sessionID, ownerID] of busyRootOwnerBySessionID) {', + ' if (ownerID === factoryID) busyRootOwnerBySessionID.delete(sessionID);', + ' }', + ' for (const [key, provisional] of provisionalBusyByKey) {', + ' if (provisional.factoryID === factoryID) provisionalBusyByKey.delete(key);', + ' }', + ' for (const [key, busyChild] of busyChildRootByKey) {', + ' if (busyChild.factoryID === factoryID) busyChildRootByKey.delete(key);', + ' }', + ' for (const [key, attention] of pendingAttentionByKey) {', + ' if (attention.factoryID === factoryID) pendingAttentionByKey.delete(key);', + ' }', + ' if (pendingAssistantPart?.factoryID === factoryID) {', + ' if (assistantPartFlushTimer) clearTimeout(assistantPartFlushTimer);', + ' assistantPartFlushTimer = null;', + ' pendingAssistantPart = null;', + ' }', + ' const ownsDeliveredMessagePart = deliveredMessagePartFactoryID === factoryID;', + ' if (desiredFactoryID === factoryID || ownsDeliveredMessagePart) {', + ' clearStatusRetry();', + ' statusRevision += 1;', + ' // A MessagePart may have changed the listener to Working after the', + ' // same lifecycle key was delivered; force that key to be reasserted.', + ' statusDeliveryDirty = ownsDeliveredMessagePart;', + ' busyRecoveryUsed = false;', + ' busyRecoveryEndpointKey = "";', + ' const fallbackFactoryID = Array.from(activeFactoryIDs).find(', + ' (id) => id !== factoryID', + ' );', + ' if (fallbackFactoryID !== undefined) {', + ' await publishAggregateStatus(', + ' fallbackFactoryID,', + ' desiredStatusProperties?.sessionID', + ' );', + ' } else {', + ' // Why: Instance disposal can happen while the PTY stays alive;', + ' // publish a final idle so Orca does not retain a dead owner.', + ' if (!deliveredStatusKey.startsWith("idle:") || ownsDeliveredMessagePart) {', + ' await setStatus(', + ' "idle",', + ' { sessionID: desiredStatusProperties?.sessionID },', + ' factoryID', + ' );', + ' }', + ' clearStatusRetry();', + ' desiredStatus = "idle";', + ' desiredHookEventName = "SessionIdle";', + ' desiredStatusKey = "idle:";', + ' desiredStatusProperties = {};', + ' desiredFactoryID = null;', + ' }', + ' }', + ' activeFactoryIDs.delete(factoryID);', + ' disposingFactoryIDs.delete(factoryID);', + ' });', + ' },', + ' };', + '};', + '', + '// Why: OpenCode also resolves plugins through the module default export, and that', + '// loader rejects the module unless the default exposes `server()` ("must default', + '// export an object with server()"). `setup()` does not satisfy it. Keep the named', + '// export so the factory-based loader still finds the same instance.', + 'export default {', + ' id: "orca-opencode-status",', + ' server: OrcaOpenCodeStatusPlugin,', + '};', + '' + ] +} diff --git a/src/main/opencode/status-plugin-lifecycle-source.ts b/src/main/opencode/status-plugin-lifecycle-source.ts new file mode 100644 index 00000000000..7f6bd094864 --- /dev/null +++ b/src/main/opencode/status-plugin-lifecycle-source.ts @@ -0,0 +1,136 @@ +export function getStatusPluginLifecycleSource(): string[] { + return [ + 'async function handleLifecycleEvent(client, event, factoryID) {', + ' const sessionID = event.properties?.sessionID;', + ' const statusType = getStatusType(event);', + ' const isResolutionEvent =', + ' event.type === "permission.replied" ||', + ' event.type === "question.replied" ||', + ' event.type === "question.rejected";', + ' if (isResolutionEvent) {', + ' // Why: the stored owner already identifies the root, so replies clear', + ' // immediately even when OpenCode session lookup is slow or unavailable.', + ' const rootSessionID = clearAttentionForResolution(event, sessionID, factoryID);', + ' if (rootSessionID) await publishOwnershipChange(factoryID, rootSessionID);', + ' return;', + ' }', + ' const isAttentionEvent =', + ' event.type === "permission.asked" ||', + ' event.type === "question.asked";', + " // Why: attention without OpenCode's required sessionID cannot be", + ' // correlated to a later reply/Idle, so it must not become UI authority.', + ' if (isAttentionEvent && !sessionID) return;', + ' const canFailOpen =', + ' statusType === "busy" || statusType === "retry" || isAttentionEvent;', + ' const rootSessionID = sessionID ? await resolveRootSessionID(client, sessionID) : null;', + ' const childState = rootSessionID === null ? null : rootSessionID !== sessionID;', + ' const isIdleEvent = event.type === "session.idle" || statusType === "idle";', + ' const resolvedProvisionalBusy =', + ' childState === false || (childState === true && isIdleEvent)', + ' ? clearProvisionalBusy(sessionID, factoryID)', + ' : false;', + ' if (resolvedProvisionalBusy && childState === false) {', + ' busyRootOwnerBySessionID.delete(sessionID);', + ' busyRootOwnerBySessionID.set(sessionID, factoryID);', + ' }', + ' // Why: child work rolls up to the pane; ignore its normal lifecycle noise,', + ' // but preserve blockers that still require the pane owner to respond.', + ' if (childState === true && !isAttentionEvent) {', + ' let attentionRootSessionID = null;', + ' let childBusyChanged = false;', + ' if (isIdleEvent) {', + ' attentionRootSessionID = clearAttentionForSession(sessionID, factoryID);', + ' childBusyChanged = clearBusyChild(sessionID, factoryID);', + ' } else if (statusType === "busy" || statusType === "retry") {', + ' childBusyChanged = rememberBusyChild(sessionID, rootSessionID, factoryID);', + ' }', + ' if (resolvedProvisionalBusy || attentionRootSessionID || childBusyChanged) {', + ' await publishOwnershipChange(factoryID, attentionRootSessionID || rootSessionID);', + ' }', + ' return;', + ' }', + ' if (childState === null && !canFailOpen) {', + ' if (isIdleEvent) {', + ' // Why: recorded ownership can safely retire a blocker during an SDK', + ' // outage without granting unknown child Idle authority over root state.', + ' const attentionRootSessionID = clearAttentionForSession(sessionID, factoryID);', + ' const clearedProvisionalBusy = clearProvisionalBusy(sessionID, factoryID);', + ' const clearedKnownBusyRoot = clearKnownBusyRoot(sessionID, factoryID);', + ' // Why: exact-session cleanup, so a child whose finishing Idle lands', + ' // during an SDK outage cannot pin the pane Busy forever.', + ' const clearedBusyChild = clearBusyChild(sessionID, factoryID);', + ' if (', + ' attentionRootSessionID ||', + ' clearedProvisionalBusy ||', + ' clearedKnownBusyRoot ||', + ' clearedBusyChild', + ' ) {', + ' await publishOwnershipChange(factoryID, attentionRootSessionID || sessionID);', + ' }', + ' }', + ' return;', + ' }', + ' if (childState === null && (statusType === "busy" || statusType === "retry")) {', + ' // Unknown lineage may be child work, so keep exact provisional ownership', + ' // only until matching Idle/disposal; other blockers still take priority.', + ' clearAttentionForSession(sessionID, factoryID);', + ' rememberProvisionalBusy(sessionID, factoryID);', + ' await publishAggregateStatus(factoryID, sessionID);', + ' return;', + ' }', + ' if (event.type === "permission.asked" || event.type === "question.asked") {', + ' stateArrivalRevision += 1;', + ' // Why: attention must share the lifecycle FIFO and retry target so a', + ' // delayed Busy post cannot overwrite a newer human blocker.', + ' const hookEventName =', + ' event.type === "permission.asked" ? "PermissionRequest" : "AskUserQuestion";', + ' // Why: show the blocker on the root turn while retaining its real child', + ' // owner for exact reply, tool-completion, and disposal cleanup.', + ' const properties = { ...(event.properties || {}), sessionID: rootSessionID || sessionID };', + ' const requestID = properties.id || sessionID || "";', + ' const key = attentionKey(factoryID, hookEventName, requestID, sessionID);', + ' // Why: unresolved blockers are live UI authority and cannot be evicted;', + ' // reply, exact Idle, tool completion, or factory disposal retires them.', + ' pendingAttentionByKey.set(key, {', + ' hookEventName,', + ' properties,', + ' factoryID,', + ' sourceSessionID: sessionID,', + ' });', + ' await publishAggregateStatus(factoryID, rootSessionID || sessionID);', + ' return;', + ' }', + ' if (isIdleEvent) {', + ' stateArrivalRevision += 1;', + ' const idleKey = "idle:" + (sessionID || "");', + ' // Why: current OpenCode emits canonical idle followed by deprecated', + ' // session.idle; a failed canonical post should keep its backoff.', + ' if (', + ' event.type === "session.idle" &&', + ' desiredStatusKey === idleKey &&', + ' statusDeliveryDirty &&', + ' statusRetryTimer', + ' ) return;', + ' // Why: flush the coalesced final reply snapshot before the idle', + ' // transition so the done-state preview shows the completed message.', + ' await flushPendingAssistantPart(true);', + ' clearAttentionForSession(sessionID, factoryID);', + ' if (busyRootOwnerBySessionID.get(sessionID) === factoryID) {', + ' busyRootOwnerBySessionID.delete(sessionID);', + ' }', + ' await publishAggregateStatus(factoryID, sessionID);', + ' return;', + ' }', + ' // Why: recoverable compaction failures emit session.error and continue;', + ' // canonical session.status is the authority for actual completion.', + ' if (event.type === "session.error") return;', + ' if (statusType === "busy" || statusType === "retry") {', + ' clearAttentionForSession(sessionID, factoryID);', + ' busyRootOwnerBySessionID.delete(sessionID);', + ' busyRootOwnerBySessionID.set(sessionID, factoryID);', + ' await publishAggregateStatus(factoryID, sessionID);', + ' }', + '}', + '' + ] +} diff --git a/src/main/opencode/status-plugin-message-preview-source.ts b/src/main/opencode/status-plugin-message-preview-source.ts new file mode 100644 index 00000000000..33e00ea1741 --- /dev/null +++ b/src/main/opencode/status-plugin-message-preview-source.ts @@ -0,0 +1,78 @@ +export function getStatusPluginMessagePreviewSource(): string[] { + return [ + '', + 'function capMessagePartText(text) {', + ' return text.length > MESSAGE_PART_MAX_CHARS ? text.slice(0, MESSAGE_PART_MAX_CHARS) : text;', + '}', + '', + 'async function postMessagePart(properties, factoryID) {', + ' while (messagePartPostInFlight) await messagePartPostInFlight;', + ' const delivery = post("MessagePart", properties);', + ' messagePartPostInFlight = delivery;', + ' try {', + ' const delivered = await delivery;', + ' if (delivered) deliveredMessagePartFactoryID = factoryID;', + ' } finally {', + ' if (messagePartPostInFlight === delivery) messagePartPostInFlight = null;', + ' }', + '}', + '', + 'async function flushPendingAssistantPart(force = false) {', + ' if (assistantPartFlushTimer) {', + ' clearTimeout(assistantPartFlushTimer);', + ' assistantPartFlushTimer = null;', + ' }', + ' // Why: an idle/waiting transition must wait for every older preview;', + ' // keep one post in flight while later snapshots coalesce in memory.', + ' while (messagePartPostInFlight) await messagePartPostInFlight;', + ' const pending = pendingAssistantPart;', + ' pendingAssistantPart = null;', + ' if (!pending) return;', + ' if (', + ' !activeFactoryIDs.has(pending.factoryID) ||', + ' disposingFactoryIDs.has(pending.factoryID)', + ' ) return;', + ' if (!force && pending.authorityRevision !== stateArrivalRevision) return;', + ' lastAssistantPartPostAt = Date.now();', + ' await postMessagePart({', + ' role: pending.role,', + ' text: capMessagePartText(pending.text),', + ' messageID: pending.messageID,', + ' sessionID: pending.sessionID,', + ' }, pending.factoryID);', + '}', + '', + 'function queueAssistantPart(part) {', + ' // Why: keep only the latest snapshot — each event already contains the', + ' // full accumulated text, so intermediate snapshots are pure waste.', + ' pendingAssistantPart = part;', + ' const sinceLastPost = Date.now() - lastAssistantPartPostAt;', + ' if (sinceLastPost >= MESSAGE_PART_THROTTLE_MS) {', + ' void flushPendingAssistantPart();', + ' return;', + ' }', + ' if (!assistantPartFlushTimer) {', + ' assistantPartFlushTimer = setTimeout(() => {', + ' void flushPendingAssistantPart();', + ' }, MESSAGE_PART_THROTTLE_MS - sinceLastPost);', + ' if (assistantPartFlushTimer.unref) assistantPartFlushTimer.unref();', + ' }', + '}', + '', + '// Why: message.part.updated fires for every Part (text, tool, reasoning)', + '// but does not include the message role — that lives on the parent', + '// message.updated event. Cache the role per messageID so the plugin can', + '// tag a TextPart as user vs assistant when POSTing. Capped at 128 entries', + '// so long-running sessions do not grow this map unboundedly.', + 'const messageRoleById = new Map();', + 'function rememberMessageRole(messageID, role) {', + ' if (!messageID || !role) return;', + ' if (messageRoleById.size >= 128) {', + ' const first = messageRoleById.keys().next().value;', + ' if (first !== undefined) messageRoleById.delete(first);', + ' }', + ' messageRoleById.set(messageID, role);', + '}', + '' + ] +} diff --git a/src/main/opencode/status-plugin-ownership-source.ts b/src/main/opencode/status-plugin-ownership-source.ts new file mode 100644 index 00000000000..056e12fa0c6 --- /dev/null +++ b/src/main/opencode/status-plugin-ownership-source.ts @@ -0,0 +1,148 @@ +export function getStatusPluginOwnershipSource(): string[] { + return [ + '', + 'function currentAttention() {', + ' let latestQuestion = null;', + ' for (const attention of pendingAttentionByKey.values()) {', + ' if (attention.hookEventName === "PermissionRequest") return attention;', + ' latestQuestion = attention;', + ' }', + ' return latestQuestion;', + '}', + '', + 'function attentionKey(factoryID, hookEventName, requestID, sourceSessionID) {', + ' // Why: custom plugins may reuse request IDs across sessions or factories;', + ' // JSON tuple identity prevents one owner from hiding another blocker.', + ' return JSON.stringify([factoryID, hookEventName, requestID, sourceSessionID || ""]);', + '}', + '', + 'function clearAttentionForSession(sessionID, factoryID) {', + ' let rootSessionID = null;', + ' for (const [key, attention] of pendingAttentionByKey) {', + ' if (attention.sourceSessionID === sessionID && attention.factoryID === factoryID) {', + ' pendingAttentionByKey.delete(key);', + ' rootSessionID = attention.properties?.sessionID || sessionID;', + ' }', + ' }', + ' return rootSessionID;', + '}', + '', + 'function clearQuestionForToolPart(part, sessionID, factoryID) {', + ' if (', + ' part?.type !== "tool" ||', + ' part.tool !== "question" ||', + ' (part.state?.status !== "completed" && part.state?.status !== "error")', + ' ) return null;', + ' let rootSessionID = null;', + ' for (const [key, attention] of pendingAttentionByKey) {', + ' const tool = attention.properties?.tool;', + ' if (', + ' attention.hookEventName === "AskUserQuestion" &&', + ' attention.sourceSessionID === sessionID &&', + ' attention.factoryID === factoryID &&', + ' tool?.messageID === part.messageID &&', + ' tool?.callID === part.callID', + ' ) {', + ' pendingAttentionByKey.delete(key);', + ' rootSessionID = attention.properties?.sessionID || sessionID;', + ' }', + ' }', + ' return rootSessionID;', + '}', + '', + 'function clearAttentionForResolution(event, sessionID, factoryID) {', + ' const hookEventName =', + ' event.type === "permission.replied" ? "PermissionRequest" : "AskUserQuestion";', + ' const requestID = event.properties?.requestID || "";', + ' const key = attentionKey(factoryID, hookEventName, requestID, sessionID);', + ' const attention = pendingAttentionByKey.get(key);', + ' if (!attention || attention.sourceSessionID !== sessionID) return null;', + ' pendingAttentionByKey.delete(key);', + ' return attention.properties?.sessionID || sessionID;', + '}', + '', + 'function provisionalBusyKey(factoryID, sessionID) {', + ' return JSON.stringify([factoryID, sessionID || ""]);', + '}', + '', + 'function rememberProvisionalBusy(sessionID, factoryID) {', + ' const key = provisionalBusyKey(factoryID, sessionID);', + ' // Why: active ownership cannot be LRU-evicted without allowing false', + ' // Idle; exact matching Idle or factory disposal lifecycle-bounds it.', + ' provisionalBusyByKey.delete(key);', + ' provisionalBusyByKey.set(key, { sessionID, factoryID });', + '}', + '', + 'function clearProvisionalBusy(sessionID, factoryID) {', + ' return provisionalBusyByKey.delete(provisionalBusyKey(factoryID, sessionID));', + '}', + '', + 'function busyChildKey(factoryID, sessionID) {', + ' return JSON.stringify([factoryID, sessionID || ""]);', + '}', + '', + 'function rememberBusyChild(sessionID, rootSessionID, factoryID) {', + ' const key = busyChildKey(factoryID, sessionID);', + ' // Why: repeated Busy for the same child is not a state change; report one', + ' // only when the pane actually gains a running descendant.', + ' if (busyChildRootByKey.get(key)?.sessionID === rootSessionID) return false;', + ' busyChildRootByKey.delete(key);', + ' busyChildRootByKey.set(key, { sessionID: rootSessionID, factoryID });', + ' return true;', + '}', + '', + 'function clearBusyChild(sessionID, factoryID) {', + ' return busyChildRootByKey.delete(busyChildKey(factoryID, sessionID));', + '}', + '', + 'function clearKnownBusyRoot(sessionID, factoryID) {', + ' if (busyRootOwnerBySessionID.get(sessionID) !== factoryID) return false;', + ' busyRootOwnerBySessionID.delete(sessionID);', + ' return true;', + '}', + '', + 'function latestBusyOwner() {', + ' let latest = null;', + ' // Why: a running descendant is reported under its ROOT id, and ranks', + ' // below a directly busy root so an ongoing turn keeps labelling the pane.', + ' for (const busyChild of busyChildRootByKey.values()) {', + ' latest = busyChild;', + ' }', + ' for (const [sessionID, factoryID] of busyRootOwnerBySessionID) {', + ' latest = { sessionID, factoryID };', + ' }', + ' for (const provisional of provisionalBusyByKey.values()) {', + ' latest = provisional;', + ' }', + ' return latest;', + '}', + '', + 'async function publishAggregateStatus(fallbackFactoryID, preferredSessionID) {', + ' const attention = currentAttention();', + ' if (attention) {', + ' await setAttention(', + ' attention.hookEventName,', + ' attention.properties,', + ' attention.factoryID,', + ' attention.sourceSessionID', + ' );', + ' return;', + ' }', + ' const busyOwner = latestBusyOwner();', + ' if (busyOwner) {', + ' await setStatus("busy", { sessionID: busyOwner.sessionID }, busyOwner.factoryID);', + ' return;', + ' }', + ' await setStatus("idle", { sessionID: preferredSessionID }, fallbackFactoryID);', + '}', + '', + 'async function publishOwnershipChange(fallbackFactoryID, preferredSessionID) {', + ' stateArrivalRevision += 1;', + ' // Why: exact blocker/Busy retirement is authoritative even if ancestry', + ' // lookup failed; every older preview must settle before its replacement.', + ' await flushPendingAssistantPart(true);', + ' await publishAggregateStatus(fallbackFactoryID, preferredSessionID);', + '}', + '' + ] +} diff --git a/src/main/opencode/status-plugin-post-source.ts b/src/main/opencode/status-plugin-post-source.ts new file mode 100644 index 00000000000..0afb7826468 --- /dev/null +++ b/src/main/opencode/status-plugin-post-source.ts @@ -0,0 +1,44 @@ +export function getStatusPluginPostSource(hookPathname: string): string[] { + return [ + 'async function post(hookEventName, extraProperties) {', + ' // Why: resolve coords per post — the endpoint file may have been', + ' // rewritten by a newer Orca since the last call. Pane/tab/worktree IDs', + ' // stay on process.env because they are per-PTY (stable for the life of', + ' // the OpenCode process), not per-Orca-instance.', + ' const coords = resolveHookCoords();', + ' const paneKey = process.env.ORCA_PANE_KEY;', + ' if (!coords.port || !coords.token || !paneKey) return false;', + ` const url = \`http://127.0.0.1:\${coords.port}${hookPathname}\`;`, + ' const body = JSON.stringify({', + ' paneKey,', + ' launchToken: process.env.ORCA_AGENT_LAUNCH_TOKEN || "",', + ' tabId: process.env.ORCA_TAB_ID || "",', + ' worktreeId: process.env.ORCA_WORKTREE_ID || "",', + ' env: coords.env,', + ' version: coords.version,', + ' payload: { hook_event_name: hookEventName, ...(extraProperties || {}) },', + ' });', + ' const controller = new AbortController();', + ' const timeout = setTimeout(() => controller.abort(), HOOK_POST_TIMEOUT_MS);', + ' if (timeout.unref) timeout.unref();', + ' try {', + ' const response = await fetch(url, {', + ' method: "POST",', + ' headers: {', + ' "Content-Type": "application/json",', + ' "X-Orca-Agent-Hook-Token": coords.token,', + ' },', + ' body,', + ' signal: controller.signal,', + ' });', + ' return response.ok;', + ' } catch {', + ' // Why: OpenCode session events must never fail the agent run just', + ' // because Orca is unavailable or the local loopback request failed.', + ' return false;', + ' } finally {', + ' clearTimeout(timeout);', + ' }', + '}' + ] +} diff --git a/src/main/opencode/status-plugin-runtime-state-source.ts b/src/main/opencode/status-plugin-runtime-state-source.ts new file mode 100644 index 00000000000..058ee2cdd07 --- /dev/null +++ b/src/main/opencode/status-plugin-runtime-state-source.ts @@ -0,0 +1,57 @@ +export function getStatusPluginRuntimeStateSource(): string[] { + return [ + 'const HOOK_POST_TIMEOUT_MS = 2000;', + 'const SESSION_LOOKUP_TIMEOUT_MS = 2000;', + 'const MAX_SESSION_ANCESTRY_DEPTH = 32;', + 'const STATUS_RETRY_BASE_MS = 500;', + 'const STATUS_RETRY_MAX_MS = 30000;', + 'let desiredStatus = "idle";', + 'let desiredHookEventName = "SessionIdle";', + 'let desiredStatusKey = "idle:";', + 'let desiredStatusProperties = {};', + 'let desiredFactoryID = null;', + 'let deliveredStatusKey = "idle:";', + 'let deliveredEndpointKey = "";', + 'let statusDeliveryDirty = false;', + 'let statusRevision = 0;', + 'let statusRetryAttempt = 0;', + 'let statusRetryTimer = null;', + 'let lifecycleQueue = Promise.resolve();', + 'let busyRecoveryQueued = false;', + 'let busyRecoveryUsed = false;', + 'let busyRecoveryEndpointKey = "";', + 'let stateArrivalRevision = 0;', + '// Why: OpenCode can create directory-scoped factories and concurrent root', + '// sessions in one pane; module ownership lets waiting/busy aggregate safely.', + 'let nextFactoryID = 0;', + 'const activeFactoryIDs = new Set();', + 'const disposingFactoryIDs = new Set();', + 'const busyRootOwnerBySessionID = new Map();', + '// Why: a matching Idle must retire fail-open Busy even when the SDK client', + '// is unavailable, without granting an unrelated unknown Idle authority.', + 'const provisionalBusyByKey = new Map();', + '// Why: a background child outlives the root turn that spawned it, so the', + '// pane must stay Busy on its behalf until its own exact Idle (or its', + "// factory's disposal) retires it — otherwise the root Idle completes a task", + '// tree that is still working.', + 'const busyChildRootByKey = new Map();', + 'const pendingAttentionByKey = new Map();', + 'const rootSessionById = new Map();', + 'const rootSessionLookupById = new Map();', + '', + '// Why: message.part.updated re-sends the FULL accumulated text of the part', + '// after every streamed append, so posting each event forwards O(n^2) bytes', + '// per turn through Orca (loopback HTTP -> main JSON parse -> status compare', + '// -> IPC -> renderer store update -> React commit). On Windows that flood', + '// saturated both event loops and froze the whole UI a few seconds into a', + '// streaming reply. The dashboard only needs a bounded preview at a human', + '// cadence: cap the text and trailing-edge coalesce assistant parts.', + 'const MESSAGE_PART_THROTTLE_MS = 250;', + 'const MESSAGE_PART_MAX_CHARS = 4000;', + 'let pendingAssistantPart = null;', + 'let assistantPartFlushTimer = null;', + 'let messagePartPostInFlight = null;', + 'let deliveredMessagePartFactoryID = null;', + 'let lastAssistantPartPostAt = 0;' + ] +} diff --git a/src/main/opencode/status-plugin-session-lineage-source.ts b/src/main/opencode/status-plugin-session-lineage-source.ts new file mode 100644 index 00000000000..941f0a4eeb6 --- /dev/null +++ b/src/main/opencode/status-plugin-session-lineage-source.ts @@ -0,0 +1,115 @@ +export function getStatusPluginSessionLineageSource(): string[] { + return [ + '// Why: oh-my-opencode style tools spawn child sessions that emit their', + '// own session.idle / message events. Those child completions must not', + '// flip the root Orca pane to done or overwrite the parent turn preview.', + '// Resolve the full parentID chain so descendant attention can be attributed', + '// to the root while child completion and previews remain non-authoritative.', + 'async function resolveRootSessionID(client, sessionID) {', + ' if (!sessionID) return null;', + ' if (rootSessionById.has(sessionID)) return rootSessionById.get(sessionID);', + ' if (!client?.session?.get && !client?.session?.list) return null;', + ' if (rootSessionLookupById.has(sessionID)) return rootSessionLookupById.get(sessionID);', + ' const lookup = lookupRootSessionID(client, sessionID);', + ' rootSessionLookupById.set(sessionID, lookup);', + ' try {', + ' return await lookup;', + ' } finally {', + ' if (rootSessionLookupById.get(sessionID) === lookup) {', + ' rootSessionLookupById.delete(sessionID);', + ' }', + ' }', + '}', + '', + 'async function isChildSession(client, sessionID) {', + ' const rootSessionID = await resolveRootSessionID(client, sessionID);', + ' return rootSessionID === null ? null : rootSessionID !== sessionID;', + '}', + '', + 'function rememberSessionRoot(sessionID, rootSessionID) {', + ' if (rootSessionById.size >= 128 && !rootSessionById.has(sessionID)) {', + ' const first = rootSessionById.keys().next().value;', + ' if (first !== undefined) rootSessionById.delete(first);', + ' }', + ' rootSessionById.set(sessionID, rootSessionID);', + '}', + '', + 'async function lookupRootSessionID(client, sessionID) {', + ' const controller = new AbortController();', + ' let timeout;', + ' const deadline = new Promise((_, reject) => {', + ' timeout = setTimeout(() => {', + ' controller.abort();', + ' reject(new Error("session lookup timed out"));', + ' }, SESSION_LOOKUP_TIMEOUT_MS);', + ' if (timeout.unref) timeout.unref();', + ' });', + ' try {', + ' const rootSessionID = await Promise.race([', + ' walkSessionParents(client, sessionID, controller.signal),', + ' deadline,', + ' ]);', + ' return rootSessionID;', + ' } catch {', + ' return null;', + ' } finally {', + ' clearTimeout(timeout);', + ' }', + '}', + '', + 'async function walkSessionParents(client, sessionID, signal) {', + ' const lineage = [];', + ' let currentSessionID = sessionID;', + ' // Why: malformed or unexpectedly deep ancestry must not monopolize the', + ' // lifecycle FIFO even when every individual SDK lookup succeeds.', + ' while (lineage.length < MAX_SESSION_ANCESTRY_DEPTH) {', + ' const cachedRoot = rootSessionById.get(currentSessionID);', + ' if (cachedRoot) {', + ' for (const id of lineage) rememberSessionRoot(id, cachedRoot);', + ' return cachedRoot;', + ' }', + ' if (lineage.includes(currentSessionID)) return null;', + ' lineage.push(currentSessionID);', + ' const sessions = await lookupSessionList(client, currentSessionID, signal);', + ' const list = Array.isArray(sessions?.data) ? sessions.data : [];', + ' const session = list.find((entry) => entry?.id === currentSessionID);', + ' if (!session) return null;', + ' if (!session.parentID) {', + ' for (const id of lineage) rememberSessionRoot(id, currentSessionID);', + ' return currentSessionID;', + ' }', + ' currentSessionID = session.parentID;', + ' }', + ' return null;', + '}', + '', + 'async function lookupSessionList(client, sessionID, signal) {', + ' // Why: point lookup avoids the SDK list page dropping older children;', + ' // current SDKs put AbortSignal in a second options argument, while legacy', + ' // generated clients accept one request-options object.', + ' if (client?.session?.get) {', + ' const calls = client.session.get.length >= 2', + ' ? [', + ' [{ sessionID }, { signal }],', + ' [{ path: { id: sessionID }, signal }],', + ' ]', + ' : [[{ path: { id: sessionID }, signal }]];', + ' for (const args of calls) {', + ' try {', + ' const result = await client.session.get(...args);', + ' if (result?.data?.id === sessionID) return { data: [result.data] };', + ' } catch {', + ' if (signal.aborted) throw new Error("session lookup aborted");', + ' // Try the other supported SDK generation, then list fallback.', + ' }', + ' }', + ' }', + ' if (!client?.session?.list) return { data: [] };', + ' if (client.session.list.length >= 2) {', + ' return client.session.list({}, { signal });', + ' }', + ' return client.session.list({ signal });', + '}', + '' + ] +} diff --git a/src/main/orcad/__fixtures__/fake-orcad-electron-sidecar.cjs b/src/main/orcad/__fixtures__/fake-orcad-electron-sidecar.cjs new file mode 100644 index 00000000000..16d356fa4d7 --- /dev/null +++ b/src/main/orcad/__fixtures__/fake-orcad-electron-sidecar.cjs @@ -0,0 +1,166 @@ +/** + * Stands in for an installed Orca app running `--serve`, so the orcad Electron + * provider can be driven over a real socket by a real child process. + * + * Contract with the provider under test: read `--user-data-dir=` from argv, + * listen on a local transport, then publish `orca-runtime.json` there. + * + * Test-only channels (injected by the spawn wrapper, never by production code): + * ORCA_FAKE_SIDECAR_LOG JSONL of every request received. + * ORCA_FAKE_SIDECAR_CONTROL JSON re-read per request: { capabilities, errors }. + * ORCA_FAKE_SIDECAR_MODE 'exit-before-ready' to die without publishing. + */ +'use strict' + +const { createServer } = require('node:net') +const { appendFileSync, readFileSync, renameSync, writeFileSync } = require('node:fs') +const { join } = require('node:path') + +const USER_DATA_FLAG = '--user-data-dir=' +const userDataDir = ( + process.argv.slice(2).find((arg) => arg.startsWith(USER_DATA_FLAG)) ?? '' +).slice(USER_DATA_FLAG.length) +const logPath = process.env.ORCA_FAKE_SIDECAR_LOG +const controlPath = process.env.ORCA_FAKE_SIDECAR_CONTROL + +if (!userDataDir) { + process.stderr.write('fake sidecar: missing --user-data-dir\n') + process.exit(2) +} +if (process.env.ORCA_FAKE_SIDECAR_MODE === 'exit-before-ready') { + process.exit(3) +} + +function control() { + try { + return JSON.parse(readFileSync(controlPath, 'utf8')) + } catch { + return {} + } +} + +const tabs = new Map() +let nextSidecarPageId = 0 +let statusCalls = 0 + +function createTab() { + const browserPageId = `sidecar-${++nextSidecarPageId}` + for (const tab of tabs.values()) { + tab.active = false + } + tabs.set(browserPageId, { browserPageId, active: true, url: 'about:blank' }) + return browserPageId +} + +function activate(browserPageId) { + for (const tab of tabs.values()) { + tab.active = tab.browserPageId === browserPageId + } +} + +function handle(method, params) { + const injected = (control().errors ?? {})[method] + if (injected) { + const error = new Error(injected.message) + error.code = injected.code + throw error + } + if (method === 'status.get') { + statusCalls += 1 + const schedule = control().capabilities ?? [['browser.headless.v1']] + return { capabilities: schedule[Math.min(statusCalls - 1, schedule.length - 1)] } + } + if (method === 'browser.tabCreate') { + return { browserPageId: createTab() } + } + if (method === 'browser.tabProfileClone') { + return { browserPageId: createTab(), sourceBrowserPageId: params.page } + } + if (method === 'browser.tabList') { + return { tabs: [...tabs.values()] } + } + if (method === 'browser.tabShow' || method === 'browser.tabSwitch') { + activate(params.page) + return { browserPageId: params.page } + } + if (method === 'browser.tabClose') { + tabs.delete(params.page) + return { closed: true } + } + if (method === 'browser.goto') { + const tab = tabs.get(params.page) + if (tab) { + tab.url = params.url + } + return { browserPageId: params.page, url: params.url, title: 'Fake page' } + } + return { browserPageId: params.page ?? undefined, method } +} + +const endpoint = + process.platform === 'win32' + ? `\\\\.\\pipe\\orcad-fake-sidecar-${process.pid}` + : join(userDataDir, 'rpc.sock') + +const server = createServer((socket) => { + socket.setEncoding('utf8') + socket.on('error', () => undefined) + let buffer = '' + socket.on('data', (chunk) => { + // Why String(): setEncoding('utf8') above makes this a string at runtime, but the + // declared type stays string | Buffer and the type-aware lint rejects the concat. + buffer += String(chunk) + let newline = buffer.indexOf('\n') + while (newline !== -1) { + const line = buffer.slice(0, newline) + buffer = buffer.slice(newline + 1) + newline = buffer.indexOf('\n') + if (!line.trim()) { + continue + } + const request = JSON.parse(line) + if (logPath) { + appendFileSync( + logPath, + `${JSON.stringify({ + method: request.method, + params: request.params ?? null, + authToken: request.authToken ?? null + })}\n` + ) + } + let response + try { + response = { + id: request.id, + ok: true, + result: handle(request.method, request.params ?? {}) + } + } catch (error) { + response = { + id: request.id, + ok: false, + error: { code: error.code ?? 'browser_error', message: error.message } + } + } + socket.write(`${JSON.stringify(response)}\n`) + } + }) +}) + +server.listen(endpoint, () => { + const metadataPath = join(userDataDir, 'orca-runtime.json') + // Publish atomically: the provider polls with existsSync + readFileSync and would + // otherwise parse a half-written file. + writeFileSync( + `${metadataPath}.staging`, + JSON.stringify({ + runtimeId: `fake-sidecar-${process.pid}`, + pid: process.pid, + transports: [{ kind: process.platform === 'win32' ? 'named-pipe' : 'unix', endpoint }], + authToken: 'fake-sidecar-auth-token', + startedAt: Date.now() + }) + ) + renameSync(`${metadataPath}.staging`, metadataPath) +}) diff --git a/src/main/orcad/electron-serve-browser-process.test.ts b/src/main/orcad/electron-serve-browser-process.test.ts new file mode 100644 index 00000000000..8f81a6f1193 --- /dev/null +++ b/src/main/orcad/electron-serve-browser-process.test.ts @@ -0,0 +1,325 @@ +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RunProcessModule from '../../shared/child-process/run-process' +import type { RuntimeBrowserCommandHost } from '../runtime/orca-runtime-browser' + +const { spawnProcessMock } = vi.hoisted(() => ({ spawnProcessMock: vi.fn() })) + +// Why wrap rather than fake: the provider must still go through the real spawn +// path (argv/env handling, a real pid to signal); only the program is swapped +// for a stub that speaks the sidecar's serve protocol. +vi.mock('../../shared/child-process/run-process', async (importOriginal) => ({ + ...(await importOriginal()), + spawnProcess: spawnProcessMock +})) + +import { ElectronServeBrowserProcess } from './electron-serve-browser-process' + +const FAKE_SIDECAR = join(import.meta.dirname, '__fixtures__', 'fake-orcad-electron-sidecar.cjs') +const INSTALLED_EXECUTABLE = join('/Applications', 'Orca.app', 'Contents', 'MacOS', 'Orca') + +/** Every key the provider must strip so the sidecar cannot inherit orcad's own browser config. */ +const AGENT_BROWSER_ENVIRONMENT_KEYS = [ + 'AGENT_BROWSER_ARGS', + 'AGENT_BROWSER_AUTO_CONNECT', + 'AGENT_BROWSER_CDP', + 'AGENT_BROWSER_ENGINE', + 'AGENT_BROWSER_EXECUTABLE_PATH', + 'AGENT_BROWSER_HEADED', + 'AGENT_BROWSER_PROFILE', + 'AGENT_BROWSER_PROVIDER', + 'AGENT_BROWSER_SESSION', + 'AGENT_BROWSER_SESSION_NAME', + 'AGENT_BROWSER_STATE' +] + +type SidecarRequest = { method: string; params: Record; authToken: string | null } + +const host: RuntimeBrowserCommandHost = { + getAgentBrowserBridge: () => null, + // Why an id distinct from the selector: it proves the registry fences on the + // resolved worktree id, not on whatever string the caller passed. + resolveWorktreeSelector: async (selector) => ({ id: `id-${selector}` }), + getAuthoritativeWindow: () => { + throw new Error('No renderer') + }, + getAvailableAuthoritativeWindow: () => null, + getOffscreenBrowserBackend: () => null +} + +let harnessRoot: string +let logPath: string +let controlPath: string +let sidecarMode: string | undefined +let started: ElectronServeBrowserProcess[] + +async function setControl(control: Record): Promise { + await writeFile(controlPath, JSON.stringify(control)) +} + +async function sidecarRequests(): Promise { + const raw = await readFile(logPath, 'utf8') + return raw + .split('\n') + .filter((line) => line.trim()) + .map((line) => JSON.parse(line) as SidecarRequest) +} + +async function browserRequests(): Promise { + return (await sidecarRequests()).filter((request) => request.method !== 'status.get') +} + +async function startProvider(): Promise { + const processHandle = new ElectronServeBrowserProcess(INSTALLED_EXECUTABLE) + started.push(processHandle) + await processHandle.start() + return processHandle +} + +function spawnSpec(): RunProcessModule.ProcessSpec { + return spawnProcessMock.mock.calls[0][0] as RunProcessModule.ProcessSpec +} + +beforeEach(async () => { + harnessRoot = await mkdtemp(join(tmpdir(), 'orcad-electron-harness-')) + logPath = join(harnessRoot, 'requests.jsonl') + controlPath = join(harnessRoot, 'control.json') + sidecarMode = undefined + started = [] + await writeFile(logPath, '') + await setControl({}) + const actual = await vi.importActual( + '../../shared/child-process/run-process' + ) + spawnProcessMock.mockReset() + spawnProcessMock.mockImplementation((spec: RunProcessModule.ProcessSpec) => + actual.spawnProcess({ + ...spec, + program: process.execPath, + args: [FAKE_SIDECAR, ...(spec.args ?? [])], + env: { + ...spec.env, + ORCA_FAKE_SIDECAR_LOG: logPath, + ORCA_FAKE_SIDECAR_CONTROL: controlPath, + ...(sidecarMode ? { ORCA_FAKE_SIDECAR_MODE: sidecarMode } : {}) + } + }) + ) +}) + +afterEach(async () => { + for (const processHandle of started) { + await processHandle.stop() + } + vi.unstubAllEnvs() + await rm(harnessRoot, { recursive: true, force: true }) +}) + +describe('ElectronServeBrowserProcess start-up', () => { + it('launches the installed app in headless serve mode without orcad browser env', async () => { + for (const key of AGENT_BROWSER_ENVIRONMENT_KEYS) { + vi.stubEnv(key, `leaked-${key}`) + } + vi.stubEnv('ORCA_HARNESS_UNRELATED', 'preserved') + + const processHandle = await startProvider() + + const spec = spawnSpec() + expect(spec.program).toBe(INSTALLED_EXECUTABLE) + const args = [...(spec.args ?? [])] + expect(args).toEqual( + expect.arrayContaining(['--serve', '--serve-port', '--serve-json', '--serve-no-pairing']) + ) + const port = Number(args[args.indexOf('--serve-port') + 1]) + expect(Number.isInteger(port) && port > 0 && port < 65_536).toBe(true) + const userDataArg = args.find((arg) => arg.startsWith('--user-data-dir=')) + expect(userDataArg).toBeDefined() + for (const key of AGENT_BROWSER_ENVIRONMENT_KEYS) { + expect(spec.env).not.toHaveProperty(key) + } + expect(spec.env?.ORCA_HARNESS_UNRELATED).toBe('preserved') + expect(processHandle.isAvailable()).toBe(true) + }) + + it('keeps polling until the sidecar advertises browser.headless.v1', async () => { + await setControl({ + capabilities: [['runtime.v1'], ['runtime.v1'], ['runtime.v1', 'browser.headless.v1']] + }) + + const processHandle = await startProvider() + + const statusCalls = (await sidecarRequests()).filter( + (request) => request.method === 'status.get' + ) + expect(statusCalls).toHaveLength(3) + expect(processHandle.isAvailable()).toBe(true) + }) + + it('fails fast when the installed app exits before publishing runtime metadata', async () => { + sidecarMode = 'exit-before-ready' + const processHandle = new ElectronServeBrowserProcess(INSTALLED_EXECUTABLE) + started.push(processHandle) + + await expect(processHandle.start()).rejects.toThrow(/did not become ready/) + expect(processHandle.isAvailable()).toBe(false) + }) + + it('stops the sidecar process and removes its user data directory', async () => { + const processHandle = await startProvider() + const userDataPath = (spawnSpec().args ?? []) + .find((arg) => arg.startsWith('--user-data-dir='))! + .slice('--user-data-dir='.length) + const metadata = JSON.parse( + await readFile(join(userDataPath, 'orca-runtime.json'), 'utf8') + ) as { pid: number } + + await processHandle.stop() + + expect(processHandle.isAvailable()).toBe(false) + expect(existsSync(userDataPath)).toBe(false) + expect(() => process.kill(metadata.pid, 0)).toThrow() + }) +}) + +describe('ElectronServeBrowserProcess command routing', () => { + it('rejects commands before start and after stop', async () => { + const processHandle = new ElectronServeBrowserProcess(INSTALLED_EXECUTABLE) + await expect( + processHandle.createCommands(host).browserGoto({ url: 'https://example.test/' }) + ).rejects.toMatchObject({ code: 'browser_unavailable' }) + + const runningHandle = await startProvider() + const commands = runningHandle.createCommands(host) + await runningHandle.stop() + await expect(commands.browserGoto({ url: 'https://example.test/' })).rejects.toMatchObject({ + code: 'browser_unavailable' + }) + }) + + it('refuses screencast without reaching the sidecar', async () => { + const processHandle = await startProvider() + + await expect( + processHandle + .createCommands(host) + .browserScreencast({ worktree: 'wt-a', format: 'jpeg' }, { sendBinary: () => true }) + ).rejects.toMatchObject({ code: 'browser_screencast_unavailable' }) + expect(await browserRequests()).toEqual([]) + }) + + it('forwards the sidecar auth token and strips the worktree selector from params', async () => { + const commands = (await startProvider()).createCommands(host) + + const created = (await commands.browserTabCreate({ worktree: 'wt-a' })) as { + browserPageId: string + } + await commands.browserGoto({ worktree: 'wt-a', url: 'https://example.test/' }) + + const requests = await browserRequests() + expect(requests.map((request) => request.method)).toEqual(['browser.tabCreate', 'browser.goto']) + for (const request of requests) { + expect(request.authToken).toBe('fake-sidecar-auth-token') + expect(request.params).not.toHaveProperty('worktree') + } + // The active page is targeted implicitly once the worktree resolves. + expect(requests[1].params.page).toBe(created.browserPageId) + }) + + it('leaves targetless methods untargeted and renumbers listed tabs per worktree', async () => { + const commands = (await startProvider()).createCommands(host) + const first = (await commands.browserTabCreate({ worktree: 'wt-a' })) as { + browserPageId: string + } + const second = (await commands.browserTabCreate({ worktree: 'wt-a' })) as { + browserPageId: string + } + await commands.browserTabCreate({ worktree: 'wt-b' }) + + const listed = (await commands.browserTabList({ worktree: 'wt-a' })) as { + tabs: { browserPageId: string; index: number }[] + } + + expect(listed.tabs).toEqual([ + expect.objectContaining({ browserPageId: first.browserPageId, index: 0 }), + expect.objectContaining({ browserPageId: second.browserPageId, index: 1 }) + ]) + const listRequest = (await browserRequests()).find( + (request) => request.method === 'browser.tabList' + ) + expect(listRequest?.params).not.toHaveProperty('page') + }) + + it('fences pages to the worktree that created them without calling the sidecar', async () => { + const commands = (await startProvider()).createCommands(host) + const created = (await commands.browserTabCreate({ worktree: 'wt-a' })) as { + browserPageId: string + } + const before = (await browserRequests()).length + + await expect( + commands.browserGoto({ + worktree: 'wt-b', + page: created.browserPageId, + url: 'https://example.test/' + }) + ).rejects.toMatchObject({ code: 'browser_tab_not_found' }) + expect(await browserRequests()).toHaveLength(before) + }) + + it('routes browserTabCurrent to browser.tabShow for the active page', async () => { + const commands = (await startProvider()).createCommands(host) + const created = (await commands.browserTabCreate({ worktree: 'wt-a' })) as { + browserPageId: string + } + + await commands.browserTabCurrent({ worktree: 'wt-a' }) + + const request = (await browserRequests()).at(-1) + expect(request?.method).toBe('browser.tabShow') + expect(request?.params.page).toBe(created.browserPageId) + }) + + it('re-creating a known page id is idempotent and issues no sidecar request', async () => { + const commands = (await startProvider()).createCommands(host) + const created = (await commands.browserTabCreate({ worktree: 'wt-a' })) as { + browserPageId: string + } + const before = (await browserRequests()).length + + await expect( + commands.browserTabCreate({ worktree: 'wt-a', page: created.browserPageId }) + ).resolves.toEqual({ browserPageId: created.browserPageId }) + expect(await browserRequests()).toHaveLength(before) + }) + + it('forgets a page the sidecar reports as closed', async () => { + const commands = (await startProvider()).createCommands(host) + await commands.browserTabCreate({ worktree: 'wt-a' }) + await setControl({ + errors: { 'browser.goto': { code: 'browser_tab_closed', message: 'Tab was closed.' } } + }) + + await expect( + commands.browserGoto({ worktree: 'wt-a', url: 'https://example.test/' }) + ).rejects.toMatchObject({ code: 'browser_tab_closed' }) + + await setControl({}) + await expect(commands.browserTabCurrent({ worktree: 'wt-a' })).rejects.toMatchObject({ + code: 'browser_no_tab' + }) + }) + + // Why this matters: the runtime advertises browser.tabCreate.known-id.v1 + // unconditionally, so a web client will send a provisional id for a page that does + // not exist yet. Requiring it first made every such create throw. + it('creates a tab under a caller-chosen page id', async () => { + const commands = (await startProvider()).createCommands(host) + + await expect( + commands.browserTabCreate({ worktree: 'wt-a', page: 'provisional-page-1' }) + ).resolves.toEqual({ browserPageId: 'provisional-page-1' }) + }) +}) diff --git a/src/main/orcad/electron-serve-browser-process.ts b/src/main/orcad/electron-serve-browser-process.ts new file mode 100644 index 00000000000..ae3508289c9 --- /dev/null +++ b/src/main/orcad/electron-serve-browser-process.ts @@ -0,0 +1,304 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { createServer, type AddressInfo } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { z } from 'zod' +import { BrowserError } from '../browser/browser-error' +import type { + RuntimeBrowserCommandHost, + RuntimeBrowserCommands +} from '../runtime/orca-runtime-browser' +import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../shared/runtime-types' +import { readRuntimeMetadata } from '../runtime/runtime-metadata' +import { spawnProcess, type SpawnedProcess } from '../../shared/child-process/run-process' +import { sendOrcadSidecarRequest } from './orcad-sidecar-runtime-client' +import { + ElectronSidecarTabRegistry, + ElectronSidecarTabSchema, + type ElectronSidecarPage +} from './electron-sidecar-tab-registry' +import { + electronSidecarRuntimeMethodName, + TARGETLESS_BROWSER_METHODS +} from './electron-sidecar-method-routing' + +const START_TIMEOUT_MS = 120_000 +const STOP_TIMEOUT_MS = 5_000 +const BrowserPageResult = z.object({ browserPageId: z.string() }).passthrough() +const BrowserCloseResult = z.object({ closed: z.boolean() }).passthrough() +const BrowserTabListResult = z.object({ tabs: z.array(ElectronSidecarTabSchema) }).passthrough() +const RuntimeStatusResult = z.object({ capabilities: z.array(z.string()).optional() }).passthrough() + +async function reserveLoopbackPort(): Promise { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject) + resolve() + }) + }) + const address = server.address() as AddressInfo + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) + return address.port +} +function electronServeEnvironment(): NodeJS.ProcessEnv { + const environment = { ...process.env } + for (const key of [ + 'AGENT_BROWSER_ARGS', + 'AGENT_BROWSER_AUTO_CONNECT', + 'AGENT_BROWSER_CDP', + 'AGENT_BROWSER_ENGINE', + 'AGENT_BROWSER_EXECUTABLE_PATH', + 'AGENT_BROWSER_HEADED', + 'AGENT_BROWSER_PROFILE', + 'AGENT_BROWSER_PROVIDER', + 'AGENT_BROWSER_SESSION', + 'AGENT_BROWSER_SESSION_NAME', + 'AGENT_BROWSER_STATE' + ]) { + delete environment[key] + } + return environment +} + +function signalProcess(pid: number, signal: NodeJS.Signals): void { + try { + process.kill(pid, signal) + } catch { + // The sidecar already exited. + } +} + +function processIsLive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +export class ElectronServeBrowserProcess { + private child: SpawnedProcess | null = null + private metadata: RuntimeMetadata | null = null + private readonly tabs = new ElectronSidecarTabRegistry() + private sidecarDataPath: string | null = null + + constructor(private readonly executablePath: string) {} + + async start(): Promise { + const temporaryRoot = process.platform === 'win32' ? tmpdir() : '/tmp' + const userDataPath = await mkdtemp(join(temporaryRoot, 'orcad-browser-')) + this.sidecarDataPath = userDataPath + const port = await reserveLoopbackPort() + const child = spawnProcess({ + program: this.executablePath, + args: [ + '--serve', + '--serve-port', + String(port), + '--serve-json', + '--serve-no-pairing', + `--user-data-dir=${userDataPath}` + ], + env: electronServeEnvironment() + }) + this.child = child + for (const stream of [child.stdout, child.stderr]) { + stream?.on('error', () => undefined) + stream?.resume() + } + const deadline = Date.now() + START_TIMEOUT_MS + let lastError: unknown = null + while (Date.now() < deadline) { + const metadata = readRuntimeMetadata(userDataPath) + if (metadata) { + try { + const status = RuntimeStatusResult.parse( + await sendOrcadSidecarRequest(metadata, 'status.get', undefined, 5_000) + ) + if (status.capabilities?.includes('browser.headless.v1')) { + this.metadata = metadata + return + } + lastError = new Error('Installed Electron app omitted browser.headless.v1.') + } catch (error) { + lastError = error + } + } + if (child.exitCode !== null || child.signalCode !== null) { + break + } + await delay(100) + } + throw new Error( + `Installed Electron browser provider did not become ready: ${ + lastError instanceof Error ? lastError.message : 'no runtime metadata' + }` + ) + } + + createCommands(host: RuntimeBrowserCommandHost): RuntimeBrowserCommands { + return new Proxy({} as RuntimeBrowserCommands, { + get: (_target, property) => { + if (property === 'then') { + return undefined + } + if (typeof property !== 'string') { + return undefined + } + return (...args: unknown[]) => this.invoke(host, property, args) + } + }) + } + isAvailable(): boolean { + return this.metadata !== null && processIsLive(this.metadata.pid) + } + + async stop(): Promise { + const child = this.child + const sidecarPid = this.metadata?.pid ?? child?.pid ?? null + const sidecarDataPath = this.sidecarDataPath + this.child = null + this.metadata = null + this.sidecarDataPath = null + this.tabs.clear() + if (sidecarPid) { + signalProcess(sidecarPid, 'SIGTERM') + const deadline = Date.now() + STOP_TIMEOUT_MS + while (processIsLive(sidecarPid) && Date.now() < deadline) { + await delay(50) + } + if (processIsLive(sidecarPid)) { + signalProcess(sidecarPid, 'SIGKILL') + } + } + if (sidecarDataPath) { + await rm(sidecarDataPath, { recursive: true, force: true }) + } + } + + private async invoke( + host: RuntimeBrowserCommandHost, + method: string, + args: unknown[] + ): Promise { + if (method === 'browserScreencast') { + throw new BrowserError( + 'browser_screencast_unavailable', + 'The orcad Electron provider does not proxy screencast.' + ) + } + const metadata = this.metadata + if (!metadata) { + throw new BrowserError( + BROWSER_UNAVAILABLE_ERROR_CODE, + 'The Electron browser provider is not running.' + ) + } + const original = (args[0] ?? {}) as Record + const worktreeId = + typeof original.worktree === 'string' + ? (await host.resolveWorktreeSelector(original.worktree)).id + : undefined + const params: Record = { ...original, worktree: undefined } + const requestedPageId = typeof original.page === 'string' ? original.page : undefined + const requestedIndex = typeof original.index === 'number' ? original.index : undefined + let targetPage: ElectronSidecarPage | undefined + let rpcMethod = electronSidecarRuntimeMethodName(method) + if (method === 'browserTabCreate' && requestedPageId) { + const existing = this.tabs.find(requestedPageId) + if (existing) { + this.tabs.require(requestedPageId, worktreeId) + return { browserPageId: requestedPageId } + } + // Why drop `page`: the runtime advertises browser.tabCreate.known-id.v1, so web + // clients send a provisional id for a page that does not exist yet. The sidecar + // mints its own id and the caller's is adopted as the public one below; passing + // the unknown id through would make the generic branch require() a missing page. + delete params.page + } + + if (method === 'browserTabCurrent' && worktreeId) { + targetPage = this.tabs.active(worktreeId) + rpcMethod = 'browser.tabShow' + params.page = targetPage.sidecarPageId + } else if (method === 'browserTabSwitch' || method === 'browserTabClose') { + targetPage = requestedPageId + ? this.tabs.require(requestedPageId, worktreeId) + : requestedIndex !== undefined + ? this.tabs.pageAt(worktreeId, requestedIndex) + : worktreeId + ? this.tabs.active(worktreeId) + : undefined + if (targetPage) { + params.page = targetPage.sidecarPageId + delete params.index + } + } else if (requestedPageId && method !== 'browserTabCreate') { + targetPage = this.tabs.require(requestedPageId, worktreeId) + params.page = targetPage.sidecarPageId + } else if (worktreeId && !TARGETLESS_BROWSER_METHODS[method]) { + targetPage = this.tabs.active(worktreeId) + params.page = targetPage.sidecarPageId + } + + let result: unknown + try { + result = await sendOrcadSidecarRequest(metadata, rpcMethod, params) + } catch (error) { + if ( + targetPage && + error instanceof BrowserError && + (error.code === 'browser_tab_not_found' || error.code === 'browser_tab_closed') + ) { + this.tabs.delete(targetPage) + } + throw error + } + if (method === 'browserTabCreate') { + const created = BrowserPageResult.parse(result) + const page = this.tabs.register(created.browserPageId, requestedPageId, worktreeId) + return { ...created, browserPageId: page.publicPageId } + } + if (method === 'browserTabList') { + const listed = BrowserTabListResult.parse(result) + return { ...listed, tabs: this.tabs.reconcileTabs(listed.tabs, worktreeId) } + } + if (method === 'browserTabSwitch') { + const switched = BrowserPageResult.parse(result) + const page = this.tabs.pageForSidecar(switched.browserPageId) + if (page) { + this.tabs.setActive(page) + } + return { + ...switched, + ...(requestedIndex !== undefined ? { switched: requestedIndex } : {}), + browserPageId: this.tabs.publicPageId(switched.browserPageId) + } + } + if (method === 'browserTabClose' && targetPage) { + const closed = BrowserCloseResult.parse(result) + if (closed.closed) { + this.tabs.delete(targetPage) + } + return closed + } + if (method === 'browserTabProfileClone') { + const cloned = BrowserPageResult.parse(result) + this.tabs.register(cloned.browserPageId, undefined, targetPage?.worktreeId) + } + return this.tabs.rewriteResult(result) + } +} diff --git a/src/main/orcad/electron-serve-provider-selection.test.ts b/src/main/orcad/electron-serve-provider-selection.test.ts new file mode 100644 index 00000000000..663802a45c5 --- /dev/null +++ b/src/main/orcad/electron-serve-provider-selection.test.ts @@ -0,0 +1,148 @@ +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RunProcessModule from '../../shared/child-process/run-process' + +const { runProcessMock, spawnProcessMock } = vi.hoisted(() => ({ + runProcessMock: vi.fn(), + spawnProcessMock: vi.fn() +})) + +vi.mock('../../shared/child-process/run-process', async (importOriginal) => ({ + ...(await importOriginal()), + runProcess: runProcessMock, + spawnProcess: spawnProcessMock +})) + +import { resolveOrcadBrowserProvider, type OrcadBrowserProvider } from './orcad-browser-provider' + +const FAKE_SIDECAR = join(import.meta.dirname, '__fixtures__', 'fake-orcad-electron-sidecar.cjs') +const INSTALLED_EXECUTABLE = join('/Applications', 'Orca.app', 'Contents', 'MacOS', 'Orca') + +let harnessRoot: string +let controlPath: string +let chromiumExecutable: string +let sidecarMode: string | undefined +let provider: OrcadBrowserProvider | null + +/** Strips the session/profile/args prefix the agent-browser invocation carries. */ +function commandFromArgs(args: readonly string[]): string[] { + let index = 0 + while (['--session', '--profile', '--args'].includes(args[index] ?? '')) { + index += 2 + } + return args.slice(index, -1) +} + +function agentBrowserSuccess(data: unknown) { + return Promise.resolve({ + code: 0, + signal: null, + stdout: JSON.stringify({ success: true, data, error: null }), + stderr: '', + timedOut: false + }) +} + +beforeEach(async () => { + harnessRoot = await mkdtemp(join(tmpdir(), 'orcad-provider-selection-')) + controlPath = join(harnessRoot, 'control.json') + chromiumExecutable = join(harnessRoot, process.platform === 'win32' ? 'chromium.exe' : 'chromium') + sidecarMode = undefined + provider = null + await writeFile(controlPath, '{}') + await writeFile(chromiumExecutable, '') + if (process.platform !== 'win32') { + await chmod(chromiumExecutable, 0o755) + } + + const actual = await vi.importActual( + '../../shared/child-process/run-process' + ) + spawnProcessMock.mockReset() + spawnProcessMock.mockImplementation((spec: RunProcessModule.ProcessSpec) => + actual.spawnProcess({ + ...spec, + program: process.execPath, + args: [FAKE_SIDECAR, ...(spec.args ?? [])], + env: { + ...spec.env, + ORCA_FAKE_SIDECAR_CONTROL: controlPath, + ...(sidecarMode ? { ORCA_FAKE_SIDECAR_MODE: sidecarMode } : {}) + } + }) + ) + runProcessMock.mockReset() + runProcessMock.mockImplementation(async (spec: RunProcessModule.ProcessSpec) => { + const command = commandFromArgs(spec.args ?? []) + if (command[0] === 'open') { + return agentBrowserSuccess({ url: 'about:blank', title: '' }) + } + if (command[0] === 'tab') { + return agentBrowserSuccess({ + tabs: [{ active: true, tabId: 't1', title: '', url: 'about:blank' }] + }) + } + if (command[0] === 'close') { + return agentBrowserSuccess({ closed: true }) + } + throw new Error(`Unexpected agent-browser command: ${command.join(' ')}`) + }) +}) + +afterEach(async () => { + await provider?.stop() + vi.restoreAllMocks() + await rm(harnessRoot, { recursive: true, force: true }) +}) + +describe('resolveOrcadBrowserProvider Electron preference', () => { + it('uses the installed Electron app even when a Chromium executable is configured', async () => { + provider = await resolveOrcadBrowserProvider({ + userDataPath: join(harnessRoot, 'state'), + environment: { ORCA_BROWSER_EXECUTABLE: chromiumExecutable }, + resolveInstalledElectronExecutable: async () => INSTALLED_EXECUTABLE, + resolveAgentBrowserBinary: () => '/agent-browser' + }) + + expect(provider?.kind).toBe('electron') + expect(provider?.isAvailable()).toBe(true) + expect(spawnProcessMock.mock.calls[0][0].program).toBe(INSTALLED_EXECUTABLE) + expect(runProcessMock).not.toHaveBeenCalled() + }) + + it('falls back to the operator Chromium when the installed Electron app cannot start', async () => { + sidecarMode = 'exit-before-ready' + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + provider = await resolveOrcadBrowserProvider({ + userDataPath: join(harnessRoot, 'state'), + environment: { ORCA_BROWSER_EXECUTABLE: chromiumExecutable }, + resolveInstalledElectronExecutable: async () => INSTALLED_EXECUTABLE, + resolveAgentBrowserBinary: () => '/agent-browser' + }) + + expect(spawnProcessMock).toHaveBeenCalled() + expect(provider?.kind).toBe('chromium') + expect(warn).toHaveBeenCalledWith( + '[orcad] Installed Electron browser provider unavailable:', + expect.anything() + ) + }) + + it('returns no provider when Electron fails and no Chromium is configured', async () => { + sidecarMode = 'exit-before-ready' + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + provider = await resolveOrcadBrowserProvider({ + userDataPath: join(harnessRoot, 'state'), + environment: {}, + resolveInstalledElectronExecutable: async () => INSTALLED_EXECUTABLE, + resolveAgentBrowserBinary: () => '/agent-browser' + }) + + expect(provider).toBeNull() + expect(runProcessMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/orcad/electron-sidecar-method-routing.test.ts b/src/main/orcad/electron-sidecar-method-routing.test.ts new file mode 100644 index 00000000000..07f96a10d7b --- /dev/null +++ b/src/main/orcad/electron-sidecar-method-routing.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { BROWSER_CORE_METHODS } from '../runtime/rpc/methods/browser-core' +import { BROWSER_EXTRA_METHODS } from '../runtime/rpc/methods/browser-extras' +import { BROWSER_SCREENCAST_METHODS } from '../runtime/rpc/methods/browser-screencast' +import { electronSidecarRuntimeMethodName } from './electron-sidecar-method-routing' + +/** + * Why drive the real registry: the Electron provider reaches the sidecar by + * reversing a string transform, so a browser RPC method whose name is not a + * mechanical camel-case of its runtime command is silently unreachable — the + * sidecar answers "unknown method", not "wrong route". + */ +const BROWSER_RPC_METHODS = [ + ...BROWSER_CORE_METHODS, + ...BROWSER_SCREENCAST_METHODS, + ...BROWSER_EXTRA_METHODS +] + +/** + * Known-broken today (PR #16193 shipped these mis-routed): the RPC names drop + * the `Log` suffix that the runtime command carries. Fix the transform and + * delete the entry — do not add to this list to silence a new mismatch. + */ +const UNROUTABLE_RUNTIME_COMMANDS = new Set(['browserConsoleLog', 'browserNetworkLog']) + +/** `browser.screencast.unsubscribe` cleans up a subscription without a browser command. */ +const RPC_METHODS_WITHOUT_BROWSER_COMMAND = new Set(['browser.screencast.unsubscribe']) + +/** Records the `runtime.browserX` the handler dispatches to, without a live runtime. */ +async function runtimeCommandFor( + method: (typeof BROWSER_RPC_METHODS)[number] +): Promise { + let recorded: string | null = null + const runtime = new Proxy({} as Record, { + get: (_target, property) => { + if (typeof property === 'string' && recorded === null && property.startsWith('browser')) { + recorded = property + } + return () => ({}) + } + }) + try { + await (method.handler as (...args: unknown[]) => Promise)( + { value: '', input: '', text: '', subscriptionId: 'subscription' }, + { runtime }, + () => undefined + ) + } catch { + // Handlers that guard on host state still record the property access above. + } + return recorded +} + +describe('electronSidecarRuntimeMethodName', () => { + it('round-trips every registered browser RPC method', async () => { + const mismatches: string[] = [] + const unmapped: string[] = [] + for (const method of BROWSER_RPC_METHODS) { + const command = await runtimeCommandFor(method) + if (!command) { + unmapped.push(method.name) + continue + } + if (UNROUTABLE_RUNTIME_COMMANDS.has(command)) { + expect(electronSidecarRuntimeMethodName(command)).not.toBe(method.name) + continue + } + const routed = electronSidecarRuntimeMethodName(command) + if (routed !== method.name) { + mismatches.push(`${command} routes to ${routed}, sidecar registers ${method.name}`) + } + } + expect(mismatches).toEqual([]) + expect(unmapped).toEqual([...RPC_METHODS_WITHOUT_BROWSER_COMMAND]) + }) + + it('covers every registered browser method, so the round-trip cannot pass vacuously', async () => { + expect(BROWSER_RPC_METHODS.length).toBeGreaterThan(70) + expect(BROWSER_RPC_METHODS.map((method) => method.name)).toEqual( + expect.arrayContaining([ + 'browser.certificate.proceed', + 'browser.viewport', + 'browser.geolocation', + 'browser.cookie.get', + 'browser.intercept.enable', + 'browser.capture.start', + 'browser.storage.local.set', + 'browser.storage.session.clear' + ]) + ) + }) +}) diff --git a/src/main/orcad/electron-sidecar-method-routing.ts b/src/main/orcad/electron-sidecar-method-routing.ts new file mode 100644 index 00000000000..a2dcfec79ef --- /dev/null +++ b/src/main/orcad/electron-sidecar-method-routing.ts @@ -0,0 +1,37 @@ +export const TARGETLESS_BROWSER_METHODS: Record = { + browserProfileClearDefaultCookies: true, + browserProfileCreate: true, + browserProfileDelete: true, + browserProfileDetectBrowsers: true, + browserProfileImportFromBrowser: true, + browserProfileList: true, + browserTabCreate: true, + browserTabList: true +} + +export function electronSidecarRuntimeMethodName(browserMethod: string): string { + const suffix = browserMethod.slice('browser'.length) + if (suffix === 'ProceedCertificate') { + return 'browser.certificate.proceed' + } + if (suffix === 'SetViewport') { + return 'browser.viewport' + } + if (suffix === 'SetGeolocation') { + return 'browser.geolocation' + } + for (const group of ['Cookie', 'Intercept', 'Capture'] as const) { + if (suffix.startsWith(group)) { + const action = suffix.slice(group.length) + return `browser.${group.toLowerCase()}.${action[0].toLowerCase()}${action.slice(1)}` + } + } + for (const storage of ['StorageLocal', 'StorageSession'] as const) { + if (suffix.startsWith(storage)) { + const action = suffix.slice(storage.length) + const area = storage === 'StorageLocal' ? 'local' : 'session' + return `browser.storage.${area}.${action[0].toLowerCase()}${action.slice(1)}` + } + } + return `browser.${suffix[0].toLowerCase()}${suffix.slice(1)}` +} diff --git a/src/main/orcad/electron-sidecar-tab-registry.test.ts b/src/main/orcad/electron-sidecar-tab-registry.test.ts new file mode 100644 index 00000000000..3a53664b031 --- /dev/null +++ b/src/main/orcad/electron-sidecar-tab-registry.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { ElectronSidecarTabRegistry } from './electron-sidecar-tab-registry' + +describe('ElectronSidecarTabRegistry', () => { + it('preserves requested ids and fences explicit worktree scope', () => { + const registry = new ElectronSidecarTabRegistry() + registry.register('sidecar-a', 'public-a', 'worktree-a') + registry.register('sidecar-b', 'public-b', 'worktree-b') + + expect(registry.require('public-a', 'worktree-a').sidecarPageId).toBe('sidecar-a') + expect(() => registry.require('public-a', 'worktree-b')).toThrow( + expect.objectContaining({ code: 'browser_tab_not_found' }) + ) + expect(registry.active('worktree-b').publicPageId).toBe('public-b') + }) + + it('rewrites sidecar ids, worktree-relative indices, and stale pages', () => { + const registry = new ElectronSidecarTabRegistry() + registry.register('sidecar-a', 'public-a', 'worktree-a') + registry.register('sidecar-b', 'public-b', 'worktree-b') + registry.register('sidecar-c', 'public-c', 'worktree-a') + + const tabs = registry.reconcileTabs( + [ + { browserPageId: 'sidecar-a', active: false }, + { browserPageId: 'sidecar-b', active: true }, + { browserPageId: 'sidecar-c', active: true } + ], + 'worktree-a' + ) + expect(tabs).toEqual([ + expect.objectContaining({ browserPageId: 'public-a', index: 0 }), + expect.objectContaining({ browserPageId: 'public-c', index: 1 }) + ]) + expect(registry.active('worktree-a').publicPageId).toBe('public-c') + + registry.reconcileTabs([{ browserPageId: 'sidecar-c' }]) + expect(() => registry.require('public-a')).toThrow( + expect.objectContaining({ code: 'browser_tab_not_found' }) + ) + }) + + it('rewrites page ids in tab and command results', () => { + const registry = new ElectronSidecarTabRegistry() + registry.register('sidecar-a', 'public-a', 'worktree-a') + + expect( + registry.rewriteResult({ + browserPageId: 'sidecar-a', + tab: { + browserPageId: 'sidecar-a', + certificateFailure: { browserPageId: 'sidecar-a', challengeId: 'challenge-a' } + } + }) + ).toEqual({ + browserPageId: 'public-a', + tab: { + browserPageId: 'public-a', + certificateFailure: { browserPageId: 'public-a', challengeId: 'challenge-a' } + } + }) + }) +}) diff --git a/src/main/orcad/electron-sidecar-tab-registry.ts b/src/main/orcad/electron-sidecar-tab-registry.ts new file mode 100644 index 00000000000..8b3b9940b2e --- /dev/null +++ b/src/main/orcad/electron-sidecar-tab-registry.ts @@ -0,0 +1,169 @@ +import { z } from 'zod' +import { BrowserError } from '../browser/browser-error' + +const CertificateFailure = z + .object({ browserPageId: z.string() }) + .passthrough() + .nullable() + .optional() +export const ElectronSidecarTabSchema = z + .object({ + browserPageId: z.string(), + index: z.number().optional(), + active: z.boolean().optional(), + certificateFailure: CertificateFailure + }) + .passthrough() +const ElectronSidecarResult = z + .object({ + browserPageId: z.string().optional(), + sourceBrowserPageId: z.string().optional(), + tab: ElectronSidecarTabSchema.optional() + }) + .passthrough() + +export type ElectronSidecarTab = z.infer + +export type ElectronSidecarPage = { + publicPageId: string + sidecarPageId: string + worktreeId?: string +} + +export class ElectronSidecarTabRegistry { + private readonly pagesByPublicId = new Map() + private readonly pagesBySidecarId = new Map() + private readonly activePageIdByWorktree = new Map() + + clear(): void { + this.pagesByPublicId.clear() + this.pagesBySidecarId.clear() + this.activePageIdByWorktree.clear() + } + + register(sidecarPageId: string, requestedPageId: string | undefined, worktreeId?: string) { + const publicPageId = requestedPageId || sidecarPageId + const page = { publicPageId, sidecarPageId, worktreeId } + this.pagesByPublicId.set(publicPageId, page) + this.pagesBySidecarId.set(sidecarPageId, page) + this.setActive(page) + return page + } + find(publicPageId: string): ElectronSidecarPage | undefined { + return this.pagesByPublicId.get(publicPageId) + } + + require(publicPageId: string, worktreeId?: string): ElectronSidecarPage { + const page = this.pagesByPublicId.get(publicPageId) + if (!page || (worktreeId && page.worktreeId !== worktreeId)) { + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${publicPageId} was not found in this worktree.` + ) + } + return page + } + + active(worktreeId?: string): ElectronSidecarPage { + const publicPageId = this.activePageIdByWorktree.get(worktreeId ?? '') + const page = publicPageId ? this.pagesByPublicId.get(publicPageId) : undefined + if (!page) { + throw new BrowserError('browser_no_tab', 'No browser tab is active in this worktree.') + } + return page + } + + pageAt(worktreeId: string | undefined, index: number): ElectronSidecarPage { + const page = [...this.pagesByPublicId.values()].filter( + (candidate) => !worktreeId || candidate.worktreeId === worktreeId + )[index] + if (!page) { + throw new BrowserError('browser_tab_not_found', `Browser tab index ${index} was not found.`) + } + return page + } + + pageForSidecar(sidecarPageId: string): ElectronSidecarPage | undefined { + return this.pagesBySidecarId.get(sidecarPageId) + } + + setActive(page: ElectronSidecarPage): void { + this.activePageIdByWorktree.set(page.worktreeId ?? '', page.publicPageId) + } + + delete(page: ElectronSidecarPage): void { + this.pagesByPublicId.delete(page.publicPageId) + this.pagesBySidecarId.delete(page.sidecarPageId) + const key = page.worktreeId ?? '' + if (this.activePageIdByWorktree.get(key) === page.publicPageId) { + const replacement = [...this.pagesByPublicId.values()].find( + (candidate) => candidate.worktreeId === page.worktreeId + ) + if (replacement) { + this.activePageIdByWorktree.set(key, replacement.publicPageId) + } else { + this.activePageIdByWorktree.delete(key) + } + } + } + + publicPageId(sidecarPageId: string): string { + return this.pagesBySidecarId.get(sidecarPageId)?.publicPageId ?? sidecarPageId + } + + reconcileTabs(tabs: ElectronSidecarTab[], worktreeId?: string): ElectronSidecarTab[] { + const liveSidecarPageIds = new Set(tabs.map((tab) => tab.browserPageId)) + for (const page of this.pagesByPublicId.values()) { + if (!liveSidecarPageIds.has(page.sidecarPageId)) { + this.delete(page) + } + } + return tabs + .flatMap((tab) => { + const page = + this.pagesBySidecarId.get(tab.browserPageId) ?? + (worktreeId ? undefined : this.register(tab.browserPageId, undefined, undefined)) + if (!page || (worktreeId && page.worktreeId !== worktreeId)) { + return [] + } + if (tab.active === true) { + this.setActive(page) + } + return [this.rewriteTab(tab)] + }) + .map((tab, index) => ({ ...tab, index })) + } + + rewriteResult(result: unknown): unknown { + const parsed = ElectronSidecarResult.safeParse(result) + if (!parsed.success) { + return result + } + const rewritten = { ...parsed.data } + if (rewritten.browserPageId) { + rewritten.browserPageId = this.publicPageId(rewritten.browserPageId) + } + if (rewritten.sourceBrowserPageId) { + rewritten.sourceBrowserPageId = this.publicPageId(rewritten.sourceBrowserPageId) + } + if (rewritten.tab) { + rewritten.tab = this.rewriteTab(rewritten.tab) + } + return rewritten + } + + private rewriteTab(tab: ElectronSidecarTab): ElectronSidecarTab { + return { + ...tab, + browserPageId: this.publicPageId(tab.browserPageId), + ...(tab.certificateFailure + ? { + certificateFailure: { + ...tab.certificateFailure, + browserPageId: this.publicPageId(tab.certificateFailure.browserPageId) + } + } + : {}) + } + } +} diff --git a/src/main/orcad/external-chromium-browser-process.integration.test.ts b/src/main/orcad/external-chromium-browser-process.integration.test.ts new file mode 100644 index 00000000000..bcf3f5f38c9 --- /dev/null +++ b/src/main/orcad/external-chromium-browser-process.integration.test.ts @@ -0,0 +1,71 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' +import { resolveOrcadBrowserProvider } from './orcad-browser-provider' + +const executablePath = process.env.ORCA_BROWSER_EXECUTABLE + +// Why 120s rather than the 30s global default: one macOS run took 30s and timed out, +// while a Linux run with an empty ~/.agent-browser was 2.8s — so the cost looks like a +// one-time Gatekeeper/codesign verification of the Rust binary, not a Linux cold start. +// Sized for the slow observation anyway: headroom on a passing test is free, and a +// timeout here would land as a flaky required check. +const COLD_BROWSER_START_TIMEOUT_MS = 120_000 + +describe('ExternalChromiumBrowserProcess integration', () => { + it.runIf(Boolean(executablePath))( + 'navigates, evaluates, and screenshots with the operator executable', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orcad-external-browser-')) + const fixturePath = join(root, 'fixture.html') + await writeFile( + fixturePath, + 'External Chromium
external-ready
' + ) + const provider = await resolveOrcadBrowserProvider({ + userDataPath: root, + environment: { ORCA_BROWSER_EXECUTABLE: executablePath }, + resolveInstalledElectronExecutable: async () => null + }) + try { + expect(provider?.kind).toBe('chromium') + if (!provider) { + throw new Error('External Chromium provider did not resolve.') + } + const commands = provider.factory({ + getAgentBrowserBridge: () => null, + resolveWorktreeSelector: async (selector) => ({ id: selector }), + getAuthoritativeWindow: () => { + throw new Error('No renderer') + }, + getAvailableAuthoritativeWindow: () => null, + getOffscreenBrowserBackend: () => null + }) + await expect( + commands.browserTabCreate({ page: 'external-page', url: 'about:blank' }) + ).resolves.toEqual({ browserPageId: 'external-page' }) + await expect( + commands.browserGoto({ + page: 'external-page', + url: pathToFileURL(fixturePath).href + }) + ).resolves.toMatchObject({ title: 'External Chromium' }) + await expect( + commands.browserEval({ + page: 'external-page', + expression: 'document.querySelector("main")?.textContent' + }) + ).resolves.toMatchObject({ result: 'external-ready' }) + await expect( + commands.browserScreenshot({ page: 'external-page', format: 'png' }) + ).resolves.toMatchObject({ data: expect.stringMatching(/\S+/), format: 'png' }) + } finally { + await provider?.stop() + await rm(root, { recursive: true, force: true }) + } + }, + COLD_BROWSER_START_TIMEOUT_MS + ) +}) diff --git a/src/main/orcad/external-chromium-browser-process.ts b/src/main/orcad/external-chromium-browser-process.ts new file mode 100644 index 00000000000..912a44775e7 --- /dev/null +++ b/src/main/orcad/external-chromium-browser-process.ts @@ -0,0 +1,188 @@ +import { z } from 'zod' +import type { + RuntimeBrowserCommandHost, + RuntimeBrowserCommands +} from '../runtime/orca-runtime-browser' +import { BrowserError } from '../browser/browser-error' +import { + ExternalChromiumBrowserSession, + type ExternalChromiumLaunch +} from './external-chromium-browser-session' +export type { ExternalChromiumLaunch } from './external-chromium-browser-session' +import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' +import { + externalChromiumCommandArguments, + normalizeExternalChromiumCommandResult +} from './external-chromium-command-arguments' +import { + externalChromiumSnapshotResult, + type ExternalChromiumPageRecord as PageRecord +} from './external-chromium-tab-projection' +import { ExternalChromiumTabRegistry } from './external-chromium-tab-registry' +const AgentBrowserSnapshot = z.object({ + refs: z + .record(z.string(), z.object({ name: z.string().optional(), role: z.string().optional() })) + .optional(), + snapshot: z.string().optional() +}) + +export class ExternalChromiumBrowserProcess { + private readonly session: ExternalChromiumBrowserSession + private readonly tabs: ExternalChromiumTabRegistry + private queue: Promise = Promise.resolve() + private available = false + + constructor(agentBrowserPath: string, launch: ExternalChromiumLaunch, statePath: string) { + this.session = new ExternalChromiumBrowserSession(agentBrowserPath, launch, statePath) + this.tabs = new ExternalChromiumTabRegistry(this.session) + } + + async start(): Promise { + this.tabs.initialize(await this.session.start()) + this.available = true + } + + createCommands(host: RuntimeBrowserCommandHost): RuntimeBrowserCommands { + return new Proxy({} as RuntimeBrowserCommands, { + get: (_target, property) => { + if (property === 'then') { + return undefined + } + if (typeof property !== 'string') { + return undefined + } + return (...args: unknown[]) => this.enqueue(() => this.invoke(host, property, args)) + } + }) + } + isAvailable(): boolean { + return this.available + } + + async stop(): Promise { + this.available = false + await this.enqueue(async () => { + await this.session.stop() + this.tabs.clear() + }) + } + + private enqueue(operation: () => Promise): Promise { + const result = this.queue.then(operation, operation) + this.queue = result.then( + () => undefined, + () => undefined + ) + return result + } + + private async invoke( + host: RuntimeBrowserCommandHost, + method: string, + args: unknown[] + ): Promise { + const params = (args[0] ?? {}) as Record + if (method === 'browserTabCreate') { + return this.tabs.createTab(host, params) + } + if (method === 'browserTabList') { + return this.tabs.listTabs(host, params) + } + if (method === 'browserTabShow') { + return { tab: await this.tabs.describeTab(host, params) } + } + if (method === 'browserTabCurrent') { + return { tab: await this.tabs.currentTab(host, params) } + } + if (method === 'browserTabSwitch') { + return this.tabs.switchTab(host, params) + } + if (method === 'browserTabClose') { + return this.tabs.closeTab(host, params) + } + if ( + method.startsWith('browserProfile') || + method === 'browserTabSetProfile' || + method === 'browserTabProfileClone' + ) { + throw new BrowserError( + 'browser_profile_unavailable', + 'Browser profile import and switching require the desktop Electron provider.' + ) + } + if (method === 'browserTabProfileShow') { + const tab = await this.tabs.describeTab(host, params) + return { + browserPageId: tab.browserPageId, + worktreeId: tab.worktreeId, + profileId: 'default', + profileLabel: 'Default' + } + } + if (method === 'browserScreencast') { + throw new BrowserError( + 'browser_screencast_unavailable', + 'This browser provider does not offer screencast.' + ) + } + if (method === 'browserProceedCertificate') { + throw new BrowserError( + 'browser_certificate_trust_unavailable', + 'This browser provider cannot override certificate failures.' + ) + } + + const page = await this.tabs.resolveTargetPage(host, params) + await this.session.selectPage(page.agentPageId) + if (method === 'browserSnapshot') { + return this.snapshot(page) + } + if (method === 'browserScreenshot') { + return this.session.screenshot(params, false) + } + if (method === 'browserFullScreenshot') { + return this.session.screenshot(params, true) + } + if (method === 'browserPdf') { + return this.session.pdf() + } + if (method === 'browserGoto') { + const url = normalizeBrowserNavigationUrl(String(params.url ?? '')) + if (!url) { + throw new BrowserError( + 'invalid_argument', + `Unsupported browser URL: ${String(params.url ?? '')}` + ) + } + return this.session.run(['open', url]) + } + if (method === 'browserEval') { + return this.session.run(['eval', String(params.expression ?? '')]) + } + if (method === 'browserSelectAll') { + await this.session.run(['focus', String(params.element ?? '')]) + await this.session.run(['press', 'Control+a']) + return { selected: String(params.element ?? '') } + } + if (method === 'browserBack' || method === 'browserForward' || method === 'browserReload') { + await this.session.run([method.slice('browser'.length).toLowerCase()]) + const tab = await this.tabs.describePage(page) + return { url: tab.url, title: tab.title } + } + + const command = externalChromiumCommandArguments(method, params) + if (!command) { + throw new BrowserError( + 'browser_command_unavailable', + `${method} is not supported by this browser provider.` + ) + } + const result = await this.session.run(command) + return normalizeExternalChromiumCommandResult(method, params, result) + } + + private async snapshot(page: PageRecord): Promise { + const data = AgentBrowserSnapshot.parse(await this.session.run(['snapshot'])) + return externalChromiumSnapshotResult(page, data, await this.tabs.describePage(page)) + } +} diff --git a/src/main/orcad/external-chromium-browser-session.ts b/src/main/orcad/external-chromium-browser-session.ts new file mode 100644 index 00000000000..a8aadac3ccd --- /dev/null +++ b/src/main/orcad/external-chromium-browser-session.ts @@ -0,0 +1,154 @@ +import { createHash, randomUUID } from 'node:crypto' +import { mkdir, readFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { z } from 'zod' +import { BrowserError } from '../browser/browser-error' +import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../shared/runtime-types' +import { runProcess } from '../../shared/child-process/run-process' + +const COMMAND_TIMEOUT_MS = 90_000 +const MAX_OUTPUT_BYTES = 50 * 1024 * 1024 +const CLOSE_TIMEOUT_MS = 5_000 + +export type ExternalChromiumLaunch = { + executablePath: string + browserArgs?: readonly string[] + provider: 'electron' | 'chromium' +} + +const AgentBrowserTab = z.object({ + active: z.boolean(), + tabId: z.string(), + title: z.string(), + url: z.string() +}) +const AgentBrowserTabsResult = z.object({ tabs: z.array(AgentBrowserTab).optional() }) +const AgentBrowserScreenshotResult = z.object({ path: z.string().min(1) }) +const AgentBrowserEnvelope = z.object({ + success: z.boolean(), + data: z.unknown().optional(), + error: z.string().nullable().optional() +}) + +export type AgentBrowserTab = z.infer + +function classifyAgentBrowserError(message: string): string { + if (/unknown ref|ref not found|element not found: @e/i.test(message)) { + return 'browser_stale_ref' + } + if (/evaluation error/i.test(message)) { + return 'browser_eval_error' + } + if (/navigation|net::err/i.test(message)) { + return 'browser_navigation_failed' + } + if (/timed out|timeout/i.test(message)) { + return 'browser_timeout' + } + if (/tab.*not found|unknown tab/i.test(message)) { + return 'browser_tab_not_found' + } + return 'browser_error' +} + +export class ExternalChromiumBrowserSession { + private readonly profilePath: string + private readonly sessionName: string + + constructor( + private readonly agentBrowserPath: string, + private readonly launch: ExternalChromiumLaunch, + statePath: string + ) { + const identity = createHash('sha256') + .update(`${statePath}:${launch.provider}`) + .digest('hex') + .slice(0, 16) + this.sessionName = `orca-orcad-${identity}` + this.profilePath = join(statePath, `browser-${launch.provider}`) + } + + async start(): Promise { + await mkdir(this.profilePath, { recursive: true }) + await this.run(['open', 'about:blank']) + const tabs = await this.readTabs() + const active = tabs.find((tab) => tab.active) ?? tabs[0] + if (!active) { + throw new BrowserError( + BROWSER_UNAVAILABLE_ERROR_CODE, + 'The browser launched without an automation target.' + ) + } + return active.tabId + } + + async stop(): Promise { + try { + await this.run(['close'], CLOSE_TIMEOUT_MS) + } catch { + // Closing an already-dead browser is complete cleanup. + } + } + + async selectPage(agentPageId: string): Promise { + await this.run(['tab', agentPageId]) + } + + async readTabs(): Promise { + return AgentBrowserTabsResult.parse(await this.run(['tab'])).tabs ?? [] + } + + async screenshot(params: Record, full: boolean): Promise { + const format = params.format === 'jpeg' ? 'jpeg' : 'png' + const command = ['screenshot', ...(full ? ['--full'] : []), '--screenshot-format', format] + const result = AgentBrowserScreenshotResult.parse(await this.run(command)) + const data = (await readFile(result.path)).toString('base64') + await rm(result.path, { force: true }).catch(() => undefined) + return { data, format } + } + + async pdf(): Promise { + const outputPath = join(this.profilePath, `capture-${randomUUID()}.pdf`) + await this.run(['pdf', outputPath]) + const data = (await readFile(outputPath)).toString('base64') + await rm(outputPath, { force: true }).catch(() => undefined) + return { data } + } + + async run(command: readonly string[], timeoutMs = COMMAND_TIMEOUT_MS): Promise { + const args = ['--session', this.sessionName, '--profile', this.profilePath] + if (this.launch.browserArgs?.length) { + args.push('--args', this.launch.browserArgs.join('\n')) + } + args.push(...command, '--json') + const env = { + ...process.env, + AGENT_BROWSER_EXECUTABLE_PATH: this.launch.executablePath, + AGENT_BROWSER_PROFILE: this.profilePath, + AGENT_BROWSER_SESSION: this.sessionName, + AGENT_BROWSER_ARGS: this.launch.browserArgs?.join('\n') ?? '' + } + const result = await runProcess({ + program: this.agentBrowserPath, + args, + env, + timeoutMs, + maxOutputBytes: MAX_OUTPUT_BYTES + }) + if (result.timedOut) { + throw new BrowserError('browser_timeout', 'Browser command timed out.') + } + let envelope: z.infer + try { + envelope = AgentBrowserEnvelope.parse(JSON.parse(result.stdout)) + } catch { + const detail = result.stderr.trim() || `exit ${String(result.code)}` + throw new BrowserError('browser_error', `Browser command failed: ${detail.slice(0, 1000)}`) + } + if (!envelope.success) { + const message = envelope.error ?? (result.stderr.trim() || 'Unknown browser error.') + throw new BrowserError(classifyAgentBrowserError(message), message) + } + return envelope.data + } +} diff --git a/src/main/orcad/external-chromium-command-arguments.ts b/src/main/orcad/external-chromium-command-arguments.ts new file mode 100644 index 00000000000..3650d993f58 --- /dev/null +++ b/src/main/orcad/external-chromium-command-arguments.ts @@ -0,0 +1,293 @@ +import { BrowserError } from '../browser/browser-error' +const TARGET_OVERRIDE_FLAGS: Record = { + '--args': true, + '--cdp': true, + '--executable-path': true, + '--profile': true, + '--session': true +} +const TARGET_OVERRIDE_FLAG_PREFIXES = Object.keys(TARGET_OVERRIDE_FLAGS).map((flag) => `${flag}=`) + +function parseExecArguments(input: string): string[] { + const args: string[] = [] + let value = '' + let quote: '"' | "'" | null = null + let escaped = false + for (const char of input.trim()) { + if (escaped) { + value += char + escaped = false + } else if (char === '\\' && quote !== "'") { + escaped = true + } else if (quote) { + if (char === quote) { + quote = null + } else { + value += char + } + } else if (char === '"' || char === "'") { + quote = char + } else if (/\s/.test(char)) { + if (value) { + args.push(value) + value = '' + } + } else { + value += char + } + } + if (escaped) { + value += '\\' + } + if (quote) { + throw new BrowserError('invalid_argument', 'Browser command has an unclosed quote.') + } + if (value) { + args.push(value) + } + return args +} +function stripTargetOverrideArguments(args: string[]): string[] { + const stripped: string[] = [] + for (let index = 0; index < args.length; index++) { + const arg = args[index] + if (TARGET_OVERRIDE_FLAGS[arg]) { + index++ + continue + } + if (TARGET_OVERRIDE_FLAG_PREFIXES.some((prefix) => arg.startsWith(prefix))) { + continue + } + stripped.push(arg) + } + return stripped +} + +function waitArguments(params: Record): string[] { + const args = ['wait'] + if (params.selector) { + args.push(String(params.selector)) + } else if (params.timeout != null && !params.text && !params.url && !params.load && !params.fn) { + args.push(String(params.timeout)) + } + for (const [key, flag] of [ + ['text', '--text'], + ['url', '--url'], + ['load', '--load'], + ['fn', '--fn'], + ['state', '--state'] + ] as const) { + if (params[key]) { + args.push(flag, String(params[key])) + } + } + return args +} + +function cookieSetArguments(params: Record): string[] { + const args = ['cookies', 'set', String(params.name ?? ''), String(params.value ?? '')] + for (const [key, flag] of [ + ['domain', '--domain'], + ['path', '--path'], + ['sameSite', '--sameSite'], + ['expires', '--expires'] + ] as const) { + if (params[key] != null) { + args.push(flag, String(params[key])) + } + } + if (params.secure) { + args.push('--secure') + } + if (params.httpOnly) { + args.push('--httpOnly') + } + return args +} +function cookieDeleteArguments(params: Record): string[] { + const args = ['cookies', 'clear'] + if (params.name) { + args.push('--name', String(params.name)) + } + if (params.domain) { + args.push('--domain', String(params.domain)) + } + return args +} + +export function externalChromiumCommandArguments( + method: string, + params: Record +): string[] | null { + const text = (key: string): string => String(params[key] ?? '') + const optional = (key: string): string[] => (params[key] == null ? [] : [String(params[key])]) + switch (method) { + case 'browserClick': + return ['click', text('element')] + case 'browserDblclick': + return ['dblclick', text('element')] + case 'browserFill': + return ['fill', text('element'), text('value')] + case 'browserType': + return ['keyboard', 'type', text('input')] + case 'browserSelect': + return ['select', text('element'), text('value')] + case 'browserScroll': + return ['scroll', text('direction'), ...optional('amount')] + case 'browserHover': + return ['hover', text('element')] + case 'browserDrag': + return ['drag', text('from'), text('to')] + case 'browserUpload': + return ['upload', text('element'), ...((params.files as string[] | undefined) ?? [])] + case 'browserCheck': + return [params.checked === false ? 'uncheck' : 'check', text('element')] + case 'browserFocus': + return ['focus', text('element')] + case 'browserClear': + return ['fill', text('element'), ''] + case 'browserKeypress': + return ['press', text('key')] + case 'browserScrollIntoView': + return ['scrollintoview', text('element')] + case 'browserGet': + return ['get', text('what'), ...optional('selector')] + case 'browserIs': + return ['is', text('what'), text('selector')] + case 'browserKeyboardInsertText': + return ['keyboard', 'inserttext', text('text')] + case 'browserMouseMove': + return ['mouse', 'move', text('x'), text('y')] + case 'browserMouseDown': + return ['mouse', 'down', ...optional('button')] + case 'browserMouseClick': + if ( + params.radius != null || + (Array.isArray(params.modifiers) && params.modifiers.length > 0) + ) { + return null + } + return ['mouse', 'click', text('x'), text('y'), ...optional('button')] + case 'browserMouseUp': + return ['mouse', 'up', ...optional('button')] + case 'browserMouseWheel': + return ['mouse', 'wheel', text('dy'), ...optional('dx')] + case 'browserFind': + return ['find', text('locator'), text('value'), text('action'), ...optional('text')] + case 'browserSetDevice': + return ['set', 'device', text('name')] + case 'browserSetOffline': + return ['set', 'offline', ...optional('state')] + case 'browserSetHeaders': + return ['set', 'headers', text('headers')] + case 'browserSetCredentials': + return ['set', 'credentials', text('user'), text('pass')] + case 'browserSetMedia': + return ['set', 'media', ...optional('colorScheme'), ...optional('reducedMotion')] + case 'browserClipboardRead': + return ['clipboard', 'read'] + case 'browserClipboardWrite': + return ['clipboard', 'write', text('text')] + case 'browserDialogAccept': + return ['dialog', 'accept', ...optional('text')] + case 'browserDialogDismiss': + return ['dialog', 'dismiss'] + case 'browserStorageLocalGet': + return ['storage', 'local', 'get', text('key')] + case 'browserStorageLocalSet': + return ['storage', 'local', 'set', text('key'), text('value')] + case 'browserStorageLocalClear': + return ['storage', 'local', 'clear'] + case 'browserStorageSessionGet': + return ['storage', 'session', 'get', text('key')] + case 'browserStorageSessionSet': + return ['storage', 'session', 'set', text('key'), text('value')] + case 'browserStorageSessionClear': + return ['storage', 'session', 'clear'] + case 'browserDownload': + return ['download', text('selector'), text('path')] + case 'browserHighlight': + return ['highlight', text('selector')] + case 'browserCookieGet': + return ['cookies', 'get'] + case 'browserCookieDelete': + return cookieDeleteArguments(params) + case 'browserSetGeolocation': + return ['set', 'geo', text('latitude'), text('longitude')] + case 'browserInterceptDisable': + return ['network', 'unroute'] + case 'browserInterceptList': + case 'browserNetworkLog': + return ['network', 'requests'] + case 'browserConsoleLog': + return ['console'] + case 'browserCaptureStart': + return ['network', 'har', 'start'] + case 'browserCaptureStop': + return ['network', 'har', 'stop'] + case 'browserExec': + return stripTargetOverrideArguments(parseExecArguments(text('command'))) + case 'browserSetViewport': + if ( + params.mobile === true || + (params.deviceScaleFactor != null && params.deviceScaleFactor !== 1) + ) { + return null + } + return ['set', 'viewport', text('width'), text('height')] + case 'browserWait': + return waitArguments(params) + case 'browserCookieSet': + return cookieSetArguments(params) + case 'browserInterceptEnable': + return ['network', 'route', (params.patterns as string[] | undefined)?.[0] ?? '**/*'] + default: + return null + } +} + +export function normalizeExternalChromiumCommandResult( + method: string, + params: Record, + result: unknown +): unknown { + switch (method) { + case 'browserClick': + return { clicked: String(params.element) } + case 'browserFill': + return { filled: String(params.element) } + case 'browserType': + return { typed: true } + case 'browserSelect': + return { selected: String(params.value) } + case 'browserScroll': + return { scrolled: params.direction } + case 'browserHover': + return { hovered: String(params.element) } + case 'browserDrag': + return { dragged: { from: params.from, to: params.to } } + case 'browserUpload': + return { uploaded: Array.isArray(params.files) ? params.files.length : 0 } + case 'browserCheck': + return { checked: params.checked !== false } + case 'browserFocus': + return { focused: String(params.element) } + case 'browserClear': + return { cleared: String(params.element) } + case 'browserSelectAll': + return { selected: String(params.element) } + case 'browserKeypress': + return { pressed: String(params.key) } + case 'browserWait': + return { waited: true } + case 'browserSetViewport': + return { + width: params.width, + height: params.height, + deviceScaleFactor: 1, + mobile: false + } + default: + return result + } +} diff --git a/src/main/orcad/external-chromium-tab-projection.ts b/src/main/orcad/external-chromium-tab-projection.ts new file mode 100644 index 00000000000..a1ad300e48c --- /dev/null +++ b/src/main/orcad/external-chromium-tab-projection.ts @@ -0,0 +1,45 @@ +import type { AgentBrowserTab } from './external-chromium-browser-session' + +export type ExternalChromiumPageRecord = { + agentPageId: string + publicPageId: string + worktreeId?: string +} + +export function externalChromiumTabInfo( + page: ExternalChromiumPageRecord, + tab: AgentBrowserTab, + index: number +): Record { + return { + browserPageId: page.publicPageId, + index, + url: tab.url, + title: tab.title, + active: tab.active, + worktreeId: page.worktreeId ?? null, + profileId: 'default', + profileLabel: 'Default' + } +} + +export function externalChromiumSnapshotResult( + page: ExternalChromiumPageRecord, + data: { + refs?: Record + snapshot?: string + }, + tab: Record +): Record { + return { + browserPageId: page.publicPageId, + snapshot: data.snapshot ?? '', + refs: Object.entries(data.refs ?? {}).map(([ref, value]) => ({ + ref, + role: value.role ?? '', + name: value.name ?? '' + })), + url: tab.url, + title: tab.title + } +} diff --git a/src/main/orcad/external-chromium-tab-registry.ts b/src/main/orcad/external-chromium-tab-registry.ts new file mode 100644 index 00000000000..1cd135bd600 --- /dev/null +++ b/src/main/orcad/external-chromium-tab-registry.ts @@ -0,0 +1,248 @@ +import { randomUUID } from 'node:crypto' +import { z } from 'zod' +import type { RuntimeBrowserCommandHost } from '../runtime/orca-runtime-browser' +import { BrowserError } from '../browser/browser-error' +import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' +import type { ExternalChromiumBrowserSession } from './external-chromium-browser-session' +import { + externalChromiumTabInfo, + type ExternalChromiumPageRecord +} from './external-chromium-tab-projection' +const AgentBrowserCreatedTab = z.object({ tabId: z.string().min(1) }) + +export class ExternalChromiumTabRegistry { + private readonly pagesByPublicId = new Map() + private readonly pagesByAgentId = new Map() + private readonly activePageIdByWorktree = new Map() + private initialAgentPageId: string | null = null + + constructor(private readonly session: ExternalChromiumBrowserSession) {} + + initialize(agentPageId: string): void { + this.initialAgentPageId = agentPageId + } + + clear(): void { + this.pagesByPublicId.clear() + this.pagesByAgentId.clear() + this.activePageIdByWorktree.clear() + this.initialAgentPageId = null + } + + async createTab( + host: RuntimeBrowserCommandHost, + params: Record + ): Promise { + if (typeof params.profileId === 'string' && params.profileId !== 'default') { + throw new BrowserError( + 'browser_profile_unavailable', + 'Only the default browser profile is available.' + ) + } + const worktreeId = await this.resolveWorktreeId(host, params.worktree) + const requestedPageId = + typeof params.page === 'string' && params.page.length > 0 ? params.page : undefined + const existing = requestedPageId ? this.pagesByPublicId.get(requestedPageId) : undefined + if (existing) { + if (worktreeId && existing.worktreeId !== worktreeId) { + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${requestedPageId} was not found in this worktree.` + ) + } + this.setActivePage(existing) + return { browserPageId: existing.publicPageId } + } + const url = normalizeBrowserNavigationUrl(String(params.url ?? 'about:blank')) + if (!url) { + throw new BrowserError( + 'invalid_argument', + `Unsupported browser URL: ${String(params.url ?? '')}` + ) + } + let agentPageId: string + if (this.initialAgentPageId) { + agentPageId = this.initialAgentPageId + this.initialAgentPageId = null + await this.session.selectPage(agentPageId) + await this.session.run(['open', url]) + } else { + agentPageId = AgentBrowserCreatedTab.parse(await this.session.run(['tab', 'new', url])).tabId + } + const publicPageId = requestedPageId ?? randomUUID() + const page = { agentPageId, publicPageId, worktreeId } + this.pagesByPublicId.set(publicPageId, page) + this.pagesByAgentId.set(agentPageId, page) + this.setActivePage(page) + host.markHeadlessBrowserSessionTabActive?.( + worktreeId, + publicPageId, + typeof params.targetGroupId === 'string' ? params.targetGroupId : undefined + ) + if (worktreeId) { + host.notifyHeadlessBrowserSessionTabsChanged?.(worktreeId) + } + return { browserPageId: publicPageId } + } + + async listTabs( + host: RuntimeBrowserCommandHost, + params: Record + ): Promise { + const requestedWorktreeId = await this.resolveWorktreeId(host, params.worktree) + const tabs = await this.session.readTabs() + const liveAgentPageIds = new Set(tabs.map((tab) => tab.tabId)) + for (const page of this.pagesByPublicId.values()) { + if (!liveAgentPageIds.has(page.agentPageId)) { + this.deletePage(page) + } + } + const visibleTabs = tabs.flatMap((tab) => { + const page = this.pagesByAgentId.get(tab.tabId) + if (!page || (requestedWorktreeId && page.worktreeId !== requestedWorktreeId)) { + return [] + } + if (tab.active) { + this.setActivePage(page) + } + return [{ page, tab }] + }) + return { + tabs: visibleTabs.map(({ page, tab }, index) => externalChromiumTabInfo(page, tab, index)) + } + } + + async describeTab( + host: RuntimeBrowserCommandHost, + params: Record + ): Promise> { + return this.describePage(await this.resolveTargetPage(host, params)) + } + + async currentTab( + host: RuntimeBrowserCommandHost, + params: Record + ): Promise { + const worktreeId = await this.resolveWorktreeId(host, params.worktree) + const page = this.findActivePage(worktreeId) + if (!page) { + throw new BrowserError('browser_no_tab', 'No browser tab is active in this worktree.') + } + return this.describePage(page) + } + + async switchTab( + host: RuntimeBrowserCommandHost, + params: Record + ): Promise { + const worktreeId = await this.resolveWorktreeId(host, params.worktree) + const pages = [...this.pagesByPublicId.values()].filter( + (page) => !worktreeId || page.worktreeId === worktreeId + ) + const page = + typeof params.page === 'string' + ? this.pagesByPublicId.get(params.page) + : typeof params.index === 'number' + ? pages[params.index] + : undefined + if (!page || (worktreeId && page.worktreeId !== worktreeId)) { + throw new BrowserError('browser_tab_not_found', 'Browser tab was not found in this worktree.') + } + await this.session.selectPage(page.agentPageId) + this.setActivePage(page) + return { switched: pages.indexOf(page), browserPageId: page.publicPageId } + } + + async closeTab( + host: RuntimeBrowserCommandHost, + params: Record + ): Promise { + const worktreeId = await this.resolveWorktreeId(host, params.worktree) + const pages = [...this.pagesByPublicId.values()].filter( + (page) => !worktreeId || page.worktreeId === worktreeId + ) + const page = + typeof params.page === 'string' + ? this.pagesByPublicId.get(params.page) + : typeof params.index === 'number' + ? pages[params.index] + : this.findActivePage(worktreeId) + if (!page || (worktreeId && page.worktreeId !== worktreeId)) { + if (typeof params.page === 'string') { + throw new BrowserError( + 'browser_tab_not_found', + 'Browser tab was not found in this worktree.' + ) + } + return { closed: false } + } + await this.session.run(['tab', 'close', page.agentPageId]) + this.deletePage(page) + if (page.worktreeId) { + host.notifyHeadlessBrowserSessionTabsChanged?.(page.worktreeId) + } + return { closed: true } + } + + async resolveTargetPage( + host: RuntimeBrowserCommandHost, + params: Record + ): Promise { + const worktreeId = await this.resolveWorktreeId(host, params.worktree) + const page = + typeof params.page === 'string' + ? this.pagesByPublicId.get(params.page) + : this.findActivePage(worktreeId) + if (!page || (worktreeId && page.worktreeId !== worktreeId)) { + throw new BrowserError('browser_no_tab', 'No browser tab is active in this worktree.') + } + return page + } + + async describePage(page: ExternalChromiumPageRecord): Promise> { + const tabs = await this.session.readTabs() + const scopedTabs = tabs.filter( + (tab) => this.pagesByAgentId.get(tab.tabId)?.worktreeId === page.worktreeId + ) + const index = scopedTabs.findIndex((tab) => tab.tabId === page.agentPageId) + if (index === -1) { + this.deletePage(page) + throw new BrowserError('browser_tab_closed', 'Browser tab is no longer available.') + } + return externalChromiumTabInfo(page, scopedTabs[index], index) + } + + private findActivePage(worktreeId?: string): ExternalChromiumPageRecord | undefined { + const key = worktreeId ?? '' + const activePageId = this.activePageIdByWorktree.get(key) + const active = activePageId ? this.pagesByPublicId.get(activePageId) : undefined + if (active) { + return active + } + return [...this.pagesByPublicId.values()].find( + (page) => !worktreeId || page.worktreeId === worktreeId + ) + } + + private setActivePage(page: ExternalChromiumPageRecord): void { + this.activePageIdByWorktree.set(page.worktreeId ?? '', page.publicPageId) + } + + private deletePage(page: ExternalChromiumPageRecord): void { + this.pagesByPublicId.delete(page.publicPageId) + this.pagesByAgentId.delete(page.agentPageId) + const key = page.worktreeId ?? '' + if (this.activePageIdByWorktree.get(key) === page.publicPageId) { + this.activePageIdByWorktree.delete(key) + } + } + + private async resolveWorktreeId( + host: RuntimeBrowserCommandHost, + worktree: unknown + ): Promise { + return typeof worktree === 'string' && worktree + ? (await host.resolveWorktreeSelector(worktree)).id + : undefined + } +} diff --git a/src/main/orcad/orcad-agent-browser-binary.ts b/src/main/orcad/orcad-agent-browser-binary.ts new file mode 100644 index 00000000000..9b7c98666a4 --- /dev/null +++ b/src/main/orcad/orcad-agent-browser-binary.ts @@ -0,0 +1,31 @@ +import { accessSync, constants, existsSync } from 'node:fs' +import { arch, platform } from 'node:os' +import { dirname, join } from 'node:path' + +export function orcadAgentBrowserNativeName( + platformName: NodeJS.Platform, + architecture: string +): string { + const ext = platformName === 'win32' ? '.exe' : '' + return `agent-browser-${platformName}-${architecture}${ext}` +} + +export function resolveOrcadAgentBrowserBinary(): string | null { + const name = orcadAgentBrowserNativeName(platform(), arch()) + const candidates = [ + join(dirname(process.argv[1] ?? __filename), name), + join(process.cwd(), 'node_modules', 'agent-browser', 'bin', name) + ] + for (const candidate of candidates) { + if (!existsSync(candidate)) { + continue + } + try { + accessSync(candidate, process.platform === 'win32' ? constants.F_OK : constants.X_OK) + return candidate + } catch { + // Keep searching; a copied but non-executable binary is not a provider. + } + } + return null +} diff --git a/src/main/orcad/orcad-app-paths.test.ts b/src/main/orcad/orcad-app-paths.test.ts new file mode 100644 index 00000000000..12ec51e2980 --- /dev/null +++ b/src/main/orcad/orcad-app-paths.test.ts @@ -0,0 +1,122 @@ +import { homedir, tmpdir } from 'node:os' +import { join, sep } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AppPathName } from '../../shared/app-environment' +import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' + +const ALL_PATH_NAMES: AppPathName[] = [ + 'userData', + 'home', + 'appData', + 'temp', + 'downloads', + 'logs', + 'exe' +] + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')! + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value }) +} + +afterEach(() => { + Object.defineProperty(process, 'platform', originalPlatform) + vi.unstubAllEnvs() +}) + +describe('resolveUserDataPath', () => { + it('prefers ORCA_USER_DATA, then XDG_DATA_HOME, then ~/.orca', () => { + vi.stubEnv('ORCA_USER_DATA', join(sep, 'srv', 'orca-state')) + vi.stubEnv('XDG_DATA_HOME', join(sep, 'xdg')) + expect(resolveUserDataPath()).toBe(join(sep, 'srv', 'orca-state')) + + vi.stubEnv('ORCA_USER_DATA', '') + expect(resolveUserDataPath()).toBe(join(sep, 'xdg', 'Orca')) + + vi.stubEnv('XDG_DATA_HOME', '') + expect(resolveUserDataPath()).toBe(join(homedir(), '.orca')) + }) +}) + +describe('resolveOrcadPath', () => { + it('answers every path name without ever falling back to the data directory', () => { + vi.stubEnv('ORCA_USER_DATA', join(sep, 'srv', 'orca-state')) + const answers = new Map(ALL_PATH_NAMES.map((name) => [name, resolveOrcadPath(name)])) + + for (const [name, answer] of answers) { + expect(answer, `${name} answered nothing`).toBeTruthy() + if (name !== 'userData') { + // The catch-all this replaced returned the data directory for four of seven + // names, 'exe' included — a data directory is not an executable. + expect(answer, `${name} answered the userData directory`).not.toBe( + join(sep, 'srv', 'orca-state') + ) + } + } + }) + + it("answers 'exe' with the Node binary running this process", () => { + expect(resolveOrcadPath('exe')).toBe(process.execPath) + }) + + it("keeps 'logs' inside the data root so the whole deployment is one directory", () => { + vi.stubEnv('ORCA_USER_DATA', join(sep, 'srv', 'orca-state')) + expect(resolveOrcadPath('logs')).toBe(join(sep, 'srv', 'orca-state', 'logs')) + }) + + it("answers 'home' and 'temp' from the OS", () => { + expect(resolveOrcadPath('home')).toBe(homedir()) + expect(resolveOrcadPath('temp')).toBe(tmpdir()) + }) + + it("answers 'appData' with the per-user application-data root of each platform", () => { + setPlatform('darwin') + expect(resolveOrcadPath('appData')).toBe(join(homedir(), 'Library', 'Application Support')) + + setPlatform('win32') + vi.stubEnv('APPDATA', join('C:', 'Users', 'orca', 'AppData', 'Roaming')) + expect(resolveOrcadPath('appData')).toBe(join('C:', 'Users', 'orca', 'AppData', 'Roaming')) + vi.stubEnv('APPDATA', '') + expect(resolveOrcadPath('appData')).toBe(join(homedir(), 'AppData', 'Roaming')) + + setPlatform('linux') + vi.stubEnv('XDG_CONFIG_HOME', join(sep, 'xdg-config')) + expect(resolveOrcadPath('appData')).toBe(join(sep, 'xdg-config')) + vi.stubEnv('XDG_CONFIG_HOME', '') + expect(resolveOrcadPath('appData')).toBe(join(homedir(), '.config')) + }) + + it("answers 'downloads' from XDG_DOWNLOAD_DIR before the home default", () => { + vi.stubEnv('XDG_DOWNLOAD_DIR', join(sep, 'srv', 'incoming')) + expect(resolveOrcadPath('downloads')).toBe(join(sep, 'srv', 'incoming')) + + vi.stubEnv('XDG_DOWNLOAD_DIR', '') + expect(resolveOrcadPath('downloads')).toBe(join(homedir(), 'Downloads')) + }) +}) + +describe('resolveOrcadInstallRoot', () => { + it('is the directory holding the running bundle, not the working directory', () => { + expect(resolveOrcadInstallRoot(join(sep, 'opt', 'orca', 'orcad.js'))).toBe( + join(sep, 'opt', 'orca') + ) + }) + + it('absolutizes a relative script path against the working directory', () => { + expect(resolveOrcadInstallRoot(join('out', 'orcad', 'orcad.js'))).toBe( + join(process.cwd(), 'out', 'orcad') + ) + }) + + it('refuses instead of guessing when the process has no main script', () => { + const originalArgv = process.argv + // `node -e` leaves argv[1] unset; cwd would be a guess, not an answer. + process.argv = [process.execPath] + try { + expect(() => resolveOrcadInstallRoot()).toThrow(/orcad_install_root_unavailable/) + } finally { + process.argv = originalArgv + } + }) +}) diff --git a/src/main/orcad/orcad-app-paths.ts b/src/main/orcad/orcad-app-paths.ts new file mode 100644 index 00000000000..7b31404e9dd --- /dev/null +++ b/src/main/orcad/orcad-app-paths.ts @@ -0,0 +1,82 @@ +/** + * The path half of orcad's `AppEnvironment`: every `AppPathName` gets a real answer for + * a Node host, or throws. + * + * Why not a catch-all: returning the data directory for names this host had not thought + * about — `'exe'` above all — hands the caller a plausible string it then forks, installs + * beside, or resolves resources against. The failure surfaces far from here, as a missing + * file rather than a missing implementation. + */ +import { homedir, tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import process from 'node:process' +import type { AppPathName } from '../../shared/app-environment' + +/** Empty is unset: a supervisor that exports `APPDATA=` has configured nothing. */ +function env(name: string): string | null { + const value = process.env[name] + return value ? value : null +} + +/** XDG-ish data root. `$ORCA_USER_DATA` wins so a smoke test can isolate state. */ +export function resolveUserDataPath(): string { + const explicit = env('ORCA_USER_DATA') + if (explicit) { + return explicit + } + const xdg = env('XDG_DATA_HOME') + return xdg ? join(xdg, 'Orca') : join(homedir(), '.orca') +} + +/** Electron's `'appData'` definition, computed without Electron. */ +function resolveAppDataPath(): string { + if (process.platform === 'darwin') { + return join(homedir(), 'Library', 'Application Support') + } + if (process.platform === 'win32') { + return env('APPDATA') ?? join(homedir(), 'AppData', 'Roaming') + } + return env('XDG_CONFIG_HOME') ?? join(homedir(), '.config') +} + +/** + * The directory orcad was launched from — the bundle root that `orcad.js` and the + * children shipped beside it (the watcher entry, the agent-browser binary) live in. + * + * Why argv[1] and not cwd: cwd is wherever the operator or supervisor happened to be, + * so resolving siblings against it finds them only by luck. Node resolves argv[1] to an + * absolute path before we see it; `resolve` covers a caller-supplied relative one. + */ +export function resolveOrcadInstallRoot(scriptPath = process.argv[1]): string { + if (!scriptPath) { + throw new Error( + 'orcad_install_root_unavailable — process.argv[1] is unset, so this process has no ' + + 'bundle root to resolve sibling entry points against' + ) + } + return dirname(resolve(scriptPath)) +} + +export function resolveOrcadPath(name: AppPathName): string { + switch (name) { + case 'userData': + return resolveUserDataPath() + case 'home': + return homedir() + case 'temp': + return tmpdir() + case 'appData': + return resolveAppDataPath() + // Why inside userData rather than Electron's macOS ~/Library/Logs: a headless + // deployment's whole state is one removable directory, and a service account + // often has no Library tree to write into. + case 'logs': + return join(resolveUserDataPath(), 'logs') + // The Node binary running this bundle. There is no app executable to point at. + case 'exe': + return process.execPath + // The XDG user-dirs override, else the cross-platform default location. + case 'downloads': + return env('XDG_DOWNLOAD_DIR') ?? join(homedir(), 'Downloads') + } +} diff --git a/src/main/orcad/orcad-browser-provider.test.ts b/src/main/orcad/orcad-browser-provider.test.ts new file mode 100644 index 00000000000..22a7c293486 --- /dev/null +++ b/src/main/orcad/orcad-browser-provider.test.ts @@ -0,0 +1,352 @@ +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess, spawnProcess } from '../../shared/child-process/run-process' +import { ExternalChromiumBrowserProcess } from './external-chromium-browser-process' +import { installedElectronCandidates, resolveOrcadBrowserProvider } from './orcad-browser-provider' +import { orcadAgentBrowserNativeName } from './orcad-agent-browser-binary' +import { + runtimeBrowserUnavailableCause, + setRuntimeBrowserUnavailableCause +} from '../runtime/runtime-browser-commands-factory' + +vi.mock('../../shared/child-process/run-process', () => ({ + runProcess: vi.fn(), + spawnProcess: vi.fn() +})) + +type BrowserState = { + activeTabId: string + tabs: { active: boolean; tabId: string; title: string; url: string }[] +} + +const runProcessMock = vi.mocked(runProcess) +const spawnProcessMock = vi.mocked(spawnProcess) +let root: string +let screenshotPath: string +let browserState: BrowserState + +function commandFromArgs(args: readonly string[]): string[] { + let index = 0 + while (['--session', '--profile', '--args'].includes(args[index] ?? '')) { + index += 2 + } + return args.slice(index, -1) +} + +function success(data: unknown) { + return Promise.resolve({ + code: 0, + signal: null, + stdout: JSON.stringify({ success: true, data, error: null }), + stderr: '', + timedOut: false + }) +} + +function installAgentBrowserMock(): void { + runProcessMock.mockImplementation(async (spec) => { + const command = commandFromArgs(spec.args ?? []) + if (command[0] === 'open') { + const active = browserState.tabs.find((tab) => tab.tabId === browserState.activeTabId)! + active.url = command[1] + active.title = command[1] === 'about:blank' ? '' : 'Fixture page' + return success({ url: active.url, title: active.title }) + } + if (command[0] === 'tab' && command.length === 1) { + return success({ + tabs: browserState.tabs.map((tab) => ({ + ...tab, + active: tab.tabId === browserState.activeTabId + })) + }) + } + if (command[0] === 'tab' && command.length === 2) { + browserState.activeTabId = command[1] + return success({ tabId: command[1] }) + } + if (command[0] === 'eval') { + return success({ result: 'Fixture page', origin: 'https://fixture.test/' }) + } + if (command[0] === 'screenshot') { + return success({ path: screenshotPath }) + } + if (command[0] === 'close') { + return success({ closed: true }) + } + throw new Error(`Unexpected agent-browser command: ${command.join(' ')}`) + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orcad-browser-provider-')) + screenshotPath = join(root, 'fixture.png') + await writeFile(screenshotPath, Buffer.from('fixture-image')) + browserState = { + activeTabId: 't1', + tabs: [{ active: true, tabId: 't1', title: '', url: 'about:blank' }] + } + runProcessMock.mockReset() + spawnProcessMock.mockReset() + installAgentBrowserMock() + setRuntimeBrowserUnavailableCause(null) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('ExternalChromiumBrowserProcess', () => { + it('navigates, evaluates, and screenshots through the operator browser', async () => { + const processHandle = new ExternalChromiumBrowserProcess( + '/agent-browser', + { executablePath: '/operator/chromium', provider: 'chromium' }, + root + ) + await processHandle.start() + const commands = processHandle.createCommands({ + getAgentBrowserBridge: () => null, + resolveWorktreeSelector: async (selector) => ({ id: selector }), + getAuthoritativeWindow: () => { + throw new Error('No renderer') + }, + getAvailableAuthoritativeWindow: () => null, + getOffscreenBrowserBackend: () => null + }) + + await expect( + commands.browserTabCreate({ + url: 'https://fixture.test/', + worktree: 'worktree-1', + page: 'page-1' + }) + ).resolves.toEqual({ browserPageId: 'page-1' }) + await expect( + commands.browserGoto({ + url: 'https://fixture.test/next', + worktree: 'worktree-1', + page: 'page-1' + }) + ).resolves.toEqual({ url: 'https://fixture.test/next', title: 'Fixture page' }) + await expect( + commands.browserEval({ + expression: 'document.title', + worktree: 'worktree-1', + page: 'page-1' + }) + ).resolves.toEqual({ result: 'Fixture page', origin: 'https://fixture.test/' }) + await expect( + commands.browserScreenshot({ format: 'png', worktree: 'worktree-1', page: 'page-1' }) + ).resolves.toEqual({ data: Buffer.from('fixture-image').toString('base64'), format: 'png' }) + await expect( + commands.browserExec({ + page: 'page-1', + worktree: 'worktree-1', + command: + '--session stolen --cdp 9222 --profile /tmp/stolen --executable-path /bad --args bad eval document.title' + }) + ).resolves.toEqual({ result: 'Fixture page', origin: 'https://fixture.test/' }) + const execArgs = runProcessMock.mock.calls.at(-1)?.[0].args ?? [] + expect(execArgs).not.toContain('stolen') + expect(execArgs).not.toContain('9222') + expect(execArgs).not.toContain('/tmp/stolen') + expect(execArgs).not.toContain('/bad') + + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ AGENT_BROWSER_EXECUTABLE_PATH: '/operator/chromium' }) + }) + ) + await processHandle.stop() + }) +}) + +describe('cross-platform browser provider paths', () => { + it('resolves installed Electron launchers on macOS, Linux, and Windows', () => { + expect(installedElectronCandidates('darwin', '/Users/test', {})).toContain( + '/Users/test/Applications/Orca.app/Contents/MacOS/Orca' + ) + expect(installedElectronCandidates('linux', '/home/test', {})).toContain( + '/home/test/.local/bin/orca-ide' + ) + expect( + installedElectronCandidates('win32', 'C:\\Users\\test', { + LOCALAPPDATA: 'C:\\Users\\test\\AppData\\Local' + }) + ).toContain('C:\\Users\\test\\AppData\\Local\\Programs\\Orca\\Orca.exe') + }) + + it('uses platform-specific bundled agent-browser names', () => { + expect(orcadAgentBrowserNativeName('darwin', 'arm64')).toBe('agent-browser-darwin-arm64') + expect(orcadAgentBrowserNativeName('linux', 'x64')).toBe('agent-browser-linux-x64') + expect(orcadAgentBrowserNativeName('win32', 'x64')).toBe('agent-browser-win32-x64.exe') + }) +}) + +describe('resolveOrcadBrowserProvider', () => { + it('uses ORCA_BROWSER_EXECUTABLE when Electron is absent', async () => { + const executable = join(root, process.platform === 'win32' ? 'chromium.exe' : 'chromium') + await writeFile(executable, '') + if (process.platform !== 'win32') { + await chmod(executable, 0o755) + } + + const provider = await resolveOrcadBrowserProvider({ + userDataPath: root, + environment: { ORCA_BROWSER_EXECUTABLE: executable }, + resolveInstalledElectronExecutable: async () => null, + resolveAgentBrowserBinary: () => '/agent-browser' + }) + + expect(provider?.kind).toBe('chromium') + await provider?.stop() + }) + + it('returns no provider when neither executable resolves', async () => { + await expect( + resolveOrcadBrowserProvider({ + userDataPath: root, + environment: {}, + resolveInstalledElectronExecutable: async () => null, + resolveAgentBrowserBinary: () => '/agent-browser' + }) + ).resolves.toBeNull() + expect(runProcessMock).not.toHaveBeenCalled() + expect(runtimeBrowserUnavailableCause()).toEqual({ reason: 'unconfigured' }) + }) + + it('reports a missing driver rather than falling through as unconfigured', async () => { + const executable = join(root, 'chromium') + await writeFile(executable, '') + if (process.platform !== 'win32') { + await chmod(executable, 0o755) + } + + await expect( + resolveOrcadBrowserProvider({ + userDataPath: root, + environment: { ORCA_BROWSER_EXECUTABLE: executable }, + resolveInstalledElectronExecutable: async () => null, + resolveAgentBrowserBinary: () => null + }) + ).resolves.toBeNull() + expect(runtimeBrowserUnavailableCause()).toEqual({ reason: 'driver_missing' }) + }) + + it('reports an ORCA_BROWSER_EXECUTABLE path that does not exist', async () => { + const missing = join(root, 'absent-chromium') + + await expect( + resolveOrcadBrowserProvider({ + userDataPath: root, + environment: { ORCA_BROWSER_EXECUTABLE: missing }, + resolveInstalledElectronExecutable: async () => null, + resolveAgentBrowserBinary: () => '/agent-browser' + }) + ).resolves.toBeNull() + expect(runtimeBrowserUnavailableCause()).toEqual({ + reason: 'executable_not_found', + detail: missing + }) + }) + + it.skipIf(process.platform === 'win32')( + 'separates a non-executable ORCA_BROWSER_EXECUTABLE from a missing one', + async () => { + const executable = join(root, 'unchmodded-chromium') + await writeFile(executable, '') + await chmod(executable, 0o644) + + await expect( + resolveOrcadBrowserProvider({ + userDataPath: root, + environment: { ORCA_BROWSER_EXECUTABLE: executable }, + resolveInstalledElectronExecutable: async () => null, + resolveAgentBrowserBinary: () => '/agent-browser' + }) + ).resolves.toBeNull() + expect(runtimeBrowserUnavailableCause()).toEqual({ + reason: 'executable_not_executable', + detail: executable + }) + } + ) + + it('reports a failed Electron start instead of staying invisible to the client', async () => { + spawnProcessMock.mockImplementation(() => { + throw new Error('spawn ENOENT') + }) + + await expect( + resolveOrcadBrowserProvider({ + userDataPath: root, + environment: {}, + resolveInstalledElectronExecutable: async () => '/opt/Orca/orca-ide', + resolveAgentBrowserBinary: () => '/agent-browser' + }) + ).resolves.toBeNull() + expect(runtimeBrowserUnavailableCause()).toEqual({ + reason: 'electron_start_failed', + detail: 'spawn ENOENT' + }) + }) + + it('prefers the configured Chromium fault over an earlier Electron failure', async () => { + spawnProcessMock.mockImplementation(() => { + throw new Error('spawn ENOENT') + }) + + await expect( + resolveOrcadBrowserProvider({ + userDataPath: root, + environment: { ORCA_BROWSER_EXECUTABLE: join(root, 'absent-chromium') }, + resolveInstalledElectronExecutable: async () => '/opt/Orca/orca-ide', + resolveAgentBrowserBinary: () => null + }) + ).resolves.toBeNull() + expect(runtimeBrowserUnavailableCause()).toEqual({ reason: 'driver_missing' }) + }) + + it('reports a failed Chromium launch with the underlying error', async () => { + const executable = join(root, 'chromium') + await writeFile(executable, '') + if (process.platform !== 'win32') { + await chmod(executable, 0o755) + } + runProcessMock.mockRejectedValue(new Error('spawn EACCES')) + + await expect( + resolveOrcadBrowserProvider({ + userDataPath: root, + environment: { ORCA_BROWSER_EXECUTABLE: executable }, + resolveInstalledElectronExecutable: async () => null, + resolveAgentBrowserBinary: () => '/agent-browser' + }) + ).resolves.toBeNull() + expect(runtimeBrowserUnavailableCause()).toEqual({ + reason: 'chromium_start_failed', + detail: 'spawn EACCES' + }) + }) + + it('clears any recorded cause once a provider resolves', async () => { + const executable = join(root, 'chromium') + await writeFile(executable, '') + if (process.platform !== 'win32') { + await chmod(executable, 0o755) + } + setRuntimeBrowserUnavailableCause({ reason: 'driver_missing' }) + + const provider = await resolveOrcadBrowserProvider({ + userDataPath: root, + environment: { ORCA_BROWSER_EXECUTABLE: executable }, + resolveInstalledElectronExecutable: async () => null, + resolveAgentBrowserBinary: () => '/agent-browser' + }) + + expect(provider?.kind).toBe('chromium') + expect(runtimeBrowserUnavailableCause()).toEqual({ reason: 'unknown' }) + await provider?.stop() + }) +}) diff --git a/src/main/orcad/orcad-browser-provider.ts b/src/main/orcad/orcad-browser-provider.ts new file mode 100644 index 00000000000..fdb8bfad34b --- /dev/null +++ b/src/main/orcad/orcad-browser-provider.ts @@ -0,0 +1,195 @@ +import { access, mkdir } from 'node:fs/promises' +import { constants } from 'node:fs' +import { homedir } from 'node:os' +import { posix, win32 } from 'node:path' +import { + setRuntimeBrowserUnavailableCause, + type RuntimeBrowserCommandsFactory, + type RuntimeBrowserUnavailableCause +} from '../runtime/runtime-browser-commands-factory' +import { + ExternalChromiumBrowserProcess, + type ExternalChromiumLaunch +} from './external-chromium-browser-process' +import { resolveOrcadAgentBrowserBinary } from './orcad-agent-browser-binary' +import { ElectronServeBrowserProcess } from './electron-serve-browser-process' + +export type OrcadBrowserProvider = { + kind: 'electron' | 'chromium' + factory: RuntimeBrowserCommandsFactory + isAvailable(): boolean + stop(): Promise +} + +export type OrcadBrowserProviderOptions = { + userDataPath: string + environment?: NodeJS.ProcessEnv + resolveInstalledElectronExecutable?: () => Promise + resolveAgentBrowserBinary?: () => string | null +} + +type ExecutableProbe = 'ok' | 'missing' | 'not_executable' + +/** Splits the two failures apart: a wrong path and a forgotten chmod +x need different fixes. */ +async function probeExecutable(path: string): Promise { + const mode = process.platform === 'win32' ? constants.F_OK : constants.X_OK + try { + await access(path, mode) + return 'ok' + } catch { + if (mode === constants.F_OK) { + return 'missing' + } + } + try { + await access(path, constants.F_OK) + return 'not_executable' + } catch { + return 'missing' + } +} + +async function executableExists(path: string): Promise { + return (await probeExecutable(path)) === 'ok' +} + +function errorDetail(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export function installedElectronCandidates( + platform: NodeJS.Platform, + homePath: string, + environment: NodeJS.ProcessEnv +): string[] { + const joinPath = platform === 'win32' ? win32.join : posix.join + if (platform === 'darwin') { + return [ + '/Applications/Orca.app/Contents/MacOS/Orca', + joinPath(homePath, 'Applications', 'Orca.app', 'Contents', 'MacOS', 'Orca') + ] + } + if (platform === 'win32') { + return [ + ...(environment.LOCALAPPDATA + ? [joinPath(environment.LOCALAPPDATA, 'Programs', 'Orca', 'Orca.exe')] + : []), + ...(environment.ProgramFiles ? [joinPath(environment.ProgramFiles, 'Orca', 'Orca.exe')] : []) + ] + } + return [ + joinPath(homePath, '.local', 'bin', 'orca-ide'), + '/usr/local/bin/orca-ide', + '/usr/bin/orca-ide', + '/opt/Orca/orca-ide' + ] +} + +/** Only paths with stable installer ownership qualify; never guess arbitrary AppImage locations. */ +export async function resolveInstalledElectronExecutable(): Promise { + const candidates = installedElectronCandidates(process.platform, homedir(), process.env) + for (const candidate of candidates) { + if (await executableExists(candidate)) { + return candidate + } + } + return null +} + +async function startProvider( + agentBrowserPath: string, + launch: ExternalChromiumLaunch, + userDataPath: string +): Promise { + const processHandle = new ExternalChromiumBrowserProcess(agentBrowserPath, launch, userDataPath) + try { + await processHandle.start() + } catch (error) { + await processHandle.stop() + throw error + } + return { + kind: launch.provider, + factory: (host) => processHandle.createCommands(host), + isAvailable: () => processHandle.isAvailable(), + stop: () => processHandle.stop() + } +} + +async function startElectronServeProvider(executablePath: string): Promise { + const processHandle = new ElectronServeBrowserProcess(executablePath) + try { + await processHandle.start() + } catch (error) { + await processHandle.stop() + throw error + } + return { + kind: 'electron', + factory: (host) => processHandle.createCommands(host), + isAvailable: () => processHandle.isAvailable(), + stop: () => processHandle.stop() + } +} + +/** Resolve once at startup: Electron first, then the operator-supplied Chromium. */ +export async function resolveOrcadBrowserProvider( + options: OrcadBrowserProviderOptions +): Promise { + const environment = options.environment ?? process.env + await mkdir(options.userDataPath, { recursive: true, mode: 0o700 }) + + const declined = (cause: RuntimeBrowserUnavailableCause): null => { + setRuntimeBrowserUnavailableCause(cause) + return null + } + + const installedElectronExecutable = await ( + options.resolveInstalledElectronExecutable ?? resolveInstalledElectronExecutable + )() + // Why held rather than reported now: Chromium may still resolve, and if it does not, its + // own concrete fault is the more actionable one for an operator who set the env var. + let electronFailure: RuntimeBrowserUnavailableCause | null = null + if (installedElectronExecutable) { + try { + setRuntimeBrowserUnavailableCause(null) + return await startElectronServeProvider(installedElectronExecutable) + } catch (error) { + console.warn('[orcad] Installed Electron browser provider unavailable:', error) + electronFailure = { reason: 'electron_start_failed', detail: errorDetail(error) } + } + } + + // Why the env var is read before the driver: with it set, a missing driver is a driver + // problem, not an unconfigured host. Telling someone to set ORCA_BROWSER_EXECUTABLE when + // they already did is the misdirection this ordering exists to prevent. + const chromiumExecutable = environment.ORCA_BROWSER_EXECUTABLE?.trim() + if (!chromiumExecutable) { + return declined(electronFailure ?? { reason: 'unconfigured' }) + } + + const agentBrowserPath = (options.resolveAgentBrowserBinary ?? resolveOrcadAgentBrowserBinary)() + if (!agentBrowserPath) { + return declined({ reason: 'driver_missing' }) + } + + const probe = await probeExecutable(chromiumExecutable) + if (probe !== 'ok') { + return declined({ + reason: probe === 'missing' ? 'executable_not_found' : 'executable_not_executable', + detail: chromiumExecutable + }) + } + + try { + setRuntimeBrowserUnavailableCause(null) + return await startProvider( + agentBrowserPath, + { executablePath: chromiumExecutable, provider: 'chromium' }, + options.userDataPath + ) + } catch (error) { + console.warn('[orcad] External Chromium browser provider unavailable:', error) + return declined({ reason: 'chromium_start_failed', detail: errorDetail(error) }) + } +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index bab68282e53..105264dd8e7 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -2,36 +2,30 @@ * `orcad` — the Orca runtime served from plain Node, with no Electron. * * Installs the Node host adapters, constructs the same `OrcaRuntimeService` the - * desktop uses, installs a PTY controller via `registerPtyHandlers(null, …)`, and + * desktop uses, installs a PTY controller via `registerHeadlessPtyRuntime`, and * serves runtime RPC. See docs/design/node-only-runtime-backend.html. * - * The desktop-only surfaces are deliberately left uninstalled: no notifications, no - * renderer window, no browser panes. Those are declared rather than faked — see - * `runtime-desktop-surface.ts` and `pty-host-bindings.ts`. + * Desktop UI surfaces stay uninstalled: no notifications, no renderer window. The + * renderer window is faked as a destroyed one because `registerPtyHandlers` takes a + * non-null `BrowserWindow`. Browser automation is different — it is installed through + * the runtime factory, but only when an Electron serve sidecar or an operator-supplied + * Chromium proves available at startup. */ -import { homedir, tmpdir } from 'node:os' -import { join } from 'node:path' import process from 'node:process' import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' import { setSecretStore, type SecretStore } from '../../shared/secret-store' import type { ServeReadiness } from '../server/serve-readiness' +import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' +import { resolveOrcadBrowserProvider, type OrcadBrowserProvider } from './orcad-browser-provider' +import { resolveOrcadInstallRoot, resolveOrcadPath, resolveUserDataPath } from './orcad-app-paths' -/** XDG-ish data root. `$ORCA_USER_DATA` wins so a smoke test can isolate state. */ -function resolveUserDataPath(): string { - const explicit = process.env.ORCA_USER_DATA - if (explicit) { - return explicit - } - const xdg = process.env.XDG_DATA_HOME - return xdg ? join(xdg, 'Orca') : join(homedir(), '.orca') -} +let runOrcadQuitHandlers = (): void => {} function createNodeAppEnvironment(): AppEnvironment { - const userData = resolveUserDataPath() const quitHandlers: (() => void)[] = [] - // Why SIGTERM/SIGINT: this is the Node equivalent of electron's will-quit, and the - // runtime's teardown (daemon disconnect, PTY kill, store flush) hangs off it. - const runQuitHandlers = (): void => { + // The main signal handler awaits runtime and browser teardown before process.exit. + // Keep will-quit callbacks synchronous, but never let them pre-empt that async barrier. + runOrcadQuitHandlers = (): void => { for (const handler of quitHandlers.splice(0)) { try { handler() @@ -40,18 +34,15 @@ function createNodeAppEnvironment(): AppEnvironment { } } } - process.once('SIGTERM', () => { - runQuitHandlers() - process.exit(0) - }) - process.once('SIGINT', () => { - runQuitHandlers() - process.exit(0) - }) return { - getPath: (name) => (name === 'home' ? homedir() : name === 'temp' ? tmpdir() : userData), - getAppPath: () => process.cwd(), + getPath: resolveOrcadPath, + getAppPath: () => resolveOrcadInstallRoot(), getVersion: () => process.env.ORCA_VERSION ?? '0.0.0-orcad', + // Why still true: consumers read this as "production build, not a dev checkout" — + // it gates HTTPS-only skill downloads, the real CLI command name, and shell-PATH + // hydration. Answering false to satisfy a path resolver would relax a security + // posture. Layout questions must ask whether the app root is an asar archive + // instead (see parcel-watcher-entry-path.ts). isPackaged: () => true, onWillQuit: (handler) => quitHandlers.push(handler), exit: (code = 0) => process.exit(code), @@ -103,7 +94,26 @@ export type OrcadHandle = { */ export async function startOrcad(options: OrcadOptions = {}): Promise { installOrcadHostAdapters() + const userDataPath = resolveUserDataPath() + const browserProvider = await resolveOrcadBrowserProvider({ userDataPath }) + setRuntimeBrowserCommandsFactory(browserProvider?.factory ?? null, { + headless: browserProvider !== null, + ...(browserProvider ? { isAvailable: () => browserProvider.isAvailable() } : {}) + }) + try { + return await startOrcadRuntime(options, browserProvider) + } catch (error) { + await browserProvider?.stop() + setRuntimeBrowserCommandsFactory(null) + runOrcadQuitHandlers() + throw error + } +} +async function startOrcadRuntime( + options: OrcadOptions, + browserProvider: OrcadBrowserProvider | null +): Promise { const { OrcaRuntimeService } = await import('../runtime/orca-runtime') const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc') const { registerHeadlessPtyRuntime, getLocalPtyProvider, getSshPtyProvider } = @@ -116,9 +126,9 @@ export async function startOrcad(options: OrcadOptions = {}): Promise 'blocked' }) - // Why the headless entry point rather than registerPtyHandlers(null, …): this is the + // Why the headless entry point rather than registerPtyHandlers directly: this is the // same call `--serve` makes, and it threads the store through. Without the store the // handlers install fine and every terminal.create then fails at persistence time. // @@ -160,7 +170,7 @@ export async function startOrcad(options: OrcadOptions = {}): Promise { - await rpc.stop() + try { + await rpc.stop() + } finally { + await browserProvider?.stop() + setRuntimeBrowserCommandsFactory(null) + runOrcadQuitHandlers() + } } } } diff --git a/src/main/orcad/orcad-forked-child-paths.test.ts b/src/main/orcad/orcad-forked-child-paths.test.ts new file mode 100644 index 00000000000..e66b3c44702 --- /dev/null +++ b/src/main/orcad/orcad-forked-child-paths.test.ts @@ -0,0 +1,61 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { getAppEnvironment } from '../../shared/app-environment' +import { getWatcherProcessEntryPath } from '../ipc/parcel-watcher-entry-path' +import { installOrcadHostAdapters } from './orcad-entry' + +/** + * The composition, not the pieces: orcad's real host adapters against the real resolver + * and a real deployment layout on disk. Both halves of this passed their own unit tests + * while the running server forked a path that has never existed in an orcad deployment. + */ +describe('orcad forked-child paths', () => { + let deployRoot: string + let originalArgv: string[] + + beforeEach(() => { + deployRoot = mkdtempSync(join(tmpdir(), 'orcad-deploy-')) + // The layout build-orcad.mjs emits: the bundle and its children side by side. + writeFileSync(join(deployRoot, 'orcad.js'), '') + writeFileSync(join(deployRoot, 'parcel-watcher-process-entry.js'), '') + originalArgv = process.argv + process.argv = [process.execPath, join(deployRoot, 'orcad.js')] + installOrcadHostAdapters() + }) + + afterEach(() => { + process.argv = originalArgv + rmSync(deployRoot, { recursive: true, force: true }) + }) + + it('forks the watcher child shipped beside orcad.js', () => { + expect(getWatcherProcessEntryPath()).toBe(join(deployRoot, 'parcel-watcher-process-entry.js')) + }) + + it('ignores a decoy child under the working directory', () => { + // A cwd-derived app root finds the real child only when the operator happens to + // launch from the install directory, which a supervisor never does — and here it + // would find someone else's build instead. + const elsewhere = mkdtempSync(join(tmpdir(), 'orcad-cwd-')) + mkdirSync(join(elsewhere, 'out', 'main'), { recursive: true }) + writeFileSync(join(elsewhere, 'out', 'main', 'parcel-watcher-process-entry.js'), '') + writeFileSync(join(elsewhere, 'parcel-watcher-process-entry.js'), '') + const originalCwd = process.cwd() + process.chdir(elsewhere) + try { + expect(getWatcherProcessEntryPath()).toBe(join(deployRoot, 'parcel-watcher-process-entry.js')) + } finally { + process.chdir(originalCwd) + rmSync(elsewhere, { recursive: true, force: true }) + } + }) + + it('reports the install root and the Node binary, not the data directory', () => { + const environment = getAppEnvironment() + expect(environment.getAppPath()).toBe(deployRoot) + expect(environment.getPath('exe')).toBe(process.execPath) + expect(environment.getPath('exe')).not.toBe(environment.getPath('userData')) + }) +}) diff --git a/src/main/orcad/orcad-sidecar-runtime-client.ts b/src/main/orcad/orcad-sidecar-runtime-client.ts new file mode 100644 index 00000000000..5fef23543ae --- /dev/null +++ b/src/main/orcad/orcad-sidecar-runtime-client.ts @@ -0,0 +1,132 @@ +import { randomUUID } from 'node:crypto' +import { createConnection } from 'node:net' +import { z } from 'zod' +import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap' +import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../shared/runtime-types' +import { BrowserError } from '../browser/browser-error' +const SIDECAR_MAX_RESPONSE_BYTES = 64 * 1024 * 1024 + +const RuntimeResponse = z.discriminatedUnion('ok', [ + z.object({ id: z.string(), ok: z.literal(true), result: z.unknown() }).passthrough(), + z + .object({ + id: z.string(), + ok: z.literal(false), + error: z.object({ code: z.string(), message: z.string() }).passthrough() + }) + .passthrough() +]) + +export async function sendOrcadSidecarRequest( + metadata: RuntimeMetadata, + method: string, + params: unknown, + timeoutMs = 90_000 +): Promise { + const transport = findTransport(metadata, 'unix', 'named-pipe') + if (!transport) { + throw new BrowserError( + BROWSER_UNAVAILABLE_ERROR_CODE, + 'Electron browser sidecar has no local RPC transport.' + ) + } + return await new Promise((resolve, reject) => { + const socket = createConnection(transport.endpoint) + const requestId = randomUUID() + let buffer = '' + let retainedBytes = 0 + let settled = false + const finish = (error: Error | null, result?: unknown): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + socket.end() + if (error) { + reject(error) + } else { + resolve(result) + } + } + const timer = setTimeout(() => { + socket.destroy() + finish(new BrowserError('browser_timeout', 'Electron browser sidecar request timed out.')) + }, timeoutMs) + timer.unref?.() + socket.setEncoding('utf8') + socket.once('error', () => { + finish( + new BrowserError( + BROWSER_UNAVAILABLE_ERROR_CODE, + 'Could not connect to Electron browser sidecar.' + ) + ) + }) + socket.once('close', () => { + finish( + new BrowserError( + BROWSER_UNAVAILABLE_ERROR_CODE, + 'Electron browser sidecar closed before responding.' + ) + ) + }) + socket.on('data', (chunk: string) => { + buffer += chunk + retainedBytes += Buffer.byteLength(chunk, 'utf8') + if (retainedBytes > SIDECAR_MAX_RESPONSE_BYTES) { + socket.destroy() + finish(new BrowserError('browser_error', 'Electron browser sidecar response is too large.')) + return + } + let newline = buffer.indexOf('\n') + while (newline !== -1 && !settled) { + const line = buffer.slice(0, newline) + buffer = buffer.slice(newline + 1) + retainedBytes = Buffer.byteLength(buffer, 'utf8') + newline = buffer.indexOf('\n') + if (!line.trim()) { + continue + } + let raw: unknown + try { + raw = JSON.parse(line) + } catch { + finish( + new BrowserError('browser_error', 'Electron browser sidecar returned invalid JSON.') + ) + return + } + if (raw && typeof raw === 'object' && '_keepalive' in raw) { + timer.refresh() + continue + } + const parsed = RuntimeResponse.safeParse(raw) + if (!parsed.success || parsed.data.id !== requestId) { + finish( + new BrowserError( + 'browser_error', + 'Electron browser sidecar returned an invalid response.' + ) + ) + return + } + if (!parsed.data.ok) { + finish(new BrowserError(parsed.data.error.code, parsed.data.error.message)) + return + } + finish(null, parsed.data.result) + } + }) + socket.on('connect', () => { + socket.write( + `${JSON.stringify({ + id: requestId, + authToken: metadata.authToken, + method, + params + })}\n` + ) + }) + }) +} diff --git a/src/main/persistence-loading-store-extraction.test.ts b/src/main/persistence-loading-store-extraction.test.ts new file mode 100644 index 00000000000..dafb6deadee --- /dev/null +++ b/src/main/persistence-loading-store-extraction.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getDefaultWorkspaceSession } from '../shared/constants' +import type { PersistedState } from '../shared/persisted-state-types' +import type { Store as PersistenceStore } from './persistence/loading-store/store' +import { createStore, dataFile, makeRepo, testState } from './persistence-test-harness' + +const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ + trackMock: vi.fn(), + getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => { + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('encrypted:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('encrypted:'.length) + } + } +})) + +vi.mock('./telemetry/client', () => ({ track: trackMock })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock })) +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => ({ hosts: [] })), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +describe('loading Store extraction seams', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-loading-store-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('accepts the first JSON-parseable backup even when an older backup has richer state', async () => { + mkdirSync(testState.dir, { recursive: true }) + writeFileSync(dataFile(), '{{corrupt-primary', 'utf-8') + writeFileSync(`${dataFile()}.bak.0`, '{}', 'utf-8') + writeFileSync( + `${dataFile()}.bak.1`, + JSON.stringify({ repos: [makeRepo({ id: 'older-complete-profile' })] }), + 'utf-8' + ) + + const store = await createStore() + + expect(store.getRepos()).toEqual([]) + expect(readFileSync(dataFile(), 'utf-8')).toBe('{}') + }) + + it('leaves backup bytes reusable when publishing recovery to the primary path fails', async () => { + mkdirSync(dataFile(), { recursive: true }) + writeFileSync( + `${dataFile()}.bak.0`, + JSON.stringify({ repos: [makeRepo({ id: 'recovery-survives-publish-failure' })] }), + 'utf-8' + ) + + const failedRecovery = await createStore() + expect(failedRecovery.getRepos()).toEqual([]) + failedRecovery.freezeWrites() + expect(existsSync(`${dataFile()}.bak.0`)).toBe(true) + + rmSync(dataFile(), { recursive: true, force: true }) + const recovered = await createStore() + expect(recovered.getRepos().map((repo) => repo.id)).toEqual([ + 'recovery-survives-publish-failure' + ]) + }) + + it('aliases blank host reads, writes, and patches to the local disk partition', async () => { + const store = await createStore() + store.setWorkspaceSession( + { ...getDefaultWorkspaceSession(), activeRepoId: 'from-blank-set' }, + ' ' + ) + store.patchWorkspaceSession({ activeRepoId: 'from-blank-patch' }, '') + store.flushOrThrow() + + expect(store.getWorkspaceSession(' ').activeRepoId).toBe('from-blank-patch') + expect(store.getWorkspaceSessionHostIds()).toEqual(['local']) + + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + expect(persisted.workspaceSession?.activeRepoId).toBe('from-blank-patch') + expect(persisted.workspaceSessionsByHostId).toEqual({}) + }) + + it('keeps the last constructed Store as the global pane-migration listener owner', async () => { + const first = await createStore() + const { Store } = await import('./persistence/loading-store/store') + const { setMigrationUnsupportedPty } = + await import('./agent-hooks/migration-unsupported-pty-state') + const secondDataFile = join(testState.dir, 'second-profile', 'orca-data.json') + const second = new Store({ dataFile: secondDataFile }) + + setMigrationUnsupportedPty({ + ptyId: 'listener-owner-pty', + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 123 + }) + first.flushOrThrow() + second.flushOrThrow() + + const firstState = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + const secondState = JSON.parse(readFileSync(secondDataFile, 'utf-8')) as PersistedState + expect( + firstState.migrationUnsupportedPtyEntries?.some( + (entry) => entry.ptyId === 'listener-owner-pty' + ) + ).toBe(false) + expect( + secondState.migrationUnsupportedPtyEntries?.some( + (entry) => entry.ptyId === 'listener-owner-pty' + ) + ).toBe(true) + }) + + it('latches final flush bytes and ignores later in-memory mutations', async () => { + const store = await createStore() + store.updateUI({ sidebarWidth: 731 }) + const finalFlush = store.flushAsync() + await finalFlush + + store.updateUI({ sidebarWidth: 732 }) + expect(store.flushAsync()).toBe(finalFlush) + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + expect(persisted.ui.sidebarWidth).toBe(731) + expect(store.getUI().sidebarWidth).toBe(732) + }) + + it('includes a same-tick mutation made after final flush is invoked', async () => { + const store = await createStore() + store.updateUI({ sidebarWidth: 741 }) + + const finalFlush = store.flushAsync() + store.updateUI({ sidebarWidth: 742 }) + await finalFlush + + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as PersistedState + expect(persisted.ui.sidebarWidth).toBe(742) + }) + + it('keeps delegated operations on the Store prototype with native receiver and override semantics', async () => { + const store = await createStore() + const { Store } = await import('./persistence/loading-store/store') + const descriptor = Object.getOwnPropertyDescriptor(Store.prototype, 'getRepoCount') + + expect(descriptor).toMatchObject({ + configurable: true, + enumerable: false, + writable: true + }) + expect(Object.hasOwn(store, 'getRepoCount')).toBe(false) + const detached = store.getRepoCount + expect(() => detached()).toThrow(TypeError) + expect(detached.call(store)).toBe(0) + expect(new Proxy(store, {}).getRepoCount()).toBe(0) + expect([ + Store.prototype.getRepo.length, + Store.prototype.updateProject.length, + Store.prototype.createAutomationRun.length + ]).toEqual([1, 2, 2]) + + const prototypeSpy = vi.spyOn(Store.prototype, 'getRepoCount') + try { + expect(store.getRepoCount()).toBe(0) + expect(prototypeSpy).toHaveBeenCalledOnce() + } finally { + prototypeSpy.mockRestore() + } + + class StoreWithRepoCountOverride extends Store { + override getRepoCount(): number { + return 47 + } + } + const overridden = new StoreWithRepoCountOverride({ + dataFile: join(testState.dir, 'override-profile', 'orca-data.json') + }) + expect(overridden.getRepoCount()).toBe(47) + expectTypeOf().not.toHaveProperty('scheduleSave') + expectTypeOf().not.toHaveProperty('enqueueWrite') + expectTypeOf().not.toHaveProperty('getProjectHostOperations') + expectTypeOf().not.toHaveProperty('getAutomationDefinitionOperations') + expectTypeOf().not.toHaveProperty('getSshTargetStateOperations') + }) +}) diff --git a/src/main/persistence-loading-store-write-risks.test.ts b/src/main/persistence-loading-store-write-risks.test.ts new file mode 100644 index 00000000000..422573516f1 --- /dev/null +++ b/src/main/persistence-loading-store-write-risks.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { Store } from './persistence/loading-store/store' +import { initDataPath } from './persistence/loading-store/user-data-path' + +const testState = { dir: '' } + +const writeControl = vi.hoisted(() => { + let releaseRename: (() => void) | null = null + let markRenameStarted: (() => void) | null = null + return { + blockPrimaryRename: false, + failPrimaryOpen: false, + renameStarted: Promise.resolve(), + renameRelease: Promise.resolve(), + reset(): void { + this.blockPrimaryRename = false + this.failPrimaryOpen = false + this.renameStarted = new Promise((resolve) => { + markRenameStarted = resolve + }) + this.renameRelease = new Promise((resolve) => { + releaseRename = resolve + }) + }, + markRenameStarted(): void { + markRenameStarted?.() + }, + releaseRename(): void { + releaseRename?.() + } + } +}) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + open: async (...args: Parameters) => { + const target = String(args[0]) + if ( + writeControl.failPrimaryOpen && + target.includes('orca-data.json.') && + target.endsWith('.tmp') + ) { + throw Object.assign(new Error('profile mount rejected write'), { code: 'EIO' }) + } + return actual.open(...args) + }, + rename: async (...args: Parameters) => { + const target = String(args[1]) + if (writeControl.blockPrimaryRename && target.endsWith('orca-data.json')) { + writeControl.markRenameStarted() + await writeControl.renameRelease + } + return actual.rename(...args) + } + } +}) + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length) + } +})) +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +function createStore(): Store { + installFakeAppEnvironment({ getPath: () => testState.dir }) + initDataPath() + return new Store({ dataFile: join(testState.dir, 'orca-data.json') }) +} + +describe('loading Store write-risk characterization', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-write-risk-')) + writeControl.reset() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('allows an already-started primary rename to publish after writes are frozen', async () => { + const store = await createStore() + writeControl.blockPrimaryRename = true + store.updateUI({ sidebarWidth: 712 }) + vi.advanceTimersByTime(1_000) + await writeControl.renameStarted + + store.freezeWrites() + writeControl.releaseRename() + await store.waitForPendingWrite() + + const persisted = JSON.parse(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')) as { + ui: { sidebarWidth: number } + } + expect(persisted.ui.sidebarWidth).toBe(712) + }) + + it('keeps a rejected durable mutation in memory for a later unrelated flush', async () => { + const store = await createStore() + writeControl.failPrimaryOpen = true + await expect( + store.upsertSshPtyConsumerRecovery({ + targetId: 'ssh-1', + clientInstanceId: 'client-1', + serverBuildId: 'relay-build-1', + clientGeneration: 3, + ownerGeneration: 5, + ownerLease: 'secret-owner-lease' + }) + ).rejects.toThrow('profile mount rejected write') + + writeControl.failPrimaryOpen = false + store.updateUI({ sidebarWidth: 713 }) + await store.flushPendingOrThrowAsync() + const persisted = JSON.parse(readFileSync(join(testState.dir, 'orca-data.json'), 'utf-8')) as { + sshPtyConsumerRecoveries: { clientInstanceId: string }[] + } + expect(persisted.sshPtyConsumerRecoveries[0]?.clientInstanceId).toBe('client-1') + }) +}) diff --git a/src/main/persistence-test-harness.ts b/src/main/persistence-test-harness.ts index 89f5f03a88f..7b628b4b678 100644 --- a/src/main/persistence-test-harness.ts +++ b/src/main/persistence-test-harness.ts @@ -1,26 +1,22 @@ import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { vi } from 'vitest' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' import type { Project, ProjectHostSetup } from '../shared/project-types' import type { Repo } from '../shared/repo-types' import type { TerminalTab } from '../shared/terminal-tab-types' import type { WorkspaceLineage, WorktreeLineage } from '../shared/worktree/lineage-types' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' +import { Store } from './persistence/loading-store/store' +import { initDataPath } from './persistence/loading-store/user-data-path' // Shared mutable state so the electron mock can reference a per-test directory export const testState = { dir: '' } -/** Reset modules and dynamically import Store so the data-file path picks up the current testState.dir */ -export async function createStore() { - vi.resetModules() - // Why here and not a per-file vi.mock('electron'): the data-file path resolves through - // AppEnvironment now, and it must point at this test's dir rather than the global - // fake's shared one. Re-installed after resetModules so the fresh graph sees it. +/** Create a profile store without rebuilding its large module graph inside each test timeout. */ +export function createStore(): Store { installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store, initDataPath } = await import('./persistence') initDataPath() - return new Store() + return new Store({ dataFile: dataFile() }) } export async function withPlatform(platform: NodeJS.Platform, fn: () => Promise): Promise { diff --git a/src/main/persistence-workspace-session-scrollback.test.ts b/src/main/persistence-workspace-session-scrollback.test.ts index f57a2a1b603..29c664be49b 100644 --- a/src/main/persistence-workspace-session-scrollback.test.ts +++ b/src/main/persistence-workspace-session-scrollback.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { writeFileSync, rmSync, mkdtempSync, mkdirSync, existsSync } from 'node:fs' +import { writeFileSync, readFileSync, rmSync, mkdtempSync, mkdirSync, existsSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { isTerminalLeafId, makePaneKey } from '../shared/stable-pane-id' @@ -8,6 +9,7 @@ import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser import { testState, createStore, + dataFile, writeDataFile, makeRepo, makeTerminalTab @@ -19,6 +21,7 @@ import { makeSessionWithBrowserHistory } from './persistence-session-fixtures' import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' +import { Store, initDataPath } from './persistence' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -26,6 +29,29 @@ const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => sshConfigHostsToTargetsMock: vi.fn() })) +const asyncPrimaryWriteFailure = vi.hoisted(() => ({ + error: null as Error | null, + targetPrefix: '' +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + open: async (...args: Parameters) => { + const target = args[0] + if ( + asyncPrimaryWriteFailure.error && + typeof target === 'string' && + target.startsWith(asyncPrimaryWriteFailure.targetPrefix) + ) { + throw asyncPrimaryWriteFailure.error + } + return actual.open(...args) + } + } +}) + vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: loadUserSshConfigMock, sshConfigHostsToTargets: sshConfigHostsToTargetsMock @@ -66,10 +92,14 @@ describe('Store', () => { trackMock.mockReset() getCohortAtEmitMock.mockReset() getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) + asyncPrimaryWriteFailure.error = null + asyncPrimaryWriteFailure.targetPrefix = '' }) afterEach(() => { rmSync(testState.dir, { recursive: true, force: true }) + asyncPrimaryWriteFailure.error = null + asyncPrimaryWriteFailure.targetPrefix = '' }) // ── GitHub Cache ─────────────────────────────────────────────────── @@ -188,11 +218,9 @@ describe('Store', () => { const profileDataFile = join(profileDataDirectory, 'orca-data.json') mkdirSync(profileDataDirectory, { recursive: true }) - vi.resetModules() // Why: the legacy snapshot dir hangs off userData, which resolves through // AppEnvironment — without this it points at the global fake's shared dir. installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store, initDataPath } = await import('./persistence') initDataPath() const store = new Store({ dataFile: profileDataFile }) store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })) @@ -221,11 +249,9 @@ describe('Store', () => { mkdirSync(legacySnapshotDir, { recursive: true }) writeFileSync(join(legacySnapshotDir, `${ref}.bin`), 'legacy-scrollback', 'utf-8') - vi.resetModules() // Why: the legacy snapshot dir hangs off userData, which resolves through // AppEnvironment — without this it points at the global fake's shared dir. installFakeAppEnvironment({ getPath: () => testState.dir }) - const { Store, initDataPath } = await import('./persistence') initDataPath() const store = new Store({ dataFile: profileDataFile }) @@ -305,6 +331,7 @@ describe('Store', () => { throw new Error('expected scrollback snapshot ref') } expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(true) + store.flushOrThrow() store.setWorkspaceSession({ activeRepoId: null, @@ -315,6 +342,74 @@ describe('Store', () => { }) expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(false) + const stillPublished = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + workspaceSession: { + terminalLayoutsByTabId: Record }> + } + } + expect( + stillPublished.workspaceSession.terminalLayoutsByTabId['remote-tab'] + ?.scrollbackRefsByLeafId?.[TEST_LEAF_2] + ).toBe(ref) + }) + + it('leaves durable JSON pointing at deleted scrollback when the replacement write fails', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'remote-repo', connectionId: 'ssh-target-1' })) + const session = makeSessionWithTerminalBuffers() + store.setWorkspaceSession({ + ...session, + tabsByWorktree: { + 'remote-repo::/remote': session.tabsByWorktree['remote-repo::/remote'] + }, + terminalLayoutsByTabId: { + 'remote-tab': session.terminalLayoutsByTabId['remote-tab'] + } + }) + const ref = + store.getWorkspaceSession().terminalLayoutsByTabId['remote-tab'].scrollbackRefsByLeafId?.[ + TEST_LEAF_2 + ] + if (!ref) { + throw new Error('expected scrollback snapshot ref') + } + const snapshotPath = join(testState.dir, 'terminal-scrollback', `${ref}.bin`) + store.flushOrThrow() + const durableBeforeRemoval = readFileSync(dataFile(), 'utf-8') + expect(existsSync(snapshotPath)).toBe(true) + + const writeError = Object.assign(new Error('profile mount rejected replacement write'), { + code: 'EIO' + }) + asyncPrimaryWriteFailure.error = writeError + asyncPrimaryWriteFailure.targetPrefix = `${dataFile()}.` + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + store.setWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + }) + + expect(existsSync(snapshotPath)).toBe(false) + expect(store.getWorkspaceSession().terminalLayoutsByTabId).toEqual({}) + await expect(store.flushPendingOrThrowAsync()).rejects.toBe(writeError) + } finally { + errors.mockRestore() + } + + expect(readFileSync(dataFile(), 'utf-8')).toBe(durableBeforeRemoval) + const stillPublished = JSON.parse(durableBeforeRemoval) as { + workspaceSession: { + terminalLayoutsByTabId: Record }> + } + } + expect( + stillPublished.workspaceSession.terminalLayoutsByTabId['remote-tab'] + ?.scrollbackRefsByLeafId?.[TEST_LEAF_2] + ).toBe(ref) }) it('reads only the replay tail from oversized terminal scrollback snapshots', async () => { diff --git a/src/main/persistence/applying-settings/feature-interaction-recording.ts b/src/main/persistence/applying-settings/feature-interaction-recording.ts index 935f11382b3..2a29bc64f66 100644 --- a/src/main/persistence/applying-settings/feature-interaction-recording.ts +++ b/src/main/persistence/applying-settings/feature-interaction-recording.ts @@ -1,5 +1,4 @@ import type { PersistedState } from '../../../shared/persisted-state-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { compareFeatureInteractionUsageBuckets, getFeatureInteractionCategory, @@ -12,7 +11,7 @@ import { track } from '../../telemetry/client' import { getCohortAtEmit } from '../../telemetry/cohort-classifier' export type FeatureInteractionOperations = { - state: StoreOwnedPersistedState + state: PersistedState scheduleSave: () => void notifyUIChanged: () => void getUI: () => PersistedState['ui'] diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index e6b18da0644..ff4c55b09a8 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -16,7 +16,7 @@ import { normalizeAppIconId } from '../../../shared/app-icon' import { normalizeUiLanguage } from '../../../shared/ui-language' import { normalizeWorktreeVisibilityDefaults } from '../../../shared/external-worktree-visibility' import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { addMobilePairingCustomAddress, normalizeMobilePairingCustomAddress, @@ -39,7 +39,7 @@ import { } from './terminal-settings-migrations' export type SettingsMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState removeRetainedBlob: ( slot: Parameters[0] ) => void diff --git a/src/main/persistence/applying-settings/ui-state-update.ts b/src/main/persistence/applying-settings/ui-state-update.ts index f757fa5a1c4..b93ddfb1296 100644 --- a/src/main/persistence/applying-settings/ui-state-update.ts +++ b/src/main/persistence/applying-settings/ui-state-update.ts @@ -24,7 +24,6 @@ import { normalizeContextualTourIds } from '../../../shared/contextual-tours' import { normalizeFeatureInteractions } from '../../../shared/feature-interactions' import { mergeWorkspaceCleanupUIState } from '../../../shared/workspace-cleanup-ui-state' import { persistedUIValuesEqual } from '../../../shared/persisted-ui-equality' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { PROTECTED_SECRET_SLOT, type ProtectedSecretPersistence @@ -44,7 +43,7 @@ import { } from './ui-interaction-merge' export type UIUpdateOperations = { - state: StoreOwnedPersistedState + state: PersistedState removeRetainedBlob: ( slot: Parameters[0] ) => void diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts index 4a094ddb242..65b879795dc 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts @@ -1,10 +1,10 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { toSshExecutionHostId } from '../../../shared/execution-host' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' export type SshPtyBindingCleanupOperations = { - state: StoreOwnedPersistedState + state: PersistedState toComparablePtyId: (targetId: string, ptyId: string) => string scheduleSave: () => void } diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts index f7c8f07f24a..3b259989906 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-consumer-recovery.ts @@ -1,11 +1,11 @@ import type { SshPtyConsumerRecovery } from '../../../shared/ssh-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' import { normalizeSshPtyConsumerRecovery } from './ssh-normalization' export type SshPtyConsumerRecoveryOperations = { - state: StoreOwnedPersistedState + state: PersistedState protectedSecrets: Pick flushDurableStateOrThrowAsync: () => Promise } 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 ef8fd642f24..2662f7f02da 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 @@ -1,10 +1,9 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { isTerminalLeafId } from '../../../shared/stable-pane-id' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' export type SshPtyLeaseOperations = { - state: StoreOwnedPersistedState + state: PersistedState toStoredPtyId: (targetId: string, ptyId: string) => string clearBindingsForTarget: (targetId: string) => void clearBindingsForLeases: (targetId: string, leases: SshRemotePtyLease[]) => boolean diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts index e0722e805c0..8dd5425b833 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-reassignment.ts @@ -1,6 +1,6 @@ import type { ProjectHostSetup } from '../../../shared/project-types' import { toSshExecutionHostId } from '../../../shared/execution-host' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { migrateUiHostScopeSshTargetId, migrateWorkspaceSessionSshTargetId @@ -13,7 +13,7 @@ import { } from '../../worktree-retirement-namespace' export type SshTargetReassignmentOperations = { - state: StoreOwnedPersistedState + state: PersistedState protectedSecrets: Pick syncProjectHostSetupCompatibilityState: () => void scheduleSave: () => void @@ -22,7 +22,7 @@ export type SshTargetReassignmentOperations = { /** Retirement namespaces key on the endpoint a target reaches, so a rotation moves them only when * the endpoint itself changed — plus any pre-identity key that embedded the row id. */ function migrateRetirementNamespaces( - state: StoreOwnedPersistedState, + state: PersistedState, oldTargetId: string, newTargetId: string ): boolean { diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts index dafd76b1b6d..dbdb03b89a1 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-target-state.ts @@ -1,6 +1,5 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import type { ProtectedSecretPersistence } from '../../protected-secret-persistence' import { sshPtyOwnerLeaseSecretSlot } from '../../protected-secret-persistence' import { @@ -15,7 +14,7 @@ import { } from '../../worktree-retirement-namespace' export type SshTargetStateOperations = { - state: StoreOwnedPersistedState + state: PersistedState protectedSecrets: Pick scheduleSave: () => void flush: () => void diff --git a/src/main/persistence/loading-store/automation-persistence.ts b/src/main/persistence/loading-store/automation-persistence.ts new file mode 100644 index 00000000000..fc62a4baf1e --- /dev/null +++ b/src/main/persistence/loading-store/automation-persistence.ts @@ -0,0 +1,158 @@ +import type { + Automation, + AutomationCreateInput, + AutomationDispatchResult, + AutomationRun, + AutomationRunTrigger, + AutomationUpdateInput +} from '../../../shared/automations-types' +import { getWorktreePathBasenameFromId } from '../../../shared/worktree/id' +import { normalizeAutomationRunWorkspaceDisplayName } from '../scheduling-automations/automation-context-migration' +import { + createAutomation as createAutomationOperation, + deleteAutomation as deleteAutomationOperation, + listAutomations as listAutomationsOperation, + updateAutomation as updateAutomationOperation, + type AutomationDefinitionOperations +} from '../scheduling-automations/automation-definition-operations' +import { + createAutomationRun as createAutomationRunOperation, + listAutomationRuns as listAutomationRunsOperation, + snapshotAutomationRunWorkspaceDisplayName as snapshotAutomationRunWorkspaceDisplayNameOperation, + updateAutomationRun as updateAutomationRunOperation, + type AutomationRunOperations +} from '../scheduling-automations/automation-run-operations' +import { + advanceAutomationNextRun as advanceAutomationNextRunOperation, + getLatestAutomationOccurrence as getLatestAutomationOccurrenceOperation +} from '../scheduling-automations/automation-schedule-operations' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' +import type { ProfilePreferences } from './profile-preferences' + +type AutomationPersistenceRuntime = Pick + +const automationPersistenceContext = Symbol('AutomationPersistence') +type AutomationPersistenceContext = { + runtime: AutomationPersistenceRuntime + flushBarriers: WriteFlushBarrierOperations + preferences: ProfilePreferences +} + +export class AutomationPersistence { + readonly [automationPersistenceContext]: AutomationPersistenceContext + + constructor( + runtime: AutomationPersistenceRuntime, + flushBarriers: WriteFlushBarrierOperations, + preferences: ProfilePreferences + ) { + this[automationPersistenceContext] = { runtime, flushBarriers, preferences } + } + + listAutomations(): Automation[] { + return listAutomationsOperation(this[automationPersistenceContext].runtime.state) + } + + listAutomationRuns(automationId?: string): AutomationRun[] { + return listAutomationRunsOperation( + this[automationPersistenceContext].runtime.state, + automationId + ) + } + + createAutomation(input: AutomationCreateInput): Automation { + return createAutomationOperation(getAutomationDefinitionOperations(this), input) + } + + updateAutomation(id: string, updates: AutomationUpdateInput): Automation { + return updateAutomationOperation(getAutomationDefinitionOperations(this), id, updates) + } + + deleteAutomation(id: string): void { + deleteAutomationOperation(getAutomationDefinitionOperations(this), id) + } + + createAutomationRun( + automation: Automation, + scheduledFor: number, + trigger: AutomationRunTrigger = 'scheduled' + ): AutomationRun { + return createAutomationRunOperation( + getAutomationRunOperations(this), + automation, + scheduledFor, + trigger + ) + } + + updateAutomationRun(result: AutomationDispatchResult): AutomationRun { + return updateAutomationRunOperation(getAutomationRunOperations(this), result) + } + + snapshotAutomationRunWorkspaceDisplayName(workspaceId: string, displayName: string): number { + return snapshotAutomationRunWorkspaceDisplayNameOperation( + getAutomationRunOperations(this), + workspaceId, + displayName + ) + } + + advanceAutomationNextRun(id: string, now = Date.now()): Automation { + return advanceAutomationNextRunOperation( + this[automationPersistenceContext].runtime.state, + () => this[automationPersistenceContext].flushBarriers.flush(), + id, + now + ) + } + + getLatestAutomationOccurrence(automation: Automation, now = Date.now()): number | null { + return getLatestAutomationOccurrenceOperation(automation, now) + } +} + +export function getAutomationDefinitionOperations( + owner: AutomationPersistence +): AutomationDefinitionOperations { + return { + state: owner[automationPersistenceContext].runtime.state, + flush: () => owner[automationPersistenceContext].flushBarriers.flush(), + recordCreated: () => + owner[automationPersistenceContext].preferences.recordFeatureInteraction('automation-created') + } +} + +export function getAutomationRunOperations(owner: AutomationPersistence): AutomationRunOperations { + return { + state: owner[automationPersistenceContext].runtime.state, + flush: () => owner[automationPersistenceContext].flushBarriers.flush(), + recordManualRun: () => + owner[automationPersistenceContext].preferences.recordFeatureInteraction('automation-run'), + getWorkspaceDisplayName: (workspaceId) => + getAutomationRunWorkspaceDisplayName(owner, workspaceId) + } +} + +export function getAutomationRunWorkspaceDisplayName( + owner: AutomationPersistence, + workspaceId: string | null | undefined +): string | null { + if (!workspaceId) { + return null + } + return normalizeAutomationRunWorkspaceDisplayName( + owner[automationPersistenceContext].runtime.state.worktreeMeta[workspaceId]?.displayName ?? + getWorktreePathBasenameFromId(workspaceId) + ) +} + +export function installAutomationPersistenceContext( + target: object, + source: AutomationPersistence +): void { + Object.defineProperty(target, automationPersistenceContext, { + value: source[automationPersistenceContext] + }) +} diff --git a/src/main/persistence/loading-store/backup-recovery-rotation.ts b/src/main/persistence/loading-store/backup-recovery-rotation.ts new file mode 100644 index 00000000000..7cf0a7a8078 --- /dev/null +++ b/src/main/persistence/loading-store/backup-recovery-rotation.ts @@ -0,0 +1,147 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + statSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { access, copyFile, rename, rm, stat } from 'node:fs/promises' +import { dirname } from 'node:path' + +const BACKUP_COUNT = 5 +const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 + +function backupPath(dataFile: string, index: number): string { + return `${dataFile}.bak.${index}` +} + +/** existsSync's non-blocking twin: existsSync is an access(F_OK) probe, so access() is the exact analogue. */ +async function exists(path: string): Promise { + return access(path).then( + () => true, + () => false + ) +} + +export function hasStateBackup(dataFile: string): boolean { + for (let index = 0; index < BACKUP_COUNT; index += 1) { + if (existsSync(backupPath(dataFile, index))) { + return true + } + } + return false +} + +import type { StoreRuntimeState } from './store-runtime-state' + +type BackupRecoveryRotationOperationsRuntime = Pick + +export class BackupRecoveryRotationOperations { + constructor(private readonly runtime: BackupRecoveryRotationOperationsRuntime) {} + + shouldRotateBackups(now: number, dataFile: string): boolean { + try { + const mtime = statSync(backupPath(dataFile, 0)).mtimeMs + return now - mtime >= BACKUP_MIN_INTERVAL_MS + } catch { + return true + } + } + + async shouldRotateBackupsAsync(dataFile: string): Promise { + try { + const mtime = (await stat(backupPath(dataFile, 0))).mtimeMs + return Date.now() - mtime >= BACKUP_MIN_INTERVAL_MS + } catch { + return true + } + } + + async rotateBackupsAsync(dataFile: string): Promise { + if (this.runtime.backupRotationInFlight) { + return + } + this.runtime.backupRotationInFlight = true + try { + if (!(await this.shouldRotateBackupsAsync(dataFile))) { + return + } + if (!(await exists(dataFile))) { + return + } + await rm(backupPath(dataFile, BACKUP_COUNT - 1)).catch((err: unknown) => { + if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[persistence] Failed to remove oldest backup:', err) + } + }) + for (let i = BACKUP_COUNT - 2; i >= 0; i--) { + const src = backupPath(dataFile, i) + const dst = backupPath(dataFile, i + 1) + // Why probe instead of rename-then-swallow-ENOENT: a degraded mount rejects a rename of an + // absent slot with ESTALE/EIO, which would log once per empty slot on every debounced save. + if (await exists(src)) { + await rename(src, dst).catch((err) => { + console.error('[persistence] Failed to rotate backup', src, '->', dst, err) + }) + } + } + await copyFile(dataFile, backupPath(dataFile, 0)).catch((err) => { + console.error('[persistence] Failed to snapshot current file to .bak.0:', err) + }) + } finally { + this.runtime.backupRotationInFlight = false + } + } + + rotateBackupsSync(dataFile: string): void { + if (!existsSync(dataFile)) { + return + } + try { + unlinkSync(backupPath(dataFile, BACKUP_COUNT - 1)) + } catch (err) { + if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[persistence] Failed to remove oldest backup:', err) + } + } + for (let i = BACKUP_COUNT - 2; i >= 0; i--) { + const src = backupPath(dataFile, i) + const dst = backupPath(dataFile, i + 1) + if (existsSync(src)) { + try { + renameSync(src, dst) + } catch (err) { + console.error('[persistence] Failed to rotate backup', src, '->', dst, err) + } + } + } + try { + copyFileSync(dataFile, backupPath(dataFile, 0)) + } catch (err) { + console.error('[persistence] Failed to snapshot current file to .bak.0:', err) + } + } + + restoreFromBackup(dataFile: string): boolean { + for (let i = 0; i < BACKUP_COUNT; i++) { + const path = backupPath(dataFile, i) + if (!existsSync(path)) { + continue + } + try { + const raw = readFileSync(path, 'utf-8') + JSON.parse(raw) + mkdirSync(dirname(dataFile), { recursive: true }) + writeFileSync(dataFile, raw, 'utf-8') + console.warn(`[persistence] Recovered state from backup slot ${i}: ${path}`) + return true + } catch (err) { + console.error(`[persistence] Backup slot ${i} unusable, trying next:`, err) + } + } + return false + } +} diff --git a/src/main/persistence/loading-store/loaded-cohort-migrations.ts b/src/main/persistence/loading-store/loaded-cohort-migrations.ts new file mode 100644 index 00000000000..c4f21df6b46 --- /dev/null +++ b/src/main/persistence/loading-store/loaded-cohort-migrations.ts @@ -0,0 +1,66 @@ +import { randomUUID } from 'node:crypto' +import type { PersistedState } from '../../../shared/persisted-state-types' + +import type { StoreRuntimeState } from './store-runtime-state' + +type LoadedCohortMigrationOperationsRuntime = Pick + +export class LoadedCohortMigrationOperations { + constructor(private readonly runtime: LoadedCohortMigrationOperationsRuntime) {} + + migrateTabSwitchKeybindings(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { + const existing = state.settings?.tabSwitchKeybindingSeed + if (existing === 'pending' || existing === 'done') { + return state + } + // Why: mark dirty so the frozen cohort persists; else a fresh install re-reads as "existing" after its file lands. + this.runtime.loadNeedsSave = true + return { + ...state, + settings: { + ...state.settings, + // Existing installs pin old chords via a keybindings.json seed; fresh installs use the new registry defaults. + tabSwitchKeybindingSeed: fileExistedOnLoad ? 'pending' : 'done' + } + } + } + + migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { + const existing = state.settings?.telemetry + // Why: require all three invariants; keying on existedBeforeTelemetryRelease alone lets a partial block skip migration. + if ( + typeof existing?.existedBeforeTelemetryRelease === 'boolean' && + typeof existing.installId === 'string' && + existing.installId.length > 0 && + (existing.optedIn === true || existing.optedIn === false || existing.optedIn === null) + ) { + return state + } + // Why: resolve cohort once; re-inferring it in the optedIn fallback could misclassify a partially-written new user. + const resolvedExistedBefore = + typeof existing?.existedBeforeTelemetryRelease === 'boolean' + ? existing.existedBeforeTelemetryRelease + : fileExistedOnLoad + return { + ...state, + settings: { + ...state.settings, + telemetry: { + ...existing, + existedBeforeTelemetryRelease: resolvedExistedBefore, + // Why: preserve any explicit opt-in/out; fall back to cohort default only when optedIn is undefined, never when false. + optedIn: + existing?.optedIn === true || existing?.optedIn === false || existing?.optedIn === null + ? existing.optedIn + : resolvedExistedBefore + ? null + : true, + installId: + typeof existing?.installId === 'string' && existing.installId.length > 0 + ? existing.installId + : randomUUID() + } + } + } + } +} diff --git a/src/main/persistence/loading-store/loaded-state-adaptation.ts b/src/main/persistence/loading-store/loaded-state-adaptation.ts new file mode 100644 index 00000000000..fe350f6b851 --- /dev/null +++ b/src/main/persistence/loading-store/loaded-state-adaptation.ts @@ -0,0 +1,116 @@ +import { isFolderRepo } from '../../../shared/repo-kind' +import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' +import { createProjectGroup } from '../../../shared/project-groups' +import { createNestedProjectGroupResolver } from '../../project-groups/nested-repo-import' + +import type { StoreRuntimeState } from './store-runtime-state' + +type LoadedStateAdaptationOperationsRuntime = Pick + +export class LoadedStateAdaptationOperations { + constructor(private readonly runtime: LoadedStateAdaptationOperationsRuntime) {} + + hydrateFolderWorkspaceDiffComments(): void { + const stored = this.runtime.state.folderWorkspaceDiffComments + let relocatedInline = false + for (const workspace of this.runtime.state.folderWorkspaces ?? []) { + if (Array.isArray(workspace.diffComments) && workspace.diffComments.length > 0) { + // Inline wins: an intervening rollback to a #14112 build writes notes inline and leaves the + // older map untouched, so inline is the last notes-aware write. Also makes the relocation + // durable even if the user never edits anything this session. + relocatedInline = true + continue + } + const comments = stored?.[workspace.id] + // Not `??`: a degenerate `{ id: [] }` entry must not delete an intact inline value. + if (Array.isArray(comments) && comments.length > 0) { + workspace.diffComments = comments + } + } + if (relocatedInline) { + this.runtime.loadNeedsSave = true + } + // Write-only projection: buildStateToSave() is the only producer, so leaving the loaded map in + // state would make it a stale second source of truth that getDurableState() spreads back out. + delete this.runtime.state.folderWorkspaceDiffComments + } + + adaptFlatFolderScanProjectGroups(): boolean { + // Why: older folder imports kept a real parent path but flat repos; upgrade that shape into v1 sparse folder scopes. + const groups = this.runtime.state.projectGroups ?? [] + const repos = this.runtime.state.repos + if (groups.length === 0 || repos.length === 0) { + return false + } + + let changed = false + let maxOrder = -1 + for (const group of groups) { + maxOrder = Math.max(maxOrder, group.tabOrder) + } + + const childGroupIds = new Set( + groups.flatMap((group) => (group.parentGroupId ? [group.parentGroupId] : [])) + ) + const initialGroupCount = groups.length + for (let groupIndex = 0; groupIndex < initialGroupCount; groupIndex += 1) { + const rootGroup = groups[groupIndex] + if (!rootGroup) { + continue + } + if ( + rootGroup.createdFrom !== 'folder-scan' || + !rootGroup.parentPath || + rootGroup.parentGroupId || + childGroupIds.has(rootGroup.id) + ) { + continue + } + const rootPath = rootGroup.parentPath + const repoCandidates = repos.filter( + (repo) => + !isFolderRepo(repo) && + repo.projectGroupId === rootGroup.id && + isPathInsideOrEqual(rootPath, repo.path) + ) + if (repoCandidates.length < 2) { + continue + } + + const resolver = createNestedProjectGroupResolver({ + parentPath: rootPath, + groupName: rootGroup.name, + mode: 'group', + repoPaths: repoCandidates.map((repo) => repo.path), + createGroup: (input) => { + if (!input.parentGroupId) { + return rootGroup + } + maxOrder += 1 + const group = createProjectGroup({ + ...input, + tabOrder: maxOrder + }) + groups.push(group) + changed = true + return group + } + }) + const nextOrderByGroupId = new Map() + for (const repo of repoCandidates) { + const group = resolver.getGroupForRepo(repo.path) + if (!group) { + continue + } + const nextOrder = nextOrderByGroupId.get(group.id) ?? 0 + nextOrderByGroupId.set(group.id, nextOrder + 1) + if (repo.projectGroupId !== group.id || repo.projectGroupOrder !== nextOrder) { + repo.projectGroupId = group.id + repo.projectGroupOrder = nextOrder + changed = true + } + } + } + return changed + } +} diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts new file mode 100644 index 00000000000..2d29844ffb0 --- /dev/null +++ b/src/main/persistence/loading-store/loaded-state-parsing.ts @@ -0,0 +1,267 @@ +import { readFileSync, existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { normalizeProxyUrl } from '../../../shared/network-proxy' +import { normalizeKagiSessionLink } from '../../../shared/browser-url' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { SshPtyConsumerRecovery } from '../../../shared/ssh-types' +import { getDefaultPersistedState } from '../../../shared/constants' +import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' +import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' +import { clearMissingProjectGroupMemberships } from '../../../shared/project-groups' +import { migrateWorkspaceSessionTerminalScrollbackSnapshots } from '../../terminal-scrollback-snapshots' +import { + isStartupDiagnosticsEnabled, + logStartupDiagnostic +} from '../../startup/startup-diagnostics' +import { + PROTECTED_SECRET_SLOT, + sshPtyOwnerLeaseSecretSlot +} from '../../protected-secret-persistence' +import { + isLegacyOpenCodeSessionCookie, + isLegacySshPtyOwnerLease +} from '../leasing-ssh-ptys/secret-validation' +import { readGithubCacheSnapshot } from './user-data-path' +import { + gcStaleWorktreeMeta, + normalizeWorktreeLinkedItemMetadata +} from '../tracking-repos/worktree-metadata-normalization' +import { backfillLegacyAutomationContexts } from '../scheduling-automations/automation-context-migration' +import { + ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH, + normalizeSshPtyConsumerRecovery +} from '../leasing-ssh-ptys/ssh-normalization' +import { + mergeProjectHostSetupCompatibilityState, + projectHostSetupCompatibilityStateEqual +} from '../tracking-repos/project-host-compatibility' +import { backfillFolderScopeConnectionIds } from '../restoring-sessions/folder-scope-migration' +import { hasStateBackup } from './backup-recovery-rotation' +import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-settings' +import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings' +import { normalizeLoadedProfileState } from './normalize-loaded-profile-state' + +function logPersistenceStartupMilestone( + event: string, + details: Record = {} +): void { + if (isStartupDiagnosticsEnabled()) { + logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) + } +} + +import type { StoreRuntimeState } from './store-runtime-state' +import type { BackupRecoveryRotationOperations } from './backup-recovery-rotation' +import type { LoadedCohortMigrationOperations } from './loaded-cohort-migrations' + +type LoadedStateParsingOperationsRuntime = Pick< + StoreRuntimeState, + | 'dataFile' + | 'githubCacheDirty' + | 'loadNeedsSave' + | 'protectedSecrets' + | 'terminalScrollbackSnapshotStorage' +> + +export class LoadedStateParsingOperations { + constructor( + private readonly runtime: LoadedStateParsingOperationsRuntime, + private readonly backups: BackupRecoveryRotationOperations, + private readonly cohorts: LoadedCohortMigrationOperations + ) {} + + load(allowBackupRecovery = true): PersistedState { + // Capture "has run Orca before?" for telemetry cohort; the telemetry field is new, so field inference misclassifies old users as fresh. + const dataFile = this.runtime.dataFile + const fileExistedOnLoad = existsSync(dataFile) + logPersistenceStartupMilestone('persistence-load-start', { + fileExists: fileExistedOnLoad + }) + + let result: PersistedState | null = null + try { + if (fileExistedOnLoad) { + const readStartedAt = performance.now() + const raw = readFileSync(dataFile, 'utf-8') + logPersistenceStartupMilestone('persistence-read-done', { + bytes: Buffer.byteLength(raw), + durationMs: Math.round(performance.now() - readStartedAt) + }) + logPersistenceStartupMilestone('persistence-json-parse-start') + const parsed = JSON.parse(raw) as PersistedState + logPersistenceStartupMilestone('persistence-json-parse-done') + + // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. + if (parsed.settings?.opencodeSessionCookie) { + parsed.settings.opencodeSessionCookie = this.runtime.protectedSecrets.decrypt( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + parsed.settings.opencodeSessionCookie, + isLegacyOpenCodeSessionCookie + ) + } + if (parsed.settings?.httpProxyUrl) { + const decryptedProxy = this.runtime.protectedSecrets.decryptWithStatus( + PROTECTED_SECRET_SLOT.httpProxyUrl, + parsed.settings.httpProxyUrl, + (value) => normalizeProxyUrl(value).ok + ) + // Why (STA-3442): after a keychain reset decrypt returns raw ciphertext; a non-URL + // value must not masquerade as a configured proxy (silent DIRECT fallback) or + // re-persist as garbage. Plaintext URLs still pass, preserving the upgrade path. + if ( + decryptedProxy.status === 'unavailable' || + (decryptedProxy.status === 'failed' && !decryptedProxy.plaintext) + ) { + parsed.settings.httpProxyUrl = '' + } else if (normalizeProxyUrl(decryptedProxy.plaintext).ok) { + parsed.settings.httpProxyUrl = decryptedProxy.plaintext + } else { + console.warn( + '[persistence] httpProxyUrl could not be decrypted — clearing the stored proxy URL. Re-enter it in Settings > Advanced > Network.' + ) + parsed.settings.httpProxyUrl = '' + this.runtime.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) + this.runtime.loadNeedsSave = true + } + } + if (parsed.ui?.browserKagiSessionLink) { + parsed.ui.browserKagiSessionLink = this.runtime.protectedSecrets.decrypt( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + parsed.ui.browserKagiSessionLink, + (value) => normalizeKagiSessionLink(value) !== null + ) + } + parsed.sshPtyConsumerRecoveries = ( + Array.isArray(parsed.sshPtyConsumerRecoveries) ? parsed.sshPtyConsumerRecoveries : [] + ) + .map((record) => + normalizeSshPtyConsumerRecovery(record, ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH) + ) + .filter((record): record is SshPtyConsumerRecovery => record !== null) + .map((record) => { + const slot = sshPtyOwnerLeaseSecretSlot(record.targetId) + const decrypted = this.runtime.protectedSecrets.decryptWithStatus( + slot, + record.ownerLease, + isLegacySshPtyOwnerLease + ) + const normalized = + decrypted.status === 'unavailable' || + (decrypted.status === 'failed' && !decrypted.plaintext) + ? record + : normalizeSshPtyConsumerRecovery({ ...record, ownerLease: decrypted.plaintext }) + if (!normalized) { + this.runtime.protectedSecrets.removeRetainedBlob(slot) + } + return normalized + }) + .filter((record): record is SshPtyConsumerRecovery => record !== null) + + const terminalSettings = prepareLoadedTerminalSettings(parsed, () => { + this.runtime.loadNeedsSave = true + }) + const profileSettings = prepareLoadedProfileSettings( + parsed, + terminalSettings.defaults, + () => { + this.runtime.loadNeedsSave = true + } + ) + result = normalizeLoadedProfileState(parsed, terminalSettings, profileSettings, () => { + this.runtime.loadNeedsSave = true + }) + } + } catch (err) { + console.error('[persistence] Failed to load primary state, trying backups:', err) + } + + // Corrupt-file and no-file paths converge here; a corrupted install counts as existing, so it sees the opt-in banner. + if (result === null && allowBackupRecovery) { + const hasBackup = hasStateBackup(dataFile) + if (fileExistedOnLoad || hasBackup) { + if (this.backups.restoreFromBackup(dataFile)) { + return this.load(false) + } + console.error('[persistence] No usable state file or backup found, using defaults') + } + } + + if (result === null) { + result = getDefaultPersistedState(homedir()) + } + + const workspaceSession = pruneWorkspaceSessionBrowserHistory( + pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos) + ) + const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( + workspaceSession, + this.runtime.terminalScrollbackSnapshotStorage + ) + if (migratedScrollback.changed) { + this.runtime.loadNeedsSave = true + } + + const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) + const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) + if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { + this.runtime.loadNeedsSave = true + } + + const automationContextMigration = backfillLegacyAutomationContexts({ + ...result, + repos, + ...projectHostSetupCompatibility + }) + if (automationContextMigration.changed) { + this.runtime.loadNeedsSave = true + } + result = { + ...result, + automations: automationContextMigration.state.automations, + automationRuns: automationContextMigration.state.automationRuns + } + + const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({ + ...result, + repos, + ...projectHostSetupCompatibility, + workspaceSession: migratedScrollback.session + }) + if (folderScopeConnectionMigration.changed) { + this.runtime.loadNeedsSave = true + } + result = folderScopeConnectionMigration.state + + if (normalizeWorktreeLinkedItemMetadata(result)) { + this.runtime.loadNeedsSave = true + } + + if (gcStaleWorktreeMeta(result) > 0) { + this.runtime.loadNeedsSave = true + } + + const migrated = this.cohorts.migrateTabSwitchKeybindings( + this.cohorts.migrateTelemetry(result, fileExistedOnLoad), + fileExistedOnLoad + ) + + // githubCache is a sidecar file now (see getGithubCacheFile); legacy in-file caches seed the session, then get stripped. + const legacyCache = migrated.githubCache + const hasLegacyCache = + Object.keys(legacyCache?.pr ?? {}).length > 0 || + Object.keys(legacyCache?.issue ?? {}).length > 0 + if (hasLegacyCache) { + this.runtime.loadNeedsSave = true + // Why: mark dirty so the first flush writes the sidecar even without a poll refresh this session, preserving the seed. + this.runtime.githubCacheDirty = true + } else { + migrated.githubCache = readGithubCacheSnapshot(this.runtime.dataFile) ?? migrated.githubCache + } + + logPersistenceStartupMilestone('persistence-load-done', { + repos: migrated.repos.length, + workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) + }) + return migrated + } +} diff --git a/src/main/persistence/loading-store/metadata-lineage-operations.ts b/src/main/persistence/loading-store/metadata-lineage-operations.ts new file mode 100644 index 00000000000..056991dd1f4 --- /dev/null +++ b/src/main/persistence/loading-store/metadata-lineage-operations.ts @@ -0,0 +1,254 @@ +import { randomUUID } from 'node:crypto' +import type { WorkspaceKey } from '../../../shared/folder-workspace-types' +import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-context' +import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' +import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner' +import { DEFAULT_WORKSPACE_STATUS_ID } from '../../../shared/workspace-statuses' +import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { + workspaceSessionOwnerPartitionForHost, + workspaceSessionPartitionIdsForHost +} from '../restoring-sessions/session-owner-removal' +import { migrateWorktreeIdentity as migrateWorktreeIdentityOperation } from '../tracking-repos/worktree-identity-migration' + +function getDefaultWorktreeMeta(): WorktreeMeta { + return { + instanceId: randomUUID(), + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null, + linkedWorkItem: null, + linkedTaskSourceContext: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: Date.now(), + lastActivityAt: 0, + workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID + } +} + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import type { SessionHostPartitionOperations } from './session-host-partitions' +import { scheduleSave } from './write-scheduling' +import { + hasPersistedWorkspaceSession, + partitionOwnsWorktreeTabs, + partitionHasOtherRepoWorktreeTabs, + removeWorkspaceSessionOwnerInPartition +} from './session-host-partitions' + +type MetadataLineageOperationsRuntime = Pick + +const metadataLineageOperationsContext = Symbol('MetadataLineageOperations') +type MetadataLineageOperationsContext = { + runtime: MetadataLineageOperationsRuntime + scheduling: WriteSchedulingOperations + sessions: SessionHostPartitionOperations +} + +export class MetadataLineageOperations { + readonly [metadataLineageOperationsContext]: MetadataLineageOperationsContext + + constructor( + runtime: MetadataLineageOperationsRuntime, + scheduling: WriteSchedulingOperations, + sessions: SessionHostPartitionOperations + ) { + this[metadataLineageOperationsContext] = { runtime, scheduling, sessions } + } + + getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined { + return this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] + } + + getAllWorktreeMeta(): Record { + return this[metadataLineageOperationsContext].runtime.state.worktreeMeta + } + + setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { + const existing = + this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] || + getDefaultWorktreeMeta() + const updated = { ...existing, ...meta } + updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem) + const linkedTaskSourceContext = normalizeStoredTaskSourceContext( + updated.linkedTaskSourceContext + ) + updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( + updated.linkedWorkItem, + linkedTaskSourceContext + ) + ? linkedTaskSourceContext + : null + if (!updated.instanceId) { + updated.instanceId = randomUUID() + } + this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] = updated + scheduleSave(this[metadataLineageOperationsContext].scheduling) + return updated + } + + removeWorktreeMeta(worktreeId: string, hostId?: ExecutionHostId | null): void { + // A host-qualified removal names the owner; the persisted host is the fallback. + const persistedOwner = + this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId]?.hostId + const owner = hostId ?? persistedOwner + const preservesDifferentPersistedOwner = Boolean( + hostId && persistedOwner && persistedOwner !== hostId + ) + const ownerPartition = workspaceSessionOwnerPartitionForHost(owner) + const preservesSameIdSessionOwner = Boolean( + preservesDifferentPersistedOwner || + (owner && + hasWorktreeRemovalRepoOwnerOnOtherHost( + { getRepos: () => this[metadataLineageOperationsContext].runtime.state.repos }, + getRepoIdFromWorktreeId(worktreeId), + ownerPartition + )) + ) + // Skip partitions main never wrote: materializing one fences every sibling worktree of the repo. + const partitions = new Set( + workspaceSessionPartitionIdsForHost(owner).filter( + (partition) => + hasPersistedWorkspaceSession( + this[metadataLineageOperationsContext].sessions, + partition + ) && + // The local partition can be a remote spill surface or a same-id owner. + // Preserve it whenever another owner may still use the bare id. + (!preservesSameIdSessionOwner || partition === ownerPartition) + ) + ) + // A repo-wide fence must not rebase a sibling's unpersisted tabs onto main's copy, and a spill + // partition that never held this worktree has no claim on the repo at all. + const fencedPartitions = new Set( + [...partitions].filter( + (partition) => + partitionOwnsWorktreeTabs( + this[metadataLineageOperationsContext].sessions, + worktreeId, + partition + ) || + (partition === ownerPartition && + !partitionHasOtherRepoWorktreeTabs( + this[metadataLineageOperationsContext].sessions, + worktreeId, + partition + )) + ) + ) + if (!preservesDifferentPersistedOwner) { + delete this[metadataLineageOperationsContext].runtime.state.worktreeMeta[worktreeId] + delete this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] + delete this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + worktreeWorkspaceKey(worktreeId) + ] + } + for (const partition of partitions) { + removeWorkspaceSessionOwnerInPartition( + this[metadataLineageOperationsContext].sessions, + worktreeId, + partition, + { + advanceTerminalTopologyRevision: fencedPartitions.has(partition) + } + ) + } + scheduleSave(this[metadataLineageOperationsContext].scheduling) + } + + getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined { + return this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] + } + + getAllWorktreeLineage(): Record { + return this[metadataLineageOperationsContext].runtime.state.worktreeLineageById + } + + setWorktreeLineage(worktreeId: string, lineage: WorktreeLineage): WorktreeLineage { + this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] = lineage + scheduleSave(this[metadataLineageOperationsContext].scheduling) + return lineage + } + + removeWorktreeLineage(worktreeId: string): void { + delete this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] + scheduleSave(this[metadataLineageOperationsContext].scheduling) + } + + migrateWorktreeIdentity(oldWorktreeId: string, newWorktreeId: string): void { + if ( + migrateWorktreeIdentityOperation( + this[metadataLineageOperationsContext].runtime.state, + oldWorktreeId, + newWorktreeId + ) + ) { + scheduleSave(this[metadataLineageOperationsContext].scheduling) + } + } + + getWorkspaceLineage(childWorkspaceKey: WorkspaceKey): WorkspaceLineage | undefined { + return this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + childWorkspaceKey + ] + } + + getAllWorkspaceLineage(): Record { + return this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey + } + + setWorkspaceLineage(lineage: WorkspaceLineage): WorkspaceLineage { + this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + lineage.childWorkspaceKey + ] = lineage + scheduleSave(this[metadataLineageOperationsContext].scheduling) + return lineage + } + + removeWorkspaceLineage(childWorkspaceKey: WorkspaceKey): void { + delete this[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + childWorkspaceKey + ] + scheduleSave(this[metadataLineageOperationsContext].scheduling) + } +} + +export function removeWorkspaceLineageForFolderParent( + owner: MetadataLineageOperations, + folderWorkspaceId: string +): void { + const parentKey = folderWorkspaceKey(folderWorkspaceId) + for (const [childKey, lineage] of Object.entries( + owner[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey + )) { + if (lineage.parentWorkspaceKey === parentKey) { + delete owner[metadataLineageOperationsContext].runtime.state.workspaceLineageByChildKey[ + childKey as WorkspaceKey + ] + } + } +} + +export function installMetadataLineageOperationsContext( + target: object, + source: MetadataLineageOperations +): void { + Object.defineProperty(target, metadataLineageOperationsContext, { + value: source[metadataLineageOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/mobile-tab-selection-persistence.ts b/src/main/persistence/loading-store/mobile-tab-selection-persistence.ts new file mode 100644 index 00000000000..321baad1aaf --- /dev/null +++ b/src/main/persistence/loading-store/mobile-tab-selection-persistence.ts @@ -0,0 +1,42 @@ +import type { PersistedMobileClientTabSelections } from '../../../shared/persisted-state-types' +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type MobileTabSelectionRuntime = Pick + +const mobileTabSelectionPersistenceContext = Symbol('MobileTabSelectionPersistence') +type MobileTabSelectionPersistenceContext = { + runtime: MobileTabSelectionRuntime + scheduling: WriteSchedulingOperations +} + +export class MobileTabSelectionPersistence { + readonly [mobileTabSelectionPersistenceContext]: MobileTabSelectionPersistenceContext + + constructor(runtime: MobileTabSelectionRuntime, scheduling: WriteSchedulingOperations) { + this[mobileTabSelectionPersistenceContext] = { runtime, scheduling } + } + + getMobileClientTabSelections(): PersistedMobileClientTabSelections { + return ( + this[mobileTabSelectionPersistenceContext].runtime.state + .mobileClientTabSelectionsByDeviceId ?? {} + ) + } + + setMobileClientTabSelections(next: PersistedMobileClientTabSelections): void { + this[mobileTabSelectionPersistenceContext].runtime.state.mobileClientTabSelectionsByDeviceId = + next + scheduleSave(this[mobileTabSelectionPersistenceContext].scheduling) + } +} + +export function installMobileTabSelectionPersistenceContext( + target: object, + source: MobileTabSelectionPersistence +): void { + Object.defineProperty(target, mobileTabSelectionPersistenceContext, { + value: source[mobileTabSelectionPersistenceContext] + }) +} diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts new file mode 100644 index 00000000000..fb5cc9f1fe8 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts @@ -0,0 +1,138 @@ +import { getDefaultVoiceSettings } from '../../../shared/constants' +import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides' +import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands' +import { normalizeOpenInApplications } from '../../../shared/open-in-applications' +import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings' +import { normalizeAppIconId } from '../../../shared/app-icon' +import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' +import { projectSourceControlAiToLegacyCommitMessageAi } from '../../../shared/source-control-ai' +import { normalizeUiLanguage } from '../../../shared/ui-language' +import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' +import { readLegacySidekickFlag } from '../applying-settings/onboarding-normalization' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { PreparedLoadedTerminalSettings } from './prepare-loaded-terminal-settings' +import type { PreparedLoadedProfileSettings } from './prepare-loaded-profile-settings' + +export function normalizeLoadedGlobalSettings( + parsed: PersistedState, + terminal: PreparedLoadedTerminalSettings, + profile: PreparedLoadedProfileSettings +): PersistedState['settings'] { + const { + defaults, + migratedExternalVisibility, + migratedTerminalScrollback, + migratedTerminalTuiScrollSensitivity, + migratedSourceControlAi, + migratedOptionAsAlt, + migratedFloatingTerminalEnabled, + migratedOsc52Clipboard, + migratedFloatingTerminalCwd, + migratedFloatingTerminalTrustedCwds + } = terminal + const { + migratedExperimentalActivity, + migratedAutoRenameBranchFromWork, + migratedTerminalCursorStyle, + migratedTerminalLineHeight, + terminalRightClickToPasteDefaultedForPlatform, + taskProviderSettings, + primarySelectionDefaultedForLinux, + primarySelectionDefaultedForTerminalDefaults, + migratePrimarySelectionPlatformDefault, + stampPrimarySelectionTerminalDefaults, + migratedDisabledTuiAgents, + migratedAgentYoloDefaults, + migratedWindowsRuntimeDefault, + migratedLocalAccountRuntime, + loadedCompactWorktreeCards, + mobilePairingCustomAddress, + mobilePairingCustomAddresses, + normalizedNotifications, + normalizedSourceControlGroupOrder + } = profile + + return { + ...defaults.settings, + // Why (#7977): v1.4.130 onboarding persisted this as a plain boolean, making the + // old default indistinguishable from a real opt-in. Preserve stored `true`; only + // the default changed. + ...stripRetiredGlobalSettings(parsed.settings), + worktreeVisibilityDefaults: migratedExternalVisibility.defaults, + prBotAuthorOverrides: normalizePRBotAuthorOverrides(parsed.settings?.prBotAuthorOverrides), + // Why: v1.3.42 renamed the sidekick setting to pet; carry the old flag forward once so enabled users don't lose it. + experimentalPet: parsed.settings?.experimentalPet ?? readLegacySidekickFlag(parsed) ?? false, + // Why: early builds saved the disabled default; flip Linux/macOS profiles once to match platform, guards keep opt-outs. + primarySelectionMiddleClickPaste: migratePrimarySelectionPlatformDefault + ? true + : (parsed.settings?.primarySelectionMiddleClickPaste ?? + defaults.settings.primarySelectionMiddleClickPaste), + primarySelectionMiddleClickPasteDefaultedForLinux: + primarySelectionDefaultedForLinux || + (process.platform === 'linux' && migratePrimarySelectionPlatformDefault), + primarySelectionMiddleClickPasteDefaultedForTerminalDefaults: + primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults, + ...migratedAutoRenameBranchFromWork, + ...migratedTerminalCursorStyle, + terminalLineHeight: migratedTerminalLineHeight, + // Why: the old true default was inherited, but false was always an explicit opt-out and must survive this one-shot reset. + terminalRightClickToPaste: terminalRightClickToPasteDefaultedForPlatform + ? (parsed.settings?.terminalRightClickToPaste ?? defaults.settings.terminalRightClickToPaste) + : parsed.settings?.terminalRightClickToPaste === false + ? false + : defaults.settings.terminalRightClickToPaste, + terminalRightClickToPasteDefaultedForPlatform: true, + ...migratedTerminalTuiScrollSensitivity.settings, + experimentalActivity: migratedExperimentalActivity, + experimentalActivityDefaultedOffForAllUsers: true, + // Why: compact worktree cards graduated from Experimental; preserve the old opt-in for rollout-era profiles. + compactWorktreeCards: loadedCompactWorktreeCards, + experimentalCompactWorktreeCards: undefined, + terminalMacOptionAsAlt: migratedOptionAsAlt, + terminalMacOptionAsAltMigrated: true, + localWindowsRuntimeDefault: migratedWindowsRuntimeDefault, + localAccountRuntime: migratedLocalAccountRuntime, + localAccountRuntimeDefaultedToAutoForAllUsers: true, + ...migratedOsc52Clipboard, + floatingTerminalEnabled: migratedFloatingTerminalEnabled, + floatingTerminalDefaultedForAllUsers: true, + floatingTerminalCwd: migratedFloatingTerminalCwd, + floatingTerminalTrustedCwds: migratedFloatingTerminalTrustedCwds, + floatingTerminalCwdMigratedToAppWorkspace: true, + terminalScrollbackRows: migratedTerminalScrollback.rows, + terminalQuickCommands: normalizeTerminalQuickCommands(parsed.settings?.terminalQuickCommands), + terminalCustomThemes: normalizeTerminalCustomThemes(parsed.settings?.terminalCustomThemes), + appIcon: normalizeAppIconId(parsed.settings?.appIcon), + mobilePairingCustomAddress, + mobilePairingCustomAddresses, + // Why: persisted settings may be hand-edited or from older builds; keep tray-minimize false unless stored value is true. + minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true, + // Why: missing means default-on; round-trips unchanged on non-mac since darwin consumers gate the effect. + showMenuBarIcon: parsed.settings?.showMenuBarIcon !== false, + uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage), + defaultTaskSource: taskProviderSettings.defaultTaskSource, + visibleTaskProviders: taskProviderSettings.visibleTaskProviders, + visibleTaskProvidersDefaultedForJira: true, + terminalShortcutPolicy: normalizeTerminalShortcutPolicy( + parsed.settings?.terminalShortcutPolicy + ), + disabledTuiAgents: migratedDisabledTuiAgents, + ...migratedAgentYoloDefaults, + claudeAgentTeamsDefaultDisabledMigrated: true, + openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, { + seedDefaults: true + }), + notifications: normalizedNotifications, + sourceControlAi: migratedSourceControlAi, + sourceControlGroupOrder: normalizedSourceControlGroupOrder, + // Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat. + commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( + migratedSourceControlAi, + parsed.settings?.commitMessageAi ?? defaults.settings.commitMessageAi + ), + voice: { + ...getDefaultVoiceSettings(), + ...parsed.settings?.voice + } + } +} diff --git a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts new file mode 100644 index 00000000000..464a1eaa9b8 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -0,0 +1,91 @@ +import { normalizePersistedMobileClientTabSelections } from '../../runtime/client-session-tab-selection-persistence' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import { normalizeFeatureInteractionTelemetryBuckets } from '../../../shared/feature-interactions' +import { normalizeFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments' +import { normalizeFolderWorkspaces } from '../../../shared/folder-workspaces' +import { normalizeWorkspaceLineageByChildKey } from '../applying-settings/ui-interaction-merge' +import { + normalizeSshRemotePtyLease, + normalizeSshTarget +} from '../leasing-ssh-ptys/ssh-normalization' +import { + normalizeClaudeLivePtySessionIds, + normalizeLegacyPaneKeyAliasEntries, + normalizeMigrationUnsupportedPtyEntries +} from '../restoring-sessions/pane-alias-normalization' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { PreparedLoadedTerminalSettings } from './prepare-loaded-terminal-settings' +import type { PreparedLoadedProfileSettings } from './prepare-loaded-profile-settings' +import { normalizeLoadedGlobalSettings } from './normalize-loaded-global-settings' +import { normalizeLoadedUiState } from './normalize-loaded-ui-state' +import { + normalizeLoadedAutomationRuns, + normalizeLoadedHostSessions, + normalizeLoadedLocalSession +} from './normalize-loaded-state-collections' +import { normalizeRetiredNameRegistryMap } from './retired-name-registry-normalization' + +export function normalizeLoadedProfileState( + parsed: PersistedState, + terminal: PreparedLoadedTerminalSettings, + profile: PreparedLoadedProfileSettings, + markNeedsSave: () => void +): PersistedState { + const { defaults, migratedExternalVisibility, osc52ClipboardNoticePending } = terminal + const { normalizedOnboarding, normalizedProjectGroups, loadedCompactWorktreeCards } = profile + + return { + ...defaults, + ...parsed, + featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets( + parsed.featureInteractionTelemetryBuckets + ), + projectGroups: normalizedProjectGroups, + repos: migratedExternalVisibility.repos, + folderWorkspaces: normalizeFolderWorkspaces(parsed.folderWorkspaces, normalizedProjectGroups), + folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments( + parsed.folderWorkspaceDiffComments + ), + worktreeLineageById: parsed.worktreeLineageById ?? {}, + mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( + parsed.mobileClientTabSelectionsByDeviceId + ), + workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey( + parsed.workspaceLineageByChildKey + ), + settings: normalizeLoadedGlobalSettings(parsed, terminal, profile), + // Why: legacy 'recent' meant the smart sort; migrate once on the raw value so a fresh 'recent' default isn't remigrated. + ui: normalizeLoadedUiState( + parsed, + defaults, + normalizedOnboarding, + loadedCompactWorktreeCards, + osc52ClipboardNoticePending, + markNeedsSave + ), + // Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash. + workspaceSession: normalizeLoadedLocalSession(parsed, defaults, markNeedsSave), + // Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat. + workspaceSessionsByHostId: normalizeLoadedHostSessions(parsed, defaults, markNeedsSave), + sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), + deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) + ? parsed.deletedSshConfigAliases.filter((alias): alias is string => typeof alias === 'string') + : [], + retiredWorktreeNamesByRepo: normalizeRetiredNameRegistryMap(parsed.retiredWorktreeNamesByRepo), + retiredWorktreeNamesByNamespace: normalizeRetiredNameRegistryMap( + parsed.retiredWorktreeNamesByNamespace + ), + sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) + .map(normalizeSshRemotePtyLease) + .filter((lease): lease is SshRemotePtyLease => lease !== null), + sshPtyConsumerRecoveries: parsed.sshPtyConsumerRecoveries, + claudeLivePtySessionIds: normalizeClaudeLivePtySessionIds(parsed.claudeLivePtySessionIds), + migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( + parsed.migrationUnsupportedPtyEntries + ), + legacyPaneKeyAliasEntries: normalizeLegacyPaneKeyAliasEntries(parsed.legacyPaneKeyAliasEntries), + automations: Array.isArray(parsed.automations) ? parsed.automations : [], + automationRuns: normalizeLoadedAutomationRuns(parsed, markNeedsSave), + onboarding: normalizedOnboarding + } +} diff --git a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts new file mode 100644 index 00000000000..978515a5752 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts @@ -0,0 +1,66 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' +import { + backfillAutomationRunNumbers, + pruneAutomationRuns +} from '../../../shared/automation-run-retention' +import { + parseWorkspaceSessionsByHostId, + workspaceSessionSalvageLogDetails +} from './workspace-session-partitions' + +export function normalizeLoadedLocalSession( + parsed: PersistedState, + defaults: PersistedState, + markNeedsSave: () => void +): WorkspaceSessionState { + if (parsed.workspaceSession === undefined) { + return defaults.workspaceSession + } + const result = parseWorkspaceSessionSalvaging(parsed.workspaceSession) + if (!result.ok) { + console.error('[persistence] Corrupt workspace session, using defaults:', result.error) + return defaults.workspaceSession + } + if (result.droppedCount > 0) { + console.warn( + '[persistence] Salvaged workspace session; dropped corrupt entries:', + workspaceSessionSalvageLogDetails(result) + ) + // Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch. + markNeedsSave() + } + return { ...defaults.workspaceSession, ...result.value } +} + +export function normalizeLoadedHostSessions( + parsed: PersistedState, + defaults: PersistedState, + markNeedsSave: () => void +): PersistedState['workspaceSessionsByHostId'] { + const { partitions, repaired } = parseWorkspaceSessionsByHostId( + parsed.workspaceSessionsByHostId, + defaults.workspaceSession + ) + if (repaired) { + // Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch. + markNeedsSave() + } + return partitions +} + +export function normalizeLoadedAutomationRuns( + parsed: PersistedState, + markNeedsSave: () => void +): PersistedState['automationRuns'] { + if (!Array.isArray(parsed.automationRuns)) { + return [] + } + const runs = pruneAutomationRuns(backfillAutomationRunNumbers(parsed.automationRuns)) + // Why: nothing else marks dirty, so an oversized legacy file would otherwise only shrink at the next unrelated save. + if (runs.length !== parsed.automationRuns.length) { + markNeedsSave() + } + return runs +} diff --git a/src/main/persistence/loading-store/normalize-loaded-ui-state.ts b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts new file mode 100644 index 00000000000..d26f4e68430 --- /dev/null +++ b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts @@ -0,0 +1,206 @@ +import { + getWorktreeCardModeProperties, + isDefaultedCompactWorktreeCardProperties, + normalizeWorktreeCardProperties +} from '../../../shared/constants' +import { isExistingPersistedProfile } from '../../../shared/project-order-manual-default-notice' +import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../../../shared/usage-percentage-display-change-notice' +import { normalizePersistedWorkspaceStatuses } from '../../../shared/workspace-statuses' +import { + normalizeRightSidebarExplorerView, + normalizeRightSidebarTab, + normalizeShowDotfilesByWorktree, + normalizeSortBy +} from '../applying-settings/ui-selection-normalization' +import { stripMainOwnedTelemetryMarkerFromUI } from '../applying-settings/ui-interaction-merge' +import { + readDeprecatedExperimentFlag, + resolveSetupGuideSidebarDismissedOnLoad +} from '../applying-settings/onboarding-normalization' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { OnboardingState } from '../../../shared/onboarding-state-types' + +export function normalizeLoadedUiState( + parsed: PersistedState, + defaults: PersistedState, + normalizedOnboarding: OnboardingState, + loadedCompactWorktreeCards: boolean, + osc52ClipboardNoticePending: boolean, + markNeedsSave: () => void +): PersistedState['ui'] { + const rawSort = parsed.ui?.sortBy + const sort = normalizeSortBy(rawSort) + const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent' + const rightSidebarOpen = + typeof parsed.ui?.rightSidebarOpen === 'boolean' + ? parsed.ui.rightSidebarOpen + : typeof parsed.settings?.rightSidebarOpenByDefault === 'boolean' + ? parsed.settings.rightSidebarOpenByDefault + : defaults.ui.rightSidebarOpen + if (typeof parsed.ui?.rightSidebarOpen !== 'boolean') { + markNeedsSave() + } + const workspaceStatusesDefaultOrderMigrated = + parsed.ui?._workspaceStatusesDefaultOrderMigrated === true + // Why: a short-lived default put Done on the left; repair only the exact raw payload once so user reorders survive. + const workspaceStatusesReorderedDefaultRepaired = + parsed.ui?._workspaceStatusesReorderedDefaultRepaired === true + // Why: only exact legacy default payloads migrate; customized status labels/colors/icons/order are kept. + const workspaceStatusesDefaultWorkflowMigrated = + parsed.ui?._workspaceStatusesDefaultWorkflowMigrated === true + // Why: visual migration has its own guard so later user choices of valid legacy color/icon IDs are preserved. + const workspaceStatusesDefaultVisualsMigrated = + parsed.ui?._workspaceStatusesDefaultVisualsMigrated === true + const workspaceStatuses = normalizePersistedWorkspaceStatuses(parsed.ui?.workspaceStatuses, { + migrateDefaultWorkflowStatuses: !workspaceStatusesDefaultWorkflowMigrated, + repairReorderedDefaultStatuses: !workspaceStatusesReorderedDefaultRepaired, + migrateLegacyDefaultStatusVisuals: !workspaceStatusesDefaultVisualsMigrated + }) + if ( + !workspaceStatusesDefaultOrderMigrated || + !workspaceStatusesReorderedDefaultRepaired || + !workspaceStatusesDefaultWorkflowMigrated || + !workspaceStatusesDefaultVisualsMigrated + ) { + markNeedsSave() + } + const rawCardProps = parsed.ui?.worktreeCardProperties + const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true + const expandedCardPropsMigrated = parsed.ui?._expandedWorktreeCardPropertiesDefaulted === true + const jiraIssueCardPropDefaulted = parsed.ui?._jiraIssueWorktreeCardPropertyDefaulted === true + const hadExperimentOn = readDeprecatedExperimentFlag(parsed) + const deliberateUncheck = + hadExperimentOn && Array.isArray(rawCardProps) && !rawCardProps.includes('inline-agents') + const needsInlineAgentsMigration = + !inlineAgentsMigrated && + !deliberateUncheck && + Array.isArray(rawCardProps) && + !rawCardProps.includes('inline-agents') + const needsLegacyDefaultedCompactMigration = + loadedCompactWorktreeCards && + parsed.ui?._worktreeCardModeDefaulted === true && + isDefaultedCompactWorktreeCardProperties(rawCardProps) + const migratedCardProps = (() => { + if (!Array.isArray(rawCardProps)) { + return undefined + } + if (needsLegacyDefaultedCompactMigration) { + return getWorktreeCardModeProperties('Compact') + } + const candidate = needsInlineAgentsMigration + ? [...rawCardProps, 'inline-agents' as const] + : rawCardProps + const expandedCandidate = (() => { + if (expandedCardPropsMigrated) { + return candidate + } + const next = [...candidate] + // Why: Linear rode the 'issue' property and Ports were always shown; split them out once to preserve existing cards. + if (candidate.includes('issue') && !candidate.includes('linear-issue')) { + next.push('linear-issue' as const) + } + if (!candidate.includes('ports')) { + next.push('ports' as const) + } + return next + })() + // Why: 'jira-issue' joined the defaults after the expansion migration already stamped upgraded profiles, so it needs its own one-shot backfill. + const jiraCandidate = + jiraIssueCardPropDefaulted || expandedCandidate.includes('jira-issue') + ? expandedCandidate + : [...expandedCandidate, 'jira-issue' as const] + const normalized = normalizeWorktreeCardProperties(jiraCandidate) + const changed = + normalized.length !== rawCardProps.length || + normalized.some((property, index) => property !== rawCardProps[index]) + return changed ? normalized : undefined + })() + if ( + migratedCardProps !== undefined || + !inlineAgentsMigrated || + !expandedCardPropsMigrated || + !jiraIssueCardPropDefaulted + ) { + markNeedsSave() + } + const rawExplorerView = parsed.ui?.rightSidebarExplorerView + const rightSidebarExplorerView = normalizeRightSidebarExplorerView( + rawExplorerView, + parsed.ui?.rightSidebarTab + ) + // Why: without a dirty mark the legacy "Search tab, no explorer view" repair stays + // in memory only, so a profile that never writes again redoes it on every launch. + if ( + rawExplorerView === undefined + ? rightSidebarExplorerView !== defaults.ui.rightSidebarExplorerView + : rawExplorerView !== rightSidebarExplorerView + ) { + markNeedsSave() + } + const setupGuideSidebarDismissed = resolveSetupGuideSidebarDismissedOnLoad( + parsed.ui?.setupGuideSidebarDismissed, + normalizedOnboarding + ) + if ( + parsed.ui?.setupGuideSidebarDismissed !== setupGuideSidebarDismissed && + (setupGuideSidebarDismissed || parsed.ui?.setupGuideSidebarDismissed !== undefined) + ) { + markNeedsSave() + } + // Why: only upgraded profiles still on the new default get the one-time usage-display notice; fresh profiles stay quiet. + const usagePercentageDisplayChangeNoticeDismissed = + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: parsed.ui?.usagePercentageDisplayChangeNoticeDismissed, + rawUsagePercentageDisplay: parsed.ui?.usagePercentageDisplay, + isExistingProfile: isExistingPersistedProfile({ + repoCount: parsed.repos?.length ?? 0, + onboardingClosedAt: normalizedOnboarding.closedAt, + ui: parsed.ui + }) + }) + if ( + parsed.ui?.usagePercentageDisplayChangeNoticeDismissed !== + usagePercentageDisplayChangeNoticeDismissed + ) { + markNeedsSave() + } + return { + ...defaults.ui, + // Why: missing card properties follow the persisted layout mode; explicit choices are preserved below. + worktreeCardProperties: getWorktreeCardModeProperties( + loadedCompactWorktreeCards ? 'Compact' : 'Default' + ), + ...stripMainOwnedTelemetryMarkerFromUI(parsed.ui), + // Why: migrate once from the retired Appearance setting only when no explicit chrome preference exists yet. + rightSidebarOpen, + rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), + // Why here and not in getPersistedUI: only the raw payload still shows the legacy + // "Search tab, no explorer view" shape — the defaults spread above fills in 'files'. + rightSidebarExplorerView, + setupGuideSidebarDismissed, + usagePercentageDisplayChangeNoticeDismissed, + setupGuideBrowserMilestoneMigrated: + typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' + ? parsed.ui.setupGuideBrowserMilestoneMigrated + : false, + setupGuideBrowserMilestoneLegacyComplete: + parsed.ui?.setupGuideBrowserMilestoneLegacyComplete === true, + // Why persist rather than notify inline: the flip lands during load, before any + // window exists, and it must survive a crash before the user ever sees the notice. + osc52ClipboardDefaultOnNoticePending: osc52ClipboardNoticePending, + sortBy: migrate ? ('smart' as const) : sort, + showDotfilesByWorktree: normalizeShowDotfilesByWorktree(parsed.ui?.showDotfilesByWorktree), + workspaceStatuses, + _workspaceStatusesDefaultOrderMigrated: true, + _workspaceStatusesReorderedDefaultRepaired: true, + _workspaceStatusesDefaultWorkflowMigrated: true, + _workspaceStatusesDefaultVisualsMigrated: true, + _sortBySmartMigrated: true, + ...(migratedCardProps !== undefined ? { worktreeCardProperties: migratedCardProps } : {}), + // Why: keep stamping the legacy flag for rollback forward-compat; the new flag actually gates the migration. + _inlineAgentsDefaultedForExperiment: true, + _inlineAgentsDefaultedForAllUsers: true, + _expandedWorktreeCardPropertiesDefaulted: true, + _jiraIssueWorktreeCardPropertyDefaulted: true + } +} diff --git a/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts b/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts new file mode 100644 index 00000000000..2410e68be54 --- /dev/null +++ b/src/main/persistence/loading-store/prepare-loaded-profile-settings.ts @@ -0,0 +1,255 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import { deriveGlobalWindowsRuntimeDefaultFromLegacySettings } from '../../../shared/project-execution-runtime' +import { normalizeTaskProviderSettings } from '../../../shared/task-providers' +import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings' +import { + addMobilePairingCustomAddress, + normalizeMobilePairingCustomAddress, + normalizeMobilePairingCustomAddresses +} from '../../../shared/mobile-pairing-custom-address' +import { normalizeSourceControlGroupOrder } from '../../../shared/source-control-group-order' +import { normalizeProjectGroups } from '../../../shared/project-groups' +import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' +import { hasUnsupportedTuiAgentArgs } from '../../../shared/tui-agent-launch-defaults' +import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' +import { normalizeTerminalLineHeight } from '../../../shared/terminal-line-height-settings' +import { migrateAgentYoloDefaults } from '../applying-settings/terminal-settings-migrations' +import { + normalizeLoadedOnboardingState, + normalizeNotificationSettings, + persistedNotificationSettingsRepaired +} from '../applying-settings/onboarding-normalization' + +export type PreparedLoadedProfileSettings = { + migratedExperimentalActivity: GlobalSettings['experimentalActivity'] + migratedAutoRenameBranchFromWork: Pick< + GlobalSettings, + 'autoRenameBranchFromWork' | 'autoRenameBranchFromWorkDefaultedOn' + > + migratedTerminalCursorStyle: Pick< + GlobalSettings, + 'terminalCursorStyle' | 'terminalCursorStyleDefaultedToBlock' + > + migratedTerminalLineHeight: GlobalSettings['terminalLineHeight'] + terminalRightClickToPasteDefaultedForPlatform: boolean + taskProviderSettings: Pick + primarySelectionDefaultedForLinux: boolean + primarySelectionDefaultedForTerminalDefaults: boolean + migratePrimarySelectionPlatformDefault: boolean + stampPrimarySelectionTerminalDefaults: boolean + migratedDisabledTuiAgents: GlobalSettings['disabledTuiAgents'] + migratedAgentYoloDefaults: Pick< + GlobalSettings, + 'agentDefaultArgs' | 'agentDefaultEnv' | 'agentYoloDefaultsMigrated' + > + migratedWindowsRuntimeDefault: GlobalSettings['localWindowsRuntimeDefault'] + migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] + loadedCompactWorktreeCards: boolean + mobilePairingCustomAddress: GlobalSettings['mobilePairingCustomAddress'] + mobilePairingCustomAddresses: GlobalSettings['mobilePairingCustomAddresses'] + normalizedNotifications: GlobalSettings['notifications'] + normalizedSourceControlGroupOrder: GlobalSettings['sourceControlGroupOrder'] + normalizedOnboarding: PersistedState['onboarding'] + normalizedProjectGroups: ProjectGroup[] +} + +export function prepareLoadedProfileSettings( + parsed: PersistedState, + defaults: PersistedState, + markNeedsSave: () => void +): PreparedLoadedProfileSettings { + const experimentalActivityDefaultedOffForAllUsers = + parsed.settings?.experimentalActivityDefaultedOffForAllUsers === true + // Why: the Agents view moved back behind Experimental; flip pre-migration profiles off once, then preserve opt-ins. + const migratedExperimentalActivity = experimentalActivityDefaultedOffForAllUsers + ? (parsed.settings?.experimentalActivity ?? false) + : false + const autoRenameBranchFromWorkDefaultedOn = + parsed.settings?.autoRenameBranchFromWorkDefaultedOn === true + // Why: default-on rollout activates old profiles once, but a later Settings opt-out survives reloads. + const migratedAutoRenameBranchFromWork = normalizeAutoRenameBranchFromWorkDefaultOn( + parsed.settings + ) + const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings) + if ( + parsed.settings?.terminalCursorStyle !== migratedTerminalCursorStyle.terminalCursorStyle || + parsed.settings?.terminalCursorStyleDefaultedToBlock !== true + ) { + markNeedsSave() + } + const migratedTerminalLineHeight = normalizeTerminalLineHeight( + parsed.settings?.terminalLineHeight + ) + const terminalRightClickToPasteDefaultedForPlatform = + parsed.settings?.terminalRightClickToPasteDefaultedForPlatform === true + if (!terminalRightClickToPasteDefaultedForPlatform) { + markNeedsSave() + } + if ( + parsed.settings?.terminalLineHeight !== undefined && + parsed.settings.terminalLineHeight !== migratedTerminalLineHeight + ) { + markNeedsSave() + } + const rawTaskProviderSettings = normalizeTaskProviderSettings({ + visibleTaskProviders: parsed.settings?.visibleTaskProviders, + defaultTaskSource: parsed.settings?.defaultTaskSource + }) + const visibleTaskProvidersDefaultedForJira = + parsed.settings?.visibleTaskProvidersDefaultedForJira === true + const migratedVisibleTaskProviders = visibleTaskProvidersDefaultedForJira + ? rawTaskProviderSettings.visibleTaskProviders + : rawTaskProviderSettings.visibleTaskProviders.includes('jira') + ? rawTaskProviderSettings.visibleTaskProviders + : [...rawTaskProviderSettings.visibleTaskProviders, 'jira' as const] + const taskProviderSettings = normalizeTaskProviderSettings({ + visibleTaskProviders: migratedVisibleTaskProviders, + defaultTaskSource: rawTaskProviderSettings.defaultTaskSource + }) + const primarySelectionDefaultedForLinux = + parsed.settings?.primarySelectionMiddleClickPasteDefaultedForLinux === true + const primarySelectionDefaultedForTerminalDefaults = + parsed.settings?.primarySelectionMiddleClickPasteDefaultedForTerminalDefaults === true + const primarySelectionPlatformDefaultEnabled = + defaults.settings.primarySelectionMiddleClickPaste === true + const primarySelectionAlreadyDefaultedForPlatform = + primarySelectionDefaultedForTerminalDefaults || + (process.platform === 'linux' && primarySelectionDefaultedForLinux) + const migratePrimarySelectionPlatformDefault = + primarySelectionPlatformDefaultEnabled && !primarySelectionAlreadyDefaultedForPlatform + const stampPrimarySelectionTerminalDefaults = + primarySelectionPlatformDefaultEnabled && !primarySelectionDefaultedForTerminalDefaults + if (migratePrimarySelectionPlatformDefault || stampPrimarySelectionTerminalDefaults) { + markNeedsSave() + } + if (!visibleTaskProvidersDefaultedForJira) { + markNeedsSave() + } + const claudeAgentTeamsDefaultDisabledMigrated = + parsed.settings?.claudeAgentTeamsDefaultDisabledMigrated === true + if (!claudeAgentTeamsDefaultDisabledMigrated) { + markNeedsSave() + } + const migratedDisabledTuiAgents = normalizeDisabledTuiAgents(parsed.settings?.disabledTuiAgents) + const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings) + if ( + parsed.settings?.agentYoloDefaultsMigrated !== true || + hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) || + hasUnsupportedTuiAgentArgs('kilo', parsed.settings?.agentDefaultArgs?.kilo) + ) { + markNeedsSave() + } + if ( + !claudeAgentTeamsDefaultDisabledMigrated && + !migratedDisabledTuiAgents.includes('claude-agent-teams') + ) { + migratedDisabledTuiAgents.push('claude-agent-teams') + } + const migratedWindowsRuntimeDefault = + parsed.settings?.localWindowsRuntimeDefault === undefined + ? deriveGlobalWindowsRuntimeDefaultFromLegacySettings(parsed.settings).defaultRuntime + : parsed.settings.localWindowsRuntimeDefault + if ( + parsed.settings?.localWindowsRuntimeDefault === undefined && + migratedWindowsRuntimeDefault.kind === 'wsl' + ) { + markNeedsSave() + } + // Why (#9537): migrate the indistinguishable legacy host default once so WSL-default users follow their runtime. + const localAccountRuntimeAlreadyMigrated = + parsed.settings?.localAccountRuntimeDefaultedToAutoForAllUsers === true + const migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] = + localAccountRuntimeAlreadyMigrated + ? (parsed.settings?.localAccountRuntime ?? defaults.settings.localAccountRuntime) + : parsed.settings?.localAccountRuntime === 'wsl' + ? 'wsl' + : 'auto' + if (!localAccountRuntimeAlreadyMigrated) { + markNeedsSave() + } + if (!autoRenameBranchFromWorkDefaultedOn) { + markNeedsSave() + } + const normalizedOnboarding = normalizeLoadedOnboardingState( + parsed.onboarding, + defaults.onboarding + ) + if (!parsed.onboarding) { + markNeedsSave() + } + const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups) + const loadedCompactWorktreeCards = + parsed.settings?.compactWorktreeCards ?? + parsed.settings?.experimentalCompactWorktreeCards ?? + defaults.settings.compactWorktreeCards + const mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( + parsed.settings?.mobilePairingCustomAddress + ) + const rawMobilePairingCustomAddresses = parsed.settings?.mobilePairingCustomAddresses + const mobilePairingCustomAddresses = mobilePairingCustomAddress + ? addMobilePairingCustomAddress( + normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses), + mobilePairingCustomAddress + ) + : normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses) + if ( + parsed.settings?.mobilePairingCustomAddress !== undefined && + parsed.settings.mobilePairingCustomAddress !== mobilePairingCustomAddress + ) { + markNeedsSave() + } + const customAddressesMatch = + Array.isArray(rawMobilePairingCustomAddresses) && + rawMobilePairingCustomAddresses.length === mobilePairingCustomAddresses.length && + rawMobilePairingCustomAddresses.every( + (address, index) => address === mobilePairingCustomAddresses[index] + ) + if ( + (rawMobilePairingCustomAddresses !== undefined || mobilePairingCustomAddress !== null) && + !customAddressesMatch + ) { + markNeedsSave() + } + const normalizedNotifications = normalizeNotificationSettings(parsed.settings?.notifications) + // Why: a type-flipped notification field is repaired in memory only; without a dirty mark the + // bad value stays on disk and the repair reruns on every launch. + if ( + persistedNotificationSettingsRepaired(parsed.settings?.notifications, normalizedNotifications) + ) { + markNeedsSave() + } + const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( + parsed.settings?.sourceControlGroupOrder + ) + if ( + parsed.settings?.sourceControlGroupOrder !== undefined && + parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder + ) { + markNeedsSave() + } + return { + migratedExperimentalActivity, + migratedAutoRenameBranchFromWork, + migratedTerminalCursorStyle, + migratedTerminalLineHeight, + terminalRightClickToPasteDefaultedForPlatform, + taskProviderSettings, + primarySelectionDefaultedForLinux, + primarySelectionDefaultedForTerminalDefaults, + migratePrimarySelectionPlatformDefault, + stampPrimarySelectionTerminalDefaults, + migratedDisabledTuiAgents, + migratedAgentYoloDefaults, + migratedWindowsRuntimeDefault, + migratedLocalAccountRuntime, + loadedCompactWorktreeCards, + mobilePairingCustomAddress, + mobilePairingCustomAddresses, + normalizedNotifications, + normalizedSourceControlGroupOrder, + normalizedOnboarding, + normalizedProjectGroups + } +} diff --git a/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts b/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts new file mode 100644 index 00000000000..65bf099891d --- /dev/null +++ b/src/main/persistence/loading-store/prepare-loaded-terminal-settings.ts @@ -0,0 +1,172 @@ +import { homedir } from 'node:os' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { + GlobalSettings, + WorktreeVisibilityDefaults +} from '../../../shared/global-settings-types' +import type { Repo } from '../../../shared/repo-types' +import { getDefaultPersistedState } from '../../../shared/constants' +import { migrateExternalWorktreeVisibilityDefaults } from '../../../shared/external-worktree-visibility' +import { + mergeLegacyCommitMessageAiIntoSourceControlAi, + sourceControlAiSettingsFromLegacy +} from '../../../shared/source-control-ai' +import { + normalizeOsc52ClipboardDefaultOn, + osc52ClipboardDefaultOnOverridesPersistedOff +} from '../../../shared/osc52-clipboard-settings' +import { + migrateTerminalScrollbackRows, + migrateTerminalTuiScrollSensitivityDefault +} from '../applying-settings/terminal-settings-migrations' +import { + canonicalizePersistedFloatingWorkspaceDirectory, + normalizeFloatingWorkspaceTrustedCwds +} from '../restoring-sessions/floating-workspace-normalization' + +export type PreparedLoadedTerminalSettings = { + defaults: PersistedState + migratedExternalVisibility: { + repos: Repo[] + defaults: WorktreeVisibilityDefaults + changed: boolean + } + migratedTerminalScrollback: { rows: number; needsSave: boolean } + migratedTerminalTuiScrollSensitivity: { + settings: Pick< + GlobalSettings, + 'terminalTuiScrollSensitivity' | 'terminalTuiScrollSensitivityDefaultedToOne' + > + needsSave: boolean + } + migratedSourceControlAi: NonNullable + migratedOptionAsAlt: GlobalSettings['terminalMacOptionAsAlt'] + migratedFloatingTerminalEnabled: GlobalSettings['floatingTerminalEnabled'] + migratedOsc52Clipboard: Pick< + GlobalSettings, + 'terminalAllowOsc52Clipboard' | 'terminalAllowOsc52ClipboardDefaultedOnForAllUsers' + > + migratedFloatingTerminalCwd: GlobalSettings['floatingTerminalCwd'] + migratedFloatingTerminalTrustedCwds: GlobalSettings['floatingTerminalTrustedCwds'] + osc52ClipboardNoticePending: boolean +} + +export function prepareLoadedTerminalSettings( + parsed: PersistedState, + markNeedsSave: () => void +): PreparedLoadedTerminalSettings { + // Merge with defaults in case new fields were added + const homeDir = homedir() + const defaults = getDefaultPersistedState(homeDir) + const migratedExternalVisibility = migrateExternalWorktreeVisibilityDefaults( + Array.isArray(parsed.repos) ? parsed.repos : [], + parsed.settings?.worktreeVisibilityDefaults + ) + if (migratedExternalVisibility.changed) { + markNeedsSave() + } + const migratedTerminalScrollback = migrateTerminalScrollbackRows(parsed.settings) + if (migratedTerminalScrollback.needsSave) { + markNeedsSave() + } + if ( + parsed.settings && + typeof parsed.settings === 'object' && + Object.hasOwn(parsed.settings, 'enableGitHubAttribution') + ) { + markNeedsSave() + } + const migratedTerminalTuiScrollSensitivity = migrateTerminalTuiScrollSensitivityDefault( + parsed.settings + ) + if (migratedTerminalTuiScrollSensitivity.needsSave) { + markNeedsSave() + } + const rawSourceControlAi = parsed.settings?.sourceControlAi + const rawSourceControlAiMissing = rawSourceControlAi === undefined + const rawSourceControlAiActionsMissing = + rawSourceControlAi !== undefined && rawSourceControlAi.actions === undefined + if (rawSourceControlAiMissing || rawSourceControlAiActionsMissing) { + markNeedsSave() + } + const legacyCommitMessageAi = parsed.settings?.commitMessageAi + const migratedSourceControlAi = rawSourceControlAiMissing + ? sourceControlAiSettingsFromLegacy(legacyCommitMessageAi ?? defaults.settings.commitMessageAi) + : mergeLegacyCommitMessageAiIntoSourceControlAi( + parsed.settings?.sourceControlAi, + legacyCommitMessageAi + ) + // Why (issue #903): old 'true' default broke non-US Option-layer chars; flip 'true'→'auto' once so the layout probe decides. + const rawOptionAsAlt = parsed.settings?.terminalMacOptionAsAlt + const alreadyMigrated = parsed.settings?.terminalMacOptionAsAltMigrated === true + const migratedOptionAsAlt: 'auto' | 'true' | 'false' | 'left' | 'right' = alreadyMigrated + ? (rawOptionAsAlt ?? 'auto') + : rawOptionAsAlt === undefined || rawOptionAsAlt === 'true' + ? 'auto' + : rawOptionAsAlt + const floatingTerminalDefaultedForAllUsers = + parsed.settings?.floatingTerminalDefaultedForAllUsers === true + // Why: early builds persisted the old off default; flip only unmigrated profiles so a later opt-out survives reload. + const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers + ? (parsed.settings?.floatingTerminalEnabled ?? true) + : true + // Why: the old off default persisted `false` for every profile, indistinguishable from a real opt-out — flip unmigrated profiles once (#10567). + const migratedOsc52Clipboard = normalizeOsc52ClipboardDefaultOn(parsed.settings) + const osc52ClipboardNoticePending = + osc52ClipboardDefaultOnOverridesPersistedOff(parsed.settings) || + parsed.ui?.osc52ClipboardDefaultOnNoticePending === true + if (parsed.settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== true) { + markNeedsSave() + } + const floatingTerminalCwdMigrated = + parsed.settings?.floatingTerminalCwdMigratedToAppWorkspace === true + // Why: an earlier migration wrote '' for the notes dir; floating terminals still open at home, notes use a separate IPC. + const migratedFloatingTerminalCwd = floatingTerminalCwdMigrated + ? !parsed.settings?.floatingTerminalCwd + ? defaults.settings.floatingTerminalCwd + : parsed.settings.floatingTerminalCwd + : parsed.settings?.floatingTerminalCwd === undefined + ? defaults.settings.floatingTerminalCwd + : parsed.settings.floatingTerminalCwd + const normalizedFloatingTerminalTrustedCwds = normalizeFloatingWorkspaceTrustedCwds( + parsed.settings?.floatingTerminalTrustedCwds, + homeDir + ) + const migratedFloatingTerminalTrustedCwds = [...normalizedFloatingTerminalTrustedCwds.trustedCwds] + const rawLegacyFloatingTerminalCwd = parsed.settings?.floatingTerminalCwd + const shouldTrustLegacyFloatingTerminalCwd = + !floatingTerminalCwdMigrated && + typeof rawLegacyFloatingTerminalCwd === 'string' && + rawLegacyFloatingTerminalCwd.trim().length > 0 && + rawLegacyFloatingTerminalCwd.trim() !== '~' + if (!floatingTerminalCwdMigrated) { + markNeedsSave() + } + if (shouldTrustLegacyFloatingTerminalCwd && rawLegacyFloatingTerminalCwd) { + const canonicalLegacyCwd = canonicalizePersistedFloatingWorkspaceDirectory( + rawLegacyFloatingTerminalCwd, + homeDir + ) + if (canonicalLegacyCwd && !migratedFloatingTerminalTrustedCwds.includes(canonicalLegacyCwd)) { + // Why: pre-grant profiles with an explicit Floating Workspace cwd already showed intent; migrate only that legacy value. + migratedFloatingTerminalTrustedCwds.push(canonicalLegacyCwd) + normalizedFloatingTerminalTrustedCwds.changed = true + } + } + if (normalizedFloatingTerminalTrustedCwds.changed) { + markNeedsSave() + } + return { + defaults, + migratedExternalVisibility, + migratedTerminalScrollback, + migratedTerminalTuiScrollSensitivity, + migratedSourceControlAi, + migratedOptionAsAlt, + migratedFloatingTerminalEnabled, + migratedOsc52Clipboard, + migratedFloatingTerminalCwd, + migratedFloatingTerminalTrustedCwds, + osc52ClipboardNoticePending + } +} diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts new file mode 100644 index 00000000000..9d6e476ebe5 --- /dev/null +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -0,0 +1,282 @@ +import { mkdirSync, existsSync, unlinkSync } from 'node:fs' +import { mkdir, open, rm } from 'node:fs/promises' +import { durableWriteTempPath, renameDurable, writeFileDurableSync } from '../../durable-file-write' +import { dirname } from 'node:path' +import { + parseCodexResetCreditAttemptLedger, + type CodexResetCreditAttemptLedger +} from '../../../shared/codex-reset-credit-attempt-ledger' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' +import type { BackupRecoveryRotationOperations } from './backup-recovery-rotation' + +type PrimaryStateWriteOperationsRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'backupRotationInFlight' + | 'dataFile' + | 'flushOrThrow' + | 'firstPendingSaveAt' + | 'inFlightAsyncTmpFile' + | 'lastDurableWriteGeneration' + | 'lastWrittenStateHash' + | 'pendingSnapshotFileWork' + | 'pendingWrite' + | 'protectedSecrets' + | 'quitFlushStarted' + | 'staleTempCleanup' + | 'state' + | 'writeGeneration' + | 'writeTimer' + | 'writesFrozen' +> + +const primaryStateWriteOperationsContext = Symbol('PrimaryStateWriteOperations') +type PrimaryStateWriteOperationsContext = { + runtime: PrimaryStateWriteOperationsRuntime + serialization: StateSerializationSecretHandlingOperations + backups: BackupRecoveryRotationOperations +} + +export class PrimaryStateWriteOperations { + readonly [primaryStateWriteOperationsContext]: PrimaryStateWriteOperationsContext + + constructor( + runtime: PrimaryStateWriteOperationsRuntime, + serialization: StateSerializationSecretHandlingOperations, + backups: BackupRecoveryRotationOperations + ) { + this[primaryStateWriteOperationsContext] = { runtime, serialization, backups } + } + + flushOrThrow(): void { + if (this[primaryStateWriteOperationsContext].runtime.quitFlushStarted) { + throw new Error('Cannot synchronously flush after final persistence has started') + } + if (this[primaryStateWriteOperationsContext].runtime.writeTimer) { + clearTimeout(this[primaryStateWriteOperationsContext].runtime.writeTimer) + this[primaryStateWriteOperationsContext].runtime.writeTimer = null + } + this[primaryStateWriteOperationsContext].runtime.firstPendingSaveAt = null + const asyncWriteWasInFlight = + this[primaryStateWriteOperationsContext].runtime.pendingWrite !== null + // Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write. + this[primaryStateWriteOperationsContext].runtime.writeGeneration++ + if (this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile) { + try { + unlinkSync(this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile) + this[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = null + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + void enqueueWrite(this).catch(() => {}) + throw error + } + } + } + // Why: later async flushes must remain serialized behind the invalidated writer. + writeToDiskSync(this, { + force: asyncWriteWasInFlight, + skipBackupRotation: this[primaryStateWriteOperationsContext].runtime.backupRotationInFlight + }) + } + + flushActiveViewPreferenceOrThrow(): void { + this[primaryStateWriteOperationsContext].runtime.activeViewPreference.flushOrThrow() + } + + getCodexResetCreditAttemptLedger(): CodexResetCreditAttemptLedger { + return parseCodexResetCreditAttemptLedger( + this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger + ) + } + + replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void { + if (this[primaryStateWriteOperationsContext].runtime.writesFrozen) { + throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen') + } + const next = parseCodexResetCreditAttemptLedger(ledger) + const previous = this[primaryStateWriteOperationsContext].runtime.state + .codexResetCreditAttemptLedger + ? structuredClone( + this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger + ) + : undefined + this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger = next + try { + this[primaryStateWriteOperationsContext].runtime.flushOrThrow() + } catch (error) { + // Why: callers use a successful return as the durability barrier before + // handing a scarce-credit mutation to the provider. + this[primaryStateWriteOperationsContext].runtime.state.codexResetCreditAttemptLedger = + previous + throw error + } + } +} + +export function enqueueWrite(owner: PrimaryStateWriteOperations): Promise { + const previousWrite = Promise.all([ + owner[primaryStateWriteOperationsContext].runtime.pendingWrite ?? + owner[primaryStateWriteOperationsContext].runtime.staleTempCleanup, + owner[primaryStateWriteOperationsContext].runtime.pendingSnapshotFileWork ?? Promise.resolve() + ]).then(() => {}) + const write = previousWrite.then(() => writeToDiskAsync(owner)) + const trackedWrite = write + .catch((err) => { + console.error('[persistence] Failed to write state:', err) + }) + .finally(() => { + if (owner[primaryStateWriteOperationsContext].runtime.pendingWrite === trackedWrite) { + owner[primaryStateWriteOperationsContext].runtime.pendingWrite = null + } + }) + owner[primaryStateWriteOperationsContext].runtime.pendingWrite = trackedWrite + return write +} + +export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Promise { + if (owner[primaryStateWriteOperationsContext].runtime.writesFrozen) { + return + } + const gen = owner[primaryStateWriteOperationsContext].runtime.writeGeneration + const { payload, stateHash, protectedSecretUpdates } = + owner[primaryStateWriteOperationsContext].serialization.buildStateToSave() + // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. + if (stateHash === owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash) { + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, + gen + ) + return + } + const dataFile = owner[primaryStateWriteOperationsContext].runtime.dataFile + const dir = dirname(dataFile) + await mkdir(dir, { recursive: true }).catch(() => {}) + const tmpFile = durableWriteTempPath(dataFile) + + // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. + let renamed = false + try { + // Why: fsync before rename, then fsync the directory; see writeFileDurable. + const handle = await open(tmpFile, 'w') + try { + await handle.writeFile(payload, 'utf-8') + await handle.sync() + } finally { + await handle.close() + } + // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. + if (owner[primaryStateWriteOperationsContext].runtime.writeGeneration !== gen) { + return + } + owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = tmpFile + try { + await renameDurable(tmpFile, dataFile) + renamed = true + } catch (error) { + if ( + (error as NodeJS.ErrnoException).code !== 'ENOENT' || + owner[primaryStateWriteOperationsContext].runtime.writeGeneration === gen + ) { + throw error + } + } finally { + if (owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile === tmpFile) { + owner[primaryStateWriteOperationsContext].runtime.inFlightAsyncTmpFile = null + } + } + // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. + if (renamed && owner[primaryStateWriteOperationsContext].runtime.writeGeneration === gen) { + owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = stateHash + owner[primaryStateWriteOperationsContext].runtime.protectedSecrets.commitRetentionUpdates( + protectedSecretUpdates + ) + } else if (renamed) { + owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = null + } + if (renamed) { + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, + gen + ) + } + } finally { + if (!renamed) { + await rm(tmpFile).catch(() => {}) + } + } + if (!renamed) { + return + } + // Why (#1158): rotate only after the primary rename while this write still owns its generation. + if (owner[primaryStateWriteOperationsContext].runtime.writeGeneration !== gen) { + return + } + await owner[primaryStateWriteOperationsContext].backups.rotateBackupsAsync(dataFile) +} + +export function writeToDiskSync( + owner: PrimaryStateWriteOperations, + opts: { force?: boolean; skipBackupRotation?: boolean } = {} +): void { + if (owner[primaryStateWriteOperationsContext].runtime.writesFrozen) { + return + } + const { payload, stateHash, protectedSecretUpdates } = + owner[primaryStateWriteOperationsContext].serialization.buildStateToSave() + // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. + if ( + !opts.force && + stateHash === owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash + ) { + return + } + const dataFile = owner[primaryStateWriteOperationsContext].runtime.dataFile + const dir = dirname(dataFile) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` + + // Why: on any write/rename failure, remove the tmp file so shutdown crashes don't leak orphans. + let renamed = false + try { + // Why: fsync the temp file and the directory; a bare rename can survive as stale or empty + // content after power loss, losing projects/tabs back to the newest usable .bak slot. + writeFileDurableSync(tmpFile, dataFile, payload) + renamed = true + owner[primaryStateWriteOperationsContext].runtime.lastWrittenStateHash = stateHash + owner[primaryStateWriteOperationsContext].runtime.protectedSecrets.commitRetentionUpdates( + protectedSecretUpdates + ) + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration = Math.max( + owner[primaryStateWriteOperationsContext].runtime.lastDurableWriteGeneration, + owner[primaryStateWriteOperationsContext].runtime.writeGeneration + ) + } finally { + if (!renamed) { + try { + unlinkSync(tmpFile) + } catch { + // Best-effort cleanup; the write already failed, swallow secondary error. + } + } + } + const now = Date.now() + if ( + !opts.skipBackupRotation && + owner[primaryStateWriteOperationsContext].backups.shouldRotateBackups(now, dataFile) + ) { + owner[primaryStateWriteOperationsContext].backups.rotateBackupsSync(dataFile) + } +} + +export function installPrimaryStateWriteOperationsContext( + target: object, + source: PrimaryStateWriteOperations +): void { + Object.defineProperty(target, primaryStateWriteOperationsContext, { + value: source[primaryStateWriteOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/profile-preferences.ts b/src/main/persistence/loading-store/profile-preferences.ts new file mode 100644 index 00000000000..228229d2584 --- /dev/null +++ b/src/main/persistence/loading-store/profile-preferences.ts @@ -0,0 +1,194 @@ +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { OnboardingChecklistState } from '../../../shared/onboarding-state-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { getDefaultOnboardingState } from '../../../shared/constants' +import type { FeatureInteractionId } from '../../../shared/feature-interactions' +import { + updateSettings as updateSettingsOperation, + type SettingsMutationOperations +} from '../applying-settings/settings-update' +import { getPersistedUI } from '../applying-settings/ui-state-read' +import { updatePersistedUI, type UIUpdateOperations } from '../applying-settings/ui-state-update' +import { + recordFeatureInteraction as recordFeatureInteractionOperation, + type FeatureInteractionOperations +} from '../applying-settings/feature-interaction-recording' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type ProfilePreferencesRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'githubCacheDirty' + | 'githubCacheGeneration' + | 'protectedSecrets' + | 'settingsChangeListeners' + | 'state' + | 'uiChangeListeners' +> + +const profilePreferencesContext = Symbol('ProfilePreferences') +type ProfilePreferencesContext = { + runtime: ProfilePreferencesRuntime + scheduling: WriteSchedulingOperations +} + +export class ProfilePreferences { + readonly [profilePreferencesContext]: ProfilePreferencesContext + + constructor(runtime: ProfilePreferencesRuntime, scheduling: WriteSchedulingOperations) { + this[profilePreferencesContext] = { runtime, scheduling } + } + + getSettings(): GlobalSettings { + return this[profilePreferencesContext].runtime.state.settings + } + + onSettingsChanged( + listener: ( + updates: Partial, + settings: GlobalSettings, + originWebContentsId?: number + ) => void + ): () => void { + this[profilePreferencesContext].runtime.settingsChangeListeners.add(listener) + return () => { + this[profilePreferencesContext].runtime.settingsChangeListeners.delete(listener) + } + } + + onUIChanged(listener: (ui: PersistedState['ui']) => void): () => void { + this[profilePreferencesContext].runtime.uiChangeListeners.add(listener) + return () => { + this[profilePreferencesContext].runtime.uiChangeListeners.delete(listener) + } + } + + updateSettings( + updates: Partial, + options: { notifyListeners?: boolean; originWebContentsId?: number } = {} + ): GlobalSettings { + return updateSettingsOperation(getSettingsMutationOperations(this), updates, options) + } + + getUI(): PersistedState['ui'] { + return getPersistedUI( + this[profilePreferencesContext].runtime.state, + this[profilePreferencesContext].runtime.activeViewPreference.get() + ) + } + + updateUI(updates: Partial): void { + updatePersistedUI(getUIUpdateOperations(this), updates) + } + + recordFeatureInteraction(id: FeatureInteractionId): PersistedState['ui'] { + return recordFeatureInteractionOperation(getFeatureInteractionOperations(this), id) + } + + getOnboarding(): PersistedState['onboarding'] { + const defaults = getDefaultOnboardingState() + return { + ...defaults, + ...this[profilePreferencesContext].runtime.state.onboarding, + checklist: { + ...defaults.checklist, + ...this[profilePreferencesContext].runtime.state.onboarding?.checklist + } + } + } + + updateOnboarding( + updates: Partial> & { + checklist?: Partial + } + ): PersistedState['onboarding'] { + const current = this.getOnboarding() + this[profilePreferencesContext].runtime.state.onboarding = { + ...current, + ...updates, + checklist: { + ...current.checklist, + ...updates.checklist + } + } + scheduleSave(this[profilePreferencesContext].scheduling) + return this.getOnboarding() + } + + getGitHubCache(): PersistedState['githubCache'] { + return this[profilePreferencesContext].runtime.state.githubCache + } + + setGitHubCache(cache: PersistedState['githubCache']): void { + // Why no scheduleSave: cache is memory-only and snapshotted to a sidecar at flush; persisting here rewrote the whole state file every poll cycle. + this[profilePreferencesContext].runtime.state.githubCache = cache + this[profilePreferencesContext].runtime.githubCacheDirty = true + this[profilePreferencesContext].runtime.githubCacheGeneration += 1 + } +} + +export function notifySettingsChanged( + owner: ProfilePreferences, + updates: Partial, + originWebContentsId?: number +): void { + for (const listener of owner[profilePreferencesContext].runtime.settingsChangeListeners) { + listener(updates, owner[profilePreferencesContext].runtime.state.settings, originWebContentsId) + } +} + +export function notifyUIChanged(owner: ProfilePreferences): void { + if (owner[profilePreferencesContext].runtime.uiChangeListeners.size === 0) { + return + } + const ui = owner.getUI() + for (const listener of owner[profilePreferencesContext].runtime.uiChangeListeners) { + listener(ui) + } +} + +export function getSettingsMutationOperations( + owner: ProfilePreferences +): SettingsMutationOperations { + return { + state: owner[profilePreferencesContext].runtime.state, + removeRetainedBlob: (slot) => + owner[profilePreferencesContext].runtime.protectedSecrets.removeRetainedBlob(slot), + scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + notifySettingsChanged: (updates, originWebContentsId) => + notifySettingsChanged(owner, updates, originWebContentsId) + } +} + +export function getUIUpdateOperations(owner: ProfilePreferences): UIUpdateOperations { + return { + state: owner[profilePreferencesContext].runtime.state, + removeRetainedBlob: (slot) => + owner[profilePreferencesContext].runtime.protectedSecrets.removeRetainedBlob(slot), + setActiveView: (activeView) => + owner[profilePreferencesContext].runtime.activeViewPreference.set(activeView), + getUI: () => owner.getUI(), + scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + notifyUIChanged: () => notifyUIChanged(owner) + } +} + +export function getFeatureInteractionOperations( + owner: ProfilePreferences +): FeatureInteractionOperations { + return { + state: owner[profilePreferencesContext].runtime.state, + scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), + notifyUIChanged: () => notifyUIChanged(owner), + getUI: () => owner.getUI() + } +} + +export function installProfilePreferencesContext(target: object, source: ProfilePreferences): void { + Object.defineProperty(target, profilePreferencesContext, { + value: source[profilePreferencesContext] + }) +} diff --git a/src/main/persistence/loading-store/project-collection-operations.ts b/src/main/persistence/loading-store/project-collection-operations.ts new file mode 100644 index 00000000000..e7100478037 --- /dev/null +++ b/src/main/persistence/loading-store/project-collection-operations.ts @@ -0,0 +1,233 @@ +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { + Project, + ProjectHostSetup, + ProjectHostSetupCreateArgs, + ProjectHostSetupCreateResult, + ProjectHostSetupDeleteArgs, + ProjectHostSetupDeleteResult, + ProjectHostSetupUpdateArgs, + ProjectHostSetupUpdateResult, + ProjectUpdateArgs +} from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import { ProjectGroupPersistenceOperations } from '../tracking-repos/project-group-operations' +import { FolderWorkspacePersistenceOperations } from '../restoring-sessions/folder-workspace-operations' +import { ProjectHostPersistenceOperations } from '../tracking-repos/project-host-operations' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { RepoLifecycleOperations } from './repo-lifecycle-operations' +import type { WriteSchedulingOperations } from './write-scheduling' +import type { MetadataLineageOperations } from './metadata-lineage-operations' +import { + hydrateRepo, + updateRepoBackedProjectHostSetup, + updateIndependentProjectHostSetup, + pruneMobileClientTabSelections +} from './repo-lifecycle-operations' +import { scheduleSave } from './write-scheduling' +import { removeWorkspaceLineageForFolderParent } from './metadata-lineage-operations' + +type ProjectCollectionOperationsRuntime = Pick< + StoreRuntimeState, + | 'folderWorkspaceOperations' + | 'gitUsernameCache' + | 'projectGroupOperations' + | 'projectHostOperations' + | 'state' +> + +const projectCollectionOperationsContext = Symbol('ProjectCollectionOperations') +type ProjectCollectionOperationsContext = { + runtime: ProjectCollectionOperationsRuntime + repos: RepoLifecycleOperations + scheduling: WriteSchedulingOperations + metadata: MetadataLineageOperations +} + +export class ProjectCollectionOperations { + readonly [projectCollectionOperationsContext]: ProjectCollectionOperationsContext + + constructor( + runtime: ProjectCollectionOperationsRuntime, + repos: RepoLifecycleOperations, + scheduling: WriteSchedulingOperations, + metadata: MetadataLineageOperations + ) { + this[projectCollectionOperationsContext] = { runtime, repos, scheduling, metadata } + } + + getRepos(): Repo[] { + return getProjectHostOperations(this).getRepos() + } + + getProjects(): Project[] { + return getProjectHostOperations(this).getProjects() + } + + updateProject(id: string, updates: ProjectUpdateArgs['updates']): Project | null { + return getProjectHostOperations(this).updateProject(id, updates) + } + + getProjectHostSetups(): ProjectHostSetup[] { + return getProjectHostOperations(this).getProjectHostSetups() + } + + createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult | null { + return getProjectHostOperations(this).createProjectHostSetup(args) + } + + updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | null { + return getProjectHostOperations(this).updateProjectHostSetup(args) + } + + deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null { + return getProjectHostOperations(this).deleteProjectHostSetup(args) + } + + getRepoCount(): number { + return getProjectHostOperations(this).getRepoCount() + } + + getRepo(id: string): Repo | undefined { + return getProjectHostOperations(this).getRepo(id) + } + + setResolvedRepoGitUsername( + target: Pick, + username: string + ): boolean { + return getProjectHostOperations(this).setResolvedRepoGitUsername(target, username) + } + + getProjectGroups(): ProjectGroup[] { + return getProjectGroupOperations(this).getProjectGroups() + } + + createProjectGroup( + input: Parameters[0] + ): ProjectGroup { + return getProjectGroupOperations(this).createProjectGroup(input) + } + + updateProjectGroup( + groupId: string, + updates: Parameters[1] + ): ProjectGroup | null { + return getProjectGroupOperations(this).updateProjectGroup(groupId, updates) + } + + deleteProjectGroup(groupId: string): boolean { + return getProjectGroupOperations(this).deleteProjectGroup(groupId) + } + + getFolderWorkspaces(): FolderWorkspace[] { + return getFolderWorkspaceOperations(this).getFolderWorkspaces() + } + + getFolderWorkspace(id: string): FolderWorkspace | undefined { + return getFolderWorkspaceOperations(this).getFolderWorkspace(id) + } + + createFolderWorkspace( + input: Parameters[0] + ): FolderWorkspace { + return getFolderWorkspaceOperations(this).createFolderWorkspace(input) + } + + updateFolderWorkspace( + id: string, + updates: Parameters[1] + ): FolderWorkspace | null { + return getFolderWorkspaceOperations(this).updateFolderWorkspace(id, updates) + } + + removeFolderWorkspace(id: string): boolean { + return getFolderWorkspaceOperations(this).removeFolderWorkspace(id) + } + + moveProjectToGroup(repoId: string, groupId: string | null, order?: number): Repo | null { + return getFolderWorkspaceOperations(this).moveProjectToGroup(repoId, groupId, order) + } +} + +export function getProjectHostOperations( + owner: ProjectCollectionOperations +): ProjectHostPersistenceOperations { + owner[projectCollectionOperationsContext].runtime.projectHostOperations ??= + new ProjectHostPersistenceOperations({ + state: owner[projectCollectionOperationsContext].runtime.state, + gitUsernameCache: owner[projectCollectionOperationsContext].runtime.gitUsernameCache, + hydrateRepo: (repo) => hydrateRepo(owner[projectCollectionOperationsContext].repos, repo), + updateRepoBackedProjectHostSetup: (setup, repo, updates) => + updateRepoBackedProjectHostSetup( + owner[projectCollectionOperationsContext].repos, + setup, + repo, + updates + ), + updateIndependentProjectHostSetup: (setup, updates) => + updateIndependentProjectHostSetup( + owner[projectCollectionOperationsContext].repos, + setup, + updates + ), + removeProjectForHost: (id, hostId) => + owner[projectCollectionOperationsContext].repos.removeProjectForHost(id, hostId), + scheduleSave: () => scheduleSave(owner[projectCollectionOperationsContext].scheduling) + }) + return owner[projectCollectionOperationsContext].runtime.projectHostOperations +} + +export function getProjectGroupOperations( + owner: ProjectCollectionOperations +): ProjectGroupPersistenceOperations { + owner[projectCollectionOperationsContext].runtime.projectGroupOperations ??= + new ProjectGroupPersistenceOperations({ + state: owner[projectCollectionOperationsContext].runtime.state, + scheduleSave: () => scheduleSave(owner[projectCollectionOperationsContext].scheduling), + removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => + removeWorkspaceLineageForFolderParent( + owner[projectCollectionOperationsContext].metadata, + folderWorkspaceId + ), + pruneMobileClientTabSelections: (matchesWorktreeId) => + pruneMobileClientTabSelections( + owner[projectCollectionOperationsContext].repos, + matchesWorktreeId + ) + }) + return owner[projectCollectionOperationsContext].runtime.projectGroupOperations +} + +export function getFolderWorkspaceOperations( + owner: ProjectCollectionOperations +): FolderWorkspacePersistenceOperations { + owner[projectCollectionOperationsContext].runtime.folderWorkspaceOperations ??= + new FolderWorkspacePersistenceOperations({ + state: owner[projectCollectionOperationsContext].runtime.state, + scheduleSave: () => scheduleSave(owner[projectCollectionOperationsContext].scheduling), + removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => + removeWorkspaceLineageForFolderParent( + owner[projectCollectionOperationsContext].metadata, + folderWorkspaceId + ), + pruneMobileClientTabSelections: (matchesWorktreeId) => + pruneMobileClientTabSelections( + owner[projectCollectionOperationsContext].repos, + matchesWorktreeId + ), + hydrateRepo: (repo) => hydrateRepo(owner[projectCollectionOperationsContext].repos, repo) + }) + return owner[projectCollectionOperationsContext].runtime.folderWorkspaceOperations +} + +export function installProjectCollectionOperationsContext( + target: object, + source: ProjectCollectionOperations +): void { + Object.defineProperty(target, projectCollectionOperationsContext, { + value: source[projectCollectionOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts new file mode 100644 index 00000000000..3c5ff20c480 --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -0,0 +1,240 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { + cloneLayoutNode, + layoutContainsLeafId +} from '../restoring-sessions/terminal-layout-normalization' +import { + cloneWorkspaceSessionState, + createMinimalPersistedTerminalTab +} from '../restoring-sessions/session-owner-fields' + +import type { PtyBindingSourceExpectation } from './store' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { SessionHostPartitionOperations } from './session-host-partitions' +import { resolveHostId } from './session-host-partitions' + +type PtyBindingPersistenceOperationsRuntime = Pick + +const ptyBindingPersistenceOperationsContext = Symbol('PtyBindingPersistenceOperations') +type PtyBindingPersistenceOperationsContext = { + runtime: PtyBindingPersistenceOperationsRuntime + sessions: SessionHostPartitionOperations +} + +export class PtyBindingPersistenceOperations { + readonly [ptyBindingPersistenceOperationsContext]: PtyBindingPersistenceOperationsContext + + constructor( + runtime: PtyBindingPersistenceOperationsRuntime, + sessions: SessionHostPartitionOperations + ) { + this[ptyBindingPersistenceOperationsContext] = { runtime, sessions } + } + + persistPtyBinding( + args: { + worktreeId: string + tabId: string + leafId: string + ptyId: string + incarnationId?: string + startupCwd?: string + expectedBinding?: { ptyId: string; incarnationId?: string } + expectedSourceBinding?: PtyBindingSourceExpectation + /** Set by host-initiated creates, which have no renderer session writer behind them. */ + hostAdmittedMembership?: boolean + }, + hostId?: string | null + ): boolean { + const resolvedHostId = resolveHostId(hostId) + const session = + this[ptyBindingPersistenceOperationsContext].sessions.getWorkspaceSession(resolvedHostId) + const paneKey = `${args.tabId}:${args.leafId}` + const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId + if (args.expectedSourceBinding) { + const expected = args.expectedSourceBinding + if (expected.tabId !== args.tabId) { + return false + } + const sourceTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === expected.tabId && candidate.worktreeId === bindingWorktreeId + ) + const sourceLayout = session.terminalLayoutsByTabId?.[expected.tabId] + const sourcePaneKey = `${expected.tabId}:${expected.leafId}` + if ( + !sourceTab || + sourceLayout?.ptyIdsByLeafId?.[expected.leafId] !== expected.ptyId || + !layoutContainsLeafId(sourceLayout.root, expected.leafId) || + (expected.incarnationId !== undefined && + session.terminalPtyIncarnationsByPaneKey?.[sourcePaneKey] !== expected.incarnationId) + ) { + return false + } + } + if (args.expectedBinding) { + const tab = session.tabsByWorktree?.[bindingWorktreeId]?.find( + (candidate) => candidate.id === args.tabId && candidate.worktreeId === bindingWorktreeId + ) + const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] + if ( + !tab || + boundPtyId !== args.expectedBinding.ptyId || + session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId + ) { + return false + } + } + if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { + this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId, + [resolvedHostId]: session + } + } + const sessionBeforeBinding = cloneWorkspaceSessionState(session) + const reconciledIncarnation = + args.expectedBinding !== undefined && + args.incarnationId !== args.expectedBinding.incarnationId + let terminalMembershipChanged = false + let hostAdmittedTabCreated = false + const advanceTopologyFence = (): void => { + const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) + const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 + // Why: a split, or a host-admitted tab the renderer has never seen, is itself + // the authority — with no fence the renderer's pre-create tab list replays + // over it and the tab is lost even on the repo's first such change. + const establishesMembershipAuthority = + args.expectedSourceBinding !== undefined || hostAdmittedTabCreated + if ( + !reconciledIncarnation && + (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) + ) { + return + } + // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. + session.terminalTopologyRevisionByRepoId = { + ...session.terminalTopologyRevisionByRepoId, + [repoId]: currentRevision + 1 + } + } + const restoreSession = (): void => { + if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { + this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSession = + sessionBeforeBinding + } else { + this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...this[ptyBindingPersistenceOperationsContext].runtime.state.workspaceSessionsByHostId, + [resolvedHostId]: sessionBeforeBinding + } + } + } + if (args.incarnationId) { + session.terminalPtyIncarnationsByPaneKey = { + ...session.terminalPtyIncarnationsByPaneKey, + [paneKey]: args.incarnationId + } + if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { + session.terminalSurfaceTombstonesByPaneKey = { + ...session.terminalSurfaceTombstonesByPaneKey + } + delete session.terminalSurfaceTombstonesByPaneKey[paneKey] + } + } + const tabs = session.tabsByWorktree?.[bindingWorktreeId] + const tab = tabs?.find((t) => t.id === args.tabId) + if (tab) { + tab.ptyId = args.ptyId + } else { + terminalMembershipChanged = true + hostAdmittedTabCreated = args.hostAdmittedMembership === true + // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. + const nextTabs = [ + ...(tabs ?? []), + createMinimalPersistedTerminalTab({ + ...args, + worktreeId: bindingWorktreeId, + existingTabCount: tabs?.length ?? 0 + }) + ] + session.tabsByWorktree = { + ...session.tabsByWorktree, + [bindingWorktreeId]: nextTabs + } + session.activeWorktreeId ??= bindingWorktreeId + session.activeTabId ??= args.tabId + session.activeTabIdByWorktree = { + ...session.activeTabIdByWorktree, + [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId + } + } + if (!isTerminalLeafId(args.leafId)) { + // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. + advanceTopologyFence() + try { + this[ptyBindingPersistenceOperationsContext].runtime.flushOrThrow() + } catch (err) { + restoreSession() + throw err + } + return true + } + const layout = session.terminalLayoutsByTabId?.[args.tabId] + if (layout) { + if (!layout.root) { + terminalMembershipChanged = true + // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. + layout.root = { type: 'leaf', leafId: args.leafId } + layout.activeLeafId = args.leafId + layout.expandedLeafId = null + } else if (!layoutContainsLeafId(layout.root, args.leafId)) { + terminalMembershipChanged = true + // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. + layout.root = { + type: 'split', + direction: 'vertical', + first: cloneLayoutNode(layout.root), + second: { type: 'leaf', leafId: args.leafId } + } + layout.activeLeafId = args.leafId + if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { + layout.expandedLeafId = null + } + } + layout.ptyIdsByLeafId = { + ...layout.ptyIdsByLeafId, + [args.leafId]: args.ptyId + } + } else { + terminalMembershipChanged = true + // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. + session.terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [args.tabId]: { + root: { type: 'leaf', leafId: args.leafId }, + activeLeafId: args.leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [args.leafId]: args.ptyId } + } + } + } + advanceTopologyFence() + try { + this[ptyBindingPersistenceOperationsContext].runtime.flushOrThrow() + } catch (err) { + restoreSession() + throw err + } + return true + } +} + +export function installPtyBindingPersistenceOperationsContext( + target: object, + source: PtyBindingPersistenceOperations +): void { + Object.defineProperty(target, ptyBindingPersistenceOperationsContext, { + value: source[ptyBindingPersistenceOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts new file mode 100644 index 00000000000..bdd3718d9a6 --- /dev/null +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -0,0 +1,264 @@ +import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import { + removeRepoFromHostWorkspaceSessions, + removeRepoFromWorkspaceSession +} from '../../orca-profiles/profile-project-session-state' +import { + getRepoExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +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 { 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 type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type RepoLifecycleOperationsRuntime = Pick< + StoreRuntimeState, + | 'gitUsernameCache' + | 'projectHostSetupOperations' + | 'repoOrderOperations' + | 'repoUpdateOperations' + | 'state' +> + +const repoLifecycleOperationsContext = Symbol('RepoLifecycleOperations') +type RepoLifecycleOperationsContext = { + runtime: RepoLifecycleOperationsRuntime + scheduling: WriteSchedulingOperations +} + +export class RepoLifecycleOperations { + readonly [repoLifecycleOperationsContext]: RepoLifecycleOperationsContext + + constructor(runtime: RepoLifecycleOperationsRuntime, scheduling: WriteSchedulingOperations) { + this[repoLifecycleOperationsContext] = { runtime, scheduling } + } + + addRepo(repo: Repo): void { + getRepoOrderOperations(this).addRepo(repo) + } + + reorderRepos(orderedIds: string[]): boolean { + return getRepoOrderOperations(this).reorderRepos(orderedIds) + } + + reorderReposForHost(orderedIds: string[], hostId: ExecutionHostId): boolean { + return getRepoOrderOperations(this).reorderReposForHost(orderedIds, hostId) + } + + removeProject(id: string): void { + this[repoLifecycleOperationsContext].runtime.state.repos = this[ + repoLifecycleOperationsContext + ].runtime.state.repos.filter((r) => r.id !== id) + syncProjectHostSetupCompatibilityState(this) + // Why: presets are repo-scoped and unreachable once the repo is gone, so drop them with it. + delete this[repoLifecycleOperationsContext].runtime.state.sparsePresetsByRepo[id] + delete this[repoLifecycleOperationsContext].runtime.state.retiredWorktreeNamesByRepo?.[id] + pruneWorktreeStateForRepo(this, id, null) + this[repoLifecycleOperationsContext].runtime.state.workspaceSession = + removeRepoFromWorkspaceSession( + this[repoLifecycleOperationsContext].runtime.state.workspaceSession, + id + ) + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId = + removeRepoFromHostWorkspaceSessions( + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId, + id + ) + scheduleSave(this[repoLifecycleOperationsContext].scheduling) + } + + removeProjectForHost(id: string, hostId: ExecutionHostId): void { + this[repoLifecycleOperationsContext].runtime.state.repos = this[ + repoLifecycleOperationsContext + ].runtime.state.repos.filter((r) => !(r.id === id && getRepoExecutionHostId(r) === hostId)) + const idStillPresent = this[repoLifecycleOperationsContext].runtime.state.repos.some( + (r) => r.id === id + ) + // Why: presets and retirements are repo-id-scoped (not host-scoped); drop them only when the last host's copy is gone. + if (!idStillPresent) { + delete this[repoLifecycleOperationsContext].runtime.state.sparsePresetsByRepo[id] + delete this[repoLifecycleOperationsContext].runtime.state.retiredWorktreeNamesByRepo?.[id] + } + syncProjectHostSetupCompatibilityState(this) + // Why: prune only this host's worktree metas if the id survives elsewhere; otherwise prune everything (matches removeProject). + pruneWorktreeStateForRepo(this, id, idStillPresent ? hostId : null) + if (!idStillPresent) { + this[repoLifecycleOperationsContext].runtime.state.workspaceSession = + removeRepoFromWorkspaceSession( + this[repoLifecycleOperationsContext].runtime.state.workspaceSession, + id + ) + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId = + removeRepoFromHostWorkspaceSessions( + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId, + id + ) + } else if (parseExecutionHostId(hostId)?.kind === 'runtime') { + const session = + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId?.[hostId] + if (session) { + this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...this[repoLifecycleOperationsContext].runtime.state.workspaceSessionsByHostId, + [hostId]: removeRepoFromWorkspaceSession(session, id) + } + } + } + scheduleSave(this[repoLifecycleOperationsContext].scheduling) + } + + updateRepo( + id: string, + updates: Partial< + Pick< + Repo, + | 'displayName' + | 'badgeColor' + | 'repoIcon' + | 'upstream' + | 'gitRemoteIdentity' + | 'hookSettings' + | 'worktreeBaseRef' + | 'worktreeBasePath' + | 'kind' + | 'executionHostId' + | 'symlinkPaths' + | 'issueSourcePreference' + | 'forkSyncMode' + | 'externalWorktreeVisibilityPromptDismissedAt' + | 'externalWorktreeInboxBaselinePaths' + | 'importedExternalWorktreePaths' + | 'customWorktreeVisibilitySources' + | 'worktreeVisibilitySourcePreferences' + | 'projectGroupId' + | 'projectGroupOrder' + | 'projectHostSetupMethod' + > + > & { + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null + agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null + sourceControlAi?: Repo['sourceControlAi'] | null + externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null + }, + hostId?: ExecutionHostId + ): Repo | null { + return getRepoUpdateOperations(this).updateRepo(id, updates, hostId) + } +} + +export function getRepoOrderOperations( + owner: RepoLifecycleOperations +): RepoOrderPersistenceOperations { + owner[repoLifecycleOperationsContext].runtime.repoOrderOperations ??= + new RepoOrderPersistenceOperations({ + state: owner[repoLifecycleOperationsContext].runtime.state, + syncProjectHostSetupCompatibilityState: () => syncProjectHostSetupCompatibilityState(owner), + scheduleSave: () => scheduleSave(owner[repoLifecycleOperationsContext].scheduling) + }) + return owner[repoLifecycleOperationsContext].runtime.repoOrderOperations +} + +export function pruneWorktreeStateForRepo( + owner: RepoLifecycleOperations, + id: string, + hostId: ExecutionHostId | null +): void { + pruneWorktreeStateForRepoOperation( + owner[repoLifecycleOperationsContext].runtime.state, + id, + hostId, + (matchesWorktreeId) => pruneMobileClientTabSelections(owner, matchesWorktreeId) + ) +} + +export function pruneMobileClientTabSelections( + owner: RepoLifecycleOperations, + matchesWorktreeId: (worktreeId: string) => boolean +): void { + for (const [clientNavigationId, selectionsByWorktree] of Object.entries( + owner[repoLifecycleOperationsContext].runtime.state.mobileClientTabSelectionsByDeviceId ?? {} + )) { + for (const worktreeId of Object.keys(selectionsByWorktree)) { + if (matchesWorktreeId(worktreeId)) { + delete selectionsByWorktree[worktreeId] + } + } + if (Object.keys(selectionsByWorktree).length === 0) { + delete owner[repoLifecycleOperationsContext].runtime.state + .mobileClientTabSelectionsByDeviceId?.[clientNavigationId] + } + } +} + +export function getRepoUpdateOperations( + owner: RepoLifecycleOperations +): RepoUpdatePersistenceOperations { + owner[repoLifecycleOperationsContext].runtime.repoUpdateOperations ??= + new RepoUpdatePersistenceOperations({ + state: owner[repoLifecycleOperationsContext].runtime.state, + syncProjectHostSetupCompatibilityState: () => syncProjectHostSetupCompatibilityState(owner), + scheduleSave: () => scheduleSave(owner[repoLifecycleOperationsContext].scheduling), + hydrateRepo: (repo) => hydrateRepo(owner, repo) + }) + return owner[repoLifecycleOperationsContext].runtime.repoUpdateOperations +} + +export function syncProjectHostSetupCompatibilityState(owner: RepoLifecycleOperations): void { + const compatibilityState = mergeProjectHostSetupCompatibilityState( + owner[repoLifecycleOperationsContext].runtime.state, + owner[repoLifecycleOperationsContext].runtime.state.repos + ) + owner[repoLifecycleOperationsContext].runtime.state.projects = compatibilityState.projects + owner[repoLifecycleOperationsContext].runtime.state.projectHostSetups = + compatibilityState.projectHostSetups +} + +export function getProjectHostSetupOperations( + owner: RepoLifecycleOperations +): ProjectHostSetupPersistenceOperations { + owner[repoLifecycleOperationsContext].runtime.projectHostSetupOperations ??= + new ProjectHostSetupPersistenceOperations({ + state: owner[repoLifecycleOperationsContext].runtime.state, + updateRepo: (id, updates, hostId) => owner.updateRepo(id, updates, hostId), + scheduleSave: () => scheduleSave(owner[repoLifecycleOperationsContext].scheduling) + }) + return owner[repoLifecycleOperationsContext].runtime.projectHostSetupOperations +} + +export function updateRepoBackedProjectHostSetup( + owner: RepoLifecycleOperations, + setup: ProjectHostSetup, + repo: Repo, + updates: ProjectHostSetupUpdateArgs['updates'] +): { setup: ProjectHostSetup; repo: Repo } | null { + return getProjectHostSetupOperations(owner).updateRepoBackedProjectHostSetup(setup, repo, updates) +} + +export function updateIndependentProjectHostSetup( + owner: RepoLifecycleOperations, + setup: ProjectHostSetup, + updates: ProjectHostSetupUpdateArgs['updates'] +): ProjectHostSetup { + return getProjectHostSetupOperations(owner).updateIndependentProjectHostSetup(setup, updates) +} + +export function hydrateRepo(owner: RepoLifecycleOperations, repo: Repo): Repo { + return hydrateRepoOperation(repo, owner[repoLifecycleOperationsContext].runtime.gitUsernameCache) +} + +export function installRepoLifecycleOperationsContext( + target: object, + source: RepoLifecycleOperations +): void { + Object.defineProperty(target, repoLifecycleOperationsContext, { + value: source[repoLifecycleOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/retired-name-registry-normalization.ts b/src/main/persistence/loading-store/retired-name-registry-normalization.ts new file mode 100644 index 00000000000..d1c412f0f46 --- /dev/null +++ b/src/main/persistence/loading-store/retired-name-registry-normalization.ts @@ -0,0 +1,46 @@ +import { normalizeRetirableGeneratedName } from '../../worktree-name-retirement' +import { + clampExhaustedTiers, + compactRetiredNames, + isEmptyRetiredNameRegistry, + type RetiredNameRegistry +} from '../../../shared/worktree/retired-name-registry' + +function normalizeRetiredNameRegistry(row: unknown): RetiredNameRegistry { + const isPlainArray = Array.isArray(row) + const rawRow = row as { exhaustedTiers?: unknown; names?: unknown } | null | undefined + const rawNames = isPlainArray ? row : Array.isArray(rawRow?.names) ? rawRow.names : [] + const names = new Set() + for (const entry of rawNames) { + if (typeof entry !== 'string') { + continue + } + const normalized = normalizeRetirableGeneratedName(entry) + if (normalized) { + names.add(normalized) + } + } + return compactRetiredNames({ + exhaustedTiers: isPlainArray ? 0 : clampExhaustedTiers(rawRow?.exhaustedTiers), + names: [...names] + }) +} + +export function normalizeRetiredNameRegistryMap( + value: unknown +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + const byRepo: Record = {} + for (const [repoId, row] of Object.entries(value as Record)) { + if (!repoId) { + continue + } + const registry = normalizeRetiredNameRegistry(row) + if (!isEmptyRetiredNameRegistry(registry)) { + byRepo[repoId] = registry + } + } + return byRepo +} diff --git a/src/main/persistence/loading-store/retired-worktree-name-persistence.ts b/src/main/persistence/loading-store/retired-worktree-name-persistence.ts new file mode 100644 index 00000000000..c5260850522 --- /dev/null +++ b/src/main/persistence/loading-store/retired-worktree-name-persistence.ts @@ -0,0 +1,119 @@ +import { normalizeRetirableGeneratedName } from '../../worktree-name-retirement' +import { recordRetirementNamespaceRegistry } from '../../worktree-retirement-namespace' +import { + addRetiredNames, + EMPTY_RETIRED_NAME_REGISTRY, + type RetiredNameRegistry +} from '../../../shared/worktree/retired-name-registry' +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type RetiredWorktreeNameRuntime = Pick + +const retiredWorktreeNamePersistenceContext = Symbol('RetiredWorktreeNamePersistence') +type RetiredWorktreeNamePersistenceContext = { + runtime: RetiredWorktreeNameRuntime + scheduling: WriteSchedulingOperations +} + +export class RetiredWorktreeNamePersistence { + readonly [retiredWorktreeNamePersistenceContext]: RetiredWorktreeNamePersistenceContext + + constructor(runtime: RetiredWorktreeNameRuntime, scheduling: WriteSchedulingOperations) { + this[retiredWorktreeNamePersistenceContext] = { runtime, scheduling } + } + + getRetiredWorktreeNameRegistry(repoId: string): RetiredNameRegistry { + const stored = + this[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByRepo?.[repoId] + return stored + ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } + : EMPTY_RETIRED_NAME_REGISTRY + } + + getRetiredWorktreeNameRegistryForNamespace(namespaceKey: string): RetiredNameRegistry { + const stored = + this[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByNamespace?.[ + namespaceKey + ] + return stored + ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } + : EMPTY_RETIRED_NAME_REGISTRY + } + + addRetiredWorktreeName(repoId: string, name: string): void { + const normalized = normalizeRetirableGeneratedName(name) + if (!repoId || !normalized) { + return + } + applyRetiredWorktreeNames(this, repoId, [normalized]) + } + + mergeRetiredWorktreeNames(repoId: string, names: Iterable): boolean { + if (!repoId) { + return false + } + const incoming = new Set() + for (const name of names) { + const normalized = normalizeRetirableGeneratedName(name) + if (normalized) { + incoming.add(normalized) + } + } + return incoming.size > 0 && applyRetiredWorktreeNames(this, repoId, incoming) + } + + mergeRetiredWorktreeNamesForNamespace(namespaceKey: string, names: Iterable): boolean { + if (!namespaceKey) { + return false + } + const normalized = new Set() + for (const name of names) { + const candidate = normalizeRetirableGeneratedName(name) + if (candidate) { + normalized.add(candidate) + } + } + const next = addRetiredNames( + this.getRetiredWorktreeNameRegistryForNamespace(namespaceKey), + normalized + ) + if (!next) { + return false + } + this[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByNamespace ??= {} + recordRetirementNamespaceRegistry( + this[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByNamespace, + namespaceKey, + next + ) + scheduleSave(this[retiredWorktreeNamePersistenceContext].scheduling) + return true + } +} + +export function applyRetiredWorktreeNames( + owner: RetiredWorktreeNamePersistence, + repoId: string, + names: Iterable +): boolean { + const next = addRetiredNames(owner.getRetiredWorktreeNameRegistry(repoId), names) + if (!next) { + return false + } + owner[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByRepo ??= {} + owner[retiredWorktreeNamePersistenceContext].runtime.state.retiredWorktreeNamesByRepo[repoId] = + next + scheduleSave(owner[retiredWorktreeNamePersistenceContext].scheduling) + return true +} + +export function installRetiredWorktreeNamePersistenceContext( + target: object, + source: RetiredWorktreeNamePersistence +): void { + Object.defineProperty(target, retiredWorktreeNamePersistenceContext, { + value: source[retiredWorktreeNamePersistenceContext] + }) +} diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts new file mode 100644 index 00000000000..53c7ed5ea06 --- /dev/null +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -0,0 +1,190 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' +import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { readTerminalScrollbackSnapshotSync } from '../../terminal-scrollback-snapshots' +import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' +import { + removeWorkspaceSessionOwner, + workspaceSessionPartitionIdsForHost +} from '../restoring-sessions/session-owner-removal' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type SessionHostPartitionOperationsRuntime = Pick< + StoreRuntimeState, + 'state' | 'terminalScrollbackSnapshotStorage' +> + +const sessionHostPartitionOperationsContext = Symbol('SessionHostPartitionOperations') +type SessionHostPartitionOperationsContext = { + runtime: SessionHostPartitionOperationsRuntime + scheduling: WriteSchedulingOperations +} + +export class SessionHostPartitionOperations { + readonly [sessionHostPartitionOperationsContext]: SessionHostPartitionOperationsContext + + constructor( + runtime: SessionHostPartitionOperationsRuntime, + scheduling: WriteSchedulingOperations + ) { + this[sessionHostPartitionOperationsContext] = { runtime, scheduling } + } + + getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] { + const resolved = resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + return ( + this[sessionHostPartitionOperationsContext].runtime.state.workspaceSession ?? + getDefaultWorkspaceSession() + ) + } + return ( + this[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[ + resolved + ] ?? getDefaultWorkspaceSession() + ) + } + + getWorkspaceSessionHostIds(): ExecutionHostId[] { + const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) + for (const key of Object.keys( + this[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId ?? {} + )) { + const hostId = normalizeExecutionHostId(key) + if (hostId) { + hostIds.add(hostId) + } + } + return [...hostIds] + } + + readTerminalScrollbackSnapshot(ref: string): string | null { + return readTerminalScrollbackSnapshotSync( + ref, + this[sessionHostPartitionOperationsContext].runtime.terminalScrollbackSnapshotStorage + ) + } + + getWorktreeIdForTab(tabId: string): string | undefined { + return findWorktreeIdForTab(this.getWorkspaceSession(), tabId) + } + + removeWorkspaceSessionStateForWorktree( + worktreeId: string, + hostId?: ExecutionHostId | null, + options: { advanceTerminalTopologyRevision?: boolean } = {} + ): void { + for (const resolved of workspaceSessionPartitionIdsForHost(hostId)) { + removeWorkspaceSessionOwnerInPartition(this, worktreeId, resolved, options) + } + } +} + +export function resolveHostId(hostId?: string | null): ExecutionHostId { + return normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID +} + +export function hasPersistedWorkspaceSession( + owner: SessionHostPartitionOperations, + hostId: ExecutionHostId +): boolean { + return ( + hostId === LOCAL_EXECUTION_HOST_ID || + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[ + hostId + ] !== undefined + ) +} + +export function removeWorkspaceSessionOwnerInPartition( + owner: SessionHostPartitionOperations, + worktreeId: string, + resolved: ExecutionHostId, + options: { advanceTerminalTopologyRevision?: boolean } +): void { + if (!hasPersistedWorkspaceSession(owner, resolved)) { + return + } + const current = owner.getWorkspaceSession(resolved) + const session = removeWorkspaceSessionOwner(current, worktreeId, { + advanceTerminalTopologyRevision: options.advanceTerminalTopologyRevision ?? true + }) + if (!session) { + return + } + if (resolved === LOCAL_EXECUTION_HOST_ID) { + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSession = session + } else { + // Host scoping matters because identical repo/path ids may exist on two servers. + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId, + [resolved]: session + } + } + scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling) +} + +export function partitionOwnsWorktreeTabs( + owner: SessionHostPartitionOperations, + worktreeId: string, + hostId: ExecutionHostId +): boolean { + return owner.getWorkspaceSession(hostId).tabsByWorktree?.[worktreeId] !== undefined +} + +export function partitionHasOtherRepoWorktreeTabs( + owner: SessionHostPartitionOperations, + worktreeId: string, + hostId: ExecutionHostId +): boolean { + const repoId = getRepoIdFromWorktreeId(worktreeId) + const tabsByWorktree = owner.getWorkspaceSession(hostId).tabsByWorktree ?? {} + return Object.entries(tabsByWorktree).some( + ([id, tabs]) => + id !== worktreeId && getRepoIdFromWorktreeId(id) === repoId && (tabs?.length ?? 0) > 0 + ) +} + +export function setHostWorkspaceSession( + owner: SessionHostPartitionOperations, + hostId: ExecutionHostId, + session: WorkspaceSessionState +): void { + // Why: each partition owns its topology fence; renderer writes omit it and must rebase locally. + session = sanitizeWorkspaceSessionTerminalRetirements( + session, + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[hostId] + ) + const pruned = pruneWorkspaceSessionBrowserHistory( + pruneLocalTerminalScrollbackBuffers( + session, + owner[sessionHostPartitionOperationsContext].runtime.state.repos + ) + ) + owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...owner[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId, + [hostId]: pruned + } + scheduleSave(owner[sessionHostPartitionOperationsContext].scheduling) +} + +export function installSessionHostPartitionOperationsContext( + target: object, + source: SessionHostPartitionOperations +): void { + Object.defineProperty(target, sessionHostPartitionOperationsContext, { + value: source[sessionHostPartitionOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/session-snapshot-operations.ts b/src/main/persistence/loading-store/session-snapshot-operations.ts new file mode 100644 index 00000000000..2f5f1c64b86 --- /dev/null +++ b/src/main/persistence/loading-store/session-snapshot-operations.ts @@ -0,0 +1,102 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' + +import { workspaceSessionPatchNeedsFullNormalization } from './terminal-session-cleanup' + +import { setLocalWorkspaceSession } from './workspace-session-snapshot-publication' +import type { StoreRuntimeState } from './store-runtime-state' +import type { SessionHostPartitionOperations } from './session-host-partitions' +import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' +import type { WriteSchedulingOperations } from './write-scheduling' +import { resolveHostId, setHostWorkspaceSession } from './session-host-partitions' +import { scheduleSave } from './write-scheduling' + +type SessionSnapshotOperationsRuntime = Pick< + StoreRuntimeState, + 'pendingSnapshotFileWork' | 'state' | 'terminalScrollbackSnapshotStorage' +> + +const sessionSnapshotOperationsContext = Symbol('SessionSnapshotOperations') +type SessionSnapshotOperationsContext = { + runtime: SessionSnapshotOperationsRuntime + sessions: SessionHostPartitionOperations + bindingRecovery: TerminalBindingRecoveryOperations + scheduling: WriteSchedulingOperations +} + +export class SessionSnapshotOperations { + readonly [sessionSnapshotOperationsContext]: SessionSnapshotOperationsContext + + constructor( + runtime: SessionSnapshotOperationsRuntime, + sessions: SessionHostPartitionOperations, + bindingRecovery: TerminalBindingRecoveryOperations, + scheduling: WriteSchedulingOperations + ) { + this[sessionSnapshotOperationsContext] = { runtime, sessions, bindingRecovery, scheduling } + } + + setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { + const resolved = resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + setLocalWorkspaceSession(this, session) + return + } + setHostWorkspaceSession(this[sessionSnapshotOperationsContext].sessions, resolved, session) + } + + stageWorkspaceSessionBeforeUnload( + session: PersistedState['workspaceSession'], + hostId?: string | null + ): void { + const resolved = resolveHostId(hostId) + if (resolved === LOCAL_EXECUTION_HOST_ID) { + setLocalWorkspaceSession(this, session, true) + return + } + setHostWorkspaceSession(this[sessionSnapshotOperationsContext].sessions, resolved, session) + } + + patchWorkspaceSession(patch: WorkspaceSessionPatch, hostId?: string | null): void { + const resolved = resolveHostId(hostId) + // Why: the debounced hot path sends only changed slices; scalar/UI patches skip terminal normalization, topology patches keep stale-PTY protections. + let next: WorkspaceSessionState = { + ...this[sessionSnapshotOperationsContext].sessions.getWorkspaceSession(resolved), + ...patch + } + if (workspaceSessionPatchNeedsFullNormalization(patch)) { + this.setWorkspaceSession(next, resolved) + return + } + if (Object.hasOwn(patch, 'browserUrlHistory')) { + next = pruneWorkspaceSessionBrowserHistory(next) + } + if (resolved === LOCAL_EXECUTION_HOST_ID) { + this[sessionSnapshotOperationsContext].runtime.state.workspaceSession = next + } else { + this[sessionSnapshotOperationsContext].runtime.state.workspaceSessionsByHostId = { + ...this[sessionSnapshotOperationsContext].runtime.state.workspaceSessionsByHostId, + [resolved]: next + } + } + scheduleSave(this[sessionSnapshotOperationsContext].scheduling) + } +} + +export function getSessionSnapshotOperationsContext(owner: SessionSnapshotOperations) { + return owner[sessionSnapshotOperationsContext] +} + +export function installSessionSnapshotOperationsContext( + target: object, + source: SessionSnapshotOperations +): void { + Object.defineProperty(target, sessionSnapshotOperationsContext, { + value: source[sessionSnapshotOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/sparse-preset-persistence.ts b/src/main/persistence/loading-store/sparse-preset-persistence.ts new file mode 100644 index 00000000000..2de83313ecd --- /dev/null +++ b/src/main/persistence/loading-store/sparse-preset-persistence.ts @@ -0,0 +1,55 @@ +import type { SparsePreset } from '../../../shared/worktree/create-types' +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import { scheduleSave } from './write-scheduling' + +type SparsePresetRuntime = Pick + +const sparsePresetPersistenceContext = Symbol('SparsePresetPersistence') +type SparsePresetPersistenceContext = { + runtime: SparsePresetRuntime + scheduling: WriteSchedulingOperations +} + +export class SparsePresetPersistence { + readonly [sparsePresetPersistenceContext]: SparsePresetPersistenceContext + + constructor(runtime: SparsePresetRuntime, scheduling: WriteSchedulingOperations) { + this[sparsePresetPersistenceContext] = { runtime, scheduling } + } + + getSparsePresets(repoId: string): SparsePreset[] { + return [ + ...(this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[repoId] ?? []) + ].sort((left, right) => left.name.localeCompare(right.name)) + } + + saveSparsePreset(preset: SparsePreset): SparsePreset { + const existing = + this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[preset.repoId] ?? [] + const index = existing.findIndex((entry) => entry.id === preset.id) + this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[preset.repoId] = + index === -1 + ? [...existing, preset] + : existing.map((entry, entryIndex) => (entryIndex === index ? preset : entry)) + scheduleSave(this[sparsePresetPersistenceContext].scheduling) + return preset + } + + removeSparsePreset(repoId: string, presetId: string): void { + const existing = + this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[repoId] ?? [] + this[sparsePresetPersistenceContext].runtime.state.sparsePresetsByRepo[repoId] = + existing.filter((entry) => entry.id !== presetId) + scheduleSave(this[sparsePresetPersistenceContext].scheduling) + } +} + +export function installSparsePresetPersistenceContext( + target: object, + source: SparsePresetPersistence +): void { + Object.defineProperty(target, sparsePresetPersistenceContext, { + value: source[sparsePresetPersistenceContext] + }) +} diff --git a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts new file mode 100644 index 00000000000..e7cea585765 --- /dev/null +++ b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts @@ -0,0 +1,182 @@ +import type { SshPtyConsumerRecovery, SshRemotePtyLease } from '../../../shared/ssh-types' +import { + getSshRemotePtyLeases as getSshRemotePtyLeasesOperation, + markSshRemotePtyLease as markSshRemotePtyLeaseOperation, + markSshRemotePtyLeases as markSshRemotePtyLeasesOperation, + markSshRemotePtyLeasesAsync as markSshRemotePtyLeasesAsyncOperation, + markSshRemotePtyLeasesAttachedAsync as markSshRemotePtyLeasesAttachedAsyncOperation, + markSshRemotePtyLeasesForShutdown as markSshRemotePtyLeasesForShutdownOperation, + removeSshRemotePtyLease as removeSshRemotePtyLeaseOperation, + removeSshRemotePtyLeases as removeSshRemotePtyLeasesOperation, + type SshPtyLeaseOperations, + upsertSshRemotePtyLease as upsertSshRemotePtyLeaseOperation +} from '../leasing-ssh-ptys/ssh-pty-lease-operations' +import { + getSshPtyConsumerRecovery as getSshPtyConsumerRecoveryOperation, + removeSshPtyConsumerRecovery as removeSshPtyConsumerRecoveryOperation, + type SshPtyConsumerRecoveryOperations, + upsertSshPtyConsumerRecovery as upsertSshPtyConsumerRecoveryOperation +} from '../leasing-ssh-ptys/ssh-pty-consumer-recovery' +import { + clearSshRemotePtyBindingsForLeases as clearSshRemotePtyBindingsForLeasesOperation, + clearSshRemotePtyBindingsForTarget as clearSshRemotePtyBindingsForTargetOperation, + type SshPtyBindingCleanupOperations +} from '../leasing-ssh-ptys/ssh-pty-binding-cleanup' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' +import type { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' +import type { WriteSchedulingOperations } from './write-scheduling' +import { flushDurableStateOrThrowAsync } from './write-flush-barriers' +import { scheduleSave } from './write-scheduling' + +type SshLeaseRecoveryOperationsRuntime = Pick + +const sshLeaseRecoveryOperationsContext = Symbol('SshLeaseRecoveryOperations') +type SshLeaseRecoveryOperationsContext = { + runtime: SshLeaseRecoveryOperationsRuntime + flushBarriers: WriteFlushBarrierOperations + bindingRecovery: TerminalBindingRecoveryOperations + scheduling: WriteSchedulingOperations +} + +export class SshLeaseRecoveryOperations { + readonly [sshLeaseRecoveryOperationsContext]: SshLeaseRecoveryOperationsContext + + constructor( + runtime: SshLeaseRecoveryOperationsRuntime, + flushBarriers: WriteFlushBarrierOperations, + bindingRecovery: TerminalBindingRecoveryOperations, + scheduling: WriteSchedulingOperations + ) { + this[sshLeaseRecoveryOperationsContext] = { + runtime, + flushBarriers, + bindingRecovery, + scheduling + } + } + + getSshPtyConsumerRecovery(targetId: string): SshPtyConsumerRecovery | null { + return getSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), targetId) + } + + async upsertSshPtyConsumerRecovery(record: SshPtyConsumerRecovery): Promise { + await upsertSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), record) + } + + async removeSshPtyConsumerRecovery(targetId: string): Promise { + await removeSshPtyConsumerRecoveryOperation(getSshPtyConsumerRecoveryOperations(this), targetId) + } + + getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { + return getSshRemotePtyLeasesOperation( + this[sshLeaseRecoveryOperationsContext].runtime.state, + targetId + ) + } + + upsertSshRemotePtyLease( + lease: Omit & + Partial> + ): void { + upsertSshRemotePtyLeaseOperation(getSshPtyLeaseOperations(this), lease) + } + + markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { + markSshRemotePtyLeasesOperation(getSshPtyLeaseOperations(this), targetId, state) + } + + markSshRemotePtyLeasesForShutdown(targetId: string, state: SshRemotePtyLease['state']): void { + markSshRemotePtyLeasesForShutdownOperation(getSshPtyLeaseOperations(this), targetId, state) + } + + async markSshRemotePtyLeasesAsync( + targetId: string, + state: SshRemotePtyLease['state'] + ): Promise { + await markSshRemotePtyLeasesAsyncOperation(getSshPtyLeaseOperations(this), targetId, state) + } + + async markSshRemotePtyLeasesAttachedAsync( + targetId: string, + ptyIds: readonly string[] + ): Promise { + await markSshRemotePtyLeasesAttachedAsyncOperation( + getSshPtyLeaseOperations(this), + targetId, + ptyIds + ) + } + + markSshRemotePtyLease(targetId: string, ptyId: string, state: SshRemotePtyLease['state']): void { + markSshRemotePtyLeaseOperation(getSshPtyLeaseOperations(this), targetId, ptyId, state) + } + + removeSshRemotePtyLease(targetId: string, ptyId: string): void { + removeSshRemotePtyLeaseOperation(getSshPtyLeaseOperations(this), targetId, ptyId) + } + + removeSshRemotePtyLeases(targetId: string): void { + removeSshRemotePtyLeasesOperation(getSshPtyLeaseOperations(this), targetId) + } +} + +export function getSshPtyConsumerRecoveryOperations( + owner: SshLeaseRecoveryOperations +): SshPtyConsumerRecoveryOperations { + return { + state: owner[sshLeaseRecoveryOperationsContext].runtime.state, + protectedSecrets: owner[sshLeaseRecoveryOperationsContext].runtime.protectedSecrets, + flushDurableStateOrThrowAsync: () => + flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) + } +} + +export function getSshPtyBindingCleanupOperations( + owner: SshLeaseRecoveryOperations +): SshPtyBindingCleanupOperations { + return { + state: owner[sshLeaseRecoveryOperationsContext].runtime.state, + toComparablePtyId: (targetId, ptyId) => + owner[sshLeaseRecoveryOperationsContext].bindingRecovery.getRelayPtyIdForSshLeaseComparison( + targetId, + ptyId + ), + scheduleSave: () => scheduleSave(owner[sshLeaseRecoveryOperationsContext].scheduling) + } +} + +export function getSshPtyLeaseOperations(owner: SshLeaseRecoveryOperations): SshPtyLeaseOperations { + return { + state: owner[sshLeaseRecoveryOperationsContext].runtime.state, + toStoredPtyId: (targetId, ptyId) => + owner[sshLeaseRecoveryOperationsContext].bindingRecovery.getRelayPtyIdForSshLeaseStorage( + targetId, + ptyId + ), + clearBindingsForTarget: (targetId) => + clearSshRemotePtyBindingsForTargetOperation( + getSshPtyBindingCleanupOperations(owner), + targetId + ), + clearBindingsForLeases: (targetId, leases) => + clearSshRemotePtyBindingsForLeasesOperation( + getSshPtyBindingCleanupOperations(owner), + targetId, + leases + ), + flush: () => owner[sshLeaseRecoveryOperationsContext].flushBarriers.flush(), + flushDurableStateOrThrowAsync: () => + flushDurableStateOrThrowAsync(owner[sshLeaseRecoveryOperationsContext].flushBarriers) + } +} + +export function installSshLeaseRecoveryOperationsContext( + target: object, + source: SshLeaseRecoveryOperations +): void { + Object.defineProperty(target, sshLeaseRecoveryOperationsContext, { + value: source[sshLeaseRecoveryOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/ssh-profile-operations.ts b/src/main/persistence/loading-store/ssh-profile-operations.ts new file mode 100644 index 00000000000..5ab7c635f27 --- /dev/null +++ b/src/main/persistence/loading-store/ssh-profile-operations.ts @@ -0,0 +1,142 @@ +import type { RemovedSshTargetTombstone, SshTarget } from '../../../shared/ssh-types' +import { + type SshTargetStateOperations, + addClaudeLivePtySessionId as addClaudeLivePtySessionIdOperation, + addDeletedSshConfigAlias as addDeletedSshConfigAliasOperation, + addRemovedSshTargetTombstone as addRemovedSshTargetTombstoneOperation, + addSshTarget as addSshTargetOperation, + clearDeletedSshConfigAliases as clearDeletedSshConfigAliasesOperation, + getClaudeLivePtySessionIds as getClaudeLivePtySessionIdsOperation, + getDeletedSshConfigAliases as getDeletedSshConfigAliasesOperation, + getRemovedSshTargetTombstones as getRemovedSshTargetTombstonesOperation, + getSshTarget as getSshTargetOperation, + getSshTargets as getSshTargetsOperation, + removeClaudeLivePtySessionId as removeClaudeLivePtySessionIdOperation, + removeDeletedSshConfigAlias as removeDeletedSshConfigAliasOperation, + removeRemovedSshTargetTombstone as removeRemovedSshTargetTombstoneOperation, + removeSshTarget as removeSshTargetOperation, + updateSshTarget as updateSshTargetOperation +} from '../leasing-ssh-ptys/ssh-target-state' +import { + reassignSshTargetId as reassignSshTargetIdOperation, + type SshTargetReassignmentOperations +} from '../leasing-ssh-ptys/ssh-target-reassignment' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { WriteSchedulingOperations } from './write-scheduling' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' +import type { RepoLifecycleOperations } from './repo-lifecycle-operations' +import { syncProjectHostSetupCompatibilityState } from './repo-lifecycle-operations' +import { scheduleSave } from './write-scheduling' + +type SshProfileOperationsRuntime = Pick + +const sshProfileOperationsContext = Symbol('SshProfileOperations') +type SshProfileOperationsContext = { + runtime: SshProfileOperationsRuntime + scheduling: WriteSchedulingOperations + flushBarriers: WriteFlushBarrierOperations + repos: RepoLifecycleOperations +} + +export class SshProfileOperations { + readonly [sshProfileOperationsContext]: SshProfileOperationsContext + + constructor( + runtime: SshProfileOperationsRuntime, + scheduling: WriteSchedulingOperations, + flushBarriers: WriteFlushBarrierOperations, + repos: RepoLifecycleOperations + ) { + this[sshProfileOperationsContext] = { runtime, scheduling, flushBarriers, repos } + } + + getSshTargets(): SshTarget[] { + return getSshTargetsOperation(this[sshProfileOperationsContext].runtime.state) + } + + getSshTarget(id: string): SshTarget | undefined { + return getSshTargetOperation(this[sshProfileOperationsContext].runtime.state, id) + } + + addSshTarget(target: SshTarget): void { + addSshTargetOperation(getSshTargetStateOperations(this), target) + } + + updateSshTarget(id: string, updates: Partial>): SshTarget | null { + return updateSshTargetOperation(getSshTargetStateOperations(this), id, updates) + } + + removeSshTarget(id: string): void { + removeSshTargetOperation(getSshTargetStateOperations(this), id) + } + + getClaudeLivePtySessionIds(): string[] { + return getClaudeLivePtySessionIdsOperation(this[sshProfileOperationsContext].runtime.state) + } + + addClaudeLivePtySessionId(sessionId: string): void { + addClaudeLivePtySessionIdOperation(getSshTargetStateOperations(this), sessionId) + } + + removeClaudeLivePtySessionId(sessionId: string): void { + removeClaudeLivePtySessionIdOperation(getSshTargetStateOperations(this), sessionId) + } + + getDeletedSshConfigAliases(): string[] { + return getDeletedSshConfigAliasesOperation(this[sshProfileOperationsContext].runtime.state) + } + + addDeletedSshConfigAlias(alias: string): void { + addDeletedSshConfigAliasOperation(getSshTargetStateOperations(this), alias) + } + + removeDeletedSshConfigAlias(alias: string): void { + removeDeletedSshConfigAliasOperation(getSshTargetStateOperations(this), alias) + } + + clearDeletedSshConfigAliases(): void { + clearDeletedSshConfigAliasesOperation(getSshTargetStateOperations(this)) + } + + getRemovedSshTargetTombstones(): RemovedSshTargetTombstone[] { + return getRemovedSshTargetTombstonesOperation(this[sshProfileOperationsContext].runtime.state) + } + + addRemovedSshTargetTombstone(tombstone: RemovedSshTargetTombstone): void { + addRemovedSshTargetTombstoneOperation(getSshTargetStateOperations(this), tombstone) + } + + removeRemovedSshTargetTombstone(oldTargetId: string): void { + removeRemovedSshTargetTombstoneOperation(getSshTargetStateOperations(this), oldTargetId) + } + + reassignSshTargetId(oldTargetId: string, newTargetId: string): string[] { + const operations: SshTargetReassignmentOperations = { + state: this[sshProfileOperationsContext].runtime.state, + protectedSecrets: this[sshProfileOperationsContext].runtime.protectedSecrets, + syncProjectHostSetupCompatibilityState: () => + syncProjectHostSetupCompatibilityState(this[sshProfileOperationsContext].repos), + scheduleSave: () => scheduleSave(this[sshProfileOperationsContext].scheduling) + } + return reassignSshTargetIdOperation(operations, oldTargetId, newTargetId) + } +} + +export function getSshTargetStateOperations(owner: SshProfileOperations): SshTargetStateOperations { + return { + state: owner[sshProfileOperationsContext].runtime.state, + protectedSecrets: owner[sshProfileOperationsContext].runtime.protectedSecrets, + scheduleSave: () => scheduleSave(owner[sshProfileOperationsContext].scheduling), + flush: () => owner[sshProfileOperationsContext].flushBarriers.flush() + } +} + +export function installSshProfileOperationsContext( + target: object, + source: SshProfileOperations +): void { + Object.defineProperty(target, sshProfileOperationsContext, { + value: source[sshProfileOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts new file mode 100644 index 00000000000..c9d81f071a5 --- /dev/null +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -0,0 +1,125 @@ +import { createHash, randomUUID } from 'node:crypto' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { collectFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments' +import { + PROTECTED_SECRET_SLOT, + sshPtyOwnerLeaseSecretSlot, + type ProtectedSecretRetentionUpdate +} from '../../protected-secret-persistence' +import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' + +import type { StoreRuntimeState } from './store-runtime-state' + +type StateSerializationSecretHandlingOperationsRuntime = Pick< + StoreRuntimeState, + 'protectedSecrets' | 'state' +> + +export class StateSerializationSecretHandlingOperations { + constructor(private readonly runtime: StateSerializationSecretHandlingOperationsRuntime) {} + + getDurableState(): Omit { + const { githubCache: _memoryOnly, ...durable } = this.runtime.state + return durable + } + + buildStateToSave(): { + payload: string + stateHash: string + protectedSecretUpdates: ProtectedSecretRetentionUpdate[] + } { + // Why sentinels (not a blob/key string match): the substitution must be + // position-exact. A plain search for the ciphertext — or even for a + // `"key":"blob"` token — can be mimicked by user-controlled state (e.g. an + // agentDefaultEnv var named after a secret field, or a value equal to a + // ciphertext), which would substitute the wrong site and let two DISTINCT + // states normalize equal → a silently dropped write (data loss), reachable + // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot + // random UUID can't occur anywhere else in the serialized state (the user + // sets their data before it is minted), so it appears exactly once. + const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = [] + const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] + let protectedStorageDegraded = false + const encryptToSentinel = (slot: string, plaintext: string): string => { + const encrypted = this.runtime.protectedSecrets.encrypt(slot, plaintext) + if (encrypted.retentionUpdate) { + protectedSecretUpdates.push(encrypted.retentionUpdate) + } + protectedStorageDegraded ||= encrypted.degraded + const { blob, hashValue = plaintext } = encrypted + // Values already identical in payload and hash need no sentinel substitution. + if (blob === plaintext && hashValue === plaintext) { + return blob + } + const sentinel = `orca-secret-slot-${randomUUID()}` + secretSubs.push({ sentinel, blob, hashValue }) + return sentinel + } + const encryptOptionalToSentinel = ( + slot: string, + plaintext: string | null | undefined + ): string | null => { + const encrypted = encryptToSentinel(slot, plaintext ?? '') + return encrypted || null + } + // Why: clone before encrypting secrets so in-memory this.state stays plaintext. + const stateToSave = { + ...this.getDurableState(), + // Why both keys unconditionally: the explicit keys always win over the spread, and + // JSON.stringify drops the `undefined` value so a note-free profile gains no key on disk. + // The strip builds a new array here only; this.state records keep their notes in memory. + folderWorkspaces: (this.runtime.state.folderWorkspaces ?? []).map( + ({ diffComments: _relocated, ...rest }) => rest + ), + folderWorkspaceDiffComments: collectFolderWorkspaceDiffComments( + this.runtime.state.folderWorkspaces + ), + sshPtyConsumerRecoveries: (this.runtime.state.sshPtyConsumerRecoveries ?? []).map( + (record) => ({ + ...record, + ownerLease: encryptToSentinel( + sshPtyOwnerLeaseSecretSlot(record.targetId), + record.ownerLease + ) + }) + ), + settings: { + ...stripRetiredGlobalSettings(this.runtime.state.settings), + opencodeSessionCookie: encryptToSentinel( + PROTECTED_SECRET_SLOT.opencodeSessionCookie, + this.runtime.state.settings.opencodeSessionCookie + ), + httpProxyUrl: encryptToSentinel( + PROTECTED_SECRET_SLOT.httpProxyUrl, + this.runtime.state.settings.httpProxyUrl ?? '' + ) + }, + ui: { + ...this.runtime.state.ui, + browserKagiSessionLink: encryptOptionalToSentinel( + PROTECTED_SECRET_SLOT.browserKagiSessionLink, + this.runtime.state.ui.browserKagiSessionLink + ) + } + } + // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. + // One full-state stringify; secret slots currently hold sentinels. + const serialized = JSON.stringify(stateToSave) + // Substitute each unique sentinel exactly once: ciphertext for the on-disk + // payload, a stable normalized value for the guard hash. Function-form + // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped + // in `serialized`, so each replace is byte-for-byte position-exact. + let payload = serialized + let hashInput = serialized + for (const { sentinel, blob, hashValue } of secretSubs) { + const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) + payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) + hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) + } + const stateHash = createHash('sha1') + .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '') + .update(hashInput) + .digest('hex') + return { payload, stateHash, protectedSecretUpdates } + } +} diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts new file mode 100644 index 00000000000..425ee2f1745 --- /dev/null +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -0,0 +1,187 @@ +import type { StoreRuntimeState } from './store-runtime-state' +import { LoadedStateAdaptationOperations } from './loaded-state-adaptation' +import { BackupRecoveryRotationOperations } from './backup-recovery-rotation' +import { LoadedCohortMigrationOperations } from './loaded-cohort-migrations' +import { LoadedStateParsingOperations } from './loaded-state-parsing' +import { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' +import { + PrimaryStateWriteOperations, + installPrimaryStateWriteOperationsContext +} from './primary-state-writes' +import { + WriteSchedulingOperations, + installWriteSchedulingOperationsContext +} from './write-scheduling' +import { + WriteFlushBarrierOperations, + installWriteFlushBarrierOperationsContext +} from './write-flush-barriers' +import { ProfilePreferences, installProfilePreferencesContext } from './profile-preferences' +import { + RepoLifecycleOperations, + installRepoLifecycleOperationsContext +} from './repo-lifecycle-operations' +import { TerminalBindingRecoveryOperations } from './terminal-binding-recovery' +import { + SessionHostPartitionOperations, + installSessionHostPartitionOperationsContext +} from './session-host-partitions' +import { + SessionSnapshotOperations, + installSessionSnapshotOperationsContext +} from './session-snapshot-operations' +import { + MetadataLineageOperations, + installMetadataLineageOperationsContext +} from './metadata-lineage-operations' +import { + ProjectCollectionOperations, + installProjectCollectionOperationsContext +} from './project-collection-operations' +import { + AutomationPersistence, + installAutomationPersistenceContext +} from './automation-persistence' +import { + MobileTabSelectionPersistence, + installMobileTabSelectionPersistenceContext +} from './mobile-tab-selection-persistence' +import { + SparsePresetPersistence, + installSparsePresetPersistenceContext +} from './sparse-preset-persistence' +import { + PtyBindingPersistenceOperations, + installPtyBindingPersistenceOperationsContext +} from './pty-binding-persistence' +import { SshProfileOperations, installSshProfileOperationsContext } from './ssh-profile-operations' +import { + RetiredWorktreeNamePersistence, + installRetiredWorktreeNamePersistenceContext +} from './retired-worktree-name-persistence' +import { + SshLeaseRecoveryOperations, + installSshLeaseRecoveryOperationsContext +} from './ssh-lease-recovery-operations' + +export type StoreDomains = { + adaptation: LoadedStateAdaptationOperations + backups: BackupRecoveryRotationOperations + cohorts: LoadedCohortMigrationOperations + loader: LoadedStateParsingOperations + serialization: StateSerializationSecretHandlingOperations + writes: PrimaryStateWriteOperations + scheduling: WriteSchedulingOperations + flushBarriers: WriteFlushBarrierOperations + preferences: ProfilePreferences + repos: RepoLifecycleOperations + bindingRecovery: TerminalBindingRecoveryOperations + sessions: SessionHostPartitionOperations + sessionSnapshots: SessionSnapshotOperations + metadata: MetadataLineageOperations + projects: ProjectCollectionOperations + automations: AutomationPersistence + mobileTabSelections: MobileTabSelectionPersistence + sparsePresets: SparsePresetPersistence + ptyBindings: PtyBindingPersistenceOperations + sshProfiles: SshProfileOperations + retiredWorktreeNames: RetiredWorktreeNamePersistence + sshLeases: SshLeaseRecoveryOperations +} + +export const STORE_DOMAIN_OPERATION_CLASSES = [ + WriteSchedulingOperations, + PrimaryStateWriteOperations, + ProjectCollectionOperations, + RepoLifecycleOperations, + MobileTabSelectionPersistence, + SparsePresetPersistence, + AutomationPersistence, + MetadataLineageOperations, + ProfilePreferences, + SessionHostPartitionOperations, + SessionSnapshotOperations, + PtyBindingPersistenceOperations, + SshProfileOperations, + RetiredWorktreeNamePersistence, + SshLeaseRecoveryOperations, + WriteFlushBarrierOperations +] as const + +export function installStoreDomainContexts(target: object, domains: StoreDomains): void { + installWriteSchedulingOperationsContext(target, domains.scheduling) + installPrimaryStateWriteOperationsContext(target, domains.writes) + installProjectCollectionOperationsContext(target, domains.projects) + installRepoLifecycleOperationsContext(target, domains.repos) + installMobileTabSelectionPersistenceContext(target, domains.mobileTabSelections) + installSparsePresetPersistenceContext(target, domains.sparsePresets) + installAutomationPersistenceContext(target, domains.automations) + installMetadataLineageOperationsContext(target, domains.metadata) + installProfilePreferencesContext(target, domains.preferences) + installSessionHostPartitionOperationsContext(target, domains.sessions) + installSessionSnapshotOperationsContext(target, domains.sessionSnapshots) + installPtyBindingPersistenceOperationsContext(target, domains.ptyBindings) + installSshProfileOperationsContext(target, domains.sshProfiles) + installRetiredWorktreeNamePersistenceContext(target, domains.retiredWorktreeNames) + installSshLeaseRecoveryOperationsContext(target, domains.sshLeases) + installWriteFlushBarrierOperationsContext(target, domains.flushBarriers) +} + +export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { + const adaptation = new LoadedStateAdaptationOperations(runtime) + const backups = new BackupRecoveryRotationOperations(runtime) + const cohorts = new LoadedCohortMigrationOperations(runtime) + const loader = new LoadedStateParsingOperations(runtime, backups, cohorts) + const serialization = new StateSerializationSecretHandlingOperations(runtime) + const writes = new PrimaryStateWriteOperations(runtime, serialization, backups) + const scheduling = new WriteSchedulingOperations(runtime, writes) + const flushBarriers = new WriteFlushBarrierOperations(runtime, writes) + const preferences = new ProfilePreferences(runtime, scheduling) + const repos = new RepoLifecycleOperations(runtime, scheduling) + const bindingRecovery = new TerminalBindingRecoveryOperations(runtime) + const sessions = new SessionHostPartitionOperations(runtime, scheduling) + const sessionSnapshots = new SessionSnapshotOperations( + runtime, + sessions, + bindingRecovery, + scheduling + ) + const metadata = new MetadataLineageOperations(runtime, scheduling, sessions) + const projects = new ProjectCollectionOperations(runtime, repos, scheduling, metadata) + const automations = new AutomationPersistence(runtime, flushBarriers, preferences) + const mobileTabSelections = new MobileTabSelectionPersistence(runtime, scheduling) + const sparsePresets = new SparsePresetPersistence(runtime, scheduling) + const ptyBindings = new PtyBindingPersistenceOperations(runtime, sessions) + const sshProfiles = new SshProfileOperations(runtime, scheduling, flushBarriers, repos) + const retiredWorktreeNames = new RetiredWorktreeNamePersistence(runtime, scheduling) + const sshLeases = new SshLeaseRecoveryOperations( + runtime, + flushBarriers, + bindingRecovery, + scheduling + ) + return { + adaptation, + backups, + cohorts, + loader, + serialization, + writes, + scheduling, + flushBarriers, + preferences, + repos, + bindingRecovery, + sessions, + sessionSnapshots, + metadata, + projects, + automations, + mobileTabSelections, + sparsePresets, + ptyBindings, + sshProfiles, + retiredWorktreeNames, + sshLeases + } +} diff --git a/src/main/persistence/loading-store/store-owned-state.ts b/src/main/persistence/loading-store/store-owned-state.ts deleted file mode 100644 index 9c752b06f92..00000000000 --- a/src/main/persistence/loading-store/store-owned-state.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { PersistedState } from '../../../shared/persisted-state-types' - -/** - * The Store's live state object, never a copy. Extracted operations read and mutate it in place, so a caller that - * passes a clone or a spread silently loses every write. The Store stays its sole owner and is the only thing that - * may hand this reference out. - */ -export type StoreOwnedPersistedState = PersistedState diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts new file mode 100644 index 00000000000..284f556ae41 --- /dev/null +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -0,0 +1,79 @@ +import { removeStaleDurableWriteTempFiles } from '../../durable-file-write' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { ActiveViewPreference } from '../../active-view-preference' +import { + getProfileTerminalScrollbackSnapshotRoot, + type TerminalScrollbackSnapshotStorage +} from '../../terminal-scrollback-snapshots' +import { ProtectedSecretPersistence } from '../../protected-secret-persistence' +import { getDataFile, getGithubCacheFile } from './user-data-path' +import { STALE_DURABLE_WRITE_TEMP_AGE_MS } from '../tracking-repos/worktree-metadata-normalization' +import type { ProjectGroupPersistenceOperations } from '../tracking-repos/project-group-operations' +import type { FolderWorkspacePersistenceOperations } from '../restoring-sessions/folder-workspace-operations' +import type { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations' +import type { ProjectHostPersistenceOperations } from '../tracking-repos/project-host-operations' +import type { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' +import type { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' + +export type StoreRuntimeOptions = { dataFile?: string } + +/** Mutable coordination state shared only with this Store's private collaborators. */ +export class StoreRuntimeState { + state!: PersistedState + readonly dataFile: string + activeViewPreference!: ActiveViewPreference + readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage + writeTimer: ReturnType | null = null + pendingWrite: Promise | null = null + pendingSnapshotFileWork: Promise | null = null + readonly staleTempCleanup: Promise + writeGeneration = 0 + inFlightAsyncTmpFile: string | null = null + backupRotationInFlight = false + writesFrozen = false + quitFlushStarted = false + quitFlushPromise: Promise | null = null + lastWrittenStateHash: string | null = null + lastDurableWriteGeneration = -1 + firstPendingSaveAt: number | null = null + githubCacheDirty = false + githubCacheGeneration = 0 + pendingGithubCacheWrite: Promise | null = null + readonly staleGithubCacheTempCleanup: Promise + readonly gitUsernameCache = new Map() + readonly protectedSecrets = new ProtectedSecretPersistence() + loadNeedsSave = false + flushOrThrow!: () => void + settingsChangeListeners = new Set< + ( + updates: Partial, + settings: GlobalSettings, + originWebContentsId?: number + ) => void + >() + uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>() + projectHostOperations: ProjectHostPersistenceOperations | null = null + projectGroupOperations: ProjectGroupPersistenceOperations | null = null + folderWorkspaceOperations: FolderWorkspacePersistenceOperations | null = null + repoOrderOperations: RepoOrderPersistenceOperations | null = null + repoUpdateOperations: RepoUpdatePersistenceOperations | null = null + projectHostSetupOperations: ProjectHostSetupPersistenceOperations | null = null + + constructor(options: StoreRuntimeOptions = {}) { + this.dataFile = options.dataFile ?? getDataFile() + this.staleTempCleanup = removeStaleDurableWriteTempFiles(this.dataFile, { + minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS + }) + this.staleGithubCacheTempCleanup = removeStaleDurableWriteTempFiles( + getGithubCacheFile(this.dataFile), + { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS } + ) + const profileSnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(this.dataFile) + const legacySnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(getDataFile()) + this.terminalScrollbackSnapshotStorage = { + snapshotRoot: profileSnapshotRoot, + fallbackSnapshotRoot: legacySnapshotRoot === profileSnapshotRoot ? null : legacySnapshotRoot + } + } +} diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index b977f40074e..c66615a03df 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -1,505 +1,39 @@ -/* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration, and load/save/flush in one file so the storage contract reviews as a unit. */ -import { - readFileSync, - writeFileSync, - mkdirSync, - existsSync, - renameSync, - unlinkSync, - copyFileSync, - statSync -} from 'node:fs' -import { rename, mkdir, rm, copyFile, open, stat, access, writeFile } from 'node:fs/promises' -import { - durableWriteTempPath, - removeStaleDurableWriteTempFiles, - renameDurable, - writeFileDurableSync -} from '../../durable-file-write' import { dirname } from 'node:path' -import { homedir } from 'node:os' -import { createHash, randomUUID } from 'node:crypto' -import type { - Automation, - AutomationCreateInput, - AutomationDispatchResult, - AutomationRun, - AutomationRunTrigger, - AutomationUpdateInput -} from '../../../shared/automations-types' -import { normalizeProxyUrl } from '../../../shared/network-proxy' -import { normalizeKagiSessionLink } from '../../../shared/browser-url' -import type { FolderWorkspace, WorkspaceKey } from '../../../shared/folder-workspace-types' -import type { GlobalSettings } from '../../../shared/global-settings-types' -import type { OnboardingChecklistState } from '../../../shared/onboarding-state-types' -import type { - PersistedMobileClientTabSelections, - PersistedState -} from '../../../shared/persisted-state-types' -import type { ProjectGroup } from '../../../shared/project-group-types' -import type { - Project, - ProjectHostSetup, - ProjectHostSetupCreateArgs, - ProjectHostSetupCreateResult, - ProjectHostSetupDeleteArgs, - ProjectHostSetupDeleteResult, - ProjectHostSetupUpdateArgs, - ProjectHostSetupUpdateResult, - ProjectUpdateArgs -} from '../../../shared/project-types' -import type { Repo } from '../../../shared/repo-types' -import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' -import type { - WorkspaceSessionPatch, - WorkspaceSessionState -} from '../../../shared/workspace-session-state-types' -import type { SparsePreset } from '../../../shared/worktree/create-types' -import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' -import type { WorktreeMeta } from '../../../shared/worktree/meta-types' -import { deriveGlobalWindowsRuntimeDefaultFromLegacySettings } from '../../../shared/project-execution-runtime' -import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-context' -import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' -import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' -import { normalizePersistedMobileClientTabSelections } from '../../runtime/client-session-tab-selection-persistence' -import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' -import { - removeRepoFromHostWorkspaceSessions, - removeRepoFromWorkspaceSession -} from '../../orca-profiles/profile-project-session-state' -import type { - RemovedSshTargetTombstone, - SshPtyConsumerRecovery, - SshRemotePtyLease, - SshTarget -} from '../../../shared/ssh-types' -import { isFolderRepo } from '../../../shared/repo-kind' -import { - getRepoExecutionHostId, - parseExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - normalizeExecutionHostId, - type ExecutionHostId -} from '../../../shared/execution-host' -import { - getDefaultPersistedState, - getDefaultOnboardingState, - getDefaultVoiceSettings, - getDefaultWorkspaceSession, - getWorktreeCardModeProperties, - isDefaultedCompactWorktreeCardProperties, - normalizeWorktreeCardProperties -} from '../../../shared/constants' -import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' -import { isExistingPersistedProfile } from '../../../shared/project-order-manual-default-notice' -import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../../../shared/usage-percentage-display-change-notice' -import { normalizePRBotAuthorOverrides } from '../../../shared/pr-bot-author-overrides' -import { toRelaySshPtyId } from '../../providers/ssh-pty-id' -import { isTerminalLeafId } from '../../../shared/stable-pane-id' import { setMigrationUnsupportedPty, setMigrationUnsupportedPtyPersistenceListener } from '../../agent-hooks/migration-unsupported-pty-state' import { agentHookServer } from '../../agent-hooks/server' -import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' -import { - backfillAutomationRunNumbers, - pruneAutomationRuns -} from '../../../shared/automation-run-retention' -import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' -import { normalizeRetirableGeneratedName } from '../../worktree-name-retirement' -import { recordRetirementNamespaceRegistry } from '../../worktree-retirement-namespace' -import { - addRetiredNames, - clampExhaustedTiers, - compactRetiredNames, - EMPTY_RETIRED_NAME_REGISTRY, - isEmptyRetiredNameRegistry, - type RetiredNameRegistry -} from '../../../shared/worktree/retired-name-registry' -import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId } from '../../../shared/worktree/id' -import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner' -import { isPathInsideOrEqual } from '../../../shared/cross-platform-path' -import { normalizeTerminalQuickCommands } from '../../../shared/terminal-quick-commands' -import { normalizeTaskProviderSettings } from '../../../shared/task-providers' -import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings' -import { - addMobilePairingCustomAddress, - normalizeMobilePairingCustomAddress, - normalizeMobilePairingCustomAddresses -} from '../../../shared/mobile-pairing-custom-address' -import { normalizeOpenInApplications } from '../../../shared/open-in-applications' -import { normalizeTerminalShortcutPolicy } from '../../../shared/keybindings' -import { normalizeSourceControlGroupOrder } from '../../../shared/source-control-group-order' -import { normalizeAppIconId } from '../../../shared/app-icon' -import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' -import { - normalizeFeatureInteractionTelemetryBuckets, - type FeatureInteractionId -} from '../../../shared/feature-interactions' -import { - parseCodexResetCreditAttemptLedger, - type CodexResetCreditAttemptLedger -} from '../../../shared/codex-reset-credit-attempt-ledger' -import { - DEFAULT_WORKSPACE_STATUS_ID, - normalizePersistedWorkspaceStatuses -} from '../../../shared/workspace-statuses' -import { migrateExternalWorktreeVisibilityDefaults } from '../../../shared/external-worktree-visibility' -import { - clearMissingProjectGroupMemberships, - createProjectGroup, - normalizeProjectGroups -} from '../../../shared/project-groups' -import { createNestedProjectGroupResolver } from '../../project-groups/nested-repo-import' -import { - mergeLegacyCommitMessageAiIntoSourceControlAi, - projectSourceControlAiToLegacyCommitMessageAi, - sourceControlAiSettingsFromLegacy -} from '../../../shared/source-control-ai' -import { normalizeDisabledTuiAgents } from '../../../shared/tui-agent-selection' -import { hasUnsupportedTuiAgentArgs } from '../../../shared/tui-agent-launch-defaults' -import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' -import { - normalizeOsc52ClipboardDefaultOn, - osc52ClipboardDefaultOnOverridesPersistedOff -} from '../../../shared/osc52-clipboard-settings' -import { normalizeTerminalLineHeight } from '../../../shared/terminal-line-height-settings' -import { normalizeUiLanguage } from '../../../shared/ui-language' import { ActiveViewPreference } from '../../active-view-preference' +import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-normalization' +import { normalizePersistedPaneIdentityState } from '../restoring-sessions/workspace-pane-normalization' +import { StoreRuntimeState } from './store-runtime-state' import { - collectFolderWorkspaceDiffComments, - normalizeFolderWorkspaceDiffComments -} from '../../folder-workspace-diff-comments' -import { normalizeFolderWorkspaces } from '../../../shared/folder-workspaces' -import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' -import { - collectTerminalScrollbackSnapshotRefs, - deleteTerminalScrollbackSnapshotSync, - getProfileTerminalScrollbackSnapshotRoot, - migrateWorkspaceSessionTerminalScrollbackSnapshots, - readTerminalScrollbackSnapshotSync, - type TerminalScrollbackSnapshotStorage -} from '../../terminal-scrollback-snapshots' -import { - deleteRemovedTerminalScrollbackSnapshotsAsync, - migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync -} from '../../terminal-scrollback-snapshot-async-migration' -import { - isStartupDiagnosticsEnabled, - logStartupDiagnostic -} from '../../startup/startup-diagnostics' -import { - PROTECTED_SECRET_SLOT, - ProtectedSecretPersistence, - sshPtyOwnerLeaseSecretSlot, - type ProtectedSecretRetentionUpdate -} from '../../protected-secret-persistence' -import { - isLegacyOpenCodeSessionCookie, - isLegacySshPtyOwnerLease -} from '../leasing-ssh-ptys/secret-validation' -import { getDataFile, getGithubCacheFile, readGithubCacheSnapshot } from './user-data-path' -import { - STALE_DURABLE_WRITE_TEMP_AGE_MS, - gcStaleWorktreeMeta, - normalizeWorktreeLinkedItemMetadata -} from '../tracking-repos/worktree-metadata-normalization' -import { - migrateAgentYoloDefaults, - migrateTerminalScrollbackRows, - migrateTerminalTuiScrollSensitivityDefault, - stripRetiredGlobalSettings -} from '../applying-settings/terminal-settings-migrations' -import { - normalizeRightSidebarExplorerView, - normalizeRightSidebarTab, - normalizeShowDotfilesByWorktree, - normalizeSortBy -} from '../applying-settings/ui-selection-normalization' -import { - normalizeWorkspaceLineageByChildKey, - stripMainOwnedTelemetryMarkerFromUI -} from '../applying-settings/ui-interaction-merge' -import { - backfillLegacyAutomationContexts, - normalizeAutomationRunWorkspaceDisplayName -} from '../scheduling-automations/automation-context-migration' -import { - normalizeLoadedOnboardingState, - normalizeNotificationSettings, - persistedNotificationSettingsRepaired, - readDeprecatedExperimentFlag, - readLegacySidekickFlag, - resolveSetupGuideSidebarDismissedOnLoad -} from '../applying-settings/onboarding-normalization' -import { - canonicalizePersistedFloatingWorkspaceDirectory, - normalizeFloatingWorkspaceTrustedCwds -} from '../restoring-sessions/floating-workspace-normalization' -import { - ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH, - normalizeSshPtyConsumerRecovery, - normalizeSshRemotePtyLease, - normalizeSshTarget -} from '../leasing-ssh-ptys/ssh-normalization' -import { - type SshTargetStateOperations, - addClaudeLivePtySessionId as addClaudeLivePtySessionIdOperation, - addDeletedSshConfigAlias as addDeletedSshConfigAliasOperation, - addRemovedSshTargetTombstone as addRemovedSshTargetTombstoneOperation, - addSshTarget as addSshTargetOperation, - clearDeletedSshConfigAliases as clearDeletedSshConfigAliasesOperation, - getClaudeLivePtySessionIds as getClaudeLivePtySessionIdsOperation, - getDeletedSshConfigAliases as getDeletedSshConfigAliasesOperation, - getRemovedSshTargetTombstones as getRemovedSshTargetTombstonesOperation, - getSshTarget as getSshTargetOperation, - getSshTargets as getSshTargetsOperation, - removeClaudeLivePtySessionId as removeClaudeLivePtySessionIdOperation, - removeDeletedSshConfigAlias as removeDeletedSshConfigAliasOperation, - removeRemovedSshTargetTombstone as removeRemovedSshTargetTombstoneOperation, - removeSshTarget as removeSshTargetOperation, - updateSshTarget as updateSshTargetOperation -} from '../leasing-ssh-ptys/ssh-target-state' -import { - reassignSshTargetId as reassignSshTargetIdOperation, - type SshTargetReassignmentOperations -} from '../leasing-ssh-ptys/ssh-target-reassignment' -import { - getSshRemotePtyLeases as getSshRemotePtyLeasesOperation, - markSshRemotePtyLease as markSshRemotePtyLeaseOperation, - markSshRemotePtyLeases as markSshRemotePtyLeasesOperation, - markSshRemotePtyLeasesAsync as markSshRemotePtyLeasesAsyncOperation, - markSshRemotePtyLeasesAttachedAsync as markSshRemotePtyLeasesAttachedAsyncOperation, - markSshRemotePtyLeasesForShutdown as markSshRemotePtyLeasesForShutdownOperation, - removeSshRemotePtyLease as removeSshRemotePtyLeaseOperation, - removeSshRemotePtyLeases as removeSshRemotePtyLeasesOperation, - type SshPtyLeaseOperations, - upsertSshRemotePtyLease as upsertSshRemotePtyLeaseOperation -} from '../leasing-ssh-ptys/ssh-pty-lease-operations' -import { - getSshPtyConsumerRecovery as getSshPtyConsumerRecoveryOperation, - removeSshPtyConsumerRecovery as removeSshPtyConsumerRecoveryOperation, - type SshPtyConsumerRecoveryOperations, - upsertSshPtyConsumerRecovery as upsertSshPtyConsumerRecoveryOperation -} from '../leasing-ssh-ptys/ssh-pty-consumer-recovery' -import { - clearSshRemotePtyBindingsForLeases as clearSshRemotePtyBindingsForLeasesOperation, - clearSshRemotePtyBindingsForTarget as clearSshRemotePtyBindingsForTargetOperation, - type SshPtyBindingCleanupOperations -} from '../leasing-ssh-ptys/ssh-pty-binding-cleanup' -import { - cloneLayoutNode, - layoutContainsLeafId, - preserveMissingLeafRecordEntries -} from '../restoring-sessions/terminal-layout-normalization' -import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' -import { - normalizeClaudeLivePtySessionIds, - normalizeLegacyPaneKeyAliasEntries, - normalizeMigrationUnsupportedPtyEntries, - registerPersistedPaneKeyAlias -} from '../restoring-sessions/pane-alias-normalization' -import { - normalizePersistedPaneIdentityState, - normalizeWorkspaceSessionPaneIdentities, - remapAcknowledgedAgentPaneKeys, - remapSshRemotePtyLeaseLeafIds, - type WorkspaceSessionPaneIdentityRemap -} from '../restoring-sessions/workspace-pane-normalization' -import { - mergeProjectHostSetupCompatibilityState, - projectHostSetupCompatibilityStateEqual -} from '../tracking-repos/project-host-compatibility' -import { - cloneWorkspaceSessionState, - createMinimalPersistedTerminalTab -} from '../restoring-sessions/session-owner-fields' -import { - removeWorkspaceSessionOwner, - workspaceSessionOwnerPartitionForHost, - workspaceSessionPartitionIdsForHost -} from '../restoring-sessions/session-owner-removal' -import { backfillFolderScopeConnectionIds } from '../restoring-sessions/folder-scope-migration' -import { - createAutomation as createAutomationOperation, - deleteAutomation as deleteAutomationOperation, - listAutomations as listAutomationsOperation, - updateAutomation as updateAutomationOperation, - type AutomationDefinitionOperations -} from '../scheduling-automations/automation-definition-operations' -import { - createAutomationRun as createAutomationRunOperation, - listAutomationRuns as listAutomationRunsOperation, - snapshotAutomationRunWorkspaceDisplayName as snapshotAutomationRunWorkspaceDisplayNameOperation, - updateAutomationRun as updateAutomationRunOperation, - type AutomationRunOperations -} from '../scheduling-automations/automation-run-operations' -import { - advanceAutomationNextRun as advanceAutomationNextRunOperation, - getLatestAutomationOccurrence as getLatestAutomationOccurrenceOperation -} from '../scheduling-automations/automation-schedule-operations' -import { migrateWorktreeIdentity as migrateWorktreeIdentityOperation } from '../tracking-repos/worktree-identity-migration' -import { - updateSettings as updateSettingsOperation, - type SettingsMutationOperations -} from '../applying-settings/settings-update' -import { getPersistedUI } from '../applying-settings/ui-state-read' -import { updatePersistedUI, type UIUpdateOperations } from '../applying-settings/ui-state-update' -import { ProjectGroupPersistenceOperations } from '../tracking-repos/project-group-operations' -import { FolderWorkspacePersistenceOperations } from '../restoring-sessions/folder-workspace-operations' -import { RepoOrderPersistenceOperations } from '../tracking-repos/repo-order-operations' -import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from '../tracking-repos/repo-worktree-pruning' -import { ProjectHostPersistenceOperations } from '../tracking-repos/project-host-operations' -import { - recordFeatureInteraction as recordFeatureInteractionOperation, - type FeatureInteractionOperations -} from '../applying-settings/feature-interaction-recording' -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' - -// Why (issue #1158): keep 5 rolling backups at >=1h spacing so a corrupt/empty write leaves an earlier copy recoverable. -const BACKUP_COUNT = 5 -const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000 -const WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS = new Set([ - 'tabsByWorktree', - 'terminalLayoutsByTabId' -]) - -function logPersistenceStartupMilestone( - event: string, - details: Record = {} -): void { - if (isStartupDiagnosticsEnabled()) { - logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) - } -} - -function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatch): boolean { - return Object.keys(patch).some((key) => - WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS.has(key as keyof WorkspaceSessionState) - ) -} - -function workspaceSessionSalvageLogDetails(result: { - droppedCount: number - droppedPaths: string[] -}): { count: number; fields: string[]; detailsTruncated: boolean } { - return { - count: result.droppedCount, - fields: [...new Set(result.droppedPaths.map((path) => path.split('.', 1)[0]))], - detailsTruncated: result.droppedCount > result.droppedPaths.length - } -} - -/** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge. - * Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */ -function parseWorkspaceSessionsByHostId( - raw: unknown, - defaults: WorkspaceSessionState -): { partitions: Partial>; repaired: boolean } { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return { partitions: {}, repaired: raw !== undefined } - } - let repaired = false - const partitions: Partial> = {} - for (const [key, value] of Object.entries(raw as Record)) { - const hostId = normalizeExecutionHostId(key) - // Why: 'local' lives in workspaceSession; a local/invalid key here is legacy noise that must not shadow the canonical partition. - if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) { - continue - } - const result = parseWorkspaceSessionSalvaging(value) - if (!result.ok) { - repaired = true - console.error( - `[persistence] Corrupt workspace session for host ${hostId}, using defaults:`, - result.error - ) - continue - } - if (result.droppedCount > 0) { - console.warn( - `[persistence] Salvaged workspace session for host ${hostId}; dropped corrupt entries:`, - workspaceSessionSalvageLogDetails(result) - ) - repaired = true - } - partitions[hostId] = { ...defaults, ...result.value } - } - return { partitions, repaired } -} - -function backupPath(dataFile: string, index: number): string { - return `${dataFile}.bak.${index}` -} - -/** existsSync's non-blocking twin: existsSync is an access(F_OK) probe, so access() is the exact analogue. */ -async function exists(path: string): Promise { - return access(path).then( - () => true, - () => false - ) -} - -function normalizeRetiredNameRegistry(row: unknown): RetiredNameRegistry { - const isPlainArray = Array.isArray(row) - const rawRow = row as { exhaustedTiers?: unknown; names?: unknown } | null | undefined - const rawNames = isPlainArray ? row : Array.isArray(rawRow?.names) ? rawRow.names : [] - const names = new Set() - for (const entry of rawNames) { - if (typeof entry !== 'string') { - continue - } - const normalized = normalizeRetirableGeneratedName(entry) - if (normalized) { - names.add(normalized) - } - } - return compactRetiredNames({ - exhaustedTiers: isPlainArray ? 0 : clampExhaustedTiers(rawRow?.exhaustedTiers), - names: [...names] - }) -} - -function normalizeRetiredNameRegistryMap(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return {} - } - const byRepo: Record = {} - for (const [repoId, row] of Object.entries(value as Record)) { - if (!repoId) { - continue - } - const registry = normalizeRetiredNameRegistry(row) - if (!isEmptyRetiredNameRegistry(registry)) { - byRepo[repoId] = registry - } - } - return byRepo -} - -function deleteRemovedTerminalScrollbackSnapshots( - prior: WorkspaceSessionState | undefined, - next: WorkspaceSessionState, - storage?: TerminalScrollbackSnapshotStorage -): void { - if (!prior) { - return - } - const nextRefs = collectTerminalScrollbackSnapshotRefs(next) - for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) { - if (!nextRefs.has(ref)) { - deleteTerminalScrollbackSnapshotSync(ref, storage) - } - } -} - -export type StoreOptions = { - dataFile?: string -} + createStoreDomains, + installStoreDomainContexts, + STORE_DOMAIN_OPERATION_CLASSES, + type StoreDomains +} from './store-domain-composition' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { scheduleSave } from './write-scheduling' +import type { WriteSchedulingOperations } from './write-scheduling' +import type { PrimaryStateWriteOperations } from './primary-state-writes' +import type { ProjectCollectionOperations } from './project-collection-operations' +import type { RepoLifecycleOperations } from './repo-lifecycle-operations' +import type { MobileTabSelectionPersistence } from './mobile-tab-selection-persistence' +import type { SparsePresetPersistence } from './sparse-preset-persistence' +import type { AutomationPersistence } from './automation-persistence' +import type { MetadataLineageOperations } from './metadata-lineage-operations' +import type { ProfilePreferences } from './profile-preferences' +import type { SessionHostPartitionOperations } from './session-host-partitions' +import type { SessionSnapshotOperations } from './session-snapshot-operations' +import type { PtyBindingPersistenceOperations } from './pty-binding-persistence' +import type { SshProfileOperations } from './ssh-profile-operations' +import type { RetiredWorktreeNamePersistence } from './retired-worktree-name-persistence' +import type { SshLeaseRecoveryOperations } from './ssh-lease-recovery-operations' +import type { WriteFlushBarrierOperations } from './write-flush-barriers' +export type StoreOptions = { dataFile?: string } export type PtyBindingSourceExpectation = { worktreeId?: string tabId: string @@ -508,69 +42,28 @@ export type PtyBindingSourceExpectation = { incarnationId?: string } +/** Concrete composition root for profile persistence. */ +// oxlint-disable-next-line typescript-eslint/no-unsafe-declaration-merging -- Store installs the exact concrete domain class descriptors and contexts below export class Store { - // Why readonly: the operations wrappers below capture this reference once and are memoized. + private readonly runtime: StoreRuntimeState + private readonly domains: StoreDomains private readonly state: PersistedState - private readonly dataFile: string - private readonly activeViewPreference: ActiveViewPreference - private readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage - private writeTimer: ReturnType | null = null - private pendingWrite: Promise | null = null - private pendingSnapshotFileWork: Promise | null = null - private readonly staleTempCleanup: Promise - private writeGeneration = 0 - private inFlightAsyncTmpFile: string | null = null - // Prevent a sync flush from interleaving a second rotation with awaited ring mutations. - private backupRotationInFlight = false - // Why: after a profile transfer rewrites this file on disk, a late flush of stale in-memory state would resurrect the moved project. - private writesFrozen = false - /** Set by flushAsync so the quit flush is the final write; see scheduleSave. */ - private quitFlushStarted = false - private quitFlushPromise: Promise | null = null - // Content hash at last write, to skip no-op writes; derived from the payload with encrypted blobs normalized back to plaintext (see buildStateToSave), since encrypt() uses a random IV per call. - private lastWrittenStateHash: string | null = null - private lastDurableWriteGeneration = -1 - private firstPendingSaveAt: number | null = null - private githubCacheDirty = false - private githubCacheGeneration = 0 - private pendingGithubCacheWrite: Promise | null = null - private readonly staleGithubCacheTempCleanup: Promise - private readonly gitUsernameCache = new Map() - private readonly protectedSecrets = new ProtectedSecretPersistence() - private loadNeedsSave = false - private settingsChangeListeners = new Set< - ( - updates: Partial, - settings: GlobalSettings, - originWebContentsId?: number - ) => void - >() - private uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>() constructor(options: StoreOptions = {}) { - // Why: profile switching yields multiple state paths; capture per Store so late async writes can't follow a global path. - this.dataFile = options.dataFile ?? getDataFile() - this.staleTempCleanup = removeStaleDurableWriteTempFiles(this.dataFile, { - minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS - }) - this.staleGithubCacheTempCleanup = removeStaleDurableWriteTempFiles( - getGithubCacheFile(this.dataFile), - { minimumAgeMs: STALE_DURABLE_WRITE_TEMP_AGE_MS } - ) - const profileSnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(this.dataFile) - const legacySnapshotRoot = getProfileTerminalScrollbackSnapshotRoot(getDataFile()) - this.terminalScrollbackSnapshotStorage = { - snapshotRoot: profileSnapshotRoot, - fallbackSnapshotRoot: legacySnapshotRoot === profileSnapshotRoot ? null : legacySnapshotRoot - } - const loaded = this.load() + this.runtime = new StoreRuntimeState(options) + this.domains = createStoreDomains(this.runtime) + installStoreDomainContexts(this, this.domains) + this.runtime.flushOrThrow = () => this.flushOrThrow() + const loaded = this.domains.loader.load() const normalized = normalizePersistedPaneIdentityState(loaded) this.state = normalized.state - // Why: activeView is a frequent, tiny preference; keeping it beside the - // profile avoids serializing the multi-MB recovery store on navigation. - this.activeViewPreference = new ActiveViewPreference(this.dataFile, this.state.ui?.activeView) - const adaptedProjectGroups = this.adaptFlatFolderScanProjectGroups() - this.hydrateFolderWorkspaceDiffComments() + this.runtime.state = this.state + this.runtime.activeViewPreference = new ActiveViewPreference( + this.runtime.dataFile, + this.state.ui?.activeView + ) + const adaptedProjectGroups = this.domains.adaptation.adaptFlatFolderScanProjectGroups() + this.domains.adaptation.hydrateFolderWorkspaceDiffComments() for (const entry of normalized.migrationUnsupportedEntries) { setMigrationUnsupportedPty(entry) } @@ -579,3341 +72,55 @@ export class Store { } setMigrationUnsupportedPtyPersistenceListener((entries) => { this.state.migrationUnsupportedPtyEntries = entries - this.scheduleSave() + scheduleSave(this.domains.scheduling) }) agentHookServer.setPaneKeyAliasPersistenceListener((entries) => { this.state.legacyPaneKeyAliasEntries = entries - this.scheduleSave() + scheduleSave(this.domains.scheduling) }) - if (normalized.changed || this.loadNeedsSave || adaptedProjectGroups) { - // Why: rewrite legacy pane:1 leaves so older renderer writes can't revive them; other migrations also set loadNeedsSave. - this.scheduleSave() + if (normalized.changed || this.runtime.loadNeedsSave || adaptedProjectGroups) { + scheduleSave(this.domains.scheduling) } } - // Why: notes live top-level on disk so an older build's field-by-field - // normalizeFolderWorkspaces can't drop them; re-attach them to the in-memory records here. - private hydrateFolderWorkspaceDiffComments(): void { - const stored = this.state.folderWorkspaceDiffComments - let relocatedInline = false - for (const workspace of this.state.folderWorkspaces ?? []) { - if (Array.isArray(workspace.diffComments) && workspace.diffComments.length > 0) { - // Inline wins: an intervening rollback to a #14112 build writes notes inline and leaves the - // older map untouched, so inline is the last notes-aware write. Also makes the relocation - // durable even if the user never edits anything this session. - relocatedInline = true - continue - } - const comments = stored?.[workspace.id] - // Not `??`: a degenerate `{ id: [] }` entry must not delete an intact inline value. - if (Array.isArray(comments) && comments.length > 0) { - workspace.diffComments = comments - } - } - if (relocatedInline) { - this.loadNeedsSave = true - } - // Write-only projection: buildStateToSave() is the only producer, so leaving the loaded map in - // state would make it a stale second source of truth that getDurableState() spreads back out. - delete this.state.folderWorkspaceDiffComments - } - - private adaptFlatFolderScanProjectGroups(): boolean { - // Why: older folder imports kept a real parent path but flat repos; upgrade that shape into v1 sparse folder scopes. - const groups = this.state.projectGroups ?? [] - const repos = this.state.repos - if (groups.length === 0 || repos.length === 0) { - return false - } - - let changed = false - let maxOrder = -1 - for (const group of groups) { - maxOrder = Math.max(maxOrder, group.tabOrder) - } - - const childGroupIds = new Set( - groups.flatMap((group) => (group.parentGroupId ? [group.parentGroupId] : [])) - ) - const initialGroupCount = groups.length - for (let groupIndex = 0; groupIndex < initialGroupCount; groupIndex += 1) { - const rootGroup = groups[groupIndex] - if (!rootGroup) { - continue - } - if ( - rootGroup.createdFrom !== 'folder-scan' || - !rootGroup.parentPath || - rootGroup.parentGroupId || - childGroupIds.has(rootGroup.id) - ) { - continue - } - const rootPath = rootGroup.parentPath - const repoCandidates = repos.filter( - (repo) => - !isFolderRepo(repo) && - repo.projectGroupId === rootGroup.id && - isPathInsideOrEqual(rootPath, repo.path) - ) - if (repoCandidates.length < 2) { - continue - } - - const resolver = createNestedProjectGroupResolver({ - parentPath: rootPath, - groupName: rootGroup.name, - mode: 'group', - repoPaths: repoCandidates.map((repo) => repo.path), - createGroup: (input) => { - if (!input.parentGroupId) { - return rootGroup - } - maxOrder += 1 - const group = createProjectGroup({ - ...input, - tabOrder: maxOrder - }) - groups.push(group) - changed = true - return group - } - }) - const nextOrderByGroupId = new Map() - for (const repo of repoCandidates) { - const group = resolver.getGroupForRepo(repo.path) - if (!group) { - continue - } - const nextOrder = nextOrderByGroupId.get(group.id) ?? 0 - nextOrderByGroupId.set(group.id, nextOrder + 1) - if (repo.projectGroupId !== group.id || repo.projectGroupOrder !== nextOrder) { - repo.projectGroupId = group.id - repo.projectGroupOrder = nextOrder - changed = true - } - } - } - return changed - } - - // Why (#1158): debounced writes fire ~every 300ms; throttle backups to distinct moments, not near-identical snapshots. - private shouldRotateBackups(now: number, dataFile: string): boolean { - try { - const mtime = statSync(backupPath(dataFile, 0)).mtimeMs - return now - mtime >= BACKUP_MIN_INTERVAL_MS - } catch { - return true - } - } - - // Why separate from the sync twin: a statSync here parks the Electron main thread in uninterruptible - // sleep on a stalled SMB/NFS profile mount. Error semantics are deliberately identical: rotate. - private async shouldRotateBackupsAsync(dataFile: string): Promise { - try { - const mtime = (await stat(backupPath(dataFile, 0))).mtimeMs - return Date.now() - mtime >= BACKUP_MIN_INTERVAL_MS - } catch { - return true - } - } - - // Why: rotate current file into the .bak ring so load() can recover if a later primary write is truncated or corrupt. - private async rotateBackupsAsync(dataFile: string): Promise { - if (this.backupRotationInFlight) { - return - } - this.backupRotationInFlight = true - try { - if (!(await this.shouldRotateBackupsAsync(dataFile))) { - return - } - if (!(await exists(dataFile))) { - return - } - await rm(backupPath(dataFile, BACKUP_COUNT - 1)).catch((err: unknown) => { - if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('[persistence] Failed to remove oldest backup:', err) - } - }) - for (let i = BACKUP_COUNT - 2; i >= 0; i--) { - const src = backupPath(dataFile, i) - const dst = backupPath(dataFile, i + 1) - // Why probe instead of rename-then-swallow-ENOENT: a degraded mount rejects a rename of an - // absent slot with ESTALE/EIO, which would log once per empty slot on every debounced save. - if (await exists(src)) { - await rename(src, dst).catch((err) => { - console.error('[persistence] Failed to rotate backup', src, '->', dst, err) - }) - } - } - await copyFile(dataFile, backupPath(dataFile, 0)).catch((err) => { - console.error('[persistence] Failed to snapshot current file to .bak.0:', err) - }) - } finally { - this.backupRotationInFlight = false - } - } - - private rotateBackupsSync(dataFile: string): void { - if (!existsSync(dataFile)) { - return - } - try { - unlinkSync(backupPath(dataFile, BACKUP_COUNT - 1)) - } catch (err) { - if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('[persistence] Failed to remove oldest backup:', err) - } - } - for (let i = BACKUP_COUNT - 2; i >= 0; i--) { - const src = backupPath(dataFile, i) - const dst = backupPath(dataFile, i + 1) - if (existsSync(src)) { - try { - renameSync(src, dst) - } catch (err) { - console.error('[persistence] Failed to rotate backup', src, '->', dst, err) - } - } - } - try { - copyFileSync(dataFile, backupPath(dataFile, 0)) - } catch (err) { - console.error('[persistence] Failed to snapshot current file to .bak.0:', err) - } - } - - private restoreFromBackup(dataFile: string): boolean { - for (let i = 0; i < BACKUP_COUNT; i++) { - const path = backupPath(dataFile, i) - if (!existsSync(path)) { - continue - } - try { - const raw = readFileSync(path, 'utf-8') - JSON.parse(raw) - mkdirSync(dirname(dataFile), { recursive: true }) - writeFileSync(dataFile, raw, 'utf-8') - console.warn(`[persistence] Recovered state from backup slot ${i}: ${path}`) - return true - } catch (err) { - console.error(`[persistence] Backup slot ${i} unusable, trying next:`, err) - } - } - return false - } - - private load(allowBackupRecovery = true): PersistedState { - // Capture "has run Orca before?" for telemetry cohort; the telemetry field is new, so field inference misclassifies old users as fresh. - const dataFile = this.dataFile - const fileExistedOnLoad = existsSync(dataFile) - logPersistenceStartupMilestone('persistence-load-start', { - fileExists: fileExistedOnLoad - }) - - let result: PersistedState | null = null - try { - if (fileExistedOnLoad) { - const readStartedAt = performance.now() - const raw = readFileSync(dataFile, 'utf-8') - logPersistenceStartupMilestone('persistence-read-done', { - bytes: Buffer.byteLength(raw), - durationMs: Math.round(performance.now() - readStartedAt) - }) - logPersistenceStartupMilestone('persistence-json-parse-start') - const parsed = JSON.parse(raw) as PersistedState - logPersistenceStartupMilestone('persistence-json-parse-done') - - // Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext. - if (parsed.settings?.opencodeSessionCookie) { - parsed.settings.opencodeSessionCookie = this.protectedSecrets.decrypt( - PROTECTED_SECRET_SLOT.opencodeSessionCookie, - parsed.settings.opencodeSessionCookie, - isLegacyOpenCodeSessionCookie - ) - } - if (parsed.settings?.httpProxyUrl) { - const decryptedProxy = this.protectedSecrets.decryptWithStatus( - PROTECTED_SECRET_SLOT.httpProxyUrl, - parsed.settings.httpProxyUrl, - (value) => normalizeProxyUrl(value).ok - ) - // Why (STA-3442): after a keychain reset decrypt returns raw ciphertext; a non-URL - // value must not masquerade as a configured proxy (silent DIRECT fallback) or - // re-persist as garbage. Plaintext URLs still pass, preserving the upgrade path. - if ( - decryptedProxy.status === 'unavailable' || - (decryptedProxy.status === 'failed' && !decryptedProxy.plaintext) - ) { - parsed.settings.httpProxyUrl = '' - } else if (normalizeProxyUrl(decryptedProxy.plaintext).ok) { - parsed.settings.httpProxyUrl = decryptedProxy.plaintext - } else { - console.warn( - '[persistence] httpProxyUrl could not be decrypted — clearing the stored proxy URL. Re-enter it in Settings > Advanced > Network.' - ) - parsed.settings.httpProxyUrl = '' - this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl) - this.loadNeedsSave = true - } - } - if (parsed.ui?.browserKagiSessionLink) { - parsed.ui.browserKagiSessionLink = this.protectedSecrets.decrypt( - PROTECTED_SECRET_SLOT.browserKagiSessionLink, - parsed.ui.browserKagiSessionLink, - (value) => normalizeKagiSessionLink(value) !== null - ) - } - parsed.sshPtyConsumerRecoveries = ( - Array.isArray(parsed.sshPtyConsumerRecoveries) ? parsed.sshPtyConsumerRecoveries : [] - ) - .map((record) => - normalizeSshPtyConsumerRecovery(record, ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH) - ) - .filter((record): record is SshPtyConsumerRecovery => record !== null) - .map((record) => { - const slot = sshPtyOwnerLeaseSecretSlot(record.targetId) - const decrypted = this.protectedSecrets.decryptWithStatus( - slot, - record.ownerLease, - isLegacySshPtyOwnerLease - ) - const normalized = - decrypted.status === 'unavailable' || - (decrypted.status === 'failed' && !decrypted.plaintext) - ? record - : normalizeSshPtyConsumerRecovery({ ...record, ownerLease: decrypted.plaintext }) - if (!normalized) { - this.protectedSecrets.removeRetainedBlob(slot) - } - return normalized - }) - .filter((record): record is SshPtyConsumerRecovery => record !== null) - - // Merge with defaults in case new fields were added - const homeDir = homedir() - const defaults = getDefaultPersistedState(homeDir) - const migratedExternalVisibility = migrateExternalWorktreeVisibilityDefaults( - Array.isArray(parsed.repos) ? parsed.repos : [], - parsed.settings?.worktreeVisibilityDefaults - ) - if (migratedExternalVisibility.changed) { - this.loadNeedsSave = true - } - const migratedTerminalScrollback = migrateTerminalScrollbackRows(parsed.settings) - if (migratedTerminalScrollback.needsSave) { - this.loadNeedsSave = true - } - if ( - parsed.settings && - typeof parsed.settings === 'object' && - Object.hasOwn(parsed.settings, 'enableGitHubAttribution') - ) { - this.loadNeedsSave = true - } - const migratedTerminalTuiScrollSensitivity = migrateTerminalTuiScrollSensitivityDefault( - parsed.settings - ) - if (migratedTerminalTuiScrollSensitivity.needsSave) { - this.loadNeedsSave = true - } - const rawSourceControlAi = parsed.settings?.sourceControlAi - const rawSourceControlAiMissing = rawSourceControlAi === undefined - const rawSourceControlAiActionsMissing = - rawSourceControlAi !== undefined && rawSourceControlAi.actions === undefined - if (rawSourceControlAiMissing || rawSourceControlAiActionsMissing) { - this.loadNeedsSave = true - } - const legacyCommitMessageAi = parsed.settings?.commitMessageAi - const migratedSourceControlAi = rawSourceControlAiMissing - ? sourceControlAiSettingsFromLegacy( - legacyCommitMessageAi ?? defaults.settings.commitMessageAi - ) - : mergeLegacyCommitMessageAiIntoSourceControlAi( - parsed.settings?.sourceControlAi, - legacyCommitMessageAi - ) - // Why (issue #903): old 'true' default broke non-US Option-layer chars; flip 'true'→'auto' once so the layout probe decides. - const rawOptionAsAlt = parsed.settings?.terminalMacOptionAsAlt - const alreadyMigrated = parsed.settings?.terminalMacOptionAsAltMigrated === true - const migratedOptionAsAlt: 'auto' | 'true' | 'false' | 'left' | 'right' = alreadyMigrated - ? (rawOptionAsAlt ?? 'auto') - : rawOptionAsAlt === undefined || rawOptionAsAlt === 'true' - ? 'auto' - : rawOptionAsAlt - const floatingTerminalDefaultedForAllUsers = - parsed.settings?.floatingTerminalDefaultedForAllUsers === true - // Why: early builds persisted the old off default; flip only unmigrated profiles so a later opt-out survives reload. - const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers - ? (parsed.settings?.floatingTerminalEnabled ?? true) - : true - // Why: the old off default persisted `false` for every profile, indistinguishable from a real opt-out — flip unmigrated profiles once (#10567). - const migratedOsc52Clipboard = normalizeOsc52ClipboardDefaultOn(parsed.settings) - const osc52ClipboardNoticePending = - osc52ClipboardDefaultOnOverridesPersistedOff(parsed.settings) || - parsed.ui?.osc52ClipboardDefaultOnNoticePending === true - if (parsed.settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== true) { - this.loadNeedsSave = true - } - const floatingTerminalCwdMigrated = - parsed.settings?.floatingTerminalCwdMigratedToAppWorkspace === true - // Why: an earlier migration wrote '' for the notes dir; floating terminals still open at home, notes use a separate IPC. - const migratedFloatingTerminalCwd = floatingTerminalCwdMigrated - ? !parsed.settings?.floatingTerminalCwd - ? defaults.settings.floatingTerminalCwd - : parsed.settings.floatingTerminalCwd - : parsed.settings?.floatingTerminalCwd === undefined - ? defaults.settings.floatingTerminalCwd - : parsed.settings.floatingTerminalCwd - const normalizedFloatingTerminalTrustedCwds = normalizeFloatingWorkspaceTrustedCwds( - parsed.settings?.floatingTerminalTrustedCwds, - homeDir - ) - const migratedFloatingTerminalTrustedCwds = [ - ...normalizedFloatingTerminalTrustedCwds.trustedCwds - ] - const rawLegacyFloatingTerminalCwd = parsed.settings?.floatingTerminalCwd - const shouldTrustLegacyFloatingTerminalCwd = - !floatingTerminalCwdMigrated && - typeof rawLegacyFloatingTerminalCwd === 'string' && - rawLegacyFloatingTerminalCwd.trim().length > 0 && - rawLegacyFloatingTerminalCwd.trim() !== '~' - if (!floatingTerminalCwdMigrated) { - this.loadNeedsSave = true - } - if (shouldTrustLegacyFloatingTerminalCwd && rawLegacyFloatingTerminalCwd) { - const canonicalLegacyCwd = canonicalizePersistedFloatingWorkspaceDirectory( - rawLegacyFloatingTerminalCwd, - homeDir - ) - if ( - canonicalLegacyCwd && - !migratedFloatingTerminalTrustedCwds.includes(canonicalLegacyCwd) - ) { - // Why: pre-grant profiles with an explicit Floating Workspace cwd already showed intent; migrate only that legacy value. - migratedFloatingTerminalTrustedCwds.push(canonicalLegacyCwd) - normalizedFloatingTerminalTrustedCwds.changed = true - } - } - if (normalizedFloatingTerminalTrustedCwds.changed) { - this.loadNeedsSave = true - } - const experimentalActivityDefaultedOffForAllUsers = - parsed.settings?.experimentalActivityDefaultedOffForAllUsers === true - // Why: the Agents view moved back behind Experimental; flip pre-migration profiles off once, then preserve opt-ins. - const migratedExperimentalActivity = experimentalActivityDefaultedOffForAllUsers - ? (parsed.settings?.experimentalActivity ?? false) - : false - const autoRenameBranchFromWorkDefaultedOn = - parsed.settings?.autoRenameBranchFromWorkDefaultedOn === true - // Why: default-on rollout activates old profiles once, but a later Settings opt-out survives reloads. - const migratedAutoRenameBranchFromWork = normalizeAutoRenameBranchFromWorkDefaultOn( - parsed.settings - ) - const migratedTerminalCursorStyle = normalizeTerminalCursorStyleDefault(parsed.settings) - if ( - parsed.settings?.terminalCursorStyle !== - migratedTerminalCursorStyle.terminalCursorStyle || - parsed.settings?.terminalCursorStyleDefaultedToBlock !== true - ) { - this.loadNeedsSave = true - } - const migratedTerminalLineHeight = normalizeTerminalLineHeight( - parsed.settings?.terminalLineHeight - ) - const terminalRightClickToPasteDefaultedForPlatform = - parsed.settings?.terminalRightClickToPasteDefaultedForPlatform === true - if (!terminalRightClickToPasteDefaultedForPlatform) { - this.loadNeedsSave = true - } - if ( - parsed.settings?.terminalLineHeight !== undefined && - parsed.settings.terminalLineHeight !== migratedTerminalLineHeight - ) { - this.loadNeedsSave = true - } - const rawTaskProviderSettings = normalizeTaskProviderSettings({ - visibleTaskProviders: parsed.settings?.visibleTaskProviders, - defaultTaskSource: parsed.settings?.defaultTaskSource - }) - const visibleTaskProvidersDefaultedForJira = - parsed.settings?.visibleTaskProvidersDefaultedForJira === true - const migratedVisibleTaskProviders = visibleTaskProvidersDefaultedForJira - ? rawTaskProviderSettings.visibleTaskProviders - : rawTaskProviderSettings.visibleTaskProviders.includes('jira') - ? rawTaskProviderSettings.visibleTaskProviders - : [...rawTaskProviderSettings.visibleTaskProviders, 'jira' as const] - const taskProviderSettings = normalizeTaskProviderSettings({ - visibleTaskProviders: migratedVisibleTaskProviders, - defaultTaskSource: rawTaskProviderSettings.defaultTaskSource - }) - const primarySelectionDefaultedForLinux = - parsed.settings?.primarySelectionMiddleClickPasteDefaultedForLinux === true - const primarySelectionDefaultedForTerminalDefaults = - parsed.settings?.primarySelectionMiddleClickPasteDefaultedForTerminalDefaults === true - const primarySelectionPlatformDefaultEnabled = - defaults.settings.primarySelectionMiddleClickPaste === true - const primarySelectionAlreadyDefaultedForPlatform = - primarySelectionDefaultedForTerminalDefaults || - (process.platform === 'linux' && primarySelectionDefaultedForLinux) - const migratePrimarySelectionPlatformDefault = - primarySelectionPlatformDefaultEnabled && !primarySelectionAlreadyDefaultedForPlatform - const stampPrimarySelectionTerminalDefaults = - primarySelectionPlatformDefaultEnabled && !primarySelectionDefaultedForTerminalDefaults - if (migratePrimarySelectionPlatformDefault || stampPrimarySelectionTerminalDefaults) { - this.loadNeedsSave = true - } - if (!visibleTaskProvidersDefaultedForJira) { - this.loadNeedsSave = true - } - const claudeAgentTeamsDefaultDisabledMigrated = - parsed.settings?.claudeAgentTeamsDefaultDisabledMigrated === true - if (!claudeAgentTeamsDefaultDisabledMigrated) { - this.loadNeedsSave = true - } - const migratedDisabledTuiAgents = normalizeDisabledTuiAgents( - parsed.settings?.disabledTuiAgents - ) - const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings) - if ( - parsed.settings?.agentYoloDefaultsMigrated !== true || - hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) || - hasUnsupportedTuiAgentArgs('kilo', parsed.settings?.agentDefaultArgs?.kilo) - ) { - this.loadNeedsSave = true - } - if ( - !claudeAgentTeamsDefaultDisabledMigrated && - !migratedDisabledTuiAgents.includes('claude-agent-teams') - ) { - migratedDisabledTuiAgents.push('claude-agent-teams') - } - const migratedWindowsRuntimeDefault = - parsed.settings?.localWindowsRuntimeDefault === undefined - ? deriveGlobalWindowsRuntimeDefaultFromLegacySettings(parsed.settings).defaultRuntime - : parsed.settings.localWindowsRuntimeDefault - if ( - parsed.settings?.localWindowsRuntimeDefault === undefined && - migratedWindowsRuntimeDefault.kind === 'wsl' - ) { - this.loadNeedsSave = true - } - // Why (#9537): migrate the indistinguishable legacy host default once so WSL-default users follow their runtime. - const localAccountRuntimeAlreadyMigrated = - parsed.settings?.localAccountRuntimeDefaultedToAutoForAllUsers === true - const migratedLocalAccountRuntime: GlobalSettings['localAccountRuntime'] = - localAccountRuntimeAlreadyMigrated - ? (parsed.settings?.localAccountRuntime ?? defaults.settings.localAccountRuntime) - : parsed.settings?.localAccountRuntime === 'wsl' - ? 'wsl' - : 'auto' - if (!localAccountRuntimeAlreadyMigrated) { - this.loadNeedsSave = true - } - if (!autoRenameBranchFromWorkDefaultedOn) { - this.loadNeedsSave = true - } - const normalizedOnboarding = normalizeLoadedOnboardingState( - parsed.onboarding, - defaults.onboarding - ) - if (!parsed.onboarding) { - this.loadNeedsSave = true - } - const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups) - const loadedCompactWorktreeCards = - parsed.settings?.compactWorktreeCards ?? - parsed.settings?.experimentalCompactWorktreeCards ?? - defaults.settings.compactWorktreeCards - const mobilePairingCustomAddress = normalizeMobilePairingCustomAddress( - parsed.settings?.mobilePairingCustomAddress - ) - const rawMobilePairingCustomAddresses = parsed.settings?.mobilePairingCustomAddresses - const mobilePairingCustomAddresses = mobilePairingCustomAddress - ? addMobilePairingCustomAddress( - normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses), - mobilePairingCustomAddress - ) - : normalizeMobilePairingCustomAddresses(rawMobilePairingCustomAddresses) - if ( - parsed.settings?.mobilePairingCustomAddress !== undefined && - parsed.settings.mobilePairingCustomAddress !== mobilePairingCustomAddress - ) { - this.loadNeedsSave = true - } - const customAddressesMatch = - Array.isArray(rawMobilePairingCustomAddresses) && - rawMobilePairingCustomAddresses.length === mobilePairingCustomAddresses.length && - rawMobilePairingCustomAddresses.every( - (address, index) => address === mobilePairingCustomAddresses[index] - ) - if ( - (rawMobilePairingCustomAddresses !== undefined || mobilePairingCustomAddress !== null) && - !customAddressesMatch - ) { - this.loadNeedsSave = true - } - const normalizedNotifications = normalizeNotificationSettings( - parsed.settings?.notifications - ) - // Why: a type-flipped notification field is repaired in memory only; without a dirty mark the - // bad value stays on disk and the repair reruns on every launch. - if ( - persistedNotificationSettingsRepaired( - parsed.settings?.notifications, - normalizedNotifications - ) - ) { - this.loadNeedsSave = true - } - const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( - parsed.settings?.sourceControlGroupOrder - ) - if ( - parsed.settings?.sourceControlGroupOrder !== undefined && - parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder - ) { - this.loadNeedsSave = true - } - result = { - ...defaults, - ...parsed, - featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets( - parsed.featureInteractionTelemetryBuckets - ), - projectGroups: normalizedProjectGroups, - repos: migratedExternalVisibility.repos, - folderWorkspaces: normalizeFolderWorkspaces( - parsed.folderWorkspaces, - normalizedProjectGroups - ), - folderWorkspaceDiffComments: normalizeFolderWorkspaceDiffComments( - parsed.folderWorkspaceDiffComments - ), - worktreeLineageById: parsed.worktreeLineageById ?? {}, - mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( - parsed.mobileClientTabSelectionsByDeviceId - ), - workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey( - parsed.workspaceLineageByChildKey - ), - settings: { - ...defaults.settings, - // Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed. - ...stripRetiredGlobalSettings(parsed.settings), - worktreeVisibilityDefaults: migratedExternalVisibility.defaults, - prBotAuthorOverrides: normalizePRBotAuthorOverrides( - parsed.settings?.prBotAuthorOverrides - ), - // Why: v1.3.42 renamed the sidekick setting to pet; carry the old flag forward once so enabled users don't lose it. - experimentalPet: - parsed.settings?.experimentalPet ?? readLegacySidekickFlag(parsed) ?? false, - // Why: early builds saved the disabled default; flip Linux/macOS profiles once to match platform, guards keep opt-outs. - primarySelectionMiddleClickPaste: migratePrimarySelectionPlatformDefault - ? true - : (parsed.settings?.primarySelectionMiddleClickPaste ?? - defaults.settings.primarySelectionMiddleClickPaste), - primarySelectionMiddleClickPasteDefaultedForLinux: - primarySelectionDefaultedForLinux || - (process.platform === 'linux' && migratePrimarySelectionPlatformDefault), - primarySelectionMiddleClickPasteDefaultedForTerminalDefaults: - primarySelectionDefaultedForTerminalDefaults || stampPrimarySelectionTerminalDefaults, - ...migratedAutoRenameBranchFromWork, - ...migratedTerminalCursorStyle, - terminalLineHeight: migratedTerminalLineHeight, - // Why: the old true default was inherited, but false was always an explicit opt-out and must survive this one-shot reset. - terminalRightClickToPaste: terminalRightClickToPasteDefaultedForPlatform - ? (parsed.settings?.terminalRightClickToPaste ?? - defaults.settings.terminalRightClickToPaste) - : parsed.settings?.terminalRightClickToPaste === false - ? false - : defaults.settings.terminalRightClickToPaste, - terminalRightClickToPasteDefaultedForPlatform: true, - ...migratedTerminalTuiScrollSensitivity.settings, - experimentalActivity: migratedExperimentalActivity, - experimentalActivityDefaultedOffForAllUsers: true, - // Why: compact worktree cards graduated from Experimental; preserve the old opt-in for rollout-era profiles. - compactWorktreeCards: loadedCompactWorktreeCards, - experimentalCompactWorktreeCards: undefined, - terminalMacOptionAsAlt: migratedOptionAsAlt, - terminalMacOptionAsAltMigrated: true, - localWindowsRuntimeDefault: migratedWindowsRuntimeDefault, - localAccountRuntime: migratedLocalAccountRuntime, - localAccountRuntimeDefaultedToAutoForAllUsers: true, - ...migratedOsc52Clipboard, - floatingTerminalEnabled: migratedFloatingTerminalEnabled, - floatingTerminalDefaultedForAllUsers: true, - floatingTerminalCwd: migratedFloatingTerminalCwd, - floatingTerminalTrustedCwds: migratedFloatingTerminalTrustedCwds, - floatingTerminalCwdMigratedToAppWorkspace: true, - terminalScrollbackRows: migratedTerminalScrollback.rows, - terminalQuickCommands: normalizeTerminalQuickCommands( - parsed.settings?.terminalQuickCommands - ), - terminalCustomThemes: normalizeTerminalCustomThemes( - parsed.settings?.terminalCustomThemes - ), - appIcon: normalizeAppIconId(parsed.settings?.appIcon), - mobilePairingCustomAddress, - mobilePairingCustomAddresses, - // Why: persisted settings may be hand-edited or from older builds; keep tray-minimize false unless stored value is true. - minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true, - // Why: missing means default-on; round-trips unchanged on non-mac since darwin consumers gate the effect. - showMenuBarIcon: parsed.settings?.showMenuBarIcon !== false, - uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage), - defaultTaskSource: taskProviderSettings.defaultTaskSource, - visibleTaskProviders: taskProviderSettings.visibleTaskProviders, - visibleTaskProvidersDefaultedForJira: true, - terminalShortcutPolicy: normalizeTerminalShortcutPolicy( - parsed.settings?.terminalShortcutPolicy - ), - disabledTuiAgents: migratedDisabledTuiAgents, - ...migratedAgentYoloDefaults, - claudeAgentTeamsDefaultDisabledMigrated: true, - openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, { - seedDefaults: true - }), - notifications: normalizedNotifications, - sourceControlAi: migratedSourceControlAi, - sourceControlGroupOrder: normalizedSourceControlGroupOrder, - // Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat. - commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( - migratedSourceControlAi, - parsed.settings?.commitMessageAi ?? defaults.settings.commitMessageAi - ), - voice: { - ...getDefaultVoiceSettings(), - ...parsed.settings?.voice - } - }, - // Why: legacy 'recent' meant the smart sort; migrate once on the raw value so a fresh 'recent' default isn't remigrated. - ui: (() => { - const rawSort = parsed.ui?.sortBy - const sort = normalizeSortBy(rawSort) - const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent' - const rightSidebarOpen = - typeof parsed.ui?.rightSidebarOpen === 'boolean' - ? parsed.ui.rightSidebarOpen - : typeof parsed.settings?.rightSidebarOpenByDefault === 'boolean' - ? parsed.settings.rightSidebarOpenByDefault - : defaults.ui.rightSidebarOpen - if (typeof parsed.ui?.rightSidebarOpen !== 'boolean') { - this.loadNeedsSave = true - } - const workspaceStatusesDefaultOrderMigrated = - parsed.ui?._workspaceStatusesDefaultOrderMigrated === true - // Why: a short-lived default put Done on the left; repair only the exact raw payload once so user reorders survive. - const workspaceStatusesReorderedDefaultRepaired = - parsed.ui?._workspaceStatusesReorderedDefaultRepaired === true - // Why: only exact legacy default payloads migrate; customized status labels/colors/icons/order are kept. - const workspaceStatusesDefaultWorkflowMigrated = - parsed.ui?._workspaceStatusesDefaultWorkflowMigrated === true - // Why: visual migration has its own guard so later user choices of valid legacy color/icon IDs are preserved. - const workspaceStatusesDefaultVisualsMigrated = - parsed.ui?._workspaceStatusesDefaultVisualsMigrated === true - const workspaceStatuses = normalizePersistedWorkspaceStatuses( - parsed.ui?.workspaceStatuses, - { - migrateDefaultWorkflowStatuses: !workspaceStatusesDefaultWorkflowMigrated, - repairReorderedDefaultStatuses: !workspaceStatusesReorderedDefaultRepaired, - migrateLegacyDefaultStatusVisuals: !workspaceStatusesDefaultVisualsMigrated - } - ) - if ( - !workspaceStatusesDefaultOrderMigrated || - !workspaceStatusesReorderedDefaultRepaired || - !workspaceStatusesDefaultWorkflowMigrated || - !workspaceStatusesDefaultVisualsMigrated - ) { - this.loadNeedsSave = true - } - const rawCardProps = parsed.ui?.worktreeCardProperties - const inlineAgentsMigrated = parsed.ui?._inlineAgentsDefaultedForAllUsers === true - const expandedCardPropsMigrated = - parsed.ui?._expandedWorktreeCardPropertiesDefaulted === true - const jiraIssueCardPropDefaulted = - parsed.ui?._jiraIssueWorktreeCardPropertyDefaulted === true - const hadExperimentOn = readDeprecatedExperimentFlag(parsed) - const deliberateUncheck = - hadExperimentOn && - Array.isArray(rawCardProps) && - !rawCardProps.includes('inline-agents') - const needsInlineAgentsMigration = - !inlineAgentsMigrated && - !deliberateUncheck && - Array.isArray(rawCardProps) && - !rawCardProps.includes('inline-agents') - const needsLegacyDefaultedCompactMigration = - loadedCompactWorktreeCards && - parsed.ui?._worktreeCardModeDefaulted === true && - isDefaultedCompactWorktreeCardProperties(rawCardProps) - const migratedCardProps = (() => { - if (!Array.isArray(rawCardProps)) { - return undefined - } - if (needsLegacyDefaultedCompactMigration) { - return getWorktreeCardModeProperties('Compact') - } - const candidate = needsInlineAgentsMigration - ? [...rawCardProps, 'inline-agents' as const] - : rawCardProps - const expandedCandidate = (() => { - if (expandedCardPropsMigrated) { - return candidate - } - const next = [...candidate] - // Why: Linear rode the 'issue' property and Ports were always shown; split them out once to preserve existing cards. - if (candidate.includes('issue') && !candidate.includes('linear-issue')) { - next.push('linear-issue' as const) - } - if (!candidate.includes('ports')) { - next.push('ports' as const) - } - return next - })() - // Why: 'jira-issue' joined the defaults after the expansion migration already stamped upgraded profiles, so it needs its own one-shot backfill. - const jiraCandidate = - jiraIssueCardPropDefaulted || expandedCandidate.includes('jira-issue') - ? expandedCandidate - : [...expandedCandidate, 'jira-issue' as const] - const normalized = normalizeWorktreeCardProperties(jiraCandidate) - const changed = - normalized.length !== rawCardProps.length || - normalized.some((property, index) => property !== rawCardProps[index]) - return changed ? normalized : undefined - })() - if ( - migratedCardProps !== undefined || - !inlineAgentsMigrated || - !expandedCardPropsMigrated || - !jiraIssueCardPropDefaulted - ) { - this.loadNeedsSave = true - } - const rawExplorerView = parsed.ui?.rightSidebarExplorerView - const rightSidebarExplorerView = normalizeRightSidebarExplorerView( - rawExplorerView, - parsed.ui?.rightSidebarTab - ) - // Why: without a dirty mark the legacy "Search tab, no explorer view" repair stays - // in memory only, so a profile that never writes again redoes it on every launch. - if ( - rawExplorerView === undefined - ? rightSidebarExplorerView !== defaults.ui.rightSidebarExplorerView - : rawExplorerView !== rightSidebarExplorerView - ) { - this.loadNeedsSave = true - } - const setupGuideSidebarDismissed = resolveSetupGuideSidebarDismissedOnLoad( - parsed.ui?.setupGuideSidebarDismissed, - normalizedOnboarding - ) - if ( - parsed.ui?.setupGuideSidebarDismissed !== setupGuideSidebarDismissed && - (setupGuideSidebarDismissed || parsed.ui?.setupGuideSidebarDismissed !== undefined) - ) { - this.loadNeedsSave = true - } - // Why: only upgraded profiles still on the new default get the one-time usage-display notice; fresh profiles stay quiet. - const usagePercentageDisplayChangeNoticeDismissed = - resolveUsagePercentageDisplayChangeNoticeDismissed({ - rawDismissed: parsed.ui?.usagePercentageDisplayChangeNoticeDismissed, - rawUsagePercentageDisplay: parsed.ui?.usagePercentageDisplay, - isExistingProfile: isExistingPersistedProfile({ - repoCount: parsed.repos?.length ?? 0, - onboardingClosedAt: normalizedOnboarding.closedAt, - ui: parsed.ui - }) - }) - if ( - parsed.ui?.usagePercentageDisplayChangeNoticeDismissed !== - usagePercentageDisplayChangeNoticeDismissed - ) { - this.loadNeedsSave = true - } - return { - ...defaults.ui, - // Why: missing card properties follow the persisted layout mode; explicit choices are preserved below. - worktreeCardProperties: getWorktreeCardModeProperties( - loadedCompactWorktreeCards ? 'Compact' : 'Default' - ), - ...stripMainOwnedTelemetryMarkerFromUI(parsed.ui), - // Why: migrate once from the retired Appearance setting only when no explicit chrome preference exists yet. - rightSidebarOpen, - rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), - // Why here and not in getPersistedUI: only the raw payload still shows the legacy - // "Search tab, no explorer view" shape — the defaults spread above fills in 'files'. - rightSidebarExplorerView, - setupGuideSidebarDismissed, - usagePercentageDisplayChangeNoticeDismissed, - setupGuideBrowserMilestoneMigrated: - typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' - ? parsed.ui.setupGuideBrowserMilestoneMigrated - : false, - setupGuideBrowserMilestoneLegacyComplete: - parsed.ui?.setupGuideBrowserMilestoneLegacyComplete === true, - // Why persist rather than notify inline: the flip lands during load, before any - // window exists, and it must survive a crash before the user ever sees the notice. - osc52ClipboardDefaultOnNoticePending: osc52ClipboardNoticePending, - sortBy: migrate ? ('smart' as const) : sort, - showDotfilesByWorktree: normalizeShowDotfilesByWorktree( - parsed.ui?.showDotfilesByWorktree - ), - workspaceStatuses, - _workspaceStatusesDefaultOrderMigrated: true, - _workspaceStatusesReorderedDefaultRepaired: true, - _workspaceStatusesDefaultWorkflowMigrated: true, - _workspaceStatusesDefaultVisualsMigrated: true, - _sortBySmartMigrated: true, - ...(migratedCardProps !== undefined - ? { worktreeCardProperties: migratedCardProps } - : {}), - // Why: keep stamping the legacy flag for rollback forward-compat; the new flag actually gates the migration. - _inlineAgentsDefaultedForExperiment: true, - _inlineAgentsDefaultedForAllUsers: true, - _expandedWorktreeCardPropertiesDefaulted: true, - _jiraIssueWorktreeCardPropertyDefaulted: true - } - })(), - // Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash. - workspaceSession: (() => { - if (parsed.workspaceSession === undefined) { - return defaults.workspaceSession - } - const result = parseWorkspaceSessionSalvaging(parsed.workspaceSession) - if (!result.ok) { - console.error( - '[persistence] Corrupt workspace session, using defaults:', - result.error - ) - return defaults.workspaceSession - } - if (result.droppedCount > 0) { - console.warn( - '[persistence] Salvaged workspace session; dropped corrupt entries:', - workspaceSessionSalvageLogDetails(result) - ) - // Why: salvage repairs only the in-memory session; without a save the corrupt entries stay on disk and get re-dropped every launch. - this.loadNeedsSave = true - } - return { ...defaults.workspaceSession, ...result.value } - })(), - // Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat. - workspaceSessionsByHostId: (() => { - const { partitions, repaired } = parseWorkspaceSessionsByHostId( - parsed.workspaceSessionsByHostId, - defaults.workspaceSession - ) - if (repaired) { - // Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch. - this.loadNeedsSave = true - } - return partitions - })(), - sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), - deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) - ? parsed.deletedSshConfigAliases.filter( - (alias): alias is string => typeof alias === 'string' - ) - : [], - retiredWorktreeNamesByRepo: normalizeRetiredNameRegistryMap( - parsed.retiredWorktreeNamesByRepo - ), - retiredWorktreeNamesByNamespace: normalizeRetiredNameRegistryMap( - parsed.retiredWorktreeNamesByNamespace - ), - sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? []) - .map(normalizeSshRemotePtyLease) - .filter((lease): lease is SshRemotePtyLease => lease !== null), - sshPtyConsumerRecoveries: parsed.sshPtyConsumerRecoveries, - claudeLivePtySessionIds: normalizeClaudeLivePtySessionIds(parsed.claudeLivePtySessionIds), - migrationUnsupportedPtyEntries: normalizeMigrationUnsupportedPtyEntries( - parsed.migrationUnsupportedPtyEntries - ), - legacyPaneKeyAliasEntries: normalizeLegacyPaneKeyAliasEntries( - parsed.legacyPaneKeyAliasEntries - ), - automations: Array.isArray(parsed.automations) ? parsed.automations : [], - automationRuns: (() => { - if (!Array.isArray(parsed.automationRuns)) { - return [] - } - const runs = pruneAutomationRuns(backfillAutomationRunNumbers(parsed.automationRuns)) - // Why: nothing else marks dirty, so an oversized legacy file would otherwise only shrink at the next unrelated save. - if (runs.length !== parsed.automationRuns.length) { - this.loadNeedsSave = true - } - return runs - })(), - onboarding: normalizedOnboarding - } - } - } catch (err) { - console.error('[persistence] Failed to load primary state, trying backups:', err) - } - - // Corrupt-file and no-file paths converge here; a corrupted install counts as existing, so it sees the opt-in banner. - if (result === null && allowBackupRecovery) { - let hasBackup = false - for (let i = 0; i < BACKUP_COUNT; i++) { - if (existsSync(backupPath(dataFile, i))) { - hasBackup = true - break - } - } - if (fileExistedOnLoad || hasBackup) { - if (this.restoreFromBackup(dataFile)) { - return this.load(false) - } - console.error('[persistence] No usable state file or backup found, using defaults') - } - } - - if (result === null) { - result = getDefaultPersistedState(homedir()) - } - - const workspaceSession = pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos) - ) - const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( - workspaceSession, - this.terminalScrollbackSnapshotStorage - ) - if (migratedScrollback.changed) { - this.loadNeedsSave = true - } - - const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []) - const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos) - if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) { - this.loadNeedsSave = true - } - - const automationContextMigration = backfillLegacyAutomationContexts({ - ...result, - repos, - ...projectHostSetupCompatibility - }) - if (automationContextMigration.changed) { - this.loadNeedsSave = true - } - result = { - ...result, - automations: automationContextMigration.state.automations, - automationRuns: automationContextMigration.state.automationRuns - } - - const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({ - ...result, - repos, - ...projectHostSetupCompatibility, - workspaceSession: migratedScrollback.session - }) - if (folderScopeConnectionMigration.changed) { - this.loadNeedsSave = true - } - result = folderScopeConnectionMigration.state - - if (normalizeWorktreeLinkedItemMetadata(result)) { - this.loadNeedsSave = true - } - - if (gcStaleWorktreeMeta(result) > 0) { - this.loadNeedsSave = true - } - - const migrated = this.migrateTabSwitchKeybindings( - this.migrateTelemetry(result, fileExistedOnLoad), - fileExistedOnLoad - ) - - // githubCache is a sidecar file now (see getGithubCacheFile); legacy in-file caches seed the session, then get stripped. - const legacyCache = migrated.githubCache - const hasLegacyCache = - Object.keys(legacyCache?.pr ?? {}).length > 0 || - Object.keys(legacyCache?.issue ?? {}).length > 0 - if (hasLegacyCache) { - this.loadNeedsSave = true - // Why: mark dirty so the first flush writes the sidecar even without a poll refresh this session, preserving the seed. - this.githubCacheDirty = true - } else { - migrated.githubCache = readGithubCacheSnapshot(this.dataFile) ?? migrated.githubCache - } - - logPersistenceStartupMilestone('persistence-load-done', { - repos: migrated.repos.length, - workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) - }) - return migrated - } - - // One-shot telemetry cohort migration: seeds existedBeforeTelemetryRelease, optedIn, and installId (no-op once set). - // One-shot tab-switch cohort freeze: fileExistedOnLoad tells existing vs fresh only on the first launch, so persist now. - private migrateTabSwitchKeybindings( - state: PersistedState, - fileExistedOnLoad: boolean - ): PersistedState { - const existing = state.settings?.tabSwitchKeybindingSeed - if (existing === 'pending' || existing === 'done') { - return state - } - // Why: mark dirty so the frozen cohort persists; else a fresh install re-reads as "existing" after its file lands. - this.loadNeedsSave = true - return { - ...state, - settings: { - ...state.settings, - // Existing installs pin old chords via a keybindings.json seed; fresh installs use the new registry defaults. - tabSwitchKeybindingSeed: fileExistedOnLoad ? 'pending' : 'done' - } - } - } - - private migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { - const existing = state.settings?.telemetry - // Why: require all three invariants; keying on existedBeforeTelemetryRelease alone lets a partial block skip migration. - if ( - typeof existing?.existedBeforeTelemetryRelease === 'boolean' && - typeof existing.installId === 'string' && - existing.installId.length > 0 && - (existing.optedIn === true || existing.optedIn === false || existing.optedIn === null) - ) { - return state - } - // Why: resolve cohort once; re-inferring it in the optedIn fallback could misclassify a partially-written new user. - const resolvedExistedBefore = - typeof existing?.existedBeforeTelemetryRelease === 'boolean' - ? existing.existedBeforeTelemetryRelease - : fileExistedOnLoad - return { - ...state, - settings: { - ...state.settings, - telemetry: { - ...existing, - existedBeforeTelemetryRelease: resolvedExistedBefore, - // Why: preserve any explicit opt-in/out; fall back to cohort default only when optedIn is undefined, never when false. - optedIn: - existing?.optedIn === true || existing?.optedIn === false || existing?.optedIn === null - ? existing.optedIn - : resolvedExistedBefore - ? null - : true, - installId: - typeof existing?.installId === 'string' && existing.installId.length > 0 - ? existing.installId - : randomUUID() - } - } - } - } - - // Why 1s trailing + 5s max-wait (was 300ms unbounded): coalesce mutation bursts; max-wait bounds crash staleness at 5s. - private static SAVE_DEBOUNCE_MS = 1_000 - private static SAVE_MAX_WAIT_MS = 5_000 - - private scheduleSave(): void { - // Why: once the quit flush has snapshotted, a newly debounced write would fire during - // teardown with nothing awaiting it, and the process can exit mid-rename. The quit - // flush is the last write by construction. - if (this.quitFlushStarted) { - return - } - this.writeGeneration += 1 - const now = Date.now() - this.firstPendingSaveAt ??= now - if (this.writeTimer) { - clearTimeout(this.writeTimer) - } - const untilMaxWait = Math.max(0, this.firstPendingSaveAt + Store.SAVE_MAX_WAIT_MS - now) - const delay = Math.min(Store.SAVE_DEBOUNCE_MS, untilMaxWait) - this.writeTimer = setTimeout(() => { - this.writeTimer = null - this.firstPendingSaveAt = null - void this.enqueueWrite() - }, delay) - } - - private enqueueWrite(): Promise { - const previousWrite = Promise.all([ - this.pendingWrite ?? this.staleTempCleanup, - this.pendingSnapshotFileWork ?? Promise.resolve() - ]).then(() => {}) - const write = previousWrite.then(() => this.writeToDiskAsync()) - const trackedWrite = write - .catch((err) => { - console.error('[persistence] Failed to write state:', err) - }) - .finally(() => { - if (this.pendingWrite === trackedWrite) { - this.pendingWrite = null - } - }) - this.pendingWrite = trackedWrite - return write - } - - /** Wait for any in-flight async disk write to complete. Used in tests. */ - async waitForPendingWrite(): Promise { - await Promise.all([this.pendingWrite, this.activeViewPreference.waitForPendingWrite()]) - } - - // Why githubCache is omitted: memory-only this session (see getGithubCacheFile), so refreshes never touch the durable file. - private getDurableState(): Omit { - const { githubCache: _memoryOnly, ...durable } = this.state - return durable - } - - // Why: build payload synchronously so hash and bytes reflect one state tick. A degraded prefix makes the first healthy retry durable even when plaintext state is unchanged. - private buildStateToSave(): { - payload: string - stateHash: string - protectedSecretUpdates: ProtectedSecretRetentionUpdate[] - } { - // Why sentinels (not a blob/key string match): the substitution must be - // position-exact. A plain search for the ciphertext — or even for a - // `"key":"blob"` token — can be mimicked by user-controlled state (e.g. an - // agentDefaultEnv var named after a secret field, or a value equal to a - // ciphertext), which would substitute the wrong site and let two DISTINCT - // states normalize equal → a silently dropped write (data loss), reachable - // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot - // random UUID can't occur anywhere else in the serialized state (the user - // sets their data before it is minted), so it appears exactly once. - const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = [] - const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] - let protectedStorageDegraded = false - const encryptToSentinel = (slot: string, plaintext: string): string => { - const encrypted = this.protectedSecrets.encrypt(slot, plaintext) - if (encrypted.retentionUpdate) { - protectedSecretUpdates.push(encrypted.retentionUpdate) - } - protectedStorageDegraded ||= encrypted.degraded - const { blob, hashValue = plaintext } = encrypted - // Values already identical in payload and hash need no sentinel substitution. - if (blob === plaintext && hashValue === plaintext) { - return blob - } - const sentinel = `orca-secret-slot-${randomUUID()}` - secretSubs.push({ sentinel, blob, hashValue }) - return sentinel - } - const encryptOptionalToSentinel = ( - slot: string, - plaintext: string | null | undefined - ): string | null => { - const encrypted = encryptToSentinel(slot, plaintext ?? '') - return encrypted || null - } - // Why: clone before encrypting secrets so in-memory this.state stays plaintext. - const stateToSave = { - ...this.getDurableState(), - // Why both keys unconditionally: the explicit keys always win over the spread, and - // JSON.stringify drops the `undefined` value so a note-free profile gains no key on disk. - // The strip builds a new array here only; this.state records keep their notes in memory. - folderWorkspaces: (this.state.folderWorkspaces ?? []).map( - ({ diffComments: _relocated, ...rest }) => rest - ), - folderWorkspaceDiffComments: collectFolderWorkspaceDiffComments(this.state.folderWorkspaces), - sshPtyConsumerRecoveries: (this.state.sshPtyConsumerRecoveries ?? []).map((record) => ({ - ...record, - ownerLease: encryptToSentinel( - sshPtyOwnerLeaseSecretSlot(record.targetId), - record.ownerLease - ) - })), - settings: { - ...stripRetiredGlobalSettings(this.state.settings), - opencodeSessionCookie: encryptToSentinel( - PROTECTED_SECRET_SLOT.opencodeSessionCookie, - this.state.settings.opencodeSessionCookie - ), - httpProxyUrl: encryptToSentinel( - PROTECTED_SECRET_SLOT.httpProxyUrl, - this.state.settings.httpProxyUrl ?? '' - ) - }, - ui: { - ...this.state.ui, - browserKagiSessionLink: encryptOptionalToSentinel( - PROTECTED_SECRET_SLOT.browserKagiSessionLink, - this.state.ui.browserKagiSessionLink - ) - } - } - // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. - // One full-state stringify; secret slots currently hold sentinels. - const serialized = JSON.stringify(stateToSave) - // Substitute each unique sentinel exactly once: ciphertext for the on-disk - // payload, a stable normalized value for the guard hash. Function-form - // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped - // in `serialized`, so each replace is byte-for-byte position-exact. - let payload = serialized - let hashInput = serialized - for (const { sentinel, blob, hashValue } of secretSubs) { - const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) - payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) - hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) - } - const stateHash = createHash('sha1') - .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '') - .update(hashInput) - .digest('hex') - return { payload, stateHash, protectedSecretUpdates } - } - - // Why: async writes avoid blocking the main Electron thread on every debounced save. - private async writeToDiskAsync(): Promise { - if (this.writesFrozen) { - return - } - const gen = this.writeGeneration - const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() - // Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted. - if (stateHash === this.lastWrittenStateHash) { - this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) - return - } - const dataFile = this.dataFile - const dir = dirname(dataFile) - await mkdir(dir, { recursive: true }).catch(() => {}) - const tmpFile = durableWriteTempPath(dataFile) - - // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. - let renamed = false - try { - // Why: fsync before rename, then fsync the directory; see writeFileDurable. - const handle = await open(tmpFile, 'w') - try { - await handle.writeFile(payload, 'utf-8') - await handle.sync() - } finally { - await handle.close() - } - // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. - if (this.writeGeneration !== gen) { - return - } - this.inFlightAsyncTmpFile = tmpFile - try { - await renameDurable(tmpFile, dataFile) - renamed = true - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || this.writeGeneration === gen) { - throw error - } - } finally { - if (this.inFlightAsyncTmpFile === tmpFile) { - this.inFlightAsyncTmpFile = null - } - } - // Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard. - if (renamed && this.writeGeneration === gen) { - this.lastWrittenStateHash = stateHash - this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) - } else if (renamed) { - this.lastWrittenStateHash = null - } - if (renamed) { - this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen) - } - } finally { - if (!renamed) { - await rm(tmpFile).catch(() => {}) - } - } - if (!renamed) { - return - } - // Why (#1158): rotate only after the primary rename while this write still owns its generation. - if (this.writeGeneration !== gen) { - return - } - await this.rotateBackupsAsync(dataFile) - } - - // Why: sync variant only for flush() at shutdown, where the process may exit before an async write completes. - private writeToDiskSync(opts: { force?: boolean; skipBackupRotation?: boolean } = {}): void { - if (this.writesFrozen) { - return - } - const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave() - // Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check. - if (!opts.force && stateHash === this.lastWrittenStateHash) { - return - } - const dataFile = this.dataFile - const dir = dirname(dataFile) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } - const tmpFile = `${dataFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` - - // Why: on any write/rename failure, remove the tmp file so shutdown crashes don't leak orphans. - let renamed = false - try { - // Why: fsync the temp file and the directory; a bare rename can survive as stale or empty - // content after power loss, losing projects/tabs back to the newest usable .bak slot. - writeFileDurableSync(tmpFile, dataFile, payload) - renamed = true - this.lastWrittenStateHash = stateHash - this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates) - this.lastDurableWriteGeneration = Math.max( - this.lastDurableWriteGeneration, - this.writeGeneration - ) - } finally { - if (!renamed) { - try { - unlinkSync(tmpFile) - } catch { - // Best-effort cleanup; the write already failed, swallow secondary error. - } - } - } - const now = Date.now() - if (!opts.skipBackupRotation && this.shouldRotateBackups(now, dataFile)) { - this.rotateBackupsSync(dataFile) - } - } - - flushOrThrow(): void { - if (this.quitFlushStarted) { - throw new Error('Cannot synchronously flush after final persistence has started') - } - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - this.firstPendingSaveAt = null - const asyncWriteWasInFlight = this.pendingWrite !== null - // Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write. - this.writeGeneration++ - if (this.inFlightAsyncTmpFile) { - try { - unlinkSync(this.inFlightAsyncTmpFile) - this.inFlightAsyncTmpFile = null - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - void this.enqueueWrite().catch(() => {}) - throw error - } - } - } - // Why: later async flushes must remain serialized behind the invalidated writer. - this.writeToDiskSync({ - force: asyncWriteWasInFlight, - skipBackupRotation: this.backupRotationInFlight - }) - } - - flushActiveViewPreferenceOrThrow(): void { - this.activeViewPreference.flushOrThrow() - } - - getCodexResetCreditAttemptLedger(): CodexResetCreditAttemptLedger { - return parseCodexResetCreditAttemptLedger(this.state.codexResetCreditAttemptLedger) - } - - replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void { - if (this.writesFrozen) { - throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen') - } - const next = parseCodexResetCreditAttemptLedger(ledger) - const previous = this.state.codexResetCreditAttemptLedger - ? structuredClone(this.state.codexResetCreditAttemptLedger) - : undefined - this.state.codexResetCreditAttemptLedger = next - try { - this.flushOrThrow() - } catch (error) { - // Why: callers use a successful return as the durability barrier before - // handing a scarce-credit mutation to the provider. - this.state.codexResetCreditAttemptLedger = previous - throw error - } - } - - // ── Repos ────────────────────────────────────────────────────────── - getProfileStorageDirectory(): string { - return dirname(this.dataFile) + return dirname(this.runtime.dataFile) } - private projectHostOperations: ProjectHostPersistenceOperations | null = null - - private getProjectHostOperations(): ProjectHostPersistenceOperations { - this.projectHostOperations ??= new ProjectHostPersistenceOperations({ - state: this.state, - gitUsernameCache: this.gitUsernameCache, - hydrateRepo: (repo) => this.hydrateRepo(repo), - updateRepoBackedProjectHostSetup: (setup, repo, updates) => - this.updateRepoBackedProjectHostSetup(setup, repo, updates), - updateIndependentProjectHostSetup: (setup, updates) => - this.updateIndependentProjectHostSetup(setup, updates), - removeProjectForHost: (id, hostId) => this.removeProjectForHost(id, hostId), - scheduleSave: () => this.scheduleSave() - }) - return this.projectHostOperations - } - - getRepos(): Repo[] { - return this.getProjectHostOperations().getRepos() - } - - getProjects(): Project[] { - return this.getProjectHostOperations().getProjects() - } - - updateProject(id: string, updates: ProjectUpdateArgs['updates']): Project | null { - return this.getProjectHostOperations().updateProject(id, updates) - } - - getProjectHostSetups(): ProjectHostSetup[] { - return this.getProjectHostOperations().getProjectHostSetups() - } - - createProjectHostSetup(args: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult | null { - return this.getProjectHostOperations().createProjectHostSetup(args) - } - - updateProjectHostSetup(args: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult | null { - return this.getProjectHostOperations().updateProjectHostSetup(args) - } - - deleteProjectHostSetup(args: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult | null { - return this.getProjectHostOperations().deleteProjectHostSetup(args) - } - - getRepoCount(): number { - return this.getProjectHostOperations().getRepoCount() - } - - getRepo(id: string): Repo | undefined { - return this.getProjectHostOperations().getRepo(id) - } - - setResolvedRepoGitUsername( - target: Pick, - username: string - ): boolean { - return this.getProjectHostOperations().setResolvedRepoGitUsername(target, username) - } - - private projectGroupOperations: ProjectGroupPersistenceOperations | null = null - - private getProjectGroupOperations(): ProjectGroupPersistenceOperations { - this.projectGroupOperations ??= new ProjectGroupPersistenceOperations({ - state: this.state, - scheduleSave: () => this.scheduleSave(), - removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => - this.removeWorkspaceLineageForFolderParent(folderWorkspaceId), - pruneMobileClientTabSelections: (matchesWorktreeId) => - this.pruneMobileClientTabSelections(matchesWorktreeId) - }) - return this.projectGroupOperations - } - - getProjectGroups(): ProjectGroup[] { - return this.getProjectGroupOperations().getProjectGroups() - } - - createProjectGroup( - input: Parameters[0] - ): ProjectGroup { - return this.getProjectGroupOperations().createProjectGroup(input) - } - - updateProjectGroup( - groupId: string, - updates: Parameters[1] - ): ProjectGroup | null { - return this.getProjectGroupOperations().updateProjectGroup(groupId, updates) - } - - deleteProjectGroup(groupId: string): boolean { - return this.getProjectGroupOperations().deleteProjectGroup(groupId) - } - - private folderWorkspaceOperations: FolderWorkspacePersistenceOperations | null = null - - private getFolderWorkspaceOperations(): FolderWorkspacePersistenceOperations { - this.folderWorkspaceOperations ??= new FolderWorkspacePersistenceOperations({ - state: this.state, - scheduleSave: () => this.scheduleSave(), - removeWorkspaceLineageForFolderParent: (folderWorkspaceId) => - this.removeWorkspaceLineageForFolderParent(folderWorkspaceId), - pruneMobileClientTabSelections: (matchesWorktreeId) => - this.pruneMobileClientTabSelections(matchesWorktreeId), - hydrateRepo: (repo) => this.hydrateRepo(repo) - }) - return this.folderWorkspaceOperations - } - - getFolderWorkspaces(): FolderWorkspace[] { - return this.getFolderWorkspaceOperations().getFolderWorkspaces() - } - - getFolderWorkspace(id: string): FolderWorkspace | undefined { - return this.getFolderWorkspaceOperations().getFolderWorkspace(id) - } - - createFolderWorkspace( - input: Parameters[0] - ): FolderWorkspace { - return this.getFolderWorkspaceOperations().createFolderWorkspace(input) - } - - updateFolderWorkspace( - id: string, - updates: Parameters[1] - ): FolderWorkspace | null { - return this.getFolderWorkspaceOperations().updateFolderWorkspace(id, updates) - } - - removeFolderWorkspace(id: string): boolean { - return this.getFolderWorkspaceOperations().removeFolderWorkspace(id) - } - - moveProjectToGroup(repoId: string, groupId: string | null, order?: number): Repo | null { - return this.getFolderWorkspaceOperations().moveProjectToGroup(repoId, groupId, order) - } - - private repoOrderOperations: RepoOrderPersistenceOperations | null = null - - private getRepoOrderOperations(): RepoOrderPersistenceOperations { - this.repoOrderOperations ??= new RepoOrderPersistenceOperations({ - state: this.state, - syncProjectHostSetupCompatibilityState: () => this.syncProjectHostSetupCompatibilityState(), - scheduleSave: () => this.scheduleSave() - }) - return this.repoOrderOperations - } - - addRepo(repo: Repo): void { - this.getRepoOrderOperations().addRepo(repo) - } - - reorderRepos(orderedIds: string[]): boolean { - return this.getRepoOrderOperations().reorderRepos(orderedIds) - } - - reorderReposForHost(orderedIds: string[], hostId: ExecutionHostId): boolean { - return this.getRepoOrderOperations().reorderReposForHost(orderedIds, hostId) - } - - removeProject(id: string): void { - this.state.repos = this.state.repos.filter((r) => r.id !== id) - this.syncProjectHostSetupCompatibilityState() - // Why: presets are repo-scoped and unreachable once the repo is gone, so drop them with it. - delete this.state.sparsePresetsByRepo[id] - delete this.state.retiredWorktreeNamesByRepo?.[id] - this.pruneWorktreeStateForRepo(id, null) - this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id) - this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( - this.state.workspaceSessionsByHostId, - id - ) - this.scheduleSave() - } - - // Why: the same repo id can exist on multiple execution hosts; remove only this host's row and metadata, never another host's. - removeProjectForHost(id: string, hostId: ExecutionHostId): void { - this.state.repos = this.state.repos.filter( - (r) => !(r.id === id && getRepoExecutionHostId(r) === hostId) - ) - const idStillPresent = this.state.repos.some((r) => r.id === id) - // Why: presets and retirements are repo-id-scoped (not host-scoped); drop them only when the last host's copy is gone. - if (!idStillPresent) { - delete this.state.sparsePresetsByRepo[id] - delete this.state.retiredWorktreeNamesByRepo?.[id] - } - this.syncProjectHostSetupCompatibilityState() - // Why: prune only this host's worktree metas if the id survives elsewhere; otherwise prune everything (matches removeProject). - this.pruneWorktreeStateForRepo(id, idStillPresent ? hostId : null) - if (!idStillPresent) { - this.state.workspaceSession = removeRepoFromWorkspaceSession(this.state.workspaceSession, id) - this.state.workspaceSessionsByHostId = removeRepoFromHostWorkspaceSessions( - this.state.workspaceSessionsByHostId, - id - ) - } else if (parseExecutionHostId(hostId)?.kind === 'runtime') { - const session = this.state.workspaceSessionsByHostId?.[hostId] - if (session) { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [hostId]: removeRepoFromWorkspaceSession(session, id) - } - } - } - this.scheduleSave() - } - - // Prune worktree meta/lineage for a repo id; hostId null prunes all entries, else only that host's (missing meta.hostId = local). - private pruneWorktreeStateForRepo(id: string, hostId: ExecutionHostId | null): void { - pruneWorktreeStateForRepoOperation(this.state, id, hostId, (matchesWorktreeId) => - this.pruneMobileClientTabSelections(matchesWorktreeId) - ) - } - - private pruneMobileClientTabSelections(matchesWorktreeId: (worktreeId: string) => boolean): void { - for (const [clientNavigationId, selectionsByWorktree] of Object.entries( - this.state.mobileClientTabSelectionsByDeviceId ?? {} - )) { - for (const worktreeId of Object.keys(selectionsByWorktree)) { - if (matchesWorktreeId(worktreeId)) { - delete selectionsByWorktree[worktreeId] - } - } - if (Object.keys(selectionsByWorktree).length === 0) { - delete this.state.mobileClientTabSelectionsByDeviceId?.[clientNavigationId] - } - } - } - - private repoUpdateOperations: RepoUpdatePersistenceOperations | null = null - - private getRepoUpdateOperations(): RepoUpdatePersistenceOperations { - this.repoUpdateOperations ??= new RepoUpdatePersistenceOperations({ - state: this.state, - syncProjectHostSetupCompatibilityState: () => this.syncProjectHostSetupCompatibilityState(), - scheduleSave: () => this.scheduleSave(), - hydrateRepo: (repo) => this.hydrateRepo(repo) - }) - return this.repoUpdateOperations - } - - updateRepo( - id: string, - updates: Partial< - Pick< - Repo, - | 'displayName' - | 'badgeColor' - | 'repoIcon' - | 'upstream' - | 'gitRemoteIdentity' - | 'hookSettings' - | 'worktreeBaseRef' - | 'worktreeBasePath' - | 'kind' - | 'executionHostId' - | 'symlinkPaths' - | 'issueSourcePreference' - | 'forkSyncMode' - | 'externalWorktreeVisibilityPromptDismissedAt' - | 'externalWorktreeInboxBaselinePaths' - | 'importedExternalWorktreePaths' - | 'customWorktreeVisibilitySources' - | 'worktreeVisibilitySourcePreferences' - | 'projectGroupId' - | 'projectGroupOrder' - | 'projectHostSetupMethod' - > - > & { - externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null - agentWorktreeVisibility?: Repo['agentWorktreeVisibility'] | null - sourceControlAi?: Repo['sourceControlAi'] | null - externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null - }, - hostId?: ExecutionHostId - ): Repo | null { - return this.getRepoUpdateOperations().updateRepo(id, updates, hostId) - } - - private syncProjectHostSetupCompatibilityState(): void { - const compatibilityState = mergeProjectHostSetupCompatibilityState(this.state, this.state.repos) - this.state.projects = compatibilityState.projects - this.state.projectHostSetups = compatibilityState.projectHostSetups - } - - private projectHostSetupOperations: ProjectHostSetupPersistenceOperations | null = null - - private getProjectHostSetupOperations(): ProjectHostSetupPersistenceOperations { - this.projectHostSetupOperations ??= new ProjectHostSetupPersistenceOperations({ - state: this.state, - updateRepo: (id, updates, hostId) => this.updateRepo(id, updates, hostId), - scheduleSave: () => this.scheduleSave() - }) - return this.projectHostSetupOperations - } - - private updateRepoBackedProjectHostSetup( - setup: ProjectHostSetup, - repo: Repo, - updates: ProjectHostSetupUpdateArgs['updates'] - ): { setup: ProjectHostSetup; repo: Repo } | null { - return this.getProjectHostSetupOperations().updateRepoBackedProjectHostSetup( - setup, - repo, - updates - ) - } - - private updateIndependentProjectHostSetup( - setup: ProjectHostSetup, - updates: ProjectHostSetupUpdateArgs['updates'] - ): ProjectHostSetup { - return this.getProjectHostSetupOperations().updateIndependentProjectHostSetup(setup, updates) - } - - private hydrateRepo(repo: Repo): Repo { - return hydrateRepoOperation(repo, this.gitUsernameCache) - } - - // ── Sparse Presets ───────────────────────────────────────────────── - - // ── Mobile client tab selections ────────────────────────────────── - - getMobileClientTabSelections(): PersistedMobileClientTabSelections { - return this.state.mobileClientTabSelectionsByDeviceId ?? {} - } - - setMobileClientTabSelections(next: PersistedMobileClientTabSelections): void { - this.state.mobileClientTabSelectionsByDeviceId = next - this.scheduleSave() - } - - getSparsePresets(repoId: string): SparsePreset[] { - return [...(this.state.sparsePresetsByRepo[repoId] ?? [])].sort((left, right) => - left.name.localeCompare(right.name) - ) - } - - saveSparsePreset(preset: SparsePreset): SparsePreset { - const existing = this.state.sparsePresetsByRepo[preset.repoId] ?? [] - const index = existing.findIndex((entry) => entry.id === preset.id) - this.state.sparsePresetsByRepo[preset.repoId] = - index === -1 - ? [...existing, preset] - : existing.map((entry, i) => (i === index ? preset : entry)) - this.scheduleSave() - return preset - } - - removeSparsePreset(repoId: string, presetId: string): void { - const existing = this.state.sparsePresetsByRepo[repoId] ?? [] - this.state.sparsePresetsByRepo[repoId] = existing.filter((entry) => entry.id !== presetId) - this.scheduleSave() - } - - // ── Automations ─────────────────────────────────────────────────── - - private getAutomationDefinitionOperations(): AutomationDefinitionOperations { - return { - state: this.state, - flush: () => this.flush(), - recordCreated: () => this.recordFeatureInteraction('automation-created') - } - } - - private getAutomationRunOperations(): AutomationRunOperations { - return { - state: this.state, - flush: () => this.flush(), - recordManualRun: () => this.recordFeatureInteraction('automation-run'), - getWorkspaceDisplayName: (workspaceId) => - this.getAutomationRunWorkspaceDisplayName(workspaceId) - } - } - - listAutomations(): Automation[] { - return listAutomationsOperation(this.state) - } - - listAutomationRuns(automationId?: string): AutomationRun[] { - return listAutomationRunsOperation(this.state, automationId) - } - - createAutomation(input: AutomationCreateInput): Automation { - return createAutomationOperation(this.getAutomationDefinitionOperations(), input) - } - - updateAutomation(id: string, updates: AutomationUpdateInput): Automation { - return updateAutomationOperation(this.getAutomationDefinitionOperations(), id, updates) - } - - deleteAutomation(id: string): void { - deleteAutomationOperation(this.getAutomationDefinitionOperations(), id) - } - - createAutomationRun( - automation: Automation, - scheduledFor: number, - trigger: AutomationRunTrigger = 'scheduled' - ): AutomationRun { - return createAutomationRunOperation( - this.getAutomationRunOperations(), - automation, - scheduledFor, - trigger - ) - } - - updateAutomationRun(result: AutomationDispatchResult): AutomationRun { - return updateAutomationRunOperation(this.getAutomationRunOperations(), result) - } - - snapshotAutomationRunWorkspaceDisplayName(workspaceId: string, displayName: string): number { - return snapshotAutomationRunWorkspaceDisplayNameOperation( - this.getAutomationRunOperations(), - workspaceId, - displayName - ) - } - - private getAutomationRunWorkspaceDisplayName( - workspaceId: string | null | undefined - ): string | null { - if (!workspaceId) { - return null - } - return normalizeAutomationRunWorkspaceDisplayName( - this.state.worktreeMeta[workspaceId]?.displayName ?? - getWorktreePathBasenameFromId(workspaceId) - ) - } - - advanceAutomationNextRun(id: string, now = Date.now()): Automation { - return advanceAutomationNextRunOperation(this.state, () => this.flush(), id, now) - } - - getLatestAutomationOccurrence(automation: Automation, now = Date.now()): number | null { - return getLatestAutomationOccurrenceOperation(automation, now) - } - - // ── Worktree Meta ────────────────────────────────────────────────── - - getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined { - return this.state.worktreeMeta[worktreeId] - } - - getAllWorktreeMeta(): Record { - return this.state.worktreeMeta - } - - setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { - const existing = this.state.worktreeMeta[worktreeId] || getDefaultWorktreeMeta() - const updated = { ...existing, ...meta } - updated.linkedWorkItem = normalizeWorkspaceLinkedItem(updated.linkedWorkItem) - const linkedTaskSourceContext = normalizeStoredTaskSourceContext( - updated.linkedTaskSourceContext - ) - updated.linkedTaskSourceContext = isWorkspaceLinkedItemSourceContextMatch( - updated.linkedWorkItem, - linkedTaskSourceContext - ) - ? linkedTaskSourceContext - : null - if (!updated.instanceId) { - updated.instanceId = randomUUID() - } - this.state.worktreeMeta[worktreeId] = updated - this.scheduleSave() - return updated - } - - removeWorktreeMeta(worktreeId: string, hostId?: ExecutionHostId | null): void { - // A host-qualified removal names the owner; the persisted host is the fallback. - const persistedOwner = this.state.worktreeMeta[worktreeId]?.hostId - const owner = hostId ?? persistedOwner - const preservesDifferentPersistedOwner = Boolean( - hostId && persistedOwner && persistedOwner !== hostId - ) - const ownerPartition = workspaceSessionOwnerPartitionForHost(owner) - const preservesSameIdSessionOwner = Boolean( - preservesDifferentPersistedOwner || - (owner && - hasWorktreeRemovalRepoOwnerOnOtherHost( - this, - getRepoIdFromWorktreeId(worktreeId), - ownerPartition - )) - ) - // Skip partitions main never wrote: materializing one fences every sibling worktree of the repo. - const partitions = new Set( - workspaceSessionPartitionIdsForHost(owner).filter( - (partition) => - this.hasPersistedWorkspaceSession(partition) && - // The local partition can be a remote spill surface or a same-id owner. - // Preserve it whenever another owner may still use the bare id. - (!preservesSameIdSessionOwner || partition === ownerPartition) - ) - ) - // A repo-wide fence must not rebase a sibling's unpersisted tabs onto main's copy, and a spill - // partition that never held this worktree has no claim on the repo at all. - const fencedPartitions = new Set( - [...partitions].filter( - (partition) => - this.partitionOwnsWorktreeTabs(worktreeId, partition) || - (partition === ownerPartition && - !this.partitionHasOtherRepoWorktreeTabs(worktreeId, partition)) - ) - ) - if (!preservesDifferentPersistedOwner) { - delete this.state.worktreeMeta[worktreeId] - delete this.state.worktreeLineageById[worktreeId] - delete this.state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)] - } - for (const partition of partitions) { - this.removeWorkspaceSessionOwnerInPartition(worktreeId, partition, { - advanceTerminalTopologyRevision: fencedPartitions.has(partition) - }) - } - this.scheduleSave() - } - - getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined { - return this.state.worktreeLineageById[worktreeId] - } - - getAllWorktreeLineage(): Record { - return this.state.worktreeLineageById - } - - setWorktreeLineage(worktreeId: string, lineage: WorktreeLineage): WorktreeLineage { - this.state.worktreeLineageById[worktreeId] = lineage - this.scheduleSave() - return lineage - } - - removeWorktreeLineage(worktreeId: string): void { - delete this.state.worktreeLineageById[worktreeId] - this.scheduleSave() - } - - /** - * Re-key every worktreeId-keyed record from `oldWorktreeId` to `newWorktreeId` after the worktree folder (and its - * `${repoId}::${path}` id) was renamed on disk, so a refresh re-binds state instead of orphaning it. Records the old id on - * the new meta's `priorWorktreeIds` so session GC/hydration still recognizes PTY sessions minted under it. No-op when ids match. - * Renderer counterpart: `buildWorktreeRenameState` in store/slices/worktrees.ts. - */ - migrateWorktreeIdentity(oldWorktreeId: string, newWorktreeId: string): void { - if (migrateWorktreeIdentityOperation(this.state, oldWorktreeId, newWorktreeId)) { - this.scheduleSave() - } - } - - getWorkspaceLineage(childWorkspaceKey: WorkspaceKey): WorkspaceLineage | undefined { - return this.state.workspaceLineageByChildKey[childWorkspaceKey] - } - - getAllWorkspaceLineage(): Record { - return this.state.workspaceLineageByChildKey - } - - setWorkspaceLineage(lineage: WorkspaceLineage): WorkspaceLineage { - this.state.workspaceLineageByChildKey[lineage.childWorkspaceKey] = lineage - this.scheduleSave() - return lineage - } - - removeWorkspaceLineage(childWorkspaceKey: WorkspaceKey): void { - delete this.state.workspaceLineageByChildKey[childWorkspaceKey] - this.scheduleSave() - } - - private removeWorkspaceLineageForFolderParent(folderWorkspaceId: string): void { - const parentKey = folderWorkspaceKey(folderWorkspaceId) - for (const [childKey, lineage] of Object.entries(this.state.workspaceLineageByChildKey)) { - if (lineage.parentWorkspaceKey === parentKey) { - delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] - } - } - } - - // ── Settings ─────────────────────────────────────────────────────── - - getSettings(): GlobalSettings { - return this.state.settings - } - - onSettingsChanged( - listener: ( - updates: Partial, - settings: GlobalSettings, - originWebContentsId?: number - ) => void - ): () => void { - this.settingsChangeListeners.add(listener) - return () => { - this.settingsChangeListeners.delete(listener) - } - } - - private notifySettingsChanged( - updates: Partial, - originWebContentsId?: number - ): void { - for (const listener of this.settingsChangeListeners) { - listener(updates, this.state.settings, originWebContentsId) - } - } - - // Why: renderer-visible UI state is written from desktop and mobile, so notify to keep bi-directional sync. - onUIChanged(listener: (ui: PersistedState['ui']) => void): () => void { - this.uiChangeListeners.add(listener) - return () => { - this.uiChangeListeners.delete(listener) - } - } - - private notifyUIChanged(): void { - if (this.uiChangeListeners.size === 0) { - return - } - const ui = this.getUI() - for (const listener of this.uiChangeListeners) { - listener(ui) - } - } - - private getSettingsMutationOperations(): SettingsMutationOperations { - return { - state: this.state, - removeRetainedBlob: (slot) => this.protectedSecrets.removeRetainedBlob(slot), - scheduleSave: () => this.scheduleSave(), - notifySettingsChanged: (updates, originWebContentsId) => - this.notifySettingsChanged(updates, originWebContentsId) - } - } - - updateSettings( - updates: Partial, - options: { notifyListeners?: boolean; originWebContentsId?: number } = {} - ): GlobalSettings { - return updateSettingsOperation(this.getSettingsMutationOperations(), updates, options) - } - - // ── UI State ─────────────────────────────────────────────────────── - - private getUIUpdateOperations(): UIUpdateOperations { - return { - state: this.state, - removeRetainedBlob: (slot) => this.protectedSecrets.removeRetainedBlob(slot), - setActiveView: (activeView) => this.activeViewPreference.set(activeView), - getUI: () => this.getUI(), - scheduleSave: () => this.scheduleSave(), - notifyUIChanged: () => this.notifyUIChanged() - } - } - - getUI(): PersistedState['ui'] { - return getPersistedUI(this.state, this.activeViewPreference.get()) - } - - updateUI(updates: Partial): void { - updatePersistedUI(this.getUIUpdateOperations(), updates) - } - - private getFeatureInteractionOperations(): FeatureInteractionOperations { - return { - state: this.state, - scheduleSave: () => this.scheduleSave(), - notifyUIChanged: () => this.notifyUIChanged(), - getUI: () => this.getUI() - } - } - - recordFeatureInteraction(id: FeatureInteractionId): PersistedState['ui'] { - return recordFeatureInteractionOperation(this.getFeatureInteractionOperations(), id) - } - - // ── Onboarding ──────────────────────────────────────────────────── - - getOnboarding(): PersistedState['onboarding'] { - const defaults = getDefaultOnboardingState() - return { - ...defaults, - ...this.state.onboarding, - checklist: { - ...defaults.checklist, - ...this.state.onboarding?.checklist - } - } - } - - updateOnboarding( - updates: Partial> & { - checklist?: Partial - } - ): PersistedState['onboarding'] { - const current = this.getOnboarding() - this.state.onboarding = { - ...current, - ...updates, - checklist: { - ...current.checklist, - ...updates.checklist - } - } - this.scheduleSave() - return this.getOnboarding() - } - - // ── GitHub Cache ────────────────────────────────────────────────── - - getGitHubCache(): PersistedState['githubCache'] { - return this.state.githubCache - } - - setGitHubCache(cache: PersistedState['githubCache']): void { - // Why no scheduleSave: cache is memory-only and snapshotted to a sidecar at flush; persisting here rewrote the whole state file every poll cycle. - this.state.githubCache = cache - this.githubCacheDirty = true - this.githubCacheGeneration += 1 - } - - // ── Workspace Session ───────────────────────────────────────────── - - /** Resolve an execution host argument to a canonical id; unknown/empty falls back to 'local' for legacy callers. */ - private resolveHostId(hostId?: string | null): ExecutionHostId { - return normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID - } - - getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] { - const resolved = this.resolveHostId(hostId) - if (resolved === LOCAL_EXECUTION_HOST_ID) { - return this.state.workspaceSession ?? getDefaultWorkspaceSession() - } - return this.state.workspaceSessionsByHostId?.[resolved] ?? getDefaultWorkspaceSession() - } - - /** Whether a partition was ever written; `getWorkspaceSession` defaults absent ones and cannot tell them apart. */ - private hasPersistedWorkspaceSession(hostId: ExecutionHostId): boolean { - return ( - hostId === LOCAL_EXECUTION_HOST_ID || - this.state.workspaceSessionsByHostId?.[hostId] !== undefined - ) - } - - getWorkspaceSessionHostIds(): ExecutionHostId[] { - const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) - for (const key of Object.keys(this.state.workspaceSessionsByHostId ?? {})) { - const hostId = normalizeExecutionHostId(key) - if (hostId) { - hostIds.add(hostId) - } - } - return [...hostIds] - } - - readTerminalScrollbackSnapshot(ref: string): string | null { - return readTerminalScrollbackSnapshotSync(ref, this.terminalScrollbackSnapshotStorage) - } - - /** Resolve the worktree a terminal tab belongs to; more reliable than agent-echoed hook fields. */ - getWorktreeIdForTab(tabId: string): string | undefined { - return findWorktreeIdForTab(this.getWorkspaceSession(), tabId) - } - - setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void { - const resolved = this.resolveHostId(hostId) - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.setLocalWorkspaceSession(session) - return - } - this.setHostWorkspaceSession(resolved, session) - } - - removeWorkspaceSessionStateForWorktree( - worktreeId: string, - hostId?: ExecutionHostId | null, - options: { advanceTerminalTopologyRevision?: boolean } = {} - ): void { - for (const resolved of workspaceSessionPartitionIdsForHost(hostId)) { - this.removeWorkspaceSessionOwnerInPartition(worktreeId, resolved, options) - } - } - - private removeWorkspaceSessionOwnerInPartition( - worktreeId: string, - resolved: ExecutionHostId, - options: { advanceTerminalTopologyRevision?: boolean } - ): void { - if (!this.hasPersistedWorkspaceSession(resolved)) { - return - } - const current = this.getWorkspaceSession(resolved) - const session = removeWorkspaceSessionOwner(current, worktreeId, { - advanceTerminalTopologyRevision: options.advanceTerminalTopologyRevision ?? true - }) - if (!session) { - return - } - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSession = session - } else { - // Host scoping matters because identical repo/path ids may exist on two servers. - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolved]: session - } - } - this.scheduleSave() - } - - /** Whether a partition still holds terminal membership for `worktreeId`. */ - private partitionOwnsWorktreeTabs(worktreeId: string, hostId: ExecutionHostId): boolean { - return this.getWorkspaceSession(hostId).tabsByWorktree?.[worktreeId] !== undefined - } - - /** Whether fencing this partition would rebase a sibling worktree of the same repo. */ - private partitionHasOtherRepoWorktreeTabs(worktreeId: string, hostId: ExecutionHostId): boolean { - const repoId = getRepoIdFromWorktreeId(worktreeId) - const tabsByWorktree = this.getWorkspaceSession(hostId).tabsByWorktree ?? {} - return Object.entries(tabsByWorktree).some( - ([id, tabs]) => - id !== worktreeId && getRepoIdFromWorktreeId(id) === repoId && (tabs?.length ?? 0) > 0 - ) - } - - stageWorkspaceSessionBeforeUnload( - session: PersistedState['workspaceSession'], - hostId?: string | null - ): void { - const resolved = this.resolveHostId(hostId) - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.setLocalWorkspaceSession(session, true) - return - } - this.setHostWorkspaceSession(resolved, session) - } - - /** Persist a non-'local' host partition; remote hosts skip setLocalWorkspaceSession's local-daemon PTY-binding race guards. */ - private setHostWorkspaceSession(hostId: ExecutionHostId, session: WorkspaceSessionState): void { - // Why: each partition owns its topology fence; renderer writes omit it and must rebase locally. - session = sanitizeWorkspaceSessionTerminalRetirements( - session, - this.state.workspaceSessionsByHostId?.[hostId] - ) - const pruned = pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - ) - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [hostId]: pruned - } - this.scheduleSave() - } - - private setLocalWorkspaceSession( - session: PersistedState['workspaceSession'], - deferSnapshotFiles = false - ): void { - const prior = this.state.workspaceSession - session = sanitizeWorkspaceSessionTerminalRetirements(session, prior) - session = pruneWorkspaceSessionBrowserHistory( - pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - ) - - // Why (Issue #217): merge existing bindings when the incoming binding is empty, so a stale pre-spawn snapshot can't overwrite the durable PTY binding. - const normalized = normalizeWorkspaceSessionPaneIdentities( - session, - prior?.terminalLayoutsByTabId - ) - for (const entry of normalized.migrationUnsupportedEntries) { - setMigrationUnsupportedPty(entry) - } - const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys( - this.state.ui?.acknowledgedAgentsByPaneKey, - normalized.leafIdByInputLeafIdByTabId - ) - if (remappedAcknowledgements.changed) { - this.state.ui = { - ...this.state.ui, - acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements - } - } - for (const entry of normalized.legacyPaneKeyAliasEntries) { - registerPersistedPaneKeyAlias(entry) - } - session = normalized.session - const remapsByHostId = new Map([ - [LOCAL_EXECUTION_HOST_ID, normalized] - ]) - const remappedLeases = remapSshRemotePtyLeaseLeafIds( - this.state.sshRemotePtyLeases ?? [], - remapsByHostId, - new Set(this.getWorkspaceSessionHostIds()) - ) - if (remappedLeases.changed) { - this.state.sshRemotePtyLeases = remappedLeases.leases - } - if (session && prior) { - const priorTabs = prior.tabsByWorktree ?? {} - const nextTabs = session.tabsByWorktree ?? {} - const worktreeIdByTabId = new Map() - for (const [worktreeId, tabs] of Object.entries({ ...priorTabs, ...nextTabs })) { - for (const tab of tabs) { - worktreeIdByTabId.set(tab.id, worktreeId) - } - } - for (const [worktreeId, tabs] of Object.entries(nextTabs)) { - const priorList = priorTabs[worktreeId] - if (!priorList) { - continue - } - for (const tab of tabs) { - if (tab.ptyId) { - continue - } - const priorTab = priorList.find((t) => t.id === tab.id) - if ( - priorTab?.ptyId && - this.isRestorablePtyBinding({ - ptyId: priorTab.ptyId, - worktreeId, - targetId: this.getConnectionIdForWorktree(worktreeId), - tabId: tab.id - }) - ) { - tab.ptyId = priorTab.ptyId - } - } - } - const priorLayouts = prior.terminalLayoutsByTabId ?? {} - const nextLayouts = session.terminalLayoutsByTabId ?? {} - for (const [tabId, layout] of Object.entries(nextLayouts)) { - const priorLayout = priorLayouts[tabId] - if (!priorLayout?.ptyIdsByLeafId) { - continue - } - const incoming = layout.ptyIdsByLeafId ?? {} - const incomingHasAnyBinding = Object.keys(incoming).length > 0 - const liveLeafIds = this.getTerminalLayoutLeafIds(layout.root) - const worktreeId = worktreeIdByTabId.get(tabId) - const targetId = worktreeId ? this.getConnectionIdForWorktree(worktreeId) : null - const restorableBindings = Object.fromEntries( - Object.entries(priorLayout.ptyIdsByLeafId).filter( - ([leafId, ptyId]) => - liveLeafIds.has(leafId) && - incoming[leafId] === undefined && - // Why: an empty layout map may be a stale pre-spawn snapshot; a partial map is intentional unless a durable SSH lease proves it. - (incomingHasAnyBinding - ? this.hasRestorableSshRemotePtyLease({ - ptyId, - targetId, - worktreeId, - tabId, - leafId - }) - : this.isRestorablePtyBinding({ ptyId, targetId, worktreeId, tabId, leafId })) - ) - ) - if (Object.keys(restorableBindings).length > 0) { - layout.ptyIdsByLeafId = { ...restorableBindings, ...incoming } - // Why: the same stale write that drops ptyIdsByLeafId may come from an older renderer lacking UUID-keyed metadata. - const buffersByLeafId = preserveMissingLeafRecordEntries( - priorLayout.buffersByLeafId, - layout.buffersByLeafId, - liveLeafIds - ) - const scrollbackRefsByLeafId = preserveMissingLeafRecordEntries( - priorLayout.scrollbackRefsByLeafId, - layout.scrollbackRefsByLeafId, - liveLeafIds - ) - const titlesByLeafId = preserveMissingLeafRecordEntries( - priorLayout.titlesByLeafId, - layout.titlesByLeafId, - liveLeafIds - ) - if (buffersByLeafId) { - layout.buffersByLeafId = buffersByLeafId - } - if (scrollbackRefsByLeafId) { - layout.scrollbackRefsByLeafId = scrollbackRefsByLeafId - } - if (titlesByLeafId) { - layout.titlesByLeafId = titlesByLeafId - } - } - } - } - session = pruneLocalTerminalScrollbackBuffers(session, this.state.repos) - if (!deferSnapshotFiles) { - const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( - session, - this.terminalScrollbackSnapshotStorage - ) - session = migratedScrollback.session - deleteRemovedTerminalScrollbackSnapshots( - prior, - session, - this.terminalScrollbackSnapshotStorage - ) - } - this.state.workspaceSession = session - if (deferSnapshotFiles) { - this.enqueueTerminalScrollbackSnapshotWork(prior, session) - } - this.scheduleSave() - } - - private enqueueTerminalScrollbackSnapshotWork( - prior: WorkspaceSessionState | undefined, - staged: WorkspaceSessionState - ): void { - const previous = this.pendingSnapshotFileWork ?? Promise.resolve() - const work = previous - .then(async () => { - if (this.state.workspaceSession !== staged) { - if (this.state.workspaceSession) { - await deleteRemovedTerminalScrollbackSnapshotsAsync( - prior, - this.state.workspaceSession, - this.terminalScrollbackSnapshotStorage - ) - } - return - } - const migrated = await migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync( - staged, - this.terminalScrollbackSnapshotStorage - ) - const current = - this.state.workspaceSession === staged ? migrated : this.state.workspaceSession - if (this.state.workspaceSession === staged) { - this.state.workspaceSession = migrated - } else if (current) { - await deleteRemovedTerminalScrollbackSnapshotsAsync( - migrated, - current, - this.terminalScrollbackSnapshotStorage - ) - } - if (current) { - await deleteRemovedTerminalScrollbackSnapshotsAsync( - prior, - current, - this.terminalScrollbackSnapshotStorage - ) - } - }) - .catch((error) => { - console.error('[terminal-scrollback] Failed to prepare unload snapshots:', error) - }) - .finally(() => { - if (this.pendingSnapshotFileWork === work) { - this.pendingSnapshotFileWork = null - } - }) - this.pendingSnapshotFileWork = work - } - - patchWorkspaceSession(patch: WorkspaceSessionPatch, hostId?: string | null): void { - const resolved = this.resolveHostId(hostId) - // Why: the debounced hot path sends only changed slices; scalar/UI patches skip terminal normalization, topology patches keep stale-PTY protections. - let next: WorkspaceSessionState = { - ...this.getWorkspaceSession(resolved), - ...patch - } - if (workspaceSessionPatchNeedsFullNormalization(patch)) { - this.setWorkspaceSession(next, resolved) - return - } - if (Object.hasOwn(patch, 'browserUrlHistory')) { - next = pruneWorkspaceSessionBrowserHistory(next) - } - if (resolved === LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSession = next - } else { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolved]: next - } - } - this.scheduleSave() - } - - private getTerminalLayoutLeafIds(root: TerminalPaneLayoutNode | null): Set { - const leafIds = new Set() - const visit = (node: TerminalPaneLayoutNode | null): void => { - if (!node) { - return - } - if (node.type === 'leaf') { - if (isTerminalLeafId(node.leafId)) { - leafIds.add(node.leafId) - } - return - } - visit(node.first) - visit(node.second) - } - visit(root) - return leafIds - } - - private isRestorablePtyBinding(binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - }): boolean { - const leases = this.state.sshRemotePtyLeases?.filter((entry) => - this.sshRemotePtyLeaseMatchesBinding(entry, binding) - ) - return !leases?.some((lease) => lease.state === 'terminated' || lease.state === 'expired') - } - - private getRelayPtyIdForSshLeaseComparison(targetId: string, ptyId: string): string { - try { - return toRelaySshPtyId(targetId, ptyId) - } catch { - return ptyId - } - } - - private getRelayPtyIdForSshLeaseStorage(targetId: string, ptyId: string): string { - return toRelaySshPtyId(targetId, ptyId) - } - - private sshRemotePtyLeaseMatchesBinding( - lease: SshRemotePtyLease, - binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - } - ): boolean { - const bindingPtyId = this.getRelayPtyIdForSshLeaseComparison(lease.targetId, binding.ptyId) - if (lease.ptyId !== bindingPtyId) { - return false - } - // Why: remote PTY ids are scoped to a relay target; require stored lease context to match so missing fields don't tombstone unrelated panes. - return ( - (binding.targetId === undefined || - binding.targetId === null || - lease.targetId === binding.targetId) && - (binding.worktreeId === undefined || lease.worktreeId === binding.worktreeId) && - (binding.tabId === undefined || lease.tabId === binding.tabId) && - (binding.leafId === undefined || lease.leafId === binding.leafId) - ) - } - - private hasRestorableSshRemotePtyLease(binding: { - ptyId: string - targetId?: string | null - worktreeId?: string - tabId?: string - leafId?: string - }): boolean { - return ( - this.state.sshRemotePtyLeases?.some( - (lease) => - this.sshRemotePtyLeaseMatchesBinding(lease, binding) && - lease.state !== 'terminated' && - lease.state !== 'expired' - ) ?? false - ) - } - - private getConnectionIdForWorktree(worktreeId: string): string | null { - const repoId = getRepoIdFromWorktreeId(worktreeId) - return this.state.repos.find((repo) => repo.id === repoId)?.connectionId ?? null - } - - // Why: sync-flush the pty binding before pty:spawn returns to close the spawn/persist SIGKILL race (Issue #217). - persistPtyBinding( - args: { - worktreeId: string - tabId: string - leafId: string - ptyId: string - incarnationId?: string - startupCwd?: string - expectedBinding?: { ptyId: string; incarnationId?: string } - expectedSourceBinding?: PtyBindingSourceExpectation - /** Set by host-initiated creates, which have no renderer session writer behind them. */ - hostAdmittedMembership?: boolean - }, - hostId?: string | null - ): boolean { - const resolvedHostId = this.resolveHostId(hostId) - const session = this.getWorkspaceSession(resolvedHostId) - const paneKey = `${args.tabId}:${args.leafId}` - const bindingWorktreeId = args.expectedSourceBinding?.worktreeId ?? args.worktreeId - if (args.expectedSourceBinding) { - const expected = args.expectedSourceBinding - if (expected.tabId !== args.tabId) { - return false - } - const sourceTab = session.tabsByWorktree?.[bindingWorktreeId]?.find( - (candidate) => candidate.id === expected.tabId && candidate.worktreeId === bindingWorktreeId - ) - const sourceLayout = session.terminalLayoutsByTabId?.[expected.tabId] - const sourcePaneKey = `${expected.tabId}:${expected.leafId}` - if ( - !sourceTab || - sourceLayout?.ptyIdsByLeafId?.[expected.leafId] !== expected.ptyId || - !layoutContainsLeafId(sourceLayout.root, expected.leafId) || - (expected.incarnationId !== undefined && - session.terminalPtyIncarnationsByPaneKey?.[sourcePaneKey] !== expected.incarnationId) - ) { - return false - } - } - if (args.expectedBinding) { - const tab = session.tabsByWorktree?.[bindingWorktreeId]?.find( - (candidate) => candidate.id === args.tabId && candidate.worktreeId === bindingWorktreeId - ) - const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] - if ( - !tab || - boundPtyId !== args.expectedBinding.ptyId || - session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId - ) { - return false - } - } - if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolvedHostId]: session - } - } - const sessionBeforeBinding = cloneWorkspaceSessionState(session) - const reconciledIncarnation = - args.expectedBinding !== undefined && - args.incarnationId !== args.expectedBinding.incarnationId - let terminalMembershipChanged = false - let hostAdmittedTabCreated = false - const advanceTopologyFence = (): void => { - const repoId = getRepoIdFromWorktreeId(bindingWorktreeId) - const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - // Why: a split, or a host-admitted tab the renderer has never seen, is itself - // the authority — with no fence the renderer's pre-create tab list replays - // over it and the tab is lost even on the repo's first such change. - const establishesMembershipAuthority = - args.expectedSourceBinding !== undefined || hostAdmittedTabCreated - if ( - !reconciledIncarnation && - (!terminalMembershipChanged || (currentRevision <= 0 && !establishesMembershipAuthority)) - ) { - return - } - // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. - session.terminalTopologyRevisionByRepoId = { - ...session.terminalTopologyRevisionByRepoId, - [repoId]: currentRevision + 1 - } - } - const restoreSession = (): void => { - if (resolvedHostId === LOCAL_EXECUTION_HOST_ID) { - this.state.workspaceSession = sessionBeforeBinding - } else { - this.state.workspaceSessionsByHostId = { - ...this.state.workspaceSessionsByHostId, - [resolvedHostId]: sessionBeforeBinding - } - } - } - if (args.incarnationId) { - session.terminalPtyIncarnationsByPaneKey = { - ...session.terminalPtyIncarnationsByPaneKey, - [paneKey]: args.incarnationId - } - if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { - session.terminalSurfaceTombstonesByPaneKey = { - ...session.terminalSurfaceTombstonesByPaneKey - } - delete session.terminalSurfaceTombstonesByPaneKey[paneKey] - } - } - const tabs = session.tabsByWorktree?.[bindingWorktreeId] - const tab = tabs?.find((t) => t.id === args.tabId) - if (tab) { - tab.ptyId = args.ptyId - } else { - terminalMembershipChanged = true - hostAdmittedTabCreated = args.hostAdmittedMembership === true - // Why: pty:spawn can beat the debounced writer; persist a minimal tab so hydration won't prune the binding as orphaned. - const nextTabs = [ - ...(tabs ?? []), - createMinimalPersistedTerminalTab({ - ...args, - worktreeId: bindingWorktreeId, - existingTabCount: tabs?.length ?? 0 - }) - ] - session.tabsByWorktree = { - ...session.tabsByWorktree, - [bindingWorktreeId]: nextTabs - } - session.activeWorktreeId ??= bindingWorktreeId - session.activeTabId ??= args.tabId - session.activeTabIdByWorktree = { - ...session.activeTabIdByWorktree, - [bindingWorktreeId]: session.activeTabIdByWorktree?.[bindingWorktreeId] ?? args.tabId - } - } - if (!isTerminalLeafId(args.leafId)) { - // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. - advanceTopologyFence() - try { - this.flushOrThrow() - } catch (err) { - restoreSession() - throw err - } - return true - } - const layout = session.terminalLayoutsByTabId?.[args.tabId] - if (layout) { - if (!layout.root) { - terminalMembershipChanged = true - // Why: createTab can persist an empty layout before TerminalPane mounts; the sync binding still needs a durable root. - layout.root = { type: 'leaf', leafId: args.leafId } - layout.activeLeafId = args.leafId - layout.expandedLeafId = null - } else if (!layoutContainsLeafId(layout.root, args.leafId)) { - terminalMembershipChanged = true - // Why: splitPane spawns before its snapshot reaches main; add a minimal leaf so a crash can't strand the pane's binding. - layout.root = { - type: 'split', - direction: 'vertical', - first: cloneLayoutNode(layout.root), - second: { type: 'leaf', leafId: args.leafId } - } - layout.activeLeafId = args.leafId - if (layout.expandedLeafId && !layoutContainsLeafId(layout.root, layout.expandedLeafId)) { - layout.expandedLeafId = null - } - } - layout.ptyIdsByLeafId = { - ...layout.ptyIdsByLeafId, - [args.leafId]: args.ptyId - } - } else { - terminalMembershipChanged = true - // Why: first tab spawn — persist a minimal layout so a SIGKILL before the renderer snapshot can't lose ptyIdsByLeafId. - session.terminalLayoutsByTabId = { - ...session.terminalLayoutsByTabId, - [args.tabId]: { - root: { type: 'leaf', leafId: args.leafId }, - activeLeafId: args.leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [args.leafId]: args.ptyId } - } - } - } - advanceTopologyFence() - try { - this.flushOrThrow() - } catch (err) { - restoreSession() - throw err - } - return true - } - - // ── SSH Targets ──────────────────────────────────────────────────── - - private getSshTargetStateOperations(): SshTargetStateOperations { - return { - state: this.state, - protectedSecrets: this.protectedSecrets, - scheduleSave: () => this.scheduleSave(), - flush: () => this.flush() - } - } - - getSshTargets(): SshTarget[] { - return getSshTargetsOperation(this.state) - } - - getSshTarget(id: string): SshTarget | undefined { - return getSshTargetOperation(this.state, id) - } - - addSshTarget(target: SshTarget): void { - addSshTargetOperation(this.getSshTargetStateOperations(), target) - } - - updateSshTarget(id: string, updates: Partial>): SshTarget | null { - return updateSshTargetOperation(this.getSshTargetStateOperations(), id, updates) - } - - removeSshTarget(id: string): void { - removeSshTargetOperation(this.getSshTargetStateOperations(), id) - } - - // ── Live Claude PTY sessions ─────────────────────────────────────── - - getClaudeLivePtySessionIds(): string[] { - return getClaudeLivePtySessionIdsOperation(this.state) - } - - addClaudeLivePtySessionId(sessionId: string): void { - addClaudeLivePtySessionIdOperation(this.getSshTargetStateOperations(), sessionId) - } - - removeClaudeLivePtySessionId(sessionId: string): void { - removeClaudeLivePtySessionIdOperation(this.getSshTargetStateOperations(), sessionId) - } - - getRetiredWorktreeNameRegistry(repoId: string): RetiredNameRegistry { - const stored = this.state.retiredWorktreeNamesByRepo?.[repoId] - return stored - ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } - : EMPTY_RETIRED_NAME_REGISTRY - } - - getRetiredWorktreeNameRegistryForNamespace(namespaceKey: string): RetiredNameRegistry { - const stored = this.state.retiredWorktreeNamesByNamespace?.[namespaceKey] - return stored - ? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] } - : EMPTY_RETIRED_NAME_REGISTRY - } - - addRetiredWorktreeName(repoId: string, name: string): void { - const normalized = normalizeRetirableGeneratedName(name) - if (!repoId || !normalized) { - return - } - this.applyRetiredWorktreeNames(repoId, [normalized]) - } - - mergeRetiredWorktreeNames(repoId: string, names: Iterable): boolean { - if (!repoId) { - return false - } - const incoming = new Set() - for (const name of names) { - const normalized = normalizeRetirableGeneratedName(name) - if (normalized) { - incoming.add(normalized) - } - } - return incoming.size > 0 && this.applyRetiredWorktreeNames(repoId, incoming) - } - - mergeRetiredWorktreeNamesForNamespace(namespaceKey: string, names: Iterable): boolean { - if (!namespaceKey) { - return false - } - const normalized = new Set() - for (const name of names) { - const candidate = normalizeRetirableGeneratedName(name) - if (candidate) { - normalized.add(candidate) - } - } - const next = addRetiredNames( - this.getRetiredWorktreeNameRegistryForNamespace(namespaceKey), - normalized - ) - if (!next) { - return false - } - this.state.retiredWorktreeNamesByNamespace ??= {} - recordRetirementNamespaceRegistry( - this.state.retiredWorktreeNamesByNamespace, - namespaceKey, - next - ) - this.scheduleSave() - return true - } - - private applyRetiredWorktreeNames(repoId: string, names: Iterable): boolean { - const next = addRetiredNames(this.getRetiredWorktreeNameRegistry(repoId), names) - if (!next) { - return false - } - this.state.retiredWorktreeNamesByRepo ??= {} - this.state.retiredWorktreeNamesByRepo[repoId] = next - this.scheduleSave() - return true - } - - getDeletedSshConfigAliases(): string[] { - return getDeletedSshConfigAliasesOperation(this.state) - } - - addDeletedSshConfigAlias(alias: string): void { - addDeletedSshConfigAliasOperation(this.getSshTargetStateOperations(), alias) - } - - removeDeletedSshConfigAlias(alias: string): void { - removeDeletedSshConfigAliasOperation(this.getSshTargetStateOperations(), alias) - } - - clearDeletedSshConfigAliases(): void { - clearDeletedSshConfigAliasesOperation(this.getSshTargetStateOperations()) - } - - getRemovedSshTargetTombstones(): RemovedSshTargetTombstone[] { - return getRemovedSshTargetTombstonesOperation(this.state) - } - - addRemovedSshTargetTombstone(tombstone: RemovedSshTargetTombstone): void { - addRemovedSshTargetTombstoneOperation(this.getSshTargetStateOperations(), tombstone) - } - - removeRemovedSshTargetTombstone(oldTargetId: string): void { - removeRemovedSshTargetTombstoneOperation(this.getSshTargetStateOperations(), oldTargetId) - } - - /** - * Re-point every repo and worktree meta pinned to a removed SSH target id onto - * a re-added target's id so orphaned workspaces reattach. Returns re-pointed repo ids. - */ - reassignSshTargetId(oldTargetId: string, newTargetId: string): string[] { - const operations: SshTargetReassignmentOperations = { - state: this.state, - protectedSecrets: this.protectedSecrets, - syncProjectHostSetupCompatibilityState: () => this.syncProjectHostSetupCompatibilityState(), - scheduleSave: () => this.scheduleSave() - } - return reassignSshTargetIdOperation(operations, oldTargetId, newTargetId) - } - - // ── SSH PTY Consumer Recovery ────────────────────────────────────── - - private getSshPtyConsumerRecoveryOperations(): SshPtyConsumerRecoveryOperations { - return { - state: this.state, - protectedSecrets: this.protectedSecrets, - flushDurableStateOrThrowAsync: () => this.flushDurableStateOrThrowAsync() - } - } - - getSshPtyConsumerRecovery(targetId: string): SshPtyConsumerRecovery | null { - return getSshPtyConsumerRecoveryOperation(this.getSshPtyConsumerRecoveryOperations(), targetId) - } - - async upsertSshPtyConsumerRecovery(record: SshPtyConsumerRecovery): Promise { - await upsertSshPtyConsumerRecoveryOperation(this.getSshPtyConsumerRecoveryOperations(), record) - } - - async removeSshPtyConsumerRecovery(targetId: string): Promise { - await removeSshPtyConsumerRecoveryOperation( - this.getSshPtyConsumerRecoveryOperations(), - targetId - ) - } - - // ── SSH Remote PTY Leases ────────────────────────────────────────── - - private getSshPtyBindingCleanupOperations(): SshPtyBindingCleanupOperations { - return { - state: this.state, - toComparablePtyId: (targetId, ptyId) => - this.getRelayPtyIdForSshLeaseComparison(targetId, ptyId), - scheduleSave: () => this.scheduleSave() - } - } - - private getSshPtyLeaseOperations(): SshPtyLeaseOperations { - return { - state: this.state, - toStoredPtyId: (targetId, ptyId) => this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId), - clearBindingsForTarget: (targetId) => - clearSshRemotePtyBindingsForTargetOperation( - this.getSshPtyBindingCleanupOperations(), - targetId - ), - clearBindingsForLeases: (targetId, leases) => - clearSshRemotePtyBindingsForLeasesOperation( - this.getSshPtyBindingCleanupOperations(), - targetId, - leases - ), - flush: () => this.flush(), - flushDurableStateOrThrowAsync: () => this.flushDurableStateOrThrowAsync() - } - } - - getSshRemotePtyLeases(targetId?: string): SshRemotePtyLease[] { - return getSshRemotePtyLeasesOperation(this.state, targetId) - } - - upsertSshRemotePtyLease( - lease: Omit & - Partial> - ): void { - upsertSshRemotePtyLeaseOperation(this.getSshPtyLeaseOperations(), lease) - } - - markSshRemotePtyLeases(targetId: string, state: SshRemotePtyLease['state']): void { - markSshRemotePtyLeasesOperation(this.getSshPtyLeaseOperations(), targetId, state) - } - - // Why no write of its own: the committed quit path calls this immediately before the final store - // flush, and that flush is what persists it. A durable write here would race the flush and be - // rejected the moment it latches, which is exactly how an attached lease used to survive quit. - markSshRemotePtyLeasesForShutdown(targetId: string, state: SshRemotePtyLease['state']): void { - markSshRemotePtyLeasesForShutdownOperation(this.getSshPtyLeaseOperations(), targetId, state) - } - - async markSshRemotePtyLeasesAsync( - targetId: string, - state: SshRemotePtyLease['state'] - ): Promise { - await markSshRemotePtyLeasesAsyncOperation(this.getSshPtyLeaseOperations(), targetId, state) - } - - async markSshRemotePtyLeasesAttachedAsync( - targetId: string, - ptyIds: readonly string[] - ): Promise { - await markSshRemotePtyLeasesAttachedAsyncOperation( - this.getSshPtyLeaseOperations(), - targetId, - ptyIds - ) - } - - markSshRemotePtyLease(targetId: string, ptyId: string, state: SshRemotePtyLease['state']): void { - markSshRemotePtyLeaseOperation(this.getSshPtyLeaseOperations(), targetId, ptyId, state) - } - - removeSshRemotePtyLease(targetId: string, ptyId: string): void { - removeSshRemotePtyLeaseOperation(this.getSshPtyLeaseOperations(), targetId, ptyId) - } - - removeSshRemotePtyLeases(targetId: string): void { - removeSshRemotePtyLeasesOperation(this.getSshPtyLeaseOperations(), targetId) - } - - // ── Flush (for shutdown) ─────────────────────────────────────────── - - flush(): void { - if (this.quitFlushStarted) { - return - } - try { - this.flushOrThrow() - } catch (err) { - console.error('[persistence] Failed to flush state:', err) - } - try { - this.flushActiveViewPreferenceOrThrow() - } catch (err) { - console.error('[active-view] Failed to flush preference:', err) - } - this.writeGithubCacheSnapshotSync() - } - - /** - * Async twin of flush() for the quit path. - * - * Why the quit path needs one: writeToDiskSync fsyncs a multi-MB file from the Electron - * main thread. On a stalled network profile mount that syscall is uninterruptible, so the - * app stops repainting and Force Quit stops working — and no main-thread deadline can - * bound it, because the deadline's own timer is stuck behind the same block. - * - * Never throws — it joins the quit teardown barrier, where a rejection is noise. - */ - flushAsync(): Promise { - if (this.quitFlushPromise) { - return this.quitFlushPromise - } - this.quitFlushStarted = true - this.quitFlushPromise = this.flushCurrentStateAsync(true).catch(() => {}) - return this.quitFlushPromise - } - - flushPendingAsync(): Promise { - // Best-effort callers must not livelock while the live app keeps mutating state. - return this.flushCurrentStateAsync(false, undefined, false).catch(() => {}) - } - - flushPendingOrThrowAsync( - options: { signal?: AbortSignal; drainToStableGeneration?: boolean } = {} - ): Promise { - if (this.writesFrozen || this.quitFlushStarted) { - return Promise.reject(new Error('Cannot flush while persistence is finalized')) - } - return this.flushCurrentStateAsync(false, options.signal, options.drainToStableGeneration, true) - } - - // Async twin of flushOrThrow: durable state only. Active-view and GitHub sidecars are - // quit/startup work and must not be snapshotted on the live SSH establish/reconnect path. - private async flushDurableStateOrThrowAsync(): Promise { - if (this.writesFrozen || this.quitFlushStarted) { - throw new Error('Cannot flush while persistence is finalized') - } - for (;;) { - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - this.firstPendingSaveAt = null - const generation = this.writeGeneration - await this.enqueueWrite() - if (generation === this.writeGeneration) { - break - } - } - } - - private async flushCurrentStateAsync( - final: boolean, - signal?: AbortSignal, - drainToStableGeneration = true, - requireInitialGenerationDurable = false - ): Promise { - const requiredDurableGeneration = requireInitialGenerationDurable ? this.writeGeneration : null - for (;;) { - if (signal?.aborted) { - throw new Error('Persistence flush aborted') - } - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - this.firstPendingSaveAt = null - const generation = this.writeGeneration - try { - await this.enqueueWrite() - } catch (error) { - await (final - ? this.activeViewPreference.flushAsync() - : this.activeViewPreference.flushPendingAsync(signal)) - await this.writeGithubCacheSnapshotAsync(final, signal) - throw error - } - await (final - ? this.activeViewPreference.flushAsync() - : this.activeViewPreference.flushPendingAsync(signal)) - await this.writeGithubCacheSnapshotAsync(final, signal) - if (signal?.aborted) { - throw new Error('Persistence flush aborted') - } - if (!drainToStableGeneration) { - if ( - requiredDurableGeneration === null || - this.lastDurableWriteGeneration >= requiredDurableGeneration - ) { - break - } - continue - } - if (generation === this.writeGeneration) { - break - } - } - } - - // Why best-effort: the sidecar is a refetchable cache; a failed write only costs a cold badge paint next launch, never data. - private async writeGithubCacheSnapshotAsync( - drainToStableGeneration = true, - signal?: AbortSignal - ): Promise { - if (!this.githubCacheDirty) { - return - } - const previousWrite = this.pendingGithubCacheWrite ?? this.staleGithubCacheTempCleanup - const nextWrite = previousWrite - .then(async () => { - while (this.githubCacheDirty) { - if (signal?.aborted) { - throw new Error('GitHub cache flush aborted') - } - const generation = this.githubCacheGeneration - const cacheFile = getGithubCacheFile(this.dataFile) - const tmpFile = durableWriteTempPath(cacheFile) - let renamed = false - try { - await writeFile(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') - if (generation === this.githubCacheGeneration) { - await rename(tmpFile, cacheFile) - renamed = true - if (generation === this.githubCacheGeneration) { - this.githubCacheDirty = false - } - } - } finally { - if (!renamed) { - await rm(tmpFile).catch(() => {}) - } - } - if (signal?.aborted) { - throw new Error('GitHub cache flush aborted') - } - if (!drainToStableGeneration) { - break - } - } - }) - .catch((err) => { - console.warn('[persistence] Failed to write github cache snapshot:', err) - }) - .finally(() => { - if (this.pendingGithubCacheWrite === nextWrite) { - this.pendingGithubCacheWrite = null - } - }) - this.pendingGithubCacheWrite = nextWrite - await nextWrite - } - - // Why: a project move rewrote the data file directly; in-memory state is now stale and any write would undo the transfer. freezeWrites(): void { - this.writesFrozen = true - if (this.writeTimer) { - clearTimeout(this.writeTimer) - this.writeTimer = null - } - } - - // Why best-effort: the sidecar is a refetchable cache; a failed write only costs a cold badge paint next launch, never data. - private writeGithubCacheSnapshotSync(): void { - if (!this.githubCacheDirty) { - return - } - if (this.pendingGithubCacheWrite) { - void this.writeGithubCacheSnapshotAsync() - return - } - const cacheFile = getGithubCacheFile(this.dataFile) - const generation = this.githubCacheGeneration - const tmpFile = durableWriteTempPath(cacheFile) - try { - writeFileSync(tmpFile, JSON.stringify(this.state.githubCache), 'utf-8') - renameSync(tmpFile, cacheFile) - if (generation === this.githubCacheGeneration) { - this.githubCacheDirty = false - } - } catch (err) { - try { - unlinkSync(tmpFile) - } catch { - // Best-effort cleanup. - } - console.warn('[persistence] Failed to write github cache snapshot:', err) + this.runtime.writesFrozen = true + if (this.runtime.writeTimer) { + clearTimeout(this.runtime.writeTimer) + this.runtime.writeTimer = null } } } -function getDefaultWorktreeMeta(): WorktreeMeta { - return { - instanceId: randomUUID(), - displayName: '', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - linkedGitLabMR: null, - linkedGitLabIssue: null, - linkedBitbucketPR: null, - linkedAzureDevOpsPR: null, - linkedGiteaPR: null, - linkedWorkItem: null, - linkedTaskSourceContext: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: Date.now(), - lastActivityAt: 0, - workspaceStatus: DEFAULT_WORKSPACE_STATUS_ID +// oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging derives Store's prototype API directly from the exact concrete domain classes installed below +export interface Store + extends + WriteSchedulingOperations, + PrimaryStateWriteOperations, + ProjectCollectionOperations, + RepoLifecycleOperations, + MobileTabSelectionPersistence, + SparsePresetPersistence, + AutomationPersistence, + MetadataLineageOperations, + ProfilePreferences, + SessionHostPartitionOperations, + SessionSnapshotOperations, + PtyBindingPersistenceOperations, + SshProfileOperations, + RetiredWorktreeNamePersistence, + SshLeaseRecoveryOperations, + WriteFlushBarrierOperations {} + +for (const OperationClass of STORE_DOMAIN_OPERATION_CLASSES) { + const descriptors = Object.getOwnPropertyDescriptors(OperationClass.prototype) + for (const [name, descriptor] of Object.entries(descriptors)) { + if (name !== 'constructor') { + Object.defineProperty(Store.prototype, name, descriptor) + } } } diff --git a/src/main/persistence/loading-store/terminal-binding-recovery.ts b/src/main/persistence/loading-store/terminal-binding-recovery.ts new file mode 100644 index 00000000000..a3e03098b70 --- /dev/null +++ b/src/main/persistence/loading-store/terminal-binding-recovery.ts @@ -0,0 +1,104 @@ +import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import { toRelaySshPtyId } from '../../providers/ssh-pty-id' +import { isTerminalLeafId } from '../../../shared/stable-pane-id' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' + +import type { StoreRuntimeState } from './store-runtime-state' + +type TerminalBindingRecoveryOperationsRuntime = Pick + +export class TerminalBindingRecoveryOperations { + constructor(private readonly runtime: TerminalBindingRecoveryOperationsRuntime) {} + + getTerminalLayoutLeafIds(root: TerminalPaneLayoutNode | null): Set { + const leafIds = new Set() + const visit = (node: TerminalPaneLayoutNode | null): void => { + if (!node) { + return + } + if (node.type === 'leaf') { + if (isTerminalLeafId(node.leafId)) { + leafIds.add(node.leafId) + } + return + } + visit(node.first) + visit(node.second) + } + visit(root) + return leafIds + } + + isRestorablePtyBinding(binding: { + ptyId: string + targetId?: string | null + worktreeId?: string + tabId?: string + leafId?: string + }): boolean { + const leases = this.runtime.state.sshRemotePtyLeases?.filter((entry) => + this.sshRemotePtyLeaseMatchesBinding(entry, binding) + ) + return !leases?.some((lease) => lease.state === 'terminated' || lease.state === 'expired') + } + + getRelayPtyIdForSshLeaseComparison(targetId: string, ptyId: string): string { + try { + return toRelaySshPtyId(targetId, ptyId) + } catch { + return ptyId + } + } + + getRelayPtyIdForSshLeaseStorage(targetId: string, ptyId: string): string { + return toRelaySshPtyId(targetId, ptyId) + } + + sshRemotePtyLeaseMatchesBinding( + lease: SshRemotePtyLease, + binding: { + ptyId: string + targetId?: string | null + worktreeId?: string + tabId?: string + leafId?: string + } + ): boolean { + const bindingPtyId = this.getRelayPtyIdForSshLeaseComparison(lease.targetId, binding.ptyId) + if (lease.ptyId !== bindingPtyId) { + return false + } + // Why: remote PTY ids are scoped to a relay target; require stored lease context to match so missing fields don't tombstone unrelated panes. + return ( + (binding.targetId === undefined || + binding.targetId === null || + lease.targetId === binding.targetId) && + (binding.worktreeId === undefined || lease.worktreeId === binding.worktreeId) && + (binding.tabId === undefined || lease.tabId === binding.tabId) && + (binding.leafId === undefined || lease.leafId === binding.leafId) + ) + } + + hasRestorableSshRemotePtyLease(binding: { + ptyId: string + targetId?: string | null + worktreeId?: string + tabId?: string + leafId?: string + }): boolean { + return ( + this.runtime.state.sshRemotePtyLeases?.some( + (lease) => + this.sshRemotePtyLeaseMatchesBinding(lease, binding) && + lease.state !== 'terminated' && + lease.state !== 'expired' + ) ?? false + ) + } + + getConnectionIdForWorktree(worktreeId: string): string | null { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return this.runtime.state.repos.find((repo) => repo.id === repoId)?.connectionId ?? null + } +} diff --git a/src/main/persistence/loading-store/terminal-session-cleanup.ts b/src/main/persistence/loading-store/terminal-session-cleanup.ts new file mode 100644 index 00000000000..2981cabc114 --- /dev/null +++ b/src/main/persistence/loading-store/terminal-session-cleanup.ts @@ -0,0 +1,36 @@ +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../../shared/workspace-session-state-types' +import { + collectTerminalScrollbackSnapshotRefs, + deleteTerminalScrollbackSnapshotSync, + type TerminalScrollbackSnapshotStorage +} from '../../terminal-scrollback-snapshots' + +const WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS = new Set([ + 'tabsByWorktree', + 'terminalLayoutsByTabId' +]) + +export function workspaceSessionPatchNeedsFullNormalization(patch: WorkspaceSessionPatch): boolean { + return Object.keys(patch).some((key) => + WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS.has(key as keyof WorkspaceSessionState) + ) +} + +export function deleteRemovedTerminalScrollbackSnapshots( + prior: WorkspaceSessionState | undefined, + next: WorkspaceSessionState, + storage?: TerminalScrollbackSnapshotStorage +): void { + if (!prior) { + return + } + const nextRefs = collectTerminalScrollbackSnapshotRefs(next) + for (const ref of collectTerminalScrollbackSnapshotRefs(prior)) { + if (!nextRefs.has(ref)) { + deleteTerminalScrollbackSnapshotSync(ref, storage) + } + } +} diff --git a/src/main/persistence/loading-store/workspace-session-partitions.ts b/src/main/persistence/loading-store/workspace-session-partitions.ts new file mode 100644 index 00000000000..50e9d4ae8cc --- /dev/null +++ b/src/main/persistence/loading-store/workspace-session-partitions.ts @@ -0,0 +1,56 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { parseWorkspaceSessionSalvaging } from '../../../shared/workspace-session-salvage' + +export function workspaceSessionSalvageLogDetails(result: { + droppedCount: number + droppedPaths: string[] +}): { count: number; fields: string[]; detailsTruncated: boolean } { + return { + count: result.droppedCount, + fields: [...new Set(result.droppedPaths.map((path) => path.split('.', 1)[0]))], + detailsTruncated: result.droppedCount > result.droppedPaths.length + } +} + +/** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge. + * Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */ +export function parseWorkspaceSessionsByHostId( + raw: unknown, + defaults: WorkspaceSessionState +): { partitions: Partial>; repaired: boolean } { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { partitions: {}, repaired: raw !== undefined } + } + let repaired = false + const partitions: Partial> = {} + for (const [key, value] of Object.entries(raw as Record)) { + const hostId = normalizeExecutionHostId(key) + // Why: 'local' lives in workspaceSession; a local/invalid key here is legacy noise that must not shadow the canonical partition. + if (!hostId || hostId === LOCAL_EXECUTION_HOST_ID) { + continue + } + const result = parseWorkspaceSessionSalvaging(value) + if (!result.ok) { + repaired = true + console.error( + `[persistence] Corrupt workspace session for host ${hostId}, using defaults:`, + result.error + ) + continue + } + if (result.droppedCount > 0) { + console.warn( + `[persistence] Salvaged workspace session for host ${hostId}; dropped corrupt entries:`, + workspaceSessionSalvageLogDetails(result) + ) + repaired = true + } + partitions[hostId] = { ...defaults, ...result.value } + } + return { partitions, repaired } +} diff --git a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts new file mode 100644 index 00000000000..6f52fdc77ca --- /dev/null +++ b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts @@ -0,0 +1,242 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' +import { setMigrationUnsupportedPty } from '../../agent-hooks/migration-unsupported-pty-state' +import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers' +import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' +import { migrateWorkspaceSessionTerminalScrollbackSnapshots } from '../../terminal-scrollback-snapshots' +import { + deleteRemovedTerminalScrollbackSnapshotsAsync, + migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync +} from '../../terminal-scrollback-snapshot-async-migration' +import { preserveMissingLeafRecordEntries } from '../restoring-sessions/terminal-layout-normalization' +import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias-normalization' +import { + normalizeWorkspaceSessionPaneIdentities, + remapAcknowledgedAgentPaneKeys, + remapSshRemotePtyLeaseLeafIds, + type WorkspaceSessionPaneIdentityRemap +} from '../restoring-sessions/workspace-pane-normalization' +import { deleteRemovedTerminalScrollbackSnapshots } from './terminal-session-cleanup' +import { + getSessionSnapshotOperationsContext, + type SessionSnapshotOperations +} from './session-snapshot-operations' +import { scheduleSave } from './write-scheduling' + +export function setLocalWorkspaceSession( + owner: SessionSnapshotOperations, + session: PersistedState['workspaceSession'], + deferSnapshotFiles = false +): void { + const context = getSessionSnapshotOperationsContext(owner) + const prior = context.runtime.state.workspaceSession + session = sanitizeWorkspaceSessionTerminalRetirements(session, prior) + session = pruneWorkspaceSessionBrowserHistory( + pruneLocalTerminalScrollbackBuffers(session, context.runtime.state.repos) + ) + + // Why (Issue #217): merge existing bindings when the incoming binding is empty, so a stale pre-spawn snapshot can't overwrite the durable PTY binding. + const normalized = normalizeWorkspaceSessionPaneIdentities(session, prior?.terminalLayoutsByTabId) + for (const entry of normalized.migrationUnsupportedEntries) { + setMigrationUnsupportedPty(entry) + } + const remappedAcknowledgements = remapAcknowledgedAgentPaneKeys( + context.runtime.state.ui?.acknowledgedAgentsByPaneKey, + normalized.leafIdByInputLeafIdByTabId + ) + if (remappedAcknowledgements.changed) { + context.runtime.state.ui = { + ...context.runtime.state.ui, + acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements + } + } + for (const entry of normalized.legacyPaneKeyAliasEntries) { + registerPersistedPaneKeyAlias(entry) + } + session = normalized.session + const remapsByHostId = new Map([ + [LOCAL_EXECUTION_HOST_ID, normalized] + ]) + const remappedLeases = remapSshRemotePtyLeaseLeafIds( + context.runtime.state.sshRemotePtyLeases ?? [], + remapsByHostId, + new Set(context.sessions.getWorkspaceSessionHostIds()) + ) + if (remappedLeases.changed) { + context.runtime.state.sshRemotePtyLeases = remappedLeases.leases + } + if (session && prior) { + const priorTabs = prior.tabsByWorktree ?? {} + const nextTabs = session.tabsByWorktree ?? {} + const worktreeIdByTabId = new Map() + for (const [worktreeId, tabs] of Object.entries({ ...priorTabs, ...nextTabs })) { + for (const tab of tabs) { + worktreeIdByTabId.set(tab.id, worktreeId) + } + } + for (const [worktreeId, tabs] of Object.entries(nextTabs)) { + const priorList = priorTabs[worktreeId] + if (!priorList) { + continue + } + for (const tab of tabs) { + if (tab.ptyId) { + continue + } + const priorTab = priorList.find((t) => t.id === tab.id) + if ( + priorTab?.ptyId && + context.bindingRecovery.isRestorablePtyBinding({ + ptyId: priorTab.ptyId, + worktreeId, + targetId: context.bindingRecovery.getConnectionIdForWorktree(worktreeId), + tabId: tab.id + }) + ) { + tab.ptyId = priorTab.ptyId + } + } + } + const priorLayouts = prior.terminalLayoutsByTabId ?? {} + const nextLayouts = session.terminalLayoutsByTabId ?? {} + for (const [tabId, layout] of Object.entries(nextLayouts)) { + const priorLayout = priorLayouts[tabId] + if (!priorLayout?.ptyIdsByLeafId) { + continue + } + const incoming = layout.ptyIdsByLeafId ?? {} + const incomingHasAnyBinding = Object.keys(incoming).length > 0 + const liveLeafIds = context.bindingRecovery.getTerminalLayoutLeafIds(layout.root) + const worktreeId = worktreeIdByTabId.get(tabId) + const targetId = worktreeId + ? context.bindingRecovery.getConnectionIdForWorktree(worktreeId) + : null + const restorableBindings = Object.fromEntries( + Object.entries(priorLayout.ptyIdsByLeafId).filter( + ([leafId, ptyId]) => + liveLeafIds.has(leafId) && + incoming[leafId] === undefined && + // Why: an empty layout map may be a stale pre-spawn snapshot; a partial map is intentional unless a durable SSH lease proves it. + (incomingHasAnyBinding + ? context.bindingRecovery.hasRestorableSshRemotePtyLease({ + ptyId, + targetId, + worktreeId, + tabId, + leafId + }) + : context.bindingRecovery.isRestorablePtyBinding({ + ptyId, + targetId, + worktreeId, + tabId, + leafId + })) + ) + ) + if (Object.keys(restorableBindings).length > 0) { + layout.ptyIdsByLeafId = { ...restorableBindings, ...incoming } + // Why: the same stale write that drops ptyIdsByLeafId may come from an older renderer lacking UUID-keyed metadata. + const buffersByLeafId = preserveMissingLeafRecordEntries( + priorLayout.buffersByLeafId, + layout.buffersByLeafId, + liveLeafIds + ) + const scrollbackRefsByLeafId = preserveMissingLeafRecordEntries( + priorLayout.scrollbackRefsByLeafId, + layout.scrollbackRefsByLeafId, + liveLeafIds + ) + const titlesByLeafId = preserveMissingLeafRecordEntries( + priorLayout.titlesByLeafId, + layout.titlesByLeafId, + liveLeafIds + ) + if (buffersByLeafId) { + layout.buffersByLeafId = buffersByLeafId + } + if (scrollbackRefsByLeafId) { + layout.scrollbackRefsByLeafId = scrollbackRefsByLeafId + } + if (titlesByLeafId) { + layout.titlesByLeafId = titlesByLeafId + } + } + } + } + session = pruneLocalTerminalScrollbackBuffers(session, context.runtime.state.repos) + if (!deferSnapshotFiles) { + const migratedScrollback = migrateWorkspaceSessionTerminalScrollbackSnapshots( + session, + context.runtime.terminalScrollbackSnapshotStorage + ) + session = migratedScrollback.session + deleteRemovedTerminalScrollbackSnapshots( + prior, + session, + context.runtime.terminalScrollbackSnapshotStorage + ) + } + context.runtime.state.workspaceSession = session + if (deferSnapshotFiles) { + enqueueTerminalScrollbackSnapshotWork(owner, prior, session) + } + scheduleSave(context.scheduling) +} + +export function enqueueTerminalScrollbackSnapshotWork( + owner: SessionSnapshotOperations, + prior: WorkspaceSessionState | undefined, + staged: WorkspaceSessionState +): void { + const context = getSessionSnapshotOperationsContext(owner) + const previous = context.runtime.pendingSnapshotFileWork ?? Promise.resolve() + const work = previous + .then(async () => { + if (context.runtime.state.workspaceSession !== staged) { + if (context.runtime.state.workspaceSession) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + prior, + context.runtime.state.workspaceSession, + context.runtime.terminalScrollbackSnapshotStorage + ) + } + return + } + const migrated = await migrateWorkspaceSessionTerminalScrollbackSnapshotsAsync( + staged, + context.runtime.terminalScrollbackSnapshotStorage + ) + const current = + context.runtime.state.workspaceSession === staged + ? migrated + : context.runtime.state.workspaceSession + if (context.runtime.state.workspaceSession === staged) { + context.runtime.state.workspaceSession = migrated + } else if (current) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + migrated, + current, + context.runtime.terminalScrollbackSnapshotStorage + ) + } + if (current) { + await deleteRemovedTerminalScrollbackSnapshotsAsync( + prior, + current, + context.runtime.terminalScrollbackSnapshotStorage + ) + } + }) + .catch((error) => { + console.error('[terminal-scrollback] Failed to prepare unload snapshots:', error) + }) + .finally(() => { + if (context.runtime.pendingSnapshotFileWork === work) { + context.runtime.pendingSnapshotFileWork = null + } + }) + context.runtime.pendingSnapshotFileWork = work +} diff --git a/src/main/persistence/loading-store/write-flush-barriers.ts b/src/main/persistence/loading-store/write-flush-barriers.ts new file mode 100644 index 00000000000..81424dc931b --- /dev/null +++ b/src/main/persistence/loading-store/write-flush-barriers.ts @@ -0,0 +1,276 @@ +import { writeFileSync, renameSync, unlinkSync } from 'node:fs' +import { rename, rm, writeFile } from 'node:fs/promises' +import { durableWriteTempPath } from '../../durable-file-write' +import { getGithubCacheFile } from './user-data-path' + +import type { StoreRuntimeState } from './store-runtime-state' +import type { PrimaryStateWriteOperations } from './primary-state-writes' +import { enqueueWrite } from './primary-state-writes' + +type WriteFlushBarrierOperationsRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'dataFile' + | 'firstPendingSaveAt' + | 'githubCacheDirty' + | 'githubCacheGeneration' + | 'lastDurableWriteGeneration' + | 'pendingGithubCacheWrite' + | 'quitFlushPromise' + | 'quitFlushStarted' + | 'staleGithubCacheTempCleanup' + | 'state' + | 'writeGeneration' + | 'writeTimer' + | 'writesFrozen' +> + +const writeFlushBarrierOperationsContext = Symbol('WriteFlushBarrierOperations') +type WriteFlushBarrierOperationsContext = { + runtime: WriteFlushBarrierOperationsRuntime + writes: PrimaryStateWriteOperations +} + +export class WriteFlushBarrierOperations { + readonly [writeFlushBarrierOperationsContext]: WriteFlushBarrierOperationsContext + + constructor(runtime: WriteFlushBarrierOperationsRuntime, writes: PrimaryStateWriteOperations) { + this[writeFlushBarrierOperationsContext] = { runtime, writes } + } + + flush(): void { + if (this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted) { + return + } + try { + this[writeFlushBarrierOperationsContext].writes.flushOrThrow() + } catch (err) { + console.error('[persistence] Failed to flush state:', err) + } + try { + this[writeFlushBarrierOperationsContext].writes.flushActiveViewPreferenceOrThrow() + } catch (err) { + console.error('[active-view] Failed to flush preference:', err) + } + writeGithubCacheSnapshotSync(this) + } + + flushAsync(): Promise { + if (this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise) { + return this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise + } + this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted = true + this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise = flushCurrentStateAsync( + this, + true + ).catch(() => {}) + return this[writeFlushBarrierOperationsContext].runtime.quitFlushPromise + } + + flushPendingAsync(): Promise { + // Best-effort callers must not livelock while the live app keeps mutating state. + return flushCurrentStateAsync(this, false, undefined, false).catch(() => {}) + } + + flushPendingOrThrowAsync( + options: { signal?: AbortSignal; drainToStableGeneration?: boolean } = {} + ): Promise { + if ( + this[writeFlushBarrierOperationsContext].runtime.writesFrozen || + this[writeFlushBarrierOperationsContext].runtime.quitFlushStarted + ) { + return Promise.reject(new Error('Cannot flush while persistence is finalized')) + } + return flushCurrentStateAsync( + this, + false, + options.signal, + options.drainToStableGeneration, + true + ) + } +} + +export async function flushDurableStateOrThrowAsync( + owner: WriteFlushBarrierOperations +): Promise { + if ( + owner[writeFlushBarrierOperationsContext].runtime.writesFrozen || + owner[writeFlushBarrierOperationsContext].runtime.quitFlushStarted + ) { + throw new Error('Cannot flush while persistence is finalized') + } + for (;;) { + if (owner[writeFlushBarrierOperationsContext].runtime.writeTimer) { + clearTimeout(owner[writeFlushBarrierOperationsContext].runtime.writeTimer) + owner[writeFlushBarrierOperationsContext].runtime.writeTimer = null + } + owner[writeFlushBarrierOperationsContext].runtime.firstPendingSaveAt = null + const generation = owner[writeFlushBarrierOperationsContext].runtime.writeGeneration + await enqueueWrite(owner[writeFlushBarrierOperationsContext].writes) + if (generation === owner[writeFlushBarrierOperationsContext].runtime.writeGeneration) { + break + } + } +} + +export async function flushCurrentStateAsync( + owner: WriteFlushBarrierOperations, + final: boolean, + signal?: AbortSignal, + drainToStableGeneration = true, + requireInitialGenerationDurable = false +): Promise { + const requiredDurableGeneration = requireInitialGenerationDurable + ? owner[writeFlushBarrierOperationsContext].runtime.writeGeneration + : null + for (;;) { + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if (owner[writeFlushBarrierOperationsContext].runtime.writeTimer) { + clearTimeout(owner[writeFlushBarrierOperationsContext].runtime.writeTimer) + owner[writeFlushBarrierOperationsContext].runtime.writeTimer = null + } + owner[writeFlushBarrierOperationsContext].runtime.firstPendingSaveAt = null + const generation = owner[writeFlushBarrierOperationsContext].runtime.writeGeneration + try { + await enqueueWrite(owner[writeFlushBarrierOperationsContext].writes) + } catch (error) { + await (final + ? owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushAsync() + : owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushPendingAsync( + signal + )) + await writeGithubCacheSnapshotAsync(owner, final, signal) + throw error + } + await (final + ? owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushAsync() + : owner[writeFlushBarrierOperationsContext].runtime.activeViewPreference.flushPendingAsync( + signal + )) + await writeGithubCacheSnapshotAsync(owner, final, signal) + if (signal?.aborted) { + throw new Error('Persistence flush aborted') + } + if (!drainToStableGeneration) { + if ( + requiredDurableGeneration === null || + owner[writeFlushBarrierOperationsContext].runtime.lastDurableWriteGeneration >= + requiredDurableGeneration + ) { + break + } + continue + } + if (generation === owner[writeFlushBarrierOperationsContext].runtime.writeGeneration) { + break + } + } +} + +export async function writeGithubCacheSnapshotAsync( + owner: WriteFlushBarrierOperations, + drainToStableGeneration = true, + signal?: AbortSignal +): Promise { + if (!owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + return + } + const previousWrite = + owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite ?? + owner[writeFlushBarrierOperationsContext].runtime.staleGithubCacheTempCleanup + const nextWrite = previousWrite + .then(async () => { + while (owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + if (signal?.aborted) { + throw new Error('GitHub cache flush aborted') + } + const generation = owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + const cacheFile = getGithubCacheFile( + owner[writeFlushBarrierOperationsContext].runtime.dataFile + ) + const tmpFile = durableWriteTempPath(cacheFile) + let renamed = false + try { + await writeFile( + tmpFile, + JSON.stringify(owner[writeFlushBarrierOperationsContext].runtime.state.githubCache), + 'utf-8' + ) + if ( + generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + ) { + await rename(tmpFile, cacheFile) + renamed = true + if ( + generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + ) { + owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty = false + } + } + } finally { + if (!renamed) { + await rm(tmpFile).catch(() => {}) + } + } + if (signal?.aborted) { + throw new Error('GitHub cache flush aborted') + } + if (!drainToStableGeneration) { + break + } + } + }) + .catch((err) => { + console.warn('[persistence] Failed to write github cache snapshot:', err) + }) + .finally(() => { + if (owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite === nextWrite) { + owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite = null + } + }) + owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite = nextWrite + await nextWrite +} + +export function writeGithubCacheSnapshotSync(owner: WriteFlushBarrierOperations): void { + if (!owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty) { + return + } + if (owner[writeFlushBarrierOperationsContext].runtime.pendingGithubCacheWrite) { + void writeGithubCacheSnapshotAsync(owner) + return + } + const cacheFile = getGithubCacheFile(owner[writeFlushBarrierOperationsContext].runtime.dataFile) + const generation = owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration + const tmpFile = durableWriteTempPath(cacheFile) + try { + writeFileSync( + tmpFile, + JSON.stringify(owner[writeFlushBarrierOperationsContext].runtime.state.githubCache), + 'utf-8' + ) + renameSync(tmpFile, cacheFile) + if (generation === owner[writeFlushBarrierOperationsContext].runtime.githubCacheGeneration) { + owner[writeFlushBarrierOperationsContext].runtime.githubCacheDirty = false + } + } catch (err) { + try { + unlinkSync(tmpFile) + } catch { + // Best-effort cleanup. + } + console.warn('[persistence] Failed to write github cache snapshot:', err) + } +} + +export function installWriteFlushBarrierOperationsContext( + target: object, + source: WriteFlushBarrierOperations +): void { + Object.defineProperty(target, writeFlushBarrierOperationsContext, { + value: source[writeFlushBarrierOperationsContext] + }) +} diff --git a/src/main/persistence/loading-store/write-scheduling.ts b/src/main/persistence/loading-store/write-scheduling.ts new file mode 100644 index 00000000000..bf9fad874c4 --- /dev/null +++ b/src/main/persistence/loading-store/write-scheduling.ts @@ -0,0 +1,71 @@ +import type { StoreRuntimeState } from './store-runtime-state' +import type { PrimaryStateWriteOperations } from './primary-state-writes' +import { enqueueWrite } from './primary-state-writes' + +const SAVE_DEBOUNCE_MS = 1_000 +const SAVE_MAX_WAIT_MS = 5_000 + +type WriteSchedulingOperationsRuntime = Pick< + StoreRuntimeState, + | 'activeViewPreference' + | 'firstPendingSaveAt' + | 'pendingWrite' + | 'quitFlushStarted' + | 'writeGeneration' + | 'writeTimer' +> + +const writeSchedulingOperationsContext = Symbol('WriteSchedulingOperations') +type WriteSchedulingOperationsContext = { + runtime: WriteSchedulingOperationsRuntime + writes: PrimaryStateWriteOperations +} + +export class WriteSchedulingOperations { + readonly [writeSchedulingOperationsContext]: WriteSchedulingOperationsContext + + constructor(runtime: WriteSchedulingOperationsRuntime, writes: PrimaryStateWriteOperations) { + this[writeSchedulingOperationsContext] = { runtime, writes } + } + + async waitForPendingWrite(): Promise { + await Promise.all([ + this[writeSchedulingOperationsContext].runtime.pendingWrite, + this[writeSchedulingOperationsContext].runtime.activeViewPreference.waitForPendingWrite() + ]) + } +} + +export function scheduleSave(owner: WriteSchedulingOperations): void { + // Why: once the quit flush has snapshotted, a newly debounced write would fire during + // teardown with nothing awaiting it, and the process can exit mid-rename. The quit + // flush is the last write by construction. + if (owner[writeSchedulingOperationsContext].runtime.quitFlushStarted) { + return + } + owner[writeSchedulingOperationsContext].runtime.writeGeneration += 1 + const now = Date.now() + owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt ??= now + if (owner[writeSchedulingOperationsContext].runtime.writeTimer) { + clearTimeout(owner[writeSchedulingOperationsContext].runtime.writeTimer) + } + const untilMaxWait = Math.max( + 0, + owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt + SAVE_MAX_WAIT_MS - now + ) + const delay = Math.min(SAVE_DEBOUNCE_MS, untilMaxWait) + owner[writeSchedulingOperationsContext].runtime.writeTimer = setTimeout(() => { + owner[writeSchedulingOperationsContext].runtime.writeTimer = null + owner[writeSchedulingOperationsContext].runtime.firstPendingSaveAt = null + void enqueueWrite(owner[writeSchedulingOperationsContext].writes) + }, delay) +} + +export function installWriteSchedulingOperationsContext( + target: object, + source: WriteSchedulingOperations +): void { + Object.defineProperty(target, writeSchedulingOperationsContext, { + value: source[writeSchedulingOperationsContext] + }) +} diff --git a/src/main/persistence/restoring-sessions/folder-workspace-operations.ts b/src/main/persistence/restoring-sessions/folder-workspace-operations.ts index 77c6547d000..1d3c2cbbcaf 100644 --- a/src/main/persistence/restoring-sessions/folder-workspace-operations.ts +++ b/src/main/persistence/restoring-sessions/folder-workspace-operations.ts @@ -8,11 +8,10 @@ import { normalizeStoredTaskSourceContext } from '../../../shared/task-source-co import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' import { folderWorkspaceKey } from '../../../shared/workspace-scope' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { removeWorkspaceSessionOwner } from './session-owner-removal' export type FolderWorkspaceMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState scheduleSave: () => void removeWorkspaceLineageForFolderParent: (folderWorkspaceId: string) => void pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void diff --git a/src/main/persistence/scheduling-automations/automation-definition-operations.ts b/src/main/persistence/scheduling-automations/automation-definition-operations.ts index 677655c575e..801746f91ff 100644 --- a/src/main/persistence/scheduling-automations/automation-definition-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-definition-operations.ts @@ -7,7 +7,6 @@ import type { import type { PersistedState } from '../../../shared/persisted-state-types' import { normalizeAutomationPrecheck } from '../../../shared/automation-precheck' import { nextAutomationOccurrenceAfter } from '../../../shared/automation-schedules' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { getAutomationContextsForRepo, getAutomationSchedulerOwner, @@ -16,7 +15,7 @@ import { } from './automation-context-migration' export type AutomationDefinitionOperations = { - state: StoreOwnedPersistedState + state: PersistedState flush: () => void recordCreated: () => void } diff --git a/src/main/persistence/scheduling-automations/automation-run-operations.ts b/src/main/persistence/scheduling-automations/automation-run-operations.ts index f83117edf8b..d1ebfe31c66 100644 --- a/src/main/persistence/scheduling-automations/automation-run-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-run-operations.ts @@ -10,7 +10,6 @@ import { nextAutomationRunNumber, pruneAutomationRuns } from '../../../shared/automation-run-retention' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { normalizeAutomationPrecheckResult, normalizeAutomationRunOutputSnapshot, @@ -20,7 +19,7 @@ import { } from './automation-context-migration' export type AutomationRunOperations = { - state: StoreOwnedPersistedState + state: PersistedState flush: () => void recordManualRun: () => void getWorkspaceDisplayName: (workspaceId: string | null | undefined) => string | null diff --git a/src/main/persistence/scheduling-automations/automation-schedule-operations.ts b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts index b52de27baa0..25788a158cb 100644 --- a/src/main/persistence/scheduling-automations/automation-schedule-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts @@ -1,12 +1,12 @@ import type { Automation } from '../../../shared/automations-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { latestAutomationOccurrenceAtOrBefore, nextAutomationOccurrenceAfter } from '../../../shared/automation-schedules' export function advanceAutomationNextRun( - state: StoreOwnedPersistedState, + state: PersistedState, flush: () => void, id: string, now = Date.now() diff --git a/src/main/persistence/tracking-repos/project-group-operations.ts b/src/main/persistence/tracking-repos/project-group-operations.ts index 95a5f2f1627..b3918a9a98f 100644 --- a/src/main/persistence/tracking-repos/project-group-operations.ts +++ b/src/main/persistence/tracking-repos/project-group-operations.ts @@ -6,11 +6,10 @@ import { normalizeProjectGroupName } from '../../../shared/project-groups' import { folderWorkspaceKey } from '../../../shared/workspace-scope' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { removeWorkspaceSessionOwner } from '../restoring-sessions/session-owner-removal' export type ProjectGroupMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState scheduleSave: () => void removeWorkspaceLineageForFolderParent: (folderWorkspaceId: string) => void pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void diff --git a/src/main/persistence/tracking-repos/project-host-operations.ts b/src/main/persistence/tracking-repos/project-host-operations.ts index baaae4777f6..c6a8441f58a 100644 --- a/src/main/persistence/tracking-repos/project-host-operations.ts +++ b/src/main/persistence/tracking-repos/project-host-operations.ts @@ -13,12 +13,11 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { Repo } from '../../../shared/repo-types' import { getRepoExecutionHostId, normalizeExecutionHostId } from '../../../shared/execution-host' import { normalizeProjectRuntimePreference } from '../../../shared/project-execution-runtime' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { makeProjectHostSetupId } from './project-host-compatibility' import { repoGitUsernameCacheKey } from './repo-hydration' export type ProjectHostMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState gitUsernameCache: Map hydrateRepo: (repo: Repo) => Repo updateRepoBackedProjectHostSetup: ( diff --git a/src/main/persistence/tracking-repos/project-host-setup-update.ts b/src/main/persistence/tracking-repos/project-host-setup-update.ts index 2a72ab055c8..05d291bd171 100644 --- a/src/main/persistence/tracking-repos/project-host-setup-update.ts +++ b/src/main/persistence/tracking-repos/project-host-setup-update.ts @@ -1,11 +1,10 @@ -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import type { PersistedState } from '../../../shared/persisted-state-types' import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' import type { RepoUpdatePersistenceOperations } from './repo-update-operations' export type ProjectHostSetupUpdateOperations = { - state: StoreOwnedPersistedState + state: PersistedState updateRepo: RepoUpdatePersistenceOperations['updateRepo'] scheduleSave: () => void } diff --git a/src/main/persistence/tracking-repos/repo-order-operations.ts b/src/main/persistence/tracking-repos/repo-order-operations.ts index d578b20dcf7..55cec62226e 100644 --- a/src/main/persistence/tracking-repos/repo-order-operations.ts +++ b/src/main/persistence/tracking-repos/repo-order-operations.ts @@ -1,10 +1,9 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { Repo } from '../../../shared/repo-types' import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' export type RepoOrderMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState syncProjectHostSetupCompatibilityState: () => void scheduleSave: () => void } diff --git a/src/main/persistence/tracking-repos/repo-update-operations.ts b/src/main/persistence/tracking-repos/repo-update-operations.ts index 669dce1a2cc..f39e0e1e26f 100644 --- a/src/main/persistence/tracking-repos/repo-update-operations.ts +++ b/src/main/persistence/tracking-repos/repo-update-operations.ts @@ -5,11 +5,10 @@ import { getRepoExecutionHostId } from '../../../shared/execution-host' import { isLegacyRepoForExternalWorktreeVisibility } from '../../../shared/external-worktree-visibility' import { normalizeRepoSourceControlAiOverrides } from '../../../shared/source-control-ai' import { normalizeWorktreeVisibilitySourcePreferences } from '../../../shared/worktree/visibility-sources' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' import { sanitizeRepoUpdatesForPersistence } from './repo-sanitization' export type RepoUpdateMutationOperations = { - state: StoreOwnedPersistedState + state: PersistedState syncProjectHostSetupCompatibilityState: () => void scheduleSave: () => void hydrateRepo: (repo: Repo) => Repo diff --git a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts index 50c4fe5f745..7032ef2ed60 100644 --- a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts +++ b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts @@ -1,7 +1,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 { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal' import { getWorktreeIdFromHostIdentity, @@ -9,7 +9,7 @@ import { } from '../../../shared/worktree/host-qualified-identity' export function pruneWorktreeStateForRepo( - state: StoreOwnedPersistedState, + state: PersistedState, id: string, hostId: ExecutionHostId | null, pruneMobileClientTabSelections: (matchesWorktreeId: (worktreeId: string) => boolean) => void diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration.ts b/src/main/persistence/tracking-repos/worktree-identity-migration.ts index a9d3397aa7c..d9451258634 100644 --- a/src/main/persistence/tracking-repos/worktree-identity-migration.ts +++ b/src/main/persistence/tracking-repos/worktree-identity-migration.ts @@ -1,11 +1,11 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import type { PersistedState } from '../../../shared/persisted-state-types' import { getWorktreeIdFromHostIdentity, isWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' /** * Re-keys every worktreeId-keyed record in `state` from `oldWorktreeId` to `newWorktreeId`. Mutates `state` in place; @@ -13,7 +13,7 @@ import type { StoreOwnedPersistedState } from '../loading-store/store-owned-stat * See `Store.migrateWorktreeIdentity` for why the rename happens. */ export function migrateWorktreeIdentity( - state: StoreOwnedPersistedState, + state: PersistedState, oldWorktreeId: string, newWorktreeId: string ): boolean { diff --git a/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts b/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts index 0ddba9dc730..5886ebb7196 100644 --- a/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts +++ b/src/main/persistence/tracking-repos/worktree-metadata-normalization.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { WorktreeMeta } from '../../../shared/worktree/meta-types' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' import { normalizeWorktreeLinkedItemMetadata } from './worktree-metadata-normalization' // Only the presence of an entry matters here; the normalizer never reads its linked-item fields. @@ -8,20 +8,20 @@ function makeMeta(): WorktreeMeta { return { createdAt: 1 } as WorktreeMeta } -function makeState(overrides: Partial): StoreOwnedPersistedState { +function makeState(overrides: Partial): PersistedState { return { worktreeMeta: {}, worktreeLineageById: {}, workspaceLineageByChildKey: {}, ...overrides - } as StoreOwnedPersistedState + } as PersistedState } describe('normalizeWorktreeLinkedItemMetadata', () => { it('reports a null lineage map repair as changed so the load path re-saves it', () => { const state = makeState({ worktreeMeta: { 'r1::/tmp/wt': makeMeta() }, - worktreeLineageById: null as unknown as StoreOwnedPersistedState['worktreeLineageById'] + worktreeLineageById: null as unknown as PersistedState['worktreeLineageById'] }) // Without this the map stays null on disk and is repaired again on every reload. @@ -32,8 +32,7 @@ describe('normalizeWorktreeLinkedItemMetadata', () => { it('reports a null child-key lineage map repair as changed', () => { const state = makeState({ worktreeMeta: {}, - workspaceLineageByChildKey: - null as unknown as StoreOwnedPersistedState['workspaceLineageByChildKey'] + workspaceLineageByChildKey: null as unknown as PersistedState['workspaceLineageByChildKey'] }) expect(normalizeWorktreeLinkedItemMetadata(state)).toBe(true) diff --git a/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts index 72f64b82906..81c6e94843e 100644 --- a/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts +++ b/src/main/persistence/tracking-repos/worktree-metadata-normalization.ts @@ -14,14 +14,14 @@ import { normalizeWorkspaceLinkedItem } from '../../../shared/workspace-linked-item' import { isWorkspaceLinkedItemSourceContextMatch } from '../../../shared/workspace-linked-item-source-context' -import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state' +import type { PersistedState } from '../../../shared/persisted-state-types' // Why: worktrees deleted outside Orca orphan their worktreeMeta, so the map grew monotonically (63% dead on a heavy install). // GC stays narrow: local-host entries only (a local existsSync would falsely condemn SSH/WSL remote paths) and only after a 30-day idle grace. export const WORKTREE_META_GC_GRACE_MS = 30 * 24 * 60 * 60 * 1000 export const STALE_DURABLE_WRITE_TEMP_AGE_MS = 24 * 60 * 60 * 1000 -export function gcStaleWorktreeMeta(state: StoreOwnedPersistedState): number { +export function gcStaleWorktreeMeta(state: PersistedState): number { // Why: a hand-corrupted "worktreeMeta": null overrides the defaults merge; normalize here instead of throwing. // Companion lineage maps get the same guard because the deletes below index them directly. state.worktreeMeta ??= {} @@ -80,7 +80,7 @@ export function gcStaleWorktreeMeta(state: StoreOwnedPersistedState): number { return removed } -export function normalizeWorktreeLinkedItemMetadata(state: StoreOwnedPersistedState): boolean { +export function normalizeWorktreeLinkedItemMetadata(state: PersistedState): boolean { // Why: a hand-corrupted null lineage map would throw on the companion deletes below. The repair // itself is dirty state — without it the map stays null on disk and is repaired again every load. let changed = diff --git a/src/main/providers/ssh-git-noninteractive-provider.ts b/src/main/providers/ssh-git-noninteractive-provider.ts new file mode 100644 index 00000000000..ee60b254134 --- /dev/null +++ b/src/main/providers/ssh-git-noninteractive-provider.ts @@ -0,0 +1,166 @@ +import type { CommitMessagePlan } from '../../shared/commit-message-plan' +import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' +import { SshGitReadProvider } from './ssh-git-read-provider' + +type NonInteractiveExecQueueEntry = { + started: boolean + canceled: boolean + done: Promise + release: () => void +} + +const NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS = 5_000 + +export class SshGitNoninteractiveProvider extends SshGitReadProvider { + private readonly nonInteractiveExecQueues = new Map() + + async executeCommitMessagePlan( + plan: CommitMessagePlan, + cwd: string, + timeoutMs: number, + operation = 'commit-message' + ): Promise { + return this.runQueuedNonInteractiveExec( + cwd, + { + binary: plan.binary, + args: plan.args, + cwd, + stdin: plan.stdinPayload, + timeoutMs, + operation + }, + undefined, + operation + ) + } + + async execNonInteractive( + binary: string, + args: string[], + cwd: string, + timeoutMs: number, + signal?: AbortSignal, + env?: Record + ): Promise { + return this.runQueuedNonInteractiveExec( + cwd, + { + binary, + args, + cwd, + stdin: null, + timeoutMs, + ...(env ? { env } : {}) + }, + signal + ) + } + + async cancelNonInteractiveExec(cwd: string, operation?: string): Promise { + const queue = this.nonInteractiveExecQueues.get(this.nonInteractiveLaneKey(cwd, operation)) + const queuedEntry = queue?.find((entry) => !entry.started && !entry.canceled) + if (queuedEntry) { + queuedEntry.canceled = true + return + } + await this.cancelActiveNonInteractiveExec(cwd, operation) + } + + async cancelGenerateCommitMessage( + worktreePath: string, + operation = 'commit-message' + ): Promise { + await this.cancelNonInteractiveExec(worktreePath, operation) + } + + private async cancelActiveNonInteractiveExec(cwd: string, operation?: string): Promise { + try { + await this.mux.request('agent.cancelExec', { + cwd, + ...(operation ? { operation } : {}) + }) + } catch { + // Best-effort: callers are already unwinding after cancellation. + } + } + + private nonInteractiveLaneKey(cwd: string, operation?: string): string { + return JSON.stringify([operation || 'default', cwd]) + } + + private async runQueuedNonInteractiveExec( + cwd: string, + payload: { + binary: string + args: string[] + cwd: string + stdin: string | null + timeoutMs: number + env?: Record + operation?: string + }, + signal?: AbortSignal, + operation?: string + ): Promise { + const laneKey = this.nonInteractiveLaneKey(cwd, operation) + const queue = this.nonInteractiveExecQueues.get(laneKey) ?? [] + const previous = queue.at(-1)?.done ?? Promise.resolve() + let releaseEntry!: () => void + const entry: NonInteractiveExecQueueEntry = { + started: false, + canceled: false, + done: previous + .catch(() => {}) + .then( + () => + new Promise((resolve) => { + releaseEntry = resolve + }) + ), + release: () => releaseEntry() + } + queue.push(entry) + this.nonInteractiveExecQueues.set(laneKey, queue) + const abortEntry = (): void => { + if (!entry.started) { + entry.canceled = true + return + } + void this.cancelActiveNonInteractiveExec(cwd, operation) + } + if (signal?.aborted) { + entry.canceled = true + } else { + signal?.addEventListener('abort', abortEntry, { once: true }) + } + + await previous.catch(() => {}) + try { + if (entry.canceled) { + return { + stdout: '', + stderr: '', + exitCode: null, + timedOut: false, + canceled: true + } + } + entry.started = true + return (await this.mux.request('agent.execNonInteractive', payload, { + timeoutMs: payload.timeoutMs + NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS + })) as RemoteCommitMessageExecResult + } finally { + signal?.removeEventListener('abort', abortEntry) + entry.release() + const currentQueue = this.nonInteractiveExecQueues.get(laneKey) + const entryIndex = currentQueue?.indexOf(entry) ?? -1 + if (entryIndex >= 0) { + currentQueue?.splice(entryIndex, 1) + } + if (currentQueue?.length === 0) { + this.nonInteractiveExecQueues.delete(laneKey) + } + } + } +} diff --git a/src/main/providers/ssh-git-provider-api.test.ts b/src/main/providers/ssh-git-provider-api.test.ts new file mode 100644 index 00000000000..dd0915dafe5 --- /dev/null +++ b/src/main/providers/ssh-git-provider-api.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import type { IGitProvider } from './types' +import { SshGitProvider } from './ssh-git-provider' +import { createMockMux } from './ssh-git-provider-test-harness' + +describe('SshGitProvider public API parity', () => { + it('retains every historical provider operation', () => { + const provider: IGitProvider & SshGitProvider = new SshGitProvider( + 'conn-1', + createMockMux() as never + ) + const methods = [ + 'getConnectionId', + 'getHostPlatform', + 'getStatus', + 'getSubmoduleStatus', + 'checkIgnoredPaths', + 'getHistory', + 'commit', + 'getStagedCommitContext', + 'executeCommitMessagePlan', + 'execNonInteractive', + 'cancelNonInteractiveExec', + 'cancelGenerateCommitMessage', + 'getDiff', + 'stageFile', + 'unstageFile', + 'bulkStageFiles', + 'bulkUnstageFiles', + 'discardChanges', + 'bulkDiscardChanges', + 'detectConflictOperation', + 'abortMerge', + 'abortRebase', + 'checkoutBranch', + 'listLocalBranches', + 'getBranchCompare', + 'getCommitCompare', + 'getUpstreamStatus', + 'pushBranch', + 'pullBranch', + 'fastForwardBranch', + 'rebaseFromBase', + 'fetchRemote', + 'syncForkDefaultBranch', + 'fetchRemoteTrackingRef', + 'fetchGitLabMergeRequestHead', + 'fetchGitHubPullRequestHead', + 'getBranchDiff', + 'getCommitDiff', + 'listWorktrees', + 'addWorktree', + 'removeWorktree', + 'worktreeIsClean', + 'refreshLocalBaseRefForWorktreeCreate', + 'renameCurrentBranch', + 'forceDeletePreservedBranch', + 'exec', + 'clone', + 'isGitRepoAsync', + 'isGitRepo', + 'getRemoteFileUrl', + 'getRemoteCommitUrl' + ] as const + + expect(methods).toHaveLength(51) + for (const method of methods) { + expect(provider[method], method).toBeTypeOf('function') + } + }) +}) diff --git a/src/main/providers/ssh-git-provider-exec.test.ts b/src/main/providers/ssh-git-provider-exec.test.ts index 9fefe4abfda..ef0c3198a8d 100644 --- a/src/main/providers/ssh-git-provider-exec.test.ts +++ b/src/main/providers/ssh-git-provider-exec.test.ts @@ -209,6 +209,40 @@ describe('SshGitProvider', () => { await second }) + it('isolates non-interactive lanes between provider instances', async () => { + const completeRequests: (() => void)[] = [] + mux.request.mockImplementation( + () => + new Promise((resolve) => { + completeRequests.push(() => + resolve({ stdout: '', stderr: '', exitCode: 0, timedOut: false }) + ) + }) + ) + const otherProvider = new SshGitProvider('conn-2', mux as never) + + const first = provider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) + const second = otherProvider.execNonInteractive('pnpm', ['install'], '/home/user/repo', 8000) + + await waitForRequestCount(mux.request, 2) + expect(mux.request).toHaveBeenCalledTimes(2) + completeRequests.forEach((complete) => complete()) + await Promise.all([first, second]) + }) + + it('forwards leading global Git options without reordering argv', async () => { + mux.request.mockResolvedValue({ stdout: '', stderr: '' }) + const args = ['-c', 'maintenance.auto=false', 'fetch', 'origin', 'main'] + + await provider.exec(args, '/home/user/repo') + + expect(mux.request).toHaveBeenCalledWith('git.exec', { + args, + cwd: '/home/user/repo', + __streamResponse: true + }) + }) + it('cancels a queued non-interactive exec without canceling the active relay child', async () => { const completeRequests: (() => void)[] = [] mux.request.mockImplementation( diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 8086548ed5d..5fd475a0698 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -1,229 +1,16 @@ -/* eslint-disable max-lines -- Why: this provider mirrors IGitProvider one - method per RPC call (~16 methods). Splitting it would only add - indirection — every method is a 1:1 forwarder to a relay RPC plus a - small amount of param plumbing. */ -import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' -import type { GitProviderStatusOptions, IGitProvider } from './types' -import type { - GitBranchCompareResult, - GitCommitCompareResult, - GitDiffResult -} from '../../shared/git-diff-compare-types' -import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' -import type { - GitConflictOperation, - GitStagingArea, - GitStatusResult, - GitUpstreamStatus -} from '../../shared/git-status-types' -import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' -import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types' -import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' -import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from '../git/hosted-remote-url' -import { JsonRpcErrorCode } from '../ssh/relay-protocol' -import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' -import type { CommitMessagePlan } from '../../shared/commit-message-plan' -import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' -import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { gitExecMutatesRepository } from '../../shared/git-exec-mutation' +import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from '../git/hosted-remote-url' import { describeMaxBufferOverflowError, isMaxBufferOverflowError } from '../git/max-buffer-overflow' -import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' -import { gitExecMutatesRepository } from '../../shared/git-exec-mutation' -import { GitStatusReadLeaseOwner } from '../git/git-status-read-lease-owner' -import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../shared/git-rebase-source' -import { GitUpstreamStatusReadOwner } from '../git/git-upstream-status-read-owner' - -type NonInteractiveExecQueueEntry = { - started: boolean - canceled: boolean - done: Promise - release: () => void -} - -const NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS = 5_000 -const ABSENT_BRANCH_DIFF_HEAD_OID = { absent: true } as const - -function isJsonRpcMethodNotFoundError(error: unknown): boolean { - if (!error || typeof error !== 'object') { - return false - } - return (error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound -} - -// Why: the relay returns the durable ref it wrote; re-deriving it on the client -// can disagree (URL normalization) and leave resolve looking at the wrong path. -function readDurableReviewHeadLocalRef( - result: unknown, - kind: 'pull request' | 'merge request' -): string { - if (result && typeof result === 'object' && 'localRef' in result) { - const localRef = (result as { localRef: unknown }).localRef - if (typeof localRef === 'string') { - const trimmed = localRef.trim() - if (trimmed.startsWith('refs/orca/')) { - return trimmed - } - } - } - throw new Error( - `This SSH host did not return the durable ${kind} head ref. Reconnect to deploy the latest relay, then try again.` - ) -} - -function formatStatusEntriesForCleanCheck(entries: GitStatusResult['entries']): string | undefined { - if (entries.length === 0) { - return undefined - } - return entries.map((entry) => `${entry.area} ${entry.status}: ${entry.path}`).join('\n') -} - -function filterUntrackedPorcelainStatus(stdout: string | undefined): string | undefined { - const trackedLines = (stdout ?? '') - .split(/\r?\n/) - .filter((line) => line.trim().length > 0 && !line.startsWith('?? ')) - return trackedLines.length > 0 ? trackedLines.join('\n') : undefined -} - -export class SshGitProvider implements IGitProvider { - private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() - private readonly statusReadLeaseOwner = new GitStatusReadLeaseOwner() - private readonly upstreamStatusReadOwner = new GitUpstreamStatusReadOwner() - - private connectionId: string - private mux: SshChannelMultiplexer - private nonInteractiveExecQueues = new Map() - - private async runWithGitReadInvalidation(run: () => Promise): Promise { - this.invalidateGitReads() - try { - return await run() - } finally { - this.invalidateGitReads() - } - } - - private invalidateGitReads(): void { - this.gitDiffReadDedupe.clear() - this.statusReadLeaseOwner.invalidate() - this.upstreamStatusReadOwner.invalidate() - } - - private loggedWorktreeIsCleanFallback = false - - constructor( - connectionId: string, - mux: SshChannelMultiplexer, - private readonly hostPlatform: RemoteHostPlatform | null = null - ) { - this.connectionId = connectionId - this.mux = mux - } - - getConnectionId(): string { - return this.connectionId - } - - getHostPlatform(): RemoteHostPlatform | null { - return this.hostPlatform - } - - async getStatus( - worktreePath: string, - options?: GitProviderStatusOptions - ): Promise { - this.gitDiffReadDedupe.clear() - const includeIgnoredArgs = options?.includeIgnored ? { includeIgnored: true } : {} - const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache - ? { bypassEffectiveUpstreamNegativeCache: true } - : {} - const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {} - const branchLineTotalArgs = - options?.branchLineTotalMergeBase === undefined - ? {} - : { branchLineTotalMergeBase: options.branchLineTotalMergeBase } - const request = { - worktreePath, - ...includeIgnoredArgs, - ...upstreamCacheBypassArgs, - ...lineStatsReuseArgs, - ...branchLineTotalArgs - } - const key = stableInFlightKey([ - worktreePath, - options?.includeIgnored === true, - options?.bypassEffectiveUpstreamNegativeCache === true, - options?.reuseLineStats === true, - // Why: the result carries a total only for callers who asked, and only for - // this fork point, so a shared lease must never serve one to the other. - options?.branchLineTotalMergeBase ?? '' - ]) - return this.statusReadLeaseOwner.lease(key, options?.signal, async (sharedSignal) => { - return (await this.mux.request('git.status', request, { - signal: sharedSignal - })) as GitStatusResult - }) - } - - async getSubmoduleStatus( - worktreePath: string, - submodulePath: string, - area: GitStagingArea = 'unstaged' - ): Promise { - // Why: mirror getStatus() — refreshing submodule state must invalidate - // in-flight diff reads so a later diff can't reuse a stale pending RPC. - this.gitDiffReadDedupe.clear() - try { - return (await this.mux.request('git.submoduleStatus', { - worktreePath, - submodulePath, - area - })) as GitStatusResult - } catch (error) { - // Why: a newer desktop client may talk to an older relay that predates - // git.submoduleStatus; surface an actionable reconnect hint instead of a - // raw JSON-RPC method-not-found error in Source Control. - if (isJsonRpcMethodNotFoundError(error)) { - throw new Error( - 'SSH submodule diff support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' - ) - } - throw error - } - } - - async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise { - return (await this.mux.request('git.checkIgnored', { - worktreePath, - paths: relativePaths - })) as string[] - } - - async getHistory( - worktreePath: string, - options: GitHistoryOptions = {} - ): Promise { - return (await this.mux.request('git.history', { - worktreePath, - ...options - })) as GitHistoryResult - } - - async commit( - worktreePath: string, - message: string - ): Promise<{ success: boolean; error?: string }> { - return this.runWithGitReadInvalidation( - async () => - (await this.mux.request('git.commit', { - worktreePath, - message - })) as { success: boolean; error?: string } - ) - } +import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' +import type { IGitProvider } from './types' +import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' +import { SshGitWorktreeProvider } from './ssh-git-worktree-provider' +export class SshGitProvider extends SshGitWorktreeProvider implements IGitProvider { async getStagedCommitContext(worktreePath: string): Promise { const branchPromise = this.exec(['branch', '--show-current'], worktreePath).catch(() => ({ stdout: '' @@ -247,9 +34,6 @@ export class SshGitProvider implements IGitProvider { if (!isMaxBufferOverflowError(error)) { throw error } - // Why: a very large staged diff can overflow the remote exec buffer. The - // patch is optional context (truncated later anyway), so degrade to the - // file-name summary instead of failing commit-message generation. console.warn( '[ssh-git] Staged patch too large to read; using file summary only:', describeMaxBufferOverflowError(error) @@ -262,594 +46,6 @@ export class SshGitProvider implements IGitProvider { } } - async executeCommitMessagePlan( - plan: CommitMessagePlan, - cwd: string, - timeoutMs: number, - operation = 'commit-message' - ): Promise { - return this.runQueuedNonInteractiveExec( - cwd, - { - binary: plan.binary, - args: plan.args, - cwd, - stdin: plan.stdinPayload, - timeoutMs, - operation - }, - undefined, - operation - ) - } - - async execNonInteractive( - binary: string, - args: string[], - cwd: string, - timeoutMs: number, - signal?: AbortSignal, - env?: Record - ): Promise { - return this.runQueuedNonInteractiveExec( - cwd, - { - binary, - args, - cwd, - stdin: null, - timeoutMs, - ...(env ? { env } : {}) - }, - signal - ) - } - - async cancelNonInteractiveExec(cwd: string, operation?: string): Promise { - const queue = this.nonInteractiveExecQueues.get(this.nonInteractiveLaneKey(cwd, operation)) - const queuedEntry = queue?.find((entry) => !entry.started && !entry.canceled) - if (queuedEntry) { - queuedEntry.canceled = true - return - } - await this.cancelActiveNonInteractiveExec(cwd, operation) - } - - private async cancelActiveNonInteractiveExec(cwd: string, operation?: string): Promise { - try { - await this.mux.request('agent.cancelExec', { - cwd, - ...(operation ? { operation } : {}) - }) - } catch { - // Best-effort: callers are already unwinding after cancellation. - } - } - - async cancelGenerateCommitMessage( - worktreePath: string, - operation = 'commit-message' - ): Promise { - // Why: best-effort — the relay returns `{canceled: false}` when there is - // nothing in flight. Callers should not block UI updates on this. - await this.cancelNonInteractiveExec(worktreePath, operation) - } - - private nonInteractiveLaneKey(cwd: string, operation?: string): string { - return JSON.stringify([operation || 'default', cwd]) - } - - private async runQueuedNonInteractiveExec( - cwd: string, - payload: { - binary: string - args: string[] - cwd: string - stdin: string | null - timeoutMs: number - env?: Record - operation?: string - }, - signal?: AbortSignal, - operation?: string - ): Promise { - const laneKey = this.nonInteractiveLaneKey(cwd, operation) - const queue = this.nonInteractiveExecQueues.get(laneKey) ?? [] - const previous = queue.at(-1)?.done ?? Promise.resolve() - let releaseEntry!: () => void - const entry: NonInteractiveExecQueueEntry = { - started: false, - canceled: false, - done: previous - .catch(() => {}) - .then( - () => - new Promise((resolve) => { - releaseEntry = resolve - }) - ), - release: () => releaseEntry() - } - queue.push(entry) - this.nonInteractiveExecQueues.set(laneKey, queue) - const abortEntry = (): void => { - if (!entry.started) { - entry.canceled = true - return - } - void this.cancelActiveNonInteractiveExec(cwd, operation) - } - if (signal?.aborted) { - entry.canceled = true - } else { - signal?.addEventListener('abort', abortEntry, { once: true }) - } - - // Why: the SSH relay tracks children per operation; serialize only matching - // lanes so commit-message and PR-field generation can coexist. - await previous.catch(() => {}) - try { - if (entry.canceled) { - return { - stdout: '', - stderr: '', - exitCode: null, - timedOut: false, - canceled: true - } - } - entry.started = true - return (await this.mux.request('agent.execNonInteractive', payload, { - timeoutMs: payload.timeoutMs + NON_INTERACTIVE_TRANSPORT_TIMEOUT_MARGIN_MS - })) as RemoteCommitMessageExecResult - } finally { - signal?.removeEventListener('abort', abortEntry) - entry.release() - const currentQueue = this.nonInteractiveExecQueues.get(laneKey) - const entryIndex = currentQueue?.indexOf(entry) ?? -1 - if (entryIndex >= 0) { - currentQueue?.splice(entryIndex, 1) - } - if (currentQueue?.length === 0) { - this.nonInteractiveExecQueues.delete(laneKey) - } - } - } - - async getDiff( - worktreePath: string, - filePath: string, - staged: boolean, - compareAgainstHead?: boolean - ): Promise { - return this.gitDiffReadDedupe.run( - stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), - async () => - (await requestGitStreamable(this.mux, 'git.diff', { - worktreePath, - filePath, - staged, - compareAgainstHead - })) as GitDiffResult - ) as Promise - } - - async stageFile(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.stage', { worktreePath, filePath }) - }) - } - - async unstageFile(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.unstage', { worktreePath, filePath }) - }) - } - - async bulkStageFiles(worktreePath: string, filePaths: string[]): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.bulkStage', { worktreePath, filePaths }) - }) - } - - async bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.bulkUnstage', { worktreePath, filePaths }) - }) - } - - async discardChanges(worktreePath: string, filePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.discard', { worktreePath, filePath }) - }) - } - - async bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.bulkDiscard', { worktreePath, filePaths }) - }) - } - - async detectConflictOperation(worktreePath: string): Promise { - return (await this.mux.request('git.conflictOperation', { - worktreePath - })) as GitConflictOperation - } - - async abortMerge(worktreePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.abortMerge', { worktreePath }) - }) - } - - async abortRebase(worktreePath: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.abortRebase', { worktreePath }) - }) - } - - async checkoutBranch(worktreePath: string, branch: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.checkout', { worktreePath, branch }) - }) - } - - async listLocalBranches( - worktreePath: string - ): Promise<{ current: string | null; branches: string[] }> { - return (await this.mux.request('git.localBranches', { worktreePath })) as { - current: string | null - branches: string[] - } - } - - async getBranchCompare(worktreePath: string, baseRef: string): Promise { - return (await this.mux.request('git.branchCompare', { - worktreePath, - baseRef - })) as GitBranchCompareResult - } - - async getCommitCompare(worktreePath: string, commitId: string): Promise { - return (await this.mux.request('git.commitCompare', { - worktreePath, - commitId - })) as GitCommitCompareResult - } - - async getUpstreamStatus( - worktreePath: string, - pushTarget?: GitPushTarget - ): Promise { - return this.upstreamStatusReadOwner.read( - { kind: 'ssh-provider' }, - worktreePath, - pushTarget, - async () => - (await this.mux.request('git.upstreamStatus', { - worktreePath, - ...(pushTarget ? { pushTarget } : {}) - })) as GitUpstreamStatus - ) - } - - async pushBranch( - worktreePath: string, - publish = false, - pushTarget?: GitPushTarget, - options: { forceWithLease?: boolean } = {} - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.push', { - worktreePath, - publish, - pushTarget, - ...(options.forceWithLease === true ? { forceWithLease: true } : {}) - }) - }) - } - - async pullBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.pull', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) - }) - } - - async fastForwardBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fastForward', { - worktreePath, - ...(pushTarget ? { pushTarget } : {}) - }) - }) - } - - async rebaseFromBase(worktreePath: string, baseRef: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request( - 'git.rebaseFromBase', - { worktreePath, baseRef }, - { timeoutMs: REBASE_FROM_BASE_RPC_TIMEOUT_MS } - ) - }) - } - - async fetchRemote(worktreePath: string, pushTarget?: GitPushTarget): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fetch', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) - }) - } - - async syncForkDefaultBranch( - worktreePath: string, - expectedUpstream: GitForkSyncExpectedUpstream - ): Promise { - return this.runWithGitReadInvalidation( - async () => - (await this.mux.request('git.forkSync', { - worktreePath, - ...(expectedUpstream ? { expectedUpstream } : {}) - })) as GitForkSyncResult - ) - } - - async fetchRemoteTrackingRef( - worktreePath: string, - remote: string, - branch: string, - ref: string, - options?: { skipAutoMaintenance?: boolean } - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.fetchRemoteTrackingRef', { - worktreePath, - remote, - branch, - ref, - ...(options?.skipAutoMaintenance ? { skipAutoMaintenance: true } : {}) - }) - }) - } - - async fetchGitLabMergeRequestHead( - worktreePath: string, - remote: string, - mrIid: number - ): Promise { - try { - return await this.runWithGitReadInvalidation(async () => { - // Why: the durable-ref RPC is a NEW method name. Old relays only implement - // FETCH_HEAD-semantics git.fetchGitLabMergeRequestHead, so calling the ref - // variant makes them return -32601 rather than silently no-op the durable - // ref (which would leave the client resolving a stale/missing MR head). - const result = await this.mux.request('git.fetchGitLabMergeRequestHeadRef', { - worktreePath, - remote, - mrIid - }) - // Why: use the host-written path; a second client-side get-url can disagree. - return readDurableReviewHeadLocalRef(result, 'merge request') - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - // Why: older SSH relays predate the durable-ref MR fetch; surface a - // reconnect prompt instead of a raw JSON-RPC method-not-found error. - throw new Error( - 'This SSH host is running an older Orca relay that cannot fetch merge request heads. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } - - async fetchGitHubPullRequestHead( - worktreePath: string, - remote: string, - prNumber: number - ): Promise { - try { - return await this.runWithGitReadInvalidation(async () => { - const result = await this.mux.request('git.fetchGitHubPullRequestHead', { - worktreePath, - remote, - prNumber - }) - // Why: use the host-written path; a second client-side get-url can disagree. - return readDurableReviewHeadLocalRef(result, 'pull request') - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - // Why: older SSH relays predate git.fetchGitHubPullRequestHead; surface a - // reconnect prompt instead of a raw JSON-RPC method-not-found error. - throw new Error( - 'This SSH host is running an older Orca relay that cannot fetch pull request heads. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } - - async getBranchDiff( - worktreePath: string, - baseRef: string, - options?: { includePatch?: boolean; filePath?: string; oldPath?: string; headOid?: string } - ): Promise { - const keyOptions = options ?? {} - const { headOid: rawHeadOid, ...relayOptions } = keyOptions - // Why: compare snapshots type headOid as `string | null`, so collapse an - // unpinned null to absent instead of putting a field on the wire that a - // pinned-OID relay must reject. - const headOid = rawHeadOid == null ? undefined : rawHeadOid - return this.gitDiffReadDedupe.run( - stableInFlightKey([ - 'branchDiff', - worktreePath, - baseRef, - keyOptions.includePatch ?? null, - keyOptions.filePath ?? null, - keyOptions.oldPath ?? null, - headOid === undefined ? ABSENT_BRANCH_DIFF_HEAD_OID : headOid - ]), - async () => - (await requestGitStreamable(this.mux, 'git.branchDiff', { - worktreePath, - baseRef, - ...relayOptions, - ...(headOid === undefined ? {} : { headOid }) - })) as GitDiffResult[] - ) as Promise - } - - async getCommitDiff( - worktreePath: string, - args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } - ): Promise { - return this.gitDiffReadDedupe.run( - stableInFlightKey([ - 'commitDiff', - worktreePath, - args.commitOid, - args.parentOid ?? null, - args.filePath, - args.oldPath ?? null - ]), - async () => - (await requestGitStreamable(this.mux, 'git.commitDiff', { - worktreePath, - ...args - })) as GitDiffResult - ) as Promise - } - - async listWorktrees( - repoPath: string, - options?: { signal?: AbortSignal } - ): Promise { - return (await this.mux.request( - 'git.listWorktrees', - { - repoPath - }, - { signal: options?.signal } - )) as GitWorktreeInfo[] - } - - async addWorktree( - repoPath: string, - branchName: string, - targetDir: string, - options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean } - ): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.addWorktree', { - repoPath, - branchName, - targetDir, - ...options - }) - }) - } - - async removeWorktree( - worktreePath: string, - force?: boolean, - options?: { deleteBranch?: boolean; forceBranchDelete?: boolean } - ): Promise { - return this.runWithGitReadInvalidation( - async () => - ((await this.mux.request('git.removeWorktree', { - worktreePath, - force, - ...options - })) ?? {}) as RemoveWorktreeResult - ) - } - - async worktreeIsClean( - worktreePath: string, - options: { includeUntracked?: boolean } = {} - ): Promise<{ clean: boolean; stdout?: string }> { - try { - const result = (await this.mux.request('git.worktreeIsClean', { - worktreePath, - ...(options.includeUntracked === false ? { includeUntracked: false } : {}) - })) as { - clean: boolean - stdout?: string - } - if (options.includeUntracked === false) { - if (!result.clean && result.stdout === undefined) { - return result - } - const trackedStdout = filterUntrackedPorcelainStatus(result.stdout) - return { clean: !trackedStdout, ...(trackedStdout ? { stdout: trackedStdout } : {}) } - } - return result - } catch (error) { - if (!isJsonRpcMethodNotFoundError(error)) { - throw error - } - if (!this.loggedWorktreeIsCleanFallback) { - this.loggedWorktreeIsCleanFallback = true - console.warn( - '[ssh-git] Relay does not implement git.worktreeIsClean; falling back to git.status clean check' - ) - } - // Why: existing SSH relays may predate git.worktreeIsClean, but git.status - // is a narrow relay RPC and avoids the generic git.exec allowlist. - const status = await this.getStatus(worktreePath) - const entries = - options.includeUntracked === false - ? status.entries.filter((entry) => entry.area !== 'untracked') - : status.entries - const clean = entries.length === 0 - return { clean, stdout: formatStatusEntriesForCleanCheck(entries) } - } - } - - async refreshLocalBaseRefForWorktreeCreate(args: { - repoPath: string - fullRef: string - remoteTrackingRef: string - ownerWorktreePath?: string - checkOnly?: boolean - }): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.refreshLocalBaseRefForWorktreeCreate', args) - }) - } - - async renameCurrentBranch(worktreePath: string, newBranch: string): Promise { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch }) - }) - } - - async forceDeletePreservedBranch( - repoPath: string, - branchName: string, - expectedHead: string - ): Promise { - try { - await this.runWithGitReadInvalidation(async () => { - await this.mux.request('git.forceDeletePreservedBranch', { - repoPath, - branchName, - expectedHead - }) - }) - } catch (error) { - if (isJsonRpcMethodNotFoundError(error)) { - // Why: older SSH relays predate git.forceDeletePreservedBranch; surface - // a reconnect prompt instead of a raw JSON-RPC method-not-found error. - throw new Error( - 'This SSH host is running an older Orca relay that cannot delete preserved branches. Reconnect to deploy the latest relay, then try again.' - ) - } - throw error - } - } - async exec( args: string[], cwd: string, @@ -862,10 +58,7 @@ export class SshGitProvider implements IGitProvider { const result = gitExecMutatesRepository(args) ? await this.runWithGitReadInvalidation(run) : await run() - return result as { - stdout: string - stderr: string - } + return result as { stdout: string; stderr: string } } async clone( @@ -897,10 +90,7 @@ export class SshGitProvider implements IGitProvider { { args, cwd, progressId }, { signal: options?.signal, timeoutMs: options?.timeoutMs } ) - return result as { - stdout: string - stderr: string - } + return result as { stdout: string; stderr: string } } catch (error) { if (isJsonRpcMethodNotFoundError(error)) { throw new Error( @@ -921,16 +111,11 @@ export class SshGitProvider implements IGitProvider { } } - // Why: isGitRepo requires synchronous return in the interface, but remote - // operations are async. We always return true for remote paths since the - // relay validates git repos on its side. The renderer already guards git - // operations behind worktree registration which validates the path. + // Remote paths are validated asynchronously by the relay before registration. isGitRepo(_path: string): boolean { return true } - // Why: SSH worktrees need the remote URL from the relay-side .git/config - // before local code can map it to a hosted source link. private async readOriginRemoteUrl(worktreePath: string): Promise { try { const result = await this.exec(['remote', 'get-url', 'origin'], worktreePath) @@ -961,7 +146,7 @@ export class SshGitProvider implements IGitProvider { defaultBranch = ref.replace(/^refs\/remotes\/origin\//, '') } } catch { - // Fall back to 'main' + // Fall back to 'main'. } return buildHostedRemoteFileUrl(remoteUrl, relativePath, defaultBranch, line) diff --git a/src/main/providers/ssh-git-read-provider.ts b/src/main/providers/ssh-git-read-provider.ts new file mode 100644 index 00000000000..54720d8d9fb --- /dev/null +++ b/src/main/providers/ssh-git-read-provider.ts @@ -0,0 +1,187 @@ +import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe' +import type { GitDiffResult } from '../../shared/git-diff-compare-types' +import type { + GitStagingArea, + GitStatusResult, + GitUpstreamStatus +} from '../../shared/git-status-types' +import type { GitPushTarget } from '../../shared/worktree/types' +import { GitStatusReadLeaseOwner } from '../git/git-status-read-lease-owner' +import { GitUpstreamStatusReadOwner } from '../git/git-upstream-status-read-owner' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' +import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' +import type { GitProviderStatusOptions } from './types' +import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' + +const ABSENT_BRANCH_DIFF_HEAD_OID = { absent: true } as const + +export class SshGitReadProvider { + private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() + private readonly statusReadLeaseOwner = new GitStatusReadLeaseOwner() + private readonly upstreamStatusReadOwner = new GitUpstreamStatusReadOwner() + + constructor( + private readonly connectionId: string, + protected readonly mux: SshChannelMultiplexer, + private readonly hostPlatform: RemoteHostPlatform | null = null + ) {} + + getConnectionId(): string { + return this.connectionId + } + + getHostPlatform(): RemoteHostPlatform | null { + return this.hostPlatform + } + + protected async runWithGitReadInvalidation(run: () => Promise): Promise { + this.invalidateGitReads() + try { + return await run() + } finally { + this.invalidateGitReads() + } + } + + private invalidateGitReads(): void { + this.gitDiffReadDedupe.clear() + this.statusReadLeaseOwner.invalidate() + this.upstreamStatusReadOwner.invalidate() + } + + async getStatus( + worktreePath: string, + options?: GitProviderStatusOptions + ): Promise { + this.gitDiffReadDedupe.clear() + const request = { + worktreePath, + ...(options?.includeIgnored ? { includeIgnored: true } : {}), + ...(options?.bypassEffectiveUpstreamNegativeCache + ? { bypassEffectiveUpstreamNegativeCache: true } + : {}), + ...(options?.reuseLineStats ? { reuseLineStats: true } : {}), + ...(options?.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: options.branchLineTotalMergeBase }) + } + const key = stableInFlightKey([ + worktreePath, + options?.includeIgnored === true, + options?.bypassEffectiveUpstreamNegativeCache === true, + options?.reuseLineStats === true, + options?.branchLineTotalMergeBase ?? '' + ]) + return this.statusReadLeaseOwner.lease(key, options?.signal, async (sharedSignal) => { + return (await this.mux.request('git.status', request, { + signal: sharedSignal + })) as GitStatusResult + }) + } + + async getSubmoduleStatus( + worktreePath: string, + submodulePath: string, + area: GitStagingArea = 'unstaged' + ): Promise { + this.gitDiffReadDedupe.clear() + try { + return (await this.mux.request('git.submoduleStatus', { + worktreePath, + submodulePath, + area + })) as GitStatusResult + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'SSH submodule diff support is unavailable on this relay. Reconnect the SSH target to update Orca on the host, then try again.' + ) + } + throw error + } + } + + async getDiff( + worktreePath: string, + filePath: string, + staged: boolean, + compareAgainstHead?: boolean + ): Promise { + return this.gitDiffReadDedupe.run( + stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), + async () => + (await requestGitStreamable(this.mux, 'git.diff', { + worktreePath, + filePath, + staged, + compareAgainstHead + })) as GitDiffResult + ) as Promise + } + + async getBranchDiff( + worktreePath: string, + baseRef: string, + options?: { includePatch?: boolean; filePath?: string; oldPath?: string; headOid?: string } + ): Promise { + const keyOptions = options ?? {} + const { headOid: rawHeadOid, ...relayOptions } = keyOptions + const headOid = rawHeadOid == null ? undefined : rawHeadOid + return this.gitDiffReadDedupe.run( + stableInFlightKey([ + 'branchDiff', + worktreePath, + baseRef, + keyOptions.includePatch ?? null, + keyOptions.filePath ?? null, + keyOptions.oldPath ?? null, + headOid === undefined ? ABSENT_BRANCH_DIFF_HEAD_OID : headOid + ]), + async () => + (await requestGitStreamable(this.mux, 'git.branchDiff', { + worktreePath, + baseRef, + ...relayOptions, + ...(headOid === undefined ? {} : { headOid }) + })) as GitDiffResult[] + ) as Promise + } + + async getCommitDiff( + worktreePath: string, + args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } + ): Promise { + return this.gitDiffReadDedupe.run( + stableInFlightKey([ + 'commitDiff', + worktreePath, + args.commitOid, + args.parentOid ?? null, + args.filePath, + args.oldPath ?? null + ]), + async () => + (await requestGitStreamable(this.mux, 'git.commitDiff', { + worktreePath, + ...args + })) as GitDiffResult + ) as Promise + } + + async getUpstreamStatus( + worktreePath: string, + pushTarget?: GitPushTarget + ): Promise { + return this.upstreamStatusReadOwner.read( + { kind: 'ssh-provider' }, + worktreePath, + pushTarget, + async () => + (await this.mux.request('git.upstreamStatus', { + worktreePath, + ...(pushTarget ? { pushTarget } : {}) + })) as GitUpstreamStatus + ) + } +} diff --git a/src/main/providers/ssh-git-relay-errors.ts b/src/main/providers/ssh-git-relay-errors.ts new file mode 100644 index 00000000000..d8bd8fe9016 --- /dev/null +++ b/src/main/providers/ssh-git-relay-errors.ts @@ -0,0 +1,8 @@ +import { JsonRpcErrorCode } from '../ssh/relay-protocol' + +export function isJsonRpcMethodNotFoundError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + return (error as { code?: unknown }).code === JsonRpcErrorCode.MethodNotFound +} diff --git a/src/main/providers/ssh-git-remote-sync-provider.ts b/src/main/providers/ssh-git-remote-sync-provider.ts new file mode 100644 index 00000000000..bead9414f3b --- /dev/null +++ b/src/main/providers/ssh-git-remote-sync-provider.ts @@ -0,0 +1,84 @@ +import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' +import type { GitPushTarget } from '../../shared/worktree/types' +import { REBASE_FROM_BASE_RPC_TIMEOUT_MS } from '../../shared/git-rebase-source' +import { SshGitWorkingTreeProvider } from './ssh-git-working-tree-provider' + +export class SshGitRemoteSyncProvider extends SshGitWorkingTreeProvider { + async pushBranch( + worktreePath: string, + publish = false, + pushTarget?: GitPushTarget, + options: { forceWithLease?: boolean } = {} + ): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.push', { + worktreePath, + publish, + pushTarget, + ...(options.forceWithLease === true ? { forceWithLease: true } : {}) + }) + }) + } + + async pullBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.pull', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) + }) + } + + async fastForwardBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.fastForward', { + worktreePath, + ...(pushTarget ? { pushTarget } : {}) + }) + }) + } + + async rebaseFromBase(worktreePath: string, baseRef: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request( + 'git.rebaseFromBase', + { worktreePath, baseRef }, + { timeoutMs: REBASE_FROM_BASE_RPC_TIMEOUT_MS } + ) + }) + } + + async fetchRemote(worktreePath: string, pushTarget?: GitPushTarget): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.fetch', { worktreePath, ...(pushTarget ? { pushTarget } : {}) }) + }) + } + + async syncForkDefaultBranch( + worktreePath: string, + expectedUpstream: GitForkSyncExpectedUpstream + ): Promise { + return this.runWithGitReadInvalidation( + async () => + (await this.mux.request('git.forkSync', { + worktreePath, + ...(expectedUpstream ? { expectedUpstream } : {}) + })) as GitForkSyncResult + ) + } + + async fetchRemoteTrackingRef( + worktreePath: string, + remote: string, + branch: string, + ref: string, + options?: { skipAutoMaintenance?: boolean } + ): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.fetchRemoteTrackingRef', { + worktreePath, + remote, + branch, + ref, + ...(options?.skipAutoMaintenance ? { skipAutoMaintenance: true } : {}) + }) + }) + } +} diff --git a/src/main/providers/ssh-git-review-head-provider.ts b/src/main/providers/ssh-git-review-head-provider.ts new file mode 100644 index 00000000000..5ff5cc69048 --- /dev/null +++ b/src/main/providers/ssh-git-review-head-provider.ts @@ -0,0 +1,70 @@ +import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' +import { SshGitRemoteSyncProvider } from './ssh-git-remote-sync-provider' + +function readDurableReviewHeadLocalRef( + result: unknown, + kind: 'pull request' | 'merge request' +): string { + if (result && typeof result === 'object' && 'localRef' in result) { + const localRef = (result as { localRef: unknown }).localRef + if (typeof localRef === 'string') { + const trimmed = localRef.trim() + if (trimmed.startsWith('refs/orca/')) { + return trimmed + } + } + } + throw new Error( + `This SSH host did not return the durable ${kind} head ref. Reconnect to deploy the latest relay, then try again.` + ) +} + +export class SshGitReviewHeadProvider extends SshGitRemoteSyncProvider { + async fetchGitLabMergeRequestHead( + worktreePath: string, + remote: string, + mrIid: number + ): Promise { + try { + return await this.runWithGitReadInvalidation(async () => { + const result = await this.mux.request('git.fetchGitLabMergeRequestHeadRef', { + worktreePath, + remote, + mrIid + }) + return readDurableReviewHeadLocalRef(result, 'merge request') + }) + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'This SSH host is running an older Orca relay that cannot fetch merge request heads. Reconnect to deploy the latest relay, then try again.' + ) + } + throw error + } + } + + async fetchGitHubPullRequestHead( + worktreePath: string, + remote: string, + prNumber: number + ): Promise { + try { + return await this.runWithGitReadInvalidation(async () => { + const result = await this.mux.request('git.fetchGitHubPullRequestHead', { + worktreePath, + remote, + prNumber + }) + return readDurableReviewHeadLocalRef(result, 'pull request') + }) + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'This SSH host is running an older Orca relay that cannot fetch pull request heads. Reconnect to deploy the latest relay, then try again.' + ) + } + throw error + } + } +} diff --git a/src/main/providers/ssh-git-working-tree-provider.ts b/src/main/providers/ssh-git-working-tree-provider.ts new file mode 100644 index 00000000000..6942b91ca1b --- /dev/null +++ b/src/main/providers/ssh-git-working-tree-provider.ts @@ -0,0 +1,122 @@ +import type { + GitBranchCompareResult, + GitCommitCompareResult +} from '../../shared/git-diff-compare-types' +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import type { GitConflictOperation } from '../../shared/git-status-types' +import { SshGitNoninteractiveProvider } from './ssh-git-noninteractive-provider' + +export class SshGitWorkingTreeProvider extends SshGitNoninteractiveProvider { + async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise { + return (await this.mux.request('git.checkIgnored', { + worktreePath, + paths: relativePaths + })) as string[] + } + + async getHistory( + worktreePath: string, + options: GitHistoryOptions = {} + ): Promise { + return (await this.mux.request('git.history', { + worktreePath, + ...options + })) as GitHistoryResult + } + + async commit( + worktreePath: string, + message: string + ): Promise<{ success: boolean; error?: string }> { + return this.runWithGitReadInvalidation( + async () => + (await this.mux.request('git.commit', { + worktreePath, + message + })) as { success: boolean; error?: string } + ) + } + + async stageFile(worktreePath: string, filePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.stage', { worktreePath, filePath }) + }) + } + + async unstageFile(worktreePath: string, filePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.unstage', { worktreePath, filePath }) + }) + } + + async bulkStageFiles(worktreePath: string, filePaths: string[]): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.bulkStage', { worktreePath, filePaths }) + }) + } + + async bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.bulkUnstage', { worktreePath, filePaths }) + }) + } + + async discardChanges(worktreePath: string, filePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.discard', { worktreePath, filePath }) + }) + } + + async bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.bulkDiscard', { worktreePath, filePaths }) + }) + } + + async detectConflictOperation(worktreePath: string): Promise { + return (await this.mux.request('git.conflictOperation', { + worktreePath + })) as GitConflictOperation + } + + async abortMerge(worktreePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.abortMerge', { worktreePath }) + }) + } + + async abortRebase(worktreePath: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.abortRebase', { worktreePath }) + }) + } + + async checkoutBranch(worktreePath: string, branch: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.checkout', { worktreePath, branch }) + }) + } + + async listLocalBranches( + worktreePath: string + ): Promise<{ current: string | null; branches: string[] }> { + return (await this.mux.request('git.localBranches', { worktreePath })) as { + current: string | null + branches: string[] + } + } + + async getBranchCompare(worktreePath: string, baseRef: string): Promise { + return (await this.mux.request('git.branchCompare', { + worktreePath, + baseRef + })) as GitBranchCompareResult + } + + async getCommitCompare(worktreePath: string, commitId: string): Promise { + return (await this.mux.request('git.commitCompare', { + worktreePath, + commitId + })) as GitCommitCompareResult + } +} diff --git a/src/main/providers/ssh-git-worktree-provider.ts b/src/main/providers/ssh-git-worktree-provider.ts new file mode 100644 index 00000000000..ce509ccf847 --- /dev/null +++ b/src/main/providers/ssh-git-worktree-provider.ts @@ -0,0 +1,143 @@ +import type { GitStatusResult } from '../../shared/git-status-types' +import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' +import { SshGitReviewHeadProvider } from './ssh-git-review-head-provider' + +function formatStatusEntriesForCleanCheck(entries: GitStatusResult['entries']): string | undefined { + if (entries.length === 0) { + return undefined + } + return entries.map((entry) => `${entry.area} ${entry.status}: ${entry.path}`).join('\n') +} + +function filterUntrackedPorcelainStatus(stdout: string | undefined): string | undefined { + const trackedLines = (stdout ?? '') + .split(/\r?\n/) + .filter((line) => line.trim().length > 0 && !line.startsWith('?? ')) + return trackedLines.length > 0 ? trackedLines.join('\n') : undefined +} + +export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { + private loggedWorktreeIsCleanFallback = false + + async listWorktrees( + repoPath: string, + options?: { signal?: AbortSignal } + ): Promise { + return (await this.mux.request( + 'git.listWorktrees', + { repoPath }, + { signal: options?.signal } + )) as GitWorktreeInfo[] + } + + async addWorktree( + repoPath: string, + branchName: string, + targetDir: string, + options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean } + ): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.addWorktree', { + repoPath, + branchName, + targetDir, + ...options + }) + }) + } + + async removeWorktree( + worktreePath: string, + force?: boolean, + options?: { deleteBranch?: boolean; forceBranchDelete?: boolean } + ): Promise { + return this.runWithGitReadInvalidation( + async () => + ((await this.mux.request('git.removeWorktree', { + worktreePath, + force, + ...options + })) ?? {}) as RemoveWorktreeResult + ) + } + + async worktreeIsClean( + worktreePath: string, + options: { includeUntracked?: boolean } = {} + ): Promise<{ clean: boolean; stdout?: string }> { + try { + const result = (await this.mux.request('git.worktreeIsClean', { + worktreePath, + ...(options.includeUntracked === false ? { includeUntracked: false } : {}) + })) as { clean: boolean; stdout?: string } + if (options.includeUntracked === false) { + if (!result.clean && result.stdout === undefined) { + return result + } + const trackedStdout = filterUntrackedPorcelainStatus(result.stdout) + return { clean: !trackedStdout, ...(trackedStdout ? { stdout: trackedStdout } : {}) } + } + return result + } catch (error) { + if (!isJsonRpcMethodNotFoundError(error)) { + throw error + } + if (!this.loggedWorktreeIsCleanFallback) { + this.loggedWorktreeIsCleanFallback = true + console.warn( + '[ssh-git] Relay does not implement git.worktreeIsClean; falling back to git.status clean check' + ) + } + const status = await this.getStatus(worktreePath) + const entries = + options.includeUntracked === false + ? status.entries.filter((entry) => entry.area !== 'untracked') + : status.entries + const clean = entries.length === 0 + return { clean, stdout: formatStatusEntriesForCleanCheck(entries) } + } + } + + async refreshLocalBaseRefForWorktreeCreate(args: { + repoPath: string + fullRef: string + remoteTrackingRef: string + ownerWorktreePath?: string + checkOnly?: boolean + }): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.refreshLocalBaseRefForWorktreeCreate', args) + }) + } + + async renameCurrentBranch(worktreePath: string, newBranch: string): Promise { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.renameCurrentBranch', { worktreePath, newBranch }) + }) + } + + async forceDeletePreservedBranch( + repoPath: string, + branchName: string, + expectedHead: string + ): Promise { + try { + await this.runWithGitReadInvalidation(async () => { + await this.mux.request('git.forceDeletePreservedBranch', { + repoPath, + branchName, + expectedHead + }) + }) + } catch (error) { + if (isJsonRpcMethodNotFoundError(error)) { + throw new Error( + 'This SSH host is running an older Orca relay that cannot delete preserved branches. Reconnect to deploy the latest relay, then try again.' + ) + } + throw error + } + } +} diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index 10feb7e90b2..210f4778ac9 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -4,6 +4,9 @@ make the lifecycle harder to audit. */ import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' import { resolveClaudeCommand } from '../codex-cli/command' +// Why: import from the shared module, not the codex-cli re-export, so a test that +// mocks '../codex-cli/command' does not have to restate this pure helper. +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import { applyClaudeEnvPatch } from '../claude-accounts/environment' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' @@ -292,7 +295,7 @@ export async function fetchViaPty(options?: { // Why: hidden usage PTYs must not inherit the process cwd (e.g. / or a // drive root), which can trigger unbounded file discovery. cwd: resolveHiddenRateLimitPtyCwd(), - env: spawnEnv + env: withCliRuntimeOnPath(claudeCommand, spawnEnv) }) const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)] let enterInterval: ReturnType | null = null diff --git a/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts b/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts new file mode 100644 index 00000000000..ee874ef5942 --- /dev/null +++ b/src/main/rate-limits/codex-fetcher-rpc-exit-diagnostics.test.ts @@ -0,0 +1,140 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as Win32Utils from '../win32-utils' +import { isCodexAuthError } from '../../shared/codex-auth-errors' + +const { getSpawnArgsForWindowsMock, ptySpawnMock, resolveCodexCommandMock } = vi.hoisted(() => ({ + getSpawnArgsForWindowsMock: vi.fn(), + ptySpawnMock: vi.fn(), + resolveCodexCommandMock: vi.fn() +})) + +vi.mock('../win32-utils', async (importOriginal) => ({ + ...(await importOriginal()), + getSpawnArgsForWindows: getSpawnArgsForWindowsMock +})) + +vi.mock('../codex-cli/command', () => ({ + resolveCodexCommand: resolveCodexCommandMock +})) + +vi.mock('node-pty', () => ({ + spawn: ptySpawnMock +})) + +vi.mock('./codex-auth-presence', () => ({ + probeCodexAuthPresence: vi.fn(() => 'present') +})) + +vi.mock('../codex/codex-state-db', () => ({ + isCodexStateDbBackfillPending: vi.fn(() => false) +})) + +vi.mock('../codex/codex-state-db-backfill-recovery', () => ({ + startCodexStateDbBackfillRecoveryInBackground: vi.fn(() => Promise.resolve(null)) +})) + +import { fetchCodexRateLimits } from './codex-fetcher' + +// Dies the way a real app-server does: complains on stderr, then exits nonzero +// once the client has spoken, so the initialize write can never race an EPIPE. +const STUB_CODEX_SOURCE = ` +process.stdin.on('data', () => { + process.stderr.write(process.env.ORCA_STUB_CODEX_STDERR ?? '') + process.exit(Number(process.env.ORCA_STUB_CODEX_EXIT_CODE ?? '1')) +}) +` + +let tempRoot: string +let stubPath: string + +function runStub(stderr: string, exitCode: number): Promise<{ error: string | null }> { + process.env.ORCA_STUB_CODEX_STDERR = stderr + process.env.ORCA_STUB_CODEX_EXIT_CODE = String(exitCode) + return fetchCodexRateLimits({ allowPtyFallback: false }) +} + +describe('Codex RPC exit diagnostics', () => { + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'orca-codex-rpc-exit-')) + stubPath = join(tempRoot, 'codex-exit-stub.cjs') + writeFileSync(stubPath, STUB_CODEX_SOURCE) + resolveCodexCommandMock.mockReturnValue('codex') + getSpawnArgsForWindowsMock.mockImplementation((_command: string, args: string[]) => ({ + spawnCmd: process.execPath, + spawnArgs: [stubPath, ...args] + })) + }) + + afterEach(() => { + delete process.env.ORCA_STUB_CODEX_STDERR + delete process.env.ORCA_STUB_CODEX_EXIT_CODE + rmSync(tempRoot, { recursive: true, force: true }) + vi.clearAllMocks() + }) + + it('names the exit code and what the child reported', async () => { + const result = await runStub( + "error: invalid value 'untrusted' for '--ask-for-approval '\n" + + " [possible values: on-request, never]\n\nFor more information, try '--help'.\n", + 2 + ) + + expect(result.error).toContain('Codex RPC process exited (exit code 2)') + expect(result.error).toContain("invalid value 'untrusted'") + expect(result.error).toContain("try '--help'") + }) + + it('names the exit code when the child says nothing', async () => { + await expect(runStub('', 1)).resolves.toMatchObject({ + error: 'Codex RPC process exited (exit code 1)', + status: 'error' + }) + }) + + it('classifies a dead refresh token so the re-auth warning fires', async () => { + const result = await runStub( + '2026-08-20T18:04:11.221Z INFO codex_core::auth: refreshing ChatGPT tokens\n' + + 'ERROR: The ChatGPT access token could not be refreshed; please sign in again.\n', + 1 + ) + + expect(result.error).toBe('Your ChatGPT session could not be refreshed. Please sign in again.') + // The renderer's re-auth warning gates on exactly this predicate; + // codex-account-auth-warning.test.ts asserts the surfaced string end-to-end. + expect(isCodexAuthError(result.error)).toBe(true) + }) + + it('keeps the re-auth phrase that path redaction would swallow', async () => { + const result = await runStub( + `ERROR: /Users/haris/.codex/auth.json is stale — please sign in again.\n`, + 1 + ) + + expect(isCodexAuthError(result.error)).toBe(true) + }) + + it('redacts paths and secrets a dying child prints, and bounds the line', async () => { + const result = await runStub( + `ERROR: could not read /Users/moveoplus/.codex/auth.json (token=sk-${'a'.repeat(40)})\n` + + `${'x'.repeat(5_000)}\n`, + 3 + ) + + expect(result.error).not.toContain('moveoplus') + expect(result.error).not.toContain('sk-aaaa') + expect(result.error?.length ?? 0).toBeLessThan(500) + }) + + it('keeps the final failure after startup banners', async () => { + const result = await runStub( + 'Welcome to WSL\nLoading shell profile\nERROR: unknown option --obsolete\n', + 2 + ) + + expect(result.error).toContain('Welcome to WSL') + expect(result.error).toContain('ERROR: unknown option --obsolete') + }) +}) diff --git a/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts b/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts new file mode 100644 index 00000000000..0353bd4c31d --- /dev/null +++ b/src/main/rate-limits/codex-fetcher-runtime-pairing.test.ts @@ -0,0 +1,133 @@ +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { EventEmitter } from 'node:events' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { childSpawnMock, readFileMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({ + childSpawnMock: vi.fn(), + readFileMock: vi.fn(), + resolveCodexCommandMock: vi.fn(), + ptySpawnMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) +vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) +vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: resolveCodexCommandMock })) +vi.mock('node-pty', () => ({ spawn: ptySpawnMock })) +vi.mock('../codex/codex-state-db', () => ({ isCodexStateDbBackfillPending: vi.fn(() => false) })) +vi.mock('../codex/codex-state-db-backfill-recovery', () => ({ + startCodexStateDbBackfillRecoveryInBackground: vi.fn(() => Promise.resolve(null)) +})) +vi.mock('./codex-auth-presence', () => ({ probeCodexAuthPresence: vi.fn(() => 'present') })) + +import { fetchCodexRateLimits } from './codex-fetcher' + +function makeRpcChild() { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + stdin: EventEmitter & { write: ReturnType; end: ReturnType } + kill: ReturnType + exitCode: number | null + } + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + const exitNow = (): void => { + child.exitCode = 0 + child.emit('exit', 0, null) + child.emit('close', 0, null) + } + child.stdin = Object.assign(new EventEmitter(), { write: vi.fn(), end: vi.fn(exitNow) }) + child.exitCode = null + child.kill = vi.fn(() => { + exitNow() + return true + }) + return child +} + +/** A CLI installed under one version manager entry, with its sibling node. */ +function makeVersionManagerCli(cliName = 'codex'): { bin: string; cli: string } { + const root = mkdtempSync(join(tmpdir(), 'orca-fetch-pair-')) + const bin = join(root, '.nvm', 'versions', 'node', 'v20.11.0', 'bin') + mkdirSync(bin, { recursive: true }) + for (const name of ['node', 'node.exe', cliName]) { + writeFileSync(join(bin, name), '') + chmodSync(join(bin, name), 0o755) + } + return { bin, cli: join(bin, cliName) } +} + +describe('codex rate-limit spawn runtime pairing', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + readFileMock.mockRejectedValue(new Error('no auth fixture')) + vi.stubGlobal('fetch', vi.fn()) + }) + + it("spawns the RPC reader with the resolved CLI's own node ahead of PATH", async () => { + // Why a real fixture: the helper short-circuits on a non-absolute command, + // so a bare 'codex' mock would make this pass vacuously. + const { bin, cli } = makeVersionManagerCli() + resolveCodexCommandMock.mockReturnValue(cli) + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + + const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + await vi.advanceTimersByTimeAsync(0) + + const spawnEnv = childSpawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv + // Guards the argument choice: pairing spawnCmd (cmd.exe on win32) rather than + // the resolved CLI silently reverts the ABI fix (stablyai/orca#10932). + expect(spawnEnv.PATH?.split(delimiter)[0]).toBe(bin) + + rpcChild.emit('close') + await resultPromise + }) + + it('pairs the resolved CLI on win32, where the spawn command is cmd.exe', async () => { + // Why win32 specifically: on posix getSpawnArgsForWindows returns the CLI + // itself, so pairing the spawn command instead of the resolved CLI is + // indistinguishable. Only here does the wrong argument become cmd.exe. + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const { bin, cli } = makeVersionManagerCli('codex.cmd') + resolveCodexCommandMock.mockReturnValue(cli) + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + + const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + await vi.advanceTimersByTimeAsync(0) + + const spawnCommand = childSpawnMock.mock.calls[0]?.[0] as string + expect(spawnCommand.toLowerCase()).toContain('cmd.exe') + const spawnEnv = childSpawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv + expect((spawnEnv.Path ?? spawnEnv.PATH)?.split(';')[0]).toBe(bin) + + rpcChild.emit('close') + await resultPromise + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('leaves PATH alone when the CLI resolves to a bare command name', async () => { + resolveCodexCommandMock.mockReturnValue('codex') + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + + const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false }) + await vi.advanceTimersByTimeAsync(0) + + const spawnEnv = childSpawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv + expect(spawnEnv.PATH).toBe(process.env.PATH) + + rpcChild.emit('close') + await resultPromise + }) +}) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 62cab4b94bb..00c78aaa5f8 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -19,12 +19,20 @@ import { type CodexRateWindowSnapshot } from './codex-rate-limit-window-classification' import { resolveCodexCommand } from '../codex-cli/command' +// Why: import from the shared module, not the codex-cli re-export, so a test that +// mocks '../codex-cli/command' does not have to restate this pure helper. +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { CODEX_READ_ONLY_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' import { parseWslUncPath } from '../../shared/wsl-paths' import { extractCodexAuthError, isCodexAuthError } from '../../shared/codex-auth-errors' +import { + excerptAgentFailureOutput, + sanitizeAgentFailureDetail +} from '../../shared/commit-message-agent-output' +import { redactString } from '../observability/redactor' import { buildWslExecArgs, buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command' import { getHiddenRateLimitWslCwdSetupCommands, @@ -643,10 +651,10 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise | null = null @@ -863,19 +871,42 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise void }[] = [registerHiddenRateLimitPty(term)] diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 79a0a30d654..fd0645f5274 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1,7 +1,10 @@ /* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */ import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { RuntimeBrowserCommands } from './orca-runtime-browser' -import { setRuntimeBrowserCommandsFactory } from './runtime-browser-commands-factory' +import { + setRuntimeBrowserCommandsFactory, + setRuntimeBrowserUnavailableCause +} from './runtime-browser-commands-factory' import { setRuntimeDesktopSurface } from './runtime-desktop-surface' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import type * as GitUsernameModule from '../git/git-username' @@ -687,6 +690,7 @@ function resetRuntimeTestMocks(): void { // production installs this at the Electron entry. A Node host installs none and the // browser RPCs reject rather than silently succeeding. setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host)) + setRuntimeBrowserUnavailableCause(null) // Why: the runtime's notification, window lookup and tab-create-reply channel are // injected now, so the electron mock alone is inert. Back the surface with the same // mocks so every existing expectation still holds. @@ -2338,6 +2342,33 @@ describe('OrcaRuntimeService', () => { expect(capabilities).toContain('browser.headless.v1') expect(capabilities).toContain('browser.certificate-trust.v1') }) + + it('advertises only while headless browser commands remain live', () => { + let available = true + setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host), { + headless: true, + isAvailable: () => available + }) + const status = createRuntime().getStatus() + + expect(status.capabilities).toContain('browser.headless.v1') + expect(status.capabilities).not.toContain('browser.screencast.v1') + expect(status.capabilities).not.toContain('browser.certificate-trust.v1') + expect(status.degradations).toBeUndefined() + + available = false + const degraded = createRuntime().getStatus() + expect(degraded.capabilities).not.toContain('browser.headless.v1') + // A provider that resolved and then died is a health failure, never a config mistake. + expect(degraded.degradations).toEqual([ + { + code: 'browser_unavailable', + capability: 'browser.headless.v1', + reason: 'provider_unhealthy', + message: 'The browser provider started but is no longer answering health checks.' + } + ]) + }) it('surfaces live offscreen load failures in headless browser snapshots', () => { const runtime = createRuntime() runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() }) @@ -2465,11 +2496,100 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus().capabilities).toContain('browser.certificate-trust.v1') }) - it('does not advertise certificate trust when no browser backend is available', () => { + it('declares browser unavailability when no browser provider resolves', async () => { + setRuntimeBrowserCommandsFactory(null) const runtime = createRuntime() + const status = runtime.getStatus() - expect(runtime.getStatus().capabilities).not.toContain('browser.certificate-trust.v1') - expect(runtime.getStatus().capabilities).not.toContain('browser.screencast.v1') + expect(status.capabilities).not.toContain('browser.headless.v1') + expect(status.capabilities).not.toContain('browser.certificate-trust.v1') + expect(status.capabilities).not.toContain('browser.screencast.v1') + expect(status.degradations).toEqual([ + { + code: 'browser_unavailable', + capability: 'browser.headless.v1', + reason: 'unknown', + message: + 'Browser automation is unavailable on this host, and the cause could not be determined.' + } + ]) + const browserCalls = Object.entries(runtime).filter( + ([name, value]) => /^browser[A-Z]/.test(name) && typeof value === 'function' + ) + expect(browserCalls.length).toBeGreaterThan(50) + for (const [name, call] of browserCalls) { + const invoke = + name === 'browserScreencast' + ? () => + (call as CallableFunction)( + { format: 'jpeg' }, + { sendBinary: () => true, emit: () => undefined } + ) + : () => (call as CallableFunction)({}) + await expect(Promise.resolve().then(invoke)).rejects.toMatchObject({ + code: 'browser_unavailable' + }) + } + }) + + it('reports the driver as missing instead of telling a configured operator to configure it', () => { + setRuntimeBrowserCommandsFactory(null) + setRuntimeBrowserUnavailableCause({ reason: 'driver_missing' }) + + const [degradation] = createRuntime().getStatus().degradations ?? [] + + expect(degradation).toEqual({ + code: 'browser_unavailable', + capability: 'browser.headless.v1', + reason: 'driver_missing', + message: + 'ORCA_BROWSER_EXECUTABLE is set, but the bundled agent-browser driver is missing or not executable on this host, so Chromium cannot be driven.' + }) + // The whole point: never send someone to set a variable they already set. + expect(degradation?.message).not.toMatch(/set ORCA_BROWSER_EXECUTABLE/) + }) + + it('carries the underlying error to the client when a provider failed to start', () => { + setRuntimeBrowserCommandsFactory(null) + setRuntimeBrowserUnavailableCause({ + reason: 'electron_start_failed', + detail: 'sidecar exited with code 1' + }) + + const [degradation] = createRuntime().getStatus().degradations ?? [] + + expect(degradation).toEqual({ + code: 'browser_unavailable', + capability: 'browser.headless.v1', + reason: 'electron_start_failed', + detail: 'sidecar exited with code 1', + message: + 'The installed Electron browser provider failed to start. (sidecar exited with code 1)' + }) + }) + + it('blames the missing desktop window when a renderer-backed factory is installed', () => { + const [degradation] = createRuntime().getStatus().degradations ?? [] + + expect(degradation).toMatchObject({ + reason: 'desktop_window_unavailable', + message: 'Browser automation on this host needs a desktop window, and none is available.' + }) + }) + + it('keeps the degradation wire-safe for peers that predate structured causes', () => { + setRuntimeBrowserCommandsFactory(null) + setRuntimeBrowserUnavailableCause({ reason: 'executable_not_found', detail: '/nope/chromium' }) + + const [degradation] = createRuntime().getStatus().degradations ?? [] + + // Old clients read only these three: the closed code must not move and the human + // sentence must stand alone without the optional fields. + expect(degradation?.code).toBe('browser_unavailable') + expect(degradation?.capability).toBe('browser.headless.v1') + expect(degradation?.message).toBe( + 'ORCA_BROWSER_EXECUTABLE points at a path that does not exist. (/nope/chromium)' + ) }) it('closes a worktree’s offscreen browser pages when its metadata is removed (leak fix)', () => { @@ -37718,6 +37838,261 @@ describe('OrcaRuntimeService', () => { expect(summary).toMatchObject({ hasHostSidebarActivity: true, status: 'working' }) }) + it('projects monitoring for folder workspaces without assuming a git worktree', async () => { + const now = Date.now() + const folderWorkspace = makeFolderWorkspace({ name: 'GG' }) + const projectGroup = makeFolderProjectGroup({ name: 'Store' }) + const runtime = new OrcaRuntimeService( + createFolderWorkspaceRuntimeStore(folderWorkspace, projectGroup) as never, + undefined, + { + getAgentStatusSnapshot: () => [ + { + paneKey: 'folder-pane', + worktreeId: TEST_FOLDER_WORKSPACE_KEY, + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + } + ) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_FOLDER_WORKSPACE_KEY) + + expect(summary).toMatchObject({ + workspaceKind: 'folder-workspace', + status: 'working', + workingMode: 'monitoring' + }) + }) + it('projects monitoring over a title-derived working status', async () => { + const now = Date.now() + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'tab-1:1', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1', + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude working' }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ status: 'working', workingMode: 'monitoring' }) + }) + it('keeps hook monitoring mode when a newer mode-less OSC row reports the same work', async () => { + const now = Date.now() + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'tab-1:1', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1', + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now - 100, + stateStartedAt: now - 200 + } + ] + }) + syncSinglePty(runtime) + runtime.onPtyData( + 'pty-1', + '\x1b]9999;{"state":"working","prompt":"watch tests","agentType":"claude"}\x07', + 1 + ) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ status: 'working', workingMode: 'monitoring' }) + expect(summary?.agents).toEqual([ + expect.objectContaining({ + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests' + }) + ]) + }) + it('does not carry hook monitoring mode into a newer OSC turn', async () => { + const now = Date.now() + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'tab-1:1', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1', + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now - 100, + stateStartedAt: now - 200 + } + ] + }) + syncSinglePty(runtime) + runtime.onPtyData( + 'pty-1', + '\x1b]9999;{"state":"working","prompt":"fix tests","agentType":"claude"}\x07', + 1 + ) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ status: 'working' }) + expect(summary).not.toHaveProperty('workingMode') + expect(summary?.agents).toEqual([ + expect.objectContaining({ state: 'working', prompt: 'fix tests' }) + ]) + }) + it('keeps title-only foreground work ahead of monitoring in another pane', async () => { + const now = Date.now() + const monitoringLeafId = '33333333-3333-4333-8333-333333333333' + const foregroundLeafId = '44444444-4444-4444-8444-444444444444' + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: `monitoring-tab:${monitoringLeafId}`, + worktreeId: TEST_WORKTREE_ID, + tabId: 'monitoring-tab', + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'monitoring-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: monitoringLeafId, + layout: null + }, + { + tabId: 'foreground-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: foregroundLeafId, + layout: null + } + ], + leaves: [ + { + tabId: 'monitoring-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: monitoringLeafId, + paneRuntimeId: 1, + ptyId: 'monitoring-pty', + paneTitle: 'claude' + }, + { + tabId: 'foreground-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: foregroundLeafId, + paneRuntimeId: 2, + ptyId: 'foreground-pty', + paneTitle: 'claude working' + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ status: 'working' }) + expect(summary).not.toHaveProperty('workingMode') + expect(summary?.agents).toEqual([ + expect.objectContaining({ paneKey: `monitoring-tab:${monitoringLeafId}` }) + ]) + }) + it('keeps title-only foreground work ahead of monitoring in another split pane', async () => { + const now = Date.now() + const tabId = 'split-tab' + const monitoringLeafId = '33333333-3333-4333-8333-333333333333' + const foregroundLeafId = '44444444-4444-4444-8444-444444444444' + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: `${tabId}:${monitoringLeafId}`, + worktreeId: TEST_WORKTREE_ID, + tabId, + state: 'working', + workingMode: 'monitoring', + prompt: 'watch tests', + agentType: 'claude', + connectionId: null, + receivedAt: now, + stateStartedAt: now - 100 + } + ] + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + title: 'Claude', + activeLeafId: monitoringLeafId, + layout: null + } + ], + leaves: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: monitoringLeafId, + paneRuntimeId: 1, + ptyId: 'monitoring-pty', + paneTitle: 'claude' + }, + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId: foregroundLeafId, + paneRuntimeId: 2, + ptyId: 'foreground-pty', + paneTitle: 'claude working' + } + ] + }) + + const { worktrees } = await runtime.getWorktreePs() + const summary = worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID) + + expect(summary).toMatchObject({ status: 'working' }) + expect(summary).not.toHaveProperty('workingMode') + }) + it('suppresses restored-unconfirmed hook rows from worktree.ps', async () => { const leafId = '33333333-3333-4333-8333-333333333333' const now = Date.now() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 60a20f7929f..98f138a3567 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -55,6 +55,7 @@ import { type AgentStatusOrchestrationContext, type AgentStatusEntry } from '../../shared/agent-status-types' +import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' import { indexAgentStatusRowsByPaneKey } from '../agent-hooks/agent-status-pane-index' import type { AgentHookAuthorityAttestation } from '../agent-hooks/server' import type { @@ -424,7 +425,10 @@ import type { LinearStatusSetResult } from '../../shared/linear/agent-access' import { + BROWSER_UNAVAILABLE_ERROR_CODE, + browserUnavailableMessage, HEADLESS_RUNTIME_WINDOW_ID, + type RuntimeDegradation, type RuntimeDesktopWindowStatus, type RuntimeGraphStatus, type RuntimeRepoSearchRefs, @@ -657,7 +661,11 @@ import { import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' import type { AutomationService } from '../automations/service' import type { RuntimeBrowserCommands } from './orca-runtime-browser' -import { createRuntimeBrowserCommands } from './runtime-browser-commands-factory' +import { + createRuntimeBrowserCommands, + runtimeBrowserCommandsFactoryIsHeadless, + runtimeBrowserUnavailableCause +} from './runtime-browser-commands-factory' import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency' import { deriveRemoteRuntimeTerminalCreateHandle } from './remote-runtime-terminal-create-identity' import { @@ -1752,6 +1760,32 @@ type RuntimeAgentRowSnapshot = { updatedAt: number } +type RuntimeWorkingTerminalEvidence = { + paneKey: string | null + ptyId: string | null + tabId: string | null +} + +type RuntimeWorktreeAgentSource = { + paneKey: string + ptyId?: string + tabId?: string + worktreeId?: string + connectionId: string | null + payload: ParsedAgentStatusPayload + state: ParsedAgentStatusPayload['state'] + workingMode?: ParsedAgentStatusPayload['workingMode'] + agentType: string | null + prompt: string + lastAssistantMessage: string | null + toolName: string | null + toolInput: string | null + interrupted: boolean + stateStartedAt: number + updatedAt: number + restoredUnconfirmed?: boolean +} + /** A hook row narrowed to what `session.tabs` publishes, shaped like the retained OSC * snapshot so one projection branch can consume either carrier. */ type HookLiveAgentRow = Pick< @@ -6105,6 +6139,7 @@ export class OrcaRuntimeService { // so they must not fall back to a local desktop browser tab. const hasRenderer = Boolean(this.getAvailableAuthoritativeWindow()) const hasOffscreen = !hasRenderer && Boolean(this.offscreenBrowserBackend) + const hasHeadlessCommands = runtimeBrowserCommandsFactoryIsHeadless() const canBrowse = hasRenderer || hasOffscreen const capabilities: RuntimeCapability[] = RUNTIME_CAPABILITIES.filter( (capability) => @@ -6115,7 +6150,7 @@ export class OrcaRuntimeService { (process.env.ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING !== '1' || capability !== TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY) ) - if (hasOffscreen) { + if (hasOffscreen || hasHeadlessCommands) { capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) } // Why: certificate proceed is owned by the browser-hosting process for both @@ -6124,6 +6159,21 @@ export class OrcaRuntimeService { if (canBrowse) { capabilities.push(BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY) } + // Why the cause and not one fixed sentence: the operator can only act on the reason + // that actually applies, and a host that says "set ORCA_BROWSER_EXECUTABLE" to someone + // who already set it sends them to fix a thing that is not broken. + const cause = canBrowse || hasHeadlessCommands ? null : runtimeBrowserUnavailableCause() + const degradations: RuntimeDegradation[] = cause + ? [ + { + code: BROWSER_UNAVAILABLE_ERROR_CODE, + capability: BROWSER_HEADLESS_RUNTIME_CAPABILITY, + message: browserUnavailableMessage(cause.reason, cause.detail), + reason: cause.reason, + ...(cause.detail ? { detail: cause.detail } : {}) + } + ] + : [] return { runtimeId: this.runtimeId, rendererGraphEpoch: this.rendererGraphEpoch, @@ -6137,6 +6187,7 @@ export class OrcaRuntimeService { // Why: headless orca serve cannot create/stream BrowserViews, so clients // must not treat browser panes as supported just because runtime RPC is up. capabilities, + ...(degradations.length > 0 ? { degradations } : {}), hostPlatform: process.platform, terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null, floatingWorkspaceEnabled: this.store?.getSettings?.().floatingTerminalEnabled !== false, @@ -12160,6 +12211,7 @@ export class OrcaRuntimeService { return ( !previous || previous.payload.state !== payload.state || + previous.payload.workingMode !== payload.workingMode || previous.payload.prompt !== payload.prompt || (previous.payload.agentType ?? null) !== (payload.agentType ?? null) || (previous.payload.toolName ?? null) !== (payload.toolName ?? null) || @@ -20020,6 +20072,7 @@ export class OrcaRuntimeService { const repoById = new Map((this.store?.getRepos() ?? []).map((repo) => [repo.id, repo])) const platformByRepoId = resolvedWorktreeSnapshot.platformByRepoId const summaries = new Map() + const workingTerminalEvidenceByWorktreeId = new Map() // Why: the GitHub cache is keyed by `repoPath::branch` (no refs/heads/ prefix), // matching how the renderer's fetchPRForBranch stores entries. We look up cached @@ -20203,10 +20256,15 @@ export class OrcaRuntimeService { summary.liveTerminalCount += 1 summary.hasAttachedPty = true summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, leaf.lastOutputAt) - summary.status = mergeWorktreeStatus( - summary.status, - getLeafWorktreeStatus(leaf, this.tabs.get(leaf.tabId)?.title ?? null) - ) + const leafStatus = getLeafWorktreeStatus(leaf, this.tabs.get(leaf.tabId)?.title ?? null) + if (leafStatus === 'working') { + addRuntimeWorkingTerminalEvidence(workingTerminalEvidenceByWorktreeId, summary.worktreeId, { + paneKey: this.makeRuntimePaneKey(leaf), + ptyId: leaf.ptyId, + tabId: leaf.tabId + }) + } + mergeWorktreeSummaryStatus(summary, leafStatus) if ( leaf.preview && (summary.preview.length === 0 || (leaf.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1)) @@ -20268,10 +20326,15 @@ export class OrcaRuntimeService { summary.hasAttachedPty = true summary.hasHostSidebarActivity = true summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, pty.lastOutputAt) - summary.status = mergeWorktreeStatus( - summary.status, - getSavedTabWorktreeStatus(owner.title, true) - ) + const ptyStatus = getSavedTabWorktreeStatus(owner.title, true) + if (ptyStatus === 'working') { + addRuntimeWorkingTerminalEvidence(workingTerminalEvidenceByWorktreeId, summary.worktreeId, { + paneKey: pty.paneKey, + ptyId: pty.ptyId, + tabId: pty.tabId ?? persistedTabId ?? null + }) + } + mergeWorktreeSummaryStatus(summary, ptyStatus) if ( pty.preview && (summary.preview.length === 0 || (pty.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1)) @@ -20372,7 +20435,8 @@ export class OrcaRuntimeService { runtimeWorktreeSummaryPathIndex, missingRuntimeWorktreeIds, mirroredWorktreeIdByTabId, - connectedPtyEvidence + connectedPtyEvidence, + workingTerminalEvidenceByWorktreeId ) const sorted = [...summaries.values()].sort(compareWorktreePs) @@ -20396,30 +20460,17 @@ export class OrcaRuntimeService { tabIds: ReadonlySet paneKeys: ReadonlySet ptyIds: ReadonlySet - } + }, + workingTerminalEvidenceByWorktreeId: ReadonlyMap< + string, + readonly RuntimeWorkingTerminalEvidence[] + > ): void { // Why: most agents report via hooks (agent-hooks/server), not OSC, so the // hook snapshot is the primary source — same one the desktop sidebar reads. // OSC-only entries (no hook) are merged in as a fallback, keyed by paneKey. - const rowSources = new Map< - string, - { - paneKey: string - ptyId?: string - tabId?: string - worktreeId?: string - connectionId: string | null - state: ParsedAgentStatusPayload['state'] - agentType: string | null - prompt: string - lastAssistantMessage: string | null - toolName: string | null - toolInput: string | null - interrupted: boolean - stateStartedAt: number - updatedAt: number - } - >() + const rowSources = new Map() + const now = Date.now() for (const snapshot of this.latestAgentStatusByPaneKey.values()) { const { payload } = snapshot rowSources.set(snapshot.paneKey, { @@ -20428,7 +20479,9 @@ export class OrcaRuntimeService { tabId: snapshot.tabId, worktreeId: snapshot.worktreeId, connectionId: snapshot.connectionId, + payload, state: payload.state, + ...(payload.workingMode ? { workingMode: payload.workingMode } : {}), agentType: payload.agentType ?? null, prompt: payload.prompt, lastAssistantMessage: payload.lastAssistantMessage ?? null, @@ -20445,9 +20498,22 @@ export class OrcaRuntimeService { continue } const existing = rowSources.get(entry.paneKey) + const hookPayload = pickParsedAgentStatusPayload(entry) // Why: hook rows win ties, but an older cached hook must not replace a // fresh OSC status and make a running mobile workspace look inactive. if (existing && existing.updatedAt > entry.receivedAt) { + if ( + entry.workingMode === 'monitoring' && + // restoredUnconfirmed rows already `continue` above. + now - entry.receivedAt <= AGENT_STATUS_STALE_AFTER_MS && + terminalStatusPayloadMatchesHook(hookPayload, existing.payload) + ) { + // Why: older OSC reporters cannot express hook-authoritative monitoring mode. + existing.workingMode = 'monitoring' + if (existing.payload.workingMode === undefined) { + existing.payload = { ...existing.payload, workingMode: 'monitoring' } + } + } continue } rowSources.set(entry.paneKey, { @@ -20458,7 +20524,9 @@ export class OrcaRuntimeService { tabId: entry.tabId, worktreeId: entry.worktreeId, connectionId: entry.connectionId, + payload: hookPayload, state: entry.state, + ...(entry.workingMode ? { workingMode: entry.workingMode } : {}), agentType: entry.agentType ?? null, prompt: entry.prompt, lastAssistantMessage: entry.lastAssistantMessage ?? null, @@ -20474,7 +20542,6 @@ export class OrcaRuntimeService { } const orchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey() const rowsByWorktree = new Map() - const now = Date.now() for (const src of rowSources.values()) { // Why: hooks retain launch-time attribution across automatic workspace // renames; the tab's current mirrored owner is authoritative when present. @@ -20521,6 +20588,7 @@ export class OrcaRuntimeService { paneKey: src.paneKey, parentPaneKey: orchestrationByPaneKey?.[src.paneKey]?.parentPaneKey ?? null, state: src.state, + ...(src.workingMode ? { workingMode: src.workingMode } : {}), agentType: src.agentType, prompt: src.prompt, taskTitle, @@ -20547,6 +20615,8 @@ export class OrcaRuntimeService { const summary = summaries.get(worktreeId) if (summary) { summary.agents = rows + let hasForegroundWorkingAgent = false + const monitoringSources: RuntimeWorktreeAgentSource[] = [] for (const row of rows) { if (!isFreshNonDoneAgentStatus(row, now)) { continue @@ -20554,9 +20624,31 @@ export class OrcaRuntimeService { // Why: worktree.ps is mobile's host-sidebar parity source, so a live // agent must survive the same temporary PTY gaps as desktop. summary.hasHostSidebarActivity = true - summary.status = mergeWorktreeStatus( - summary.status, - row.state === 'working' ? 'working' : 'permission' + if (row.state === 'working') { + if (row.workingMode === 'monitoring') { + const source = rowSources.get(row.paneKey) + if (source) { + monitoringSources.push(source) + } + } else { + hasForegroundWorkingAgent = true + } + } else { + mergeWorktreeSummaryStatus(summary, 'permission') + } + } + if (hasForegroundWorkingAgent || monitoringSources.length > 0) { + const hasIndependentWorkingTerminal = ( + workingTerminalEvidenceByWorktreeId.get(worktreeId) ?? [] + ).some((evidence) => + monitoringSources.every( + (source) => !runtimeWorkingTerminalEvidenceMatchesSource(evidence, source) + ) + ) + mergeWorktreeSummaryStatus( + summary, + 'working', + hasForegroundWorkingAgent || hasIndependentWorkingTerminal ? undefined : 'monitoring' ) } } @@ -30341,6 +30433,12 @@ export class OrcaRuntimeService { }) } + // Why: a leader handle that never binds to a PTY (lost pane race) has no exit + // or close path to evict its team, so the abandoning caller must release it. + releaseClaudeAgentTeamsLeaderForHandle(handle: string): void { + this.claudeAgentTeams.removeTeamForLeaderHandle(handle) + } + private waitForNewLeafInTab( tabId: string, existingLeafKeys: Set, @@ -40979,11 +41077,58 @@ function mapExplicitAgentStateToRuntimeTerminalStatus( } } -function mergeWorktreeStatus( - current: RuntimeWorktreeStatus, - next: RuntimeWorktreeStatus -): RuntimeWorktreeStatus { - return WORKTREE_STATUS_PRIORITY[next] > WORKTREE_STATUS_PRIORITY[current] ? next : current +function addRuntimeWorkingTerminalEvidence( + evidenceByWorktreeId: Map, + worktreeId: string, + evidence: RuntimeWorkingTerminalEvidence +): void { + const existing = evidenceByWorktreeId.get(worktreeId) + if (existing) { + existing.push(evidence) + } else { + evidenceByWorktreeId.set(worktreeId, [evidence]) + } +} + +function runtimeWorkingTerminalEvidenceMatchesSource( + evidence: RuntimeWorkingTerminalEvidence, + source: RuntimeWorktreeAgentSource +): boolean { + if (evidence.paneKey) { + return ( + evidence.paneKey === source.paneKey || + Boolean(evidence.ptyId && source.ptyId && evidence.ptyId === source.ptyId) + ) + } + if (evidence.ptyId && source.ptyId) { + return evidence.ptyId === source.ptyId + } + return Boolean(evidence.tabId && evidence.tabId === source.tabId) +} + +function mergeWorktreeSummaryStatus( + summary: RuntimeWorktreePsSummary, + next: RuntimeWorktreeStatus, + nextWorkingMode?: RuntimeWorktreePsSummary['workingMode'] +): void { + const currentPriority = WORKTREE_STATUS_PRIORITY[summary.status] + const nextPriority = WORKTREE_STATUS_PRIORITY[next] + if (nextPriority > currentPriority) { + summary.status = next + if (next === 'working' && nextWorkingMode === 'monitoring') { + summary.workingMode = 'monitoring' + } else { + delete summary.workingMode + } + return + } + if (nextPriority === currentPriority && next === 'working') { + if (nextWorkingMode === 'monitoring') { + summary.workingMode = 'monitoring' + } else { + delete summary.workingMode + } + } } function normalizeTerminalChunk( diff --git a/src/main/runtime/pty-waiver-source-invariant.test.ts b/src/main/runtime/pty-waiver-source-invariant.test.ts index e0b7122d825..5e4bcf7b4f2 100644 --- a/src/main/runtime/pty-waiver-source-invariant.test.ts +++ b/src/main/runtime/pty-waiver-source-invariant.test.ts @@ -7,7 +7,21 @@ import { describe, expect, it } from 'vitest' // while silently disabling the PTY gate on that path. `force` is set by the // ordinary delete confirmation (to skip the dirty-file prompt) and is NOT user // intent to delete past live terminals — so pin the wiring itself, at every site. -const FILES = [join(__dirname, '..', 'ipc', 'worktrees.ts'), join(__dirname, 'orca-runtime.ts')] +const FILE_GROUPS = [ + { + label: 'extracted worktree removal', + files: [ + join(__dirname, '..', 'ipc', 'worktrees', 'removal', 'worktree-removal-ownership.ts'), + join(__dirname, '..', 'ipc', 'worktrees', 'removal', 'remove-registered-local-worktree.ts'), + join(__dirname, '..', 'ipc', 'worktrees', 'removal', 'remove-registered-remote-worktree.ts'), + join(__dirname, '..', 'ipc', 'worktrees', 'removal', 'remove-unregistered-worktree.ts') + ] + }, + { + label: 'runtime removal', + files: [join(__dirname, 'orca-runtime.ts')] + } +] as const // Why: a comment quoting `allowUnverifiedStop:` would otherwise count as a site — // and this very invariant invites people to write one in the file it guards. @@ -20,8 +34,8 @@ function stripComments(source: string): string { } describe('the PTY-stop waiver is never derived from `force`', () => { - it.each(FILES)('%s passes only an explicit waiver to the teardown', (file) => { - const source = stripComments(readFileSync(file, 'utf8')) + it.each(FILE_GROUPS)('$label paths pass only an explicit waiver to the teardown', ({ files }) => { + const source = stripComments(files.map((file) => readFileSync(file, 'utf8')).join('\n')) // Why: derived, not hardcoded — merging two removal branches is a legitimate // refactor and must not read as a deleted safety wiring, while dropping the diff --git a/src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts b/src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts new file mode 100644 index 00000000000..7eac4f1f324 --- /dev/null +++ b/src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaCloudAuthConfig } from '../../orca-profiles/profile-cloud-auth-config' +import type { OrcaRuntimeRpcServer } from '../runtime-rpc' + +const fakes = vi.hoisted(() => ({ + readRelayAuthContext: vi.fn(), + brokers: [] as { live: boolean }[] +})) + +vi.mock('./relay-auth-context', () => ({ readRelayAuthContext: fakes.readRelayAuthContext })) + +vi.mock('./relay-session-broker', () => { + // Mirrors the real broker: a control that died rejects with relay_control_not_active. + class RelaySessionBroker { + live = true + readonly hostId = 'relay-host-1' + readonly ownerIdentityKey = 'user-1\0profile-1\0org-1' + readonly endpoint = { v: 1 as const, relayHostId: 'relay-host-1' } + isLive(): boolean { + return this.live + } + closeNow(): void { + this.live = false + } + async createPairingRelay(relayDeviceId: string): Promise { + if (!this.live) { + throw new Error('relay_control_not_active') + } + return { v: 1, relayHostId: this.hostId, relayDeviceId, inviteExpiresAt: 0 } + } + static connect = vi.fn(async () => { + const broker = new RelaySessionBroker() + fakes.brokers.push(broker) + return broker + }) + } + return { RelaySessionBroker } +}) + +import { DesktopRelayService } from './desktop-relay-service' + +function service(): DesktopRelayService { + fakes.brokers.length = 0 + fakes.readRelayAuthContext.mockResolvedValue({ + identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' }, + accessToken: 'access-1', + relayEntitled: true + }) + const runtimeRpc = { + getE2EEKeypair: () => ({ + publicKey: new Uint8Array(32).fill(7), + secretKey: new Uint8Array(32).fill(9), + publicKeyB64: 'x' + }), + getMobileSocketWiring: () => ({ attachTransport: () => () => {} }), + getRelayRevokeOutbox: () => ({ pendingFor: () => [], remove: vi.fn() }), + getDeviceRegistry: () => ({ + listDevices: () => [], + getDevice: () => ({ deviceId: 'device-1', scope: 'mobile' }), + getMobilePairingConnectionMode: () => 'automatic' + }) + } as unknown as OrcaRuntimeRpcServer + return new DesktopRelayService({ + authConfig: { + relayDirectorUrl: 'https://relay.example.test', + relayTokenEndpoint: 'https://login.example.test/relay-token' + } as OrcaCloudAuthConfig, + userDataPath: '/tmp/orca-relay-liveness-test', + appVersion: '1.4.188', + runtimeRpc, + onStatus: () => {} + }) +} + +describe('DesktopRelayService broker liveness', () => { + it('pairs through a replacement when the owned broker control died', async () => { + // Why: ownership stays 'valid' after a control socket dies, so the stale + // handle otherwise reaches create_pairing_relay and fails the pairing. + const relayService = service() + try { + await expect(relayService.createPairingRelay('device-1')).resolves.toMatchObject({ + binding: { relayDeviceId: 'device-1' } + }) + expect(fakes.brokers).toHaveLength(1) + + fakes.brokers[0]!.live = false + + await expect(relayService.createPairingRelay('device-2')).resolves.toMatchObject({ + binding: { relayDeviceId: 'device-2' } + }) + expect(fakes.brokers).toHaveLength(2) + expect(fakes.brokers[1]!.live).toBe(true) + } finally { + relayService.stop() + } + }) + + it('keeps using a live broker instead of replacing it', async () => { + const relayService = service() + try { + await relayService.createPairingRelay('device-1') + await relayService.createPairingRelay('device-2') + expect(fakes.brokers).toHaveLength(1) + } finally { + relayService.stop() + } + }) +}) diff --git a/src/main/runtime/relay/desktop-relay-service.ts b/src/main/runtime/relay/desktop-relay-service.ts index 6ce62270750..55b993bd5a6 100644 --- a/src/main/runtime/relay/desktop-relay-service.ts +++ b/src/main/runtime/relay/desktop-relay-service.ts @@ -297,8 +297,7 @@ export class DesktopRelayService { } private async activeBrokerForDemand(): Promise { - const broker = - this.coordinator.getActiveBroker() ?? (await this.coordinator.waitForActiveBroker()) + const broker = this.coordinator.getLiveBroker() ?? (await this.coordinator.waitForLiveBroker()) return broker instanceof RelaySessionBroker ? broker : null } diff --git a/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts b/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts index 8bfc71688e0..1cbdb600568 100644 --- a/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts +++ b/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts @@ -259,6 +259,40 @@ describe('RelayAuthCoordinator transient recovery', () => { }) describe('RelayAuthCoordinator liveness safety net', () => { + it('withholds a dead broker from control work while identity matching still sees it', async () => { + vi.useFakeTimers() + const dead = { closeNow: vi.fn(), isLive: () => false } + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker: vi.fn().mockResolvedValue(dead), + onStatus: vi.fn(), + random: () => 0.5 + }) + + coordinator.reconcile() + await vi.advanceTimersByTimeAsync(0) + expect(coordinator.getActiveBroker()).toBe(dead) + expect(coordinator.getLiveBroker()).toBeNull() + }) + + it('treats a broker that cannot report liveness as usable, not dead', async () => { + // Why: unverifiable is not exited — only a broker that proves its control + // died is withheld; silence must never cost a working relay. + vi.useFakeTimers() + const unverifiable = { closeNow: vi.fn() } + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker: vi.fn().mockResolvedValue(unverifiable), + onStatus: vi.fn(), + random: () => 0.5 + }) + + coordinator.reconcile() + await vi.advanceTimersByTimeAsync(0) + expect(coordinator.getLiveBroker()).toBe(unverifiable) + await expect(coordinator.waitForLiveBroker()).resolves.toBe(unverifiable) + }) + it('reopens a broker that died leaving no retry timer behind', async () => { // Why: an auth refresh failing past token expiry closes the broker with // no timer; only ensureLive (periodic/power-resume) can revive it. diff --git a/src/main/runtime/relay/relay-auth-coordinator.test.ts b/src/main/runtime/relay/relay-auth-coordinator.test.ts index 005b33fb8df..819ccf15da5 100644 --- a/src/main/runtime/relay/relay-auth-coordinator.test.ts +++ b/src/main/runtime/relay/relay-auth-coordinator.test.ts @@ -26,7 +26,7 @@ describe('RelayAuthCoordinator', () => { onStatus: (status) => statuses.push(status) }) coordinator.reconcile() - await coordinator.waitForActiveBroker() + await coordinator.waitForLiveBroker() expect(openBroker).not.toHaveBeenCalled() expect(statuses.at(-1)).toBe('standby') }) @@ -43,10 +43,10 @@ describe('RelayAuthCoordinator', () => { lingerMs: 250 }) coordinator.reconcile() - await coordinator.waitForActiveBroker() + await coordinator.waitForLiveBroker() demanded = true coordinator.reconcile() - await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker) + await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker) demanded = false coordinator.reconcile() await vi.waitFor(() => expect(statuses.at(-1)).toBe('standby')) @@ -65,7 +65,7 @@ describe('RelayAuthCoordinator', () => { lingerMs: 20 }) coordinator.reconcile() - await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker) + await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker) demanded = false coordinator.reconcile() demanded = true @@ -85,10 +85,10 @@ describe('RelayAuthCoordinator', () => { lingerMs: 10_000 }) coordinator.reconcile() - await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker) + await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker) current = { ...context, identity: { ...context.identity, profileId: 'profile-2' } } coordinator.reconcile() - await coordinator.waitForActiveBroker() + await coordinator.waitForLiveBroker() expect(broker.closeNow).toHaveBeenCalledOnce() }) diff --git a/src/main/runtime/relay/relay-auth-coordinator.ts b/src/main/runtime/relay/relay-auth-coordinator.ts index f6539842f8a..ae439a320d9 100644 --- a/src/main/runtime/relay/relay-auth-coordinator.ts +++ b/src/main/runtime/relay/relay-auth-coordinator.ts @@ -88,10 +88,18 @@ export class RelayAuthCoordinator { this.options.onStatus('offline') } + // Raw ownership handle for identity matching (revoke routing); control work uses getLiveBroker. getActiveBroker(): CoordinatedRelayBroker | null { return this.ownership?.valid ? this.ownership.broker : null } + // Why: ownership stays valid across a control death, so control work must + // apply the same liveness gate reconcile does; unprovable liveness stays usable. + getLiveBroker(): CoordinatedRelayBroker | null { + const broker = this.getActiveBroker() + return broker && (broker.isLive?.() ?? true) ? broker : null + } + // Why: some broker deaths end with no retry timer — an auth refresh that // fails past token expiry (laptop sleep), or a transient context read that // returned null at open. Periodic/power-resume callers use this as a @@ -108,16 +116,16 @@ export class RelayAuthCoordinator { this.beginReconcile(false) } - async waitForActiveBroker(): Promise { + async waitForLiveBroker(): Promise { while (!this.stopped) { - const broker = this.getActiveBroker() + const broker = this.getLiveBroker() if (broker) { return broker } const pending = this.latestReconcile await pending if (pending === this.latestReconcile) { - return this.getActiveBroker() + return this.getLiveBroker() } } return null diff --git a/src/main/runtime/rpc/methods/browser-screencast.ts b/src/main/runtime/rpc/methods/browser-screencast.ts index 98321949953..b37bfeb1a22 100644 --- a/src/main/runtime/rpc/methods/browser-screencast.ts +++ b/src/main/runtime/rpc/methods/browser-screencast.ts @@ -1,6 +1,9 @@ import { z } from 'zod' import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { Screencast } from './browser-schemas' +import { BrowserError } from '../../../browser/browser-error' +import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../../../shared/runtime-types' +import { runtimeBrowserCommandsFactoryIsAvailable } from '../../runtime-browser-commands-factory' const ScreencastUnsubscribe = z.object({ subscriptionId: z.string().min(1, 'Missing required --subscription-id') @@ -17,6 +20,12 @@ export const BROWSER_SCREENCAST_METHODS: RpcAnyMethod[] = [ name: 'browser.screencast.unsubscribe', params: ScreencastUnsubscribe, handler: async (params, { runtime }) => { + if (!runtimeBrowserCommandsFactoryIsAvailable()) { + throw new BrowserError( + BROWSER_UNAVAILABLE_ERROR_CODE, + 'Browser automation is unavailable on this host.' + ) + } runtime.cleanupSubscription(params.subscriptionId) return { unsubscribed: true } } diff --git a/src/main/runtime/rpc/methods/browser.test.ts b/src/main/runtime/rpc/methods/browser.test.ts index 638483f843d..5ebe2420a55 100644 --- a/src/main/runtime/rpc/methods/browser.test.ts +++ b/src/main/runtime/rpc/methods/browser.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' +import { setRuntimeBrowserCommandsFactory } from '../../runtime-browser-commands-factory' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS, CLIPBOARD_TEXT_WRITE_MAX_BYTES, @@ -155,6 +156,32 @@ describe('browser RPC methods', () => { cleanupSubscription: vi.fn() } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_SCREENCAST_METHODS }) + setRuntimeBrowserCommandsFactory(() => ({}) as never) + + try { + const response = await dispatcher.dispatch( + makeRequest('browser.screencast.unsubscribe', { + subscriptionId: 'browser-screencast:page-1:test' + }) + ) + + expect(runtime.cleanupSubscription).toHaveBeenCalledWith('browser-screencast:page-1:test') + expect(response).toMatchObject({ + ok: true, + result: { unsubscribed: true } + }) + } finally { + setRuntimeBrowserCommandsFactory(null) + } + }) + + it('rejects browser screencast unsubscribe when no provider resolved', async () => { + setRuntimeBrowserCommandsFactory(null) + const runtime = { + getRuntimeId: () => 'test-runtime', + cleanupSubscription: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_SCREENCAST_METHODS }) const response = await dispatcher.dispatch( makeRequest('browser.screencast.unsubscribe', { @@ -162,11 +189,11 @@ describe('browser RPC methods', () => { }) ) - expect(runtime.cleanupSubscription).toHaveBeenCalledWith('browser-screencast:page-1:test') expect(response).toMatchObject({ - ok: true, - result: { unsubscribed: true } + ok: false, + error: { code: 'browser_unavailable' } }) + expect(runtime.cleanupSubscription).not.toHaveBeenCalled() }) it('routes browser session and environment controls to the runtime server', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration-inject-rejection-message.test.ts b/src/main/runtime/rpc/methods/orchestration-inject-rejection-message.test.ts new file mode 100644 index 00000000000..a7ae03b00a8 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-inject-rejection-message.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { buildInjectRejectionMessage } from './orchestration-inject-rejection-message' +import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' +import { recognizeAgentProcess } from '../../../../shared/agent-process-recognition' + +describe('buildInjectRejectionMessage', () => { + const message = buildInjectRejectionMessage('term_a') + + it('keeps the substring callers and scripts match on', () => { + expect(message).toContain('Cannot dispatch --inject to terminal term_a') + expect(message).toContain('no recognized agent detected') + }) + + it('names every agent Orca recognizes, including agy', () => { + expect(message).toMatch(/\bagy\b/) + for (const config of Object.values(TUI_AGENT_CONFIG)) { + expect(message).toContain(config.expectedProcess) + } + }) + + it('lists only names detection actually resolves, deduped and sorted', () => { + const listed = (/\(([^)]+)\)/.exec(message)?.[1] ?? '').split(', ') + + expect(listed.length).toBeGreaterThan(0) + expect(new Set(listed).size).toBe(listed.length) + expect([...listed].sort()).toEqual(listed) + for (const name of listed) { + expect(recognizeAgentProcess(name)).not.toBeNull() + } + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-inject-rejection-message.ts b/src/main/runtime/rpc/methods/orchestration-inject-rejection-message.ts new file mode 100644 index 00000000000..33d622ca80e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-inject-rejection-message.ts @@ -0,0 +1,16 @@ +import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' + +// Why: the old five-name example read as an allowlist (#15125); derive from the field detection keys on so it cannot drift. +// Not filtered by `disabledTuiAgents` — that gates Orca's launchers, not detection, so a hand-started disabled agent still injects. +const RECOGNIZED_AGENT_PROCESS_NAMES = [ + ...new Set(Object.values(TUI_AGENT_CONFIG).map((config) => config.expectedProcess)) +].sort() + +export function buildInjectRejectionMessage(terminal: string): string { + return ( + `Cannot dispatch --inject to terminal ${terminal}: no recognized agent detected. ` + + `Orca detects these agent CLIs (${RECOGNIZED_AGENT_PROCESS_NAMES.join(', ')}). ` + + 'Start one in the terminal and let it finish launching, ' + + 'or dispatch without --inject and send the prompt manually.' + ) +} diff --git a/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts b/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts index a7b6250595d..8c6c3156c52 100644 --- a/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts @@ -3,6 +3,7 @@ import type { RpcContext } from '../core' import { createOrchestrationRpcHarness } from './orchestration-rpc-test-harness' import type { OrchestrationDb } from '../../orchestration/db' import type { OrcaRuntimeService } from '../../orca-runtime' +import { buildInjectRejectionMessage } from './orchestration-inject-rejection-message' describe('orchestration RPC methods', () => { const h = createOrchestrationRpcHarness() @@ -389,7 +390,7 @@ describe('orchestration RPC methods', () => { to: 'term_a', inject: true }) - ).rejects.toThrow('no recognized agent detected') + ).rejects.toThrow(buildInjectRejectionMessage('term_a')) }) it('rejects dispatch to occupied terminal', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 30e0b516129..b986fa25148 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -27,6 +27,7 @@ import { resolveBareOrchestrationRecipient, type SendRecipientWarning } from './orchestration-recipient-routing' +import { buildInjectRejectionMessage } from './orchestration-inject-rejection-message' import { resolveRunScope } from './orchestration-run-scope' import { ORCHESTRATION_RUN_METHODS } from './orchestration-runs' import { ORCHESTRATION_WORKER_METHODS } from './orchestration-worker-methods' @@ -1643,11 +1644,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ if (params.inject) { const hasAgent = await runtime.isTerminalRunningAgent(to) if (!hasAgent) { - throw new Error( - `Cannot dispatch --inject to terminal ${to}: no recognized agent detected. ` + - 'Start an agent CLI (e.g. claude, codex, gemini, droid, cursor) in the terminal first, ' + - 'or dispatch without --inject and send the prompt manually.' - ) + throw new Error(buildInjectRejectionMessage(to)) } } diff --git a/src/main/runtime/rpc/methods/paired-caller-host-id.test.ts b/src/main/runtime/rpc/methods/paired-caller-host-id.test.ts new file mode 100644 index 00000000000..551e5f5cacb --- /dev/null +++ b/src/main/runtime/rpc/methods/paired-caller-host-id.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import { resolvePairedCallerHostId } from './paired-caller-host-id' + +function repos(...hostIds: (string | undefined)[]): Repo[] { + return hostIds.map((executionHostId, index) => ({ + id: 'repo-1', + path: `/repo-${index}`, + executionHostId + })) as unknown as Repo[] +} + +const SELECTOR = 'id:repo-1::/repo/wt' + +describe('resolvePairedCallerHostId', () => { + it('passes through our own vocabulary and an absent qualifier', () => { + expect(resolvePairedCallerHostId(() => repos('local'), SELECTOR, 'local')).toBe('local') + expect(resolvePairedCallerHostId(() => repos('ssh:a'), SELECTOR, 'ssh:a')).toBe('ssh:a') + expect(resolvePairedCallerHostId(() => repos('local'), SELECTOR, undefined)).toBeUndefined() + }) + + it('answers with `local` when nothing carries the client-minted stamp', () => { + expect(resolvePairedCallerHostId(() => repos('local'), SELECTOR, 'runtime:env-1')).toBe('local') + expect(resolvePairedCallerHostId(() => repos('ssh:a'), SELECTOR, 'runtime:env-1')).toBe('local') + expect(resolvePairedCallerHostId(() => [], SELECTOR, 'runtime:env-1')).toBe('local') + }) + + it('keeps a stamp this store still uses for its own repo', () => { + expect(resolvePairedCallerHostId(() => repos('runtime:env-1'), SELECTOR, 'runtime:env-1')).toBe( + 'runtime:env-1' + ) + }) + + it('ignores another repo id carrying the other spelling', () => { + const otherRepo = { id: 'repo-2', path: '/other' } as unknown as Repo + expect( + resolvePairedCallerHostId( + () => [...repos('runtime:env-1'), otherRepo], + SELECTOR, + 'runtime:env-1' + ) + ).toBe('runtime:env-1') + }) + + it('refuses to guess when one repo id carries both spellings', () => { + expect(() => + resolvePairedCallerHostId(() => repos('local', 'runtime:env-1'), SELECTOR, 'runtime:env-1') + ).toThrow('ambiguous across hosts') + }) + + it('falls back to every repo when the selector names no repo id', () => { + expect( + resolvePairedCallerHostId(() => repos('runtime:env-1'), 'feature/x', 'runtime:env-1') + ).toBe('runtime:env-1') + expect(resolvePairedCallerHostId(() => repos('local'), 'feature/x', 'runtime:env-1')).toBe( + 'local' + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/paired-caller-host-id.ts b/src/main/runtime/rpc/methods/paired-caller-host-id.ts new file mode 100644 index 00000000000..da418c57479 --- /dev/null +++ b/src/main/runtime/rpc/methods/paired-caller-host-id.ts @@ -0,0 +1,34 @@ +import { + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import type { Repo } from '../../../../shared/repo-types' +import { splitWorktreeId } from '../../../../shared/worktree/id' + +/** Maps a paired client's runtime-local host spelling to this host's repo spelling. */ +export function resolvePairedCallerHostId( + getRepos: () => readonly Repo[], + worktreeSelector: string, + callerHostId: ExecutionHostId | undefined +): ExecutionHostId | undefined { + if (!callerHostId || parseExecutionHostId(callerHostId)?.kind !== 'runtime') { + return callerHostId + } + const repoId = splitWorktreeId( + worktreeSelector.startsWith('id:') ? worktreeSelector.slice(3) : worktreeSelector + )?.repoId + const spellings = new Set( + getRepos() + .filter((repo) => (repoId ? repo.id === repoId : true)) + .map((repo) => getRepoExecutionHostId(repo)) + .filter((hostId) => hostId === LOCAL_EXECUTION_HOST_ID || hostId === callerHostId) + ) + if (spellings.size > 1) { + throw new Error( + `Workspace identity is ambiguous across hosts: ${worktreeSelector}. Retry with an explicit host.` + ) + } + return [...spellings][0] ?? LOCAL_EXECUTION_HOST_ID +} diff --git a/src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts new file mode 100644 index 00000000000..3f3630b0ffa --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-legacy-stream-id-characterization.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RuntimeTerminalWait } from '../../../../shared/runtime-types' +import { createSubscriptionRegistryDouble } from '../subscription-registry-test-double' +import { TERMINAL_METHODS } from './terminal' + +function request(params: unknown): RpcRequest { + return { + id: 'request', + authToken: 'token', + method: 'terminal.subscribe', + params + } +} + +describe('legacy terminal subscription stream IDs', () => { + it('allocates binary and JSON subscriptions from one monotonically increasing owner', async () => { + const registry = createSubscriptionRegistryDouble() + const runtime = { + getRuntimeId: () => 'runtime', + resolveLeafForHandle: vi.fn(() => ({ ptyId: 'pty' })), + readTerminal: vi.fn(async () => ({ tail: [], truncated: false })), + serializeTerminalBuffer: vi.fn(async () => ({ data: '', cols: 80, rows: 24 })), + getTerminalSize: vi.fn(() => ({ cols: 80, rows: 24 })), + getMobileDisplayMode: vi.fn(() => 'auto'), + getLayout: vi.fn(() => ({ seq: 1 })), + subscribeToTerminalData: vi.fn(() => () => {}), + registerRemoteTerminalViewSubscriber: vi.fn(() => () => {}), + subscribeToTerminalResize: vi.fn(() => () => {}), + subscribeToFitOverrideChanges: vi.fn(() => () => {}), + subscribeToPtyExit: vi.fn(() => () => {}), + registerSubscriptionCleanup: vi.fn(registry.registerSubscriptionCleanup), + registerOwnedSubscriptionCleanup: vi.fn(registry.registerOwnedSubscriptionCleanup), + cleanupSubscription: vi.fn(registry.cleanupSubscription), + waitForTerminal: vi.fn(() => new Promise(() => {})) + } as unknown as Partial + const dispatcher = new RpcDispatcher({ + runtime: runtime as OrcaRuntimeService, + methods: TERMINAL_METHODS + }) + + const subscribeBinary = async (terminal: string, clientId: string) => { + const messages: string[] = [] + const pending = dispatcher.dispatchStreaming( + request({ + terminal, + client: { id: clientId, type: 'desktop' }, + capabilities: { terminalBinaryStream: 1 } + }), + (message) => messages.push(message), + { connectionId: `connection-${clientId}`, sendBinary: () => {} } + ) + await vi.waitFor(() => + expect(messages.some((message) => JSON.parse(message).result?.type === 'subscribed')).toBe( + true + ) + ) + const subscribed = messages + .map((message) => JSON.parse(message)) + .find((message) => message.result?.type === 'subscribed') + const streamId = subscribed?.result?.streamId + if (typeof streamId !== 'number') { + throw new Error('Missing legacy binary stream ID') + } + runtime.cleanupSubscription?.(`${terminal}:${clientId}`) + await pending + return streamId + } + + const first = await subscribeBinary('terminal-a', 'client-a') + + const jsonMessages: string[] = [] + const jsonPending = dispatcher.dispatchStreaming( + request({ terminal: 'terminal-json', client: { id: 'client-json', type: 'desktop' } }), + (message) => jsonMessages.push(message), + { connectionId: 'connection-json' } + ) + await vi.waitFor(() => + expect( + jsonMessages.some((message) => JSON.parse(message).result?.type === 'scrollback') + ).toBe(true) + ) + runtime.cleanupSubscription?.('terminal-json:client-json') + await jsonPending + + const second = await subscribeBinary('terminal-b', 'client-b') + expect(second).toBe(first + 2) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts new file mode 100644 index 00000000000..daa25165d3d --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-manifest-characterization.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { TERMINAL_METHODS } from './terminal' +import { + TerminalMultiplexLegacyAckFrame, + TerminalMultiplexSourceRangeAckFrame, + TerminalMultiplexSubscribeFrame +} from './terminal/stream-schemas' + +const METHOD_CASES: readonly (readonly [string, unknown, boolean])[] = [ + ['terminal.list', {}, false], + ['terminal.resolveActive', {}, false], + ['terminal.resolvePane', { paneKey: 'pane' }, false], + ['terminal.recoverPane', { paneKey: 'pane', worktreeId: 'worktree' }, false], + ['terminal.show', { terminal: 'term' }, false], + ['terminal.read', { terminal: 'term' }, false], + ['terminal.inspectProcess', { terminal: 'term' }, false], + ['terminal.isRunningAgent', { terminal: 'term' }, false], + ['terminal.agentStatus', { terminal: 'term' }, false], + ['terminal.rename', { terminal: 'term', title: null }, false], + ['terminal.clearBuffer', { terminal: 'term' }, false], + ['terminal.send', { terminal: 'term', text: 'x' }, false], + ['terminal.wait', { terminal: 'term', for: 'exit' }, false], + ['terminal.create', {}, false], + ['terminal.split', { terminal: 'term' }, false], + ['terminal.stop', { worktree: 'worktree' }, false], + ['terminal.sleep', { worktree: 'worktree' }, false], + ['terminal.stopExact', { worktree: 'worktree', expectedPtyIds: ['pty'] }, false], + ['terminal.resizeForClient', { terminal: 'term', mode: 'restore', clientId: 'client' }, false], + ['terminal.focus', { terminal: 'term' }, false], + ['terminal.close', { terminal: 'term' }, false], + ['terminal.closeTab', { terminal: 'term' }, false], + ['agentTeams.tmuxCompat', { teamId: 'team', token: 'token', envPane: 'pane', argv: [] }, false], + ['agentTeams.prepareLaunch', { paneKey: 'pane' }, false], + ['terminal.setDisplayMode', { terminal: 'term', mode: 'auto' }, false], + ['terminal.restoreFit', { terminal: 'term' }, false], + ['terminal.getDisplayMode', { terminal: 'term' }, false], + [ + 'terminal.updateViewport', + { terminal: 'term', client: { id: 'client' }, viewport: { cols: 80, rows: 24 } }, + false + ], + ['terminal.multiplex', {}, true], + ['terminal.subscribe', { terminal: 'term' }, true], + ['terminal.unsubscribe', { subscriptionId: 'term' }, false], + ['terminal.getAutoRestoreFit', {}, false], + ['terminal.setAutoRestoreFit', { ms: null }, false] +] + +function schemaFor(name: string) { + const method = TERMINAL_METHODS.find((candidate) => candidate.name === name) + if (!method?.params) { + throw new Error(`Missing terminal schema: ${name}`) + } + return method.params +} +async function invoke(name: string, params: unknown, runtime: Partial) { + const method = TERMINAL_METHODS.find((candidate) => candidate.name === name) + if (!method?.params || 'stream' in method) { + throw new Error(`Missing unary terminal method: ${name}`) + } + return method.handler(method.params.parse(params), { runtime: runtime as OrcaRuntimeService }) +} + +describe('terminal RPC manifest characterization', () => { + it('preserves all method names, order, streaming flags, and parseable minimum inputs', () => { + expect(TERMINAL_METHODS).toHaveLength(33) + expect(TERMINAL_METHODS.map((method) => [method.name, 'stream' in method])).toEqual( + METHOD_CASES.map(([name, _params, stream]) => [name, stream]) + ) + expect(new Set(TERMINAL_METHODS.map((method) => method.name)).size).toBe(33) + for (const [name, params] of METHOD_CASES) { + expect(() => schemaFor(name).parse(params), name).not.toThrow() + } + }) + + it('keeps legacy coercion and viewport-boundary differences', () => { + expect(schemaFor('terminal.list').parse({ limit: 'invalid' })).toEqual({}) + expect(schemaFor('terminal.split').parse({ terminal: 'term', direction: 'diagonal' })).toEqual({ + terminal: 'term' + }) + expect(() => schemaFor('terminal.rename').parse({ terminal: 'term' })).toThrow() + expect(schemaFor('terminal.rename').parse({ terminal: 'term', title: '' })).toEqual({ + terminal: 'term', + title: '' + }) + expect(() => + schemaFor('terminal.updateViewport').parse({ + terminal: 'term', + client: { id: 'client' }, + viewport: { cols: 241, rows: 120 } + }) + ).toThrow() + expect(() => + schemaFor('terminal.subscribe').parse({ + terminal: 'term', + viewport: { cols: 1000, rows: 500 } + }) + ).not.toThrow() + expect(() => + schemaFor('terminal.setDisplayMode').parse({ + terminal: 'term', + mode: 'auto', + viewport: { cols: 1001, rows: 501 } + }) + ).not.toThrow() + }) + + it('keeps multiplex control objects strict while subscribe remains skew-tolerant', () => { + expect(TerminalMultiplexLegacyAckFrame.safeParse({ bytes: 1, future: true }).success).toBe( + false + ) + expect( + TerminalMultiplexSourceRangeAckFrame.safeParse({ + streamGeneration: 'generation', + ackedEndByte: 1, + future: true + }).success + ).toBe(false) + expect( + TerminalMultiplexSubscribeFrame.parse({ + streamId: 1, + terminal: 'term', + futureCapability: 1 + }) + ).toEqual({ streamId: 1, terminal: 'term' }) + }) + + it('returns execution-host authority from lifecycle handlers without reinterpretation', async () => { + const pane = { + handle: 'term-pane', + executionHostId: 'ssh-host', + hostPlatform: 'win32' + } + const recovered = { + handle: 'term-recovered', + executionHostId: 'folder-host', + hostPlatform: 'linux' + } + const shown = { + handle: 'term-shown', + executionHostId: 'ssh-host', + hostPlatform: 'win32' + } + const split = { + handle: 'term-split', + executionHostId: 'folder-host', + hostPlatform: 'linux' + } + const runtime = { + resolveTerminalPane: vi.fn(() => pane), + recoverTerminalPane: vi.fn(async () => recovered), + showTerminal: vi.fn(async () => shown), + splitTerminal: vi.fn(async () => split) + } as unknown as Partial + + await expect(invoke('terminal.resolvePane', { paneKey: 'pane' }, runtime)).resolves.toEqual({ + terminal: pane + }) + await expect( + invoke('terminal.recoverPane', { paneKey: 'pane', worktreeId: 'folder-worktree' }, runtime) + ).resolves.toEqual({ terminal: recovered }) + await expect(invoke('terminal.show', { terminal: 'term' }, runtime)).resolves.toEqual({ + terminal: shown + }) + await expect(invoke('terminal.split', { terminal: 'term' }, runtime)).resolves.toEqual({ + split + }) + }) + + it('keeps remote and folder selectors opaque through terminal.create', async () => { + const created = { + handle: 'term-created', + executionHostId: 'ssh-host', + hostPlatform: 'win32' + } + const createTerminal = vi.fn(async () => created) + const runtime = { + dedupeTerminalCreate: vi.fn( + async ( + _owner: string, + selector: string | undefined, + _mutationId: string | undefined, + _reconcile: boolean, + create: (selector: string | undefined, preAllocatedHandle?: string) => Promise + ) => create(selector, 'term-preallocated') + ), + createTerminal + } as unknown as Partial + const selector = 'ssh://windows-host/C:/Users/dev/repo' + + await expect( + invoke( + 'terminal.create', + { worktree: selector, clientMutationId: 'mutation', command: 'provider resume-token' }, + runtime + ) + ).resolves.toEqual({ terminal: created }) + expect(createTerminal).toHaveBeenCalledWith( + selector, + expect.objectContaining({ + command: 'provider resume-token', + preAllocatedHandle: 'term-preallocated' + }) + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts b/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts new file mode 100644 index 00000000000..db05c6b3fe5 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-stream-extraction-characterization.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamJson, + encodeTerminalStreamFrame +} from '../../../../shared/terminal-stream-protocol' +import { sendSnapshotFrames } from './terminal/terminal-snapshot-publication' +import { TerminalSourceRangeRegistry } from '../terminal-source-range-registry' +import { initializeMultiplexStream } from './terminal/terminal-multiplex-stream-initialization' +import type { TerminalMultiplexConnection } from './terminal/terminal-multiplex-connection' +import type { OrcaRuntimeService } from '../../orca-runtime' + +// These assertions protect the permanent binary publication seam while its state machine +// lives in concrete terminal domain modules. +describe('terminal stream extraction characterization', () => { + it('publishes an empty snapshot as Start then End without a synthetic chunk', () => { + const frames: { + opcode: TerminalStreamOpcode + payload?: Uint8Array + }[] = [] + const stats = sendSnapshotFrames( + (opcode, payload) => { + frames.push({ opcode, payload }) + }, + { + kind: 'scrollback', + cols: 80, + rows: 24, + data: '', + seq: 41, + truncated: false, + truncatedByByteBudget: false + } + ) + + expect(frames.map((frame) => frame.opcode)).toEqual([ + TerminalStreamOpcode.SnapshotStart, + TerminalStreamOpcode.SnapshotEnd + ]) + expect(decodeTerminalStreamJson(frames[0]!.payload ?? new Uint8Array())).toEqual({ + kind: 'scrollback', + cols: 80, + rows: 24, + seq: 41, + truncated: false, + truncatedByByteBudget: false + }) + expect(stats).toEqual({ bytes: 0, chunks: 0, published: true }) + }) + + it('stops publication immediately when SnapshotStart is rejected', () => { + const opcodes: TerminalStreamOpcode[] = [] + const stats = sendSnapshotFrames( + (opcode) => { + opcodes.push(opcode) + return false + }, + { + kind: 'scrollback', + cols: 80, + rows: 24, + data: 'unpublished' + } + ) + + expect(opcodes).toEqual([TerminalStreamOpcode.SnapshotStart]) + expect(stats).toEqual({ bytes: 0, chunks: 0, published: false }) + }) + + it('keeps the permanent v1 header bytes and opcode assignments', () => { + const seq = 2 ** 32 + 0x89abcdef + const bytes = encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.WriteUnavailable, + streamId: 0x78563412, + seq, + payload: new Uint8Array([0xaa]) + }) + const header = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + + expect(Array.from(bytes.slice(0, 4))).toEqual([0x74, 1, 17, 0]) + expect(header.getUint32(4, true)).toBe(0x78563412) + expect(header.getUint32(8, true)).toBe(1) + expect(header.getUint32(12, true)).toBe(0x89abcdef) + expect(bytes[16]).toBe(0xaa) + expect([ + TerminalStreamOpcode.Output, + TerminalStreamOpcode.SnapshotStart, + TerminalStreamOpcode.SnapshotChunk, + TerminalStreamOpcode.SnapshotEnd, + TerminalStreamOpcode.Resized, + TerminalStreamOpcode.Error, + TerminalStreamOpcode.Input, + TerminalStreamOpcode.Resize, + TerminalStreamOpcode.Subscribe, + TerminalStreamOpcode.Unsubscribe, + TerminalStreamOpcode.SnapshotRequest, + TerminalStreamOpcode.Metadata, + TerminalStreamOpcode.Ack, + TerminalStreamOpcode.ClaimViewport, + TerminalStreamOpcode.OutputSpan, + TerminalStreamOpcode.SetOutputPaused, + TerminalStreamOpcode.WriteUnavailable + ]).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]) + }) + + it('lets a slot-handler registration throw escape before per-stream catch ownership begins', async () => { + const streams = new Map() + const runtime = { + attachRemoteTerminalSourceRangeConsumer: vi.fn(() => false) + } as unknown as OrcaRuntimeService + const state = { + runtime, + connectionId: 'connection', + streams, + sourceRangeRegistry: new TerminalSourceRangeRegistry(), + registerBinaryStreamHandler: vi.fn(() => { + throw new Error('registrar-failed') + }), + sendFrame: vi.fn(), + queueOrSendOutput: vi.fn() + } as unknown as TerminalMultiplexConnection + const installed = vi.fn() + + await expect( + initializeMultiplexStream(state, { streamId: 7, terminal: 'terminal' }, 'pty', installed) + ).rejects.toThrow('registrar-failed') + expect(installed).not.toHaveBeenCalled() + expect(streams.has(7)).toBe(true) + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 5dee92a7676..e80d07773dc 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1,3826 +1,22 @@ -/* oxlint-disable max-lines -- Why: terminal RPC methods are co-located for discoverability; splitting would scatter related handlers across files. */ -import { z } from 'zod' -import { randomUUID } from 'node:crypto' +import type { RpcAnyMethod } from '../core' +import { TERMINAL_LIFECYCLE_METHODS } from './terminal/terminal-lifecycle-methods' +import { TERMINAL_MULTIPLEX_METHODS } from './terminal/terminal-multiplex-method' +import { TERMINAL_QUERY_METHODS } from './terminal/terminal-query-methods' +import { TERMINAL_SEND_METHODS } from './terminal/terminal-send-method' +import { TERMINAL_SUBSCRIBE_METHODS } from './terminal/terminal-subscribe-method' import { - InvalidArgumentError, - defineMethod, - defineStreamingMethod, - type RpcAnyMethod -} from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' -import type { DriverState, OrcaRuntimeService, SubscriptionRegistration } from '../../orca-runtime' -import { - TerminalStreamOpcode, - decodeTerminalStreamJson, - decodeTerminalStreamText, - encodeTerminalStreamFrame, - encodeTerminalStreamJson, - encodeTerminalStreamText, - type TerminalStreamFrame -} from '../../../../shared/terminal-stream-protocol' -import { - iterateTerminalOutputFrameChunks, - sliceTerminalOutputSourceRanges, - type TerminalOutputFrameChunk, - type TerminalOutputMeta -} from '../terminal-output-frame-chunks' -import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../shared/feature-education-telemetry' -import type { TerminalOscLinkRange } from '../../../../shared/terminal-osc-link-ranges' -import { - TERMINAL_INPUT_MAX_BYTES, - TERMINAL_INPUT_TOO_LARGE_ERROR, - isTerminalInputTooLargeWithYield -} from '../../../../shared/terminal-input' -import { - measureTerminalStreamByteLength, - terminalStreamByteLength, - terminalStreamByteLengthExceeds -} from '../terminal-stream-byte-length' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' -import { - EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE, - scanTerminalReplyQuerySequences, - type TerminalReplyQuerySequence, - type TerminalReplyQueryScanState -} from '../../../../shared/terminal-reply-query-scan' -import { - MOBILE_SNAPSHOT_BYTE_BUDGET, - MOBILE_SUBSCRIBE_SCROLLBACK_ROWS -} from '../../scrollback-limits' -import { assertTerminalAgentSendable } from '../terminal-agent-send-guard' -import { - navigationTargetsHost, - resolveRuntimeNavigationTarget -} from '../../../../shared/runtime-navigation' -import { - TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES, - TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES, - TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES, - TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES, - TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION, - TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION, - TERMINAL_MULTIPLEX_PENDING_MAX_BYTES, - TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR, - TERMINAL_OUTPUT_BATCH_MAX_BYTES -} from '../../../../shared/terminal-multiplex-flow-control' -import { drainTerminalMultiplexRoundRobin } from '../terminal-multiplex-round-robin' -import type { TerminalSourceRangeLedger } from '../terminal-source-range-ledger' -import { TerminalSourceRangeRegistry } from '../terminal-source-range-registry' -import { - sameTerminalOutputSourceIdentity, - type TerminalOutputSourceRange -} from '../../../../shared/terminal-output-source-range' -import type { TerminalSnapshotUnavailableReason } from '../../../../shared/terminal-snapshot-unavailability' -import type { RemoteTerminalSourceRangeReplacementReservation } from '../../remote-terminal-source-range-consumer' -import { withTerminalCloseAttribution } from '../terminal-close-attribution' - -const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 -const TERMINAL_OUTPUT_FLUSH_MS = 5 -const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 -// Why: bound initial subscribe latency; readiness after this deadline triggers an in-stream recovery snapshot. -const MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS = 3_000 -let nextTerminalStreamId = 1 - -type SnapshotFrameOptions = { - kind: 'scrollback' | 'resized' - cols: number - rows: number - data: string - requestId?: number - displayMode?: string - reason?: string - seq?: number - cwd?: string | null - truncated?: boolean - truncatedByByteBudget?: boolean - // Why: distinguishes "I could not answer right now" from a genuinely empty buffer; omitted on success. - unavailable?: TerminalSnapshotUnavailableReason - source?: 'headless' | 'renderer' - oscLinks?: TerminalOscLinkRange[] - pendingEscapeTailAnsi?: string - /** Effective kitty flags proven at this frame's own `seq`. */ - kittyKeyboardFlags?: number -} - -type SerializedSnapshot = { - data: string - scrollbackAnsi?: string - cols: number - rows: number - seq?: number - cwd?: string | null - source?: 'headless' | 'renderer' - oscLinks?: TerminalOscLinkRange[] - scrollbackRows: number - truncatedByByteBudget: boolean - pendingEscapeTailAnsi?: string - kittyKeyboardFlags?: number -} | null - -type TerminalViewportClient = { - id: string - type?: 'mobile' | 'desktop' -} - -type TerminalMultiplexStream = { - streamId: number - terminal: string - ptyId: string - client: TerminalViewportClient | undefined - isMobile: boolean - ackOutput: boolean - ackOutputSourceRanges: boolean - streamGeneration: string - sourceRangeLedger: TerminalSourceRangeLedger | null - sourceRangeConsumerAttached: boolean - sourceRangeReplacement: RemoteTerminalSourceRangeReplacementReservation | null - ackInFlightBytes: number - ackWindowBytes: number - supportsOutputPause: boolean - supportsWriteUnavailable: boolean - outputPaused: boolean - supportsDesktopViewportClaims: boolean - desktopClaimTail: Promise - // Whether THIS stream registered the width driver, so detach won't release a peer stream's floor. - registeredRemoteDesktopDriver: boolean - remoteDesktopSubscriptionKey: string - pendingRemoteDesktopViewport: { cols: number; rows: number } | null - buffering: boolean - ackPendingOutput: TerminalOutputFrameChunk[] - ackPendingOutputBytes: number - ackPendingOutputOverflowed: boolean - ackRecoverySnapshotInFlight: boolean - pendingOutput: TerminalOutputChunk[] - pendingOutputBytes: number - pendingOutputOverflowed: boolean - // Cols the mobile client last rewrapped to; re-stream full scrollback only when width actually changes. - lastResizeCols: number | undefined - resizeGeneration: number - outputBatcher: ReturnType - unsubscribeData: () => void - unsubscribeResize: () => void - unsubscribeFit: () => void - unsubscribeDriver: () => void - unregisterBinaryHandler: () => void - // Why: the runtime drops the exit-waiter only on real PTY exit; abort on detach so a never-exiting agent terminal doesn't leak the waiter. - exitWaiterAbort: AbortController -} - -type TerminalOutputChunk = { - data: string - bytes: number - meta?: TerminalOutputMeta -} - -function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutputMeta) => void): { - push: (data: string, meta?: TerminalOutputMeta) => void - flush: () => void - dispose: () => void -} { - let chunks: string[] = [] - let bytes = 0 - let lastSeq: number | undefined - let pendingCwd: string | undefined - let pendingRawLength = 0 - let pendingSourceRanges: TerminalOutputSourceRange[] = [] - let timer: ReturnType | null = null - - const clearTimer = (): void => { - if (!timer) { - return - } - clearTimeout(timer) - timer = null - } - - const flush = (): void => { - clearTimer() - if (chunks.length === 0 && pendingRawLength === 0) { - return - } - const data = chunks.length === 1 ? chunks[0]! : chunks.join('') - const meta = - typeof lastSeq === 'number' || pendingCwd !== undefined || pendingSourceRanges.length > 0 - ? { - ...(typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: pendingRawLength } : {}), - ...(pendingCwd !== undefined ? { cwd: pendingCwd } : {}), - ...(pendingSourceRanges.length > 0 - ? { sourceRanges: Object.freeze(pendingSourceRanges.slice()) } - : {}) - } - : undefined - chunks = [] - bytes = 0 - lastSeq = undefined - pendingCwd = undefined - pendingRawLength = 0 - pendingSourceRanges = [] - onFlush(data, meta) - } - - return { - push(data: string, meta?: TerminalOutputMeta): void { - const rawLength = meta?.rawLength ?? data.length - if (!data && rawLength === 0) { - return - } - if (meta?.transformed || rawLength !== data.length) { - flush() - onFlush(data, { ...meta, rawLength, transformed: true }) - return - } - const nextSourceRanges = meta?.sourceRanges ?? [] - const lastSourceRange = pendingSourceRanges.at(-1) - const firstNextSourceRange = nextSourceRanges[0] - if ( - chunks.length > 0 && - (pendingSourceRanges.length > 0 !== nextSourceRanges.length > 0 || - (lastSourceRange && - firstNextSourceRange && - (!sameTerminalOutputSourceIdentity(lastSourceRange, firstNextSourceRange) || - lastSourceRange.displayEnd !== firstNextSourceRange.displayStart))) - ) { - flush() - } - if (meta?.cwd !== undefined) { - flush() - pendingCwd = meta.cwd - } - chunks.push(data) - pendingRawLength += rawLength - pendingSourceRanges.push(...nextSourceRanges) - const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes) - const measurement = measureTerminalStreamByteLength(data, { - stopAfterBytes: remainingBudget - }) - bytes += measurement.byteLength - if (typeof meta?.seq === 'number') { - lastSeq = meta.seq - } - if (measurement.exceededLimit || bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) { - flush() - return - } - if (!timer) { - // Why: coalesce stream output before it crosses the network; desktop subscribers share the same burst boundary. - timer = setTimeout(flush, TERMINAL_OUTPUT_FLUSH_MS) - if (typeof timer.unref === 'function') { - timer.unref() - } - } - }, - flush, - dispose(): void { - clearTimer() - chunks = [] - bytes = 0 - pendingRawLength = 0 - pendingSourceRanges = [] - } - } -} - -function isTerminalInputLockedForClient( - runtime: OrcaRuntimeService, - ptyId: string, - client: TerminalViewportClient | undefined -): boolean { - if (client?.type === 'mobile') { - return false - } - // Why: pre-refactor mobile builds sent no client metadata, so treat a missing client as legacy mobile (unlocked). - if (!client) { - return false - } - return runtime.getDriver(ptyId).kind === 'mobile' -} - -async function assertTerminalSendTextWithinLimit(text: string | undefined): Promise { - if (!text) { - return - } - // Why: sends can be paste-sized; validate outside Zod so large input yields before runtime dispatch. - if (await isTerminalInputTooLargeWithYield(text, TERMINAL_INPUT_MAX_BYTES)) { - throw new InvalidArgumentError(TERMINAL_INPUT_TOO_LARGE_ERROR) - } -} - -function resolveMobileFloorClientId( - driver: DriverState | null, - client: TerminalViewportClient | undefined -): string | null { - if (client?.type === 'mobile') { - return client.id - } - if (!client && driver?.kind === 'mobile') { - return driver.clientId - } - return null -} - -type TerminalStreamInputOutcome = 'delivered' | 'rejected' | 'failed' - -function watchSubscriptionLifetime( - runtime: OrcaRuntimeService, - ptyId: string, - signal: AbortSignal | undefined, - registration: SubscriptionRegistration -): () => void { - let unsubscribeExit: (() => void) | null = null - let removeAbort: (() => void) | null = null - let stopped = false - const stop = (): void => { - stopped = true - unsubscribeExit?.() - removeAbort?.() - } - const release = (): void => { - registration.releaseIfCurrent() - stop() - } - unsubscribeExit = runtime.subscribeToPtyExit(ptyId, release) - if (stopped) { - unsubscribeExit() - return stop - } - if (!signal) { - return stop - } - if (signal.aborted) { - release() - return stop - } - const onAbort = (): void => release() - removeAbort = () => signal.removeEventListener('abort', onAbort) - signal.addEventListener('abort', onAbort, { once: true }) - if (stopped) { - removeAbort() - } - return stop -} - -function isTerminalStreamInputRejection(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return message.includes('terminal_not_writable') || message.includes('terminal_handle_stale') -} - -async function sendTerminalStreamInput( - runtime: OrcaRuntimeService, - args: { - terminal: string - text: string - client: TerminalViewportClient | undefined - isMobile: boolean - } -): Promise { - const action = { text: args.text, enter: false, interrupt: false } - const clientId = args.isMobile ? args.client?.id : undefined - const floorClaim: MobileInputFloorClaimHolder = { current: null } - try { - if (!clientId) { - const result = await runtime.sendTerminal(args.terminal, action) - return result.accepted ? 'delivered' : 'rejected' - } - const result = await runtime.sendTerminal(args.terminal, action, { - reserveWrite: (writePtyId) => { - const claim = runtime.beginMobileInputFloor(writePtyId, clientId) - if (!claim) { - throw new Error('mobile_input_floor_unavailable') - } - floorClaim.current = claim - }, - afterWrite: () => commitMobileInputFloorClaim(floorClaim) - }) - if (!result.accepted) { - floorClaim.current?.rollback() - return 'rejected' - } - return 'delivered' - } catch (error) { - floorClaim.current?.rollback() - return isTerminalStreamInputRejection(error) ? 'rejected' : 'failed' - } -} - -type MobileInputFloorClaimHolder = { - current: ReturnType -} - -async function commitMobileInputFloorClaim(claim: MobileInputFloorClaimHolder): Promise { - const current = claim.current - if (!current) { - return - } - try { - await current.commit() - } finally { - // Why: the runtime may yield before the next write, which then needs a fresh reservation if desktop reclaimed the floor. - if (claim.current === current) { - claim.current = null - } - } -} - -function getTerminalSendGuardRefusedReason(error: unknown): 'no-agent' | 'permission' | undefined { - const message = error instanceof Error ? error.message : String(error) - if (message.includes('terminal_guard_permission')) { - return 'permission' - } - if (message.includes('terminal_guard_no_agent')) { - return 'no-agent' - } - return undefined -} - -function isTerminalSendGuardNotWritable(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return message.includes('terminal_guard_not_writable') -} - -function assertTerminalSendExactPtyBinding( - runtime: OrcaRuntimeService, - handle: string, - expectedPtyId: string | undefined -): void { - try { - if (expectedPtyId && runtime.resolveLiveLeafForHandle(handle)?.ptyId === expectedPtyId) { - return - } - } catch { - // Fall through to the stable guarded-send result below. - } - throw new Error('terminal_guard_not_writable') -} - -function appendPendingMultiplexOutput( - stream: TerminalMultiplexStream, - data: string, - meta?: TerminalOutputMeta -): void { - const remainingBudget = Math.max( - 1, - TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - stream.pendingOutputBytes - ) - const measurement = measureTerminalStreamByteLength(data, { - stopAfterBytes: remainingBudget - }) - stream.pendingOutput.push({ data, bytes: measurement.byteLength, meta }) - stream.pendingOutputBytes += measurement.byteLength - const trimmed = trimPendingOutputToBudget(stream.pendingOutput, stream.pendingOutputBytes) - stream.pendingOutputBytes = trimmed.bytes - stream.pendingOutputOverflowed ||= trimmed.overflowed -} - -function getOutputAfterSnapshotSeq( - chunk: TerminalOutputChunk, - snapshotSeq: number | undefined -): TerminalOutputChunk | null { - if ( - typeof snapshotSeq !== 'number' || - typeof chunk.meta?.seq !== 'number' || - typeof chunk.meta.rawLength !== 'number' - ) { - return chunk - } - if (chunk.meta.seq <= snapshotSeq) { - return null - } - const chunkStartSeq = chunk.meta.seq - chunk.meta.rawLength - if (chunkStartSeq >= snapshotSeq) { - return chunk - } - if (chunk.meta.transformed) { - return null - } - const offset = snapshotSeq - chunkStartSeq - return { - data: chunk.data.slice(offset), - bytes: chunk.bytes, - meta: { - ...chunk.meta, - rawLength: chunk.meta.rawLength - offset, - sourceRanges: sliceTerminalOutputSourceRanges( - chunk.meta.sourceRanges, - offset, - chunk.data.length - ) - } - } -} - -function stripSnapshotBoundaryQuerySuffixes( - data: string, - dataStartSeq: number, - snapshotSeq: number, - queries: TerminalReplyQuerySequence[] -): string { - let output = '' - let offset = 0 - for (const query of queries) { - if (query.startSeq >= snapshotSeq || query.endSeq <= snapshotSeq) { - continue - } - const removeStart = Math.max(0, query.startSeq - dataStartSeq) - const removeEnd = Math.min(data.length, query.endSeq - dataStartSeq) - if (removeEnd <= offset || removeStart >= data.length) { - continue - } - output += data.slice(offset, removeStart) - offset = removeEnd - } - return output + data.slice(offset) -} - -function appendAckPendingOutput( - stream: TerminalMultiplexStream, - chunk: TerminalOutputFrameChunk -): void { - stream.ackPendingOutput.push(chunk) - stream.ackPendingOutputBytes += chunk.bytes.byteLength - let omittedChunkCount = 0 - while ( - stream.ackPendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && - omittedChunkCount < stream.ackPendingOutput.length - ) { - stream.ackPendingOutputBytes -= stream.ackPendingOutput[omittedChunkCount]!.bytes.byteLength - omittedChunkCount += 1 - } - if (omittedChunkCount > 0) { - stream.ackPendingOutput.splice(0, omittedChunkCount) - stream.ackPendingOutputOverflowed = true - } -} - -function trimPendingOutputToBudget( - pendingOutput: TerminalOutputChunk[], - pendingOutputBytes: number -): { bytes: number; overflowed: boolean } { - let omittedChunkCount = 0 - while ( - pendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && - omittedChunkCount < pendingOutput.length - ) { - const chunk = pendingOutput[omittedChunkCount] - pendingOutputBytes -= chunk.bytes - omittedChunkCount += 1 - } - if (omittedChunkCount > 0) { - pendingOutput.splice(0, omittedChunkCount) - } - return { bytes: pendingOutputBytes, overflowed: omittedChunkCount > 0 } -} - -function trimPendingOutputCoveredBySnapshot( - pendingOutput: TerminalOutputChunk[], - snapshotSeq: number | undefined -): { chunks: TerminalOutputChunk[]; bytes: number } { - if (typeof snapshotSeq !== 'number') { - return { - chunks: pendingOutput, - bytes: pendingOutput.reduce((sum, chunk) => sum + chunk.bytes, 0) - } - } - const chunks: TerminalOutputChunk[] = [] - let bytes = 0 - for (const chunk of pendingOutput) { - const chunkSeq = chunk.meta?.seq - const rawLength = chunk.meta?.rawLength ?? chunk.data.length - if (typeof chunkSeq !== 'number' || rawLength !== chunk.data.length) { - chunks.push(chunk) - bytes += chunk.bytes - continue - } - const startSeq = chunkSeq - rawLength - if (snapshotSeq >= chunkSeq) { - continue - } - if (snapshotSeq <= startSeq) { - chunks.push(chunk) - bytes += chunk.bytes - continue - } - const data = chunk.data.slice(snapshotSeq - startSeq) - const slicedBytes = terminalStreamByteLength(data) - chunks.push({ data, bytes: slicedBytes, meta: undefined }) - bytes += slicedBytes - } - return { chunks, bytes } -} - -function* iterateTerminalStreamTextPayloads(data: string): Generator> { - if (!data) { - return - } - for (const chunk of iterateTerminalOutputFrameChunks(data)) { - yield chunk.bytes - } -} - -function isTerminalReadPayloadIncomplete(read: { truncated: boolean; limited?: boolean }): boolean { - // Why: a limited preview is an incomplete payload even when the retained buffer wasn't truncated. - return read.truncated || read.limited === true -} - -function normalizeMultiplexSnapshotScrollbackRows(value: number | undefined): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return undefined - } - return Math.max(0, Math.min(50_000, Math.floor(value))) -} - -function requestedSnapshotScrollbackCandidates(requestedRows: number | undefined): number[] { - const candidates = [requestedRows ?? 0, 1000, 500, 250, 100, 25, 0] - .filter((rows): rows is number => typeof rows === 'number') - .map((rows) => Math.max(0, Math.min(50_000, Math.floor(rows)))) - return [...new Set(candidates)] -} - -async function serializeBudgetedRequestedSnapshot( - runtime: OrcaRuntimeService, - ptyId: string, - scrollbackRows: number | undefined -): Promise { - const requestedRows = scrollbackRows ?? 0 - for (const rows of requestedSnapshotScrollbackCandidates(scrollbackRows)) { - const serialized = await runtime.serializeAuthoritativeTerminalBuffer(ptyId, { - scrollbackRows: rows - }) - if (!serialized) { - return null - } - const scrollbackAnsi = - 'scrollbackAnsi' in serialized && typeof serialized.scrollbackAnsi === 'string' - ? serialized.scrollbackAnsi - : '' - const data = scrollbackAnsi + serialized.data - const overByteBudget = terminalStreamByteLengthExceeds(data, REQUESTED_SNAPSHOT_BYTE_BUDGET) - if (!overByteBudget || rows === 0) { - return { - ...serialized, - data, - scrollbackRows: rows, - truncatedByByteBudget: rows < requestedRows || overByteBudget - } - } - } - return null -} - -function sendSnapshotFrames( - sendFrame: ( - opcode: TerminalStreamOpcode, - payload?: Uint8Array - ) => boolean | void, - options: SnapshotFrameOptions -): { bytes: number; chunks: number; published: boolean } { - if ( - sendFrame( - TerminalStreamOpcode.SnapshotStart, - encodeTerminalStreamJson({ - kind: options.kind, - cols: options.cols, - rows: options.rows, - requestId: options.requestId, - displayMode: options.displayMode, - reason: options.reason, - unavailable: options.unavailable, - seq: options.seq, - cwd: options.cwd, - source: options.source, - oscLinks: options.oscLinks, - pendingEscapeTailAnsi: options.pendingEscapeTailAnsi, - // Why conditional and additive: old clients ignore the unknown field, - // and a new client must read absence as unknown rather than zero, so - // no opcode or capability negotiation is involved (Rule 1 of - // docs/reference/remote-wire-compatibility.md). - // Why `seq` is required: the flags are only proven at this frame's own - // seq, so without a replay boundary the client cannot order them. - ...(typeof options.seq === 'number' && options.kittyKeyboardFlags !== undefined - ? { kittyKeyboardFlags: options.kittyKeyboardFlags } - : {}), - truncated: options.truncated === true, - truncatedByByteBudget: options.truncatedByByteBudget === true - }) - ) === false - ) { - return { bytes: 0, chunks: 0, published: false } - } - let chunks = 0 - let bytes = 0 - for (const chunk of iterateTerminalStreamTextPayloads(options.data)) { - if (sendFrame(TerminalStreamOpcode.SnapshotChunk, chunk) === false) { - return { bytes, chunks, published: false } - } - chunks++ - bytes += chunk.byteLength - } - const published = sendFrame(TerminalStreamOpcode.SnapshotEnd) !== false - return { bytes, chunks, published } -} - -async function serializeBudgetedMobileSnapshot( - runtime: OrcaRuntimeService, - ptyId: string, - isMobile: boolean -): Promise { - if (!isMobile) { - const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: 0 }) - return serialized - ? { - ...serialized, - data: (serialized.scrollbackAnsi ?? '') + serialized.data, - scrollbackRows: 0, - truncatedByByteBudget: false - } - : null - } - const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] - for (const rows of candidates) { - const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: rows }) - if (!serialized) { - return null - } - const data = (serialized.scrollbackAnsi ?? '') + serialized.data - const overByteBudget = terminalStreamByteLengthExceeds(data, MOBILE_SNAPSHOT_BYTE_BUDGET) - if (!overByteBudget || rows === 0) { - return { - ...serialized, - data, - scrollbackRows: rows, - truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget - } - } - } - return null -} - -async function serializeStableMobileRendererSnapshot( - runtime: OrcaRuntimeService, - ptyId: string -): Promise { - const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] - let candidateIndex = 0 - for (let attempt = 0; attempt < candidates.length; attempt += 1) { - // Why: advance toward zero scrollback each retry so the final attempt always has a bounded payload. - candidateIndex = Math.max(candidateIndex, attempt) - const rows = candidates[candidateIndex] - const outputSequenceBefore = runtime.getPtyOutputSequence(ptyId) - const serialized = await runtime.serializeRendererTerminalBuffer(ptyId, { - scrollbackRows: rows - }) - const outputSequenceAfter = runtime.getPtyOutputSequence(ptyId) - if (outputSequenceBefore !== outputSequenceAfter) { - continue - } - if (!serialized) { - return null - } - const overByteBudget = terminalStreamByteLengthExceeds( - serialized.data, - MOBILE_SNAPSHOT_BYTE_BUDGET - ) - if (!overByteBudget || rows === 0) { - return { - ...serialized, - scrollbackRows: rows, - truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget - } - } - candidateIndex += 1 - } - return null -} - -// Why: mobile xterm can't rewrap the HARD newlines baked into a restored snapshot, so a real reflow re-serializes and replays the FULL buffer at the new cols. -async function sendMobileResizeRestream( - runtime: OrcaRuntimeService, - ptyId: string, - sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array) => void, - event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number }, - shouldSend?: () => boolean -): Promise { - // Why: only a true geometry reflow rewraps scrollback; a dimensionless mode-change would re-send the whole buffer for nothing. - if (event.reason !== 'apply-layout' || runtime.isTerminalAlternateScreen(ptyId)) { - return false - } - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true) - if (!serialized) { - return false - } - if (shouldSend && !shouldSend()) { - return true - } - sendSnapshotFrames(sendFrame, { - kind: 'resized', - cols: serialized.cols, - rows: serialized.rows, - displayMode: event.displayMode, - reason: event.reason, - seq: event.seq ?? serialized.seq, - source: serialized.source, - cwd: serialized.cwd, - oscLinks: serialized.oscLinks, - truncated: false, - truncatedByByteBudget: serialized.truncatedByByteBudget, - data: serialized.data - }) - return true -} - -async function updateViewportForClient( - runtime: OrcaRuntimeService, - ptyId: string, - subscriptionKey: string, - client: TerminalViewportClient, - viewport: { cols: number; rows: number }, - defaultType: 'mobile' | 'desktop', - // Why: the one-shot RPC has no disconnect hook, so 'refresh' only updates a stream-owned floor; stream paths that own cleanup 'register'. - registration: 'register' | 'refresh' = 'register', - claim = false -): Promise<{ updated: boolean; applied: boolean }> { - const type = client.type ?? defaultType - if (type === 'mobile') { - return runtime.updateMobileViewport(ptyId, client.id, viewport) - } - // Why: stream attachment observes geometry without taking control; a later claim frame makes it authoritative. - const updated = - registration === 'refresh' - ? await runtime.refreshRemoteDesktopViewer( - ptyId, - client.id, - viewport.cols, - viewport.rows, - claim - ) - : await runtime.updateRemoteDesktopViewer( - ptyId, - subscriptionKey, - client.id, - viewport.cols, - viewport.rows, - claim - ) - return { updated, applied: updated } -} - -const TerminalHandle = z.object({ - terminal: requiredString('Missing terminal handle') -}) - -const TerminalFocus = TerminalHandle.extend({ - navigation: z.enum(['caller', 'host']).optional() -}) - -const TerminalListParams = z.object({ - worktree: OptionalString, - limit: OptionalFiniteNumber, - handles: z - .array(requiredString('Missing terminal handle').pipe(z.string().max(256))) - .max(64) - .optional(), - requireFreshPtyLiveness: z.boolean().optional(), - // Why: layouts are ~31% of a large listing and only the human CLI formatter - // reads them. Absent means "include" so pre-flag clients keep rendering them. - includeVisualLayouts: z.boolean().optional() -}) - -const TerminalResolveActive = z.object({ - worktree: OptionalString -}) - -const TerminalResolvePane = z.object({ - paneKey: requiredString('Missing pane key'), - worktreeId: OptionalString -}) - -const TerminalRecoverPane = z.object({ - paneKey: requiredString('Missing pane key'), - worktreeId: requiredString('Missing worktree ID'), - expectedTerminal: requiredString('Missing expected terminal handle').optional() -}) - -const TerminalRead = TerminalHandle.extend({ - cursor: z - .unknown() - .transform((value) => { - if (value === undefined) { - return undefined - } - if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { - return Number.NaN - } - return value - }) - .pipe( - z - .number() - .optional() - .refine((v) => v === undefined || Number.isFinite(v), { - message: 'Cursor must be a non-negative integer' - }) - ) - .optional(), - limit: OptionalFiniteNumber, - // Why: optional so an older host that does not understand it simply drops the key and answers - // with its usual stream read; the response's `source` is what tells the caller which it got. - screen: z.literal(true).optional() -}).refine((params) => !(params.screen === true && params.cursor !== undefined), { - // Why: a cursor pages through accumulated output; a screen is the current frame with nothing - // behind it. Honoring both would answer with rendered lines carrying the stream's pagination - // metadata — two frames of reference in one payload, which is the confusion `source` exists to - // remove. The CLI already refuses the pair, but the RPC is reachable without it. - message: 'Cursor cannot be combined with a screen read' -}) - -// Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct. -const TerminalRename = TerminalHandle.extend({ - title: z.custom((value) => value === null || typeof value === 'string', { - message: 'Missing --title (pass empty string or null to reset)' - }) -}) - -const TerminalSend = TerminalHandle.extend({ - text: OptionalString, - enter: z.unknown().optional(), - interrupt: z.unknown().optional(), - // Why: older hosts strip this optional intent and retain their direct-send behavior. - agentPrompt: z.literal(true).optional(), - resolvedLaunchDraft: z - .object({ - text: z.string(), - createdAt: z.number().finite() - }) - .optional(), - requireAgentStatus: z.enum(['sendable']).optional(), - // Why: terminal-generated replies are valid input but must not transfer the shared terminal floor. - inputKind: z.enum(['query-reply']).optional(), - // Why: identifies the caller for the driver state machine; when absent (older clients) the server falls back to the most recent mobile actor (docs/mobile-presence-lock.md). - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop').optional() - }) - .optional(), - viewport: z - .object({ - cols: z.number().int().min(1).max(1000), - rows: z.number().int().min(1).max(500) - }) - .optional(), - claimViewport: z.literal(true).optional() -}) - -const TerminalViewport = z.object({ - cols: z.number().int().min(1).max(1000), - rows: z.number().int().min(1).max(500) -}) - -const TerminalWait = TerminalHandle.extend({ - for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', { - message: 'Invalid --for value. Supported: exit, tui-idle' - }), - timeoutMs: OptionalFiniteNumber -}) - -const TerminalCreateParams = z.object({ - worktree: OptionalString, - clientMutationId: z.string().min(1).max(128).optional(), - reconcileExisting: z.boolean().optional(), - command: OptionalString, - startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), - env: z.record(z.string(), z.string()).optional(), - envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), - launchConfig: z - .object({ - agentCommand: z.string().optional(), - agentArgs: z.string(), - agentEnv: z.record(z.string(), z.string()), - ompResumeFilePath: z - .string() - .min(1) - .max(32 * 1024) - .optional() - }) - .optional(), - resumeProviderSession: z - .object({ - key: z.enum(['session_id', 'conversation_id']), - id: z.string().min(1).max(512), - transcriptPath: z.string().min(1).max(32_768).optional() - }) - .optional(), - launchToken: OptionalString, - launchAgent: z.string().refine(isTuiAgent).optional(), - terminalColorQueryReplies: z - .object({ - foreground: z.string().max(128).optional(), - background: z.string().max(128).optional() - }) - .optional(), - title: OptionalString, - focus: z.unknown().optional(), - rendererBacked: z.unknown().optional(), - activate: z.unknown().optional(), - presentation: z.enum(['background', 'focused']).optional(), - tabId: OptionalString, - leafId: OptionalString -}) - -const TerminalSplit = TerminalHandle.extend({ - direction: z - .unknown() - .transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined)) - .pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()])) - .optional(), - command: OptionalString, - env: z.record(z.string(), z.string()).optional(), - telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional() -}) - -const TerminalStop = z.object({ - worktree: requiredString('Missing worktree selector') -}) - -const TerminalSleep = TerminalStop - -const TerminalStopExact = TerminalStop.extend({ - expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1), - keepHistory: z.boolean().optional(), - targetOnly: z.boolean().optional() -}) - -const AgentTeamsTmuxCompat = z.object({ - teamId: requiredString('Missing agent team ID'), - token: requiredString('Missing agent team token'), - envPane: requiredString('Missing tmux pane identity'), - cwd: OptionalString, - argv: z.array(z.string()) -}) - -const AgentTeamsPrepareLaunch = z.object({ - paneKey: requiredString('Missing pane key'), - env: z.record(z.string(), z.string()).optional() -}) - -const TerminalResizeForClient = z.discriminatedUnion('mode', [ - z.object({ - terminal: requiredString('Missing terminal handle'), - mode: z.literal('mobile-fit'), - cols: z.number().finite().positive(), - rows: z.number().finite().positive(), - clientId: requiredString('Missing client ID') - }), - z.object({ - terminal: requiredString('Missing terminal handle'), - mode: z.literal('restore'), - clientId: requiredString('Missing client ID') - }) -]) - -const TerminalSubscribe = TerminalHandle.extend({ - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop') - }) - .optional(), - viewport: TerminalViewport.optional(), - capabilities: z - .object({ - terminalBinaryStream: z.literal(1).optional(), - desktopViewportClaims: z.literal(1).optional(), - mobileInputLeaseOnly: z.literal(1).optional(), - writeUnavailable: z.literal(1).optional() - }) - .optional() -}) - -const TerminalMultiplex = z.object({}) - -const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ - streamId: z.number().int().min(1), - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop') - }) - .optional(), - viewport: TerminalViewport.optional(), - capabilities: z - .object({ - ackOutput: z.literal(1).optional(), - ackOutputSourceRanges: z.literal(1).optional(), - desktopViewportClaims: z.literal(1).optional(), - outputPause: z.literal(1).optional(), - writeUnavailable: z.literal(1).optional() - }) - .optional() -}) - -const TerminalMultiplexLegacyAckFrame = z - .object({ - bytes: z.number().int().nonnegative() - }) - .strict() - -const TerminalMultiplexSourceRangeAckFrame = z - .object({ - streamGeneration: z.string().min(1), - ackedEndByte: z.number().int().nonnegative() - }) - .strict() - -const TerminalMultiplexSnapshotRequestFrame = z.object({ - requestId: z.number().int().positive().optional(), - scrollbackRows: z.number().finite().optional() -}) - -const TerminalSetDisplayMode = TerminalHandle.extend({ - // Why: 'auto' = mobile drives dims while subscribed (desktop restores on last-leave); 'desktop' = no resize, mobile scales to fit. - mode: z.enum(['auto', 'desktop']), - // Why: identifies the caller for the driver state machine; optional for older mobile clients. - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop').optional() - }) - .optional(), - // Why: carries the measured viewport so an 'auto' toggle on a viewport-less record can phone-fit instead of no-op'ing. - viewport: z - .object({ - cols: z.number().int().positive(), - rows: z.number().int().positive() - }) - .optional() -}) - -const TerminalUnsubscribe = z.object({ - subscriptionId: requiredString('Missing subscription ID'), - // Why: lets the server rebuild the composite `${terminal}:${clientId}` cleanup key when older clients pass a bare subscriptionId (docs/mobile-presence-lock.md). - client: z - .object({ - id: requiredString('Missing client ID') - }) - .optional() -}) - -// Why: in-place update avoids an unsubscribe→resubscribe that flashed the lock banner and stranded the PTY at phone dims (docs/mobile-presence-lock.md). -const TerminalUpdateViewport = TerminalHandle.extend({ - client: z.object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('mobile').optional() - }), - viewport: z.object({ - cols: z.number().int().min(20).max(240), - rows: z.number().int().min(8).max(120) - }), - claim: z.boolean().optional() -}) - -// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md); `null` = Indefinite, finite ms clamped to [5_000, 60min] server-side. -const TerminalSetAutoRestoreFit = z.object({ - ms: z.number().nullable() -}) + TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS, + TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS +} from './terminal/terminal-viewport-methods' +// The manifest order is part of the released RPC contract. Keep composition here so the +// public entry point owns registration rather than forwarding an aggregated child export. export const TERMINAL_METHODS: RpcAnyMethod[] = [ - defineMethod({ - name: 'terminal.list', - params: TerminalListParams, - handler: async (params, { runtime }) => - runtime.listTerminals(params.worktree, params.limit, { - handles: params.handles, - requireFreshPtyLiveness: params.requireFreshPtyLiveness, - includeVisualLayouts: params.includeVisualLayouts - }) - }), - defineMethod({ - name: 'terminal.resolveActive', - params: TerminalResolveActive, - handler: async (params, { runtime }) => ({ - handle: await runtime.resolveActiveTerminal(params.worktree) - }) - }), - defineMethod({ - name: 'terminal.resolvePane', - params: TerminalResolvePane, - handler: async (params, { runtime }) => ({ - terminal: runtime.resolveTerminalPane(params.paneKey, params.worktreeId) - }) - }), - defineMethod({ - name: 'terminal.recoverPane', - params: TerminalRecoverPane, - handler: async (params, { runtime }) => ({ - terminal: await runtime.recoverTerminalPane( - params.paneKey, - params.worktreeId, - params.expectedTerminal - ) - }) - }), - defineMethod({ - name: 'terminal.show', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - terminal: await runtime.showTerminal(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.read', - params: TerminalRead, - handler: async (params, { runtime }) => ({ - terminal: await runtime.readTerminal(params.terminal, { - cursor: params.cursor, - limit: params.limit, - screen: params.screen - }) - }) - }), - defineMethod({ - name: 'terminal.inspectProcess', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - process: await runtime.inspectTerminalProcess(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.isRunningAgent', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - isRunningAgent: await runtime.isTerminalRunningAgent(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.agentStatus', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - agentStatus: await runtime.getTerminalAgentStatus(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.rename', - params: TerminalRename, - handler: async (params, { runtime }) => ({ - rename: await runtime.renameTerminal(params.terminal, params.title || null) - }) - }), - defineMethod({ - name: 'terminal.clearBuffer', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - clear: await runtime.clearTerminalBuffer(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.send', - params: TerminalSend, - handler: async (params, { runtime, clientId, signal }) => { - await assertTerminalSendTextWithinLimit(params.text) - await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text) - const queryReplyClientId = clientId ?? params.client?.id - if ( - params.inputKind === 'query-reply' && - (!params.text || - !isTerminalQueryReply(params.text) || - params.enter === true || - params.interrupt === true || - params.agentPrompt === true || - params.requireAgentStatus !== undefined || - params.client?.type !== 'mobile' || - !queryReplyClientId || - (clientId !== undefined && params.client.id !== clientId)) - ) { - throw new InvalidArgumentError('Invalid terminal query reply') - } - // Why: a stale handle must fail with terminal_handle_stale, not evaluate driver/lock state against the wrong PTY (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null - if ( - params.inputKind === 'query-reply' && - leaf?.ptyId && - !runtime.isMobileTerminalQueryReplyAuthority(leaf.ptyId, queryReplyClientId!) - ) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - if ( - leaf?.ptyId && - params.client?.type === 'desktop' && - params.claimViewport === true && - params.viewport - ) { - const claim = await updateViewportForClient( - runtime, - leaf.ptyId, - `send:${params.client.id}`, - params.client, - params.viewport, - 'desktop', - 'refresh', - true - ) - // Why: a stream-less request can't safely create ownership, so never write at stale geometry. - if (!claim.updated || isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - } - const hasText = typeof params.text === 'string' && params.text.length > 0 - const hasSuffix = params.enter === true || params.interrupt === true - if (params.requireAgentStatus === 'sendable' && hasText && hasSuffix) { - // Why: guarded sends are two-phase; reject combined payload + submit so a guard flip can't cause partial delivery. - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - // Why: recheck permission/no-agent state immediately before accepting the PTY write. - const assertSendPreconditions = - params.requireAgentStatus === 'sendable' - ? async (ptyId?: string): Promise => { - await assertTerminalAgentSendable({ - runtime, - handle: params.terminal, - assertWritable: () => { - assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) - if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - throw new Error('terminal_guard_not_writable') - } - } - }) - } - : undefined - if (params.requireAgentStatus === 'sendable') { - try { - await assertSendPreconditions?.(leaf?.ptyId ?? undefined) - } catch (error) { - if (isTerminalSendGuardNotWritable(error)) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - const refusedReason = getTerminalSendGuardRefusedReason(error) - if (!refusedReason) { - throw error - } - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0, - refusedReason - } - } - } - } - const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) - const mobileFloorClaim: MobileInputFloorClaimHolder = { current: null } - const beforeWrite = assertSendPreconditions - const useSettledAgentPrompt = - params.agentPrompt === true && - hasText && - params.enter === true && - params.interrupt !== true && - params.client?.type === 'desktop' && - (await runtime.isTerminalRunningSettledPromptAgent(params.terminal)) - const reserveWrite = - params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId - ? (ptyId: string): void => { - const claim = runtime.beginMobileInputFloor(ptyId, mobileFloorClientId) - if (!claim) { - throw new Error('mobile_input_floor_unavailable') - } - mobileFloorClaim.current = claim - } - : undefined - let result - try { - result = useSettledAgentPrompt - ? await runtime.sendTerminalAgentPrompt(params.terminal, params.text!, { - beforeWrite, - signal - }) - : await runtime.sendTerminal( - params.terminal, - { - text: params.text, - enter: params.enter === true, - interrupt: params.interrupt === true - }, - { - beforeWrite, - ...(reserveWrite ? { reserveWrite } : {}), - ...(params.inputKind !== 'query-reply' && mobileFloorClientId - ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } - : {}) - } - ) - } catch (error) { - mobileFloorClaim.current?.rollback() - const refusedReason = getTerminalSendGuardRefusedReason(error) - if (refusedReason) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0, - refusedReason - } - } - } - if (isTerminalSendGuardNotWritable(error)) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - throw error - } - if (result.accepted !== true) { - mobileFloorClaim.current?.rollback() - } - if ( - result.accepted === true && - params.enter === true && - params.client?.type === 'mobile' && - params.resolvedLaunchDraft - ) { - runtime.notifyNativeChatLaunchDraftResolved(params.terminal, params.resolvedLaunchDraft) - } - // Why: deliberate mobile input takes the floor (drives `* → mobile{clientId}`); clientless sends fall back to the current mobile driver. - return { send: result } - } - }), - defineMethod({ - name: 'terminal.wait', - params: TerminalWait, - handler: async (params, { runtime, signal }) => ({ - wait: await runtime.waitForTerminal(params.terminal, { - condition: params.for, - timeoutMs: params.timeoutMs, - signal - }) - }) - }), - defineMethod({ - name: 'terminal.create', - params: TerminalCreateParams, - handler: async (params, { runtime, pairedDeviceId, clientId }) => ({ - terminal: await runtime.dedupeTerminalCreate( - pairedDeviceId ?? clientId ?? 'local', - params.worktree, - params.clientMutationId, - params.reconcileExisting === true, - (canonicalWorktreeSelector, preAllocatedHandle) => - runtime.createTerminal(canonicalWorktreeSelector, { - command: params.command, - startupCommandDelivery: params.startupCommandDelivery, - env: params.env, - envToDelete: params.envToDelete, - ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), - ...(params.resumeProviderSession - ? { resumeProviderSession: params.resumeProviderSession } - : {}), - ...(params.launchToken ? { launchToken: params.launchToken } : {}), - ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), - ...(params.terminalColorQueryReplies - ? { terminalColorQueryReplies: params.terminalColorQueryReplies } - : {}), - title: params.title, - focus: params.focus === true, - rendererBacked: params.rendererBacked === true, - activate: params.activate === true, - presentation: params.presentation, - tabId: params.tabId, - leafId: params.leafId, - ...(preAllocatedHandle ? { preAllocatedHandle } : {}) - }) - ) - }) - }), - defineMethod({ - name: 'terminal.split', - params: TerminalSplit, - handler: async (params, { runtime }) => ({ - split: await runtime.splitTerminal(params.terminal, { - direction: params.direction, - command: params.command, - env: params.env, - telemetrySource: params.telemetrySource - }) - }) - }), - defineMethod({ - name: 'terminal.stop', - params: TerminalStop, - handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree) - }), - defineMethod({ - name: 'terminal.sleep', - params: TerminalSleep, - handler: async (params, { runtime }) => runtime.sleepTerminalsForWorktree(params.worktree) - }), - defineMethod({ - name: 'terminal.stopExact', - params: TerminalStopExact, - handler: async (params, { runtime }) => - runtime.stopExactTerminalsForWorktree(params.worktree, params.expectedPtyIds, { - keepHistory: params.keepHistory, - targetOnly: params.targetOnly - }) - }), - defineMethod({ - name: 'terminal.resizeForClient', - params: TerminalResizeForClient, - handler: async (params, { runtime }) => { - // Why: a stale handle must fail with terminal_handle_stale, not resize the wrong PTY (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - if (!leaf?.ptyId) { - throw new Error('no_connected_pty') - } - const result = await runtime.resizeForClient( - leaf.ptyId, - params.mode, - params.clientId, - params.mode === 'mobile-fit' ? params.cols : undefined, - params.mode === 'mobile-fit' ? params.rows : undefined - ) - return { - terminal: { - handle: params.terminal, - ...result - } - } - } - }), - defineMethod({ - name: 'terminal.focus', - params: TerminalFocus, - handler: async (params, { runtime, clientKind }) => ({ - focus: await runtime.focusTerminal(params.terminal, { - navigateHost: navigationTargetsHost( - resolveRuntimeNavigationTarget({ navigation: params.navigation, clientKind }) - ) - }) - }) - }), - defineMethod({ - name: 'terminal.close', - params: TerminalHandle, - handler: async (params, context) => ({ - close: await withTerminalCloseAttribution( - 'terminal.close', - context, - 'terminal', - params.terminal, - () => context.runtime.closeTerminal(params.terminal) - ) - }) - }), - defineMethod({ - name: 'terminal.closeTab', - params: TerminalHandle, - handler: async (params, context) => ({ - close: await withTerminalCloseAttribution( - 'terminal.closeTab', - context, - 'terminal-tab', - params.terminal, - () => context.runtime.closeTerminalTab(params.terminal) - ) - }) - }), - defineMethod({ - name: 'agentTeams.tmuxCompat', - params: AgentTeamsTmuxCompat, - handler: async (params, { runtime }) => ({ - tmux: await runtime.handleAgentTeamsTmuxCompat(params) - }) - }), - defineMethod({ - name: 'agentTeams.prepareLaunch', - params: AgentTeamsPrepareLaunch, - handler: async (params, { runtime }) => ({ - launch: await runtime.prepareClaudeAgentTeamsLeader({ - paneKey: params.paneKey, - baseEnv: params.env - }) - }) - }), - defineMethod({ - name: 'terminal.setDisplayMode', - params: TerminalSetDisplayMode, - handler: async (params, { runtime }) => { - // Why: a stale handle must fail with terminal_handle_stale, not mutate the wrong PTY's display mode/viewport (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - if (!leaf?.ptyId) { - throw new Error('no_connected_pty') - } - // Why: late-bind viewport for desktop-subscribed callers; otherwise an 'auto' toggle skips phone-fit and nothing resizes. - if (params.viewport && params.client?.id) { - runtime.updateMobileSubscriberViewport(leaf.ptyId, params.client.id, params.viewport) - } - if (params.client && params.client.type === 'mobile' && params.mode !== 'desktop') { - runtime.markMobileActor(leaf.ptyId, params.client.id) - } - runtime.setMobileDisplayMode(leaf.ptyId, params.mode) - await runtime.applyMobileDisplayMode(leaf.ptyId) - return { mode: params.mode, seq: runtime.getLayout(leaf.ptyId)?.seq } - } - }), - defineMethod({ - name: 'terminal.restoreFit', - params: TerminalHandle, - handler: async (params, { runtime }) => { - // Why: a stale handle must fail with terminal_handle_stale, not reclaim the wrong PTY to desktop dims (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - if (!leaf?.ptyId) { - throw new Error('no_connected_pty') - } - return { restored: await runtime.reclaimTerminalForDesktop(leaf.ptyId) } - } - }), - defineMethod({ - name: 'terminal.getDisplayMode', - params: TerminalHandle, - handler: async (params, { runtime }) => { - const leaf = runtime.resolveLeafForHandle(params.terminal) - const mode = leaf?.ptyId ? runtime.getMobileDisplayMode(leaf.ptyId) : 'auto' - const isPhoneFitted = leaf?.ptyId ? runtime.isMobileSubscriberActive(leaf.ptyId) : false - return { mode, isPhoneFitted } - } - }), - defineMethod({ - name: 'terminal.updateViewport', - params: TerminalUpdateViewport, - handler: async (params, { runtime }) => { - // Why: a stale handle must fail with terminal_handle_stale, not write viewport state to the wrong PTY (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - if (!leaf?.ptyId) { - throw new Error('no_connected_pty') - } - const viewportUpdate = await updateViewportForClient( - runtime, - leaf.ptyId, - `viewport:${params.client.id}`, - params.client, - params.viewport, - 'mobile', - // Why: one-shot RPC with no disconnect hook — refresh the existing stream-owned floor, never create a leak-prone one. - 'refresh', - params.claim === true - ) - return { ...viewportUpdate, seq: runtime.getLayout(leaf.ptyId)?.seq } - } - }), - // Why: one streaming RPC owns the binary socket and routes many panes by streamId; legacy subscribe stays as fallback. - defineStreamingMethod({ - name: 'terminal.multiplex', - params: TerminalMultiplex, - handler: async ( - _params, - { runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal }, - emit - ) => { - if (!sendBinary || !registerBinaryStreamHandler || !connectionId) { - throw new Error('binary_terminal_stream_required') - } - - let closed = false - let cursor = 0 - const streams = new Map() - const sourceRangeRegistry = new TerminalSourceRangeRegistry() - const pendingPtyWaitControllers = new Map>() - let ackTotalInFlightBytes = 0 - let ackTotalWindowBytes = TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES - let ackFlushCursorStreamId: number | null = null - let resolveMultiplex = (): void => {} - const multiplexClosed = new Promise((resolve) => { - resolveMultiplex = resolve - }) - const sendFrame = ( - streamId: number, - opcode: TerminalStreamOpcode, - payload: Uint8Array = new Uint8Array(), - seq?: number, - onRejected?: () => void - ): boolean => { - if (closed) { - onRejected?.() - return false - } - // Why: a seq-less Output chunk must carry sentinel 0, not the control-frame cursor, or it poisons the client's frame-drop tracker. - const resolvedSeq = - typeof seq === 'number' ? seq : opcode === TerminalStreamOpcode.Output ? 0 : cursor++ - let sent: boolean | void - try { - sent = sendBinary( - encodeTerminalStreamFrame({ opcode, streamId, seq: resolvedSeq, payload }) - ) - } catch { - onRejected?.() - closeMultiplex() - return false - } - if (sent === false) { - onRejected?.() - // Why: false means the transport discarded this frame; reconnect is the only available retry boundary with an authoritative snapshot. - closeMultiplex() - return false - } - return true - } - const sendStreamError = (streamId: number, message: string): void => { - sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message)) - emit({ type: 'error', streamId, message }) - } - const notifyStreamWriteUnavailable = ( - stream: TerminalMultiplexStream, - outcome: TerminalStreamInputOutcome - ): void => { - if ( - closed || - streams.get(stream.streamId) !== stream || - outcome !== 'rejected' || - !stream.supportsWriteUnavailable - ) { - return - } - sendFrame(stream.streamId, TerminalStreamOpcode.WriteUnavailable) - } - const sendResizedFrame = ( - stream: TerminalMultiplexStream, - event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } - ): void => { - stream.lastResizeCols = event.cols - sendFrame( - stream.streamId, - TerminalStreamOpcode.Resized, - encodeTerminalStreamJson({ - cols: event.cols, - rows: event.rows, - displayMode: event.displayMode, - reason: event.reason, - seq: event.seq - }) - ) - } - const canSendAckGatedOutput = (stream: TerminalMultiplexStream, bytes: number): boolean => { - if (!stream.ackOutput) { - return true - } - return ( - stream.ackInFlightBytes + bytes <= stream.ackWindowBytes && - ackTotalInFlightBytes + bytes <= ackTotalWindowBytes && - (!stream.ackOutputSourceRanges || stream.sourceRangeLedger?.canAccept(bytes) === true) - ) - } - const sendAckGatedOutput = ( - stream: TerminalMultiplexStream, - chunk: TerminalOutputFrameChunk - ): boolean => { - const prepared = stream.ackOutputSourceRanges - ? stream.sourceRangeLedger?.prepareAccept( - chunk.bytes.byteLength, - chunk.displayLength, - chunk.sourceRanges ?? [], - chunk.seq - ) - : undefined - if (stream.ackOutputSourceRanges && prepared?.status !== 'ready') { - if (prepared?.status !== 'capacity') { - detachStream(stream.streamId, true) - } - return false - } - const admission = prepared?.status === 'ready' ? prepared.admission : undefined - const sent = sendFrame( - stream.streamId, - chunk.opcode ?? TerminalStreamOpcode.Output, - chunk.bytes, - chunk.seq, - admission?.rollback - ) - if (!sent) { - return false - } - if (admission && !admission.commit()) { - detachStream(stream.streamId, true) - return false - } - if (stream.ackOutput) { - stream.ackInFlightBytes += chunk.bytes.byteLength - ackTotalInFlightBytes += chunk.bytes.byteLength - } - return true - } - const queueOrSendOutput = ( - stream: TerminalMultiplexStream, - chunk: TerminalOutputFrameChunk - ): void => { - if (closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { - return - } - if ( - stream.ackPendingOutputOverflowed || - stream.ackPendingOutput.length > 0 || - !canSendAckGatedOutput(stream, chunk.bytes.byteLength) - ) { - appendAckPendingOutput(stream, chunk) - return - } - sendAckGatedOutput(stream, chunk) - } - const sendAckRecoverySnapshot = async (stream: TerminalMultiplexStream): Promise => { - if ( - closed || - streams.get(stream.streamId) !== stream || - stream.outputPaused || - stream.ackRecoverySnapshotInFlight - ) { - return - } - stream.ackRecoverySnapshotInFlight = true - let replacement: RemoteTerminalSourceRangeReplacementReservation | null = null - try { - const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0) - if (closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { - return - } - if (!serialized) { - throw new Error('Remote terminal recovery snapshot unavailable.') - } - if ( - stream.ackOutputSourceRanges && - (serialized.source === undefined || typeof serialized.seq !== 'number') - ) { - throw new Error('Remote terminal recovery snapshot source identity unavailable.') - } - if ( - stream.ackOutputSourceRanges && - serialized.source !== undefined && - typeof serialized.seq === 'number' - ) { - replacement = runtime.reserveRemoteTerminalSourceRangeReplacement( - { - ptyId: stream.ptyId, - consumerId: stream.remoteDesktopSubscriptionKey, - streamGeneration: stream.streamGeneration - }, - serialized.seq, - 'ack-pending-overflow' - ) - stream.sourceRangeReplacement = replacement - } - const displayMode = runtime.getMobileDisplayMode(stream.ptyId) - const publication = sendSnapshotFrames( - (opcode, payload) => - !closed && - streams.get(stream.streamId) === stream && - sendFrame(stream.streamId, opcode, payload), - { - kind: 'scrollback', - cols: serialized.cols, - rows: serialized.rows, - displayMode, - reason: 'ack-pending-overflow', - seq: serialized.seq, - source: serialized.source, - kittyKeyboardFlags: serialized.kittyKeyboardFlags, - truncatedByByteBudget: serialized.truncatedByByteBudget, - data: serialized.data - } - ) - if (!publication.published) { - throw new Error('Remote terminal recovery snapshot was not published.') - } - if (closed || streams.get(stream.streamId) !== stream) { - throw new Error('Remote terminal recovery snapshot stream detached.') - } - const localReplacement = replacement - ? typeof serialized.seq === 'number' - ? stream.sourceRangeLedger?.planSourceRangeReplacement(serialized.seq) - : null - : null - if (replacement && !localReplacement) { - throw new Error('Remote terminal recovery source ledger replacement unavailable.') - } - if ( - replacement && - (!serialized.source || - typeof serialized.seq !== 'number' || - !runtime.commitRemoteTerminalSourceRangeReplacement(replacement, { - source: serialized.source, - seq: serialized.seq - })) - ) { - throw new Error('Remote terminal recovery snapshot replacement was not accepted.') - } - localReplacement?.commit() - stream.sourceRangeReplacement = null - replacement = null - if (typeof serialized.seq === 'number') { - const snapshotSeq = serialized.seq - const retained = stream.ackPendingOutput.filter( - (chunk) => !(typeof chunk.seq === 'number' && chunk.seq <= snapshotSeq) - ) - stream.ackPendingOutput = retained - stream.ackPendingOutputBytes = retained.reduce( - (total, chunk) => total + chunk.bytes.byteLength, - 0 - ) - } - stream.ackPendingOutputOverflowed = false - } catch (error) { - if (replacement) { - if (stream.sourceRangeReplacement === replacement) { - stream.sourceRangeReplacement = null - runtime.rollbackRemoteTerminalSourceRangeReplacement( - replacement, - 'ack-pending-overflow-unpublished' - ) - } - replacement = null - } - if (closed || streams.get(stream.streamId) !== stream) { - return - } - sendStreamError( - stream.streamId, - error instanceof Error ? error.message : 'Remote terminal recovery snapshot failed.' - ) - detachStream(stream.streamId, true) - } finally { - if (streams.get(stream.streamId) === stream) { - stream.ackRecoverySnapshotInFlight = false - flushAllAckPendingOutput() - } - } - } - const flushAckPendingOutput = ( - stream: TerminalMultiplexStream, - maxChunks = Number.POSITIVE_INFINITY - ): number => { - if (stream.outputPaused) { - return 0 - } - if (stream.ackPendingOutputOverflowed) { - void sendAckRecoverySnapshot(stream) - return 0 - } - let flushed = 0 - while ( - flushed < stream.ackPendingOutput.length && - flushed < maxChunks && - canSendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!.bytes.byteLength) - ) { - if (!sendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!)) { - return flushed - } - flushed += 1 - } - if (flushed > 0) { - stream.ackPendingOutput.splice(0, flushed) - stream.ackPendingOutputBytes = stream.ackPendingOutput.reduce( - (total, pending) => total + pending.bytes.byteLength, - 0 - ) - } - return flushed - } - const flushAllAckPendingOutput = (): void => { - const ordered = Array.from(streams.values()) - ackFlushCursorStreamId = drainTerminalMultiplexRoundRobin({ - streams: ordered, - cursorStreamId: ackFlushCursorStreamId, - canContinue: () => !closed, - drainOne: (stream) => { - if (streams.get(stream.streamId) !== stream) { - return false - } - if (flushAckPendingOutput(stream, 1) > 0) { - return true - } - return false - } - }) - } - const acknowledgeOutput = (stream: TerminalMultiplexStream, bytes: number): void => { - if (!stream.ackOutput || bytes <= 0) { - return - } - const acknowledged = Math.min(stream.ackInFlightBytes, bytes) - stream.ackWindowBytes = Math.min( - TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES, - stream.ackWindowBytes + acknowledged - ) - ackTotalWindowBytes = Math.min( - TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES, - ackTotalWindowBytes + acknowledged - ) - stream.ackInFlightBytes -= acknowledged - ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - acknowledged) - flushAllAckPendingOutput() - } - const acknowledgeSourceRanges = ( - stream: TerminalMultiplexStream, - streamGeneration: string, - ackedEndByte: number - ): void => { - if (!stream.ackOutputSourceRanges) { - return - } - const result = stream.sourceRangeLedger?.acknowledge(streamGeneration, ackedEndByte) - if (!result) { - return - } - if (result.status !== 'accepted') { - return - } - if (result.settled.length > 0) { - runtime.settleRemoteTerminalSourceRanges( - { - ptyId: stream.ptyId, - consumerId: stream.remoteDesktopSubscriptionKey, - streamGeneration: stream.streamGeneration - }, - result.settled - ) - } - acknowledgeOutput(stream, result.acknowledgedBytes) - } - const detachSourceRangeConsumer = (stream: TerminalMultiplexStream, reason: string): void => { - if (!stream.sourceRangeConsumerAttached) { - return - } - stream.sourceRangeConsumerAttached = false - const ledger = stream.sourceRangeLedger - stream.sourceRangeLedger = null - if (!ledger) { - return - } - const identity = { - ptyId: stream.ptyId, - consumerId: stream.remoteDesktopSubscriptionKey, - streamGeneration: stream.streamGeneration - } - const transfer = ledger.beginTransfer() - const ranges = transfer.frames.flatMap((frame) => frame.sourceRanges) - try { - runtime.cancelRemoteTerminalSourceRanges(identity, ranges, reason) - } finally { - transfer.commit() - } - } - const detachStream = ( - streamId: number, - emitEnd: boolean, - releaseRemoteDesktopDriver = true - ): void => { - const stream = streams.get(streamId) - if (!stream) { - return - } - const replacement = stream.sourceRangeReplacement - stream.sourceRangeReplacement = null - if (replacement) { - runtime.rollbackRemoteTerminalSourceRangeReplacement( - replacement, - 'stream-detached-replacement-aborted' - ) - } - stream.outputBatcher.flush() - stream.outputBatcher.dispose() - detachSourceRangeConsumer(stream, 'stream-detached') - ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - stream.ackInFlightBytes) - stream.ackInFlightBytes = 0 - stream.ackPendingOutput = [] - stream.ackPendingOutputBytes = 0 - stream.ackPendingOutputOverflowed = false - stream.ackRecoverySnapshotInFlight = false - stream.unsubscribeData() - stream.unsubscribeResize() - stream.unsubscribeFit() - stream.unsubscribeDriver() - stream.unregisterBinaryHandler() - streams.delete(streamId) - flushAllAckPendingOutput() - // Why: release the runtime exit-waiter for this slot (see the field's note); delete before abort so its .catch no-ops instead of re-detaching. - stream.exitWaiterAbort.abort() - if (stream.isMobile && stream.client?.id) { - runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id) - } else if ( - releaseRemoteDesktopDriver && - stream.registeredRemoteDesktopDriver && - stream.client?.id - ) { - // Why: release the width floor only if THIS stream took it, so a passive stream can't release a peer's floor. - runtime.unregisterRemoteDesktopViewer(stream.ptyId, stream.remoteDesktopSubscriptionKey) - } - if (emitEnd) { - emit({ type: 'end', streamId }) - } - } - const cancelPendingPtyWaits = (streamId: number): void => { - const controllers = pendingPtyWaitControllers.get(streamId) - if (!controllers) { - return - } - pendingPtyWaitControllers.delete(streamId) - for (const controller of controllers) { - controller.abort() - } - } - const cancelAllPendingPtyWaits = (): void => { - for (const streamId of Array.from(pendingPtyWaitControllers.keys())) { - cancelPendingPtyWaits(streamId) - } - } - const closeMultiplex = (): void => { - if (closed) { - return - } - closed = true - signal?.removeEventListener('abort', cancelAllPendingPtyWaits) - cancelAllPendingPtyWaits() - const remoteDesktopKeysByPty = new Map() - for (const streamId of Array.from(streams.keys())) { - const stream = streams.get(streamId) - if (stream?.registeredRemoteDesktopDriver && !stream.isMobile && stream.client?.id) { - const keys = remoteDesktopKeysByPty.get(stream.ptyId) ?? [] - keys.push(stream.remoteDesktopSubscriptionKey) - remoteDesktopKeysByPty.set(stream.ptyId, keys) - } - detachStream(streamId, false, false) - } - // Why: one connection can own many panes on the same PTY; remove floors together so close scans each registry once. - for (const [ptyId, subscriptionKeys] of remoteDesktopKeysByPty) { - void runtime.unregisterRemoteDesktopViewers(ptyId, subscriptionKeys) - } - unregisterControlHandler() - resolveMultiplex() - } - const handleSlotFrame = ( - stream: TerminalMultiplexStream, - frame: TerminalStreamFrame - ): void => { - if (closed || streams.get(stream.streamId) !== stream) { - return - } - if (frame.opcode === TerminalStreamOpcode.Unsubscribe) { - cancelPendingPtyWaits(stream.streamId) - detachStream(stream.streamId, false) - return - } - if (frame.opcode === TerminalStreamOpcode.Ack) { - const payload = decodeTerminalStreamJson(frame.payload) ?? {} - if (stream.ackOutputSourceRanges) { - const parsed = TerminalMultiplexSourceRangeAckFrame.safeParse(payload) - if (parsed.success) { - acknowledgeSourceRanges( - stream, - parsed.data.streamGeneration, - parsed.data.ackedEndByte - ) - } - } else { - const parsed = TerminalMultiplexLegacyAckFrame.safeParse(payload) - if (parsed.success) { - acknowledgeOutput(stream, parsed.data.bytes) - } - } - return - } - if (frame.opcode === TerminalStreamOpcode.Input) { - const text = decodeTerminalStreamText(frame.payload) - if (!text) { - return - } - if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { - return - } - // Mobile already has the higher-priority floor, so a rejected desktop claim must not suppress later phone input. - const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail - void inputClaimTail.then(async (claimed) => { - if (!claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { - return - } - const outcome = await sendTerminalStreamInput(runtime, { - terminal: stream.terminal, - text, - client: stream.client, - isMobile: stream.isMobile - }) - notifyStreamWriteUnavailable(stream, outcome) - }) - return - } - if (frame.opcode === TerminalStreamOpcode.SetOutputPaused && stream.supportsOutputPause) { - const payload = decodeTerminalStreamJson<{ paused?: unknown }>(frame.payload) - if (typeof payload?.paused !== 'boolean' || stream.outputPaused === payload.paused) { - return - } - stream.outputPaused = payload.paused - if (stream.outputPaused) { - stream.outputBatcher.flush() - stream.ackPendingOutput = [] - stream.ackPendingOutputBytes = 0 - stream.ackPendingOutputOverflowed = false - } - return - } - if (frame.opcode === TerminalStreamOpcode.Resize && stream.client) { - const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( - frame.payload - ) - if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { - return - } - const cols = viewport.cols - const rows = viewport.rows - // Why: resize registers stream-scoped geometry so detach can release it; older clients lack explicit claims. - if (!stream.isMobile && stream.client?.id) { - stream.registeredRemoteDesktopDriver = true - if (stream.buffering) { - stream.pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows } - return - } - } - stream.desktopClaimTail = stream.desktopClaimTail - .then(async (priorClaimed) => { - const result = await updateViewportForClient( - runtime, - stream.ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client!, - { cols, rows }, - stream.isMobile ? 'mobile' : 'desktop', - 'register', - !stream.supportsDesktopViewportClaims - ) - return stream.supportsDesktopViewportClaims - ? priorClaimed && result.applied - : result.applied - }) - .catch(() => false) - return - } - if ( - frame.opcode === TerminalStreamOpcode.ClaimViewport && - stream.client && - !stream.isMobile - ) { - const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( - frame.payload - ) - if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { - return - } - const cols = viewport.cols - const rows = viewport.rows - stream.registeredRemoteDesktopDriver = true - stream.desktopClaimTail = stream.desktopClaimTail - .then( - () => - runtime.updateRemoteDesktopViewer( - stream.ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client!.id, - cols, - rows, - true - ), - () => - runtime.updateRemoteDesktopViewer( - stream.ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client!.id, - cols, - rows, - true - ) - ) - .catch(() => false) - return - } - if (frame.opcode === TerminalStreamOpcode.SnapshotRequest) { - const payload = TerminalMultiplexSnapshotRequestFrame.safeParse( - decodeTerminalStreamJson(frame.payload) ?? {} - ) - void sendRequestedSnapshot(stream, payload.success ? payload.data : {}) - } - } - const sendRequestedSnapshot = async ( - stream: TerminalMultiplexStream, - request: z.infer - ): Promise => { - if (closed || streams.get(stream.streamId) !== stream) { - return - } - stream.outputBatcher.flush() - stream.pendingOutputOverflowed = false - stream.buffering = true - const requestId = request.requestId - let sentSnapshotOutputSeq: number | undefined - try { - const scrollbackRows = normalizeMultiplexSnapshotScrollbackRows(request.scrollbackRows) - let serialized = await serializeBudgetedRequestedSnapshot( - runtime, - stream.ptyId, - scrollbackRows - ) - if (closed || streams.get(stream.streamId) !== stream) { - return - } - let size = runtime.getTerminalSize(stream.ptyId) - let displayMode = runtime.getMobileDisplayMode(stream.ptyId) - if (stream.pendingOutputOverflowed) { - // Why: the overflowed tail is newer than the first snapshot, so retry for a current image instead of null. - stream.pendingOutput.splice(0) - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - serialized = await serializeBudgetedRequestedSnapshot( - runtime, - stream.ptyId, - scrollbackRows - ) - if (closed || streams.get(stream.streamId) !== stream) { - return - } - size = runtime.getTerminalSize(stream.ptyId) - displayMode = runtime.getMobileDisplayMode(stream.ptyId) - if (stream.pendingOutputOverflowed) { - sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), { - kind: 'scrollback', - cols: size?.cols ?? 80, - rows: size?.rows ?? 24, - requestId, - displayMode, - truncated: true, - truncatedByByteBudget: false, - unavailable: 'pending-output-overflowed', - data: '' - }) - return - } - } - sentSnapshotOutputSeq = serialized?.seq - sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), { - kind: 'scrollback', - cols: serialized?.cols ?? size?.cols ?? 80, - rows: serialized?.rows ?? size?.rows ?? 24, - requestId, - displayMode, - seq: serialized?.seq, - cwd: serialized?.cwd, - source: serialized?.source, - kittyKeyboardFlags: serialized?.kittyKeyboardFlags, - oscLinks: serialized?.oscLinks, - pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, - truncated: false, - truncatedByByteBudget: serialized?.truncatedByByteBudget, - // Why: no serializer answered, which is not proof the pane is empty — say so instead of passing off '' as the buffer. - unavailable: serialized ? undefined : 'no-serializable-buffer', - data: serialized?.data ?? '' - }) - } catch (error) { - sendStreamError( - stream.streamId, - error instanceof Error ? error.message : 'Remote terminal snapshot failed.' - ) - } finally { - if (streams.get(stream.streamId) === stream) { - const shouldFlushPendingOutput = !stream.pendingOutputOverflowed - stream.buffering = false - const pendingOutput = stream.pendingOutput.splice(0) - if (shouldFlushPendingOutput) { - for (const chunk of pendingOutput) { - // Why: an untagged reply resets the client to the snapshot's - // high-water, so covered bytes would render twice; tagged - // snapshots feed a side consumer and the live view still - // needs every buffered chunk. - const uncovered = - typeof requestId === 'number' - ? chunk - : getOutputAfterSnapshotSeq(chunk, sentSnapshotOutputSeq) - if (uncovered) { - stream.outputBatcher.push(uncovered.data, uncovered.meta) - } - } - } - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - stream.outputBatcher.flush() - // Why: a resize parked during snapshot buffering must be applied now, or it is dropped until the viewer's next resize. - if ( - !stream.isMobile && - stream.client?.id && - stream.registeredRemoteDesktopDriver && - stream.pendingRemoteDesktopViewport - ) { - const viewport = stream.pendingRemoteDesktopViewport - stream.pendingRemoteDesktopViewport = null - void updateViewportForClient( - runtime, - stream.ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client, - viewport, - 'desktop', - 'register', - !stream.supportsDesktopViewportClaims - ).catch(() => {}) - } - } - } - } - const handleSubscribeFrame = async (payload: Uint8Array): Promise => { - const raw = decodeTerminalStreamJson(payload) - const parsed = TerminalMultiplexSubscribeFrame.safeParse(raw) - if (!parsed.success) { - return - } - const request = parsed.data - detachStream(request.streamId, false) - cancelPendingPtyWaits(request.streamId) - - const isMobile = request.client?.type === 'mobile' - let leaf: { ptyId: string | null } | null - try { - // Why: binding the stream to whatever PTY now occupies a stale handle's pane would mirror the wrong terminal (#7718). - leaf = runtime.resolveLiveLeafForHandle(request.terminal) - } catch { - sendStreamError(request.streamId, 'terminal_handle_stale') - emit({ type: 'end', streamId: request.streamId }) - return - } - if (!leaf?.ptyId && request.client) { - if ( - pendingPtyWaitControllers.size >= - TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION - ) { - sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) - emit({ type: 'end', streamId: request.streamId }) - return - } - // Why: a never-mounted tab has no graph leaf to await; mounting the exact tab attaches its PTY without activating the worktree. - runtime.requestRendererTerminalTabMount(request.terminal) - const waitController = new AbortController() - const pendingControllers = pendingPtyWaitControllers.get(request.streamId) ?? new Set() - pendingControllers.add(waitController) - pendingPtyWaitControllers.set(request.streamId, pendingControllers) - if (signal?.aborted) { - waitController.abort() - } - // Why: the live slot handler does not exist until the PTY attaches; retain cancellation ownership while the pane is still pending. - const unregisterPendingHandler = registerBinaryStreamHandler( - request.streamId, - (frame) => { - if (frame.opcode === TerminalStreamOpcode.Unsubscribe) { - cancelPendingPtyWaits(request.streamId) - detachStream(request.streamId, false) - } - } - ) - try { - const ptyId = await runtime.waitForLeafPtyId( - request.terminal, - 10_000, - waitController.signal - ) - leaf = { ptyId } - } catch { - if (closed || signal?.aborted || waitController.signal.aborted) { - return - } - // Fall through to the explicit no_connected_pty error below. - } finally { - const currentControllers = pendingPtyWaitControllers.get(request.streamId) - currentControllers?.delete(waitController) - if (currentControllers?.size === 0) { - pendingPtyWaitControllers.delete(request.streamId) - } - unregisterPendingHandler() - } - } - if (!leaf?.ptyId) { - sendStreamError(request.streamId, 'no_connected_pty') - emit({ type: 'end', streamId: request.streamId }) - return - } - if (closed) { - return - } - // Why: a competing subscribe may own this streamId after the PTY await; detach it so an orphaned view subscriber can't silence the model responder (terminal-query-authority.md). - detachStream(request.streamId, false) - if (streams.size >= TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION) { - sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) - emit({ type: 'end', streamId: request.streamId }) - return - } - - const ptyId = leaf.ptyId - const remoteDesktopSubscriptionKey = `multiplex:${connectionId}:${request.streamId}` - const streamGeneration = randomUUID() - const requestedSourceRangeConsumer = - request.capabilities?.ackOutput === 1 && request.capabilities?.ackOutputSourceRanges === 1 - const sourceRangeLedger = requestedSourceRangeConsumer - ? sourceRangeRegistry.open(streamGeneration) - : null - const sourceRangeConsumerAttached = - sourceRangeLedger !== null && - runtime.attachRemoteTerminalSourceRangeConsumer({ - ptyId, - consumerId: remoteDesktopSubscriptionKey, - streamGeneration - }) - if (!sourceRangeConsumerAttached) { - sourceRangeLedger?.close() - } - const stream: TerminalMultiplexStream = { - streamId: request.streamId, - terminal: request.terminal, - ptyId, - client: request.client, - isMobile, - ackOutput: request.capabilities?.ackOutput === 1, - ackOutputSourceRanges: sourceRangeConsumerAttached, - streamGeneration, - sourceRangeLedger: sourceRangeConsumerAttached ? sourceRangeLedger : null, - sourceRangeConsumerAttached, - sourceRangeReplacement: null, - ackInFlightBytes: 0, - ackWindowBytes: TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES, - supportsOutputPause: request.capabilities?.outputPause === 1, - supportsWriteUnavailable: request.capabilities?.writeUnavailable === 1, - outputPaused: false, - supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1, - desktopClaimTail: Promise.resolve(true), - registeredRemoteDesktopDriver: false, - // Why: streamId is client-local, so key the width floor by connectionId or two connections sharing stream 1 for one PTY clobber each other's floor. - remoteDesktopSubscriptionKey, - pendingRemoteDesktopViewport: null, - buffering: true, - ackPendingOutput: [], - ackPendingOutputBytes: 0, - ackPendingOutputOverflowed: false, - ackRecoverySnapshotInFlight: false, - pendingOutput: [], - pendingOutputBytes: 0, - pendingOutputOverflowed: false, - lastResizeCols: undefined, - resizeGeneration: 0, - outputBatcher: createTerminalOutputBatcher((data, meta) => { - if (meta?.cwd !== undefined) { - sendFrame( - request.streamId, - TerminalStreamOpcode.Metadata, - encodeTerminalStreamJson({ cwd: meta.cwd }), - meta.seq - ) - } - for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { - queueOrSendOutput(stream, chunk) - } - }), - unsubscribeData: () => {}, - unsubscribeResize: () => {}, - unsubscribeFit: () => {}, - unsubscribeDriver: () => {}, - unregisterBinaryHandler: () => {}, - exitWaiterAbort: new AbortController() - } - streams.set(request.streamId, stream) - stream.unregisterBinaryHandler = registerBinaryStreamHandler(request.streamId, (frame) => - handleSlotFrame(stream, frame) - ) - - try { - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { - if (closed || streams.get(request.streamId) !== stream) { - return - } - if (stream.outputPaused) { - return - } - if (stream.buffering) { - appendPendingMultiplexOutput(stream, data, meta) - return - } - stream.outputBatcher.push(data, meta) - }) - // Why: a multiplexed stream feeds a remote xterm view with query authority, so the main model responder yields while attached (terminal-query-authority.md). - const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - stream.unsubscribeData = () => { - releaseViewSubscriber() - unsubscribeStreamData() - } - - if (isMobile && request.client?.id) { - await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport) - } else if (request.client?.id && request.viewport) { - // Why: subscribe records this stream's geometry and cleanup key but doesn't claim ownership; activity frames claim later. - stream.registeredRemoteDesktopDriver = true - stream.pendingRemoteDesktopViewport = request.viewport - } - if ( - !isMobile && - request.client?.id && - stream.registeredRemoteDesktopDriver && - stream.pendingRemoteDesktopViewport - ) { - const viewport = stream.pendingRemoteDesktopViewport - stream.pendingRemoteDesktopViewport = null - await updateViewportForClient( - runtime, - ptyId, - stream.remoteDesktopSubscriptionKey, - request.client, - viewport, - 'desktop', - 'register', - !stream.supportsDesktopViewportClaims - ) - } - if (closed || streams.get(request.streamId) !== stream) { - return - } - - let read = await runtime.readTerminal(request.terminal) - let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed || streams.get(request.streamId) !== stream) { - return - } - let initialOutputOverflowed = false - if (stream.pendingOutputOverflowed) { - stream.pendingOutput.splice(0) - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - read = await runtime.readTerminal(request.terminal) - serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed || streams.get(request.streamId) !== stream) { - return - } - if (stream.pendingOutputOverflowed) { - initialOutputOverflowed = true - stream.pendingOutput.splice(0) - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - } - } - const size = runtime.getTerminalSize(ptyId) - const displayMode = runtime.getMobileDisplayMode(ptyId) - const layoutSeq = runtime.getLayout(ptyId)?.seq - // Why: layout versions and output offsets are different sequence domains. - const snapshotOutputSeq = serialized?.seq - emit({ - type: 'subscribed', - streamId: request.streamId, - terminal: request.terminal, - cols: serialized?.cols ?? size?.cols, - rows: serialized?.rows ?? size?.rows, - displayMode, - seq: layoutSeq, - ...((stream.ackOutputSourceRanges || stream.supportsOutputPause) && { - capabilities: { - ...(stream.ackOutputSourceRanges ? { ackOutputSourceRanges: 1 as const } : {}), - ...(stream.supportsOutputPause ? { outputPause: 1 as const } : {}) - } - }), - ...(stream.ackOutputSourceRanges ? { streamGeneration: stream.streamGeneration } : {}), - // Why: retained-tail truncation loses history, not the authoritative latest-screen fallback. - truncated: initialOutputOverflowed - }) - stream.sourceRangeReplacement = - stream.ackOutputSourceRanges && - serialized?.source !== undefined && - typeof serialized.seq === 'number' - ? runtime.reserveRemoteTerminalSourceRangeReplacement( - { - ptyId, - consumerId: stream.remoteDesktopSubscriptionKey, - streamGeneration: stream.streamGeneration - }, - serialized.seq, - 'initial-snapshot' - ) - : null - const snapshotPublication = sendSnapshotFrames( - (opcode, payload) => sendFrame(request.streamId, opcode, payload), - { - kind: 'scrollback', - cols: serialized?.cols ?? size?.cols ?? 80, - rows: serialized?.rows ?? size?.rows ?? 24, - displayMode, - seq: snapshotOutputSeq, - cwd: serialized?.cwd, - truncated: initialOutputOverflowed, - truncatedByByteBudget: serialized?.truncatedByByteBudget, - source: serialized?.source, - kittyKeyboardFlags: serialized?.kittyKeyboardFlags, - oscLinks: serialized?.oscLinks, - pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, - data: - serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '') - } - ) - const replacement = stream.sourceRangeReplacement - stream.sourceRangeReplacement = null - if (replacement) { - const committed = - snapshotPublication.published && - serialized?.source !== undefined && - typeof serialized.seq === 'number' && - runtime.commitRemoteTerminalSourceRangeReplacement(replacement, { - source: serialized.source, - seq: serialized.seq - }) - if (!committed) { - runtime.rollbackRemoteTerminalSourceRangeReplacement( - replacement, - 'initial-snapshot-unpublished' - ) - } - } - // Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay. - stream.lastResizeCols = serialized?.cols ?? size?.cols - stream.buffering = false - const pendingOutput = stream.pendingOutput.splice(0) - if (!initialOutputOverflowed) { - for (const chunk of pendingOutput) { - const uncovered = getOutputAfterSnapshotSeq(chunk, snapshotOutputSeq) - if (uncovered) { - stream.outputBatcher.push(uncovered.data, uncovered.meta) - } - } - } - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - stream.outputBatcher.flush() - if (!isMobile) { - stream.unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { - const mode = - event.mode === 'mobile-fit' - ? event.mode - : (runtime.getRemoteDesktopFitHold?.(ptyId, stream.remoteDesktopSubscriptionKey) - .mode ?? 'desktop-fit') - emit({ - type: 'fit-override-changed', - streamId: request.streamId, - mode, - cols: event.cols, - rows: event.rows - }) - }) - stream.unsubscribeDriver = runtime.subscribeToDriverChanges(ptyId, (driver) => { - emit({ - type: 'driver-changed', - streamId: request.streamId, - driver - }) - }) - const fitOverride = runtime.getTerminalFitOverride(ptyId) - const desktopHold = runtime.getRemoteDesktopFitHold?.( - ptyId, - stream.remoteDesktopSubscriptionKey - ) ?? { mode: 'desktop-fit' as const, cols: size?.cols ?? 0, rows: size?.rows ?? 0 } - emit({ - type: 'fit-override-changed', - streamId: request.streamId, - mode: fitOverride?.mode ?? desktopHold.mode, - cols: fitOverride?.cols ?? desktopHold.cols, - rows: fitOverride?.rows ?? desktopHold.rows - }) - emit({ - type: 'driver-changed', - streamId: request.streamId, - driver: runtime.getDriver(ptyId) - }) - } - stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { - stream.outputBatcher.flush() - const resizeGeneration = stream.resizeGeneration + 1 - stream.resizeGeneration = resizeGeneration - const widthChanged = stream.isMobile && event.cols !== stream.lastResizeCols - if (widthChanged) { - stream.lastResizeCols = event.cols - // Why: re-serialize+replay the full scrollback at the new cols so restored hard-wrapped lines rewrap; live output resumes after the snapshot lands. - void sendMobileResizeRestream( - runtime, - ptyId, - (opcode, payload) => sendFrame(request.streamId, opcode, payload), - event, - () => - !closed && - streams.get(request.streamId) === stream && - stream.resizeGeneration === resizeGeneration - ) - .then((restreamed) => { - if ( - closed || - streams.get(request.streamId) !== stream || - stream.resizeGeneration !== resizeGeneration - ) { - return - } - if (!restreamed) { - sendResizedFrame(stream, event) - } - }) - // Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize. - .catch(() => { - if ( - closed || - streams.get(request.streamId) !== stream || - stream.resizeGeneration !== resizeGeneration - ) { - return - } - sendResizedFrame(stream, event) - }) - return - } - sendResizedFrame(stream, event) - }) - // Install the resize listener before draining the parked viewport, since applyLayout emits synchronously. - if ( - !stream.isMobile && - stream.client?.id && - stream.registeredRemoteDesktopDriver && - stream.pendingRemoteDesktopViewport - ) { - const viewport = stream.pendingRemoteDesktopViewport - stream.pendingRemoteDesktopViewport = null - void updateViewportForClient( - runtime, - ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client, - viewport, - 'desktop', - 'register', - !stream.supportsDesktopViewportClaims - ).catch(() => {}) - } - void runtime - .waitForTerminal(request.terminal, { - condition: 'exit', - signal: stream.exitWaiterAbort.signal - }) - .then(() => { - if (streams.get(request.streamId) === stream) { - detachStream(request.streamId, true) - } - }) - .catch(() => { - if (streams.get(request.streamId) === stream) { - detachStream(request.streamId, true) - } - }) - } catch (error) { - // Why the ownership check: a newer subscribe may already own this streamId; tearing down the slot here would kill the successor's live registrations. - if (streams.get(request.streamId) !== stream) { - return - } - detachStream(request.streamId, false) - sendStreamError(request.streamId, error instanceof Error ? error.message : String(error)) - emit({ type: 'end', streamId: request.streamId }) - } - } - const unregisterControlHandler = registerBinaryStreamHandler(0, (frame) => { - if (frame.opcode === TerminalStreamOpcode.Subscribe) { - void handleSubscribeFrame(frame.payload) - } - }) - - signal?.addEventListener('abort', cancelAllPendingPtyWaits, { once: true }) - - runtime.registerSubscriptionCleanup( - `terminal-multiplex:${connectionId}`, - closeMultiplex, - connectionId - ) - emit({ type: 'ready' }) - await multiplexClosed - } - }), - // terminal.subscribe: streams live terminal output over WebSocket; mobile clients pass client+viewport for server-side auto-fit. - defineStreamingMethod({ - name: 'terminal.subscribe', - params: TerminalSubscribe, - handler: async ( - params, - { runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal }, - emit - ) => { - let leaf = runtime.resolveLeafForHandle(params.terminal) - const isMobile = params.client?.type === 'mobile' - const serializerGenerationBeforeAnyMount = isMobile - ? (runtime.getRendererTerminalSerializerGenerationForHandle?.(params.terminal) ?? 0) - : 0 - let rendererMountRequestedBeforePty = false - const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary) - // Why: a closed stream must not allocate listeners, mobile-fit state, or a hidden renderer surface no client will consume. - if (signal?.aborted) { - return - } - - // Why: the PTY spawns asynchronously after tab creation; wait for it so an early subscribe gets a live stream instead of a bare scrollback+end. - if (!leaf?.ptyId && params.client) { - // Why: a never-mounted tab has no graph leaf to await; mounting the exact tab attaches its PTY without activating the worktree. - rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal) - try { - const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) - leaf = { ptyId } - } catch { - if (signal?.aborted) { - return - } - // PTY wait timed out — fall through to scrollback-only path below - } - } - - if (!leaf?.ptyId) { - const read = await runtime.readTerminal(params.terminal) - emit({ - type: 'subscribed', - streamId: null, - lines: read.tail, - truncated: isTerminalReadPayloadIncomplete(read) - }) - emit({ type: 'end' }) - return - } - - if (isMobile && (!useBinaryStream || !sendBinary)) { - throw new Error('binary_terminal_stream_required') - } - - const ptyId = leaf.ptyId - const clientId = params.client?.id - const mobileInputLeaseOnly = - isMobile && params.capabilities?.mobileInputLeaseOnly === 1 && Boolean(clientId) - // Why: mount/PTY wait and phone-fit can each emit a redraw creating suffix-only state, so capture the pre-mount absence signal first. - const missingHeadlessStateBeforeMobileFit = - isMobile && - (rendererMountRequestedBeforePty || runtime.hasHeadlessTerminalState?.(ptyId) === false) - const serializerGenerationBeforeMobileFit = missingHeadlessStateBeforeMobileFit - ? rendererMountRequestedBeforePty - ? serializerGenerationBeforeAnyMount - : runtime.getRendererTerminalSerializerGeneration(ptyId) - : 0 - const supportsDesktopViewportClaims = params.capabilities?.desktopViewportClaims === 1 - const supportsWriteUnavailable = params.capabilities?.writeUnavailable === 1 - if (mobileInputLeaseOnly && clientId) { - let closed = false - let stopWatchingLifetime = (): void => {} - let resolveStream = (): void => {} - const streamClosed = new Promise((resolve) => { - resolveStream = resolve - }) - const subscriptionId = `${params.terminal}:${clientId}` - // Why: chat needs the input-floor ack without registering a view subscriber or transporting duplicate PTY output. - const registration = runtime.registerOwnedSubscriptionCleanup( - subscriptionId, - () => { - stopWatchingLifetime() - closed = true - runtime.handleMobileUnsubscribe(ptyId, clientId) - emit({ type: 'end' }) - resolveStream() - }, - connectionId - ) - stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) - if (closed) { - // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. - return - } - try { - // Why: a lease-only subscriber has no terminal view, so its cached viewport must never phone-fit the PTY. - await runtime.handleMobileSubscribe(ptyId, clientId, undefined) - if (closed || signal?.aborted) { - // Why: a disconnect can win the awaited subscribe and resurrect mobile presence after cleanup already released it. - // Unguarded on purpose: this must still fire when our own cleanup already ran. - // Safe only because lease-only passes no viewport and both !viewport paths in - // handleMobileSubscribeInternal return with no await, so no rebind can land - // first. Adding an await there — or passing a viewport here — makes a - // superseded handler delete the replacement's (ptyId, clientId) presence. - runtime.handleMobileUnsubscribe(ptyId, clientId) - if (!closed) { - registration.releaseIfCurrent() - } - return - } - emit({ type: 'subscribed', streamId: null, lines: [], truncated: false }) - await streamClosed - } catch (error) { - registration.releaseIfCurrent() - throw error - } - return - } - // Why: only unregister the width floor this subscription took (see the multiplex stream's registeredRemoteDesktopDriver note). - let registeredRemoteDesktopDriver = false - if (!useBinaryStream) { - // Why: a hidden watcher and a visible pane can subscribe to one terminal, so key by client so neither stream evicts the other. - const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal - const remoteDesktopSubscriptionKey = `json:${nextTerminalStreamId++}` - let closed = false - let outputBatcher: ReturnType | null = null - let unsubscribeData = (): void => {} - let unsubscribeFit = (): void => {} - let stopWatchingLifetime = (): void => {} - let resolveStream = (): void => {} - const streamClosed = new Promise((resolve) => { - resolveStream = resolve - }) - // Why: register before viewport/snapshot awaits so a socket close can't orphan the stream listeners or its remote-desktop width floor. - const registration = runtime.registerOwnedSubscriptionCleanup( - subscriptionId, - () => { - stopWatchingLifetime() - closed = true - outputBatcher?.flush() - outputBatcher?.dispose() - unsubscribeData() - unsubscribeFit() - if (registeredRemoteDesktopDriver && clientId) { - runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) - } - emit({ type: 'end' }) - resolveStream() - }, - connectionId - ) - stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) - if (closed) { - // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. - return - } - try { - if (clientId && params.client && params.viewport) { - registeredRemoteDesktopDriver = true - await updateViewportForClient( - runtime, - ptyId, - remoteDesktopSubscriptionKey, - params.client, - params.viewport, - 'desktop', - 'register', - !supportsDesktopViewportClaims - ) - } - if (closed || signal?.aborted) { - registration.releaseIfCurrent() - return - } - const read = await runtime.readTerminal(params.terminal) - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) - if (closed || signal?.aborted) { - registration.releaseIfCurrent() - return - } - const size = runtime.getTerminalSize(ptyId) - const displayMode = runtime.getMobileDisplayMode(ptyId) - const seq = runtime.getLayout(ptyId)?.seq - emit({ - type: 'scrollback', - lines: read.tail, - truncated: isTerminalReadPayloadIncomplete(read), - serialized: serialized?.data, - oscLinks: serialized?.oscLinks, - cwd: serialized?.cwd, - // Why: an empty snapshot with no PTY size must still report the dims the fit - // will produce — dimless frames re-armed the mobile fit loop (STA-3337). - cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols, - rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows, - displayMode, - seq - }) - outputBatcher = createTerminalOutputBatcher((chunk) => { - emit({ type: 'data', chunk }) - }) - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => { - outputBatcher?.push(data) - }) - // Why: the legacy JSON stream can feed a live xterm view, so register as a view subscriber; worst case is a withheld model reply, safer than a double reply. - const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - unsubscribeData = () => { - releaseViewSubscriber() - unsubscribeStreamData() - } - unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { - outputBatcher?.flush() - const mode = - event.mode === 'mobile-fit' - ? event.mode - : (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ?? - 'desktop-fit') - emit({ - type: 'fit-override-changed', - mode, - cols: event.cols, - rows: event.rows - }) - }) - await streamClosed - } catch (error) { - registration.releaseIfCurrent() - throw error - } - return - } - - const streamId = nextTerminalStreamId++ - const remoteDesktopSubscriptionKey = `stream:${streamId}` - let cursor = 0 - let closed = false - let buffering = true - let pendingRemoteDesktopViewport: { cols: number; rows: number } | null = null - // Why: cols the mobile client last rewrapped to; gates the resize re-stream to fire only on an actual width change. - let lastResizeCols: number | undefined - let resizeGeneration = 0 - let pendingOutput: TerminalOutputChunk[] = [] - let desktopClaimTail: Promise = Promise.resolve(true) - let pendingOutputBytes = 0 - let pendingOutputOverflowed = false - let pendingQueryScanState: TerminalReplyQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE - const pendingQuerySequences: TerminalReplyQuerySequence[] = [] - let pendingQueryChars = 0 - let pendingQueryOverflowed = false - let unsubscribeData = (): void => {} - let unsubscribeResize = (): void => {} - let unsubscribeFit = (): void => {} - let unregisterBinaryHandler = (): void => {} - let abortRendererMountWait = (): void => {} - let stopWatchingLifetime = (): void => {} - let lateRendererReadyPromise: Promise | null = null - let outputBatcher: ReturnType | null = null - let resolveStream = (): void => {} - const streamClosed = new Promise((resolve) => { - resolveStream = resolve - }) - // Why: register cleanup before any await so a mid-subscribe disconnect still removes mobile presence; client-scoped ids also allow parallel desktop subscribers. - const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal - const registration = runtime.registerOwnedSubscriptionCleanup( - subscriptionId, - () => { - stopWatchingLifetime() - outputBatcher?.flush() - outputBatcher?.dispose() - closed = true - unsubscribeData() - unsubscribeResize() - unsubscribeFit() - unregisterBinaryHandler() - abortRendererMountWait() - if (isMobile && clientId) { - runtime.handleMobileUnsubscribe(ptyId, clientId) - } else if (registeredRemoteDesktopDriver && clientId) { - runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) - } - emit({ type: 'end' }) - resolveStream() - }, - connectionId - ) - stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) - if (closed) { - // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. - return - } - const sendFrame = ( - opcode: TerminalStreamOpcode, - payload: Uint8Array = new Uint8Array(), - frameSeq = cursor++ - ): void => { - if (closed || !sendBinary) { - return - } - sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: frameSeq, payload })) - } - outputBatcher = createTerminalOutputBatcher((data, meta) => { - if (meta?.cwd !== undefined) { - sendFrame( - TerminalStreamOpcode.Metadata, - encodeTerminalStreamJson({ cwd: meta.cwd }), - meta.seq - ) - } - for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { - sendFrame(chunk.opcode ?? TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) - } - }) - unregisterBinaryHandler = - registerBinaryStreamHandler?.(streamId, (frame) => { - if (closed) { - return - } - if (frame.opcode === TerminalStreamOpcode.Input) { - const text = decodeTerminalStreamText(frame.payload) - if (!text) { - return - } - if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - return - } - void desktopClaimTail.then(async (claimed) => { - if (!claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - return - } - const outcome = await sendTerminalStreamInput(runtime, { - terminal: params.terminal, - text, - client: params.client, - isMobile - }) - if (!closed && outcome === 'rejected' && supportsWriteUnavailable) { - sendFrame(TerminalStreamOpcode.WriteUnavailable) - } - }) - return - } - if (frame.opcode === TerminalStreamOpcode.Resize && params.client) { - const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( - frame.payload - ) - if ( - !viewport || - typeof viewport.cols !== 'number' || - typeof viewport.rows !== 'number' - ) { - return - } - const cols = viewport.cols - const rows = viewport.rows - if (clientId) { - registeredRemoteDesktopDriver = true - if (buffering) { - pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows } - return - } - } - desktopClaimTail = desktopClaimTail - .then(async (priorClaimed) => { - const result = await updateViewportForClient( - runtime, - ptyId, - remoteDesktopSubscriptionKey, - params.client!, - { cols, rows }, - 'desktop', - 'register', - !supportsDesktopViewportClaims - ) - return supportsDesktopViewportClaims - ? priorClaimed && result.applied - : result.applied - }) - .catch(() => false) - return - } - if ( - frame.opcode === TerminalStreamOpcode.ClaimViewport && - params.client && - clientId && - !isMobile - ) { - const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( - frame.payload - ) - if ( - !viewport || - typeof viewport.cols !== 'number' || - typeof viewport.rows !== 'number' - ) { - return - } - const cols = viewport.cols - const rows = viewport.rows - registeredRemoteDesktopDriver = true - desktopClaimTail = desktopClaimTail - .then( - () => - runtime.updateRemoteDesktopViewer( - ptyId, - remoteDesktopSubscriptionKey, - clientId, - cols, - rows, - true - ), - () => - runtime.updateRemoteDesktopViewer( - ptyId, - remoteDesktopSubscriptionKey, - clientId, - cols, - rows, - true - ) - ) - .catch(() => false) - } - }) ?? (() => {}) - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { - if (closed) { - return - } - if (buffering) { - const rawLength = meta?.rawLength - if ( - typeof meta?.seq === 'number' && - typeof rawLength === 'number' && - rawLength === data.length - ) { - const scan = scanTerminalReplyQuerySequences( - data, - meta.seq - rawLength, - pendingQueryScanState - ) - pendingQueryScanState = scan.state - for (const query of scan.queries) { - if (pendingQueryChars + query.data.length > TERMINAL_QUERY_REPLAY_MAX_CHARS) { - pendingQueryOverflowed = true - break - } - pendingQuerySequences.push(query) - pendingQueryChars += query.data.length - } - } else { - pendingQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE - } - const remainingBudget = Math.max( - 1, - TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes - ) - const measurement = measureTerminalStreamByteLength(data, { - stopAfterBytes: remainingBudget - }) - pendingOutput.push({ data, bytes: measurement.byteLength, meta }) - pendingOutputBytes += measurement.byteLength - const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) - pendingOutputBytes = trimmed.bytes - pendingOutputOverflowed ||= trimmed.overflowed - return - } - outputBatcher?.push(data, meta) - }) - // Why: capture live bytes before mobile-fit awaits; registering presence first would suppress main while no view held the query. - const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - unsubscribeData = () => { - releaseViewSubscriber() - unsubscribeStreamData() - } - // Server-side auto-fit: resize PTY to phone dims before serializing scrollback - try { - if (isMobile && clientId) { - await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) - } else if (clientId && params.viewport) { - // Why: legacy subscribe records geometry without taking ownership; only an explicit activity/claim frame may suppress the host. - registeredRemoteDesktopDriver = true - pendingRemoteDesktopViewport = params.viewport - } - if (closed) { - return - } - - let read = await runtime.readTerminal(params.terminal) - let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed) { - return - } - // Why: missing model state (not blank snapshot text) signals a never-attached PTY; a renderer-sourced snapshot already proves attachment, so skip the remount. - const mountRequested = - missingHeadlessStateBeforeMobileFit && - serialized?.source !== 'renderer' && - (rendererMountRequestedBeforePty || - runtime.requestRendererTerminalTabMount(params.terminal)) - if (missingHeadlessStateBeforeMobileFit && mountRequested) { - // Why: an idle legacy PTY emits no later byte, so wait for a settle proving this remount completed before replaying its screen. - const mountWaitController = new AbortController() - const abortMountWait = (): void => mountWaitController.abort() - abortRendererMountWait = abortMountWait - if (signal?.aborted) { - abortMountWait() - } else { - signal?.addEventListener('abort', abortMountWait, { once: true }) - } - const rendererReadyPromise = runtime - .waitForRendererTerminalSerializer( - ptyId, - serializerGenerationBeforeMobileFit, - undefined, - mountWaitController.signal - ) - .catch(() => false) - const finishMountWait = (): void => { - signal?.removeEventListener('abort', abortMountWait) - if (abortRendererMountWait === abortMountWait) { - abortRendererMountWait = () => {} - } - } - void rendererReadyPromise.then(finishMountWait, finishMountWait) - let deadlineTimer: ReturnType | null = null - const initialDeadline = new Promise((resolve) => { - deadlineTimer = setTimeout(() => resolve(false), MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS) - if (typeof deadlineTimer.unref === 'function') { - deadlineTimer.unref() - } - }) - const rendererReady = await Promise.race([rendererReadyPromise, initialDeadline]) - if (deadlineTimer) { - clearTimeout(deadlineTimer) - } - if (closed || signal?.aborted) { - return - } - if (rendererReady) { - read = await runtime.readTerminal(params.terminal) - const stableRendererSnapshot = await serializeStableMobileRendererSnapshot( - runtime, - ptyId - ) - if (closed) { - return - } - if (stableRendererSnapshot?.data.length) { - serialized = stableRendererSnapshot - const trailingOutput = pendingOutput.flatMap((item) => { - const output = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq) - const seq = item.meta?.seq - return output && typeof seq === 'number' ? [{ data: output.data, seq }] : [] - }) - runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery( - ptyId, - stableRendererSnapshot, - trailingOutput - ) - } - } else { - // Why: a renderer can settle after the bounded initial response; keep observing so an idle PTY self-heals without bytes. - lateRendererReadyPromise = rendererReadyPromise - } - } - let initialOutputOverflowed = false - if (pendingOutputOverflowed) { - pendingOutput.splice(0) - pendingOutputBytes = 0 - pendingOutputOverflowed = false - read = await runtime.readTerminal(params.terminal) - serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed) { - return - } - if (pendingOutputOverflowed) { - initialOutputOverflowed = true - pendingOutput.splice(0) - pendingOutputBytes = 0 - pendingOutputOverflowed = false - } - } - const size = runtime.getTerminalSize(ptyId) - const displayMode = runtime.getMobileDisplayMode(ptyId) - // Why: layout seq is the mobile stale-event filter's high-water mark (undefined pre-transition is fail-open). See docs/mobile-terminal-layout-state-machine.md. - const layoutSeq = runtime.getLayout(ptyId)?.seq - // Why: only an output offset can cover buffered chunks; layout versions are a separate sequence domain. - let snapshotOutputSeq = serialized?.seq - emit({ - type: 'subscribed', - streamId, - lines: read.tail, - truncated: - initialOutputOverflowed || (!sendBinary && isTerminalReadPayloadIncomplete(read)), - cols: serialized?.cols ?? size?.cols, - rows: serialized?.rows ?? size?.rows, - displayMode, - seq: layoutSeq - }) - const snapshotStats = sendSnapshotFrames(sendFrame, { - kind: 'scrollback', - // Why: prefer the subscriber's viewport over the 80x24 stopgap when the PTY has - // no size yet — the mismatch made mobile burn its resubscribe budget (STA-3337). - cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols ?? 80, - rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows ?? 24, - displayMode, - seq: snapshotOutputSeq, - cwd: serialized?.cwd, - truncated: initialOutputOverflowed, - truncatedByByteBudget: serialized?.truncatedByByteBudget, - oscLinks: serialized?.oscLinks, - data: serialized?.data ?? '' - }) - console.log('[mobile-terminal-stream] snapshot', { - terminal: params.terminal, - streamId, - kind: 'scrollback', - bytes: snapshotStats.bytes, - chunks: snapshotStats.chunks, - scrollbackRows: serialized?.scrollbackRows, - truncatedByByteBudget: serialized?.truncatedByByteBudget === true - }) - // Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay. - lastResizeCols = serialized?.cols ?? size?.cols - let recoveryAttempts = 0 - // Why: if the bounded pre-subscribe tail overflowed, only a fresh model snapshot covers the dropped middle without replay gaps. - while (pendingOutputOverflowed && recoveryAttempts < 2) { - pendingOutputOverflowed = false - recoveryAttempts += 1 - const recovery = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed) { - return - } - if (!recovery) { - break - } - // Why: without an output seq (renderer fallback) covered chunks can't be trimmed exactly, so keep the bounded replay over an unverifiable snapshot. - if (typeof recovery.seq !== 'number') { - break - } - // Why: clients drop a repeat scrollback snapshot but apply 'resized' inline; omit seq so output-byte seqs don't pollute the layout-seq filter. - const recoveryStats = sendSnapshotFrames(sendFrame, { - kind: 'resized', - cols: recovery.cols, - rows: recovery.rows, - displayMode, - reason: 'pending-output-overflow', - source: recovery.source, - truncated: false, - truncatedByByteBudget: recovery.truncatedByByteBudget, - data: recovery.data - }) - console.log('[mobile-terminal-stream] recovery snapshot', { - terminal: params.terminal, - streamId, - reason: 'pending-output-overflow', - bytes: recoveryStats.bytes, - chunks: recoveryStats.chunks, - scrollbackRows: recovery.scrollbackRows, - truncatedByByteBudget: recovery.truncatedByByteBudget === true - }) - const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq) - pendingOutput = trimmed.chunks - pendingOutputBytes = trimmed.bytes - snapshotOutputSeq = recovery.seq - } - buffering = false - const bufferedOutput = pendingOutput.splice(0) - const queryReplayData = pendingQueryOverflowed - ? '' - : pendingQuerySequences - .filter( - (query) => - initialOutputOverflowed || - (typeof snapshotOutputSeq === 'number' && query.startSeq < snapshotOutputSeq) - ) - .map((query) => query.data) - .join('') - if (queryReplayData) { - // Why: snapshots omit control queries but their seq trims the live chunk; replay the post-snapshot query so the mobile xterm answers once. - outputBatcher.push(queryReplayData) - } - if (!initialOutputOverflowed) { - for (const item of bufferedOutput) { - const uncovered = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) - let uncoveredData = uncovered?.data ?? null - let uncoveredMeta = uncovered?.meta - if ( - uncoveredData && - uncoveredData !== item.data && - typeof snapshotOutputSeq === 'number' && - typeof item.meta?.seq === 'number' && - typeof item.meta.rawLength === 'number' - ) { - if (item.meta.rawLength === item.data.length) { - uncoveredMeta = { ...item.meta, rawLength: uncoveredData.length } - } - uncoveredData = stripSnapshotBoundaryQuerySuffixes( - uncoveredData, - snapshotOutputSeq, - snapshotOutputSeq, - pendingQuerySequences - ) - } - if (uncoveredData) { - outputBatcher.push(uncoveredData, uncoveredMeta) - } - } - } - pendingOutputBytes = 0 - outputBatcher.flush() - const lateRendererReady = lateRendererReadyPromise - lateRendererReadyPromise = null - if (lateRendererReady) { - void lateRendererReady - .then(async (rendererReady) => { - if (!rendererReady || closed) { - return - } - outputBatcher?.flush() - const recovery = await serializeStableMobileRendererSnapshot(runtime, ptyId) - if (closed) { - return - } - if (!recovery?.data.length) { - return - } - // Why: late recovery has no buffered-output gate, so only an exact renderer high-water may reset mobile without erasing live bytes. - if (recovery.seq !== runtime.getPtyOutputSequence(ptyId)) { - return - } - runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, recovery) - // Why: shipped mobile clients apply resized snapshots in place, so a blank xterm recovers without resubscribe. - const recoveryStats = sendSnapshotFrames(sendFrame, { - kind: 'resized', - cols: recovery.cols, - rows: recovery.rows, - displayMode, - reason: 'renderer-mount-ready', - source: recovery.source, - truncated: false, - truncatedByByteBudget: recovery.truncatedByByteBudget, - data: recovery.data - }) - lastResizeCols = recovery.cols - console.log('[mobile-terminal-stream] recovery snapshot', { - terminal: params.terminal, - streamId, - reason: 'renderer-mount-ready', - bytes: recoveryStats.bytes, - chunks: recoveryStats.chunks, - scrollbackRows: recovery.scrollbackRows, - truncatedByByteBudget: recovery.truncatedByByteBudget === true - }) - }) - .catch(() => {}) - } - const sendResizedFrame = (event: { - cols: number - rows: number - displayMode: string - reason: string - seq?: number - }): void => { - lastResizeCols = event.cols - sendFrame( - TerminalStreamOpcode.Resized, - encodeTerminalStreamJson({ - cols: event.cols, - rows: event.rows, - displayMode: event.displayMode, - reason: event.reason, - seq: event.seq - }) - ) - } - unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { - outputBatcher?.flush() - const eventGeneration = resizeGeneration + 1 - resizeGeneration = eventGeneration - // Why: xterm only re-wraps soft-wrapped lines, so a width change needs a full re-serialize+replay to rewrap restored hard-wrapped scrollback. - const widthChanged = isMobile && event.cols !== lastResizeCols - if (widthChanged) { - lastResizeCols = event.cols - void sendMobileResizeRestream( - runtime, - ptyId, - sendFrame, - event, - () => !closed && resizeGeneration === eventGeneration - ) - .then((restreamed) => { - if (closed || resizeGeneration !== eventGeneration) { - return - } - if (!restreamed) { - sendResizedFrame(event) - } - }) - // Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize. - .catch(() => { - if (closed || resizeGeneration !== eventGeneration) { - return - } - sendResizedFrame(event) - }) - return - } - sendResizedFrame(event) - }) - - // Install the resize listener before draining the parked viewport, since applyLayout emits synchronously. - if ( - clientId && - params.client && - registeredRemoteDesktopDriver && - pendingRemoteDesktopViewport - ) { - const viewport = pendingRemoteDesktopViewport - pendingRemoteDesktopViewport = null - void updateViewportForClient( - runtime, - ptyId, - remoteDesktopSubscriptionKey, - params.client, - viewport, - 'desktop', - 'register', - !supportsDesktopViewportClaims - ).catch(() => {}) - } - - // Legacy fit-override-changed for non-mobile (desktop) subscribers - unsubscribeFit = !isMobile - ? runtime.subscribeToFitOverrideChanges(ptyId, (event) => { - const mode = - event.mode === 'mobile-fit' - ? event.mode - : (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ?? - 'desktop-fit') - emit({ - type: 'fit-override-changed', - mode, - cols: event.cols, - rows: event.rows - }) - }) - : () => {} - } catch (error) { - registration.releaseIfCurrent() - throw error - } - - await streamClosed - } - }), - defineMethod({ - name: 'terminal.unsubscribe', - params: TerminalUnsubscribe, - handler: async (params, { runtime, connectionId }) => { - // Why: only the connection that owns the subscription may retire it — a stale - // unsubscribe from the pre-reconnect socket names an id the replacement now owns. - let unsubscribed = runtime.cleanupSubscriptionIfOwnedByConnection( - params.subscriptionId, - connectionId - ) - // Why: older builds send a bare-handle subscriptionId, so also try the reconstructed `${terminal}:${clientId}` composite key. - // Why AND over the calls that ran: a clientless stream registers under the bare id - // and a client-scoped one under the composite, so either call can be the real - // teardown. Reporting false needs one genuine refusal, not merely a missing id. - if (params.client && !params.subscriptionId.includes(':')) { - unsubscribed = - runtime.cleanupSubscriptionIfOwnedByConnection( - `${params.subscriptionId}:${params.client.id}`, - connectionId - ) && unsubscribed - } - return { unsubscribed } - } - }), - defineMethod({ - name: 'terminal.getAutoRestoreFit', - params: z.object({}), - handler: async (_params, { runtime }) => ({ - ms: runtime.getMobileAutoRestoreFitMs() - }) - }), - defineMethod({ - name: 'terminal.setAutoRestoreFit', - params: TerminalSetAutoRestoreFit, - handler: async (params, { runtime }) => ({ - ms: runtime.setMobileAutoRestoreFitMs(params.ms) - }) - }) + ...TERMINAL_QUERY_METHODS, + ...TERMINAL_SEND_METHODS, + ...TERMINAL_LIFECYCLE_METHODS, + ...TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS, + ...TERMINAL_MULTIPLEX_METHODS, + ...TERMINAL_SUBSCRIBE_METHODS, + ...TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS ] diff --git a/src/main/runtime/rpc/methods/terminal/stream-schemas.ts b/src/main/runtime/rpc/methods/terminal/stream-schemas.ts new file mode 100644 index 00000000000..04a6990fe51 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/stream-schemas.ts @@ -0,0 +1,78 @@ +import { z } from 'zod' +import { requiredString } from '../../schemas' +import { TerminalViewport } from './unary-schemas' + +const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) + +export const TerminalResizeForClient = z.discriminatedUnion('mode', [ + z.object({ + terminal: requiredString('Missing terminal handle'), + mode: z.literal('mobile-fit'), + cols: z.number().finite().positive(), + rows: z.number().finite().positive(), + clientId: requiredString('Missing client ID') + }), + z.object({ + terminal: requiredString('Missing terminal handle'), + mode: z.literal('restore'), + clientId: requiredString('Missing client ID') + }) +]) + +export const TerminalSubscribe = TerminalHandle.extend({ + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop') + }) + .optional(), + viewport: TerminalViewport.optional(), + capabilities: z + .object({ + terminalBinaryStream: z.literal(1).optional(), + desktopViewportClaims: z.literal(1).optional(), + mobileInputLeaseOnly: z.literal(1).optional(), + writeUnavailable: z.literal(1).optional() + }) + .optional() +}) + +export const TerminalMultiplex = z.object({}) + +export const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ + streamId: z.number().int().min(1), + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop') + }) + .optional(), + viewport: TerminalViewport.optional(), + capabilities: z + .object({ + ackOutput: z.literal(1).optional(), + ackOutputSourceRanges: z.literal(1).optional(), + desktopViewportClaims: z.literal(1).optional(), + outputPause: z.literal(1).optional(), + writeUnavailable: z.literal(1).optional() + }) + .optional() +}) + +export const TerminalMultiplexLegacyAckFrame = z + .object({ + bytes: z.number().int().nonnegative() + }) + .strict() + +export const TerminalMultiplexSourceRangeAckFrame = z + .object({ + streamGeneration: z.string().min(1), + ackedEndByte: z.number().int().nonnegative() + }) + .strict() + +export const TerminalMultiplexSnapshotRequestFrame = z.object({ + requestId: z.number().int().positive().optional(), + scrollbackRows: z.number().finite().optional() +}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts new file mode 100644 index 00000000000..7313eb82b94 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts @@ -0,0 +1,188 @@ +import { InvalidArgumentError } from '../../core' +import type { + DriverState, + OrcaRuntimeService, + SubscriptionRegistration +} from '../../../orca-runtime' +import { + TERMINAL_INPUT_MAX_BYTES, + TERMINAL_INPUT_TOO_LARGE_ERROR, + isTerminalInputTooLargeWithYield +} from '../../../../../shared/terminal-input' +import type { TerminalViewportClient } from './terminal-stream-types' + +export function isTerminalInputLockedForClient( + runtime: OrcaRuntimeService, + ptyId: string, + client: TerminalViewportClient | undefined +): boolean { + if (client?.type === 'mobile') { + return false + } + // Why: pre-refactor mobile builds sent no client metadata, so treat a missing client as legacy mobile (unlocked). + if (!client) { + return false + } + return runtime.getDriver(ptyId).kind === 'mobile' +} + +export async function assertTerminalSendTextWithinLimit(text: string | undefined): Promise { + if (!text) { + return + } + // Why: sends can be paste-sized; validate outside Zod so large input yields before runtime dispatch. + if (await isTerminalInputTooLargeWithYield(text, TERMINAL_INPUT_MAX_BYTES)) { + throw new InvalidArgumentError(TERMINAL_INPUT_TOO_LARGE_ERROR) + } +} + +export function resolveMobileFloorClientId( + driver: DriverState | null, + client: TerminalViewportClient | undefined +): string | null { + if (client?.type === 'mobile') { + return client.id + } + if (!client && driver?.kind === 'mobile') { + return driver.clientId + } + return null +} + +export type TerminalStreamInputOutcome = 'delivered' | 'rejected' | 'failed' + +export function watchSubscriptionLifetime( + runtime: OrcaRuntimeService, + ptyId: string, + signal: AbortSignal | undefined, + registration: SubscriptionRegistration +): () => void { + let unsubscribeExit: (() => void) | null = null + let removeAbort: (() => void) | null = null + let stopped = false + const stop = (): void => { + stopped = true + unsubscribeExit?.() + removeAbort?.() + } + const release = (): void => { + registration.releaseIfCurrent() + stop() + } + unsubscribeExit = runtime.subscribeToPtyExit(ptyId, release) + if (stopped) { + unsubscribeExit() + return stop + } + if (!signal) { + return stop + } + if (signal.aborted) { + release() + return stop + } + const onAbort = (): void => release() + removeAbort = () => signal.removeEventListener('abort', onAbort) + signal.addEventListener('abort', onAbort, { once: true }) + if (stopped) { + removeAbort() + } + return stop +} + +export function isTerminalStreamInputRejection(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return message.includes('terminal_not_writable') || message.includes('terminal_handle_stale') +} + +export async function sendTerminalStreamInput( + runtime: OrcaRuntimeService, + args: { + terminal: string + text: string + client: TerminalViewportClient | undefined + isMobile: boolean + } +): Promise { + const action = { text: args.text, enter: false, interrupt: false } + const clientId = args.isMobile ? args.client?.id : undefined + const floorClaim: MobileInputFloorClaimHolder = { current: null } + try { + if (!clientId) { + const result = await runtime.sendTerminal(args.terminal, action) + return result.accepted ? 'delivered' : 'rejected' + } + const result = await runtime.sendTerminal(args.terminal, action, { + reserveWrite: (writePtyId) => { + const claim = runtime.beginMobileInputFloor(writePtyId, clientId) + if (!claim) { + throw new Error('mobile_input_floor_unavailable') + } + floorClaim.current = claim + }, + afterWrite: () => commitMobileInputFloorClaim(floorClaim) + }) + if (!result.accepted) { + floorClaim.current?.rollback() + return 'rejected' + } + return 'delivered' + } catch (error) { + floorClaim.current?.rollback() + return isTerminalStreamInputRejection(error) ? 'rejected' : 'failed' + } +} + +export type MobileInputFloorClaimHolder = { + current: ReturnType +} + +export async function commitMobileInputFloorClaim( + claim: MobileInputFloorClaimHolder +): Promise { + const current = claim.current + if (!current) { + return + } + try { + await current.commit() + } finally { + // Why: the runtime may yield before the next write, which then needs a fresh reservation if desktop reclaimed the floor. + if (claim.current === current) { + claim.current = null + } + } +} + +export function getTerminalSendGuardRefusedReason( + error: unknown +): 'no-agent' | 'permission' | undefined { + const message = error instanceof Error ? error.message : String(error) + if (message.includes('terminal_guard_permission')) { + return 'permission' + } + if (message.includes('terminal_guard_no_agent')) { + return 'no-agent' + } + return undefined +} + +export function isTerminalSendGuardNotWritable(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return message.includes('terminal_guard_not_writable') +} + +export function assertTerminalSendExactPtyBinding( + runtime: OrcaRuntimeService, + handle: string, + expectedPtyId: string | undefined +): void { + try { + if (expectedPtyId && runtime.resolveLiveLeafForHandle(handle)?.ptyId === expectedPtyId) { + return + } + } catch { + // Fall through to the stable guarded-send result below. + } + throw new Error('terminal_guard_not_writable') +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts new file mode 100644 index 00000000000..1033464071c --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts @@ -0,0 +1,141 @@ +import { + TerminalStreamOpcode, + decodeTerminalStreamJson, + decodeTerminalStreamText +} from '../../../../../shared/terminal-stream-protocol' +import { isTerminalInputLockedForClient, sendTerminalStreamInput } from './terminal-input-delivery' +import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types' +import { updateViewportForClient } from './terminal-viewport-update' + +type LegacyBinaryControlState = { + isClosed: () => boolean + isBuffering: () => boolean + setRegisteredRemoteDesktopDriver: () => void + setPendingRemoteDesktopViewport: (viewport: { cols: number; rows: number }) => void + getDesktopClaimTail: () => Promise + setDesktopClaimTail: (tail: Promise) => void + sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array) => void +} + +export function registerLegacyBinaryControlFrames( + args: TerminalSubscriptionArgs, + streamId: number, + remoteDesktopSubscriptionKey: string, + controls: LegacyBinaryControlState +): () => void { + const { + params, + runtime, + registerBinaryStreamHandler, + ptyId, + clientId, + isMobile, + supportsDesktopViewportClaims, + supportsWriteUnavailable + } = args + if (!registerBinaryStreamHandler) { + return () => {} + } + return registerBinaryStreamHandler(streamId, (frame) => { + if (controls.isClosed()) { + return + } + if (frame.opcode === TerminalStreamOpcode.Input) { + const text = decodeTerminalStreamText(frame.payload) + if (!text) { + return + } + if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + return + } + void controls.getDesktopClaimTail().then(async (claimed) => { + if (!claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + return + } + const outcome = await sendTerminalStreamInput(runtime, { + terminal: params.terminal, + text, + client: params.client, + isMobile + }) + if (!controls.isClosed() && outcome === 'rejected' && supportsWriteUnavailable) { + controls.sendFrame(TerminalStreamOpcode.WriteUnavailable) + } + }) + return + } + if (frame.opcode === TerminalStreamOpcode.Resize && params.client) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(frame.payload) + if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { + return + } + const cols = viewport.cols + const rows = viewport.rows + if (clientId) { + controls.setRegisteredRemoteDesktopDriver() + if (controls.isBuffering()) { + controls.setPendingRemoteDesktopViewport({ cols: viewport.cols, rows: viewport.rows }) + return + } + } + controls.setDesktopClaimTail( + controls + .getDesktopClaimTail() + .then(async (priorClaimed) => { + const result = await updateViewportForClient( + runtime, + ptyId, + remoteDesktopSubscriptionKey, + params.client!, + { cols, rows }, + 'desktop', + 'register', + !supportsDesktopViewportClaims + ) + return supportsDesktopViewportClaims ? priorClaimed && result.applied : result.applied + }) + .catch(() => false) + ) + return + } + if ( + frame.opcode === TerminalStreamOpcode.ClaimViewport && + params.client && + clientId && + !isMobile + ) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(frame.payload) + if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { + return + } + const cols = viewport.cols + const rows = viewport.rows + controls.setRegisteredRemoteDesktopDriver() + controls.setDesktopClaimTail( + controls + .getDesktopClaimTail() + .then( + () => + runtime.updateRemoteDesktopViewer( + ptyId, + remoteDesktopSubscriptionKey, + clientId, + cols, + rows, + true + ), + () => + runtime.updateRemoteDesktopViewer( + ptyId, + remoteDesktopSubscriptionKey, + clientId, + cols, + rows, + true + ) + ) + .catch(() => false) + ) + } + }) +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts new file mode 100644 index 00000000000..ad4c7dd05da --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-simple-subscriptions.ts @@ -0,0 +1,184 @@ +import { createTerminalOutputBatcher } from './terminal-output-batcher' +import type { TerminalOutputBatcher } from './terminal-output-batcher' +import { isTerminalReadPayloadIncomplete } from './terminal-stream-replay' +import { serializeBudgetedMobileSnapshot } from './terminal-snapshot-publication' +import { updateViewportForClient } from './terminal-viewport-update' +import { watchSubscriptionLifetime } from './terminal-input-delivery' +import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types' +import { allocateTerminalSubscriptionStreamId } from './terminal-subscription-stream-id' + +export async function runTerminalLeaseSubscription(args: TerminalSubscriptionArgs): Promise { + const { params, runtime, connectionId, signal, emit, ptyId, clientId } = args + if (!clientId) { + return + } + let closed = false + let stopWatchingLifetime = (): void => {} + let resolveStream = (): void => {} + const streamClosed = new Promise((resolve) => { + resolveStream = resolve + }) + const subscriptionId = `${params.terminal}:${clientId}` + // Why: chat needs the input-floor ack without registering a view subscriber or transporting duplicate PTY output. + const registration = runtime.registerOwnedSubscriptionCleanup( + subscriptionId, + () => { + stopWatchingLifetime() + closed = true + runtime.handleMobileUnsubscribe(ptyId, clientId) + emit({ type: 'end' }) + resolveStream() + }, + connectionId + ) + stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) + if (closed) { + // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. + return + } + try { + // Why: a lease-only subscriber has no terminal view, so its cached viewport must never phone-fit the PTY. + await runtime.handleMobileSubscribe(ptyId, clientId, undefined) + if (closed || signal?.aborted) { + // Why: a disconnect can win the awaited subscribe and resurrect mobile presence after cleanup already released it. + // Unguarded on purpose: this must still fire when our own cleanup already ran. + // Safe only because lease-only passes no viewport and both !viewport paths in + // handleMobileSubscribeInternal return with no await, so no rebind can land + // first. Adding an await there — or passing a viewport here — makes a + // superseded handler delete the replacement's (ptyId, clientId) presence. + runtime.handleMobileUnsubscribe(ptyId, clientId) + if (!closed) { + registration.releaseIfCurrent() + } + return + } + emit({ type: 'subscribed', streamId: null, lines: [], truncated: false }) + await streamClosed + } catch (error) { + registration.releaseIfCurrent() + throw error + } +} + +export async function runTerminalJsonSubscription(args: TerminalSubscriptionArgs): Promise { + const { + params, + runtime, + connectionId, + signal, + emit, + ptyId, + clientId, + supportsDesktopViewportClaims + } = args + // Why: only unregister the width floor this subscription took (see the multiplex stream's registeredRemoteDesktopDriver note). + let registeredRemoteDesktopDriver = false + + // Why: a hidden watcher and a visible pane can subscribe to one terminal, so key by client so neither stream evicts the other. + const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal + const remoteDesktopSubscriptionKey = `json:${allocateTerminalSubscriptionStreamId()}` + let closed = false + let outputBatcher: TerminalOutputBatcher | null = null + let unsubscribeData = (): void => {} + let unsubscribeFit = (): void => {} + let stopWatchingLifetime = (): void => {} + let resolveStream = (): void => {} + const streamClosed = new Promise((resolve) => { + resolveStream = resolve + }) + // Why: register before viewport/snapshot awaits so a socket close can't orphan the stream listeners or its remote-desktop width floor. + const registration = runtime.registerOwnedSubscriptionCleanup( + subscriptionId, + () => { + stopWatchingLifetime() + closed = true + outputBatcher?.flush() + outputBatcher?.dispose() + unsubscribeData() + unsubscribeFit() + if (registeredRemoteDesktopDriver && clientId) { + runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) + } + emit({ type: 'end' }) + resolveStream() + }, + connectionId + ) + stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) + if (closed) { + // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. + return + } + try { + if (clientId && params.client && params.viewport) { + registeredRemoteDesktopDriver = true + await updateViewportForClient( + runtime, + ptyId, + remoteDesktopSubscriptionKey, + params.client, + params.viewport, + 'desktop', + 'register', + !supportsDesktopViewportClaims + ) + } + if (closed || signal?.aborted) { + registration.releaseIfCurrent() + return + } + const read = await runtime.readTerminal(params.terminal) + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) + if (closed || signal?.aborted) { + registration.releaseIfCurrent() + return + } + const size = runtime.getTerminalSize(ptyId) + const displayMode = runtime.getMobileDisplayMode(ptyId) + const seq = runtime.getLayout(ptyId)?.seq + emit({ + type: 'scrollback', + lines: read.tail, + truncated: isTerminalReadPayloadIncomplete(read), + serialized: serialized?.data, + oscLinks: serialized?.oscLinks, + cwd: serialized?.cwd, + // Why: an empty snapshot with no PTY size must still report the dims the fit + // will produce — dimless frames re-armed the mobile fit loop (STA-3337). + cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols, + rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows, + displayMode, + seq + }) + outputBatcher = createTerminalOutputBatcher((chunk) => { + emit({ type: 'data', chunk }) + }) + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => { + outputBatcher?.push(data) + }) + // Why: the legacy JSON stream can feed a live xterm view, so register as a view subscriber; worst case is a withheld model reply, safer than a double reply. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } + unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + outputBatcher?.flush() + const mode = + event.mode === 'mobile-fit' + ? event.mode + : (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ?? + 'desktop-fit') + emit({ + type: 'fit-override-changed', + mode, + cols: event.cols, + rows: event.rows + }) + }) + await streamClosed + } catch (error) { + registration.releaseIfCurrent() + throw error + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts new file mode 100644 index 00000000000..08639f16f48 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-binary.ts @@ -0,0 +1,286 @@ +import { + TerminalStreamOpcode, + encodeTerminalStreamFrame, + encodeTerminalStreamJson +} from '../../../../../shared/terminal-stream-protocol' +import { iterateTerminalOutputFrameChunks } from '../../terminal-output-frame-chunks' +import { + EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE, + scanTerminalReplyQuerySequences, + type TerminalReplyQueryScanState, + type TerminalReplyQuerySequence +} from '../../../../../shared/terminal-reply-query-scan' +import { TERMINAL_MULTIPLEX_PENDING_MAX_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' +import { measureTerminalStreamByteLength } from '../../terminal-stream-byte-length' +import { createTerminalOutputBatcher, type TerminalOutputBatcher } from './terminal-output-batcher' +import { watchSubscriptionLifetime } from './terminal-input-delivery' +import { trimPendingOutputToBudget } from './terminal-stream-replay' +import { allocateTerminalSubscriptionStreamId } from './terminal-subscription-stream-id' +import type { + LegacyBinarySubscriptionState, + TerminalSubscriptionArgs +} from './terminal-legacy-subscription-types' +import type { TerminalOutputChunk } from './terminal-stream-types' +import { publishLegacyBinaryInitialSnapshot } from './terminal-legacy-subscribe-snapshot' +import { activateLegacyBinarySubscription } from './terminal-legacy-subscribe-live' +import { registerLegacyBinaryControlFrames } from './terminal-legacy-binary-control-frames' +const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 +export async function runTerminalBinarySubscription(args: TerminalSubscriptionArgs): Promise { + const { params, runtime, connectionId, sendBinary, signal, emit, ptyId, clientId, isMobile } = + args + if (!sendBinary) { + throw new Error('binary_terminal_stream_required') + } + let registeredRemoteDesktopDriver = false + const streamId = allocateTerminalSubscriptionStreamId() + const remoteDesktopSubscriptionKey = `stream:${streamId}` + let cursor = 0 + let closed = false + let buffering = true + let pendingRemoteDesktopViewport: { cols: number; rows: number } | null = null + let lastResizeCols: number | undefined + let resizeGeneration = 0 + let pendingOutput: TerminalOutputChunk[] = [] + let desktopClaimTail: Promise = Promise.resolve(true) + let pendingOutputBytes = 0 + let pendingOutputOverflowed = false + let pendingQueryScanState: TerminalReplyQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + const pendingQuerySequences: TerminalReplyQuerySequence[] = [] + let pendingQueryChars = 0 + let pendingQueryOverflowed = false + let unsubscribeData = (): void => {} + let unsubscribeResize = (): void => {} + let unsubscribeFit = (): void => {} + let unregisterBinaryHandler = (): void => {} + let abortRendererMountWait = (): void => {} + let stopWatchingLifetime = (): void => {} + let lateRendererReadyPromise: Promise | null = null + let outputBatcher: TerminalOutputBatcher | null = null + let resolveStream = (): void => {} + const streamClosed = new Promise((resolve) => { + resolveStream = resolve + }) + // Why: register cleanup before any await so a mid-subscribe disconnect still removes mobile presence; client-scoped ids also allow parallel desktop subscribers. + const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal + const registration = runtime.registerOwnedSubscriptionCleanup( + subscriptionId, + () => { + stopWatchingLifetime() + outputBatcher?.flush() + outputBatcher?.dispose() + closed = true + unsubscribeData() + unsubscribeResize() + unsubscribeFit() + unregisterBinaryHandler() + abortRendererMountWait() + if (isMobile && clientId) { + runtime.handleMobileUnsubscribe(ptyId, clientId) + } else if (registeredRemoteDesktopDriver && clientId) { + runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) + } + emit({ type: 'end' }) + resolveStream() + }, + connectionId + ) + stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) + if (closed) { + // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. + return + } + const sendFrame = ( + opcode: TerminalStreamOpcode, + payload: Uint8Array = new Uint8Array(), + frameSeq = cursor++ + ): void => { + if (closed || !sendBinary) { + return + } + sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: frameSeq, payload })) + } + outputBatcher = createTerminalOutputBatcher((data, meta) => { + if (meta?.cwd !== undefined) { + sendFrame( + TerminalStreamOpcode.Metadata, + encodeTerminalStreamJson({ cwd: meta.cwd }), + meta.seq + ) + } + for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { + sendFrame(chunk.opcode ?? TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) + } + }) + unregisterBinaryHandler = registerLegacyBinaryControlFrames( + args, + streamId, + remoteDesktopSubscriptionKey, + { + isClosed: () => closed, + isBuffering: () => buffering, + setRegisteredRemoteDesktopDriver: () => { + registeredRemoteDesktopDriver = true + }, + setPendingRemoteDesktopViewport: (viewport) => { + pendingRemoteDesktopViewport = viewport + }, + getDesktopClaimTail: () => desktopClaimTail, + setDesktopClaimTail: (tail) => { + desktopClaimTail = tail + }, + sendFrame + } + ) + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + if (closed) { + return + } + if (buffering) { + const rawLength = meta?.rawLength + if ( + typeof meta?.seq === 'number' && + typeof rawLength === 'number' && + rawLength === data.length + ) { + const scan = scanTerminalReplyQuerySequences( + data, + meta.seq - rawLength, + pendingQueryScanState + ) + pendingQueryScanState = scan.state + for (const query of scan.queries) { + if (pendingQueryChars + query.data.length > TERMINAL_QUERY_REPLAY_MAX_CHARS) { + pendingQueryOverflowed = true + break + } + pendingQuerySequences.push(query) + pendingQueryChars += query.data.length + } + } else { + pendingQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + } + const remainingBudget = Math.max(1, TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes) + const measurement = measureTerminalStreamByteLength(data, { + stopAfterBytes: remainingBudget + }) + pendingOutput.push({ data, bytes: measurement.byteLength, meta }) + pendingOutputBytes += measurement.byteLength + const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) + pendingOutputBytes = trimmed.bytes + pendingOutputOverflowed ||= trimmed.overflowed + return + } + outputBatcher?.push(data, meta) + }) + // Why: capture live bytes before mobile-fit awaits; registering presence first would suppress main while no view held the query. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } + const state: LegacyBinarySubscriptionState = { + streamId, + remoteDesktopSubscriptionKey, + get closed() { + return closed + }, + set closed(value) { + closed = value + }, + get buffering() { + return buffering + }, + set buffering(value) { + buffering = value + }, + get pendingRemoteDesktopViewport() { + return pendingRemoteDesktopViewport + }, + set pendingRemoteDesktopViewport(value) { + pendingRemoteDesktopViewport = value + }, + get lastResizeCols() { + return lastResizeCols + }, + set lastResizeCols(value) { + lastResizeCols = value + }, + get resizeGeneration() { + return resizeGeneration + }, + set resizeGeneration(value) { + resizeGeneration = value + }, + get pendingOutput() { + return pendingOutput + }, + set pendingOutput(value) { + pendingOutput = value + }, + get pendingOutputBytes() { + return pendingOutputBytes + }, + set pendingOutputBytes(value) { + pendingOutputBytes = value + }, + get pendingOutputOverflowed() { + return pendingOutputOverflowed + }, + set pendingOutputOverflowed(value) { + pendingOutputOverflowed = value + }, + pendingQuerySequences, + get pendingQueryOverflowed() { + return pendingQueryOverflowed + }, + set pendingQueryOverflowed(value) { + pendingQueryOverflowed = value + }, + get lateRendererReadyPromise() { + return lateRendererReadyPromise + }, + set lateRendererReadyPromise(value) { + lateRendererReadyPromise = value + }, + get abortRendererMountWait() { + return abortRendererMountWait + }, + set abortRendererMountWait(value) { + abortRendererMountWait = value + }, + outputBatcher: outputBatcher!, + get unsubscribeResize() { + return unsubscribeResize + }, + set unsubscribeResize(value) { + unsubscribeResize = value + }, + get unsubscribeFit() { + return unsubscribeFit + }, + set unsubscribeFit(value) { + unsubscribeFit = value + }, + get registeredRemoteDesktopDriver() { + return registeredRemoteDesktopDriver + }, + set registeredRemoteDesktopDriver(value) { + registeredRemoteDesktopDriver = value + }, + displayMode: 'auto', + registration, + streamClosed, + sendFrame + } + try { + await publishLegacyBinaryInitialSnapshot(args, state) + if (state.closed || signal?.aborted) { + return + } + activateLegacyBinarySubscription(args, state) + } catch (error) { + registration.releaseIfCurrent() + throw error + } + await streamClosed +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts new file mode 100644 index 00000000000..73a359d73af --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-live.ts @@ -0,0 +1,158 @@ +import { + TerminalStreamOpcode, + encodeTerminalStreamJson +} from '../../../../../shared/terminal-stream-protocol' +import { + sendMobileResizeRestream, + sendSnapshotFrames, + serializeStableMobileRendererSnapshot +} from './terminal-snapshot-publication' +import { updateViewportForClient } from './terminal-viewport-update' +import type { + LegacyBinarySubscriptionState, + TerminalSubscriptionArgs +} from './terminal-legacy-subscription-types' + +export function activateLegacyBinarySubscription( + args: TerminalSubscriptionArgs, + state: LegacyBinarySubscriptionState +): void { + const { params, runtime, emit, ptyId, clientId, isMobile, supportsDesktopViewportClaims } = args + const lateRendererReady = state.lateRendererReadyPromise + state.lateRendererReadyPromise = null + if (lateRendererReady) { + void lateRendererReady + .then(async (rendererReady) => { + if (!rendererReady || state.closed) { + return + } + state.outputBatcher?.flush() + const recovery = await serializeStableMobileRendererSnapshot(runtime, ptyId) + if (state.closed) { + return + } + if (!recovery?.data.length) { + return + } + // Why: late recovery has no buffered-output gate, so only an exact renderer high-water may reset mobile without erasing live bytes. + if (recovery.seq !== runtime.getPtyOutputSequence(ptyId)) { + return + } + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, recovery) + // Why: shipped mobile clients apply resized snapshots in place, so a blank xterm recovers without resubscribe. + const recoveryStats = sendSnapshotFrames(state.sendFrame, { + kind: 'resized', + cols: recovery.cols, + rows: recovery.rows, + displayMode: state.displayMode, + reason: 'renderer-mount-ready', + source: recovery.source, + truncated: false, + truncatedByByteBudget: recovery.truncatedByByteBudget, + data: recovery.data + }) + state.lastResizeCols = recovery.cols + console.log('[mobile-terminal-stream] recovery snapshot', { + terminal: params.terminal, + streamId: state.streamId, + reason: 'renderer-mount-ready', + bytes: recoveryStats.bytes, + chunks: recoveryStats.chunks, + scrollbackRows: recovery.scrollbackRows, + truncatedByByteBudget: recovery.truncatedByByteBudget === true + }) + }) + .catch(() => {}) + } + const sendResizedFrame = (event: { + cols: number + rows: number + displayMode: string + reason: string + seq?: number + }): void => { + state.lastResizeCols = event.cols + state.sendFrame( + TerminalStreamOpcode.Resized, + encodeTerminalStreamJson({ + cols: event.cols, + rows: event.rows, + displayMode: event.displayMode, + reason: event.reason, + seq: event.seq + }) + ) + } + state.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { + state.outputBatcher?.flush() + const eventGeneration = state.resizeGeneration + 1 + state.resizeGeneration = eventGeneration + // Why: xterm only re-wraps soft-wrapped lines, so a width change needs a full re-serialize+replay to rewrap restored hard-wrapped scrollback. + const widthChanged = isMobile && event.cols !== state.lastResizeCols + if (widthChanged) { + state.lastResizeCols = event.cols + void sendMobileResizeRestream( + runtime, + ptyId, + state.sendFrame, + event, + () => !state.closed && state.resizeGeneration === eventGeneration + ) + .then((restreamed) => { + if (state.closed || state.resizeGeneration !== eventGeneration) { + return + } + if (!restreamed) { + sendResizedFrame(event) + } + }) + // Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize. + .catch(() => { + if (state.closed || state.resizeGeneration !== eventGeneration) { + return + } + sendResizedFrame(event) + }) + return + } + sendResizedFrame(event) + }) + + // Install the resize listener before draining the parked viewport, since applyLayout emits synchronously. + if ( + clientId && + params.client && + state.registeredRemoteDesktopDriver && + state.pendingRemoteDesktopViewport + ) { + const viewport = state.pendingRemoteDesktopViewport + state.pendingRemoteDesktopViewport = null + void updateViewportForClient( + runtime, + ptyId, + state.remoteDesktopSubscriptionKey, + params.client, + viewport, + 'desktop', + 'register', + !supportsDesktopViewportClaims + ).catch(() => {}) + } + + // Legacy fit-override-changed for non-mobile (desktop) subscribers + state.unsubscribeFit = !isMobile + ? runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + const mode = + event.mode === 'mobile-fit' + ? event.mode + : (runtime.getRemoteDesktopFitHold?.(ptyId, state.remoteDesktopSubscriptionKey).mode ?? + 'desktop-fit') + emit({ + type: 'fit-override-changed', + mode, + cols: event.cols, + rows: event.rows + }) + }) + : () => {} +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts new file mode 100644 index 00000000000..fcae3d02f6f --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts @@ -0,0 +1,265 @@ +import { + sendSnapshotFrames, + serializeBudgetedMobileSnapshot, + serializeStableMobileRendererSnapshot +} from './terminal-snapshot-publication' +import { + getOutputAfterSnapshotSeq, + isTerminalReadPayloadIncomplete, + stripSnapshotBoundaryQuerySuffixes, + trimPendingOutputCoveredBySnapshot +} from './terminal-stream-replay' +import type { + LegacyBinarySubscriptionState, + TerminalSubscriptionArgs +} from './terminal-legacy-subscription-types' + +const MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS = 3_000 + +export async function publishLegacyBinaryInitialSnapshot( + args: TerminalSubscriptionArgs, + state: LegacyBinarySubscriptionState +): Promise { + const { + params, + runtime, + signal, + sendBinary, + emit, + ptyId, + clientId, + isMobile, + missingHeadlessStateBeforeMobileFit, + rendererMountRequestedBeforePty, + serializerGenerationBeforeMobileFit + } = args + if (isMobile && clientId) { + await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) + } else if (clientId && params.viewport) { + // Why: legacy subscribe records geometry without taking ownership; only an explicit activity/claim frame may suppress the host. + state.registeredRemoteDesktopDriver = true + state.pendingRemoteDesktopViewport = params.viewport + } + if (state.closed) { + return + } + + let read = await runtime.readTerminal(params.terminal) + let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (state.closed) { + return + } + // Why: missing model state (not blank snapshot text) signals a never-attached PTY; a renderer-sourced snapshot already proves attachment, so skip the remount. + const mountRequested = + missingHeadlessStateBeforeMobileFit && + serialized?.source !== 'renderer' && + (rendererMountRequestedBeforePty || runtime.requestRendererTerminalTabMount(params.terminal)) + if (missingHeadlessStateBeforeMobileFit && mountRequested) { + // Why: an idle legacy PTY emits no later byte, so wait for a settle proving this remount completed before replaying its screen. + const mountWaitController = new AbortController() + const abortMountWait = (): void => mountWaitController.abort() + state.abortRendererMountWait = abortMountWait + if (signal?.aborted) { + abortMountWait() + } else { + signal?.addEventListener('abort', abortMountWait, { once: true }) + } + const rendererReadyPromise = runtime + .waitForRendererTerminalSerializer( + ptyId, + serializerGenerationBeforeMobileFit, + undefined, + mountWaitController.signal + ) + .catch(() => false) + const finishMountWait = (): void => { + signal?.removeEventListener('abort', abortMountWait) + if (state.abortRendererMountWait === abortMountWait) { + state.abortRendererMountWait = () => {} + } + } + void rendererReadyPromise.then(finishMountWait, finishMountWait) + let deadlineTimer: ReturnType | null = null + const initialDeadline = new Promise((resolve) => { + deadlineTimer = setTimeout(() => resolve(false), MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS) + if (typeof deadlineTimer.unref === 'function') { + deadlineTimer.unref() + } + }) + const rendererReady = await Promise.race([rendererReadyPromise, initialDeadline]) + if (deadlineTimer) { + clearTimeout(deadlineTimer) + } + if (state.closed || signal?.aborted) { + return + } + if (rendererReady) { + read = await runtime.readTerminal(params.terminal) + const stableRendererSnapshot = await serializeStableMobileRendererSnapshot(runtime, ptyId) + if (state.closed) { + return + } + if (stableRendererSnapshot?.data.length) { + serialized = stableRendererSnapshot + const trailingOutput = state.pendingOutput.flatMap((item) => { + const output = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq) + const seq = item.meta?.seq + return output && typeof seq === 'number' ? [{ data: output.data, seq }] : [] + }) + runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery( + ptyId, + stableRendererSnapshot, + trailingOutput + ) + } + } else { + // Why: a renderer can settle after the bounded initial response; keep observing so an idle PTY self-heals without bytes. + state.lateRendererReadyPromise = rendererReadyPromise + } + } + let initialOutputOverflowed = false + if (state.pendingOutputOverflowed) { + state.pendingOutput.splice(0) + state.pendingOutputBytes = 0 + state.pendingOutputOverflowed = false + read = await runtime.readTerminal(params.terminal) + serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (state.closed) { + return + } + if (state.pendingOutputOverflowed) { + initialOutputOverflowed = true + state.pendingOutput.splice(0) + state.pendingOutputBytes = 0 + state.pendingOutputOverflowed = false + } + } + const size = runtime.getTerminalSize(ptyId) + state.displayMode = runtime.getMobileDisplayMode(ptyId) + // Why: layout seq is the mobile stale-event filter's high-water mark (undefined pre-transition is fail-open). See docs/mobile-terminal-layout-state-machine.md. + const layoutSeq = runtime.getLayout(ptyId)?.seq + // Why: only an output offset can cover buffered chunks; layout versions are a separate sequence domain. + let snapshotOutputSeq = serialized?.seq + emit({ + type: 'subscribed', + streamId: state.streamId, + lines: read.tail, + truncated: initialOutputOverflowed || (!sendBinary && isTerminalReadPayloadIncomplete(read)), + cols: serialized?.cols ?? size?.cols, + rows: serialized?.rows ?? size?.rows, + displayMode: state.displayMode, + seq: layoutSeq + }) + const snapshotStats = sendSnapshotFrames(state.sendFrame, { + kind: 'scrollback', + // Why: prefer the subscriber's viewport over the 80x24 stopgap when the PTY has + // no size yet — the mismatch made mobile burn its resubscribe budget (STA-3337). + cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows ?? 24, + displayMode: state.displayMode, + seq: snapshotOutputSeq, + cwd: serialized?.cwd, + truncated: initialOutputOverflowed, + truncatedByByteBudget: serialized?.truncatedByByteBudget, + oscLinks: serialized?.oscLinks, + data: serialized?.data ?? '' + }) + console.log('[mobile-terminal-stream] snapshot', { + terminal: params.terminal, + streamId: state.streamId, + kind: 'scrollback', + bytes: snapshotStats.bytes, + chunks: snapshotStats.chunks, + scrollbackRows: serialized?.scrollbackRows, + truncatedByByteBudget: serialized?.truncatedByByteBudget === true + }) + // Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay. + state.lastResizeCols = serialized?.cols ?? size?.cols + let recoveryAttempts = 0 + // Why: if the bounded pre-subscribe tail overflowed, only a fresh model snapshot covers the dropped middle without replay gaps. + while (state.pendingOutputOverflowed && recoveryAttempts < 2) { + state.pendingOutputOverflowed = false + recoveryAttempts += 1 + const recovery = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (state.closed) { + return + } + if (!recovery) { + break + } + // Why: without an output seq (renderer fallback) covered chunks can't be trimmed exactly, so keep the bounded replay over an unverifiable snapshot. + if (typeof recovery.seq !== 'number') { + break + } + // Why: clients drop a repeat scrollback snapshot but apply 'resized' inline; omit seq so output-byte seqs don't pollute the layout-seq filter. + const recoveryStats = sendSnapshotFrames(state.sendFrame, { + kind: 'resized', + cols: recovery.cols, + rows: recovery.rows, + displayMode: state.displayMode, + reason: 'pending-output-overflow', + source: recovery.source, + truncated: false, + truncatedByByteBudget: recovery.truncatedByByteBudget, + data: recovery.data + }) + console.log('[mobile-terminal-stream] recovery snapshot', { + terminal: params.terminal, + streamId: state.streamId, + reason: 'pending-output-overflow', + bytes: recoveryStats.bytes, + chunks: recoveryStats.chunks, + scrollbackRows: recovery.scrollbackRows, + truncatedByByteBudget: recovery.truncatedByByteBudget === true + }) + const trimmed = trimPendingOutputCoveredBySnapshot(state.pendingOutput, recovery.seq) + state.pendingOutput = trimmed.chunks + state.pendingOutputBytes = trimmed.bytes + snapshotOutputSeq = recovery.seq + } + state.buffering = false + const bufferedOutput = state.pendingOutput.splice(0) + const queryReplayData = state.pendingQueryOverflowed + ? '' + : state.pendingQuerySequences + .filter( + (query) => + initialOutputOverflowed || + (typeof snapshotOutputSeq === 'number' && query.startSeq < snapshotOutputSeq) + ) + .map((query) => query.data) + .join('') + if (queryReplayData) { + // Why: snapshots omit control queries but their seq trims the live chunk; replay the post-snapshot query so the mobile xterm answers once. + state.outputBatcher.push(queryReplayData) + } + if (!initialOutputOverflowed) { + for (const item of bufferedOutput) { + const uncovered = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) + let uncoveredData = uncovered?.data ?? null + let uncoveredMeta = uncovered?.meta + if ( + uncoveredData && + uncoveredData !== item.data && + typeof snapshotOutputSeq === 'number' && + typeof item.meta?.seq === 'number' && + typeof item.meta.rawLength === 'number' + ) { + if (item.meta.rawLength === item.data.length) { + uncoveredMeta = { ...item.meta, rawLength: uncoveredData.length } + } + uncoveredData = stripSnapshotBoundaryQuerySuffixes( + uncoveredData, + snapshotOutputSeq, + snapshotOutputSeq, + state.pendingQuerySequences + ) + } + if (uncoveredData) { + state.outputBatcher.push(uncoveredData, uncoveredMeta) + } + } + } + state.pendingOutputBytes = 0 + state.outputBatcher.flush() +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts new file mode 100644 index 00000000000..d9eb624f0e1 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts @@ -0,0 +1,58 @@ +import type { z } from 'zod' +import type { RpcContext } from '../../core' +import type { SubscriptionRegistration } from '../../../orca-runtime' +import type { TerminalReplyQuerySequence } from '../../../../../shared/terminal-reply-query-scan' +import type { TerminalOutputChunk } from './terminal-stream-types' +import type { TerminalSubscribe } from './stream-schemas' +import type { TerminalOutputBatcher } from './terminal-output-batcher' +import type { TerminalStreamOpcode } from '../../../../../shared/terminal-stream-protocol' + +export type TerminalSubscribeParams = z.infer +export type TerminalSubscriptionEmit = (result: unknown) => void + +export type TerminalSubscriptionArgs = { + params: TerminalSubscribeParams + runtime: RpcContext['runtime'] + connectionId: RpcContext['connectionId'] + sendBinary: RpcContext['sendBinary'] + registerBinaryStreamHandler: RpcContext['registerBinaryStreamHandler'] + signal: RpcContext['signal'] + emit: TerminalSubscriptionEmit + ptyId: string + clientId: string | undefined + isMobile: boolean + supportsDesktopViewportClaims: boolean + supportsWriteUnavailable: boolean + missingHeadlessStateBeforeMobileFit: boolean + rendererMountRequestedBeforePty: boolean + serializerGenerationBeforeMobileFit: number +} + +export type LegacyBinarySubscriptionState = { + readonly streamId: number + readonly remoteDesktopSubscriptionKey: string + closed: boolean + buffering: boolean + pendingRemoteDesktopViewport: { cols: number; rows: number } | null + lastResizeCols: number | undefined + resizeGeneration: number + pendingOutput: TerminalOutputChunk[] + pendingOutputBytes: number + pendingOutputOverflowed: boolean + readonly pendingQuerySequences: TerminalReplyQuerySequence[] + pendingQueryOverflowed: boolean + lateRendererReadyPromise: Promise | null + abortRendererMountWait: () => void + outputBatcher: TerminalOutputBatcher + unsubscribeResize: () => void + unsubscribeFit: () => void + registeredRemoteDesktopDriver: boolean + displayMode: string + readonly registration: SubscriptionRegistration + readonly streamClosed: Promise + readonly sendFrame: ( + opcode: TerminalStreamOpcode, + payload?: Uint8Array, + frameSeq?: number + ) => void +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts new file mode 100644 index 00000000000..37c4cda51b4 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -0,0 +1,178 @@ +import { defineMethod, type RpcAnyMethod } from '../../core' +import { + navigationTargetsHost, + resolveRuntimeNavigationTarget +} from '../../../../../shared/runtime-navigation' +import { withTerminalCloseAttribution } from '../../terminal-close-attribution' +import { + AgentTeamsPrepareLaunch, + AgentTeamsTmuxCompat, + TerminalCreateParams, + TerminalFocus, + TerminalHandle, + TerminalSleep, + TerminalSplit, + TerminalStop, + TerminalStopExact, + TerminalWait +} from './unary-schemas' +import { TerminalResizeForClient } from './stream-schemas' + +export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'terminal.wait', + params: TerminalWait, + handler: async (params, { runtime, signal }) => ({ + wait: await runtime.waitForTerminal(params.terminal, { + condition: params.for, + timeoutMs: params.timeoutMs, + signal + }) + }) + }), + defineMethod({ + name: 'terminal.create', + params: TerminalCreateParams, + handler: async (params, { runtime, pairedDeviceId, clientId }) => ({ + terminal: await runtime.dedupeTerminalCreate( + pairedDeviceId ?? clientId ?? 'local', + params.worktree, + params.clientMutationId, + params.reconcileExisting === true, + (canonicalWorktreeSelector, preAllocatedHandle) => + runtime.createTerminal(canonicalWorktreeSelector, { + command: params.command, + startupCommandDelivery: params.startupCommandDelivery, + env: params.env, + envToDelete: params.envToDelete, + ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), + ...(params.resumeProviderSession + ? { resumeProviderSession: params.resumeProviderSession } + : {}), + ...(params.launchToken ? { launchToken: params.launchToken } : {}), + ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.terminalColorQueryReplies + ? { terminalColorQueryReplies: params.terminalColorQueryReplies } + : {}), + title: params.title, + focus: params.focus === true, + rendererBacked: params.rendererBacked === true, + activate: params.activate === true, + presentation: params.presentation, + tabId: params.tabId, + leafId: params.leafId, + ...(preAllocatedHandle ? { preAllocatedHandle } : {}) + }) + ) + }) + }), + defineMethod({ + name: 'terminal.split', + params: TerminalSplit, + handler: async (params, { runtime }) => ({ + split: await runtime.splitTerminal(params.terminal, { + direction: params.direction, + command: params.command, + env: params.env, + telemetrySource: params.telemetrySource + }) + }) + }), + defineMethod({ + name: 'terminal.stop', + params: TerminalStop, + handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree) + }), + defineMethod({ + name: 'terminal.sleep', + params: TerminalSleep, + handler: async (params, { runtime }) => runtime.sleepTerminalsForWorktree(params.worktree) + }), + defineMethod({ + name: 'terminal.stopExact', + params: TerminalStopExact, + handler: async (params, { runtime }) => + runtime.stopExactTerminalsForWorktree(params.worktree, params.expectedPtyIds, { + keepHistory: params.keepHistory, + targetOnly: params.targetOnly + }) + }), + defineMethod({ + name: 'terminal.resizeForClient', + params: TerminalResizeForClient, + handler: async (params, { runtime }) => { + // Why: a stale handle must fail with terminal_handle_stale, not resize the wrong PTY (#7718). + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + const result = await runtime.resizeForClient( + leaf.ptyId, + params.mode, + params.clientId, + params.mode === 'mobile-fit' ? params.cols : undefined, + params.mode === 'mobile-fit' ? params.rows : undefined + ) + return { + terminal: { + handle: params.terminal, + ...result + } + } + } + }), + defineMethod({ + name: 'terminal.focus', + params: TerminalFocus, + handler: async (params, { runtime, clientKind }) => ({ + focus: await runtime.focusTerminal(params.terminal, { + navigateHost: navigationTargetsHost( + resolveRuntimeNavigationTarget({ navigation: params.navigation, clientKind }) + ) + }) + }) + }), + defineMethod({ + name: 'terminal.close', + params: TerminalHandle, + handler: async (params, context) => ({ + close: await withTerminalCloseAttribution( + 'terminal.close', + context, + 'terminal', + params.terminal, + () => context.runtime.closeTerminal(params.terminal) + ) + }) + }), + defineMethod({ + name: 'terminal.closeTab', + params: TerminalHandle, + handler: async (params, context) => ({ + close: await withTerminalCloseAttribution( + 'terminal.closeTab', + context, + 'terminal-tab', + params.terminal, + () => context.runtime.closeTerminalTab(params.terminal) + ) + }) + }), + defineMethod({ + name: 'agentTeams.tmuxCompat', + params: AgentTeamsTmuxCompat, + handler: async (params, { runtime }) => ({ + tmux: await runtime.handleAgentTeamsTmuxCompat(params) + }) + }), + defineMethod({ + name: 'agentTeams.prepareLaunch', + params: AgentTeamsPrepareLaunch, + handler: async (params, { runtime }) => ({ + launch: await runtime.prepareClaudeAgentTeamsLeader({ + paneKey: params.paneKey, + baseEnv: params.env + }) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-cleanup.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-cleanup.ts new file mode 100644 index 00000000000..924975d34b6 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-cleanup.ts @@ -0,0 +1,100 @@ +import type { + TerminalMultiplexCleanupStage, + TerminalMultiplexConnection, + TerminalMultiplexFlowControlStage +} from './terminal-multiplex-connection' + +export function installMultiplexCleanup( + build: TerminalMultiplexFlowControlStage +): asserts build is TerminalMultiplexCleanupStage { + const state = build as TerminalMultiplexConnection + const { runtime, streams, pendingPtyWaitControllers, emit, signal } = state + state.detachStream = ( + streamId: number, + emitEnd: boolean, + releaseRemoteDesktopDriver = true + ): void => { + const stream = streams.get(streamId) + if (!stream) { + return + } + const replacement = stream.sourceRangeReplacement + stream.sourceRangeReplacement = null + if (replacement) { + runtime.rollbackRemoteTerminalSourceRangeReplacement( + replacement, + 'stream-detached-replacement-aborted' + ) + } + stream.outputBatcher.flush() + stream.outputBatcher.dispose() + state.detachSourceRangeConsumer(stream, 'stream-detached') + state.ackTotalInFlightBytes = Math.max(0, state.ackTotalInFlightBytes - stream.ackInFlightBytes) + stream.ackInFlightBytes = 0 + stream.ackPendingOutput = [] + stream.ackPendingOutputBytes = 0 + stream.ackPendingOutputOverflowed = false + stream.ackRecoverySnapshotInFlight = false + stream.unsubscribeData() + stream.unsubscribeResize() + stream.unsubscribeFit() + stream.unsubscribeDriver() + stream.unregisterBinaryHandler() + streams.delete(streamId) + state.flushAllAckPendingOutput() + // Why: release the runtime exit-waiter for this slot (see the field's note); delete before abort so its .catch no-ops instead of re-detaching. + stream.exitWaiterAbort.abort() + if (stream.isMobile && stream.client?.id) { + runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id) + } else if ( + releaseRemoteDesktopDriver && + stream.registeredRemoteDesktopDriver && + stream.client?.id + ) { + // Why: release the width floor only if THIS stream took it, so a passive stream can't release a peer's floor. + runtime.unregisterRemoteDesktopViewer(stream.ptyId, stream.remoteDesktopSubscriptionKey) + } + if (emitEnd) { + emit({ type: 'end', streamId }) + } + } + state.cancelPendingPtyWaits = (streamId: number): void => { + const controllers = pendingPtyWaitControllers.get(streamId) + if (!controllers) { + return + } + pendingPtyWaitControllers.delete(streamId) + for (const controller of controllers) { + controller.abort() + } + } + state.cancelAllPendingPtyWaits = (): void => { + for (const streamId of Array.from(pendingPtyWaitControllers.keys())) { + state.cancelPendingPtyWaits(streamId) + } + } + state.closeMultiplex = (): void => { + if (state.closed) { + return + } + state.closed = true + signal?.removeEventListener('abort', state.cancelAllPendingPtyWaits) + state.cancelAllPendingPtyWaits() + const remoteDesktopKeysByPty = new Map() + for (const streamId of Array.from(streams.keys())) { + const stream = streams.get(streamId) + if (stream?.registeredRemoteDesktopDriver && !stream.isMobile && stream.client?.id) { + const keys = remoteDesktopKeysByPty.get(stream.ptyId) ?? [] + keys.push(stream.remoteDesktopSubscriptionKey) + remoteDesktopKeysByPty.set(stream.ptyId, keys) + } + state.detachStream(streamId, false, false) + } + // Why: one connection can own many panes on the same PTY; remove floors together so close scans each registry once. + for (const [ptyId, subscriptionKeys] of remoteDesktopKeysByPty) { + void runtime.unregisterRemoteDesktopViewers(ptyId, subscriptionKeys) + } + state.unregisterControlHandler() + state.resolveMultiplex() + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts new file mode 100644 index 00000000000..67d68602b62 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-connection.ts @@ -0,0 +1,106 @@ +import type { z } from 'zod' +import type { RpcContext } from '../../core' +import type { + TerminalStreamFrame, + TerminalStreamOpcode +} from '../../../../../shared/terminal-stream-protocol' +import type { TerminalOutputFrameChunk } from '../../terminal-output-frame-chunks' +import type { + TerminalMultiplexSnapshotRequestFrame, + TerminalMultiplexSubscribeFrame +} from './stream-schemas' +import type { TerminalMultiplexStream } from './terminal-stream-types' +import type { TerminalSourceRangeRegistry } from '../../terminal-source-range-registry' + +export type MultiplexSubscribeRequest = z.infer +export type MultiplexSnapshotRequest = z.infer +export type MultiplexEmit = (result: unknown) => void + +export type TerminalMultiplexConnectionBase = { + runtime: RpcContext['runtime'] + connectionId: string + sendBinary: NonNullable + registerBinaryStreamHandler: NonNullable + signal: RpcContext['signal'] + emit: MultiplexEmit + closed: boolean + cursor: number + streams: Map + sourceRangeRegistry: TerminalSourceRangeRegistry + pendingPtyWaitControllers: Map> + ackTotalInFlightBytes: number + ackTotalWindowBytes: number + ackFlushCursorStreamId: number | null + resolveMultiplex: () => void + multiplexClosed: Promise + unregisterControlHandler: () => void +} + +export type TerminalMultiplexFrameDelivery = { + sendFrame: ( + streamId: number, + opcode: TerminalStreamOpcode, + payload?: Uint8Array, + frameSeq?: number, + onRejected?: () => void + ) => boolean + sendStreamError: (streamId: number, message: string) => void + notifyStreamWriteUnavailable: ( + stream: TerminalMultiplexStream, + outcome: 'delivered' | 'rejected' | 'failed' + ) => void + sendResizedFrame: ( + stream: TerminalMultiplexStream, + event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } + ) => void + canSendAckGatedOutput: (stream: TerminalMultiplexStream, bytes: number) => boolean + sendAckGatedOutput: (stream: TerminalMultiplexStream, chunk: TerminalOutputFrameChunk) => boolean + queueOrSendOutput: (stream: TerminalMultiplexStream, chunk: TerminalOutputFrameChunk) => void +} + +export type TerminalMultiplexFlowControl = { + sendAckRecoverySnapshot: (stream: TerminalMultiplexStream) => Promise + flushAckPendingOutput: (stream: TerminalMultiplexStream, maxChunks?: number) => number + flushAllAckPendingOutput: () => void + acknowledgeOutput: (stream: TerminalMultiplexStream, bytes: number) => void + acknowledgeSourceRanges: ( + stream: TerminalMultiplexStream, + streamGeneration: string, + ackedEndByte: number + ) => void + detachSourceRangeConsumer: (stream: TerminalMultiplexStream, reason: string) => void +} + +export type TerminalMultiplexCleanup = { + detachStream: (streamId: number, emitEnd: boolean, releaseRemoteDesktopDriver?: boolean) => void + cancelPendingPtyWaits: (streamId: number) => void + cancelAllPendingPtyWaits: () => void + closeMultiplex: () => void +} + +export type TerminalMultiplexSlotFrames = { + handleSlotFrame: (stream: TerminalMultiplexStream, frame: TerminalStreamFrame) => void + sendRequestedSnapshot: ( + stream: TerminalMultiplexStream, + request: MultiplexSnapshotRequest + ) => Promise +} + +export type TerminalMultiplexSubscribeFrame = { + handleSubscribeFrame: (payload: Uint8Array) => Promise +} + +export type TerminalMultiplexFrameDeliveryStage = TerminalMultiplexConnectionBase & + TerminalMultiplexFrameDelivery + +export type TerminalMultiplexFlowControlStage = TerminalMultiplexFrameDeliveryStage & + TerminalMultiplexFlowControl + +export type TerminalMultiplexCleanupStage = TerminalMultiplexFlowControlStage & + TerminalMultiplexCleanup + +export type TerminalMultiplexSlotFramesStage = TerminalMultiplexCleanupStage & + TerminalMultiplexSlotFrames + +export type TerminalMultiplexConnection = TerminalMultiplexSlotFramesStage & + TerminalMultiplexSubscribeFrame diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts new file mode 100644 index 00000000000..a469b2d7a00 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control.ts @@ -0,0 +1,264 @@ +import { + TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES, + TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES +} from '../../../../../shared/terminal-multiplex-flow-control' +import { drainTerminalMultiplexRoundRobin } from '../../terminal-multiplex-round-robin' +import { + sendSnapshotFrames, + serializeBudgetedRequestedSnapshot +} from './terminal-snapshot-publication' +import type { + TerminalMultiplexConnection, + TerminalMultiplexFlowControlStage, + TerminalMultiplexFrameDeliveryStage +} from './terminal-multiplex-connection' +import type { TerminalMultiplexStream } from './terminal-stream-types' +import type { RemoteTerminalSourceRangeReplacementReservation } from '../../../remote-terminal-source-range-consumer' + +export function installMultiplexFlowControl( + build: TerminalMultiplexFrameDeliveryStage +): asserts build is TerminalMultiplexFlowControlStage { + const state = build as TerminalMultiplexConnection + const { runtime, streams } = state + state.sendAckRecoverySnapshot = async (stream: TerminalMultiplexStream): Promise => { + if ( + state.closed || + streams.get(stream.streamId) !== stream || + stream.outputPaused || + stream.ackRecoverySnapshotInFlight + ) { + return + } + stream.ackRecoverySnapshotInFlight = true + let replacement: RemoteTerminalSourceRangeReplacementReservation | null = null + try { + const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0) + if (state.closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { + return + } + if (!serialized) { + throw new Error('Remote terminal recovery snapshot unavailable.') + } + if ( + stream.ackOutputSourceRanges && + (serialized.source === undefined || typeof serialized.seq !== 'number') + ) { + throw new Error('Remote terminal recovery snapshot source identity unavailable.') + } + if ( + stream.ackOutputSourceRanges && + serialized.source !== undefined && + typeof serialized.seq === 'number' + ) { + replacement = runtime.reserveRemoteTerminalSourceRangeReplacement( + { + ptyId: stream.ptyId, + consumerId: stream.remoteDesktopSubscriptionKey, + streamGeneration: stream.streamGeneration + }, + serialized.seq, + 'ack-pending-overflow' + ) + stream.sourceRangeReplacement = replacement + } + const displayMode = runtime.getMobileDisplayMode(stream.ptyId) + const publication = sendSnapshotFrames( + (opcode, payload) => + !state.closed && + streams.get(stream.streamId) === stream && + state.sendFrame(stream.streamId, opcode, payload), + { + kind: 'scrollback', + cols: serialized.cols, + rows: serialized.rows, + displayMode, + reason: 'ack-pending-overflow', + seq: serialized.seq, + source: serialized.source, + kittyKeyboardFlags: serialized.kittyKeyboardFlags, + truncatedByByteBudget: serialized.truncatedByByteBudget, + data: serialized.data + } + ) + if (!publication.published) { + throw new Error('Remote terminal recovery snapshot was not published.') + } + if (state.closed || streams.get(stream.streamId) !== stream) { + throw new Error('Remote terminal recovery snapshot stream detached.') + } + const localReplacement = replacement + ? typeof serialized.seq === 'number' + ? stream.sourceRangeLedger?.planSourceRangeReplacement(serialized.seq) + : null + : null + if (replacement && !localReplacement) { + throw new Error('Remote terminal recovery source ledger replacement unavailable.') + } + if ( + replacement && + (!serialized.source || + typeof serialized.seq !== 'number' || + !runtime.commitRemoteTerminalSourceRangeReplacement(replacement, { + source: serialized.source, + seq: serialized.seq + })) + ) { + throw new Error('Remote terminal recovery snapshot replacement was not accepted.') + } + localReplacement?.commit() + stream.sourceRangeReplacement = null + replacement = null + if (typeof serialized.seq === 'number') { + const snapshotSeq = serialized.seq + const retained = stream.ackPendingOutput.filter( + (chunk) => !(typeof chunk.seq === 'number' && chunk.seq <= snapshotSeq) + ) + stream.ackPendingOutput = retained + stream.ackPendingOutputBytes = retained.reduce( + (total, chunk) => total + chunk.bytes.byteLength, + 0 + ) + } + stream.ackPendingOutputOverflowed = false + } catch (error) { + if (replacement) { + if (stream.sourceRangeReplacement === replacement) { + stream.sourceRangeReplacement = null + runtime.rollbackRemoteTerminalSourceRangeReplacement( + replacement, + 'ack-pending-overflow-unpublished' + ) + } + replacement = null + } + if (state.closed || streams.get(stream.streamId) !== stream) { + return + } + state.sendStreamError( + stream.streamId, + error instanceof Error ? error.message : 'Remote terminal recovery snapshot failed.' + ) + state.detachStream(stream.streamId, true) + } finally { + if (streams.get(stream.streamId) === stream) { + stream.ackRecoverySnapshotInFlight = false + state.flushAllAckPendingOutput() + } + } + } + state.flushAckPendingOutput = ( + stream: TerminalMultiplexStream, + maxChunks = Number.POSITIVE_INFINITY + ): number => { + if (stream.outputPaused) { + return 0 + } + if (stream.ackPendingOutputOverflowed) { + void state.sendAckRecoverySnapshot(stream) + return 0 + } + let flushed = 0 + while ( + flushed < stream.ackPendingOutput.length && + flushed < maxChunks && + state.canSendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!.bytes.byteLength) + ) { + if (!state.sendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!)) { + return flushed + } + flushed += 1 + } + if (flushed > 0) { + stream.ackPendingOutput.splice(0, flushed) + stream.ackPendingOutputBytes = stream.ackPendingOutput.reduce( + (total, pending) => total + pending.bytes.byteLength, + 0 + ) + } + return flushed + } + state.flushAllAckPendingOutput = (): void => { + const ordered = Array.from(streams.values()) + state.ackFlushCursorStreamId = drainTerminalMultiplexRoundRobin({ + streams: ordered, + cursorStreamId: state.ackFlushCursorStreamId, + canContinue: () => !state.closed, + drainOne: (stream) => { + if (streams.get(stream.streamId) !== stream) { + return false + } + if (state.flushAckPendingOutput(stream, 1) > 0) { + return true + } + return false + } + }) + } + state.acknowledgeOutput = (stream: TerminalMultiplexStream, bytes: number): void => { + if (!stream.ackOutput || bytes <= 0) { + return + } + const acknowledged = Math.min(stream.ackInFlightBytes, bytes) + stream.ackWindowBytes = Math.min( + TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES, + stream.ackWindowBytes + acknowledged + ) + state.ackTotalWindowBytes = Math.min( + TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES, + state.ackTotalWindowBytes + acknowledged + ) + stream.ackInFlightBytes -= acknowledged + state.ackTotalInFlightBytes = Math.max(0, state.ackTotalInFlightBytes - acknowledged) + state.flushAllAckPendingOutput() + } + state.acknowledgeSourceRanges = ( + stream: TerminalMultiplexStream, + streamGeneration: string, + ackedEndByte: number + ): void => { + if (!stream.ackOutputSourceRanges) { + return + } + const result = stream.sourceRangeLedger?.acknowledge(streamGeneration, ackedEndByte) + if (!result) { + return + } + if (result.status !== 'accepted') { + return + } + if (result.settled.length > 0) { + runtime.settleRemoteTerminalSourceRanges( + { + ptyId: stream.ptyId, + consumerId: stream.remoteDesktopSubscriptionKey, + streamGeneration: stream.streamGeneration + }, + result.settled + ) + } + state.acknowledgeOutput(stream, result.acknowledgedBytes) + } + state.detachSourceRangeConsumer = (stream: TerminalMultiplexStream, reason: string): void => { + if (!stream.sourceRangeConsumerAttached) { + return + } + stream.sourceRangeConsumerAttached = false + const ledger = stream.sourceRangeLedger + stream.sourceRangeLedger = null + if (!ledger) { + return + } + const identity = { + ptyId: stream.ptyId, + consumerId: stream.remoteDesktopSubscriptionKey, + streamGeneration: stream.streamGeneration + } + const transfer = ledger.beginTransfer() + const ranges = transfer.frames.flatMap((frame) => frame.sourceRanges) + try { + runtime.cancelRemoteTerminalSourceRanges(identity, ranges, reason) + } finally { + transfer.commit() + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts new file mode 100644 index 00000000000..6e9c0e38231 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-frame-delivery.ts @@ -0,0 +1,153 @@ +import { + TerminalStreamOpcode, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../../../shared/terminal-stream-protocol' +import { appendAckPendingOutput } from './terminal-stream-replay' +import type { + TerminalMultiplexConnection, + TerminalMultiplexConnectionBase, + TerminalMultiplexFrameDeliveryStage +} from './terminal-multiplex-connection' +import type { TerminalOutputFrameChunk } from '../../terminal-output-frame-chunks' +import type { TerminalStreamInputOutcome } from './terminal-input-delivery' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export function installMultiplexFrameDelivery( + build: TerminalMultiplexConnectionBase +): asserts build is TerminalMultiplexFrameDeliveryStage { + const state = build as TerminalMultiplexConnection + const { sendBinary, emit, streams } = state + state.sendFrame = ( + streamId: number, + opcode: TerminalStreamOpcode, + payload: Uint8Array = new Uint8Array(), + seq?: number, + onRejected?: () => void + ): boolean => { + if (state.closed) { + onRejected?.() + return false + } + // Why: a seq-less Output chunk must carry sentinel 0, not the control-frame state.cursor, or it poisons the client's frame-drop tracker. + const resolvedSeq = + typeof seq === 'number' ? seq : opcode === TerminalStreamOpcode.Output ? 0 : state.cursor++ + let sent: boolean | void + try { + sent = sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: resolvedSeq, payload })) + } catch { + onRejected?.() + state.closeMultiplex() + return false + } + if (sent === false) { + onRejected?.() + // Why: false means the transport discarded this frame; reconnect is the only available retry boundary with an authoritative snapshot. + state.closeMultiplex() + return false + } + return true + } + state.sendStreamError = (streamId: number, message: string): void => { + state.sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message)) + emit({ type: 'error', streamId, message }) + } + state.notifyStreamWriteUnavailable = ( + stream: TerminalMultiplexStream, + outcome: TerminalStreamInputOutcome + ): void => { + if ( + state.closed || + streams.get(stream.streamId) !== stream || + outcome !== 'rejected' || + !stream.supportsWriteUnavailable + ) { + return + } + state.sendFrame(stream.streamId, TerminalStreamOpcode.WriteUnavailable) + } + state.sendResizedFrame = ( + stream: TerminalMultiplexStream, + event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } + ): void => { + stream.lastResizeCols = event.cols + state.sendFrame( + stream.streamId, + TerminalStreamOpcode.Resized, + encodeTerminalStreamJson({ + cols: event.cols, + rows: event.rows, + displayMode: event.displayMode, + reason: event.reason, + seq: event.seq + }) + ) + } + state.canSendAckGatedOutput = (stream: TerminalMultiplexStream, bytes: number): boolean => { + if (!stream.ackOutput) { + return true + } + return ( + stream.ackInFlightBytes + bytes <= stream.ackWindowBytes && + state.ackTotalInFlightBytes + bytes <= state.ackTotalWindowBytes && + (!stream.ackOutputSourceRanges || stream.sourceRangeLedger?.canAccept(bytes) === true) + ) + } + state.sendAckGatedOutput = ( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk + ): boolean => { + const prepared = stream.ackOutputSourceRanges + ? stream.sourceRangeLedger?.prepareAccept( + chunk.bytes.byteLength, + chunk.displayLength, + chunk.sourceRanges ?? [], + chunk.seq + ) + : undefined + if (stream.ackOutputSourceRanges && prepared?.status !== 'ready') { + if (prepared?.status !== 'capacity') { + state.detachStream(stream.streamId, true) + } + return false + } + const admission = prepared?.status === 'ready' ? prepared.admission : undefined + const sent = state.sendFrame( + stream.streamId, + chunk.opcode ?? TerminalStreamOpcode.Output, + chunk.bytes, + chunk.seq, + admission?.rollback + ) + if (!sent) { + return false + } + if (admission && !admission.commit()) { + state.detachStream(stream.streamId, true) + return false + } + if (stream.ackOutput) { + stream.ackInFlightBytes += chunk.bytes.byteLength + state.ackTotalInFlightBytes += chunk.bytes.byteLength + } + return true + } + state.queueOrSendOutput = ( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk + ): void => { + if (state.closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { + return + } + if ( + stream.ackPendingOutputOverflowed || + stream.ackPendingOutput.length > 0 || + !state.canSendAckGatedOutput(stream, chunk.bytes.byteLength) + ) { + appendAckPendingOutput(stream, chunk) + return + } + state.sendAckGatedOutput(stream, chunk) + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts new file mode 100644 index 00000000000..724e14f2cc5 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts @@ -0,0 +1,137 @@ +import { + sendSnapshotFrames, + serializeBudgetedMobileSnapshot +} from './terminal-snapshot-publication' +import { getOutputAfterSnapshotSeq } from './terminal-stream-replay' +import type { + MultiplexSubscribeRequest, + TerminalMultiplexConnection +} from './terminal-multiplex-connection' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export type MultiplexPublishedInitialState = { + isMobile: boolean + size: { cols: number; rows: number } | null + displayMode: string +} + +export async function publishMultiplexInitialSnapshot( + state: TerminalMultiplexConnection, + request: MultiplexSubscribeRequest, + stream: TerminalMultiplexStream +): Promise { + const { runtime, streams, emit } = state + const { ptyId } = stream + const isMobile = stream.isMobile + let read = await runtime.readTerminal(request.terminal) + let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (state.closed || streams.get(request.streamId) !== stream) { + return null + } + let initialOutputOverflowed = false + if (stream.pendingOutputOverflowed) { + stream.pendingOutput.splice(0) + stream.pendingOutputBytes = 0 + stream.pendingOutputOverflowed = false + read = await runtime.readTerminal(request.terminal) + serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (state.closed || streams.get(request.streamId) !== stream) { + return null + } + if (stream.pendingOutputOverflowed) { + initialOutputOverflowed = true + stream.pendingOutput.splice(0) + stream.pendingOutputBytes = 0 + stream.pendingOutputOverflowed = false + } + } + const size = runtime.getTerminalSize(ptyId) + const displayMode = runtime.getMobileDisplayMode(ptyId) + const layoutSeq = runtime.getLayout(ptyId)?.seq + // Why: layout versions and output offsets are different sequence domains. + const snapshotOutputSeq = serialized?.seq + emit({ + type: 'subscribed', + streamId: request.streamId, + terminal: request.terminal, + cols: serialized?.cols ?? size?.cols, + rows: serialized?.rows ?? size?.rows, + displayMode, + seq: layoutSeq, + ...((stream.ackOutputSourceRanges || stream.supportsOutputPause) && { + capabilities: { + ...(stream.ackOutputSourceRanges ? { ackOutputSourceRanges: 1 as const } : {}), + ...(stream.supportsOutputPause ? { outputPause: 1 as const } : {}) + } + }), + ...(stream.ackOutputSourceRanges ? { streamGeneration: stream.streamGeneration } : {}), + // Why: retained-tail truncation loses history, not the authoritative latest-screen fallback. + truncated: initialOutputOverflowed + }) + stream.sourceRangeReplacement = + stream.ackOutputSourceRanges && + serialized?.source !== undefined && + typeof serialized.seq === 'number' + ? runtime.reserveRemoteTerminalSourceRangeReplacement( + { + ptyId, + consumerId: stream.remoteDesktopSubscriptionKey, + streamGeneration: stream.streamGeneration + }, + serialized.seq, + 'initial-snapshot' + ) + : null + const snapshotPublication = sendSnapshotFrames( + (opcode, payload) => state.sendFrame(request.streamId, opcode, payload), + { + kind: 'scrollback', + cols: serialized?.cols ?? size?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? 24, + displayMode, + seq: snapshotOutputSeq, + cwd: serialized?.cwd, + truncated: initialOutputOverflowed, + truncatedByByteBudget: serialized?.truncatedByByteBudget, + source: serialized?.source, + kittyKeyboardFlags: serialized?.kittyKeyboardFlags, + oscLinks: serialized?.oscLinks, + pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, + data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '') + } + ) + const replacement = stream.sourceRangeReplacement + stream.sourceRangeReplacement = null + if (replacement) { + const committed = + snapshotPublication.published && + serialized?.source !== undefined && + typeof serialized.seq === 'number' && + runtime.commitRemoteTerminalSourceRangeReplacement(replacement, { + source: serialized.source, + seq: serialized.seq + }) + if (!committed) { + runtime.rollbackRemoteTerminalSourceRangeReplacement( + replacement, + 'initial-snapshot-unpublished' + ) + } + } + // Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay. + stream.lastResizeCols = serialized?.cols ?? size?.cols + stream.buffering = false + const pendingOutput = stream.pendingOutput.splice(0) + if (!initialOutputOverflowed) { + for (const chunk of pendingOutput) { + const uncovered = getOutputAfterSnapshotSeq(chunk, snapshotOutputSeq) + if (uncovered) { + stream.outputBatcher.push(uncovered.data, uncovered.meta) + } + } + } + stream.pendingOutputBytes = 0 + stream.pendingOutputOverflowed = false + stream.outputBatcher.flush() + return { isMobile, size, displayMode } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-live-stream.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-live-stream.ts new file mode 100644 index 00000000000..45604dc4a01 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-live-stream.ts @@ -0,0 +1,139 @@ +import { sendMobileResizeRestream } from './terminal-snapshot-publication' +import { updateViewportForClient } from './terminal-viewport-update' +import type { + MultiplexSubscribeRequest, + TerminalMultiplexConnection +} from './terminal-multiplex-connection' +import type { MultiplexPublishedInitialState } from './terminal-multiplex-initial-snapshot' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export function activateMultiplexStream( + state: TerminalMultiplexConnection, + request: MultiplexSubscribeRequest, + stream: TerminalMultiplexStream, + published: MultiplexPublishedInitialState +): void { + const { runtime, streams, emit } = state + const { ptyId } = stream + const { isMobile, size } = published + if (!isMobile) { + stream.unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { + const mode = + event.mode === 'mobile-fit' + ? event.mode + : (runtime.getRemoteDesktopFitHold?.(ptyId, stream.remoteDesktopSubscriptionKey).mode ?? + 'desktop-fit') + emit({ + type: 'fit-override-changed', + streamId: request.streamId, + mode, + cols: event.cols, + rows: event.rows + }) + }) + stream.unsubscribeDriver = runtime.subscribeToDriverChanges(ptyId, (driver) => { + emit({ + type: 'driver-changed', + streamId: request.streamId, + driver + }) + }) + const fitOverride = runtime.getTerminalFitOverride(ptyId) + const desktopHold = runtime.getRemoteDesktopFitHold?.( + ptyId, + stream.remoteDesktopSubscriptionKey + ) ?? { mode: 'desktop-fit' as const, cols: size?.cols ?? 0, rows: size?.rows ?? 0 } + emit({ + type: 'fit-override-changed', + streamId: request.streamId, + mode: fitOverride?.mode ?? desktopHold.mode, + cols: fitOverride?.cols ?? desktopHold.cols, + rows: fitOverride?.rows ?? desktopHold.rows + }) + emit({ + type: 'driver-changed', + streamId: request.streamId, + driver: runtime.getDriver(ptyId) + }) + } + stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { + stream.outputBatcher.flush() + const resizeGeneration = stream.resizeGeneration + 1 + stream.resizeGeneration = resizeGeneration + const widthChanged = stream.isMobile && event.cols !== stream.lastResizeCols + if (widthChanged) { + stream.lastResizeCols = event.cols + // Why: re-serialize+replay the full scrollback at the new cols so restored hard-wrapped lines rewrap; live output resumes after the snapshot lands. + void sendMobileResizeRestream( + runtime, + ptyId, + (opcode, payload) => state.sendFrame(request.streamId, opcode, payload), + event, + () => + !state.closed && + streams.get(request.streamId) === stream && + stream.resizeGeneration === resizeGeneration + ) + .then((restreamed) => { + if ( + state.closed || + streams.get(request.streamId) !== stream || + stream.resizeGeneration !== resizeGeneration + ) { + return + } + if (!restreamed) { + state.sendResizedFrame(stream, event) + } + }) + // Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize. + .catch(() => { + if ( + state.closed || + streams.get(request.streamId) !== stream || + stream.resizeGeneration !== resizeGeneration + ) { + return + } + state.sendResizedFrame(stream, event) + }) + return + } + state.sendResizedFrame(stream, event) + }) + // Install the resize listener before draining the parked viewport, since applyLayout emits synchronously. + if ( + !stream.isMobile && + stream.client?.id && + stream.registeredRemoteDesktopDriver && + stream.pendingRemoteDesktopViewport + ) { + const viewport = stream.pendingRemoteDesktopViewport + stream.pendingRemoteDesktopViewport = null + void updateViewportForClient( + runtime, + ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client, + viewport, + 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ).catch(() => {}) + } + void runtime + .waitForTerminal(request.terminal, { + condition: 'exit', + signal: stream.exitWaiterAbort.signal + }) + .then(() => { + if (streams.get(request.streamId) === stream) { + state.detachStream(request.streamId, true) + } + }) + .catch(() => { + if (streams.get(request.streamId) === stream) { + state.detachStream(request.streamId, true) + } + }) +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts new file mode 100644 index 00000000000..11fd0c4d039 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-method.ts @@ -0,0 +1,72 @@ +import { defineStreamingMethod, type RpcAnyMethod } from '../../core' +import { TerminalStreamOpcode } from '../../../../../shared/terminal-stream-protocol' +import { TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' +import { TerminalSourceRangeRegistry } from '../../terminal-source-range-registry' +import { TerminalMultiplex } from './stream-schemas' +import type { TerminalMultiplexConnectionBase } from './terminal-multiplex-connection' +import type { TerminalMultiplexStream } from './terminal-stream-types' +import { installMultiplexFrameDelivery } from './terminal-multiplex-frame-delivery' +import { installMultiplexFlowControl } from './terminal-multiplex-flow-control' +import { installMultiplexCleanup } from './terminal-multiplex-cleanup' +import { installMultiplexSlotFrames } from './terminal-multiplex-slot-frames' +import { installMultiplexSubscribeFrame } from './terminal-multiplex-subscribe-frame' + +export const TERMINAL_MULTIPLEX_METHODS: RpcAnyMethod[] = [ + defineStreamingMethod({ + name: 'terminal.multiplex', + params: TerminalMultiplex, + handler: async ( + _params, + { runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal }, + emit + ) => { + if (!sendBinary || !registerBinaryStreamHandler || !connectionId) { + throw new Error('binary_terminal_stream_required') + } + let resolveMultiplex = (): void => {} + const multiplexClosed = new Promise((resolve) => { + resolveMultiplex = resolve + }) + // Installers only close over this per-connection state; none of it is module-global. + const state: TerminalMultiplexConnectionBase = { + runtime, + connectionId, + sendBinary, + registerBinaryStreamHandler, + signal, + emit, + closed: false, + cursor: 0, + streams: new Map(), + sourceRangeRegistry: new TerminalSourceRangeRegistry(), + pendingPtyWaitControllers: new Map>(), + ackTotalInFlightBytes: 0, + ackTotalWindowBytes: TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES, + ackFlushCursorStreamId: null, + resolveMultiplex, + multiplexClosed, + unregisterControlHandler: () => {} + } + + installMultiplexFrameDelivery(state) + installMultiplexFlowControl(state) + installMultiplexCleanup(state) + installMultiplexSlotFrames(state) + installMultiplexSubscribeFrame(state) + + state.unregisterControlHandler = registerBinaryStreamHandler(0, (frame) => { + if (frame.opcode === TerminalStreamOpcode.Subscribe) { + void state.handleSubscribeFrame(frame.payload) + } + }) + signal?.addEventListener('abort', state.cancelAllPendingPtyWaits, { once: true }) + runtime.registerSubscriptionCleanup( + `terminal-multiplex:${connectionId}`, + state.closeMultiplex, + connectionId + ) + emit({ type: 'ready' }) + await multiplexClosed + } + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts new file mode 100644 index 00000000000..7a4ae50e701 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts @@ -0,0 +1,297 @@ +import { + TerminalStreamOpcode, + decodeTerminalStreamJson, + decodeTerminalStreamText, + type TerminalStreamFrame +} from '../../../../../shared/terminal-stream-protocol' +import { + TerminalMultiplexLegacyAckFrame, + TerminalMultiplexSnapshotRequestFrame, + TerminalMultiplexSourceRangeAckFrame +} from './stream-schemas' +import { isTerminalInputLockedForClient, sendTerminalStreamInput } from './terminal-input-delivery' +import { + getOutputAfterSnapshotSeq, + normalizeMultiplexSnapshotScrollbackRows +} from './terminal-stream-replay' +import { + sendSnapshotFrames, + serializeBudgetedRequestedSnapshot +} from './terminal-snapshot-publication' +import { updateViewportForClient } from './terminal-viewport-update' +import type { + MultiplexSnapshotRequest, + TerminalMultiplexCleanupStage, + TerminalMultiplexConnection, + TerminalMultiplexSlotFramesStage +} from './terminal-multiplex-connection' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export function installMultiplexSlotFrames( + build: TerminalMultiplexCleanupStage +): asserts build is TerminalMultiplexSlotFramesStage { + const state = build as TerminalMultiplexConnection + const { runtime, streams } = state + state.handleSlotFrame = (stream: TerminalMultiplexStream, frame: TerminalStreamFrame): void => { + if (state.closed || streams.get(stream.streamId) !== stream) { + return + } + if (frame.opcode === TerminalStreamOpcode.Unsubscribe) { + state.cancelPendingPtyWaits(stream.streamId) + state.detachStream(stream.streamId, false) + return + } + if (frame.opcode === TerminalStreamOpcode.Ack) { + const payload = decodeTerminalStreamJson(frame.payload) ?? {} + if (stream.ackOutputSourceRanges) { + const parsed = TerminalMultiplexSourceRangeAckFrame.safeParse(payload) + if (parsed.success) { + state.acknowledgeSourceRanges( + stream, + parsed.data.streamGeneration, + parsed.data.ackedEndByte + ) + } + } else { + const parsed = TerminalMultiplexLegacyAckFrame.safeParse(payload) + if (parsed.success) { + state.acknowledgeOutput(stream, parsed.data.bytes) + } + } + return + } + if (frame.opcode === TerminalStreamOpcode.Input) { + const text = decodeTerminalStreamText(frame.payload) + if (!text) { + return + } + if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { + return + } + // Mobile already has the higher-priority floor, so a rejected desktop claim must not suppress later phone input. + const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail + void inputClaimTail.then(async (claimed) => { + if (!claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { + return + } + const outcome = await sendTerminalStreamInput(runtime, { + terminal: stream.terminal, + text, + client: stream.client, + isMobile: stream.isMobile + }) + state.notifyStreamWriteUnavailable(stream, outcome) + }) + return + } + if (frame.opcode === TerminalStreamOpcode.SetOutputPaused && stream.supportsOutputPause) { + const payload = decodeTerminalStreamJson<{ paused?: unknown }>(frame.payload) + if (typeof payload?.paused !== 'boolean' || stream.outputPaused === payload.paused) { + return + } + stream.outputPaused = payload.paused + if (stream.outputPaused) { + stream.outputBatcher.flush() + stream.ackPendingOutput = [] + stream.ackPendingOutputBytes = 0 + stream.ackPendingOutputOverflowed = false + } + return + } + if (frame.opcode === TerminalStreamOpcode.Resize && stream.client) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(frame.payload) + if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { + return + } + const cols = viewport.cols + const rows = viewport.rows + // Why: resize registers stream-scoped geometry so detach can release it; older clients lack explicit claims. + if (!stream.isMobile && stream.client?.id) { + stream.registeredRemoteDesktopDriver = true + if (stream.buffering) { + stream.pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows } + return + } + } + stream.desktopClaimTail = stream.desktopClaimTail + .then(async (priorClaimed) => { + const result = await updateViewportForClient( + runtime, + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client!, + { cols, rows }, + stream.isMobile ? 'mobile' : 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ) + return stream.supportsDesktopViewportClaims + ? priorClaimed && result.applied + : result.applied + }) + .catch(() => false) + return + } + if (frame.opcode === TerminalStreamOpcode.ClaimViewport && stream.client && !stream.isMobile) { + const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>(frame.payload) + if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { + return + } + const cols = viewport.cols + const rows = viewport.rows + stream.registeredRemoteDesktopDriver = true + stream.desktopClaimTail = stream.desktopClaimTail + .then( + () => + runtime.updateRemoteDesktopViewer( + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client!.id, + cols, + rows, + true + ), + () => + runtime.updateRemoteDesktopViewer( + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client!.id, + cols, + rows, + true + ) + ) + .catch(() => false) + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotRequest) { + const payload = TerminalMultiplexSnapshotRequestFrame.safeParse( + decodeTerminalStreamJson(frame.payload) ?? {} + ) + void state.sendRequestedSnapshot(stream, payload.success ? payload.data : {}) + } + } + state.sendRequestedSnapshot = async ( + stream: TerminalMultiplexStream, + request: MultiplexSnapshotRequest + ): Promise => { + if (state.closed || streams.get(stream.streamId) !== stream) { + return + } + stream.outputBatcher.flush() + stream.pendingOutputOverflowed = false + stream.buffering = true + const requestId = request.requestId + let sentSnapshotOutputSeq: number | undefined + try { + const scrollbackRows = normalizeMultiplexSnapshotScrollbackRows(request.scrollbackRows) + let serialized = await serializeBudgetedRequestedSnapshot( + runtime, + stream.ptyId, + scrollbackRows + ) + if (state.closed || streams.get(stream.streamId) !== stream) { + return + } + let size = runtime.getTerminalSize(stream.ptyId) + let displayMode = runtime.getMobileDisplayMode(stream.ptyId) + if (stream.pendingOutputOverflowed) { + // Why: the overflowed tail is newer than the first snapshot, so retry for a current image instead of null. + stream.pendingOutput.splice(0) + stream.pendingOutputBytes = 0 + stream.pendingOutputOverflowed = false + serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, scrollbackRows) + if (state.closed || streams.get(stream.streamId) !== stream) { + return + } + size = runtime.getTerminalSize(stream.ptyId) + displayMode = runtime.getMobileDisplayMode(stream.ptyId) + if (stream.pendingOutputOverflowed) { + sendSnapshotFrames( + (opcode, payload) => state.sendFrame(stream.streamId, opcode, payload), + { + kind: 'scrollback', + cols: size?.cols ?? 80, + rows: size?.rows ?? 24, + requestId, + displayMode, + truncated: true, + truncatedByByteBudget: false, + unavailable: 'pending-output-overflowed', + data: '' + } + ) + return + } + } + sentSnapshotOutputSeq = serialized?.seq + sendSnapshotFrames((opcode, payload) => state.sendFrame(stream.streamId, opcode, payload), { + kind: 'scrollback', + cols: serialized?.cols ?? size?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? 24, + requestId, + displayMode, + seq: serialized?.seq, + cwd: serialized?.cwd, + source: serialized?.source, + kittyKeyboardFlags: serialized?.kittyKeyboardFlags, + oscLinks: serialized?.oscLinks, + pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, + truncated: false, + truncatedByByteBudget: serialized?.truncatedByByteBudget, + // Why: no serializer answered, which is not proof the pane is empty — say so instead of passing off '' as the buffer. + unavailable: serialized ? undefined : 'no-serializable-buffer', + data: serialized?.data ?? '' + }) + } catch (error) { + state.sendStreamError( + stream.streamId, + error instanceof Error ? error.message : 'Remote terminal snapshot failed.' + ) + } finally { + if (streams.get(stream.streamId) === stream) { + const shouldFlushPendingOutput = !stream.pendingOutputOverflowed + stream.buffering = false + const pendingOutput = stream.pendingOutput.splice(0) + if (shouldFlushPendingOutput) { + for (const chunk of pendingOutput) { + // Why: an untagged reply resets the client to the snapshot's + // high-water, so covered bytes would render twice; tagged + // snapshots feed a side consumer and the live view still + // needs every buffered chunk. + const uncovered = + typeof requestId === 'number' + ? chunk + : getOutputAfterSnapshotSeq(chunk, sentSnapshotOutputSeq) + if (uncovered) { + stream.outputBatcher.push(uncovered.data, uncovered.meta) + } + } + } + stream.pendingOutputBytes = 0 + stream.pendingOutputOverflowed = false + stream.outputBatcher.flush() + // Why: a resize parked during snapshot buffering must be applied now, or it is dropped until the viewer's next resize. + if ( + !stream.isMobile && + stream.client?.id && + stream.registeredRemoteDesktopDriver && + stream.pendingRemoteDesktopViewport + ) { + const viewport = stream.pendingRemoteDesktopViewport + stream.pendingRemoteDesktopViewport = null + void updateViewportForClient( + runtime, + stream.ptyId, + stream.remoteDesktopSubscriptionKey, + stream.client, + viewport, + 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ).catch(() => {}) + } + } + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts new file mode 100644 index 00000000000..cc3ff90b2d4 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts @@ -0,0 +1,151 @@ +import { randomUUID } from 'node:crypto' +import { + TerminalStreamOpcode, + encodeTerminalStreamJson +} from '../../../../../shared/terminal-stream-protocol' +import { iterateTerminalOutputFrameChunks } from '../../terminal-output-frame-chunks' +import { TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' +import { createTerminalOutputBatcher } from './terminal-output-batcher' +import { appendPendingMultiplexOutput } from './terminal-stream-replay' +import { updateViewportForClient } from './terminal-viewport-update' +import type { + MultiplexSubscribeRequest, + TerminalMultiplexConnection +} from './terminal-multiplex-connection' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export async function initializeMultiplexStream( + state: TerminalMultiplexConnection, + request: MultiplexSubscribeRequest, + ptyId: string, + onInstalled: (stream: TerminalMultiplexStream) => void +): Promise { + const { runtime, connectionId, streams, sourceRangeRegistry, registerBinaryStreamHandler } = state + const isMobile = request.client?.type === 'mobile' + const remoteDesktopSubscriptionKey = `multiplex:${connectionId}:${request.streamId}` + const streamGeneration = randomUUID() + const requestedSourceRangeConsumer = + request.capabilities?.ackOutput === 1 && request.capabilities?.ackOutputSourceRanges === 1 + const sourceRangeLedger = requestedSourceRangeConsumer + ? sourceRangeRegistry.open(streamGeneration) + : null + const sourceRangeConsumerAttached = + sourceRangeLedger !== null && + runtime.attachRemoteTerminalSourceRangeConsumer({ + ptyId, + consumerId: remoteDesktopSubscriptionKey, + streamGeneration + }) + if (!sourceRangeConsumerAttached) { + sourceRangeLedger?.close() + } + const stream: TerminalMultiplexStream = { + streamId: request.streamId, + terminal: request.terminal, + ptyId, + client: request.client, + isMobile, + ackOutput: request.capabilities?.ackOutput === 1, + ackOutputSourceRanges: sourceRangeConsumerAttached, + streamGeneration, + sourceRangeLedger: sourceRangeConsumerAttached ? sourceRangeLedger : null, + sourceRangeConsumerAttached, + sourceRangeReplacement: null, + ackInFlightBytes: 0, + ackWindowBytes: TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES, + supportsOutputPause: request.capabilities?.outputPause === 1, + supportsWriteUnavailable: request.capabilities?.writeUnavailable === 1, + outputPaused: false, + supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1, + desktopClaimTail: Promise.resolve(true), + registeredRemoteDesktopDriver: false, + // Why: streamId is client-local, so key the width floor by connectionId or two connections sharing stream 1 for one PTY clobber each other's floor. + remoteDesktopSubscriptionKey, + pendingRemoteDesktopViewport: null, + buffering: true, + ackPendingOutput: [], + ackPendingOutputBytes: 0, + ackPendingOutputOverflowed: false, + ackRecoverySnapshotInFlight: false, + pendingOutput: [], + pendingOutputBytes: 0, + pendingOutputOverflowed: false, + lastResizeCols: undefined, + resizeGeneration: 0, + outputBatcher: createTerminalOutputBatcher((data, meta) => { + if (meta?.cwd !== undefined) { + state.sendFrame( + request.streamId, + TerminalStreamOpcode.Metadata, + encodeTerminalStreamJson({ cwd: meta.cwd }), + meta.seq + ) + } + for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { + state.queueOrSendOutput(stream, chunk) + } + }), + unsubscribeData: () => {}, + unsubscribeResize: () => {}, + unsubscribeFit: () => {}, + unsubscribeDriver: () => {}, + unregisterBinaryHandler: () => {}, + exitWaiterAbort: new AbortController() + } + streams.set(request.streamId, stream) + stream.unregisterBinaryHandler = registerBinaryStreamHandler(request.streamId, (frame) => + state.handleSlotFrame(stream, frame) + ) + onInstalled(stream) + + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + if (state.closed || streams.get(request.streamId) !== stream) { + return + } + if (stream.outputPaused) { + return + } + if (stream.buffering) { + appendPendingMultiplexOutput(stream, data, meta) + return + } + stream.outputBatcher.push(data, meta) + }) + // Why: a multiplexed stream feeds a remote xterm view with query authority, so the main model responder yields while attached (terminal-query-authority.md). + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + stream.unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } + + if (isMobile && request.client?.id) { + await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport) + } else if (request.client?.id && request.viewport) { + // Why: subscribe records this stream's geometry and cleanup key but doesn't claim ownership; activity frames claim later. + stream.registeredRemoteDesktopDriver = true + stream.pendingRemoteDesktopViewport = request.viewport + } + if ( + !isMobile && + request.client?.id && + stream.registeredRemoteDesktopDriver && + stream.pendingRemoteDesktopViewport + ) { + const viewport = stream.pendingRemoteDesktopViewport + stream.pendingRemoteDesktopViewport = null + await updateViewportForClient( + runtime, + ptyId, + stream.remoteDesktopSubscriptionKey, + request.client, + viewport, + 'desktop', + 'register', + !stream.supportsDesktopViewportClaims + ) + } + if (state.closed || streams.get(request.streamId) !== stream) { + return null + } + return stream +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts new file mode 100644 index 00000000000..dcb7b64ece4 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-frame.ts @@ -0,0 +1,73 @@ +import { decodeTerminalStreamJson } from '../../../../../shared/terminal-stream-protocol' +import { TerminalMultiplexSubscribeFrame } from './stream-schemas' +import type { + TerminalMultiplexConnection, + TerminalMultiplexSlotFramesStage +} from './terminal-multiplex-connection' +import { resolveMultiplexSubscribePty } from './terminal-multiplex-subscribe-resolution' +import { initializeMultiplexStream } from './terminal-multiplex-stream-initialization' +import { publishMultiplexInitialSnapshot } from './terminal-multiplex-initial-snapshot' +import { activateMultiplexStream } from './terminal-multiplex-live-stream' +import type { TerminalMultiplexStream } from './terminal-stream-types' + +export function installMultiplexSubscribeFrame( + build: TerminalMultiplexSlotFramesStage +): asserts build is TerminalMultiplexConnection { + const state = build as TerminalMultiplexConnection + state.handleSubscribeFrame = async (payload) => { + const raw = decodeTerminalStreamJson(payload) + const parsed = TerminalMultiplexSubscribeFrame.safeParse(raw) + if (!parsed.success) { + return + } + const request = parsed.data + const resolution = resolveMultiplexSubscribePty(state, request) + const ptyId = + typeof resolution === 'string' || resolution === null ? resolution : await resolution + if (!ptyId) { + return + } + let stream: TerminalMultiplexStream | null = null + let installedStream: TerminalMultiplexStream | null = null + try { + stream = await initializeMultiplexStream(state, request, ptyId, (installed) => { + installedStream = installed + }) + } catch (error) { + if (!installedStream) { + throw error + } + if (state.streams.get(request.streamId) !== installedStream) { + return + } + state.detachStream(request.streamId, false) + state.sendStreamError( + request.streamId, + error instanceof Error ? error.message : String(error) + ) + state.emit({ type: 'end', streamId: request.streamId }) + return + } + if (!stream) { + return + } + try { + const published = await publishMultiplexInitialSnapshot(state, request, stream) + if (!published) { + return + } + activateMultiplexStream(state, request, stream, published) + } catch (error) { + // A successor may already own the reused stream ID; never tear it down here. + if (state.streams.get(request.streamId) !== stream) { + return + } + state.detachStream(request.streamId, false) + state.sendStreamError( + request.streamId, + error instanceof Error ? error.message : String(error) + ) + state.emit({ type: 'end', streamId: request.streamId }) + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts new file mode 100644 index 00000000000..6197850a47a --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts @@ -0,0 +1,96 @@ +import { TerminalStreamOpcode } from '../../../../../shared/terminal-stream-protocol' +import { + TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION, + TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION, + TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR +} from '../../../../../shared/terminal-multiplex-flow-control' +import type { + MultiplexSubscribeRequest, + TerminalMultiplexConnection +} from './terminal-multiplex-connection' + +function finalizeResolvedMultiplexPty( + state: TerminalMultiplexConnection, + request: MultiplexSubscribeRequest, + leaf: { ptyId: string | null } | null +): string | null { + if (!leaf?.ptyId) { + state.sendStreamError(request.streamId, 'no_connected_pty') + state.emit({ type: 'end', streamId: request.streamId }) + return null + } + if (state.closed) { + return null + } + // Why: a competing subscribe may own this streamId after the PTY await; detach it so an orphaned view subscriber can't silence the model responder (terminal-query-authority.md). + state.detachStream(request.streamId, false) + if (state.streams.size >= TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION) { + state.sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) + state.emit({ type: 'end', streamId: request.streamId }) + return null + } + return leaf.ptyId +} + +export function resolveMultiplexSubscribePty( + state: TerminalMultiplexConnection, + request: MultiplexSubscribeRequest +): string | null | Promise { + const { runtime, pendingPtyWaitControllers, registerBinaryStreamHandler, signal, emit } = state + state.detachStream(request.streamId, false) + state.cancelPendingPtyWaits(request.streamId) + + let leaf: { ptyId: string | null } | null + try { + // Why: binding the stream to whatever PTY now occupies a stale handle's pane would mirror the wrong terminal (#7718). + leaf = runtime.resolveLiveLeafForHandle(request.terminal) + } catch { + state.sendStreamError(request.streamId, 'terminal_handle_stale') + emit({ type: 'end', streamId: request.streamId }) + return null + } + if (leaf?.ptyId || !request.client) { + return finalizeResolvedMultiplexPty(state, request, leaf) + } + if (pendingPtyWaitControllers.size >= TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION) { + state.sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) + emit({ type: 'end', streamId: request.streamId }) + return null + } + + // Why: a never-mounted tab has no graph leaf to await; mounting the exact tab attaches its PTY without activating the worktree. + runtime.requestRendererTerminalTabMount(request.terminal) + const waitController = new AbortController() + const pendingControllers = pendingPtyWaitControllers.get(request.streamId) ?? new Set() + pendingControllers.add(waitController) + pendingPtyWaitControllers.set(request.streamId, pendingControllers) + if (signal?.aborted) { + waitController.abort() + } + // Why: the live slot handler does not exist until the PTY attaches; retain cancellation ownership while the pane is still pending. + const unregisterPendingHandler = registerBinaryStreamHandler(request.streamId, (frame) => { + if (frame.opcode === TerminalStreamOpcode.Unsubscribe) { + state.cancelPendingPtyWaits(request.streamId) + state.detachStream(request.streamId, false) + } + }) + return (async () => { + try { + const ptyId = await runtime.waitForLeafPtyId(request.terminal, 10_000, waitController.signal) + leaf = { ptyId } + } catch { + if (state.closed || signal?.aborted || waitController.signal.aborted) { + return null + } + // Fall through to the explicit no_connected_pty error below. + } finally { + const currentControllers = pendingPtyWaitControllers.get(request.streamId) + currentControllers?.delete(waitController) + if (currentControllers?.size === 0) { + pendingPtyWaitControllers.delete(request.streamId) + } + unregisterPendingHandler() + } + return finalizeResolvedMultiplexPty(state, request, leaf) + })() +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts b/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts new file mode 100644 index 00000000000..4a5056946d6 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-output-batcher.ts @@ -0,0 +1,120 @@ +import type { TerminalOutputMeta } from '../../terminal-output-frame-chunks' +import { TERMINAL_OUTPUT_BATCH_MAX_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' +import { measureTerminalStreamByteLength } from '../../terminal-stream-byte-length' +import { + sameTerminalOutputSourceIdentity, + type TerminalOutputSourceRange +} from '../../../../../shared/terminal-output-source-range' + +const TERMINAL_OUTPUT_FLUSH_MS = 5 +export type TerminalOutputBatcher = { + push: (data: string, meta?: TerminalOutputMeta) => void + flush: () => void + dispose: () => void +} + +export function createTerminalOutputBatcher( + onFlush: (data: string, meta?: TerminalOutputMeta) => void +): TerminalOutputBatcher { + let chunks: string[] = [] + let bytes = 0 + let lastSeq: number | undefined + let pendingCwd: string | undefined + let pendingRawLength = 0 + let pendingSourceRanges: TerminalOutputSourceRange[] = [] + let timer: ReturnType | null = null + + const clearTimer = (): void => { + if (!timer) { + return + } + clearTimeout(timer) + timer = null + } + + const flush = (): void => { + clearTimer() + if (chunks.length === 0 && pendingRawLength === 0) { + return + } + const data = chunks.length === 1 ? chunks[0]! : chunks.join('') + const meta = + typeof lastSeq === 'number' || pendingCwd !== undefined || pendingSourceRanges.length > 0 + ? { + ...(typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: pendingRawLength } : {}), + ...(pendingCwd !== undefined ? { cwd: pendingCwd } : {}), + ...(pendingSourceRanges.length > 0 + ? { sourceRanges: Object.freeze(pendingSourceRanges.slice()) } + : {}) + } + : undefined + chunks = [] + bytes = 0 + lastSeq = undefined + pendingCwd = undefined + pendingRawLength = 0 + pendingSourceRanges = [] + onFlush(data, meta) + } + + return { + push(data: string, meta?: TerminalOutputMeta): void { + const rawLength = meta?.rawLength ?? data.length + if (!data && rawLength === 0) { + return + } + if (meta?.transformed || rawLength !== data.length) { + flush() + onFlush(data, { ...meta, rawLength, transformed: true }) + return + } + const nextSourceRanges = meta?.sourceRanges ?? [] + const lastSourceRange = pendingSourceRanges.at(-1) + const firstNextSourceRange = nextSourceRanges[0] + if ( + chunks.length > 0 && + (pendingSourceRanges.length > 0 !== nextSourceRanges.length > 0 || + (lastSourceRange && + firstNextSourceRange && + (!sameTerminalOutputSourceIdentity(lastSourceRange, firstNextSourceRange) || + lastSourceRange.displayEnd !== firstNextSourceRange.displayStart))) + ) { + flush() + } + if (meta?.cwd !== undefined) { + flush() + pendingCwd = meta.cwd + } + chunks.push(data) + pendingRawLength += rawLength + pendingSourceRanges.push(...nextSourceRanges) + const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes) + const measurement = measureTerminalStreamByteLength(data, { + stopAfterBytes: remainingBudget + }) + bytes += measurement.byteLength + if (typeof meta?.seq === 'number') { + lastSeq = meta.seq + } + if (measurement.exceededLimit || bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) { + flush() + return + } + if (!timer) { + // Why: coalesce stream output before it crosses the network; desktop subscribers share the same burst boundary. + timer = setTimeout(flush, TERMINAL_OUTPUT_FLUSH_MS) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + }, + flush, + dispose(): void { + clearTimer() + chunks = [] + bytes = 0 + pendingRawLength = 0 + pendingSourceRanges = [] + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts new file mode 100644 index 00000000000..91ce5498506 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts @@ -0,0 +1,101 @@ +import { defineMethod, type RpcAnyMethod } from '../../core' +import { + TerminalHandle, + TerminalListParams, + TerminalRead, + TerminalRecoverPane, + TerminalRename, + TerminalResolveActive, + TerminalResolvePane +} from './unary-schemas' + +export const TERMINAL_QUERY_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'terminal.list', + params: TerminalListParams, + handler: async (params, { runtime }) => + runtime.listTerminals(params.worktree, params.limit, { + handles: params.handles, + requireFreshPtyLiveness: params.requireFreshPtyLiveness, + includeVisualLayouts: params.includeVisualLayouts + }) + }), + defineMethod({ + name: 'terminal.resolveActive', + params: TerminalResolveActive, + handler: async (params, { runtime }) => ({ + handle: await runtime.resolveActiveTerminal(params.worktree) + }) + }), + defineMethod({ + name: 'terminal.resolvePane', + params: TerminalResolvePane, + handler: async (params, { runtime }) => ({ + terminal: runtime.resolveTerminalPane(params.paneKey, params.worktreeId) + }) + }), + defineMethod({ + name: 'terminal.recoverPane', + params: TerminalRecoverPane, + handler: async (params, { runtime }) => ({ + terminal: await runtime.recoverTerminalPane( + params.paneKey, + params.worktreeId, + params.expectedTerminal + ) + }) + }), + defineMethod({ + name: 'terminal.show', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + terminal: await runtime.showTerminal(params.terminal) + }) + }), + defineMethod({ + name: 'terminal.read', + params: TerminalRead, + handler: async (params, { runtime }) => ({ + terminal: await runtime.readTerminal(params.terminal, { + cursor: params.cursor, + limit: params.limit, + screen: params.screen + }) + }) + }), + defineMethod({ + name: 'terminal.inspectProcess', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + process: await runtime.inspectTerminalProcess(params.terminal) + }) + }), + defineMethod({ + name: 'terminal.isRunningAgent', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + isRunningAgent: await runtime.isTerminalRunningAgent(params.terminal) + }) + }), + defineMethod({ + name: 'terminal.agentStatus', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + agentStatus: await runtime.getTerminalAgentStatus(params.terminal) + }) + }), + defineMethod({ + name: 'terminal.rename', + params: TerminalRename, + handler: async (params, { runtime }) => ({ + rename: await runtime.renameTerminal(params.terminal, params.title || null) + }) + }), + defineMethod({ + name: 'terminal.clearBuffer', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + clear: await runtime.clearTerminalBuffer(params.terminal) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts new file mode 100644 index 00000000000..a4c58fbab68 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -0,0 +1,227 @@ +import { InvalidArgumentError, defineMethod, type RpcAnyMethod } from '../../core' +import { isTerminalQueryReply } from '../../../../../shared/terminal-query-reply' +import { assertTerminalAgentSendable } from '../../terminal-agent-send-guard' +import { TerminalSend } from './unary-schemas' +import { + assertTerminalSendExactPtyBinding, + assertTerminalSendTextWithinLimit, + commitMobileInputFloorClaim, + getTerminalSendGuardRefusedReason, + isTerminalInputLockedForClient, + isTerminalSendGuardNotWritable, + resolveMobileFloorClientId, + type MobileInputFloorClaimHolder +} from './terminal-input-delivery' +import { updateViewportForClient } from './terminal-viewport-update' + +export const TERMINAL_SEND_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'terminal.send', + params: TerminalSend, + handler: async (params, { runtime, clientId, signal }) => { + await assertTerminalSendTextWithinLimit(params.text) + await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text) + const queryReplyClientId = clientId ?? params.client?.id + if ( + params.inputKind === 'query-reply' && + (!params.text || + !isTerminalQueryReply(params.text) || + params.enter === true || + params.interrupt === true || + params.agentPrompt === true || + params.requireAgentStatus !== undefined || + params.client?.type !== 'mobile' || + !queryReplyClientId || + (clientId !== undefined && params.client.id !== clientId)) + ) { + throw new InvalidArgumentError('Invalid terminal query reply') + } + // Why: a stale handle must fail with terminal_handle_stale, not evaluate driver/lock state against the wrong PTY (#7718). + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) + const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null + if ( + params.inputKind === 'query-reply' && + leaf?.ptyId && + !runtime.isMobileTerminalQueryReplyAuthority(leaf.ptyId, queryReplyClientId!) + ) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + if ( + leaf?.ptyId && + params.client?.type === 'desktop' && + params.claimViewport === true && + params.viewport + ) { + const claim = await updateViewportForClient( + runtime, + leaf.ptyId, + `send:${params.client.id}`, + params.client, + params.viewport, + 'desktop', + 'refresh', + true + ) + // Why: a stream-less request can't safely create ownership, so never write at stale geometry. + if (!claim.updated || isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + } + const hasText = typeof params.text === 'string' && params.text.length > 0 + const hasSuffix = params.enter === true || params.interrupt === true + if (params.requireAgentStatus === 'sendable' && hasText && hasSuffix) { + // Why: guarded sends are two-phase; reject combined payload + submit so a guard flip can't cause partial delivery. + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + // Why: recheck permission/no-agent state immediately before accepting the PTY write. + const assertSendPreconditions = + params.requireAgentStatus === 'sendable' + ? async (ptyId?: string): Promise => { + await assertTerminalAgentSendable({ + runtime, + handle: params.terminal, + assertWritable: () => { + assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) + if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { + throw new Error('terminal_guard_not_writable') + } + } + }) + } + : undefined + if (params.requireAgentStatus === 'sendable') { + try { + await assertSendPreconditions?.(leaf?.ptyId ?? undefined) + } catch (error) { + if (isTerminalSendGuardNotWritable(error)) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + const refusedReason = getTerminalSendGuardRefusedReason(error) + if (!refusedReason) { + throw error + } + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0, + refusedReason + } + } + } + } + const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) + const mobileFloorClaim: MobileInputFloorClaimHolder = { current: null } + const beforeWrite = assertSendPreconditions + const useSettledAgentPrompt = + params.agentPrompt === true && + hasText && + params.enter === true && + params.interrupt !== true && + params.client?.type === 'desktop' && + (await runtime.isTerminalRunningSettledPromptAgent(params.terminal)) + const reserveWrite = + params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId + ? (ptyId: string): void => { + const claim = runtime.beginMobileInputFloor(ptyId, mobileFloorClientId) + if (!claim) { + throw new Error('mobile_input_floor_unavailable') + } + mobileFloorClaim.current = claim + } + : undefined + let result + try { + result = useSettledAgentPrompt + ? await runtime.sendTerminalAgentPrompt(params.terminal, params.text!, { + beforeWrite, + signal + }) + : await runtime.sendTerminal( + params.terminal, + { + text: params.text, + enter: params.enter === true, + interrupt: params.interrupt === true + }, + { + beforeWrite, + ...(reserveWrite ? { reserveWrite } : {}), + ...(params.inputKind !== 'query-reply' && mobileFloorClientId + ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } + : {}) + } + ) + } catch (error) { + mobileFloorClaim.current?.rollback() + const refusedReason = getTerminalSendGuardRefusedReason(error) + if (refusedReason) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0, + refusedReason + } + } + } + if (isTerminalSendGuardNotWritable(error)) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } + throw error + } + if (result.accepted !== true) { + mobileFloorClaim.current?.rollback() + } + if ( + result.accepted === true && + params.enter === true && + params.client?.type === 'mobile' && + params.resolvedLaunchDraft + ) { + runtime.notifyNativeChatLaunchDraftResolved(params.terminal, params.resolvedLaunchDraft) + } + // Why: deliberate mobile input takes the floor (drives `* → mobile{clientId}`); clientless sends fall back to the current mobile driver. + return { send: result } + } + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts new file mode 100644 index 00000000000..33b09a0ead9 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-snapshot-publication.ts @@ -0,0 +1,209 @@ +import type { OrcaRuntimeService } from '../../../orca-runtime' +import { + TerminalStreamOpcode, + encodeTerminalStreamJson +} from '../../../../../shared/terminal-stream-protocol' +import { + MOBILE_SNAPSHOT_BYTE_BUDGET, + MOBILE_SUBSCRIBE_SCROLLBACK_ROWS +} from '../../../scrollback-limits' +import { terminalStreamByteLengthExceeds } from '../../terminal-stream-byte-length' +import { + iterateTerminalStreamTextPayloads, + requestedSnapshotScrollbackCandidates +} from './terminal-stream-replay' +import type { SerializedSnapshot, SnapshotFrameOptions } from './terminal-stream-types' + +const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 + +export async function serializeBudgetedRequestedSnapshot( + runtime: OrcaRuntimeService, + ptyId: string, + scrollbackRows: number | undefined +): Promise { + const requestedRows = scrollbackRows ?? 0 + for (const rows of requestedSnapshotScrollbackCandidates(scrollbackRows)) { + const serialized = await runtime.serializeAuthoritativeTerminalBuffer(ptyId, { + scrollbackRows: rows + }) + if (!serialized) { + return null + } + const scrollbackAnsi = + 'scrollbackAnsi' in serialized && typeof serialized.scrollbackAnsi === 'string' + ? serialized.scrollbackAnsi + : '' + const data = scrollbackAnsi + serialized.data + const overByteBudget = terminalStreamByteLengthExceeds(data, REQUESTED_SNAPSHOT_BYTE_BUDGET) + if (!overByteBudget || rows === 0) { + return { + ...serialized, + data, + scrollbackRows: rows, + truncatedByByteBudget: rows < requestedRows || overByteBudget + } + } + } + return null +} + +export function sendSnapshotFrames( + sendFrame: ( + opcode: TerminalStreamOpcode, + payload?: Uint8Array + ) => boolean | void, + options: SnapshotFrameOptions +): { bytes: number; chunks: number; published: boolean } { + if ( + sendFrame( + TerminalStreamOpcode.SnapshotStart, + encodeTerminalStreamJson({ + kind: options.kind, + cols: options.cols, + rows: options.rows, + requestId: options.requestId, + displayMode: options.displayMode, + reason: options.reason, + unavailable: options.unavailable, + seq: options.seq, + cwd: options.cwd, + source: options.source, + oscLinks: options.oscLinks, + pendingEscapeTailAnsi: options.pendingEscapeTailAnsi, + // Why conditional and additive: old clients ignore the unknown field, + // and a new client must read absence as unknown rather than zero, so + // no opcode or capability negotiation is involved (Rule 1 of + // docs/reference/remote-wire-compatibility.md). + // Why `seq` is required: the flags are only proven at this frame's own + // seq, so without a replay boundary the client cannot order them. + ...(typeof options.seq === 'number' && options.kittyKeyboardFlags !== undefined + ? { kittyKeyboardFlags: options.kittyKeyboardFlags } + : {}), + truncated: options.truncated === true, + truncatedByByteBudget: options.truncatedByByteBudget === true + }) + ) === false + ) { + return { bytes: 0, chunks: 0, published: false } + } + let chunks = 0 + let bytes = 0 + for (const chunk of iterateTerminalStreamTextPayloads(options.data)) { + if (sendFrame(TerminalStreamOpcode.SnapshotChunk, chunk) === false) { + return { bytes, chunks, published: false } + } + chunks++ + bytes += chunk.byteLength + } + const published = sendFrame(TerminalStreamOpcode.SnapshotEnd) !== false + return { bytes, chunks, published } +} + +export async function serializeBudgetedMobileSnapshot( + runtime: OrcaRuntimeService, + ptyId: string, + isMobile: boolean +): Promise { + if (!isMobile) { + const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: 0 }) + return serialized + ? { + ...serialized, + data: (serialized.scrollbackAnsi ?? '') + serialized.data, + scrollbackRows: 0, + truncatedByByteBudget: false + } + : null + } + const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] + for (const rows of candidates) { + const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: rows }) + if (!serialized) { + return null + } + const data = (serialized.scrollbackAnsi ?? '') + serialized.data + const overByteBudget = terminalStreamByteLengthExceeds(data, MOBILE_SNAPSHOT_BYTE_BUDGET) + if (!overByteBudget || rows === 0) { + return { + ...serialized, + data, + scrollbackRows: rows, + truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget + } + } + } + return null +} + +export async function serializeStableMobileRendererSnapshot( + runtime: OrcaRuntimeService, + ptyId: string +): Promise { + const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] + let candidateIndex = 0 + for (let attempt = 0; attempt < candidates.length; attempt += 1) { + // Why: advance toward zero scrollback each retry so the final attempt always has a bounded payload. + candidateIndex = Math.max(candidateIndex, attempt) + const rows = candidates[candidateIndex] + const outputSequenceBefore = runtime.getPtyOutputSequence(ptyId) + const serialized = await runtime.serializeRendererTerminalBuffer(ptyId, { + scrollbackRows: rows + }) + const outputSequenceAfter = runtime.getPtyOutputSequence(ptyId) + if (outputSequenceBefore !== outputSequenceAfter) { + continue + } + if (!serialized) { + return null + } + const overByteBudget = terminalStreamByteLengthExceeds( + serialized.data, + MOBILE_SNAPSHOT_BYTE_BUDGET + ) + if (!overByteBudget || rows === 0) { + return { + ...serialized, + scrollbackRows: rows, + truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget + } + } + candidateIndex += 1 + } + return null +} + +// Why: mobile xterm can't rewrap the HARD newlines baked into a restored snapshot, so a real reflow re-serializes and replays the FULL buffer at the new cols. +export async function sendMobileResizeRestream( + runtime: OrcaRuntimeService, + ptyId: string, + sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array) => void, + event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number }, + shouldSend?: () => boolean +): Promise { + // Why: only a true geometry reflow rewraps scrollback; a dimensionless mode-change would re-send the whole buffer for nothing. + if (event.reason !== 'apply-layout' || runtime.isTerminalAlternateScreen(ptyId)) { + return false + } + const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true) + if (!serialized) { + return false + } + if (shouldSend && !shouldSend()) { + return true + } + sendSnapshotFrames(sendFrame, { + kind: 'resized', + cols: serialized.cols, + rows: serialized.rows, + displayMode: event.displayMode, + reason: event.reason, + seq: event.seq ?? serialized.seq, + source: serialized.source, + cwd: serialized.cwd, + oscLinks: serialized.oscLinks, + truncated: false, + truncatedByByteBudget: serialized.truncatedByByteBudget, + data: serialized.data + }) + return true +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-stream-replay.ts b/src/main/runtime/rpc/methods/terminal/terminal-stream-replay.ts new file mode 100644 index 00000000000..5ffd1079a3c --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-stream-replay.ts @@ -0,0 +1,203 @@ +import type { TerminalReplyQuerySequence } from '../../../../../shared/terminal-reply-query-scan' +import { + iterateTerminalOutputFrameChunks, + sliceTerminalOutputSourceRanges, + type TerminalOutputFrameChunk, + type TerminalOutputMeta +} from '../../terminal-output-frame-chunks' +import { + measureTerminalStreamByteLength, + terminalStreamByteLength +} from '../../terminal-stream-byte-length' +import { TERMINAL_MULTIPLEX_PENDING_MAX_BYTES } from '../../../../../shared/terminal-multiplex-flow-control' +import type { TerminalMultiplexStream, TerminalOutputChunk } from './terminal-stream-types' + +export function appendPendingMultiplexOutput( + stream: TerminalMultiplexStream, + data: string, + meta?: TerminalOutputMeta +): void { + const remainingBudget = Math.max( + 1, + TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - stream.pendingOutputBytes + ) + const measurement = measureTerminalStreamByteLength(data, { + stopAfterBytes: remainingBudget + }) + stream.pendingOutput.push({ data, bytes: measurement.byteLength, meta }) + stream.pendingOutputBytes += measurement.byteLength + const trimmed = trimPendingOutputToBudget(stream.pendingOutput, stream.pendingOutputBytes) + stream.pendingOutputBytes = trimmed.bytes + stream.pendingOutputOverflowed ||= trimmed.overflowed +} + +export function getOutputAfterSnapshotSeq( + chunk: TerminalOutputChunk, + snapshotSeq: number | undefined +): TerminalOutputChunk | null { + if ( + typeof snapshotSeq !== 'number' || + typeof chunk.meta?.seq !== 'number' || + typeof chunk.meta.rawLength !== 'number' + ) { + return chunk + } + if (chunk.meta.seq <= snapshotSeq) { + return null + } + const chunkStartSeq = chunk.meta.seq - chunk.meta.rawLength + if (chunkStartSeq >= snapshotSeq) { + return chunk + } + if (chunk.meta.transformed) { + return null + } + const offset = snapshotSeq - chunkStartSeq + return { + data: chunk.data.slice(offset), + bytes: chunk.bytes, + meta: { + ...chunk.meta, + rawLength: chunk.meta.rawLength - offset, + sourceRanges: sliceTerminalOutputSourceRanges( + chunk.meta.sourceRanges, + offset, + chunk.data.length + ) + } + } +} + +export function stripSnapshotBoundaryQuerySuffixes( + data: string, + dataStartSeq: number, + snapshotSeq: number, + queries: TerminalReplyQuerySequence[] +): string { + let output = '' + let offset = 0 + for (const query of queries) { + if (query.startSeq >= snapshotSeq || query.endSeq <= snapshotSeq) { + continue + } + const removeStart = Math.max(0, query.startSeq - dataStartSeq) + const removeEnd = Math.min(data.length, query.endSeq - dataStartSeq) + if (removeEnd <= offset || removeStart >= data.length) { + continue + } + output += data.slice(offset, removeStart) + offset = removeEnd + } + return output + data.slice(offset) +} + +export function appendAckPendingOutput( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk +): void { + stream.ackPendingOutput.push(chunk) + stream.ackPendingOutputBytes += chunk.bytes.byteLength + let omittedChunkCount = 0 + while ( + stream.ackPendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && + omittedChunkCount < stream.ackPendingOutput.length + ) { + stream.ackPendingOutputBytes -= stream.ackPendingOutput[omittedChunkCount]!.bytes.byteLength + omittedChunkCount += 1 + } + if (omittedChunkCount > 0) { + stream.ackPendingOutput.splice(0, omittedChunkCount) + stream.ackPendingOutputOverflowed = true + } +} + +export function trimPendingOutputToBudget( + pendingOutput: TerminalOutputChunk[], + pendingOutputBytes: number +): { bytes: number; overflowed: boolean } { + let omittedChunkCount = 0 + while ( + pendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && + omittedChunkCount < pendingOutput.length + ) { + const chunk = pendingOutput[omittedChunkCount] + pendingOutputBytes -= chunk.bytes + omittedChunkCount += 1 + } + if (omittedChunkCount > 0) { + pendingOutput.splice(0, omittedChunkCount) + } + return { bytes: pendingOutputBytes, overflowed: omittedChunkCount > 0 } +} + +export function trimPendingOutputCoveredBySnapshot( + pendingOutput: TerminalOutputChunk[], + snapshotSeq: number | undefined +): { chunks: TerminalOutputChunk[]; bytes: number } { + if (typeof snapshotSeq !== 'number') { + return { + chunks: pendingOutput, + bytes: pendingOutput.reduce((sum, chunk) => sum + chunk.bytes, 0) + } + } + const chunks: TerminalOutputChunk[] = [] + let bytes = 0 + for (const chunk of pendingOutput) { + const chunkSeq = chunk.meta?.seq + const rawLength = chunk.meta?.rawLength ?? chunk.data.length + if (typeof chunkSeq !== 'number' || rawLength !== chunk.data.length) { + chunks.push(chunk) + bytes += chunk.bytes + continue + } + const startSeq = chunkSeq - rawLength + if (snapshotSeq >= chunkSeq) { + continue + } + if (snapshotSeq <= startSeq) { + chunks.push(chunk) + bytes += chunk.bytes + continue + } + const data = chunk.data.slice(snapshotSeq - startSeq) + const slicedBytes = terminalStreamByteLength(data) + chunks.push({ data, bytes: slicedBytes, meta: undefined }) + bytes += slicedBytes + } + return { chunks, bytes } +} + +export function* iterateTerminalStreamTextPayloads( + data: string +): Generator> { + if (!data) { + return + } + for (const chunk of iterateTerminalOutputFrameChunks(data)) { + yield chunk.bytes + } +} + +export function isTerminalReadPayloadIncomplete(read: { + truncated: boolean + limited?: boolean +}): boolean { + // Why: a limited preview is an incomplete payload even when the retained buffer wasn't truncated. + return read.truncated || read.limited === true +} + +export function normalizeMultiplexSnapshotScrollbackRows( + value: number | undefined +): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined + } + return Math.max(0, Math.min(50_000, Math.floor(value))) +} + +export function requestedSnapshotScrollbackCandidates(requestedRows: number | undefined): number[] { + const candidates = [requestedRows ?? 0, 1000, 500, 250, 100, 25, 0] + .filter((rows): rows is number => typeof rows === 'number') + .map((rows) => Math.max(0, Math.min(50_000, Math.floor(rows)))) + return [...new Set(candidates)] +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts new file mode 100644 index 00000000000..484a9f0f933 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts @@ -0,0 +1,100 @@ +import type { TerminalOscLinkRange } from '../../../../../shared/terminal-osc-link-ranges' +import type { TerminalSnapshotUnavailableReason } from '../../../../../shared/terminal-snapshot-unavailability' +import type { TerminalSourceRangeLedger } from '../../terminal-source-range-ledger' +import type { RemoteTerminalSourceRangeReplacementReservation } from '../../../remote-terminal-source-range-consumer' +import type { + TerminalOutputFrameChunk, + TerminalOutputMeta +} from '../../terminal-output-frame-chunks' +import type { TerminalOutputBatcher } from './terminal-output-batcher' + +export type SnapshotFrameOptions = { + kind: 'scrollback' | 'resized' + cols: number + rows: number + data: string + requestId?: number + displayMode?: string + reason?: string + seq?: number + cwd?: string | null + truncated?: boolean + truncatedByByteBudget?: boolean + // Why: distinguishes "I could not answer right now" from a genuinely empty buffer; omitted on success. + unavailable?: TerminalSnapshotUnavailableReason + source?: 'headless' | 'renderer' + oscLinks?: TerminalOscLinkRange[] + pendingEscapeTailAnsi?: string + /** Effective kitty flags proven at this frame's own `seq`. */ + kittyKeyboardFlags?: number +} + +export type SerializedSnapshot = { + data: string + scrollbackAnsi?: string + cols: number + rows: number + seq?: number + cwd?: string | null + source?: 'headless' | 'renderer' + oscLinks?: TerminalOscLinkRange[] + scrollbackRows: number + truncatedByByteBudget: boolean + pendingEscapeTailAnsi?: string + kittyKeyboardFlags?: number +} | null + +export type TerminalViewportClient = { + id: string + type?: 'mobile' | 'desktop' +} + +export type TerminalMultiplexStream = { + streamId: number + terminal: string + ptyId: string + client: TerminalViewportClient | undefined + isMobile: boolean + ackOutput: boolean + ackOutputSourceRanges: boolean + streamGeneration: string + sourceRangeLedger: TerminalSourceRangeLedger | null + sourceRangeConsumerAttached: boolean + sourceRangeReplacement: RemoteTerminalSourceRangeReplacementReservation | null + ackInFlightBytes: number + ackWindowBytes: number + supportsOutputPause: boolean + supportsWriteUnavailable: boolean + outputPaused: boolean + supportsDesktopViewportClaims: boolean + desktopClaimTail: Promise + // Whether THIS stream registered the width driver, so detach won't release a peer stream's floor. + registeredRemoteDesktopDriver: boolean + remoteDesktopSubscriptionKey: string + pendingRemoteDesktopViewport: { cols: number; rows: number } | null + buffering: boolean + ackPendingOutput: TerminalOutputFrameChunk[] + ackPendingOutputBytes: number + ackPendingOutputOverflowed: boolean + ackRecoverySnapshotInFlight: boolean + pendingOutput: TerminalOutputChunk[] + pendingOutputBytes: number + pendingOutputOverflowed: boolean + // Cols the mobile client last rewrapped to; re-stream full scrollback only when width actually changes. + lastResizeCols: number | undefined + resizeGeneration: number + outputBatcher: TerminalOutputBatcher + unsubscribeData: () => void + unsubscribeResize: () => void + unsubscribeFit: () => void + unsubscribeDriver: () => void + unregisterBinaryHandler: () => void + // Why: the runtime drops the exit-waiter only on real PTY exit; abort on detach so a never-exiting agent terminal doesn't leak the waiter. + exitWaiterAbort: AbortController +} + +export type TerminalOutputChunk = { + data: string + bytes: number + meta?: TerminalOutputMeta +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts new file mode 100644 index 00000000000..bd16382d747 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts @@ -0,0 +1,95 @@ +import { defineStreamingMethod, type RpcAnyMethod } from '../../core' +import { TerminalSubscribe } from './stream-schemas' +import { isTerminalReadPayloadIncomplete } from './terminal-stream-replay' +import { runTerminalBinarySubscription } from './terminal-legacy-subscribe-binary' +import { + runTerminalJsonSubscription, + runTerminalLeaseSubscription +} from './terminal-legacy-simple-subscriptions' +import type { TerminalSubscriptionArgs } from './terminal-legacy-subscription-types' + +export const TERMINAL_SUBSCRIBE_METHODS: RpcAnyMethod[] = [ + // Streams live terminal output over WebSocket; mobile clients pass client+viewport for server-side auto-fit. + defineStreamingMethod({ + name: 'terminal.subscribe', + params: TerminalSubscribe, + handler: async ( + params, + { runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal }, + emit + ) => { + let leaf = runtime.resolveLeafForHandle(params.terminal) + const isMobile = params.client?.type === 'mobile' + const serializerGenerationBeforeAnyMount = isMobile + ? (runtime.getRendererTerminalSerializerGenerationForHandle?.(params.terminal) ?? 0) + : 0 + let rendererMountRequestedBeforePty = false + const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary) + if (signal?.aborted) { + return + } + + if (!leaf?.ptyId && params.client) { + rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal) + try { + const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) + leaf = { ptyId } + } catch { + if (signal?.aborted) { + return + } + } + } + if (!leaf?.ptyId) { + const read = await runtime.readTerminal(params.terminal) + emit({ + type: 'subscribed', + streamId: null, + lines: read.tail, + truncated: isTerminalReadPayloadIncomplete(read) + }) + emit({ type: 'end' }) + return + } + if (isMobile && (!useBinaryStream || !sendBinary)) { + throw new Error('binary_terminal_stream_required') + } + + const ptyId = leaf.ptyId + const clientId = params.client?.id + const missingHeadlessStateBeforeMobileFit = + isMobile && + (rendererMountRequestedBeforePty || runtime.hasHeadlessTerminalState?.(ptyId) === false) + const args: TerminalSubscriptionArgs = { + params, + runtime, + connectionId, + sendBinary, + registerBinaryStreamHandler, + signal, + emit, + ptyId, + clientId, + isMobile, + supportsDesktopViewportClaims: params.capabilities?.desktopViewportClaims === 1, + supportsWriteUnavailable: params.capabilities?.writeUnavailable === 1, + rendererMountRequestedBeforePty, + missingHeadlessStateBeforeMobileFit, + serializerGenerationBeforeMobileFit: missingHeadlessStateBeforeMobileFit + ? rendererMountRequestedBeforePty + ? serializerGenerationBeforeAnyMount + : runtime.getRendererTerminalSerializerGeneration(ptyId) + : 0 + } + if (isMobile && params.capabilities?.mobileInputLeaseOnly === 1 && Boolean(clientId)) { + await runTerminalLeaseSubscription(args) + return + } + if (!useBinaryStream) { + await runTerminalJsonSubscription(args) + return + } + await runTerminalBinarySubscription(args) + } + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscription-stream-id.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscription-stream-id.ts new file mode 100644 index 00000000000..66c50b3f8c3 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscription-stream-id.ts @@ -0,0 +1,5 @@ +let nextTerminalStreamId = 1 + +export function allocateTerminalSubscriptionStreamId(): number { + return nextTerminalStreamId++ +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts new file mode 100644 index 00000000000..71feee4f24d --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewport-methods.ts @@ -0,0 +1,120 @@ +import { z } from 'zod' +import { defineMethod, type RpcAnyMethod } from '../../core' +import { TerminalHandle } from './unary-schemas' +import { + TerminalSetAutoRestoreFit, + TerminalSetDisplayMode, + TerminalUnsubscribe, + TerminalUpdateViewport +} from './viewport-schemas' +import { updateViewportForClient } from './terminal-viewport-update' + +export const TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS: RpcAnyMethod[] = [ + defineMethod({ + name: 'terminal.setDisplayMode', + params: TerminalSetDisplayMode, + handler: async (params, { runtime }) => { + // Why: a stale handle must fail with terminal_handle_stale, not mutate the wrong PTY's display mode/viewport (#7718). + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + // Why: late-bind viewport for desktop-subscribed callers; otherwise an 'auto' toggle skips phone-fit and nothing resizes. + if (params.viewport && params.client?.id) { + runtime.updateMobileSubscriberViewport(leaf.ptyId, params.client.id, params.viewport) + } + if (params.client && params.client.type === 'mobile' && params.mode !== 'desktop') { + runtime.markMobileActor(leaf.ptyId, params.client.id) + } + runtime.setMobileDisplayMode(leaf.ptyId, params.mode) + await runtime.applyMobileDisplayMode(leaf.ptyId) + return { mode: params.mode, seq: runtime.getLayout(leaf.ptyId)?.seq } + } + }), + defineMethod({ + name: 'terminal.restoreFit', + params: TerminalHandle, + handler: async (params, { runtime }) => { + // Why: a stale handle must fail with terminal_handle_stale, not reclaim the wrong PTY to desktop dims (#7718). + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + return { restored: await runtime.reclaimTerminalForDesktop(leaf.ptyId) } + } + }), + defineMethod({ + name: 'terminal.getDisplayMode', + params: TerminalHandle, + handler: async (params, { runtime }) => { + const leaf = runtime.resolveLeafForHandle(params.terminal) + const mode = leaf?.ptyId ? runtime.getMobileDisplayMode(leaf.ptyId) : 'auto' + const isPhoneFitted = leaf?.ptyId ? runtime.isMobileSubscriberActive(leaf.ptyId) : false + return { mode, isPhoneFitted } + } + }), + defineMethod({ + name: 'terminal.updateViewport', + params: TerminalUpdateViewport, + handler: async (params, { runtime }) => { + // Why: a stale handle must fail with terminal_handle_stale, not write viewport state to the wrong PTY (#7718). + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) + if (!leaf?.ptyId) { + throw new Error('no_connected_pty') + } + const viewportUpdate = await updateViewportForClient( + runtime, + leaf.ptyId, + `viewport:${params.client.id}`, + params.client, + params.viewport, + 'mobile', + // Why: one-shot RPC with no disconnect hook — refresh the existing stream-owned floor, never create a leak-prone one. + 'refresh', + params.claim === true + ) + return { ...viewportUpdate, seq: runtime.getLayout(leaf.ptyId)?.seq } + } + }) +] + +export const TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS: RpcAnyMethod[] = [ + defineMethod({ + name: 'terminal.unsubscribe', + params: TerminalUnsubscribe, + handler: async (params, { runtime, connectionId }) => { + // Why: only the connection that owns the subscription may retire it — a stale + // unsubscribe from the pre-reconnect socket names an id the replacement now owns. + let unsubscribed = runtime.cleanupSubscriptionIfOwnedByConnection( + params.subscriptionId, + connectionId + ) + // Why: older builds send a bare-handle subscriptionId, so also try the reconstructed `${terminal}:${clientId}` composite key. + // Why AND over the calls that ran: a clientless stream registers under the bare id + // and a client-scoped one under the composite, so either call can be the real + // teardown. Reporting false needs one genuine refusal, not merely a missing id. + if (params.client && !params.subscriptionId.includes(':')) { + unsubscribed = + runtime.cleanupSubscriptionIfOwnedByConnection( + `${params.subscriptionId}:${params.client.id}`, + connectionId + ) && unsubscribed + } + return { unsubscribed } + } + }), + defineMethod({ + name: 'terminal.getAutoRestoreFit', + params: z.object({}), + handler: async (_params, { runtime }) => ({ + ms: runtime.getMobileAutoRestoreFitMs() + }) + }), + defineMethod({ + name: 'terminal.setAutoRestoreFit', + params: TerminalSetAutoRestoreFit, + handler: async (params, { runtime }) => ({ + ms: runtime.setMobileAutoRestoreFitMs(params.ms) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/terminal/terminal-viewport-update.ts b/src/main/runtime/rpc/methods/terminal/terminal-viewport-update.ts new file mode 100644 index 00000000000..cfcaa3a0896 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/terminal-viewport-update.ts @@ -0,0 +1,38 @@ +import type { OrcaRuntimeService } from '../../../orca-runtime' +import type { TerminalViewportClient } from './terminal-stream-types' + +export async function updateViewportForClient( + runtime: OrcaRuntimeService, + ptyId: string, + subscriptionKey: string, + client: TerminalViewportClient, + viewport: { cols: number; rows: number }, + defaultType: 'mobile' | 'desktop', + // Why: the one-shot RPC has no disconnect hook, so 'refresh' only updates a stream-owned floor; stream paths that own cleanup 'register'. + registration: 'register' | 'refresh' = 'register', + claim = false +): Promise<{ updated: boolean; applied: boolean }> { + const type = client.type ?? defaultType + if (type === 'mobile') { + return runtime.updateMobileViewport(ptyId, client.id, viewport) + } + // Why: stream attachment observes geometry without taking control; a later claim frame makes it authoritative. + const updated = + registration === 'refresh' + ? await runtime.refreshRemoteDesktopViewer( + ptyId, + client.id, + viewport.cols, + viewport.rows, + claim + ) + : await runtime.updateRemoteDesktopViewer( + ptyId, + subscriptionKey, + client.id, + viewport.cols, + viewport.rows, + claim + ) + return { updated, applied: updated } +} diff --git a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts new file mode 100644 index 00000000000..f60b418ca05 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts @@ -0,0 +1,203 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from '../../schemas' +import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../../shared/feature-education-telemetry' +import { isTuiAgent } from '../../../../../shared/tui-agent-config' + +export const TerminalHandle = z.object({ + terminal: requiredString('Missing terminal handle') +}) + +export const TerminalFocus = TerminalHandle.extend({ + navigation: z.enum(['caller', 'host']).optional() +}) + +export const TerminalListParams = z.object({ + worktree: OptionalString, + limit: OptionalFiniteNumber, + handles: z + .array(requiredString('Missing terminal handle').pipe(z.string().max(256))) + .max(64) + .optional(), + requireFreshPtyLiveness: z.boolean().optional(), + // Why: layouts are ~31% of a large listing and only the human CLI formatter + // reads them. Absent means "include" so pre-flag clients keep rendering them. + includeVisualLayouts: z.boolean().optional() +}) + +export const TerminalResolveActive = z.object({ + worktree: OptionalString +}) + +export const TerminalResolvePane = z.object({ + paneKey: requiredString('Missing pane key'), + worktreeId: OptionalString +}) + +export const TerminalRecoverPane = z.object({ + paneKey: requiredString('Missing pane key'), + worktreeId: requiredString('Missing worktree ID'), + expectedTerminal: requiredString('Missing expected terminal handle').optional() +}) + +export const TerminalRead = TerminalHandle.extend({ + cursor: z + .unknown() + .transform((value) => { + if (value === undefined) { + return undefined + } + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + return Number.NaN + } + return value + }) + .pipe( + z + .number() + .optional() + .refine((v) => v === undefined || Number.isFinite(v), { + message: 'Cursor must be a non-negative integer' + }) + ) + .optional(), + limit: OptionalFiniteNumber, + // Why: optional so an older host that does not understand it simply drops the key and answers + // with its usual stream read; the response's `source` is what tells the caller which it got. + screen: z.literal(true).optional() +}).refine((params) => !(params.screen === true && params.cursor !== undefined), { + // Why: a cursor pages through accumulated output; a screen is the current frame with nothing + // behind it. Honoring both would answer with rendered lines carrying the stream's pagination + // metadata — two frames of reference in one payload, which is the confusion `source` exists to + // remove. The CLI already refuses the pair, but the RPC is reachable without it. + message: 'Cursor cannot be combined with a screen read' +}) + +// Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct. +export const TerminalRename = TerminalHandle.extend({ + title: z.custom((value) => value === null || typeof value === 'string', { + message: 'Missing --title (pass empty string or null to reset)' + }) +}) + +export const TerminalSend = TerminalHandle.extend({ + text: OptionalString, + enter: z.unknown().optional(), + interrupt: z.unknown().optional(), + // Why: older hosts strip this optional intent and retain their direct-send behavior. + agentPrompt: z.literal(true).optional(), + resolvedLaunchDraft: z + .object({ + text: z.string(), + createdAt: z.number().finite() + }) + .optional(), + requireAgentStatus: z.enum(['sendable']).optional(), + // Why: terminal-generated replies are valid input but must not transfer the shared terminal floor. + inputKind: z.enum(['query-reply']).optional(), + // Why: identifies the caller for the driver state machine; when absent (older clients) the server falls back to the most recent mobile actor (docs/mobile-presence-lock.md). + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop').optional() + }) + .optional(), + viewport: z + .object({ + cols: z.number().int().min(1).max(1000), + rows: z.number().int().min(1).max(500) + }) + .optional(), + claimViewport: z.literal(true).optional() +}) + +export const TerminalViewport = z.object({ + cols: z.number().int().min(1).max(1000), + rows: z.number().int().min(1).max(500) +}) + +export const TerminalWait = TerminalHandle.extend({ + for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', { + message: 'Invalid --for value. Supported: exit, tui-idle' + }), + timeoutMs: OptionalFiniteNumber +}) + +export const TerminalCreateParams = z.object({ + worktree: OptionalString, + clientMutationId: z.string().min(1).max(128).optional(), + reconcileExisting: z.boolean().optional(), + command: OptionalString, + startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), + env: z.record(z.string(), z.string()).optional(), + envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), + launchConfig: z + .object({ + agentCommand: z.string().optional(), + agentArgs: z.string(), + agentEnv: z.record(z.string(), z.string()), + ompResumeFilePath: z + .string() + .min(1) + .max(32 * 1024) + .optional() + }) + .optional(), + resumeProviderSession: z + .object({ + key: z.enum(['session_id', 'conversation_id']), + id: z.string().min(1).max(512), + transcriptPath: z.string().min(1).max(32_768).optional() + }) + .optional(), + launchToken: OptionalString, + launchAgent: z.string().refine(isTuiAgent).optional(), + terminalColorQueryReplies: z + .object({ + foreground: z.string().max(128).optional(), + background: z.string().max(128).optional() + }) + .optional(), + title: OptionalString, + focus: z.unknown().optional(), + rendererBacked: z.unknown().optional(), + activate: z.unknown().optional(), + presentation: z.enum(['background', 'focused']).optional(), + tabId: OptionalString, + leafId: OptionalString +}) + +export const TerminalSplit = TerminalHandle.extend({ + direction: z + .unknown() + .transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined)) + .pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()])) + .optional(), + command: OptionalString, + env: z.record(z.string(), z.string()).optional(), + telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional() +}) + +export const TerminalStop = z.object({ + worktree: requiredString('Missing worktree selector') +}) + +export const TerminalSleep = TerminalStop + +export const TerminalStopExact = TerminalStop.extend({ + expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1), + keepHistory: z.boolean().optional(), + targetOnly: z.boolean().optional() +}) + +export const AgentTeamsTmuxCompat = z.object({ + teamId: requiredString('Missing agent team ID'), + token: requiredString('Missing agent team token'), + envPane: requiredString('Missing tmux pane identity'), + cwd: OptionalString, + argv: z.array(z.string()) +}) + +export const AgentTeamsPrepareLaunch = z.object({ + paneKey: requiredString('Missing pane key'), + env: z.record(z.string(), z.string()).optional() +}) diff --git a/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts b/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts new file mode 100644 index 00000000000..d9b76d66ea1 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal/viewport-schemas.ts @@ -0,0 +1,51 @@ +import { z } from 'zod' +import { requiredString } from '../../schemas' + +const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') }) + +export const TerminalSetDisplayMode = TerminalHandle.extend({ + // Why: 'auto' = mobile drives dims while subscribed (desktop restores on last-leave); 'desktop' = no resize, mobile scales to fit. + mode: z.enum(['auto', 'desktop']), + // Why: identifies the caller for the driver state machine; optional for older mobile clients. + client: z + .object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('desktop').optional() + }) + .optional(), + // Why: carries the measured viewport so an 'auto' toggle on a viewport-less record can phone-fit instead of no-op'ing. + viewport: z + .object({ + cols: z.number().int().positive(), + rows: z.number().int().positive() + }) + .optional() +}) + +export const TerminalUnsubscribe = z.object({ + subscriptionId: requiredString('Missing subscription ID'), + // Why: lets the server rebuild the composite `${terminal}:${clientId}` cleanup key when older clients pass a bare subscriptionId (docs/mobile-presence-lock.md). + client: z + .object({ + id: requiredString('Missing client ID') + }) + .optional() +}) + +// Why: in-place update avoids an unsubscribe→resubscribe that flashed the lock banner and stranded the PTY at phone dims (docs/mobile-presence-lock.md). +export const TerminalUpdateViewport = TerminalHandle.extend({ + client: z.object({ + id: requiredString('Missing client ID'), + type: z.enum(['mobile', 'desktop']).default('mobile').optional() + }), + viewport: z.object({ + cols: z.number().int().min(20).max(240), + rows: z.number().int().min(8).max(120) + }), + claim: z.boolean().optional() +}) + +// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md); `null` = Indefinite, finite ms clamped to [5_000, 60min] server-side. +export const TerminalSetAutoRestoreFit = z.object({ + ms: z.number().nullable() +}) diff --git a/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts b/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts index d394caa30cd..68f8b040479 100644 --- a/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts +++ b/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts @@ -4,14 +4,18 @@ import type { RpcRequest } from '../core' import { RpcDispatcher } from '../dispatcher' import { WORKTREE_METHODS } from './worktree' -function makeRuntime(): OrcaRuntimeService { +function makeRuntime(repoHostIds: (string | undefined)[] = ['local']): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + listRepos: () => + repoHostIds.map((executionHostId) => ({ id: 'repo-1', path: '/repo', executionHostId })), showManagedWorktree: vi.fn().mockResolvedValue({ id: 'wt-1', hostId: 'local' }), removeManagedWorktree: vi.fn().mockResolvedValue({}) } as unknown as OrcaRuntimeService } +const WORKTREE_ID = 'repo-1::/repo/wt' + function makeRequest(params: unknown): RpcRequest { return { id: 'req-1', authToken: 'tok', method: 'worktree.rm', params } } @@ -35,6 +39,93 @@ describe('worktree.rm host qualification', () => { expect(response).toMatchObject({ ok: true, result: { removed: true } }) }) + it("resolves a paired client's own name for this host to our spelling", async () => { + const runtime = makeRuntime(['local']) + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest({ + worktree: `id:${WORKTREE_ID}`, + hostId: 'runtime:env-1', + force: true, + runHooks: false + }) + ) + + expect(runtime.removeManagedWorktree).toHaveBeenCalledWith( + `id:${WORKTREE_ID}`, + true, + false, + false, + 'local' + ) + expect(response).toMatchObject({ ok: true, result: { removed: true } }) + }) + + it('keeps a client-minted stamp this store still uses for its own repo', async () => { + const runtime = makeRuntime(['runtime:env-1']) + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + await dispatcher.dispatch( + makeRequest({ + worktree: `id:${WORKTREE_ID}`, + hostId: 'runtime:env-1', + force: true, + runHooks: false + }) + ) + + expect(runtime.removeManagedWorktree).toHaveBeenCalledWith( + `id:${WORKTREE_ID}`, + true, + false, + false, + 'runtime:env-1' + ) + }) + + it('fails closed when the repo id carries both spellings', async () => { + const runtime = makeRuntime(['local', 'runtime:env-1']) + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest({ + worktree: `id:${WORKTREE_ID}`, + hostId: 'runtime:env-1', + force: true, + runHooks: false + }) + ) + + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('ambiguous across hosts') } + }) + expect(runtime.removeManagedWorktree).not.toHaveBeenCalled() + }) + + it('leaves an ssh qualifier alone, since we do proxy those onward', async () => { + const runtime = makeRuntime(['ssh:target-a']) + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + await dispatcher.dispatch( + makeRequest({ + worktree: `id:${WORKTREE_ID}`, + hostId: 'ssh:target-a', + force: true, + runHooks: false + }) + ) + + expect(runtime.removeManagedWorktree).toHaveBeenCalledWith( + `id:${WORKTREE_ID}`, + true, + false, + false, + 'ssh:target-a' + ) + }) + it.each([['bogus'], ['ssh:'], ['runtime:'], [''], [null], [42]])( 'fails closed when an explicit host id is malformed: %j', async (hostId) => { diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index ce02c032daf..53419d80e18 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -174,7 +174,7 @@ export const WorktreeRemove = WorktreeSelector.extend({ }) export const WorktreeForceDeleteBranch = WorktreeSelector.extend({ - hostId: OptionalString, + hostId: OptionalExecutionHostId, branchName: z .unknown() .transform((v) => (typeof v === 'string' ? v : '')) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 7503d65d623..080d68a71a6 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -6,6 +6,7 @@ import { import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-provenance' import { defineMethod, type RpcMethod } from '../core' import { buildManagedWorktreeCreateArgs } from './worktree-create-args' +import { resolvePairedCallerHostId } from './paired-caller-host-id' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-context' import { WorktreeCreate, WorktreePrefetchCreateBase } from './worktree-create-schemas' @@ -204,10 +205,15 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: 'worktree.rm', params: WorktreeRemove, handler: async (params, { runtime }) => { + // Translate a paired client's runtime-local host spelling before host-qualified reads. + let resolvedHostId = resolvePairedCallerHostId( + () => runtime.listRepos(), + params.worktree, + params.hostId + ) // Older mobile clients omit hostId, so resolve through the ambiguity gate // before pinning removal. An ambiguous selector still fails closed: two // hosts own the id and an unqualified client cannot say which it meant. - let resolvedHostId = params.hostId if (!resolvedHostId) { try { resolvedHostId = (await runtime.showManagedWorktree(params.worktree)).hostId @@ -237,18 +243,24 @@ export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.forceDeleteBranch', params: WorktreeForceDeleteBranch, - handler: async (params, { runtime }) => - params.hostId + handler: async (params, { runtime }) => { + const hostId = resolvePairedCallerHostId( + () => runtime.listRepos(), + params.worktree, + params.hostId + ) + return hostId ? runtime.forceDeletePreservedBranch( params.worktree, params.branchName, params.expectedHead, - params.hostId + hostId ) : runtime.forceDeletePreservedBranch( params.worktree, params.branchName, params.expectedHead ) + } }) ] diff --git a/src/main/runtime/runtime-browser-commands-factory.ts b/src/main/runtime/runtime-browser-commands-factory.ts index 99e321aa310..da6ad00d67a 100644 --- a/src/main/runtime/runtime-browser-commands-factory.ts +++ b/src/main/runtime/runtime-browser-commands-factory.ts @@ -1,3 +1,8 @@ +import { BrowserError } from '../browser/browser-error' +import { + BROWSER_UNAVAILABLE_ERROR_CODE, + type RuntimeBrowserUnavailableReason +} from '../../shared/runtime-types' import type { RuntimeBrowserCommandHost, RuntimeBrowserCommands } from './orca-runtime-browser' /** @@ -8,9 +13,9 @@ import type { RuntimeBrowserCommandHost, RuntimeBrowserCommands } from './orca-r * 15 modules that a Node host cannot load at all. Importing the class for its *type* * is free; constructing it is what drags the cluster in. * - * The desktop installs the real factory. A Node host installs none and every browser - * RPC rejects with `browser_unavailable`, which the runtime already advertises through - * capability filtering — clients do not offer the affordance. + * The desktop installs the Electron factory. A Node host installs an external provider + * when one resolves; otherwise every browser RPC rejects with the closed + * `browser_unavailable` code and capability filtering hides the affordance. * * Deliberately NOT a stub object with silently-succeeding methods: that is the * "looks fine, returns a lie" shape this codebase rejects. Absent means rejected. @@ -20,12 +25,67 @@ export type RuntimeBrowserCommandsFactory = ( host: RuntimeBrowserCommandHost ) => RuntimeBrowserCommands +export type RuntimeBrowserCommandsFactoryOptions = { + /** The provider owns browser pages without a renderer window. */ + headless?: boolean + /** Live health probe; failure must remove advertised capability. */ + isAvailable?: () => boolean +} + let currentFactory: RuntimeBrowserCommandsFactory | null = null +let currentOptions: RuntimeBrowserCommandsFactoryOptions = {} export function setRuntimeBrowserCommandsFactory( - factory: RuntimeBrowserCommandsFactory | null + factory: RuntimeBrowserCommandsFactory | null, + options: RuntimeBrowserCommandsFactoryOptions = {} ): void { currentFactory = factory + currentOptions = factory ? options : {} +} +export function runtimeBrowserCommandsFactoryIsAvailable(): boolean { + if (!currentFactory) { + return false + } + try { + return currentOptions.isAvailable?.() !== false + } catch { + return false + } +} + +export function runtimeBrowserCommandsFactoryIsHeadless(): boolean { + return runtimeBrowserCommandsFactoryIsAvailable() && currentOptions.headless === true +} + +export type RuntimeBrowserUnavailableCause = { + reason: RuntimeBrowserUnavailableReason + detail?: string +} + +let unavailableCause: RuntimeBrowserUnavailableCause | null = null + +/** + * Recorded by whoever tried to resolve a provider, because only that code can tell a + * missing driver from an unset env var from a launch that threw. Null once one resolves. + */ +export function setRuntimeBrowserUnavailableCause( + cause: RuntimeBrowserUnavailableCause | null +): void { + unavailableCause = cause +} + +/** + * The cause this process can prove. An installed factory outranks any recorded note: it + * means resolution succeeded, so the failure is later and elsewhere — a dead provider, or + * a renderer-backed factory with no window. Unknown beats guessing. + */ +export function runtimeBrowserUnavailableCause(): RuntimeBrowserUnavailableCause { + if (currentFactory) { + return runtimeBrowserCommandsFactoryIsAvailable() + ? { reason: 'desktop_window_unavailable' } + : { reason: 'provider_unhealthy' } + } + return unavailableCause ?? { reason: 'unknown' } } /** @@ -46,7 +106,10 @@ export function createRuntimeBrowserCommands( return undefined } return () => { - throw new Error(`browser_unavailable: ${String(property)} needs a desktop host`) + throw new BrowserError( + BROWSER_UNAVAILABLE_ERROR_CODE, + 'Browser automation is unavailable on this host.' + ) } } }) diff --git a/src/main/skills/skill-delete/recovery.test.ts b/src/main/skills/skill-delete/recovery.test.ts index 70d15bf5b76..86be60d3d68 100644 --- a/src/main/skills/skill-delete/recovery.test.ts +++ b/src/main/skills/skill-delete/recovery.test.ts @@ -175,6 +175,57 @@ describe('recoverSkillDeleteTransaction', () => { expect(await exists(skillDeleteJournalPath(stateDirectory, canonicalPath))).toBe(false) }) + it('clears the journal when the recorded move was never performed', async () => { + // The journal records intent before each rename, so a crash in that window + // leaves a move whose staged path never existed. Rollback must read that as + // already restored, not fail it and retry the same journal at every startup. + const { stateDirectory, root } = await fixture() + const canonicalPath = join(root, 'demo') + await mkdir(canonicalPath, { recursive: true }) + await writeSkillDeleteJournal( + stateDirectory, + journal(root, canonicalPath, { phase: 'staging' }) + ) + + await recoverSkillDeleteTransaction(stateDirectory, canonicalPath) + expect(await exists(canonicalPath)).toBe(true) + expect(await exists(skillDeleteJournalPath(stateDirectory, canonicalPath))).toBe(false) + }) + + it('clears the journal on retry after an earlier pass already restored a move', async () => { + const { home, stateDirectory, root } = await fixture() + const canonicalPath = join(root, 'demo') + const aliasRoot = join(home, '.claude', 'skills', 'demo') + await mkdir(aliasRoot, { recursive: true }) + const entry = journal(root, canonicalPath, { + phase: 'staging', + movedCount: 2, + allowedRoots: [root, join(home, '.claude', 'skills')], + moves: [ + { + sourcePath: join(aliasRoot, 'SKILL.md'), + stagedPath: join(aliasRoot, skillDeleteStagedName('SKILL.md', 'id-2')), + kind: 'alias-file' + }, + { + sourcePath: canonicalPath, + stagedPath: join(root, skillDeleteStagedName('demo', 'id-1')), + kind: 'canonical' + } + ] + }) + // The canonical move came back in an interrupted earlier pass; only the + // alias-file is still parked at its staged path. + await mkdir(canonicalPath, { recursive: true }) + await writeFile(entry.moves[0].stagedPath, 'link-stand-in') + await writeSkillDeleteJournal(stateDirectory, entry) + + await recoverSkillDeleteTransaction(stateDirectory, canonicalPath) + expect(await exists(join(aliasRoot, 'SKILL.md'))).toBe(true) + expect(await exists(canonicalPath)).toBe(true) + expect(await exists(skillDeleteJournalPath(stateDirectory, canonicalPath))).toBe(false) + }) + it('restores canonical before alias-file, so no alias dangles mid-recovery', async () => { const { home, stateDirectory, root } = await fixture() const canonicalPath = join(root, 'demo') diff --git a/src/main/skills/skill-delete/recovery.ts b/src/main/skills/skill-delete/recovery.ts index 960b7f8ed41..10901c0e1b3 100644 --- a/src/main/skills/skill-delete/recovery.ts +++ b/src/main/skills/skill-delete/recovery.ts @@ -168,12 +168,30 @@ export async function recoverSkillDeleteTransaction( let restoredEvery = true for (let index = staged.length - 1; index >= 0; index -= 1) { const move = staged[index] - // Keep going past a failure so the other moves still come back, but keep - // the journal: dropping it here strands the staged path with no record - // that startup could retry from. Restores are idempotent. - await filesystem.rename(move.stagedPath, move.sourcePath).catch(() => { - restoredEvery = false - }) + try { + await filesystem.rename(move.stagedPath, move.sourcePath) + } catch { + // The journal records intent before each rename, so a recorded move may + // never have been performed — and a retry re-walks moves an earlier + // pass already restored. Both leave the staged path gone with nothing + // to move back; counting that as failure would pin the journal forever + // on the exact crash it exists to recover from. Any other failure keeps + // the journal (with the other moves still restored): dropping it would + // strand the staged path with no record startup could retry from. + if (!(await skillStagedPathGone(move.stagedPath, filesystem))) { + restoredEvery = false + } + } + + /** True only on positive proof of absence: an unanswerable inspection must keep + * the journal, never clear it. */ + async function skillStagedPathGone( + stagedPath: string, + filesystem: SkillInstallFilesystem + ): Promise { + const inspections = await filesystem.inspectPaths?.([stagedPath]).catch(() => null) + return inspections?.get(stagedPath)?.kind === 'missing' + } } if (!restoredEvery) { return diff --git a/src/main/skills/skill-delete/wsl-enumeration-protocol.test.ts b/src/main/skills/skill-delete/wsl-enumeration-protocol.test.ts index f5fad941e9f..b80d5feee46 100644 --- a/src/main/skills/skill-delete/wsl-enumeration-protocol.test.ts +++ b/src/main/skills/skill-delete/wsl-enumeration-protocol.test.ts @@ -45,6 +45,14 @@ describe('parseWslListEntriesOutput', () => { WslEnumerationProtocolError ) }) + + it('refuses an unreadable directory rather than reporting it empty', () => { + // An empty listing tells the planner "nothing left here"; the guest marks + // an unreadable directory with `X` so it fails like native EACCES instead. + expect(() => parseWslListEntriesOutput(records('D', '0', 'X'), ['/a'])).toThrow( + WslEnumerationProtocolError + ) + }) }) describe('parseWslInspectPathsOutput', () => { @@ -89,4 +97,9 @@ describe('guest scripts', () => { expect(WSL_LIST_ENTRIES_SCRIPT).toContain('for dir in "$@"; do') expect(WSL_INSPECT_PATHS_SCRIPT).toContain('for path in "$@"; do') }) + + it('marks an unreadable directory instead of listing it as empty', () => { + expect(WSL_LIST_ENTRIES_SCRIPT).toContain('if ! [ -r "$dir" ]; then') + expect(WSL_LIST_ENTRIES_SCRIPT).toContain(`printf 'X\\0'`) + }) }) diff --git a/src/main/skills/skill-delete/wsl-enumeration-protocol.ts b/src/main/skills/skill-delete/wsl-enumeration-protocol.ts index 3776ee58ee3..f548393fcb4 100644 --- a/src/main/skills/skill-delete/wsl-enumeration-protocol.ts +++ b/src/main/skills/skill-delete/wsl-enumeration-protocol.ts @@ -16,6 +16,13 @@ import type { export const WSL_LIST_ENTRIES_SCRIPT = [ 'set -u', 'index=0', + // An unreadable directory must fail the whole call, matching the native + // listEntries: silently reporting it empty would let the planner treat + // unenumerated contents as "nothing left here" and remove the parent. + ' if ! [ -r "$dir" ]; then', + ` printf 'X\\0'`, + ' continue', + ' fi', 'for dir in "$@"; do', ` printf 'D\\0%s\\0' "$index"`, ' index=$((index + 1))', @@ -93,6 +100,9 @@ export function parseWslListEntriesOutput( const directory = directories[Number.parseInt(fields[index++] ?? '', 10)] if (directory === undefined) { throw new WslEnumerationProtocolError() + // `X`: the guest could not read the directory. Refusing the whole call is + // the same contract as the native listEntries throwing on EACCES — only a + // confirmed absence may read as an empty directory. } current = listings.get(directory) continue diff --git a/src/main/skills/skill-update-run.ts b/src/main/skills/skill-update-run.ts index 71365dd12c4..46061ec6905 100644 --- a/src/main/skills/skill-update-run.ts +++ b/src/main/skills/skill-update-run.ts @@ -5,6 +5,7 @@ import { type SkillUpdateStartResult } from '../../shared/skill-freshness' import { resolveCliCommand } from '../codex-cli/command' +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { killWithDescendantSweep } from '../pty-descendant-termination' import { getSpawnArgsForWindows, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from '../win32-utils' @@ -128,7 +129,9 @@ export class SkillUpdateRunner { // is the second half of the CLI's non-interactive gate. stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, - env: process.env + // Why: npx is an `#!/usr/bin/env node` script — without its own node ahead of + // PATH it loads under whatever version leads, and a native dep dies on ABI. + env: withCliRuntimeOnPath(npxCommand, { ...process.env }) }) this.child = child diff --git a/src/main/ssh/ssh-connection-sftp-namespace.test.ts b/src/main/ssh/ssh-connection-sftp-namespace.test.ts index e79f16d65e0..dd568e4032c 100644 --- a/src/main/ssh/ssh-connection-sftp-namespace.test.ts +++ b/src/main/ssh/ssh-connection-sftp-namespace.test.ts @@ -296,25 +296,33 @@ describe('SshConnection SFTP namespace resolution', () => { }) it('aborts a transfer stuck in discovery and reports a confirmed channel close', async () => { - const sftp = createFakeSftp({ pendingRealpath: true }) - const conn = connectedTo([sftp]) - const controller = new AbortController() + vi.useFakeTimers() + try { + const sftp = createFakeSftp({ pendingRealpath: true }) + const conn = connectedTo([sftp]) + const controller = new AbortController() - const write = conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { - hostPlatform: linux, - sftpNamespace: fileNamespace('.version'), - signal: controller.signal - }) - await vi.waitFor(() => expect(sftp.realpathCalls).toHaveLength(1)) - controller.abort() + const write = conn + .writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version'), + signal: controller.signal + }) + .catch((err: Error) => err) + await vi.waitFor(() => expect(sftp.realpathCalls).toHaveLength(1)) + controller.abort() + await vi.advanceTimersByTimeAsync(5_000) - const error = (await write.catch((err: Error) => err)) as Error & { - sshChannelCloseConfirmed?: boolean + const error = (await write) as Error & { + sshChannelCloseConfirmed?: boolean + } + expect(error.name).toBe('AbortError') + expect(error.sshChannelCloseConfirmed).toBe(true) + expect(sftp.endCalls).toBe(1) + expect(sftp.writtenPaths).toEqual([]) + } finally { + vi.useRealTimers() } - expect(error.name).toBe('AbortError') - expect(error.sshChannelCloseConfirmed).toBe(true) - expect(sftp.endCalls).toBe(1) - expect(sftp.writtenPaths).toEqual([]) }) it('reports an unconfirmed close when the aborted session never closes', async () => { @@ -389,7 +397,9 @@ describe('SshConnection SFTP namespace resolution', () => { }) .catch((err: Error) => err) await vi.waitFor(() => expect(sftp.pendingRealpathCallbacks).toHaveLength(1)) + vi.useFakeTimers() controller.abort() + await vi.advanceTimersByTimeAsync(5_000) const error = (await write) as Error & { sshChannelCloseConfirmed?: boolean } expect(error).toMatchObject({ @@ -398,13 +408,15 @@ describe('SshConnection SFTP namespace resolution', () => { }) completeRealpath(sftp.pendingRealpathCallbacks[0]!) - await new Promise((resolve) => setImmediate(resolve)) + await Promise.resolve() + await Promise.resolve() expect(unhandledRejection).not.toHaveBeenCalled() expect(sftp.writtenPaths).toEqual([]) expect(sftp.endCalls).toBe(1) } finally { process.off('unhandledRejection', unhandledRejection) + vi.useRealTimers() } } ) diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 7e03d75ce7a..89c38a6003f 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -50,6 +50,7 @@ type CapturedStatus = { connectionId: string | null payload: { state: string + workingMode?: 'monitoring' prompt: string agentType?: string toolName?: string @@ -184,6 +185,7 @@ function captureAgentStatuses(events: CapturedStatus[]): void { connectionId: event.connectionId, payload: { state: event.payload.state, + ...(event.payload.workingMode ? { workingMode: event.payload.workingMode } : {}), prompt: event.payload.prompt, agentType: event.payload.agentType, toolName: event.payload.toolName @@ -298,6 +300,42 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { }) }) + it('preserves Claude monitoring mode across the SSH relay boundary', async () => { + relay = createFakeRelay() + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: relay.transport, + serverBuildId: 'test-relay-build', + platform: 'linux-x64' + }) + const events: CapturedStatus[] = [] + captureAgentStatuses(events) + session = createSession('conn-monitoring') + await session.establish({} as SshConnection) + + relay.notifyAgentHook( + makeEnvelope({ + source: 'claude', + claudeRunningNonAgentTask: true, + payload: { + state: 'working', + workingMode: 'monitoring', + prompt: 'watch the build', + agentType: 'claude' + } + }) + ) + + await waitForStatusCount(events, 1) + expect(events[0]).toMatchObject({ + connectionId: 'conn-monitoring', + payload: { state: 'working', workingMode: 'monitoring', agentType: 'claude' } + }) + expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + workingMode: 'monitoring' + }) + }) + it('stamps SSH ownership and settles only the exact manual compact identity', async () => { relay = createFakeRelay() vi.mocked(deployAndLaunchRelay).mockResolvedValue({ diff --git a/src/main/startup/hydrate-shell-path.test.ts b/src/main/startup/hydrate-shell-path.test.ts index f096dd03ed3..60c8dc0bd17 100644 --- a/src/main/startup/hydrate-shell-path.test.ts +++ b/src/main/startup/hydrate-shell-path.test.ts @@ -6,6 +6,7 @@ import { _resetHydrateShellPathCache, hydrateShellPath, mergePathSegments, + _setLaunchPathForTests, type HydrationResult } from './hydrate-shell-path' @@ -39,6 +40,7 @@ describe('hydrateShellPath', () => { }) afterEach(() => { + vi.restoreAllMocks() if (originalPath === undefined) { delete process.env.PATH } else { @@ -177,6 +179,57 @@ describe('hydrateShellPath', () => { vi.useRealTimers() } }) + + async function captureProbeEnv(shell = '/bin/bash'): Promise { + const proc = createMockShellProcess() + spawnMock.mockReturnValue(proc) + const resultPromise = hydrateShellPath({ shellOverride: shell, force: true }) + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + proc.emit('close', 0) + await resultPromise + return spawnMock.mock.calls[0][2].env + } + + it('probes with the launch PATH so seeded fallbacks cannot pin nvm', async () => { + process.env.PATH = `/home/u/.nvm/versions/node/v26.7.0/bin${delimiter}/usr/bin` + _setLaunchPathForTests('/usr/bin') + + expect((await captureProbeEnv()).PATH).toBe('/usr/bin') + }) + + it('uses the PATH captured at module load, not a later mutation of process.env', async () => { + // Why: this is the anti-regression guard. patchPackagedProcessPath mutates + // process.env.PATH after this module is evaluated; the probe must not see it. + const seeded = `/seeded/newest-nvm/bin${delimiter}/usr/bin` + process.env.PATH = seeded + + expect((await captureProbeEnv()).PATH).not.toBe(seeded) + }) + + it('sets a probe marker so rc files can take a fast path', async () => { + expect((await captureProbeEnv()).ORCA_SHELL_PATH_PROBE).toBe('1') + }) + + it('overwrites the captured key in place so Windows never carries both Path and PATH', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const originalWindowsPath = process.env.Path + delete process.env.PATH + process.env.PATH = 'C:\\Users\\u\\.nvm\\versions\\node\\v22.9.0\\bin;C:\\Windows' + process.env.Path = 'C:\\Users\\u\\.nvm\\versions\\node\\v22.9.0\\bin;C:\\Windows' + _setLaunchPathForTests('C:\\Windows', 'Path') + + try { + const env = await captureProbeEnv('C:/Git/bin/bash.exe') + expect(env.Path).toBe('C:\\Windows') + expect(env.PATH).toBeUndefined() + } finally { + if (originalWindowsPath === undefined) { + delete process.env.Path + } else { + process.env.Path = originalWindowsPath + } + } + }) }) describe('mergePathSegments', () => { diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index db41fe3f462..3deeb30a738 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -32,6 +32,23 @@ export type HydrationResult = let cached: Promise | null = null let probeQueue = Promise.resolve() +// Why: patchPackagedProcessPath seeds a version manager's newest install dir +// ahead of PATH. nvm's startup `use` honors whatever node is already on PATH over +// the user's `default` alias, so a probe inheriting that seed comes back pinned to +// the newest install and every terminal loses the global CLIs installed under +// `default`. Snapshot PATH here instead: module init runs while the import graph is +// evaluated, strictly before any statement in main's body, so it cannot observe the +// seeds — and unlike an explicit hand-off from the seeding site, there is no call +// ordering left for a later refactor to break. Windows keys it `Path`, and a Git Bash +// user whose nvm uses the POSIX ~/.nvm/versions/node layout is seeded there too, so +// capture the key alongside the value. +const LAUNCH_PATH_KEY = + process.platform === 'win32' && process.env.Path !== undefined ? 'Path' : 'PATH' +const LAUNCH_PATH = process.env[LAUNCH_PATH_KEY] ?? null +// Why: rc files that exec into a multiplexer or start a heavy prompt can outrun the +// probe budget. This lets them detect the probe and take a fast path. +const PROBE_MARKER_ENV_VAR = 'ORCA_SHELL_PATH_PROBE' +let launchPathOverride: { key: string; value: string } | null = null let configuredWindowsShell = 'powershell.exe' let configuredWindowsGitBashPath: string | null = null let configuredWindowsFallbackShell: string | null = null @@ -42,6 +59,7 @@ const windowsPathOwnership = new WindowsShellPathOwnership() export function _resetHydrateShellPathCache(): void { cached = null probeQueue = Promise.resolve() + launchPathOverride = null configuredWindowsShell = 'powershell.exe' configuredWindowsGitBashPath = null configuredWindowsFallbackShell = null @@ -94,6 +112,60 @@ function parseCapturedPath(stdout: string, pathDelimiter: string = delimiter): s ] } +/** @internal - tests need a launch PATH the runner's own environment cannot supply. */ +export function _setLaunchPathForTests(value: string, key = 'PATH'): void { + launchPathOverride = { key, value } +} + +function isLaunchPathKey(name: string, key: string): boolean { + return process.platform === 'win32' ? name.toLowerCase() === key.toLowerCase() : name === key +} + +function applyLaunchPath(env: NodeJS.ProcessEnv, key: string, value: string | null): void { + if (value === null) { + return + } + // Why: Windows resolves env names case-insensitively even though a spread is + // a plain object, so never leave both the captured key and another casing. + for (const name of Object.keys(env)) { + if (name !== key && isLaunchPathKey(name, key)) { + delete env[name] + } + } + env[key] = value +} + +function shellProbeEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, [PROBE_MARKER_ENV_VAR]: '1' } + const key = launchPathOverride?.key ?? LAUNCH_PATH_KEY + const value = launchPathOverride?.value ?? LAUNCH_PATH + applyLaunchPath(env, key, value) + return env +} + +/** Run a synchronous launcher without passing Orca's seeded PATH to its child. */ +export function runWithLaunchPath(action: () => T): T { + const key = launchPathOverride?.key ?? LAUNCH_PATH_KEY + const value = launchPathOverride?.value ?? LAUNCH_PATH + if (value === null) { + return action() + } + const previous = Object.entries(process.env).filter(([name]) => isLaunchPathKey(name, key)) + applyLaunchPath(process.env, key, value) + try { + return action() + } finally { + for (const name of Object.keys(process.env)) { + if (isLaunchPathKey(name, key)) { + delete process.env[name] + } + } + for (const [name, previousValue] of previous) { + process.env[name] = previousValue + } + } +} + function shellPathProbe(shell: string): { args: string[]; pathDelimiter: string } { if (process.platform !== 'win32') { const command = `printf '%s' '${DELIMITER}'; printf '%s' "$PATH"; printf '%s' '${DELIMITER}'` @@ -125,7 +197,7 @@ function spawnShellAndReadPath(shell: string): Promise { // it layer its own rc files on top. Do NOT forward stdio — some shells // (oh-my-zsh setups, powerlevel10k) print a lot to stderr on startup, // and we don't want that in Orca's console. - env: process.env, + env: shellProbeEnv(), stdio: ['ignore', 'pipe', 'ignore'], detached: false, windowsHide: true diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index e4077b3bddf..613b4ab5577 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -19,7 +19,8 @@ import { } from '../../shared/pull-request-generation' import { cleanGeneratedCommitMessage, - excerptAgentFailureOutput + excerptAgentFailureOutput, + sanitizeAgentFailureDetail } from '../../shared/commit-message-prompt' import { captureAgentGenerationFailureOutput, @@ -53,6 +54,7 @@ import type { SourceControlAiOperation } from '../../shared/source-control-ai-ty import { formatLinkedIssueTemplateValue } from '../../shared/source-control-ai-action-variables' import { renderSourceControlActionCommandTemplate } from '../../shared/source-control-ai-actions' import { resolveCliCommand } from '../codex-cli/command' +import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { resolveCodexHomeProcessLockKeyForSpawnEnv, withCodexHomeProcessLock @@ -196,40 +198,6 @@ function formatAgentCliFailureMessage( : withMacTailscaleDnsHint(message, detail) } -function sanitizeAgentFailureDetail(detail: string | null): string | null { - // Cf covers bidi overrides (U+202E etc.) that could visually reorder the - // persisted, client-synced detail. - const trimmed = detail - ?.replace(/[\p{Cc}\p{Cf}]+/gu, ' ') - .replace(/\s+/g, ' ') - .trim() - if (!trimmed) { - return null - } - // Why: agent stderr often includes local or SSH repo paths. Persisting those - // into worktree metadata leaks environment details into synced renderer state. - const redacted = trimmed - .replace( - /\\\\[^\s"'`<>\\]+\\(?:[^\s"'`<>\\]+(?:\s+[^\s"'`<>\\]+)*(?=\\)\\)*[^\s"'`<>\\]+/g, - '[path]' - ) - // Only backslashes may repeat: JSON provider bodies double them - // (`C:\\Users\\name\\…`), while a URL's `://` must stay single so remedy - // links like `https://…` survive redaction. - .replace( - /[A-Za-z]:(?:\\+|\/)(?:[^\s"'`<>\\/|:*?]+(?:\s+[^\s"'`<>\\/|:*?]+)*(?=[\\/])(?:\\+|\/))*[^\s"'`<>\\/|:*?]+/g, - '[path]' - ) - // Why: require ≥2 segments (one internal `/`) so provider remedy tokens like - // `/login` survive while multi-segment paths (`/Users/name/repo`) still redact. - // `=:,` prefixes catch key=/path value:/path list,/path shapes in provider bodies. - .replace( - /(^|[\s"'`(=:,])\/(?:[^\s"'`<>/]+(?:\s+[^\s"'`<>/]+)*(?=\/)\/)+[^\s"'`<>/]+/g, - '$1[path]' - ) - return redacted.length > 240 ? `${redacted.slice(0, 240).trimEnd()}...` : redacted -} - function userFacingUnsafeWindowsBatchArgs(label: string): string { return `${label} cannot be run as a Windows batch command with the prompt in argv. Remove {prompt} so Orca sends the prompt on stdin.` } @@ -377,7 +345,7 @@ export async function discoverCommitMessageModelsLocal( : planned.plan.binary const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, planned.plan.args) child = spawn(spawnCmd, spawnArgs, { - env: spawnEnv, + env: withCliRuntimeOnPath(resolvedBinary, spawnEnv), stdio: [stdinMode, 'pipe', 'pipe'], windowsHide: true }) diff --git a/src/main/updater.quit-and-install.test.ts b/src/main/updater.quit-and-install.test.ts index 2ec23352bf2..945538b3895 100644 --- a/src/main/updater.quit-and-install.test.ts +++ b/src/main/updater.quit-and-install.test.ts @@ -12,6 +12,10 @@ const { resetUpdaterMocks } = await vi.hoisted(async () => (await import('./updater-test-harness')).createUpdaterMocks()) +const { launchPathScope } = vi.hoisted(() => ({ + launchPathScope: { active: false, calls: 0 } +})) + vi.mock('electron', () => moduleFactories.electron()) vi.mock('electron-updater', () => moduleFactories.electronUpdater()) vi.mock('./electron-updater-loader', () => moduleFactories.electronUpdaterLoader()) @@ -25,10 +29,23 @@ vi.mock('./update-install-exit-watchdog', () => moduleFactories.updateInstallExi vi.mock('./updater-prerelease-feed', () => moduleFactories.updaterPrereleaseFeed()) vi.mock('./local-builds/local-build-switch', () => moduleFactories.localBuildSwitch()) vi.mock('./local-builds/local-build-feed-server', () => moduleFactories.localBuildFeedServer()) +vi.mock('./startup/hydrate-shell-path', () => ({ + runWithLaunchPath: (action: () => unknown): unknown => { + launchPathScope.active = true + launchPathScope.calls += 1 + try { + return action() + } finally { + launchPathScope.active = false + } + } +})) describe('updater', () => { beforeEach(() => { resetUpdaterMocks() + launchPathScope.active = false + launchPathScope.calls = 0 }) it('still surfaces updater error events while a download is in flight', async () => { @@ -118,8 +135,12 @@ describe('updater', () => { expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(2) }) - it('defers quitAndInstall through the shared main-process entrypoint', async () => { + it('defers quitAndInstall and runs its launcher within the launch PATH scope', async () => { vi.useFakeTimers() + let launcherSawLaunchPathScope = false + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + launcherSawLaunchPathScope = launchPathScope.active + }) const mainWindow = { webContents: { send: vi.fn() } } const { setupAutoUpdater, quitAndInstall } = await import('./updater') @@ -135,6 +156,9 @@ describe('updater', () => { await vi.advanceTimersByTimeAsync(1) expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(false, true) + expect(launcherSawLaunchPathScope).toBe(true) + expect(launchPathScope.calls).toBe(1) + expect(launchPathScope.active).toBe(false) }) it('runs pre-quit cleanup before local PTY cleanup during update install', async () => { diff --git a/src/main/updater.ts b/src/main/updater.ts index 640906197c8..912c681cfad 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -21,6 +21,7 @@ import { killAllPty } from './ipc/pty' import { withUpdaterSpan } from './observability/instrumentation' import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader' import { writeMainThreadDiagnosticMarker } from './diagnostics/main-thread-churn-probe' +import { runWithLaunchPath } from './startup/hydrate-shell-path' import { beginMacUpdateDownload, deferMacQuitUntilInstallerReady, @@ -788,7 +789,9 @@ async function performQuitAndInstall(): Promise { // Why: BaseUpdater logs child stderr but drops it from the 'error' event, so retain it for the span of this call. beginLinuxPackageInstallDiagnosticCapture(getTrackedLinuxPackageArtifact()?.path ?? null) try { - getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) + runWithLaunchPath(() => + getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) + ) } finally { const diagnostic = endLinuxPackageInstallDiagnosticCapture() // Why: a synchronous 'error' already consumed and reset this attempt; re-stashing would leak it into the next one. diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index 381fef6643e..4fa9436e19e 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -6,9 +6,11 @@ import { getCmdExePath, getRegExePath, getSpawnArgsForWindows, + wrapWindowsStartWait, isPermissionError, isWindowsBatchScript, resolveWindowsCommand, + UnsafeWindowsBatchArgumentsError, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL } from './win32-utils' @@ -119,6 +121,46 @@ describe('getSpawnArgsForWindows', () => { }) }) + it('wraps an interactive login in start /wait with an empty title argv', () => { + withPlatform('win32', () => { + const { spawnCmd, spawnArgs } = wrapWindowsStartWait(getCmdExePath(), [ + '/d', + '/c', + 'C:\\Tools\\claude.cmd', + 'auth', + 'login', + '--claudeai' + ]) + expect(spawnCmd).toBe(getCmdExePath()) + expect(spawnArgs).toEqual([ + '/d', + '/c', + 'start', + '', + '/wait', + getCmdExePath(), + '/d', + '/c', + 'C:\\Tools\\claude.cmd', + 'auth', + 'login', + '--claudeai' + ]) + expect(spawnArgs[3]).toBe('') + expect(spawnArgs).not.toContain('/B') + expect(spawnArgs).not.toContain('""') + }) + }) + + it('rejects cmd metacharacters in executable paths passed through start /wait', () => { + expect(() => wrapWindowsStartWait('C:\\Users\\A%B\\codex.exe', ['login'])).toThrow( + UnsafeWindowsBatchArgumentsError + ) + expect(() => wrapWindowsStartWait('C:\\Tools\\codex.exe', ['log&in'])).toThrow( + UnsafeWindowsBatchArgumentsError + ) + }) + it('leaves .exe GUI launches alone even when detachedGui is requested', () => { withPlatform('win32', () => { const { spawnCmd, spawnArgs } = getSpawnArgsForWindows( diff --git a/src/main/win32-utils.ts b/src/main/win32-utils.ts index 151dc9306f0..5798f22be5a 100644 --- a/src/main/win32-utils.ts +++ b/src/main/win32-utils.ts @@ -5,6 +5,7 @@ import { existsSync } from 'node:fs' export { getCmdExePath, getSpawnArgsForWindows, + wrapWindowsStartWait, isWindowsBatchScript, WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR, WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL, diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index 92068c515ba..d194604ca1f 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -1,172 +1,30 @@ -/* eslint-disable max-lines -- Why: this module keeps local and SSH directory-walk - semantics paired so reclaimable-byte, symlink, and partial-failure behavior cannot drift. */ -import { lstat, opendir } from 'node:fs/promises' import { execFile } from 'node:child_process' -import { posix, win32 } from 'node:path' import { platform } from 'node:process' -import type { Dirent } from 'node:fs' import type { Store } from './persistence' -import { isFolderRepo } from '../shared/repo-kind' -import type { DirEntry } from '../shared/filesystem-entry-types' -import type { Repo } from '../shared/repo-types' -import type { GitWorktreeInfo, Worktree } from '../shared/worktree/types' import type { WorkspaceSpaceAnalysis, - WorkspaceSpaceDirectoryScanResult, - WorkspaceSpaceItem, - WorkspaceSpaceRepoSummary, - WorkspaceSpaceScanProgress, - WorkspaceSpaceScanStatus, - WorkspaceSpaceWorktree + WorkspaceSpaceScanProgress } from '../shared/workspace-space-types' -import { compactWorkspaceSpaceItems } from '../shared/workspace-space-compaction' import { mapWithConcurrency } from '../shared/map-with-concurrency' import { escapeRegex } from '../shared/string-utils' import { - scanWorkspaceSpaceEntryTree, - type WorkspaceSpaceEntryScan -} from '../shared/workspace-space-entry-traversal' + WorkspaceSpaceScanCancelledError, + createWorkspaceSpaceScanLimiter, + throwIfWorkspaceSpaceScanAborted +} from './workspace-space-scan-control' import { - collectWorkspaceSpaceDirectoryEntries, - createWorkspaceSpaceScanBudget, - WorkspaceSpaceScanCapacityError -} from '../shared/workspace-space-scan-budget' -import type { IFilesystemProvider } from './providers/types' -import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' -import { getSshGitProvider } from './providers/ssh-git-dispatch' -import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees' -import { mergeWorktree } from './ipc/worktree-logic' -import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options' -import { getRepoExecutionHostId, getWorktreeExecutionHostId } from '../shared/execution-host' + scanWorkspaceSpaceRepo, + type WorkspaceSpaceAnalyzeOptions, + type WorkspaceSpaceScanLimiters +} from './workspace-space-repo-scan' const REPO_SCAN_CONCURRENCY = 2 -const WORKTREE_SCAN_CONCURRENCY = 3 const LOCAL_WORKTREE_SCAN_CONCURRENCY = 1 -// Why: the SSH compatibility walker traverses inside the desktop main process -// and each traversal carries its own admission budget, so repo × worktree -// concurrency would otherwise stack six independent budgets on this heap. const REMOTE_FALLBACK_SCAN_CONCURRENCY = 2 -const LOCAL_FS_CONCURRENCY = 48 -const REMOTE_FS_CONCURRENCY = 10 const DU_TIMEOUT_MS = 120_000 const DU_MAX_BUFFER_BYTES = 16 * 1024 * 1024 -type AsyncLimiter = (task: () => Promise) => Promise - -type WorkspaceSpaceScanLimiters = { - localWorktree: AsyncLimiter - remoteFallbackTraversal: AsyncLimiter -} - -type ScanStats = WorkspaceSpaceEntryScan - -type WorktreeListResult = - | { ok: true; worktrees: GitWorktreeInfo[] } - | { ok: false; status: Exclude; error: string } - -type RepoScanResult = { - summary: WorkspaceSpaceRepoSummary - worktrees: WorkspaceSpaceWorktree[] -} - -type WorkspaceSpaceAnalyzeOptions = { - signal?: AbortSignal - scanId?: string - onProgress?: (progress: WorkspaceSpaceScanProgress) => void -} - -type WorkspaceSpaceProgressState = WorkspaceSpaceScanProgress - -export class WorkspaceSpaceScanCancelledError extends Error { - constructor() { - super('Workspace space scan cancelled') - this.name = 'WorkspaceSpaceScanCancelledError' - } -} - -function throwIfAborted(signal: AbortSignal | undefined): void { - if (signal?.aborted) { - throw new WorkspaceSpaceScanCancelledError() - } -} - -function isAbortError(error: unknown): boolean { - if (!error || typeof error !== 'object') { - return false - } - return (error as { name?: unknown }).name === 'AbortError' -} - -function isRelayMethodNotFoundError(error: unknown): boolean { - if (!error || typeof error !== 'object') { - return false - } - return (error as { code?: unknown }).code === -32601 -} - -function createAsyncLimiter(maxConcurrent: number, signal?: AbortSignal): AsyncLimiter { - let active = 0 - const queue: { resolve: () => void; reject: (error: Error) => void }[] = [] - - const acquire = async (): Promise => { - throwIfAborted(signal) - if (active < maxConcurrent) { - active += 1 - return - } - await new Promise((resolve, reject) => { - let onAbort: (() => void) | null = null - const waiter = { - resolve: () => { - if (onAbort) { - signal?.removeEventListener('abort', onAbort) - } - resolve() - }, - reject - } - onAbort = () => { - const index = queue.indexOf(waiter) - if (index !== -1) { - queue.splice(index, 1) - } - reject(new WorkspaceSpaceScanCancelledError()) - } - queue.push(waiter) - if (signal) { - signal.addEventListener('abort', onAbort, { once: true }) - if (signal.aborted) { - onAbort() - } - } - }) - throwIfAborted(signal) - active += 1 - } - - return async (task: () => Promise): Promise => { - await acquire() - try { - return await task() - } finally { - active -= 1 - const next = queue.shift() - next?.resolve() - } - } -} - -function looksLikeWindowsPath(pathValue: string): boolean { - return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') -} - -function basenameFilesystemPath(pathValue: string): string { - return looksLikeWindowsPath(pathValue) ? win32.basename(pathValue) : posix.basename(pathValue) -} - -function joinFilesystemPath(parent: string, child: string): string { - return looksLikeWindowsPath(parent) ? win32.join(parent, child) : posix.join(parent, child) -} +export { WorkspaceSpaceScanCancelledError } function normalizeLocalDuPath(pathValue: string): string { const separator = platform === 'win32' ? '\\' : '/' @@ -174,7 +32,7 @@ function normalizeLocalDuPath(pathValue: string): string { return trimmed.length > 0 ? trimmed : pathValue } -function parseDuDepthOneOutput(stdout: string): Map { +function parseWorkspaceSpaceDuOutput(stdout: string): Map { const sizes = new Map() for (const line of stdout.split('\n')) { const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line @@ -229,625 +87,34 @@ async function readLocalDuDepthOne( onAbort() return } - - // Why: execFile's timeout only signals `du`; a wedged child that never - // calls back must not block the Space scan or its portable fallback. try { child = execFile( 'du', ['-k', '-d', '1', rootPath], - { - encoding: 'utf8', - maxBuffer: DU_MAX_BUFFER_BYTES, - signal, - timeout: DU_TIMEOUT_MS - }, - (error, stdout) => { + { encoding: 'utf8', maxBuffer: DU_MAX_BUFFER_BYTES, signal, timeout: DU_TIMEOUT_MS }, + (error, output) => { if (error) { settle(() => reject(error)) return } - settle(() => resolve(String(stdout))) + settle(() => resolve(String(output))) } ) } catch (error) { settle(() => reject(error)) } }) - return parseDuDepthOneOutput(stdout) -} - -function classifyError(error: unknown): { - status: Exclude - message: string -} { - const code = - error && typeof error === 'object' && 'code' in error - ? String((error as { code?: unknown }).code) - : '' - const message = error instanceof Error ? error.message : String(error) - - // Why: a workspace over the scan budget is intact and readable, just too big - // to size safely, so it reads as unavailable rather than a filesystem error. - if (error instanceof WorkspaceSpaceScanCapacityError) { - return { status: 'unavailable', message } - } - if (code === 'ENOENT' || code === 'ENOTDIR') { - return { status: 'missing', message } - } - if (code === 'EACCES' || code === 'EPERM') { - return { status: 'permission-denied', message } - } - return { status: 'error', message } -} - -function toWorkspaceSpaceItem(stats: ScanStats): WorkspaceSpaceItem { - return { - name: stats.name, - path: stats.path, - kind: stats.kind, - sizeBytes: stats.sizeBytes - } -} - -function createBaseWorktreeRow( - repo: Repo, - worktree: Worktree, - scannedAt: number -): Omit< - WorkspaceSpaceWorktree, - | 'status' - | 'error' - | 'sizeBytes' - | 'reclaimableBytes' - | 'skippedEntryCount' - | 'topLevelItems' - | 'omittedTopLevelItemCount' - | 'omittedTopLevelSizeBytes' -> { - const canDelete = !worktree.isMainWorktree - return { - worktreeId: worktree.id, - repoId: repo.id, - executionHostId: getWorktreeExecutionHostId(worktree, repo), - repoDisplayName: repo.displayName, - repoPath: repo.path, - displayName: worktree.displayName, - path: worktree.path, - branch: worktree.branch, - isMainWorktree: worktree.isMainWorktree, - isRemote: Boolean(repo.connectionId), - isSparse: worktree.isSparse === true, - canDelete, - lastActivityAt: worktree.lastActivityAt, - scannedAt - } -} - -function createUnavailableWorktreeRow( - repo: Repo, - worktree: Worktree, - scannedAt: number, - status: Exclude, - error: string -): WorkspaceSpaceWorktree { - return { - ...createBaseWorktreeRow(repo, worktree, scannedAt), - status, - error, - sizeBytes: 0, - reclaimableBytes: 0, - skippedEntryCount: 0, - topLevelItems: [], - omittedTopLevelItemCount: 0, - omittedTopLevelSizeBytes: 0 - } -} - -function createScannedWorktreeRow( - repo: Repo, - worktree: Worktree, - scannedAt: number, - scan: WorkspaceSpaceDirectoryScanResult -): WorkspaceSpaceWorktree { - return { - ...createBaseWorktreeRow(repo, worktree, scannedAt), - status: 'ok', - error: null, - sizeBytes: scan.sizeBytes, - reclaimableBytes: worktree.isMainWorktree ? 0 : scan.sizeBytes, - skippedEntryCount: scan.skippedEntryCount, - topLevelItems: scan.topLevelItems, - omittedTopLevelItemCount: scan.omittedTopLevelItemCount, - omittedTopLevelSizeBytes: scan.omittedTopLevelSizeBytes - } -} - -async function scanLocalEntry( - entryPath: string, - name: string, - signal?: AbortSignal -): Promise { - return scanWorkspaceSpaceEntryTree({ - rootPath: entryPath, - rootName: name, - concurrency: LOCAL_FS_CONCURRENCY, - signal, - entryName: (entry) => entry.name, - joinPath: joinFilesystemPath, - classifyEntry: async (path) => { - const stats = await lstat(path) - throwIfAborted(signal) - if (stats.isSymbolicLink()) { - return { kind: 'symlink', sizeBytes: stats.size } - } - return stats.isDirectory() - ? { kind: 'directory', sizeBytes: stats.size } - : { kind: 'file', sizeBytes: stats.size } - }, - readDirectory: (path) => opendir(path), - checkCancelled: () => throwIfAborted(signal), - createCancellationError: () => new WorkspaceSpaceScanCancelledError(), - isCancellationError: (error) => error instanceof WorkspaceSpaceScanCancelledError - }) -} - -async function scanRemoteEntry( - entryPath: string, - name: string, - provider: IFilesystemProvider, - signal?: AbortSignal -): Promise { - return scanWorkspaceSpaceEntryTree({ - rootPath: entryPath, - rootName: name, - concurrency: REMOTE_FS_CONCURRENCY, - signal, - entryName: (entry) => entry.name, - joinPath: joinFilesystemPath, - classifyEntry: async (path, sourceEntry) => { - if (sourceEntry?.isSymlink) { - return { kind: 'symlink', sizeBytes: 0 } - } - const stats = await provider.stat(path) - throwIfAborted(signal) - if (stats.type === 'symlink') { - return { kind: 'symlink', sizeBytes: stats.size } - } - return stats.type === 'directory' - ? { kind: 'directory', sizeBytes: stats.size } - : { kind: 'file', sizeBytes: stats.size } - }, - readDirectory: (path) => provider.readDir(path), - checkCancelled: () => throwIfAborted(signal), - createCancellationError: () => new WorkspaceSpaceScanCancelledError(), - isCancellationError: (error) => error instanceof WorkspaceSpaceScanCancelledError - }) -} - -async function scanLocalTopLevelEntry( - entryPath: string, - name: string, - duSizes: Map, - signal?: AbortSignal -): Promise { - throwIfAborted(signal) - const stats = await lstat(entryPath) - throwIfAborted(signal) - - if (stats.isSymbolicLink()) { - return { - name, - path: entryPath, - kind: 'symlink', - sizeBytes: stats.size, - skippedEntryCount: 0 - } - } - - if (!stats.isDirectory()) { - return { - name, - path: entryPath, - kind: 'file', - sizeBytes: stats.size, - skippedEntryCount: 0 - } - } - - return { - name, - path: entryPath, - kind: 'directory', - sizeBytes: duSizes.get(normalizeLocalDuPath(entryPath)) ?? stats.size, - skippedEntryCount: 0 - } -} - -async function scanLocalWorktreeWithDu( - repo: Repo, - worktree: Worktree, - scannedAt: number, - signal?: AbortSignal -): Promise { - throwIfAborted(signal) - const rootStats = await lstat(worktree.path) - if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) { - const root = await scanLocalEntry(worktree.path, basenameFilesystemPath(worktree.path), signal) - const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) - return { - ...createBaseWorktreeRow(repo, worktree, scannedAt), - status: 'ok', - error: null, - sizeBytes: root.sizeBytes, - reclaimableBytes: worktree.isMainWorktree ? 0 : root.sizeBytes, - skippedEntryCount: root.skippedEntryCount, - ...compact - } - } - - const [entries, duSizes] = await Promise.all([ - opendir(worktree.path).then(async (directory) => { - const admission = await collectWorkspaceSpaceDirectoryEntries( - directory, - worktree.path, - (entry) => entry.name, - createWorkspaceSpaceScanBudget(), - () => throwIfAborted(signal) - ) - return admission.entries - }), - readLocalDuDepthOne(worktree.path, signal) - ]) - throwIfAborted(signal) - const childStats = await mapWithConcurrency( - entries, - LOCAL_FS_CONCURRENCY, - async (entry): Promise => { - try { - return await scanLocalTopLevelEntry( - joinFilesystemPath(worktree.path, entry.name), - entry.name, - duSizes, - signal - ) - } catch (error) { - if (error instanceof WorkspaceSpaceScanCancelledError) { - throw error - } - return null - } - } - ) - const children = childStats.filter((child): child is ScanStats => child !== null) - const skippedEntryCount = childStats.length - children.length - const rootSize = - duSizes.get(normalizeLocalDuPath(worktree.path)) ?? - rootStats.size + children.reduce((sum, child) => sum + child.sizeBytes, 0) - const compact = compactWorkspaceSpaceItems(children.map(toWorkspaceSpaceItem)) - - return { - ...createBaseWorktreeRow(repo, worktree, scannedAt), - status: 'ok', - error: null, - sizeBytes: rootSize, - reclaimableBytes: worktree.isMainWorktree ? 0 : rootSize, - skippedEntryCount, - ...compact - } -} - -async function scanLocalWorktreeWithNode( - repo: Repo, - worktree: Worktree, - scannedAt: number, - signal?: AbortSignal -): Promise { - try { - const root = await scanLocalEntry(worktree.path, basenameFilesystemPath(worktree.path), signal) - const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) - return { - ...createBaseWorktreeRow(repo, worktree, scannedAt), - status: 'ok', - error: null, - sizeBytes: root.sizeBytes, - reclaimableBytes: worktree.isMainWorktree ? 0 : root.sizeBytes, - skippedEntryCount: root.skippedEntryCount, - ...compact - } - } catch (error) { - if (error instanceof WorkspaceSpaceScanCancelledError) { - throw error - } - const classified = classifyError(error) - return createUnavailableWorktreeRow( - repo, - worktree, - scannedAt, - classified.status, - classified.message - ) - } -} - -async function scanLocalWorktree( - repo: Repo, - worktree: Worktree, - scannedAt: number, - signal?: AbortSignal -): Promise { - throwIfAborted(signal) - if (platform !== 'win32') { - try { - // Why: JS per-file stats are too slow for large local workspace fleets; - // POSIX du gives bounded top-level sizing without following symlinks. - return await scanLocalWorktreeWithDu(repo, worktree, scannedAt, signal) - } catch (error) { - throwIfAborted(signal) - if (error instanceof WorkspaceSpaceScanCancelledError) { - throw error - } - if (error instanceof WorkspaceSpaceScanCapacityError) { - const classified = classifyError(error) - return createUnavailableWorktreeRow( - repo, - worktree, - scannedAt, - classified.status, - classified.message - ) - } - // Fall through to the portable scanner so unsupported du variants or - // permission edge cases still produce partial rows instead of failing. - } - } - return scanLocalWorktreeWithNode(repo, worktree, scannedAt, signal) -} - -async function scanRemoteWorktree( - repo: Repo, - worktree: Worktree, - scannedAt: number, - provider: IFilesystemProvider, - fallbackTraversalLimit: AsyncLimiter, - signal?: AbortSignal -): Promise { - try { - if (provider.scanWorkspaceSpace) { - try { - const scan = await provider.scanWorkspaceSpace(worktree.path, { signal }) - return createScannedWorktreeRow(repo, worktree, scannedAt, scan) - } catch (error) { - if (isAbortError(error)) { - throw new WorkspaceSpaceScanCancelledError() - } - if (!isRelayMethodNotFoundError(error)) { - throw error - } - // Why: old SSH relays do not know the bulk Space scan method. Fall - // back to the request-by-request walker instead of marking SSH rows - // unavailable after an app upgrade. - } - } - - const root = await fallbackTraversalLimit(() => - scanRemoteEntry(worktree.path, basenameFilesystemPath(worktree.path), provider, signal) - ) - const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) - return createScannedWorktreeRow(repo, worktree, scannedAt, { - sizeBytes: root.sizeBytes, - skippedEntryCount: root.skippedEntryCount, - ...compact - }) - } catch (error) { - if (error instanceof WorkspaceSpaceScanCancelledError) { - throw error - } - const classified = classifyError(error) - return createUnavailableWorktreeRow( - repo, - worktree, - scannedAt, - classified.status, - classified.message - ) - } -} - -async function listWorktreesForSpaceScan( - store: Store, - repo: Repo, - signal?: AbortSignal -): Promise { - try { - throwIfAborted(signal) - if (isFolderRepo(repo)) { - return { ok: true, worktrees: [createFolderWorktree(repo)] } - } - if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - return { - ok: false, - status: 'unavailable', - error: `SSH connection "${repo.connectionId}" is not connected.` - } - } - const worktrees = await provider.listWorktrees(repo.path, { signal }) - throwIfAborted(signal) - return { ok: true, worktrees } - } - const worktrees = await listRepoWorktrees(repo, { - ...getLocalProjectWorktreeGitOptions(store, repo), - signal - }) - throwIfAborted(signal) - return { ok: true, worktrees } - } catch (error) { - if (error instanceof WorkspaceSpaceScanCancelledError) { - throw error - } - const classified = classifyError(error) - return { ok: false, status: classified.status, error: classified.message } - } -} - -function mergeForSpaceScan(repo: Repo, gitWorktree: GitWorktreeInfo, store: Store): Worktree { - const worktreeId = `${repo.id}::${gitWorktree.path}` - return mergeWorktree(repo.id, gitWorktree, store.getWorktreeMeta(worktreeId), repo.displayName) -} - -function reportProgress( - progress: WorkspaceSpaceProgressState, - updates: Partial, - onProgress: WorkspaceSpaceAnalyzeOptions['onProgress'] -): void { - const completedMeasurements = updates.completedMeasurements - Object.assign(progress, updates, { updatedAt: Date.now() }) - delete progress.completedMeasurements - onProgress?.({ - ...progress, - ...(completedMeasurements?.length ? { completedMeasurements } : {}) - }) -} - -async function scanRepo( - repo: Repo, - scannedAt: number, - store: Store, - limiters: WorkspaceSpaceScanLimiters, - progress: WorkspaceSpaceProgressState, - options: WorkspaceSpaceAnalyzeOptions -): Promise { - throwIfAborted(options.signal) - reportProgress( - progress, - { - currentRepoDisplayName: repo.displayName, - currentWorktreeDisplayName: null - }, - options.onProgress - ) - const listed = await listWorktreesForSpaceScan(store, repo, options.signal) - if (!listed.ok) { - reportProgress( - progress, - { scannedRepoCount: progress.scannedRepoCount + 1 }, - options.onProgress - ) - return { - worktrees: [], - summary: { - repoId: repo.id, - executionHostId: getRepoExecutionHostId(repo), - displayName: repo.displayName, - path: repo.path, - isRemote: Boolean(repo.connectionId), - worktreeCount: 0, - scannedWorktreeCount: 0, - unavailableWorktreeCount: 1, - totalSizeBytes: 0, - reclaimableBytes: 0, - error: listed.error - } - } - } - - // Why: a prunable registration has no directory to size or reclaim (issue - // #8389); it would only render a dead "Missing" row with no available - // action. Removal flows list worktrees separately and still see it. - const worktrees = listed.worktrees - .filter((gitWorktree) => !gitWorktree.prunable) - .map((gitWorktree) => mergeForSpaceScan(repo, gitWorktree, store)) - reportProgress( - progress, - { totalWorktreeCount: progress.totalWorktreeCount + worktrees.length }, - options.onProgress - ) - const remoteProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : undefined - const rows = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { - throwIfAborted(options.signal) - reportProgress( - progress, - { - currentRepoDisplayName: repo.displayName, - currentWorktreeDisplayName: worktree.displayName - }, - options.onProgress - ) - const row: WorkspaceSpaceWorktree = repo.connectionId - ? remoteProvider - ? await scanRemoteWorktree( - repo, - worktree, - scannedAt, - remoteProvider, - limiters.remoteFallbackTraversal, - options.signal - ) - : createUnavailableWorktreeRow( - repo, - worktree, - scannedAt, - 'unavailable', - `SSH filesystem for "${repo.connectionId}" is not connected.` - ) - : await limiters.localWorktree(() => - scanLocalWorktree(repo, worktree, scannedAt, options.signal) - ) - reportProgress( - progress, - { - scannedWorktreeCount: progress.scannedWorktreeCount + 1, - completedMeasurements: [ - { - worktreeId: row.worktreeId, - executionHostId: row.executionHostId, - status: row.status, - sizeBytes: row.sizeBytes - } - ] - }, - options.onProgress - ) - return row - }) - reportProgress( - progress, - { - scannedRepoCount: progress.scannedRepoCount + 1, - currentRepoDisplayName: repo.displayName, - currentWorktreeDisplayName: null - }, - options.onProgress - ) - - return { - worktrees: rows, - summary: { - repoId: repo.id, - executionHostId: getRepoExecutionHostId(repo), - displayName: repo.displayName, - path: repo.path, - isRemote: Boolean(repo.connectionId), - worktreeCount: rows.length, - scannedWorktreeCount: rows.filter((row) => row.status === 'ok').length, - unavailableWorktreeCount: rows.filter((row) => row.status !== 'ok').length, - totalSizeBytes: rows.reduce((sum, row) => sum + row.sizeBytes, 0), - reclaimableBytes: rows.reduce((sum, row) => sum + row.reclaimableBytes, 0), - error: null - } - } + return parseWorkspaceSpaceDuOutput(stdout) } export async function analyzeWorkspaceSpace( store: Store, options: WorkspaceSpaceAnalyzeOptions = {} ): Promise { - throwIfAborted(options.signal) + throwIfWorkspaceSpaceScanAborted(options.signal) const scannedAt = Date.now() const reposToScan = store.getRepos() - const progress: WorkspaceSpaceProgressState = { + const progress: WorkspaceSpaceScanProgress = { scanId: options.scanId ?? String(scannedAt), state: 'running', startedAt: scannedAt, @@ -861,19 +128,30 @@ export async function analyzeWorkspaceSpace( } options.onProgress?.({ ...progress }) const limiters: WorkspaceSpaceScanLimiters = { - localWorktree: createAsyncLimiter(LOCAL_WORKTREE_SCAN_CONCURRENCY, options.signal), - remoteFallbackTraversal: createAsyncLimiter(REMOTE_FALLBACK_SCAN_CONCURRENCY, options.signal) + localWorktree: createWorkspaceSpaceScanLimiter(LOCAL_WORKTREE_SCAN_CONCURRENCY, options.signal), + remoteFallbackTraversal: createWorkspaceSpaceScanLimiter( + REMOTE_FALLBACK_SCAN_CONCURRENCY, + options.signal + ) } const repoResults = await mapWithConcurrency(reposToScan, REPO_SCAN_CONCURRENCY, (repo) => - scanRepo(repo, scannedAt, store, limiters, progress, options) + scanWorkspaceSpaceRepo({ + repo, + scannedAt, + store, + limiters, + progress, + options, + readLocalDuDepthOne, + normalizeLocalDuPath + }) ) - throwIfAborted(options.signal) + throwIfWorkspaceSpaceScanAborted(options.signal) const repos = repoResults.map((result) => result.summary) const worktrees = repoResults .flatMap((result) => result.worktrees) .sort((a, b) => b.sizeBytes - a.sizeBytes || a.displayName.localeCompare(b.displayName)) - throwIfAborted(options.signal) - + throwIfWorkspaceSpaceScanAborted(options.signal) return { scannedAt, totalSizeBytes: worktrees.reduce((sum, row) => sum + row.sizeBytes, 0), diff --git a/src/main/workspace-space-local-scan.ts b/src/main/workspace-space-local-scan.ts new file mode 100644 index 00000000000..4e37ce9e891 --- /dev/null +++ b/src/main/workspace-space-local-scan.ts @@ -0,0 +1,225 @@ +import { lstat, opendir } from 'node:fs/promises' +import { platform } from 'node:process' +import type { Dirent } from 'node:fs' +import type { Repo } from '../shared/repo-types' +import type { Worktree } from '../shared/worktree/types' +import type { WorkspaceSpaceWorktree } from '../shared/workspace-space-types' +import { compactWorkspaceSpaceItems } from '../shared/workspace-space-compaction' +import { mapWithConcurrency } from '../shared/map-with-concurrency' +import { + scanWorkspaceSpaceEntryTree, + type WorkspaceSpaceEntryScan +} from '../shared/workspace-space-entry-traversal' +import { + collectWorkspaceSpaceDirectoryEntries, + createWorkspaceSpaceScanBudget, + WorkspaceSpaceScanCapacityError +} from '../shared/workspace-space-scan-budget' +import { + WorkspaceSpaceScanCancelledError, + classifyWorkspaceSpaceError, + throwIfWorkspaceSpaceScanAborted +} from './workspace-space-scan-control' +import { + basenameWorkspaceFilesystemPath, + createScannedWorkspaceSpaceRow, + createUnavailableWorkspaceSpaceRow, + joinWorkspaceFilesystemPath, + toWorkspaceSpaceItem +} from './workspace-space-worktree-row' + +const LOCAL_FS_CONCURRENCY = 48 + +export type ReadLocalDuDepthOne = ( + rootPath: string, + signal?: AbortSignal +) => Promise> + +async function scanLocalEntry( + entryPath: string, + name: string, + signal?: AbortSignal +): Promise { + return scanWorkspaceSpaceEntryTree({ + rootPath: entryPath, + rootName: name, + concurrency: LOCAL_FS_CONCURRENCY, + signal, + entryName: (entry) => entry.name, + joinPath: joinWorkspaceFilesystemPath, + classifyEntry: async (path) => { + const stats = await lstat(path) + throwIfWorkspaceSpaceScanAborted(signal) + if (stats.isSymbolicLink()) { + return { kind: 'symlink', sizeBytes: stats.size } + } + return stats.isDirectory() + ? { kind: 'directory', sizeBytes: stats.size } + : { kind: 'file', sizeBytes: stats.size } + }, + readDirectory: (path) => opendir(path), + checkCancelled: () => throwIfWorkspaceSpaceScanAborted(signal), + createCancellationError: () => new WorkspaceSpaceScanCancelledError(), + isCancellationError: (error) => error instanceof WorkspaceSpaceScanCancelledError + }) +} + +async function scanLocalTopLevelEntry( + entryPath: string, + name: string, + duSizes: Map, + normalizeDuPath: (path: string) => string, + signal?: AbortSignal +): Promise { + throwIfWorkspaceSpaceScanAborted(signal) + const stats = await lstat(entryPath) + throwIfWorkspaceSpaceScanAborted(signal) + if (stats.isSymbolicLink()) { + return { name, path: entryPath, kind: 'symlink', sizeBytes: stats.size, skippedEntryCount: 0 } + } + if (!stats.isDirectory()) { + return { name, path: entryPath, kind: 'file', sizeBytes: stats.size, skippedEntryCount: 0 } + } + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes: duSizes.get(normalizeDuPath(entryPath)) ?? stats.size, + skippedEntryCount: 0 + } +} + +async function scanLocalWorktreeWithDu( + repo: Repo, + worktree: Worktree, + scannedAt: number, + readDu: ReadLocalDuDepthOne, + normalizeDuPath: (path: string) => string, + signal?: AbortSignal +): Promise { + throwIfWorkspaceSpaceScanAborted(signal) + const rootStats = await lstat(worktree.path) + if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) { + const root = await scanLocalEntry( + worktree.path, + basenameWorkspaceFilesystemPath(worktree.path), + signal + ) + const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) + return createScannedWorkspaceSpaceRow(repo, worktree, scannedAt, { + sizeBytes: root.sizeBytes, + skippedEntryCount: root.skippedEntryCount, + ...compact + }) + } + const [entries, duSizes] = await Promise.all([ + opendir(worktree.path).then(async (directory) => { + const admission = await collectWorkspaceSpaceDirectoryEntries( + directory, + worktree.path, + (entry) => entry.name, + createWorkspaceSpaceScanBudget(), + () => throwIfWorkspaceSpaceScanAborted(signal) + ) + return admission.entries + }), + readDu(worktree.path, signal) + ]) + throwIfWorkspaceSpaceScanAborted(signal) + const childStats = await mapWithConcurrency(entries, LOCAL_FS_CONCURRENCY, async (entry) => { + try { + return await scanLocalTopLevelEntry( + joinWorkspaceFilesystemPath(worktree.path, entry.name), + entry.name, + duSizes, + normalizeDuPath, + signal + ) + } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } + return null + } + }) + const children = childStats.filter((child): child is WorkspaceSpaceEntryScan => child !== null) + const rootSize = + duSizes.get(normalizeDuPath(worktree.path)) ?? + rootStats.size + children.reduce((sum, child) => sum + child.sizeBytes, 0) + return createScannedWorkspaceSpaceRow(repo, worktree, scannedAt, { + sizeBytes: rootSize, + skippedEntryCount: childStats.length - children.length, + ...compactWorkspaceSpaceItems(children.map(toWorkspaceSpaceItem)) + }) +} + +async function scanLocalWorktreeWithNode( + repo: Repo, + worktree: Worktree, + scannedAt: number, + signal?: AbortSignal +): Promise { + try { + const root = await scanLocalEntry( + worktree.path, + basenameWorkspaceFilesystemPath(worktree.path), + signal + ) + return createScannedWorkspaceSpaceRow(repo, worktree, scannedAt, { + sizeBytes: root.sizeBytes, + skippedEntryCount: root.skippedEntryCount, + ...compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) + }) + } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } + const classified = classifyWorkspaceSpaceError(error) + return createUnavailableWorkspaceSpaceRow( + repo, + worktree, + scannedAt, + classified.status, + classified.message + ) + } +} + +export async function scanLocalWorkspaceSpaceWorktree( + repo: Repo, + worktree: Worktree, + scannedAt: number, + readDu: ReadLocalDuDepthOne, + normalizeDuPath: (path: string) => string, + signal?: AbortSignal +): Promise { + throwIfWorkspaceSpaceScanAborted(signal) + if (platform !== 'win32') { + try { + return await scanLocalWorktreeWithDu( + repo, + worktree, + scannedAt, + readDu, + normalizeDuPath, + signal + ) + } catch (error) { + throwIfWorkspaceSpaceScanAborted(signal) + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } + if (error instanceof WorkspaceSpaceScanCapacityError) { + const classified = classifyWorkspaceSpaceError(error) + return createUnavailableWorkspaceSpaceRow( + repo, + worktree, + scannedAt, + classified.status, + classified.message + ) + } + } + } + return scanLocalWorktreeWithNode(repo, worktree, scannedAt, signal) +} diff --git a/src/main/workspace-space-remote-scan.ts b/src/main/workspace-space-remote-scan.ts new file mode 100644 index 00000000000..c7936127f81 --- /dev/null +++ b/src/main/workspace-space-remote-scan.ts @@ -0,0 +1,107 @@ +import type { DirEntry } from '../shared/filesystem-entry-types' +import type { Repo } from '../shared/repo-types' +import type { Worktree } from '../shared/worktree/types' +import type { WorkspaceSpaceWorktree } from '../shared/workspace-space-types' +import { compactWorkspaceSpaceItems } from '../shared/workspace-space-compaction' +import { scanWorkspaceSpaceEntryTree } from '../shared/workspace-space-entry-traversal' +import type { IFilesystemProvider } from './providers/types' +import { + WorkspaceSpaceScanCancelledError, + classifyWorkspaceSpaceError, + isWorkspaceSpaceAbortError, + isWorkspaceSpaceRelayMethodMissing, + throwIfWorkspaceSpaceScanAborted, + type AsyncLimiter +} from './workspace-space-scan-control' +import { + basenameWorkspaceFilesystemPath, + createScannedWorkspaceSpaceRow, + createUnavailableWorkspaceSpaceRow, + joinWorkspaceFilesystemPath, + toWorkspaceSpaceItem +} from './workspace-space-worktree-row' + +const REMOTE_FS_CONCURRENCY = 10 + +async function scanRemoteEntry( + entryPath: string, + name: string, + provider: IFilesystemProvider, + signal?: AbortSignal +) { + return scanWorkspaceSpaceEntryTree({ + rootPath: entryPath, + rootName: name, + concurrency: REMOTE_FS_CONCURRENCY, + signal, + entryName: (entry) => entry.name, + joinPath: joinWorkspaceFilesystemPath, + classifyEntry: async (path, sourceEntry) => { + if (sourceEntry?.isSymlink) { + return { kind: 'symlink', sizeBytes: 0 } + } + const stats = await provider.stat(path) + throwIfWorkspaceSpaceScanAborted(signal) + if (stats.type === 'symlink') { + return { kind: 'symlink', sizeBytes: stats.size } + } + return stats.type === 'directory' + ? { kind: 'directory', sizeBytes: stats.size } + : { kind: 'file', sizeBytes: stats.size } + }, + readDirectory: (path) => provider.readDir(path), + checkCancelled: () => throwIfWorkspaceSpaceScanAborted(signal), + createCancellationError: () => new WorkspaceSpaceScanCancelledError(), + isCancellationError: (error) => error instanceof WorkspaceSpaceScanCancelledError + }) +} + +export async function scanRemoteWorkspaceSpaceWorktree( + repo: Repo, + worktree: Worktree, + scannedAt: number, + provider: IFilesystemProvider, + fallbackTraversalLimit: AsyncLimiter, + signal?: AbortSignal +): Promise { + try { + if (provider.scanWorkspaceSpace) { + try { + const scan = await provider.scanWorkspaceSpace(worktree.path, { signal }) + return createScannedWorkspaceSpaceRow(repo, worktree, scannedAt, scan) + } catch (error) { + if (isWorkspaceSpaceAbortError(error)) { + throw new WorkspaceSpaceScanCancelledError() + } + if (!isWorkspaceSpaceRelayMethodMissing(error)) { + throw error + } + } + } + const root = await fallbackTraversalLimit(() => + scanRemoteEntry( + worktree.path, + basenameWorkspaceFilesystemPath(worktree.path), + provider, + signal + ) + ) + return createScannedWorkspaceSpaceRow(repo, worktree, scannedAt, { + sizeBytes: root.sizeBytes, + skippedEntryCount: root.skippedEntryCount, + ...compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) + }) + } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } + const classified = classifyWorkspaceSpaceError(error) + return createUnavailableWorkspaceSpaceRow( + repo, + worktree, + scannedAt, + classified.status, + classified.message + ) + } +} diff --git a/src/main/workspace-space-repo-scan.ts b/src/main/workspace-space-repo-scan.ts new file mode 100644 index 00000000000..8b97a89c041 --- /dev/null +++ b/src/main/workspace-space-repo-scan.ts @@ -0,0 +1,239 @@ +import type { Store } from './persistence' +import { isFolderRepo } from '../shared/repo-kind' +import type { Repo } from '../shared/repo-types' +import type { GitWorktreeInfo, Worktree } from '../shared/worktree/types' +import type { + WorkspaceSpaceRepoSummary, + WorkspaceSpaceScanProgress, + WorkspaceSpaceScanStatus, + WorkspaceSpaceWorktree +} from '../shared/workspace-space-types' +import { mapWithConcurrency } from '../shared/map-with-concurrency' +import { getRepoExecutionHostId } from '../shared/execution-host' +import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' +import { getSshGitProvider } from './providers/ssh-git-dispatch' +import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees' +import { mergeWorktree } from './ipc/worktree-logic' +import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options' +import { + WorkspaceSpaceScanCancelledError, + classifyWorkspaceSpaceError, + throwIfWorkspaceSpaceScanAborted, + type AsyncLimiter +} from './workspace-space-scan-control' +import { createUnavailableWorkspaceSpaceRow } from './workspace-space-worktree-row' +import { scanRemoteWorkspaceSpaceWorktree } from './workspace-space-remote-scan' +import { + scanLocalWorkspaceSpaceWorktree, + type ReadLocalDuDepthOne +} from './workspace-space-local-scan' + +const WORKTREE_SCAN_CONCURRENCY = 3 + +type WorktreeListResult = + | { ok: true; worktrees: GitWorktreeInfo[] } + | { ok: false; status: Exclude; error: string } + +export type WorkspaceSpaceAnalyzeOptions = { + signal?: AbortSignal + scanId?: string + onProgress?: (progress: WorkspaceSpaceScanProgress) => void +} + +export type WorkspaceSpaceScanLimiters = { + localWorktree: AsyncLimiter + remoteFallbackTraversal: AsyncLimiter +} + +export type WorkspaceSpaceRepoScanResult = { + summary: WorkspaceSpaceRepoSummary + worktrees: WorkspaceSpaceWorktree[] +} + +async function listWorktreesForSpaceScan( + store: Store, + repo: Repo, + signal?: AbortSignal +): Promise { + try { + throwIfWorkspaceSpaceScanAborted(signal) + if (isFolderRepo(repo)) { + return { ok: true, worktrees: [createFolderWorktree(repo)] } + } + if (repo.connectionId) { + const provider = getSshGitProvider(repo.connectionId) + if (!provider) { + return { + ok: false, + status: 'unavailable', + error: `SSH connection "${repo.connectionId}" is not connected.` + } + } + const worktrees = await provider.listWorktrees(repo.path, { signal }) + throwIfWorkspaceSpaceScanAborted(signal) + return { ok: true, worktrees } + } + const worktrees = await listRepoWorktrees(repo, { + ...getLocalProjectWorktreeGitOptions(store, repo), + signal + }) + throwIfWorkspaceSpaceScanAborted(signal) + return { ok: true, worktrees } + } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } + const classified = classifyWorkspaceSpaceError(error) + return { ok: false, status: classified.status, error: classified.message } + } +} + +function reportProgress( + progress: WorkspaceSpaceScanProgress, + updates: Partial, + onProgress: WorkspaceSpaceAnalyzeOptions['onProgress'] +): void { + const completedMeasurements = updates.completedMeasurements + Object.assign(progress, updates, { updatedAt: Date.now() }) + delete progress.completedMeasurements + onProgress?.({ + ...progress, + ...(completedMeasurements?.length ? { completedMeasurements } : {}) + }) +} + +function mergeForSpaceScan(repo: Repo, gitWorktree: GitWorktreeInfo, store: Store): Worktree { + const worktreeId = `${repo.id}::${gitWorktree.path}` + return mergeWorktree(repo.id, gitWorktree, store.getWorktreeMeta(worktreeId), repo.displayName) +} + +export async function scanWorkspaceSpaceRepo(args: { + repo: Repo + scannedAt: number + store: Store + limiters: WorkspaceSpaceScanLimiters + progress: WorkspaceSpaceScanProgress + options: WorkspaceSpaceAnalyzeOptions + readLocalDuDepthOne: ReadLocalDuDepthOne + normalizeLocalDuPath: (path: string) => string +}): Promise { + const { repo, scannedAt, store, limiters, progress, options } = args + throwIfWorkspaceSpaceScanAborted(options.signal) + reportProgress( + progress, + { currentRepoDisplayName: repo.displayName, currentWorktreeDisplayName: null }, + options.onProgress + ) + const listed = await listWorktreesForSpaceScan(store, repo, options.signal) + if (!listed.ok) { + reportProgress( + progress, + { scannedRepoCount: progress.scannedRepoCount + 1 }, + options.onProgress + ) + return { + worktrees: [], + summary: { + repoId: repo.id, + executionHostId: getRepoExecutionHostId(repo), + displayName: repo.displayName, + path: repo.path, + isRemote: Boolean(repo.connectionId), + worktreeCount: 0, + scannedWorktreeCount: 0, + unavailableWorktreeCount: 1, + totalSizeBytes: 0, + reclaimableBytes: 0, + error: listed.error + } + } + } + const worktrees = listed.worktrees + .filter((gitWorktree) => !gitWorktree.prunable) + .map((gitWorktree) => mergeForSpaceScan(repo, gitWorktree, store)) + reportProgress( + progress, + { totalWorktreeCount: progress.totalWorktreeCount + worktrees.length }, + options.onProgress + ) + const remoteProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : undefined + const rows = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { + throwIfWorkspaceSpaceScanAborted(options.signal) + reportProgress( + progress, + { + currentRepoDisplayName: repo.displayName, + currentWorktreeDisplayName: worktree.displayName + }, + options.onProgress + ) + const row = repo.connectionId + ? remoteProvider + ? await scanRemoteWorkspaceSpaceWorktree( + repo, + worktree, + scannedAt, + remoteProvider, + limiters.remoteFallbackTraversal, + options.signal + ) + : createUnavailableWorkspaceSpaceRow( + repo, + worktree, + scannedAt, + 'unavailable', + `SSH filesystem for "${repo.connectionId}" is not connected.` + ) + : await limiters.localWorktree(() => + scanLocalWorkspaceSpaceWorktree( + repo, + worktree, + scannedAt, + args.readLocalDuDepthOne, + args.normalizeLocalDuPath, + options.signal + ) + ) + reportProgress( + progress, + { + scannedWorktreeCount: progress.scannedWorktreeCount + 1, + completedMeasurements: [ + { + worktreeId: row.worktreeId, + executionHostId: row.executionHostId, + status: row.status, + sizeBytes: row.sizeBytes + } + ] + }, + options.onProgress + ) + return row + }) + reportProgress( + progress, + { + scannedRepoCount: progress.scannedRepoCount + 1, + currentRepoDisplayName: repo.displayName, + currentWorktreeDisplayName: null + }, + options.onProgress + ) + return { + worktrees: rows, + summary: { + repoId: repo.id, + executionHostId: getRepoExecutionHostId(repo), + displayName: repo.displayName, + path: repo.path, + isRemote: Boolean(repo.connectionId), + worktreeCount: rows.length, + scannedWorktreeCount: rows.filter((row) => row.status === 'ok').length, + unavailableWorktreeCount: rows.filter((row) => row.status !== 'ok').length, + totalSizeBytes: rows.reduce((sum, row) => sum + row.sizeBytes, 0), + reclaimableBytes: rows.reduce((sum, row) => sum + row.reclaimableBytes, 0), + error: null + } + } +} diff --git a/src/main/workspace-space-scan-control.test.ts b/src/main/workspace-space-scan-control.test.ts new file mode 100644 index 00000000000..5e031a6c537 --- /dev/null +++ b/src/main/workspace-space-scan-control.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { WorkspaceSpaceScanCapacityError } from '../shared/workspace-space-scan-budget' +import { + WorkspaceSpaceScanCancelledError, + classifyWorkspaceSpaceError, + createWorkspaceSpaceScanLimiter +} from './workspace-space-scan-control' + +describe('workspace space scan boundaries', () => { + it.each([ + [{ code: 'ENOENT', message: 'gone' }, 'missing'], + [{ code: 'ENOTDIR', message: 'not a directory' }, 'missing'], + [{ code: 'EACCES', message: 'denied' }, 'permission-denied'], + [{ code: 'EPERM', message: 'denied' }, 'permission-denied'], + [new Error('other'), 'error'] + ] as const)('classifies %o as %s without changing error semantics', (error, status) => { + expect(classifyWorkspaceSpaceError(error).status).toBe(status) + }) + + it('keeps capacity exhaustion distinct from filesystem errors', () => { + const error = new WorkspaceSpaceScanCapacityError({ maxEntries: 1, maxRetainedBytes: 1 }) + expect(classifyWorkspaceSpaceError(error)).toEqual({ + status: 'unavailable', + message: error.message + }) + }) + + it('rejects queued work with the public cancellation error', async () => { + const controller = new AbortController() + const limit = createWorkspaceSpaceScanLimiter(1, controller.signal) + let release!: () => void + const active = limit(() => new Promise((resolve) => (release = resolve))) + const queued = limit(async () => undefined) + controller.abort() + await expect(queued).rejects.toBeInstanceOf(WorkspaceSpaceScanCancelledError) + release() + await active + }) +}) diff --git a/src/main/workspace-space-scan-control.ts b/src/main/workspace-space-scan-control.ts new file mode 100644 index 00000000000..3d8aa73bfb1 --- /dev/null +++ b/src/main/workspace-space-scan-control.ts @@ -0,0 +1,104 @@ +import type { WorkspaceSpaceScanStatus } from '../shared/workspace-space-types' +import { WorkspaceSpaceScanCapacityError } from '../shared/workspace-space-scan-budget' + +export type AsyncLimiter = (task: () => Promise) => Promise + +export class WorkspaceSpaceScanCancelledError extends Error { + constructor() { + super('Workspace space scan cancelled') + this.name = 'WorkspaceSpaceScanCancelledError' + } +} + +export function throwIfWorkspaceSpaceScanAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new WorkspaceSpaceScanCancelledError() + } +} + +export function isWorkspaceSpaceAbortError(error: unknown): boolean { + return Boolean( + error && typeof error === 'object' && (error as { name?: unknown }).name === 'AbortError' + ) +} + +export function isWorkspaceSpaceRelayMethodMissing(error: unknown): boolean { + return Boolean( + error && typeof error === 'object' && (error as { code?: unknown }).code === -32601 + ) +} + +export function classifyWorkspaceSpaceError(error: unknown): { + status: Exclude + message: string +} { + const code = + error && typeof error === 'object' && 'code' in error + ? String((error as { code?: unknown }).code) + : '' + const message = error instanceof Error ? error.message : String(error) + if (error instanceof WorkspaceSpaceScanCapacityError) { + return { status: 'unavailable', message } + } + if (code === 'ENOENT' || code === 'ENOTDIR') { + return { status: 'missing', message } + } + if (code === 'EACCES' || code === 'EPERM') { + return { status: 'permission-denied', message } + } + return { status: 'error', message } +} + +export function createWorkspaceSpaceScanLimiter( + maxConcurrent: number, + signal?: AbortSignal +): AsyncLimiter { + let active = 0 + const queue: { resolve: () => void; reject: (error: Error) => void }[] = [] + + const acquire = async (): Promise => { + throwIfWorkspaceSpaceScanAborted(signal) + if (active < maxConcurrent) { + active += 1 + return + } + await new Promise((resolve, reject) => { + let onAbort: (() => void) | null = null + const waiter = { + resolve: () => { + if (onAbort) { + signal?.removeEventListener('abort', onAbort) + } + resolve() + }, + reject + } + onAbort = () => { + const index = queue.indexOf(waiter) + if (index !== -1) { + queue.splice(index, 1) + } + reject(new WorkspaceSpaceScanCancelledError()) + } + queue.push(waiter) + if (signal) { + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) { + onAbort() + } + } + }) + throwIfWorkspaceSpaceScanAborted(signal) + active += 1 + } + + return async (task: () => Promise): Promise => { + await acquire() + try { + return await task() + } finally { + active -= 1 + queue.shift()?.resolve() + } + } +} diff --git a/src/main/workspace-space-worktree-row.ts b/src/main/workspace-space-worktree-row.ts new file mode 100644 index 00000000000..e94c755e17e --- /dev/null +++ b/src/main/workspace-space-worktree-row.ts @@ -0,0 +1,85 @@ +import { posix, win32 } from 'node:path' +import type { Repo } from '../shared/repo-types' +import type { Worktree } from '../shared/worktree/types' +import type { + WorkspaceSpaceDirectoryScanResult, + WorkspaceSpaceItem, + WorkspaceSpaceScanStatus, + WorkspaceSpaceWorktree +} from '../shared/workspace-space-types' +import type { WorkspaceSpaceEntryScan } from '../shared/workspace-space-entry-traversal' +import { getWorktreeExecutionHostId } from '../shared/execution-host' + +export function basenameWorkspaceFilesystemPath(pathValue: string): string { + return looksLikeWindowsPath(pathValue) ? win32.basename(pathValue) : posix.basename(pathValue) +} + +export function joinWorkspaceFilesystemPath(parent: string, child: string): string { + return looksLikeWindowsPath(parent) ? win32.join(parent, child) : posix.join(parent, child) +} + +function looksLikeWindowsPath(pathValue: string): boolean { + return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') +} + +export function toWorkspaceSpaceItem(stats: WorkspaceSpaceEntryScan): WorkspaceSpaceItem { + return { name: stats.name, path: stats.path, kind: stats.kind, sizeBytes: stats.sizeBytes } +} + +function createBaseWorktreeRow(repo: Repo, worktree: Worktree, scannedAt: number) { + return { + worktreeId: worktree.id, + repoId: repo.id, + executionHostId: getWorktreeExecutionHostId(worktree, repo), + repoDisplayName: repo.displayName, + repoPath: repo.path, + displayName: worktree.displayName, + path: worktree.path, + branch: worktree.branch, + isMainWorktree: worktree.isMainWorktree, + isRemote: Boolean(repo.connectionId), + isSparse: worktree.isSparse === true, + canDelete: !worktree.isMainWorktree, + lastActivityAt: worktree.lastActivityAt, + scannedAt + } +} + +export function createUnavailableWorkspaceSpaceRow( + repo: Repo, + worktree: Worktree, + scannedAt: number, + status: Exclude, + error: string +): WorkspaceSpaceWorktree { + return { + ...createBaseWorktreeRow(repo, worktree, scannedAt), + status, + error, + sizeBytes: 0, + reclaimableBytes: 0, + skippedEntryCount: 0, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } +} + +export function createScannedWorkspaceSpaceRow( + repo: Repo, + worktree: Worktree, + scannedAt: number, + scan: WorkspaceSpaceDirectoryScanResult +): WorkspaceSpaceWorktree { + return { + ...createBaseWorktreeRow(repo, worktree, scannedAt), + status: 'ok', + error: null, + sizeBytes: scan.sizeBytes, + reclaimableBytes: worktree.isMainWorktree ? 0 : scan.sizeBytes, + skippedEntryCount: scan.skippedEntryCount, + topLevelItems: scan.topLevelItems, + omittedTopLevelItemCount: scan.omittedTopLevelItemCount, + omittedTopLevelSizeBytes: scan.omittedTopLevelSizeBytes + } +} diff --git a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt index 01dc111ae1b..c943b8b9ce3 100644 --- a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt @@ -15,13 +15,12 @@ # host-level flag like --status / --list that the guest-command API cannot # express because it always prepends --exec. main/agent-hooks/wsl-hook-relay-launch.ts -main/claude-accounts/service.ts +main/claude-accounts/claude-command-process.ts main/codex-accounts/runtime-home-service.ts main/codex-accounts/service.ts main/codex/codex-state-db-backfill-recovery.ts main/codex/codex-trust-grant-host.ts main/codex/codex-wsl-hook-install-plan.ts -main/daemon/pty-subprocess.ts main/git/runner.ts main/git/wsl-git-read-environment.ts main/ipc/filesystem-watcher-wsl.ts diff --git a/src/preload/api/agent-skill-api.ts b/src/preload/api/agent-skill-api.ts index c42cbd358b0..a930218068e 100644 --- a/src/preload/api/agent-skill-api.ts +++ b/src/preload/api/agent-skill-api.ts @@ -71,6 +71,10 @@ export type SkillsApi = { input: SkillBundleInstallPreviewInput ) => Promise removeInstall: (input: SkillRemoveInput) => Promise + /** Whether the host answering `previewDelete`/`delete` supports them. Always + * true on desktop; on web the "local" host is a remote server that updates + * independently and may predate the capability. */ + deleteSupported: () => Promise previewDelete: (request: SkillDeleteRequest) => Promise delete: (request: SkillDeleteRequest) => Promise listManagedInstalls: (environmentId?: string) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index e57bfc586a9..b1d2242b172 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2633,6 +2633,8 @@ const api = { ipcRenderer.invoke('skills:previewBundleInstall', input), removeInstall: (input: SkillRemoveInput): Promise => ipcRenderer.invoke('skills:removeInstall', input), + // Desktop always registers the delete IPC handlers in its own main process. + deleteSupported: (): Promise => Promise.resolve(true), previewDelete: (request: SkillDeleteRequest): Promise => ipcRenderer.invoke('skills:previewDelete', request), delete: (request: SkillDeleteRequest): Promise => diff --git a/src/relay/agent-hook-envelope-build.ts b/src/relay/agent-hook-envelope-build.ts index 34b4cbbdec6..14a7430f997 100644 --- a/src/relay/agent-hook-envelope-build.ts +++ b/src/relay/agent-hook-envelope-build.ts @@ -1,7 +1,7 @@ // Pure mapping from a parsed hook event plus its raw body onto the `agent.hook` envelope that goes // over the SSH channel. Sits beside agent-hook-envelope-publication.ts, which owns shedding and // redelivery of the same envelope. -import type { AgentHookEventPayload } from '../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay' // Why: cap metadata to prevent a misbehaving CLI growing the cache unboundedly. diff --git a/src/relay/agent-hook-result-retry-scheduler.ts b/src/relay/agent-hook-result-retry-scheduler.ts index b69abcb49a8..bd6d2796163 100644 --- a/src/relay/agent-hook-result-retry-scheduler.ts +++ b/src/relay/agent-hook-result-retry-scheduler.ts @@ -2,14 +2,14 @@ // when the hook fired, so re-read the same body on a timer and re-apply only if it changed. Both // timer families live in one owner so pane teardown and server stop tear both down in one ordered // place before the listener caches are cleared. +import { hasCodexTranscriptSubagents } from '../shared/agent-hook-listener/providers/codex-state' import { - hasCodexTranscriptSubagents, hasPendingAgentResultText, - normalizeHookPayload, - preparePendingGrokResultDiscovery, - type AgentHookEventPayload, - type HookListenerState -} from '../shared/agent-hook-listener' + preparePendingGrokResultDiscovery +} from '../shared/agent-hook-listener/grok-result-discovery' +import { normalizeHookPayload } from '../shared/agent-hook-listener' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' +import type { HookListenerState } from '../shared/agent-hook-listener/listener-state' import type { AgentHookSource } from '../shared/agent-hook-relay' const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 3458814ed5b..0e3771aff55 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -3,14 +3,20 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { RelayAgentHookServer } from './agent-hook-server' +import type { AgentHookResultRetryScheduler } from './agent-hook-result-retry-scheduler' import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' import { makePaneKey } from '../shared/stable-pane-id' -import * as agentHookListener from '../shared/agent-hook-listener' +import * as agentHookListener from '../shared/agent-hook-listener/grok-result-discovery' const LEAF_ID = '11111111-1111-4111-8111-111111111111' const PANE_KEY = makePaneKey('tab-1', LEAF_ID) +type RelayServerInternals = { + state: { lastStatusByPaneKey: Map } + retryScheduler: AgentHookResultRetryScheduler +} + describe('RelayAgentHookServer', () => { let dir: string beforeEach(() => { @@ -76,7 +82,98 @@ describe('RelayAgentHookServer', () => { } }) - it('forwards Claude background-work evidence with the normalized status', async () => { + it('caches before forwarding, schedules retries after forwarding, and responds last', async () => { + const order: string[] = [] + let server!: RelayAgentHookServer + let internals!: RelayServerInternals + server = new RelayAgentHookServer({ + endpointDir: dir, + forward: () => { + expect(internals.state.lastStatusByPaneKey.has(PANE_KEY)).toBe(true) + order.push('forward') + } + }) + // Characterization deliberately observes the server-owned scheduler without widening production API. + internals = server as unknown as RelayServerInternals + const retryScheduler = internals.retryScheduler + const originalAssistantRetry = retryScheduler.scheduleAssistantMessageRetry.bind(retryScheduler) + const originalCodexRetry = retryScheduler.scheduleCodexSubagentPoll.bind(retryScheduler) + const assistantRetry = vi + .spyOn(retryScheduler, 'scheduleAssistantMessageRetry') + .mockImplementation((...args) => { + order.push('assistant-retry') + originalAssistantRetry(...args) + }) + const codexRetry = vi + .spyOn(retryScheduler, 'scheduleCodexSubagentPoll') + .mockImplementation((...args) => { + order.push('codex-retry') + originalCodexRetry(...args) + }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + hook_event_name: 'UserPromptSubmit', + payload: { prompt: 'ordered' } + }) + }) + order.push('response') + expect(res.status).toBe(204) + expect(order).toEqual(['forward', 'assistant-retry', 'codex-retry', 'response']) + } finally { + server.stop() + assistantRetry.mockRestore() + codexRetry.mockRestore() + } + }) + + it('fails open after a throwing forward while retaining the already-cached event', async () => { + const server = new RelayAgentHookServer({ + endpointDir: dir, + forward: () => { + throw new Error('forward failed') + } + }) + // Characterization deliberately observes cache/scheduler order without widening production API. + const internals = server as unknown as RelayServerInternals + const retryScheduler = internals.retryScheduler + const assistantRetry = vi.spyOn(retryScheduler, 'scheduleAssistantMessageRetry') + const codexRetry = vi.spyOn(retryScheduler, 'scheduleCodexSubagentPoll') + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + hook_event_name: 'UserPromptSubmit', + payload: { prompt: 'cached before throw' } + }) + }) + expect(res.status).toBe(204) + expect(internals.state.lastStatusByPaneKey.has(PANE_KEY)).toBe(true) + expect(assistantRetry).not.toHaveBeenCalled() + expect(codexRetry).not.toHaveBeenCalled() + } finally { + server.stop() + assistantRetry.mockRestore() + codexRetry.mockRestore() + } + }) + + it('forwards Claude background monitoring until its authoritative inventory drains', async () => { const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) await server.start() @@ -99,7 +196,40 @@ describe('RelayAgentHookServer', () => { expect(forward.mock.calls[0][0]).toMatchObject({ claudeRunningNonAgentTask: true, - payload: { state: 'working', agentType: 'claude' } + payload: { state: 'working', workingMode: 'monitoring', agentType: 'claude' } + }) + + await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: + 'shell-1completed' + } + }) + }) + await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + payload: { hook_event_name: 'Stop', background_tasks: [], session_crons: [] } + }) + }) + + expect(forward).toHaveBeenCalledTimes(3) + expect(forward.mock.calls[2][0]).toMatchObject({ + claudeRunningNonAgentTask: false, + payload: { state: 'done', agentType: 'claude' } }) } finally { server.stop() diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index fb37dc64e3d..84474cbab00 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -1,8 +1,3 @@ -// Relay-side adapter for the shared agent-hook listener: hosts a loopback HTTP server and -// forwards each parsed payload via a callback so `relay.ts` re-emits it as an `agent.hook` -// JSON-RPC notification over the SSH channel. Replay cache is bounded one-entry-per-paneKey: a -// reattaching Orca only needs each pane's current status, never its history, and the bound keeps a -// long-lived relay from growing with every event. import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import { randomUUID } from 'node:crypto' import { join } from 'node:path' @@ -12,15 +7,17 @@ import { clearAllListenerCaches, clearPaneCacheState, createHookListenerState, - getEndpointFileName, - HOOK_REQUEST_SLOWLORIS_MS, - normalizeHookPayload, - readRequestBody, - resolveHookSource, - writeEndpointFile, - type AgentHookEventPayload, type HookListenerState -} from '../shared/agent-hook-listener' +} from '../shared/agent-hook-listener/listener-state' +import { + getEndpointFileName, + writeEndpointFile +} from '../shared/agent-hook-listener/endpoint-publication' +import { HOOK_REQUEST_SLOWLORIS_MS } from '../shared/agent-hook-listener/listener-limits' +import { normalizeHookPayload } from '../shared/agent-hook-listener' +import { readRequestBody } from '../shared/agent-hook-listener/request-body' +import { resolveHookSource } from '../shared/agent-hook-listener/source-routing' +import type { AgentHookEventPayload } from '../shared/agent-hook-listener/listener-event' import { createHookTransportInterferenceTracker, describeHookTransportInterference, @@ -37,7 +34,6 @@ import { AgentHookResultRetryScheduler } from './agent-hook-result-retry-schedul export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void -// Why: WSL lacks per-pane teardown, so cap replay-cache recency. const MAX_CACHED_PANES = 256 export type RelayHookServerOptions = { @@ -49,7 +45,6 @@ export type RelayHookServerOptions = { token?: string /** Preferred bind port. WSL relay passes the Windows listener's port so env-sourced client coords stay truthful; falls back to :0 if occupied. Defaults to :0. */ preferredPort?: number - /** Called once per parsed payload; the relay wires this to `dispatcher.notify('agent.hook', envelope)`. */ forward: RelayHookForward } @@ -121,7 +116,6 @@ export class RelayAgentHookServer { } } - /** True when the preferred port was occupied and the server fell back to an ephemeral bind. */ get usedPortFallback(): boolean { return this.portFallbackApplied } diff --git a/src/relay/external-automation-command-executor.ts b/src/relay/external-automation-command-executor.ts new file mode 100644 index 00000000000..83f899241e9 --- /dev/null +++ b/src/relay/external-automation-command-executor.ts @@ -0,0 +1,148 @@ +import { + EXTERNAL_AUTOMATION_JOB_ID_PATTERN, + externalAutomationProvider, + isExternalAutomationAction, + type ExternalAutomationAction +} from './external-automation-provider' + +export type ExternalAutomationCommandRunner = ( + command: string, + args: string[], + options: { encoding: 'utf-8'; timeout: number } +) => Promise + +type HermesJobMutation = { + name: string + prompt: string + schedule: string + workdir: string +} + +function hermesCommand(action: ExternalAutomationAction): string { + switch (action) { + case 'pause': + return 'pause' + case 'resume': + return 'resume' + case 'run': + return 'run' + case 'delete': + return 'remove' + } +} + +function openClawCommand(action: ExternalAutomationAction): string { + switch (action) { + case 'pause': + return 'disable' + case 'resume': + return 'enable' + case 'run': + return 'run' + case 'delete': + return 'rm' + } +} + +function normalizeHermesJobMutation(params: Record): HermesJobMutation { + const provider = externalAutomationProvider(params.provider) + if (provider !== 'hermes') { + throw new Error('Only Hermes cron creation and editing are supported.') + } + const name = typeof params.name === 'string' ? params.name.trim() : '' + const prompt = typeof params.prompt === 'string' ? params.prompt.trim() : '' + const schedule = typeof params.schedule === 'string' ? params.schedule.trim() : '' + const workdir = typeof params.workdir === 'string' ? params.workdir.trim() : '' + if (!prompt) { + throw new Error('Hermes cron requires a prompt.') + } + if (!schedule) { + throw new Error('Hermes cron requires a schedule.') + } + return { + name: name || prompt.slice(0, 50).trim(), + prompt, + schedule, + workdir + } +} + +export class ExternalAutomationCommandExecutor { + constructor( + private readonly runCommand: ExternalAutomationCommandRunner, + private readonly clearHermesRunCount: (jobId?: string) => void + ) {} + + async createJob(params: Record = {}): Promise<{ ok: true }> { + const input = normalizeHermesJobMutation(params) + const args = [ + 'cron', + 'create', + input.schedule, + input.prompt, + '--name', + input.name, + '--deliver', + 'local' + ] + if (input.workdir) { + args.push('--workdir', input.workdir) + } + await this.runHermesCronCommand(args) + this.clearHermesRunCount() + return { ok: true } + } + + async updateJob(params: Record = {}): Promise<{ ok: true }> { + const input = normalizeHermesJobMutation(params) + const jobId = params.jobId + if (typeof jobId !== 'string' || !EXTERNAL_AUTOMATION_JOB_ID_PATTERN.test(jobId)) { + throw new Error('Invalid external automation job ID.') + } + const args = [ + 'cron', + 'edit', + jobId, + '--schedule', + input.schedule, + '--prompt', + input.prompt, + '--name', + input.name + ] + if (input.workdir) { + args.push('--workdir', input.workdir) + } + await this.runHermesCronCommand(args) + this.clearHermesRunCount(jobId) + return { ok: true } + } + + async runAction(params: Record = {}): Promise<{ ok: true }> { + const provider = externalAutomationProvider(params.provider) + const action = params.action + const jobId = params.jobId + if (!isExternalAutomationAction(action)) { + throw new Error('Unsupported external automation action.') + } + if (typeof jobId !== 'string' || !EXTERNAL_AUTOMATION_JOB_ID_PATTERN.test(jobId)) { + throw new Error('Invalid external automation job ID.') + } + const command = provider === 'hermes' ? hermesCommand(action) : openClawCommand(action) + await this.runCommand(provider, ['cron', command, jobId], { + encoding: 'utf-8', + timeout: 30_000 + }) + if (provider === 'hermes') { + this.clearHermesRunCount(jobId) + } + return { ok: true } + } + + private async runHermesCronCommand(args: string[]): Promise { + await this.runCommand('hermes', args, { + encoding: 'utf-8', + timeout: 30_000 + }) + } +} diff --git a/src/relay/external-automation-provider-catalog.test.ts b/src/relay/external-automation-provider-catalog.test.ts new file mode 100644 index 00000000000..d2a600b073e --- /dev/null +++ b/src/relay/external-automation-provider-catalog.test.ts @@ -0,0 +1,87 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('ExternalAutomationProviderCatalog', () => { + let hermesHome: string + let previousHermesHome: string | undefined + + beforeEach(async () => { + previousHermesHome = process.env.HERMES_HOME + hermesHome = await mkdtemp(join(tmpdir(), 'relay-hermes-catalog-')) + process.env.HERMES_HOME = hermesHome + await mkdir(join(hermesHome, 'cron'), { recursive: true }) + vi.resetModules() + }) + + afterEach(async () => { + if (previousHermesHome === undefined) { + delete process.env.HERMES_HOME + } else { + process.env.HERMES_HOME = previousHermesHome + } + await rm(hermesHome, { recursive: true, force: true }) + vi.resetModules() + }) + + it('returns readable jobs when the remote CLI is unavailable', async () => { + await writeFile( + join(hermesHome, 'cron', 'jobs.json'), + JSON.stringify({ jobs: [{ id: 'job-1', name: 'Monitor' }] }), + 'utf-8' + ) + const { ExternalAutomationProviderCatalog } = + await import('./external-automation-provider-catalog') + const catalog = new ExternalAutomationProviderCatalog( + vi.fn().mockRejectedValue(new Error('not found')), + vi.fn().mockResolvedValue({ total: 3, runs: [] }) + ) + + await expect(catalog.listJobs({ provider: 'hermes' })).resolves.toEqual({ + jobs: [{ id: 'job-1', name: 'Monitor', run_count: 3, runs: [] }], + hermesAvailable: false, + openclawAvailable: false, + error: null + }) + }) + + it('projects jobs-file parse failures without hiding command availability', async () => { + await writeFile(join(hermesHome, 'cron', 'jobs.json'), '{not-json', 'utf-8') + const { ExternalAutomationProviderCatalog } = + await import('./external-automation-provider-catalog') + const catalog = new ExternalAutomationProviderCatalog( + vi.fn().mockResolvedValue(undefined), + vi.fn() + ) + + const result = await catalog.listJobs({ provider: 'hermes' }) + + expect(result.jobs).toEqual([]) + expect(result.hermesAvailable).toBe(true) + expect(result.openclawAvailable).toBe(false) + expect(result.error).toMatch(/^SyntaxError:/) + }) + + it('preserves all-or-nothing Hermes count enrichment failures', async () => { + await writeFile( + join(hermesHome, 'cron', 'jobs.json'), + JSON.stringify([{ id: 'job-1' }, { id: '--invalid' }]), + 'utf-8' + ) + const { ExternalAutomationProviderCatalog } = + await import('./external-automation-provider-catalog') + const catalog = new ExternalAutomationProviderCatalog( + vi.fn().mockResolvedValue(undefined), + vi + .fn() + .mockResolvedValueOnce({ total: 1, runs: [] }) + .mockRejectedValueOnce(new Error('Invalid external automation job ID.')) + ) + + const result = await catalog.listJobs({ provider: 'hermes' }) + + expect(result.jobs).toEqual([]) + expect(result.error).toBe('Error: Invalid external automation job ID.') + }) +}) diff --git a/src/relay/external-automation-provider-catalog.ts b/src/relay/external-automation-provider-catalog.ts new file mode 100644 index 00000000000..06408160e98 --- /dev/null +++ b/src/relay/external-automation-provider-catalog.ts @@ -0,0 +1,93 @@ +import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { HERMES_JOBS_FILE, OPENCLAW_JOBS_FILE } from './external-automation-storage-paths' +import { + externalAutomationProvider, + type ExternalAutomationProvider +} from './external-automation-provider' +import type { ExternalAutomationCommandRunner } from './external-automation-command-executor' + +type HermesRunLister = (params: Record) => Promise<{ + total: number + runs: unknown[] +}> + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export class ExternalAutomationProviderCatalog { + constructor( + private readonly runCommand: ExternalAutomationCommandRunner, + private readonly listRuns: HermesRunLister + ) {} + + async listJobs(params?: Record): Promise<{ + jobs: unknown[] + hermesAvailable: boolean + openclawAvailable: boolean + error: string | null + }> { + const provider = externalAutomationProvider(params?.provider) + const [commandAvailable, jobsResult] = await Promise.allSettled([ + this.isCommandAvailable(provider), + this.readJobs(provider) + ]) + const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : [] + const available = commandAvailable.status === 'fulfilled' && commandAvailable.value + return { + jobs, + hermesAvailable: provider === 'hermes' && available, + openclawAvailable: provider === 'openclaw' && available, + error: jobsResult.status === 'rejected' ? String(jobsResult.reason) : null + } + } + + private async isCommandAvailable(command: string): Promise { + const finder = process.platform === 'win32' ? 'where' : 'which' + try { + await this.runCommand(finder, [command], { + encoding: 'utf-8', + timeout: 5000 + }) + return true + } catch { + return false + } + } + + private async readJobs(provider: ExternalAutomationProvider): Promise { + const jobsFile = provider === 'hermes' ? HERMES_JOBS_FILE : OPENCLAW_JOBS_FILE + if (!existsSync(jobsFile)) { + return [] + } + const content = await readFile(jobsFile, 'utf-8') + const parsed = JSON.parse(content) as unknown + const jobs = Array.isArray(parsed) + ? parsed + : isRecord(parsed) && Array.isArray(parsed.jobs) + ? parsed.jobs + : [] + if (provider !== 'hermes') { + return jobs + } + return Promise.all( + jobs.map(async (job) => { + if (!isRecord(job) || typeof job.id !== 'string') { + return job + } + const runsPage = await this.listRuns({ + provider: 'hermes', + jobId: job.id, + page: 1, + pageSize: 0 + }) + return { + ...job, + run_count: runsPage.total, + runs: runsPage.runs + } + }) + ) + } +} diff --git a/src/relay/external-automation-provider.ts b/src/relay/external-automation-provider.ts new file mode 100644 index 00000000000..08028ab5439 --- /dev/null +++ b/src/relay/external-automation-provider.ts @@ -0,0 +1,13 @@ +export type ExternalAutomationProvider = 'hermes' | 'openclaw' + +export type ExternalAutomationAction = 'pause' | 'resume' | 'run' | 'delete' + +export const EXTERNAL_AUTOMATION_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ + +export function externalAutomationProvider(value: unknown): ExternalAutomationProvider { + return value === 'openclaw' ? 'openclaw' : 'hermes' +} + +export function isExternalAutomationAction(value: unknown): value is ExternalAutomationAction { + return value === 'pause' || value === 'resume' || value === 'run' || value === 'delete' +} diff --git a/src/relay/external-automation-storage-paths.ts b/src/relay/external-automation-storage-paths.ts new file mode 100644 index 00000000000..5079c8bf960 --- /dev/null +++ b/src/relay/external-automation-storage-paths.ts @@ -0,0 +1,9 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' + +export const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes') +export const HERMES_CRON_DIR = join(HERMES_HOME, 'cron') +export const HERMES_JOBS_FILE = join(HERMES_CRON_DIR, 'jobs.json') +export const HERMES_OUTPUT_DIR = join(HERMES_CRON_DIR, 'output') +export const HERMES_STATE_DB = join(HERMES_HOME, 'state.db') +export const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json') diff --git a/src/relay/external-automations-handler-log-path.test.ts b/src/relay/external-automations-handler-log-path.test.ts index fb48f21397c..ca865c86540 100644 --- a/src/relay/external-automations-handler-log-path.test.ts +++ b/src/relay/external-automations-handler-log-path.test.ts @@ -2,11 +2,6 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { RelayDispatcher } from './dispatcher' - -type ReferencedLogReader = { - readReferencedLogFile(content: string): Promise<{ content: string } | null> -} describe('ExternalAutomationsHandler referenced log paths', () => { let hermesHome: string @@ -34,12 +29,9 @@ describe('ExternalAutomationsHandler referenced log paths', () => { await mkdir(dirname(logPath), { recursive: true }) await writeFile(logPath, 'remote dot-dot-prefixed log line\n', 'utf-8') - const { ExternalAutomationsHandler } = await import('./external-automations-handler') - const handler = new ExternalAutomationsHandler({ - onRequest: () => {} - } as unknown as RelayDispatcher) as unknown as ReferencedLogReader + const { readHermesReferencedLogFile } = await import('./hermes-output-run-files') - const result = await handler.readReferencedLogFile(`Latest log path: ${logPath} + const result = await readHermesReferencedLogFile(`Latest log path: ${logPath} Run summary: monitor automation completed successfully.`) expect(result?.content).toBe('remote dot-dot-prefixed log line\n') diff --git a/src/relay/external-automations-handler.test.ts b/src/relay/external-automations-handler.test.ts index d2a19d79c2d..66209b3e121 100644 --- a/src/relay/external-automations-handler.test.ts +++ b/src/relay/external-automations-handler.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ExternalAutomationCommandExecutor } from './external-automation-command-executor' import { ExternalAutomationsHandler } from './external-automations-handler' import type { RelayDispatcher } from './dispatcher' @@ -16,18 +17,15 @@ vi.mock('child_process', () => ({ execFile: execFileMock })) type CapturedHandler = (params?: Record) => Promise -function createHandlerHarness(): { - handler: ExternalAutomationsHandler - requestHandlers: Map -} { +function createHandlerHarness(): Map { const requestHandlers = new Map() const dispatcher = { onRequest(method: string, handler: CapturedHandler): void { requestHandlers.set(method, handler) } } - const handler = new ExternalAutomationsHandler(dispatcher as unknown as RelayDispatcher) - return { handler, requestHandlers } + new ExternalAutomationsHandler(dispatcher as unknown as RelayDispatcher) + return requestHandlers } beforeEach(() => { @@ -35,8 +33,18 @@ beforeEach(() => { }) describe('ExternalAutomationsHandler', () => { - it('runs external lifecycle actions without shell wrapping', async () => { - const { requestHandlers } = createHandlerHarness() + it('preserves the five external automation routes', () => { + expect([...createHandlerHarness().keys()]).toEqual([ + 'externalAutomations.list', + 'externalAutomations.runs', + 'externalAutomations.create', + 'externalAutomations.update', + 'externalAutomations.act' + ]) + }) + + it('runs lifecycle actions without shell wrapping', async () => { + const requestHandlers = createHandlerHarness() await requestHandlers.get('externalAutomations.act')?.({ provider: 'hermes', @@ -52,189 +60,128 @@ describe('ExternalAutomationsHandler', () => { ) }) - it('paginates remote Hermes run history after ref lookup', async () => { - const { handler, requestHandlers } = createHandlerHarness() - const handlerInternals = handler as unknown as { - readHermesRunRefs: (jobId: string) => Promise<{ id: string; run_at: string }[]> - hydrateHermesRunRef: ( - jobId: string, - ref: { id: string; run_at: string } - ) => Promise<{ id: string; run_at: string }> - } - handlerInternals.readHermesRunRefs = vi.fn().mockResolvedValue([ - { - id: 'cron_job-1_20260516_090000', - run_at: '2026-05-16T09:00:00' - }, - { - id: 'job-1:2026-05-15_09-00-00.md', - run_at: '2026-05-15T09:00:00' - }, - { - id: 'job-1:2026-05-14_09-00-00.md', - run_at: '2026-05-14T09:00:00' - } - ]) - handlerInternals.hydrateHermesRunRef = vi.fn(async (_jobId, ref) => ref) - - const result = (await requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-1', - page: 1, - pageSize: 2 - })) as { total: number; runs: { id: string }[] } - - expect(result.total).toBe(3) - expect(result.runs.map((run) => run.id)).toEqual([ - 'cron_job-1_20260516_090000', - 'job-1:2026-05-15_09-00-00.md' - ]) - }) - - it('uses a count-only path for remote Hermes manager listing run counts', async () => { - const { handler, requestHandlers } = createHandlerHarness() - const handlerInternals = handler as unknown as { - readHermesRunCount: (jobId: string) => Promise - } - handlerInternals.readHermesRunCount = vi.fn().mockResolvedValue(42) - - const result = (await requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-1', - page: 1, - pageSize: 0 - })) as { total: number; runs: unknown[] } - - expect(result).toEqual({ total: 42, runs: [] }) - }) - - it('deduplicates concurrent remote Hermes count reads', async () => { - const { handler, requestHandlers } = createHandlerHarness() - let resolveRefs: (refs: { id: string; run_at: string }[]) => void = () => {} - const readHermesRunRefs = vi.fn( - () => - new Promise<{ id: string; run_at: string }[]>((resolve) => { - resolveRefs = resolve - }) - ) - const handlerInternals = handler as unknown as { - readHermesRunRefs: typeof readHermesRunRefs - } - handlerInternals.readHermesRunRefs = readHermesRunRefs - - const first = requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-1', - page: 1, - pageSize: 0 + it('propagates command failures through the registered route', async () => { + const commandError = new Error('remote Hermes failed') + execFileMock.mockImplementationOnce((...args: unknown[]) => { + const callback = args.at(-1) as (error: Error) => void + callback(commandError) }) - const second = requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-1', - page: 1, - pageSize: 0 - }) - - expect(readHermesRunRefs).toHaveBeenCalledTimes(1) - resolveRefs([ - { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }, - { id: 'job-1:2026-05-16_09-00-00.md', run_at: '2026-05-16T09:00:00' } - ]) - - await expect(Promise.all([first, second])).resolves.toEqual([ - { total: 2, runs: [] }, - { total: 2, runs: [] } - ]) - }) - - it('clears the remote Hermes count cache after lifecycle actions', async () => { - const { handler, requestHandlers } = createHandlerHarness() - const readHermesRunRefs = vi - .fn() - .mockResolvedValueOnce([ - { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' } - ]) - .mockResolvedValueOnce([ - { id: 'job-1:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }, - { id: 'job-1:2026-05-16_09-00-00.md', run_at: '2026-05-16T09:00:00' } - ]) - const handlerInternals = handler as unknown as { - readHermesRunRefs: typeof readHermesRunRefs - } - handlerInternals.readHermesRunRefs = readHermesRunRefs + const requestHandlers = createHandlerHarness() await expect( - requestHandlers.get('externalAutomations.runs')?.({ + requestHandlers.get('externalAutomations.act')?.({ provider: 'hermes', - jobId: 'job-1', - page: 1, - pageSize: 0 + action: 'run', + jobId: 'job-1' }) - ).resolves.toEqual({ total: 1, runs: [] }) + ).rejects.toBe(commandError) + }) +}) + +describe('ExternalAutomationCommandExecutor', () => { + it.each([ + ['hermes', 'pause', 'pause'], + ['hermes', 'resume', 'resume'], + ['hermes', 'run', 'run'], + ['hermes', 'delete', 'remove'], + ['openclaw', 'pause', 'disable'], + ['openclaw', 'resume', 'enable'], + ['openclaw', 'run', 'run'], + ['openclaw', 'delete', 'rm'] + ])('maps %s %s to its provider command', async (provider, action, command) => { + const runCommand = vi.fn().mockResolvedValue(undefined) + const executor = new ExternalAutomationCommandExecutor(runCommand, vi.fn()) + + await executor.runAction({ provider, action, jobId: 'job-1' }) + + expect(runCommand).toHaveBeenCalledWith(provider, ['cron', command, 'job-1'], { + encoding: 'utf-8', + timeout: 30_000 + }) + }) + + it('does not clear count state when a command fails', async () => { + const commandError = new Error('command failed') + const clearRunCount = vi.fn() + const executor = new ExternalAutomationCommandExecutor( + vi.fn().mockRejectedValue(commandError), + clearRunCount + ) + await expect( - requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-1', - page: 1, - pageSize: 0 - }) - ).resolves.toEqual({ total: 1, runs: [] }) + executor.runAction({ provider: 'hermes', action: 'run', jobId: 'job-1' }) + ).rejects.toBe(commandError) + expect(clearRunCount).not.toHaveBeenCalled() + }) + + it('preserves Hermes create arguments and clears all cached counts after success', async () => { + const runCommand = vi.fn().mockResolvedValue(undefined) + const clearRunCount = vi.fn() + const executor = new ExternalAutomationCommandExecutor(runCommand, clearRunCount) - await requestHandlers.get('externalAutomations.act')?.({ + await executor.createJob({ provider: 'hermes', - action: 'run', - jobId: 'job-1' + name: 'Nightly review', + prompt: 'Review the workspace', + schedule: '0 2 * * *', + workdir: '/remote/workspace' }) - await expect( - requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-1', - page: 1, - pageSize: 0 - }) - ).resolves.toEqual({ total: 2, runs: [] }) - expect(readHermesRunRefs).toHaveBeenCalledTimes(2) + + expect(runCommand).toHaveBeenCalledWith( + 'hermes', + [ + 'cron', + 'create', + '0 2 * * *', + 'Review the workspace', + '--name', + 'Nightly review', + '--deliver', + 'local', + '--workdir', + '/remote/workspace' + ], + { encoding: 'utf-8', timeout: 30_000 } + ) + expect(clearRunCount).toHaveBeenCalledWith() }) - it('evicts oldest remote Hermes count cache entries when many job ids are observed', async () => { - const { handler, requestHandlers } = createHandlerHarness() - const readHermesRunRefs = vi.fn(async (jobId: string) => - jobId === 'job-0' - ? [{ id: 'job-0:2026-05-15_09-00-00.md', run_at: '2026-05-15T09:00:00' }] - : [] + it('preserves Hermes update arguments and clears only the edited job count', async () => { + const runCommand = vi.fn().mockResolvedValue(undefined) + const clearRunCount = vi.fn() + const executor = new ExternalAutomationCommandExecutor(runCommand, clearRunCount) + + await executor.updateJob({ + provider: 'hermes', + jobId: 'job-1', + name: 'Updated review', + prompt: 'Review changes', + schedule: '15 3 * * *' + }) + + expect(runCommand).toHaveBeenCalledWith( + 'hermes', + [ + 'cron', + 'edit', + 'job-1', + '--schedule', + '15 3 * * *', + '--prompt', + 'Review changes', + '--name', + 'Updated review' + ], + { encoding: 'utf-8', timeout: 30_000 } ) - const handlerInternals = handler as unknown as { - readHermesRunRefs: typeof readHermesRunRefs - } - handlerInternals.readHermesRunRefs = readHermesRunRefs + expect(clearRunCount).toHaveBeenCalledWith('job-1') + }) - await expect( - requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-0', - page: 1, - pageSize: 0 - }) - ).resolves.toEqual({ total: 1, runs: [] }) + it('preserves update validation order', async () => { + const executor = new ExternalAutomationCommandExecutor(vi.fn(), vi.fn()) - for (let i = 1; i <= 200; i += 1) { - await requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: `job-${i}`, - page: 1, - pageSize: 0 - }) - } - await expect( - requestHandlers.get('externalAutomations.runs')?.({ - provider: 'hermes', - jobId: 'job-0', - page: 1, - pageSize: 0 - }) - ).resolves.toEqual({ total: 1, runs: [] }) - - expect(readHermesRunRefs).toHaveBeenCalledTimes(202) + executor.updateJob({ provider: 'hermes', jobId: '--help', prompt: '', schedule: '' }) + ).rejects.toThrow('Hermes cron requires a prompt.') }) }) diff --git a/src/relay/external-automations-handler.ts b/src/relay/external-automations-handler.ts index 03bb0b9beba..6c5d8af98ae 100644 --- a/src/relay/external-automations-handler.ts +++ b/src/relay/external-automations-handler.ts @@ -1,967 +1,39 @@ -/* eslint-disable max-lines -- Why: relay external automation listing, paginated - * run history, and actions must stay co-located behind one relay request handler. */ import { execFile } from 'node:child_process' -import { existsSync } from 'node:fs' -import { open, readdir, readFile, realpath, stat } from 'node:fs/promises' -import { createRequire } from 'node:module' -import { homedir } from 'node:os' -import { isAbsolute, join, relative, resolve, sep } from 'node:path' import { promisify } from 'node:util' import type { RelayDispatcher } from './dispatcher' +import { + ExternalAutomationCommandExecutor, + type ExternalAutomationCommandRunner +} from './external-automation-command-executor' +import { ExternalAutomationProviderCatalog } from './external-automation-provider-catalog' +import { HermesRunHistory } from './hermes-run-history' const execFileAsync = promisify(execFile) -const requireOptional = createRequire(__filename) -const HERMES_HOME = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes') -const HERMES_CRON_DIR = join(HERMES_HOME, 'cron') -const HERMES_JOBS_FILE = join(HERMES_CRON_DIR, 'jobs.json') -const HERMES_OUTPUT_DIR = join(HERMES_CRON_DIR, 'output') -const HERMES_STATE_DB = join(HERMES_HOME, 'state.db') -const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json') -const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ -const HERMES_OUTPUT_FILE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.md$/ -const HERMES_RUN_KEY_PATTERN = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/ -const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000 -const MAX_REFERENCED_LOG_BYTES = 5 * 1024 * 1024 -const HERMES_RUN_COUNT_CACHE_MAX_ENTRIES = 200 -const FULL_SESSION_LOG_HEADING = '## Full session log' -const REFERENCED_LOG_HEADING = '## Latest log file' -const LATEST_LOG_PATH_PATTERN = - /\bLatest log path:\s*(?(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?)(?=\s+Run summary:|\r?\n|$)/i -type SqliteStatement = { - get: (...args: unknown[]) => Record | undefined - all: (...args: unknown[]) => Record[] -} -type SqliteDatabase = { - prepare: (sql: string) => SqliteStatement - close: () => void -} -type DatabaseConstructor = new ( - path: string, - options?: { readonly?: boolean; fileMustExist?: boolean; timeout?: number } -) => SqliteDatabase -type NodeSqliteDatabaseSync = new ( - path: string, - options?: { readOnly?: boolean; timeout?: number } -) => SqliteDatabase -let databaseConstructor: DatabaseConstructor | null | undefined -type ExternalProvider = 'hermes' | 'openclaw' -type HermesAction = 'pause' | 'resume' | 'run' | 'delete' -type HermesOutputRunRef = { - kind: 'output' - id: string - job_id: string - run_at: string | null - run_key: string | null - output_path: string +const runExternalAutomationCommand: ExternalAutomationCommandRunner = async ( + command, + args, + options +) => { + await execFileAsync(command, args, options) } -type HermesSessionRunRef = { - kind: 'session' - id: string - job_id: string - run_at: string | null - run_key: string | null -} -type HermesMergedRunRef = { - id: string - job_id: string - run_at: string | null - run_key: string | null - output: HermesOutputRunRef | null - session: HermesSessionRunRef | null -} -type HermesRunCountCacheEntry = { - promise: Promise - expiresAt: number -} - -const HERMES_RUN_COUNT_CACHE_TTL_MS = 2000 export class ExternalAutomationsHandler { - private readonly hermesRunCountCache = new Map() + private readonly runHistory = new HermesRunHistory() + private readonly commands = new ExternalAutomationCommandExecutor( + runExternalAutomationCommand, + (jobId) => this.runHistory.clearRunCount(jobId) + ) + private readonly providers = new ExternalAutomationProviderCatalog( + runExternalAutomationCommand, + (params) => this.runHistory.listRuns(params) + ) - constructor(private readonly dispatcher: RelayDispatcher) { - this.dispatcher.onRequest('externalAutomations.list', (params) => this.listJobs(params)) - this.dispatcher.onRequest('externalAutomations.runs', (params) => this.listRuns(params)) - this.dispatcher.onRequest('externalAutomations.create', (params) => this.createJob(params)) - this.dispatcher.onRequest('externalAutomations.update', (params) => this.updateJob(params)) - this.dispatcher.onRequest('externalAutomations.act', (params) => this.runAction(params)) - } - - private async isCommandAvailable(command: string): Promise { - const finder = process.platform === 'win32' ? 'where' : 'which' - try { - await execFileAsync(finder, [command], { - encoding: 'utf-8', - timeout: 5000 - }) - return true - } catch { - return false - } - } - - private async readJobs(provider: ExternalProvider): Promise { - const jobsFile = provider === 'hermes' ? HERMES_JOBS_FILE : OPENCLAW_JOBS_FILE - if (!existsSync(jobsFile)) { - return [] - } - const content = await readFile(jobsFile, 'utf-8') - const parsed = JSON.parse(content) as unknown - const jobs = Array.isArray(parsed) - ? parsed - : typeof parsed === 'object' && - parsed !== null && - !Array.isArray(parsed) && - Array.isArray((parsed as { jobs?: unknown }).jobs) - ? (parsed as { jobs: unknown[] }).jobs - : [] - if (provider !== 'hermes') { - return jobs - } - return Promise.all( - jobs.map(async (job) => { - if (!this.isRecord(job) || typeof job.id !== 'string') { - return job - } - const runsPage = await this.listRuns({ - provider: 'hermes', - jobId: job.id, - page: 1, - pageSize: 0 - }) - return { - ...job, - run_count: runsPage.total, - runs: runsPage.runs - } - }) - ) - } - - private isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) - } - - private runAtFromHermesOutputFile(filename: string): string | null { - const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename) - if (!match) { - return null - } - const [, year, month, day, hour, minute, second] = match - return `${year}-${month}-${day}T${hour}:${minute}:${second}` - } - - private runKeyFromHermesOutputFile(filename: string): string | null { - const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename) - if (!match) { - return null - } - const [, year, month, day, hour, minute, second] = match - return `${year}${month}${day}_${hour}${minute}${second}` - } - - private runAtFromUnixSeconds(value: unknown): string | null { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return null - } - const date = new Date(value * 1000) - return Number.isNaN(date.getTime()) ? null : date.toISOString() - } - - private sortableTimeFromRunKey(runKey: string | null): number { - if (!runKey) { - return Number.NaN - } - const match = HERMES_RUN_KEY_PATTERN.exec(runKey) - if (!match) { - return Number.NaN - } - const [, year, month, day, hour, minute, second] = match - return Date.UTC( - Number(year), - Number(month) - 1, - Number(day), - Number(hour), - Number(minute), - Number(second) - ) - } - - private escapeSqlLike(value: string): string { - return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_') - } - - private cleanRunPreview(value: string): string | null { - const normalized = value - .replace(/```[\s\S]*?```/g, ' ') - .replace(/[#*_`>()]/g, ' ') - .replaceAll('[', ' ') - .replaceAll(']', ' ') - .replace(/\s+/g, ' ') - .trim() - if (!normalized) { - return null - } - return normalized.length > 180 ? `${normalized.slice(0, 177)}...` : normalized - } - - private parseHermesOutput(content: string): { - status: 'completed' | 'failed' | 'unknown' - outputPreview: string | null - outputContent: string - error: string | null - } { - const failed = /^#\s+Cron Job:.*\(FAILED\)/m.test(content) || /^##\s+Error\b/m.test(content) - const errorMatch = /##\s+Error\s+```([\s\S]*?)```/m.exec(content) - const responseMatch = /##\s+Response\s+([\s\S]*)$/m.exec(content) - const error = errorMatch ? this.cleanRunPreview(errorMatch[1]) : null - return { - status: failed ? 'failed' : responseMatch ? 'completed' : 'unknown', - outputPreview: this.cleanRunPreview(responseMatch?.[1] ?? errorMatch?.[1] ?? content), - outputContent: content, - error - } - } - - private extractLatestLogPath(content: string): string | null { - const rawPath = LATEST_LOG_PATH_PATTERN.exec(content)?.groups?.path?.trim() - if (!rawPath) { - return null - } - return rawPath.replace(/^`|`$/g, '').trim() - } - - private async readReferencedLogFile(content: string): Promise<{ - path: string - content: string - truncated: boolean - } | null> { - const logPath = this.extractLatestLogPath(content) - if (!logPath || !isAbsolute(logPath)) { - return null - } - try { - const homeRealPath = await realpath(HERMES_HOME) - const logRealPath = await realpath(logPath) - const relativeToHermesHome = relative(resolve(homeRealPath), resolve(logRealPath)) - // Why: the output body can contain agent-authored text, so only hydrate - // referenced files that resolve inside Hermes' own data directory. - if ( - relativeToHermesHome === '..' || - relativeToHermesHome.startsWith(`..${sep}`) || - isAbsolute(relativeToHermesHome) - ) { - return null - } - const logStat = await stat(logPath) - if (!logStat.isFile()) { - return null - } - if (logStat.size <= MAX_REFERENCED_LOG_BYTES) { - return { - path: logPath, - content: await readFile(logPath, 'utf-8'), - truncated: false - } - } - const file = await open(logPath, 'r') - try { - const buffer = Buffer.alloc(MAX_REFERENCED_LOG_BYTES) - await file.read( - buffer, - 0, - MAX_REFERENCED_LOG_BYTES, - logStat.size - MAX_REFERENCED_LOG_BYTES - ) - return { - path: logPath, - content: buffer.toString('utf-8'), - truncated: true - } - } finally { - await file.close() - } - } catch { - return null - } - } - - private async appendReferencedLogFile(content: string): Promise { - if (content.includes(REFERENCED_LOG_HEADING)) { - return content - } - const logFile = await this.readReferencedLogFile(content) - if (!logFile) { - return content - } - const note = logFile.truncated - ? `Showing the last ${MAX_REFERENCED_LOG_BYTES} bytes because the log file is larger.` - : null - return [ - content, - '---', - REFERENCED_LOG_HEADING, - '', - `Path: ${logFile.path}`, - note, - '```text', - logFile.content.trimEnd(), - '```' - ] - .filter((part) => part !== null) - .join('\n\n') - } - - private formatSessionMessages(messages: Record[]): string | null { - if (messages.length === 0) { - return null - } - return messages - .map((message) => { - const role = typeof message.role === 'string' ? message.role : 'message' - const content = typeof message.content === 'string' ? message.content.trim() : '' - const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : '' - const reasoning = - typeof message.reasoning_content === 'string' - ? message.reasoning_content.trim() - : typeof message.reasoning === 'string' - ? message.reasoning.trim() - : '' - const parts = [ - `## ${role}${toolName ? ` / ${toolName}` : ''}`, - reasoning ? `### Reasoning\n\n${reasoning}` : null, - content || '(empty)' - ].filter(Boolean) - return parts.join('\n\n') - }) - .join('\n\n---\n\n') - } - - private getRunKey(run: unknown): string | null { - return this.isRecord(run) && typeof run.run_key === 'string' && run.run_key.trim() - ? run.run_key - : null - } - - private getRunOutputContent(run: unknown): string | null { - return this.isRecord(run) && typeof run.output_content === 'string' && run.output_content.trim() - ? run.output_content - : null - } - - private getRunOutputPreview(run: unknown): string | null { - return this.isRecord(run) && typeof run.output_preview === 'string' && run.output_preview.trim() - ? run.output_preview - : null - } - - private mergeOutputAndSessionContent( - outputContent: string | null, - sessionContent: string | null - ): string | null { - if (!sessionContent) { - return outputContent - } - if (!outputContent) { - return `${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` - } - if (outputContent.includes(FULL_SESSION_LOG_HEADING)) { - return outputContent - } - return `${outputContent}\n\n---\n\n${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` - } - - private findMatchingSessionRunIndex( - outputRun: unknown, - sessionRuns: unknown[], - usedSessionRunIndexes: Set - ): number | null { - const outputRunKey = this.getRunKey(outputRun) - const exactMatchIndex = sessionRuns.findIndex( - (sessionRun, index) => - !usedSessionRunIndexes.has(index) && this.getRunKey(sessionRun) === outputRunKey - ) - if (exactMatchIndex !== -1) { - return exactMatchIndex - } - - const outputTime = this.sortableTimeFromRunKey(outputRunKey) - if (!Number.isFinite(outputTime)) { - return null - } - - let bestIndex: number | null = null - let bestGap = Number.POSITIVE_INFINITY - for (let index = 0; index < sessionRuns.length; index += 1) { - if (usedSessionRunIndexes.has(index)) { - continue - } - const sessionTime = this.sortableTimeFromRunKey(this.getRunKey(sessionRuns[index])) - if (!Number.isFinite(sessionTime)) { - continue - } - const gap = outputTime - sessionTime - if (gap < 0 || gap > MAX_SESSION_OUTPUT_GAP_MS || gap >= bestGap) { - continue - } - bestIndex = index - bestGap = gap - } - return bestIndex - } - - private mergeHermesOutputAndSessionRuns( - outputRuns: unknown[], - sessionRuns: unknown[] - ): unknown[] { - const usedSessionRunIndexes = new Set() - const mergedOutputRuns = outputRuns.map((outputRun) => { - if (!this.isRecord(outputRun)) { - return outputRun - } - const sessionRunIndex = this.findMatchingSessionRunIndex( - outputRun, - sessionRuns, - usedSessionRunIndexes - ) - if (sessionRunIndex === null) { - return outputRun - } - const sessionRun = sessionRuns[sessionRunIndex] - if (!this.isRecord(sessionRun)) { - return outputRun - } - usedSessionRunIndexes.add(sessionRunIndex) - // Hermes writes the markdown output at completion, while state.db keeps - // the actual turn-by-turn transcript under the cron session start time. - return { - ...outputRun, - output_preview: this.getRunOutputPreview(outputRun) ?? this.getRunOutputPreview(sessionRun), - output_content: this.mergeOutputAndSessionContent( - this.getRunOutputContent(outputRun), - this.getRunOutputContent(sessionRun) - ) - } - }) - return [ - ...mergedOutputRuns, - ...sessionRuns.filter((_, index) => !usedSessionRunIndexes.has(index)) - ] - } - - private mergeHermesOutputAndSessionRunRefs( - outputRefs: HermesOutputRunRef[], - sessionRefs: HermesSessionRunRef[] - ): HermesMergedRunRef[] { - const usedSessionRunIndexes = new Set() - const mergedOutputRefs = outputRefs.map((outputRef) => { - const sessionRunIndex = this.findMatchingSessionRunIndex( - outputRef, - sessionRefs, - usedSessionRunIndexes - ) - const sessionRef = sessionRunIndex === null ? null : sessionRefs[sessionRunIndex] - if (sessionRunIndex !== null) { - usedSessionRunIndexes.add(sessionRunIndex) - } - return { - id: outputRef.id, - job_id: outputRef.job_id, - run_at: outputRef.run_at, - run_key: outputRef.run_key, - output: outputRef, - session: sessionRef - } - }) - return [ - ...mergedOutputRefs, - ...sessionRefs - .filter((_, index) => !usedSessionRunIndexes.has(index)) - .map((sessionRef) => ({ - id: sessionRef.id, - job_id: sessionRef.job_id, - run_at: sessionRef.run_at, - run_key: sessionRef.run_key, - output: null, - session: sessionRef - })) - ] - } - - private getDatabaseConstructor(): DatabaseConstructor | null { - if (databaseConstructor !== undefined) { - return databaseConstructor - } - try { - // Why: the remote relay still targets Node 18. Hosts without node:sqlite - // should keep listing file-backed runs and simply omit DB transcripts. - const loaded = requireOptional('node:sqlite') as { - DatabaseSync?: NodeSqliteDatabaseSync - } - const DatabaseSync = loaded.DatabaseSync - if (typeof DatabaseSync !== 'function') { - databaseConstructor = null - return databaseConstructor - } - const SqliteDatabaseSync = DatabaseSync - databaseConstructor = class RelaySqliteDatabase { - private readonly db: SqliteDatabase - - constructor( - path: string, - options: { readonly?: boolean; fileMustExist?: boolean; timeout?: number } = {} - ) { - if (options.fileMustExist && !existsSync(path)) { - throw new Error(`SQLite database does not exist: ${path}`) - } - this.db = new SqliteDatabaseSync(path, { - readOnly: options.readonly, - timeout: options.timeout - }) - } - - prepare(sql: string): SqliteStatement { - return this.db.prepare(sql) - } - - close(): void { - this.db.close() - } - } - } catch { - databaseConstructor = null - } - return databaseConstructor - } - - private async readHermesRunRefs(jobId: string): Promise { - const outputRuns = await this.readHermesOutputFileRunRefs(jobId) - return this.mergeHermesOutputAndSessionRunRefs( - outputRuns, - this.readHermesSessionDbRunRefs(jobId) - ).sort((a, b) => { - const aTime = this.getRawRunTime(a) - const bTime = this.getRawRunTime(b) - if (Number.isFinite(aTime) && Number.isFinite(bTime)) { - return bTime - aTime - } - return this.getRawRunId(b).localeCompare(this.getRawRunId(a)) - }) - } - - private async hydrateHermesRunRef(jobId: string, ref: HermesMergedRunRef): Promise { - const outputRun = ref.output ? await this.readHermesOutputFileRun(ref.output) : null - const sessionRun = ref.session ? this.readHermesSessionDbRunById(jobId, ref.session.id) : null - return ( - this.mergeHermesOutputAndSessionRuns( - outputRun ? [outputRun] : [], - sessionRun ? [sessionRun] : [] - )[0] ?? - outputRun ?? - sessionRun ?? - ref - ) - } - - private async readHermesRunCount(jobId: string): Promise { - if (!EXTERNAL_JOB_ID_PATTERN.test(jobId)) { - return 0 - } - const now = Date.now() - const cached = this.hermesRunCountCache.get(jobId) - if (cached && cached.expiresAt > now) { - return cached.promise - } - if (cached) { - this.hermesRunCountCache.delete(jobId) - } - // Why: remote Hermes jobs can churn independently of Orca; relay - // processes are long-lived, so stale job ids need both TTL and a hard cap. - this.pruneHermesRunCountCache(now) - const entry: HermesRunCountCacheEntry = { - promise: this.readHermesRunRefs(jobId).then((refs) => refs.length), - expiresAt: Number.POSITIVE_INFINITY - } - this.hermesRunCountCache.set(jobId, entry) - try { - const count = await entry.promise - entry.expiresAt = Date.now() + HERMES_RUN_COUNT_CACHE_TTL_MS - return count - } catch (error) { - if (this.hermesRunCountCache.get(jobId) === entry) { - this.hermesRunCountCache.delete(jobId) - } - throw error - } - } - - private pruneHermesRunCountCache(now: number): void { - for (const [jobId, entry] of this.hermesRunCountCache) { - if (entry.expiresAt <= now) { - this.hermesRunCountCache.delete(jobId) - } - } - while (this.hermesRunCountCache.size >= HERMES_RUN_COUNT_CACHE_MAX_ENTRIES) { - const oldestJobId = this.hermesRunCountCache.keys().next().value - if (oldestJobId === undefined) { - return - } - this.hermesRunCountCache.delete(oldestJobId) - } - } - - private clearHermesRunCountCache(jobId?: string): void { - if (jobId) { - this.hermesRunCountCache.delete(jobId) - return - } - this.hermesRunCountCache.clear() - } - - private async listRuns(params: Record = {}): Promise<{ - total: number - runs: unknown[] - }> { - const provider = params.provider === 'openclaw' ? 'openclaw' : 'hermes' - const jobId = params.jobId - const page = - typeof params.page === 'number' && Number.isFinite(params.page) - ? Math.max(1, Math.floor(params.page)) - : 1 - const pageSize = - typeof params.pageSize === 'number' && Number.isFinite(params.pageSize) - ? Math.min(100, Math.max(0, Math.floor(params.pageSize))) - : 25 - if (provider !== 'hermes') { - return { total: 0, runs: [] } - } - if (typeof jobId !== 'string' || !EXTERNAL_JOB_ID_PATTERN.test(jobId)) { - throw new Error('Invalid external automation job ID.') - } - if (pageSize === 0) { - // Why: manager listing only needs a badge count; hydrating markdown logs - // and full session transcripts can make opening Automations very slow. - return { total: await this.readHermesRunCount(jobId), runs: [] } - } - const runRefs = await this.readHermesRunRefs(jobId) - const start = (page - 1) * pageSize - return { - total: runRefs.length, - runs: await Promise.all( - runRefs.slice(start, start + pageSize).map((ref) => this.hydrateHermesRunRef(jobId, ref)) - ) - } - } - - private getRawRunId(run: unknown): string { - if (this.isRecord(run) && 'id' in run) { - return String(run.id) - } - return '' - } - - private getRawRunTime(run: unknown): number { - if (!this.isRecord(run) || !('run_at' in run)) { - return Number.NaN - } - return typeof run.run_at === 'string' ? Date.parse(run.run_at) : Number.NaN - } - - private async readHermesOutputFileRunRefs(jobId: string): Promise { - const outputDir = join(HERMES_OUTPUT_DIR, jobId) - if (!existsSync(outputDir)) { - return [] - } - const entries = await readdir(outputDir, { withFileTypes: true }) - return entries - .filter((entry) => entry.isFile() && HERMES_OUTPUT_FILE_PATTERN.test(entry.name)) - .map((entry) => ({ - kind: 'output' as const, - id: `${jobId}:${entry.name}`, - job_id: jobId, - run_at: this.runAtFromHermesOutputFile(entry.name), - run_key: this.runKeyFromHermesOutputFile(entry.name), - output_path: join(outputDir, entry.name) - })) - } - - private async readHermesOutputFileRun(ref: HermesOutputRunRef): Promise { - try { - const content = await readFile(ref.output_path, 'utf-8') - const parsed = this.parseHermesOutput(content) - const outputContent = await this.appendReferencedLogFile(parsed.outputContent) - return { - id: ref.id, - job_id: ref.job_id, - run_at: ref.run_at, - run_key: ref.run_key, - status: parsed.status, - output_preview: parsed.outputPreview, - output_content: outputContent, - error: parsed.error, - output_path: ref.output_path - } - } catch (error) { - return { - id: ref.id, - job_id: ref.job_id, - run_at: ref.run_at, - run_key: ref.run_key, - status: 'unknown', - output_preview: null, - output_content: null, - error: error instanceof Error ? error.message : String(error), - output_path: ref.output_path - } - } - } - - private readHermesSessionDbRunRefs(jobId: string): HermesSessionRunRef[] { - if (!existsSync(HERMES_STATE_DB)) { - return [] - } - const Database = this.getDatabaseConstructor() - if (!Database) { - return [] - } - try { - const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) - try { - const pattern = `cron\\_${this.escapeSqlLike(jobId)}\\_%` - const rows = db - .prepare( - `SELECT id, started_at - FROM sessions - WHERE id LIKE ? ESCAPE '\\' - ORDER BY started_at DESC` - ) - .all(pattern) as Record[] - return rows.map((row) => { - const runId = typeof row.id === 'string' ? row.id : `${jobId}:${String(row.started_at)}` - return { - kind: 'session', - id: runId, - job_id: jobId, - run_at: this.runAtFromUnixSeconds(row.started_at), - run_key: runId.split(`${jobId}_`).at(-1) ?? null - } - }) - } finally { - db.close() - } - } catch { - return [] - } - } - - private readHermesSessionDbRunById(jobId: string, runId: string): unknown { - if (!existsSync(HERMES_STATE_DB)) { - return null - } - const Database = this.getDatabaseConstructor() - if (!Database) { - return null - } - try { - const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) - try { - const row = db - .prepare( - `SELECT id, title, started_at, ended_at, end_reason, model, message_count, - input_tokens, output_tokens, estimated_cost_usd - FROM sessions - WHERE id = ?` - ) - .get(runId) as Record | undefined - if (!row) { - return null - } - const messages = db - .prepare( - `SELECT role, content, tool_name, reasoning, reasoning_content - FROM messages - WHERE session_id = ? - ORDER BY timestamp, id` - ) - .all(runId) as Record[] - const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null - const model = typeof row.model === 'string' && row.model.trim() ? row.model.trim() : null - const messageCount = typeof row.message_count === 'number' ? row.message_count : null - const tokenCount = - (typeof row.input_tokens === 'number' ? row.input_tokens : 0) + - (typeof row.output_tokens === 'number' ? row.output_tokens : 0) - const summaryParts = [ - title, - model ? `Model: ${model}` : null, - messageCount !== null ? `${messageCount} messages` : null, - tokenCount > 0 ? `${tokenCount} tokens` : null - ].filter(Boolean) - return { - id: runId, - job_id: jobId, - run_at: this.runAtFromUnixSeconds(row.started_at), - run_key: runId.split(`${jobId}_`).at(-1) ?? null, - status: typeof row.ended_at === 'number' ? 'completed' : 'unknown', - output_preview: summaryParts.join(' · ') || null, - output_content: this.formatSessionMessages(messages), - error: null, - output_path: HERMES_STATE_DB - } - } finally { - db.close() - } - } catch { - return null - } - } - - private async listJobs(params?: Record): Promise<{ - jobs: unknown[] - hermesAvailable: boolean - openclawAvailable: boolean - error: string | null - }> { - const provider = params?.provider === 'openclaw' ? 'openclaw' : 'hermes' - const [commandAvailable, jobsResult] = await Promise.allSettled([ - this.isCommandAvailable(provider), - this.readJobs(provider) - ]) - const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : [] - const available = commandAvailable.status === 'fulfilled' && commandAvailable.value - return { - jobs, - hermesAvailable: provider === 'hermes' && available, - openclawAvailable: provider === 'openclaw' && available, - error: jobsResult.status === 'rejected' ? String(jobsResult.reason) : null - } - } - - private hermesCommand(action: HermesAction): string { - switch (action) { - case 'pause': - return 'pause' - case 'resume': - return 'resume' - case 'run': - return 'run' - case 'delete': - return 'remove' - } - } - - private openClawCommand(action: HermesAction): string { - switch (action) { - case 'pause': - return 'disable' - case 'resume': - return 'enable' - case 'run': - return 'run' - case 'delete': - return 'rm' - } - } - - private normalizeHermesJobMutation(params: Record): { - name: string - prompt: string - schedule: string - workdir: string - } { - const provider = params.provider === 'openclaw' ? 'openclaw' : 'hermes' - if (provider !== 'hermes') { - throw new Error('Only Hermes cron creation and editing are supported.') - } - const name = typeof params.name === 'string' ? params.name.trim() : '' - const prompt = typeof params.prompt === 'string' ? params.prompt.trim() : '' - const schedule = typeof params.schedule === 'string' ? params.schedule.trim() : '' - const workdir = typeof params.workdir === 'string' ? params.workdir.trim() : '' - if (!prompt) { - throw new Error('Hermes cron requires a prompt.') - } - if (!schedule) { - throw new Error('Hermes cron requires a schedule.') - } - return { - name: name || prompt.slice(0, 50).trim(), - prompt, - schedule, - workdir - } - } - - private async runHermesCronCommand(args: string[]): Promise { - await execFileAsync('hermes', args, { - encoding: 'utf-8', - timeout: 30_000 - }) - } - - private async createJob(params: Record = {}): Promise<{ ok: true }> { - const input = this.normalizeHermesJobMutation(params) - const args = [ - 'cron', - 'create', - input.schedule, - input.prompt, - '--name', - input.name, - '--deliver', - 'local' - ] - if (input.workdir) { - args.push('--workdir', input.workdir) - } - await this.runHermesCronCommand(args) - this.clearHermesRunCountCache() - return { ok: true } - } - - private async updateJob(params: Record = {}): Promise<{ ok: true }> { - const input = this.normalizeHermesJobMutation(params) - const jobId = params.jobId - if (typeof jobId !== 'string' || !EXTERNAL_JOB_ID_PATTERN.test(jobId)) { - throw new Error('Invalid external automation job ID.') - } - const args = [ - 'cron', - 'edit', - jobId, - '--schedule', - input.schedule, - '--prompt', - input.prompt, - '--name', - input.name - ] - if (input.workdir) { - args.push('--workdir', input.workdir) - } - await this.runHermesCronCommand(args) - this.clearHermesRunCountCache(jobId) - return { ok: true } - } - - private async runAction(params: Record = {}): Promise<{ ok: true }> { - const provider = params.provider === 'openclaw' ? 'openclaw' : 'hermes' - const action = params.action - const jobId = params.jobId - if (action !== 'pause' && action !== 'resume' && action !== 'run' && action !== 'delete') { - throw new Error('Unsupported external automation action.') - } - if (typeof jobId !== 'string' || !EXTERNAL_JOB_ID_PATTERN.test(jobId)) { - throw new Error('Invalid external automation job ID.') - } - const command = - provider === 'hermes' ? this.hermesCommand(action) : this.openClawCommand(action) - await execFileAsync(provider, ['cron', command, jobId], { - encoding: 'utf-8', - timeout: 30_000 - }) - if (provider === 'hermes') { - this.clearHermesRunCountCache(jobId) - } - return { ok: true } + constructor(dispatcher: RelayDispatcher) { + dispatcher.onRequest('externalAutomations.list', (params) => this.providers.listJobs(params)) + dispatcher.onRequest('externalAutomations.runs', (params) => this.runHistory.listRuns(params)) + dispatcher.onRequest('externalAutomations.create', (params) => this.commands.createJob(params)) + dispatcher.onRequest('externalAutomations.update', (params) => this.commands.updateJob(params)) + dispatcher.onRequest('externalAutomations.act', (params) => this.commands.runAction(params)) } } diff --git a/src/relay/hermes-output-run-files.ts b/src/relay/hermes-output-run-files.ts new file mode 100644 index 00000000000..31aa213abe7 --- /dev/null +++ b/src/relay/hermes-output-run-files.ts @@ -0,0 +1,192 @@ +import { existsSync } from 'node:fs' +import { open, readdir, readFile, realpath, stat } from 'node:fs/promises' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { HERMES_HOME, HERMES_OUTPUT_DIR } from './external-automation-storage-paths' +import type { HermesOutputRunRef } from './hermes-run-correlation' + +const HERMES_OUTPUT_FILE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.md$/ +const MAX_REFERENCED_LOG_BYTES = 5 * 1024 * 1024 +const REFERENCED_LOG_HEADING = '## Latest log file' +const LATEST_LOG_PATH_PATTERN = + /\bLatest log path:\s*(?(?:[A-Za-z]:[\\/]|\/)[^\r\n]*?)(?=\s+Run summary:|\r?\n|$)/i + +function runAtFromHermesOutputFile(filename: string): string | null { + const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename) + if (!match) { + return null + } + const [, year, month, day, hour, minute, second] = match + return `${year}-${month}-${day}T${hour}:${minute}:${second}` +} + +function runKeyFromHermesOutputFile(filename: string): string | null { + const match = HERMES_OUTPUT_FILE_PATTERN.exec(filename) + if (!match) { + return null + } + const [, year, month, day, hour, minute, second] = match + return `${year}${month}${day}_${hour}${minute}${second}` +} + +function cleanRunPreview(value: string): string | null { + const normalized = value + .replace(/```[\s\S]*?```/g, ' ') + .replace(/[#*_`>()]/g, ' ') + .replaceAll('[', ' ') + .replaceAll(']', ' ') + .replace(/\s+/g, ' ') + .trim() + if (!normalized) { + return null + } + return normalized.length > 180 ? `${normalized.slice(0, 177)}...` : normalized +} + +function parseHermesOutput(content: string): { + status: 'completed' | 'failed' | 'unknown' + outputPreview: string | null + outputContent: string + error: string | null +} { + const failed = /^#\s+Cron Job:.*\(FAILED\)/m.test(content) || /^##\s+Error\b/m.test(content) + const errorMatch = /##\s+Error\s+```([\s\S]*?)```/m.exec(content) + const responseMatch = /##\s+Response\s+([\s\S]*)$/m.exec(content) + const error = errorMatch ? cleanRunPreview(errorMatch[1]) : null + return { + status: failed ? 'failed' : responseMatch ? 'completed' : 'unknown', + outputPreview: cleanRunPreview(responseMatch?.[1] ?? errorMatch?.[1] ?? content), + outputContent: content, + error + } +} + +function extractLatestLogPath(content: string): string | null { + const rawPath = LATEST_LOG_PATH_PATTERN.exec(content)?.groups?.path?.trim() + if (!rawPath) { + return null + } + return rawPath.replace(/^`|`$/g, '').trim() +} + +export async function readHermesReferencedLogFile(content: string): Promise<{ + path: string + content: string + truncated: boolean +} | null> { + const logPath = extractLatestLogPath(content) + if (!logPath || !isAbsolute(logPath)) { + return null + } + try { + const homeRealPath = await realpath(HERMES_HOME) + const logRealPath = await realpath(logPath) + const relativeToHermesHome = relative(resolve(homeRealPath), resolve(logRealPath)) + if ( + relativeToHermesHome === '..' || + relativeToHermesHome.startsWith(`..${sep}`) || + isAbsolute(relativeToHermesHome) + ) { + return null + } + const logStat = await stat(logPath) + if (!logStat.isFile()) { + return null + } + if (logStat.size <= MAX_REFERENCED_LOG_BYTES) { + return { + path: logPath, + content: await readFile(logPath, 'utf-8'), + truncated: false + } + } + const file = await open(logPath, 'r') + try { + const buffer = Buffer.alloc(MAX_REFERENCED_LOG_BYTES) + await file.read(buffer, 0, MAX_REFERENCED_LOG_BYTES, logStat.size - MAX_REFERENCED_LOG_BYTES) + return { + path: logPath, + content: buffer.toString('utf-8'), + truncated: true + } + } finally { + await file.close() + } + } catch { + return null + } +} + +async function appendReferencedLogFile(content: string): Promise { + if (content.includes(REFERENCED_LOG_HEADING)) { + return content + } + const logFile = await readHermesReferencedLogFile(content) + if (!logFile) { + return content + } + const note = logFile.truncated + ? `Showing the last ${MAX_REFERENCED_LOG_BYTES} bytes because the log file is larger.` + : null + return [ + content, + '---', + REFERENCED_LOG_HEADING, + '', + `Path: ${logFile.path}`, + note, + '```text', + logFile.content.trimEnd(), + '```' + ] + .filter((part) => part !== null) + .join('\n\n') +} + +export async function readHermesOutputFileRunRefs(jobId: string): Promise { + const outputDir = join(HERMES_OUTPUT_DIR, jobId) + if (!existsSync(outputDir)) { + return [] + } + const entries = await readdir(outputDir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isFile() && HERMES_OUTPUT_FILE_PATTERN.test(entry.name)) + .map((entry) => ({ + kind: 'output', + id: `${jobId}:${entry.name}`, + job_id: jobId, + run_at: runAtFromHermesOutputFile(entry.name), + run_key: runKeyFromHermesOutputFile(entry.name), + output_path: join(outputDir, entry.name) + })) +} + +export async function readHermesOutputFileRun(ref: HermesOutputRunRef): Promise { + try { + const content = await readFile(ref.output_path, 'utf-8') + const parsed = parseHermesOutput(content) + const outputContent = await appendReferencedLogFile(parsed.outputContent) + return { + id: ref.id, + job_id: ref.job_id, + run_at: ref.run_at, + run_key: ref.run_key, + status: parsed.status, + output_preview: parsed.outputPreview, + output_content: outputContent, + error: parsed.error, + output_path: ref.output_path + } + } catch (error) { + return { + id: ref.id, + job_id: ref.job_id, + run_at: ref.run_at, + run_key: ref.run_key, + status: 'unknown', + output_preview: null, + output_content: null, + error: error instanceof Error ? error.message : String(error), + output_path: ref.output_path + } + } +} diff --git a/src/relay/hermes-run-correlation.test.ts b/src/relay/hermes-run-correlation.test.ts new file mode 100644 index 00000000000..c009792e63f --- /dev/null +++ b/src/relay/hermes-run-correlation.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { mergeHermesOutputAndSessionRuns } from './hermes-run-correlation' + +function run(runKey: string, content: string): Record { + return { + id: runKey, + run_key: runKey, + output_preview: content, + output_content: content + } +} + +describe('mergeHermesOutputAndSessionRuns', () => { + it('prefers an exact run key and appends its full session transcript', () => { + const output = run('20260516_090000', 'summary') + const exact = run('20260516_090000', 'exact transcript') + const nearerButDifferent = run('20260516_085959', 'other transcript') + + const result = mergeHermesOutputAndSessionRuns([output], [nearerButDifferent, exact]) as Record< + string, + unknown + >[] + + expect(result[0].output_content).toBe( + 'summary\n\n---\n\n## Full session log\n\nexact transcript' + ) + expect(result[1]).toBe(nearerButDifferent) + }) + + it('matches only the nearest earlier session inside the 24-hour window', () => { + const output = run('20260516_090000', 'summary') + const later = run('20260516_090001', 'later') + const tooOld = run('20260515_085959', 'too old') + const nearestEarlier = run('20260516_085959', 'nearest earlier') + + const result = mergeHermesOutputAndSessionRuns( + [output], + [later, tooOld, nearestEarlier] + ) as Record[] + + expect(result[0].output_content).toContain('nearest earlier') + expect(result.slice(1)).toEqual([later, tooOld]) + }) + + it('uses each session run at most once', () => { + const first = run('20260516_090000', 'first') + const second = run('20260516_090100', 'second') + const session = run('20260516_085900', 'one transcript') + + const result = mergeHermesOutputAndSessionRuns([first, second], [session]) as Record< + string, + unknown + >[] + + expect( + result.filter((item) => String(item.output_content).includes('one transcript')) + ).toHaveLength(1) + }) +}) diff --git a/src/relay/hermes-run-correlation.ts b/src/relay/hermes-run-correlation.ts new file mode 100644 index 00000000000..75d3960a8e5 --- /dev/null +++ b/src/relay/hermes-run-correlation.ts @@ -0,0 +1,197 @@ +const HERMES_RUN_KEY_PATTERN = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/ +const MAX_SESSION_OUTPUT_GAP_MS = 24 * 60 * 60 * 1000 +const FULL_SESSION_LOG_HEADING = '## Full session log' + +export type HermesOutputRunRef = { + kind: 'output' + id: string + job_id: string + run_at: string | null + run_key: string | null + output_path: string +} + +export type HermesSessionRunRef = { + kind: 'session' + id: string + job_id: string + run_at: string | null + run_key: string | null +} + +export type HermesMergedRunRef = { + id: string + job_id: string + run_at: string | null + run_key: string | null + output: HermesOutputRunRef | null + session: HermesSessionRunRef | null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function getRunKey(run: unknown): string | null { + return isRecord(run) && typeof run.run_key === 'string' && run.run_key.trim() ? run.run_key : null +} + +function getRunOutputContent(run: unknown): string | null { + return isRecord(run) && typeof run.output_content === 'string' && run.output_content.trim() + ? run.output_content + : null +} + +function getRunOutputPreview(run: unknown): string | null { + return isRecord(run) && typeof run.output_preview === 'string' && run.output_preview.trim() + ? run.output_preview + : null +} + +function sortableTimeFromRunKey(runKey: string | null): number { + if (!runKey) { + return Number.NaN + } + const match = HERMES_RUN_KEY_PATTERN.exec(runKey) + if (!match) { + return Number.NaN + } + const [, year, month, day, hour, minute, second] = match + return Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second) + ) +} + +function findMatchingSessionRunIndex( + outputRun: unknown, + sessionRuns: unknown[], + usedSessionRunIndexes: Set +): number | null { + const outputRunKey = getRunKey(outputRun) + const exactMatchIndex = sessionRuns.findIndex( + (sessionRun, index) => + !usedSessionRunIndexes.has(index) && getRunKey(sessionRun) === outputRunKey + ) + if (exactMatchIndex !== -1) { + return exactMatchIndex + } + const outputTime = sortableTimeFromRunKey(outputRunKey) + if (!Number.isFinite(outputTime)) { + return null + } + let bestIndex: number | null = null + let bestGap = Number.POSITIVE_INFINITY + for (let index = 0; index < sessionRuns.length; index += 1) { + if (usedSessionRunIndexes.has(index)) { + continue + } + const sessionTime = sortableTimeFromRunKey(getRunKey(sessionRuns[index])) + if (!Number.isFinite(sessionTime)) { + continue + } + const gap = outputTime - sessionTime + if (gap < 0 || gap > MAX_SESSION_OUTPUT_GAP_MS || gap >= bestGap) { + continue + } + bestIndex = index + bestGap = gap + } + return bestIndex +} + +function mergeOutputAndSessionContent( + outputContent: string | null, + sessionContent: string | null +): string | null { + if (!sessionContent) { + return outputContent + } + if (!outputContent) { + return `${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` + } + if (outputContent.includes(FULL_SESSION_LOG_HEADING)) { + return outputContent + } + return `${outputContent}\n\n---\n\n${FULL_SESSION_LOG_HEADING}\n\n${sessionContent}` +} + +export function mergeHermesOutputAndSessionRuns( + outputRuns: unknown[], + sessionRuns: unknown[] +): unknown[] { + const usedSessionRunIndexes = new Set() + const mergedOutputRuns = outputRuns.map((outputRun) => { + if (!isRecord(outputRun)) { + return outputRun + } + const sessionRunIndex = findMatchingSessionRunIndex( + outputRun, + sessionRuns, + usedSessionRunIndexes + ) + if (sessionRunIndex === null) { + return outputRun + } + const sessionRun = sessionRuns[sessionRunIndex] + if (!isRecord(sessionRun)) { + return outputRun + } + usedSessionRunIndexes.add(sessionRunIndex) + return { + ...outputRun, + output_preview: getRunOutputPreview(outputRun) ?? getRunOutputPreview(sessionRun), + output_content: mergeOutputAndSessionContent( + getRunOutputContent(outputRun), + getRunOutputContent(sessionRun) + ) + } + }) + return [ + ...mergedOutputRuns, + ...sessionRuns.filter((_, index) => !usedSessionRunIndexes.has(index)) + ] +} + +export function mergeHermesOutputAndSessionRunRefs( + outputRefs: HermesOutputRunRef[], + sessionRefs: HermesSessionRunRef[] +): HermesMergedRunRef[] { + const usedSessionRunIndexes = new Set() + const mergedOutputRefs = outputRefs.map((outputRef) => { + const sessionRunIndex = findMatchingSessionRunIndex( + outputRef, + sessionRefs, + usedSessionRunIndexes + ) + const sessionRef = sessionRunIndex === null ? null : sessionRefs[sessionRunIndex] + if (sessionRunIndex !== null) { + usedSessionRunIndexes.add(sessionRunIndex) + } + return { + id: outputRef.id, + job_id: outputRef.job_id, + run_at: outputRef.run_at, + run_key: outputRef.run_key, + output: outputRef, + session: sessionRef + } + }) + return [ + ...mergedOutputRefs, + ...sessionRefs + .filter((_, index) => !usedSessionRunIndexes.has(index)) + .map((sessionRef) => ({ + id: sessionRef.id, + job_id: sessionRef.job_id, + run_at: sessionRef.run_at, + run_key: sessionRef.run_key, + output: null, + session: sessionRef + })) + ] +} diff --git a/src/relay/hermes-run-history.test.ts b/src/relay/hermes-run-history.test.ts new file mode 100644 index 00000000000..4c24c7d94f9 --- /dev/null +++ b/src/relay/hermes-run-history.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest' +import { HermesRunHistory, type HermesRunHistorySources } from './hermes-run-history' +import type { HermesOutputRunRef } from './hermes-run-correlation' + +function outputRef(day: number, jobId = 'job-1'): HermesOutputRunRef { + const dayText = String(day).padStart(2, '0') + return { + kind: 'output', + id: `${jobId}:2026-05-${dayText}_09-00-00.md`, + job_id: jobId, + run_at: `2026-05-${dayText}T09:00:00`, + run_key: `202605${dayText}_090000`, + output_path: `/output/${jobId}/${dayText}.md` + } +} + +function sources(overrides: Partial = {}): HermesRunHistorySources { + return { + readOutputRefs: async () => [], + readSessionRefs: () => [], + readOutputRun: async (ref) => ref, + readSessionRun: () => null, + ...overrides + } +} + +describe('HermesRunHistory', () => { + it('paginates refs before hydrating run content', async () => { + const readOutputRun = vi.fn(async (ref: HermesOutputRunRef) => ref) + const history = new HermesRunHistory( + sources({ + readOutputRefs: async () => [outputRef(16), outputRef(15), outputRef(14)], + readOutputRun + }) + ) + + const result = await history.listRuns({ + provider: 'hermes', + jobId: 'job-1', + page: 1, + pageSize: 2 + }) + + expect(result.total).toBe(3) + expect(result.runs).toEqual([outputRef(16), outputRef(15)]) + expect(readOutputRun).toHaveBeenCalledTimes(2) + }) + + it('uses a count-only path without hydrating content', async () => { + const readOutputRun = vi.fn() + const history = new HermesRunHistory( + sources({ readOutputRefs: async () => [outputRef(16), outputRef(15)], readOutputRun }) + ) + + await expect( + history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) + ).resolves.toEqual({ total: 2, runs: [] }) + expect(readOutputRun).not.toHaveBeenCalled() + }) + + it('deduplicates concurrent count reads', async () => { + let resolveRefs: (refs: HermesOutputRunRef[]) => void = () => {} + const readOutputRefs = vi.fn( + () => + new Promise((resolve) => { + resolveRefs = resolve + }) + ) + const history = new HermesRunHistory(sources({ readOutputRefs })) + + const first = history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) + const second = history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) + expect(readOutputRefs).toHaveBeenCalledTimes(1) + resolveRefs([outputRef(16), outputRef(15)]) + + await expect(Promise.all([first, second])).resolves.toEqual([ + { total: 2, runs: [] }, + { total: 2, runs: [] } + ]) + }) + + it('evicts a failed count read so the next call retries', async () => { + const readError = new Error('output directory unavailable') + const readOutputRefs = vi + .fn<() => Promise>() + .mockRejectedValueOnce(readError) + .mockResolvedValueOnce([outputRef(16)]) + const history = new HermesRunHistory(sources({ readOutputRefs })) + + await expect( + history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) + ).rejects.toBe(readError) + await expect( + history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) + ).resolves.toEqual({ total: 1, runs: [] }) + expect(readOutputRefs).toHaveBeenCalledTimes(2) + }) + + it('clears a cached count for one lifecycle-mutated job', async () => { + const readOutputRefs = vi + .fn<() => Promise>() + .mockResolvedValueOnce([outputRef(15)]) + .mockResolvedValueOnce([outputRef(16), outputRef(15)]) + const history = new HermesRunHistory(sources({ readOutputRefs })) + + await history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) + await history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) + history.clearRunCount('job-1') + + await expect( + history.listRuns({ provider: 'hermes', jobId: 'job-1', pageSize: 0 }) + ).resolves.toEqual({ total: 2, runs: [] }) + expect(readOutputRefs).toHaveBeenCalledTimes(2) + }) + + it('returns empty OpenClaw history before validating its job ID', async () => { + const history = new HermesRunHistory(sources()) + + await expect( + history.listRuns({ provider: 'openclaw', jobId: '--invalid', pageSize: 25 }) + ).resolves.toEqual({ total: 0, runs: [] }) + }) + + it('evicts the oldest cached job at the 200-entry bound', async () => { + const readOutputRefs = vi.fn(async (jobId: string) => + jobId === 'job-0' ? [outputRef(15, jobId)] : [] + ) + const history = new HermesRunHistory(sources({ readOutputRefs })) + + await history.listRuns({ provider: 'hermes', jobId: 'job-0', pageSize: 0 }) + for (let index = 1; index <= 200; index += 1) { + await history.listRuns({ provider: 'hermes', jobId: `job-${index}`, pageSize: 0 }) + } + await history.listRuns({ provider: 'hermes', jobId: 'job-0', pageSize: 0 }) + + expect(readOutputRefs).toHaveBeenCalledTimes(202) + }) +}) diff --git a/src/relay/hermes-run-history.ts b/src/relay/hermes-run-history.ts new file mode 100644 index 00000000000..4715f5cd455 --- /dev/null +++ b/src/relay/hermes-run-history.ts @@ -0,0 +1,169 @@ +import { + EXTERNAL_AUTOMATION_JOB_ID_PATTERN, + externalAutomationProvider +} from './external-automation-provider' +import { + mergeHermesOutputAndSessionRunRefs, + mergeHermesOutputAndSessionRuns, + type HermesMergedRunRef, + type HermesOutputRunRef, + type HermesSessionRunRef +} from './hermes-run-correlation' +import { readHermesOutputFileRun, readHermesOutputFileRunRefs } from './hermes-output-run-files' +import { + readHermesSessionDbRunById, + readHermesSessionDbRunRefs +} from './hermes-session-run-database' + +const HERMES_RUN_COUNT_CACHE_TTL_MS = 2000 +const HERMES_RUN_COUNT_CACHE_MAX_ENTRIES = 200 + +type HermesRunCountCacheEntry = { + promise: Promise + expiresAt: number +} + +export type HermesRunHistorySources = { + readOutputRefs: (jobId: string) => Promise + readSessionRefs: (jobId: string) => HermesSessionRunRef[] + readOutputRun: (ref: HermesOutputRunRef) => Promise + readSessionRun: (jobId: string, runId: string) => unknown +} + +const DEFAULT_SOURCES: HermesRunHistorySources = { + readOutputRefs: readHermesOutputFileRunRefs, + readSessionRefs: readHermesSessionDbRunRefs, + readOutputRun: readHermesOutputFileRun, + readSessionRun: readHermesSessionDbRunById +} + +function getRawRunId(run: unknown): string { + if (typeof run === 'object' && run !== null && 'id' in run) { + return String(run.id) + } + return '' +} + +function getRawRunTime(run: unknown): number { + if (typeof run !== 'object' || run === null || !('run_at' in run)) { + return Number.NaN + } + return typeof run.run_at === 'string' ? Date.parse(run.run_at) : Number.NaN +} + +export class HermesRunHistory { + private readonly runCountCache = new Map() + + constructor(private readonly sources: HermesRunHistorySources = DEFAULT_SOURCES) {} + + async listRuns(params: Record = {}): Promise<{ + total: number + runs: unknown[] + }> { + const provider = externalAutomationProvider(params.provider) + const jobId = params.jobId + const page = + typeof params.page === 'number' && Number.isFinite(params.page) + ? Math.max(1, Math.floor(params.page)) + : 1 + const pageSize = + typeof params.pageSize === 'number' && Number.isFinite(params.pageSize) + ? Math.min(100, Math.max(0, Math.floor(params.pageSize))) + : 25 + if (provider !== 'hermes') { + return { total: 0, runs: [] } + } + if (typeof jobId !== 'string' || !EXTERNAL_AUTOMATION_JOB_ID_PATTERN.test(jobId)) { + throw new Error('Invalid external automation job ID.') + } + if (pageSize === 0) { + return { total: await this.readRunCount(jobId), runs: [] } + } + const runRefs = await this.readRunRefs(jobId) + const start = (page - 1) * pageSize + return { + total: runRefs.length, + runs: await Promise.all( + runRefs.slice(start, start + pageSize).map((ref) => this.hydrateRunRef(jobId, ref)) + ) + } + } + + clearRunCount(jobId?: string): void { + if (jobId) { + this.runCountCache.delete(jobId) + return + } + this.runCountCache.clear() + } + + private async readRunRefs(jobId: string): Promise { + const outputRuns = await this.sources.readOutputRefs(jobId) + return mergeHermesOutputAndSessionRunRefs(outputRuns, this.sources.readSessionRefs(jobId)).sort( + (a, b) => { + const aTime = getRawRunTime(a) + const bTime = getRawRunTime(b) + if (Number.isFinite(aTime) && Number.isFinite(bTime)) { + return bTime - aTime + } + return getRawRunId(b).localeCompare(getRawRunId(a)) + } + ) + } + + private async hydrateRunRef(jobId: string, ref: HermesMergedRunRef): Promise { + const outputRun = ref.output ? await this.sources.readOutputRun(ref.output) : null + const sessionRun = ref.session ? this.sources.readSessionRun(jobId, ref.session.id) : null + return ( + mergeHermesOutputAndSessionRuns( + outputRun ? [outputRun] : [], + sessionRun ? [sessionRun] : [] + )[0] ?? + outputRun ?? + sessionRun ?? + ref + ) + } + + private async readRunCount(jobId: string): Promise { + const now = Date.now() + const cached = this.runCountCache.get(jobId) + if (cached && cached.expiresAt > now) { + return cached.promise + } + if (cached) { + this.runCountCache.delete(jobId) + } + this.pruneRunCountCache(now) + const entry: HermesRunCountCacheEntry = { + promise: this.readRunRefs(jobId).then((refs) => refs.length), + expiresAt: Number.POSITIVE_INFINITY + } + this.runCountCache.set(jobId, entry) + try { + const count = await entry.promise + entry.expiresAt = Date.now() + HERMES_RUN_COUNT_CACHE_TTL_MS + return count + } catch (error) { + if (this.runCountCache.get(jobId) === entry) { + this.runCountCache.delete(jobId) + } + throw error + } + } + + private pruneRunCountCache(now: number): void { + for (const [jobId, entry] of this.runCountCache) { + if (entry.expiresAt <= now) { + this.runCountCache.delete(jobId) + } + } + while (this.runCountCache.size >= HERMES_RUN_COUNT_CACHE_MAX_ENTRIES) { + const oldestJobId = this.runCountCache.keys().next().value + if (oldestJobId === undefined) { + return + } + this.runCountCache.delete(oldestJobId) + } + } +} diff --git a/src/relay/hermes-session-run-database.ts b/src/relay/hermes-session-run-database.ts new file mode 100644 index 00000000000..3cdd8badecb --- /dev/null +++ b/src/relay/hermes-session-run-database.ts @@ -0,0 +1,205 @@ +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { HERMES_STATE_DB } from './external-automation-storage-paths' +import type { HermesSessionRunRef } from './hermes-run-correlation' + +type SqliteStatement = { + get: (...args: unknown[]) => Record | undefined + all: (...args: unknown[]) => Record[] +} + +type SqliteDatabase = { + prepare: (sql: string) => SqliteStatement + close: () => void +} + +type DatabaseConstructor = new ( + path: string, + options?: { readonly?: boolean; fileMustExist?: boolean; timeout?: number } +) => SqliteDatabase + +type NodeSqliteDatabaseSync = new ( + path: string, + options?: { readOnly?: boolean; timeout?: number } +) => SqliteDatabase + +const requireOptional = createRequire(__filename) +let databaseConstructor: DatabaseConstructor | null | undefined + +function getDatabaseConstructor(): DatabaseConstructor | null { + if (databaseConstructor !== undefined) { + return databaseConstructor + } + try { + const loaded = requireOptional('node:sqlite') as { DatabaseSync?: NodeSqliteDatabaseSync } + const DatabaseSync = loaded.DatabaseSync + if (typeof DatabaseSync !== 'function') { + databaseConstructor = null + return databaseConstructor + } + const SqliteDatabaseSync = DatabaseSync + databaseConstructor = class RelaySqliteDatabase { + private readonly db: SqliteDatabase + + constructor( + path: string, + options: { readonly?: boolean; fileMustExist?: boolean; timeout?: number } = {} + ) { + if (options.fileMustExist && !existsSync(path)) { + throw new Error(`SQLite database does not exist: ${path}`) + } + this.db = new SqliteDatabaseSync(path, { + readOnly: options.readonly, + timeout: options.timeout + }) + } + + prepare(sql: string): SqliteStatement { + return this.db.prepare(sql) + } + + close(): void { + this.db.close() + } + } + } catch { + databaseConstructor = null + } + return databaseConstructor +} + +function escapeSqlLike(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_') +} + +function runAtFromUnixSeconds(value: unknown): string | null { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null + } + const date = new Date(value * 1000) + return Number.isNaN(date.getTime()) ? null : date.toISOString() +} + +function formatSessionMessages(messages: Record[]): string | null { + if (messages.length === 0) { + return null + } + return messages + .map((message) => { + const role = typeof message.role === 'string' ? message.role : 'message' + const content = typeof message.content === 'string' ? message.content.trim() : '' + const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : '' + const reasoning = + typeof message.reasoning_content === 'string' + ? message.reasoning_content.trim() + : typeof message.reasoning === 'string' + ? message.reasoning.trim() + : '' + const parts = [ + `## ${role}${toolName ? ` / ${toolName}` : ''}`, + reasoning ? `### Reasoning\n\n${reasoning}` : null, + content || '(empty)' + ].filter(Boolean) + return parts.join('\n\n') + }) + .join('\n\n---\n\n') +} + +export function readHermesSessionDbRunRefs(jobId: string): HermesSessionRunRef[] { + if (!existsSync(HERMES_STATE_DB)) { + return [] + } + const Database = getDatabaseConstructor() + if (!Database) { + return [] + } + try { + const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) + try { + const pattern = `cron\\_${escapeSqlLike(jobId)}\\_%` + const rows = db + .prepare( + `SELECT id, started_at + FROM sessions + WHERE id LIKE ? ESCAPE '\\' + ORDER BY started_at DESC` + ) + .all(pattern) as Record[] + return rows.map((row) => { + const runId = typeof row.id === 'string' ? row.id : `${jobId}:${String(row.started_at)}` + return { + kind: 'session', + id: runId, + job_id: jobId, + run_at: runAtFromUnixSeconds(row.started_at), + run_key: runId.split(`${jobId}_`).at(-1) ?? null + } + }) + } finally { + db.close() + } + } catch { + return [] + } +} + +export function readHermesSessionDbRunById(jobId: string, runId: string): unknown { + if (!existsSync(HERMES_STATE_DB)) { + return null + } + const Database = getDatabaseConstructor() + if (!Database) { + return null + } + try { + const db = new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true }) + try { + const row = db + .prepare( + `SELECT id, title, started_at, ended_at, end_reason, model, message_count, + input_tokens, output_tokens, estimated_cost_usd + FROM sessions + WHERE id = ?` + ) + .get(runId) as Record | undefined + if (!row) { + return null + } + const messages = db + .prepare( + `SELECT role, content, tool_name, reasoning, reasoning_content + FROM messages + WHERE session_id = ? + ORDER BY timestamp, id` + ) + .all(runId) as Record[] + const title = typeof row.title === 'string' && row.title.trim() ? row.title.trim() : null + const model = typeof row.model === 'string' && row.model.trim() ? row.model.trim() : null + const messageCount = typeof row.message_count === 'number' ? row.message_count : null + const tokenCount = + (typeof row.input_tokens === 'number' ? row.input_tokens : 0) + + (typeof row.output_tokens === 'number' ? row.output_tokens : 0) + const summaryParts = [ + title, + model ? `Model: ${model}` : null, + messageCount !== null ? `${messageCount} messages` : null, + tokenCount > 0 ? `${tokenCount} tokens` : null + ].filter(Boolean) + return { + id: runId, + job_id: jobId, + run_at: runAtFromUnixSeconds(row.started_at), + run_key: runId.split(`${jobId}_`).at(-1) ?? null, + status: typeof row.ended_at === 'number' ? 'completed' : 'unknown', + output_preview: summaryParts.join(' · ') || null, + output_content: formatSessionMessages(messages), + error: null, + output_path: HERMES_STATE_DB + } + } finally { + db.close() + } + } catch { + return null + } +} diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts new file mode 100644 index 00000000000..1dca9bbfe62 --- /dev/null +++ b/src/relay/relay-agent-hook-runtime.ts @@ -0,0 +1,160 @@ +import type { RelayDispatcher } from './dispatcher' +import type { PtyEnvAugmenter, PtyHandler } from './pty-handler' +import { RelayAgentHookServer } from './agent-hook-server' +import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates' +import { PluginOverlayManager } from './plugin-overlay' +import { + AGENT_HOOK_INSTALL_PLUGINS_METHOD, + AGENT_HOOK_REQUEST_REPLAY_METHOD +} from '../shared/agent-hook-relay' +import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' +import { assertPluginSourceUnderByteCap } from './plugin-source-limit' +import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env' +import { + detectExplicitPiAgentKindFromCommand, + isPiCompatibleAgentType +} from '../shared/pi-agent-kind' +import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' +import { relayLogLine } from './relay-diagnostic-log' +import { registerManagedHookInstaller } from './managed-hook-installer' + +export class RelayAgentHookRuntime { + private readonly hookServer: RelayAgentHookServer + private readonly pluginOverlay = new PluginOverlayManager() + + constructor( + private readonly dispatcher: RelayDispatcher, + private readonly ptyHandler: PtyHandler, + sockPath: string, + endpointDir?: string + ) { + this.hookServer = new RelayAgentHookServer({ + endpointDir: endpointDir ?? endpointDirForRelaySocket(sockPath), + forward: (envelope) => publishAgentHookEnvelope(dispatcher, envelope) + }) + } + + async start(): Promise { + try { + await this.hookServer.start({ publishEndpoint: false }) + } catch (error) { + relayLogLine( + `[relay] agent-hook server failed to start: ${error instanceof Error ? error.message : String(error)}` + ) + } + this.registerPtyEnvironment() + this.registerHandlers() + } + + publishEndpointFile(): void { + this.hookServer.publishEndpointFile() + } + + stop(): void { + this.hookServer.stop() + } + + private registerPtyEnvironment(): void { + this.ptyHandler.addEnvAugmenter(() => this.hookServer.buildPtyEnv()) + this.ptyHandler.addEnvAugmenter((context) => this.buildPluginEnvironment(context)) + this.ptyHandler.setExitListener(({ paneKey, id }) => { + if (paneKey) { + this.hookServer.clearPaneState(paneKey) + } + this.pluginOverlay.clearOverlay(paneKey ?? id) + }) + } + + private buildPluginEnvironment(context: Parameters[0]): Record { + const env: Record = {} + const overlayId = context.paneKey ?? context.id + if (this.pluginOverlay.hasOpenCodeSource()) { + const sourceDir = resolveOpenCodeSourceConfigDir(context.env, context.shell) + const dir = this.pluginOverlay.materializeOpenCode(overlayId, sourceDir) + if (dir) { + env.OPENCODE_CONFIG_DIR = dir + env.ORCA_OPENCODE_CONFIG_DIR = dir + if (sourceDir) { + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = sourceDir + } + } + } + if (!this.pluginOverlay.hasPiSource()) { + return env + } + const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(context.env, context.command) + const explicitKind = isPiCompatibleAgentType(context.launchAgent) + ? context.launchAgent + : context.launchAgent === undefined + ? detectExplicitPiAgentKindFromCommand(launchCommandHint) + : null + const kind = explicitKind ?? 'pi' + const hasLaunchCommand = + typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 + if (kind === 'pi') { + const sourceDir = resolvePiSourceAgentDir(context.env, context.shell, 'pi') + const result = this.pluginOverlay.materializePi(overlayId, sourceDir, 'pi', { + materializeDefaultHome: explicitKind === 'pi' + }) + if (result?.sourceAgentDir) { + env.ORCA_PI_SOURCE_AGENT_DIR = result.sourceAgentDir + } + } + if (kind === 'omp' || !hasLaunchCommand) { + const sourceDir = + kind === 'omp' + ? resolvePiSourceAgentDir(context.env, context.shell, 'omp') + : context.env.ORCA_OMP_SOURCE_AGENT_DIR + const result = this.pluginOverlay.materializePi(overlayId, sourceDir, 'omp', { + materializeDefaultHome: explicitKind === 'omp' + }) + if (result?.statusExtensionPath) { + env.ORCA_OMP_STATUS_EXTENSION = result.statusExtensionPath + } + if (result?.sourceAgentDir) { + env.ORCA_OMP_SOURCE_AGENT_DIR = result.sourceAgentDir + } + } + if (kind === 'prime-agent') { + const sourceDir = resolvePiSourceAgentDir(context.env, context.shell, 'prime-agent') + const result = this.pluginOverlay.materializePi(overlayId, sourceDir, 'prime-agent', { + materializeDefaultHome: explicitKind === 'prime-agent' + }) + if (result?.sourceAgentDir) { + env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR = result.sourceAgentDir + } + } + return env + } + + private registerHandlers(): void { + this.dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({ + replayed: this.hookServer.replayCachedPayloadsForPanes() + })) + registerManagedHookInstaller(this.dispatcher) + this.dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { + const opencode = params.opencodePluginSource + const pi = params.piExtensionSource + const omp = params.ompExtensionSource + const primeAgent = params.primeAgentExtensionSource + assertPluginSourceUnderByteCap('opencodePluginSource', opencode) + assertPluginSourceUnderByteCap('piExtensionSource', pi) + assertPluginSourceUnderByteCap('ompExtensionSource', omp) + assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) + this.pluginOverlay.setSources({ + opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, + piExtensionSource: typeof pi === 'string' ? pi : undefined, + ompExtensionSource: typeof omp === 'string' ? omp : undefined, + primeAgentExtensionSource: typeof primeAgent === 'string' ? primeAgent : undefined + }) + return { + installed: { + opencode: this.pluginOverlay.hasOpenCodeSource(), + pi: this.pluginOverlay.hasPiSource('pi'), + omp: this.pluginOverlay.hasPiSource('omp'), + primeAgent: this.pluginOverlay.hasPiSource('prime-agent') + } + } + }) + } +} diff --git a/src/relay/relay-connect-channel.ts b/src/relay/relay-connect-channel.ts new file mode 100644 index 00000000000..3bf0b7ae870 --- /dev/null +++ b/src/relay/relay-connect-channel.ts @@ -0,0 +1,104 @@ +import { createConnection } from 'node:net' +import { DispatcherClientWriter } from './dispatcher-client-writer' +import { RELAY_SENTINEL } from './protocol' +import { readLaunchVersion, runConnectHandshake } from './relay-handshake' + +const CONNECT_TIMEOUT_MS = 5_000 + +// Why: bridges a new SSH channel to the relay process that owns the live PTYs. +export function runRelayConnectChannel(sockPath: string, endpointCredential?: string): void { + const myVersion = readLaunchVersion() + const sock = createConnection({ path: sockPath }) + const stdoutWriter = new DispatcherClientWriter( + (data, onSettled) => + process.stdout.write(data, (error) => { + onSettled(error ? { ok: false, error } : { ok: true }) + }), + { + supportsWriteCallback: true, + writableLength: () => process.stdout.writableLength, + writableHighWaterMark: () => process.stdout.writableHighWaterMark, + waitWriteDrain: (callback) => { + process.stdout.once('drain', callback) + return () => process.stdout.off('drain', callback) + } + }, + () => { + sock.destroy() + process.exit(1) + } + ) + + const connectTimeout = setTimeout(() => { + process.stderr.write(`[relay-connect] Connection timed out after ${CONNECT_TIMEOUT_MS}ms\n`) + sock.destroy() + process.exit(1) + }, CONNECT_TIMEOUT_MS) + + sock.on('connect', () => { + clearTimeout(connectTimeout) + runConnectHandshake( + sock, + myVersion, + { + onAccepted: (leftover: Buffer) => { + stdoutWriter.enqueue('control', () => Buffer.from(RELAY_SENTINEL), RELAY_SENTINEL.length) + if (leftover.length > 0) { + stdoutWriter.enqueue('control', () => leftover, leftover.length) + } + process.stdin.pipe(sock) + sock.on('data', (data: Buffer) => { + sock.pause() + let offset = 0 + const writeNext = (): void => { + if (offset >= data.length) { + sock.resume() + return + } + const bytes = Math.min(stdoutWriter.producerFrameCapacity, data.length - offset) + if (bytes <= 0) { + stdoutWriter.close(new Error('Relay stdout has no producer capacity')) + return + } + const chunk = data.subarray(offset, offset + bytes) + if ( + !stdoutWriter.enqueue( + 'ordinary', + () => chunk, + chunk.length, + (result) => { + if (!result.ok) { + return + } + offset += bytes + writeNext() + } + ) + ) { + stdoutWriter.close(new Error('Relay stdout bridge capacity exceeded')) + } + } + writeNext() + }) + } + }, + endpointCredential + ) + }) + + // Why: Node swallows EPIPE on stdout, so close the bridge and enter relay grace promptly. + process.stdout.on('error', () => { + stdoutWriter.close(new Error('Relay stdout closed')) + }) + + sock.on('error', (error) => { + clearTimeout(connectTimeout) + process.stderr.write(`[relay-connect] Socket error: ${error.message}\n`) + process.exit(1) + }) + + sock.on('close', async () => { + await stdoutWriter.waitForIdle() + process.exit(0) + }) +} diff --git a/src/relay/relay-daemon.ts b/src/relay/relay-daemon.ts new file mode 100644 index 00000000000..6d4968c2ea9 --- /dev/null +++ b/src/relay/relay-daemon.ts @@ -0,0 +1,154 @@ +import { installRelayLogRotation } from './rotating-log-writer' +import { readLaunchVersion } from './relay-handshake' +import type { RelayLaunchOptions } from './relay-launch-options' +import { RELAY_EMPTY_DETACHED_STARTUP_GRACE_MS, RELAY_IDLE_GRACE_MS } from './relay-launch-options' +import { relayLogLine } from './relay-diagnostic-log' +import { RelayPrimaryChannel } from './relay-primary-channel' +import { RelayRuntimeServices } from './relay-runtime-services' +import { RelayAgentHookRuntime } from './relay-agent-hook-runtime' +import { RelaySocketOwnership } from './relay-socket-ownership' +import { RelayReconnectListener } from './relay-reconnect-listener' +import { RelayGraceLifecycle } from './relay-grace-lifecycle' +import { SKILL_RELAY_CAPABILITIES } from './skill-install-handler' + +export async function runRelayDaemon( + options: RelayLaunchOptions, + endpointCredential: string | undefined +): Promise { + if (options.detached && options.logFile) { + installRelayLogRotation(options.logFile) + } + + const socketOwnership = new RelaySocketOwnership(options.sockPath) + process.on('uncaughtException', (error) => { + relayLogLine(`[relay] Uncaught exception: ${error.message}\n${error.stack}`) + socketOwnership.cleanup() + process.exit(1) + }) + process.on('unhandledRejection', (reason) => { + relayLogLine(`[relay] Unhandled rejection: ${String(reason)}`) + }) + + const primaryChannel = new RelayPrimaryChannel() + const launchVersion = readLaunchVersion() + const runtime = new RelayRuntimeServices( + primaryChannel.dispatcher, + options.graceTimeMs, + launchVersion + ) + let reconnectListener: RelayReconnectListener | null = null + const agentHooks = new RelayAgentHookRuntime( + primaryChannel.dispatcher, + runtime.ptyHandler, + options.sockPath, + options.endpointDir + ) + const lifecycle = new RelayGraceLifecycle({ + dispatcher: primaryChannel.dispatcher, + ptyHandler: runtime.ptyHandler, + detached: options.detached, + emptyDetachedStartupGraceMs: RELAY_EMPTY_DETACHED_STARTUP_GRACE_MS, + idleRelayGraceMs: RELAY_IDLE_GRACE_MS, + readSocketClientCount: () => reconnectListener?.clientCount ?? 0, + hasAcceptedSocketClient: () => reconnectListener?.hasAcceptedClient ?? false, + ownsSocketPath: () => socketOwnership.owned, + disposeOwnedProcesses: () => runtime.disposeOwnedProcesses(), + disposeRuntime: () => { + primaryChannel.dispatcher.dispose() + runtime.disposeHandlers() + agentHooks.stop() + socketOwnership.closeAndCleanup() + } + }) + + await agentHooks.start() + reconnectListener = new RelayReconnectListener( + primaryChannel.dispatcher, + socketOwnership, + launchVersion, + endpointCredential, + { + detachPrimaryInput: () => primaryChannel.detachInput(), + cancelGrace: (reason) => lifecycle.cancel(reason), + onLastClientClosed: () => { + if (!primaryChannel.isAlive) { + lifecycle.start('socket client closed') + } + } + } + ) + const startedAt = Date.now() + registerRelayStatus( + primaryChannel, + runtime, + reconnectListener, + socketOwnership, + lifecycle, + options, + startedAt + ) + + try { + await reconnectListener.start() + agentHooks.publishEndpointFile() + } catch { + process.exit(1) + return + } + + primaryChannel.startOutputFailureHandling() + if (options.detached) { + lifecycle.start('detached startup') + } else { + primaryChannel.startInput({ + onData: () => lifecycle.cancel('stdin data'), + onDisconnect: (reason) => { + if ((reconnectListener?.clientCount ?? 0) === 0) { + lifecycle.start(reason) + } + } + }) + } + lifecycle.installProcessLifecycle() + primaryChannel.writeSentinel() + if (options.detached) { + primaryChannel.detachPrimaryClient() + } +} + +function registerRelayStatus( + primaryChannel: RelayPrimaryChannel, + runtime: RelayRuntimeServices, + reconnectListener: RelayReconnectListener, + socketOwnership: RelaySocketOwnership, + lifecycle: RelayGraceLifecycle, + options: RelayLaunchOptions, + startedAt: number +): void { + primaryChannel.dispatcher.onRequest('relay.status', async () => ({ + capabilities: SKILL_RELAY_CAPABILITIES, + pid: process.pid, + uptimeMs: Date.now() - startedAt, + detached: options.detached, + stdoutAlive: primaryChannel.isAlive, + memory: process.memoryUsage(), + ptys: { active: runtime.ptyHandler.activePtyCount }, + ptySourceCredit: { + enabled: true, + session: runtime.ptyConsumerSessionAdapter.getDebugSnapshot(), + publication: runtime.ptySourcePublication.getDebugSnapshot() + }, + socket: { + path: options.sockPath, + owned: socketOwnership.owned, + listening: socketOwnership.server?.listening ?? false, + clients: reconnectListener.clientCount, + acceptedConnections: reconnectListener.acceptedConnections + }, + grace: { + active: runtime.ptyHandler.graceTimerActive, + deadlineAt: lifecycle.deadlineAt, + reason: lifecycle.reason + } + })) +} diff --git a/src/relay/relay-grace-lifecycle.ts b/src/relay/relay-grace-lifecycle.ts new file mode 100644 index 00000000000..116b8873115 --- /dev/null +++ b/src/relay/relay-grace-lifecycle.ts @@ -0,0 +1,160 @@ +import type { PtyHandler } from './pty-handler' +import { + applyRelayGraceTimeConfiguration, + decideRelayGrace, + type RelayGraceBranch +} from './relay-grace-branch' +import { relayLogLine } from './relay-diagnostic-log' +import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../shared/ssh-types' +import type { RelayDispatcher } from './dispatcher' + +type RelayGraceLifecycleOptions = { + dispatcher: RelayDispatcher + ptyHandler: PtyHandler + detached: boolean + emptyDetachedStartupGraceMs: number + idleRelayGraceMs: number + readSocketClientCount: () => number + hasAcceptedSocketClient: () => boolean + ownsSocketPath: () => boolean + disposeOwnedProcesses: () => Promise + disposeRuntime: () => void +} + +export class RelayGraceLifecycle { + private graceDeadlineAt: number | null = null + private graceReason: string | null = null + private graceBranch: RelayGraceBranch | null = null + private shutdownInFlight = false + private stopPoolWatch = (): void => {} + private stopPoolActiveWatch = (): void => {} + + constructor(private readonly options: RelayGraceLifecycleOptions) { + options.dispatcher.onNotification(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, (params) => { + this.configure(params) + }) + options.dispatcher.onRequest(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, async (params) => + this.configure(params) + ) + } + + get deadlineAt(): number | null { + return this.graceDeadlineAt + } + + get reason(): string | null { + return this.graceReason + } + + cancel(reason: string): void { + if (this.options.ptyHandler.graceTimerActive) { + relayLogLine(`[relay] Grace canceled: ${reason}`) + } + this.graceDeadlineAt = null + this.graceReason = null + this.graceBranch = null + this.options.ptyHandler.cancelGraceTimer() + } + + start(reason: string, options?: { retryDeferredShutdown?: boolean }): void { + const decision = decideRelayGrace({ + configuredGraceMs: this.options.ptyHandler.configuredGraceTimeMs, + relayIdle: this.isRelayIdle(), + detached: this.options.detached, + hasAcceptedSocketClient: this.options.hasAcceptedSocketClient(), + activePtyCount: this.options.ptyHandler.activePtyCount, + retryDeferredShutdown: options?.retryDeferredShutdown === true, + emptyDetachedStartupGraceMs: this.options.emptyDetachedStartupGraceMs, + idleRelayGraceMs: this.options.idleRelayGraceMs + }) + this.graceBranch = decision.branch + this.graceDeadlineAt = decision.timeoutMs === 0 ? null : Date.now() + decision.timeoutMs + this.graceReason = reason + relayLogLine( + `[relay] Grace started (${reason}): timeoutMs=${decision.timeoutMs}, branch=${this.graceBranch}, ptys=${this.options.ptyHandler.activePtyCount}, clients=${this.options.readSocketClientCount()}` + ) + this.options.ptyHandler.startGraceTimer(() => { + if (this.graceBranch === 'idle-no-ptys' && !this.isRelayIdle()) { + relayLogLine(`[relay] Grace expired (${reason}) but relay is no longer idle; re-evaluating`) + this.start(reason) + return + } + relayLogLine(`[relay] Grace expired (${reason}); shutting down`) + this.shutdown() + }, decision.timeoutMs) + } + + installProcessLifecycle(): void { + this.stopPoolWatch = this.options.ptyHandler.onPtyPoolEmpty(() => { + if (this.graceReason !== null && this.graceDeadlineAt === null && !this.shutdownInFlight) { + this.start('last pty exited') + } + }) + this.stopPoolActiveWatch = this.options.ptyHandler.onPtyPoolActive(() => { + if ( + this.graceBranch === 'idle-no-ptys' && + this.graceReason !== null && + !this.shutdownInFlight + ) { + this.start(this.graceReason) + } + }) + process.on('SIGTERM', this.shutdown) + process.on('SIGINT', this.shutdown) + process.on('SIGHUP', () => { + relayLogLine('[relay] Received SIGHUP (SSH session dropped), ignoring') + }) + process.on('exit', (code) => { + relayLogLine(`[relay] Process exiting with code ${code}`) + }) + } + + readonly shutdown = (): void => { + if (this.shutdownInFlight) { + return + } + this.shutdownInFlight = true + relayLogLine( + `[relay] Shutdown: ptys=${this.options.ptyHandler.activePtyCount}, clients=${this.options.readSocketClientCount()}, ownsSocket=${this.options.ownsSocketPath()}` + ) + this.graceDeadlineAt = null + this.graceReason = null + this.graceBranch = null + void this.options.ptyHandler + .dispose() + .then(async () => { + await this.options.disposeOwnedProcesses() + this.stopPoolWatch() + this.stopPoolActiveWatch() + this.options.disposeRuntime() + process.exit(0) + }) + .catch((error) => { + this.shutdownInFlight = false + relayLogLine( + `[relay] Shutdown deferred: ${error instanceof Error ? error.message : String(error)}` + ) + if (this.options.readSocketClientCount() === 0) { + this.start('shutdown deferred', { retryDeferredShutdown: true }) + } + }) + } + + private configure(params: Record): { graceTimeMs: number } { + return applyRelayGraceTimeConfiguration(params.graceTimeSeconds, { + readConfiguredGraceMs: () => this.options.ptyHandler.configuredGraceTimeMs, + writeConfiguredGraceMs: (graceMs) => this.options.ptyHandler.setGraceTimeMs(graceMs), + isGraceTimerArmed: () => this.graceDeadlineAt !== null && this.graceReason !== null, + isShutdownInFlight: () => this.shutdownInFlight, + readGraceBranch: () => this.graceBranch, + startGrace: (reason, startOptions) => this.start(reason, startOptions) + }) + } + + private isRelayIdle(): boolean { + return ( + this.options.ptyHandler.activePtyCount === 0 && + this.options.ptyHandler.pendingPtyCreationCount === 0 + ) + } +} diff --git a/src/relay/relay-launch-options.test.ts b/src/relay/relay-launch-options.test.ts new file mode 100644 index 00000000000..bfcbf9143fc --- /dev/null +++ b/src/relay/relay-launch-options.test.ts @@ -0,0 +1,82 @@ +import { chmodSync, mkdtempSync, statSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseRelayLaunchOptions, readRelayEndpointCredential } from './relay-launch-options' + +describe('relay launch options', () => { + const temporaryDirectories: string[] = [] + + afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })) + ) + }) + + it('preserves daemon mode flags and grace seconds conversion', () => { + expect( + parseRelayLaunchOptions([ + 'node', + 'relay.js', + '--detached', + '--connect', + '--grace-time', + '17', + '--sock-path', + 'relay-endpoint', + '--endpoint-dir', + 'hooks', + '--log-file', + 'relay.log', + '--credential-file', + 'relay.credential' + ]) + ).toEqual({ + graceTimeMs: 17_000, + connectMode: true, + detached: true, + cliMode: false, + sockPath: 'relay-endpoint', + endpointDir: 'hooks', + logFile: 'relay.log', + credentialFile: 'relay.credential' + }) + }) + + it('keeps zero grace unlimited and ignores invalid replacements', () => { + expect( + parseRelayLaunchOptions(['node', 'relay.js', '--grace-time', '0', '--grace-time', '-1']) + .graceTimeMs + ).toBe(0) + }) + + it('validates and restricts the endpoint credential file', () => { + const directory = mkdtempSync(join(tmpdir(), 'relay-launch-options-')) + temporaryDirectories.push(directory) + const credentialFile = join(directory, 'endpoint.credential') + const credential = 'a'.repeat(32) + writeFileSync(credentialFile, `${credential}\n`) + if (process.platform !== 'win32') { + chmodSync(credentialFile, 0o644) + } + + expect(readRelayEndpointCredential(credentialFile)).toBe(credential) + if (process.platform !== 'win32') { + expect(statSync(credentialFile).mode & 0o777).toBe(0o600) + } + }) + + it('rejects credentials that cannot authenticate a reconnect client', () => { + const directory = mkdtempSync(join(tmpdir(), 'relay-launch-options-')) + temporaryDirectories.push(directory) + const credentialFile = join(directory, 'endpoint.credential') + writeFileSync(credentialFile, 'too-short') + + expect(() => readRelayEndpointCredential(credentialFile)).toThrow( + 'Relay endpoint credential is missing or invalid' + ) + }) +}) diff --git a/src/relay/relay-launch-options.ts b/src/relay/relay-launch-options.ts new file mode 100644 index 00000000000..95fafae818a --- /dev/null +++ b/src/relay/relay-launch-options.ts @@ -0,0 +1,102 @@ +import { chmodSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' + +const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 +const DEFAULT_SOCKET_NAME = 'relay.sock' + +export const RELAY_EMPTY_DETACHED_STARTUP_GRACE_MS = parseNonNegativeIntEnv( + 'ORCA_RELAY_EMPTY_STARTUP_GRACE_MS', + 60_000 +) +// Why: a relay holding zero PTYs preserves nothing, so an unlimited grace only accumulates idle daemons. +// The env override is test-only — the remote relay is launched over a non-interactive SSH exec channel that carries no client env. +export const RELAY_IDLE_GRACE_MS = parseNonNegativeIntEnv('ORCA_RELAY_IDLE_GRACE_MS', 15 * 60_000) + +export type RelayLaunchOptions = { + graceTimeMs: number + connectMode: boolean + detached: boolean + cliMode: boolean + sockPath: string + endpointDir?: string + logFile?: string + credentialFile?: string +} + +export function parseRelayLaunchOptions(argv: string[]): RelayLaunchOptions { + let graceTimeMs = DEFAULT_GRACE_MS + let connectMode = false + let detached = false + let cliMode = false + let sockPath = '' + let endpointDir: string | undefined + let logFile: string | undefined + let credentialFile: string | undefined + for (let i = 2; i < argv.length; i++) { + if (argv[i] === '--grace-time' && argv[i + 1]) { + const parsed = Number.parseInt(argv[i + 1], 10) + // Why: flag is seconds (internally ms); 0 keeps the relay alive until explicitly terminated for synced workspaces. + if (!Number.isNaN(parsed) && parsed >= 0) { + graceTimeMs = parsed * 1000 + } + i++ + } else if (argv[i] === '--connect') { + connectMode = true + } else if (argv[i] === '--orca-cli') { + cliMode = true + } else if (argv[i] === '--detached') { + detached = true + } else if (argv[i] === '--sock-path' && argv[i + 1]) { + sockPath = argv[i + 1] + i++ + } else if (argv[i] === '--endpoint-dir' && argv[i + 1]) { + endpointDir = argv[i + 1] + i++ + } else if (argv[i] === '--log-file' && argv[i + 1]) { + logFile = argv[i + 1] + i++ + } else if (argv[i] === '--credential-file' && argv[i + 1]) { + credentialFile = argv[i + 1] + i++ + } + } + if (!sockPath) { + sockPath = join(process.cwd(), DEFAULT_SOCKET_NAME) + } + return { + graceTimeMs, + connectMode, + detached, + cliMode, + sockPath, + endpointDir, + logFile, + credentialFile + } +} + +export function readRelayEndpointCredential( + credentialFile: string | undefined +): string | undefined { + if (!credentialFile) { + return undefined + } + const credential = readFileSync(credentialFile, 'utf8').trim() + if (!/^[A-Za-z0-9_-]{32,256}$/.test(credential)) { + throw new Error('Relay endpoint credential is missing or invalid') + } + if (process.platform !== 'win32') { + chmodSync(credentialFile, 0o600) + } + return credential +} + +function parseNonNegativeIntEnv(name: string, fallback: number): number { + const raw = process.env[name] + if (raw === undefined) { + return fallback + } + const parsed = Number.parseInt(raw, 10) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback +} diff --git a/src/relay/relay-orca-cli-channel.ts b/src/relay/relay-orca-cli-channel.ts new file mode 100644 index 00000000000..2afbd363fe5 --- /dev/null +++ b/src/relay/relay-orca-cli-channel.ts @@ -0,0 +1,241 @@ +import { createConnection } from 'node:net' +import { DispatcherClientWriter } from './dispatcher-client-writer' +import { pickRemoteCliEnv } from './remote-cli-env' +import { shouldReadRemoteCliStdin } from './remote-cli-stdin' +import { prepareRemoteArtifactCliInput } from './remote-artifact-cli-input' +import { + assertRemoteArtifactCliForwardingFits, + type RemoteArtifactCliForwardingParams +} from './remote-artifact-cli-forwarding' +import { + FrameDecoder, + MessageType, + encodeJsonRpcFrame, + parseJsonRpcMessage, + type DecodedFrame, + type JsonRpcResponse +} from './protocol' +import { readLaunchVersion, runConnectHandshake } from './relay-handshake' + +const CONNECT_TIMEOUT_MS = 5_000 + +export async function runRelayOrcaCliChannel( + sockPath: string, + argv: string[], + endpointCredential?: string +): Promise { + const myVersion = readLaunchVersion() + let preparedArtifact: Awaited> + try { + preparedArtifact = await prepareRemoteArtifactCliInput(argv, process.cwd()) + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + return + } + const stdin = + preparedArtifact.stdin ?? + (shouldReadRemoteCliStdin(argv) ? await readOrcaCliStdin() : undefined) + const env = pickRemoteCliEnv(process.env) + const requestParams: RemoteArtifactCliForwardingParams = { + argv, + cwd: process.cwd(), + env, + ...(stdin !== undefined ? { stdin } : {}), + ...(preparedArtifact.artifactInput ? { artifactInput: preparedArtifact.artifactInput } : {}) + } + if (preparedArtifact.artifactInput) { + try { + assertRemoteArtifactCliForwardingFits(requestParams) + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + return + } + } + const sock = createConnection({ path: sockPath }) + const stdoutWriter = new DispatcherClientWriter( + (data, onSettled) => + process.stdout.write(data, (error) => { + onSettled(error ? { ok: false, error } : { ok: true }) + }), + { + supportsWriteCallback: true, + writableLength: () => process.stdout.writableLength, + writableHighWaterMark: () => process.stdout.writableHighWaterMark, + waitWriteDrain: (callback) => { + process.stdout.once('drain', callback) + return () => process.stdout.off('drain', callback) + } + }, + () => process.exit(1) + ) + let nextSeq = 1 + let highestReceivedSeq = 0 + const requestId = 1 + const postOutputRequestId = 2 + let initialExitCode = 0 + + const sendRequest = (): void => { + const frame = encodeJsonRpcFrame( + { + jsonrpc: '2.0', + id: requestId, + method: 'orca.cli', + params: requestParams + }, + nextSeq++, + highestReceivedSeq + ) + sock.write(frame) + } + + const finish = (exitCode: number): void => { + sock.destroy() + process.exit(exitCode) + } + + const sendPostOutput = (postOutput: unknown): void => { + sock.write( + encodeJsonRpcFrame( + { + jsonrpc: '2.0', + id: postOutputRequestId, + method: 'orca.cli.postOutput', + params: { postOutput, env: pickRemoteCliEnv(process.env) } + }, + nextSeq++, + highestReceivedSeq + ) + ) + } + + const writeOutput = ( + result: { stdout?: unknown; stderr?: unknown }, + onFlushed: (error?: Error) => void + ): void => { + let pending = 0 + let completed = false + const settle = (error?: Error): void => { + if (completed) { + return + } + if (error) { + completed = true + onFlushed(error) + return + } + pending -= 1 + if (pending === 0) { + completed = true + onFlushed() + } + } + if (typeof result.stdout === 'string' && result.stdout.length > 0) { + pending += 1 + const output = Buffer.from(result.stdout) + stdoutWriter.enqueue( + 'control', + () => output, + output.length, + (settlement) => settle(settlement.ok ? undefined : settlement.error) + ) + } + if (typeof result.stderr === 'string' && result.stderr.length > 0) { + pending += 1 + process.stderr.write(result.stderr, 'utf8', (error) => settle(error ?? undefined)) + } + if (pending === 0) { + completed = true + onFlushed() + } + } + + const decoder = new FrameDecoder((frame: DecodedFrame) => { + if (frame.id > highestReceivedSeq) { + highestReceivedSeq = frame.id + } + if (frame.type !== MessageType.Regular) { + return + } + const msg = parseJsonRpcMessage(frame.payload) + if ( + !('id' in msg) || + (msg.id !== requestId && msg.id !== postOutputRequestId) || + !('result' in msg || 'error' in msg) + ) { + return + } + const response = msg as JsonRpcResponse + if (response.error) { + process.stderr.write(`${response.error.message}\n`) + finish(1) + return + } + if (response.id === postOutputRequestId) { + finish(initialExitCode) + return + } + const result = (response.result ?? {}) as { + stdout?: unknown + stderr?: unknown + exitCode?: unknown + postOutput?: unknown + } + initialExitCode = typeof result.exitCode === 'number' ? result.exitCode : 0 + writeOutput(result, (error) => { + if (error) { + finish(1) + return + } + if (result.postOutput === undefined) { + finish(initialExitCode) + return + } + sendPostOutput(result.postOutput) + }) + }) + + const connectTimeout = setTimeout(() => { + process.stderr.write(`[orca-cli] Relay connection timed out after ${CONNECT_TIMEOUT_MS}ms\n`) + sock.destroy() + process.exit(1) + }, CONNECT_TIMEOUT_MS) + + sock.on('connect', () => { + clearTimeout(connectTimeout) + runConnectHandshake( + sock, + myVersion, + { + onAccepted: (leftover) => { + if (leftover.length > 0) { + decoder.feed(leftover) + } + sock.on('data', (chunk) => + decoder.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + ) + sendRequest() + } + }, + endpointCredential + ) + }) + + sock.on('error', (error) => { + clearTimeout(connectTimeout) + process.stderr.write(`[orca-cli] Relay socket error: ${error.message}\n`) + process.exit(1) + }) +} + +async function readOrcaCliStdin(): Promise { + if (process.stdin.isTTY) { + return undefined + } + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) + } + return Buffer.concat(chunks).toString('utf8') +} diff --git a/src/relay/relay-primary-channel.ts b/src/relay/relay-primary-channel.ts new file mode 100644 index 00000000000..9b2e50eaaa1 --- /dev/null +++ b/src/relay/relay-primary-channel.ts @@ -0,0 +1,126 @@ +import { closeSync, openSync } from 'node:fs' +import { RelayDispatcher } from './dispatcher' +import { RELAY_SENTINEL } from './protocol' + +export class RelayPrimaryChannel { + readonly dispatcher: RelayDispatcher + private stdoutAlive = true + private readonly stdoutDrainWaiters = new Set<() => void>() + + constructor() { + process.stdout.on('drain', this.flushStdoutDrainWaiters) + this.dispatcher = new RelayDispatcher( + (data, onSettled) => { + if (!this.stdoutAlive) { + onSettled({ ok: false, error: new Error('Relay stdout is closed') }) + return false + } + try { + return process.stdout.write(data, (error) => { + onSettled(error ? { ok: false, error } : { ok: true }) + }) + } catch (error) { + this.stdoutAlive = false + this.flushStdoutDrainWaiters() + onSettled({ + ok: false, + error: error instanceof Error ? error : new Error(String(error)) + }) + return false + } + }, + { + supportsWriteCallback: true, + writableLength: () => process.stdout.writableLength, + writableHighWaterMark: () => process.stdout.writableHighWaterMark, + waitWriteDrain: (callback) => { + if (!this.stdoutAlive) { + callback() + return + } + this.stdoutDrainWaiters.add(callback) + return () => this.stdoutDrainWaiters.delete(callback) + }, + close: () => this.closePrimaryDescriptors() + }, + undefined, + { pauseReads: () => process.stdin.pause(), resumeReads: () => process.stdin.resume() } + ) + } + + get isAlive(): boolean { + return this.stdoutAlive + } + + detachInput(): void { + process.stdin.pause() + process.stdin.removeAllListeners('data') + } + + startOutputFailureHandling(): void { + process.stdout.on('error', () => { + this.markClosed('peer-closed') + }) + } + + startInput(callbacks: { onData: () => void; onDisconnect: (reason: string) => void }): void { + process.stdin.on('data', (chunk: Buffer) => { + callbacks.onData() + this.dispatcher.feed(chunk) + }) + process.stdin.on('end', () => { + this.markClosed('peer-closed') + callbacks.onDisconnect('stdin ended') + }) + process.stdin.on('error', () => { + this.markClosed('peer-closed') + callbacks.onDisconnect('stdin error') + }) + } + + writeSentinel(): void { + this.dispatcher.writePrimaryBytes(Buffer.from(RELAY_SENTINEL)) + } + + detachPrimaryClient(): void { + this.stdoutAlive = false + this.flushStdoutDrainWaiters() + this.dispatcher.invalidateClient() + } + + private readonly flushStdoutDrainWaiters = (): void => { + for (const callback of Array.from(this.stdoutDrainWaiters)) { + this.stdoutDrainWaiters.delete(callback) + callback() + } + } + + private markClosed(cause: 'peer-closed'): void { + this.stdoutAlive = false + this.flushStdoutDrainWaiters() + this.dispatcher.invalidateClient(cause) + } + + private closePrimaryDescriptors(): void { + this.stdoutAlive = false + this.flushStdoutDrainWaiters() + for (const fd of [process.stdin.fd, process.stdout.fd]) { + try { + closeSync(fd) + } catch { + // Already closed by the peer. + } + } + const devNull = process.platform === 'win32' ? 'NUL' : '/dev/null' + try { + openSync(devNull, 'r') + } catch { + // Best-effort pin of the lowest free descriptor. + } + try { + openSync(devNull, 'w') + } catch { + // Best-effort pin of the next free descriptor. + } + } +} diff --git a/src/relay/relay-reconnect-listener.ts b/src/relay/relay-reconnect-listener.ts new file mode 100644 index 00000000000..0b0c3dcfd46 --- /dev/null +++ b/src/relay/relay-reconnect-listener.ts @@ -0,0 +1,131 @@ +import type { Socket } from 'node:net' +import type { RelayDispatcher } from './dispatcher' +import { setupDaemonHandshake } from './relay-handshake' +import { relayLogLine } from './relay-diagnostic-log' +import type { RelaySocketOwnership } from './relay-socket-ownership' + +type RelayReconnectCallbacks = { + detachPrimaryInput: () => void + cancelGrace: (reason: string) => void + onLastClientClosed: () => void +} + +export class RelayReconnectListener { + private readonly socketClients = new Map() + private acceptedSocketConnections = 0 + private acceptedSocketClient = false + + constructor( + private readonly dispatcher: RelayDispatcher, + readonly ownership: RelaySocketOwnership, + private readonly launchVersion: string, + private readonly endpointCredential: string | undefined, + private readonly callbacks: RelayReconnectCallbacks + ) {} + + get clientCount(): number { + return this.socketClients.size + } + + get hasAcceptedClient(): boolean { + return this.acceptedSocketClient + } + + get acceptedConnections(): number { + return this.acceptedSocketConnections + } + + async start(): Promise { + await this.ownership.listen((socket) => this.acceptConnection(socket)) + } + + private acceptConnection(socket: Socket): void { + setupDaemonHandshake(socket, { + launchVersion: this.launchVersion, + endpointCredential: this.endpointCredential, + onAccepted: (acceptedSocket, leftover) => this.attachAcceptedSocket(acceptedSocket, leftover) + }) + socket.on('end', () => { + if (!socket.destroyed) { + socket.destroy() + } + }) + socket.on('error', () => { + // The close event owns client cleanup and grace startup. + }) + socket.on('close', () => this.handleSocketClose(socket)) + } + + private attachAcceptedSocket(socket: Socket, leftover: Buffer): void { + this.callbacks.detachPrimaryInput() + this.acceptedSocketClient = true + this.acceptedSocketConnections++ + relayLogLine( + `[relay] Socket client accepted (clients=${this.socketClients.size + 1}, accepted=${this.acceptedSocketConnections})` + ) + this.callbacks.cancelGrace('socket client accepted') + + const drainWaiters = new Set<() => void>() + const flushDrainWaiters = (): void => { + for (const callback of Array.from(drainWaiters)) { + drainWaiters.delete(callback) + callback() + } + } + socket.on('drain', flushDrainWaiters) + socket.on('close', flushDrainWaiters) + socket.on('error', flushDrainWaiters) + const clientId = this.dispatcher.attachClient( + (data, onSettled) => { + if (!socket.destroyed) { + return socket.write(data, (error) => { + onSettled(error ? { ok: false, error } : { ok: true }) + }) + } + onSettled({ ok: false, error: new Error('Relay socket is closed') }) + return false + }, + { + supportsWriteCallback: true, + writableLength: () => socket.writableLength, + writableHighWaterMark: () => socket.writableHighWaterMark, + close: () => socket.destroy(), + waitWriteDrain: (callback) => { + if (socket.destroyed) { + callback() + return + } + drainWaiters.add(callback) + return () => drainWaiters.delete(callback) + } + }, + { + principal: `relay-endpoint:${this.launchVersion}`, + authenticated: this.endpointCredential !== undefined, + allowSessionOwner: this.endpointCredential !== undefined, + authenticationKind: this.endpointCredential ? 'endpoint-credential' : 'unproved' + }, + { pauseReads: () => socket.pause(), resumeReads: () => socket.resume() } + ) + this.socketClients.set(socket, clientId) + if (leftover.length > 0) { + this.dispatcher.feedClient(clientId, leftover) + } + socket.on('data', (chunk: Buffer) => { + this.callbacks.cancelGrace('socket client data') + this.dispatcher.feedClient(clientId, chunk) + }) + } + + private handleSocketClose(socket: Socket): void { + const clientId = this.socketClients.get(socket) + this.socketClients.delete(socket) + if (clientId !== undefined) { + this.dispatcher.detachClient(clientId, 'peer-closed') + } + relayLogLine(`[relay] Socket client closed (clients=${this.socketClients.size})`) + if (this.socketClients.size === 0) { + this.callbacks.onLastClientClosed() + } + } +} diff --git a/src/relay/relay-runtime-services.ts b/src/relay/relay-runtime-services.ts new file mode 100644 index 00000000000..36276ed9b70 --- /dev/null +++ b/src/relay/relay-runtime-services.ts @@ -0,0 +1,143 @@ +import { homedir } from 'node:os' +import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol' +import type { RelayDispatcher } from './dispatcher' +import { RelayContext, expandTilde } from './context' +import { PtyHandler } from './pty-handler' +import { FsHandler } from './fs-handler' +import { GitHandler } from './git-handler' +import { PreflightHandler } from './preflight-handler' +import { ExternalAutomationsHandler } from './external-automations-handler' +import { PortScanHandler } from './port-scan-handler' +import { AgentExecHandler } from './agent-exec-handler' +import { WorkspaceSessionHandler } from './workspace-session-handler' +import { AiVaultHandler } from './ai-vault-handler' +import { createRelayAiVaultService } from './ai-vault-service-factory' +import { registerRelayPluginHostCallHandlers } from './plugin-host-call-handler' +import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' +import { RelayPtySourcePublication } from './relay-pty-source-publication' +import { SkillInstallHandler } from './skill-install-handler' +import { relayLogLine } from './relay-diagnostic-log' +import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' + +export class RelayRuntimeServices { + readonly ptyHandler: PtyHandler + readonly ptyConsumerSessionAdapter: SshPtyConsumerSessionAdapter + readonly ptySourcePublication: RelayPtySourcePublication + readonly fsHandler: FsHandler + readonly gitHandler: GitHandler + readonly skillInstallHandler: SkillInstallHandler + private readonly aiVaultService: ReturnType | null + private readonly registeredHandlers: readonly unknown[] + + constructor( + readonly dispatcher: RelayDispatcher, + graceTimeMs: number, + launchVersion: string + ) { + const context = new RelayContext() + this.registerSessionHandlers(context) + this.ptyHandler = new PtyHandler(dispatcher, graceTimeMs) + this.ptyConsumerSessionAdapter = new SshPtyConsumerSessionAdapter( + dispatcher, + launchVersion, + (id, paused) => this.ptyHandler.setConsumerDeliveryPaused(id, paused), + (id) => this.ptyHandler.handleSourceCreditAvailable(id) + ) + this.ptySourcePublication = new RelayPtySourcePublication( + dispatcher, + this.ptyConsumerSessionAdapter, + (id) => this.ptyHandler.handleSourcePublicationCapacity(id) + ) + this.ptyHandler.setSourcePublication(this.ptySourcePublication) + + this.fsHandler = new FsHandler(dispatcher, context) + const watchRegistry = this.fsHandler.getWatchRegistry() + this.ptyHandler.setWorktreeRemovalCoordinator(watchRegistry) + watchRegistry.setWorktreePtyTeardown((rootPath) => + this.ptyHandler.shutdownForWorktreePath(rootPath) + ) + this.gitHandler = new GitHandler(dispatcher, context, watchRegistry) + const preflightHandler = new PreflightHandler(dispatcher) + this.skillInstallHandler = new SkillInstallHandler(dispatcher) + const externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher) + const portScanHandler = new PortScanHandler(dispatcher) + const agentExecHandler = new AgentExecHandler(dispatcher) + const workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) + const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) + const hostPlatform = relayPlatform ? getRemoteHostPlatform(relayPlatform) : undefined + this.aiVaultService = hostPlatform ? createRelayAiVaultService(homedir(), hostPlatform) : null + this.registeredHandlers = [ + preflightHandler, + this.skillInstallHandler, + externalAutomationsHandler, + portScanHandler, + agentExecHandler, + workspaceSessionHandler, + new AiVaultHandler(dispatcher, { + hostPlatform, + service: this.aiVaultService ?? undefined + }) + ] + + registerRelayPluginHostCallHandlers( + dispatcher, + () => null, + () => ({ grantedCapabilities: null, services: null }) + ) + this.registerRemoteCliRoutes() + } + + async disposeOwnedProcesses(): Promise { + await this.skillInstallHandler.dispose().catch((error) => { + relayLogLine( + `[relay] Skill upload cleanup failed: ${error instanceof Error ? error.message : String(error)}` + ) + }) + await this.aiVaultService?.dispose().catch((error) => { + relayLogLine( + `[relay] AI Vault sidecar shutdown failed: ${error instanceof Error ? error.message : String(error)}` + ) + }) + } + + disposeHandlers(): void { + this.fsHandler.dispose() + this.gitHandler.dispose() + void this.registeredHandlers + } + + private registerSessionHandlers(context: RelayContext): void { + this.dispatcher.onNotification('session.registerRoot', (params) => { + const rootPath = params.rootPath as string + if (rootPath) { + context.registerRoot(rootPath) + } + }) + this.dispatcher.onRequest('session.registerRoot', async (params) => { + const rootPath = params.rootPath as string + if (rootPath) { + context.registerRoot(rootPath) + } + return { ok: true } + }) + this.dispatcher.onRequest('session.resolveHome', async (params) => ({ + resolvedPath: expandTilde(params.path as string) + })) + } + + private registerRemoteCliRoutes(): void { + this.dispatcher.onRequest('orca.cli', async (params, context) => + this.dispatcher.requestAnyClient('orca.cli', params, { + excludeClientId: context.clientId, + timeoutMs: remoteCliRequestTimeoutMs(params) + }) + ) + this.dispatcher.onRequest('orca.cli.postOutput', async (params, context) => + this.dispatcher.requestAnyClient('orca.cli.postOutput', params, { + excludeClientId: context.clientId, + timeoutMs: remoteCliRequestTimeoutMs(params) + }) + ) + } +} diff --git a/src/relay/relay-socket-ownership.ts b/src/relay/relay-socket-ownership.ts new file mode 100644 index 00000000000..b7c2c9611fe --- /dev/null +++ b/src/relay/relay-socket-ownership.ts @@ -0,0 +1,219 @@ +import { existsSync, statSync, unlinkSync } from 'node:fs' +import { createConnection, createServer, type Server, type Socket } from 'node:net' +import { relayLogLine } from './relay-diagnostic-log' + +const STALE_SOCKET_PROBE_TIMEOUT_MS = 500 + +type SocketIdentity = { + dev: bigint + ino: bigint + ctimeNs: bigint +} + +function sameSocketIdentity(a: SocketIdentity, b: SocketIdentity): boolean { + return a.dev === b.dev && a.ino === b.ino && a.ctimeNs === b.ctimeNs +} + +export function isRelayNamedPipePath(sockPath: string): boolean { + return process.platform === 'win32' && /^\\\\[.?]\\pipe\\/i.test(sockPath) +} + +export class RelaySocketOwnership { + private ownsSocketPath = false + private ownedSocketIdentity: SocketIdentity | null = null + private socketServer: Server | null = null + + constructor(readonly sockPath: string) {} + + get server(): Server | null { + return this.socketServer + } + + get owned(): boolean { + return this.ownsSocketPath + } + + ownsCurrentPath(): boolean { + if (isRelayNamedPipePath(this.sockPath)) { + return this.ownsSocketPath + } + const currentIdentity = this.readIdentity() + return ( + this.ownsSocketPath && + this.ownedSocketIdentity !== null && + currentIdentity !== null && + sameSocketIdentity(currentIdentity, this.ownedSocketIdentity) + ) + } + + async listen(onConnection: (socket: Socket) => void): Promise { + const server = createServer(onConnection) + const shouldSetUmask = !isRelayNamedPipePath(this.sockPath) + const previousUmask = shouldSetUmask ? process.umask(0o177) : 0 + let umaskRestored = false + const restoreUmask = (): void => { + if (shouldSetUmask && !umaskRestored) { + process.umask(previousUmask) + umaskRestored = true + } + } + + await new Promise((resolve, reject) => { + let staleRetryAttempted = false + + const removeStartupListeners = (): void => { + server.off('listening', onListening) + server.off('error', onInitialError) + server.off('error', failInitial) + } + const listenForStartupError = (onError: (error: NodeJS.ErrnoException) => void): void => { + server.once('listening', onListening) + server.once('error', onError) + server.listen(this.sockPath) + } + const onListening = (): void => { + removeStartupListeners() + restoreUmask() + this.ownsSocketPath = true + this.ownedSocketIdentity = this.readIdentity() + this.socketServer = server + server.on('error', (error) => { + relayLogLine(`[relay] Socket server error: ${error.message}`) + }) + relayLogLine(`[relay] Socket server listening: ${this.sockPath}`) + resolve() + } + const failInitial = (error: NodeJS.ErrnoException): void => { + removeStartupListeners() + restoreUmask() + if (error.code === 'EADDRINUSE') { + relayLogLine( + `[relay] Socket path already in use: ${this.sockPath}; another relay is likely active. Use --connect instead of starting a new daemon.` + ) + } else { + relayLogLine(`[relay] Socket server error before listen: ${error.message}`) + } + reject(error) + } + const onInitialError = (error: NodeJS.ErrnoException): void => { + if ( + error.code !== 'EADDRINUSE' || + staleRetryAttempted || + isRelayNamedPipePath(this.sockPath) + ) { + failInitial(error) + return + } + staleRetryAttempted = true + this.probeBlockedPath(error, failInitial, () => { + relayLogLine(`[relay] Removed stale socket at ${this.sockPath} and retrying listen`) + removeStartupListeners() + listenForStartupError(failInitial) + }) + } + listenForStartupError(onInitialError) + }) + return server + } + + closeAndCleanup(): void { + if (this.socketServer && this.ownsCurrentPath()) { + this.socketServer.close() + } + this.cleanup() + } + + cleanup(): void { + if (this.ownsCurrentPath()) { + this.unlinkPath() + } + this.ownsSocketPath = false + this.ownedSocketIdentity = null + } + + private readIdentity(): SocketIdentity | null { + if (isRelayNamedPipePath(this.sockPath)) { + return null + } + try { + const stat = statSync(this.sockPath, { bigint: true }) + return { dev: stat.dev, ino: stat.ino, ctimeNs: stat.ctimeNs } + } catch { + return null + } + } + + private probeBlockedPath( + listenError: NodeJS.ErrnoException, + fail: (error: NodeJS.ErrnoException) => void, + retry: () => void + ): void { + const blockedIdentity = this.readIdentity() + const probe = createConnection({ path: this.sockPath }) + let settled = false + let timeout: NodeJS.Timeout | null = null + const finish = (callback: () => void): void => { + if (settled) { + return + } + settled = true + if (timeout) { + clearTimeout(timeout) + } + callback() + } + probe.once('connect', () => { + finish(() => { + probe.destroy() + fail(listenError) + }) + }) + probe.once('error', (probeError: NodeJS.ErrnoException) => { + finish(() => { + if ( + (probeError.code !== 'ECONNREFUSED' && probeError.code !== 'ENOENT') || + !this.unlinkIfStillStale(blockedIdentity) + ) { + fail(listenError) + return + } + retry() + }) + }) + timeout = setTimeout(() => { + finish(() => { + probe.destroy() + fail(listenError) + }) + }, STALE_SOCKET_PROBE_TIMEOUT_MS) + } + + private unlinkIfStillStale(blockedIdentity: SocketIdentity | null): boolean { + const currentIdentity = this.readIdentity() + if (currentIdentity === null) { + return true + } + if (blockedIdentity === null || !sameSocketIdentity(currentIdentity, blockedIdentity)) { + return false + } + try { + unlinkSync(this.sockPath) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' + } + } + + private unlinkPath(): void { + if (isRelayNamedPipePath(this.sockPath)) { + return + } + try { + if (existsSync(this.sockPath)) { + unlinkSync(this.sockPath) + } + } catch { + // Best-effort cleanup. + } + } +} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index a2dcb1bcf40..b4f05d836ec 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -1,1403 +1,35 @@ #!/usr/bin/env node -/* oxlint-disable max-lines -- Why: the entry point keeps process lifecycle and handler registration in one file so the boot sequence stays in topological order. */ -/* eslint-disable max-lines -- Why: splitting the entrypoint's startup/reconnect/registration would hide the startup order, the key invariant here. */ +// Orca Relay — remote-host daemon and reconnect bridge entry point. -// Orca Relay — lightweight daemon deployed to remote hosts over SCP and launched via an SSH exec channel. -// Communicates over stdin/stdout using the framed JSON-RPC protocol. -// On client disconnect it enters a grace period, keeping PTYs alive on a Unix domain socket; a later launch -// reconnects via `relay.js --connect`, bridging the new SSH channel's stdio to the existing relay's socket. - -import { createServer, createConnection, type Socket, type Server } from 'node:net' -import { homedir } from 'node:os' -import { join } from 'node:path' -import { - unlinkSync, - existsSync, - statSync, - readFileSync, - chmodSync, - closeSync, - openSync -} from 'node:fs' -import { - RELAY_SENTINEL, - FrameDecoder, - MessageType, - encodeJsonRpcFrame, - parseJsonRpcMessage, - type DecodedFrame, - type JsonRpcResponse -} from './protocol' -import { readLaunchVersion, runConnectHandshake, setupDaemonHandshake } from './relay-handshake' -import { RelayDispatcher } from './dispatcher' -import { RelayContext, expandTilde } from './context' -import { PtyHandler } from './pty-handler' -import { FsHandler } from './fs-handler' -import { installRelayLogRotation } from './rotating-log-writer' -import { GitHandler } from './git-handler' -import { PreflightHandler } from './preflight-handler' -import { ExternalAutomationsHandler } from './external-automations-handler' -import { PortScanHandler } from './port-scan-handler' -import { AgentExecHandler } from './agent-exec-handler' -import { WorkspaceSessionHandler } from './workspace-session-handler' -import { AiVaultHandler } from './ai-vault-handler' -import { createRelayAiVaultService } from './ai-vault-service-factory' -import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' -import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol' -import { RelayAgentHookServer } from './agent-hook-server' -import { endpointDirForRelaySocket } from './agent-hook-endpoint-coordinates' -import { PluginOverlayManager } from './plugin-overlay' -import { - AGENT_HOOK_INSTALL_PLUGINS_METHOD, - AGENT_HOOK_REQUEST_REPLAY_METHOD -} from '../shared/agent-hook-relay' -import { publishAgentHookEnvelope } from './agent-hook-envelope-publication' -import { - DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, - SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD -} from '../shared/ssh-types' -import { assertPluginSourceUnderByteCap } from './plugin-source-limit' -import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env' -import { - detectExplicitPiAgentKindFromCommand, - isPiCompatibleAgentType -} from '../shared/pi-agent-kind' -import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' -import { pickRemoteCliEnv } from './remote-cli-env' -import { - applyRelayGraceTimeConfiguration, - decideRelayGrace, - type RelayGraceBranch -} from './relay-grace-branch' +import { parseRelayLaunchOptions, readRelayEndpointCredential } from './relay-launch-options' +import { runRelayConnectChannel } from './relay-connect-channel' +import { runRelayOrcaCliChannel } from './relay-orca-cli-channel' +import { runRelayDaemon } from './relay-daemon' import { relayLogLine } from './relay-diagnostic-log' -import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' -import { shouldReadRemoteCliStdin } from './remote-cli-stdin' -import { prepareRemoteArtifactCliInput } from './remote-artifact-cli-input' -import { - assertRemoteArtifactCliForwardingFits, - type RemoteArtifactCliForwardingParams -} from './remote-artifact-cli-forwarding' -import { registerManagedHookInstaller } from './managed-hook-installer' -import { registerRelayPluginHostCallHandlers } from './plugin-host-call-handler' -import { DispatcherClientWriter } from './dispatcher-client-writer' -import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter' -import { RelayPtySourcePublication } from './relay-pty-source-publication' -import { SKILL_RELAY_CAPABILITIES, SkillInstallHandler } from './skill-install-handler' - -const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 -const SOCK_NAME = 'relay.sock' -const CONNECT_TIMEOUT_MS = 5_000 -const STALE_SOCKET_PROBE_TIMEOUT_MS = 500 -const EMPTY_DETACHED_STARTUP_GRACE_MS = parseNonNegativeIntEnv( - 'ORCA_RELAY_EMPTY_STARTUP_GRACE_MS', - 60_000 -) -// Why: a relay holding zero PTYs preserves nothing, so an unlimited grace only accumulates idle daemons. -// The env override is test-only — the remote relay is launched over a non-interactive SSH exec channel that carries no client env. -const IDLE_RELAY_GRACE_MS = parseNonNegativeIntEnv('ORCA_RELAY_IDLE_GRACE_MS', 15 * 60_000) - -type SocketIdentity = { - dev: bigint - ino: bigint - ctimeNs: bigint -} - -function sameSocketIdentity(a: SocketIdentity, b: SocketIdentity): boolean { - return a.dev === b.dev && a.ino === b.ino && a.ctimeNs === b.ctimeNs -} - -function parseNonNegativeIntEnv(name: string, fallback: number): number { - const raw = process.env[name] - if (raw === undefined) { - return fallback - } - const parsed = Number.parseInt(raw, 10) - return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback -} - -function readSocketIdentity(sockPath: string): SocketIdentity | null { - if (isWindowsNamedPipePath(sockPath)) { - return null - } - try { - const stat = statSync(sockPath, { bigint: true }) - return { dev: stat.dev, ino: stat.ino, ctimeNs: stat.ctimeNs } - } catch { - return null - } -} - -function isWindowsNamedPipePath(sockPath: string): boolean { - return process.platform === 'win32' && /^\\\\[.?]\\pipe\\/i.test(sockPath) -} - -function parseArgs(argv: string[]): { - graceTimeMs: number - connectMode: boolean - detached: boolean - cliMode: boolean - sockPath: string - endpointDir?: string - logFile?: string - credentialFile?: string -} { - let graceTimeMs = DEFAULT_GRACE_MS - let connectMode = false - let detached = false - let cliMode = false - let sockPath = '' - let endpointDir: string | undefined - let logFile: string | undefined - let credentialFile: string | undefined - for (let i = 2; i < argv.length; i++) { - if (argv[i] === '--grace-time' && argv[i + 1]) { - const parsed = Number.parseInt(argv[i + 1], 10) - // Why: flag is seconds (internally ms); 0 keeps the relay alive until explicitly terminated for synced workspaces. - if (!Number.isNaN(parsed) && parsed >= 0) { - graceTimeMs = parsed * 1000 - } - i++ - } else if (argv[i] === '--connect') { - connectMode = true - } else if (argv[i] === '--orca-cli') { - cliMode = true - } else if (argv[i] === '--detached') { - detached = true - } else if (argv[i] === '--sock-path' && argv[i + 1]) { - sockPath = argv[i + 1] - i++ - } else if (argv[i] === '--endpoint-dir' && argv[i + 1]) { - endpointDir = argv[i + 1] - i++ - } else if (argv[i] === '--log-file' && argv[i + 1]) { - logFile = argv[i + 1] - i++ - } else if (argv[i] === '--credential-file' && argv[i + 1]) { - credentialFile = argv[i + 1] - i++ - } - } - if (!sockPath) { - sockPath = join(process.cwd(), SOCK_NAME) - } - return { - graceTimeMs, - connectMode, - detached, - cliMode, - sockPath, - endpointDir, - logFile, - credentialFile - } -} - -function readEndpointCredential(credentialFile: string | undefined): string | undefined { - if (!credentialFile) { - return undefined - } - const credential = readFileSync(credentialFile, 'utf8').trim() - if (!/^[A-Za-z0-9_-]{32,256}$/.test(credential)) { - throw new Error('Relay endpoint credential is missing or invalid') - } - if (process.platform !== 'win32') { - chmodSync(credentialFile, 0o600) - } - return credential -} - -// ── Connect mode ───────────────────────────────────────────────────── -// Why: --connect bridges a new SSH channel's stdin/stdout to the existing relay's socket so the client keeps talking to the process that owns the live PTYs. - -function runConnectMode(sockPath: string, endpointCredential?: string): void { - const myVersion = readLaunchVersion() - const sock = createConnection({ path: sockPath }) - const stdoutWriter = new DispatcherClientWriter( - (data, onSettled) => - process.stdout.write(data, (error) => { - onSettled(error ? { ok: false, error } : { ok: true }) - }), - { - supportsWriteCallback: true, - writableLength: () => process.stdout.writableLength, - writableHighWaterMark: () => process.stdout.writableHighWaterMark, - waitWriteDrain: (callback) => { - process.stdout.once('drain', callback) - return () => process.stdout.off('drain', callback) - } - }, - () => { - sock.destroy() - process.exit(1) - } - ) - - const connectTimeout = setTimeout(() => { - process.stderr.write(`[relay-connect] Connection timed out after ${CONNECT_TIMEOUT_MS}ms\n`) - sock.destroy() - process.exit(1) - }, CONNECT_TIMEOUT_MS) - - sock.on('connect', () => { - clearTimeout(connectTimeout) - runConnectHandshake( - sock, - myVersion, - { - onAccepted: (leftover: Buffer) => { - stdoutWriter.enqueue('control', () => Buffer.from(RELAY_SENTINEL), RELAY_SENTINEL.length) - if (leftover.length > 0) { - stdoutWriter.enqueue('control', () => leftover, leftover.length) - } - process.stdin.pipe(sock) - sock.on('data', (data: Buffer) => { - sock.pause() - let offset = 0 - const writeNext = (): void => { - if (offset >= data.length) { - sock.resume() - return - } - const bytes = Math.min(stdoutWriter.producerFrameCapacity, data.length - offset) - if (bytes <= 0) { - stdoutWriter.close(new Error('Relay stdout has no producer capacity')) - return - } - const chunk = data.subarray(offset, offset + bytes) - if ( - !stdoutWriter.enqueue( - 'ordinary', - () => chunk, - chunk.length, - (result) => { - if (!result.ok) { - return - } - offset += bytes - writeNext() - } - ) - ) { - stdoutWriter.close(new Error('Relay stdout bridge capacity exceeded')) - } - } - writeNext() - }) - } - }, - endpointCredential - ) - }) - - // Why: Node swallows EPIPE on stdout, so the bridge would zombie and drop frames; exit on stdout error so the relay enters grace promptly. - process.stdout.on('error', () => { - stdoutWriter.close(new Error('Relay stdout closed')) - }) - - sock.on('error', (err) => { - clearTimeout(connectTimeout) - process.stderr.write(`[relay-connect] Socket error: ${err.message}\n`) - process.exit(1) - }) - - sock.on('close', async () => { - await stdoutWriter.waitForIdle() - process.exit(0) - }) -} - -async function runOrcaCliMode( - sockPath: string, - argv: string[], - endpointCredential?: string -): Promise { - const myVersion = readLaunchVersion() - let preparedArtifact: Awaited> - try { - preparedArtifact = await prepareRemoteArtifactCliInput(argv, process.cwd()) - } catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) - process.exitCode = 1 - return - } - const stdin = - preparedArtifact.stdin ?? - (shouldReadRemoteCliStdin(argv) ? await readOrcaCliStdin() : undefined) - const env = pickRemoteCliEnv(process.env) - const requestParams: RemoteArtifactCliForwardingParams = { - argv, - cwd: process.cwd(), - env, - ...(stdin !== undefined ? { stdin } : {}), - ...(preparedArtifact.artifactInput ? { artifactInput: preparedArtifact.artifactInput } : {}) - } - if (preparedArtifact.artifactInput) { - try { - assertRemoteArtifactCliForwardingFits(requestParams) - } catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) - process.exitCode = 1 - return - } - } - const sock = createConnection({ path: sockPath }) - const stdoutWriter = new DispatcherClientWriter( - (data, onSettled) => - process.stdout.write(data, (error) => { - onSettled(error ? { ok: false, error } : { ok: true }) - }), - { - supportsWriteCallback: true, - writableLength: () => process.stdout.writableLength, - writableHighWaterMark: () => process.stdout.writableHighWaterMark, - waitWriteDrain: (callback) => { - process.stdout.once('drain', callback) - return () => process.stdout.off('drain', callback) - } - }, - () => process.exit(1) - ) - let nextSeq = 1 - let highestReceivedSeq = 0 - const requestId = 1 - const postOutputRequestId = 2 - let initialExitCode = 0 - - const sendRequest = (): void => { - const frame = encodeJsonRpcFrame( - { - jsonrpc: '2.0', - id: requestId, - method: 'orca.cli', - params: requestParams - }, - nextSeq++, - highestReceivedSeq - ) - sock.write(frame) - } - - const finish = (exitCode: number): void => { - sock.destroy() - process.exit(exitCode) - } - - const sendPostOutput = (postOutput: unknown): void => { - sock.write( - encodeJsonRpcFrame( - { - jsonrpc: '2.0', - id: postOutputRequestId, - method: 'orca.cli.postOutput', - params: { postOutput, env: pickRemoteCliEnv(process.env) } - }, - nextSeq++, - highestReceivedSeq - ) - ) - } - - const writeOutput = ( - result: { stdout?: unknown; stderr?: unknown }, - onFlushed: (error?: Error) => void - ): void => { - let pending = 0 - let completed = false - const settle = (error?: Error): void => { - if (completed) { - return - } - if (error) { - completed = true - onFlushed(error) - return - } - pending -= 1 - if (pending === 0) { - completed = true - onFlushed() - } - } - if (typeof result.stdout === 'string' && result.stdout.length > 0) { - pending += 1 - const output = Buffer.from(result.stdout) - stdoutWriter.enqueue( - 'control', - () => output, - output.length, - (settlement) => settle(settlement.ok ? undefined : settlement.error) - ) - } - if (typeof result.stderr === 'string' && result.stderr.length > 0) { - pending += 1 - process.stderr.write(result.stderr, 'utf8', (error) => settle(error ?? undefined)) - } - if (pending === 0) { - completed = true - onFlushed() - } - } - - const decoder = new FrameDecoder((frame: DecodedFrame) => { - if (frame.id > highestReceivedSeq) { - highestReceivedSeq = frame.id - } - if (frame.type !== MessageType.Regular) { - return - } - const msg = parseJsonRpcMessage(frame.payload) - if ( - !('id' in msg) || - (msg.id !== requestId && msg.id !== postOutputRequestId) || - !('result' in msg || 'error' in msg) - ) { - return - } - const response = msg as JsonRpcResponse - if (response.error) { - process.stderr.write(`${response.error.message}\n`) - finish(1) - return - } - if (response.id === postOutputRequestId) { - finish(initialExitCode) - return - } - const result = (response.result ?? {}) as { - stdout?: unknown - stderr?: unknown - exitCode?: unknown - postOutput?: unknown - } - initialExitCode = typeof result.exitCode === 'number' ? result.exitCode : 0 - writeOutput(result, (error) => { - if (error) { - finish(1) - return - } - if (result.postOutput === undefined) { - finish(initialExitCode) - return - } - sendPostOutput(result.postOutput) - }) - }) - - const connectTimeout = setTimeout(() => { - process.stderr.write(`[orca-cli] Relay connection timed out after ${CONNECT_TIMEOUT_MS}ms\n`) - sock.destroy() - process.exit(1) - }, CONNECT_TIMEOUT_MS) - - sock.on('connect', () => { - clearTimeout(connectTimeout) - runConnectHandshake( - sock, - myVersion, - { - onAccepted: (leftover) => { - if (leftover.length > 0) { - decoder.feed(leftover) - } - sock.on('data', (chunk) => - decoder.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) - ) - sendRequest() - } - }, - endpointCredential - ) - }) - - sock.on('error', (err) => { - clearTimeout(connectTimeout) - process.stderr.write(`[orca-cli] Relay socket error: ${err.message}\n`) - process.exit(1) - }) -} - -async function readOrcaCliStdin(): Promise { - if (process.stdin.isTTY) { - return undefined - } - const chunks: Buffer[] = [] - for await (const chunk of process.stdin) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) - } - return Buffer.concat(chunks).toString('utf8') -} - -// ── Normal mode ────────────────────────────────────────────────────── async function main(): Promise { - const { - graceTimeMs, - connectMode, - detached, - cliMode, - sockPath, - endpointDir, - logFile, - credentialFile - } = parseArgs(process.argv) - const endpointCredential = readEndpointCredential(credentialFile) - - if (connectMode) { - runConnectMode(sockPath, endpointCredential) + const options = parseRelayLaunchOptions(process.argv) + const endpointCredential = readRelayEndpointCredential(options.credentialFile) + if (options.connectMode) { + runRelayConnectChannel(options.sockPath, endpointCredential) return } - if (cliMode) { + if (options.cliMode) { const marker = process.argv.indexOf('--orca-cli') - await runOrcaCliMode( - sockPath, - marker !== -1 ? process.argv.slice(marker + 1) : [], + await runRelayOrcaCliChannel( + options.sockPath, + marker === -1 ? [] : process.argv.slice(marker + 1), endpointCredential ) return } - - // Why: only the long-lived detached daemon accumulates relay.log; route it through a size-capped rotator so it can't grow forever. - if (detached && logFile) { - installRelayLogRotation(logFile) - } - - let ownsSocketPath = false - let ownedSocketIdentity: SocketIdentity | null = null - const ownsCurrentSocketPath = (): boolean => { - if (isWindowsNamedPipePath(sockPath)) { - return ownsSocketPath - } - const currentIdentity = readSocketIdentity(sockPath) - return ( - ownsSocketPath && - ownedSocketIdentity !== null && - currentIdentity !== null && - sameSocketIdentity(currentIdentity, ownedSocketIdentity) - ) - } - const cleanupOwnedSocket = (): void => { - if (ownsCurrentSocketPath()) { - cleanupSocket(sockPath) - } - ownsSocketPath = false - ownedSocketIdentity = null - } - - // Why: after an uncaught exception Node's state may be corrupted; log and exit rather than risk data corruption or zombie PTYs. - process.on('uncaughtException', (err) => { - relayLogLine(`[relay] Uncaught exception: ${err.message}\n${err.stack}`) - cleanupOwnedSocket() - process.exit(1) - }) - - process.on('unhandledRejection', (reason) => { - relayLogLine(`[relay] Unhandled rejection: ${String(reason)}`) - }) - - // Why: guards writes after the stdin/SSH channel drops so keepalive/pty.data frames don't hit a dead pipe (EPIPE). - let stdoutAlive = true - // Why: one-shot waiters parked when stdout saturates (write() === false); flushed on 'drain' and every stdout-death path. - const stdoutDrainWaiters = new Set<() => void>() - const flushStdoutDrainWaiters = (): void => { - for (const cb of Array.from(stdoutDrainWaiters)) { - stdoutDrainWaiters.delete(cb) - cb() - } - } - process.stdout.on('drain', flushStdoutDrainWaiters) - const dispatcher = new RelayDispatcher( - (data, onSettled) => { - if (!stdoutAlive) { - onSettled({ ok: false, error: new Error('Relay stdout is closed') }) - return false - } - try { - return process.stdout.write(data, (error) => { - onSettled(error ? { ok: false, error } : { ok: true }) - }) - } catch (error) { - stdoutAlive = false - flushStdoutDrainWaiters() - onSettled({ - ok: false, - error: error instanceof Error ? error : new Error(String(error)) - }) - return false - } - }, - { - supportsWriteCallback: true, - writableLength: () => process.stdout.writableLength, - writableHighWaterMark: () => process.stdout.writableHighWaterMark, - waitWriteDrain: (cb) => { - if (!stdoutAlive) { - cb() - return - } - stdoutDrainWaiters.add(cb) - return () => stdoutDrainWaiters.delete(cb) - }, - close: () => { - stdoutAlive = false - flushStdoutDrainWaiters() - // Why close then re-pin: the SSH peer must see EOF, but a long-lived daemon that - // frees fds 0/1 lets accept()/open() recycle them while Node still treats - // process.stdin/stdout as those numbers — corrupting socket clients and shutdown. - for (const fd of [process.stdin.fd, process.stdout.fd]) { - try { - closeSync(fd) - } catch { - // Already closed by the peer. - } - } - const devNull = process.platform === 'win32' ? 'NUL' : '/dev/null' - try { - openSync(devNull, 'r') - } catch { - /* best-effort pin of the lowest free fd (normally 0) */ - } - try { - openSync(devNull, 'w') - } catch { - /* best-effort pin of the next free fd (normally 1) */ - } - } - }, - undefined, - { pauseReads: () => process.stdin.pause(), resumeReads: () => process.stdin.resume() } - ) - const launchVersion = readLaunchVersion() - - const context = new RelayContext() - - // Why: registerRoot is a no-op now (allowlist removed, docs/relay-fs-allowlist-removal.md); both handlers kept for version-skew compat until the version floor moves. - dispatcher.onNotification('session.registerRoot', (params) => { - const rootPath = params.rootPath as string - if (rootPath) { - context.registerRoot(rootPath) - } - }) - - dispatcher.onRequest('session.registerRoot', async (params) => { - const rootPath = params.rootPath as string - if (rootPath) { - context.registerRoot(rootPath) - } - return { ok: true } - }) - - // Why: `~` is a shell expansion Node's fs APIs don't understand; resolve it to an absolute path on the remote host before persisting. - dispatcher.onRequest('session.resolveHome', async (params) => { - const inputPath = params.path as string - // Use the shared expander so Windows `~\…` paths resolve too — a remote - // relay host can be Windows, where a literal `~\` would otherwise fall - // through unexpanded and break every downstream fs op. - return { resolvedPath: expandTilde(inputPath) } - }) - - const ptyHandler = new PtyHandler(dispatcher, graceTimeMs) - const ptyConsumerSessionAdapter = new SshPtyConsumerSessionAdapter( - dispatcher, - launchVersion, - (id, paused) => ptyHandler.setConsumerDeliveryPaused(id, paused), - (id) => ptyHandler.handleSourceCreditAvailable(id) - ) - const ptySourcePublication = new RelayPtySourcePublication( - dispatcher, - ptyConsumerSessionAdapter, - (id) => ptyHandler.handleSourcePublicationCapacity(id) - ) - ptyHandler.setSourcePublication(ptySourcePublication) - const fsHandler = new FsHandler(dispatcher, context) - const watchRegistry = fsHandler.getWatchRegistry() - ptyHandler.setWorktreeRemovalCoordinator(watchRegistry) - watchRegistry.setWorktreePtyTeardown((rootPath) => ptyHandler.shutdownForWorktreePath(rootPath)) - const gitHandler = new GitHandler(dispatcher, context, watchRegistry) - - const _preflightHandler = new PreflightHandler(dispatcher) - const _skillInstallHandler = new SkillInstallHandler(dispatcher) - const _externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher) - void _preflightHandler - void _skillInstallHandler - void _externalAutomationsHandler - - const _portScanHandler = new PortScanHandler(dispatcher) - void _portScanHandler - - const _agentExecHandler = new AgentExecHandler(dispatcher) - void _agentExecHandler - - const _workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) - void _workspaceSessionHandler - - const aiVaultRelayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) - const aiVaultHostPlatform = aiVaultRelayPlatform - ? getRemoteHostPlatform(aiVaultRelayPlatform) - : undefined - const aiVaultService = aiVaultHostPlatform - ? createRelayAiVaultService(homedir(), aiVaultHostPlatform) - : null - const _aiVaultHandler = new AiVaultHandler(dispatcher, { - hostPlatform: aiVaultHostPlatform, - service: aiVaultService ?? undefined - }) - void _aiVaultHandler - - // Why: relay-hosted plugin provisioning is a later phase. Register the - // enforcement boundary now with no consented identities or runtime services. - registerRelayPluginHostCallHandlers( - dispatcher, - () => null, - () => ({ grantedCapabilities: null, services: null }) - ) - - dispatcher.onRequest('orca.cli', async (params, context) => { - return await dispatcher.requestAnyClient('orca.cli', params, { - excludeClientId: context.clientId, - timeoutMs: remoteCliRequestTimeoutMs(params) - }) - }) - dispatcher.onRequest('orca.cli.postOutput', async (params, context) => { - return await dispatcher.requestAnyClient('orca.cli.postOutput', params, { - excludeClientId: context.clientId, - timeoutMs: remoteCliRequestTimeoutMs(params) - }) - }) - - function configureRelayGraceTime(params: Record): { graceTimeMs: number } { - return applyRelayGraceTimeConfiguration(params.graceTimeSeconds, { - readConfiguredGraceMs: () => ptyHandler.configuredGraceTimeMs, - writeConfiguredGraceMs: (graceMs) => ptyHandler.setGraceTimeMs(graceMs), - isGraceTimerArmed: () => graceDeadlineAt !== null && graceReason !== null, - isShutdownInFlight: () => shutdownInFlight, - readGraceBranch: () => graceBranch, - startGrace - }) - } - - dispatcher.onNotification(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, (params) => { - configureRelayGraceTime(params) - }) - dispatcher.onRequest(SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD, async (params) => - configureRelayGraceTime(params) - ) - - // ── Agent-hook server ───────────────────────────────────────────── - // Why: loopback HTTP receiver so remote-PTY agent CLIs post hook events to a local port (they can't reach Orca's host); the relay forwards them as agent.hook notifications. - const hookServer = new RelayAgentHookServer({ - // Why: scope endpoint.env/cmd by socket path so multiple relay daemons on one account can't overwrite each other's hook tokens. - endpointDir: endpointDir ?? endpointDirForRelaySocket(sockPath), - // Why: publication is fire-and-forget and drops during reconnect; the per-paneKey cache lets us replay last status after --connect. - forward: (envelope) => publishAgentHookEnvelope(dispatcher, envelope) - }) - // Why: await the bind before announcing readiness so the first PTY spawn already sees ORCA_AGENT_HOOK_* env; bind failure is soft (log and continue). - try { - await hookServer.start({ publishEndpoint: false }) - } catch (err) { - relayLogLine( - `[relay] agent-hook server failed to start: ${err instanceof Error ? err.message : String(err)}` - ) - } - - // Why: read the augmenter on every spawn so a late (or restarted) hook-server bind still lands in the next PTY's ORCA_AGENT_HOOK_* env. - ptyHandler.addEnvAugmenter(() => hookServer.buildPtyEnv()) - - // Why: plugin paths resolve on the relay host — OpenCode gets a relay-local overlay; Pi/OMP get extensions in their real remote dirs. - const pluginOverlay = new PluginOverlayManager() - ptyHandler.addEnvAugmenter((ctx) => { - const env: Record = {} - // Why: prefer paneKey for overlay identity so a renderer remount reusing it lands in the same dir; fall back to pty-id when absent. - const overlayId = ctx.paneKey ?? ctx.id - if (pluginOverlay.hasOpenCodeSource()) { - const sourceDir = resolveOpenCodeSourceConfigDir(ctx.env, ctx.shell) - const dir = pluginOverlay.materializeOpenCode(overlayId, sourceDir) - if (dir) { - env.OPENCODE_CONFIG_DIR = dir - env.ORCA_OPENCODE_CONFIG_DIR = dir - if (sourceDir) { - env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = sourceDir - } - } - } - if (pluginOverlay.hasPiSource()) { - // Why: install Orca's guarded extension into the launched agent's (Pi vs OMP) real remote dir without redirecting PI_CODING_AGENT_DIR. - const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command) - const explicitKind = isPiCompatibleAgentType(ctx.launchAgent) - ? ctx.launchAgent - : ctx.launchAgent === undefined - ? detectExplicitPiAgentKindFromCommand(launchCommandHint) - : null - const kind = explicitKind ?? 'pi' - const hasLaunchCommand = - typeof launchCommandHint === 'string' && launchCommandHint.trim().length > 0 - const shouldPrepareOmpShadow = kind === 'omp' || !hasLaunchCommand - if (kind === 'pi') { - const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell, 'pi') - // Why: do not mkdir ~/. on bare shells when the agent home is - // missing — unused agents kept recreating deleted homes (#10196). - const result = pluginOverlay.materializePi(overlayId, sourceDir, 'pi', { - materializeDefaultHome: explicitKind === 'pi' - }) - if (result?.sourceAgentDir) { - env.ORCA_PI_SOURCE_AGENT_DIR = result.sourceAgentDir - } - } - if (shouldPrepareOmpShadow) { - // Why: prepare OMP's status extension for a bare shell so a typed `omp` gets integration, without making OMP the shell's home. - const sourceDir = - kind === 'omp' - ? resolvePiSourceAgentDir(ctx.env, ctx.shell, 'omp') - : ctx.env.ORCA_OMP_SOURCE_AGENT_DIR - const result = pluginOverlay.materializePi(overlayId, sourceDir, 'omp', { - materializeDefaultHome: explicitKind === 'omp' - }) - // Why: status-only fallback (no sourceAgentDir) is intentional for bare - // shells without ~/.omp — still export ORCA_OMP_STATUS_EXTENSION (#10196). - if (result?.statusExtensionPath) { - env.ORCA_OMP_STATUS_EXTENSION = result.statusExtensionPath - } - if (result?.sourceAgentDir) { - env.ORCA_OMP_SOURCE_AGENT_DIR = result.sourceAgentDir - } - } - if (kind === 'prime-agent') { - const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell, 'prime-agent') - const result = pluginOverlay.materializePi(overlayId, sourceDir, 'prime-agent', { - materializeDefaultHome: explicitKind === 'prime-agent' - }) - if (result?.sourceAgentDir) { - env.ORCA_PRIME_AGENT_SOURCE_AGENT_DIR = result.sourceAgentDir - } - } - } - return env - }) - - // Why: evict pane status cache + overlay dirs on PTY exit, else the next reconnect replays a dead pane's last status (ghost row) and overlay dirs leak. - ptyHandler.setExitListener(({ paneKey, id }) => { - if (paneKey) { - hookServer.clearPaneState(paneKey) - } - pluginOverlay.clearOverlay(paneKey ?? id) - }) - - // Why: forward cached entries as notifications before returning, so the response trails every replay and Orca can't treat replay as done while frames are still in flight. - dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => { - const replayed = hookServer.replayCachedPayloadsForPanes() - return { replayed } - }) - - // Why: relay-local installers collapse hundreds of SFTP request/response RTTs to one RPC. - registerManagedHookInstaller(dispatcher) - - // Why: plugin sources ship over the wire — the relay is versioned independently of Orca, so bundling them would make every agent-event change a relay redeploy. - // Why: bound per-source size so a buggy/hostile Orca can't OOM the relay by pushing a giant string. - dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { - const opencode = params.opencodePluginSource - const pi = params.piExtensionSource - const omp = params.ompExtensionSource - const primeAgent = params.primeAgentExtensionSource - assertPluginSourceUnderByteCap('opencodePluginSource', opencode) - assertPluginSourceUnderByteCap('piExtensionSource', pi) - assertPluginSourceUnderByteCap('ompExtensionSource', omp) - assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) - pluginOverlay.setSources({ - opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, - piExtensionSource: typeof pi === 'string' ? pi : undefined, - ompExtensionSource: typeof omp === 'string' ? omp : undefined, - primeAgentExtensionSource: typeof primeAgent === 'string' ? primeAgent : undefined - }) - return { - installed: { - opencode: pluginOverlay.hasOpenCodeSource(), - pi: pluginOverlay.hasPiSource('pi'), - omp: pluginOverlay.hasPiSource('omp'), - primeAgent: pluginOverlay.hasPiSource('prime-agent') - } - } - }) - - // ── Socket server for reconnection ────────────────────────────────── - // Why: the SSH channel dies on app restart; a Unix socket lets a new --connect bridge reach the dispatcher that owns live PTYs. - - const socketClients = new Map() - let socketServer: Server | null = null - const startedAt = Date.now() - let acceptedSocketConnections = 0 - let hasAcceptedSocketClient = false - let graceDeadlineAt: number | null = null - let graceReason: string | null = null - // Why: only the idle branch is a "nothing left to preserve" bet, so only it may be revoked when a - // PTY appears mid-window; the other branches keep their armed deadline. - let graceBranch: RelayGraceBranch | null = null - - dispatcher.onRequest('relay.status', async () => ({ - capabilities: SKILL_RELAY_CAPABILITIES, - pid: process.pid, - uptimeMs: Date.now() - startedAt, - detached, - stdoutAlive, - memory: process.memoryUsage(), - ptys: { - active: ptyHandler.activePtyCount - }, - ptySourceCredit: { - enabled: true, - session: ptyConsumerSessionAdapter.getDebugSnapshot(), - publication: ptySourcePublication.getDebugSnapshot() - }, - socket: { - path: sockPath, - owned: ownsSocketPath, - listening: socketServer?.listening ?? false, - clients: socketClients.size, - acceptedConnections: acceptedSocketConnections - }, - grace: { - active: ptyHandler.graceTimerActive, - deadlineAt: graceDeadlineAt, - reason: graceReason - } - })) - - function cancelGrace(reason: string): void { - if (ptyHandler.graceTimerActive) { - relayLogLine(`[relay] Grace canceled: ${reason}`) - } - graceDeadlineAt = null - graceReason = null - graceBranch = null - ptyHandler.cancelGraceTimer() - } - - function attachAcceptedSocket(sock: Socket, leftover: Buffer): void { - // Why: remove the initial stdin data listener once a socket client is accepted, so stale SSH-channel bytes can't interleave. - process.stdin.pause() - process.stdin.removeAllListeners('data') - - hasAcceptedSocketClient = true - acceptedSocketConnections++ - relayLogLine( - `[relay] Socket client accepted (clients=${socketClients.size + 1}, accepted=${acceptedSocketConnections})` - ) - cancelGrace('socket client accepted') - - // Why: same backpressure surface as stdout — bulk frames wait for socket drain so they can't bury interactive PTY frames. - const sockDrainWaiters = new Set<() => void>() - const flushSockDrainWaiters = (): void => { - for (const cb of Array.from(sockDrainWaiters)) { - sockDrainWaiters.delete(cb) - cb() - } - } - sock.on('drain', flushSockDrainWaiters) - sock.on('close', flushSockDrainWaiters) - sock.on('error', flushSockDrainWaiters) - const clientId = dispatcher.attachClient( - (data, onSettled) => { - if (!sock.destroyed) { - return sock.write(data, (error) => { - onSettled(error ? { ok: false, error } : { ok: true }) - }) - } - onSettled({ ok: false, error: new Error('Relay socket is closed') }) - return false - }, - { - supportsWriteCallback: true, - writableLength: () => sock.writableLength, - writableHighWaterMark: () => sock.writableHighWaterMark, - close: () => sock.destroy(), - waitWriteDrain: (cb) => { - if (sock.destroyed) { - cb() - return - } - sockDrainWaiters.add(cb) - return () => sockDrainWaiters.delete(cb) - } - }, - { - principal: `relay-endpoint:${launchVersion}`, - authenticated: endpointCredential !== undefined, - allowSessionOwner: endpointCredential !== undefined, - authenticationKind: endpointCredential ? 'endpoint-credential' : 'unproved' - }, - { - pauseReads: () => sock.pause(), - resumeReads: () => sock.resume() - } - ) - socketClients.set(sock, clientId) - - // Why: feed handshake-buffered leftover bytes before wiring sock.on('data') so frame ordering is preserved. - if (leftover.length > 0) { - dispatcher.feedClient(clientId, leftover) - } - - sock.on('data', (chunk: Buffer) => { - cancelGrace('socket client data') - dispatcher.feedClient(clientId, chunk) - }) - } - - async function startSocketServer(): Promise { - const server = createServer((sock) => { - // Why: pre-dispatcher version handshake — see relay-handshake.ts. - setupDaemonHandshake(sock, { - launchVersion, - endpointCredential, - onAccepted: attachAcceptedSocket - }) - - // Why: destroy on 'end' (FIN from --connect's dying channel) so the 'close' handler fires promptly and the daemon enters grace. - sock.on('end', () => { - if (!sock.destroyed) { - sock.destroy() - } - }) - - sock.on('error', () => { - // Why: Node emits 'error' then 'close'; the close handler owns cleanup and grace startup. - }) - - sock.on('close', () => { - const clientId = socketClients.get(sock) - socketClients.delete(sock) - if (clientId !== undefined) { - // Why 'peer-closed' only here: the socket itself ended, which is the one signal that - // actually says the client is gone rather than merely slow. - dispatcher.detachClient(clientId, 'peer-closed') - } - relayLogLine(`[relay] Socket client closed (clients=${socketClients.size})`) - if (!stdoutAlive && socketClients.size === 0) { - startGrace('socket client closed') - } - }) - }) - - // Why: umask 0o177 before listen makes the socket 0o600 atomically, closing the chmod-after-listen TOCTOU window. - const shouldSetSocketUmask = !isWindowsNamedPipePath(sockPath) - const prevUmask = shouldSetSocketUmask ? process.umask(0o177) : 0 - let umaskRestored = false - const restoreUmask = (): void => { - if (shouldSetSocketUmask && !umaskRestored) { - process.umask(prevUmask) - umaskRestored = true - } - } - - await new Promise((resolve, reject) => { - let staleRetryAttempted = false - - function removeStartupListeners(): void { - server.off('listening', onListening) - server.off('error', onInitialError) - server.off('error', failInitial) - } - - function listenForStartupError(onError: (err: NodeJS.ErrnoException) => void): void { - server.once('listening', onListening) - server.once('error', onError) - server.listen(sockPath) - } - - function onListening(): void { - removeStartupListeners() - restoreUmask() - ownsSocketPath = true - ownedSocketIdentity = readSocketIdentity(sockPath) - server.on('error', (err) => { - relayLogLine(`[relay] Socket server error: ${err.message}`) - }) - relayLogLine(`[relay] Socket server listening: ${sockPath}`) - resolve() - } - - function failInitial(err: NodeJS.ErrnoException): void { - removeStartupListeners() - restoreUmask() - if (err.code === 'EADDRINUSE') { - relayLogLine( - `[relay] Socket path already in use: ${sockPath}; another relay is likely active. Use --connect instead of starting a new daemon.` - ) - } else { - relayLogLine(`[relay] Socket server error before listen: ${err.message}`) - } - reject(err) - } - - function unlinkIfStillStale(blockedIdentity: SocketIdentity | null): boolean { - const currentIdentity = readSocketIdentity(sockPath) - if (currentIdentity === null) { - return true - } - if (blockedIdentity === null || !sameSocketIdentity(currentIdentity, blockedIdentity)) { - return false - } - try { - unlinkSync(sockPath) - return true - } catch (unlinkErr) { - const e = unlinkErr as NodeJS.ErrnoException - return e.code === 'ENOENT' - } - } - - // Why: EADDRINUSE may be a stale socket from a crashed relay, not a live one; probe-connect to tell them apart before unlinking. - function onInitialError(err: NodeJS.ErrnoException): void { - if (err.code !== 'EADDRINUSE' || staleRetryAttempted) { - failInitial(err) - return - } - if (isWindowsNamedPipePath(sockPath)) { - failInitial(err) - return - } - staleRetryAttempted = true - const blockedIdentity = readSocketIdentity(sockPath) - const probe = createConnection({ path: sockPath }) - let probeSettled = false - let probeTimeout: NodeJS.Timeout | null = null - const finishProbe = (callback: () => void): void => { - if (probeSettled) { - return - } - probeSettled = true - if (probeTimeout) { - clearTimeout(probeTimeout) - } - callback() - } - probe.once('connect', () => { - finishProbe(() => { - probe.destroy() - failInitial(err) - }) - }) - probe.once('error', (probeErr: NodeJS.ErrnoException) => { - finishProbe(() => { - if (probeErr.code !== 'ECONNREFUSED' && probeErr.code !== 'ENOENT') { - failInitial(err) - return - } - if (!unlinkIfStillStale(blockedIdentity)) { - failInitial(err) - return - } - relayLogLine(`[relay] Removed stale socket at ${sockPath} and retrying listen`) - removeStartupListeners() - listenForStartupError(failInitial) - }) - }) - probeTimeout = setTimeout(() => { - finishProbe(() => { - probe.destroy() - failInitial(err) - }) - }, STALE_SOCKET_PROBE_TIMEOUT_MS) - } - - listenForStartupError(onInitialError) - }) - - return server - } - - try { - socketServer = await startSocketServer() - // Why: publish endpoint.env only after socket ownership is proven, so a refused duplicate daemon can't poison hook coordinates. - hookServer.publishEndpointFile() - } catch { - process.exit(1) - } - - // ── stdin/stdout transport (initial connection) ───────────────────── - - // Why: without this handler an EPIPE/ERR_STREAM_DESTROYED on stdout becomes an uncaught exception, exiting before grace starts. - process.stdout.on('error', () => { - stdoutAlive = false - flushStdoutDrainWaiters() - dispatcher.invalidateClient('peer-closed') - }) - - function startGrace(reason: string, options?: { retryDeferredShutdown?: boolean }): void { - // Why: the live configured value, not the launch-time argv closure — the host can raise the grace - // after launch via relay.configureGraceTime, and a zero-only gate reading a stale zero would be - // zero-at-launch-only, i.e. correct only by coincidence. - const decision = decideRelayGrace({ - configuredGraceMs: ptyHandler.configuredGraceTimeMs, - relayIdle: isRelayIdle(), - detached, - hasAcceptedSocketClient, - activePtyCount: ptyHandler.activePtyCount, - retryDeferredShutdown: options?.retryDeferredShutdown === true, - emptyDetachedStartupGraceMs: EMPTY_DETACHED_STARTUP_GRACE_MS, - idleRelayGraceMs: IDLE_RELAY_GRACE_MS - }) - graceBranch = decision.branch - const timeoutMs = decision.timeoutMs - graceDeadlineAt = timeoutMs === 0 ? null : Date.now() + timeoutMs - graceReason = reason - relayLogLine( - `[relay] Grace started (${reason}): timeoutMs=${timeoutMs}, branch=${graceBranch}, ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}` - ) - ptyHandler.startGraceTimer(() => { - // Why: last line of defense for the idle cap — a PTY that appeared without announcing itself - // must not be killed by a timer armed while the relay was still empty (#6955). - if (graceBranch === 'idle-no-ptys' && !isRelayIdle()) { - relayLogLine(`[relay] Grace expired (${reason}) but relay is no longer idle; re-evaluating`) - startGrace(reason) - return - } - relayLogLine(`[relay] Grace expired (${reason}); shutting down`) - shutdown() - }, timeoutMs) - } - - // Why: a creation admitted but not yet pooled already owns a shell, so it counts as non-idle. - function isRelayIdle(): boolean { - return ptyHandler.activePtyCount === 0 && ptyHandler.pendingPtyCreationCount === 0 - } - - if (detached) { - // Why: detached stdin is /dev/null, so listening would EOF → grace → shutdown before --connect arrives; use the socket instead. - startGrace('detached startup') - } else { - process.stdin.on('data', (chunk: Buffer) => { - cancelGrace('stdin data') - dispatcher.feed(chunk) - }) - - process.stdin.on('end', () => { - // Why: stdin close means the SSH channel is gone; mark stdout dead so its write callback no-ops instead of hitting a dead pipe. - stdoutAlive = false - flushStdoutDrainWaiters() - dispatcher.invalidateClient('peer-closed') - if (socketClients.size === 0) { - startGrace('stdin ended') - } - }) - - process.stdin.on('error', () => { - stdoutAlive = false - flushStdoutDrainWaiters() - dispatcher.invalidateClient('peer-closed') - if (socketClients.size === 0) { - startGrace('stdin error') - } - }) - } - - let shutdownInFlight = false - function shutdown(): void { - if (shutdownInFlight) { - return - } - shutdownInFlight = true - relayLogLine( - `[relay] Shutdown: ptys=${ptyHandler.activePtyCount}, clients=${socketClients.size}, ownsSocket=${ownsSocketPath}` - ) - graceDeadlineAt = null - graceReason = null - graceBranch = null - void ptyHandler - .dispose() - .then(async () => { - await _skillInstallHandler.dispose().catch((error) => { - relayLogLine( - `[relay] Skill upload cleanup failed: ${error instanceof Error ? error.message : String(error)}` - ) - }) - await aiVaultService?.dispose().catch((error) => { - relayLogLine( - `[relay] AI Vault sidecar shutdown failed: ${error instanceof Error ? error.message : String(error)}` - ) - }) - stopPoolWatch() - stopPoolActiveWatch() - dispatcher.dispose() - fsHandler.dispose() - gitHandler.dispose() - hookServer.stop() - // Why: server.close() unlinks the listen path; skip if a newer relay rebound it, else we strand that newer daemon. - if (socketServer && ownsCurrentSocketPath()) { - socketServer.close() - } - cleanupOwnedSocket() - process.exit(0) - }) - .catch((error) => { - // Why: keep owning a PTY whose native kill was rejected so a transient signal failure doesn't orphan a remote shell. - // Why: the pool watches stay registered — the socket server is still listening, so a client can - // reconnect and cancel this grace, and that revived relay still needs both re-evaluations. - shutdownInFlight = false - relayLogLine( - `[relay] Shutdown deferred: ${error instanceof Error ? error.message : String(error)}` - ) - // Why: shutdown() already cleared graceReason, so without re-arming a client-less relay - // whose kill was refused would stay resident forever with nothing left to retry it. - if (socketClients.size === 0) { - startGrace('shutdown deferred', { retryDeferredShutdown: true }) - } - }) - } - - // Why: with the shipped unlimited default the grace timer is never armed, so an expiry can't - // notice the pool emptying; re-evaluate on the last exit instead. graceReason (not - // graceTimerActive) is the only signal that a grace window is open in that configuration. - // Why: a null deadline is exactly that unlimited case; re-arming a scheduled one would restart - // an explicitly configured window and double it. - const stopPoolWatch = ptyHandler.onPtyPoolEmpty(() => { - if (graceReason !== null && graceDeadlineAt === null && !shutdownInFlight) { - startGrace('last pty exited') - } - }) - - // Why: the idle cap is armed on an empty pool, but creation is asynchronous — a spawn or revive - // admitted after that decision (the client dropped mid-`pty.revive`, say) makes the relay non-idle - // again, and re-evaluating disarms the timer instead of letting it kill a live PTY (#6955). - // The grace window itself stays open, so the PTY's own exit still re-arms the cap. - const stopPoolActiveWatch = ptyHandler.onPtyPoolActive(() => { - if (graceBranch === 'idle-no-ptys' && graceReason !== null && !shutdownInFlight) { - startGrace(graceReason) - } - }) - - process.on('SIGTERM', shutdown) - process.on('SIGINT', shutdown) - // Why: default SIGHUP exits immediately, killing PTYs before grace; ignore it so the relay survives SSH disconnect. - process.on('SIGHUP', () => { - relayLogLine('[relay] Received SIGHUP (SSH session dropped), ignoring') - }) - process.on('exit', (code) => { - relayLogLine(`[relay] Process exiting with code ${code}`) - }) - - dispatcher.writePrimaryBytes(Buffer.from(RELAY_SENTINEL)) - if (detached) { - stdoutAlive = false - dispatcher.invalidateClient() - } + await runRelayDaemon(options, endpointCredential) } -function cleanupSocket(sockPath: string): void { - if (isWindowsNamedPipePath(sockPath)) { - return - } - try { - if (existsSync(sockPath)) { - unlinkSync(sockPath) - } - } catch { - /* best-effort */ - } -} - -void main().catch((err) => { +void main().catch((error) => { relayLogLine( - `[relay] Fatal startup error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}` + `[relay] Fatal startup error: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}` ) process.exit(1) }) diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index 57b3466707d..18d837bb4ef 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -14,7 +14,7 @@ import { tmpdir } from 'node:os' import { execFileSync, spawn as spawnChild } from 'node:child_process' import { build } from 'esbuild' import { spawnRelay, type RelayProcess } from './subprocess-test-utils' -import { getEndpointFileName } from '../shared/agent-hook-listener' +import { getEndpointFileName } from '../shared/agent-hook-listener/endpoint-publication' import { relayTestSocketPath } from './relay-test-socket-path' const RELAY_TS_ENTRY = path.resolve(__dirname, 'relay.ts') diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index aed0685dbb7..5047feac381 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import { translate } from '@/i18n/i18n' import { useAppStore } from '../store' -import { isRemoteWorkspaceSnapshotApplyInProgress } from '../hooks/useIpcEvents' +import { isDirectSshRemoteWorkspaceApplyInProgress } from '../hooks/remote-workspace-snapshot-apply' import { createSessionWriteSubscriber } from '../lib/session-write-subscriber' import { buildActiveViewUnloadPatch } from '../lib/active-view-persist' import { @@ -76,7 +76,7 @@ export function useAppSessionPersistence(): void { useEffect(() => { return createSessionWriteSubscriber({ store: useAppStore, - shouldSchedulePersist: () => !isRemoteWorkspaceSnapshotApplyInProgress(), + shouldSchedulePersist: () => !isDirectSshRemoteWorkspaceApplyInProgress(), persist: ({ patch }) => { const state = useAppStore.getState() // Why: route each host's worktree-scoped slice to its own partition; return the local write so the remote-workspace upload chain below keeps its ordering. diff --git a/src/renderer/src/app-shell/use-document-appearance.test.tsx b/src/renderer/src/app-shell/use-document-appearance.test.tsx new file mode 100644 index 00000000000..b06b9c7517c --- /dev/null +++ b/src/renderer/src/app-shell/use-document-appearance.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../shared/constants' +import { useAppStore } from '../store' + +const mocks = vi.hoisted(() => ({ + applyDocumentTheme: vi.fn(), + buildAppFontFamily: vi.fn((fontFamily: string | null | undefined) => fontFamily ?? '') +})) + +vi.mock('../lib/document-theme', () => ({ + applyDocumentTheme: mocks.applyDocumentTheme +})) + +vi.mock('@/lib/app-font-family', () => ({ + buildAppFontFamily: mocks.buildAppFontFamily +})) + +vi.mock('../runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync: vi.fn() +})) + +import { useDocumentAppearance } from './use-document-appearance' + +const initialState = useAppStore.getState() + +describe('useDocumentAppearance', () => { + beforeEach(() => { + mocks.applyDocumentTheme.mockReset() + mocks.buildAppFontFamily.mockClear() + useAppStore.setState({ + settings: { + ...getDefaultSettings('/tmp'), + theme: 'dark' + } + }) + }) + + afterEach(() => { + useAppStore.setState(initialState, true) + }) + + it('ignores unrelated settings object replacements', () => { + const { unmount } = renderHook(() => useDocumentAppearance()) + + expect(mocks.applyDocumentTheme).toHaveBeenCalledTimes(1) + expect(mocks.buildAppFontFamily).toHaveBeenCalledTimes(1) + + act(() => { + const settings = useAppStore.getState().settings! + useAppStore.setState({ + settings: { ...settings, editorAutoSave: !settings.editorAutoSave } + }) + }) + + expect(mocks.applyDocumentTheme).toHaveBeenCalledTimes(1) + expect(mocks.buildAppFontFamily).toHaveBeenCalledTimes(1) + unmount() + }) + + it('still applies changed theme and font values', () => { + const { unmount } = renderHook(() => useDocumentAppearance()) + + act(() => { + const settings = useAppStore.getState().settings! + useAppStore.setState({ + settings: { ...settings, theme: 'light', appFontFamily: 'Monaco' } + }) + }) + + expect(mocks.applyDocumentTheme).toHaveBeenLastCalledWith('light') + expect(mocks.applyDocumentTheme).toHaveBeenCalledTimes(2) + expect(mocks.buildAppFontFamily).toHaveBeenLastCalledWith('Monaco') + expect(mocks.buildAppFontFamily).toHaveBeenCalledTimes(2) + unmount() + }) +}) diff --git a/src/renderer/src/app-shell/use-document-appearance.ts b/src/renderer/src/app-shell/use-document-appearance.ts index 7f0c3f8af72..3d4c8e23daf 100644 --- a/src/renderer/src/app-shell/use-document-appearance.ts +++ b/src/renderer/src/app-shell/use-document-appearance.ts @@ -6,17 +6,18 @@ import { useAppStore } from '../store' /** Applies the settings-driven theme and app font to the document root. */ export function useDocumentAppearance(): void { - const settings = useAppStore((s) => s.settings) + const theme = useAppStore((s) => s.settings?.theme) + const appFontFamily = useAppStore((s) => s.settings?.appFontFamily) useEffect(() => { - if (!settings) { + if (!theme) { return } - if (settings.theme === 'dark') { + if (theme === 'dark') { applyDocumentTheme('dark') return undefined - } else if (settings.theme === 'light') { + } else if (theme === 'light') { applyDocumentTheme('light') return undefined } @@ -30,12 +31,12 @@ export function useDocumentAppearance(): void { } mq.addEventListener('change', handler) return () => mq.removeEventListener('change', handler) - }, [settings]) + }, [theme]) useEffect(() => { document.documentElement.style.setProperty( '--app-font-family', - buildAppFontFamily(settings?.appFontFamily) + buildAppFontFamily(appFontFamily) ) - }, [settings?.appFontFamily]) + }, [appFontFamily]) } diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index ed032d2bbf9..6304b1cfd68 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -42,6 +42,15 @@ describe('AgentStateDot', () => { expect(markup).toContain('motion-reduce:border-t-yellow-500') }) + it('renders monitoring as a static yellow radio glyph', () => { + const markup = renderMarkup('monitoring') + + expect(markup).toContain('aria-label="Monitoring background tasks"') + expect(markup).toContain('lucide-radio') + expect(markup).toContain('text-yellow-500') + expect(markup).not.toContain('data-agent-spinner') + }) + it('renders done as an emerald check icon', () => { const markup = renderMarkup('done') diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 0c182480d98..4f361d01f87 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { CircleCheck } from 'lucide-react' +import { CircleCheck, Radio } from 'lucide-react' import { cn } from '@/lib/utils' import { AgentQuestionIcon } from '@/components/AgentQuestionIcon' import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' @@ -17,6 +17,7 @@ import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' export type AgentDotState = | 'working' + | 'monitoring' | 'blocked' | 'waiting' | 'interrupted' @@ -37,6 +38,8 @@ export function agentStateLabel(state: AgentDotState): string { switch (state) { case 'working': return 'Working' + case 'monitoring': + return 'Monitoring background tasks' case 'blocked': return 'Blocked' case 'waiting': @@ -81,6 +84,17 @@ export const AgentStateDot = React.memo(function AgentStateDot({ ) } + if (state === 'monitoring') { + return ( + + + ) + } + if (state === 'done') { // Why: the dashboard lists many agents, so a check glyph scans well for // agent-reported completion and keeps 'done' visually distinct from diff --git a/src/renderer/src/components/LinearIssueWorkspace.tsx b/src/renderer/src/components/LinearIssueWorkspace.tsx index c211a13581b..e5808aa5459 100644 --- a/src/renderer/src/components/LinearIssueWorkspace.tsx +++ b/src/renderer/src/components/LinearIssueWorkspace.tsx @@ -1,79 +1,22 @@ -/* eslint-disable max-lines -- Why: the Linear issue page co-locates the - full-page layout with its hydration/comment state so the selected issue - surface stays coherent with the existing Linear drawer behavior. */ -/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: Linear issue hydration and comments are loaded from provider IPC for the selected issue while preserving edit guards. */ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - ArrowRight, - Check, - ChevronDown, - ChevronLeft, - ChevronRight, - Clipboard, - Copy, - ExternalLink, - FolderKanban, - FolderOpen, - GitBranch, - LoaderCircle, - Plus, - RefreshCw, - X -} from 'lucide-react' -import { toast } from 'sonner' +import React, { useCallback, useMemo } from 'react' import { VisuallyHidden } from 'radix-ui' -import { LinearIcon } from '@/components/icons/LinearIcon' -import CommentMarkdown from '@/components/sidebar/CommentMarkdown' -import { - initLinearIssueEditState, - LinearIssueCommentFooter, - LinearIssueEditSection, - type LinearEditState, - type LinearLocalComment -} from '@/components/LinearItemDrawer' -import { Button } from '@/components/ui/button' -import { ButtonGroup } from '@/components/ui/button-group' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' import { LinearIssueTextEditor } from '@/components/LinearIssueTextEditor' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { createBrowserUuid } from '@/lib/browser-uuid' -import { buildLinearIssueContextSnapshot } from '@/lib/linear-issue-context-snapshot' import { findLinearIssueWorkspaceAttachment } from '@/lib/linear-issue-workspace-attachment' import { openLinearIssueWorkspaceOrStart } from '@/lib/linear-issue-workspace-open' import { getWorktreeAttachmentLabel } from '@/lib/worktree-attachment-label' import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree' -import { buildContainedLinkedContextBlock } from '@/lib/linked-work-item-context' -import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' -import { - buildLinearIssueBranchName, - formatLinearIssueRelativeTime -} from '@/components/linear-issue-workspace-text' -import { getLinearProjectSearchRequestQuery } from '@/components/linear-project-search-query' -import { - linearCreateSubIssue, - linearGetIssue, - linearIssueComments, - linearListProjects, - linearUpdateIssue -} from '@/runtime/runtime-linear-client' -import type { - LinearComment, - LinearIssue, - LinearIssueChildSummary -} from '../../../shared/linear/issue-types' -import type { LinearProjectSummary } from '../../../shared/linear/project-types' +import type { LinearIssue } from '../../../shared/linear/issue-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' +import { LinearIssueActivity } from './linear-issue-activity' +import { LinearIssueSubIssues } from './linear-issue-sub-issues' +import { useLinearIssueWorkspaceDetail } from './linear-issue-workspace-detail-state' +import { LinearIssueWorkspaceHeader } from './linear-issue-workspace-header' +import { LinearIssueWorkspaceSidebar } from './linear-issue-workspace-sidebar' type LinearIssueWorkspaceProps = { issue: LinearIssue | null @@ -85,433 +28,134 @@ type LinearIssueWorkspaceProps = { sourceContext?: TaskSourceContext | null } -async function copyTextToClipboard(text: string, label: string): Promise { - try { - await window.api.ui.writeClipboardText(text) - toast.success( - translate('auto.components.LinearIssueWorkspace.7835483c43', '{{value0}} copied', { - value0: label - }) - ) - } catch { - toast.error( - translate('auto.components.LinearIssueWorkspace.9bcbaa2737', 'Failed to copy {{value0}}', { - value0: label.toLowerCase() - }) - ) - } +type LinearIssueWorkspaceIssueProps = Omit< + LinearIssueWorkspaceProps, + 'issue' | 'variant' | 'backLabel' +> & { + issue: LinearIssue + variant: 'sheet' | 'page' + backLabel: string + requestKey: string } -function LinearIssueAvatar({ - avatarUrl, - name, - className = 'size-6' -}: { - avatarUrl?: string - name?: string - className?: string -}): React.JSX.Element { - if (avatarUrl) { - return {name - } - - const initial = name?.trim().charAt(0).toUpperCase() || '?' - return ( - - ) -} - -function LinearIssueSubIssueButton({ +function LinearIssueWorkspaceIssue({ issue, + onUse, onOpenIssue, - sourceContext -}: { - issue: LinearIssue - onOpenIssue: (issue: LinearIssue) => void - sourceContext?: TaskSourceContext | null -}): React.JSX.Element { - const settings = useAppStore((s) => s.settings) + onClose, + variant, + backLabel, + sourceContext, + requestKey +}: LinearIssueWorkspaceIssueProps): React.JSX.Element { + const settings = useAppStore((state) => state.settings) const providerSettings = sourceContext ?? settings - const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue) - const [open, setOpen] = useState(false) - const [title, setTitle] = useState('') - const [optimisticSubIssues, setOptimisticSubIssues] = useState<{ - issueId: string - subIssues: LinearIssueChildSummary[] - }>(() => ({ - issueId: issue.id, - subIssues: [] - })) - const [submitting, setSubmitting] = useState(false) - const [openingSubIssueId, setOpeningSubIssueId] = useState(null) - const mountedRef = useMountedRef() - - const subIssues = useMemo(() => { - const baseSubIssues = issue.subIssues ?? [] - if (optimisticSubIssues.issueId !== issue.id || optimisticSubIssues.subIssues.length === 0) { - return baseSubIssues - } - const baseIds = new Set(baseSubIssues.map((subIssue) => subIssue.id)) - const additions = optimisticSubIssues.subIssues.filter((subIssue) => !baseIds.has(subIssue.id)) - return additions.length === 0 ? baseSubIssues : [...baseSubIssues, ...additions] - }, [issue.id, issue.subIssues, optimisticSubIssues]) - - const handleOpenSubIssue = useCallback( - async (subIssue: LinearIssueChildSummary) => { - setOpeningSubIssueId(subIssue.id) - try { - const fullIssue = await fetchLinearIssue(subIssue.id, issue.workspaceId, { - sourceContext - }) - if (!mountedRef.current) { - return - } - if (fullIssue) { - onOpenIssue(fullIssue) - } else { - toast.error( - translate('auto.components.LinearIssueWorkspace.9a1317cdd3', 'Failed to load sub-issue') - ) - } - } catch (error) { - if (mountedRef.current) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.LinearIssueWorkspace.9a1317cdd3', - 'Failed to load sub-issue' - ) - ) - } - } finally { - if (mountedRef.current) { - setOpeningSubIssueId(null) - } - } - }, - [fetchLinearIssue, issue.workspaceId, mountedRef, onOpenIssue, sourceContext] + const allWorktrees = useAllWorktrees() + const folderWorkspaces = useAppStore((state) => state.folderWorkspaces) + const attachmentWorkspaces = useMemo( + () => [...allWorktrees, ...folderWorkspaces.map(folderWorkspaceToWorktree)], + [allWorktrees, folderWorkspaces] ) + const detail = useLinearIssueWorkspaceDetail({ issue, providerSettings, requestKey }) + const attachedWorkspace = useMemo( + () => findLinearIssueWorkspaceAttachment(attachmentWorkspaces, detail.displayed), + [attachmentWorkspaces, detail.displayed] + ) + const attachedWorkspaceLabel = attachedWorkspace + ? getWorktreeAttachmentLabel(attachedWorkspace) + : null - const handleCreate = useCallback(async () => { - const trimmed = title.trim() - if (!trimmed) { - return - } - setSubmitting(true) - try { - const result = await linearCreateSubIssue(providerSettings, { - parentIssueId: issue.id, - teamId: issue.team.id, - title: trimmed, - workspaceId: issue.workspaceId, - projectId: issue.project?.id ?? null - }) - if (result.ok) { - const child = { - id: result.id, - identifier: result.identifier, - title: result.title || trimmed, - url: result.url - } - setOptimisticSubIssues((current) => { - const currentSubIssues = current.issueId === issue.id ? current.subIssues : [] - if ( - currentSubIssues.some((subIssue) => subIssue.id === child.id) || - issue.subIssues?.some((subIssue) => subIssue.id === child.id) - ) { - return current - } - return { issueId: issue.id, subIssues: [...currentSubIssues, child] } - }) - toast.success( - translate('auto.components.LinearIssueWorkspace.aeed19d003', 'Created {{value0}}', { - value0: result.identifier - }) - ) - setTitle('') - setOpen(false) - } else { - toast.error(result.error) - } - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.LinearIssueWorkspace.b25e453c9d', - 'Failed to create sub-issue' - ) - ) - } finally { - setSubmitting(false) - } - }, [ - issue.id, - issue.project?.id, - issue.subIssues, - issue.team.id, - issue.workspaceId, - providerSettings, - title - ]) + const handleUseIssue = useCallback((): void => { + onUse(detail.displayed) + }, [detail.displayed, onUse]) - return ( -
- {subIssues.length > 0 ? ( -
- {subIssues.map((subIssue) => ( - - ))} + const handleOpenOrUseIssue = useCallback((): void => { + openLinearIssueWorkspaceOrStart(detail.displayed, () => onUse(detail.displayed)) + }, [detail.displayed, onUse]) + + const content = ( +
+ +
+
+
+ + + +
+
- ) : null} - - - - - - -
- setTitle(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - void handleCreate() - } - }} - placeholder={translate( - 'auto.components.LinearIssueWorkspace.c182e02de5', - 'Sub-issue title' - )} - className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring" - /> -
- -
-
-
-
-
+ + ) -} -function LinearIssueSidebarProjectCard({ - issue, - onProjectChanged, - sourceContext -}: { - issue: LinearIssue - onProjectChanged: (project: LinearProjectSummary) => void - sourceContext?: TaskSourceContext | null -}): React.JSX.Element { - const settings = useAppStore((s) => s.settings) - const providerSettings = sourceContext ?? settings - const patchLinearIssue = useAppStore((s) => s.patchLinearIssue) - const [open, setOpen] = useState(false) - const [query, setQuery] = useState('') - const [projects, setProjects] = useState([]) - const [loading, setLoading] = useState(false) - const [savingProjectId, setSavingProjectId] = useState(null) - - useEffect(() => { - if (!open) { - return - } - const requestQuery = getLinearProjectSearchRequestQuery(query) - if (requestQuery === null) { - // Oversized pasted queries stay visible but must not reach provider IPC/RPC. - setProjects([]) - setLoading(false) - return - } - let cancelled = false - const timeout = window.setTimeout(() => { - setLoading(true) - void linearListProjects(providerSettings, requestQuery, 20, issue.workspaceId) - .then((result) => { - if (!cancelled) { - setProjects(result.items) - } - }) - .catch((error) => { - if (!cancelled) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.LinearIssueWorkspace.38b80780c2', - 'Failed to load projects' - ) - ) - } - }) - .finally(() => { - if (!cancelled) { - setLoading(false) - } - }) - }, 150) - return () => { - cancelled = true - window.clearTimeout(timeout) - } - }, [issue.workspaceId, open, providerSettings, query]) - - const handleSelectProject = useCallback( - async (project: LinearProjectSummary) => { - setSavingProjectId(project.id) - try { - const result = await linearUpdateIssue( - providerSettings, - issue.id, - { projectId: project.id }, - issue.workspaceId - ) - if (result.ok) { - onProjectChanged(project) - patchLinearIssue(issue.id, { project }, { sourceContext }) - toast.success( - translate('auto.components.LinearIssueWorkspace.f9d4ef9807', 'Project updated') - ) - setOpen(false) - } else { - toast.error(result.error) - } - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : translate( - 'auto.components.LinearIssueWorkspace.8b5b593053', - 'Failed to update project' - ) - ) - } finally { - setSavingProjectId(null) - } - }, - [ - issue.id, - issue.workspaceId, - onProjectChanged, - patchLinearIssue, - providerSettings, - sourceContext - ] - ) + if (variant === 'page') { + return ( +
+ {content} +
+ ) + } return ( -
-
- {translate('auto.components.LinearIssueWorkspace.b51276c8d6', 'Project')} - -
- - - - - -
- setQuery(event.target.value)} - placeholder={translate( - 'auto.components.LinearIssueWorkspace.db3f269d98', - 'Search projects' - )} - className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring" - /> -
- {loading ? ( -
- - {translate('auto.components.LinearIssueWorkspace.937ba6ad9a', 'Loading projects')} -
- ) : projects.length > 0 ? ( - projects.map((project) => ( - - )) - ) : ( -
- {query.trim() - ? translate( - 'auto.components.LinearIssueWorkspace.c11b4e3cc2', - 'No projects found.' - ) - : translate( - 'auto.components.LinearIssueWorkspace.76ffd3c937', - 'Search for a project to add.' - )} -
- )} -
-
-
-
-
+ !open && onClose()}> + event.preventDefault()} + > + + + {detail.displayed.title ?? + translate('auto.components.LinearIssueWorkspace.61f424f8ca', 'Linear issue')} + + + + + {translate( + 'auto.components.LinearIssueWorkspace.ad5dec37b7', + 'Preview, edit, and start work from the selected issue.' + )} + + + {content} + + ) } @@ -524,605 +168,27 @@ export default function LinearIssueWorkspace({ backLabel = 'Back', sourceContext }: LinearIssueWorkspaceProps): React.JSX.Element { - const settings = useAppStore((s) => s.settings) - const providerSettings = sourceContext ?? settings - const allWorktrees = useAllWorktrees() - const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) - const attachmentWorkspaces = useMemo( - () => [...allWorktrees, ...folderWorkspaces.map(folderWorkspaceToWorktree)], - [allWorktrees, folderWorkspaces] - ) - const [fullIssue, setFullIssue] = useState(null) - const [issueLoading, setIssueLoading] = useState(false) - const [comments, setComments] = useState([]) - const [commentsLoading, setCommentsLoading] = useState(false) - const [commentsError, setCommentsError] = useState(null) - const [editState, setEditState] = useState(null) - const requestIdRef = useRef(0) - const hydratedIssueKeyRef = useRef(null) - const hasEditedRef = useRef(false) - const optimisticCommentsRef = useRef([]) - const mountedRef = useMountedRef() - - const handleEditStateChange = useCallback((patch: Partial) => { - hasEditedRef.current = true - setFullIssue((prev) => (prev ? { ...prev, ...patch } : prev)) - setEditState((prev) => (prev ? { ...prev, ...patch } : prev)) - }, []) - - const handleIssueTextChange = useCallback( - (patch: Partial>) => { - hasEditedRef.current = true - setFullIssue((prev) => (prev ? { ...prev, ...patch } : prev)) - }, - [] - ) - - const loadComments = useCallback( - async (targetIssue: LinearIssue, requestId: number): Promise => { - if (mountedRef.current) { - setCommentsLoading(true) - setCommentsError(null) - } - try { - let fetched = (await linearIssueComments( - providerSettings, - targetIssue.id, - targetIssue.workspaceId - )) as LinearComment[] - if (!mountedRef.current || requestId !== requestIdRef.current) { - return - } - const optimistic = optimisticCommentsRef.current - if (optimistic.length > 0) { - const fetchedIds = new Set(fetched.map((comment) => comment.id)) - fetched = [...fetched, ...optimistic.filter((comment) => !fetchedIds.has(comment.id))] - } - setComments(fetched) - } catch (error) { - if (mountedRef.current && requestId === requestIdRef.current) { - setCommentsError(error instanceof Error ? error.message : 'Failed to load comments.') - } - } finally { - if (mountedRef.current && requestId === requestIdRef.current) { - setCommentsLoading(false) - } - } - }, - [mountedRef, providerSettings] - ) - - useEffect(() => { - if (!issue) { - hydratedIssueKeyRef.current = null - setFullIssue(null) - setIssueLoading(false) - setComments([]) - setCommentsError(null) - setEditState(null) - hasEditedRef.current = false - optimisticCommentsRef.current = [] - return + const settings = useAppStore((state) => state.settings) + if (!issue) { + if (variant === 'page') { + return ( +
+ ) } - - const issueKey = `${sourceContext?.hostId ?? settings?.activeRuntimeEnvironmentId ?? 'local'}:${issue.workspaceId ?? 'selected'}:${issue.id}` - if (hydratedIssueKeyRef.current === issueKey) { - return - } - hydratedIssueKeyRef.current = issueKey - - requestIdRef.current += 1 - const requestId = requestIdRef.current - hasEditedRef.current = false - optimisticCommentsRef.current = [] - setFullIssue(issue) - setEditState(initLinearIssueEditState(issue)) - setComments([]) - setCommentsError(null) - setIssueLoading(true) - - // Why: issue hydration and comments are separate surfaces; a comments - // failure should not blank the issue detail the user selected. - void linearGetIssue(providerSettings, issue.id, issue.workspaceId) - .then((issueResult) => { - if (!mountedRef.current || requestId !== requestIdRef.current) { - return - } - if (issueResult) { - const fetched = issueResult as LinearIssue - setFullIssue((prev) => { - if (!hasEditedRef.current || !prev) { - return fetched - } - // Why: late hydration can carry pre-edit field data; keep the - // optimistic fields while accepting fresh non-field detail data. - return { - ...fetched, - state: prev.state, - title: prev.title, - description: prev.description, - priority: prev.priority, - assignee: prev.assignee, - estimate: prev.estimate, - labelIds: prev.labelIds, - labels: prev.labels - } - }) - if (!hasEditedRef.current) { - setEditState(initLinearIssueEditState(fetched)) - } - } - }) - .catch(() => { - /* The list issue remains useful if detail hydration is temporarily unavailable. */ - }) - .finally(() => { - if (mountedRef.current && requestId === requestIdRef.current) { - setIssueLoading(false) - } - }) - - void loadComments(issue, requestId) - }, [issue, loadComments, mountedRef, providerSettings, settings, sourceContext?.hostId]) - - const displayed = fullIssue ?? issue - - const attachedWorkspace = useMemo( - () => (displayed ? findLinearIssueWorkspaceAttachment(attachmentWorkspaces, displayed) : null), - [attachmentWorkspaces, displayed] - ) - const attachedWorkspaceLabel = attachedWorkspace - ? getWorktreeAttachmentLabel(attachedWorkspace) - : null - - const handleUseIssue = useCallback((): void => { - if (!displayed) { - return - } - onUse(displayed) - }, [displayed, onUse]) - - const handleOpenOrUseIssue = useCallback((): void => { - if (!displayed) { - return - } - openLinearIssueWorkspaceOrStart(displayed, () => onUse(displayed)) - }, [displayed, onUse]) - - const handleCommentAdded = useCallback((comment: LinearLocalComment) => { - const newComment: LinearComment = { - id: comment.id || createBrowserUuid(), - body: comment.body, - createdAt: comment.createdAt, - user: { displayName: 'You' } - } - optimisticCommentsRef.current.push(newComment) - setComments((prev) => [...prev, newComment]) - }, []) - - const handleProjectChanged = useCallback((project: LinearProjectSummary) => { - setFullIssue((prev) => (prev ? { ...prev, project } : prev)) - }, []) - - const actionItems = useMemo(() => { - if (!displayed) { - return [] - } - return [ - { - label: translate('auto.components.LinearIssueWorkspace.9a9a884236', 'Copy URL'), - icon: Clipboard, - action: () => void copyTextToClipboard(displayed.url, 'URL') - }, - { - label: translate('auto.components.LinearIssueWorkspace.30c1242f3a', 'Copy identifier'), - icon: Clipboard, - action: () => void copyTextToClipboard(displayed.identifier, 'Identifier') - }, - { - label: translate( - 'auto.components.LinearIssueWorkspace.5d670ec8dc', - 'Copy suggested branch name' - ), - icon: GitBranch, - action: () => - void copyTextToClipboard(buildLinearIssueBranchName(displayed), 'Suggested branch name') - }, - { - label: translate('auto.components.LinearIssueWorkspace.f6c6381593', 'Copy prompt'), - icon: Clipboard, - action: () => { - const renderedText = buildLinearIssueContextSnapshot(displayed, comments) - const prompt = - buildContainedLinkedContextBlock({ - provider: 'linear', - version: 1, - renderedText - }) ?? renderedText - void copyTextToClipboard(prompt, 'Prompt') - } - } - ] - }, [comments, displayed]) - - const content = displayed ? ( -
-
-
- {variant === 'page' ? ( - - ) : null} - - - {displayed.workspaceName ?? - translate('auto.components.LinearIssueWorkspace.65239a714b', 'Linear')} - - - - {translate('auto.components.LinearIssueWorkspace.f63ef94ea8', 'Issues')} - - - {displayed.identifier} - {displayed.title} - {issueLoading ? : null} -
-
- 2 / 17 - - - - - - {translate('auto.components.LinearIssueWorkspace.9a9a884236', 'Copy URL')} - - - - - - - - {translate('auto.components.LinearIssueWorkspace.openOnLinear', 'Open on Linear')} - - - {attachedWorkspace ? ( - - - - - - - - - - - {translate( - 'auto.components.LinearIssueWorkspace.startNewWorkspace', - 'Start new workspace' - )} - - - - ) : ( - - )} - {variant === 'sheet' ? ( - - - - - - {translate('auto.components.LinearIssueWorkspace.df4c86ed12', 'Close')} - - - ) : null} -
-
- -
-
-
- - - - -
-
-

- {translate('auto.components.LinearIssueWorkspace.543970c87a', 'Activity')} -

-
- -
-
- -
- - - {displayed.assignee?.displayName ?? - translate('auto.components.LinearIssueWorkspace.8a33c85e9c', 'Someone')}{' '} - {translate( - 'auto.components.LinearIssueWorkspace.fabbd3f974', - 'updated the issue ·' - )}{' '} - {formatLinearIssueRelativeTime(displayed.updatedAt)} - -
- - {commentsError ? ( -
- {commentsError} - -
- ) : null} - - {commentsLoading && comments.length === 0 ? ( -
- -
- ) : comments.length > 0 ? ( -
- {comments.map((comment) => ( -
- -
-
- - {comment.user?.displayName ?? - translate( - 'auto.components.LinearIssueWorkspace.ca8778c124', - 'Unknown' - )} - - - {formatLinearIssueRelativeTime(comment.createdAt)} - -
-
- -
-
-
- ))} -
- ) : null} - - -
-
- - -
-
-
- ) : null - - if (variant === 'page') { - return ( -
- {content} -
- ) + return !open && onClose()} /> } - + const requestKey = `${sourceContext?.hostId ?? settings?.activeRuntimeEnvironmentId ?? 'local'}:${issue.workspaceId ?? 'selected'}:${issue.id}` return ( - !open && onClose()}> - { - event.preventDefault() - }} - > - - - {displayed?.title ?? - translate('auto.components.LinearIssueWorkspace.61f424f8ca', 'Linear issue')} - - - - - {translate( - 'auto.components.LinearIssueWorkspace.ad5dec37b7', - 'Preview, edit, and start work from the selected issue.' - )} - - - - {content} - - + ) } diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx index 92c7e889075..fe68dc4f53c 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx @@ -74,17 +74,28 @@ vi.mock('@/components/new-workspace/ProjectCombobox', () => ({ vi.mock('@/components/new-workspace/SetProjectLocationDialog', () => ({ SetProjectLocationDialog: ({ option, - projectName + projectName, + onClose, + onReady }: { option: { label: string } | null projectName: string + onClose: () => void + onReady: (setupId: string) => void }) => option ? (
+ > + + +
) : null })) @@ -235,4 +246,29 @@ describe('NewWorkspaceComposerCard set location', () => { expect(storeMocks.openModal).not.toHaveBeenCalled() expect(storeMocks.openSettingsPage).not.toHaveBeenCalled() }) + + it('closes the nested dialog before publishing the ready run target', () => { + const nestedOpenChanges: boolean[] = [] + const setupChanges: string[] = [] + container = renderCard({ + onNestedDialogOpenChange: (open) => nestedOpenChanges.push(open), + onProjectHostSetupChange: (setupId) => setupChanges.push(setupId) + }) + + act(() => { + container?.querySelector('div[data-run-target-combobox-root="true"]')?.click() + }) + const setLocation = [...document.body.querySelectorAll('button')].find( + (button) => button.textContent?.includes('Set project location') + ) + act(() => setLocation?.click()) + const complete = [...document.body.querySelectorAll('button')].find( + (button) => button.textContent === 'Complete location' + ) + act(() => complete?.click()) + + expect(nestedOpenChanges).toEqual([true, false]) + expect(setupChanges).toEqual(['setup-remote']) + expect(document.body.querySelector('[data-testid="set-project-location-dialog"]')).toBeNull() + }) }) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 2de214624e1..b0a711cb0eb 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -1,404 +1,100 @@ -/* eslint-disable max-lines -- Why: keep the full composer card markup together so the inline and modal variants share one UI surface. */ import React from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { - AlertTriangle, - Check, - ChevronDown, - CornerDownLeft, - FolderPlus, - LoaderCircle, - PlugZap, - Settings2 -} from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { SwitchIndicator } from '@/components/ui/switch' -import { SettingsSwitch } from '@/components/settings/SettingsFormControls' -import type RepoCombobox from '@/components/repo/RepoCombobox' -import AgentCombobox from '@/components/agent/AgentCombobox' -import { getAgentCatalog } from '@/lib/agent-catalog' -import { - DEFAULT_DISABLED_TUI_AGENTS, - filterEnabledTuiAgents -} from '../../../shared/tui-agent-selection' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' -import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' -import { - TEXT_CONTROL_PASTE_DIRECT_MAX_BYTES, - measureTextControlPasteByteLength, - pasteTextIntoTextControl, - shouldHandleTextControlPaste -} from '@/lib/text-control-paste' +import { getAgentCatalog } from '@/lib/agent-catalog' import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut' -import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import { resolveProjectCloneUrlPrefill } from '@/lib/project-clone-url-prefill' -import type { GitHubWorkItem } from '../../../shared/github/work-item-types' -import type { GitLabWorkItem } from '../../../shared/gitlab-types' -import type { JiraIssue } from '../../../shared/jira-types' -import type { LinearIssue } from '../../../shared/linear/issue-types' -import type { OrcaHooks, SetupAgentStartupPolicy } from '../../../shared/orca-yaml-hook-types' -import type { TuiAgent } from '../../../shared/tui-agent' -import type { SparsePreset } from '../../../shared/worktree/create-types' -import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPresetSelect' -import SmartWorkspaceNameField, { - type SmartWorkspaceNameSelection -} from '@/components/new-workspace/SmartWorkspaceNameField' -import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results' -import ProjectCombobox from '@/components/new-workspace/ProjectCombobox' -import RunTargetCombobox from '@/components/new-workspace/RunTargetCombobox' -import { SetProjectLocationDialog } from '@/components/new-workspace/SetProjectLocationDialog' +import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import { AddRemoteHostDialog, type AddRemoteHostMode } from '@/components/sidebar/AddRemoteHostDialog' -import type { SetupConfig } from '@/lib/new-workspace' -import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' -import type { - NeedsSetupProjectHostOption, - ProjectHostSetupOption -} from '@/lib/project-host-setup-options' -import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format' -import type { SshConnectionStatus } from '../../../shared/ssh-types' -import type { TaskSourceContext } from '../../../shared/task-source-context' -import type { RuntimeStatus } from '../../../shared/runtime-types' +import { SetProjectLocationDialog } from '@/components/new-workspace/SetProjectLocationDialog' import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' -import { translate } from '@/i18n/i18n' import { withUiConnectTimeout } from '@/ssh/ssh-connect-ui-timeout' import { isSshConnectInFlight, trackSshConnect } from '@/ssh/ssh-connect-in-flight' +import { translate } from '@/i18n/i18n' +import { + DEFAULT_DISABLED_TUI_AGENTS, + filterEnabledTuiAgents +} from '../../../shared/tui-agent-selection' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import type { TuiAgent } from '../../../shared/tui-agent' +import { NewWorkspaceComposerAdvancedSection } from './new-workspace/NewWorkspaceComposerAdvancedSection' +import { NewWorkspaceComposerAgentSection } from './new-workspace/NewWorkspaceComposerAgentSection' +import { NewWorkspaceComposerFooter } from './new-workspace/NewWorkspaceComposerFooter' +import { NewWorkspaceComposerNameSection } from './new-workspace/NewWorkspaceComposerNameSection' +import { NewWorkspaceComposerProjectSection } from './new-workspace/NewWorkspaceComposerProjectSection' +import { + EMPTY_EPHEMERAL_VM_RECIPES, + EMPTY_PROJECT_HOST_SETUP_OPTIONS, + EMPTY_PROJECT_OPTIONS, + type NeedsProjectHostOption, + type NewWorkspaceComposerCardProps +} from './new-workspace/new-workspace-composer-card-props' +import { getSshStatusLabel } from './new-workspace/new-workspace-composer-ssh-status' +import { useComposerFileDragOver } from './new-workspace/use-composer-file-drag-over' -type RepoOption = React.ComponentProps['repos'][number] -type EphemeralVmRecipeOption = NonNullable[number] -const EMPTY_PROJECT_OPTIONS: NewWorkspaceProjectOption[] = [] -const EMPTY_PROJECT_HOST_SETUP_OPTIONS: ProjectHostSetupOption[] = [] -const EMPTY_EPHEMERAL_VM_RECIPES: EphemeralVmRecipeOption[] = [] - -type NewWorkspaceComposerCardProps = { - contextualTourSource?: string - containerClassName?: string - composerRef?: React.RefObject - onComposerNodeChange?: (node: HTMLDivElement | null) => void - nameInputRef?: React.RefObject - quickAgent: TuiAgent | null - onQuickAgentChange: (agent: TuiAgent | null) => void - eligibleRepos: readonly RepoOption[] - repoId: string - projectOptions?: NewWorkspaceProjectOption[] - selectedProjectId?: string | null - selectedRepoIsGit: boolean - onRepoChange: (value: string) => void - onProjectChange: (value: string) => void - projectHostSetupOptions?: ProjectHostSetupOption[] - selectedProjectHostSetupId?: string | null - onProjectHostSetupChange?: (setupId: string) => void - ephemeralVmRecipes?: EphemeralVmRecipeOption[] - selectedEphemeralVmRecipeId?: string | null - onEphemeralVmRecipeChange?: (recipeId: string | null) => void - ephemeralVmRecipeError?: string | null - repoBackedSearchRepos?: readonly RepoOption[] - repoBackedSourcesDisabled?: boolean - allowSmartNameAddProject?: boolean - smartNameRepoSwitchTarget?: 'project' | 'task-source' - primaryActionLabel: string - projectLabel?: string - projectPlaceholder?: string - emptyProjectMessage?: string - showAddProjectButton?: boolean - name: string - onNameValueChange: (value: string) => void - branchNameOverride: string | undefined - onBranchNameOverrideChange: (value: string | undefined) => void - onSmartGitHubItemSelect: (item: GitHubWorkItem) => void - onSmartGitLabItemSelect: (item: GitLabWorkItem) => void - onSmartBranchSelect: (refName: string, localBranchName: string) => void - onSmartNameModeChange?: (mode: SmartNameMode) => void - onSmartLinearIssueSelect: (issue: LinearIssue) => void - onSmartJiraIssueSelect?: (issue: JiraIssue, sourceContext: TaskSourceContext) => void - onOpenJiraSettings?: () => void - smartNameSelection: SmartWorkspaceNameSelection | null - onClearSmartNameSelection: () => void - /** True when an existing local branch is selected and can be reused. */ - canReuseSelectedBranch: boolean - reuseSelectedBranch: boolean - onReuseSelectedBranchChange: (next: boolean) => void - /** Shows the footer "Create more" switch — worktree targets only. */ - showCreateMultiple?: boolean - createMultiple?: boolean - onCreateMultipleChange?: (next: boolean) => void - smartNameGitHubSourceContext?: TaskSourceContext | null - smartNameJiraSourceContext?: TaskSourceContext | null - /** Advisory shown under the name field when a fork PR can't accept maintainer pushes. */ - forkPushWarning: string | null - detectedAgentIds: Set | null - onOpenAgentSettings: () => void - advancedOpen: boolean - onToggleAdvanced: () => void - createDisabled: boolean - projectError: string | null - creating: boolean - onCreate: () => void - note: string - onNoteChange: (value: string) => void - setupConfig: SetupConfig | null - requiresExplicitSetupChoice: boolean - setupDecision: 'run' | 'skip' | null - onSetupDecisionChange: (value: 'run' | 'skip') => void - setupAgentStartupPolicy: SetupAgentStartupPolicy - onSetupAgentStartupPolicyChange: (value: SetupAgentStartupPolicy) => void - shouldWaitForSetupCheck: boolean - resolvedSetupDecision: 'run' | 'skip' | null - createError: WorkspaceCreateErrorDisplay | null - selectedRepoConnectionId: string | null - selectedRepoSshStatus: SshConnectionStatus | null - selectedRepoRequiresConnection: boolean - selectedRepoConnectInProgress: boolean - onConnectSelectedRepo: () => Promise - branchesEnabled?: boolean - setupControlsEnabled?: boolean - canUseSparseCheckout: boolean - sparsePresets: SparsePreset[] - sparseSelectedPresetId: string | null - onSparseSelectPreset: (preset: SparsePreset | null) => void - sparseControlsEnabled?: boolean - /** When set, "Add project" opens a host-provided flow instead of swapping the store's active modal. */ - onAddProjectOverride?: () => void - /** Fires as the nested Set-project-location dialog opens and closes, so the host can stand down its Escape/submit handling. */ - onNestedDialogOpenChange?: (open: boolean) => void -} - -const SSH_STATUS_LABELS: Partial> = { - get disconnected() { - return translate( - 'auto.components.NewWorkspaceComposerCard.sshNotConnected', - 'SSH not connected' - ) - }, - get connecting() { - return translate('auto.components.NewWorkspaceComposerCard.connectingSsh', 'Connecting SSH...') - }, - get 'auth-failed'() { - return translate( - 'auto.components.NewWorkspaceComposerCard.sshAuthenticationFailed', - 'SSH authentication failed' - ) - }, - get 'deploying-relay'() { - return translate( - 'auto.components.NewWorkspaceComposerCard.preparingSshConnection', - 'Preparing SSH connection...' - ) - }, - get connected() { - return translate('auto.components.NewWorkspaceComposerCard.connected', 'Connected') - }, - get reconnecting() { - return translate( - 'auto.components.NewWorkspaceComposerCard.reconnectingSsh', - 'Reconnecting SSH...' - ) - }, - get 'reconnection-failed'() { - return translate( - 'auto.components.NewWorkspaceComposerCard.sshReconnectionFailed', - 'SSH reconnection failed' - ) - }, - get error() { - return translate('auto.components.NewWorkspaceComposerCard.a239038146', 'SSH connection error') - } -} - -function getSshStatusLabel(status: SshConnectionStatus): string { - return SSH_STATUS_LABELS[status] ?? status -} - -function SetupCommandPreview({ setupConfig }: { setupConfig: SetupConfig }): React.JSX.Element { - // Why: just the script in a quiet monochrome card — the source label (orca.yaml / local) and - // the run-setup toggle live in the section header above, so the card carries no chrome of its - // own. Neutral foreground avoids the colored-terminal look. max-h keeps long scripts from - // growing the dialog past the viewport. - return ( -
-
-        {setupConfig.command}
-      
-
- ) -} - -function useComposerFileDragOver(): { - isFileDragOver: boolean - dragHandlers: { - onDragEnter: (event: React.DragEvent) => void - onDragLeave: (event: React.DragEvent) => void - } -} { - const [isFileDragOver, setIsFileDragOver] = React.useState(false) - const dragCounterRef = React.useRef(0) - - const reset = React.useCallback(() => { - dragCounterRef.current = 0 - setIsFileDragOver(false) - }, []) - - const onDragEnter = React.useCallback((event: React.DragEvent): void => { - // Why: "Files" is the DataTransfer type the OS adds for native drags; skip internal drags so they route to their own handlers. - if (!event.dataTransfer.types.includes('Files')) { - return - } - if (event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME)) { - return - } - dragCounterRef.current += 1 - setIsFileDragOver(true) - }, []) - - const onDragLeave = React.useCallback( - (event: React.DragEvent): void => { - if (!event.dataTransfer.types.includes('Files')) { - return - } - // Why: mirror the onDragEnter guard so internal drags don't decrement a counter enter skipped incrementing (else it goes negative). - if (event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME)) { - return - } - dragCounterRef.current -= 1 - if (dragCounterRef.current <= 0) { - reset() - } - }, - [reset] - ) - - // Why: preload stops native drop events before React's onDrop, so reset the drag highlight via a document capture listener. - React.useEffect(() => { - const handler = (): void => { - reset() - } - document.addEventListener('drop', handler, true) - document.addEventListener('dragend', handler, true) - return () => { - document.removeEventListener('drop', handler, true) - document.removeEventListener('dragend', handler, true) - } - }, [reset]) - - return { - isFileDragOver, - dragHandlers: { onDragEnter, onDragLeave } - } -} - -export default function NewWorkspaceComposerCard({ - contextualTourSource, - containerClassName, - composerRef, - onComposerNodeChange, - nameInputRef, - quickAgent, - onQuickAgentChange, - eligibleRepos, - repoId, - projectOptions = EMPTY_PROJECT_OPTIONS, - selectedProjectId = null, - selectedRepoIsGit, - onRepoChange, - onProjectChange, - projectHostSetupOptions = EMPTY_PROJECT_HOST_SETUP_OPTIONS, - selectedProjectHostSetupId = null, - onProjectHostSetupChange, - ephemeralVmRecipes = EMPTY_EPHEMERAL_VM_RECIPES, - selectedEphemeralVmRecipeId = null, - onEphemeralVmRecipeChange, - ephemeralVmRecipeError = null, - repoBackedSearchRepos, - repoBackedSourcesDisabled = false, - allowSmartNameAddProject = true, - smartNameRepoSwitchTarget = 'project', - primaryActionLabel, - projectLabel, - projectPlaceholder, - emptyProjectMessage, - showAddProjectButton = true, - name, - onNameValueChange, - branchNameOverride, - onBranchNameOverrideChange, - onSmartGitHubItemSelect, - onSmartGitLabItemSelect, - onSmartBranchSelect, - onSmartNameModeChange, - onSmartLinearIssueSelect, - onSmartJiraIssueSelect, - onOpenJiraSettings, - smartNameSelection, - onClearSmartNameSelection, - canReuseSelectedBranch, - reuseSelectedBranch, - onReuseSelectedBranchChange, - showCreateMultiple = false, - createMultiple = false, - onCreateMultipleChange, - smartNameGitHubSourceContext, - smartNameJiraSourceContext, - forkPushWarning, - detectedAgentIds, - onOpenAgentSettings, - advancedOpen, - onToggleAdvanced, - createDisabled, - projectError, - creating, - onCreate, - note, - onNoteChange, - setupConfig, - requiresExplicitSetupChoice, - setupDecision, - onSetupDecisionChange, - setupAgentStartupPolicy, - onSetupAgentStartupPolicyChange, - shouldWaitForSetupCheck, - resolvedSetupDecision, - createError, - selectedRepoConnectionId, - selectedRepoSshStatus, - selectedRepoRequiresConnection, - selectedRepoConnectInProgress, - onConnectSelectedRepo, - branchesEnabled = true, - setupControlsEnabled = true, - canUseSparseCheckout, - sparsePresets, - sparseSelectedPresetId, - onSparseSelectPreset, - sparseControlsEnabled = true, - onAddProjectOverride, - onNestedDialogOpenChange -}: NewWorkspaceComposerCardProps): React.JSX.Element { - // Why: subscribe (form uses translate() directly) so an open create dialog repaints when the UI language changes. +export default function NewWorkspaceComposerCard( + props: NewWorkspaceComposerCardProps +): React.JSX.Element { useTranslation() + const { + contextualTourSource, + containerClassName, + composerRef, + onComposerNodeChange, + nameInputRef, + eligibleRepos, + repoId, + selectedRepoIsGit, + onProjectHostSetupChange, + selectedRepoSshStatus, + setupConfig, + setupControlsEnabled = true, + selectedProjectId = null, + onAddProjectOverride, + onNestedDialogOpenChange + } = props + const projectOptions = props.projectOptions ?? EMPTY_PROJECT_OPTIONS + const projectHostSetupOptions = props.projectHostSetupOptions ?? EMPTY_PROJECT_HOST_SETUP_OPTIONS + const ephemeralVmRecipes = props.ephemeralVmRecipes ?? EMPTY_EPHEMERAL_VM_RECIPES const { isFileDragOver, dragHandlers } = useComposerFileDragOver() - const openModal = useAppStore((s) => s.openModal) - const activeModal = useAppStore((s) => s.activeModal) - const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) + const openModal = useAppStore((state) => state.openModal) + const activeModal = useAppStore((state) => state.activeModal) + const defaultTuiAgent = useAppStore((state) => state.settings?.defaultTuiAgent ?? null) const disabledTuiAgents = useAppStore( - (s) => s.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS + (state) => state.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS ) - const updateSettings = useAppStore((s) => s.updateSettings) + const updateSettings = useAppStore((state) => state.updateSettings) + const projects = useAppStore((state) => state.projects) + const repos = useAppStore((state) => state.repos) const nameInputFocusFrameRef = React.useRef(null) const branchNameInputId = React.useId() - const submitShortcutModifierLabel = getScreenSubmitModifierLabel() - const selectedRepoName = React.useMemo(() => { - const repo = eligibleRepos.find((candidate) => candidate.id === repoId) - return repo?.displayName ?? repo?.path ?? 'This project' - }, [eligibleRepos, repoId]) - const selectedProjectName = React.useMemo(() => { - const option = projectOptions.find((candidate) => candidate.id === selectedProjectId) - return option?.displayName ?? selectedRepoName - }, [projectOptions, selectedProjectId, selectedRepoName]) + const projectDescriptionId = React.useId() + const [addRemoteHostMode, setAddRemoteHostMode] = React.useState(null) + const [setLocationOption, setSetLocationOption] = React.useState( + null + ) + + const selectedRepo = eligibleRepos.find((candidate) => candidate.id === repoId) + const selectedRepoName = selectedRepo?.displayName ?? selectedRepo?.path ?? 'This project' + const selectedProjectName = + projectOptions.find((candidate) => candidate.id === selectedProjectId)?.displayName ?? + selectedRepoName + const defaultCloneUrl = resolveProjectCloneUrlPrefill(projects, repos, selectedProjectId) + const readyProjectHostSetupOptions = projectHostSetupOptions.filter( + (option) => option.kind === 'ready' + ) + const needsSetupProjectHostSetupOptions = projectHostSetupOptions.filter( + (option) => option.kind === 'needs-setup' + ) + const shouldShowRunTargetPicker = + readyProjectHostSetupOptions.length > 0 || + ephemeralVmRecipes.length > 0 || + needsSetupProjectHostSetupOptions.length > 0 const sshStatusLabel = selectedRepoSshStatus ? getSshStatusLabel(selectedRepoSshStatus) : translate('auto.components.NewWorkspaceComposerCard.notConnected', 'Not connected') @@ -424,35 +120,32 @@ export default function NewWorkspaceComposerCard({ : setupConfig?.kind === 'setup-and-default-tabs' ? 'Run setup and default tab commands now?' : 'Run setup now?' - const setupRunButtonLabel = - setupConfig?.kind === 'default-tabs' - ? 'Run commands now' - : setupConfig?.kind === 'setup-and-default-tabs' - ? 'Run commands now' - : 'Run setup now' + const setupRunButtonLabel = setupConfig?.kind === 'setup' ? 'Run setup now' : 'Run commands now' const setupSkipButtonLabel = setupConfig?.kind === 'setup' ? 'Skip for now' : 'Skip commands' - // Why: defaultTabs launch commands can run long too, but aren't the setup command this setting gates agent startup on. const showSetupAgentStartupPolicy = setupControlsEnabled && setupConfig !== null && setupConfig.kind !== 'default-tabs' - - const handleSetDefaultAgent = React.useCallback( - (next: TuiAgent | 'blank' | null) => { - updateSettings({ defaultTuiAgent: next }) - }, - [updateSettings] + const agentCatalog = getAgentCatalog() + const enabledAgentIds = new Set( + filterEnabledTuiAgents( + agentCatalog.map((candidate) => candidate.id), + disabledTuiAgents + ) ) + const visibleQuickAgents = agentCatalog.filter((agent) => { + return ( + enabledAgentIds.has(agent.id) && + (props.detectedAgentIds === null || props.detectedAgentIds.has(agent.id)) + ) + }) const cancelNameInputFocusFrame = React.useCallback((): void => { - if (nameInputFocusFrameRef.current === null) { - return + if (nameInputFocusFrameRef.current !== null) { + cancelAnimationFrame(nameInputFocusFrameRef.current) + nameInputFocusFrameRef.current = null } - cancelAnimationFrame(nameInputFocusFrameRef.current) - nameInputFocusFrameRef.current = null }, []) - const setComposerNode = React.useCallback( (node: HTMLDivElement | null): void => { - // Why: the queued repo-picker focus is only valid while this composer exists. if (!node) { cancelNameInputFocusFrame() } @@ -463,51 +156,22 @@ export default function NewWorkspaceComposerCard({ }, [cancelNameInputFocusFrame, composerRef, onComposerNodeChange] ) - - const focusNameInput = React.useCallback(() => { - // Why: move focus to the name field after the repo pick so keyboard flow continues instead of trapping in the repo popover. + const focusNameInput = React.useCallback((): void => { cancelNameInputFocusFrame() nameInputFocusFrameRef.current = requestAnimationFrame(() => { nameInputFocusFrameRef.current = null nameInputRef?.current?.focus() }) }, [cancelNameInputFocusFrame, nameInputRef]) - - const visibleQuickAgents = React.useMemo(() => { - const enabledIds = new Set( - filterEnabledTuiAgents( - getAgentCatalog().map((agent) => agent.id), - disabledTuiAgents - ) - ) - return getAgentCatalog().filter( - (agent) => - enabledIds.has(agent.id) && (detectedAgentIds === null || detectedAgentIds.has(agent.id)) - ) - }, [detectedAgentIds, disabledTuiAgents]) - - const handleAddRepo = React.useCallback((): void => { - // Why: swapping activeModal would unmount the composer, so the override layers Add Project on top instead. + const handleAddProject = React.useCallback((): void => { if (onAddProjectOverride) { onAddProjectOverride() return } openModal('add-repo') }, [onAddProjectOverride, openModal]) - // Why: open the host-add form inline over the composer (not via Settings) so the user's - // in-progress workspace form survives; the new host lands in the store and flows straight - // back into the run-target picker without a navigation round-trip. - const [addRemoteHostMode, setAddRemoteHostMode] = React.useState(null) - const handleAddSshHost = React.useCallback((): void => { - setAddRemoteHostMode('ssh') - }, []) - const handleAddRemoteServer = React.useCallback((): void => { - setAddRemoteHostMode('server') - }, []) - const [setLocationOption, setSetLocationOption] = - React.useState(null) const handleSetLocation = React.useCallback( - (option: NeedsSetupProjectHostOption): void => { + (option: NeedsProjectHostOption): void => { setSetLocationOption(option) onNestedDialogOpenChange?.(true) }, @@ -524,14 +188,8 @@ export default function NewWorkspaceComposerCard({ }, [handleSetLocationClose, onProjectHostSetupChange] ) - const projects = useAppStore((state) => state.projects) - const repos = useAppStore((state) => state.repos) - const defaultCloneUrl = React.useMemo( - () => resolveProjectCloneUrlPrefill(projects, repos, selectedProjectId), - [projects, repos, selectedProjectId] - ) const handleConnectRunTargetHost = React.useCallback( - async (option: NeedsSetupProjectHostOption): Promise => { + async (option: NeedsProjectHostOption): Promise => { const action = option.connectAction if (!action) { return @@ -541,25 +199,16 @@ export default function NewWorkspaceComposerCard({ if (isSshConnectInFlight(action.targetId)) { return } - // Why: ssh.connect has no built-in timeout; a stalled connect would otherwise leave - // the row's spinner/disabled state stuck forever. Bound the UI wait — the backend - // keeps connecting and the picker updates from store SSH state if it later succeeds. - // The shared registry tracks that backend request (not this bounded wait), so the - // sidebar card control and terminal overlay for this host stay locked until it - // settles — a second dial on a passphrase-gated target means a second prompt. await withUiConnectTimeout( trackSshConnect(action.targetId, window.api.ssh.connect({ targetId: action.targetId })) ) return } - const response = await window.api.runtimeEnvironments.getStatus({ selector: action.environmentId, timeoutMs: 15_000 }) const runtimeStatus = unwrapRuntimeRpcResult(response) - // Why: the composer button is only a reachability retry; the separate - // project setup flow remains a follow-up once the host is online. useAppStore.getState().setRuntimeEnvironmentStatus(action.environmentId, { status: runtimeStatus, checkedAt: Date.now() @@ -583,59 +232,19 @@ export default function NewWorkspaceComposerCard({ }, [] ) - const handleNotePaste = React.useCallback((event: React.ClipboardEvent) => { - const text = event.clipboardData.getData('text/plain') - const byteLengthMeasurement = measureTextControlPasteByteLength(text, { - stopAfterBytes: TEXT_CONTROL_PASTE_DIRECT_MAX_BYTES - }) - if ( - !byteLengthMeasurement.exceededLimit && - !shouldHandleTextControlPaste(text, { measuredByteLength: byteLengthMeasurement.byteLength }) - ) { - return - } - - event.preventDefault() - event.stopPropagation() - const textarea = event.currentTarget - // Why: large note pastes need one controlled owner so React gets a single final input event after chunked DOM insertion. - void pasteTextIntoTextControl(textarea, text, { - source: 'clipboard', - canContinue: (target) => target.ownerDocument.activeElement === target - }) - .then((result) => { - if (result.status === 'rejected' && result.reason === 'too-large') { - toast.error( - translate( - 'auto.components.NewWorkspaceComposerCard.notePasteTooLarge', - 'Paste is too large for the note field.' - ) - ) - } - }) - .catch(() => {}) - }, []) - const projectDescriptionId = React.useId() - const readyProjectHostSetupOptions = React.useMemo( - () => projectHostSetupOptions.filter((option) => option.kind === 'ready'), - [projectHostSetupOptions] - ) - const needsSetupProjectHostSetupOptions = React.useMemo( - () => projectHostSetupOptions.filter((option) => option.kind === 'needs-setup'), - [projectHostSetupOptions] - ) - // Why: the picker now also hosts the Add host handoff; even a single ready - // host needs this affordance for users who have not registered the target yet. - const shouldShowRunTargetPicker = - readyProjectHostSetupOptions.length > 0 || - ephemeralVmRecipes.length > 0 || - needsSetupProjectHostSetupOptions.length > 0 - const handleProjectHostSetupChange = React.useCallback( - (setupId: string): void => { - onProjectHostSetupChange?.(setupId) + const handleSetDefaultAgent = React.useCallback( + (next: TuiAgent | 'blank' | null): void => { + void updateSettings({ defaultTuiAgent: next }) }, - [onProjectHostSetupChange] + [updateSettings] ) + const handleNamePlainEnter = React.useCallback((): void => { + const agentTrigger = composerRef?.current?.querySelector( + '[data-agent-combobox-root="true"][role="combobox"]' + ) + agentTrigger?.focus() + }, [composerRef]) + useContextualTour( 'workspace-creation', projectOptions.length > 0 && Boolean(selectedProjectId), @@ -649,7 +258,6 @@ export default function NewWorkspaceComposerCard({
-
-
- - {showAddProjectButton ? ( - - - - - - {translate('auto.components.NewWorkspaceComposerCard.d6b0a96f32', 'Add project')} - - - ) : null} -
- - {projectError ? ( -

- {projectError} -

- ) : projectOptions.length === 0 ? ( -

- {emptyProjectMessage ?? - translate( - 'auto.components.NewWorkspaceComposerCard.addProjectBeforeWorkspace', - 'Add a project before creating a workspace.' - )} -

- ) : null} - {shouldShowRunTargetPicker ? ( - // Why: Run on is nested in the Project block (so they share the - // error/empty states), which put it on the block's 4px rhythm. It's - // its own field, so give it the 16px other fields get. -
- - - {ephemeralVmRecipeError ? ( -

- {ephemeralVmRecipeError} -

- ) : null} -
- ) : ephemeralVmRecipeError ? ( -

- {ephemeralVmRecipeError} -

- ) : null} - {selectedRepoRequiresConnection && selectedRepoConnectionId ? ( -
-
-
- {translate('auto.components.NewWorkspaceComposerCard.b5a0796911', 'Connect')}{' '} - {selectedProjectName} -
-
{sshStatusLabel}
-
- -
- ) : null} -
- -
- - { - // Why: Enter advances focus to the Agent combobox rather than submitting, keeping keyboard flow through the form. - const root = composerRef?.current - const agentTrigger = root?.querySelector( - '[data-agent-combobox-root="true"][role="combobox"]' - ) - agentTrigger?.focus() - }} - /> - {forkPushWarning ? ( -

-

- ) : null} - {/* Why (#5181): sits under the branch selection (not Name, which can differ) so reusing the picked branch is an explicit choice. */} -
-
-
- -

- {translate( - 'auto.components.NewWorkspaceComposerCard.reuseExistingBranchHint', - 'Check out the existing branch instead of creating a new one from it.' - )} -

-
-
-
-
- -
-
- - - - - - - {translate( - 'auto.components.NewWorkspaceComposerCard.ba64270bdb', - 'Configure agents' - )} - - -
- -
- - {/* Why: keep the Advanced disclosure header grouped with the content below while preserving spacing from the Agent field above. */} -
- {/* Why: -ml-2 pulls the button so its label aligns flush-left with the field labels above - while the padded hover highlight extends past the label on the left. The scroll - container's px-2 inset gives that overhang room so it isn't clipped. */} - -
- -
-
- {/* Why: px-1 gives the Note textarea's 3px outset focus ring breathing room so the overflow-hidden drawer doesn't clip it. */} -
- {smartNameSelection ? ( - // Why: with a source pill the smart field isn't editable, so surface the derived name here; a typed name already is the name field. -
- - onNameValueChange(event.target.value)} - placeholder={translate( - 'auto.components.NewWorkspaceComposerCard.0ee17638fe', - 'Workspace name' - )} - className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" - /> -
- ) : null} - - {/* Why: for a tracked work item (PR/issue/MR/Linear) the branch is derived from the item, so a manual override here would be silently ignored. */} - {selectedRepoIsGit && - branchesEnabled && - (!smartNameSelection || smartNameSelection.kind === 'branch') ? ( -
- - onBranchNameOverrideChange(event.target.value)} - placeholder={translate( - 'auto.components.NewWorkspaceComposerCard.branchNamePlaceholder', - 'feature/my-branch' - )} - className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" - /> -
- ) : null} - -
- -