From fc1686efd6f654f8334f7fcdb9aed17dcab3f399 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:34:38 -0700 Subject: [PATCH 01/28] fix: preserve module URLs in the headless server bundle (#26450) --- config/scripts/build-orcad.mjs | 17 ++++ config/scripts/orcad-entry-build.mjs | 21 ++++- config/scripts/orcad-entry-build.test.mjs | 79 +++++++++++++++++++ src/main/orcad/main.ts | 7 +- .../orcad/orcad-structured-provider-smoke.ts | 22 ++++++ 5 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 config/scripts/orcad-entry-build.test.mjs create mode 100644 src/main/orcad/orcad-structured-provider-smoke.ts diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index d548e5c65d1..351f71b51d0 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -9,6 +9,7 @@ import { externalNativeAddons, ORCAD_EXTERNAL_MODULES, ORCAD_CHILD_ENTRY_POINTS, + ORCAD_COMMONJS_MODULE_OPTIONS, orcadChildOutputFilename } from './orcad-entry-build.mjs' import { createRequire } from 'node:module' @@ -52,6 +53,8 @@ import { computeOrcadFullVersion } from './orcad-artifact-version.mjs' import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' import { findSlotProblems, readManifest } from './orcad-prebuild-slot-contents.mjs' import { orcadAgentBrowserNativeName } from '../../src/shared/orcad-agent-browser-name.ts' +import { runProcessSync } from '@orca/process-host' +import { describeProcessFailure } from './process-failure-message.mjs' const ROOT = join(import.meta.dirname, '..', '..') const OUT_DIR = process.env.ORCAD_OUT_DIR @@ -99,6 +102,7 @@ async function stageParcelWatcher(target) { const wrapperSource = requireFromWatcher.resolve('@parcel/watcher/wrapper.js') mkdirSync(WATCHER_MODULE_DIR, { recursive: true }) await build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, stdin: { contents: `const {createWrapper}=require(${JSON.stringify(wrapperSource)});` + @@ -197,6 +201,7 @@ cpSync(join(ROOT, 'resources', 'native-chat-visuals'), join(OUT_DIR, 'native-cha * their runtime resolvers look for them. */ function buildForkedChild(entryPoint, outfile) { return build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, entryPoints: [entryPoint], bundle: true, platform: 'node', @@ -209,6 +214,7 @@ function buildForkedChild(entryPoint, outfile) { minify: true, sourcemap: false, define: { + ...ORCAD_COMMONJS_MODULE_OPTIONS.define, 'process.env.NODE_ENV': '"production"' }, logLevel: 'error' @@ -312,6 +318,17 @@ if (graphErrors.length > 0) { ) process.exitCode = 1 } + const providerSmoke = runProcessSync({ + program: process.execPath, + args: [SERVER_OUT_FILE, '--orcad-structured-provider-load-check'], + timeoutMs: 60_000 + }) + if (providerSmoke.code !== 0 || providerSmoke.timedOut || providerSmoke.outputTruncated) { + console.error( + `[build-orcad] the packaged structured provider failed to load.\n${describeProcessFailure(providerSmoke)}` + ) + process.exitCode = 1 + } // Why require + parseArgs and not a real daemon: requiring the bundle evaluates every // top-level import, and calling its exported argv parser proves the entry's own code is // there rather than a graph that merely resolved. Booting one would need a socket, a diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index 1f0de9a11b2..7b636937b3e 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -25,6 +25,15 @@ export function orcadChildOutputFilename(entryPoint) { export const ORCAD_EXTERNAL_MODULES = ['electron', 'node-pty', '@parcel/watcher', 'fsevents'] +// Bundled ESM resolves from the deployed bundle, like CommonJS __filename. +export const ORCAD_COMMONJS_MODULE_OPTIONS = { + banner: { + js: 'var __orcadModuleUrl = require("node:url").pathToFileURL(__filename).href;' + }, + define: { 'import.meta.url': '__orcadModuleUrl' }, + logOverride: { 'empty-import-meta': 'error' } +} + // Native binaries are staged separately from every JavaScript entry. export const externalNativeAddons = { name: 'external-native-addons', @@ -45,6 +54,7 @@ const jsoncParserEsm = { export function buildOrcadEntry(outfile) { return build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, entryPoints: [join(root, ORCAD_ENTRY_POINT)], bundle: true, platform: 'node', @@ -56,7 +66,10 @@ export function buildOrcadEntry(outfile) { metafile: true, minify: true, sourcemap: false, - define: { 'process.env.NODE_ENV': '"production"' }, + define: { + ...ORCAD_COMMONJS_MODULE_OPTIONS.define, + 'process.env.NODE_ENV': '"production"' + }, logLevel: 'error' }) } @@ -81,6 +94,7 @@ export function buildOrcadLauncher(outfile) { .update(readFileSync(join(dirname(outfile), ORCAD_SERVER_ENTRY_FILENAME))) .digest('hex') return build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, entryPoints: [join(root, ORCAD_LAUNCHER_ENTRY_POINT)], bundle: true, platform: 'node', @@ -90,7 +104,10 @@ export function buildOrcadLauncher(outfile) { metafile: true, minify: true, sourcemap: false, - define: { ORCAD_SERVER_SHA256: JSON.stringify(serverSha256) }, + define: { + ...ORCAD_COMMONJS_MODULE_OPTIONS.define, + ORCAD_SERVER_SHA256: JSON.stringify(serverSha256) + }, logLevel: 'error' }) } diff --git a/config/scripts/orcad-entry-build.test.mjs b/config/scripts/orcad-entry-build.test.mjs new file mode 100644 index 00000000000..b367aba66ad --- /dev/null +++ b/config/scripts/orcad-entry-build.test.mjs @@ -0,0 +1,79 @@ +import { build } from 'esbuild' +import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, expect, it } from 'vitest' +import { buildOrcadEntry, ORCAD_COMMONJS_MODULE_OPTIONS } from './orcad-entry-build.mjs' +import { runProcessSync } from '@orca/process-host' + +const directory = mkdtempSync(join(tmpdir(), 'orca-structured-provider-build-')) +const bundle = join(directory, 'orcad-server.js') + +beforeAll(async () => { + await buildOrcadEntry(bundle) +}, 60_000) + +afterAll(() => { + rmSync(directory, { recursive: true, force: true }) +}) + +it('loads the structured Claude SDK through the packaged server under plain Node', () => { + const loaded = runProcessSync({ + program: process.execPath, + args: [bundle, '--orcad-structured-provider-load-check'], + cwd: directory, + env: { ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 30_000 + }) + expect(loaded.code, loaded.stderr.slice(-2_000)).toBe(0) +}, 40_000) + +it('resolves bundle-relative requires after deployment to a path with URL-special characters', async () => { + const buildDirectory = join(directory, 'build') + mkdirSync(buildDirectory) + const outfile = join(buildDirectory, 'entry.cjs') + await build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, + stdin: { + contents: `import { createRequire } from 'node:module'; + console.log(createRequire(import.meta.url)('./asset.cjs'));`, + resolveDir: directory + }, + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile, + minify: true, + logLevel: 'error' + }) + writeFileSync(join(buildDirectory, 'asset.cjs'), 'module.exports = "deployed asset"') + const deployedDirectory = join(directory, 'deployed # 100%') + renameSync(buildDirectory, deployedDirectory) + const loaded = runProcessSync({ + program: process.execPath, + args: [join(deployedDirectory, 'entry.cjs')], + cwd: directory, + env: { ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 30_000 + }) + expect(loaded.code, loaded.stderr).toBe(0) + expect(loaded.stdout.trim()).toBe('deployed asset') +}) + +it.each(['import.meta', 'import.meta.dirname', 'import.meta.filename', 'import.meta.resolve'])( + 'rejects unsupported %s instead of silently replacing it with an empty object', + async (expression) => { + await expect( + build({ + ...ORCAD_COMMONJS_MODULE_OPTIONS, + stdin: { contents: `console.log(${expression})`, resolveDir: directory }, + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + logLevel: 'silent' + }) + ).rejects.toThrow('"import.meta" is not available') + } +) diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts index 30e21462a8f..ca28cea4174 100644 --- a/src/main/orcad/main.ts +++ b/src/main/orcad/main.ts @@ -42,7 +42,12 @@ function failStartup(error: unknown): void { } // One-shot launch and stop commands must skip server startup and preflights. -if (process.argv[2] === WINDOWS_BREAKAWAY_LAUNCH_FLAG) { +if (process.argv[2] === '--orcad-structured-provider-load-check') { + void import('./orcad-structured-provider-smoke') + .then(({ smokeLoadOrcadStructuredProviders }) => smokeLoadOrcadStructuredProviders()) + .then(() => process.exit(0)) + .catch(failStartup) +} else if (process.argv[2] === WINDOWS_BREAKAWAY_LAUNCH_FLAG) { runWindowsBreakawayLaunchIfRequested(ORCAD_WINDOWS_BREAKAWAY_CONTRACT, process.argv) } else if ( process.argv[2] === ORCAD_COMPLETE_MANAGED_STOP_FLAG || diff --git a/src/main/orcad/orcad-structured-provider-smoke.ts b/src/main/orcad/orcad-structured-provider-smoke.ts new file mode 100644 index 00000000000..7e64c62f38a --- /dev/null +++ b/src/main/orcad/orcad-structured-provider-smoke.ts @@ -0,0 +1,22 @@ +import { openClaudeStreamJsonConnection } from '../claude/claude-stream-json-connection' + +export async function smokeLoadOrcadStructuredProviders(): Promise { + const loaded = new Error('structured provider module loaded') + try { + await openClaudeStreamJsonConnection( + { pathToClaudeCodeExecutable: 'unused', cwd: process.cwd(), options: {} }, + {}, + undefined, + () => { + // The production connection loads the SDK before calling the query implementation. + throw loaded + } + ) + } catch (error) { + if (error === loaded) { + return + } + throw error + } + throw new Error('structured provider load check did not reach the SDK boundary') +} From 79a597375b858f517495bf5a745ed595d0491c97 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:38:56 -0700 Subject: [PATCH 02/28] test(native-chat): keep #26586's path-link cases as regression tests (#26819) Command-, route-, version- and host-shaped text stays plain unless the host confirms the path, in chat and terminal alike, since #26675. Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> --- .../CommentMarkdown.file-link-cases.test.tsx | 105 ++++++++++++++++++ .../terminal-link-provider-chat-cases.test.ts | 65 +++++++++++ 2 files changed, 170 insertions(+) create mode 100644 src/renderer/src/components/sidebar/CommentMarkdown.file-link-cases.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-provider-chat-cases.test.ts diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.file-link-cases.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.file-link-cases.test.tsx new file mode 100644 index 00000000000..6959798e9b1 --- /dev/null +++ b/src/renderer/src/components/sidebar/CommentMarkdown.file-link-cases.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment happy-dom + +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { routeNativeChatHref } from '../../../../shared/native-chat-href-routing' +import CommentMarkdown from './CommentMarkdown' + +afterEach(cleanup) + +describe('CommentMarkdown host-confirmed file cases', () => { + it.each([ + { + name: 'slash commands and app routes', + content: + 'Type /orca-native-chat:orca-chat-visuals, run `/code-review`, strip `/api/v1`, use /clear and /compact, then open /tmp/report.html.', + files: ['/tmp/report.html'], + linked: ['/tmp/report.html'] + }, + { + name: 'numeric versions and model names', + content: + 'Try HTTP/1.1, 1.5/2.0 and z-ai/glm-5.3; open docs/archive.7z, man/ls.1 and lib/libfoo.so.1.', + files: ['docs/archive.7z', 'man/ls.1', 'lib/libfoo.so.1'], + linked: ['docs/archive.7z', 'man/ls.1', 'lib/libfoo.so.1'] + }, + { + name: 'scheme-less hosts and dotted directories', + content: + 'See example.com/docs/guide.html and 127.0.0.1/api/data.json, then open conf.d/nginx.conf and v1.2/notes.md.', + files: ['conf.d/nginx.conf', 'v1.2/notes.md'], + linked: ['conf.d/nginx.conf', 'v1.2/notes.md'] + }, + { + name: 'commands in inline code and real spaced relative paths', + content: + 'Run `git log origin/main..HEAD`; open `My Folder/notes.md` and "Brennan\'s Folder/notes.md".', + files: ['My Folder/notes.md', "Brennan's Folder/notes.md"], + linked: ['My Folder/notes.md', "Brennan's Folder/notes.md"] + }, + { + name: 'spaced paths anchored at a root', + content: + 'Open `~/Library/Application Support/orca/log.json` and "C:\\My Folder\\notes.txt:12:3".', + files: ['~/Library/Application Support/orca/log.json', String.raw`C:\My Folder\notes.txt`], + linked: [ + '~/Library/Application Support/orca/log.json', + String.raw`C:\My Folder\notes.txt:12:3` + ] + }, + { + name: 'existing paths under known and unlisted roots', + content: + 'Open /etc/hosts, /usr/local/bin, /data/out.json, /data/raw, /mnt/c/Users/me/notes.md and /Users/me/project/src/main.ts:12.', + files: [ + '/etc/hosts', + '/usr/local/bin', + '/data/out.json', + '/data/raw', + '/mnt/c/Users/me/notes.md', + '/Users/me/project/src/main.ts' + ], + linked: [ + '/etc/hosts', + '/usr/local/bin', + '/data/out.json', + '/data/raw', + '/mnt/c/Users/me/notes.md', + '/Users/me/project/src/main.ts:12' + ] + }, + { + name: 'plain file URIs with extensionless basenames', + content: 'Open file:///tmp/Makefile and `file:///tmp/report%20notes.md#L4`.', + files: ['/tmp/Makefile', '/tmp/report notes.md'], + linked: ['file:///tmp/Makefile', 'file:///tmp/report%20notes.md#L4'] + }, + { + name: 'command-shaped and host-shaped text that really names a path', + content: 'Open /code-review, `/api/v1` and example.com/docs/guide.html.', + files: ['/code-review', '/api/v1', 'example.com/docs/guide.html'], + linked: ['/code-review', '/api/v1', 'example.com/docs/guide.html'] + }, + { + name: 'missing file URIs', + content: 'Open file:///tmp/missing and `file:///tmp/missing.ts`.', + files: [], + linked: [] + } + ])('links only confirmed paths for $name', ({ content, files, linked }) => { + const { container } = render( + files.includes(link.pathText)} + /> + ) + + const anchors = Array.from(container.querySelectorAll('a')) + expect(anchors.map((anchor) => anchor.textContent)).toEqual(linked) + for (const anchor of anchors) { + expect(routeNativeChatHref(anchor.getAttribute('href')).kind).toBe('file') + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-provider-chat-cases.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-provider-chat-cases.test.ts new file mode 100644 index 00000000000..b7842d30b3f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-provider-chat-cases.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTerminalLinkTestDoubles } from './terminal-link-handlers-test-fixtures' +import { installTerminalLinkTestEnvironment } from './terminal-link-handlers-test-harness' +import { collectLinks } from './terminal-link-provider-buffer-fixtures' + +const doubles = createTerminalLinkTestDoubles() + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => doubles.storeState } +})) + +vi.mock('@/lib/connection-context', () => ({ + getConnectionId: vi.fn(() => null) +})) + +installTerminalLinkTestEnvironment(doubles) + +describe('terminal host-confirmed chat regression cases', () => { + it.each([ + { + text: 'Type /orca-native-chat:orca-chat-visuals, run /code-review, strip /api/v1, use /clear and /compact, then open /tmp/report.html.', + files: ['/tmp/report.html'], + linked: ['/tmp/report.html'] + }, + { + text: 'HTTP/1.1, 1.5/2.0, z-ai/glm-5.3; docs/archive.7z, man/ls.1, lib/libfoo.so.1.', + files: ['/repo/docs/archive.7z', '/repo/man/ls.1', '/repo/lib/libfoo.so.1'], + linked: ['docs/archive.7z', 'man/ls.1', 'lib/libfoo.so.1'] + }, + { + text: 'See example.com/docs/guide.html and 127.0.0.1/api/data.json, then open conf.d/nginx.conf.', + files: ['/repo/conf.d/nginx.conf'], + linked: ['conf.d/nginx.conf'] + }, + { + text: 'docs/Makefile, .github/CODEOWNERS; BUILD SUCCESSFUL', + files: ['/repo/docs/Makefile', '/repo/.github/CODEOWNERS'], + linked: ['docs/Makefile', '.github/CODEOWNERS'] + }, + { + text: 'Open file:///tmp/Makefile and file:///tmp/report%20notes.md#L4.', + files: ['/tmp/Makefile', '/tmp/report notes.md'], + linked: ['file:///tmp/Makefile', 'file:///tmp/report%20notes.md#L4'] + } + ])('keeps only existing paths in $text', async ({ text, files, linked }) => { + vi.mocked(window.api.shell.pathExists).mockImplementation(async (path) => files.includes(path)) + + const links = await collectLinks(text) + + expect(links.map((link) => link.text)).toEqual(linked) + }) + + it('allows command-shaped and host-shaped text when it really names a path', async () => { + const paths = ['/code-review', '/api/v1', '/repo/example.com/docs/guide.html'] + vi.mocked(window.api.shell.pathExists).mockImplementation(async (path) => paths.includes(path)) + + const links = await collectLinks('/code-review, /api/v1, example.com/docs/guide.html') + + expect(links.map((link) => link.text)).toEqual([ + '/code-review', + '/api/v1', + 'example.com/docs/guide.html' + ]) + }) +}) From fee083fa47cd4e18ae07cb4c76329e0851f51aee Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 14:40:36 -0700 Subject: [PATCH 03/28] feat(runtime): session-only browser keys and opt-in for pairing from plain-http pages (host) (#27540) * feat(runtime): session-only browser keys and an opt-in for pairing from plain-http pages (host) - pairing.registerDeviceKey accepts ephemeral: true (advertised as pairing.device-key-ephemeral.v1). Such keyed rows are dropped when the host restarts and after 12 hours with no live socket; a sweep removes them and cuts any socket still bound to them, so later key auth gets device_revoked. - The WebSocket upgrade Origin is recorded per socket. A first pairing (a never-used pairing code) from an http:// page on another machine is refused with a sealed insecure_origin error unless the operator sets ORCA_ALLOW_INSECURE_WEB_PAIRING=1. Already-paired browsers, https and loopback pages, and native clients are unchanged. - Registry file reading moved to device-registry-file-read.ts to keep the registry under the size cap. * fix(runtime): gate first pairings only for the marked web client, never phones React Native sends an http Origin on every LAN WebSocket, so the Origin alone refused first-time QR pairing from released phones. The web bundle now marks its upgrade with ?orca-client=web; only a marked socket from an http page on another machine is gated. Released hosts ignore the query string. --------- Co-authored-by: m4air --- src/main/runtime/device-entry.ts | 2 + src/main/runtime/device-key-fork.ts | 4 +- src/main/runtime/device-registry-file-read.ts | 41 +++ .../runtime/device-registry-row-validation.ts | 12 +- src/main/runtime/device-registry.ts | 75 +++--- .../runtime/ephemeral-device-rows.test.ts | 107 ++++++++ src/main/runtime/ephemeral-device-rows.ts | 30 +++ src/main/runtime/rpc/e2ee-channel.ts | 5 +- .../rpc/mobile-e2ee-auth-validation.ts | 14 +- src/main/runtime/rpc/mobile-socket-wiring.ts | 60 ++++- .../runtime/rpc/web-session-pairing.test.ts | 239 ++++++++++++++++++ src/main/runtime/rpc/ws-transport.ts | 16 +- ...runtime-rpc-browser-host-admission.test.ts | 4 + ...ntime-rpc-websocket-long-poll-caps.test.ts | 6 + .../runtime-rpc-device-key-registration.ts | 3 +- .../runtime-rpc/runtime-rpc-lifecycle.ts | 8 +- .../runtime-rpc/runtime-rpc-shutdown.ts | 1 + src/shared/device-key-capability.ts | 8 + .../device-key-registration-contract.ts | 5 +- src/shared/protocol-version.ts | 4 +- src/shared/web-origin-security.test.ts | 47 ++++ src/shared/web-origin-security.ts | 61 +++++ ...s-version-web-session-pairing.unit.test.ts | 71 ++++++ ...ss-version-web-upgrade-marker.unit.test.ts | 85 +++++++ 24 files changed, 856 insertions(+), 52 deletions(-) create mode 100644 src/main/runtime/device-registry-file-read.ts create mode 100644 src/main/runtime/ephemeral-device-rows.test.ts create mode 100644 src/main/runtime/ephemeral-device-rows.ts create mode 100644 src/main/runtime/rpc/web-session-pairing.test.ts create mode 100644 src/shared/web-origin-security.test.ts create mode 100644 src/shared/web-origin-security.ts create mode 100644 tests/e2e/cross-version-wire/cross-version-web-session-pairing.unit.test.ts create mode 100644 tests/e2e/cross-version-wire/cross-version-web-upgrade-marker.unit.test.ts diff --git a/src/main/runtime/device-entry.ts b/src/main/runtime/device-entry.ts index 41804c1163d..ac05b74f120 100644 --- a/src/main/runtime/device-entry.ts +++ b/src/main/runtime/device-entry.ts @@ -35,6 +35,8 @@ export type DeviceEntry = { devicePublicKeyB64?: string // The token row this installation migrated from; that row stays for its other holders. forkedFrom?: string + // Session-only browser key (memory only); the row is dropped on host restart and after idling. + ephemeral?: true } /** A row that authenticates by bearer token: every pairing offer and delegated child. */ diff --git a/src/main/runtime/device-key-fork.ts b/src/main/runtime/device-key-fork.ts index 00099a31bdb..cfa95d464f0 100644 --- a/src/main/runtime/device-key-fork.ts +++ b/src/main/runtime/device-key-fork.ts @@ -29,7 +29,8 @@ export function planDeviceKeyFork( legacyDeviceId: string, devicePublicKeyB64: string, label: string, - now: number + now: number, + options: { ephemeral?: boolean } = {} ): DeviceKeyFork { const legacy = devices.find((device) => device.deviceId === legacyDeviceId) if (!legacy) { @@ -70,6 +71,7 @@ export function planDeviceKeyFork( credentialKind: 'device-key', devicePublicKeyB64, forkedFrom: legacy.deviceId, + ...(options.ephemeral ? { ephemeral: true as const } : {}), // Why: orchestration work owned under the token keeps its owner after migration. callerFingerprint: devicePrincipalOf(legacy).callerFingerprint, pairedAt: now, diff --git a/src/main/runtime/device-registry-file-read.ts b/src/main/runtime/device-registry-file-read.ts new file mode 100644 index 00000000000..30bab126e83 --- /dev/null +++ b/src/main/runtime/device-registry-file-read.ts @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs' +import { hardenExistingSecureFile, isUnreadableError } from '../../shared/secure-file' +import { corruptRegistryCopyExists, isMissingFileError } from './device-registry-corruption' +import type { DeviceEntry } from './device-entry' +import { normalizeLoadedDeviceRow } from './device-registry-row-validation' + +export type DeviceRegistryFileRead = { + devices: DeviceEntry[] + /** The registry exists but could not be read, which makes `devices` a lie to save from. */ + unreadable: boolean + /** A parse failure, now or in an earlier run, means an unlisted device may still exist. */ + doubtful: boolean + /** A corrupt file must be set aside before the first rewrite. */ + corruptFilePending: boolean +} + +export function readDeviceRegistryFile(registryPath: string): DeviceRegistryFileRead { + // Why: a set-aside corrupt copy keeps revocation verdicts off until the owner removes it, even + // when the rewrite after quarantine never landed and the registry file itself is missing. + const doubtful = corruptRegistryCopyExists(registryPath) + try { + hardenExistingSecureFile(registryPath) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: normalizeLoadedDeviceRow re-validates every field; a non-array throws below and reads as corrupt. + const parsed = JSON.parse(readFileSync(registryPath, 'utf-8')) as DeviceEntry[] + return { + devices: parsed.map(normalizeLoadedDeviceRow), + unreadable: false, + doubtful, + corruptFilePending: false + } + } catch (error) { + if (isMissingFileError(error)) { + return { devices: [], unreadable: false, doubtful, corruptFilePending: false } + } + // "Cannot read" is not "is empty". Saving an empty list over a registry we were merely + // denied would erase every paired device's bearer token, and the write would succeed. + const unreadable = isUnreadableError(error) + // Why: a corrupt file is not evidence any device was revoked; never tell a keyed client so. + return { devices: [], unreadable, doubtful: true, corruptFilePending: !unreadable } + } +} diff --git a/src/main/runtime/device-registry-row-validation.ts b/src/main/runtime/device-registry-row-validation.ts index 591732b98ca..04966feec63 100644 --- a/src/main/runtime/device-registry-row-validation.ts +++ b/src/main/runtime/device-registry-row-validation.ts @@ -76,15 +76,21 @@ export function normalizeLoadedDeviceRow(device: DeviceEntry): DeviceEntry { // Why: a malformed keyed row stays keyed (so it never falls back to a token) but cannot authenticate. function validDeviceKeyFields( device: DeviceEntry -): Pick { +): Pick { if (device.credentialKind !== 'device-key') { - return { credentialKind: undefined, devicePublicKeyB64: undefined, forkedFrom: undefined } + return { + credentialKind: undefined, + devicePublicKeyB64: undefined, + forkedFrom: undefined, + ephemeral: undefined + } } return { credentialKind: 'device-key', devicePublicKeyB64: isDevicePublicKeyB64(device.devicePublicKeyB64) ? device.devicePublicKeyB64 : undefined, - forkedFrom: typeof device.forkedFrom === 'string' ? device.forkedFrom : undefined + forkedFrom: typeof device.forkedFrom === 'string' ? device.forkedFrom : undefined, + ephemeral: device.ephemeral === true ? true : undefined } } diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 677d5b189b7..cf312bc79d7 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -2,24 +2,16 @@ // (mobile) connections. Each paired device gets its own revocable token so // compromising one device doesn't expose others. The registry is a simple // JSON file with hardened permissions matching the runtime metadata pattern. -import { readFileSync } from 'node:fs' import { join } from 'node:path' -import { - hardenExistingSecureFile, - isUnreadableError, - writeDurableSecureJsonFile, - writeSecureJsonFile -} from '../../shared/secure-file' +import { writeDurableSecureJsonFile, writeSecureJsonFile } from '../../shared/secure-file' import type { DeviceScope } from '../../shared/runtime-types' import { removeStaleDurableWriteTempFiles } from '../durable-file-write' import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' import { mintDeviceCredential } from './device-credential' import { planDeviceKeyFork } from './device-key-fork' -import { - corruptRegistryCopyExists, - isMissingFileError, - quarantineCorruptRegistry -} from './device-registry-corruption' +import { planIdleEphemeralPrune, withoutEphemeralRows } from './ephemeral-device-rows' +import { quarantineCorruptRegistry } from './device-registry-corruption' +import { readDeviceRegistryFile } from './device-registry-file-read' import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' @@ -29,7 +21,7 @@ import { type DelegatedMobileDeviceEntry } from './delegated-mobile-devices' import type { RuntimeDeviceGrant } from './rpc/rpc-method-permission' -import { normalizeLoadedDeviceRow, validGrants } from './device-registry-row-validation' +import { validGrants } from './device-registry-row-validation' import { isPendingTokenRow, mobilePairingConnectionModeOf, @@ -247,13 +239,19 @@ export class DeviceRegistry { } /** Forks `legacyDeviceId` into a keyed row; durable before it returns (security transition). */ - forkDeviceKey(legacyDeviceId: string, devicePublicKeyB64: string, label: string): DeviceEntry { + forkDeviceKey( + legacyDeviceId: string, + devicePublicKeyB64: string, + label: string, + options: { ephemeral?: boolean } = {} + ): DeviceEntry { const fork = planDeviceKeyFork( this.devices, legacyDeviceId, devicePublicKeyB64, label, - Date.now() + Date.now(), + options ) if (fork.created) { this.save(fork.nextDevices, { durable: true }) @@ -262,6 +260,16 @@ export class DeviceRegistry { return fork.entry } + /** Drops idle session-only rows; durable, since a removed row is a revocation. */ + pruneIdleEphemeralDevices(now: number, isLive: (deviceId: string) => boolean): string[] { + const plan = planIdleEphemeralPrune(this.devices, now, isLive) + if (plan.removed.length > 0) { + this.save(plan.nextDevices, { durable: true }) + this.devices = plan.nextDevices + } + return plan.removed + } + /** The row an authenticated socket is still entitled to, or null once revoked or re-credentialed. */ resolveAuthenticatedPrincipal(principal: AuthenticatedPrincipalBinding): DeviceEntry | null { const device = this.getDevice(principal.deviceId) @@ -306,11 +314,15 @@ export class DeviceRegistry { return } this.cancelPendingLastSeenFlush() + // Why: matches the async hardening path — a failed bookkeeping write must not take down the runtime. + this.saveOrLog({}, 'persist device lastSeen') + } + + private saveOrLog(options: { durable?: boolean }, action: string): void { try { - this.save(this.devices) + this.save(this.devices, options) } catch (error) { - // Why: matches the async hardening path — a failed bookkeeping write must not take down the runtime. - console.error('[mobile] Failed to persist device lastSeen:', error) + console.error(`[runtime] Failed to ${action}:`, error) } } @@ -322,25 +334,14 @@ export class DeviceRegistry { } private load(): void { - // Why: a set-aside corrupt copy keeps revocation verdicts off until the owner removes it, even - // when the rewrite after quarantine never landed and the registry file itself is missing. - this.registryDoubtful = corruptRegistryCopyExists(this.registryPath) - try { - hardenExistingSecureFile(this.registryPath) - const parsed = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[] - this.devices = parsed.map(normalizeLoadedDeviceRow) - this.registryUnreadable = false - } catch (error) { - this.devices = [] - if (isMissingFileError(error)) { - return - } - // "Cannot read" is not "is empty". Saving an empty list over a registry we were merely - // denied would erase every paired device's bearer token, and the write would succeed. - this.registryUnreadable = isUnreadableError(error) - // Why: a corrupt file is not evidence any device was revoked; never tell a keyed client so. - this.registryDoubtful = true - this.corruptFilePending = !this.registryUnreadable + const read = readDeviceRegistryFile(this.registryPath) + this.registryUnreadable = read.unreadable + this.registryDoubtful = read.doubtful + this.corruptFilePending = read.corruptFilePending + this.devices = withoutEphemeralRows(read.devices) + // Why: a session-only browser's key died with the last host process; its row must not outlive it. + if (this.devices.length !== read.devices.length) { + this.saveOrLog({ durable: true }, 'drop session-only devices after restart') } } diff --git a/src/main/runtime/ephemeral-device-rows.test.ts b/src/main/runtime/ephemeral-device-rows.test.ts new file mode 100644 index 00000000000..d213a73aba0 --- /dev/null +++ b/src/main/runtime/ephemeral-device-rows.test.ts @@ -0,0 +1,107 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { planDeviceKeyFork } from './device-key-fork' +import { DeviceRegistry, type DeviceEntry } from './device-registry' +import { EPHEMERAL_DEVICE_IDLE_MS, planIdleEphemeralPrune } from './ephemeral-device-rows' +import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' + +const KEY_A = Buffer.alloc(32, 1).toString('base64') +const dirs: string[] = [] + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tokenRow(overrides: Partial = {}): DeviceEntry { + return { + deviceId: 'legacy', + name: 'Browser', + token: 'token', + scope: 'runtime', + pairedAt: 1, + lastSeenAt: 2, + ...overrides + } +} + +function keyedRow(overrides: Partial = {}): DeviceEntry { + return { + deviceId: 'keyed', + name: 'Browser', + scope: 'runtime', + pairedAt: 1, + lastSeenAt: 1_000, + credentialKind: 'device-key', + devicePublicKeyB64: KEY_A, + forkedFrom: 'legacy', + ephemeral: true, + ...overrides + } +} + +function registryWith(rows: DeviceEntry[]): { dir: string; registry: DeviceRegistry } { + const dir = mkdtempSync(join(tmpdir(), 'orca-ephemeral-rows-')) + dirs.push(dir) + writeFileSync(join(dir, DEVICE_REGISTRY_FILENAME), JSON.stringify(rows)) + return { dir, registry: new DeviceRegistry(dir) } +} + +describe('ephemeral device rows', () => { + it('marks a session-only fork ephemeral and keeps persistent forks unmarked', () => { + const session = planDeviceKeyFork([tokenRow()], 'legacy', KEY_A, 'Tab', 9, { + ephemeral: true + }) + expect(session.entry.ephemeral).toBe(true) + expect(planDeviceKeyFork([tokenRow()], 'legacy', KEY_A, 'Tab', 9).entry.ephemeral).toBe( + undefined + ) + }) + + it('drops ephemeral rows when the host restarts and persists that', () => { + const { dir, registry } = registryWith([tokenRow(), keyedRow()]) + expect(registry.getDevice('keyed')).toBeNull() + expect(registry.lookupDeviceKey('keyed')).toEqual({ kind: 'unknown' }) + expect(registry.getDevice('legacy')).not.toBeNull() + const onDisk = JSON.parse(readFileSync(join(dir, DEVICE_REGISTRY_FILENAME), 'utf8')) + expect(onDisk.map((row: DeviceEntry) => row.deviceId)).toEqual(['legacy']) + }) + + it('ignores an ephemeral flag on a token row', () => { + const { registry } = registryWith([tokenRow({ ephemeral: true })]) + expect(registry.getDevice('legacy')?.ephemeral).toBeUndefined() + }) + + it('prunes only idle, disconnected ephemeral rows', () => { + const rows = [ + tokenRow(), + keyedRow(), + keyedRow({ deviceId: 'live', lastSeenAt: 1_000 }), + keyedRow({ deviceId: 'recent', lastSeenAt: 1_000 + EPHEMERAL_DEVICE_IDLE_MS }), + keyedRow({ deviceId: 'persistent', ephemeral: undefined }) + ] + const now = 2_000 + EPHEMERAL_DEVICE_IDLE_MS + const plan = planIdleEphemeralPrune(rows, now, (deviceId) => deviceId === 'live') + expect(plan.removed).toEqual(['keyed']) + expect(plan.nextDevices.map((row) => row.deviceId)).toEqual([ + 'legacy', + 'live', + 'recent', + 'persistent' + ]) + }) + + it('removes idle rows from the registry durably', () => { + const { registry } = registryWith([tokenRow()]) + const fork = registry.forkDeviceKey('legacy', KEY_A, 'Tab', { ephemeral: true }) + const removed = registry.pruneIdleEphemeralDevices( + fork.lastSeenAt + EPHEMERAL_DEVICE_IDLE_MS + 1, + () => false + ) + expect(removed).toEqual([fork.deviceId]) + expect(registry.lookupDeviceKey(fork.deviceId)).toEqual({ kind: 'unknown' }) + }) +}) diff --git a/src/main/runtime/ephemeral-device-rows.ts b/src/main/runtime/ephemeral-device-rows.ts new file mode 100644 index 00000000000..4249a44493c --- /dev/null +++ b/src/main/runtime/ephemeral-device-rows.ts @@ -0,0 +1,30 @@ +import type { DeviceEntry } from './device-entry' + +// Why: a session-only browser keeps its key in memory; an idle row is an abandoned tab, not a device. +export const EPHEMERAL_DEVICE_IDLE_MS = 12 * 60 * 60 * 1000 +export const EPHEMERAL_DEVICE_SWEEP_INTERVAL_MS = 5 * 60 * 1000 + +/** Rows that survive a host restart: a session-only browser's key did not. */ +export function withoutEphemeralRows(devices: readonly DeviceEntry[]): DeviceEntry[] { + return devices.filter((device) => device.ephemeral !== true) +} + +/** Idle is measured from the last connect or disconnect; a row with a live socket never expires. */ +export function planIdleEphemeralPrune( + devices: readonly DeviceEntry[], + now: number, + isLive: (deviceId: string) => boolean +): { nextDevices: DeviceEntry[]; removed: string[] } { + const removed = devices + .filter( + (device) => + device.ephemeral === true && + now - device.lastSeenAt > EPHEMERAL_DEVICE_IDLE_MS && + !isLive(device.deviceId) + ) + .map((device) => device.deviceId) + return { + nextDevices: devices.filter((device) => !removed.includes(device.deviceId)), + removed + } +} diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index b7c3506d1f9..4bf7c1d0e7d 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -11,7 +11,8 @@ import { authenticateMobileE2EE, decodeMobileE2EEPublicKey, describeMobileE2EEAuthFailure, - type DeviceKeyResolution + type DeviceKeyResolution, + type DeviceTokenRefusal } from './mobile-e2ee-auth-validation' import { isMobileE2EEOutboundItemWithinLimit } from './mobile-e2ee-outbound-admission' import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-request-frames' @@ -36,7 +37,7 @@ const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5 export type E2EEChannelOptions = { serverSecretKey: Uint8Array - resolveAuthenticatedDevice: (token: string) => E2EEAuthenticatedDevice | null + resolveAuthenticatedDevice: (token: string) => E2EEAuthenticatedDevice | DeviceTokenRefusal | null // Absent: key auth is refused as bad_auth, the same as before device keys existed. resolveAuthenticatedDeviceKey?: (deviceId: string) => DeviceKeyResolution onReady: (channel: E2EEChannel, device: E2EEAuthenticatedDevice) => void diff --git a/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts b/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts index 18e15fa6281..cb2c4b2bb28 100644 --- a/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts +++ b/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts @@ -2,6 +2,7 @@ import type { DesktopMobileE2EEV2Session } from './mobile-e2ee-v2-desktop-sessio import { publicKeyFromBase64 } from './e2ee-crypto' import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-request-frames' import { isDeviceProofB64, verifyDeviceProof } from '../../../shared/device-key-proof' +import { INSECURE_WEB_ORIGIN_AUTH_CODE } from '../../../shared/web-origin-security' export type ClassifiedE2EEAuth = | { kind: 'token'; deviceToken: string; clientCapabilities?: unknown } @@ -80,6 +81,10 @@ export type MobileE2EEAuthFailureCode = | 'device_revoked' | 'registry_unavailable' | 'bad_proof' + | typeof INSECURE_WEB_ORIGIN_AUTH_CODE + +// The token matched, but this transport may not claim it (e.g. a first pairing from an insecure page). +export type DeviceTokenRefusal = { refused: typeof INSECURE_WEB_ORIGIN_AUTH_CODE } export type DeviceKeyResolution = | { kind: 'found'; device: TDevice; devicePublicKeyB64: string } @@ -92,7 +97,7 @@ export type DeviceKeyResolution = export function authenticateMobileE2EE(args: { plaintext: string v2Session: DesktopMobileE2EEV2Session | null - resolveDevice: (token: string) => TDevice | null + resolveDevice: (token: string) => TDevice | DeviceTokenRefusal | null // Absent on a host that does not accept device keys: key auth is then bad_auth, as before. resolveDeviceKey?: (deviceId: string) => DeviceKeyResolution }): @@ -101,6 +106,9 @@ export function authenticateMobileE2EE const auth = classifyE2EEAuth(args.plaintext, args.v2Session) if (auth.kind === 'token') { const device = args.resolveDevice(auth.deviceToken) + if (device && 'refused' in device) { + return { ok: false, code: device.refused } + } // Why: a keyed row has no token, so presenting one can never match it. return device?.deviceToken === auth.deviceToken ? { ok: true, device, clientCapabilities: auth.clientCapabilities } @@ -144,7 +152,9 @@ export function describeMobileE2EEAuthFailure(failure: { ? 'Unauthorized' : failure.code === 'registry_unavailable' ? 'Device registry unavailable' - : 'Invalid e2ee_auth' + : failure.code === INSECURE_WEB_ORIGIN_AUTH_CODE + ? 'Insecure web origin' + : 'Invalid e2ee_auth' return { sealedError: { type: 'e2ee_error', diff --git a/src/main/runtime/rpc/mobile-socket-wiring.ts b/src/main/runtime/rpc/mobile-socket-wiring.ts index 009a35c9363..4305a73c89b 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.ts @@ -1,6 +1,12 @@ import { randomBytes } from 'node:crypto' import type { WebSocket } from 'ws' import type { DeviceEntry, DeviceRegistry } from '../device-registry' +import { isPendingTokenRow } from '../device-entry' +import { EPHEMERAL_DEVICE_SWEEP_INTERVAL_MS } from '../ephemeral-device-rows' +import { + INSECURE_WEB_ORIGIN_AUTH_CODE, + isInsecureRemoteWebOrigin +} from '../../../shared/web-origin-security' import { devicePrincipalOf } from '../device-principal' import type { E2EEKeypair } from '../e2ee-keypair' import { E2EEChannel, type E2EEAuthenticatedDevice } from './e2ee-channel' @@ -11,7 +17,8 @@ import type { RpcBinarySender } from './rpc-binary-sender' type MobileSocketPayload = string | Uint8Array export type MobileSocketTransportMetadata = - | { transport: 'direct' } + // webClient: the Orca web bundle marked its upgrade; webOrigin: that page's origin. + | { transport: 'direct'; webOrigin?: string; webClient?: true } | { transport: 'relay' relayHostId: string @@ -59,6 +66,8 @@ type MobileSocketWiringOptions = { onReady?: (socket: AuthenticatedMobileSocket) => void // Why: stale keys and missing registry entries both fail before RPC can explain the re-pair action. onUnpairedDeviceAuthFailure?: (metadata: MobileSocketTransportMetadata) => void + // Operator opt-in for first pairings from a plain-http page on another machine. + allowInsecureWebPairing?: () => boolean } function toAuthenticatedDevice(device: DeviceEntry): E2EEAuthenticatedDevice { @@ -76,6 +85,8 @@ export class MobileSocketWiring { private readonly onClose: MobileSocketWiringOptions['onClose'] private readonly onReady: MobileSocketWiringOptions['onReady'] private readonly onUnpairedDeviceAuthFailure: MobileSocketWiringOptions['onUnpairedDeviceAuthFailure'] + private readonly allowInsecureWebPairing: () => boolean + private readonly ephemeralSweep: ReturnType private readonly channels = new Map() private readonly connectionIds = new Map() private readonly authenticatedSockets = new Map() @@ -90,6 +101,39 @@ export class MobileSocketWiring { this.onClose = options.onClose this.onReady = options.onReady this.onUnpairedDeviceAuthFailure = options.onUnpairedDeviceAuthFailure + this.allowInsecureWebPairing = options.allowInsecureWebPairing ?? (() => false) + this.ephemeralSweep = setInterval( + () => this.sweepIdleEphemeralDevices(Date.now()), + EPHEMERAL_DEVICE_SWEEP_INTERVAL_MS + ) + // Why: bookkeeping must never hold the process open. + this.ephemeralSweep.unref?.() + } + + dispose(): void { + clearInterval(this.ephemeralSweep) + } + + /** Removes idle session-only browser rows and cuts any socket still bound to them. */ + sweepIdleEphemeralDevices(now: number): string[] { + let removed: string[] = [] + try { + removed = this.deviceRegistry.pruneIdleEphemeralDevices(now, (deviceId) => + this.hasAuthenticatedSocket(deviceId) + ) + } catch (error) { + console.error('[mobile] Failed to prune idle browser-session devices:', error) + } + for (const deviceId of removed) { + this.terminateDeviceConnections(deviceId) + } + return removed + } + + private hasAuthenticatedSocket(deviceId: string): boolean { + return Array.from(this.authenticatedSockets.values()).some( + (socket) => socket.device.deviceId === deviceId + ) } attachTransport( @@ -156,6 +200,16 @@ export class MobileSocketWiring { if (!device) { return null } + if ( + isPendingTokenRow(device) && + metadata.transport === 'direct' && + // Why the marker too: phones send an http Origin as well, and must never be gated. + metadata.webClient === true && + isInsecureRemoteWebOrigin(metadata.webOrigin) && + !this.allowInsecureWebPairing() + ) { + return { refused: INSECURE_WEB_ORIGIN_AUTH_CODE } + } // Why: outer relay authorization cannot choose the local Orca // identity; E2EE must resolve the same device before readiness. if (metadata.transport === 'relay' && metadata.relayDeviceId !== device.deviceId) { @@ -251,6 +305,10 @@ export class MobileSocketWiring { Array.from(this.authenticatedSockets.values()).some( (candidate) => candidate.device.deviceId === socket.device.deviceId ) + // Why: a session-only row idles from its last disconnect, not from when it connected. + if (socket && !hasOtherConnections && socket.device.deviceToken === undefined) { + this.deviceRegistry.updateLastSeenDeferred(socket.device.deviceId) + } this.onClose(socket, hasOtherConnections) } } diff --git a/src/main/runtime/rpc/web-session-pairing.test.ts b/src/main/runtime/rpc/web-session-pairing.test.ts new file mode 100644 index 00000000000..0965e4971f3 --- /dev/null +++ b/src/main/runtime/rpc/web-session-pairing.test.ts @@ -0,0 +1,239 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import nacl from 'tweetnacl' +import type { WebSocket } from 'ws' +import { describe, expect, it, vi } from 'vitest' +import { encodeDeviceProofMessage } from '../../../shared/device-key-proof' +import { DeviceRegistry } from '../device-registry' +import { EPHEMERAL_DEVICE_IDLE_MS } from '../ephemeral-device-rows' +import { SimulatedMobileE2EEV2Peer } from '../relay/simulated-mobile-e2ee-v2-peer' +import { registerSocketDeviceKey } from '../runtime-rpc/runtime-rpc-device-key-registration' +import { generateKeyPair } from './e2ee-crypto' +import { + MobileSocketWiring, + type AuthenticatedMobileSocket, + type MobileSocketTransport, + type MobileSocketTransportMetadata +} from './mobile-socket-wiring' + +class FakeSocket { + readonly OPEN = 1 + readyState = this.OPEN + bufferedAmount = 0 + readonly sent: (string | Buffer)[] = [] + readonly send = vi.fn((data: string | Buffer) => this.sent.push(data)) + readonly close = vi.fn() +} + +class FakeTransport implements MobileSocketTransport { + private messageHandler: Parameters[0] | null = null + readonly setClientId = vi.fn() + readonly terminateClientConnections = vi.fn(() => 0) + onMessage(handler: Parameters[0]): void { + this.messageHandler = handler + } + onConnectionClose(): void {} + receive(ws: FakeSocket, message: string): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wiring only sends, closes and reads OPEN/readyState/bufferedAmount. + this.messageHandler?.(message, vi.fn(), ws as unknown as WebSocket) + } +} + +const desktop = generateKeyPair() +const LAN_PAGE = 'http://192.168.1.20:6768' +const RELAY_HOST_ID = 'AbCdEf0123_-xyZ9' + +// The new web bundle marks its upgrade; phones (React Native) send an http Origin but no marker. +const WEB_CLIENT = { webOrigin: LAN_PAGE, webClient: true as const } + +function harness( + page: { webOrigin?: string; webClient?: true } | undefined, + allowInsecureWebPairing = false, + relay?: (deviceId: string) => MobileSocketTransportMetadata +) { + const registry = new DeviceRegistry(mkdtempSync(join(tmpdir(), 'orca-web-session-'))) + const transport = new FakeTransport() + const ready: AuthenticatedMobileSocket[] = [] + const wiring = new MobileSocketWiring({ + deviceRegistry: registry, + e2eeKeypair: { + publicKey: desktop.publicKey, + secretKey: desktop.secretKey, + publicKeyB64: Buffer.from(desktop.publicKey).toString('base64') + }, + onText: vi.fn(), + onBinary: vi.fn(), + onClose: vi.fn(), + onReady: (socket) => ready.push(socket), + allowInsecureWebPairing: () => allowInsecureWebPairing + }) + let relayDeviceId = '' + wiring.attachTransport(transport, () => + relay ? relay(relayDeviceId) : { transport: 'direct', ...page } + ) + + function connect(auth: (transcriptHashB64: string) => Record) { + const ws = new FakeSocket() + const peer = new SimulatedMobileE2EEV2Peer( + nacl.box.keyPair(), + desktop.publicKey, + relay ? RELAY_HOST_ID : null + ) + transport.receive(ws, JSON.stringify(peer.hello)) + expect(peer.acceptReady(JSON.parse(String(ws.sent[0])))).toBe(true) + const transcriptHashB64 = peer.transcriptHashB64 + const before = ready.length + transport.receive( + ws, + peer.sealText( + JSON.stringify({ type: 'e2ee_auth', v: 2, transcriptHashB64, ...auth(transcriptHashB64) }) + ) + ) + const reply = ws.sent[1] ? JSON.parse(peer.openText(String(ws.sent[1]))!) : null + return { ws, transcriptHashB64, reply, socket: ready.length > before ? ready.at(-1) : null } + } + return { + registry, + wiring, + transport, + connect, + bindRelay: (deviceId: string) => (relayDeviceId = deviceId) + } +} + +function sign(key: nacl.SignKeyPair, purpose: 'auth' | 'register', transcriptHashB64: string) { + const message = encodeDeviceProofMessage(purpose, Buffer.from(transcriptHashB64, 'base64')) + return Buffer.from(nacl.sign.detached(message, key.secretKey)).toString('base64') +} + +describe('pairing from a plain-http page on another machine', () => { + it('refuses a first pairing without operator opt-in, with a sealed reason', () => { + const { registry, connect } = harness(WEB_CLIENT) + const pending = registry.getOrCreatePendingDevice('Browser', 'runtime') + const attempt = connect(() => ({ deviceToken: pending.token })) + expect(attempt.reply).toEqual({ type: 'e2ee_error', error: { code: 'insecure_origin' } }) + expect(attempt.socket).toBeNull() + expect(attempt.ws.close).toHaveBeenCalledWith(4001, 'Insecure web origin') + expect(registry.getDevice(pending.deviceId)?.lastSeenAt).toBe(0) + }) + + it('pairs when the operator opted in', () => { + const { registry, connect } = harness(WEB_CLIENT, true) + const pending = registry.getOrCreatePendingDevice('Browser', 'runtime') + expect(connect(() => ({ deviceToken: pending.token })).reply).toMatchObject({ + type: 'e2ee_authenticated' + }) + }) + + it('never cuts a browser that was already paired', () => { + const { registry, connect } = harness(WEB_CLIENT) + const paired = registry.addDevice('Browser', 'runtime') + registry.updateLastSeen(paired.deviceId) + expect(connect(() => ({ deviceToken: paired.token })).reply).toMatchObject({ + type: 'e2ee_authenticated' + }) + }) + + it('lets https, loopback and native clients pair as before', () => { + for (const page of [ + { webOrigin: 'https://desk.example.com', webClient: true as const }, + { webOrigin: 'http://localhost:6768', webClient: true as const }, + undefined + ]) { + const { registry, connect } = harness(page) + const pending = registry.getOrCreatePendingDevice('Client', 'runtime') + expect(connect(() => ({ deviceToken: pending.token })).reply).toMatchObject({ + type: 'e2ee_authenticated' + }) + } + }) +}) + +describe('phones over direct LAN are never gated by their Origin', () => { + it('pairs a released phone whose socket sends an http LAN Origin but no web marker', () => { + for (const scope of ['mobile', 'runtime'] as const) { + const { registry, connect } = harness({ webOrigin: LAN_PAGE }) + const pending = registry.getOrCreatePendingDevice('Phone', scope) + expect(connect(() => ({ deviceToken: pending.token })).reply).toMatchObject({ + type: 'e2ee_authenticated' + }) + } + }) +}) + +describe('relayed phones are never gated', () => { + it('pairs over Relay whatever the page marker says', () => { + const { registry, connect, bindRelay } = harness(WEB_CLIENT, false, (relayDeviceId) => ({ + transport: 'relay', + relayHostId: RELAY_HOST_ID, + relayDeviceId, + basisConnId: 'conn', + credentialKind: 'invite' + })) + const pending = registry.getOrCreatePendingDevice('Phone', 'mobile') + bindRelay(pending.deviceId) + expect(connect(() => ({ deviceToken: pending.token })).reply).toMatchObject({ + type: 'e2ee_authenticated' + }) + }) +}) + +describe('session-only browser keys', () => { + it('registers an ephemeral row, sweeps it once idle and disconnected, and then revokes it', () => { + const { registry, wiring, transport, connect } = harness({ + webOrigin: 'https://desk.example.com', + webClient: true as const + }) + const paired = registry.addDevice('Browser', 'runtime') + registry.updateLastSeen(paired.deviceId) + const migrating = connect(() => ({ deviceToken: paired.token })) + const key = nacl.sign.keyPair() + const fork = registerSocketDeviceKey(registry, migrating.socket!, { + devicePublicKeyB64: Buffer.from(key.publicKey).toString('base64'), + deviceProofB64: sign(key, 'register', migrating.transcriptHashB64), + ephemeral: true + }) + expect(registry.getDevice(fork.deviceId)?.ephemeral).toBe(true) + + const keyed = connect((hash) => ({ + deviceId: fork.deviceId, + deviceProofB64: sign(key, 'auth', hash) + })) + expect(keyed.reply).toMatchObject({ type: 'e2ee_authenticated' }) + const idle = registry.getDevice(fork.deviceId)!.lastSeenAt + EPHEMERAL_DEVICE_IDLE_MS + 1 + // A live socket keeps the row however long the tab stays open. + expect(wiring.sweepIdleEphemeralDevices(idle)).toEqual([]) + + transport.receive(keyed.ws, 'ignored') + wiring.dispose() + expect(registry.pruneIdleEphemeralDevices(idle, () => false)).toEqual([fork.deviceId]) + const after = connect((hash) => ({ + deviceId: fork.deviceId, + deviceProofB64: sign(key, 'auth', hash) + })) + expect(after.reply).toEqual({ + type: 'e2ee_error', + error: { code: 'device_revoked', deviceId: fork.deviceId } + }) + }) + + it('cuts sockets bound to a swept row', () => { + const { registry, wiring, transport } = harness(undefined) + const paired = registry.addDevice('Browser', 'runtime') + registry.updateLastSeen(paired.deviceId) + const key = nacl.sign.keyPair() + const fork = registry.forkDeviceKey( + paired.deviceId, + Buffer.from(key.publicKey).toString('base64'), + 'Tab', + { ephemeral: true } + ) + expect( + wiring.sweepIdleEphemeralDevices(fork.lastSeenAt + EPHEMERAL_DEVICE_IDLE_MS + 1) + ).toEqual([fork.deviceId]) + expect(transport.terminateClientConnections).toHaveBeenCalledWith(fork.deviceId) + expect(registry.getDevice(paired.deviceId)).not.toBeNull() + wiring.dispose() + }) +}) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 29a6aa4b854..06d943faddf 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -4,6 +4,7 @@ import { createServer as createHttpServer, type Server as HttpServer } from 'nod import { WebSocketServer, type WebSocket } from 'ws' import type { RpcTransport } from './transport' import { createStaticWebClientHandler } from './static-web-client-handler' +import { hasWebClientUpgradeMarker } from '../../../shared/web-origin-security' import { attachNodeWebSocketLifecycle, clearNodeWebSocketPreAuthTimer, @@ -59,6 +60,7 @@ export class WebSocketTransport implements RpcTransport { private readonly fallbackLadder: readonly number[] private persistedFallbackBindFailed = false private httpServer: HttpsServer | HttpServer | null = null + private readonly webPages = new WeakMap() private wss: WebSocketServer | null = null private messageHandler: WebSocketMessageHandler | null = null private connectionCloseHandler: WebSocketConnectionCloseHandler | null = null @@ -110,6 +112,11 @@ export class WebSocketTransport implements RpcTransport { this.connectionCloseHandler = handler } + /** The Orca web bundle that opened `ws` (it marks its upgrade), with its page origin. */ + webPageOf(ws: WebSocket): { webOrigin?: string; webClient: true } | undefined { + return this.webPages.get(ws) + } + setClientId(ws: WebSocket, clientId: string): void { this.wsClientIds.set(ws, clientId) clearNodeWebSocketPreAuthTimer(ws, this.preAuthTimers) @@ -213,11 +220,18 @@ export class WebSocketTransport implements RpcTransport { maxPayload: WEBSOCKET_TRANSPORT_MAX_MESSAGE_BYTES }) - wss.on('connection', (ws) => { + wss.on('connection', (ws, request) => { if (wss.clients.size > WEBSOCKET_TRANSPORT_MAX_CONNECTIONS) { rejectNodeWebSocketOverCapacity(ws) return } + if (hasWebClientUpgradeMarker(request.url)) { + const origin = request.headers.origin + this.webPages.set(ws, { + ...(typeof origin === 'string' ? { webOrigin: origin } : {}), + webClient: true + }) + } this.handleConnection(ws) }) diff --git a/src/main/runtime/runtime-rpc-browser-host-admission.test.ts b/src/main/runtime/runtime-rpc-browser-host-admission.test.ts index b9187315478..24b94ee4fe4 100644 --- a/src/main/runtime/runtime-rpc-browser-host-admission.test.ts +++ b/src/main/runtime/runtime-rpc-browser-host-admission.test.ts @@ -55,7 +55,9 @@ describe('runtime RPC browser-host admission', () => { }) server['deviceRegistry'] = new DeviceRegistry(userDataPath) const device = server['deviceRegistry'].addDevice('runtime-test', 'runtime') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server reads only these wiring members in this test. server['mobileSocketWiring'] = { + dispose: () => {}, getConnectionId: () => 'connection-a' } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> const socket = new FakeWebSocket() @@ -125,7 +127,9 @@ describe('runtime RPC browser-host admission', () => { [socketB, 'connection-b'], [socketAReplacement, 'connection-a-replacement'] ]) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server reads only these wiring members in this test. server['mobileSocketWiring'] = { + dispose: () => {}, getConnectionId: (socket) => connectionIds.get(socket as unknown as FakeWebSocket) } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> const repliesA: Record[] = [] diff --git a/src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts b/src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts index ea8837c01c9..2054fa23de8 100644 --- a/src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts +++ b/src/main/runtime/runtime-rpc-websocket-long-poll-caps.test.ts @@ -54,7 +54,9 @@ describe('OrcaRuntimeRpcServer', () => { server['deviceRegistry'] = new DeviceRegistry(userDataPath) const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime') const ws = new FakeWebSocket() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server reads only these wiring members in this test. server['mobileSocketWiring'] = { + dispose: () => {}, getConnectionId: () => 'conn-test' } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> const replies: Record[] = [] @@ -132,7 +134,9 @@ describe('OrcaRuntimeRpcServer', () => { // Why: 'runtime' scope, not 'mobile' — orchestration.ask is absent from the mobile allowlist. const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime') const ws = new FakeWebSocket() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server reads only these wiring members in this test. server['mobileSocketWiring'] = { + dispose: () => {}, getConnectionId: () => 'conn-test' } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> const replies: Record[] = [] @@ -214,7 +218,9 @@ describe('OrcaRuntimeRpcServer', () => { server['deviceRegistry'] = new DeviceRegistry(userDataPath) const entry = server['deviceRegistry']!.addDevice('runtime-test', 'runtime') const ws = new FakeWebSocket() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the server reads only these wiring members in this test. server['mobileSocketWiring'] = { + dispose: () => {}, getConnectionId: () => 'conn-test' } as unknown as NonNullable<(typeof server)['mobileSocketWiring']> let activeDispatches = 0 diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-device-key-registration.ts b/src/main/runtime/runtime-rpc/runtime-rpc-device-key-registration.ts index e63b2d7f38c..7c566e7c407 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-device-key-registration.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-device-key-registration.ts @@ -35,7 +35,8 @@ export function registerSocketDeviceKey( const entry = registry.forkDeviceKey( legacy.deviceId, params.devicePublicKeyB64, - sanitizeDeviceLabel(params.deviceLabel, legacy.name) + sanitizeDeviceLabel(params.deviceLabel, legacy.name), + { ephemeral: params.ephemeral === true } ) return { deviceId: entry.deviceId, credentialRevision: entry.credentialRevision ?? 0 } } diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index 3159f9f6286..188ce1318e4 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -1,3 +1,4 @@ +import { ALLOW_INSECURE_WEB_PAIRING_ENV } from '../../../shared/web-origin-security' import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../shared/agent-launch-runtime-capability' import type { RuntimeTransportMetadata } from '../../../shared/runtime-bootstrap' import { watchRuntimeMetadataOwnership } from '../runtime-metadata-ownership-watch' @@ -112,6 +113,7 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { } catch (error) { // Why: WebSocket transport is supplementary; on failure (e.g. port in use) continue with Unix socket only. console.error('[runtime] Failed to start WebSocket transport:', error) + this.mobileSocketWiring?.dispose() this.mobileSocketWiring = null } } @@ -191,7 +193,10 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { fallbackLadder: wsFallbackPortLadder(this.wsPort) }) const mobileSocketWiring = this.ensureMobileSocketWiring(deviceRegistry, e2eeKeypair) - this.detachWebSocketWiring = mobileSocketWiring.attachTransport(wsTransport) + this.detachWebSocketWiring = mobileSocketWiring.attachTransport(wsTransport, (ws) => ({ + transport: 'direct', + ...wsTransport.webPageOf(ws) + })) try { await wsTransport.start() @@ -270,6 +275,7 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { this.runtime.onClientDisconnected(socket.device.clientId) } }, + allowInsecureWebPairing: () => process.env[ALLOW_INSECURE_WEB_PAIRING_ENV] === '1', // Why: relay attempts are authorized upstream; only direct failures should prompt local re-pairing. onUnpairedDeviceAuthFailure: (metadata) => { if (metadata.transport === 'direct') { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts index 2efd9bc7678..d5e5b8610dd 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts @@ -41,6 +41,7 @@ export class RuntimeRpcShutdown extends RuntimeRpcMobilePairing { if (this.hostDescriptor) { publishHostDescriptor(this.runtime.getRuntimeId(), null) } + this.mobileSocketWiring?.dispose() this.mobileSocketWiring = null this.detachWebSocketWiring = null const stopResults = await Promise.allSettled( diff --git a/src/shared/device-key-capability.ts b/src/shared/device-key-capability.ts index 62bc1a8810a..48a8d4b8a9f 100644 --- a/src/shared/device-key-capability.ts +++ b/src/shared/device-key-capability.ts @@ -5,4 +5,12 @@ // pairing.registerDeviceKey; clients migrate or present a key only when this is advertised. export const DEVICE_KEY_RUNTIME_CAPABILITY = 'pairing.device-key.v1' as const +// Why: registerDeviceKey's params are strict on older hosts, so `ephemeral` is sent only when advertised. +export const DEVICE_KEY_EPHEMERAL_RUNTIME_CAPABILITY = 'pairing.device-key-ephemeral.v1' as const + +export const DEVICE_KEY_RUNTIME_CAPABILITIES = [ + DEVICE_KEY_RUNTIME_CAPABILITY, + DEVICE_KEY_EPHEMERAL_RUNTIME_CAPABILITY +] as const + export const REGISTER_DEVICE_KEY_METHOD = 'pairing.registerDeviceKey' as const diff --git a/src/shared/device-key-registration-contract.ts b/src/shared/device-key-registration-contract.ts index 8655cc67acb..d02e326bc4e 100644 --- a/src/shared/device-key-registration-contract.ts +++ b/src/shared/device-key-registration-contract.ts @@ -6,7 +6,10 @@ export const RegisterDeviceKeyParamsSchema = z devicePublicKeyB64: z.string().refine(isDevicePublicKeyB64, 'expected a 32-byte Ed25519 key'), // Ed25519 over encodeDeviceProofMessage('register', this connection's v2 transcript hash). deviceProofB64: z.string().refine(isDeviceProofB64, 'expected a 64-byte signature'), - deviceLabel: z.string().max(256).optional() + deviceLabel: z.string().max(256).optional(), + // Session-only browser key: the host drops the row on restart and after idling. + // Sent only to hosts advertising DEVICE_KEY_EPHEMERAL_RUNTIME_CAPABILITY (older hosts are strict). + ephemeral: z.literal(true).optional() }) .strict() diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index a24fd1ef1be..b71bdce3726 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -45,7 +45,7 @@ import { AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES } from './agent-session-resum import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from './qoder-terminal-create-capability' import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from './agent-session-continue-interrupted-capability' import { ORCAD_RUNTIME_CAPABILITIES } from './orcad-runtime-capabilities' -import { DEVICE_KEY_RUNTIME_CAPABILITY } from './device-key-capability' +import { DEVICE_KEY_RUNTIME_CAPABILITIES } from './device-key-capability' // Why a star: every resume capability is part of the runtime contract this module re-exports. export * from './agent-session-resume-runtime-capabilities' import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability' @@ -356,7 +356,7 @@ export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY = export const RUNTIME_CAPABILITIES = [ ...ORCAD_RUNTIME_CAPABILITIES, - DEVICE_KEY_RUNTIME_CAPABILITY, + ...DEVICE_KEY_RUNTIME_CAPABILITIES, QODER_OWNED_TERMINAL_CREATE_CAPABILITY, ...AGENT_SESSION_STOP_RUNTIME_CAPABILITIES, ...AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES, diff --git a/src/shared/web-origin-security.test.ts b/src/shared/web-origin-security.test.ts new file mode 100644 index 00000000000..a7fdeb6821d --- /dev/null +++ b/src/shared/web-origin-security.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + hasWebClientUpgradeMarker, + isInsecureRemoteWebOrigin, + withWebClientUpgradeMarker +} from './web-origin-security' + +describe('isInsecureRemoteWebOrigin', () => { + it('flags plain http pages served to other machines', () => { + expect(isInsecureRemoteWebOrigin('http://192.168.1.20:6768')).toBe(true) + expect(isInsecureRemoteWebOrigin('http://desk.local')).toBe(true) + expect(isInsecureRemoteWebOrigin('http://[fe80::1]:6768')).toBe(true) + }) + + it('treats https and loopback pages as secure', () => { + expect(isInsecureRemoteWebOrigin('https://desk.example.com')).toBe(false) + expect(isInsecureRemoteWebOrigin('http://localhost:6768')).toBe(false) + expect(isInsecureRemoteWebOrigin('http://app.localhost')).toBe(false) + expect(isInsecureRemoteWebOrigin('http://127.0.0.1:6768')).toBe(false) + expect(isInsecureRemoteWebOrigin('http://127.8.0.1')).toBe(false) + expect(isInsecureRemoteWebOrigin('http://[::1]:6768')).toBe(false) + }) + + it('leaves non-browser and unparseable origins to token auth', () => { + expect(isInsecureRemoteWebOrigin(undefined)).toBe(false) + expect(isInsecureRemoteWebOrigin('null')).toBe(false) + expect(isInsecureRemoteWebOrigin('file://')).toBe(false) + expect(isInsecureRemoteWebOrigin('not a url')).toBe(false) + }) +}) + +describe('web client upgrade marker', () => { + it('round-trips through the endpoint the web bundle dials', () => { + const marked = withWebClientUpgradeMarker('ws://192.168.1.20:6768') + expect(marked).toBe('ws://192.168.1.20:6768/?orca-client=web') + expect(hasWebClientUpgradeMarker(new URL(marked).pathname + new URL(marked).search)).toBe(true) + expect(withWebClientUpgradeMarker('wss://desk.example.com/orca?x=1')).toBe( + 'wss://desk.example.com/orca?x=1&orca-client=web' + ) + }) + + it('is absent for phones, CLIs and other paths', () => { + expect(hasWebClientUpgradeMarker('/')).toBe(false) + expect(hasWebClientUpgradeMarker(undefined)).toBe(false) + expect(hasWebClientUpgradeMarker('/?orca-client=phone')).toBe(false) + }) +}) diff --git a/src/shared/web-origin-security.ts b/src/shared/web-origin-security.ts new file mode 100644 index 00000000000..a6a6bae1f12 --- /dev/null +++ b/src/shared/web-origin-security.ts @@ -0,0 +1,61 @@ +// Why: a plain-http page on another machine can be swapped by anyone on the LAN path, so a +// pairing code typed into it is exposed. Browsers treat loopback http as a secure context. +export function isInsecureRemoteWebOrigin(origin: string | undefined): boolean { + if (!origin) { + return false + } + let url: URL + try { + url = new URL(origin) + } catch { + return false + } + return url.protocol === 'http:' && !isLoopbackHostname(url.hostname) +} + +function isLoopbackHostname(hostname: string): boolean { + const host = hostname.toLowerCase() + return ( + host === 'localhost' || + host.endsWith('.localhost') || + host === '[::1]' || + /^127(?:\.\d{1,3}){3}$/.test(host) + ) +} + +/** Sealed auth error for a first pairing from such a page without operator opt-in. */ +export const INSECURE_WEB_ORIGIN_AUTH_CODE = 'insecure_origin' as const + +// Operator opt-in, read by the host serving the page (desktop or `orca serve`). +export const ALLOW_INSECURE_WEB_PAIRING_ENV = 'ORCA_ALLOW_INSECURE_WEB_PAIRING' as const + +// Why a marker: React Native phones also send an http Origin on LAN sockets, so the Origin alone +// cannot say "browser". Only the web bundle adds this query to its upgrade URL; hosts that predate +// it ignore the query string, so the marked upgrade still connects to them. +export const WEB_CLIENT_UPGRADE_PARAM = 'orca-client' as const +export const WEB_CLIENT_UPGRADE_VALUE = 'web' as const + +export function withWebClientUpgradeMarker(endpoint: string): string { + try { + const url = new URL(endpoint) + url.searchParams.set(WEB_CLIENT_UPGRADE_PARAM, WEB_CLIENT_UPGRADE_VALUE) + return url.toString() + } catch { + return endpoint + } +} + +/** True when an upgrade request path (`/path?query`) carries the web bundle's marker. */ +export function hasWebClientUpgradeMarker(requestUrl: string | undefined): boolean { + if (!requestUrl) { + return false + } + try { + return ( + new URL(requestUrl, 'http://upgrade.invalid').searchParams.get(WEB_CLIENT_UPGRADE_PARAM) === + WEB_CLIENT_UPGRADE_VALUE + ) + } catch { + return false + } +} diff --git a/tests/e2e/cross-version-wire/cross-version-web-session-pairing.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-web-session-pairing.unit.test.ts new file mode 100644 index 00000000000..508efdcb94b --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-web-session-pairing.unit.test.ts @@ -0,0 +1,71 @@ +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import nacl from 'tweetnacl' +import { beforeAll, describe, expect, it } from 'vitest' +import { DeviceRegistry, type DeviceEntry } from '../../../src/main/runtime/device-registry' +import { DEVICE_REGISTRY_FILENAME } from '../../../src/main/runtime/mobile-pairing-files' +import { DEVICE_KEY_EPHEMERAL_RUNTIME_CAPABILITY } from '../../../src/shared/device-key-capability' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + resolveBaselineReleaseRef +} from './release-checkout' + +/** + * Session-only browser keys add an `ephemeral` flag to keyed rows. A released host (desktop + * downgrade) must keep such rows, keep token rows authenticating, and never advertise the + * capability that makes a browser send `ephemeral` to it. + */ +const SUITE_TIMEOUT_MS = 180_000 + +type RegistryLike = { + validateToken(token: string): { deviceId: string } | null + updateLastSeen(deviceId: string): void +} + +let ReleasedRegistry: new (userDataPath: string) => RegistryLike +let releasedCapabilities: readonly string[] + +beforeAll(async () => { + const checkout = await materializeReleaseCheckout(resolveBaselineReleaseRef()) + const [registryModule, protocolModule] = await Promise.all([ + importReleaseCheckoutModule(checkout, 'src/main/runtime/device-registry.ts'), + importReleaseCheckoutModule(checkout, 'src/shared/protocol-version.ts') + ]) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release's DeviceRegistry class; a changed shape fails the assertions below. + ReleasedRegistry = registryModule.DeviceRegistry as typeof ReleasedRegistry + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release's capability list; a changed shape fails the assertion below. + releasedCapabilities = protocolModule.RUNTIME_CAPABILITIES as readonly string[] +}, SUITE_TIMEOUT_MS) + +describe('cross-version: session-only browser rows', () => { + it('a released host never invites a browser to send ephemeral', () => { + expect(releasedCapabilities).not.toContain(DEVICE_KEY_EPHEMERAL_RUNTIME_CAPABILITY) + }) + + it('a released registry keeps the session row and token rows; this host drops it on restart', () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-xv-web-session-')) + const current = new DeviceRegistry(userDataPath) + const legacy = current.addDevice('Browser', 'runtime') + current.updateLastSeen(legacy.deviceId) + const keyB64 = Buffer.from(nacl.sign.keyPair().publicKey).toString('base64') + const session = current.forkDeviceKey(legacy.deviceId, keyB64, 'Tab', { ephemeral: true }) + + const released = new ReleasedRegistry(userDataPath) + expect(released.validateToken(legacy.token)?.deviceId).toBe(legacy.deviceId) + released.updateLastSeen(legacy.deviceId) + const rows: DeviceEntry[] = JSON.parse( + readFileSync(join(userDataPath, DEVICE_REGISTRY_FILENAME), 'utf8') + ) + expect(rows.find((row) => row.deviceId === session.deviceId)).toMatchObject({ + ephemeral: true, + devicePublicKeyB64: keyB64 + }) + expect(rows.find((row) => row.deviceId === session.deviceId)?.token).toBeUndefined() + + const restarted = new DeviceRegistry(userDataPath) + expect(restarted.getDevice(session.deviceId)).toBeNull() + expect(restarted.validateToken(legacy.token)?.deviceId).toBe(legacy.deviceId) + }) +}) diff --git a/tests/e2e/cross-version-wire/cross-version-web-upgrade-marker.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-web-upgrade-marker.unit.test.ts new file mode 100644 index 00000000000..7d148b3df2f --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-web-upgrade-marker.unit.test.ts @@ -0,0 +1,85 @@ +import WebSocket from 'ws' +import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import { WebSocketTransport } from '../../../src/main/runtime/rpc/ws-transport' +import { withWebClientUpgradeMarker } from '../../../src/shared/web-origin-security' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + resolveBaselineReleaseRef +} from './release-checkout' + +/** + * The updated web client marks its WebSocket upgrade with `?orca-client=web` so a host can tell + * it from a phone (React Native also sends an http Origin). A released host must still accept the + * marked upgrade, and this host must record the marker only when it is present. + */ +const SUITE_TIMEOUT_MS = 180_000 +const LAN_PAGE = 'http://192.168.1.20:6768' + +type TransportLike = { + start(): Promise + stop(): Promise + readonly resolvedPort: number + onMessage(handler: (message: unknown, reply: unknown, ws: WebSocket) => void): void +} +type TransportConstructor = new (options: Record) => TransportLike + +let ReleasedTransport: TransportConstructor +const running: TransportLike[] = [] + +beforeAll(async () => { + const checkout = await materializeReleaseCheckout(resolveBaselineReleaseRef()) + const module = await importReleaseCheckoutModule(checkout, 'src/main/runtime/rpc/ws-transport.ts') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release's WebSocketTransport; a changed shape fails the assertions below. + ReleasedTransport = module.WebSocketTransport as TransportConstructor +}, SUITE_TIMEOUT_MS) + +afterEach(async () => { + await Promise.all(running.splice(0).map((transport) => transport.stop())) +}) + +async function dial(transport: TransportLike, marked: boolean): Promise { + const received = new Promise((resolve) => + transport.onMessage((_message, _reply, ws) => resolve(ws)) + ) + const base = `ws://127.0.0.1:${transport.resolvedPort}` + const client = new WebSocket(marked ? withWebClientUpgradeMarker(base) : base, { + headers: { Origin: LAN_PAGE } + }) + await new Promise((resolve, reject) => { + client.once('open', resolve) + client.once('error', reject) + }) + client.send('{"type":"e2ee_hello"}') + const serverSide = await received + client.close() + return serverSide +} + +async function started(Transport: TransportConstructor): Promise { + const transport = new Transport({ host: '127.0.0.1', port: 0, preAuthTimeoutMs: 60_000 }) + await transport.start() + running.push(transport) + return transport +} + +describe('cross-version: web client upgrade marker', () => { + it('a released host accepts the marked upgrade and receives its frames', async () => { + const transport = await started(ReleasedTransport) + await expect(dial(transport, true)).resolves.toBeDefined() + }) + + it('this host records the marker for the web bundle only, never for a phone', async () => { + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: 0, + preAuthTimeoutMs: 60_000 + }) + await transport.start() + running.push(transport) + const web = await dial(transport, true) + expect(transport.webPageOf(web)).toEqual({ webOrigin: LAN_PAGE, webClient: true }) + const phone = await dial(transport, false) + expect(transport.webPageOf(phone)).toBeUndefined() + }) +}) From bd54fc7fa32c196e3566fabcee8da330e40e96f3 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 14:41:17 -0700 Subject: [PATCH 04/28] refactor(renderer): delete the direct-SSH authority and reconnect coordinator (#27587) No ssh: git provider is registered in production, so the reconnect coordinator's catalog and lineage reads always came back non-authoritative and its worktree refresh never merged. Its only live effect was clearing an unconverted target's stale PTY bindings on each connected push and on wake; the state bridge now does that directly. Deletes the coordinator, preparation, worktree refresh scheduler, host hydration, rollout flag and their product telemetry event, the scoped direct-SSH reconnect/hydration options, and the now caller-less repos:listForExecutionHost and worktrees:listLineageForHost IPC handlers. Co-authored-by: m4air --- config/reliability-gates.jsonc | 83 +-- config/scripts/ci-shard-timings.json | 9 - .../ipc/repos-execution-host-catalog.test.ts | 129 +---- src/main/ipc/repos.ts | 1 - .../ipc/repos/host-repo-catalog-snapshot.ts | 111 ---- src/main/ipc/repos/repo-catalog-handlers.ts | 11 - src/main/ipc/worktree-create-lineage.test.ts | 1 - src/main/ipc/worktree-remote.ts | 4 +- ...ktrees-create-metadata-persistence.test.ts | 1 - .../ipc/worktrees-lineage-hydration.test.ts | 156 +---- src/main/ipc/worktrees.ts | 1 - .../listing/detected-provider-listing.ts | 10 - .../metadata/host-lineage-listing.ts | 77 --- .../metadata/lineage-owner-resolution.ts | 123 ---- .../register-worktree-metadata-handlers.ts | 11 - .../workspace-lineage-candidate-scan.test.ts | 158 ----- .../metadata/workspace-lineage-filtering.ts | 186 ------ .../ssh/orcad-retained-source-lists.test.ts | 17 - src/preload/api/repos-bridge.ts | 7 - src/preload/api/repository-api.ts | 5 - src/preload/api/worktree-api.ts | 5 - src/preload/api/worktrees-bridge.ts | 7 - .../direct-ssh-host-catalog-noop.test.ts | 162 ------ .../hooks/direct-ssh-host-hydration-scope.ts | 20 - .../direct-ssh-host-hydration-telemetry.ts | 17 - .../hooks/direct-ssh-host-hydration.test.ts | 545 ------------------ .../src/hooks/direct-ssh-host-hydration.ts | 315 ---------- ...rect-ssh-reconnect-coordinator-outcomes.ts | 66 --- ...ssh-reconnect-coordinator-stabilization.ts | 25 - ...ect-ssh-reconnect-coordinator-telemetry.ts | 118 ---- .../direct-ssh-reconnect-coordinator-types.ts | 145 ----- .../direct-ssh-reconnect-coordinator.test.ts | 542 ----------------- .../hooks/direct-ssh-reconnect-coordinator.ts | 291 ---------- ...irect-ssh-reconnect-preparation-metrics.ts | 39 -- .../hooks/direct-ssh-reconnect-preparation.ts | 291 ---------- .../direct-ssh-reconnect-rollout.test.ts | 42 -- .../src/hooks/direct-ssh-reconnect-rollout.ts | 25 - .../hooks/direct-ssh-reconnect-tokens.test.ts | 27 - .../src/hooks/direct-ssh-reconnect-tokens.ts | 58 -- ...direct-ssh-runtime-wake-isolation.test.tsx | 5 +- .../hooks/direct-ssh-state-routing.test.ts | 106 +--- .../src/hooks/direct-ssh-state-routing.ts | 55 -- ...worktree-refresh-scheduler-cancellation.ts | 16 - ...-ssh-worktree-refresh-scheduler-metrics.ts | 95 --- ...ct-ssh-worktree-refresh-scheduler-types.ts | 128 ---- ...ect-ssh-worktree-refresh-scheduler.test.ts | 435 -------------- .../direct-ssh-worktree-refresh-scheduler.ts | 316 ---------- ...irect-ssh-worktree-refresh-target-queue.ts | 44 -- .../ipc-events-ssh-authority-test-fixtures.ts | 31 +- .../ipc-events/app-lifetime-ipc-bridge.ts | 6 +- .../ipc-events/direct-ssh-bridge-runtime.ts | 160 ----- .../direct-ssh-connect-reply-routing.test.ts | 103 +--- .../direct-ssh-hydration-fanout.test.ts | 23 +- ...rect-ssh-hydration-target-metadata.test.ts | 33 +- .../direct-ssh-initial-state-hydration.ts | 11 +- .../ipc-events/direct-ssh-state-ipc-bridge.ts | 86 ++- ...cEvents-agent-status-ssh-authority.test.ts | 158 +---- ...t-ssh-reconnect-product-telemetry-types.ts | 8 - ...ct-ssh-reconnect-product-telemetry.test.ts | 106 ---- .../direct-ssh-reconnect-product-telemetry.ts | 127 ---- .../store-session-terminal-reconnect.test.ts | 125 ---- .../store-session-workspace-hydration.test.ts | 11 +- .../slices/terminal-session-row-hydration.ts | 15 +- ...ls-hydration-canonical-pty-overlap.test.ts | 26 - .../store/slices/terminals-hydration.test.ts | 122 ---- ...-fetch-persisted-metadata-fallback.test.ts | 227 +------- ...worktrees-fetch-refresh-coalescing.test.ts | 164 +----- ...ktrees-fetch-stale-catalog-version.test.ts | 60 +- src/renderer/src/store/slices/worktrees.ts | 2 - .../listing/known-ssh-worktree-fetch.ts | 116 +--- .../terminals/direct-ssh-terminal-bindings.ts | 26 +- .../src/store/terminals/terminal-actions.ts | 10 +- .../src/store/terminals/terminal-contracts.ts | 9 - .../terminals/terminal-pty-identities.ts | 13 - .../workspace-terminal-hydration-patch.ts | 19 - .../terminals/workspace-terminal-hydration.ts | 2 +- .../terminals/workspace-terminal-reconnect.ts | 75 +-- .../terminals/workspace-terminal-row-plan.ts | 15 +- ...ect-ssh-reconnect-telemetry-schema.test.ts | 112 ---- src/shared/host-lineage-contract.ts | 29 - src/shared/host-repo-catalog-contract.ts | 28 - src/shared/telemetry-event-registry.ts | 2 - ...lemetry-feature-education-event-schemas.ts | 56 -- 83 files changed, 137 insertions(+), 7020 deletions(-) delete mode 100644 src/main/ipc/repos/host-repo-catalog-snapshot.ts delete mode 100644 src/main/ipc/worktrees/metadata/host-lineage-listing.ts delete mode 100644 src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts delete mode 100644 src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts delete mode 100644 src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-host-catalog-noop.test.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-host-hydration-scope.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-host-hydration-telemetry.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-host-hydration.test.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-host-hydration.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-reconnect-coordinator-outcomes.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-reconnect-coordinator-stabilization.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-reconnect-coordinator-telemetry.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-reconnect-preparation-metrics.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-reconnect-preparation.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-reconnect-rollout.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-reconnect-tokens.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-cancellation.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-metrics.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-types.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts delete mode 100644 src/renderer/src/hooks/direct-ssh-worktree-refresh-target-queue.ts delete mode 100644 src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts delete mode 100644 src/renderer/src/lib/direct-ssh-reconnect-product-telemetry-types.ts delete mode 100644 src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts delete mode 100644 src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.ts delete mode 100644 src/shared/direct-ssh-reconnect-telemetry-schema.test.ts delete mode 100644 src/shared/host-lineage-contract.ts delete mode 100644 src/shared/host-repo-catalog-contract.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index c06100173c6..bbcedfea963 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -4263,28 +4263,25 @@ "terminal input availability", "worktree discovery", "Source Control status refresh", - "direct SSH detected-worktree scheduling", - "direct SSH reconnect telemetry", - "direct SSH host catalog authority", "direct SSH generation-scope rollover" ], "platforms": ["macos", "linux", "windows"], "providers": ["local", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "ssh"], - "coverageNotes": "Local deterministic evidence covers git-common classification, desktop watcher debounce counts, non-overlapping poller semantics, macOS native-watch fallback, preload cleanup, Source Control active-visible repo filtering, paired-runtime publication to two independent clients with reconnect and stale-cache checks, the direct SSH five-slot fair scheduler, timeout barrier, aggregate privacy schema, coordinator-to-renderer telemetry wiring, host-catalog provenance rejection, and process generation-scope rollover across sibling targets. A headed macOS Electron host and separate paired Electron client both rendered a worktree created through the real Git binary and watcher entry path without reconnect. A macOS Electron client also completed a direct SSH disconnect/reconnect against an ephemeral Linux Docker target with exact host/authority hydration and remote proof-file verification. Linux/Windows desktop clients, multi-target live fanout, and WSL runs remain gaps.", + "coverageNotes": "Local deterministic evidence covers git-common classification, desktop watcher debounce counts, non-overlapping poller semantics, macOS native-watch fallback, preload cleanup, Source Control active-visible repo filtering, paired-runtime publication to two independent clients with reconnect and stale-cache checks, and process generation-scope rollover across sibling targets. A headed macOS Electron host and separate paired Electron client both rendered a worktree created through the real Git binary and watcher entry path without reconnect. Linux/Windows desktop clients, multi-target live fanout, and WSL runs remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/7086", "https://github.com/stablyai/orca/issues/16477" ], - "invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control. When the authoritative paired host accepts an external structural change for a uniquely identified server-local repo, each live client for that host must receive one existing host-scoped worktreesChanged invalidation and converge to the same repo/worktree identity without reconnect; duplicate watcher events must coalesce, stale cached or in-flight catalogs must not survive invalidation, and event-ignoring clients must remain compatible. Nested SSH watchers and colliding local/SSH repo IDs must not publish the host-blind event until existing semantics can carry owner identity. External head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout. Direct SSH reconnect discovery must stay host- and authority-qualified, reject contradictory main-catalog provenance without returning rows, admit at most five locally unsettled provider calls, retain a retrying timeout barrier, and emit one identifier-free aggregate product event per target operation. A process generation-scope rollover revokes every direct SSH target and old-scope provider request, not only the target whose counter exhausted.", - "oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters. Join the real runtime RPC server to two independently authenticated E2EE clients plus an event-ignoring client, advance the host Git inventory, trigger duplicate structural watcher callbacks, and require one host renderer notification plus one runtime event and a refreshed catalog per subscribed client; require the event-ignoring client to refresh safely on explicit read. Hold a scan after it captures old inventory, join it from a second paired client, publish the watcher invalidation, advance Git independently, and require both overlapping RPCs to retry or join one fresh scan before returning. Disconnect one client, advance inventory again, and require reconnect to read current state without stale event replay. Give a watcher a nested SSH connection id or a local repo ID colliding with an SSH owner and require the host renderer refresh while the unqualified runtime event stays absent. Force the real path in isolated Electron apps with git worktree add, a host Git inventory assertion, store-subscription barriers, matching host/client worktree identity, and visible rows in both renderers. For direct SSH, reject catalog rows whose explicit and legacy host provenance contradict, roll one exhausted target into a fresh process generation scope while invalidating sibling target tokens, count locally unsettled attempts and round-robin admissions, keep lineage blocked through the first timeout retry, distinguish timeout/rejection/cancel/stale results, and reject telemetry properties carrying target, repo, host, path, label, user, request, lease, terminal, or raw-error data.", + "invariant": "Index-only Git activity below the common Git directory must not emit worktrees:changed, invalidate worktree caches, or trigger fetchWorktrees fanout; structural add/remove/HEAD/gitdir/locked/config.worktree changes must still refresh worktrees and nudge Source Control. When the authoritative paired host accepts an external structural change for a uniquely identified server-local repo, each live client for that host must receive one existing host-scoped worktreesChanged invalidation and converge to the same repo/worktree identity without reconnect; duplicate watcher events must coalesce, stale cached or in-flight catalogs must not survive invalidation, and event-ignoring clients must remain compatible. Nested SSH watchers and colliding local/SSH repo IDs must not publish the host-blind event until existing semantics can carry owner identity. External head moves (commit, amend, reset) must reach background worktree rows through spawn-free metadata reads, never through structural fanout. A process generation-scope rollover revokes every direct SSH target and old-scope provider request, not only the target whose counter exhausted.", + "oracle": "Classify exact git-common paths as structural, status-only, or ignored; count notifications from debounced watcher events; force the Linux/Windows poll path to emit allowlisted leaf events, detect linked HEAD rewrites independent of entry-directory mtime, and surface in-place index rewrites via the backstop re-stat; diff head identities from metadata-file reads and notify only real head moves; assert Source Control subscribes to both structural and status-only signals with active-repo and visibility filters. Join the real runtime RPC server to two independently authenticated E2EE clients plus an event-ignoring client, advance the host Git inventory, trigger duplicate structural watcher callbacks, and require one host renderer notification plus one runtime event and a refreshed catalog per subscribed client; require the event-ignoring client to refresh safely on explicit read. Hold a scan after it captures old inventory, join it from a second paired client, publish the watcher invalidation, advance Git independently, and require both overlapping RPCs to retry or join one fresh scan before returning. Disconnect one client, advance inventory again, and require reconnect to read current state without stale event replay. Give a watcher a nested SSH connection id or a local repo ID colliding with an SSH owner and require the host renderer refresh while the unqualified runtime event stays absent. Force the real path in isolated Electron apps with git worktree add, a host Git inventory assertion, store-subscription barriers, matching host/client worktree identity, and visible rows in both renderers. For direct SSH, roll one exhausted target into a fresh process generation scope while invalidating sibling target tokens, and fill a partial pushed authority before clearing that target's PTY bindings once.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/worktree-base-directory-event-filter.test.ts src/main/ipc/worktree-base-directory-watcher.test.ts src/main/ipc/worktree-base-directory-poller.test.ts src/main/ipc/worktree-head-identity-reader.test.ts src/renderer/src/hooks/worktree-head-identity-apply.test.ts src/renderer/src/components/right-sidebar/git-status-push-signal-refresh.test.ts src/renderer/src/hooks/useIpcEvents-browser-tab-create.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/watched-worktree-catalog-notification.test.ts src/main/runtime/external-worktree-paired-client-discovery.integration.test.ts src/main/window/attach-main-window-services.test.ts", "ORCA_E2E_FORCE_HEADFUL=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-external-worktree-discovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ssh/ssh-connection-generation.test.ts src/main/ssh/ssh-provider-authority.test.ts --reporter=dot", - "pnpm exec vitest run --config config/vitest.config.ts src/shared/direct-ssh-reconnect-telemetry-schema.test.ts src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts --reporter=dot" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts --reporter=dot" ], "testFiles": [ "src/main/ipc/worktree-base-directory-event-filter.test.ts", @@ -4301,11 +4298,7 @@ "src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts", "src/main/ipc/repos-remote.test.ts", "src/main/ssh/ssh-connection-generation.test.ts", - "src/main/ssh/ssh-provider-authority.test.ts", - "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts", - "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts", - "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts", - "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts" + "src/main/ssh/ssh-provider-authority.test.ts" ], "assertionRefs": [ { @@ -4367,7 +4360,7 @@ { "file": "src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts", "assertions": [ - "direct SSH coordinator telemetry is wired through the fail-soft product adapter" + "a filled partial SSH authority clears the target's PTY bindings once and a conflicting reply is rejected" ] }, { @@ -4423,36 +4416,6 @@ "generation-scope rollover invalidates every target authority before abort callbacks run", "every registered old-scope provider request aborts exactly once" ] - }, - { - "file": "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts", - "assertions": [ - "coordinator-owned locally unsettled provider work never exceeds five and target lanes round-robin", - "the first timeout remains retrying and reports queue wait separately from provider execution", - "cancel debt admits at most two replacements and terminally distinguishes budget exhaustion" - ] - }, - { - "file": "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts", - "assertions": [ - "lineage and token creation remain blocked until a timed-out repo retry settles", - "exact overlapping preparation emits one aggregate with a join count", - "telemetry callback failure cannot affect reconnect completion" - ] - }, - { - "file": "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts", - "assertions": [ - "timeout, rejection, cancellation, and stale outcomes have independent fields", - "target, repo, host, path, label, user, request, lease, terminal, and raw-error fields are rejected" - ] - }, - { - "file": "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts", - "assertions": [ - "one coordinator aggregate maps to one typed product event with queue and provider percentiles", - "adapter failure is swallowed before it can reach recovery" - ] } ], "evidenceRuns": [ @@ -4475,13 +4438,13 @@ "summary": "Three main-process catalog and authority files passed with 118 tests, including contradictory catalog provenance rejection and all-target generation-scope revocation." }, { - "date": "2026-07-27", + "date": "2026-10-11", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/direct-ssh-reconnect-telemetry-schema.test.ts src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts --reporter=dot", "result": "passed", - "durationSeconds": 3.45, - "summary": "Five focused direct SSH scheduler, coordinator, telemetry, and hook-wiring files passed with 130 tests." + "durationSeconds": 1.21, + "summary": "The direct SSH state-bridge wiring file passed with 6 tests after the reconnect coordinator, scheduler and telemetry were deleted." }, { "date": "2026-08-26", @@ -4512,11 +4475,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "The watcher count assertions fail against the old single-signal classifier because linked index events call notifyWorktreesChanged. The direct SSH tests encode failures for unbounded admission, early lineage release, merged timeout/rejection/cancel/stale results, identifier-bearing telemetry, duplicate joined events, and telemetry exceptions, but no intentional-break artifact was run or claimed; saved red/green artifacts are still needed before blocking promotion." + "evidence": "The watcher count assertions fail against the old single-signal classifier because linked index events call notifyWorktreesChanged. No intentional-break artifact was run or claimed; saved red/green artifacts are still needed before blocking promotion." }, "performanceBudget": { "required": true, - "evidence": "Index-only bursts produce zero structural notifications, so renderer fetchWorktrees and detected-worktree cache invalidation are not reached. The non-darwin poller stays bounded and non-overlapping. Direct SSH coordinator-owned detected-worktree work is capped at five locally unsettled calls with a two-call late-work allowance; terminal finalization precedes provider discovery, and queue wait and provider execution are reported separately. A 30-second live Electron run with 2,000 external linked-status calls delivered 50 ordered input chunks and recorded zero Orca-owned git worktree spawns across six diagnostic windows; no equivalent live direct SSH fanout benchmark is claimed." + "evidence": "Index-only bursts produce zero structural notifications, so renderer fetchWorktrees and detected-worktree cache invalidation are not reached. The non-darwin poller stays bounded and non-overlapping. A 30-second live Electron run with 2,000 external linked-status calls delivered 50 ordered input chunks and recorded zero Orca-owned git worktree spawns across six diagnostic windows." }, "promotionCriteria": [ "Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.", @@ -12958,7 +12921,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-error-accumulation.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-remote.test.ts src/main/ipc/ssh.test.ts src/main/ipc/worktrees-ssh-repo-owner-resolution.test.ts src/main/ipc/worktrees-lineage-hydration.test.ts src/main/runtime/public-ssh-state.test.ts src/main/ssh/ssh-connection-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/ssh/ssh-provider-authority.test.ts src/preload/ssh-authority-forwarding.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/runtime/runtime-environment-ssh-state.test.ts src/shared/ssh-retained-payload-admission.test.ts --reporter=dot", "pnpm exec electron-vite build --mode e2e", "pnpm run build:web-from-renderer", @@ -13001,8 +12964,6 @@ "src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts", "src/renderer/src/store/slices/terminals-hydration.test.ts", "src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts", - "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts", - "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts", "src/renderer/src/hooks/direct-ssh-state-routing.test.ts", "src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts", "src/main/ipc/repos-remote.test.ts", @@ -13270,20 +13231,6 @@ "a provider result becomes stale when same-ID repo ownership turns malformed or contradictory during the await" ] }, - { - "file": "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts", - "assertions": [ - "terminal invalidation and retry run synchronously before provider preparation", - "hydrated terminal finalization and same-authority correction are current-authority fenced", - "rapid authority rotation keeps immediate terminal checks while damping full preparation" - ] - }, - { - "file": "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts", - "assertions": [ - "exact-host catalog and lineage hydration preserves sibling SSH, local, runtime, ambiguous, and contradictory rows" - ] - }, { "file": "src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts", "assertions": [ @@ -13317,7 +13264,7 @@ "date": "2026-08-29", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "result": "passed", "durationSeconds": 10.7, "summary": "Fourteen direct-SSH files and 155 tests passed, including 32-arrival placement and readiness bursts with one listener/timer maximum, immediate supersession and stop cleanup, latest-only hydration, stale-push fencing, pending-capture and in-flight-write upload exclusion, and existing retry, authority, hydration, split-pane, and detach contracts." @@ -13353,7 +13300,7 @@ "date": "2026-07-28", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts src/renderer/src/hooks/direct-ssh-host-hydration.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/direct-ssh-terminal-recovery.test.ts src/renderer/src/store/slices/direct-ssh-terminal-workspace-scope.test.ts src/renderer/src/store/slices/terminals-hydration.test.ts src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts src/renderer/src/hooks/direct-ssh-state-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts --reporter=dot", "result": "passed", "durationSeconds": 15.8, "summary": "Twelve direct SSH files and 647 tests passed, including exact lease revalidation after asynchronous SSH preparation, primary-exit continuation gaps, pending-only and live null-PTY two-sided split-detach authority, delayed post-success sibling admission, stale-authority provider retirement, late ownership-provenance rejection, and deleted-tab ledger pruning." diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 6d3e961d2fe..5d0cdc3e667 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -2841,7 +2841,6 @@ "src/main/ipc/worktrees/listing/detected-worktree-scan-listing-distro.test.ts": 559, "src/main/ipc/worktrees/listing/detected-worktree-scan-superseded.test.ts": 68, "src/main/ipc/worktrees/listing/register-sparse-checkout-cache-invalidation.test.ts": 197, - "src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts": 80, "src/main/ipc/zcode-plan-credentials.test.ts": 58, "src/main/issue-command-ignore.test.ts": 620, "src/main/jcode/daemon-prewarm.test.ts": 54, @@ -9019,14 +9018,8 @@ "src/renderer/src/hooks/composer-state/multiple-create-reset.test.ts": 1678, "src/renderer/src/hooks/composer-state/provider-runtime-sync.test.ts": 1661, "src/renderer/src/hooks/composer-state/quick-creation-request.test.ts": 110, - "src/renderer/src/hooks/direct-ssh-host-catalog-noop.test.ts": 1364, - "src/renderer/src/hooks/direct-ssh-host-hydration.test.ts": 79, - "src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts": 130, - "src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts": 49, - "src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts": 41, "src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx": 1605, "src/renderer/src/hooks/direct-ssh-state-routing.test.ts": 183, - "src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts": 72, "src/renderer/src/hooks/editor-external-watch-fallback-explorer.test.ts": 355, "src/renderer/src/hooks/editor-external-watch-selected-host.test.ts": 324, "src/renderer/src/hooks/editor-runtime-file-watch.test.ts": 55, @@ -9298,7 +9291,6 @@ "src/renderer/src/lib/desktop-window-chrome.test.ts": 56, "src/renderer/src/lib/diff-comment-compat.test.ts": 50, "src/renderer/src/lib/diff-comments-format.test.ts": 47, - "src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts": 56, "src/renderer/src/lib/direct-ssh-target-scope.test.ts": 70, "src/renderer/src/lib/doc-preview-grants.test.ts": 370, "src/renderer/src/lib/document-theme.test.ts": 55, @@ -10835,7 +10827,6 @@ "src/shared/cross-platform-path.test.ts": 55, "src/shared/currency-format.test.ts": 47, "src/shared/daemon-adoption-telemetry.test.ts": 349, - "src/shared/direct-ssh-reconnect-telemetry-schema.test.ts": 279, "src/shared/doc-preview-file-access.test.ts": 93, "src/shared/doc-preview-scheme.test.ts": 44, "src/shared/draft-paste-ready-scanner-grok-trace-replay.test.ts": 75, diff --git a/src/main/ipc/repos-execution-host-catalog.test.ts b/src/main/ipc/repos-execution-host-catalog.test.ts index 6e4de0adbdd..74b9626574d 100644 --- a/src/main/ipc/repos-execution-host-catalog.test.ts +++ b/src/main/ipc/repos-execution-host-catalog.test.ts @@ -30,13 +30,8 @@ vi.mock('../ssh/ssh-target-registry', () => moduleMocks.sshModuleMock(reposMocks import { registerRepoHandlers } from './repos' import { clearGitCapabilityStateForTests } from '../git/git-capability-state' -import { - getSshProviderAuthority, - resetSshProviderAuthorities, - rotateSshProviderAuthority -} from '../ssh/ssh-provider-authority' +import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { getGitRepoRoot, isGitRepo } from '../git/repo' -import { toSshExecutionHostId } from '../../shared/execution-host' import { createRepoHandlerHarness, resetProjectGroupMocks } from './repos-remote-test-harness' const { handleMock, mockStore } = reposMocks @@ -65,128 +60,6 @@ describe('projectGroups IPC validation', () => { expect(mockStore.createProjectGroup).not.toHaveBeenCalled() }) - it('returns an immutable repo catalog for exactly one execution host', async () => { - const localRepo = { - id: 'duplicate', - path: '/local/repo', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - } - const sshRepo = { - id: 'duplicate', - path: '/remote/repo', - displayName: 'remote', - badgeColor: '#000', - addedAt: 0, - connectionId: 'conn-1' - } - const runtimeRepo = { - id: 'duplicate', - path: '/runtime/repo', - displayName: 'runtime', - badgeColor: '#000', - addedAt: 0, - executionHostId: 'runtime:environment-a' - } - mockStore.getRepos.mockReturnValue([localRepo, sshRepo, runtimeRepo]) - - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1'), - expectedAuthority: getSshProviderAuthority('conn-1') - }) - ).resolves.toMatchObject({ - authoritative: true, - authority: { - kind: 'direct-ssh', - executionHostId: 'ssh:conn-1', - targetId: 'conn-1' - }, - repos: [sshRepo] - }) - - const local = await handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: 'local' - }) - expect(local).toMatchObject({ authoritative: true, repos: [localRepo] }) - expect((local as { repos: object[] }).repos[0]).not.toBe(localRepo) - }) - - it('rejects repo catalogs whose execution host contradicts their SSH connection', async () => { - const baseRepo = { - id: 'repo-1', - path: '/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0 - } - mockStore.getRepos.mockReturnValue([ - { - ...baseRepo, - connectionId: 'conn-1', - executionHostId: 'local' - } - ]) - - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: 'local' - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1'), - expectedAuthority: getSshProviderAuthority('conn-1') - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - - mockStore.getRepos.mockReturnValue([ - { - ...baseRepo, - connectionId: 'conn-1', - executionHostId: toSshExecutionHostId('conn-2') - } - ]) - - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1'), - expectedAuthority: getSshProviderAuthority('conn-1') - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - }) - - it('rejects runtime, partial, mismatched, and stale catalog authority', async () => { - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: 'runtime:environment-a' - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1') - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - await expect( - handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1'), - expectedAuthority: { - ...getSshProviderAuthority('other-target'), - targetId: 'other-target' - } - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - - const expectedAuthority = getSshProviderAuthority('conn-1') - const pending = handlers.get('repos:listForExecutionHost')!(null, { - executionHostId: toSshExecutionHostId('conn-1'), - expectedAuthority - }) - rotateSshProviderAuthority('conn-1') - await expect(pending).resolves.toMatchObject({ authoritative: false, reason: 'stale' }) - }) - it('rejects malformed local project group update arguments before persistence', () => { expect(() => handlers.get('projectGroups:update')!(null, { diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 9e7a4175c22..3c12365aa0d 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -22,7 +22,6 @@ export function registerRepoHandlers( ): void { // Remove previously registered handlers so we can re-register on macOS app re-activation (new window). ipcMain.removeHandler('repos:list') - ipcMain.removeHandler('repos:listForExecutionHost') ipcMain.removeHandler('repos:add') ipcMain.removeHandler('repos:removeForHost') ipcMain.removeHandler('repos:reorder') diff --git a/src/main/ipc/repos/host-repo-catalog-snapshot.ts b/src/main/ipc/repos/host-repo-catalog-snapshot.ts deleted file mode 100644 index 3e16cf6245b..00000000000 --- a/src/main/ipc/repos/host-repo-catalog-snapshot.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { Store } from '../../persistence' -import type { Repo } from '../../../shared/repo-types' -import type { - HostRepoCatalogSnapshot, - ListReposForExecutionHostArgs -} from '../../../shared/host-repo-catalog-contract' -import { - getRepoExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId -} from '../../../shared/execution-host' -import { isAdmissibleDirectSshAuthority } from '../../../shared/ssh-retained-payload-admission' -import { isCurrentSshProviderAuthority } from '../../ssh/ssh-provider-authority' -import { getSshGitProvider } from '../../providers/ssh-git-dispatch' -import { visibleRepos } from '../../ssh/orcad-retained-source' - -function hasValidCatalogSshAuthority( - args: ListReposForExecutionHostArgs -): args is Extract { - if (!('expectedAuthority' in args)) { - return false - } - return isAdmissibleDirectSshAuthority(args.expectedAuthority) -} - -function repoHostContradictsConnection(repo: Repo): boolean { - if (!repo.executionHostId || !repo.connectionId) { - return false - } - const explicitHost = parseExecutionHostId(repo.executionHostId) - return explicitHost?.kind !== 'ssh' || explicitHost.targetId !== repo.connectionId -} - -function getConsistentRepoCatalogForHost( - repos: readonly Repo[], - host: NonNullable> -): Repo[] | null { - const hasContradiction = repos.some( - (repo) => - repoHostContradictsConnection(repo) && - (getRepoExecutionHostId(repo) === host.id || - (host.kind === 'ssh' && repo.connectionId === host.targetId)) - ) - return hasContradiction ? null : repos.filter((repo) => getRepoExecutionHostId(repo) === host.id) -} - -export async function listReposForExecutionHost( - store: Store, - args: ListReposForExecutionHostArgs -): Promise { - const parsedHost = parseExecutionHostId(args?.executionHostId) - const rejected = ( - reason: Extract['reason'] - ): HostRepoCatalogSnapshot => ({ - authoritative: false, - executionHostId: args.executionHostId, - reason - }) - if (!parsedHost || parsedHost.kind === 'runtime') { - return rejected('rejected') - } - if (parsedHost.kind === 'local') { - if ('expectedAuthority' in args) { - return rejected('rejected') - } - const repos = getConsistentRepoCatalogForHost(visibleRepos(store), parsedHost) - if (!repos) { - return rejected('rejected') - } - return { - authoritative: true, - authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, - repos: structuredClone(repos) - } - } - if ( - !hasValidCatalogSshAuthority(args) || - args.expectedAuthority.targetId !== parsedHost.targetId - ) { - return rejected('rejected') - } - const authority = { ...args.expectedAuthority } - if (!isCurrentSshProviderAuthority(authority)) { - return rejected('stale') - } - const provider = getSshGitProvider(parsedHost.targetId) - if (!provider) { - return rejected('unavailable') - } - const matchingRepos = getConsistentRepoCatalogForHost(visibleRepos(store), parsedHost) - if (!matchingRepos) { - return rejected('rejected') - } - const repos = structuredClone(matchingRepos) - await Promise.resolve() - if ( - getSshGitProvider(parsedHost.targetId) !== provider || - !isCurrentSshProviderAuthority(authority) - ) { - return rejected('stale') - } - return { - authoritative: true, - authority: { - kind: 'direct-ssh', - executionHostId: parsedHost.id, - ...authority - }, - repos - } -} diff --git a/src/main/ipc/repos/repo-catalog-handlers.ts b/src/main/ipc/repos/repo-catalog-handlers.ts index 608d9105f19..ddb5d058056 100644 --- a/src/main/ipc/repos/repo-catalog-handlers.ts +++ b/src/main/ipc/repos/repo-catalog-handlers.ts @@ -2,17 +2,12 @@ import type { BrowserWindow } from 'electron' import { ipcMain } from 'electron' import type { Store } from '../../persistence' import type { Project, ProjectUpdateArgs } from '../../../shared/project-types' -import type { - HostRepoCatalogSnapshot, - ListReposForExecutionHostArgs -} from '../../../shared/host-repo-catalog-contract' import { normalizeExecutionHostId } from '../../../shared/execution-host' import { enrichRepoGitUsernames } from '../../repo-git-username-enrichment' import { enrichMissingRepoGitRemoteIdentities } from '../../repo-git-remote-identity-enrichment' import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cache' import { notifyReposChanged } from './repos-changed-notification' import { ProjectUpdateIpcArgs, parseProjectGroupIpcArgs } from './repo-ipc-arg-schemas' -import { listReposForExecutionHost } from './host-repo-catalog-snapshot' import { visibleRepos } from '../../ssh/orcad-retained-source' export function registerRepoCatalogHandlers(mainWindow: BrowserWindow, store: Store): void { @@ -27,12 +22,6 @@ export function registerRepoCatalogHandlers(mainWindow: BrowserWindow, store: St return visibleRepos(store) }) - ipcMain.handle( - 'repos:listForExecutionHost', - (_event, args: ListReposForExecutionHostArgs): Promise => - listReposForExecutionHost(store, args) - ) - ipcMain.handle('projects:list', () => { enrichMissingRepoGitRemoteIdentities(store, { onChanged: broadcastReposChanged }) return store.getProjects() diff --git a/src/main/ipc/worktree-create-lineage.test.ts b/src/main/ipc/worktree-create-lineage.test.ts index fa7e101ac3f..46c6e4d2a63 100644 --- a/src/main/ipc/worktree-create-lineage.test.ts +++ b/src/main/ipc/worktree-create-lineage.test.ts @@ -150,7 +150,6 @@ describe('recordWorkspaceLineageForCreatedWorktree', () => { expect(store.setWorktreeLineage).not.toHaveBeenCalled() expect(result.lineage).toBeNull() - // A persisted cross-host row makes filterLineageForHost return null for the entire host. expect(store.setWorkspaceLineage).not.toHaveBeenCalled() expect(result.workspaceLineage).toBeNull() } diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 2a78b770e36..db38fec58da 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -285,9 +285,7 @@ export function recordWorkspaceLineageForCreatedWorktree( } } - // Why: persisting a workspace row for an out-of-boundary worktree parent poisons the whole host — - // `filterLineageForHost` returns null for any owned row whose child and parent hosts differ, so - // every nesting on that host stops hydrating, and the row survives restarts. + // Why: a workspace row for an out-of-boundary worktree parent is a cross-host link that survives restarts. if (parentOutsideLineageBoundary) { return NO_CREATED_WORKTREE_LINEAGE } diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index e0e7b6cc0e4..0109057430a 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -36,7 +36,6 @@ const WORKTREE_HANDLER_CHANNELS = [ 'worktrees:forceDeletePreservedBranch', 'worktrees:updateMeta', 'worktrees:listLineage', - 'worktrees:listLineageForHost', 'worktrees:updateLineage', 'worktrees:persistSortOrder', 'worktrees:getBranchRenameFailureOutput', diff --git a/src/main/ipc/worktrees-lineage-hydration.test.ts b/src/main/ipc/worktrees-lineage-hydration.test.ts index 68de6fb5dd3..08163de9d87 100644 --- a/src/main/ipc/worktrees-lineage-hydration.test.ts +++ b/src/main/ipc/worktrees-lineage-hydration.test.ts @@ -1,13 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { Worktree } from '../../shared/worktree/types' -import { toSshExecutionHostId } from '../../shared/execution-host' -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' +import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' import { makeWorktreeMeta } from './worktrees-test-fixtures' -import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub' vi.mock('electron', async () => (await import('./worktrees-test-module-mocks')).electronModuleMock() @@ -92,156 +88,8 @@ vi.mock('../runtime/worktree-teardown', async () => vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) describe('registerWorktreeHandlers', () => { - let runtimeStub: WorktreeRuntimeStub - beforeEach(() => { - runtimeStub = setupWorktreeHandlers() - }) - - it('answers SSH lineage as unavailable without reading this machine', async () => { - const result = await handlers['worktrees:listLineageForHost'](ipcEvent, { - executionHostId: toSshExecutionHostId('target-a'), - expectedAuthority: getSshProviderAuthority('target-a') - }) - - expect(result).toEqual({ - authoritative: false, - executionHostId: 'ssh:target-a', - reason: 'unavailable' - }) - expect(runtimeStub.hydrateInferredWorktreeLineage).not.toHaveBeenCalled() - expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - }) - - it('snapshots lineage catalogs once and memoizes repeated owner resolution', async () => { - const worktreeIds = Array.from( - { length: 101 }, - (_, index) => `repo-1::/workspace/repo-${index}` - ) - const lineage = Object.fromEntries( - worktreeIds.slice(1).map((worktreeId, index) => [ - worktreeId, - { - worktreeId, - worktreeInstanceId: `child-${index}`, - parentWorktreeId: worktreeIds[index], - parentWorktreeInstanceId: `parent-${index}`, - origin: 'cli', - capture: { source: 'cwd-context', confidence: 'inferred' }, - createdAt: index - } - ]) - ) - store.getAllWorktreeLineage.mockReturnValue(lineage) - store.getRepos.mockClear() - store.getFolderWorkspaces.mockClear() - store.getProjectGroups.mockClear() - store.getWorktreeMeta.mockClear() - - const result = await handlers['worktrees:listLineageForHost'](ipcEvent, { - executionHostId: 'local' - }) - - expect(result).toMatchObject({ authoritative: true }) - expect( - Object.keys((result as { worktreeLineageById: Record }).worktreeLineageById) - ).toHaveLength(100) - expect(store.getRepos).toHaveBeenCalledOnce() - expect(store.getFolderWorkspaces).toHaveBeenCalledOnce() - expect(store.getProjectGroups).toHaveBeenCalledOnce() - expect(store.getWorktreeMeta).toHaveBeenCalledTimes(101) - }) - - it('preserves ambiguous legacy lineage instead of guessing among duplicate repo owners', async () => { - const child = 'duplicate::/child' - const parent = 'duplicate::/parent' - store.getRepos.mockReturnValue([ - { - id: 'duplicate', - path: '/local', - displayName: 'local', - badgeColor: '#000', - addedAt: 0 - }, - { - id: 'duplicate', - path: '/remote', - displayName: 'remote', - badgeColor: '#000', - addedAt: 0, - connectionId: 'target-a' - } - ]) - store.getAllWorktreeLineage.mockReturnValue({ - [child]: { - worktreeId: child, - worktreeInstanceId: 'child', - parentWorktreeId: parent, - parentWorktreeInstanceId: 'parent', - origin: 'cli', - capture: { source: 'cwd-context', confidence: 'inferred' }, - createdAt: 1 - } - }) - - await expect( - handlers['worktrees:listLineageForHost'](ipcEvent, { executionHostId: 'local' }) - ).resolves.toEqual({ - authoritative: false, - executionHostId: 'local', - reason: 'ambiguous-owner' - }) - expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - }) - - it('rejects runtime lineage reads and stale SSH authority', async () => { - await expect( - handlers['worktrees:listLineageForHost'](ipcEvent, { - executionHostId: 'runtime:environment-a' - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'rejected' }) - expect(runtimeStub.hydrateInferredWorktreeLineage).not.toHaveBeenCalled() - - const authority = getSshProviderAuthority('target-a') - rotateSshProviderAuthority('target-a') - - await expect( - handlers['worktrees:listLineageForHost'](ipcEvent, { - executionHostId: toSshExecutionHostId('target-a'), - expectedAuthority: authority - }) - ).resolves.toMatchObject({ authoritative: false, reason: 'stale' }) - expect(store.removeWorktreeLineage).not.toHaveBeenCalled() - }) - - it('bounds noncooperative lineage hydration and permits a later read', async () => { - vi.useFakeTimers() - try { - runtimeStub.hydrateInferredWorktreeLineage.mockReturnValue(new Promise(() => {})) - const pending = handlers['worktrees:listLineageForHost'](ipcEvent, { - executionHostId: 'local' - }) - await vi.advanceTimersByTimeAsync(LINEAGE_HYDRATION_TIMEOUT_MS - 1) - let settled = false - void Promise.resolve(pending).finally(() => { - settled = true - }) - await Promise.resolve() - expect(settled).toBe(false) - await vi.advanceTimersByTimeAsync(1) - await expect(pending).resolves.toMatchObject({ - authoritative: false, - reason: 'unavailable' - }) - expect(vi.getTimerCount()).toBe(0) - - runtimeStub.hydrateInferredWorktreeLineage.mockResolvedValue(undefined) - await expect( - handlers['worktrees:listLineageForHost'](ipcEvent, { executionHostId: 'local' }) - ).resolves.toMatchObject({ authoritative: true }) - } finally { - vi.useRealTimers() - } + setupWorktreeHandlers() }) it('hydrates detected worktrees with instance-validated legacy lineage after an update', async () => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 0e06b3ee0ec..c581c9f2e15 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -47,7 +47,6 @@ const WORKTREE_HANDLER_CHANNELS = [ 'worktrees:forceDeletePreservedBranch', 'worktrees:updateMeta', 'worktrees:listLineage', - 'worktrees:listLineageForHost', 'worktrees:updateLineage', 'worktrees:persistSortOrder', 'worktrees:getBranchRenameFailureOutput', diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts index 491a2342ee2..a5b78aa53de 100644 --- a/src/main/ipc/worktrees/listing/detected-provider-listing.ts +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -14,7 +14,6 @@ import { isFolderRepo } from '../../../../shared/repo-kind' import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' 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, @@ -206,12 +205,3 @@ export function hasValidDirectSshAuthority( ): 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/metadata/host-lineage-listing.ts b/src/main/ipc/worktrees/metadata/host-lineage-listing.ts deleted file mode 100644 index 48a25ec9d4c..00000000000 --- a/src/main/ipc/worktrees/metadata/host-lineage-listing.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 { 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') - } - if (parsedHost.kind === 'ssh') { - if ( - !hasValidLineageSshAuthority(args) || - args.expectedAuthority.targetId !== parsedHost.targetId - ) { - return rejected('rejected') - } - // An `ssh:` host is not served here, so its lineage is never answered from this machine. - return rejected( - isCurrentSshProviderAuthority({ ...args.expectedAuthority }) ? 'unavailable' : 'stale' - ) - } - if ('expectedAuthority' in args) { - return rejected('rejected') - } - if (!(await hydrateLineageWithinDeadline(runtime))) { - return rejected('unavailable') - } - const lineage = filterLineageForHost(store, parsedHost.id) - if (!lineage) { - return rejected('ambiguous-owner') - } - return { - authoritative: true, - authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, - ...lineage - } -} diff --git a/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts b/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts deleted file mode 100644 index a8a58bf81d1..00000000000 --- a/src/main/ipc/worktrees/metadata/lineage-owner-resolution.ts +++ /dev/null @@ -1,123 +0,0 @@ -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 index 0fe85e09884..1cb36b600b1 100644 --- a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts +++ b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts @@ -4,17 +4,12 @@ import { displayNameUpdatePinsLabel } from '../../../../shared/worktree/display- import { parseExecutionHostId } from '../../../../shared/execution-host' 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 { isRemovedFolderWorkspaceInstance } from '../../../folder-workspace-meta-presence' -import { listDesktopLineageForHost } from './host-lineage-listing' import type { WorktreeIpcContext } from '../worktree-ipc-context' export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): void { @@ -72,12 +67,6 @@ export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): v } }) - 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 }) => { diff --git a/src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts b/src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts deleted file mode 100644 index e3ccee07086..00000000000 --- a/src/main/ipc/worktrees/metadata/workspace-lineage-candidate-scan.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../../shared/repo-types' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' -import { createLineageResolutionContext } from './lineage-owner-resolution' -import { getFolderLineageCandidateRepos } from './workspace-lineage-filtering' - -vi.mock('../../worktree-logic', () => ({ parseWorktreeId: vi.fn() })) - -function repo(id: string, fields: Partial = {}): Repo { - return { id, path: `/root/${id}`, displayName: id, badgeColor: '#000', addedAt: 0, ...fields } -} - -function group(id = 'group', fields: Partial = {}): ProjectGroup { - return { - id, - name: id, - parentPath: null, - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 0, - updatedAt: 0, - ...fields - } -} - -function folder(fields: Partial = {}): FolderWorkspace { - return { - id: 'folder', - projectGroupId: 'group', - name: 'folder', - folderPath: '/root', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 0, - createdAt: 0, - updatedAt: 0, - ...fields - } -} - -function context(repos: Repo[], groups = [group()]) { - const store = { - getRepos: () => repos, - getFolderWorkspaces: () => [], - getProjectGroups: () => groups - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Context construction only reads these three store catalogs. - return createLineageResolutionContext(store as never) -} - -describe('folder lineage candidate lookup', () => { - it('reads grouped target membership once across a large path-candidate list', () => { - let targetReads = 0 - const grouped = Array.from({ length: 300 }, (_, index): Repo => ({ - ...repo(`grouped-${index}`, { projectGroupId: 'group', path: `/elsewhere/${index}` }), - get connectionId() { - targetReads += 1 - return null - } - })) - const candidates = Array.from({ length: 1_000 }, (_, index) => repo(`path-${index}`)) - const result = getFolderLineageCandidateRepos(context([...grouped, ...candidates]), folder()) - - expect(result).toEqual([...grouped, ...candidates]) - expect(targetReads).toBe(300) - }) - - it('preserves descendant-group priority, path boundaries and local/SSH membership', () => { - const entries = [ - repo('path-a', { connectionId: 'a' }), - repo('group-b', { projectGroupId: 'nested', path: '/elsewhere', connectionId: 'b' }), - repo('local-null', { connectionId: null }), - repo('group-local', { projectGroupId: 'group' }), - repo('path-b', { connectionId: 'b' }), - repo('local-absent'), - repo('sibling', { path: '/root-other/repo', connectionId: 'b' }), - repo('root', { path: '/root', connectionId: 'b' }) - ] - const snapshot = structuredClone(entries) - const result = getFolderLineageCandidateRepos( - context(entries, [group(), group('nested', { parentGroupId: 'group' })]), - folder() - ) - - expect(result.map((entry) => entry.id)).toEqual([ - 'group-b', - 'group-local', - 'local-null', - 'path-b', - 'local-absent', - 'root' - ]) - expect(result[0]).toBe(entries[1]) - expect(entries).toEqual(snapshot) - }) - - it.each([ - { folderTarget: 'a', groupTarget: 'b', expected: ['grouped', 'a'] }, - { folderTarget: null, groupTarget: 'b', expected: ['grouped', 'b'] }, - { folderTarget: undefined, groupTarget: undefined, expected: ['grouped', 'local'] } - ])( - 'keeps explicit target precedence: $folderTarget / $groupTarget', - ({ folderTarget, groupTarget, expected }) => { - const entries = [ - repo('a', { connectionId: 'a' }), - repo('b', { connectionId: 'b' }), - repo('local'), - repo('grouped', { projectGroupId: 'group', path: '/outside' }) - ] - expect( - getFolderLineageCandidateRepos( - context(entries, [group('group', { connectionId: groupTarget })]), - folder({ connectionId: folderTarget }) - ).map((entry) => entry.id) - ).toEqual(expected) - } - ) - - it('keeps every contained target when the folder has no grouped repositories', () => { - const entries = [ - repo('local'), - repo('remote', { connectionId: 'a' }), - repo('outside', { path: '/elsewhere' }) - ] - expect(getFolderLineageCandidateRepos(context(entries), folder())).toEqual(entries.slice(0, 2)) - }) - - it('does not read grouped targets when there are no path candidates', () => { - let targetReads = 0 - const grouped: Repo = { - ...repo('grouped', { projectGroupId: 'group' }), - get connectionId() { - targetReads += 1 - return 'a' - } - } - expect(getFolderLineageCandidateRepos(context([grouped]), folder())).toEqual([grouped]) - expect(targetReads).toBe(0) - }) - - it('rebuilds target membership after a later change in the same context', () => { - const grouped = repo('grouped', { projectGroupId: 'group', connectionId: 'a' }) - const a = repo('a', { connectionId: 'a' }) - const b = repo('b', { connectionId: 'b' }) - const current = context([grouped, a, b]) - expect(getFolderLineageCandidateRepos(current, folder())).toEqual([grouped, a]) - grouped.connectionId = 'b' - expect(getFolderLineageCandidateRepos(current, folder())).toEqual([grouped, b]) - }) -}) diff --git a/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts b/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts deleted file mode 100644 index c478b59b81a..00000000000 --- a/src/main/ipc/worktrees/metadata/workspace-lineage-filtering.ts +++ /dev/null @@ -1,186 +0,0 @@ -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 - if (connectionId) { - return [...grouped, ...pathRepos.filter((repo) => (repo.connectionId ?? null) === connectionId)] - } - if (grouped.length === 0) { - return pathRepos - } - if (pathRepos.length === 0) { - return grouped - } - const groupedConnectionIds = new Set(grouped.map((repo) => repo.connectionId ?? null)) - return [ - ...grouped, - ...pathRepos.filter((repo) => groupedConnectionIds.has(repo.connectionId ?? null)) - ] -} - -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/ssh/orcad-retained-source-lists.test.ts b/src/main/ssh/orcad-retained-source-lists.test.ts index 3c9f2903e99..6ada0e73d81 100644 --- a/src/main/ssh/orcad-retained-source-lists.test.ts +++ b/src/main/ssh/orcad-retained-source-lists.test.ts @@ -6,7 +6,6 @@ import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' import type { SshTarget } from '../../shared/ssh-types' -import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission' import type { Store } from '../persistence' import { orcadMigrationCutoverFixture } from './orcad-migration-cutover-fixture' import { writeOrcadMigrationSourceCutover } from './orcad-migration-cutover-journal' @@ -15,13 +14,9 @@ const userData = vi.hoisted(() => ({ dir: '' })) vi.mock('../../shared/app-environment', () => ({ getAppEnvironment: () => ({ getPath: () => userData.dir }) })) -vi.mock('./ssh-provider-authority', () => ({ isCurrentSshProviderAuthority: () => true })) -const provider = {} -vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: () => provider })) const { isFrozenOrcadSourceSessionPartition, visibleProjectGroups } = await import('./orcad-retained-source') -const { listReposForExecutionHost } = await import('../ipc/repos/host-repo-catalog-snapshot') const FENCED: SshTarget = { id: 'ssh-box', @@ -125,16 +120,4 @@ describe('lists while a converted host keeps its source rows', () => { rmSync(userDataPath, { recursive: true, force: true }) } }) - - it('leaves the source repos out of the host catalog the SSH bridge hydrates from', async () => { - const authority: unknown = { targetId: FENCED.id, providerEpoch: 'e1', connectionGeneration: 1 } - if (!isAdmissibleDirectSshAuthority(authority)) { - throw new Error('fixture authority rejected') - } - const snapshot = await listReposForExecutionHost(catalog([FENCED]), { - executionHostId: `ssh:${FENCED.id}`, - expectedAuthority: authority - }) - expect(snapshot).toMatchObject({ authoritative: true, repos: [] }) - }) }) diff --git a/src/preload/api/repos-bridge.ts b/src/preload/api/repos-bridge.ts index b7eae7dbb03..2357912c640 100644 --- a/src/preload/api/repos-bridge.ts +++ b/src/preload/api/repos-bridge.ts @@ -1,8 +1,4 @@ import { ipcRenderer } from 'electron' -import type { - HostRepoCatalogSnapshot, - ListReposForExecutionHostArgs -} from '../../shared/host-repo-catalog-contract' import type { BaseRefDefaultResult, BaseRefSearchResult } from '../../shared/repo-types' import type { ExecutionHostId } from '../../shared/execution-host' import type { PreloadApi } from '../api-types' @@ -10,9 +6,6 @@ import type { PreloadApi } from '../api-types' export const reposApi = { list: () => ipcRenderer.invoke('repos:list'), - listForExecutionHost: (args: ListReposForExecutionHostArgs): Promise => - ipcRenderer.invoke('repos:listForExecutionHost', args), - add: (args) => ipcRenderer.invoke('repos:add', args), addRemote: (args) => ipcRenderer.invoke('repos:addRemote', args), diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index dd76d3f1b23..2efa64fcbf6 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -1,9 +1,5 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { GhAccountBinding } from '../../shared/github/account-binding' -import type { - HostRepoCatalogSnapshot, - ListReposForExecutionHostArgs -} from '../../shared/host-repo-catalog-contract' import type { NestedRepoScanResult, ProjectGroup, @@ -27,7 +23,6 @@ import type { BaseRefDefaultResult, BaseRefSearchResult, Repo } from '../../shar export type RepositoryApi = { list: () => Promise - listForExecutionHost?: (args: ListReposForExecutionHostArgs) => Promise // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. add: (args: { path: string diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts index a0b17122722..aa4bb092182 100644 --- a/src/preload/api/worktree-api.ts +++ b/src/preload/api/worktree-api.ts @@ -15,10 +15,6 @@ import type { FolderWorkspacePathStatus, FolderWorkspacePathStatusRequest } from '../../shared/folder-workspace-path-status' -import type { - HostLineageSnapshot, - ListDesktopLineageForHostArgs -} from '../../shared/host-lineage-contract' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { WorktreeBaseStatusEvent, @@ -131,7 +127,6 @@ export type WorktreeApi = { lineage: Record workspaceLineage?: Record }> - listLineageForHost?: (args: ListDesktopLineageForHostArgs) => Promise updateLineage: (args: { worktreeId: string parentWorktreeId?: string diff --git a/src/preload/api/worktrees-bridge.ts b/src/preload/api/worktrees-bridge.ts index b141c1cd17a..1dc4dfdd67e 100644 --- a/src/preload/api/worktrees-bridge.ts +++ b/src/preload/api/worktrees-bridge.ts @@ -1,8 +1,4 @@ import { ipcRenderer } from 'electron' -import type { - HostLineageSnapshot, - ListDesktopLineageForHostArgs -} from '../../shared/host-lineage-contract' import type { WorktreeBaseStatusEvent, WorktreeRemoteBranchConflictEvent @@ -60,9 +56,6 @@ export const worktreesApi = { listLineage: () => ipcRenderer.invoke('worktrees:listLineage'), - listLineageForHost: (args: ListDesktopLineageForHostArgs): Promise => - ipcRenderer.invoke('worktrees:listLineageForHost', args), - updateLineage: (args) => ipcRenderer.invoke('worktrees:updateLineage', args), persistSortOrder: (args) => ipcRenderer.invoke('worktrees:persistSortOrder', args), diff --git a/src/renderer/src/hooks/direct-ssh-host-catalog-noop.test.ts b/src/renderer/src/hooks/direct-ssh-host-catalog-noop.test.ts deleted file mode 100644 index 0568f79bf9b..00000000000 --- a/src/renderer/src/hooks/direct-ssh-host-catalog-noop.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { Repo } from '../../../shared/repo-types' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import { createTestStore } from '../store/slices/store-test-helpers' -import { createDirectSshHostHydration } from './direct-ssh-host-hydration' - -const authority: DirectSshAuthority = { - targetId: 'target-a', - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture uses one opaque epoch identity throughout. - providerEpoch: 'catalog-epoch' as SshProviderEpoch, - connectionGeneration: 1 -} - -const remoteRepo: Repo = { - id: 'shared', - path: '/srv/repo', - displayName: 'Remote repo', - badgeColor: '', - addedAt: 1, - connectionId: authority.targetId, - executionHostId: 'ssh:target-a', - hookSettings: { mode: 'auto', scripts: { setup: 'echo ready', archive: '' } }, - symlinkPaths: ['.env'], - repoIcon: { type: 'lucide', name: 'Box' } -} - -function fixture() { - const localRepo: Repo = { - ...remoteRepo, - path: '/local/repo', - connectionId: null, - executionHostId: 'local' - } - const folderRepo: Repo = { ...remoteRepo, id: 'folder', kind: 'folder', path: '/srv/folder' } - const initialRepos = [remoteRepo, localRepo, folderRepo] - const store = createTestStore() - store.setState({ - repos: initialRepos, - manualRepoOrder: [ - { hostId: 'ssh:target-a', repoId: 'shared' }, - { hostId: 'local', repoId: 'shared' }, - { hostId: 'ssh:target-a', repoId: 'folder' } - ] - }) - let fetched: readonly Repo[] = [remoteRepo, folderRepo] - const hydration = createDirectSshHostHydration({ - store, - listRepos: async () => ({ - authoritative: true, - authority: { kind: 'direct-ssh', executionHostId: 'ssh:target-a', ...authority }, - repos: structuredClone(fetched) - }), - listLineage: vi.fn(), - isCurrentAuthority: () => true - }) - const listener = vi.fn() - store.subscribe(listener) - return { - store, - hydration, - listener, - initialRepos, - localRepo, - folderRepo, - setFetched: (repos: readonly Repo[]) => { - fetched = repos - } - } -} - -describe('direct SSH host catalog publication', () => { - it('does not publish cloned equal repos across twenty refreshes', async () => { - const { store, hydration, listener, initialRepos } = fixture() - const initialState = store.getState() - for (let index = 0; index < 20; index++) { - await hydration.capturePreparationInput(authority, 'wake-refresh') - } - expect(store.getState().repos).toBe(initialRepos) - expect(store.getState()).toBe(initialState) - expect(listener).not.toHaveBeenCalled() - hydration.stop() - }) - - it('publishes a nested change once and retains equal sibling identities', async () => { - const { store, hydration, listener, localRepo, folderRepo, setFetched } = fixture() - const changed = { ...remoteRepo, symlinkPaths: ['.env', '.env.local'] } - setFetched([changed, folderRepo]) - await hydration.capturePreparationInput(authority, 'wake-refresh') - expect(store.getState().repos).toEqual([changed, localRepo, folderRepo]) - expect(store.getState().repos[0]).not.toBe(remoteRepo) - expect(store.getState().repos[1]).toBe(localRepo) - expect(store.getState().repos[2]).toBe(folderRepo) - expect(listener).toHaveBeenCalledOnce() - await hydration.capturePreparationInput(authority, 'wake-refresh') - expect(listener).toHaveBeenCalledOnce() - hydration.stop() - }) - - it('removes only target rows and retains manual order when adding a repo', async () => { - const { store, hydration, listener, localRepo, folderRepo, setFetched } = fixture() - const added = { ...remoteRepo, id: 'added', path: '/srv/added' } - setFetched([added, folderRepo]) - await hydration.capturePreparationInput(authority, 'wake-refresh') - expect(store.getState().repos).toEqual([localRepo, folderRepo, added]) - expect(store.getState().repos[0]).toBe(localRepo) - expect(store.getState().repos[1]).toBe(folderRepo) - expect(listener).toHaveBeenCalledOnce() - hydration.stop() - }) - - it('still advances catalog observation revisions when rows stay equal', async () => { - const { hydration, listener } = fixture() - const first = await hydration.capturePreparationInput(authority, 'reconnect') - const second = await hydration.capturePreparationInput(authority, 'wake-refresh') - expect(first).not.toBeNull() - expect(second?.catalogRevision).toBe(first!.catalogRevision + 1) - const repoFingerprint = JSON.stringify([ - ['ssh:target-a', 'folder'], - ['ssh:target-a', 'shared'] - ]) - expect( - hydration.isPreparationTokenCurrent({ - authority, - catalogRevision: first!.catalogRevision, - repoFingerprint - }) - ).toBe(false) - expect( - hydration.isPreparationTokenCurrent({ - authority, - catalogRevision: second!.catalogRevision, - repoFingerprint - }) - ).toBe(true) - expect(listener).not.toHaveBeenCalled() - hydration.stop() - }) - - // Why these two shapes: the gate must fail closed on a field no previous row carried, so a - // `Repo` field added after this gate landed still publishes without anyone revisiting the - // comparison. `'auto'` is documented as semantically identical to an absent - // `issueSourcePreference`, which makes it the sharpest case for the strict own-key policy. - it.each([ - { - label: 'an optional key no previous row carried', - changed: { ...remoteRepo, issueSourcePreference: 'auto' } satisfies Repo - }, - { - label: 'a nested record no previous row carried', - changed: { ...remoteRepo, upstream: { owner: 'octo', repo: 'remote' } } satisfies Repo - } - ])('publishes when a fetched row gains $label', async ({ changed }) => { - const { store, hydration, listener, folderRepo, setFetched } = fixture() - setFetched([changed, folderRepo]) - await hydration.capturePreparationInput(authority, 'wake-refresh') - expect(listener).toHaveBeenCalledOnce() - expect(store.getState().repos).toContainEqual(changed) - await hydration.capturePreparationInput(authority, 'wake-refresh') - expect(listener).toHaveBeenCalledOnce() - hydration.stop() - }) -}) diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration-scope.ts b/src/renderer/src/hooks/direct-ssh-host-hydration-scope.ts deleted file mode 100644 index 7bb5e6cfd53..00000000000 --- a/src/renderer/src/hooks/direct-ssh-host-hydration-scope.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import { resolveDirectSshTargetScope } from '../lib/direct-ssh-target-scope' -import type { AppState } from '../store/types' - -export function directSshHostHydrationScope( - state: AppState, - authority: DirectSshAuthority, - catalogRevision: number -) { - return resolveDirectSshTargetScope({ - targetId: authority.targetId, - catalogRevision, - repos: state.repos, - worktreesByRepo: state.worktreesByRepo, - detectedWorktreesByRepo: state.detectedWorktreesByRepo, - folderWorkspaces: state.folderWorkspaces, - projectGroups: state.projectGroups, - restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey - }) -} diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration-telemetry.ts b/src/renderer/src/hooks/direct-ssh-host-hydration-telemetry.ts deleted file mode 100644 index 7e1e776674a..00000000000 --- a/src/renderer/src/hooks/direct-ssh-host-hydration-telemetry.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { DirectSshTargetScope } from '../lib/direct-ssh-target-scope' -import type { DirectSshPreparationInputTelemetry } from './direct-ssh-reconnect-coordinator' - -export function directSshHostHydrationTelemetry( - scope: DirectSshTargetScope, - catalogOutcome: DirectSshPreparationInputTelemetry['catalogOutcome'], - catalogDurationMs: number -): DirectSshPreparationInputTelemetry { - return { - catalogOutcome, - catalogDurationMs, - gitWorktreeCount: scope.gitWorktreeIds.size, - folderWorkspaceCount: Math.max(0, scope.terminalWorkspaceKeys.size - scope.gitWorktreeIds.size), - ambiguousOwnerCount: scope.ambiguousOwnerCount, - contradictoryOwnerCount: scope.contradictoryOwnerCount - } -} diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts b/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts deleted file mode 100644 index cc13aa983aa..00000000000 --- a/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts +++ /dev/null @@ -1,545 +0,0 @@ -import { createStore } from 'zustand/vanilla' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { HostLineageSnapshot } from '../../../shared/host-lineage-contract' -import type { HostRepoCatalogSnapshot } from '../../../shared/host-repo-catalog-contract' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import type { Repo } from '../../../shared/repo-types' -import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' -import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' -import type { AppState } from '../store/types' -import { createDirectSshHostHydration } from './direct-ssh-host-hydration' - -function authority(targetId = 'target-a', epoch = 'epoch-a'): DirectSshAuthority { - return { - targetId, - providerEpoch: epoch as SshProviderEpoch, - connectionGeneration: 1 - } -} - -function repo(id: string, targetId: string | null): Repo { - return { - id, - path: targetId ? `/${targetId}/${id}` : `/local/${id}`, - projectGroupId: null, - connectionId: targetId, - executionHostId: targetId ? (`ssh:${targetId}` as const) : ('local' as const) - } as Repo -} - -function state(overrides: Record = {}): AppState { - return { - repos: [], - worktreesByRepo: {}, - detectedWorktreesByRepo: {}, - folderWorkspaces: [], - projectGroups: [], - restoredRuntimeHostIdByWorkspaceSessionKey: {}, - worktreeLineageById: {}, - workspaceLineageByChildKey: {}, - ...overrides - } as unknown as AppState -} - -function productionLineage(worktreeId: string, parentWorktreeId: string): WorktreeLineage { - return { - worktreeId, - worktreeInstanceId: `${worktreeId}-instance`, - parentWorktreeId, - parentWorktreeInstanceId: `${parentWorktreeId}-instance`, - origin: 'orchestration', - capture: { source: 'orchestration-context', confidence: 'explicit' }, - taskId: 'task-1', - coordinatorHandle: 'coord-1', - createdAt: 1 - } -} - -function productionWorkspaceLineage(childId: string, parentId: string): WorkspaceLineage { - return { - childWorkspaceKey: worktreeWorkspaceKey(childId), - childInstanceId: `${childId}-instance`, - parentWorkspaceKey: worktreeWorkspaceKey(parentId), - parentInstanceId: `${parentId}-instance`, - origin: 'orchestration', - capture: { source: 'orchestration-context', confidence: 'explicit' }, - taskId: 'task-1', - coordinatorHandle: 'coord-1', - createdAt: 1 - } -} - -function hostSnapshot( - owner: DirectSshAuthority, - repos: ReturnType[] -): HostRepoCatalogSnapshot { - return { - authoritative: true as const, - authority: { - kind: 'direct-ssh' as const, - executionHostId: `ssh:${owner.targetId}` as const, - ...owner - }, - repos - } -} - -afterEach(() => { - vi.useRealTimers() -}) - -describe('createDirectSshHostHydration', () => { - it('replaces only the exact SSH host catalog when repo IDs collide', async () => { - const owner = authority() - const store = createStore(() => - state({ - repos: [repo('shared', null), repo('shared', 'target-a'), repo('shared', 'target-b')] - }) - ) - const hydration = createDirectSshHostHydration({ - store, - listRepos: vi.fn(async () => hostSnapshot(owner, [repo('shared', 'target-a')])), - listLineage: vi.fn(), - isCurrentAuthority: () => true - }) - - const input = await hydration.capturePreparationInput(owner, 'reconnect') - - expect(input?.repoRefs).toEqual([{ repoId: 'shared', executionHostId: 'ssh:target-a' }]) - expect(store.getState().repos).toEqual([ - repo('shared', null), - repo('shared', 'target-b'), - repo('shared', 'target-a') - ]) - }) - - it('keeps the manual cross-host order when the host catalog is republished', async () => { - const owner = authority('box') - const store = createStore(() => - state({ - repos: [repo('bravo', 'box'), repo('alpha', null), repo('delta', 'box')], - manualRepoOrder: [ - { hostId: 'ssh:box', repoId: 'bravo' }, - { hostId: 'local', repoId: 'alpha' }, - { hostId: 'ssh:box', repoId: 'delta' } - ] - }) - ) - const hydration = createDirectSshHostHydration({ - store, - listRepos: vi.fn(async () => - hostSnapshot(owner, [repo('bravo', 'box'), repo('delta', 'box')]) - ), - listLineage: vi.fn(), - isCurrentAuthority: () => true - }) - - await hydration.capturePreparationInput(owner, 'reconnect') - - expect(store.getState().repos.map((entry) => entry.id)).toEqual(['bravo', 'alpha', 'delta']) - }) - - // A repo added after the last drag has no overlay entry. Ranked rows must keep their order and - // the newcomer sinks to the tail, rather than the overlay being discarded for being incomplete. - it('keeps ranked rows in order and appends unranked ones when the overlay is partial', async () => { - const owner = authority('box') - const store = createStore(() => - state({ - repos: [repo('bravo', 'box'), repo('alpha', null), repo('delta', 'box')], - manualRepoOrder: [ - { hostId: 'ssh:box', repoId: 'delta' }, - { hostId: 'local', repoId: 'alpha' } - ] - }) - ) - const hydration = createDirectSshHostHydration({ - store, - listRepos: vi.fn(async () => - hostSnapshot(owner, [repo('bravo', 'box'), repo('delta', 'box')]) - ), - listLineage: vi.fn(), - isCurrentAuthority: () => true - }) - - await hydration.capturePreparationInput(owner, 'reconnect') - - expect(store.getState().repos.map((entry) => entry.id)).toEqual(['delta', 'alpha', 'bravo']) - }) - - it('rejects a mismatched host response without publishing', async () => { - const owner = authority() - const store = createStore(() => state({ repos: [repo('cached', 'target-a')] })) - const before = store.getState().repos - let publications = 0 - store.subscribe(() => { - publications += 1 - }) - const hydration = createDirectSshHostHydration({ - store, - listRepos: vi.fn(async () => - hostSnapshot(authority('target-b', 'epoch-b'), [repo('wrong', 'target-b')]) - ), - listLineage: vi.fn(), - isCurrentAuthority: () => true - }) - - await expect(hydration.capturePreparationInput(owner, 'wake-refresh')).resolves.toBeNull() - expect(store.getState().repos).toBe(before) - expect(publications).toBe(0) - }) - - it('settles a non-cooperative catalog read at five seconds and clears its timer', async () => { - vi.useFakeTimers() - const owner = authority() - const store = createStore(() => state({ repos: [repo('cached', 'target-a')] })) - let resolveLate!: (value: HostRepoCatalogSnapshot) => void - const lateSnapshot = new Promise((resolve) => { - resolveLate = resolve - }) - let publications = 0 - store.subscribe(() => { - publications += 1 - }) - const hydration = createDirectSshHostHydration({ - store, - listRepos: vi.fn(() => lateSnapshot), - listLineage: vi.fn(), - isCurrentAuthority: () => true - }) - - const pending = hydration.capturePreparationInput(owner, 'wake-refresh') - let settled = false - void pending.then(() => { - settled = true - }) - await vi.advanceTimersByTimeAsync(4_999) - expect(settled).toBe(false) - await vi.advanceTimersByTimeAsync(1) - await expect(pending).resolves.toMatchObject({ - catalogRevision: 0, - repoRefs: [{ repoId: 'cached', executionHostId: 'ssh:target-a' }] - }) - expect(vi.getTimerCount()).toBe(0) - resolveLate(hostSnapshot(owner, [repo('late', 'target-a')])) - await Promise.resolve() - await Promise.resolve() - expect(store.getState().repos).toEqual([repo('cached', 'target-a')]) - expect(publications).toBe(0) - }) - - it('discards a catalog reply after authority changes with zero mutation', async () => { - const owner = authority() - const store = createStore(() => state({ repos: [repo('cached', 'target-a')] })) - let current = true - let resolve!: (value: ReturnType) => void - const pendingSnapshot = new Promise((settle) => { - resolve = settle - }) - let publications = 0 - store.subscribe(() => { - publications += 1 - }) - const hydration = createDirectSshHostHydration({ - store, - listRepos: () => pendingSnapshot, - listLineage: vi.fn(), - isCurrentAuthority: () => current - }) - - const pending = hydration.capturePreparationInput(owner, 'reconnect') - current = false - resolve(hostSnapshot(owner, [repo('new', 'target-a')])) - - await expect(pending).resolves.toBeNull() - expect(store.getState().repos).toEqual([repo('cached', 'target-a')]) - expect(publications).toBe(0) - }) - - it('settles pending host reads and clears their timers on stop', async () => { - vi.useFakeTimers() - const owner = authority() - const store = createStore(() => state()) - const hydration = createDirectSshHostHydration({ - store, - listRepos: () => new Promise(() => {}), - listLineage: vi.fn(), - isCurrentAuthority: () => true - }) - - const pending = hydration.capturePreparationInput(owner, 'wake-refresh') - await Promise.resolve() - expect(vi.getTimerCount()).toBe(1) - hydration.stop() - - await expect(pending).resolves.toBeNull() - expect(vi.getTimerCount()).toBe(0) - }) - - it('replaces only exact-host Git and folder lineage across both key namespaces', async () => { - const owner = authority() - const store = createStore(() => - state({ - repos: [ - repo('a', 'target-a'), - repo('b', 'target-b'), - repo('ambiguous', 'target-a'), - repo('ambiguous', 'target-a'), - repo('contradictory', 'target-a') - ], - worktreesByRepo: { - a: [{ id: 'a::/work', repoId: 'a', hostId: 'ssh:target-a' }], - b: [{ id: 'b::/work', repoId: 'b', hostId: 'ssh:target-b' }], - ambiguous: [ - { - id: 'ambiguous::/work', - repoId: 'ambiguous', - hostId: 'ssh:target-a' - } - ], - contradictory: [ - { - id: 'contradictory::/work', - repoId: 'contradictory', - hostId: 'ssh:target-b' - } - ] - }, - projectGroups: [ - { - id: 'group-a', - parentGroupId: null, - connectionId: 'target-a', - executionHostId: 'ssh:target-a' - }, - { - id: 'group-b', - parentGroupId: null, - connectionId: 'target-b', - executionHostId: 'ssh:target-b' - } - ], - folderWorkspaces: [ - { - id: 'folder-a', - projectGroupId: 'group-a', - folderPath: '/target-a/folder', - connectionId: 'target-a' - }, - { - id: 'folder-b', - projectGroupId: 'group-b', - folderPath: '/target-b/folder', - connectionId: 'target-b' - } - ], - worktreeLineageById: { - 'a::/work': { parentWorktreeId: 'stale-a' }, - 'b::/work': { parentWorktreeId: 'keep-b' }, - 'ambiguous::/work': { parentWorktreeId: 'keep-ambiguous' }, - 'contradictory::/work': { parentWorktreeId: 'keep-contradictory' } - }, - workspaceLineageByChildKey: { - [worktreeWorkspaceKey('a::/work')]: { parentWorkspaceKey: 'stale-a-workspace' }, - [worktreeWorkspaceKey('b::/work')]: { parentWorkspaceKey: 'keep-b-workspace' }, - [worktreeWorkspaceKey('ambiguous::/work')]: { - parentWorkspaceKey: 'keep-ambiguous-workspace' - }, - [worktreeWorkspaceKey('contradictory::/work')]: { - parentWorkspaceKey: 'keep-contradictory-workspace' - }, - [folderWorkspaceKey('folder-a')]: { parentWorkspaceKey: 'stale-folder' }, - [folderWorkspaceKey('folder-b')]: { parentWorkspaceKey: 'keep-folder-b' } - } - }) - ) - const hydration = createDirectSshHostHydration({ - store, - listRepos: vi.fn(), - listLineage: vi.fn( - async () => - ({ - authoritative: true, - authority: { - kind: 'direct-ssh', - executionHostId: 'ssh:target-a', - ...owner - }, - worktreeLineageById: { - 'a::/work': { parentWorktreeId: 'fresh-a' }, - 'b::/work': { parentWorktreeId: 'foreign-overwrite' }, - 'ambiguous::/work': { parentWorktreeId: 'ambiguous-overwrite' }, - 'contradictory::/work': { parentWorktreeId: 'contradictory-overwrite' } - }, - workspaceLineageByChildKey: { - 'a::/work': { parentWorkspaceKey: 'raw-terminal-key-must-not-enter-lineage' }, - [worktreeWorkspaceKey('a::/work')]: { - parentWorkspaceKey: 'fresh-a-workspace' - }, - [worktreeWorkspaceKey('b::/work')]: { - parentWorkspaceKey: 'foreign-workspace-overwrite' - }, - [worktreeWorkspaceKey('ambiguous::/work')]: { - parentWorkspaceKey: 'ambiguous-workspace-overwrite' - }, - [worktreeWorkspaceKey('contradictory::/work')]: { - parentWorkspaceKey: 'contradictory-workspace-overwrite' - }, - [folderWorkspaceKey('folder-a')]: { parentWorkspaceKey: 'fresh-folder' }, - [folderWorkspaceKey('folder-b')]: { - parentWorkspaceKey: 'foreign-folder-overwrite' - } - } - }) as unknown as HostLineageSnapshot - ), - isCurrentAuthority: () => true - }) - - await expect( - hydration.readHostScopedLineage({ - ...owner, - catalogRevision: 0, - repoRefs: [{ repoId: 'a', executionHostId: 'ssh:target-a' }], - authorityRequirement: 'required', - reason: 'reconnect' - }) - ).resolves.toBe('complete') - expect(store.getState().worktreeLineageById).toEqual({ - 'a::/work': { parentWorktreeId: 'fresh-a' }, - 'b::/work': { parentWorktreeId: 'keep-b' }, - 'ambiguous::/work': { parentWorktreeId: 'keep-ambiguous' }, - 'contradictory::/work': { parentWorktreeId: 'keep-contradictory' } - }) - expect(store.getState().workspaceLineageByChildKey).toEqual({ - [worktreeWorkspaceKey('b::/work')]: { parentWorkspaceKey: 'keep-b-workspace' }, - [worktreeWorkspaceKey('ambiguous::/work')]: { - parentWorkspaceKey: 'keep-ambiguous-workspace' - }, - [worktreeWorkspaceKey('contradictory::/work')]: { - parentWorkspaceKey: 'keep-contradictory-workspace' - }, - [folderWorkspaceKey('folder-b')]: { parentWorkspaceKey: 'keep-folder-b' }, - [worktreeWorkspaceKey('a::/work')]: { parentWorkspaceKey: 'fresh-a-workspace' }, - [folderWorkspaceKey('folder-a')]: { parentWorkspaceKey: 'fresh-folder' } - }) - }) - - it('keeps lineage map identity for a cloned no-op host snapshot', async () => { - const owner = authority() - const hostLineage = productionLineage('a::/work', 'a::/parent') - const foreignLineage = productionLineage('b::/work', 'b::/parent') - const hostWorkspace = productionWorkspaceLineage('a::/work', 'a::/parent') - const foreignWorkspace = productionWorkspaceLineage('b::/work', 'b::/parent') - const store = createStore(() => - state({ - repos: [repo('a', 'target-a'), repo('b', 'target-b')], - worktreesByRepo: { - a: [{ id: 'a::/work', repoId: 'a', hostId: 'ssh:target-a' }], - b: [{ id: 'b::/work', repoId: 'b', hostId: 'ssh:target-b' }] - }, - worktreeLineageById: { - 'a::/work': hostLineage, - 'b::/work': foreignLineage - }, - workspaceLineageByChildKey: { - [worktreeWorkspaceKey('a::/work')]: hostWorkspace, - [worktreeWorkspaceKey('b::/work')]: foreignWorkspace - } - }) - ) - const snapshot: HostLineageSnapshot = { - authoritative: true, - authority: { - kind: 'direct-ssh', - executionHostId: 'ssh:target-a', - ...owner - }, - worktreeLineageById: { - 'a::/work': hostLineage - }, - workspaceLineageByChildKey: { - [worktreeWorkspaceKey('a::/work')]: hostWorkspace - } - } - let publications = 0 - store.subscribe(() => { - publications += 1 - }) - const hydration = createDirectSshHostHydration({ - store, - listRepos: vi.fn(), - listLineage: vi.fn(async () => structuredClone(snapshot)), - isCurrentAuthority: () => true - }) - const beforeLineage = store.getState().worktreeLineageById - const beforeWorkspace = store.getState().workspaceLineageByChildKey - - await expect( - hydration.readHostScopedLineage({ - ...owner, - catalogRevision: 0, - repoRefs: [{ repoId: 'a', executionHostId: 'ssh:target-a' }], - authorityRequirement: 'required', - reason: 'reconnect' - }) - ).resolves.toBe('complete') - - expect(store.getState().worktreeLineageById).toBe(beforeLineage) - expect(store.getState().workspaceLineageByChildKey).toBe(beforeWorkspace) - expect(store.getState().worktreeLineageById['a::/work']).toBe(hostLineage) - expect(store.getState().worktreeLineageById['b::/work']).toBe(foreignLineage) - expect(store.getState().workspaceLineageByChildKey[worktreeWorkspaceKey('a::/work')]).toBe( - hostWorkspace - ) - expect(store.getState().workspaceLineageByChildKey[worktreeWorkspaceKey('b::/work')]).toBe( - foreignWorkspace - ) - expect(publications).toBe(0) - }) - - it('rejects lineage captured before a newer same-authority catalog revision', async () => { - const owner = authority() - const store = createStore(() => - state({ - repos: [repo('a', 'target-a')], - worktreesByRepo: { - a: [{ id: 'a::/work', repoId: 'a', hostId: 'ssh:target-a' }] - }, - worktreeLineageById: { - 'a::/work': { parentWorktreeId: 'keep-current' } - } - }) - ) - let resolveLineage!: (value: HostLineageSnapshot) => void - const pendingLineage = new Promise((resolve) => { - resolveLineage = resolve - }) - const hydration = createDirectSshHostHydration({ - store, - listRepos: vi.fn(async () => hostSnapshot(owner, [repo('a', 'target-a')])), - listLineage: () => pendingLineage, - isCurrentAuthority: () => true - }) - const firstInput = await hydration.capturePreparationInput(owner, 'reconnect') - if (!firstInput) { - throw new Error('Expected first preparation input') - } - const pending = hydration.readHostScopedLineage(firstInput) - await hydration.capturePreparationInput(owner, 'wake-refresh') - const beforeLateLineage = store.getState().worktreeLineageById - resolveLineage({ - authoritative: true, - authority: { - kind: 'direct-ssh', - executionHostId: 'ssh:target-a', - ...owner - }, - worktreeLineageById: { - 'a::/work': { parentWorktreeId: 'stale-overwrite' } - }, - workspaceLineageByChildKey: {} - } as unknown as HostLineageSnapshot) - - await expect(pending).resolves.toBe('stale') - expect(store.getState().worktreeLineageById).toBe(beforeLateLineage) - }) -}) diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration.ts b/src/renderer/src/hooks/direct-ssh-host-hydration.ts deleted file mode 100644 index 32b97b50a55..00000000000 --- a/src/renderer/src/hooks/direct-ssh-host-hydration.ts +++ /dev/null @@ -1,315 +0,0 @@ -import type { StoreApi } from 'zustand' -import { getRepoExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' -import type { HostLineageSnapshot } from '../../../shared/host-lineage-contract' -import type { HostRepoCatalogSnapshot } from '../../../shared/host-repo-catalog-contract' -import { applyManualRepoOrder } from '../../../shared/manual-repo-order' -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import { isWorkspaceKey } from '../../../shared/workspace-scope' -import type { AppState } from '../store/types' -import { reconcileFetchedRepos, reuseEqualRecordMap } from '../store/slices/repo-identity-reconcile' -import type { - DirectSshLineageOutcome, - DirectSshPreparationInput, - DirectSshPreparationReason -} from './direct-ssh-reconnect-coordinator' -import { directSshHostHydrationTelemetry } from './direct-ssh-host-hydration-telemetry' -import { directSshHostHydrationScope } from './direct-ssh-host-hydration-scope' -import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' - -export const DIRECT_SSH_HOST_READ_TIMEOUT_MS = 5_000 - -type HostReadTimer = ReturnType - -export type DirectSshHostHydrationDeps = { - store: Pick, 'getState' | 'setState'> - listRepos: (authority: DirectSshAuthority) => Promise - listLineage: (authority: DirectSshAuthority) => Promise - isCurrentAuthority: (authority: DirectSshAuthority) => boolean - setTimer?: (callback: () => void, delayMs: number) => HostReadTimer - clearTimer?: (timer: HostReadTimer) => void -} - -export type DirectSshHostHydration = { - capturePreparationInput: ( - authority: DirectSshAuthority, - reason: DirectSshPreparationReason, - snapshotRevision?: number - ) => Promise - readHostScopedLineage: (input: DirectSshPreparationInput) => Promise - isPreparationTokenCurrent: (token: { - authority: DirectSshAuthority - catalogRevision: number - repoFingerprint: string - }) => boolean - stop: () => void -} - -type BoundedResult = { status: 'complete'; value: T } | { status: 'timed-out' | 'unavailable' } - -function isAuthoritativeHost( - authority: HostRepoCatalogSnapshot | HostLineageSnapshot, - expected: DirectSshAuthority -): authority is Extract { - if (!authority.authoritative || authority.authority.kind !== 'direct-ssh') { - return false - } - return ( - authority.authority.executionHostId === toSshExecutionHostId(expected.targetId) && - directSshAuthoritiesEqual(authority.authority, expected) - ) -} - -function mergeExactHostCatalog(state: AppState, snapshot: HostRepoCatalogSnapshot): AppState { - if (!snapshot.authoritative || snapshot.authority.kind !== 'direct-ssh') { - return state - } - const hostId = snapshot.authority.executionHostId - // Why re-apply the overlay: re-appending the host's rows puts them at the tail, which would - // undo the user's manual cross-host order on every connect. - const repos = reconcileFetchedRepos( - state.repos, - applyManualRepoOrder( - [...state.repos.filter((repo) => getRepoExecutionHostId(repo) !== hostId), ...snapshot.repos], - state.manualRepoOrder - ) - ) - return repos === state.repos ? state : { ...state, repos } -} - -// Why: the host owns every in-scope key, so drop the stale in-scope rows, overlay the snapshot's, -// and leave out-of-scope hosts alone. Reusing equal rows keeps a cloned no-op read from republishing. -function overlayHostScopedLineage( - previous: Readonly>, - incoming: Readonly>, - isHostScoped: (key: string) => boolean -): Readonly> { - const next: Record = Object.fromEntries([ - ...Object.entries(previous).filter(([key]) => !isHostScoped(key)), - ...Object.entries(incoming).filter(([key]) => isHostScoped(key)) - ]) - return reuseEqualRecordMap(previous, next) -} - -function mergeExactHostLineage( - state: AppState, - snapshot: Extract, - authority: DirectSshAuthority, - catalogRevision: number -): AppState { - const scope = directSshHostHydrationScope(state, authority, catalogRevision) - const worktreeLineageById = overlayHostScopedLineage( - state.worktreeLineageById, - snapshot.worktreeLineageById, - (worktreeId) => scope.gitWorktreeIds.has(worktreeId) - ) - const workspaceLineageByChildKey = overlayHostScopedLineage( - state.workspaceLineageByChildKey, - snapshot.workspaceLineageByChildKey, - (childKey) => isWorkspaceKey(childKey) && scope.lineageWorkspaceKeys.has(childKey) - ) - if ( - worktreeLineageById === state.worktreeLineageById && - workspaceLineageByChildKey === state.workspaceLineageByChildKey - ) { - return state - } - return { ...state, worktreeLineageById, workspaceLineageByChildKey } -} - -export function createDirectSshHostHydration( - deps: DirectSshHostHydrationDeps -): DirectSshHostHydration { - const setTimer: NonNullable = - deps.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)) - const clearTimer: NonNullable = - deps.clearTimer ?? ((timer) => clearTimeout(timer)) - const catalogRevisionByTarget = new Map() - const catalogInFlight = new Map>() - const pendingDeadlines = new Set<{ timer: HostReadTimer; settle: () => void }>() - let stopped = false - - const bounded = async (operation: Promise): Promise> => { - let pendingDeadline: { timer: HostReadTimer; settle: () => void } | undefined - const deadline = new Promise>((resolve) => { - const settle = (): void => resolve({ status: 'timed-out' }) - const timer = setTimer(settle, DIRECT_SSH_HOST_READ_TIMEOUT_MS) - pendingDeadline = { timer, settle } - pendingDeadlines.add(pendingDeadline) - }) - try { - return await Promise.race([ - operation.then, BoundedResult>( - (value) => ({ status: 'complete', value }), - () => ({ status: 'unavailable' }) - ), - deadline - ]) - } finally { - if (pendingDeadline) { - clearTimer(pendingDeadline.timer) - pendingDeadlines.delete(pendingDeadline) - } - } - } - - const refreshCatalog = ( - authority: DirectSshAuthority - ): Promise<'complete' | 'degraded' | 'stale'> => { - const key = JSON.stringify([ - authority.targetId, - authority.providerEpoch, - authority.connectionGeneration - ]) - const existing = catalogInFlight.get(key) - if (existing) { - return existing - } - const operation = (async (): Promise<'complete' | 'degraded' | 'stale'> => { - const boundedResult = await bounded(Promise.resolve().then(() => deps.listRepos(authority))) - if (stopped || !deps.isCurrentAuthority(authority)) { - return 'stale' - } - if (boundedResult.status !== 'complete') { - return 'degraded' - } - const snapshot = boundedResult.value - if (!snapshot.authoritative) { - return snapshot.reason === 'stale' || snapshot.reason === 'rejected' ? 'stale' : 'degraded' - } - if ( - !isAuthoritativeHost(snapshot, authority) || - snapshot.repos.some( - (repo) => - getRepoExecutionHostId(repo) !== toSshExecutionHostId(authority.targetId) || - repo.connectionId !== authority.targetId - ) - ) { - return 'stale' - } - let admitted = false - deps.store.setState((state) => { - if (stopped || !deps.isCurrentAuthority(authority)) { - return state - } - admitted = true - return mergeExactHostCatalog(state, snapshot) - }) - if (!admitted) { - return 'stale' - } - catalogRevisionByTarget.set( - authority.targetId, - (catalogRevisionByTarget.get(authority.targetId) ?? 0) + 1 - ) - return 'complete' - })().finally(() => { - if (catalogInFlight.get(key) === operation) { - catalogInFlight.delete(key) - } - }) - catalogInFlight.set(key, operation) - return operation - } - - const capturePreparationInput = async ( - authority: DirectSshAuthority, - reason: DirectSshPreparationReason, - snapshotRevision?: number - ): Promise => { - const catalogStartedAt = Date.now() - const catalogOutcome = await refreshCatalog(authority) - const catalogDurationMs = Math.max(0, Date.now() - catalogStartedAt) - if (catalogOutcome === 'stale' || stopped || !deps.isCurrentAuthority(authority)) { - return null - } - const catalogRevision = catalogRevisionByTarget.get(authority.targetId) ?? 0 - const scope = directSshHostHydrationScope(deps.store.getState(), authority, catalogRevision) - return { - ...authority, - catalogRevision, - repoRefs: scope.gitRepos, - authorityRequirement: 'required', - ...(snapshotRevision === undefined ? {} : { snapshotRevision }), - reason, - telemetry: directSshHostHydrationTelemetry(scope, catalogOutcome, catalogDurationMs) - } - } - - const readHostScopedLineage = async ( - input: DirectSshPreparationInput - ): Promise => { - const authority = { - targetId: input.targetId, - providerEpoch: input.providerEpoch, - connectionGeneration: input.connectionGeneration - } - const boundedResult = await bounded(Promise.resolve().then(() => deps.listLineage(authority))) - if ( - stopped || - !deps.isCurrentAuthority(authority) || - (catalogRevisionByTarget.get(authority.targetId) ?? 0) !== input.catalogRevision - ) { - return 'stale' - } - if (boundedResult.status !== 'complete') { - return 'degraded' - } - const snapshot = boundedResult.value - if (!snapshot.authoritative) { - return snapshot.reason === 'stale' || snapshot.reason === 'rejected' ? 'stale' : 'degraded' - } - if (!isAuthoritativeHost(snapshot, authority)) { - return 'stale' - } - let admitted = false - deps.store.setState((state) => { - if ( - stopped || - !deps.isCurrentAuthority(authority) || - (catalogRevisionByTarget.get(authority.targetId) ?? 0) !== input.catalogRevision - ) { - return state - } - admitted = true - return mergeExactHostLineage(state, snapshot, authority, input.catalogRevision) - }) - return admitted ? 'complete' : 'stale' - } - - return { - capturePreparationInput, - readHostScopedLineage, - isPreparationTokenCurrent: (token) => { - if ( - stopped || - !deps.isCurrentAuthority(token.authority) || - (catalogRevisionByTarget.get(token.authority.targetId) ?? 0) !== token.catalogRevision - ) { - return false - } - const scope = directSshHostHydrationScope( - deps.store.getState(), - token.authority, - token.catalogRevision - ) - const sortedRepos = [...scope.gitRepos].sort((left, right) => { - const leftKey = `${left.executionHostId}\0${left.repoId}` - const rightKey = `${right.executionHostId}\0${right.repoId}` - return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0 - }) - return ( - JSON.stringify(sortedRepos.map((repo) => [repo.executionHostId, repo.repoId])) === - token.repoFingerprint - ) - }, - stop: () => { - stopped = true - for (const deadline of pendingDeadlines) { - clearTimer(deadline.timer) - deadline.settle() - } - pendingDeadlines.clear() - catalogInFlight.clear() - } - } -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-outcomes.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-outcomes.ts deleted file mode 100644 index 8e5a411eded..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-outcomes.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { - DirectSshPreparationMetrics, - DirectSshPreparationOutcome, - DirectSshReconnectOutcome, - DirectSshRepoOutcomeCounts -} from './direct-ssh-reconnect-coordinator-types' - -export function createEmptyDirectSshRepoOutcomeCounts(): DirectSshRepoOutcomeCounts { - return { - complete: 0, - 'non-authoritative': 0, - 'timed-out': 0, - 'cancel-budget-exhausted': 0, - canceled: 0, - stale: 0, - rejected: 0 - } -} - -export function createEmptyDirectSshPreparationMetrics(): DirectSshPreparationMetrics { - return { - queueWaitDurationsMs: [], - providerExecutionDurationsMs: [], - timeoutRetryCount: 0, - locallySettledWaiterCount: 0, - cancelDebtCount: 0, - replacementAdmissionDelayedCount: 0, - schedulerOverlappingJoinCount: 0, - peakLocallyUnsettled: 0, - estimatedLateWorkAllowanceCount: 0, - lineageDurationMs: 0 - } -} - -export function combineDirectSshReconnectOutcome( - prepared: DirectSshPreparationOutcome, - staleBindingsCleared: number, - retriedTerminals: number, - correctedTerminals: number -): DirectSshReconnectOutcome { - return { - ...prepared, - staleBindingsCleared, - retriedTerminals, - correctedTerminals, - stabilizing: false - } -} - -export function createTerminalOnlyDirectSshReconnectOutcome( - status: DirectSshReconnectOutcome['status'], - staleBindingsCleared = 0, - retriedTerminals = 0 -): DirectSshReconnectOutcome { - return { - status, - token: null, - repoOutcomes: createEmptyDirectSshRepoOutcomeCounts(), - lineageOutcome: 'not-started', - metrics: createEmptyDirectSshPreparationMetrics(), - staleBindingsCleared, - retriedTerminals, - correctedTerminals: 0, - stabilizing: status === 'stabilizing' - } -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-stabilization.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-stabilization.ts deleted file mode 100644 index 9924e55d70b..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-stabilization.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import type { DirectSshReconnectTimer } from './direct-ssh-reconnect-coordinator-types' - -export type DirectSshReconnectTargetState = { - authority: DirectSshAuthority - installedAt: number - dampUntil: number | null - timer: DirectSshReconnectTimer | null -} - -export function createDirectSshReconnectTargetState( - authority: DirectSshAuthority, - previous: DirectSshReconnectTargetState | undefined, - installedAt: number, - stabilizationMs: number -): DirectSshReconnectTargetState { - const rotatedRapidly = - previous !== undefined && installedAt - previous.installedAt < stabilizationMs - return { - authority, - installedAt, - dampUntil: rotatedRapidly ? installedAt + stabilizationMs : null, - timer: null - } -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-telemetry.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-telemetry.ts deleted file mode 100644 index 14bc881ced4..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-telemetry.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { - DirectSshCoordinatorTelemetry, - DirectSshPreparationInput, - DirectSshPreparationOutcome, - DirectSshPreparationReason, - DirectSshReconnectOutcome -} from './direct-ssh-reconnect-coordinator-types' -import { createTerminalOnlyDirectSshReconnectOutcome } from './direct-ssh-reconnect-coordinator-outcomes' - -type TelemetryOutcome = DirectSshPreparationOutcome | DirectSshReconnectOutcome - -type TelemetryOptions = { - terminalFinalizationDurationMs?: number - staleBindingsCleared?: number - retriedTerminals?: number - correctedTerminals?: number - damped?: boolean - authorityRotationCount?: number - catalogOutcome?: DirectSshCoordinatorTelemetry['catalogOutcome'] - catalogDurationMs?: number -} - -type DirectSshCoordinatorTelemetryReporter = { - report: ( - mode: DirectSshCoordinatorTelemetry['mode'], - input: DirectSshPreparationInput, - outcome: TelemetryOutcome, - startedAt: number, - options?: TelemetryOptions - ) => void - reportWithoutInput: ( - mode: DirectSshCoordinatorTelemetry['mode'], - reason: DirectSshPreparationReason, - outcome: TelemetryOutcome, - startedAt: number, - options?: TelemetryOptions - ) => void -} - -export function createDirectSshCoordinatorTelemetryReporter(args: { - onTelemetry?: (event: DirectSshCoordinatorTelemetry) => void - now: () => number -}): DirectSshCoordinatorTelemetryReporter { - const emit = (event: DirectSshCoordinatorTelemetry): void => { - try { - args.onTelemetry?.(event) - } catch { - // Recovery cannot depend on diagnostics. - } - } - - const report = ( - mode: DirectSshCoordinatorTelemetry['mode'], - input: DirectSshPreparationInput, - outcome: TelemetryOutcome, - startedAt: number, - options: TelemetryOptions = {} - ): void => { - const inputMetrics = input.telemetry - const outcomeMetrics = - outcome.metrics ?? createTerminalOnlyDirectSshReconnectOutcome('stale').metrics! - emit({ - mode, - reason: input.reason, - outcome: outcome.status, - durationMs: Math.max(0, args.now() - startedAt), - staleBindingsCleared: options.staleBindingsCleared ?? 0, - retriedTerminals: options.retriedTerminals ?? 0, - correctedTerminals: options.correctedTerminals ?? 0, - terminalFinalizationDurationMs: options.terminalFinalizationDurationMs ?? 0, - catalogOutcome: options.catalogOutcome ?? inputMetrics?.catalogOutcome ?? 'complete', - catalogDurationMs: options.catalogDurationMs ?? inputMetrics?.catalogDurationMs ?? 0, - gitWorktreeCount: inputMetrics?.gitWorktreeCount ?? 0, - folderWorkspaceCount: inputMetrics?.folderWorkspaceCount ?? 0, - ambiguousOwnerCount: inputMetrics?.ambiguousOwnerCount ?? 0, - contradictoryOwnerCount: inputMetrics?.contradictoryOwnerCount ?? 0, - repoOutcomes: { ...outcome.repoOutcomes }, - lineageOutcome: outcome.lineageOutcome, - queueWaitDurationsMs: [...outcomeMetrics.queueWaitDurationsMs], - providerExecutionDurationsMs: [...outcomeMetrics.providerExecutionDurationsMs], - timeoutRetryCount: outcomeMetrics.timeoutRetryCount, - locallySettledWaiterCount: outcomeMetrics.locallySettledWaiterCount, - cancelDebtCount: outcomeMetrics.cancelDebtCount, - replacementAdmissionDelayedCount: outcomeMetrics.replacementAdmissionDelayedCount, - overlappingJoinCount: outcomeMetrics.schedulerOverlappingJoinCount, - peakLocallyUnsettled: outcomeMetrics.peakLocallyUnsettled, - estimatedLateWorkAllowanceCount: outcomeMetrics.estimatedLateWorkAllowanceCount, - authorityRotationCount: options.authorityRotationCount ?? 0, - damped: options.damped ?? false - }) - } - - const reportWithoutInput: DirectSshCoordinatorTelemetryReporter['reportWithoutInput'] = ( - mode, - reason, - outcome, - startedAt, - options = {} - ) => { - report( - mode, - { - targetId: '', - providerEpoch: '' as DirectSshPreparationInput['providerEpoch'], - connectionGeneration: 0, - catalogRevision: 0, - repoRefs: [], - authorityRequirement: 'required', - reason - }, - outcome, - startedAt, - options - ) - } - - return { report, reportWithoutInput } -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts deleted file mode 100644 index 5c3d5b5436b..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator-types.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import type { DirectSshGitRepoRef } from '../lib/direct-ssh-target-scope-types' -import type { - DirectSshWorktreeRefreshScheduler, - DirectSshWorktreeRefreshTerminalStatus -} from './direct-ssh-worktree-refresh-scheduler' - -export type DirectSshPreparationReason = - | 'reconnect' - | 'initial-hydration' - | 'workspace-snapshot' - | 'wake-refresh' - -export type DirectSshAuthorityRequirement = 'required' | 'allow-metadata-fallback' - -export type DirectSshPreparationInput = DirectSshAuthority & { - catalogRevision: number - repoRefs: readonly DirectSshGitRepoRef[] - authorityRequirement: DirectSshAuthorityRequirement - snapshotRevision?: number - reason: DirectSshPreparationReason - telemetry?: DirectSshPreparationInputTelemetry -} - -export type DirectSshPreparationInputTelemetry = { - catalogOutcome: 'complete' | 'degraded' - catalogDurationMs: number - gitWorktreeCount: number - folderWorkspaceCount: number - ambiguousOwnerCount: number - contradictoryOwnerCount: number -} - -export type DirectSshPreparationToken = { - authority: DirectSshAuthority - catalogRevision: number - repoFingerprint: string - authorityRequirement: DirectSshAuthorityRequirement - snapshotRevision: number | null - outcome: 'complete' | 'degraded' -} - -export type DirectSshLineageOutcome = 'complete' | 'degraded' | 'canceled' | 'stale' - -export type DirectSshRepoOutcomeCounts = Record - -export type DirectSshPreparationOutcome = { - status: 'complete' | 'degraded' | 'canceled' | 'stale' | 'stopped' - token: DirectSshPreparationToken | null - repoOutcomes: DirectSshRepoOutcomeCounts - lineageOutcome: DirectSshLineageOutcome | 'not-started' - metrics?: DirectSshPreparationMetrics -} - -export type DirectSshPreparationMetrics = { - queueWaitDurationsMs: readonly number[] - providerExecutionDurationsMs: readonly number[] - timeoutRetryCount: number - locallySettledWaiterCount: number - cancelDebtCount: number - replacementAdmissionDelayedCount: number - schedulerOverlappingJoinCount: number - peakLocallyUnsettled: number - estimatedLateWorkAllowanceCount: number - lineageDurationMs: number -} - -export type DirectSshReconnectOutcome = Omit & { - status: DirectSshPreparationOutcome['status'] | 'stabilizing' - staleBindingsCleared: number - retriedTerminals: number - correctedTerminals: number - stabilizing: boolean -} - -export type DirectSshCorrectionReason = - | 'preparation-complete' - | 'wake-refresh' - | 'workspace-hydrated' - | 'snapshot-applied' - -export type DirectSshCoordinatorTelemetry = { - mode: 'reconnect' | 'prepare-only' - reason: DirectSshPreparationReason - outcome: DirectSshReconnectOutcome['status'] - durationMs: number - staleBindingsCleared: number - retriedTerminals: number - correctedTerminals: number - terminalFinalizationDurationMs: number - catalogOutcome: 'complete' | 'degraded' | 'stale' - catalogDurationMs: number - gitWorktreeCount: number - folderWorkspaceCount: number - ambiguousOwnerCount: number - contradictoryOwnerCount: number - repoOutcomes: DirectSshRepoOutcomeCounts - lineageOutcome: DirectSshPreparationOutcome['lineageOutcome'] - queueWaitDurationsMs: readonly number[] - providerExecutionDurationsMs: readonly number[] - timeoutRetryCount: number - locallySettledWaiterCount: number - cancelDebtCount: number - replacementAdmissionDelayedCount: number - overlappingJoinCount: number - peakLocallyUnsettled: number - estimatedLateWorkAllowanceCount: number - authorityRotationCount: number - damped: boolean -} - -export type DirectSshReconnectTimer = ReturnType - -export type DirectSshReconnectCoordinatorDeps = { - scheduler: DirectSshWorktreeRefreshScheduler - isCurrentConnectedAuthority: (authority: DirectSshAuthority) => boolean - capturePreparationInput: ( - authority: DirectSshAuthority, - reason: DirectSshPreparationReason - ) => Promise - readHostScopedLineage: (input: DirectSshPreparationInput) => Promise - invalidateStaleTerminalBindings: (authority: DirectSshAuthority) => number - retryTargetPanes: (authority: DirectSshAuthority) => number - correctUnboundTerminalPanes: ( - authority: DirectSshAuthority, - reason: DirectSshCorrectionReason - ) => number - onTelemetry?: (event: DirectSshCoordinatorTelemetry) => void - now?: () => number - setTimer?: (callback: () => void, delayMs: number) => DirectSshReconnectTimer - clearTimer?: (timer: DirectSshReconnectTimer) => void - stabilizationMs?: number -} - -export type DirectSshReconnectCoordinator = { - requestReconnect: (authority: DirectSshAuthority) => Promise - prepareOnly: (input: DirectSshPreparationInput) => Promise - correctUnboundTerminals: ( - authority: DirectSshAuthority, - reason: DirectSshCorrectionReason - ) => number - replaceAuthority: (authority: DirectSshAuthority) => void - invalidate: (targetId: string) => void - stop: () => void -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts deleted file mode 100644 index 7d149a20c58..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.test.ts +++ /dev/null @@ -1,542 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { - HostQualifiedDetectedWorktreeResult, - ProviderRequestId -} from '../../../shared/detected-worktree-provider-contract' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import { - createDirectSshWorktreeRefreshScheduler, - type DirectSshWorktreeRefreshKey, - type DirectSshWorktreeRefreshLease, - type DirectSshWorktreeRefreshOutcome, - type DirectSshWorktreeRefreshScheduler, - type WaiterLeaseId -} from './direct-ssh-worktree-refresh-scheduler' -import { - createDirectSshReconnectCoordinator, - type DirectSshLineageOutcome, - type DirectSshPreparationInput, - type DirectSshReconnectCoordinatorDeps -} from './direct-ssh-reconnect-coordinator' - -type Deferred = { - promise: Promise - resolve: (value: T) => void - reject: (error: unknown) => void -} - -function deferred(): Deferred { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise - }) - return { promise, resolve, reject } -} - -function epoch(value: string): SshProviderEpoch { - return value as SshProviderEpoch -} - -function authority(targetId: string, epochId = `epoch-${targetId}`): DirectSshAuthority { - return { - targetId, - providerEpoch: epoch(epochId), - connectionGeneration: 1 - } -} - -function preparationInput( - owner: DirectSshAuthority, - repoIds: readonly string[] = ['repo'], - overrides: Partial = {} -): DirectSshPreparationInput { - return { - ...owner, - catalogRevision: 1, - repoRefs: repoIds.map((repoId) => ({ - repoId, - executionHostId: `ssh:${owner.targetId}` - })), - authorityRequirement: 'required', - reason: 'workspace-snapshot', - ...overrides - } -} - -type FakeLease = { - key: DirectSshWorktreeRefreshKey - deferred: Deferred - lease: DirectSshWorktreeRefreshLease -} - -function createFakeScheduler(): { - scheduler: DirectSshWorktreeRefreshScheduler - leases: FakeLease[] - complete: (index: number, status?: DirectSshWorktreeRefreshOutcome['status']) => void -} { - const leases: FakeLease[] = [] - const request = vi.fn((key: DirectSshWorktreeRefreshKey) => { - const leaseDeferred = deferred() - let released = false - const lease: DirectSshWorktreeRefreshLease = { - waiterLeaseId: `lease-${leases.length + 1}` as WaiterLeaseId, - result: leaseDeferred.promise, - release: vi.fn(() => { - if (!released) { - released = true - leaseDeferred.resolve({ status: 'canceled' }) - } - }) - } - leases.push({ key, deferred: leaseDeferred, lease }) - return lease - }) - const scheduler: DirectSshWorktreeRefreshScheduler = { - request, - invalidateAuthority: vi.fn(), - invalidateTarget: vi.fn(), - disposeProvider: vi.fn(), - getSnapshot: vi.fn(() => ({ - locallyUnsettled: 0, - queued: 0, - retrying: 0, - logicalTasks: leases.length, - waiters: leases.length, - cancelDebtByAuthority: new Map() - })), - stop: vi.fn() - } - return { - scheduler, - leases, - complete: (index, status = 'complete') => leases[index].deferred.resolve({ status }) - } -} - -function createCoordinatorHarness(fakeScheduler = createFakeScheduler()) { - const current = new Map() - const events: string[] = [] - const capturePreparationInput = vi.fn( - async (owner: DirectSshAuthority, reason: DirectSshPreparationInput['reason']) => { - events.push(`capture:${owner.targetId}`) - return preparationInput(owner, ['repo'], { reason }) - } - ) - const deps: DirectSshReconnectCoordinatorDeps = { - scheduler: fakeScheduler.scheduler, - isCurrentConnectedAuthority: (owner) => { - const active = current.get(owner.targetId) - return ( - active?.providerEpoch === owner.providerEpoch && - active.connectionGeneration === owner.connectionGeneration - ) - }, - capturePreparationInput, - readHostScopedLineage: vi.fn(async (): Promise => 'complete'), - invalidateStaleTerminalBindings: vi.fn((owner) => { - events.push(`invalidate:${owner.targetId}`) - return 1 - }), - retryTargetPanes: vi.fn((owner) => { - events.push(`retry:${owner.targetId}`) - return 2 - }), - correctUnboundTerminalPanes: vi.fn(() => 1), - onTelemetry: vi.fn() - } - const coordinator = createDirectSshReconnectCoordinator(deps) - return { coordinator, deps, current, events, ...fakeScheduler } -} - -async function flush(): Promise { - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() -} - -afterEach(() => { - vi.useRealTimers() -}) - -describe('createDirectSshReconnectCoordinator', () => { - it('runs terminal invalidation and retry synchronously before preparation awaits', () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - - void harness.coordinator.requestReconnect(owner) - - expect(harness.events).toEqual(['invalidate:target-a', 'retry:target-a', 'capture:target-a']) - expect(harness.leases).toHaveLength(0) - }) - - it('prepares the first observed authority immediately', async () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - - const pending = harness.coordinator.requestReconnect(owner) - await flush() - - expect(harness.deps.capturePreparationInput).toHaveBeenCalledWith(owner, 'reconnect') - expect(harness.leases).toHaveLength(1) - harness.complete(0) - await expect(pending).resolves.toMatchObject({ status: 'complete', stabilizing: false }) - }) - - it('keeps target B independent while target A preparation is blocked', async () => { - const harness = createCoordinatorHarness() - const targetA = authority('target-a') - const targetB = authority('target-b') - harness.current.set(targetA.targetId, targetA) - harness.current.set(targetB.targetId, targetB) - - const pendingA = harness.coordinator.requestReconnect(targetA) - await flush() - const pendingB = harness.coordinator.requestReconnect(targetB) - await flush() - expect(harness.leases).toHaveLength(2) - - harness.complete(1) - await expect(pendingB).resolves.toMatchObject({ status: 'complete' }) - expect(harness.deps.correctUnboundTerminalPanes).toHaveBeenCalledTimes(1) - - let targetASettled = false - void pendingA.then(() => { - targetASettled = true - }) - await flush() - expect(targetASettled).toBe(false) - harness.coordinator.stop() - }) - - it('treats exact-equal authority replacement as a true timing no-op', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const harness = createCoordinatorHarness() - const owner = authority('target-a') - - harness.coordinator.replaceAuthority(owner) - vi.setSystemTime(4_000) - harness.coordinator.replaceAuthority({ ...owner }) - - expect(harness.scheduler.disposeProvider).not.toHaveBeenCalled() - expect(harness.scheduler.invalidateTarget).not.toHaveBeenCalled() - - vi.setSystemTime(5_001) - const next = authority('target-a', 'next') - harness.current.set(next.targetId, next) - const pending = harness.coordinator.requestReconnect(next) - await flush() - - expect(harness.deps.capturePreparationInput).toHaveBeenCalledWith(next, 'reconnect') - harness.complete(0) - await expect(pending).resolves.toMatchObject({ status: 'complete', stabilizing: false }) - }) - - it('cancels obsolete work on changed authority without ordering epochs', async () => { - const harness = createCoordinatorHarness() - const oldOwner = authority('target-a', 'z-old') - const newOwner = authority('target-a', 'a-new') - harness.current.set(oldOwner.targetId, oldOwner) - harness.coordinator.replaceAuthority(oldOwner) - const pending = harness.coordinator.prepareOnly(preparationInput(oldOwner)) - await flush() - - harness.current.set(newOwner.targetId, newOwner) - harness.coordinator.replaceAuthority(newOwner) - - await expect(pending).resolves.toMatchObject({ status: 'stale', token: null }) - expect(harness.leases[0].lease.release).toHaveBeenCalledWith('invalidated') - expect(harness.scheduler.disposeProvider).toHaveBeenCalledWith(oldOwner) - }) - - it('settles invalidation locally while a late lineage read remains pending', async () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - const lineage = deferred<'complete'>() - vi.mocked(harness.deps.readHostScopedLineage).mockReturnValue(lineage.promise) - harness.current.set(owner.targetId, owner) - harness.coordinator.replaceAuthority(owner) - const pending = harness.coordinator.prepareOnly(preparationInput(owner)) - harness.complete(0) - await flush() - expect(harness.deps.readHostScopedLineage).toHaveBeenCalledOnce() - - harness.coordinator.invalidate(owner.targetId) - await expect(pending).resolves.toMatchObject({ status: 'stale', token: null }) - - lineage.resolve('complete') - await flush() - expect(harness.deps.correctUnboundTerminalPanes).not.toHaveBeenCalled() - }) - - it('joins exact overlapping preparation and reruns after completion', async () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - harness.coordinator.replaceAuthority(owner) - const input = preparationInput(owner) - - const first = harness.coordinator.prepareOnly(input) - const joined = harness.coordinator.prepareOnly({ - ...input, - repoRefs: input.repoRefs.toReversed() - }) - expect(joined).toBe(first) - expect(harness.leases).toHaveLength(1) - - harness.complete(0) - await expect(first).resolves.toMatchObject({ status: 'complete' }) - const later = harness.coordinator.prepareOnly(input) - expect(later).not.toBe(first) - expect(harness.leases).toHaveLength(2) - harness.coordinator.stop() - }) - - it('keeps prepare-only isolated from terminal retry and correction', async () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - harness.coordinator.replaceAuthority(owner) - const pending = harness.coordinator.prepareOnly(preparationInput(owner)) - harness.complete(0, 'non-authoritative') - - await expect(pending).resolves.toMatchObject({ - status: 'degraded', - token: { outcome: 'degraded' } - }) - expect(harness.deps.invalidateStaleTerminalBindings).not.toHaveBeenCalled() - expect(harness.deps.retryTargetPanes).not.toHaveBeenCalled() - expect(harness.deps.correctUnboundTerminalPanes).not.toHaveBeenCalled() - }) - - it('rejects repo refs for another SSH host before scheduler admission', async () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - harness.coordinator.replaceAuthority(owner) - - await expect( - harness.coordinator.prepareOnly( - preparationInput(owner, ['repo'], { - repoRefs: [{ repoId: 'repo', executionHostId: 'ssh:target-b' }] - }) - ) - ).resolves.toMatchObject({ status: 'stale', token: null }) - - expect(harness.leases).toHaveLength(0) - expect(harness.deps.readHostScopedLineage).not.toHaveBeenCalled() - }) - - it('holds lineage and token creation through the scheduler timeout retry barrier', async () => { - const attempts: { - id: ProviderRequestId - deferred: Deferred - key: DirectSshWorktreeRefreshKey - }[] = [] - const scheduler = createDirectSshWorktreeRefreshScheduler({ - startAttempt: (attemptKey) => { - const result = deferred() - const id = `provider-${attempts.length + 1}` as ProviderRequestId - attempts.push({ id, deferred: result, key: attemptKey }) - return { providerRequestId: id, result: result.promise, cancel: vi.fn() } - } - }) - const harness = createCoordinatorHarness({ - scheduler, - leases: [], - complete: () => {} - }) - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - harness.coordinator.replaceAuthority(owner) - const pending = harness.coordinator.prepareOnly(preparationInput(owner)) - - attempts[0].deferred.resolve({ - status: 'timed-out', - providerRequestId: attempts[0].id, - executionHostId: attempts[0].key.executionHostId - }) - await flush() - expect(attempts).toHaveLength(2) - expect(harness.deps.readHostScopedLineage).not.toHaveBeenCalled() - - attempts[1].deferred.resolve({ - status: 'complete', - providerRequestId: attempts[1].id, - repoId: 'repo', - authority: { - kind: 'direct-ssh', - executionHostId: attempts[1].key.executionHostId, - ...owner - }, - result: { repoId: 'repo', authoritative: true, source: 'git', worktrees: [] } - }) - await expect(pending).resolves.toMatchObject({ status: 'complete', token: {} }) - expect(harness.deps.readHostScopedLineage).toHaveBeenCalledOnce() - }) - - it('starts lineage only after every repo is terminal and returns a fenced token', async () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - harness.coordinator.replaceAuthority(owner) - const pending = harness.coordinator.prepareOnly( - preparationInput(owner, ['repo-b', 'repo-a'], { - catalogRevision: 7, - snapshotRevision: 11 - }) - ) - expect(harness.leases.map((lease) => lease.key.repoId)).toEqual(['repo-a', 'repo-b']) - - harness.complete(0) - await flush() - expect(harness.deps.readHostScopedLineage).not.toHaveBeenCalled() - harness.complete(1) - - await expect(pending).resolves.toMatchObject({ - status: 'complete', - token: { - authority: owner, - catalogRevision: 7, - snapshotRevision: 11, - outcome: 'complete' - } - }) - expect(harness.deps.readHostScopedLineage).toHaveBeenCalledOnce() - }) - - it('damps rapid rotations to the latest authority while terminal checks stay immediate', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const harness = createCoordinatorHarness() - const initial = authority('target-a', 'initial') - const middle = authority('target-a', 'middle') - const flapping = authority('target-a', 'flapping') - const latest = authority('target-a', 'latest') - harness.current.set(initial.targetId, initial) - harness.coordinator.replaceAuthority(initial) - - vi.setSystemTime(5_001) - harness.current.set(middle.targetId, middle) - const stableReplacement = harness.coordinator.requestReconnect(middle) - await flush() - harness.complete(0) - await expect(stableReplacement).resolves.toMatchObject({ - status: 'complete', - stabilizing: false - }) - - vi.setSystemTime(6_000) - harness.current.set(flapping.targetId, flapping) - await expect(harness.coordinator.requestReconnect(flapping)).resolves.toMatchObject({ - status: 'stabilizing' - }) - vi.setSystemTime(7_000) - harness.current.set(latest.targetId, latest) - await expect(harness.coordinator.requestReconnect(latest)).resolves.toMatchObject({ - status: 'stabilizing' - }) - - expect(harness.deps.retryTargetPanes).toHaveBeenCalledTimes(3) - expect(harness.deps.capturePreparationInput).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(4_999) - expect(harness.deps.capturePreparationInput).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(1) - expect(harness.deps.capturePreparationInput).toHaveBeenCalledTimes(2) - expect(harness.deps.capturePreparationInput).toHaveBeenCalledWith(latest, 'reconnect') - expect(harness.deps.capturePreparationInput).not.toHaveBeenCalledWith(flapping, 'reconnect') - harness.complete(1) - }) - - it('corrects only current authority and cleans pending work and timers on stop', async () => { - vi.useFakeTimers() - vi.setSystemTime(0) - const harness = createCoordinatorHarness() - const initial = authority('target-a', 'initial') - const latest = authority('target-a', 'latest') - harness.current.set(initial.targetId, initial) - harness.coordinator.replaceAuthority(initial) - harness.current.set(latest.targetId, latest) - void harness.coordinator.requestReconnect(latest) - - expect(harness.coordinator.correctUnboundTerminals(latest, 'wake-refresh')).toBe(1) - harness.coordinator.stop() - await vi.advanceTimersByTimeAsync(5_000) - - expect(harness.deps.capturePreparationInput).not.toHaveBeenCalled() - expect(harness.scheduler.stop).toHaveBeenCalledOnce() - expect(harness.coordinator.correctUnboundTerminals(latest, 'wake-refresh')).toBe(0) - }) - - it('emits one aggregate for joined work with scheduler and scope metrics', async () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - harness.coordinator.replaceAuthority(owner) - const input = preparationInput(owner, ['repo'], { - telemetry: { - catalogOutcome: 'degraded', - catalogDurationMs: 12, - gitWorktreeCount: 3, - folderWorkspaceCount: 2, - ambiguousOwnerCount: 1, - contradictoryOwnerCount: 1 - } - }) - - const first = harness.coordinator.prepareOnly(input) - const joined = harness.coordinator.prepareOnly(input) - harness.leases[0].deferred.resolve({ - status: 'complete', - metrics: { - queueWaitDurationsMs: [8], - providerExecutionDurationsMs: [13], - timeoutRetryCount: 1, - locallySettledWaiterCount: 1, - cancelDebtCount: 1, - replacementAdmissionDelayedCount: 1, - overlappingJoinCount: 2, - peakLocallyUnsettled: 4, - estimatedLateWorkAllowanceCount: 1 - } - }) - await Promise.all([first, joined]) - - expect(harness.deps.onTelemetry).toHaveBeenCalledOnce() - expect(harness.deps.onTelemetry).toHaveBeenCalledWith( - expect.objectContaining({ - mode: 'prepare-only', - catalogOutcome: 'degraded', - catalogDurationMs: 12, - gitWorktreeCount: 3, - folderWorkspaceCount: 2, - queueWaitDurationsMs: [8], - providerExecutionDurationsMs: [13], - timeoutRetryCount: 1, - cancelDebtCount: 1, - replacementAdmissionDelayedCount: 1, - overlappingJoinCount: 3, - peakLocallyUnsettled: 4 - }) - ) - }) - - it('does not let a telemetry callback failure affect recovery', async () => { - const harness = createCoordinatorHarness() - const owner = authority('target-a') - harness.current.set(owner.targetId, owner) - vi.mocked(harness.deps.onTelemetry!).mockImplementation(() => { - throw new Error('telemetry unavailable') - }) - - const pending = harness.coordinator.requestReconnect(owner) - await flush() - harness.complete(0) - - await expect(pending).resolves.toMatchObject({ status: 'complete' }) - }) -}) diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts b/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts deleted file mode 100644 index e46f4063c41..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-coordinator.ts +++ /dev/null @@ -1,291 +0,0 @@ -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import { - createDirectSshPreparationCoordinator, - type DirectSshPreparationCoordinator -} from './direct-ssh-reconnect-preparation' -import { - combineDirectSshReconnectOutcome, - createEmptyDirectSshRepoOutcomeCounts, - createTerminalOnlyDirectSshReconnectOutcome -} from './direct-ssh-reconnect-coordinator-outcomes' -import { - createDirectSshReconnectTargetState, - type DirectSshReconnectTargetState -} from './direct-ssh-reconnect-coordinator-stabilization' -import { createDirectSshCoordinatorTelemetryReporter } from './direct-ssh-reconnect-coordinator-telemetry' -import type { - DirectSshCorrectionReason, - DirectSshPreparationInput, - DirectSshPreparationOutcome, - DirectSshPreparationReason, - DirectSshReconnectCoordinator, - DirectSshReconnectCoordinatorDeps, - DirectSshReconnectOutcome, - DirectSshReconnectTimer -} from './direct-ssh-reconnect-coordinator-types' -import { - directSshAuthoritiesEqual, - isDirectSshPreparationInputHostConsistent, - normalizeDirectSshPreparationInput -} from './direct-ssh-reconnect-tokens' -export type * from './direct-ssh-reconnect-coordinator-types' - -export const DIRECT_SSH_RELAY_STABILIZATION_MS = 5_000 - -export function createDirectSshReconnectCoordinator( - deps: DirectSshReconnectCoordinatorDeps -): DirectSshReconnectCoordinator { - const now = deps.now ?? Date.now - const setTimer = - deps.setTimer ?? ((callback: () => void, delayMs: number) => setTimeout(callback, delayMs)) - const clearTimer = deps.clearTimer ?? ((timer: DirectSshReconnectTimer) => clearTimeout(timer)) - const stabilizationMs = deps.stabilizationMs ?? DIRECT_SSH_RELAY_STABILIZATION_MS - const targets = new Map() - let stopped = false - - const isCurrent = (authority: DirectSshAuthority): boolean => { - const state = targets.get(authority.targetId) - return ( - !stopped && - directSshAuthoritiesEqual(state?.authority, authority) && - deps.isCurrentConnectedAuthority(authority) - ) - } - - const preparation: DirectSshPreparationCoordinator = createDirectSshPreparationCoordinator({ - scheduler: deps.scheduler, - isCurrentAuthority: isCurrent, - readLineage: deps.readHostScopedLineage, - now - }) - const telemetry = createDirectSshCoordinatorTelemetryReporter({ - onTelemetry: deps.onTelemetry, - now - }) - - const replaceAuthority = (authority: DirectSshAuthority): boolean => { - if (stopped) { - return false - } - const previous = targets.get(authority.targetId) - if (directSshAuthoritiesEqual(previous?.authority, authority)) { - return false - } - if (previous) { - if (previous.timer) { - clearTimer(previous.timer) - } - preparation.invalidateAuthority(previous.authority) - deps.scheduler.disposeProvider(previous.authority) - } - const installedAt = now() - targets.set( - authority.targetId, - createDirectSshReconnectTargetState(authority, previous, installedAt, stabilizationMs) - ) - return previous !== undefined - } - - const captureInput = async ( - authority: DirectSshAuthority, - reason: DirectSshPreparationReason - ): Promise => { - const captured = await deps.capturePreparationInput(authority, reason) - if ( - !captured || - !directSshAuthoritiesEqual(captured, authority) || - !isDirectSshPreparationInputHostConsistent(captured) || - !isCurrent(authority) - ) { - return null - } - return normalizeDirectSshPreparationInput({ ...captured, reason }) - } - - const runPreparedReconnect = async ( - authority: DirectSshAuthority, - staleBindingsCleared: number, - retriedTerminals: number, - damped: boolean, - operationStartedAt = now(), - terminalFinalizationDurationMs = 0, - authorityRotationCount = 0 - ): Promise => { - const input = await captureInput(authority, 'reconnect') - if (!input) { - const outcome = createTerminalOnlyDirectSshReconnectOutcome( - 'stale', - staleBindingsCleared, - retriedTerminals - ) - telemetry.reportWithoutInput('reconnect', 'reconnect', outcome, operationStartedAt, { - staleBindingsCleared, - retriedTerminals, - terminalFinalizationDurationMs, - catalogOutcome: 'stale', - catalogDurationMs: Math.max(0, now() - operationStartedAt), - authorityRotationCount, - damped - }) - return outcome - } - const acquired = preparation.acquire(input) - const prepared = await acquired.promise - let correctedTerminals = 0 - if (prepared.token && isCurrent(authority)) { - correctedTerminals = deps.correctUnboundTerminalPanes(authority, 'preparation-complete') - } - const outcome = combineDirectSshReconnectOutcome( - prepared, - staleBindingsCleared, - retriedTerminals, - correctedTerminals - ) - if (!acquired.joined) { - telemetry.report('reconnect', input, prepared, operationStartedAt, { - terminalFinalizationDurationMs, - staleBindingsCleared, - retriedTerminals, - correctedTerminals, - damped, - authorityRotationCount - }) - } - return outcome - } - - const runDelayedPreparation = async (authority: DirectSshAuthority): Promise => { - if (!isCurrent(authority)) { - return - } - await runPreparedReconnect(authority, 0, 0, true, now(), 0, 1) - } - - const scheduleLatestPreparation = (state: DirectSshReconnectTargetState): void => { - if (state.timer || state.dampUntil === null) { - return - } - const delayMs = Math.max(0, state.dampUntil - now()) - state.timer = setTimer(() => { - state.timer = null - state.dampUntil = null - void runDelayedPreparation(state.authority) - }, delayMs) - } - - const requestReconnect = async ( - authority: DirectSshAuthority - ): Promise => { - const startedAt = now() - if (stopped || !deps.isCurrentConnectedAuthority(authority)) { - const outcome = createTerminalOnlyDirectSshReconnectOutcome(stopped ? 'stopped' : 'stale') - telemetry.reportWithoutInput('reconnect', 'reconnect', outcome, startedAt, { - catalogOutcome: 'degraded' - }) - return outcome - } - const rotated = replaceAuthority(authority) - if (!isCurrent(authority)) { - const outcome = createTerminalOnlyDirectSshReconnectOutcome('stale') - telemetry.reportWithoutInput('reconnect', 'reconnect', outcome, startedAt, { - authorityRotationCount: rotated ? 1 : 0 - }) - return outcome - } - const terminalStartedAt = now() - const staleBindingsCleared = deps.invalidateStaleTerminalBindings(authority) - const retriedTerminals = deps.retryTargetPanes(authority) - const terminalFinalizationDurationMs = Math.max(0, now() - terminalStartedAt) - const state = targets.get(authority.targetId)! - if (state.dampUntil !== null && now() < state.dampUntil) { - scheduleLatestPreparation(state) - const outcome = createTerminalOnlyDirectSshReconnectOutcome( - 'stabilizing', - staleBindingsCleared, - retriedTerminals - ) - telemetry.reportWithoutInput('reconnect', 'reconnect', outcome, startedAt, { - staleBindingsCleared, - retriedTerminals, - terminalFinalizationDurationMs, - catalogOutcome: 'degraded', - authorityRotationCount: rotated ? 1 : 0, - damped: true - }) - return outcome - } - return runPreparedReconnect( - authority, - staleBindingsCleared, - retriedTerminals, - false, - startedAt, - terminalFinalizationDurationMs, - rotated ? 1 : 0 - ) - } - - const prepareOnly = ( - rawInput: DirectSshPreparationInput - ): Promise => { - const input = normalizeDirectSshPreparationInput(rawInput) - if (stopped || !isCurrent(input) || !isDirectSshPreparationInputHostConsistent(input)) { - const outcome: DirectSshPreparationOutcome = { - status: stopped ? 'stopped' : 'stale', - token: null, - repoOutcomes: createEmptyDirectSshRepoOutcomeCounts(), - lineageOutcome: 'not-started', - metrics: createTerminalOnlyDirectSshReconnectOutcome('stale').metrics - } - telemetry.report('prepare-only', input, outcome, now()) - return Promise.resolve(outcome) - } - const startedAt = now() - const acquired = preparation.acquire(input) - if (!acquired.joined) { - void acquired.promise.then((outcome) => { - telemetry.report('prepare-only', input, outcome, startedAt) - }) - } - return acquired.promise - } - - const correctUnboundTerminals = ( - authority: DirectSshAuthority, - reason: DirectSshCorrectionReason - ): number => (isCurrent(authority) ? deps.correctUnboundTerminalPanes(authority, reason) : 0) - - const invalidate = (targetId: string): void => { - const state = targets.get(targetId) - if (state?.timer) { - clearTimer(state.timer) - } - preparation.invalidateTarget(targetId) - deps.scheduler.invalidateTarget(targetId) - targets.delete(targetId) - } - - const stop = (): void => { - if (stopped) { - return - } - stopped = true - for (const state of targets.values()) { - if (state.timer) { - clearTimer(state.timer) - } - } - preparation.stop() - deps.scheduler.stop() - targets.clear() - } - - return { - requestReconnect, - prepareOnly, - correctUnboundTerminals, - replaceAuthority, - invalidate, - stop - } -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-preparation-metrics.ts b/src/renderer/src/hooks/direct-ssh-reconnect-preparation-metrics.ts deleted file mode 100644 index 6068319fc32..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-preparation-metrics.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { DirectSshWorktreeRefreshOutcome } from './direct-ssh-worktree-refresh-scheduler' -import type { DirectSshPreparationMetrics } from './direct-ssh-reconnect-coordinator-types' -import { createEmptyDirectSshPreparationMetrics } from './direct-ssh-reconnect-coordinator-outcomes' - -export function aggregateDirectSshPreparationMetrics( - outcomes: readonly DirectSshWorktreeRefreshOutcome[], - joinCount: number -): DirectSshPreparationMetrics { - const aggregate = createEmptyDirectSshPreparationMetrics() - for (const outcome of outcomes) { - const metrics = outcome.metrics - if (!metrics) { - continue - } - aggregate.queueWaitDurationsMs = [ - ...aggregate.queueWaitDurationsMs, - ...metrics.queueWaitDurationsMs - ] - aggregate.providerExecutionDurationsMs = [ - ...aggregate.providerExecutionDurationsMs, - ...metrics.providerExecutionDurationsMs - ] - aggregate.timeoutRetryCount += metrics.timeoutRetryCount - aggregate.locallySettledWaiterCount += metrics.locallySettledWaiterCount - aggregate.cancelDebtCount += metrics.cancelDebtCount - aggregate.replacementAdmissionDelayedCount += metrics.replacementAdmissionDelayedCount - aggregate.schedulerOverlappingJoinCount += metrics.overlappingJoinCount - aggregate.peakLocallyUnsettled = Math.max( - aggregate.peakLocallyUnsettled, - metrics.peakLocallyUnsettled - ) - aggregate.estimatedLateWorkAllowanceCount = Math.max( - aggregate.estimatedLateWorkAllowanceCount, - metrics.estimatedLateWorkAllowanceCount - ) - } - aggregate.schedulerOverlappingJoinCount += joinCount - return aggregate -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-preparation.ts b/src/renderer/src/hooks/direct-ssh-reconnect-preparation.ts deleted file mode 100644 index e18dced3112..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-preparation.ts +++ /dev/null @@ -1,291 +0,0 @@ -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import type { - DirectSshWorktreeRefreshLease, - DirectSshWorktreeRefreshOutcome, - DirectSshWorktreeRefreshScheduler, - DirectSshWorktreeRefreshTerminalStatus -} from './direct-ssh-worktree-refresh-scheduler' -import type { - DirectSshLineageOutcome, - DirectSshPreparationInput, - DirectSshPreparationOutcome, - DirectSshPreparationToken, - DirectSshRepoOutcomeCounts -} from './direct-ssh-reconnect-coordinator-types' -import { - createEmptyDirectSshPreparationMetrics, - createEmptyDirectSshRepoOutcomeCounts -} from './direct-ssh-reconnect-coordinator-outcomes' -import { aggregateDirectSshPreparationMetrics } from './direct-ssh-reconnect-preparation-metrics' -import { - directSshAuthoritiesEqual, - directSshPreparationOperationKey, - directSshRepoFingerprint, - normalizeDirectSshPreparationInput -} from './direct-ssh-reconnect-tokens' - -type PreparationOperation = { - key: string - input: DirectSshPreparationInput - leases: DirectSshWorktreeRefreshLease[] - joinCount: number - invalidatedAs: 'stale' | 'stopped' | null - settleInvalidation: (reason: 'stale' | 'stopped') => void - invalidation: Promise<'stale' | 'stopped'> - promise: Promise -} - -export type DirectSshPreparationCoordinator = { - acquire: (input: DirectSshPreparationInput) => { - promise: Promise - joined: boolean - } - invalidateAuthority: (authority: DirectSshAuthority) => void - invalidateTarget: (targetId: string) => void - stop: () => void -} - -type PreparationCoordinatorDeps = { - scheduler: DirectSshWorktreeRefreshScheduler - isCurrentAuthority: (authority: DirectSshAuthority) => boolean - readLineage: (input: DirectSshPreparationInput) => Promise - now?: () => number -} - -const TERMINAL_STATUSES: readonly DirectSshWorktreeRefreshTerminalStatus[] = [ - 'complete', - 'non-authoritative', - 'timed-out', - 'cancel-budget-exhausted', - 'canceled', - 'stale', - 'rejected' -] - -function countRepoOutcomes( - outcomes: readonly DirectSshWorktreeRefreshOutcome[] -): DirectSshRepoOutcomeCounts { - const counts = createEmptyDirectSshRepoOutcomeCounts() - for (const outcome of outcomes) { - counts[outcome.status]++ - } - return counts -} - -function awaitRepoLeases( - leases: readonly DirectSshWorktreeRefreshLease[] -): Promise { - if (leases.length === 0) { - return Promise.resolve([]) - } - return new Promise((resolve) => { - const outcomes: DirectSshWorktreeRefreshOutcome[] = [] - let remaining = leases.length - leases.forEach((lease, index) => { - void lease.result.then( - (outcome) => { - outcomes[index] = outcome - remaining-- - if (remaining === 0) { - resolve(outcomes) - } - }, - () => { - outcomes[index] = { status: 'rejected' } - remaining-- - if (remaining === 0) { - resolve(outcomes) - } - } - ) - }) - }) -} - -function terminalPreparationOutcome( - status: 'canceled' | 'stale' | 'stopped', - repoOutcomes: DirectSshRepoOutcomeCounts, - metrics = createEmptyDirectSshPreparationMetrics() -): DirectSshPreparationOutcome { - return { - status, - token: null, - repoOutcomes, - lineageOutcome: 'not-started', - metrics - } -} - -function buildPreparationToken( - input: DirectSshPreparationInput, - outcome: 'complete' | 'degraded' -): DirectSshPreparationToken { - return { - authority: { - targetId: input.targetId, - providerEpoch: input.providerEpoch, - connectionGeneration: input.connectionGeneration - }, - catalogRevision: input.catalogRevision, - repoFingerprint: directSshRepoFingerprint(input), - authorityRequirement: input.authorityRequirement, - snapshotRevision: input.snapshotRevision ?? null, - outcome - } -} - -function hasOutcome( - counts: DirectSshRepoOutcomeCounts, - statuses: readonly DirectSshWorktreeRefreshTerminalStatus[] -): boolean { - return statuses.some((status) => counts[status] > 0) -} - -export function createDirectSshPreparationCoordinator( - deps: PreparationCoordinatorDeps -): DirectSshPreparationCoordinator { - const now = deps.now ?? Date.now - const operations = new Map() - let stopped = false - - const run = async (operation: PreparationOperation): Promise => { - const { input } = operation - operation.leases = input.repoRefs.map((repo) => - deps.scheduler.request({ - targetId: input.targetId, - providerEpoch: input.providerEpoch, - connectionGeneration: input.connectionGeneration, - repoId: repo.repoId, - executionHostId: repo.executionHostId, - catalogRevision: input.catalogRevision, - authorityRequirement: input.authorityRequirement - }) - ) - const outcomes = await awaitRepoLeases(operation.leases) - const repoOutcomes = countRepoOutcomes(outcomes) - const metrics = aggregateDirectSshPreparationMetrics(outcomes, operation.joinCount) - if (operation.invalidatedAs) { - return terminalPreparationOutcome(operation.invalidatedAs, repoOutcomes, metrics) - } - if (!deps.isCurrentAuthority(input)) { - return terminalPreparationOutcome('stale', repoOutcomes, metrics) - } - if (hasOutcome(repoOutcomes, ['canceled', 'stale'])) { - const status = repoOutcomes.stale > 0 ? 'stale' : 'canceled' - return terminalPreparationOutcome(status, repoOutcomes, metrics) - } - - let lineageOutcome: DirectSshLineageOutcome - const lineageStartedAt = now() - try { - lineageOutcome = await deps.readLineage(input) - } catch { - lineageOutcome = 'degraded' - } - metrics.lineageDurationMs = Math.max(0, now() - lineageStartedAt) - if (operation.invalidatedAs) { - return terminalPreparationOutcome(operation.invalidatedAs, repoOutcomes, metrics) - } - if (!deps.isCurrentAuthority(input) || lineageOutcome === 'stale') { - return terminalPreparationOutcome('stale', repoOutcomes, metrics) - } - if (lineageOutcome === 'canceled') { - return terminalPreparationOutcome('canceled', repoOutcomes, metrics) - } - const degraded = - lineageOutcome === 'degraded' || - TERMINAL_STATUSES.some((status) => status !== 'complete' && repoOutcomes[status] > 0) - const status = degraded ? 'degraded' : 'complete' - return { - status, - token: buildPreparationToken(input, status), - repoOutcomes, - lineageOutcome, - metrics - } - } - - const acquire = ( - rawInput: DirectSshPreparationInput - ): { promise: Promise; joined: boolean } => { - const input = normalizeDirectSshPreparationInput(rawInput) - const key = directSshPreparationOperationKey(input) - const current = operations.get(key) - if (current) { - current.joinCount++ - return { promise: current.promise, joined: true } - } - if (stopped) { - return { - promise: Promise.resolve( - terminalPreparationOutcome('stopped', createEmptyDirectSshRepoOutcomeCounts()) - ), - joined: false - } - } - const operation: PreparationOperation = { - key, - input, - leases: [], - joinCount: 0, - invalidatedAs: null, - settleInvalidation: () => {}, - invalidation: Promise.resolve('stopped'), - promise: Promise.resolve( - terminalPreparationOutcome('stopped', createEmptyDirectSshRepoOutcomeCounts()) - ) - } - operation.invalidation = new Promise((resolve) => { - operation.settleInvalidation = resolve - }) - operations.set(key, operation) - operation.promise = Promise.race([ - run(operation), - operation.invalidation.then((reason) => - terminalPreparationOutcome(reason, createEmptyDirectSshRepoOutcomeCounts()) - ) - ]).finally(() => { - if (operations.get(key) === operation) { - operations.delete(key) - } - }) - return { promise: operation.promise, joined: false } - } - - const invalidateMatching = ( - predicate: (operation: PreparationOperation) => boolean, - reason: 'stale' | 'stopped' - ): void => { - for (const operation of operations.values()) { - if (!predicate(operation)) { - continue - } - operation.invalidatedAs = reason - operation.settleInvalidation(reason) - for (const lease of operation.leases) { - lease.release(reason === 'stopped' ? 'stopped' : 'invalidated') - } - } - } - - const invalidateAuthority = (authority: DirectSshAuthority): void => { - invalidateMatching( - (operation) => directSshAuthoritiesEqual(operation.input, authority), - 'stale' - ) - } - - const invalidateTarget = (targetId: string): void => { - invalidateMatching((operation) => operation.input.targetId === targetId, 'stale') - } - - const stop = (): void => { - if (stopped) { - return - } - stopped = true - invalidateMatching(() => true, 'stopped') - } - - return { acquire, invalidateAuthority, invalidateTarget, stop } -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts b/src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts deleted file mode 100644 index a835ea81856..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-rollout.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - DIRECT_SSH_RECONNECT_SESSION_ROUTE_KEY, - isDirectSshReconnectCoordinatorRoutingEnabled, - resolveDirectSshReconnectCoordinatorRouting -} from './direct-ssh-reconnect-rollout' - -describe('direct SSH reconnect rollout', () => { - it('defaults coordinator routing on', () => { - expect(resolveDirectSshReconnectCoordinatorRouting({})).toBe(true) - expect(resolveDirectSshReconnectCoordinatorRouting({ buildValue: 'true' })).toBe(true) - }) - - it('supports build and session disable routes without session re-enablement', () => { - expect( - resolveDirectSshReconnectCoordinatorRouting({ - buildValue: 'false', - sessionValue: 'true' - }) - ).toBe(false) - expect( - resolveDirectSshReconnectCoordinatorRouting({ - buildValue: 'true', - sessionValue: 'false' - }) - ).toBe(false) - }) - - it('reads the current session route and fails open when storage is unavailable', () => { - const getItem = vi.fn(() => 'false') - vi.stubGlobal('sessionStorage', { getItem }) - - expect(isDirectSshReconnectCoordinatorRoutingEnabled()).toBe(false) - expect(getItem).toHaveBeenCalledWith(DIRECT_SSH_RECONNECT_SESSION_ROUTE_KEY) - - getItem.mockImplementation(() => { - throw new Error('blocked') - }) - expect(isDirectSshReconnectCoordinatorRoutingEnabled()).toBe(true) - vi.unstubAllGlobals() - }) -}) diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-rollout.ts b/src/renderer/src/hooks/direct-ssh-reconnect-rollout.ts deleted file mode 100644 index aa8645cb7ba..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-rollout.ts +++ /dev/null @@ -1,25 +0,0 @@ -export const DIRECT_SSH_RECONNECT_SESSION_ROUTE_KEY = 'orca.directSshReconnectCoordinator.enabled' - -export function resolveDirectSshReconnectCoordinatorRouting(args: { - buildValue?: string - sessionValue?: string | null -}): boolean { - if (args.buildValue?.trim().toLowerCase() === 'false') { - return false - } - return args.sessionValue?.trim().toLowerCase() !== 'false' -} - -export function isDirectSshReconnectCoordinatorRoutingEnabled(): boolean { - let sessionValue: string | null = null - try { - sessionValue = - globalThis.sessionStorage?.getItem(DIRECT_SSH_RECONNECT_SESSION_ROUTE_KEY) ?? null - } catch { - // Storage can be unavailable in privacy-restricted renderer sessions. - } - return resolveDirectSshReconnectCoordinatorRouting({ - buildValue: import.meta.env.VITE_DIRECT_SSH_RECONNECT_COORDINATOR, - sessionValue - }) -} diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts b/src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts deleted file mode 100644 index 98790b7362e..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-tokens.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' - -const authority: DirectSshAuthority = { - targetId: 'target-a', - providerEpoch: 'epoch-a' as SshProviderEpoch, - connectionGeneration: 1 -} - -describe('directSshAuthoritiesEqual', () => { - it('fails closed when either authority is missing', () => { - expect(directSshAuthoritiesEqual(null, null)).toBe(false) - expect(directSshAuthoritiesEqual(authority, null)).toBe(false) - expect(directSshAuthoritiesEqual(undefined, authority)).toBe(false) - }) - - it('requires the complete authority tuple', () => { - expect(directSshAuthoritiesEqual(authority, { ...authority })).toBe(true) - expect( - directSshAuthoritiesEqual(authority, { - ...authority, - connectionGeneration: authority.connectionGeneration + 1 - }) - ).toBe(false) - }) -}) diff --git a/src/renderer/src/hooks/direct-ssh-reconnect-tokens.ts b/src/renderer/src/hooks/direct-ssh-reconnect-tokens.ts deleted file mode 100644 index 58e666a457a..00000000000 --- a/src/renderer/src/hooks/direct-ssh-reconnect-tokens.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { toSshExecutionHostId } from '../../../shared/execution-host' -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import type { DirectSshPreparationInput } from './direct-ssh-reconnect-coordinator-types' - -export function directSshAuthoritiesEqual( - left: DirectSshAuthority | null | undefined, - right: DirectSshAuthority | null | undefined -): boolean { - if (!left || !right) { - return false - } - return ( - left.targetId === right.targetId && - left.providerEpoch === right.providerEpoch && - left.connectionGeneration === right.connectionGeneration - ) -} - -export function normalizeDirectSshPreparationInput( - input: DirectSshPreparationInput -): DirectSshPreparationInput { - return { - ...input, - repoRefs: [...input.repoRefs].sort( - (left, right) => - compareText(left.executionHostId, right.executionHostId) || - compareText(left.repoId, right.repoId) - ) - } -} - -function compareText(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0 -} - -export function isDirectSshPreparationInputHostConsistent( - input: DirectSshPreparationInput -): boolean { - const expectedHost = toSshExecutionHostId(input.targetId) - return input.repoRefs.every((repo) => repo.executionHostId === expectedHost) -} - -export function directSshRepoFingerprint(input: DirectSshPreparationInput): string { - return JSON.stringify(input.repoRefs.map((repo) => [repo.executionHostId, repo.repoId])) -} - -export function directSshPreparationOperationKey(input: DirectSshPreparationInput): string { - return JSON.stringify([ - input.targetId, - input.providerEpoch, - input.connectionGeneration, - input.catalogRevision, - directSshRepoFingerprint(input), - input.authorityRequirement, - input.snapshotRevision ?? null, - input.reason - ]) -} diff --git a/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx b/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx index e2d8a015dc1..4da89ed8686 100644 --- a/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx +++ b/src/renderer/src/hooks/direct-ssh-runtime-wake-isolation.test.tsx @@ -142,16 +142,13 @@ describe('runtime and direct SSH wake isolation', () => { ] ]) }) - const staleAuthority = { ...currentAuthority, connectionGeneration: 6 } - expect(store.getState().invalidateStaleDirectSshTargetPtyBindings(staleAuthority)).toBe(0) - expect(store.getState().tabsByWorktree[directWorktreeId][0].ptyId).toBe(directPtyId) const wakePreparation = vi.fn() const correctedCounts: number[] = [] const unregisterDirectWake = registerDirectSshWakeRouting({ getConnectionStates: () => store.getState().sshConnectionStates, wakeAuthority: (nextAuthority) => { correctedCounts.push( - store.getState().invalidateStaleDirectSshTargetPtyBindings(nextAuthority) + store.getState().clearDirectSshTargetPtyBindings(nextAuthority.targetId) ) wakePreparation(nextAuthority) }, diff --git a/src/renderer/src/hooks/direct-ssh-state-routing.test.ts b/src/renderer/src/hooks/direct-ssh-state-routing.test.ts index e59014adb2e..673fd0e2d6c 100644 --- a/src/renderer/src/hooks/direct-ssh-state-routing.test.ts +++ b/src/renderer/src/hooks/direct-ssh-state-routing.test.ts @@ -3,8 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' import { directSshAuthorityFromConnectionState, - registerDirectSshWakeRouting, - routeDirectSshConnectedState + registerDirectSshWakeRouting } from './direct-ssh-state-routing' function authority(epoch = 'epoch-a'): DirectSshAuthority { @@ -15,109 +14,6 @@ function authority(epoch = 'epoch-a'): DirectSshAuthority { } } -function harness() { - return { - coordinatorRoutingEnabled: true, - coordinator: { - requestReconnect: vi.fn(async () => ({ status: 'complete' })), - correctUnboundTerminals: vi.fn(() => 0), - replaceAuthority: vi.fn() - }, - invalidateStaleTerminalBindings: vi.fn(() => 0), - retryTargetPanes: vi.fn(() => 0), - prepareHost: vi.fn(), - rememberReconnectAuthority: vi.fn() - } -} - -describe('routeDirectSshConnectedState', () => { - it('keeps initial hydration preparation-only', () => { - const deps = harness() - const current = authority() - - expect( - routeDirectSshConnectedState(deps, { - authority: current, - previousAuthority: null, - origin: 'initial-hydration' - }) - ).toBe('initial-hydration') - expect(deps.prepareHost).toHaveBeenCalledWith(current, 'initial-hydration') - expect(deps.coordinator.requestReconnect).not.toHaveBeenCalled() - expect(deps.coordinator.correctUnboundTerminals).not.toHaveBeenCalled() - }) - - it('requests reconnect only when the complete pushed authority changes', () => { - const deps = harness() - const previous = authority('epoch-old') - const current = authority('epoch-new') - - expect( - routeDirectSshConnectedState(deps, { - authority: current, - previousAuthority: previous, - origin: 'push' - }) - ).toBe('changed-authority') - expect(deps.rememberReconnectAuthority).toHaveBeenCalledWith(current) - expect(deps.coordinator.requestReconnect).toHaveBeenCalledWith(current) - expect(deps.coordinator.replaceAuthority).not.toHaveBeenCalled() - expect(deps.prepareHost).not.toHaveBeenCalled() - }) - - it('runs the disabled fallback terminal actions synchronously before bounded preparation', () => { - const order: string[] = [] - const deps = harness() - deps.coordinatorRoutingEnabled = false - deps.coordinator.replaceAuthority.mockImplementation(() => order.push('replace')) - deps.invalidateStaleTerminalBindings.mockImplementation(() => { - order.push('invalidate') - return 1 - }) - deps.retryTargetPanes.mockImplementation(() => { - order.push('retry') - return 1 - }) - deps.prepareHost.mockImplementation(() => { - order.push('prepare') - return new Promise(() => {}) - }) - const previous = authority('epoch-old') - const current = authority('epoch-new') - - expect( - routeDirectSshConnectedState(deps, { - authority: current, - previousAuthority: previous, - origin: 'push' - }) - ).toBe('changed-authority-fallback') - - expect(order).toEqual(['replace', 'invalidate', 'retry', 'prepare']) - expect(deps.coordinator.requestReconnect).not.toHaveBeenCalled() - expect(deps.prepareHost).toHaveBeenCalledWith(current, 'reconnect', { - authorityAlreadyReplaced: true - }) - expect(deps.rememberReconnectAuthority).toHaveBeenCalledWith(current) - }) - - it('routes an exact-equal push through correction and fresh preparation', () => { - const deps = harness() - const current = authority() - - expect( - routeDirectSshConnectedState(deps, { - authority: current, - previousAuthority: { ...current }, - origin: 'push' - }) - ).toBe('same-authority-wake') - expect(deps.coordinator.correctUnboundTerminals).toHaveBeenCalledWith(current, 'wake-refresh') - expect(deps.prepareHost).toHaveBeenCalledWith(current, 'wake-refresh') - expect(deps.coordinator.requestReconnect).not.toHaveBeenCalled() - }) -}) - describe('registerDirectSshWakeRouting', () => { it('wakes only complete connected direct authorities and removes both listeners', () => { const wakeAuthority = vi.fn() diff --git a/src/renderer/src/hooks/direct-ssh-state-routing.ts b/src/renderer/src/hooks/direct-ssh-state-routing.ts index b9ae9f4f607..1cdb1b27c08 100644 --- a/src/renderer/src/hooks/direct-ssh-state-routing.ts +++ b/src/renderer/src/hooks/direct-ssh-state-routing.ts @@ -1,62 +1,7 @@ import type { DirectSshAuthority, SshConnectionState } from '../../../shared/ssh-types' -import { directSshAuthoritiesEqual } from './direct-ssh-reconnect-tokens' export type DirectSshConnectedStateOrigin = 'push' | 'initial-hydration' -export type DirectSshConnectedStateRoutingDeps = { - coordinator: { - requestReconnect: (authority: DirectSshAuthority) => Promise - correctUnboundTerminals: (authority: DirectSshAuthority, reason: 'wake-refresh') => number - replaceAuthority: (authority: DirectSshAuthority) => void - } - coordinatorRoutingEnabled: boolean - invalidateStaleTerminalBindings: (authority: DirectSshAuthority) => number - retryTargetPanes: (authority: DirectSshAuthority) => number - prepareHost: ( - authority: DirectSshAuthority, - reason: 'reconnect' | 'initial-hydration' | 'wake-refresh', - options?: { authorityAlreadyReplaced?: boolean } - ) => void | Promise - rememberReconnectAuthority: (authority: DirectSshAuthority | null) => void -} - -export type DirectSshConnectedStateRoute = - | 'initial-hydration' - | 'changed-authority' - | 'changed-authority-fallback' - | 'same-authority-wake' - -export function routeDirectSshConnectedState( - deps: DirectSshConnectedStateRoutingDeps, - input: { - authority: DirectSshAuthority - previousAuthority: DirectSshAuthority | null - origin: DirectSshConnectedStateOrigin - } -): DirectSshConnectedStateRoute { - const { authority } = input - if (input.origin === 'initial-hydration') { - deps.rememberReconnectAuthority(null) - void deps.prepareHost(authority, 'initial-hydration') - return 'initial-hydration' - } - if (!directSshAuthoritiesEqual(input.previousAuthority, authority)) { - deps.rememberReconnectAuthority(authority) - if (deps.coordinatorRoutingEnabled) { - void deps.coordinator.requestReconnect(authority) - return 'changed-authority' - } - deps.coordinator.replaceAuthority(authority) - deps.invalidateStaleTerminalBindings(authority) - deps.retryTargetPanes(authority) - void deps.prepareHost(authority, 'reconnect', { authorityAlreadyReplaced: true }) - return 'changed-authority-fallback' - } - deps.coordinator.correctUnboundTerminals(authority, 'wake-refresh') - void deps.prepareHost(authority, 'wake-refresh') - return 'same-authority-wake' -} - export function directSshAuthorityFromConnectionState( targetId: string, state: SshConnectionState diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-cancellation.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-cancellation.ts deleted file mode 100644 index b7c75454220..00000000000 --- a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-cancellation.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { - DirectSshWorktreeRefreshAttempt, - DirectSshWorktreeRefreshReleaseReason -} from './direct-ssh-worktree-refresh-scheduler-types' - -export function cancelDirectSshWorktreeRefreshAttempt( - attempt: DirectSshWorktreeRefreshAttempt, - reason: DirectSshWorktreeRefreshReleaseReason -): boolean { - try { - const outcome = attempt.cancel(reason) - return outcome !== false && outcome !== 'retained' && outcome !== 'already-settled' - } catch { - return true - } -} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-metrics.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-metrics.ts deleted file mode 100644 index 52064521dd7..00000000000 --- a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-metrics.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { - DirectSshWorktreeRefreshLogicalTask, - DirectSshWorktreeRefreshMetrics, - DirectSshWorktreeRefreshSchedulerSnapshot -} from './direct-ssh-worktree-refresh-scheduler-types' - -export function createDirectSshWorktreeRefreshMetrics(): DirectSshWorktreeRefreshMetrics { - return { - queueWaitDurationsMs: [], - providerExecutionDurationsMs: [], - timeoutRetryCount: 0, - locallySettledWaiterCount: 0, - cancelDebtCount: 0, - replacementAdmissionDelayedCount: 0, - overlappingJoinCount: 0, - peakLocallyUnsettled: 0, - estimatedLateWorkAllowanceCount: 0 - } -} - -export function adjustDirectSshAuthorityUnsettled( - unsettledByAuthority: Map, - authorityId: string, - delta: number -): void { - const next = (unsettledByAuthority.get(authorityId) ?? 0) + delta - if (next > 0) { - unsettledByAuthority.set(authorityId, next) - } else { - unsettledByAuthority.delete(authorityId) - } -} - -export function copyDirectSshWorktreeRefreshMetrics( - metrics: DirectSshWorktreeRefreshMetrics -): DirectSshWorktreeRefreshMetrics { - return { - ...metrics, - queueWaitDurationsMs: [...metrics.queueWaitDurationsMs], - providerExecutionDurationsMs: [...metrics.providerExecutionDurationsMs] - } -} - -export function recordDirectSshCancelDebt( - metrics: DirectSshWorktreeRefreshMetrics, - lateWorkLimit: number -): void { - metrics.cancelDebtCount++ - metrics.estimatedLateWorkAllowanceCount = Math.min( - lateWorkLimit, - metrics.estimatedLateWorkAllowanceCount + 1 - ) -} - -export function recordDirectSshQueueAdmission( - metrics: DirectSshWorktreeRefreshMetrics, - queueWaitDurationMs: number, - locallyUnsettled: number -): void { - metrics.queueWaitDurationsMs = [...metrics.queueWaitDurationsMs, queueWaitDurationMs] - metrics.peakLocallyUnsettled = Math.max(metrics.peakLocallyUnsettled, locallyUnsettled) -} - -export function recordDirectSshProviderExecution( - metrics: DirectSshWorktreeRefreshMetrics, - providerExecutionDurationMs: number -): void { - metrics.providerExecutionDurationsMs = [ - ...metrics.providerExecutionDurationsMs, - providerExecutionDurationMs - ] -} - -export function directSshWorktreeRefreshSchedulerSnapshot( - tasks: ReadonlyMap, - locallyUnsettled: number, - cancelDebtByAuthority: ReadonlyMap -): DirectSshWorktreeRefreshSchedulerSnapshot { - let queued = 0 - let retrying = 0 - let waiters = 0 - for (const task of tasks.values()) { - queued += task.state === 'queued' ? 1 : 0 - retrying += task.state === 'retrying' ? 1 : 0 - waiters += task.waiters.size - } - return { - locallyUnsettled, - queued, - retrying, - logicalTasks: tasks.size, - waiters, - cancelDebtByAuthority: new Map(cancelDebtByAuthority) - } -} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-types.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-types.ts deleted file mode 100644 index 1240304f8cd..00000000000 --- a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler-types.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { - HostQualifiedDetectedWorktreeResult, - ProviderRequestId, - SshExecutionHostId -} from '../../../shared/detected-worktree-provider-contract' -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import type { - DetectedWorktreeRefreshReleaseOutcome, - WaiterLeaseId -} from '../store/slices/detected-worktree-refresh-leases' - -export type { WaiterLeaseId } from '../store/slices/detected-worktree-refresh-leases' - -export type DirectSshWorktreeRefreshAuthorityRequirement = 'required' | 'allow-metadata-fallback' - -export type DirectSshWorktreeRefreshKey = DirectSshAuthority & { - repoId: string - executionHostId: SshExecutionHostId - catalogRevision: number - authorityRequirement: DirectSshWorktreeRefreshAuthorityRequirement -} - -export type DirectSshWorktreeRefreshTerminalStatus = - | 'complete' - | 'non-authoritative' - | 'timed-out' - | 'cancel-budget-exhausted' - | 'canceled' - | 'stale' - | 'rejected' - -export type DirectSshWorktreeRefreshOutcome = { - status: DirectSshWorktreeRefreshTerminalStatus - providerRequestId?: ProviderRequestId - providerResult?: HostQualifiedDetectedWorktreeResult - metrics?: DirectSshWorktreeRefreshMetrics -} - -export type DirectSshWorktreeRefreshMetrics = { - queueWaitDurationsMs: readonly number[] - providerExecutionDurationsMs: readonly number[] - timeoutRetryCount: number - locallySettledWaiterCount: number - cancelDebtCount: number - replacementAdmissionDelayedCount: number - overlappingJoinCount: number - peakLocallyUnsettled: number - estimatedLateWorkAllowanceCount: number -} - -export type DirectSshWorktreeRefreshReleaseReason = 'superseded' | 'invalidated' | 'stopped' - -export type DirectSshWorktreeRefreshLease = { - waiterLeaseId: WaiterLeaseId - result: Promise - release: (reason: DirectSshWorktreeRefreshReleaseReason) => void -} - -export type DirectSshWorktreeRefreshAttempt = { - providerRequestId: ProviderRequestId - result: Promise - cancel: ( - reason: DirectSshWorktreeRefreshReleaseReason - ) => boolean | void | DetectedWorktreeRefreshReleaseOutcome -} - -export type DirectSshWorktreeRefreshSchedulerDeps = { - startAttempt: (key: DirectSshWorktreeRefreshKey) => DirectSshWorktreeRefreshAttempt - createWaiterLeaseId?: () => WaiterLeaseId - onUnexpectedError?: (error: unknown) => void - now?: () => number -} - -export type DirectSshWorktreeRefreshSchedulerSnapshot = { - locallyUnsettled: number - queued: number - retrying: number - logicalTasks: number - waiters: number - cancelDebtByAuthority: ReadonlyMap -} - -export type DirectSshWorktreeRefreshScheduler = { - request: (key: DirectSshWorktreeRefreshKey) => DirectSshWorktreeRefreshLease - invalidateAuthority: (authority: DirectSshAuthority) => void - invalidateTarget: (targetId: string) => void - disposeProvider: (authority: DirectSshAuthority) => void - getSnapshot: () => DirectSshWorktreeRefreshSchedulerSnapshot - stop: () => void -} - -export type DirectSshWorktreeRefreshWaiter = { - resolve: (outcome: DirectSshWorktreeRefreshOutcome) => void -} - -export type DirectSshWorktreeRefreshLogicalTask = { - key: DirectSshWorktreeRefreshKey - keyId: string - authorityId: string - state: 'queued' | 'running' | 'retrying' | 'terminal' - attemptCount: number - attempt: DirectSshWorktreeRefreshAttempt | null - attemptCanceled: boolean - attemptStartedAt: number | null - queuedAt: number - metrics: DirectSshWorktreeRefreshMetrics - waiters: Map -} - -export function directSshProviderAuthorityKey(authority: DirectSshAuthority): string { - return JSON.stringify([ - authority.targetId, - authority.providerEpoch, - authority.connectionGeneration - ]) -} - -export function directSshWorktreeRefreshKey(key: DirectSshWorktreeRefreshKey): string { - return JSON.stringify([ - key.targetId, - key.repoId, - key.executionHostId, - key.providerEpoch, - key.connectionGeneration, - key.catalogRevision, - key.authorityRequirement - ]) -} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts deleted file mode 100644 index 800938a1a67..00000000000 --- a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.test.ts +++ /dev/null @@ -1,435 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { - HostQualifiedDetectedWorktreeResult, - ProviderRequestId -} from '../../../shared/detected-worktree-provider-contract' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import { - createDirectSshWorktreeRefreshScheduler, - DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY, - type DirectSshWorktreeRefreshAttempt, - type DirectSshWorktreeRefreshKey, - type DirectSshWorktreeRefreshReleaseReason -} from './direct-ssh-worktree-refresh-scheduler' - -type Deferred = { - promise: Promise - resolve: (value: T) => void - reject: (error: unknown) => void -} - -type ControlledAttempt = DirectSshWorktreeRefreshAttempt & { - key: DirectSshWorktreeRefreshKey - deferred: Deferred - cancel: ReturnType> -} - -function deferred(): Deferred { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise - reject = rejectPromise - }) - return { promise, resolve, reject } -} - -function epoch(value: string): SshProviderEpoch { - return value as SshProviderEpoch -} - -function requestId(value: string): ProviderRequestId { - return value as ProviderRequestId -} - -function key( - targetId: string, - repoId: string, - overrides: Partial = {} -): DirectSshWorktreeRefreshKey { - return { - targetId, - repoId, - executionHostId: `ssh:${targetId}`, - providerEpoch: epoch(`epoch-${targetId}`), - connectionGeneration: 1, - catalogRevision: 1, - authorityRequirement: 'required', - ...overrides - } -} - -function terminalResult( - attempt: ControlledAttempt, - status: 'timed-out' | 'canceled' | 'stale' | 'rejected' -): HostQualifiedDetectedWorktreeResult { - return { - status, - providerRequestId: attempt.providerRequestId, - executionHostId: attempt.key.executionHostId - } -} - -function completeResult( - attempt: ControlledAttempt, - status: 'complete' | 'non-authoritative' = 'complete' -): HostQualifiedDetectedWorktreeResult { - return { - status, - providerRequestId: attempt.providerRequestId, - repoId: attempt.key.repoId, - authority: { - kind: 'direct-ssh', - executionHostId: attempt.key.executionHostId, - targetId: attempt.key.targetId, - providerEpoch: attempt.key.providerEpoch, - connectionGeneration: attempt.key.connectionGeneration - }, - result: { - repoId: attempt.key.repoId, - authoritative: status === 'complete', - source: status === 'complete' ? 'git' : 'metadata-fallback', - worktrees: [] - } - } -} - -function createHarness(cancelOutcome?: 'cancel-failed', now?: () => number) { - const attempts: ControlledAttempt[] = [] - const onUnexpectedError = vi.fn() - const startAttempt = vi.fn((attemptKey: DirectSshWorktreeRefreshKey) => { - const providerDeferred = deferred() - const waiterDeferred = deferred() - const providerRequestId = requestId(`provider-${attempts.length + 1}`) - const attempt: ControlledAttempt = { - key: attemptKey, - providerRequestId, - result: waiterDeferred.promise, - deferred: providerDeferred, - cancel: vi.fn((_reason: DirectSshWorktreeRefreshReleaseReason) => { - waiterDeferred.resolve({ - providerRequestId, - executionHostId: attemptKey.executionHostId, - status: 'canceled' - }) - return cancelOutcome - }) - } - void providerDeferred.promise.then(waiterDeferred.resolve, waiterDeferred.reject) - attempts.push(attempt) - return attempt - }) - const scheduler = createDirectSshWorktreeRefreshScheduler({ - startAttempt, - onUnexpectedError, - now - }) - return { scheduler, attempts, startAttempt, onUnexpectedError } -} - -async function flushAttempt(): Promise { - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() -} - -describe('createDirectSshWorktreeRefreshScheduler', () => { - it('admits an idle singleton immediately and cleans it up on completion', async () => { - const { scheduler, attempts, startAttempt } = createHarness() - const lease = scheduler.request(key('target-a', 'repo-a')) - - expect(startAttempt).toHaveBeenCalledTimes(1) - expect(scheduler.getSnapshot().locallyUnsettled).toBe(1) - - attempts[0].deferred.resolve(completeResult(attempts[0])) - await expect(lease.result).resolves.toMatchObject({ status: 'complete' }) - await flushAttempt() - expect(scheduler.getSnapshot()).toMatchObject({ - locallyUnsettled: 0, - logicalTasks: 0, - waiters: 0 - }) - }) - - it('never admits more than five locally unsettled attempts', async () => { - const { scheduler, attempts } = createHarness() - const leases = Array.from({ length: 6 }, (_, index) => - scheduler.request(key('target-a', `repo-${index}`)) - ) - - expect(attempts).toHaveLength(DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY) - expect(scheduler.getSnapshot().queued).toBe(1) - - attempts[0].deferred.resolve(completeResult(attempts[0])) - await leases[0].result - await flushAttempt() - expect(attempts).toHaveLength(6) - expect(scheduler.getSnapshot().locallyUnsettled).toBe(5) - - scheduler.stop() - }) - - it('round-robins target lanes after at most one earlier lane admission', async () => { - const { scheduler, attempts } = createHarness() - const leases = Array.from({ length: 7 }, (_, index) => - scheduler.request(key('target-a', `repo-a-${index}`)) - ) - const targetB = scheduler.request(key('target-b', 'repo-b')) - - attempts[0].deferred.resolve(completeResult(attempts[0])) - await leases[0].result - await flushAttempt() - expect(attempts[5].key.targetId).toBe('target-a') - - attempts[1].deferred.resolve(completeResult(attempts[1])) - await leases[1].result - await flushAttempt() - expect(attempts[6].key.targetId).toBe('target-b') - - scheduler.stop() - await expect(targetB.result).resolves.toMatchObject({ status: 'canceled' }) - }) - - it('joins only exact keys and separates authority, revision, and requirement', () => { - const { scheduler, attempts } = createHarness() - const base = key('target-a', 'repo') - const first = scheduler.request(base) - const joined = scheduler.request({ ...base }) - scheduler.request({ ...base, providerEpoch: epoch('epoch-next') }) - scheduler.request({ ...base, connectionGeneration: 2 }) - scheduler.request({ ...base, catalogRevision: 2 }) - scheduler.request({ ...base, authorityRequirement: 'allow-metadata-fallback' }) - - expect(first.waiterLeaseId).not.toBe(joined.waiterLeaseId) - expect(attempts).toHaveLength(5) - expect(scheduler.getSnapshot()).toMatchObject({ logicalTasks: 5, waiters: 6 }) - scheduler.stop() - }) - - it('keeps the logical barrier pending through one timeout and uses a fresh request ID', async () => { - const { scheduler, attempts } = createHarness() - const lease = scheduler.request(key('target-a', 'repo')) - let settled = false - void lease.result.then(() => { - settled = true - }) - - attempts[0].deferred.resolve(terminalResult(attempts[0], 'timed-out')) - await flushAttempt() - - expect(settled).toBe(false) - expect(attempts).toHaveLength(2) - expect(attempts[1].providerRequestId).not.toBe(attempts[0].providerRequestId) - - attempts[1].deferred.resolve(completeResult(attempts[1])) - await expect(lease.result).resolves.toMatchObject({ - status: 'complete', - providerRequestId: attempts[1].providerRequestId - }) - }) - - it('requeues a timed-out retry at its target lane tail', async () => { - const { scheduler, attempts } = createHarness() - const retrying = scheduler.request(key('target-a', 'retrying')) - const blockers = Array.from({ length: 4 }, (_, index) => - scheduler.request(key('target-a', `blocker-${index}`)) - ) - const targetB = scheduler.request(key('target-b', 'target-b')) - - attempts[0].deferred.resolve(terminalResult(attempts[0], 'timed-out')) - await flushAttempt() - expect(attempts[5].key.targetId).toBe('target-b') - - attempts[1].deferred.resolve(completeResult(attempts[1])) - await blockers[0].result - await flushAttempt() - expect(attempts[6].key.repoId).toBe('retrying') - - attempts[6].deferred.resolve(completeResult(attempts[6])) - await expect(retrying.result).resolves.toMatchObject({ status: 'complete' }) - scheduler.stop() - await expect(targetB.result).resolves.toMatchObject({ status: 'canceled' }) - }) - - it('makes a second timeout terminal', async () => { - const { scheduler, attempts } = createHarness() - const lease = scheduler.request(key('target-a', 'repo')) - - attempts[0].deferred.resolve(terminalResult(attempts[0], 'timed-out')) - await flushAttempt() - attempts[1].deferred.resolve(terminalResult(attempts[1], 'timed-out')) - - await expect(lease.result).resolves.toMatchObject({ - status: 'timed-out', - providerRequestId: attempts[1].providerRequestId - }) - }) - - it('invalidates queued and current work without reporting cancellation errors', async () => { - const { scheduler, attempts, onUnexpectedError } = createHarness() - const leases = Array.from({ length: 6 }, (_, index) => - scheduler.request(key('target-a', `repo-${index}`)) - ) - - scheduler.invalidateTarget('target-a') - await expect(Promise.all(leases.map((lease) => lease.result))).resolves.toEqual( - Array.from({ length: 6 }, () => expect.objectContaining({ status: 'stale' })) - ) - expect(attempts.map((attempt) => attempt.cancel.mock.calls.length)).toEqual([1, 1, 1, 1, 1]) - expect(scheduler.getSnapshot()).toMatchObject({ logicalTasks: 0, waiters: 0 }) - - for (const attempt of attempts) { - attempt.deferred.reject(new Error('expected provider cancellation')) - } - await flushAttempt() - expect(onUnexpectedError).not.toHaveBeenCalled() - expect(scheduler.getSnapshot().locallyUnsettled).toBe(0) - }) - - it('bounds five failed cancellations to two replacements while old calls remain pending', async () => { - const { scheduler, attempts } = createHarness('cancel-failed') - const authority: DirectSshAuthority = { - targetId: 'target-a', - providerEpoch: epoch('epoch-target-a'), - connectionGeneration: 1 - } - const initial = Array.from({ length: 5 }, (_, index) => - scheduler.request(key('target-a', `old-${index}`)) - ) - scheduler.invalidateAuthority(authority) - await Promise.all(initial.map((lease) => lease.result)) - await flushAttempt() - expect(scheduler.getSnapshot()).toMatchObject({ - locallyUnsettled: 0, - queued: 0, - logicalTasks: 0 - }) - - const replacements = Array.from({ length: 3 }, (_, index) => - scheduler.request(key('target-a', `replacement-${index}`)) - ) - expect(attempts).toHaveLength(7) - expect(scheduler.getSnapshot().locallyUnsettled).toBe(2) - await expect(replacements[2].result).resolves.toMatchObject({ - status: 'cancel-budget-exhausted' - }) - - attempts[5].deferred.resolve(completeResult(attempts[5])) - attempts[6].deferred.resolve(completeResult(attempts[6])) - await expect( - Promise.all(replacements.slice(0, 2).map((lease) => lease.result)) - ).resolves.toEqual( - Array.from({ length: 2 }, () => expect.objectContaining({ status: 'complete' })) - ) - await flushAttempt() - expect(scheduler.getSnapshot().locallyUnsettled).toBe(0) - - scheduler.disposeProvider(authority) - for (const attempt of attempts.slice(0, 5)) { - attempt.deferred.reject(new Error('late canceled provider')) - } - await flushAttempt() - expect([...scheduler.getSnapshot().cancelDebtByAuthority.values()]).toEqual([]) - - const afterDisposal = scheduler.request(key('target-a', 'after-disposal')) - expect(attempts).toHaveLength(8) - attempts[7].deferred.resolve(completeResult(attempts[7])) - await expect(afterDisposal.result).resolves.toMatchObject({ status: 'complete' }) - }) - - it('settles one released waiter without canceling a joined provider', async () => { - const { scheduler, attempts } = createHarness() - const refreshKey = key('target-a', 'repo') - const released = scheduler.request(refreshKey) - const retained = scheduler.request(refreshKey) - - released.release('superseded') - await expect(released.result).resolves.toMatchObject({ status: 'canceled' }) - expect(attempts[0].cancel).not.toHaveBeenCalled() - expect(scheduler.getSnapshot().waiters).toBe(1) - - attempts[0].deferred.resolve(completeResult(attempts[0])) - await expect(retained.result).resolves.toMatchObject({ status: 'complete' }) - released.release('stopped') - await flushAttempt() - expect(scheduler.getSnapshot()).toMatchObject({ logicalTasks: 0, waiters: 0 }) - }) - - it('does not charge cancel debt when a shared provider invocation stays active', async () => { - const { scheduler, attempts } = createHarness() - const lease = scheduler.request(key('target-a', 'repo')) - attempts[0].cancel.mockReturnValue(false) - - lease.release('superseded') - - await expect(lease.result).resolves.toMatchObject({ status: 'canceled' }) - expect(attempts[0].cancel).toHaveBeenCalledOnce() - expect([...scheduler.getSnapshot().cancelDebtByAuthority.values()]).toEqual([]) - - attempts[0].deferred.resolve(completeResult(attempts[0])) - await flushAttempt() - }) - - it('stops current and queued waiters and rejects later requests without starting', async () => { - const { scheduler, attempts, startAttempt } = createHarness() - const current = scheduler.request(key('target-a', 'current')) - scheduler.stop() - - await expect(current.result).resolves.toMatchObject({ status: 'canceled' }) - expect(attempts[0].cancel).toHaveBeenCalledOnce() - - const afterStop = scheduler.request(key('target-a', 'after-stop')) - await expect(afterStop.result).resolves.toMatchObject({ status: 'canceled' }) - expect(startAttempt).toHaveBeenCalledOnce() - expect(scheduler.getSnapshot()).toMatchObject({ logicalTasks: 0, waiters: 0 }) - - attempts[0].deferred.reject(new Error('stopped')) - await flushAttempt() - }) - - it('surfaces non-authoritative and rejected terminal outcomes', async () => { - const { scheduler, attempts } = createHarness() - const metadata = scheduler.request(key('target-a', 'metadata')) - const rejected = scheduler.request(key('target-a', 'rejected')) - - attempts[0].deferred.resolve(completeResult(attempts[0], 'non-authoritative')) - attempts[1].deferred.resolve(terminalResult(attempts[1], 'rejected')) - - await expect(metadata.result).resolves.toMatchObject({ status: 'non-authoritative' }) - await expect(rejected.result).resolves.toMatchObject({ status: 'rejected' }) - }) - - it('reports queue wait, provider execution, retry, joins, and scoped peak metrics', async () => { - let now = 0 - const { scheduler, attempts } = createHarness(undefined, () => now) - const leases = Array.from({ length: 6 }, (_, index) => - scheduler.request(key('target-a', `repo-${index}`)) - ) - scheduler.request(key('target-a', 'repo-5')) - - now = 25 - attempts[0].deferred.resolve(completeResult(attempts[0])) - await leases[0].result - await flushAttempt() - expect(attempts).toHaveLength(6) - - now = 100 - attempts[5].deferred.resolve(terminalResult(attempts[5], 'timed-out')) - await flushAttempt() - now = 140 - attempts[6].deferred.resolve(completeResult(attempts[6])) - - await expect(leases[5].result).resolves.toMatchObject({ - status: 'complete', - metrics: { - queueWaitDurationsMs: [25, 0], - providerExecutionDurationsMs: [75, 40], - timeoutRetryCount: 1, - cancelDebtCount: 1, - overlappingJoinCount: 1, - peakLocallyUnsettled: 5 - } - }) - scheduler.stop() - }) -}) diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts deleted file mode 100644 index 2e95404f1b2..00000000000 --- a/src/renderer/src/hooks/direct-ssh-worktree-refresh-scheduler.ts +++ /dev/null @@ -1,316 +0,0 @@ -import type { - HostQualifiedDetectedWorktreeResult, - ProviderRequestId -} from '../../../shared/detected-worktree-provider-contract' -import type { DirectSshAuthority } from '../../../shared/ssh-types' -import type { - DirectSshWorktreeRefreshAttempt, - DirectSshWorktreeRefreshKey, - DirectSshWorktreeRefreshLease, - DirectSshWorktreeRefreshLogicalTask as LogicalTask, - DirectSshWorktreeRefreshOutcome, - DirectSshWorktreeRefreshReleaseReason, - DirectSshWorktreeRefreshScheduler, - DirectSshWorktreeRefreshSchedulerDeps, - WaiterLeaseId -} from './direct-ssh-worktree-refresh-scheduler-types' -import { cancelDirectSshWorktreeRefreshAttempt } from './direct-ssh-worktree-refresh-scheduler-cancellation' -import { - adjustDirectSshAuthorityUnsettled, - copyDirectSshWorktreeRefreshMetrics, - createDirectSshWorktreeRefreshMetrics, - directSshWorktreeRefreshSchedulerSnapshot, - recordDirectSshCancelDebt, - recordDirectSshProviderExecution, - recordDirectSshQueueAdmission -} from './direct-ssh-worktree-refresh-scheduler-metrics' -import { DirectSshWorktreeRefreshTargetQueue } from './direct-ssh-worktree-refresh-target-queue' -import { - directSshProviderAuthorityKey, - directSshWorktreeRefreshKey -} from './direct-ssh-worktree-refresh-scheduler-types' -export type * from './direct-ssh-worktree-refresh-scheduler-types' - -export const DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY = 5 -export const DIRECT_SSH_PROVIDER_START_BUDGET = 7 - -export function createDirectSshWorktreeRefreshScheduler( - deps: DirectSshWorktreeRefreshSchedulerDeps -): DirectSshWorktreeRefreshScheduler { - const now = deps.now ?? Date.now - const tasksByKey = new Map() - const targetQueue = new DirectSshWorktreeRefreshTargetQueue() - const unsettledByAuthority = new Map() - const cancelDebtByAuthority = new Map() - let locallyUnsettled = 0 - let leaseSequence = 0 - let stopped = false - - const createLeaseId = (): WaiterLeaseId => - deps.createWaiterLeaseId?.() ?? (`direct-ssh-waiter-${++leaseSequence}` as WaiterLeaseId) - - const metricsFor = ( - task: LogicalTask, - locallySettledWaiterCount = task.metrics.locallySettledWaiterCount - ): DirectSshWorktreeRefreshOutcome['metrics'] => { - const metrics = copyDirectSshWorktreeRefreshMetrics(task.metrics) - metrics.locallySettledWaiterCount = locallySettledWaiterCount - return metrics - } - - const finishTask = (task: LogicalTask, outcome: DirectSshWorktreeRefreshOutcome): void => { - if (task.state === 'terminal') { - return - } - task.state = 'terminal' - task.attempt = null - tasksByKey.delete(task.keyId) - task.metrics.locallySettledWaiterCount += task.waiters.size - const settledOutcome = { ...outcome, metrics: metricsFor(task) } - for (const waiter of task.waiters.values()) { - waiter.resolve(settledOutcome) - } - task.waiters.clear() - } - - const addCancelDebt = (task: LogicalTask): void => { - recordDirectSshCancelDebt( - task.metrics, - DIRECT_SSH_PROVIDER_START_BUDGET - DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY - ) - cancelDebtByAuthority.set( - task.authorityId, - (cancelDebtByAuthority.get(task.authorityId) ?? 0) + 1 - ) - } - - const cancelAttempt = ( - task: LogicalTask, - reason: DirectSshWorktreeRefreshReleaseReason - ): void => { - if (!task.attempt || task.attemptCanceled) { - return - } - task.attemptCanceled = true - if (cancelDirectSshWorktreeRefreshAttempt(task.attempt, reason)) { - addCancelDebt(task) - } - } - - const canStart = (task: LogicalTask): boolean => { - const unsettled = unsettledByAuthority.get(task.authorityId) ?? 0 - const debt = cancelDebtByAuthority.get(task.authorityId) ?? 0 - return unsettled + debt + 1 <= DIRECT_SSH_PROVIDER_START_BUDGET - } - - const handleAttemptResult = ( - task: LogicalTask, - requestId: ProviderRequestId, - result: HostQualifiedDetectedWorktreeResult - ): void => { - if (task.state === 'terminal' || task.attempt?.providerRequestId !== requestId) { - return - } - if (result.providerRequestId !== requestId) { - finishTask(task, { status: 'rejected', providerRequestId: requestId }) - return - } - if (result.status === 'timed-out') { - addCancelDebt(task) - if (task.attemptCount === 1) { - task.metrics.timeoutRetryCount++ - task.attempt = null - task.attemptCanceled = true - targetQueue.enqueue(task, true, now()) - return - } - } - const status = - result.status === 'authority-unknown' || result.status === 'ambiguous-owner' - ? 'non-authoritative' - : result.status - finishTask(task, { - status, - providerRequestId: result.providerRequestId, - providerResult: result - }) - } - - function drain(): void { - while (!stopped && locallyUnsettled < DIRECT_SSH_WORKTREE_SCAN_CONCURRENCY) { - const task = targetQueue.takeNext() - if (!task) { - return - } - if (!canStart(task)) { - task.metrics.replacementAdmissionDelayedCount++ - finishTask(task, { status: 'cancel-budget-exhausted' }) - continue - } - task.state = 'running' - task.attemptCount++ - locallyUnsettled++ - recordDirectSshQueueAdmission( - task.metrics, - Math.max(0, now() - task.queuedAt), - locallyUnsettled - ) - adjustDirectSshAuthorityUnsettled(unsettledByAuthority, task.authorityId, 1) - let attempt: DirectSshWorktreeRefreshAttempt - try { - attempt = deps.startAttempt(task.key) - task.attempt = attempt - task.attemptCanceled = false - task.attemptStartedAt = now() - } catch (error) { - locallyUnsettled-- - adjustDirectSshAuthorityUnsettled(unsettledByAuthority, task.authorityId, -1) - deps.onUnexpectedError?.(error) - finishTask(task, { status: 'rejected' }) - continue - } - void attempt.result - .then((result) => { - if (task.attemptStartedAt !== null) { - recordDirectSshProviderExecution( - task.metrics, - Math.max(0, now() - task.attemptStartedAt) - ) - task.attemptStartedAt = null - } - handleAttemptResult(task, attempt.providerRequestId, result) - }) - .catch((error) => { - if (task.attemptStartedAt !== null) { - recordDirectSshProviderExecution( - task.metrics, - Math.max(0, now() - task.attemptStartedAt) - ) - task.attemptStartedAt = null - } - if (task.state !== 'terminal' && !task.attemptCanceled) { - deps.onUnexpectedError?.(error) - finishTask(task, { - status: 'rejected', - providerRequestId: attempt.providerRequestId - }) - } - }) - .finally(() => { - locallyUnsettled-- - adjustDirectSshAuthorityUnsettled(unsettledByAuthority, task.authorityId, -1) - drain() - }) - } - } - - const releaseWaiter = ( - task: LogicalTask, - waiterLeaseId: WaiterLeaseId, - reason: DirectSshWorktreeRefreshReleaseReason - ): void => { - const waiter = task.waiters.get(waiterLeaseId) - if (!waiter) { - return - } - task.waiters.delete(waiterLeaseId) - task.metrics.locallySettledWaiterCount++ - waiter.resolve({ status: 'canceled', metrics: metricsFor(task) }) - if (task.waiters.size > 0) { - return - } - cancelAttempt(task, reason) - finishTask(task, { status: 'canceled' }) - } - - const request = (key: DirectSshWorktreeRefreshKey): DirectSshWorktreeRefreshLease => { - const keyId = directSshWorktreeRefreshKey(key) - let task = tasksByKey.get(keyId) - if (!task) { - const requestedAt = now() - task = { - key, - keyId, - authorityId: directSshProviderAuthorityKey(key), - state: 'queued', - attemptCount: 0, - attempt: null, - attemptCanceled: false, - attemptStartedAt: null, - queuedAt: requestedAt, - metrics: createDirectSshWorktreeRefreshMetrics(), - waiters: new Map() - } - tasksByKey.set(keyId, task) - targetQueue.enqueue(task, false, now()) - } else { - task.metrics.overlappingJoinCount++ - } - const waiterLeaseId = createLeaseId() - let resolve!: (outcome: DirectSshWorktreeRefreshOutcome) => void - const result = new Promise((settle) => { - resolve = settle - }) - task.waiters.set(waiterLeaseId, { resolve }) - if (stopped) { - releaseWaiter(task, waiterLeaseId, 'stopped') - targetQueue.clear() - } else { - drain() - } - return { - waiterLeaseId, - result, - release: (reason) => releaseWaiter(task!, waiterLeaseId, reason) - } - } - - const invalidateMatching = (predicate: (task: LogicalTask) => boolean): void => { - for (const task of tasksByKey.values()) { - if (!predicate(task)) { - continue - } - cancelAttempt(task, 'invalidated') - finishTask(task, { status: 'stale', providerRequestId: task.attempt?.providerRequestId }) - } - drain() - } - - const invalidateAuthority = (authority: DirectSshAuthority): void => { - const id = directSshProviderAuthorityKey(authority) - invalidateMatching((task) => task.authorityId === id) - } - - const invalidateTarget = (targetId: string): void => { - invalidateMatching((task) => task.key.targetId === targetId) - } - - const disposeProvider = (authority: DirectSshAuthority): void => { - invalidateAuthority(authority) - cancelDebtByAuthority.delete(directSshProviderAuthorityKey(authority)) - } - - const getSnapshot = () => - directSshWorktreeRefreshSchedulerSnapshot(tasksByKey, locallyUnsettled, cancelDebtByAuthority) - - const stop = (): void => { - if (stopped) { - return - } - stopped = true - for (const task of tasksByKey.values()) { - cancelAttempt(task, 'stopped') - finishTask(task, { status: 'canceled', providerRequestId: task.attempt?.providerRequestId }) - } - targetQueue.clear() - } - - return { - request, - invalidateAuthority, - invalidateTarget, - disposeProvider, - getSnapshot, - stop - } -} diff --git a/src/renderer/src/hooks/direct-ssh-worktree-refresh-target-queue.ts b/src/renderer/src/hooks/direct-ssh-worktree-refresh-target-queue.ts deleted file mode 100644 index 4d549b4b437..00000000000 --- a/src/renderer/src/hooks/direct-ssh-worktree-refresh-target-queue.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { DirectSshWorktreeRefreshLogicalTask } from './direct-ssh-worktree-refresh-scheduler-types' - -export class DirectSshWorktreeRefreshTargetQueue { - private readonly queuedByTarget = new Map() - private readonly targetOrder: string[] = [] - - enqueue(task: DirectSshWorktreeRefreshLogicalTask, retrying: boolean, now: number): void { - task.state = retrying ? 'retrying' : 'queued' - task.queuedAt = now - const lane = this.queuedByTarget.get(task.key.targetId) - if (lane) { - lane.push(task) - return - } - this.queuedByTarget.set(task.key.targetId, [task]) - this.targetOrder.push(task.key.targetId) - } - - takeNext(): DirectSshWorktreeRefreshLogicalTask | null { - while (this.targetOrder.length > 0) { - const targetId = this.targetOrder.shift()! - const lane = this.queuedByTarget.get(targetId) - const task = lane?.shift() - if (!lane || !task) { - this.queuedByTarget.delete(targetId) - continue - } - if (lane.length > 0) { - this.targetOrder.push(targetId) - } else { - this.queuedByTarget.delete(targetId) - } - if (task.state !== 'terminal' && task.waiters.size > 0) { - return task - } - } - return null - } - - clear(): void { - this.queuedByTarget.clear() - this.targetOrder.length = 0 - } -} diff --git a/src/renderer/src/hooks/ipc-events-ssh-authority-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-ssh-authority-test-fixtures.ts index bdbddb8ee0e..bb78b94aff2 100644 --- a/src/renderer/src/hooks/ipc-events-ssh-authority-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-ssh-authority-test-fixtures.ts @@ -3,24 +3,14 @@ import { buildStoreState } from './ipc-events-agent-status-store-test-fixtures' import type { StoreLike } from './ipc-events-agent-status-store-test-fixtures' import { buildWindowApi } from './ipc-events-agent-status-window-test-fixtures' -export type DirectSshReconnectCoordinatorDouble = { - requestReconnect: ReturnType - replaceAuthority: ReturnType - prepareOnly: ReturnType - correctUnboundTerminals: ReturnType - invalidate: ReturnType - stop: ReturnType -} - -/** Store/coordinator doubles for the partial-authority reconciliation path; the spec wires them. */ +/** Store doubles for the partial-authority reconciliation path; the spec wires them. */ export function buildSshAuthorityReconciliationHarness(args: { partialAuthority: { providerEpoch?: string; connectionGeneration?: number } latestAuthority: { providerEpoch: string; connectionGeneration: number } }): { - coordinator: DirectSshReconnectCoordinatorDouble + clearBindings: ReturnType emitPartialState: () => void getState: ReturnType - requestReconnect: ReturnType setSshConnectionState: ReturnType storeState: StoreLike storedState: () => Record | undefined @@ -37,7 +27,7 @@ export function buildSshAuthorityReconciliationHarness(args: { const sshConnectionStates = new Map>() let sshStateListener: ((data: { targetId: string; state: unknown }) => void) | undefined const getState = vi.fn(() => Promise.resolve(latestState)) - const requestReconnect = vi.fn(async () => ({ status: 'complete' })) + const clearBindings = vi.fn() const setSshConnectionState = vi.fn((nextTargetId: string, state: Record) => { sshConnectionStates.set(nextTargetId, state) }) @@ -45,22 +35,12 @@ export function buildSshAuthorityReconciliationHarness(args: { sshTargetLabels: new Map([[targetId, 'Reconciliation Target']]), sshConnectionStates, setSshConnectionState, - invalidateStaleDirectSshTargetPtyBindings: vi.fn(() => 0), setSshTargetsMetadata: vi.fn(), setRemovedSshTargetLabels: vi.fn(), clearRemoteDetectedAgents: vi.fn(), - clearDirectSshTargetPtyBindings: vi.fn(), + clearDirectSshTargetPtyBindings: clearBindings, clearRemovedSshTargetState: vi.fn() }) - const coordinator = { - requestReconnect, - replaceAuthority: vi.fn(), - prepareOnly: vi.fn(), - correctUnboundTerminals: vi.fn(() => 0), - invalidate: vi.fn(), - stop: vi.fn() - } - vi.stubGlobal( 'window', buildWindowApi({ @@ -76,7 +56,7 @@ export function buildSshAuthorityReconciliationHarness(args: { ) return { - coordinator, + clearBindings, emitPartialState: () => { if (!sshStateListener) { throw new Error('Expected SSH state listener') @@ -84,7 +64,6 @@ export function buildSshAuthorityReconciliationHarness(args: { sshStateListener({ targetId, state: partialState }) }, getState, - requestReconnect, setSshConnectionState, storeState, storedState: () => sshConnectionStates.get(targetId) diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index 54f88778d43..747dcf8a653 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -11,7 +11,6 @@ import { registerBackgroundWorktreeRemovalBridge } from './background-worktree-r import { registerBrowserRequestIpcBridge } from './browser-request-ipc-bridge' import { registerBrowserStateIpcBridge } from './browser-state-ipc-bridge' import { registerContentCreationIpcBridge } from './content-creation-ipc-bridge' -import { createDirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' import { registerMobileAndTerminalCloseIpcBridge } from './mobile-terminal-close-ipc-bridge' import { registerMobileDriverIpcBridge } from './mobile-driver-ipc-bridge' @@ -57,7 +56,6 @@ export function installAppLifetimeIpcEvents( onCleanupPhase?: (phase: IpcEventsCleanupPhase) => void ): () => void { const unsubs: (() => void)[] = [] - const directSshRuntime = createDirectSshBridgeRuntime() const backgroundWakeDispatcher = createBackgroundSleepingAgentWakeDispatcher() unsubs.push(backgroundWakeDispatcher.dispose) unsubs.push(attachMobileMarkdownBridge()) @@ -128,7 +126,7 @@ export function installAppLifetimeIpcEvents( registerBrowserRequestIpcBridge(unsubs) registerTabLifecycleIpcBridge(unsubs) registerRateLimitIpcBridge(unsubs) - registerDirectSshStateIpcBridge(unsubs, directSshRuntime) + const stopDirectSshStateBridge = registerDirectSshStateIpcBridge(unsubs) registerZoomIpcBridge(unsubs) const agentStatusBridge = registerAgentStatusIpcBridge(unsubs) const disposeMobileDriverHydration = registerMobileDriverIpcBridge( @@ -147,7 +145,7 @@ export function installAppLifetimeIpcEvents( onCleanupPhase?.('agentStore.unsubscribe') unsubs.forEach((unsubscribe) => unsubscribe()) onCleanupPhase?.('ipc.dispose') - directSshRuntime.stop() + stopDirectSshStateBridge() onCleanupPhase?.('directSsh.stop') resetAgentHookCompletionNotificationCoordinators() onCleanupPhase?.('notifications.reset') diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts deleted file mode 100644 index 5d44880146e..00000000000 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-bridge-runtime.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { createDirectSshReconnectProductTelemetryAdapter } from '@/lib/direct-ssh-reconnect-product-telemetry' -import { acquireDirectSshDetectedWorktreeRefresh } from '@/store/slices/worktrees' -import { toSshExecutionHostId } from '../../../../shared/execution-host' -import type { DirectSshAuthority } from '../../../../shared/ssh-types' -import { useAppStore } from '../../store' -import { - createDirectSshHostHydration, - type DirectSshHostHydration -} from '../direct-ssh-host-hydration' -import { - createDirectSshReconnectCoordinator, - type DirectSshPreparationInput, - type DirectSshPreparationReason, - type DirectSshReconnectCoordinator -} from '../direct-ssh-reconnect-coordinator' -import { directSshAuthoritiesEqual } from '../direct-ssh-reconnect-tokens' -import { createDirectSshWorktreeRefreshScheduler } from '../direct-ssh-worktree-refresh-scheduler' -import type { AppState } from '../../store/types' - -type DirectSshTerminalActions = Partial> -type AuthorityDeadline = { timer: ReturnType; settle: () => void } - -export type DirectSshBridgeRuntime = { - reconnectAuthorityByTarget: Map - reconnectCoordinator: DirectSshReconnectCoordinator - hostHydration: DirectSshHostHydration - currentAuthority: (targetId: string) => DirectSshAuthority | null - terminalActions: () => DirectSshTerminalActions - prepareHost: ( - authority: DirectSshAuthority, - reason: DirectSshPreparationReason, - options?: { authorityAlreadyReplaced?: boolean } - ) => Promise - isStopped: () => boolean - addDeadline: (deadline: AuthorityDeadline) => void - removeDeadline: (deadline: AuthorityDeadline) => void - stop: () => void -} - -export function createDirectSshBridgeRuntime(): DirectSshBridgeRuntime { - const reconnectAuthorityByTarget = new Map() - const deadlines = new Set() - let stopped = false - const currentAuthority = (targetId: string): DirectSshAuthority | null => { - const state = useAppStore.getState().sshConnectionStates?.get(targetId) - if ( - state?.status !== 'connected' || - state.targetId !== targetId || - !state.providerEpoch || - state.connectionGeneration === undefined - ) { - return null - } - return { - targetId, - providerEpoch: state.providerEpoch, - connectionGeneration: state.connectionGeneration - } - } - const scheduler = createDirectSshWorktreeRefreshScheduler({ - startAttempt: (key) => { - const acquired = acquireDirectSshDetectedWorktreeRefresh(useAppStore, { - repoId: key.repoId, - executionHostId: key.executionHostId, - authority: { - targetId: key.targetId, - providerEpoch: key.providerEpoch, - connectionGeneration: key.connectionGeneration - }, - requireAuthoritative: key.authorityRequirement === 'required' - }) - return { - providerRequestId: acquired.providerRequestId, - result: acquired.result.then((result) => acquired.merge(result)), - cancel: acquired.release - } - } - }) - const hostHydration = createDirectSshHostHydration({ - store: useAppStore, - isCurrentAuthority: (authority) => - directSshAuthoritiesEqual(currentAuthority(authority.targetId), authority), - listRepos: (authority) => { - const executionHostId = toSshExecutionHostId(authority.targetId) - return ( - window.api.repos.listForExecutionHost?.({ - executionHostId, - expectedAuthority: authority - }) ?? - Promise.resolve({ authoritative: false, executionHostId, reason: 'unavailable' as const }) - ) - }, - listLineage: (authority) => { - const executionHostId = toSshExecutionHostId(authority.targetId) - return ( - window.api.worktrees.listLineageForHost?.({ - executionHostId, - expectedAuthority: authority - }) ?? - Promise.resolve({ authoritative: false, executionHostId, reason: 'unavailable' as const }) - ) - } - }) - const terminalActions = (): DirectSshTerminalActions => - useAppStore.getState() as DirectSshTerminalActions - const reconnectCoordinator = createDirectSshReconnectCoordinator({ - scheduler, - isCurrentConnectedAuthority: (authority) => - directSshAuthoritiesEqual(currentAuthority(authority.targetId), authority), - capturePreparationInput: hostHydration.capturePreparationInput, - readHostScopedLineage: hostHydration.readHostScopedLineage, - invalidateStaleTerminalBindings: (authority) => - terminalActions().invalidateStaleDirectSshTargetPtyBindings?.(authority) ?? 0, - // Why 0: a pane on an `ssh:` row never spawns, so there is no pane to retry. - retryTargetPanes: () => 0, - correctUnboundTerminalPanes: (authority) => { - terminalActions().invalidateStaleDirectSshTargetPtyBindings?.(authority) - return 0 - }, - onTelemetry: createDirectSshReconnectProductTelemetryAdapter() - }) - const prepareHost: DirectSshBridgeRuntime['prepareHost'] = async (authority, reason, options) => { - try { - if (!options?.authorityAlreadyReplaced) { - reconnectCoordinator.replaceAuthority(authority) - } - const input: DirectSshPreparationInput | null = await hostHydration.capturePreparationInput( - authority, - reason - ) - if (input) { - await reconnectCoordinator.prepareOnly(input) - } - } catch { - // A failed preparation leaves the previous host picture; the next connect or wake retries. - } - } - return { - reconnectAuthorityByTarget, - reconnectCoordinator, - hostHydration, - currentAuthority, - terminalActions, - prepareHost, - isStopped: () => stopped, - addDeadline: (deadline) => deadlines.add(deadline), - removeDeadline: (deadline) => deadlines.delete(deadline), - stop: () => { - stopped = true - for (const deadline of deadlines) { - clearTimeout(deadline.timer) - deadline.settle() - } - deadlines.clear() - hostHydration.stop() - reconnectCoordinator.stop() - reconnectAuthorityByTarget.clear() - } - } -} diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts index afcb4aa8248..6213422710d 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-connect-reply-routing.test.ts @@ -5,7 +5,6 @@ import type { SshConnectionState, SshProviderEpoch } from '../../../../shared/ssh-types' -import { createDirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' function connectedState( @@ -81,27 +80,19 @@ async function createHarness( } }) const store = useAppStore.getState() - const invalidate = vi.spyOn(store, 'invalidateStaleDirectSshTargetPtyBindings').mockReturnValue(1) const clearBindings = vi.spyOn(store, 'clearDirectSshTargetPtyBindings').mockReturnValue(1) - const runtime = createDirectSshBridgeRuntime() - const requestReconnect = vi.spyOn(runtime.reconnectCoordinator, 'requestReconnect') - const prepareHost = vi.spyOn(runtime, 'prepareHost') const unsubs: (() => void)[] = [] + const stop = registerDirectSshStateIpcBridge(unsubs) cleanups.push(() => { for (const unsubscribe of unsubs) { unsubscribe() } - runtime.stop() + stop() }) - registerDirectSshStateIpcBridge(unsubs, runtime) await settle() return { - runtime, getState, - invalidate, clearBindings, - requestReconnect, - prepareHost, emit: (state: SshConnectionState) => { if (!stateListener) { throw new Error('SSH state listener was not registered') @@ -128,9 +119,9 @@ afterEach(async () => { vi.unstubAllGlobals() }) -describe('direct SSH connect reply routing', () => { +describe('direct SSH connected-state routing', () => { it.each(['reply-first', 'push-first'] as const)( - 'clears stale bindings through the real coordinator once when connection ordering is %s', + 'clears the target bindings once per connected push when ordering is %s', async (ordering) => { const harness = await createHarness() const state = connectedState() @@ -142,50 +133,33 @@ describe('direct SSH connect reply routing', () => { harness.applyConnectReply(state) } - expect(harness.requestReconnect).toHaveBeenCalledExactlyOnceWith(authority(state)) - expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(state)) - expect(harness.runtime.reconnectAuthorityByTarget.get(state.targetId)).toEqual( - authority(state) - ) + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) await settle() - harness.invalidate.mockClear() + harness.clearBindings.mockClear() harness.emit({ ...state }) - - expect(harness.requestReconnect).toHaveBeenCalledOnce() - expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(state)) - expect(harness.prepareHost).toHaveBeenLastCalledWith(authority(state), 'wake-refresh') + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) } ) - it('keeps hydration preparation-only and corrects its duplicate without requesting reconnect', async () => { + it('leaves bindings alone on initial hydration and clears on the next push', async () => { const state = connectedState() const harness = await createHarness([state]) - expect(harness.prepareHost).toHaveBeenCalledExactlyOnceWith( - authority(state), - 'initial-hydration' - ) - expect(harness.requestReconnect).not.toHaveBeenCalled() - expect(harness.invalidate).not.toHaveBeenCalled() + expect(harness.clearBindings).not.toHaveBeenCalled() harness.emit({ ...state }) - - expect(harness.requestReconnect).not.toHaveBeenCalled() - expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(state)) - expect(harness.runtime.reconnectAuthorityByTarget.size).toBe(0) + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) const next = connectedState(state.targetId, 2) harness.applyConnectReply(next) - harness.invalidate.mockClear() + harness.clearBindings.mockClear() harness.emit(next) - - expect(harness.requestReconnect).toHaveBeenCalledExactlyOnceWith(authority(next)) - expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(next)) + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(next.targetId) }) it.each(['disconnected', 'error'] as const)( - 'forgets a routed authority after %s even when the next connect reply reaches the store first', + 'clears bindings on %s and again on the next connected push', async (status) => { const state = connectedState() const harness = await createHarness([state]) @@ -194,17 +168,12 @@ describe('direct SSH connect reply routing', () => { expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) harness.applyConnectReply(state) harness.emit(state) - - expect(harness.requestReconnect).toHaveBeenCalledExactlyOnceWith(authority(state)) - expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(state)) - expect(harness.runtime.reconnectAuthorityByTarget.get(state.targetId)).toEqual( - authority(state) - ) + expect(harness.clearBindings).toHaveBeenCalledTimes(2) } ) it.each(['providerEpoch', 'connectionGeneration'] as const)( - 'preserves initial hydration routing when %s must be reconciled', + 'reconciles a partial %s on initial hydration without clearing', async (missingField) => { const state = connectedState() const partial: SshConnectionState = { ...state } @@ -212,18 +181,10 @@ describe('direct SSH connect reply routing', () => { const harness = await createHarness([partial], [state]) expect(useAppStore.getState().sshConnectionStates.get(state.targetId)).toEqual(state) - expect(harness.prepareHost).toHaveBeenCalledExactlyOnceWith( - authority(state), - 'initial-hydration' - ) - expect(harness.requestReconnect).not.toHaveBeenCalled() - expect(harness.invalidate).not.toHaveBeenCalled() + expect(harness.clearBindings).not.toHaveBeenCalled() harness.emit(state) - - expect(harness.requestReconnect).not.toHaveBeenCalled() - expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(state)) - expect(harness.runtime.reconnectAuthorityByTarget.size).toBe(0) + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) } ) @@ -244,19 +205,18 @@ describe('direct SSH connect reply routing', () => { delete partial[missingField] harness.emit(partial) - expect(harness.requestReconnect).not.toHaveBeenCalled() - expect(harness.invalidate).not.toHaveBeenCalled() + expect(harness.clearBindings).not.toHaveBeenCalled() resolveState(state) await settle() - expect(harness.requestReconnect).toHaveBeenCalledExactlyOnceWith(authority(state)) - expect(harness.invalidate).toHaveBeenCalledWith(authority(state)) - harness.emit(state) - expect(harness.requestReconnect).toHaveBeenCalledOnce() + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(state.targetId) + expect(useAppStore.getState().sshConnectionStates.get(state.targetId)).toMatchObject( + authority(state) + ) } ) - it('keeps routed authorities and disconnect cleanup separate for each target', async () => { + it('keeps disconnect cleanup separate for each target', async () => { const harness = await createHarness() const first = connectedState('target-a') const second = connectedState('target-b') @@ -265,21 +225,10 @@ describe('direct SSH connect reply routing', () => { harness.emit(state) } await settle() - expect(harness.requestReconnect.mock.calls).toEqual([[authority(first)], [authority(second)]]) + expect(harness.clearBindings.mock.calls).toEqual([[first.targetId], [second.targetId]]) + harness.clearBindings.mockClear() harness.emit({ ...first, status: 'disconnected' }) - harness.invalidate.mockClear() - harness.emit(second) - - expect(harness.requestReconnect).toHaveBeenCalledTimes(2) - expect(harness.invalidate).toHaveBeenCalledExactlyOnceWith(authority(second)) - expect(harness.runtime.reconnectAuthorityByTarget.get(second.targetId)).toEqual( - authority(second) - ) - expect(harness.runtime.reconnectAuthorityByTarget.has(first.targetId)).toBe(false) - - harness.applyConnectReply(first) - harness.emit(first) - expect(harness.requestReconnect).toHaveBeenNthCalledWith(3, authority(first)) + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith(first.targetId) }) }) diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts index 41559141daf..73030d0fd97 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-fanout.test.ts @@ -1,33 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import type { SshConnectionState } from '../../../../shared/ssh-types' -import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' function connectingState(targetId: string): SshConnectionState { return { targetId, status: 'connecting', error: null, reconnectAttempt: 0 } } -function bridgeRuntime(): DirectSshBridgeRuntime { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the routing under test reads only these members; the rest of the runtime is never touched. - return { - reconnectAuthorityByTarget: new Map(), - reconnectCoordinator: { - requestReconnect: vi.fn(async () => ({ status: 'complete' })), - replaceAuthority: vi.fn(), - correctUnboundTerminals: vi.fn(() => 0), - invalidate: vi.fn() - }, - currentAuthority: () => null, - terminalActions: () => ({}), - prepareHost: vi.fn(async () => {}), - isStopped: () => false, - addDeadline: vi.fn(), - removeDeadline: vi.fn(), - stop: vi.fn() - } as unknown as DirectSshBridgeRuntime -} - async function settle(): Promise { for (let index = 0; index < 40; index += 1) { await Promise.resolve() @@ -85,7 +64,7 @@ describe('direct SSH hydration fanout', () => { } }) - registerDirectSshStateIpcBridge(unsubs, bridgeRuntime()) + unsubs.push(registerDirectSshStateIpcBridge(unsubs)) await settle() const states = useAppStore.getState().sshConnectionStates diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts index d2031965bbf..4873d5481bc 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts @@ -6,7 +6,6 @@ import { } from '@/store/slices/runtime-environment-ssh' import type { SshConnectionState } from '../../../../shared/ssh-types' import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' -import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' type SshApiStub = { listTargets: () => Promise<{ id: string; label: string }[]> @@ -43,26 +42,6 @@ function stubWindow(ssh: SshApiStub): void { }) } -function bridgeRuntime(): DirectSshBridgeRuntime { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the routing under test reads only these members; the rest of the runtime is never touched. - return { - reconnectAuthorityByTarget: new Map(), - reconnectCoordinator: { - requestReconnect: vi.fn(async () => ({ status: 'complete' })), - replaceAuthority: vi.fn(), - correctUnboundTerminals: vi.fn(() => 0), - invalidate: vi.fn() - }, - currentAuthority: () => null, - terminalActions: () => ({}), - prepareHost: vi.fn(async () => {}), - isStopped: () => false, - addDeadline: vi.fn(), - removeDeadline: vi.fn(), - stop: vi.fn() - } as unknown as DirectSshBridgeRuntime -} - /** Lets pending IPC promises settle without leaning on a wall-clock delay. */ async function settle(): Promise { for (let index = 0; index < 12; index += 1) { @@ -102,7 +81,7 @@ describe('direct SSH hydration target metadata', () => { getState: () => Promise.resolve(null) }) - registerDirectSshStateIpcBridge(unsubs, bridgeRuntime()) + unsubs.push(registerDirectSshStateIpcBridge(unsubs)) await settle() resolveRemovedLabels({ 'ssh-ghost': 'old devbox' }) @@ -126,7 +105,7 @@ describe('direct SSH hydration target metadata', () => { getState: () => Promise.resolve(connectingState('ssh-live')) }) - registerDirectSshStateIpcBridge(unsubs, bridgeRuntime()) + unsubs.push(registerDirectSshStateIpcBridge(unsubs)) await settle() expect({ @@ -153,7 +132,7 @@ describe('direct SSH hydration target metadata', () => { getState: () => Promise.resolve(null) }) - registerDirectSshStateIpcBridge(unsubs, bridgeRuntime()) + unsubs.push(registerDirectSshStateIpcBridge(unsubs)) await settle() // Older paired hosts lack the RPC; the target list is the evidence that matters and must survive. @@ -177,7 +156,7 @@ describe('direct SSH hydration target metadata', () => { : Promise.resolve(connectingState(targetId)) }) - registerDirectSshStateIpcBridge(unsubs, bridgeRuntime()) + unsubs.push(registerDirectSshStateIpcBridge(unsubs)) await settle() const state = useAppStore.getState() @@ -195,7 +174,7 @@ describe('direct SSH hydration target metadata', () => { getState: () => Promise.resolve(null) }) - registerDirectSshStateIpcBridge(unsubs, bridgeRuntime()) + unsubs.push(registerDirectSshStateIpcBridge(unsubs)) await settle() expect({ @@ -226,7 +205,7 @@ describe('direct SSH hydration target metadata', () => { getState: () => Promise.resolve(null) }) - registerDirectSshStateIpcBridge(unsubs, bridgeRuntime()) + unsubs.push(registerDirectSshStateIpcBridge(unsubs)) await settle() // A target added during the tombstone round trip pushes state and re-queries the list. diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-initial-state-hydration.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-initial-state-hydration.ts index 2967a74a0ce..f7336d2a55e 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-initial-state-hydration.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-initial-state-hydration.ts @@ -1,6 +1,5 @@ import type { SshConnectionState } from '../../../../shared/ssh-types' import { useAppStore } from '../../store' -import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' /** Why: an unanswered ssh.getState burns the full 30s runtime-RPC timeout, so a serial walk * makes the last target wait (N-1) timeouts; a small pool keeps a wedged target from blocking @@ -8,13 +7,13 @@ import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' const HYDRATION_FANOUT = 4 export async function hydrateDirectSshInitialState( - runtime: DirectSshBridgeRuntime, + isStopped: () => boolean, watermarkByTargetId: ReadonlyMap, applySshConnectionStateChange: (targetId: string, state: SshConnectionState) => void ): Promise { try { const targets = await window.api.ssh.listTargets() - if (runtime.isStopped()) { + if (isStopped()) { return } // Why: the loaded list is the hydration evidence (#9911) — never gate it behind the @@ -23,7 +22,7 @@ export async function hydrateDirectSshInitialState( const hydrateRemovedLabels = async (): Promise => { try { const removedLabels = await window.api.ssh.listRemovedTargetLabels() - if (!runtime.isStopped()) { + if (!isStopped()) { useAppStore.getState().setRemovedSshTargetLabels(removedLabels) } } catch (error) { @@ -34,14 +33,14 @@ export async function hydrateDirectSshInitialState( void hydrateRemovedLabels() let nextIndex = 0 const drainTargets = async (): Promise => { - while (nextIndex < targets.length && !runtime.isStopped()) { + while (nextIndex < targets.length && !isStopped()) { const target = targets[nextIndex] nextIndex += 1 const hydrationWatermark = watermarkByTargetId.get(target.id) ?? 0 try { const state = await window.api.ssh.getState({ targetId: target.id }) if ( - !runtime.isStopped() && + !isStopped() && state && (watermarkByTargetId.get(target.id) ?? 0) === hydrationWatermark ) { diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts index 81f2ff21a42..88908fbf106 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts @@ -1,30 +1,27 @@ import { canConnectSshStatus } from '@/ssh/ssh-connection-recoverability' import type { DirectSshAuthority, SshConnectionState } from '../../../../shared/ssh-types' import { useAppStore } from '../../store' -import { isDirectSshReconnectCoordinatorRoutingEnabled } from '../direct-ssh-reconnect-rollout' -import { directSshAuthoritiesEqual } from '../direct-ssh-reconnect-tokens' +import { directSshAuthoritiesEqual } from '../../store/slices/worktrees/listing/direct-ssh-authority' import { + directSshAuthorityFromConnectionState, registerDirectSshWakeRouting, - routeDirectSshConnectedState, type DirectSshConnectedStateOrigin } from '../direct-ssh-state-routing' -import type { DirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' import { hydrateDirectSshInitialState } from './direct-ssh-initial-state-hydration' import { applySshManagedServerTransition } from './ssh-managed-server-state-effects' -export function registerDirectSshStateIpcBridge( - unsubs: (() => void)[], - runtime: DirectSshBridgeRuntime -): void { - const { - reconnectAuthorityByTarget, - reconnectCoordinator, - currentAuthority, - terminalActions, - prepareHost - } = runtime + +type AuthorityDeadline = { timer: ReturnType; settle: () => void } + +/** Returns the stop that settles pending authority reads; listeners are released through unsubs. */ +export function registerDirectSshStateIpcBridge(unsubs: (() => void)[]): () => void { + let stopped = false + const isStopped = (): boolean => stopped + const deadlines = new Set() + const currentAuthority = (targetId: string): DirectSshAuthority | null => { + const state = useAppStore.getState().sshConnectionStates?.get(targetId) + return state ? directSshAuthorityFromConnectionState(targetId, state) : null + } const sshStateWatermarkByTargetId = new Map() - // Connect replies can update the store before this bridge routes the matching push. - const routedAuthorityByTarget = new Map() const pendingPortHydrationByTargetId = new Map< string, { receivedForwardPush: boolean; receivedDetectedPush: boolean } @@ -36,7 +33,7 @@ export function registerDirectSshStateIpcBridge( } pendingPortHydrationByTargetId.set(targetId, pendingPortHydration) const isHydrationAuthorityCurrent = (): boolean => - !runtime.isStopped() && directSshAuthoritiesEqual(currentAuthority(targetId), authority) + !isStopped() && directSshAuthoritiesEqual(currentAuthority(targetId), authority) const forwardHydration = window.api.ssh.listPortForwards({ targetId }).then((forwards) => { if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedForwardPush) { useAppStore.getState().setPortForwards(targetId, forwards) @@ -58,7 +55,7 @@ export function registerDirectSshStateIpcBridge( state: SshConnectionState, origin: DirectSshConnectedStateOrigin ) => void - void hydrateDirectSshInitialState(runtime, sshStateWatermarkByTargetId, (targetId, state) => + void hydrateDirectSshInitialState(isStopped, sshStateWatermarkByTargetId, (targetId, state) => applySshConnectionStateChange(targetId, state, 'initial-hydration') ) unsubs.push( @@ -98,17 +95,17 @@ export function registerDirectSshStateIpcBridge( origin: DirectSshConnectedStateOrigin, watermark: number ): void => { - let pendingDeadline: { timer: ReturnType; settle: () => void } | undefined + let pendingDeadline: AuthorityDeadline | undefined const deadline = new Promise((resolve) => { const settle = (): void => resolve(null) const timer = setTimeout(settle, 5_000) pendingDeadline = { timer, settle } - runtime.addDeadline(pendingDeadline) + deadlines.add(pendingDeadline) }) void Promise.race([window.api.ssh.getState({ targetId }).catch(() => null), deadline]) .then((latest) => { if ( - runtime.isStopped() || + isStopped() || latest?.targetId !== targetId || !latest?.providerEpoch || latest.connectionGeneration === undefined || @@ -144,7 +141,7 @@ export function registerDirectSshStateIpcBridge( .finally(() => { if (pendingDeadline) { clearTimeout(pendingDeadline.timer) - runtime.removeDeadline(pendingDeadline) + deadlines.delete(pendingDeadline) } }) } @@ -160,9 +157,6 @@ export function registerDirectSshStateIpcBridge( applySshManagedServerTransition(targetId, previousManagedServer, state.managedServer) if (canConnectSshStatus(state.status)) { - routedAuthorityByTarget.delete(targetId) - reconnectAuthorityByTarget.delete(targetId) - reconnectCoordinator.invalidate(targetId) store.clearRemoteDetectedAgents(targetId) store.clearPortForwards(targetId) @@ -180,26 +174,10 @@ export function registerDirectSshStateIpcBridge( reconcileSshAuthority(targetId, state, origin, sshStateWatermarkByTargetId.get(targetId) ?? 0) return } - const previousAuthority = routedAuthorityByTarget.get(targetId) ?? null - routedAuthorityByTarget.set(targetId, authority) - routeDirectSshConnectedState( - { - coordinator: reconnectCoordinator, - coordinatorRoutingEnabled: isDirectSshReconnectCoordinatorRoutingEnabled(), - invalidateStaleTerminalBindings: (nextAuthority) => - terminalActions().invalidateStaleDirectSshTargetPtyBindings?.(nextAuthority) ?? 0, - retryTargetPanes: () => 0, - prepareHost, - rememberReconnectAuthority: (nextAuthority) => { - if (nextAuthority) { - reconnectAuthorityByTarget.set(targetId, nextAuthority) - } else { - reconnectAuthorityByTarget.delete(targetId) - } - } - }, - { authority, previousAuthority, origin } - ) + // Why: no pane binds an `ssh:` PTY, so every binding a newly pushed connection finds is stale. + if (origin === 'push') { + store.clearDirectSshTargetPtyBindings(targetId) + } if (origin === 'initial-hydration') { hydrateSshPorts(targetId, authority) } @@ -226,12 +204,11 @@ export function registerDirectSshStateIpcBridge( return } latestSshTargetStateEventByTargetId.delete(data.targetId) - if (runtime.isStopped()) { + if (isStopped()) { return } const latestStore = useAppStore.getState() if (!targets.some((target) => target.id === data.targetId)) { - routedAuthorityByTarget.delete(data.targetId) latestStore.clearRemovedSshTargetState(data.targetId) return } @@ -240,7 +217,7 @@ export function registerDirectSshStateIpcBridge( }) .catch(() => { if ( - !runtime.isStopped() && + !isStopped() && latestSshTargetStateEventByTargetId.get(data.targetId) === stateEventId ) { latestSshTargetStateEventByTargetId.delete(data.targetId) @@ -259,12 +236,19 @@ export function registerDirectSshStateIpcBridge( registerDirectSshWakeRouting({ getConnectionStates: () => useAppStore.getState().sshConnectionStates ?? [], wakeAuthority: (authority) => { - reconnectCoordinator.correctUnboundTerminals(authority, 'wake-refresh') - void prepareHost(authority, 'wake-refresh') + useAppStore.getState().clearDirectSshTargetPtyBindings(authority.targetId) }, ...(typeof window.api.ui.onSystemResumed === 'function' ? { onSystemResumed: (callback: () => void) => window.api.ui.onSystemResumed(callback) } : {}) }) ) + return () => { + stopped = true + for (const deadline of deadlines) { + clearTimeout(deadline.timer) + deadline.settle() + } + deadlines.clear() + } } diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts index 83e6d3781e0..e8a8d7c3054 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts @@ -7,14 +7,8 @@ import { stubAuxiliaryModules } from './ipc-events-agent-status-window-test-fixtures' import { buildSshAuthorityReconciliationHarness } from './ipc-events-ssh-authority-test-fixtures' -import type { DirectSshReconnectCoordinatorDouble } from './ipc-events-ssh-authority-test-fixtures' -function stubDirectSshModules(args: { - storeState: StoreLike - coordinator: DirectSshReconnectCoordinatorDouble - coordinatorRoutingEnabled?: boolean - capturePreparationInput?: ReturnType -}): void { +function stubDirectSshModules(args: { storeState: StoreLike }): void { stubReactSyncEffect() stubAuxiliaryModules() vi.doMock('../store', () => ({ @@ -23,29 +17,6 @@ function stubDirectSshModules(args: { getState: () => args.storeState } })) - vi.doMock('./direct-ssh-reconnect-rollout', () => ({ - isDirectSshReconnectCoordinatorRoutingEnabled: () => args.coordinatorRoutingEnabled ?? true - })) - vi.doMock('./direct-ssh-worktree-refresh-scheduler', () => ({ - createDirectSshWorktreeRefreshScheduler: () => ({ - stop: vi.fn(), - disposeProvider: vi.fn() - }) - })) - vi.doMock('./direct-ssh-host-hydration', () => ({ - createDirectSshHostHydration: () => ({ - capturePreparationInput: args.capturePreparationInput ?? vi.fn(), - readHostScopedLineage: vi.fn(), - isPreparationTokenCurrent: vi.fn(() => true), - stop: vi.fn() - }) - })) - vi.doMock('./direct-ssh-reconnect-coordinator', () => ({ - createDirectSshReconnectCoordinator: () => args.coordinator - })) - vi.doMock('@/lib/direct-ssh-reconnect-product-telemetry', () => ({ - createDirectSshReconnectProductTelemetryAdapter: vi.fn() - })) } // Why: end-to-end exercise of startup agent-status restoration through @@ -103,7 +74,7 @@ describe('useIpcEvents agent status snapshot integration', () => { { partialAuthority: { providerEpoch: 'epoch-current' } }, { partialAuthority: { connectionGeneration: 7 } } ])( - 'fills a same-watermark partial SSH authority and routes it once', + 'fills a same-watermark partial SSH authority and clears its bindings once', async ({ partialAuthority }) => { const harness = buildSshAuthorityReconciliationHarness({ partialAuthority, @@ -112,14 +83,14 @@ describe('useIpcEvents agent status snapshot integration', () => { connectionGeneration: 7 } }) - stubDirectSshModules({ storeState: harness.storeState, coordinator: harness.coordinator }) + stubDirectSshModules({ storeState: harness.storeState }) const { useIpcEvents } = await import('./useIpcEvents') useIpcEvents() harness.emitPartialState() await vi.waitFor(() => { - expect(harness.requestReconnect).toHaveBeenCalledOnce() + expect(harness.clearBindings).toHaveBeenCalledExactlyOnceWith('target-reconciliation') }) expect(harness.getState).toHaveBeenCalledOnce() expect(harness.setSshConnectionState).toHaveBeenCalledTimes(2) @@ -147,7 +118,7 @@ describe('useIpcEvents agent status snapshot integration', () => { connectionGeneration: 7 } }) - stubDirectSshModules({ storeState: harness.storeState, coordinator: harness.coordinator }) + stubDirectSshModules({ storeState: harness.storeState }) const { useIpcEvents } = await import('./useIpcEvents') useIpcEvents() @@ -157,7 +128,7 @@ describe('useIpcEvents agent status snapshot integration', () => { expect(harness.getState).toHaveBeenCalledOnce() }) await Promise.resolve() - expect(harness.requestReconnect).not.toHaveBeenCalled() + expect(harness.clearBindings).not.toHaveBeenCalled() expect(harness.setSshConnectionState).toHaveBeenCalledOnce() expect(harness.storedState()).toEqual( expect.objectContaining({ @@ -168,110 +139,6 @@ describe('useIpcEvents agent status snapshot integration', () => { } ) - it.each([ - { enabled: true, expectedRoute: ['request'] }, - { - enabled: false, - expectedRoute: ['replace', 'invalidate', 'capture', 'prepare'] - } - ])( - 'routes a changed direct SSH authority through the enabled=$enabled path', - async ({ enabled, expectedRoute }) => { - const order: string[] = [] - let sshStateListener: ((data: { targetId: string; state: unknown }) => void) | undefined - const oldState = { - targetId: 'target-a', - status: 'connected' as const, - error: null, - reconnectAttempt: 0, - providerEpoch: 'epoch-old', - connectionGeneration: 1 - } - const nextState = { - ...oldState, - providerEpoch: 'epoch-new', - connectionGeneration: 2 - } - const storeState = buildStoreState({ - sshTargetLabels: new Map([['target-a', 'Target A']]), - sshConnectionStates: new Map([['target-a', oldState]]), - setSshConnectionState: (targetId: string, state: unknown) => { - ;(storeState.sshConnectionStates as Map).set(targetId, state) - }, - invalidateStaleDirectSshTargetPtyBindings: () => { - order.push('invalidate') - return 1 - }, - setSshTargetsMetadata: vi.fn(), - setRemovedSshTargetLabels: vi.fn(), - clearRemoteDetectedAgents: vi.fn(), - clearDirectSshTargetPtyBindings: vi.fn(), - clearRemovedSshTargetState: vi.fn() - }) - const coordinator = { - requestReconnect: vi.fn(async () => { - order.push('request') - return { status: 'complete' } - }), - replaceAuthority: vi.fn(() => { - order.push('replace') - }), - prepareOnly: vi.fn(async () => { - order.push('prepare') - return { token: null } - }), - correctUnboundTerminals: vi.fn(() => 0), - invalidate: vi.fn(), - stop: vi.fn() - } - const capturePreparationInput = vi.fn(async (authority, reason) => { - order.push('capture') - return { - ...authority, - reason, - catalogRevision: 1, - repoRefs: [], - authorityRequirement: 'required' - } - }) - - stubDirectSshModules({ - storeState, - coordinator, - coordinatorRoutingEnabled: enabled, - capturePreparationInput - }) - vi.stubGlobal( - 'window', - buildWindowApi({ - onSet: () => () => {}, - ssh: { - onStateChanged: (listener: (data: { targetId: string; state: unknown }) => void) => { - sshStateListener = listener - return () => {} - } - } - }) - ) - - const { useIpcEvents } = await import('./useIpcEvents') - useIpcEvents() - sshStateListener?.({ targetId: 'target-a', state: nextState }) - await Promise.resolve() - await Promise.resolve() - - expect(order).toEqual(expectedRoute) - if (enabled) { - expect(coordinator.requestReconnect).toHaveBeenCalledOnce() - expect(coordinator.prepareOnly).not.toHaveBeenCalled() - } else { - expect(coordinator.requestReconnect).not.toHaveBeenCalled() - expect(coordinator.replaceAuthority).toHaveBeenCalledOnce() - expect(coordinator.prepareOnly).toHaveBeenCalledOnce() - } - } - ) - it('does not let initial SSH port snapshots overwrite newer push events', async () => { const targetId = 'target-ports' const secondTargetId = 'target-ports-second' @@ -425,20 +292,11 @@ describe('useIpcEvents agent status snapshot integration', () => { fetchWorktreeLineage: vi.fn(async () => undefined), clearRemoteDetectedAgents: vi.fn(), clearDirectSshTargetPtyBindings: vi.fn(), - clearRemovedSshTargetState: vi.fn(), - invalidateStaleDirectSshTargetPtyBindings: vi.fn(() => 0) + clearRemovedSshTargetState: vi.fn() }) - const coordinator = { - requestReconnect: vi.fn(async () => ({ status: 'complete' })), - replaceAuthority: vi.fn(), - prepareOnly: vi.fn(async () => ({ token: null })), - correctUnboundTerminals: vi.fn(() => 0), - invalidate: vi.fn(), - stop: vi.fn() - } let partialTargetStateCalls = 0 - stubDirectSshModules({ storeState, coordinator }) + stubDirectSshModules({ storeState }) vi.stubGlobal( 'window', buildWindowApi({ diff --git a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry-types.ts b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry-types.ts deleted file mode 100644 index 49dcf0e80cb..00000000000 --- a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry-types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { EventProps } from '../../../shared/telemetry-events' - -export type DirectSshReconnectProductProps = EventProps<'direct_ssh_reconnect_operation'> - -export type DirectSshReconnectProductSink = ( - name: 'direct_ssh_reconnect_operation', - props: DirectSshReconnectProductProps -) => void diff --git a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts deleted file mode 100644 index 6df19c7a78c..00000000000 --- a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { DirectSshCoordinatorTelemetry } from '../hooks/direct-ssh-reconnect-coordinator' -import { - createDirectSshReconnectProductTelemetryAdapter, - toDirectSshReconnectProductProps -} from './direct-ssh-reconnect-product-telemetry' - -function coordinatorEvent( - overrides: Partial = {} -): DirectSshCoordinatorTelemetry { - return { - mode: 'prepare-only', - reason: 'workspace-snapshot', - outcome: 'degraded', - durationMs: 900, - staleBindingsCleared: 1, - retriedTerminals: 2, - correctedTerminals: 3, - terminalFinalizationDurationMs: 4, - catalogOutcome: 'complete', - catalogDurationMs: 20, - gitWorktreeCount: 5, - folderWorkspaceCount: 2, - ambiguousOwnerCount: 1, - contradictoryOwnerCount: 1, - repoOutcomes: { - complete: 2, - 'non-authoritative': 1, - 'timed-out': 1, - 'cancel-budget-exhausted': 1, - canceled: 1, - stale: 1, - rejected: 1 - }, - lineageOutcome: 'degraded', - queueWaitDurationsMs: [30, 10, 20, 40], - providerExecutionDurationsMs: [400, 100, 300, 200], - timeoutRetryCount: 2, - locallySettledWaiterCount: 4, - cancelDebtCount: 2, - replacementAdmissionDelayedCount: 1, - overlappingJoinCount: 3, - peakLocallyUnsettled: 4, - estimatedLateWorkAllowanceCount: 2, - authorityRotationCount: 1, - damped: true, - ...overrides - } -} - -describe('direct SSH reconnect product telemetry', () => { - it('maps one coordinator aggregate to privacy-safe product fields', () => { - const props = toDirectSshReconnectProductProps(coordinatorEvent()) - - expect(props).toMatchObject({ - mode: 'prepare_only', - reason: 'workspace_snapshot', - outcome: 'degraded', - repo_retrying_count: 2, - repo_timed_out_count: 1, - repo_rejected_count: 1, - repo_canceled_count: 1, - repo_stale_count: 1, - lineage_degraded_count: 1, - queue_wait_sample_count: 4, - queue_wait_duration_ms_p50: 20, - queue_wait_duration_ms_p95: 40, - provider_execution_sample_count: 4, - provider_execution_duration_ms_p50: 200, - provider_execution_duration_ms_p99: 400, - cancel_debt_count: 2, - replacement_admission_delayed_count: 1, - overlapping_join_count: 3, - timeout_retry_count: 2, - damped_preparation_count: 1 - }) - expect(Object.keys(props).join(' ')).not.toMatch( - /target_id|repo_id|host|path|label|user|request_id|lease_id|terminal_id|error/ - ) - expect(props).not.toHaveProperty('terminal_correction_failed_count') - expect(props).not.toHaveProperty('terminal_correction_rearmed_count') - expect(props).not.toHaveProperty('concurrent_non_coordinator_call_count') - expect(props).not.toHaveProperty('arrival_order_reply_discarded_count') - }) - - it('emits exactly one typed event per adapter call', () => { - const sink = vi.fn() - const emit = createDirectSshReconnectProductTelemetryAdapter(sink) - - emit(coordinatorEvent()) - - expect(sink).toHaveBeenCalledOnce() - expect(sink).toHaveBeenCalledWith( - 'direct_ssh_reconnect_operation', - expect.objectContaining({ total_duration_ms: 900 }) - ) - }) - - it('does not let telemetry failures escape into recovery', () => { - const emit = createDirectSshReconnectProductTelemetryAdapter(() => { - throw new Error('telemetry unavailable') - }) - - expect(() => emit(coordinatorEvent())).not.toThrow() - }) -}) diff --git a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.ts b/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.ts deleted file mode 100644 index bd798ad4e06..00000000000 --- a/src/renderer/src/lib/direct-ssh-reconnect-product-telemetry.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { DirectSshCoordinatorTelemetry } from '../hooks/direct-ssh-reconnect-coordinator' -import { track } from './telemetry' -import type { - DirectSshReconnectProductProps, - DirectSshReconnectProductSink -} from './direct-ssh-reconnect-product-telemetry-types' - -const MAX_COUNT = 1_000_000 -const MAX_DURATION_MS = 86_400_000 - -function boundedInteger(value: number, maximum = MAX_COUNT): number { - return Math.min(maximum, Math.max(0, Math.round(Number.isFinite(value) ? value : 0))) -} - -function durationDistribution(values: readonly number[]): { - count: number - p50: number - p95: number - p99: number - max: number -} { - const sorted = values - .map((value) => boundedInteger(value, MAX_DURATION_MS)) - .toSorted((a, b) => a - b) - const percentile = (quantile: number): number => - sorted.length === 0 ? 0 : sorted[Math.max(0, Math.ceil(sorted.length * quantile) - 1)] - return { - count: boundedInteger(sorted.length), - p50: percentile(0.5), - p95: percentile(0.95), - p99: percentile(0.99), - max: sorted.at(-1) ?? 0 - } -} - -function lineageCounts( - outcome: DirectSshCoordinatorTelemetry['lineageOutcome'] -): Pick< - DirectSshReconnectProductProps, - | 'lineage_complete_count' - | 'lineage_degraded_count' - | 'lineage_canceled_count' - | 'lineage_stale_count' - | 'lineage_not_started_count' -> { - return { - lineage_complete_count: outcome === 'complete' ? 1 : 0, - lineage_degraded_count: outcome === 'degraded' ? 1 : 0, - lineage_canceled_count: outcome === 'canceled' ? 1 : 0, - lineage_stale_count: outcome === 'stale' ? 1 : 0, - lineage_not_started_count: outcome === 'not-started' ? 1 : 0 - } -} - -export function toDirectSshReconnectProductProps( - event: DirectSshCoordinatorTelemetry -): DirectSshReconnectProductProps { - const queueWait = durationDistribution(event.queueWaitDurationsMs) - const providerExecution = durationDistribution(event.providerExecutionDurationsMs) - return { - mode: event.mode === 'prepare-only' ? 'prepare_only' : 'reconnect', - reason: event.reason.replaceAll('-', '_') as DirectSshReconnectProductProps['reason'], - outcome: event.outcome, - terminal_retried_count: boundedInteger(event.retriedTerminals), - terminal_stale_binding_cleared_count: boundedInteger(event.staleBindingsCleared), - terminal_correction_succeeded_count: boundedInteger(event.correctedTerminals), - catalog_complete_count: event.catalogOutcome === 'complete' ? 1 : 0, - catalog_degraded_count: event.catalogOutcome === 'degraded' ? 1 : 0, - catalog_stale_count: event.catalogOutcome === 'stale' ? 1 : 0, - repo_complete_count: boundedInteger(event.repoOutcomes.complete), - repo_non_authoritative_count: boundedInteger(event.repoOutcomes['non-authoritative']), - // One first-timeout transition per repo makes retrying repos equal timeout retries. - repo_retrying_count: boundedInteger(event.timeoutRetryCount), - repo_timed_out_count: boundedInteger(event.repoOutcomes['timed-out']), - repo_cancel_budget_exhausted_count: boundedInteger( - event.repoOutcomes['cancel-budget-exhausted'] - ), - repo_canceled_count: boundedInteger(event.repoOutcomes.canceled), - repo_stale_count: boundedInteger(event.repoOutcomes.stale), - repo_rejected_count: boundedInteger(event.repoOutcomes.rejected), - ...lineageCounts(event.lineageOutcome), - git_worktree_count: boundedInteger(event.gitWorktreeCount), - folder_workspace_count: boundedInteger(event.folderWorkspaceCount), - ambiguous_owner_count: boundedInteger(event.ambiguousOwnerCount), - contradictory_owner_count: boundedInteger(event.contradictoryOwnerCount), - total_duration_ms: boundedInteger(event.durationMs, MAX_DURATION_MS), - terminal_finalization_duration_ms: boundedInteger( - event.terminalFinalizationDurationMs, - MAX_DURATION_MS - ), - catalog_duration_ms: boundedInteger(event.catalogDurationMs, MAX_DURATION_MS), - queue_wait_sample_count: queueWait.count, - queue_wait_duration_ms_p50: queueWait.p50, - queue_wait_duration_ms_p95: queueWait.p95, - queue_wait_duration_ms_p99: queueWait.p99, - queue_wait_duration_ms_max: queueWait.max, - provider_execution_sample_count: providerExecution.count, - provider_execution_duration_ms_p50: providerExecution.p50, - provider_execution_duration_ms_p95: providerExecution.p95, - provider_execution_duration_ms_p99: providerExecution.p99, - provider_execution_duration_ms_max: providerExecution.max, - timeout_retry_count: boundedInteger(event.timeoutRetryCount), - locally_settled_waiter_count: boundedInteger(event.locallySettledWaiterCount), - cancel_debt_count: boundedInteger(event.cancelDebtCount), - replacement_admission_delayed_count: boundedInteger(event.replacementAdmissionDelayedCount), - overlapping_join_count: boundedInteger(event.overlappingJoinCount), - coordinator_owned_direct_ssh_detected_worktree_concurrency_peak: boundedInteger( - event.peakLocallyUnsettled, - 5 - ), - estimated_late_work_allowance_count: boundedInteger(event.estimatedLateWorkAllowanceCount, 2), - authority_rotation_count: boundedInteger(event.authorityRotationCount), - damped_preparation_count: event.damped ? 1 : 0 - } -} - -export function createDirectSshReconnectProductTelemetryAdapter( - sink: DirectSshReconnectProductSink = track -): (event: DirectSshCoordinatorTelemetry) => void { - return (event) => { - try { - sink('direct_ssh_reconnect_operation', toDirectSshReconnectProductProps(event)) - } catch { - // Recovery cannot depend on product telemetry. - } - } -} diff --git a/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts b/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts index c0f05124eda..44a71a64730 100644 --- a/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts +++ b/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts @@ -5,7 +5,6 @@ import { createTestStore, makeWorktree, makeTab, makeLayout } from './store-test import { computeVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { createStoreSessionMockApi } from './store-session-test-harness' -import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' // Mock sonner (imported by repos.ts) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) @@ -524,128 +523,4 @@ describe('reconnectPersistedTerminals', () => { expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId) expect(after.workspaceSessionReady).toBe(false) }) - - it('leaves scoped reconnect state untouched when direct SSH authority is stale at entry', async () => { - const store = createDaemonEnabledStore() - const targetId = 'ssh-1' - const worktreeId = 'repo1::/remote/wt1' - const staleAuthority: DirectSshAuthority = { - targetId, - providerEpoch: 'epoch-1' as SshProviderEpoch, - connectionGeneration: 1 - } - store.setState({ - tabsByWorktree: { - [worktreeId]: [makeTab({ id: 'tab1', worktreeId, ptyId: null })] - }, - ptyIdsByTabId: { tab1: [] }, - pendingReconnectWorktreeIds: [worktreeId], - pendingReconnectTabByWorktree: { [worktreeId]: ['tab1'] }, - pendingReconnectPtyIdByTabId: { tab1: 'ssh:ssh-1@@relay-1' }, - deferredSshSessionIdsByTabId: { tab1: 'ssh:ssh-1@@deferred-1' }, - sshConnectionStates: new Map([ - [ - targetId, - { - targetId, - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: staleAuthority.providerEpoch, - connectionGeneration: 2 - } - ] - ]), - workspaceSessionReady: false - }) - const before = store.getState() - - await store.getState().reconnectPersistedTerminals(undefined, { - directSshAuthority: staleAuthority, - workspaceKeys: [worktreeId] - }) - - const after = store.getState() - expect(after.tabsByWorktree).toBe(before.tabsByWorktree) - expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId) - expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds) - expect(after.pendingReconnectTabByWorktree).toBe(before.pendingReconnectTabByWorktree) - expect(after.pendingReconnectPtyIdByTabId).toBe(before.pendingReconnectPtyIdByTabId) - expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId) - expect(after.workspaceSessionReady).toBe(before.workspaceSessionReady) - }) - - it('leaves scoped reconnect state untouched when direct SSH authority rotates before publication', async () => { - const store = createDaemonEnabledStore() - const targetId = 'ssh-1' - const worktreeId = 'repo1::/remote/wt1' - const authority: DirectSshAuthority = { - targetId, - providerEpoch: 'epoch-1' as SshProviderEpoch, - connectionGeneration: 1 - } - let authorityReads = 0 - const currentAuthorityState = { - targetId, - status: 'connected' as const, - error: null, - reconnectAttempt: 0, - providerEpoch: authority.providerEpoch, - connectionGeneration: authority.connectionGeneration - } - const rotatedAuthorityState = { - ...currentAuthorityState, - connectionGeneration: 2 - } - const sshConnectionStates = new Map([[targetId, currentAuthorityState]]) - const originalGet = sshConnectionStates.get.bind(sshConnectionStates) - // Rotate from the first read after the entry authority check, so the - // pre-publication check sees the new generation whatever number of - // intermediate reads the reconnect pass happens to make. - sshConnectionStates.get = ((key: string) => { - authorityReads += 1 - return authorityReads >= 2 ? rotatedAuthorityState : originalGet(key) - }) as typeof sshConnectionStates.get - store.setState({ - repos: [ - { - id: 'repo1', - path: '/repo1', - displayName: 'Repo 1', - badgeColor: '#000', - addedAt: 0, - connectionId: targetId - } - ], - worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/remote/wt1' })] - }, - tabsByWorktree: { - [worktreeId]: [makeTab({ id: 'tab1', worktreeId, ptyId: null })] - }, - ptyIdsByTabId: { tab1: [] }, - pendingReconnectWorktreeIds: [worktreeId], - pendingReconnectTabByWorktree: { [worktreeId]: ['tab1'] }, - pendingReconnectPtyIdByTabId: { tab1: 'ssh:ssh-1@@relay-1' }, - deferredSshSessionIdsByTabId: {}, - sshConnectionStates, - workspaceSessionReady: false - }) - const before = store.getState() - - await store.getState().reconnectPersistedTerminals(undefined, { - directSshAuthority: authority, - workspaceKeys: [worktreeId] - }) - - const after = store.getState() - expect(authorityReads).toBeGreaterThanOrEqual(2) - expect(after.tabsByWorktree).toBe(before.tabsByWorktree) - expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId) - expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds) - expect(after.pendingReconnectTabByWorktree).toBe(before.pendingReconnectTabByWorktree) - expect(after.pendingReconnectPtyIdByTabId).toBe(before.pendingReconnectPtyIdByTabId) - expect(after.deferredSshSessionIdsByTabId).toBe(before.deferredSshSessionIdsByTabId) - expect(after.workspaceSessionReady).toBe(false) - }) }) diff --git a/src/renderer/src/store/slices/store-session-workspace-hydration.test.ts b/src/renderer/src/store/slices/store-session-workspace-hydration.test.ts index 3aca30fa942..88c43deedfb 100644 --- a/src/renderer/src/store/slices/store-session-workspace-hydration.test.ts +++ b/src/renderer/src/store/slices/store-session-workspace-hydration.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/worktree/types' -import type { SshProviderEpoch } from '../../../../shared/ssh-types' import { getDefaultWorkspaceSession } from '../../../../shared/constants' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { createTestStore, makeWorktree, makeTab, makeLayout, TEST_REPO } from './store-test-helpers' @@ -453,14 +452,8 @@ describe('restored folder workspace hydration', () => { const worktreeId = 'repo-a::/target' const retainedTab = makeTab({ id: 'tab-retained', worktreeId }) const retiredTab = makeTab({ id: 'tab-retired', worktreeId }) - const authority = { - targetId: 'target-a', - providerEpoch: 'epoch-a' as SshProviderEpoch, - connectionGeneration: 1 - } - store.setState({ - repos: [{ ...TEST_REPO, id: 'repo-a', connectionId: authority.targetId }], + repos: [{ ...TEST_REPO, id: 'repo-a', connectionId: 'target-a' }], worktreesByRepo: { 'repo-a': [makeWorktree({ id: worktreeId, repoId: 'repo-a', path: '/target' })] }, @@ -481,7 +474,7 @@ describe('restored folder workspace hydration', () => { tabsByWorktree: { [worktreeId]: [retainedTab] }, terminalLayoutsByTabId: {} }, - { directSshAuthority: authority, replaceWorkspaceKeys: [worktreeId] } + { replaceWorkspaceKeys: [worktreeId] } ) expect(store.getState().unverifiedPtyLossTabIds).toEqual({ diff --git a/src/renderer/src/store/slices/terminal-session-row-hydration.ts b/src/renderer/src/store/slices/terminal-session-row-hydration.ts index ea61dfe8ae5..ea0d6e5e8f9 100644 --- a/src/renderer/src/store/slices/terminal-session-row-hydration.ts +++ b/src/renderer/src/store/slices/terminal-session-row-hydration.ts @@ -21,14 +21,6 @@ type CanonicalTerminals = { aiVaultTitleByTabId: Map } -export type HydrateWorkspaceTerminalRowsOptions = { - /** - * Rows arrived from a remote snapshot. `unifiedTabs` is not on the remote wire, so the session's - * canonical list still describes the local client and cannot arbitrate ownership of these rows. - */ - rowsFromRemoteSnapshot?: boolean -} - export type WorkspaceTerminalRowHydration = { rows: TerminalTab[] /** PTYs a retained row must give up because a canonical row already owns them. */ @@ -50,8 +42,7 @@ export type WorkspaceTerminalRowHydration = { export function hydrateWorkspaceTerminalRows( session: WorkspaceSessionState, worktreeId: string, - rows: readonly TerminalTab[], - options: HydrateWorkspaceTerminalRowsOptions = {} + rows: readonly TerminalTab[] ): WorkspaceTerminalRowHydration { const canonical = readCanonicalTerminals(session, worktreeId, rows) const releasedPtyIdsByTabId = new Map>() @@ -66,9 +57,7 @@ export function hydrateWorkspaceTerminalRows( invalidTabIds.push(row.id) continue } - const claim = options.rowsFromRemoteSnapshot - ? RETAINED_UNCLAIMED - : resolveCanonicalPtyClaim(session, row, canonical) + const claim = resolveCanonicalPtyClaim(session, row, canonical) if (claim.kind === 'subsumed') { subsumedTabIds.push(row.id) canonicalTabIdBySubsumedTabId.set(row.id, claim.canonicalTabId) diff --git a/src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts b/src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts index 63daa20874f..f5a908633d6 100644 --- a/src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration-canonical-pty-overlap.test.ts @@ -1,7 +1,6 @@ // Keep this bare import first: it installs the preload-API stub the store imports read at eval time. import './terminal-hydration-store-test-bootstrap' import { describe, expect, it, vi } from 'vitest' -import { hydrateWorkspaceTerminalRows } from './terminal-session-row-hydration' import { getOrphanTerminalIds } from './terminal-orphan-helpers' import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import type { Tab } from '../../../../shared/tab-types' @@ -337,31 +336,6 @@ describe('hydrateWorkspaceSession canonical PTY overlap', () => { expect(Object.keys(state.sleepingAgentSessionsByPaneKey)).toEqual([`split-tab:${soloLeafId}`]) }) - it('lets remote-snapshot rows keep every PTY, since unifiedTabs describes the local client', () => { - const sharedPtyId = 'daemon-shared' - const session = makeSession({ - tabs: [ - makeTab({ id: 'canonical-tab', worktreeId: WORKTREE_ID, ptyId: sharedPtyId }), - makeTab({ id: 'remote-tab', worktreeId: WORKTREE_ID, ptyId: sharedPtyId, sortOrder: 1 }) - ], - layouts: { - 'canonical-tab': { ...makeLayout(), ptyIdsByLeafId: { 'canonical-leaf': sharedPtyId } }, - 'remote-tab': { ...makeLayout(), ptyIdsByLeafId: { 'remote-leaf': sharedPtyId } } - }, - canonicalEntityIds: ['canonical-tab'] - }) - const rows = session.tabsByWorktree[WORKTREE_ID]! - - expect( - hydrateWorkspaceTerminalRows(session, WORKTREE_ID, rows, { rowsFromRemoteSnapshot: true }) - .subsumedTabIds - ).toEqual([]) - // Guards the gate: without the flag the same rows collapse, so a caller that forgets it drops live remote rows. - expect(hydrateWorkspaceTerminalRows(session, WORKTREE_ID, rows).subsumedTabIds).toEqual([ - 'remote-tab' - ]) - }) - it('retains a non-canonical row that owns no PTY at all', () => { const sharedPtyId = 'daemon-shared' const session = makeSession({ diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index ed66a70a9cc..46b39f2f6df 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -92,7 +92,6 @@ const mockApi = { globalThis.window = { api: mockApi } import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' -import type { SshProviderEpoch } from '../../../../shared/ssh-types' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultWorkspaceSession @@ -711,127 +710,6 @@ describe('hydrateWorkspaceSession', () => { expect(store.getState().worktreeNavHistoryIndex).toBe(1) expect(canGoBackWorktreeHistory(store.getState())).toBe(true) }) - - it('hydrates and reconnects one SSH target without mutating unrelated state', async () => { - const store = createTestStore() - const targetWorktreeId = 'repo-a::/target' - const siblingWorktreeId = 'repo-b::/sibling' - const localWorktreeId = 'repo-local::/local' - const runtimeWorktreeId = 'repo-runtime::/runtime' - const folderWorktreeId = folderWorkspaceKey('folder-1') - const targetTab = makeTab({ id: 'tab-target', worktreeId: targetWorktreeId, ptyId: null }) - const deletedTargetTab = makeTab({ id: 'tab-target-deleted', worktreeId: targetWorktreeId }) - const siblingTab = makeTab({ - id: 'tab-sibling', - worktreeId: siblingWorktreeId, - ptyId: 'ssh:target-b@@pty-b' - }) - const runtimeTab = makeTab({ - id: 'tab-runtime', - worktreeId: runtimeWorktreeId, - ptyId: 'runtime:env@@pty-runtime' - }) - const localTab = makeTab({ id: 'tab-local', worktreeId: localWorktreeId, ptyId: null }) - const folderTab = makeTab({ id: 'tab-folder', worktreeId: folderWorktreeId, ptyId: null }) - const siblingTabs = [siblingTab] - const runtimeTabs = [runtimeTab] - const [localTabs, folderTabs] = [[localTab], [folderTab]] - const runtimeOwners = { [runtimeWorktreeId]: 'runtime:env' as const } - const authority = { - targetId: 'target-a', - providerEpoch: 'epoch-a' as SshProviderEpoch, - connectionGeneration: 7 - } - seedStore(store, { - workspaceSessionReady: true, - repos: [ - { ...TEST_REPO, id: 'repo-a', connectionId: 'target-a' }, - { ...TEST_REPO, id: 'repo-b', connectionId: 'target-b' } - ], - worktreesByRepo: { - 'repo-a': [makeWorktree({ id: targetWorktreeId, repoId: 'repo-a', path: '/target' })], - 'repo-b': [makeWorktree({ id: siblingWorktreeId, repoId: 'repo-b', path: '/sibling' })] - }, - tabsByWorktree: { - [targetWorktreeId]: [targetTab, deletedTargetTab], - [siblingWorktreeId]: siblingTabs, - [localWorktreeId]: localTabs, - [runtimeWorktreeId]: runtimeTabs, - [folderWorktreeId]: folderTabs - }, - ptyIdsByTabId: { - [targetTab.id]: [], - [siblingTab.id]: [siblingTab.ptyId!], - [runtimeTab.id]: [runtimeTab.ptyId!] - }, - activeRepoId: 'repo-b', - activeWorktreeId: siblingWorktreeId, - activeTabId: siblingTab.id, - restoredRuntimeHostIdByWorkspaceSessionKey: runtimeOwners, - sshConnectionStates: new Map([ - [ - authority.targetId, - { - targetId: authority.targetId, - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: authority.providerEpoch, - connectionGeneration: authority.connectionGeneration - } - ] - ]) - }) - const session: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - activeRepoId: 'repo-a', - activeWorktreeId: targetWorktreeId, - activeTabId: targetTab.id, - tabsByWorktree: { - [targetWorktreeId]: [ - { ...targetTab, ptyId: 'ssh:target-a@@pty-a' }, - makeTab({ - id: 'tab-wrong-host', - worktreeId: targetWorktreeId, - ptyId: 'ssh:target-b@@pty-wrong' - }) - ], - [siblingWorktreeId]: siblingTabs, - [runtimeWorktreeId]: runtimeTabs - }, - activeWorktreeIdsOnShutdown: [targetWorktreeId], - terminalLayoutsByTabId: {} - } - - store.getState().hydrateWorkspaceSession(session, { - directSshAuthority: authority, - replaceWorkspaceKeys: [targetWorktreeId] - }) - - expect(store.getState().tabsByWorktree[siblingWorktreeId]).toBe(siblingTabs) - expect(store.getState().tabsByWorktree[localWorktreeId]).toBe(localTabs) - expect(store.getState().tabsByWorktree[runtimeWorktreeId]).toBe(runtimeTabs) - expect(store.getState().tabsByWorktree[folderWorktreeId]).toBe(folderTabs) - expect(store.getState().ptyIdsByTabId[siblingTab.id]).toEqual([siblingTab.ptyId]) - expect(store.getState().ptyIdsByTabId[runtimeTab.id]).toEqual([runtimeTab.ptyId]) - expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toBe(runtimeOwners) - expect(store.getState().activeWorktreeId).toBe(siblingWorktreeId) - expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({ - [targetTab.id]: 'ssh:target-a@@pty-a' - }) - - await store.getState().reconnectPersistedTerminals(undefined, { - directSshAuthority: authority, - workspaceKeys: [targetWorktreeId] - }) - - const targetTabs = store.getState().tabsByWorktree[targetWorktreeId] - expect(targetTabs.find((tab) => tab.id === targetTab.id)?.ptyId).toBe('ssh:target-a@@pty-a') - expect(targetTabs.find((tab) => tab.id === 'tab-wrong-host')?.ptyId).toBeNull() - expect(store.getState().tabsByWorktree[siblingWorktreeId]).toBe(siblingTabs) - expect(store.getState().tabsByWorktree[runtimeWorktreeId]).toBe(runtimeTabs) - expect(store.getState().workspaceSessionReady).toBe(true) - }) }) describe('hydrationSucceeded flag (issue #1158)', () => { diff --git a/src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts b/src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts index 88bebff58f2..ca4cb4584b3 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts @@ -1,18 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' -import type { - HostQualifiedDetectedWorktreeResult, - ListDetectedWorktreesArgs -} from '../../../../shared/detected-worktree-provider-contract' -import type { SshProviderEpoch } from '../../../../shared/ssh-types' import { clearHugeRepoWarningDismissalsForTests } from '@/lib/source-control-huge-repo-warning-dismissals' -import { acquireDirectSshDetectedWorktreeRefresh } from './worktrees' -import { - TEST_SSH_AUTHORITY, - makeDetectedResult, - qualifyDetectedResult -} from './worktrees-detected-listing-fixtures' +import { TEST_SSH_AUTHORITY, makeDetectedResult } from './worktrees-detected-listing-fixtures' import { makeWorktree } from './worktrees-slice-test-fixtures' import { createTestStore, @@ -513,219 +503,4 @@ describe('fetchWorktrees', () => { expect(store.getState().worktreesByRepo).toBe(worktreesByRepo) expect(store.getState().detectedWorktreesByRepo).toBe(detectedWorktreesByRepo) }) - - it('keeps worktree maps byte-identical for stale and malformed direct results', async () => { - const store = createTestStore() - const existing = makeWorktree({ - id: 'repo-ssh::/home/orca/existing', - repoId: 'repo-ssh', - path: '/home/orca/existing', - hostId: 'ssh:ssh-1' - }) - store.setState({ - repos: [ - { - id: 'repo-ssh', - path: '/home/orca/repo', - displayName: 'SSH Repo', - badgeColor: '#000', - addedAt: 0, - connectionId: 'ssh-1' - } - ], - worktreesByRepo: { 'repo-ssh': [existing] }, - detectedWorktreesByRepo: { - 'repo-ssh': makeDetectedResult('repo-ssh', [existing]) - } - } as Partial) - const beforeWorktrees = store.getState().worktreesByRepo - const beforeDetected = store.getState().detectedWorktreesByRepo - const beforeBytes = JSON.stringify([beforeWorktrees, beforeDetected]) - let request!: ListDetectedWorktreesArgs - let resolveProvider!: (result: HostQualifiedDetectedWorktreeResult) => void - mockApi.worktrees.listDetected.mockImplementationOnce( - async (args: ListDetectedWorktreesArgs) => { - request = args - return new Promise((resolve) => { - resolveProvider = resolve - }) - } - ) - const lease = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'repo-ssh', - executionHostId: 'ssh:ssh-1', - authority: TEST_SSH_AUTHORITY - }) - const nextAuthority = { - ...TEST_SSH_AUTHORITY, - providerEpoch: 'provider-ssh-2' as SshProviderEpoch, - connectionGeneration: 2 - } - store.setState({ - sshConnectionStates: new Map([ - [ - 'ssh-1', - { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: nextAuthority.providerEpoch, - connectionGeneration: nextAuthority.connectionGeneration - } - ] - ]) - } as Partial) - const subscriber = vi.fn() - const unsubscribe = store.subscribe(subscriber) - resolveProvider( - qualifyDetectedResult( - request, - makeDetectedResult('repo-ssh', [ - makeWorktree({ - id: 'repo-ssh::/home/orca/stale', - repoId: 'repo-ssh', - path: '/home/orca/stale' - }) - ]) - ) - ) - const staleResult = await lease.result - - expect(lease.merge(staleResult)).toMatchObject({ status: 'stale' }) - expect(store.getState().worktreesByRepo).toBe(beforeWorktrees) - expect(store.getState().detectedWorktreesByRepo).toBe(beforeDetected) - expect(JSON.stringify([beforeWorktrees, beforeDetected])).toBe(beforeBytes) - expect(subscriber).not.toHaveBeenCalled() - unsubscribe() - - store.setState({ - sshConnectionStates: new Map([ - [ - 'ssh-1', - { - targetId: 'ssh-1', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: nextAuthority.providerEpoch, - connectionGeneration: nextAuthority.connectionGeneration - } - ] - ]) - } as Partial) - mockApi.worktrees.listDetected.mockImplementationOnce( - async (args: ListDetectedWorktreesArgs) => ({ - ...qualifyDetectedResult(args, makeDetectedResult(args.repoId, [])), - repoId: 'wrong-repo' - }) - ) - const malformed = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'repo-ssh', - executionHostId: 'ssh:ssh-1', - authority: nextAuthority - }) - const malformedResult = await malformed.result - - expect(malformed.merge(malformedResult)).toMatchObject({ - status: 'rejected' - }) - expect(store.getState().worktreesByRepo).toBe(beforeWorktrees) - expect(store.getState().detectedWorktreesByRepo).toBe(beforeDetected) - }) - - it('keeps duplicate repo IDs isolated across direct SSH hosts', async () => { - const store = createTestStore() - const authorityA = { - targetId: 'ssh-a', - providerEpoch: 'provider-a' as SshProviderEpoch, - connectionGeneration: 1 - } - const authorityB = { - targetId: 'ssh-b', - providerEpoch: 'provider-b' as SshProviderEpoch, - connectionGeneration: 4 - } - const worktreeA = makeWorktree({ - id: 'same-repo::/srv/a', - repoId: 'same-repo', - path: '/srv/a' - }) - const worktreeB = makeWorktree({ - id: 'same-repo::/srv/b', - repoId: 'same-repo', - path: '/srv/b' - }) - store.setState({ - repos: [ - { - id: 'same-repo', - path: '/srv/repo-a', - displayName: 'Repo A', - badgeColor: '#000', - addedAt: 0, - connectionId: 'ssh-a' - }, - { - id: 'same-repo', - path: '/srv/repo-b', - displayName: 'Repo B', - badgeColor: '#111', - addedAt: 1, - connectionId: 'ssh-b' - } - ], - sshConnectionStates: new Map([ - [ - 'ssh-a', - { - targetId: 'ssh-a', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: authorityA.providerEpoch, - connectionGeneration: authorityA.connectionGeneration - } - ], - [ - 'ssh-b', - { - targetId: 'ssh-b', - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: authorityB.providerEpoch, - connectionGeneration: authorityB.connectionGeneration - } - ] - ]) - } as Partial) - mockApi.worktrees.listDetected - .mockImplementationOnce(async (args: ListDetectedWorktreesArgs) => - qualifyDetectedResult(args, makeDetectedResult(args.repoId, [worktreeA])) - ) - .mockImplementationOnce(async (args: ListDetectedWorktreesArgs) => - qualifyDetectedResult(args, makeDetectedResult(args.repoId, [worktreeB])) - ) - - const refreshA = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'same-repo', - executionHostId: 'ssh:ssh-a', - authority: authorityA - }) - const refreshB = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'same-repo', - executionHostId: 'ssh:ssh-b', - authority: authorityB - }) - refreshA.merge(await refreshA.result) - refreshB.merge(await refreshB.result) - - expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(2) - expect(refreshA.providerRequestId).not.toBe(refreshB.providerRequestId) - expect(store.getState().worktreesByRepo['same-repo']).toEqual([ - { ...worktreeA, hostId: 'ssh:ssh-a' }, - { ...worktreeB, hostId: 'ssh:ssh-b' } - ]) - }) }) diff --git a/src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts b/src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts index 0d77e8e4667..2c294dc9d4c 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts @@ -1,11 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' -import type { - HostQualifiedDetectedWorktreeResult, - ListDetectedWorktreesArgs -} from '../../../../shared/detected-worktree-provider-contract' +import type { ListDetectedWorktreesArgs } from '../../../../shared/detected-worktree-provider-contract' import { clearHugeRepoWarningDismissalsForTests } from '@/lib/source-control-huge-repo-warning-dismissals' -import { acquireDirectSshDetectedWorktreeRefresh } from './worktrees' import { TEST_SSH_AUTHORITY, makeDetectedResult, @@ -466,164 +462,6 @@ describe('fetchWorktrees', () => { expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1) }) - it('exposes shared direct leases and reports cancellation only for the last waiter', async () => { - const store = createTestStore() - let request!: ListDetectedWorktreesArgs - let resolveProvider!: (result: HostQualifiedDetectedWorktreeResult) => void - const provider = new Promise((resolve) => { - resolveProvider = resolve - }) - mockApi.worktrees.listDetected.mockImplementationOnce( - async (args: ListDetectedWorktreesArgs) => { - request = args - return provider - } - ) - - const input = { - repoId: 'repo-ssh', - executionHostId: 'ssh:ssh-1' as const, - authority: TEST_SSH_AUTHORITY - } - const first = acquireDirectSshDetectedWorktreeRefresh(store, input) - const second = acquireDirectSshDetectedWorktreeRefresh(store, input) - - expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1) - expect(first.providerRequestId).toBe(second.providerRequestId) - expect(first.waiterLeaseId).not.toBe(second.waiterLeaseId) - expect(first.release('superseded')).toBe('retained') - expect(mockApi.worktrees.cancelListDetected).not.toHaveBeenCalled() - expect(second.release('invalidated')).toBe('cancel-started') - expect(second.release('stopped')).toBe('already-settled') - expect(mockApi.worktrees.cancelListDetected).toHaveBeenCalledWith({ - providerRequestId: first.providerRequestId - }) - - resolveProvider(qualifyDetectedResult(request, makeDetectedResult('repo-ssh', []))) - await provider - await Promise.resolve() - }) - - it('merges one exact direct provider result once without a second scan', async () => { - const store = createTestStore() - const worktree = makeWorktree({ - id: 'repo-ssh::/home/orca/feature', - repoId: 'repo-ssh', - path: '/home/orca/feature' - }) - store.setState({ - repos: [ - { - id: 'repo-ssh', - path: '/home/orca/repo', - displayName: 'SSH Repo', - badgeColor: '#000', - addedAt: 0, - connectionId: 'ssh-1' - } - ] - } as Partial) - mockApi.worktrees.listDetected.mockImplementationOnce(async (args: ListDetectedWorktreesArgs) => - qualifyDetectedResult(args, makeDetectedResult(args.repoId, [worktree])) - ) - const subscriber = vi.fn() - const unsubscribe = store.subscribe(subscriber) - - const lease = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'repo-ssh', - executionHostId: 'ssh:ssh-1', - authority: TEST_SSH_AUTHORITY - }) - const providerResult = await lease.result - const firstMerge = lease.merge(providerResult) - const secondMerge = lease.merge(providerResult) - - expect(firstMerge).toBe(providerResult) - expect(secondMerge).toBe(providerResult) - expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1) - expect(subscriber).toHaveBeenCalledTimes(1) - expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([ - { ...worktree, hostId: 'ssh:ssh-1' } - ]) - unsubscribe() - }) - - it('rejects a late duplicate exact-host owner with zero mutation publications', async () => { - const store = createTestStore() - const existing = makeWorktree({ - id: 'repo-ssh::/home/orca/existing', - repoId: 'repo-ssh', - path: '/home/orca/existing', - branch: 'refs/heads/old', - hostId: 'ssh:ssh-1' - }) - store.setState({ - repos: [ - { - id: 'repo-ssh', - path: '/home/orca/repo', - displayName: 'SSH Repo', - badgeColor: '#000', - addedAt: 0, - connectionId: 'ssh-1' - } - ], - worktreesByRepo: { 'repo-ssh': [existing] }, - detectedWorktreesByRepo: { - 'repo-ssh': makeDetectedResult('repo-ssh', [existing]) - } - } as Partial) - let request!: ListDetectedWorktreesArgs - let resolveProvider!: (result: HostQualifiedDetectedWorktreeResult) => void - mockApi.worktrees.listDetected.mockImplementationOnce( - (args: ListDetectedWorktreesArgs) => - new Promise((resolve) => { - request = args - resolveProvider = resolve - }) - ) - const lease = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'repo-ssh', - executionHostId: 'ssh:ssh-1', - authority: TEST_SSH_AUTHORITY - }) - - store.setState((state) => ({ - repos: [ - ...state.repos, - { - id: 'repo-ssh', - path: '/home/orca/duplicate', - displayName: 'Duplicate SSH Repo', - badgeColor: '#111', - addedAt: 1, - connectionId: 'ssh-1' - } - ] - })) - const beforeWorktrees = store.getState().worktreesByRepo - const beforeDetected = store.getState().detectedWorktreesByRepo - const subscriber = vi.fn() - const unsubscribe = store.subscribe(subscriber) - resolveProvider( - qualifyDetectedResult( - request, - makeDetectedResult('repo-ssh', [ - { - ...existing, - branch: 'refs/heads/new' - } - ]) - ) - ) - - expect(lease.merge(await lease.result)).toMatchObject({ status: 'stale' }) - expect(store.getState().worktreesByRepo).toBe(beforeWorktrees) - expect(store.getState().detectedWorktreesByRepo).toBe(beforeDetected) - expect(subscriber).not.toHaveBeenCalled() - unsubscribe() - }) - it('fails closed before provider acquisition when direct authority is partial', async () => { const store = createTestStore() const worktreesByRepo = store.getState().worktreesByRepo diff --git a/src/renderer/src/store/slices/worktrees-fetch-stale-catalog-version.test.ts b/src/renderer/src/store/slices/worktrees-fetch-stale-catalog-version.test.ts index 1a6d8161912..a585def1496 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-stale-catalog-version.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-stale-catalog-version.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' import type { WorktreeCatalogVersion } from '../../../../shared/worktree/catalog-version' -import { acquireDirectSshDetectedWorktreeRefresh } from './worktrees' import { TEST_SSH_AUTHORITY, makeDetectedResult, @@ -459,8 +458,7 @@ describe('the startup hydration purge behind a listing that is no longer current }) }) -// Why this suite exists: the SSH reconnect preparation ends on any repo reporting 'stale' and -// skips its post-connect workspace sync, which nothing retries while the connection holds. +// An older listing must neither roll back the create's catalog nor report the repo stale. describe('a direct SSH listing older than an applied create', () => { const sshHost = 'ssh:ssh-1' @@ -502,20 +500,6 @@ describe('a direct SSH listing older than an applied create', () => { return created } - it('is not applied, and reports the repo current rather than stale', async () => { - const store = createTestStore() - const created = seedSsh(store) - const lease = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'repo-ssh', - executionHostId: sshHost, - authority: TEST_SSH_AUTHORITY - }) - const providerResult = await lease.result - - expect(lease.merge(providerResult)).toBe(providerResult) - expect(store.getState().worktreesByRepo['repo-ssh']?.map((w) => w.id)).toEqual([created.id]) - }) - it('a direct-authority fetchWorktrees caller gets the same report, without a relist', async () => { const store = createTestStore() const created = seedSsh(store) @@ -530,46 +514,4 @@ describe('a direct SSH listing older than an applied create', () => { expect(mockApi.worktrees.listDetected).toHaveBeenCalledOnce() expect(store.getState().worktreesByRepo['repo-ssh']?.map((w) => w.id)).toEqual([created.id]) }) - - it('still reports stale when the repo owner went away during the listing, though it is also older', async () => { - const store = createTestStore() - seedSsh(store) - const lease = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'repo-ssh', - executionHostId: sshHost, - authority: TEST_SSH_AUTHORITY - }) - const providerResult = await lease.result - store.setState({ repos: [] }) - - expect(lease.merge(providerResult)).toMatchObject({ status: 'stale' }) - }) - - it('control: still reports stale when the connection moved during the listing', async () => { - const store = createTestStore() - seedSsh(store) - const lease = acquireDirectSshDetectedWorktreeRefresh(store, { - repoId: 'repo-ssh', - executionHostId: sshHost, - authority: TEST_SSH_AUTHORITY - }) - const providerResult = await lease.result - store.setState({ - sshConnectionStates: new Map([ - [ - TEST_SSH_AUTHORITY.targetId, - { - targetId: TEST_SSH_AUTHORITY.targetId, - status: 'connected', - error: null, - reconnectAttempt: 0, - providerEpoch: TEST_SSH_AUTHORITY.providerEpoch, - connectionGeneration: TEST_SSH_AUTHORITY.connectionGeneration + 1 - } - ] - ]) - }) - - expect(lease.merge(providerResult)).toMatchObject({ status: 'stale' }) - }) }) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 873bb72253a..e7c5d5a40a0 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -71,8 +71,6 @@ export { setDetachedHeadAutoDerivedDisplayNameForTests } from './worktrees/metadata/detached-head-display-name' export { resetAuthoritativelyRemovedWorktreeMemoryForTests } from './worktrees/listing/authoritative-worktree-removal-memory' -export type { DirectSshDetectedWorktreeRefresh } from './worktrees/listing/known-ssh-worktree-fetch' -export { acquireDirectSshDetectedWorktreeRefresh } from './worktrees/listing/known-ssh-worktree-fetch' export const createWorktreeSlice: StateCreator = (set, get) => ({ ...worktreeSliceInitialState, diff --git a/src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts b/src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts index 68b478e9f26..3ec7e7a8f3b 100644 --- a/src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts +++ b/src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts @@ -1,15 +1,11 @@ -import type { StateCreator, StoreApi } from 'zustand' +import type { StateCreator } from 'zustand' import type { AppState } from '../../../types' import type { WorktreeSlice } from '../../worktree-helpers' import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' import type { - HostQualifiedDetectedWorktreeResult, HostQualifiedKnownWorktreeResult, - ProviderRequestId, SshExecutionHostId } from '../../../../../../shared/detected-worktree-provider-contract' -import type { DirectSshAuthority } from '../../../../../../shared/ssh-types' -import type { DetectedWorktreeRefreshLease } from '../../detected-worktree-refresh-leases' import { areDetectedWorktreeResultsEqual, areWorktreesEqual } from './worktree-catalog-visibility' import { getProjectHostSetupForRepoHost, @@ -20,20 +16,9 @@ import { worktreeMatchesHost } from './worktree-host-ownership' import { getCurrentDirectSshAuthority } from './direct-ssh-authority' -import { - acquireDetectedWorktreeRefreshLeaseForRepo, - normalizeNotAdmittedProviderResult, - qualifiedProviderResultIsAdmitted -} from './detected-worktree-refresh' import { isDetectedWorktreeListResult } from './detected-worktree-provider-request' -import { staleDetectedWorktreeProviderResult } from './detected-worktree-refresh-admission' -import { mergeFetchedWorktrees } from './fetched-worktree-merge' import { getAuthoritativelyRemovedWorktreeIds } from './authoritative-worktree-removal-memory' -import type { - AdmittedDetectedWorktreeRefresh, - DetectedWorktreeRefreshOptions, - WorktreeHostMatchOptions -} from './worktree-slice-types' +import type { WorktreeHostMatchOptions } from './worktree-slice-types' import type { ExecutionHostId } from '../../../../../../shared/execution-host' export function appendMissingWorktreesForHost< @@ -181,100 +166,3 @@ export async function runKnownSshWorktreeFetch( }) return admitted ? known : null } - -export type DirectSshDetectedWorktreeRefresh = { - waiterLeaseId: DetectedWorktreeRefreshLease['waiterLeaseId'] - providerRequestId: ProviderRequestId - result: Promise - release: DetectedWorktreeRefreshLease['release'] - merge(result: HostQualifiedDetectedWorktreeResult): HostQualifiedDetectedWorktreeResult -} - -export function acquireDirectSshDetectedWorktreeRefresh( - store: Pick, 'getState' | 'setState'>, - request: { - repoId: string - executionHostId: SshExecutionHostId - authority: DirectSshAuthority - requireAuthoritative?: boolean - } -): DirectSshDetectedWorktreeRefresh { - const requestStartedState = store.getState() - const requestStartedWorktrees = requestStartedState.worktreesByRepo[request.repoId] - const ownerWasMissingAtStart = !requestStartedState.repos.some( - (repo) => repo.id === request.repoId - ) - const setup = getProjectHostSetupForRepoHost( - requestStartedState, - request.repoId, - request.executionHostId - ) - const options: DetectedWorktreeRefreshOptions = { - executionHostId: request.executionHostId, - directSshAuthority: request.authority, - requireAuthoritative: request.requireAuthoritative - } - const lease = acquireDetectedWorktreeRefreshLeaseForRepo(request.repoId, options) - let mergedResult: HostQualifiedDetectedWorktreeResult | undefined - - return { - waiterLeaseId: lease.waiterLeaseId, - providerRequestId: lease.providerRequestId, - result: lease.result, - release: lease.release, - merge: (providerResult) => { - if (mergedResult) { - return mergedResult - } - if ( - !qualifiedProviderResultIsAdmitted( - providerResult, - lease.providerRequestId, - request.repoId, - options - ) - ) { - mergedResult = normalizeNotAdmittedProviderResult( - providerResult, - lease.providerRequestId, - request.executionHostId - ) - return mergedResult - } - if (request.requireAuthoritative && providerResult.status !== 'complete') { - mergedResult = providerResult - return mergedResult - } - const refresh: AdmittedDetectedWorktreeRefresh = { - status: 'admitted', - result: providerResult.result, - providerResult, - executionHostId: request.executionHostId, - directSshAuthority: request.authority - } - const outcome = mergeFetchedWorktrees( - store.setState as Parameters>[0], - { - repoId: request.repoId, - hostId: request.executionHostId, - ownerWasMissingAtStart, - requestStartedWorktrees, - setup, - refresh - } - ) - // Why 'superseded' is current: a newer catalog is already applied for this host. 'stale' would - // end a reconnect preparation, and nothing retries it while the connection holds. - switch (outcome) { - case 'applied': - case 'superseded': - mergedResult = providerResult - break - case 'not-current': - mergedResult = staleDetectedWorktreeProviderResult(refresh) ?? providerResult - break - } - return mergedResult - } - } -} diff --git a/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts b/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts index 8a694e9b515..6a5ea2afda9 100644 --- a/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts +++ b/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts @@ -1,17 +1,11 @@ import { clearDirectSshTerminalBindings } from '../slices/direct-ssh-terminal-recovery' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' -import { - isCurrentDirectSshAuthority, - resolveDirectSshTerminalKeys -} from './terminal-pty-identities' +import { resolveDirectSshTerminalKeys } from './terminal-pty-identities' export function createDirectSshTerminalBindingActions( set: TerminalStoreSet, _get: TerminalStoreGet -): Pick< - TerminalSlice, - 'clearDirectSshTargetPtyBindings' | 'invalidateStaleDirectSshTargetPtyBindings' -> { +): Pick { return { clearDirectSshTargetPtyBindings: (targetId) => { let clearedCount = 0 @@ -21,22 +15,6 @@ export function createDirectSshTerminalBindingActions( return result.patch ?? s }) return clearedCount - }, - // Why: no pane binds an `ssh:` PTY any more, so every binding a new authority finds is stale. - invalidateStaleDirectSshTargetPtyBindings: (authority) => { - let clearedCount = 0 - set((s) => { - if (!isCurrentDirectSshAuthority(s, authority)) { - return s - } - const result = clearDirectSshTerminalBindings( - s, - resolveDirectSshTerminalKeys(s, authority.targetId) - ) - clearedCount = result.clearedCount - return result.patch ?? s - }) - return clearedCount } } } diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 56c7dfd82e5..c7b32b08237 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -9,7 +9,6 @@ import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' -import type { DirectSshAuthority } from '../../../../shared/ssh-types' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import type { AgentStartedTelemetry } from '../../lib/worktree-startup-payload' @@ -27,8 +26,7 @@ import type { import type { AutomaticAgentResumeClaim, CodexRestartNotice, - HydrateWorkspaceSessionOptions, - ReconnectPersistedTerminalsOptions + HydrateWorkspaceSessionOptions } from './terminal-contracts' export type TerminalActions = { @@ -145,7 +143,6 @@ export type TerminalActions = { /** Records the relay's own answer that a PTY id is gone; the one `exited` a respawn may act on. */ markPtySourceDisowned: (ptyId: string) => void clearDirectSshTargetPtyBindings: (targetId: string) => number - invalidateStaleDirectSshTargetPtyBindings: (authority: DirectSshAuthority) => number /** Awaited verified shutdown; unlike closeTab, physical teardown gates the state commit. */ shutdownWorktreeTerminals: ( worktreeId: string, @@ -279,8 +276,5 @@ export type TerminalActions = { options?: HydrateWorkspaceSessionOptions ) => void /** Publishes wake hints without spawning; global completion alone opens the readiness gate. */ - reconnectPersistedTerminals: ( - signal?: AbortSignal, - options?: ReconnectPersistedTerminalsOptions - ) => Promise + reconnectPersistedTerminals: (signal?: AbortSignal) => Promise } diff --git a/src/renderer/src/store/terminals/terminal-contracts.ts b/src/renderer/src/store/terminals/terminal-contracts.ts index 7bb43481ad5..e67343c4b86 100644 --- a/src/renderer/src/store/terminals/terminal-contracts.ts +++ b/src/renderer/src/store/terminals/terminal-contracts.ts @@ -1,6 +1,5 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' -import type { DirectSshAuthority } from '../../../../shared/ssh-types' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys' import type { HostSessionSlices } from '@/lib/workspace-session-host-split' @@ -24,18 +23,10 @@ export type CodexRestartNotice = { dismissed?: true } -/** Scoped direct-SSH hydration replaces only named workspace keys and authority. */ export type HydrateWorkspaceSessionOptions = { - directSshAuthority?: DirectSshAuthority runtimeHostIdByWorkspaceSessionKey?: Record /** Rows parked for hosts that lost a contested workspace id; omitted leaves the store's copy. */ contestedHostWorkspaceSessions?: HostSessionSlices /** Partition each restored session key was read from; omitted leaves the store's copy. */ contestedPrimaryHostBySessionKey?: Record } & WorkspaceSessionHydrationOptions - -/** Scoped reconnect must still match this exact provider epoch and connection generation. */ -export type ReconnectPersistedTerminalsOptions = { - directSshAuthority: DirectSshAuthority - workspaceKeys: readonly string[] -} diff --git a/src/renderer/src/store/terminals/terminal-pty-identities.ts b/src/renderer/src/store/terminals/terminal-pty-identities.ts index 08cd8dc9865..a227560e12d 100644 --- a/src/renderer/src/store/terminals/terminal-pty-identities.ts +++ b/src/renderer/src/store/terminals/terminal-pty-identities.ts @@ -1,21 +1,8 @@ import type { AppState } from '../types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' -import type { DirectSshAuthority } from '../../../../shared/ssh-types' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import { resolveDirectSshTerminalWorkspaceKeys } from '../slices/direct-ssh-terminal-workspace-scope' -export function isCurrentDirectSshAuthority( - state: AppState, - authority: DirectSshAuthority -): boolean { - const current = state.sshConnectionStates.get(authority.targetId) - return Boolean( - current?.status === 'connected' && - current.providerEpoch === authority.providerEpoch && - current.connectionGeneration === authority.connectionGeneration - ) -} - export function resolveDirectSshTerminalKeys(state: AppState, targetId: string): Set { return resolveDirectSshTerminalWorkspaceKeys( { diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts index d2996c7477d..06eb392f30c 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts @@ -1,6 +1,5 @@ import type { AppState } from '../types' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' -import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' import { resolveAgentPaneAuthorityKey } from '../slices/agent-pane-authority' import type { HydrateWorkspaceSessionOptions } from './terminal-contracts' @@ -127,24 +126,6 @@ export function targetScopedWorkspaceHydrationPatch( {}, targetTabIds ) - const authority = options.directSshAuthority - if (authority) { - for (const workspaceKey of hydrated.pendingReconnectWorktreeIds) { - if (!workspaceKeys.has(workspaceKey)) { - continue - } - for (const tab of session.tabsByWorktree[workspaceKey] ?? []) { - // Why: rows hydration dropped (invalid id, canonical duplicate) would leak reconnect keys nothing owns. - if (!retainedTargetTabIds.has(tab.id)) { - continue - } - const ptyId = session.remoteSessionIdsByTabId?.[tab.id] ?? tab.ptyId - if (ptyId && parseAppSshPtyId(ptyId)?.connectionId === authority.targetId) { - pendingReconnectPtyIdByTabId[tab.id] = ptyId - } - } - } - } const activeOutsideScope = state.activeWorktreeId != null && !workspaceKeys.has(state.activeWorktreeId) const sleepingAgentSessionsByPaneKey = Object.fromEntries([ diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts index 9cfe4e687e8..e7e2197b54f 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts @@ -67,7 +67,7 @@ export function createWorkspaceTerminalHydrationActions( sleepingAgentSessionsByPaneKey, tabsByWorktree, validTabIds - } = buildWorkspaceTerminalRowPlan(session, validWorktreeIds, options) + } = buildWorkspaceTerminalRowPlan(session, validWorktreeIds) const fallbackActiveWorktreeId = !session.activeWorktreeId && session.activeRepoId && diff --git a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts index c379a85774c..7c4a916ab41 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts @@ -1,20 +1,15 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { buildByIdIndex, buildWorktreeByIdIndex } from '../slices/worktree-by-id-index' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' -import { isCurrentDirectSshAuthority } from './terminal-pty-identities' export function createWorkspaceTerminalReconnectActions( set: TerminalStoreSet, get: TerminalStoreGet ): Pick { return { - reconnectPersistedTerminals: async (signal, options) => { - if ( - signal?.aborted || - (options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority)) - ) { + reconnectPersistedTerminals: async (signal) => { + if (signal?.aborted) { return } const { @@ -25,14 +20,8 @@ export function createWorkspaceTerminalReconnectActions( tabsByWorktree, ptyIdsByTabId } = get() - const scopedWorkspaceKeys = options ? new Set(options.workspaceKeys) : null - const ids = (pendingReconnectWorktreeIds ?? []).filter( - (id) => !scopedWorkspaceKeys || scopedWorkspaceKeys.has(id) - ) + const ids = pendingReconnectWorktreeIds ?? [] if (ids.length === 0) { - if (options) { - return - } set({ workspaceSessionReady: true, pendingReconnectWorktreeIds: [], @@ -65,13 +54,7 @@ export function createWorkspaceTerminalReconnectActions( const tabId = tab.id const layout = terminalLayoutsByTabId[tabId] const leafPtyMap = layout?.ptyIdsByLeafId ?? {} - const pendingPtyId = pendingReconnectPtyIdByTabId[tabId] - const tabLevelPtyId = - options && - parseAppSshPtyId(pendingPtyId ?? '')?.connectionId !== - options.directSshAuthority.targetId - ? undefined - : pendingPtyId + const tabLevelPtyId = pendingReconnectPtyIdByTabId[tabId] const hasLeafMappings = Object.keys(leafPtyMap).length > 0 // Why: publish live PTY hints before mount (pty-connection reattaches later) so the // sessions status segment maps daemon IDs to tabs; otherwise all sessions look like orphans until the pane mounts. @@ -99,24 +82,13 @@ export function createWorkspaceTerminalReconnectActions( } } // Why: keep deferred SSH session IDs for post-cleanup reconnect. - const scopedTabIds = new Set( - [...(scopedWorkspaceKeys ?? ids)].flatMap((workspaceKey) => - (tabsByWorktree[workspaceKey] ?? []).map((tab) => tab.id) - ) - ) - const deferredSshSessionIdsByTabId: Record = options - ? Object.fromEntries( - Object.entries(get().deferredSshSessionIdsByTabId).filter( - ([tabId]) => !scopedTabIds.has(tabId) - ) - ) - : {} + const deferredSshSessionIdsByTabId: Record = {} for (const worktreeId of ids) { const worktree = worktreeById.get(worktreeId) // Why: SSH worktrees aren't in worktreesByRepo at cold start; fall back to the repo id in the composite worktree id so sessions still reach the deferred map. const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId) const repo = repoId ? (repoById.get(repoId) ?? null) : null - const connectionId = options?.directSshAuthority.targetId ?? repo?.connectionId + const connectionId = repo?.connectionId if (!connectionId) { continue } @@ -136,44 +108,21 @@ export function createWorkspaceTerminalReconnectActions( const tabs = tabsByWorktree[worktreeId] ?? [] for (const tab of tabs) { const sessionId = pendingReconnectPtyIdByTabId[tab.id] - if ( - sessionId && - (!options || parseAppSshPtyId(sessionId)?.connectionId === connectionId) - ) { + if (sessionId) { deferredSshSessionIdsByTabId[tab.id] = sessionId } } } - if ( - signal?.aborted || - (options && !isCurrentDirectSshAuthority(get(), options.directSshAuthority)) - ) { + if (signal?.aborted) { return } - const remainingReconnectWorktreeIds = options - ? pendingReconnectWorktreeIds.filter((id) => !scopedWorkspaceKeys?.has(id)) - : [] - const remainingReconnectTabByWorktree = options - ? Object.fromEntries( - Object.entries(pendingReconnectTabByWorktree).filter( - ([workspaceKey]) => !scopedWorkspaceKeys?.has(workspaceKey) - ) - ) - : {} - const remainingReconnectPtyIdByTabId = options - ? Object.fromEntries( - Object.entries(pendingReconnectPtyIdByTabId).filter( - ([tabId]) => !scopedTabIds.has(tabId) - ) - ) - : {} set({ ...(reconnectedTabsByWorktree ? { tabsByWorktree: reconnectedTabsByWorktree } : {}), ...(reconnectedPtyIdsByTabId ? { ptyIdsByTabId: reconnectedPtyIdsByTabId } : {}), - ...(options ? {} : { workspaceSessionReady: true }), - pendingReconnectWorktreeIds: remainingReconnectWorktreeIds, - pendingReconnectTabByWorktree: remainingReconnectTabByWorktree, - pendingReconnectPtyIdByTabId: remainingReconnectPtyIdByTabId, + workspaceSessionReady: true, + pendingReconnectWorktreeIds: [], + pendingReconnectTabByWorktree: {}, + pendingReconnectPtyIdByTabId: {}, deferredSshSessionIdsByTabId }) } diff --git a/src/renderer/src/store/terminals/workspace-terminal-row-plan.ts b/src/renderer/src/store/terminals/workspace-terminal-row-plan.ts index fc565f3a3b7..3890b7d229d 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-row-plan.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-row-plan.ts @@ -7,7 +7,6 @@ import { hydrateWorkspaceTerminalRows } from '../slices/terminal-session-row-hydration' import { removeSleepingAgentSessionsForTab } from '../slices/terminal-tab-retirement' -import type { HydrateWorkspaceSessionOptions } from './terminal-contracts' export type WorkspaceTerminalRowPlan = { canonicalTabIdBySubsumedTabId: Map @@ -20,23 +19,13 @@ export type WorkspaceTerminalRowPlan = { export function buildWorkspaceTerminalRowPlan( session: WorkspaceSessionState, - validWorktreeIds: ReadonlySet, - options?: HydrateWorkspaceSessionOptions + validWorktreeIds: ReadonlySet ): WorkspaceTerminalRowPlan { - // Remote wire rows are authoritative independently of locally persisted unified tabs. - const remoteSnapshotWorkspaceKeys = new Set( - options?.directSshAuthority ? (options.replaceWorkspaceKeys ?? []) : [] - ) const rowHydrationByWorktree = Object.entries(session.tabsByWorktree) .filter(([worktreeId]) => validWorktreeIds.has(worktreeId)) .map( ([worktreeId, tabs]) => - [ - worktreeId, - hydrateWorkspaceTerminalRows(session, worktreeId, tabs, { - rowsFromRemoteSnapshot: remoteSnapshotWorkspaceKeys.has(worktreeId) - }) - ] as const + [worktreeId, hydrateWorkspaceTerminalRows(session, worktreeId, tabs)] as const ) const tabsByWorktree: Record = Object.fromEntries( rowHydrationByWorktree diff --git a/src/shared/direct-ssh-reconnect-telemetry-schema.test.ts b/src/shared/direct-ssh-reconnect-telemetry-schema.test.ts deleted file mode 100644 index 10d71e95a24..00000000000 --- a/src/shared/direct-ssh-reconnect-telemetry-schema.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { eventSchemas, type EventProps } from './telemetry-events' - -const validEvent = { - mode: 'reconnect', - reason: 'wake_refresh', - outcome: 'degraded', - terminal_retried_count: 3, - terminal_stale_binding_cleared_count: 1, - terminal_correction_succeeded_count: 2, - catalog_complete_count: 1, - catalog_degraded_count: 0, - catalog_stale_count: 0, - repo_complete_count: 2, - repo_non_authoritative_count: 1, - repo_retrying_count: 1, - repo_timed_out_count: 1, - repo_cancel_budget_exhausted_count: 0, - repo_canceled_count: 0, - repo_stale_count: 0, - repo_rejected_count: 1, - lineage_complete_count: 0, - lineage_degraded_count: 1, - lineage_canceled_count: 0, - lineage_stale_count: 0, - lineage_not_started_count: 0, - git_worktree_count: 4, - folder_workspace_count: 2, - ambiguous_owner_count: 1, - contradictory_owner_count: 1, - total_duration_ms: 750, - terminal_finalization_duration_ms: 5, - catalog_duration_ms: 25, - queue_wait_sample_count: 3, - queue_wait_duration_ms_p50: 10, - queue_wait_duration_ms_p95: 30, - queue_wait_duration_ms_p99: 30, - queue_wait_duration_ms_max: 30, - provider_execution_sample_count: 3, - provider_execution_duration_ms_p50: 100, - provider_execution_duration_ms_p95: 300, - provider_execution_duration_ms_p99: 300, - provider_execution_duration_ms_max: 300, - timeout_retry_count: 1, - locally_settled_waiter_count: 3, - cancel_debt_count: 1, - replacement_admission_delayed_count: 0, - overlapping_join_count: 1, - coordinator_owned_direct_ssh_detected_worktree_concurrency_peak: 3, - estimated_late_work_allowance_count: 1, - authority_rotation_count: 1, - damped_preparation_count: 0 -} satisfies EventProps<'direct_ssh_reconnect_operation'> - -describe('direct SSH reconnect telemetry schema', () => { - it('accepts the aggregate count and duration distribution', () => { - expect(eventSchemas.direct_ssh_reconnect_operation.safeParse(validEvent).success).toBe(true) - }) - - it.each([ - 'target_id', - 'repo_id', - 'host', - 'path', - 'label', - 'username', - 'request_id', - 'lease_id', - 'terminal_id', - 'error', - 'raw_error' - ])('rejects identifier or raw failure field %s', (field) => { - expect( - eventSchemas.direct_ssh_reconnect_operation.safeParse({ - ...validEvent, - [field]: 'sensitive-value' - }).success - ).toBe(false) - }) - - it('keeps timeout, rejection, cancellation, and stale counts independent', () => { - const parsed = eventSchemas.direct_ssh_reconnect_operation.parse({ - ...validEvent, - repo_timed_out_count: 2, - repo_rejected_count: 3, - repo_canceled_count: 4, - repo_stale_count: 5 - }) - - expect([ - parsed.repo_timed_out_count, - parsed.repo_rejected_count, - parsed.repo_canceled_count, - parsed.repo_stale_count - ]).toEqual([2, 3, 4, 5]) - }) - - it('rejects concurrency and late-work values beyond scheduler policy', () => { - expect( - eventSchemas.direct_ssh_reconnect_operation.safeParse({ - ...validEvent, - coordinator_owned_direct_ssh_detected_worktree_concurrency_peak: 6 - }).success - ).toBe(false) - expect( - eventSchemas.direct_ssh_reconnect_operation.safeParse({ - ...validEvent, - estimated_late_work_allowance_count: 3 - }).success - ).toBe(false) - }) -}) diff --git a/src/shared/host-lineage-contract.ts b/src/shared/host-lineage-contract.ts deleted file mode 100644 index 7bb69f02bb9..00000000000 --- a/src/shared/host-lineage-contract.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ExecutionHostId, LOCAL_EXECUTION_HOST_ID } from './execution-host' -import type { SshExecutionHostId } from './detected-worktree-provider-contract' -import type { DirectSshAuthority } from './ssh-types' -import type { WorkspaceLineage, WorktreeLineage } from './worktree/lineage-types' - -export type ListDesktopLineageForHostArgs = - | { executionHostId: typeof LOCAL_EXECUTION_HOST_ID } - | { - executionHostId: SshExecutionHostId - expectedAuthority: DirectSshAuthority - } - -export type HostLineageSnapshot = - | { - authoritative: true - authority: - | { kind: 'local'; executionHostId: typeof LOCAL_EXECUTION_HOST_ID } - | ({ - kind: 'direct-ssh' - executionHostId: SshExecutionHostId - } & DirectSshAuthority) - worktreeLineageById: Record - workspaceLineageByChildKey: Record - } - | { - authoritative: false - executionHostId: ExecutionHostId - reason: 'ambiguous-owner' | 'authority-unknown' | 'stale' | 'unavailable' | 'rejected' - } diff --git a/src/shared/host-repo-catalog-contract.ts b/src/shared/host-repo-catalog-contract.ts deleted file mode 100644 index 30f88d7f271..00000000000 --- a/src/shared/host-repo-catalog-contract.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ExecutionHostId, LOCAL_EXECUTION_HOST_ID } from './execution-host' -import type { SshExecutionHostId } from './detected-worktree-provider-contract' -import type { DirectSshAuthority } from './ssh-types' -import type { Repo } from './repo-types' - -export type ListReposForExecutionHostArgs = - | { executionHostId: typeof LOCAL_EXECUTION_HOST_ID } - | { - executionHostId: SshExecutionHostId - expectedAuthority: DirectSshAuthority - } - -export type HostRepoCatalogSnapshot = - | { - authoritative: true - authority: - | { kind: 'local'; executionHostId: typeof LOCAL_EXECUTION_HOST_ID } - | ({ - kind: 'direct-ssh' - executionHostId: SshExecutionHostId - } & DirectSshAuthority) - repos: readonly Repo[] - } - | { - authoritative: false - executionHostId: ExecutionHostId - reason: 'authority-unknown' | 'stale' | 'unavailable' | 'rejected' - } diff --git a/src/shared/telemetry-event-registry.ts b/src/shared/telemetry-event-registry.ts index 4cfa4ac49b5..63d6bd58929 100644 --- a/src/shared/telemetry-event-registry.ts +++ b/src/shared/telemetry-event-registry.ts @@ -30,7 +30,6 @@ import { import { contextualTourOutcomeSchema, contextualTourShownSchema, - directSshReconnectOperationSchema, editorExternalChangeConflictActionSchema, editorExternalChangeConflictShownSchema, setupGuideClosedSchema, @@ -203,7 +202,6 @@ export const eventSchemas = { editor_external_change_conflict_shown: editorExternalChangeConflictShownSchema, editor_external_change_conflict_action: editorExternalChangeConflictActionSchema, - direct_ssh_reconnect_operation: directSshReconnectOperationSchema, ssh_remote_runtime_resolved: sshRemoteRuntimeResolvedSchema, ssh_host_server_decided: sshHostServerDecidedSchema, ssh_host_server_conversion: sshHostServerConversionSchema, diff --git a/src/shared/telemetry-feature-education-event-schemas.ts b/src/shared/telemetry-feature-education-event-schemas.ts index 18e6546026b..0fc1bc7fc95 100644 --- a/src/shared/telemetry-feature-education-event-schemas.ts +++ b/src/shared/telemetry-feature-education-event-schemas.ts @@ -119,59 +119,3 @@ export const editorExternalChangeConflictActionSchema = z transport: z.enum(['local', 'ssh', 'runtime']) }) .strict() - -export const directSshReconnectCountSchema = z.number().int().min(0).max(1_000_000) -export const directSshReconnectDurationSchema = z.number().int().min(0).max(86_400_000) -export const directSshReconnectOperationSchema = z - .object({ - mode: z.enum(['reconnect', 'prepare_only']), - reason: z.enum(['reconnect', 'initial_hydration', 'workspace_snapshot', 'wake_refresh']), - outcome: z.enum(['complete', 'degraded', 'canceled', 'stale', 'stopped', 'stabilizing']), - terminal_retried_count: directSshReconnectCountSchema, - terminal_stale_binding_cleared_count: directSshReconnectCountSchema, - terminal_correction_succeeded_count: directSshReconnectCountSchema, - catalog_complete_count: directSshReconnectCountSchema, - catalog_degraded_count: directSshReconnectCountSchema, - catalog_stale_count: directSshReconnectCountSchema, - repo_complete_count: directSshReconnectCountSchema, - repo_non_authoritative_count: directSshReconnectCountSchema, - repo_retrying_count: directSshReconnectCountSchema, - repo_timed_out_count: directSshReconnectCountSchema, - repo_cancel_budget_exhausted_count: directSshReconnectCountSchema, - repo_canceled_count: directSshReconnectCountSchema, - repo_stale_count: directSshReconnectCountSchema, - repo_rejected_count: directSshReconnectCountSchema, - lineage_complete_count: directSshReconnectCountSchema, - lineage_degraded_count: directSshReconnectCountSchema, - lineage_canceled_count: directSshReconnectCountSchema, - lineage_stale_count: directSshReconnectCountSchema, - lineage_not_started_count: directSshReconnectCountSchema, - git_worktree_count: directSshReconnectCountSchema, - folder_workspace_count: directSshReconnectCountSchema, - ambiguous_owner_count: directSshReconnectCountSchema, - contradictory_owner_count: directSshReconnectCountSchema, - total_duration_ms: directSshReconnectDurationSchema, - terminal_finalization_duration_ms: directSshReconnectDurationSchema, - catalog_duration_ms: directSshReconnectDurationSchema, - queue_wait_sample_count: directSshReconnectCountSchema, - queue_wait_duration_ms_p50: directSshReconnectDurationSchema, - queue_wait_duration_ms_p95: directSshReconnectDurationSchema, - queue_wait_duration_ms_p99: directSshReconnectDurationSchema, - queue_wait_duration_ms_max: directSshReconnectDurationSchema, - provider_execution_sample_count: directSshReconnectCountSchema, - provider_execution_duration_ms_p50: directSshReconnectDurationSchema, - provider_execution_duration_ms_p95: directSshReconnectDurationSchema, - provider_execution_duration_ms_p99: directSshReconnectDurationSchema, - provider_execution_duration_ms_max: directSshReconnectDurationSchema, - timeout_retry_count: directSshReconnectCountSchema, - locally_settled_waiter_count: directSshReconnectCountSchema, - cancel_debt_count: directSshReconnectCountSchema, - replacement_admission_delayed_count: directSshReconnectCountSchema, - overlapping_join_count: directSshReconnectCountSchema, - coordinator_owned_direct_ssh_detected_worktree_concurrency_peak: - directSshReconnectCountSchema.max(5), - estimated_late_work_allowance_count: directSshReconnectCountSchema.max(2), - authority_rotation_count: directSshReconnectCountSchema, - damped_preparation_count: directSshReconnectCountSchema - }) - .strict() From c180ba6338b33e6451cb9ae816da265316fe31c8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:42:38 -0700 Subject: [PATCH 05/28] [CI] Narrow daemon fixture checks and advance coordinator test clocks (#27592) --- .github/workflows/pr.yml | 10 +- .../pr-auxiliary-preflight-admission.test.mjs | 96 +++++++++++++++++-- config/scripts/pr-code-change-scope.mjs | 36 ++++++- config/scripts/pr-e2e-gate-contract.test.mjs | 22 +++++ config/scripts/pr-e2e-source-routing.mjs | 28 ++++-- .../structured-chat-coordinator-mail.test.ts | 5 +- 6 files changed, 176 insertions(+), 21 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8f8766d53bf..2d3b3c1e5c7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -118,7 +118,7 @@ jobs: printf '%s\n' "$CHANGED" MOBILE_CI_ARGS=() if git diff --quiet --no-renames --diff-filter=ACDRTUXB "$DIFF_BASE" HEAD; then - MOBILE_CI_ARGS=(--mobile-ci-tools) + MOBILE_CI_ARGS=(--mobile-ci-tools --daemon-init-test-support) fi printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs "${MOBILE_CI_ARGS[@]}" | tee -a "$GITHUB_OUTPUT" @@ -131,9 +131,13 @@ jobs: BASE="${{ github.event.pull_request.base.sha }}" DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE")" CHANGED="$(git diff --name-only --diff-filter=AMCR "$DIFF_BASE" HEAD)" + E2E_SCOPE_ARGS=() + if git diff --quiet --no-renames --diff-filter=ACDRTUXB "$DIFF_BASE" HEAD; then + E2E_SCOPE_ARGS=(--daemon-init-test-support) + fi # Source routes are executable contracts so a test can prove exact # authorities, exclusions, and sentinels without evaluating workflow shell. - TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)" + TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs "${E2E_SCOPE_ARGS[@]}")" echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT" printf '%s\n' "$TEST_FILES_JSON" | node config/scripts/ci-e2e-job-selection.mjs --job-outputs >> "$GITHUB_OUTPUT" # Why its own signal: the real-IME lane is a whole ibus session, not a spec, so it must @@ -144,7 +148,7 @@ jobs: WSL_SOURCE_CHANGED="$(printf '%s\n' "$WSL_CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --wsl-source)" echo "wsl_source_changed=$WSL_SOURCE_CHANGED" >> "$GITHUB_OUTPUT" echo "Native IME source changed: $NATIVE_IME_SOURCE_CHANGED" - SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --reusable-workflow)" + SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --reusable-workflow "${E2E_SCOPE_ARGS[@]}")" if [ "$SHOULD_RUN" = true ]; then echo "should_run=true" >> "$GITHUB_OUTPUT" echo "Changed E2E specs: $TEST_FILES_JSON" diff --git a/config/scripts/pr-auxiliary-preflight-admission.test.mjs b/config/scripts/pr-auxiliary-preflight-admission.test.mjs index bd2b9e9a095..f177f3040fe 100644 --- a/config/scripts/pr-auxiliary-preflight-admission.test.mjs +++ b/config/scripts/pr-auxiliary-preflight-admission.test.mjs @@ -290,14 +290,20 @@ afterEach(() => { } }) -function createMobileCiScopeRepository(withTool = true) { +function createMobileCiScopeRepository( + withTool = true, + toolPath = 'config/scripts/run-mobile-ci-tests.mjs', + companionPaths = [] +) { const directory = mkdtempSync(join(tmpdir(), 'orca-mobile-ci-scope-')) mobileCiScopeDirectories.push(directory) const root = resolve(import.meta.dirname, '../..') for (const file of [ 'package.json', 'config/scripts/pr-code-change-scope.mjs', - 'config/scripts/git-pull-request-diff-base.mjs' + 'config/scripts/git-pull-request-diff-base.mjs', + 'config/scripts/pr-e2e-source-routing.mjs', + 'config/scripts/ci-e2e-job-selection.mjs' ]) { mkdirSync(dirname(join(directory, file)), { recursive: true }) copyFileSync(join(root, file), join(directory, file)) @@ -323,17 +329,29 @@ function createMobileCiScopeRepository(withTool = true) { git('init', '--quiet') git('config', 'user.name', 'Scope control') git('config', 'user.email', 'scope@example.invalid') - const tool = join(directory, 'config/scripts/run-mobile-ci-tests.mjs') + const tool = join(directory, toolPath) + mkdirSync(dirname(tool), { recursive: true }) if (withTool) { writeFileSync(tool, 'export const revision = 1\n') } + for (const file of companionPaths) { + mkdirSync(dirname(join(directory, file)), { recursive: true }) + writeFileSync(join(directory, file), 'export const revision = 1\n') + } const base = commit() - const detect = () => { - const output = join(directory, 'github-output') - const step = workflow.jobs.code_paths.steps.find((candidate) => candidate.id === 'filter') + const detect = (stepId = 'filter') => { + const output = join( + directory, + stepId === 'filter' ? 'github-output' : `github-output-${stepId}` + ) + const step = workflow.jobs.code_paths.steps.find((candidate) => candidate.id === stepId) const result = runProcessSync({ program: 'bash', - args: ['-e', '-c', step.run], + args: [ + '-e', + '-c', + step.run.replace('${{ github.event.pull_request.base.sha }}', '$BASE_SHA') + ], cwd: directory, env: { ...process.env, @@ -397,3 +415,67 @@ describe.skipIf(process.platform === 'win32')('Mobile CI complete Git change evi expect(result.stderr).toContain('not a git repository') }) }) + +describe.skipIf(process.platform === 'win32')('Daemon mock complete Git change evidence', () => { + const helper = 'src/main/daemon/daemon-init-dependency-mocks.ts' + it('keeps real unit and type contracts while omitting unrelated packages for a modification', () => { + const repo = createMobileCiScopeRepository(true, helper) + writeFileSync(repo.tool, 'export const revision = 2\n') + repo.commit() + const { result, output } = repo.detect() + expect(result.code, result.stderr).toBe(0) + const flags = readFileSync(output, 'utf8') + expect(flags).toContain('package=false\n') + expect(flags).toContain('package_windows=false\n') + expect(flags).toContain('test=true\n') + expect(flags).toContain('typecheck=true\n') + }) + it('routes the complete four-file modification through the real detector', () => { + const companions = [ + 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts', + 'config/scripts/process-host-build-dist.test.mjs', + 'src/main/codex/codex-app-server-connection.test.ts' + ] + const repo = createMobileCiScopeRepository(true, helper, companions) + for (const file of [helper, ...companions]) { + writeFileSync(join(repo.directory, file), 'export const revision = 2\n') + } + repo.commit() + const { result, output } = repo.detect() + expect(result.code, result.stderr).toBe(0) + const flags = readFileSync(output, 'utf8') + for (const name of ['package', 'package_windows']) { + expect(classifyPrJobs([helper, ...companions])[name]).toBe(true) + expect(flags).toContain(`${name}=false\n`) + } + for (const name of ['test', 'static_analysis', 'typecheck', 'cross-version-wire']) { + expect(flags).toContain(`${name}=true\n`) + } + const e2e = repo.detect('e2e_filter') + expect(e2e.result.code, e2e.result.stderr).toBe(0) + const routes = readFileSync(e2e.output, 'utf8') + expect(routes).toContain('test_files=[]\n') + expect(routes).toContain('should_run=false\n') + }) + it.each(['addition', 'deletion', 'rename', 'type change'])( + 'keeps platform packages for an actual %s', + (change) => { + const repo = createMobileCiScopeRepository(change !== 'addition', helper) + if (change === 'addition') { + writeFileSync(repo.tool, 'export const revision = 1\n') + } else if (change === 'deletion') { + rmSync(repo.tool) + } else if (change === 'rename') { + repo.git('mv', helper, helper.replace('.ts', '-renamed.ts')) + } else { + rmSync(repo.tool) + symlinkSync('../../../config/scripts/pr-code-change-scope.mjs', repo.tool) + } + repo.commit() + const { result, output } = repo.detect() + expect(result.code, result.stderr).toBe(0) + expect(readFileSync(output, 'utf8')).toContain('package=true\n') + expect(readFileSync(output, 'utf8')).toContain('package_windows=true\n') + } + ) +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 13e5d6b1e5e..59bf0e191e4 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -499,14 +499,43 @@ export function needsMobileDependencies(changedFiles) { return changedFiles.length === 0 || changedFiles.some((file) => file.startsWith('mobile/')) } -export function classifyPrJobs(changedFiles, { allowMobileCiTools = false } = {}) { +export const DAEMON_INIT_TEST_SUPPORT = 'src/main/daemon/daemon-init-dependency-mocks.ts' + +const DAEMON_INIT_TEST_SCOPE = new Set([ + DAEMON_INIT_TEST_SUPPORT, + 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts', + 'config/scripts/process-host-build-dist.test.mjs', + 'src/main/codex/codex-app-server-connection.test.ts' +]) + +export function isDaemonInitTestSupportChange(files, modifiedOnly = false) { + return ( + modifiedOnly && + files.includes(DAEMON_INIT_TEST_SUPPORT) && + new Set(files).size === files.length && + files.every((file) => DAEMON_INIT_TEST_SCOPE.has(file)) + ) +} + +export function classifyPrJobs( + changedFiles, + { allowMobileCiTools = false, allowDaemonInitTestSupport = false } = {} +) { const emptyDiff = changedFiles.length === 0 const shouldRun = shouldRunPrChecks(changedFiles) const forceAll = emptyDiff || changedFiles.some(isGlobalForcePath) + const bundleFiles = isDaemonInitTestSupportChange(changedFiles, allowDaemonInitTestSupport) + ? changedFiles.filter((file) => file !== DAEMON_INIT_TEST_SUPPORT) + : changedFiles const jobs = Object.fromEntries( PR_CHECK_JOBS.map((job) => [ job, - shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles)) + shouldRun && + (forceAll || + ALWAYS_ON_CODE_JOBS.has(job) || + jobDetector(job)( + ['package', 'package_windows'].includes(job) ? bundleFiles : changedFiles + )) ]) ) // Why outside should_run: a mobile-only diff is desktop-irrelevant and skips every job above, @@ -634,7 +663,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) } const files = input.split(/\r?\n/).filter(Boolean) const classification = classifyPrJobs(files, { - allowMobileCiTools: process.argv.includes('--mobile-ci-tools') + allowMobileCiTools: process.argv.includes('--mobile-ci-tools'), + allowDaemonInitTestSupport: process.argv.includes('--daemon-init-test-support') }) for (const [name, value] of Object.entries(classification)) { process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`) diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 60c6c46248e..df9c9cc4a60 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -237,3 +237,25 @@ describe('PR E2E gate contract', () => { ) }) }) + +describe('Daemon mock modified-only source routing', () => { + const helper = 'src/main/daemon/daemon-init-dependency-mocks.ts' + const verified = { allowDaemonInitTestSupport: true } + it('omits the real D7 route only for the proven unit-support scope', () => { + expect(selectPrE2eSpecs([helper])).toContain('tests/e2e/orcad-serve-mode-switch.spec.ts') + expect(selectPrE2eSpecs([helper], undefined, verified)).toEqual([]) + const companion = 'src/main/codex/codex-app-server-connection.test.ts' + expect(selectPrE2eSpecs([helper, companion], undefined, verified)).toEqual( + selectPrE2eSpecs([companion]) + ) + }) + it.each([ + { files: [] }, + { files: [helper, 'src/main/daemon/daemon-init.ts'] }, + { files: [helper, 'package.json'] }, + { files: [helper, 'src/main/daemon/unknown-support.ts'] }, + { files: [helper, helper] } + ])('retains existing routes for an uncertain or mixed vector $files', ({ files }) => { + expect(selectPrE2eSpecs(files, undefined, verified)).toEqual(selectPrE2eSpecs(files)) + }) +}) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index d634a5f378c..fdfc3ae1237 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -1,6 +1,10 @@ import process from 'node:process' import { pathToFileURL } from 'node:url' -import { isUnitTestSupportSource } from './pr-code-change-scope.mjs' +import { + DAEMON_INIT_TEST_SUPPORT, + isDaemonInitTestSupportChange, + isUnitTestSupportSource +} from './pr-code-change-scope.mjs' const isProductSource = (file) => !/\.test\.tsx?$/.test(file) && !isUnitTestSupportSource(file) const isLayoutOracleSource = (file) => @@ -408,10 +412,17 @@ export const PR_E2E_SOURCE_ROUTES = [ } ] -export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) { +export function selectPrE2eSpecs( + changedPaths, + reportRoute = () => undefined, + { allowDaemonInitTestSupport = false } = {} +) { + const routePaths = isDaemonInitTestSupportChange(changedPaths, allowDaemonInitTestSupport) + ? changedPaths.filter((file) => file !== DAEMON_INIT_TEST_SUPPORT) + : changedPaths const specs = new Set(changedPaths.filter((file) => /^tests\/e2e\/.*\.spec\.ts$/.test(file))) for (const route of PR_E2E_SOURCE_ROUTES) { - const matchedFiles = changedPaths.filter(route.matches) + const matchedFiles = routePaths.filter(route.matches) if (matchedFiles.length === 0) { continue } @@ -432,9 +443,9 @@ export function hasNativeImeSourceChange(changedPaths) { ).some((route) => changedPaths.some(route.matches)) } -export function shouldRunReusablePrE2e(changedPaths) { +export function shouldRunReusablePrE2e(changedPaths, options = {}) { // Native IME has its own workflow. - return selectPrE2eSpecs(changedPaths).some( + return selectPrE2eSpecs(changedPaths, undefined, options).some( (spec) => spec !== 'tests/e2e/terminal-ibus-hangul-native.spec.ts' ) } @@ -453,14 +464,17 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) input += chunk } const changedPaths = input.split(/\r?\n/).filter(Boolean) + const options = { + allowDaemonInitTestSupport: process.argv.includes('--daemon-init-test-support') + } if (process.argv.includes('--reusable-workflow')) { - process.stdout.write(`${shouldRunReusablePrE2e(changedPaths)}\n`) + process.stdout.write(`${shouldRunReusablePrE2e(changedPaths, options)}\n`) } else if (process.argv.includes('--wsl-source')) { process.stdout.write(`${hasWslSourceChange(changedPaths)}\n`) } else if (process.argv.includes('--native-ime-source')) { process.stdout.write(`${hasNativeImeSourceChange(changedPaths)}\n`) } else { - const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message)) + const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message), options) process.stdout.write(`${JSON.stringify(specs)}\n`) } } diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 132154d98c7..49453beb171 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -206,6 +206,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = }) it('leaves a pointer the person stopped while its agent was starting stopped', async () => { + observationClock.start() await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() await host.close(COORDINATOR, 'evict') @@ -214,7 +215,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = await finishWorker(taskId) await vi.waitFor(() => expect(providerFaults.starts).toBe(before + 1), WAIT) // The person presses Stop while the agent is still starting. - const cancelled = await host.cancel( + const cancelling = host.cancel( { callerKey: 'test-surface' }, { envelope: { @@ -230,6 +231,8 @@ describe('a worker result reaches the structured chat that coordinates it', () = turnId: 'turn-x' } ) + await observationClock.observe(400) + const cancelled = await cancelling expect(cancelled).toMatchObject({ ok: true }) providerFaults.startDelayMs = 0 // A fixed window, not a poll: a re-point would start the agent again in it. From 6dafde48e7ea2b5fbf7e6940e9114c0958213e92 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 14:56:50 -0700 Subject: [PATCH 06/28] refactor(renderer): no client SSH operation arms in project creation or native chat (#27593) * refactor(renderer): no client SSH operation arms in project creation or native chat * fix(add-project): show the start step when the remote step loses its server --------- Co-authored-by: m4air --- config/scripts/locale-ko-key-overrides.json | 9 - .../NativeChatComposer.paste-remount.test.tsx | 31 +-- .../native-chat-attachment-upload.test.ts | 142 +------------- .../native-chat-attachment-upload.ts | 90 +-------- .../native-chat-clipboard-image-save.ts | 14 +- .../native-chat-composer-drop-scope.test.tsx | 50 ++--- ...ative-chat-composer-ime-file-drop.test.tsx | 20 +- ...chat-composer-workspace-file-drop.test.tsx | 69 +------ ...-external-attachments-destination.test.tsx | 54 +----- .../native-chat-image-runtime-context.test.ts | 12 ++ .../native-chat-image-runtime-context.ts | 7 +- ...ative-chat-resolved-path-ownership.test.ts | 28 +-- .../native-chat-resolved-path-ownership.ts | 14 +- .../use-native-chat-composer-paste.test.tsx | 91 +++------ .../use-native-chat-composer-paste.ts | 7 +- ...-native-chat-external-attachments.test.tsx | 172 +--------------- .../use-native-chat-external-attachments.ts | 21 +- ...e-native-chat-menu-paste-lifetime.test.tsx | 148 ++++++-------- .../use-native-chat-paste-lifetime.test.tsx | 14 +- .../use-native-chat-paste-lifetime.ts | 6 +- .../use-native-chat-workspace-file-drop.ts | 10 +- .../file-explorer-add-project-action.test.ts | 23 +-- .../file-explorer-add-project-action.ts | 12 +- .../AddProjectFromFolderDialog.test.tsx | 68 +------ .../sidebar/AddProjectFromFolderDialog.tsx | 48 +---- .../components/sidebar/AddRepoCloneStep.tsx | 39 ++-- .../components/sidebar/AddRepoCreateStep.tsx | 12 +- .../src/components/sidebar/AddRepoDialog.tsx | 31 +-- .../sidebar/AddRepoDialogStepContent.test.tsx | 118 +---------- .../sidebar/AddRepoDialogStepContent.tsx | 39 +--- .../components/sidebar/AddRepoRemoteStep.tsx | 176 +++++------------ .../sidebar/AddRepoStartSteps.test.tsx | 118 ++--------- .../components/sidebar/AddRepoStartSteps.tsx | 11 +- .../AddRepoSteps.default-checkout.test.ts | 156 +++------------ .../src/components/sidebar/AddRepoSteps.tsx | 183 ++++-------------- .../sidebar/CreateProjectLocationField.tsx | 4 +- .../sidebar/NonGitFolderDialog.test.tsx | 96 +++++---- .../components/sidebar/NonGitFolderDialog.tsx | 38 ++-- .../sidebar/add-repo-browse-authority.test.ts | 19 +- .../sidebar/add-repo-browse-authority.ts | 5 - .../sidebar/add-repo-host-availability.ts | 3 +- .../sidebar/add-repo-local-start-actions.ts | 74 ++----- .../sidebar/add-repo-store-upsert.ts | 8 +- .../components/sidebar/clone-defaults.test.ts | 13 -- .../src/components/sidebar/clone-defaults.ts | 4 +- .../use-add-repo-host-selection.test.ts | 22 ++- .../sidebar/use-add-repo-host-selection.ts | 10 +- .../use-add-repo-hosted-controller.test.ts | 22 +-- .../sidebar/use-add-repo-hosted-controller.ts | 17 +- .../use-add-repo-remote-nested-scan.ts | 62 ------ .../sidebar/useAddRepoCloneFlow.test.ts | 68 +------ .../components/sidebar/useAddRepoCloneFlow.ts | 40 ++-- .../useAddRepoNestedReviewController.ts | 10 +- .../sidebar/useCreateProjectDefaults.test.ts | 13 -- .../sidebar/useCreateProjectDefaults.ts | 31 ++- .../useCreateRepo.default-checkout.test.ts | 37 +--- .../src/components/sidebar/useCreateRepo.ts | 43 ++-- src/renderer/src/i18n/locales/en.json | 18 +- src/renderer/src/i18n/locales/es.json | 20 +- src/renderer/src/i18n/locales/fr.json | 18 +- src/renderer/src/i18n/locales/ja.json | 20 +- src/renderer/src/i18n/locales/ko.json | 20 +- src/renderer/src/i18n/locales/zh.json | 20 +- .../src/runtime/runtime-client-target.ts | 5 +- .../projects/project-host-setup-actions.ts | 60 +++--- src/renderer/src/store/slices/repos.test.ts | 51 +---- 66 files changed, 579 insertions(+), 2335 deletions(-) delete mode 100644 src/renderer/src/components/sidebar/use-add-repo-remote-nested-scan.ts diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index b7ec782b42b..b1195ea2064 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -4403,15 +4403,6 @@ "auto.components.sidebar.add.repo.local.start.actions.runtimeBrowseDescription": { "ko": "이 호스트의 기존 Git 리포지토리 또는 폴더" }, - "auto.components.sidebar.add.repo.local.start.actions.sshBrowseDescription": { - "ko": "이 SSH 호스트의 기존 Git 리포지토리 또는 폴더" - }, - "auto.components.sidebar.add.repo.local.start.actions.sshBrowseTitle": { - "ko": "SSH 호스트에서 프로젝트 열기" - }, - "auto.components.sidebar.add.repo.local.start.actions.sshCreateUnavailable": { - "ko": "아직 SSH 호스트에서는 사용할 수 없습니다." - }, "auto.components.sidebar.index.b826a98b6f": { "ko": "사용 중" }, diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx index 6357b604916..c9bb62b6b26 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx @@ -304,25 +304,10 @@ it('keeps a local image paste after the real composer closes and reopens', async await waitFor(() => expect(mocks.state.fieldProps?.imageAttachments?.[0]?.pending).toBeFalsy()) }) -it.each([ - { kind: 'local', acceptsImages: false }, - { kind: 'ssh', acceptsImages: false }, - { kind: 'local', acceptsImages: true }, - { kind: 'ssh', acceptsImages: true } -] as const)( - 'can remove a stalled $kind paste after reopen with acceptsImages=$acceptsImages', - async ({ kind, acceptsImages }) => { - mocks.state.owner = - kind === 'local' - ? { kind: 'local' } - : { - kind: 'ssh', - connectionId: 'ssh-1', - worktreePath: '/remote/folder', - expectedExecutionHostId: 'ssh:ssh-1', - expectedSshTargetId: 'ssh-1', - expectedSshConnectionGeneration: 1 - } +it.each([false, true])( + 'can remove a stalled paste after reopen with acceptsImages=%s', + async (acceptsImages) => { + mocks.state.owner = { kind: 'local' } vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) const finishUpload = holdUpload() writeNativeChatDraftCache(PANE, 'look at this') @@ -338,13 +323,7 @@ it.each([ fireEvent.click(screen.getByRole('button', { name: 'Remove attachment' })) ) expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(false) - await act(async () => - finishUpload( - kind === 'local' - ? '/local/native-chat-pastes/orca-paste-1-image.png' - : '/tmp/orca-paste-1-image.png' - ) - ) + await act(async () => finishUpload('/local/native-chat-pastes/orca-paste-1-image.png')) expect(readNativeChatAttachmentCache(PANE)).toEqual([]) expect(readNativeChatDraftCache(PANE)).toBe('look at this') expect(mocks.state.fieldProps?.imageAttachments).toEqual([]) diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts index e94dbad20d5..94d9ea6816e 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts @@ -5,25 +5,11 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { getDefaultSettings } from '../../../../shared/constants' const mocks = vi.hoisted(() => ({ - toastLoading: vi.fn(() => 'toast-1'), - toastDismiss: vi.fn(), - toastError: vi.fn(), - toastMessage: vi.fn(), - resolveDroppedPathsForAgent: vi.fn(), callRuntimeRpc: vi.fn() })) vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) -vi.mock('sonner', () => ({ - toast: { - loading: mocks.toastLoading, - dismiss: mocks.toastDismiss, - error: mocks.toastError, - message: mocks.toastMessage - } -})) - vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string, values?: Record) => fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => String(values?.[name])) @@ -34,8 +20,7 @@ import { prepareNativeChatSessionAttachmentUpload, resolveNativeChatAttachmentOwner, resolveNativeChatAttachmentOwnerForWorktree, - resolveNativeChatRuntimeSessionAttachmentOwner, - uploadNativeChatAttachmentPaths + resolveNativeChatRuntimeSessionAttachmentOwner } from './native-chat-attachment-upload' import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' import { AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' @@ -95,42 +80,15 @@ describe('resolveNativeChatAttachmentOwner', () => { }) }) - it('resolves a structured SSH owner directly from its worktree', () => { - expect( - resolveNativeChatAttachmentOwnerForWorktree( - state({ - repos: [{ id: 'repo', connectionId: 'conn-1' }] as never, - sshConnectionStates: new Map([['conn-1', { connectionGeneration: 4 } as never]]) - }), - 'wt-1' - ) - ).toMatchObject({ - kind: 'ssh', - connectionId: 'conn-1', - worktreePath: '/repo/worktree' + it('refuses client files for an SSH-owned worktree, whatever its connection state', () => { + const sshState = state({ repos: [repoFixture({ connectionId: 'conn-1' })] }) + expect(resolveNativeChatAttachmentOwner(sshState, 'tab-1')).toEqual({ kind: 'remote' }) + expect(resolveNativeChatAttachmentOwnerForWorktree(sshState, 'wt-1')).toEqual({ + kind: 'remote' }) }) - it('resolves an SSH repo worktree to ssh with the worktree path', () => { - expect( - resolveNativeChatAttachmentOwner( - state({ - repos: [{ id: 'repo', connectionId: 'conn-1' }] as never, - sshConnectionStates: new Map([['conn-1', { connectionGeneration: 4 } as never]]) - }), - 'tab-1' - ) - ).toEqual({ - kind: 'ssh', - connectionId: 'conn-1', - worktreePath: '/repo/worktree', - expectedExecutionHostId: 'ssh:conn-1', - expectedSshTargetId: 'conn-1', - expectedSshConnectionGeneration: 4 - }) - }) - - it('resolves a runtime-owned repo to runtime', () => { + it('refuses client files for a runtime-owned repo', () => { expect( resolveNativeChatAttachmentOwner( state({ @@ -138,7 +96,7 @@ describe('resolveNativeChatAttachmentOwner', () => { }), 'tab-1' ) - ).toEqual({ kind: 'runtime' }) + ).toEqual({ kind: 'remote' }) }) it('keeps a recorded local workspace local when a runtime is focused', () => { @@ -161,90 +119,6 @@ describe('resolveNativeChatAttachmentOwner', () => { kind: 'not-ready' }) }) - - it('reports not-ready instead of throwing when the SSH generation is gone', () => { - expect( - resolveNativeChatAttachmentOwner( - state({ - repos: [{ id: 'repo', connectionId: 'conn-1' }] as never, - sshConnectionStates: new Map() - }), - 'tab-1' - ) - ).toEqual({ kind: 'not-ready' }) - }) - - it('reports not-ready when an SSH worktree has no known path yet', () => { - expect( - resolveNativeChatAttachmentOwner( - state({ - repos: [{ id: 'repo', connectionId: 'conn-1' }] as never, - worktreesByRepo: { repo: [{ id: 'wt-1', repoId: 'repo' } as never] }, - tabsByWorktree: { 'wt-1': [terminalTab()] } - }), - 'tab-1' - ) - ).toEqual({ kind: 'not-ready' }) - }) -}) - -describe('uploadNativeChatAttachmentPaths', () => { - const owner = { - kind: 'ssh' as const, - connectionId: 'conn-1', - worktreePath: '/remote/worktree', - expectedExecutionHostId: 'ssh:conn-1' as const, - expectedSshTargetId: 'conn-1', - expectedSshConnectionGeneration: 4 - } - - beforeEach(() => { - vi.clearAllMocks() - vi.stubGlobal('window', { - api: { fs: { resolveDroppedPathsForAgent: mocks.resolveDroppedPathsForAgent } } - }) - }) - - it('uploads through the terminal drop resolver and returns remote paths', async () => { - mocks.resolveDroppedPathsForAgent.mockResolvedValue({ - resolvedPaths: ['/remote/worktree/.orca/drops/a.txt'], - skipped: [], - failed: [] - }) - await expect(uploadNativeChatAttachmentPaths(['/local/a.txt'], owner)).resolves.toEqual([ - '/remote/worktree/.orca/drops/a.txt' - ]) - expect(mocks.resolveDroppedPathsForAgent).toHaveBeenCalledWith({ - paths: ['/local/a.txt'], - worktreePath: '/remote/worktree', - connectionId: 'conn-1', - expectedExecutionHostId: 'ssh:conn-1', - expectedSshTargetId: 'conn-1', - expectedSshConnectionGeneration: 4 - }) - expect(mocks.toastLoading).toHaveBeenCalledTimes(1) - expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1') - }) - - it('surfaces per-file skips and failures through the shared drop toasts', async () => { - mocks.resolveDroppedPathsForAgent.mockResolvedValue({ - resolvedPaths: [], - skipped: [{ sourcePath: '/local/link', reason: 'symlink' }], - failed: [{ sourcePath: '/local/b.txt', reason: 'boom' }] - }) - await expect( - uploadNativeChatAttachmentPaths(['/local/link', '/local/b.txt'], owner) - ).resolves.toEqual([]) - expect(mocks.toastMessage).toHaveBeenCalledTimes(1) - expect(mocks.toastError).toHaveBeenCalledTimes(1) - }) - - it('returns null and reports when the upload IPC fails', async () => { - mocks.resolveDroppedPathsForAgent.mockRejectedValue(new Error('sftp down')) - await expect(uploadNativeChatAttachmentPaths(['/local/a.txt'], owner)).resolves.toBeNull() - expect(mocks.toastError).toHaveBeenCalledTimes(1) - expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1') - }) }) describe('a structured chat on a paired server', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index 0ca498fa617..68a4351006c 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -1,16 +1,12 @@ // Host-aware resolution for composer attachments (STA-1465). The composer's // attach surfaces (file drop, file picker, image paste) receive client-local -// paths, but an SSH worktree's agent runs on the remote host — local paths must -// be uploaded first, exactly like terminal drops (docs/terminal-drop-ssh.md). A -// structured chat on a paired server uploads into that server's attachment store. +// paths. A structured chat on a paired server uploads into that server's +// attachment store; any other remote chat refuses client files. -import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -import { extractIpcErrorMessage } from '@/lib/ipc-error' import { findKnownWorktreeById } from '@/store/slices/worktrees/listing/detected-worktree-meta' import { getConnectionExecutionHostId, - parseExecutionHostId, toRuntimeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' @@ -30,19 +26,8 @@ import type { AgentSessionAttachmentUploadTarget } from '../../../../shared/agent-session-attachments' import type { AppState } from '@/store/types' -import { reportTerminalDropUploadSkipsAndFailures } from '../terminal-pane/terminal-drop-upload-report' import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop' import { findTerminalTabWorktreeId } from './native-chat-file-link' -import { - captureDirectSshMutationExpectation, - type DirectSshMutationExpectation -} from '@/lib/ssh-mutation-expectation' - -export type NativeChatSshAttachmentOwner = DirectSshMutationExpectation & { - kind: 'ssh' - connectionId: string - worktreePath: string -} /** A structured chat on a paired server: files upload into that server's attachment store, which * checks every stored path a message names when it admits the message. */ @@ -55,11 +40,11 @@ export type NativeChatRuntimeSessionAttachmentOwner = { export type NativeChatAttachmentOwner = | { kind: 'local' } - | NativeChatSshAttachmentOwner | NativeChatRuntimeSessionAttachmentOwner - /** A terminal-backed chat on a paired server: its agent may run on that server's SSH or WSL - * host, which the server's store cannot reach, so it keeps refusing client files. */ - | { kind: 'runtime' } + /** Refuses client files: a terminal-backed chat on a paired server (its agent may run on that + * server's SSH or WSL host, which the server's store cannot reach), or an `ssh:` host, which + * only its paired server serves. */ + | { kind: 'remote' } /** Store not hydrated / worktree unknown. Callers must not attach local * paths in this window — the worktree may turn out to be remote, and the * agent would silently receive paths it cannot read (see #6648). */ @@ -118,35 +103,10 @@ export function resolveNativeChatAttachmentOwnerForWorktree( if (!hostId) { return { kind: 'not-ready' } } - if (parseExecutionHostId(hostId)?.kind === 'runtime') { - return { kind: 'runtime' } - } if (hostId === 'local') { return { kind: 'local' } } - const connectionId = getConnectionIdFromState(state, worktreeId) - if (connectionId === undefined) { - return { kind: 'not-ready' } - } - if (connectionId === null) { - return { kind: 'not-ready' } - } - const worktreePath = findKnownWorktreeById(state, worktreeId, hostId)?.path - if (!worktreePath) { - return { kind: 'not-ready' } - } - try { - return { - kind: 'ssh', - connectionId, - worktreePath, - ...captureDirectSshMutationExpectation(state, connectionId) - } - } catch { - // The connection's generation is gone (disconnect mid-attach). That is an - // unknown owner, not a reason to throw out of the drop/IME handler. - return { kind: 'not-ready' } - } + return { kind: 'remote' } } /** The chat behind a structured composer, as the attachment owner needs it. */ @@ -220,42 +180,6 @@ export function nativeChatTooManyAttachmentsNotice(): string { export { nativeChatLocalAttachmentUnsupportedNotice } from './native-chat-composer-target' -/** - * Upload client-local paths into `${worktreePath}/.orca/drops` on the SSH - * remote and return the remote paths the agent can read (input order - * preserved). Returns null when the upload IPC itself failed; per-file - * skips/failures surface through the shared drop toasts. - */ -export async function uploadNativeChatAttachmentPaths( - paths: string[], - owner: NativeChatSshAttachmentOwner -): Promise { - const pending = toast.loading( - translate( - 'components.native-chat.composer.uploadingAttachments', - 'Uploading {{value0}} file(s) to remote…', - { value0: paths.length } - ) - ) - try { - const { resolvedPaths, skipped, failed } = await window.api.fs.resolveDroppedPathsForAgent({ - paths, - worktreePath: owner.worktreePath, - connectionId: owner.connectionId, - expectedExecutionHostId: owner.expectedExecutionHostId, - expectedSshTargetId: owner.expectedSshTargetId, - expectedSshConnectionGeneration: owner.expectedSshConnectionGeneration - }) - reportTerminalDropUploadSkipsAndFailures(skipped, failed) - return resolvedPaths - } catch (err) { - toast.error(extractIpcErrorMessage(err, 'Failed to upload files.')) - return null - } finally { - toast.dismiss(pending) - } -} - /** * Ask the chat's server, right before uploading, whether it keeps chat attachments, and which * server process will receive the bytes. An older server gets the update notice instead of an diff --git a/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts b/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts index d0fd3b9268d..1e65eb59caa 100644 --- a/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts +++ b/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts @@ -6,20 +6,17 @@ import { type NativeChatAttachmentOwner } from './native-chat-attachment-upload' -type ClipboardImageOwner = Extract< - NativeChatAttachmentOwner, - { kind: 'local' | 'ssh' | 'runtime-session' } -> +type ClipboardImageOwner = Extract /** Owners whose attachment path is a file this client can write right now. */ export function ownerAcceptsClipboardImage( owner: NativeChatAttachmentOwner ): owner is ClipboardImageOwner { - return owner.kind === 'local' || owner.kind === 'ssh' || owner.kind === 'runtime-session' + return owner.kind === 'local' || owner.kind === 'runtime-session' } -/** Where the clipboard image is saved: this machine, the SSH host, or the chat's store on its - * paired server, after asking that server whether it keeps one. */ +/** Where the clipboard image is saved: this machine, or the chat's store on its paired server, + * after asking that server whether it keeps one. */ async function clipboardImageSaveArgs( owner: ClipboardImageOwner ): Promise< @@ -29,9 +26,6 @@ async function clipboardImageSaveArgs( if (owner.kind === 'local') { return { ok: true, args: { forNativeChatDraft: true } } } - if (owner.kind === 'ssh') { - return { ok: true, args: { connectionId: owner.connectionId } } - } const prepared = await prepareNativeChatSessionAttachmentUpload(owner) if (!prepared.ok) { return { ...prepared, cause: 'serverTooOld' } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index a4a2ca227ce..a769887d443 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -25,20 +25,21 @@ const electron = vi.hoisted(() => ({ getPathForFile: vi.fn((file: File) => `/repro/${file.name}`) })) -const intake = vi.hoisted(() => ({ - owner: { kind: 'local' } as { kind: string; connectionId?: string }, - stat: vi.fn(), - readFile: vi.fn(), - upload: vi.fn(), - prepare: vi.fn(async ({ paths }: { paths: string[] }) => ({ paths, failures: [] })), - pick: vi.fn() -})) +const intake = vi.hoisted(() => { + const owner: { kind: string } = { kind: 'local' } + return { + owner, + stat: vi.fn(), + readFile: vi.fn(), + prepare: vi.fn(async ({ paths }: { paths: string[] }) => ({ paths, failures: [] })), + pick: vi.fn() + } +}) vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ tabsByWorktree: {} }) } })) // Keeps the real notice strings so the silent-failure guards assert what users see. vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({ ...(await importOriginal()), - resolveNativeChatAttachmentOwner: () => intake.owner, - uploadNativeChatAttachmentPaths: intake.upload + resolveNativeChatAttachmentOwner: () => intake.owner })) vi.mock('electron', () => ({ @@ -182,7 +183,6 @@ describe('native chat composer drop scoping', () => { electron.getPathForFile.mockReset().mockImplementation((file: File) => `/repro/${file.name}`) intake.stat.mockReset().mockResolvedValue(undefined) intake.readFile.mockReset().mockResolvedValue({ content: '', isBinary: false }) - intake.upload.mockReset() intake.prepare.mockReset().mockImplementation(async ({ paths }) => ({ paths, failures: [] })) intake.pick.mockReset() vi.stubGlobal('IntersectionObserver', undefined) @@ -232,10 +232,9 @@ describe('native chat composer drop scoping', () => { intake.prepare.mockImplementationOnce(() => gate.promise) const view = render() await dropTwoImages(view.container.querySelector('.ProseMirror')!) - intake.owner = { kind: 'ssh', connectionId: 'other-host' } + intake.owner = { kind: 'remote' } await act(async () => gate.resolve({ paths: ['/prepared/a.png'], failures: [] })) expect(intake.stat).not.toHaveBeenCalled() - expect(intake.upload).not.toHaveBeenCalled() expect(readNativeChatAttachmentCache('chat-a')).toEqual([]) }) it('refuses preparation captured for a draft that changed in the same pane', async () => { @@ -281,7 +280,7 @@ describe('native chat composer drop scoping', () => { it('notices an OS drop whose owner changes while checking the files', async () => { intake.stat.mockImplementation(async () => { - intake.owner = { kind: 'ssh', connectionId: 'conn-1' } + intake.owner = { kind: 'remote' } }) const view = render() @@ -415,33 +414,10 @@ describe('native chat composer drop scoping', () => { [{ filePath: '/repro/second.png', access: { kind: 'user-file' } }] ]) expect(intake.readFile).toHaveBeenCalledTimes(2) - expect(intake.upload).not.toHaveBeenCalled() expect(readNativeChatAttachmentCache('chat-a')).toHaveLength(2) expect(readNativeChatAttachmentCache('chat-b')).toEqual([]) await expect(intake.readFile({ filePath: '/repro/sibling.png' })).rejects.toThrow( 'Access denied' ) }) - - it('uploads once for the SSH drop owner without checking remote paths locally', async () => { - intake.owner = { kind: 'ssh', connectionId: 'conn-1' } - intake.upload.mockResolvedValue(['/remote/first.png', '/remote/second.png']) - const view = render( - <> - -