diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 92e33a18d19..c23aa0cb679 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -55,9 +55,13 @@ jobs: env: VITE_EXPOSE_STORE: 'true' run: | - npx electron-vite build --mode e2e - pnpm run build:web-from-renderer - pnpm run build:relay + status=0 + pnpm run build:relay & + relay_pid=$! + npx electron-vite build --mode e2e || status=1 + pnpm run build:web-from-renderer || status=1 + wait "$relay_pid" || status=1 + exit "$status" - name: Upload E2E build output uses: actions/upload-artifact@v7 @@ -71,9 +75,29 @@ jobs: retention-days: 1 if-no-files-found: error + # Build Electron-native dependencies once per workflow. Consumer shards restore + # this immutable cache instead of compiling the same ABI concurrently. + prepare-native-cache: + name: prepare Electron native cache + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.ref }} + + - name: Install native build tools + run: sudo apt-get update && sudo apt-get install -y build-essential python3 + + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: electron + e2e: name: e2e ${{ matrix.shard_name }} - needs: build + needs: [build, prepare-native-cache] if: inputs.test_files == '' runs-on: ubuntu-latest timeout-minutes: 30 @@ -159,7 +183,7 @@ jobs: changed-e2e: name: changed e2e specs - needs: build + needs: [build, prepare-native-cache] if: inputs.test_files != '' runs-on: ubuntu-latest # Why 45: pr.yml now maps SSH source edits onto Docker-backed specs, so this lane can @@ -227,8 +251,7 @@ jobs: ssh-docker-watcher-isolation: name: ssh docker watcher isolation - needs: build - # Why ssh_source_changed first: this lane used to trigger on SSH source only as a side + needs: [build, prepare-native-cache] # effect of one route listing a startup-readiness spec — pruning that spec would have # silently retired the whole lane. The signal is now derived from the SSH routes directly. # The two spec clauses stay for their honest purpose: changed-e2e hands these specs to this diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index 24f138723c9..2478087ba8a 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -8,6 +8,7 @@ const EXPECTED_MATRIX = { '.github/workflows/e2e.yml#build': { contents: 'read' }, '.github/workflows/e2e.yml#changed-e2e': { contents: 'read' }, '.github/workflows/e2e.yml#e2e': { contents: 'read' }, + '.github/workflows/e2e.yml#prepare-native-cache': { contents: 'read' }, '.github/workflows/e2e.yml#ssh-docker-watcher-isolation': { contents: 'read' }, '.github/workflows/homebrew-bump.yml#bump-cask': { contents: 'read' }, '.github/workflows/release-mac-build.yml#build-mac': { contents: 'write' }, diff --git a/config/scripts/release-e2e-dispatch-contract.test.mjs b/config/scripts/release-e2e-dispatch-contract.test.mjs index d48c7b3fa30..5fd4503d4e3 100644 --- a/config/scripts/release-e2e-dispatch-contract.test.mjs +++ b/config/scripts/release-e2e-dispatch-contract.test.mjs @@ -46,6 +46,28 @@ describe('release E2E dispatch contract', () => { expect(refInput.required).toBe(false) }) + it('overlaps the relay bundle with the Electron build', () => { + const buildStep = e2eWorkflow.jobs.build.steps.find((step) => step.name === 'Build E2E outputs') + + expect(buildStep.run).toContain('pnpm run build:relay &') + expect(buildStep.run).toContain('relay_pid=$!') + expect(buildStep.run).toContain('wait "$relay_pid"') + expect(buildStep.run).toContain('pnpm run build:web-from-renderer') + }) + + it('primes the Electron native cache before every E2E consumer', () => { + const primer = e2eWorkflow.jobs['prepare-native-cache'] + expect(primer.steps).toBeDefined() + expect( + primer.steps.find((step) => step.uses === './.github/actions/install-node-dependencies').with + ).toEqual({ + 'native-runtime': 'electron' + }) + for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) { + expect(e2eWorkflow.jobs[jobName].needs, jobName).toEqual(['build', 'prepare-native-cache']) + } + }) + it('includes the paired-runtime web client in the shared E2E build artifact', () => { const buildStep = e2eWorkflow.jobs.build.steps.find((step) => step.name === 'Build E2E outputs') @@ -71,7 +93,7 @@ describe('release E2E dispatch contract', () => { const downloadStep = job.steps.find((step) => step.name === 'Download E2E build output') const runStep = job.steps.find((step) => step.name === runStepName) - expect(job.needs).toBe('build') + expect(job.needs).toEqual(['build', 'prepare-native-cache']) expect(downloadStep.with.name).toBe('e2e-build-out') expect(downloadStep.with.path).toBe('out/') expect(runStep.run).toContain('ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay"') diff --git a/src/main/browser/popup-origin-bar-window.test.ts b/src/main/browser/popup-origin-bar-window.test.ts index 3e816e74b07..fedcd4dfdd0 100644 --- a/src/main/browser/popup-origin-bar-window.test.ts +++ b/src/main/browser/popup-origin-bar-window.test.ts @@ -285,6 +285,7 @@ describe('openPopupWithOriginBar', () => { it('closes the window when the popup content is destroyed, without re-closing the contents', () => { const adopted = createFakeWebContents() openPopupWithOriginBar({ webContents: adopted as never }, 'https://example.com/') + const { bar } = lastViews() adopted.emit('destroyed') @@ -292,6 +293,7 @@ describe('openPopupWithOriginBar', () => { // The window's closed handler must not call close() on already-destroyed // contents — that throws in real Electron. expect(adopted.close).not.toHaveBeenCalled() + expect(bar.webContents.close).toHaveBeenCalledTimes(1) }) it('re-asserts the origin when the popup finishes loading', () => { @@ -326,11 +328,54 @@ describe('openPopupWithOriginBar', () => { const adopted = createFakeWebContents() const onClosed = vi.fn() const popup = openPopupWithOriginBar({ webContents: adopted as never }, 'https://example.com/') + const { bar } = lastViews() popup.onClosed(onClosed) popup.close() expect(adopted.close).toHaveBeenCalledTimes(1) + expect(bar.webContents.close).toHaveBeenCalledTimes(1) expect(onClosed).toHaveBeenCalledTimes(1) }) + + it('releases every origin bar across repeated popup lifecycles', () => { + const cycleCount = 25 + const originBars: FakeWebContents[] = [] + + for (let cycle = 0; cycle < cycleCount; cycle += 1) { + const popup = openPopupWithOriginBar({}, `https://example.com/${cycle}`) + originBars.push(lastViews().bar.webContents) + popup.close() + } + + expect(originBars.filter((contents) => contents.close.mock.calls.length === 1)).toHaveLength( + cycleCount + ) + }) + + it('tolerates an origin bar destroyed before its window closes', () => { + const adopted = createFakeWebContents() + const onClosed = vi.fn() + const popup = openPopupWithOriginBar({ webContents: adopted as never }, 'https://example.com/') + popup.onClosed(onClosed) + const { bar } = lastViews() + const barWebContents = bar.webContents + barWebContents.emit('destroyed') + Object.defineProperty(bar, 'webContents', { get: () => undefined }) + + expect(() => popup.close()).not.toThrow() + expect(adopted.close).toHaveBeenCalledTimes(1) + expect(barWebContents.close).not.toHaveBeenCalled() + expect(onClosed).toHaveBeenCalledTimes(1) + }) + + it('closes both child contents when popup preparation fails', () => { + const adopted = createFakeWebContents() + + openPopupWithOriginBar({ webContents: adopted as never }, 'https://example.com/', () => false) + + expect(adopted.close).toHaveBeenCalledTimes(1) + expect(lastViews().bar.webContents.close).toHaveBeenCalledTimes(1) + expect(lastWindow().close).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/main/browser/popup-origin-bar-window.ts b/src/main/browser/popup-origin-bar-window.ts index 376c318d1ae..cfbe3ef3d3b 100644 --- a/src/main/browser/popup-origin-bar-window.ts +++ b/src/main/browser/popup-origin-bar-window.ts @@ -92,7 +92,8 @@ function clampPopupContentSize(options: PopupChildWindowOptions): { // caller's popup bookkeeping cannot leak an entry for a window that no longer exists. function closeUnpreparedPopup( window: BaseWindow, - contentWebContents: Electron.WebContents + contentWebContents: Electron.WebContents, + originBarWebContents: Electron.WebContents ): PopupOriginBarWindow { const closedListeners: (() => void)[] = [] let closed = false @@ -105,6 +106,9 @@ function closeUnpreparedPopup( if (!contentWebContents.isDestroyed()) { contentWebContents.close() } + if (!originBarWebContents.isDestroyed()) { + originBarWebContents.close() + } if (!window.isDestroyed()) { window.close() } @@ -159,6 +163,7 @@ export function openPopupWithOriginBar( const originBarView = new WebContentsView({ webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true } }) + const originBarWebContents = originBarView.webContents const contentView = new WebContentsView({ // Why: Electron rejects an explicitly undefined webContents; omitting it // lets WebContentsView create contents for Cmd/Ctrl-click popups. @@ -188,7 +193,7 @@ export function openPopupWithOriginBar( const contentWebContents = contentView.webContents if (prepareContent && !prepareContent(contentWebContents)) { - return closeUnpreparedPopup(window, contentWebContents) + return closeUnpreparedPopup(window, contentWebContents, originBarWebContents) } let currentUrl = initialUrl const renderOrigin = (): void => { @@ -202,15 +207,15 @@ export function openPopupWithOriginBar( } // Why: textContent + JSON encoding — the URL is attacker-controlled and // must never be interpolated into the bar's markup. - void originBarView.webContents + void originBarWebContents .executeJavaScript( `document.body.classList.toggle('insecure', ${insecure ? 'true' : 'false'});` + `document.getElementById('origin').textContent = ${JSON.stringify(label)};` ) .catch(() => {}) } - originBarView.webContents.once('did-finish-load', renderOrigin) - void originBarView.webContents.loadURL( + originBarWebContents.once('did-finish-load', renderOrigin) + void originBarWebContents.loadURL( `data:text/html;charset=utf-8,${encodeURIComponent(ORIGIN_BAR_HTML)}` ) @@ -253,6 +258,10 @@ export function openPopupWithOriginBar( // pages often notify the opener from unload. contentWebContents.close() } + // Why: BaseWindow does not destroy child WebContentsView contents when it closes. + if (!originBarWebContents.isDestroyed()) { + originBarWebContents.close() + } for (const listener of closedListeners) { listener() } diff --git a/src/main/crash-reporting/expected-teardown-state.test.ts b/src/main/crash-reporting/expected-teardown-state.test.ts index 17a296bb582..ccfc729092b 100644 --- a/src/main/crash-reporting/expected-teardown-state.test.ts +++ b/src/main/crash-reporting/expected-teardown-state.test.ts @@ -13,6 +13,7 @@ import { function shouldRecordKilledRenderer(expectedTeardown: 'none' | 'renderer-reload' | 'app-shutdown') { return shouldRecordProcessGoneCrash({ + platform: 'win32', source: 'renderer', processType: 'renderer', reason: 'killed', diff --git a/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts index 3df9849505d..aec22e2e446 100644 --- a/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts +++ b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts @@ -66,7 +66,13 @@ const FIELD_GPU_EVENT = { describe('1.4.190 win32 GPU-child crash cluster', () => { it('does not let process_gone_suppressed gate the fallback candidate check', () => { // The GPU death is suppressed as recoverable churn (no user-facing report)... - expect(shouldRecordProcessGoneCrash({ ...FIELD_GPU_EVENT, exitCode: -2147483645 })).toBe(false) + expect( + shouldRecordProcessGoneCrash({ + ...FIELD_GPU_EVENT, + platform: 'win32', + exitCode: -2147483645 + }) + ).toBe(false) // ...but the fallback path reads the raw child-process-gone event, so the // suppression cannot hide a broken driver from recovery. expect( diff --git a/src/main/crash-reporting/process-gone-classification.test.ts b/src/main/crash-reporting/process-gone-classification.test.ts index 78522b76617..c26d3385406 100644 --- a/src/main/crash-reporting/process-gone-classification.test.ts +++ b/src/main/crash-reporting/process-gone-classification.test.ts @@ -1,9 +1,20 @@ import { describe, expect, it } from 'vitest' import { - shouldRecordProcessGoneCrash, + shouldRecordProcessGoneCrash as classifyProcessGoneCrash, shouldRecoverRendererAfterProcessGone } from './process-gone-classification' +type ProcessGoneClassificationInput = Parameters[0] + +function shouldRecordProcessGoneCrash({ + platform = 'darwin', + ...event +}: Omit & { + platform?: NodeJS.Platform +}): boolean { + return classifyProcessGoneCrash({ platform, ...event }) +} + describe('shouldRecordProcessGoneCrash', () => { it('suppresses killed process exits during expected lifecycle teardown', () => { expect( @@ -258,15 +269,36 @@ describe('shouldRecordProcessGoneCrash', () => { ).toBe(true) }) - it('still records renderer kills from the recent Linux crash-report cluster', () => { + it('suppresses namespace-encoded SIGTERM kills from the recent Linux cluster', () => { expect( shouldRecordProcessGoneCrash({ + platform: 'linux', source: 'renderer', processType: 'renderer', reason: 'killed', exitCode: 61696, expectedTeardown: 'none' }) + ).toBe(false) + }) + + it('keeps wait status 61696 reportable outside the exact Linux killed boundary', () => { + const processGone = { + source: 'renderer' as const, + processType: 'renderer', + reason: 'killed', + exitCode: 61696, + expectedTeardown: 'none' as const + } + + expect(shouldRecordProcessGoneCrash({ ...processGone, platform: 'darwin' })).toBe(true) + expect(shouldRecordProcessGoneCrash({ ...processGone, platform: 'win32' })).toBe(true) + expect( + shouldRecordProcessGoneCrash({ + ...processGone, + platform: 'linux', + reason: 'abnormal-exit' + }) ).toBe(true) }) @@ -285,6 +317,7 @@ describe('shouldRecordProcessGoneCrash', () => { it('records non-SIGTERM killed process exits outside expected lifecycle teardown', () => { expect( shouldRecordProcessGoneCrash({ + platform: 'linux', source: 'renderer', processType: 'renderer', reason: 'killed', @@ -339,6 +372,15 @@ describe('shouldRecordProcessGoneCrash', () => { }) describe('shouldRecoverRendererAfterProcessGone', () => { + it('recovers unexpected killed renderers', () => { + expect( + shouldRecoverRendererAfterProcessGone({ + reason: 'killed', + expectedTeardown: 'none' + }) + ).toBe(true) + }) + it('does not recover expected renderer reload teardown', () => { expect( shouldRecoverRendererAfterProcessGone({ diff --git a/src/main/crash-reporting/process-gone-classification.ts b/src/main/crash-reporting/process-gone-classification.ts index d3f363d1332..ee6dcdc868d 100644 --- a/src/main/crash-reporting/process-gone-classification.ts +++ b/src/main/crash-reporting/process-gone-classification.ts @@ -2,6 +2,8 @@ export type ProcessGoneSource = 'renderer' | 'child' export type ExpectedTeardownScope = 'none' | 'renderer-reload' | 'app-shutdown' const WINDOWS_CONTROL_TERMINATION_EXIT_CODES = new Set([0xc000013a, 0x40010004]) +// Chromium's PID-namespace SIGTERM handler exits 241; waitpid reports 241 << 8. +const LINUX_NAMESPACE_SIGTERM_WAIT_STATUS = 0xf100 const RECOVERABLE_CHILD_PROCESS_TYPES = new Set(['gpu']) const RECOVERABLE_UTILITY_SERVICE_NAMES = new Set([ 'audio.mojom.AudioService', @@ -49,6 +51,7 @@ function isRecoverableChromiumChildProcess({ } export function shouldRecordProcessGoneCrash({ + platform, source, processType, serviceName, @@ -56,6 +59,7 @@ export function shouldRecordProcessGoneCrash({ exitCode, expectedTeardown }: { + platform: NodeJS.Platform source: ProcessGoneSource processType?: string serviceName?: string @@ -76,7 +80,11 @@ export function shouldRecordProcessGoneCrash({ // Why: Electron reports expected Chromium teardown during reload/update as // `killed` + SIGTERM or Windows control termination statuses. Treat real // crash reasons as reportable, but skip these normal termination shapes. - if (exitCode === 15 || isWindowsControlTerminationExitCode(exitCode)) { + if ( + exitCode === 15 || + isWindowsControlTerminationExitCode(exitCode) || + (platform === 'linux' && exitCode === LINUX_NAMESPACE_SIGTERM_WAIT_STATUS) + ) { return false } if (expectedTeardown === 'app-shutdown') { diff --git a/src/main/crash-reporting/process-gone-recorder.test.ts b/src/main/crash-reporting/process-gone-recorder.test.ts index 95dbd992401..6c5962f688e 100644 --- a/src/main/crash-reporting/process-gone-recorder.test.ts +++ b/src/main/crash-reporting/process-gone-recorder.test.ts @@ -117,6 +117,41 @@ describe('recordProcessGoneCrash', () => { expect(sink.flushMock).toHaveBeenCalledOnce() }) + it('durably suppresses a Linux namespace-encoded renderer SIGTERM', () => { + const record = vi.fn() + + withStubbedPlatform('linux', () => { + recordProcessGoneCrash( + { record } as never, + event({ reason: 'killed', exitCode: 61696 }), + new ProcessGoneDedupe() + ) + }) + + expect(record).not.toHaveBeenCalled() + expect(getCrashBreadcrumbSnapshot()).toEqual([ + expect.objectContaining({ + name: 'process_gone_suppressed', + data: expect.objectContaining({ + source: 'renderer', + reason: 'killed', + exitCode: 61696, + expectedTeardown: 'none' + }) + }) + ]) + expect(sink.records).toEqual([ + expect.objectContaining({ + name: 'crash.breadcrumb', + attributes: expect.objectContaining({ + 'breadcrumb.name': 'process_gone_suppressed', + 'breadcrumb.data': expect.objectContaining({ exitCode: 61696 }) + }) + }) + ]) + expect(sink.flushMock).toHaveBeenCalledOnce() + }) + it('keeps suppressed renderer evidence scoped to its renderer', () => { const dedupe = new ProcessGoneDedupe() const suppressed = (webContentsId: number) => @@ -475,10 +510,10 @@ describe('recordProcessGoneCrash', () => { } } - // Why child kills: the decode gate is source-agnostic and reads + // Why child exits: the decode gate is source-agnostic and reads // process.platform synchronously at record time, so the platform stub is // still in force when it runs. - const nonRecoverableChildKill = ( + const nonRecoverableChildExit = ( overrides: Partial ): ProcessGoneCrashEvent => event({ @@ -494,7 +529,7 @@ describe('recordProcessGoneCrash', () => { withStubbedPlatform('linux', () => { recordProcessGoneCrash( { record } as never, - nonRecoverableChildKill({ reason: 'killed', exitCode: 61696 }), + nonRecoverableChildExit({ reason: 'abnormal-exit', exitCode: 61696 }), new ProcessGoneDedupe() ) }) @@ -518,14 +553,14 @@ describe('recordProcessGoneCrash', () => { withStubbedPlatform('win32', () => { recordProcessGoneCrash( { record } as never, - nonRecoverableChildKill({ reason: 'killed', exitCode: 1 }), + nonRecoverableChildExit({ reason: 'killed', exitCode: 1 }), new ProcessGoneDedupe() ) }) withStubbedPlatform('linux', () => { recordProcessGoneCrash( { record } as never, - nonRecoverableChildKill({ reason: 'launch-failed', exitCode: 18 }), + nonRecoverableChildExit({ reason: 'launch-failed', exitCode: 18 }), new ProcessGoneDedupe() ) }) diff --git a/src/main/crash-reporting/process-gone-recorder.ts b/src/main/crash-reporting/process-gone-recorder.ts index 47ac561c4a7..acb33c71b7e 100644 --- a/src/main/crash-reporting/process-gone-recorder.ts +++ b/src/main/crash-reporting/process-gone-recorder.ts @@ -80,10 +80,6 @@ const captureProcessMinidump: MinidumpCapture = (crashedAtMs, expectedProcessTyp // one here would weaken the other 30s coalescers. Stay uniform with them. const SUPPRESSED_PROCESS_GONE_COALESCE_MS = 30_000 -function processGoneBreadcrumbData(event: ProcessGoneCrashEvent) { - return buildSuppressedProcessGoneBreadcrumbData(event) -} - function processGoneRendererOrigin(event: ProcessGoneCrashEvent): string | undefined { return event.webContentsId === undefined ? undefined @@ -122,7 +118,7 @@ function persistFailureData(event: ProcessGoneCrashEvent, error: unknown) { ? error.code : undefined return { - ...processGoneBreadcrumbData(event), + ...buildSuppressedProcessGoneBreadcrumbData(event), errorName: error instanceof Error ? error.name : typeof error, errorMessage: sanitizeCrashReportString(error instanceof Error ? error.message : String(error)), ...(errorCode ? { errorCode } : {}) @@ -194,6 +190,7 @@ export function recordProcessGoneCrash( scheduleCrashpadDumpPrune() if ( !shouldRecordProcessGoneCrash({ + platform: process.platform, source: event.source, processType: event.processType, serviceName, @@ -205,7 +202,7 @@ export function recordProcessGoneCrash( // Why: Chromium can crash-loop a recoverable child (network service seen at // 1459/min) and each suppressed event costs a span plus a forced disk flush, // which both floods the 30-entry ring and evicts the real pre-crash trail. - const suppressedData = processGoneBreadcrumbData(event) + const suppressedData = buildSuppressedProcessGoneBreadcrumbData(event) const origin = processGoneRendererOrigin(event) recordCoalescedDurableCrashBreadcrumb({ name: 'process_gone_suppressed', @@ -221,7 +218,7 @@ export function recordProcessGoneCrash( if (!store) { recordDurableCrashBreadcrumb( 'crash_report_store_unavailable', - processGoneBreadcrumbData(event), + buildSuppressedProcessGoneBreadcrumbData(event), 'Crash report store unavailable', processGoneRendererOrigin(event) ) @@ -305,7 +302,7 @@ export function recordProcessGoneCrash( siblingDeaths, (reportId, details) => store.attachDetails(reportId, details), recorded, - processGoneBreadcrumbData(event), + buildSuppressedProcessGoneBreadcrumbData(event), processGoneRendererOrigin(event) ) void recorded @@ -322,7 +319,7 @@ export function recordProcessGoneCrash( console.error('[crash-reporting] Failed to attach minidump signature:', error) recordDurableCrashBreadcrumb( 'minidump_signature_attach_failed', - processGoneBreadcrumbData(event), + buildSuppressedProcessGoneBreadcrumbData(event), error instanceof Error ? error.message : String(error), processGoneRendererOrigin(event) ) diff --git a/src/main/git/worktree-scan-cache-sharing.test.ts b/src/main/git/worktree-scan-cache-sharing.test.ts index cee700b8597..14fd5e86a90 100644 --- a/src/main/git/worktree-scan-cache-sharing.test.ts +++ b/src/main/git/worktree-scan-cache-sharing.test.ts @@ -30,6 +30,7 @@ import { _getWorktreeScanCacheSizesForTests, _resetWorktreeScanCacheForTests, listWorktrees, + listWorktreesStrict, moveWorktree, removeWorktree, WORKTREE_LIST_TIMEOUT_MS @@ -120,6 +121,19 @@ describe('listWorktrees in-flight sharing', () => { } }) + it('distinguishes fail-soft empty results from strict scan failures', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + const failure = new Error('git spawn timed out') + gitExecFileAsyncMock.mockRejectedValue(failure) + + await expect(listWorktrees('/repo')).resolves.toEqual([]) + await expect(listWorktreesStrict('/repo')).rejects.toBe(failure) + } finally { + warnSpy.mockRestore() + } + }) + it('does not share scans across different repos', async () => { gitExecFileAsyncMock.mockImplementation((_args: string[], options: { cwd: string }) => Promise.resolve({ diff --git a/src/main/index.ts b/src/main/index.ts index 7df14b894a1..00b059885ad 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3389,12 +3389,12 @@ void app.whenReady().then(async () => { let desktopWindow: BrowserWindow | null = null if (process.platform === 'win32' && app.isPackaged && !serveOptions) { const desktopStartup = startWindowsDesktopBeforeShellPathReady({ + bindServices: bindTerminalRuntimeStartupServices, openWindow: () => openMainWindow({ revealOnDidFinishLoad: true }), shellPathReady, startServices: startTerminalRuntimeStartupServices }) desktopWindow = desktopStartup.window - bindTerminalRuntimeStartupServices(desktopStartup.services) } else { await shellPathReady bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices())) @@ -3410,7 +3410,8 @@ void app.whenReady().then(async () => { }) // Why: headless PTYs must not start on the fallback provider, then get swept when an activated renderer registers desktop lifecycle handlers. await localPtyStartupReady - registerHeadlessPtyRuntime( + await localPtyProviderStartupReady + await registerHeadlessPtyRuntime( runtime, prepareCodexRuntimeHomeForLaunch, () => store!.getSettings(), diff --git a/src/main/ipc/pty/register-headless-runtime.test.ts b/src/main/ipc/pty/register-headless-runtime.test.ts new file mode 100644 index 00000000000..7063ec2454d --- /dev/null +++ b/src/main/ipc/pty/register-headless-runtime.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest' + +const { hydrateMock, registerHandlersMock } = vi.hoisted(() => ({ + hydrateMock: vi.fn(), + registerHandlersMock: vi.fn() +})) + +vi.mock('./register-handlers', () => ({ + registerPtyHandlers: registerHandlersMock +})) + +vi.mock('../../memory/hydrate-local-pty-registry', () => ({ + hydrateLocalPtyRegistryAtBoot: hydrateMock +})) + +import { registerHeadlessPtyRuntime } from './register-headless-runtime' + +describe('registerHeadlessPtyRuntime', () => { + it('registers handlers before awaiting registry hydration', async () => { + let resolveHydration!: () => void + const hydration = new Promise((resolve) => { + resolveHydration = resolve + }) + const events: string[] = [] + registerHandlersMock.mockImplementation(() => events.push('handlers')) + hydrateMock.mockImplementation(() => { + events.push('hydrate') + return hydration + }) + const store = {} as never + + const ready = registerHeadlessPtyRuntime({} as never, undefined, undefined, undefined, store) + + expect(events).toEqual(['handlers', 'hydrate']) + expect(registerHandlersMock).toHaveBeenCalledOnce() + expect(hydrateMock).toHaveBeenCalledWith(store) + + resolveHydration() + await ready + }) +}) diff --git a/src/main/ipc/pty/register-headless-runtime.ts b/src/main/ipc/pty/register-headless-runtime.ts index f380482a079..2212fbfac3a 100644 --- a/src/main/ipc/pty/register-headless-runtime.ts +++ b/src/main/ipc/pty/register-headless-runtime.ts @@ -9,6 +9,7 @@ import type { PrepareCodexSessionResume } from './host-env/types' import { registerPtyHandlers } from './register-handlers' +import { hydrateLocalPtyRegistryAtBoot } from '../../memory/hydrate-local-pty-registry' export function registerHeadlessPtyRuntime( runtime: OrcaRuntimeService, @@ -21,7 +22,7 @@ export function registerHeadlessPtyRuntime( onCodexHomePtySpawned?: (args: CodexHomePtySpawnedLifecycleArgs) => void onPtyExit?: (id: string, exitSequence: number) => void } -): void { +): Promise { // Why: headless `orca serve` has no renderer window but still needs the same PTY handlers so remote clients can drive terminals. // Why a fake rather than null: `registerPtyHandlers` takes a non-null BrowserWindow. `isDestroyed: () => true` // is what makes that safe — every renderer-liveness guard reads it and skips, so no send is ever attempted. @@ -44,4 +45,5 @@ export function registerHeadlessPtyRuntime( store, { prepareCodexSessionResume, ...lifecycle } ) + return store ? hydrateLocalPtyRegistryAtBoot(store) : Promise.resolve() } diff --git a/src/main/ipc/registered-worktree-roots-cache.ts b/src/main/ipc/registered-worktree-roots-cache.ts index 8ca19984298..92ae4fc334f 100644 --- a/src/main/ipc/registered-worktree-roots-cache.ts +++ b/src/main/ipc/registered-worktree-roots-cache.ts @@ -7,6 +7,9 @@ import { isDescendantOrEqual, normalizeExistingPath } from './filesystem-path-co const registeredWorktreeRoots = new Set() const registeredWorktreeRootsByRepo = new Map>() const registeredWorktreeRootRepoIds = new Set() +const registeredWorktreeRootsRevisionByRepo = new Map() +let registeredWorktreeRootsRevisionSequence = 0 +let registeredWorktreeRootsBaseRevision = 0 let registeredWorktreeRootsDirty = true let registeredWorktreeRootsRefresh: Promise | null = null const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8 @@ -17,6 +20,8 @@ export function invalidateAuthorizedRootsCache(): void { registeredWorktreeRoots.clear() registeredWorktreeRootsByRepo.clear() registeredWorktreeRootRepoIds.clear() + registeredWorktreeRootsBaseRevision = ++registeredWorktreeRootsRevisionSequence + registeredWorktreeRootsRevisionByRepo.clear() } export async function rebuildAuthorizedRootsCache(store: Store): Promise { @@ -55,6 +60,8 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise { registeredWorktreeRootRepoIds.add(repoId) } registeredWorktreeRootsDirty = false + registeredWorktreeRootsBaseRevision = ++registeredWorktreeRootsRevisionSequence + registeredWorktreeRootsRevisionByRepo.clear() } async function mapWithConcurrency( @@ -87,6 +94,10 @@ export function registerWorktreeRootsForRepo( if (!localRepoIds.has(registeredRepoId)) { registeredWorktreeRootsByRepo.delete(registeredRepoId) registeredWorktreeRootRepoIds.delete(registeredRepoId) + registeredWorktreeRootsRevisionByRepo.set( + registeredRepoId, + ++registeredWorktreeRootsRevisionSequence + ) } } @@ -98,10 +109,15 @@ export function registerWorktreeRootsForRepo( registeredWorktreeRootsByRepo.set(repoId, new Set(worktreeRoots.map((root) => resolve(root)))) registeredWorktreeRootRepoIds.add(repoId) + registeredWorktreeRootsRevisionByRepo.set(repoId, ++registeredWorktreeRootsRevisionSequence) refreshRegisteredWorktreeRoots() registeredWorktreeRootsDirty = !allLocalRepoRootsRegistered(localRepoIds) } +export function getRegisteredWorktreeRootsRevision(repoId: string): number { + return registeredWorktreeRootsRevisionByRepo.get(repoId) ?? registeredWorktreeRootsBaseRevision +} + export async function ensureAuthorizedRootsCache(store: Store): Promise { if (!registeredWorktreeRootsDirty) { return diff --git a/src/main/ipc/worktree-base-directory-watch-targets.test.ts b/src/main/ipc/worktree-base-directory-watch-targets.test.ts new file mode 100644 index 00000000000..250b3d7625d --- /dev/null +++ b/src/main/ipc/worktree-base-directory-watch-targets.test.ts @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join, sep } from 'node:path' +import type { GlobalSettings } from '../../shared/global-settings-types' +import type { Repo } from '../../shared/repo-types' + +const { getSshFilesystemProviderMock, readFileMock, realpathMock, statMock } = vi.hoisted(() => ({ + getSshFilesystemProviderMock: vi.fn(), + readFileMock: vi.fn(), + realpathMock: vi.fn(), + statMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock, + realpath: realpathMock, + stat: statMock +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock +})) + +import { + buildWorktreeBaseDirectoryWatchTargets, + clearWorktreeBaseDirectoryWatchTargetWarnings, + WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY +} from './worktree-base-directory-watch-targets' + +const absolutePath = (...parts: string[]): string => join(sep, ...parts) +const WORKTREE_ROOT = absolutePath('workspace', 'worktrees') +const PROJECT_ROOT = absolutePath('workspace', 'projects') +const localDirectoryStat = { isDirectory: () => true } +const remoteDirectoryStat = { type: 'directory', size: 0, mtime: 0 } +const settings = { + workspaceDir: WORKTREE_ROOT, + nestWorkspaces: true +} as GlobalSettings + +function makeRepo(index: number, overrides: Partial = {}): Repo { + return { + id: `repo-${index}`, + path: join(PROJECT_ROOT, `project-${index}`), + displayName: `Project ${index}`, + badgeColor: '#000000', + addedAt: index, + ...overrides + } as Repo +} + +function makeStore(repos: Repo[]) { + return { + getSettings: () => settings, + getRepos: () => repos + } +} + +function makeRemoteProvider() { + return { + stat: vi.fn(async () => remoteDirectoryStat), + realpath: vi.fn(async (path: string) => path), + readFile: vi.fn(async () => ({ content: '', isBinary: false })) + } +} + +describe('worktree base directory watch target resolution', () => { + let warnSpy: ReturnType + + beforeEach(() => { + statMock.mockReset().mockResolvedValue(localDirectoryStat) + realpathMock.mockReset().mockImplementation(async (path: string) => path) + readFileMock.mockReset().mockResolvedValue('') + getSshFilesystemProviderMock.mockReset().mockReturnValue(undefined) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + clearWorktreeBaseDirectoryWatchTargetWarnings() + warnSpy.mockRestore() + }) + + it('bounds resolution concurrency and starts one successor per released slot', async () => { + const repos = Array.from( + { length: WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY + 4 }, + (_, index) => makeRepo(index) + ) + const gates = repos.map(() => Promise.withResolvers()) + let startedRootStats = 0 + let activeRootStats = 0 + let peakRootStats = 0 + statMock.mockImplementation(async (path: string) => { + if (path === WORKTREE_ROOT) { + const gate = gates[startedRootStats++] + activeRootStats++ + peakRootStats = Math.max(peakRootStats, activeRootStats) + await gate.promise + activeRootStats-- + } + return localDirectoryStat + }) + + const resultPromise = buildWorktreeBaseDirectoryWatchTargets(makeStore(repos) as never) + await vi.waitFor(() => { + expect(startedRootStats).toBe(WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY) + }) + expect(peakRootStats).toBe(WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY) + + gates[0].resolve() + await vi.waitFor(() => { + expect(startedRootStats).toBe(WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY + 1) + }) + expect(peakRootStats).toBe(WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY) + + for (const gate of gates) { + gate.resolve() + } + await resultPromise + expect(startedRootStats).toBe(repos.length) + expect(peakRootStats).toBe(WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY) + }) + + it('merges targets and shared repo membership in store order after reverse completion', async () => { + const repos = Array.from({ length: 4 }, (_, index) => makeRepo(index)) + const gates = repos.map(() => Promise.withResolvers()) + const gitStatStarted = new Set() + const completionOrder: number[] = [] + statMock.mockImplementation(async (path: string) => { + const index = repos.findIndex((repo) => path === join(repo.path, '.git')) + if (index !== -1) { + gitStatStarted.add(index) + await gates[index].promise + completionOrder.push(index) + } + return localDirectoryStat + }) + + const resultPromise = buildWorktreeBaseDirectoryWatchTargets(makeStore(repos) as never) + await vi.waitFor(() => expect(gitStatStarted.size).toBe(repos.length)) + for (let index = repos.length - 1; index >= 0; index--) { + gates[index].resolve() + await vi.waitFor(() => expect(completionOrder).toContain(index)) + } + const targets = await resultPromise + + expect(completionOrder).toEqual([3, 2, 1, 0]) + expect([...targets.values()].map((target) => [target.kind, target.path])).toEqual([ + ['base', WORKTREE_ROOT], + ...repos.map((repo) => ['git-common', join(repo.path, '.git')]) + ]) + expect([...([...targets.values()][0]?.repos.keys() ?? [])]).toEqual( + repos.map((repo) => repo.id) + ) + }) + + it('isolates missing paths to the affected repo', async () => { + const missing = Object.assign(new Error('missing'), { code: 'ENOENT' }) + const goodBefore = makeRepo(0, { worktreeBasePath: absolutePath('worktrees', 'before') }) + const unavailable = makeRepo(1, { worktreeBasePath: absolutePath('worktrees', 'missing') }) + const goodAfter = makeRepo(2, { worktreeBasePath: absolutePath('worktrees', 'after') }) + statMock.mockImplementation(async (path: string) => { + if (path === unavailable.worktreeBasePath || path === join(unavailable.path, '.git')) { + throw missing + } + return localDirectoryStat + }) + + const targets = await buildWorktreeBaseDirectoryWatchTargets( + makeStore([goodBefore, unavailable, goodAfter]) as never + ) + const repoIds = [...targets.values()].flatMap((target) => [...target.repos.keys()]) + + expect(repoIds).toContain(goodBefore.id) + expect(repoIds).not.toContain(unavailable.id) + expect(repoIds).toContain(goodAfter.id) + }) + + it('keeps identical SSH paths isolated by connection and provider', async () => { + const providerA = makeRemoteProvider() + const providerB = makeRemoteProvider() + getSshFilesystemProviderMock.mockImplementation((connectionId: string) => + connectionId === 'ssh-a' ? providerA : connectionId === 'ssh-b' ? providerB : undefined + ) + const sharedPath = '/srv/project' + const repos = [ + makeRepo(0, { path: sharedPath, connectionId: 'ssh-a' }), + makeRepo(1, { path: sharedPath, connectionId: 'ssh-b' }) + ] + + const targets = await buildWorktreeBaseDirectoryWatchTargets(makeStore(repos) as never) + + expect(providerA.stat).toHaveBeenCalledTimes(2) + expect(providerA.realpath).toHaveBeenCalledTimes(2) + expect(providerB.stat).toHaveBeenCalledTimes(2) + expect(providerB.realpath).toHaveBeenCalledTimes(2) + expect([...targets.values()].map((target) => target.connectionId)).toEqual([ + 'ssh-a', + 'ssh-a', + 'ssh-b', + 'ssh-b' + ]) + expect(new Set(targets.keys()).size).toBe(4) + }) + + it('skips folder workspaces, unavailable SSH providers, and WSL UNC roots', async () => { + const folder = makeRepo(0, { kind: 'folder' }) + const unavailableSsh = makeRepo(1, { connectionId: 'missing', path: '/srv/missing' }) + const wslPath = '\\\\wsl.localhost\\Ubuntu\\home\\alice\\project' + const wsl = makeRepo(2, { path: wslPath, worktreeBasePath: wslPath }) + + const targets = await buildWorktreeBaseDirectoryWatchTargets( + makeStore([folder, unavailableSsh, wsl]) as never + ) + + expect(targets.size).toBe(0) + expect(statMock).not.toHaveBeenCalled() + expect(getSshFilesystemProviderMock).toHaveBeenCalledWith('missing') + }) + + it('does not publish ordered partial targets when a repo resolver rejects unexpectedly', async () => { + const completed = makeRepo(0) + const broken = makeRepo(1) + Object.defineProperty(broken, 'path', { + get: () => { + throw new Error('unexpected resolver failure') + } + }) + + await expect( + buildWorktreeBaseDirectoryWatchTargets(makeStore([completed, broken]) as never) + ).rejects.toThrow('unexpected resolver failure') + expect(statMock).toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/worktree-base-directory-watch-targets.ts b/src/main/ipc/worktree-base-directory-watch-targets.ts index cfa825e09ab..5300475593d 100644 --- a/src/main/ipc/worktree-base-directory-watch-targets.ts +++ b/src/main/ipc/worktree-base-directory-watch-targets.ts @@ -15,6 +15,7 @@ import { resolveRuntimePath } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { computeWorkspaceRoot, @@ -32,6 +33,9 @@ import type { const missingRootWarnings = new Set() const skippedWslWarnings = new Set() +// Why: match existing worktree probe caps while bounding aggregate SSH filesystem RPC pressure. +export const WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY = 8 + function normalizeWatchKey(pathValue: string): string { return normalizeRuntimePathForComparison(normalize(pathValue)) } @@ -177,21 +181,51 @@ async function maybeAddBaseTarget( } } +async function resolveRepoTargets( + repo: Repo, + settings: GlobalSettings +): Promise> { + const targets = new Map() + if (isFolderRepo(repo)) { + return targets + } + const executionHostId = getRepoExecutionHostId(repo) + if (executionHostId === LOCAL_EXECUTION_HOST_ID) { + await maybeAddBaseTarget(targets, repo, settings) + } else if (repo.connectionId) { + await maybeAddBaseTarget(targets, repo, settings, repo.connectionId) + } + return targets +} + +function mergeRepoTargets( + targets: Map, + repoTargets: Map +): void { + for (const [key, target] of repoTargets) { + const existing = targets.get(key) + if (!existing) { + targets.set(key, target) + continue + } + for (const [repoId, config] of target.repos) { + existing.repos.set(repoId, config) + } + } +} + export async function buildWorktreeBaseDirectoryWatchTargets( store: Store ): Promise> { const settings = store.getSettings() + const resolvedRepoTargets = await mapWithConcurrency( + store.getRepos(), + WORKTREE_BASE_TARGET_RESOLUTION_CONCURRENCY, + (repo) => resolveRepoTargets(repo, settings) + ) const targets = new Map() - for (const repo of store.getRepos()) { - if (isFolderRepo(repo)) { - continue - } - const executionHostId = getRepoExecutionHostId(repo) - if (executionHostId === LOCAL_EXECUTION_HOST_ID) { - await maybeAddBaseTarget(targets, repo, settings) - } else if (repo.connectionId) { - await maybeAddBaseTarget(targets, repo, settings, repo.connectionId) - } + for (const repoTargets of resolvedRepoTargets) { + mergeRepoTargets(targets, repoTargets) } return targets } diff --git a/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts new file mode 100644 index 00000000000..aa3fff56f77 --- /dev/null +++ b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts @@ -0,0 +1,611 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitWorktreeInfo } from '../../shared/worktree/types' +import type { NativeLocalWorktreeMetadataScanExpectation } from '../persistence/tracking-repos/missing-local-worktree-metadata-pruning' +import { notifyWorktreesChanged } from './worktree-remote' +import { + listWorktreesMock, + pruneCleanupScanSnapshotsMock, + pruneSpaceAnalysisSnapshotsMock +} from './worktrees-test-module-mocks' +import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { mockSelectedWslProjectRuntime } from './worktrees-test-fixtures' +import { pruneMetadataMissingFromAuthoritativeLocalScan } from './worktrees/listing/authoritative-local-worktree-metadata-pruning' +import { listDetectedWorktreesForCapturedRepo } from './worktrees/listing/detected-provider-listing' +import { getLocalWorktreeScanGeneration } from '../local-worktree-scan-generation' +import { + isRegisteredWorktreePath, + registerWorktreeRootsForRepo +} from './registered-worktree-roots-cache' + +const localWorktreePathPresenceMock = vi.hoisted(() => vi.fn()) + +vi.mock('electron', async () => + (await import('./worktrees-test-module-mocks')).electronModuleMock() +) +vi.mock('../git/worktree', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() +) +vi.mock('../git/runner', async () => + (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() +) +vi.mock('../git/repo', async () => + (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +) +vi.mock('../git/git-username', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) + .resolveLocalGitUsernameMock +})) +vi.mock('../github/client', async () => + (await import('./worktrees-test-module-mocks')).githubClientModuleMock() +) +vi.mock('../source-control/hosted-review', async () => + (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() +) +vi.mock('../providers/ssh-git-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() +) +vi.mock('../providers/ssh-filesystem-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() +) +vi.mock('./worktree-symlinks', async () => + (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() +) +vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) +vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) +vi.mock('../setup-runner-script-text', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../worktree-runner-script', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../effective-hook-config', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../setup-hook-env-vars', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('./worktree-logic', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../terminal-history-deletion', async () => + (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() +) +vi.mock('../ports/advertised-url-watcher', async () => + (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() +) +vi.mock('../workspace-cleanup-scan-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() +) +vi.mock('../workspace-space-analysis-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() +) +vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() +) +vi.mock('../local-worktree-path-presence', async (importOriginal) => ({ + ...(await importOriginal>()), + localWorktreePathsExistOrAreUnverifiable: localWorktreePathPresenceMock +})) +vi.mock('../runtime/worktree-teardown', async () => + (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() +) +vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) + +const REPO_ID = 'repo-1' +const REPO_PATH = '/workspace/repo' +const LOCAL_HOST_ID = 'local' + +function worktree(path: string, overrides: Partial = {}): GitWorktreeInfo { + return { + path, + head: path, + branch: `refs/heads/${path.split('/').at(-1) ?? 'main'}`, + isBare: false, + isMainWorktree: path === REPO_PATH, + ...overrides + } +} + +function scanExpectation( + worktreeIds: readonly string[] +): NativeLocalWorktreeMetadataScanExpectation { + const expectedRepo = { + id: REPO_ID, + path: REPO_PATH, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + return { + repo: { + id: REPO_ID, + path: REPO_PATH, + kind: 'git', + expectedRepo + }, + routing: { + expectedProject: undefined, + expectedProjectUpdatedAt: undefined, + expectedSettings: + {} as NativeLocalWorktreeMetadataScanExpectation['routing']['expectedSettings'] + }, + metadata: worktreeIds.map((worktreeId) => ({ + worktreeId, + expectedLegacy: { + expectedPresent: true, + expectedMeta: undefined, + expectedSerialized: undefined, + expectedInstanceId: undefined, + expectedHostId: LOCAL_HOST_ID + }, + expectedAliases: [] + })) + } +} + +function listDetected(): Promise { + return handlers['worktrees:listDetected'](null, { repoId: REPO_ID }) as Promise +} + +const localListingCalls = [ + ['worktrees:listDetected', listDetected], + [ + 'worktrees:list', + () => handlers['worktrees:list'](null, { repoId: REPO_ID }) as Promise + ], + ['worktrees:listAll', () => handlers['worktrees:listAll'](null, undefined) as Promise] +] as const + +describe('authoritative local worktree metadata pruning integration', () => { + beforeEach(() => { + setupWorktreeHandlers() + localWorktreePathPresenceMock.mockReset() + localWorktreePathPresenceMock.mockImplementation( + async (pathValues: readonly string[]) => + new Map(pathValues.map((pathValue) => [pathValue, false])) + ) + }) + + it('captures metadata expectations before starting the Git scan', async () => { + const order: string[] = [] + store.captureNativeLocalWorktreeMetadataScanExpectation.mockImplementation(() => { + order.push('capture') + return scanExpectation([]) + }) + listWorktreesMock.mockImplementation(() => { + order.push('scan') + return Promise.resolve([worktree(REPO_PATH)]) + }) + + await listDetected() + + expect(order).toEqual(['capture', 'scan']) + }) + + it('prunes once for the fresh producer, while coalesced and cached callers do nothing', async () => { + const staleId = `${REPO_ID}::/workspace/stale` + const rows = [worktree(REPO_PATH), worktree('/workspace/live')] + store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue( + scanExpectation([staleId]) + ) + store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId]) + listWorktreesMock.mockImplementation(async () => { + await Promise.resolve() + return rows + }) + + await Promise.all([listDetected(), listDetected(), listDetected()]) + await listDetected() + + expect(store.captureNativeLocalWorktreeMetadataScanExpectation).toHaveBeenCalledTimes(1) + expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).toHaveBeenCalledTimes(1) + const firstPruneCall = store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mock + .calls[0] as [unknown, readonly { worktreeId: string }[]] | undefined + expect(firstPruneCall?.[1].map(({ worktreeId }) => worktreeId)).toEqual([staleId]) + expect(pruneCleanupScanSnapshotsMock).toHaveBeenCalledTimes(1) + expect(pruneCleanupScanSnapshotsMock).toHaveBeenCalledWith('/profile-a', [ + { worktreeId: staleId, executionHostId: LOCAL_HOST_ID } + ]) + expect(pruneSpaceAnalysisSnapshotsMock).toHaveBeenCalledTimes(1) + expect(pruneSpaceAnalysisSnapshotsMock).toHaveBeenCalledWith('/profile-a', [ + { worktreeId: staleId, executionHostId: LOCAL_HOST_ID } + ]) + }) + + it('does not prune when the authoritative scan is invalidated while in flight', async () => { + const staleId = `${REPO_ID}::/workspace/stale` + let resolveScan: (rows: GitWorktreeInfo[]) => void = () => {} + store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue( + scanExpectation([staleId]) + ) + store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId]) + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolveScan = resolve as (rows: GitWorktreeInfo[]) => void + }) + ) + + const pending = listDetected() + await Promise.resolve() + notifyWorktreesChanged(mainWindow as never, REPO_ID) + resolveScan([worktree(REPO_PATH), worktree('/workspace/live')]) + await pending + + expect(store.captureNativeLocalWorktreeMetadataScanExpectation).toHaveBeenCalledTimes(1) + expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).not.toHaveBeenCalled() + expect(pruneCleanupScanSnapshotsMock).not.toHaveBeenCalled() + expect(pruneSpaceAnalysisSnapshotsMock).not.toHaveBeenCalled() + }) + + it('does not prune when the completed scan is invalidated before its caller resumes', async () => { + const staleId = `${REPO_ID}::/workspace/stale` + let resolveScan: (rows: GitWorktreeInfo[]) => void = () => {} + store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue( + scanExpectation([staleId]) + ) + store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId]) + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolveScan = resolve as (rows: GitWorktreeInfo[]) => void + }) + ) + + const pending = listDetected() + await Promise.resolve() + resolveScan([worktree(REPO_PATH)]) + queueMicrotask(() => notifyWorktreesChanged(mainWindow as never, REPO_ID)) + await pending + + expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).not.toHaveBeenCalled() + }) + + it.each(localListingCalls)( + 'skips stale WSL root and lineage side effects after %s caller resumption', + async (_channel, listWorktrees) => { + const orphanId = `${REPO_ID}::/workspace/orphan` + let resolveScan: (rows: GitWorktreeInfo[]) => void = () => {} + mockSelectedWslProjectRuntime() + store.getAllWorktreeLineage.mockReturnValue({ + [orphanId]: { + worktreeId: orphanId, + worktreeInstanceId: 'orphan-instance', + parentWorktreeId: `${REPO_ID}::${REPO_PATH}`, + parentWorktreeInstanceId: 'main-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 0 + } + }) + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolveScan = resolve as (rows: GitWorktreeInfo[]) => void + }) + ) + + const pending = listWorktrees() + await Promise.resolve() + resolveScan([worktree(REPO_PATH)]) + queueMicrotask(() => notifyWorktreesChanged(mainWindow as never, REPO_ID)) + await pending + + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + expect(isRegisteredWorktreePath(REPO_PATH)).toBe(false) + } + ) + + it('preserves WSL roots and lineage created while an older Git scan is pending', async () => { + const newPath = '/workspace/new-wsl-worktree' + const newId = `${REPO_ID}::${newPath}` + let resolveScan: (rows: GitWorktreeInfo[]) => void = () => {} + mockSelectedWslProjectRuntime() + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolveScan = resolve as (rows: GitWorktreeInfo[]) => void + }) + ) + + const pending = listDetected() + await Promise.resolve() + + resolveScan([worktree(REPO_PATH)]) + queueMicrotask(() => { + registerWorktreeRootsForRepo(store as never, REPO_ID, [REPO_PATH, newPath]) + store.setWorktreeMeta(newId, { hostId: LOCAL_HOST_ID, instanceId: 'new-instance' }) + store.getAllWorktreeLineage.mockReturnValue({ + [newId]: { + worktreeId: newId, + worktreeInstanceId: 'new-instance', + parentWorktreeId: `${REPO_ID}::${REPO_PATH}`, + parentWorktreeInstanceId: 'main-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 0 + } + }) + }) + await pending + + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + expect(isRegisteredWorktreePath(newPath)).toBe(true) + }) + + it.each(['scan generation', 'caller request'] as const)( + 'skips metadata, root, and lineage mutations when the %s is invalidated during path probes', + async (invalidation) => { + const staleId = `${REPO_ID}::/workspace/stale` + const orphanId = `${REPO_ID}::/workspace/orphan` + let callerCurrent = true + let resolvePresence: (presence: ReadonlyMap) => void = () => {} + store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue( + scanExpectation([staleId]) + ) + store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId]) + store.getAllWorktreeLineage.mockReturnValue({ + [orphanId]: { + worktreeId: orphanId, + worktreeInstanceId: 'orphan-instance', + parentWorktreeId: `${REPO_ID}::${REPO_PATH}`, + parentWorktreeInstanceId: 'main-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 0 + } + }) + localWorktreePathPresenceMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePresence = resolve + }) + ) + listWorktreesMock.mockResolvedValue([worktree(REPO_PATH)]) + + const pending = listDetectedWorktreesForCapturedRepo( + store as never, + store.getRepo(REPO_ID) as never, + () => callerCurrent + ) + await vi.waitFor(() => expect(localWorktreePathPresenceMock).toHaveBeenCalledTimes(1)) + if (invalidation === 'scan generation') { + notifyWorktreesChanged(mainWindow as never, REPO_ID) + } else { + callerCurrent = false + } + resolvePresence(new Map([['/workspace/stale', false]])) + await pending + + expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).not.toHaveBeenCalled() + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + expect(isRegisteredWorktreePath(REPO_PATH)).toBe(false) + expect(pruneCleanupScanSnapshotsMock).not.toHaveBeenCalled() + expect(pruneSpaceAnalysisSnapshotsMock).not.toHaveBeenCalled() + } + ) + + it('preserves roots and lineage created while an older path probe is pending', async () => { + const staleId = `${REPO_ID}::/workspace/stale` + const newPath = '/workspace/new-worktree' + const newId = `${REPO_ID}::${newPath}` + let resolvePresence: (presence: ReadonlyMap) => void = () => {} + const metadata = new Map() + store.getWorktreeMeta.mockImplementation((worktreeId) => metadata.get(worktreeId)) + store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue( + scanExpectation([staleId]) + ) + store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId]) + localWorktreePathPresenceMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePresence = resolve + }) + ) + listWorktreesMock.mockResolvedValue([worktree(REPO_PATH)]) + + const pending = listDetected() + await vi.waitFor(() => expect(localWorktreePathPresenceMock).toHaveBeenCalledTimes(1)) + + registerWorktreeRootsForRepo(store as never, REPO_ID, [REPO_PATH, newPath]) + const newMetadata = { hostId: LOCAL_HOST_ID, instanceId: 'new-instance' } + metadata.set(newId, newMetadata) + store.setWorktreeMeta(newId, newMetadata) + store.getAllWorktreeLineage.mockReturnValue({ + [newId]: { + worktreeId: newId, + worktreeInstanceId: newMetadata.instanceId, + parentWorktreeId: `${REPO_ID}::${REPO_PATH}`, + parentWorktreeInstanceId: 'main-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 0 + } + }) + resolvePresence(new Map([['/workspace/stale', false]])) + await pending + + expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).toHaveBeenCalledTimes(1) + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + expect(isRegisteredWorktreePath(newPath)).toBe(true) + }) + + it('does not capture or prune on an initial WSL scan', async () => { + mockSelectedWslProjectRuntime() + listWorktreesMock.mockResolvedValue([worktree(REPO_PATH)]) + + await listDetected() + + expect(listWorktreesMock).toHaveBeenCalledWith(REPO_PATH, { wslDistro: 'Ubuntu' }) + expect(store.captureNativeLocalWorktreeMetadataScanExpectation).not.toHaveBeenCalled() + expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).not.toHaveBeenCalled() + }) + + it('does not let a native scan that changes to WSL routing become destructive', async () => { + const staleId = `${REPO_ID}::/workspace/stale` + const resolvers: ((rows: GitWorktreeInfo[]) => void)[] = [] + store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue( + scanExpectation([staleId]) + ) + store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId]) + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve as (rows: GitWorktreeInfo[]) => void) + }) + ) + + const nativePending = listDetected() + await Promise.resolve() + mockSelectedWslProjectRuntime() + const wslPending = listDetected() + await Promise.resolve() + + resolvers[1]?.([worktree(REPO_PATH)]) + await wslPending + resolvers[0]?.([worktree(REPO_PATH), worktree('/workspace/live')]) + await nativePending + + expect(store.captureNativeLocalWorktreeMetadataScanExpectation).toHaveBeenCalledTimes(1) + expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).not.toHaveBeenCalled() + expect(pruneCleanupScanSnapshotsMock).not.toHaveBeenCalled() + expect(pruneSpaceAnalysisSnapshotsMock).not.toHaveBeenCalled() + }) + + it.each([ + ['empty', [] as GitWorktreeInfo[]], + ['failed', new Error('git worktree list failed')] + ] as const)('does nothing for an %s authoritative scan', async (_label, result) => { + const staleId = `${REPO_ID}::/workspace/stale` + store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue( + scanExpectation([staleId]) + ) + store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId]) + if (result instanceof Error) { + listWorktreesMock.mockRejectedValue(result) + } else { + listWorktreesMock.mockResolvedValue(result) + } + + await listDetected() + + expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).not.toHaveBeenCalled() + expect(pruneCleanupScanSnapshotsMock).not.toHaveBeenCalled() + expect(pruneSpaceAnalysisSnapshotsMock).not.toHaveBeenCalled() + }) + + it('keeps prunable Git registrations live and does not probe their paths', async () => { + const staleId = `${REPO_ID}::/workspace/stale` + const liveId = `${REPO_ID}::/workspace/live` + const prunableId = `${REPO_ID}::/workspace/prunable` + store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue( + scanExpectation([staleId, liveId, prunableId]) + ) + store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId]) + listWorktreesMock.mockResolvedValue([ + worktree(REPO_PATH), + worktree('/workspace/live'), + worktree('/workspace/prunable', { prunable: true }) + ]) + + await listDetected() + + expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).toHaveBeenCalledTimes(1) + const missing = store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mock.calls[0]?.[1] as + | readonly { worktreeId: string }[] + | undefined + expect(missing?.map(({ worktreeId }) => worktreeId)).toEqual([staleId]) + expect(missing?.map(({ worktreeId }) => worktreeId)).not.toContain(liveId) + expect(missing?.map(({ worktreeId }) => worktreeId)).not.toContain(prunableId) + expect(localWorktreePathPresenceMock).toHaveBeenCalledWith(['/workspace/stale'], { + signal: undefined + }) + }) + + it('keeps the configured repo path live when Git reports a canonical spelling', async () => { + const configuredMainId = `${REPO_ID}::${REPO_PATH}` + const staleId = `${REPO_ID}::/workspace/stale` + store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue( + scanExpectation([configuredMainId, staleId]) + ) + store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId]) + listWorktreesMock.mockResolvedValue([worktree('/canonical/workspace/repo')]) + + await listDetected() + + const missing = store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mock.calls[0]?.[1] as + | readonly { worktreeId: string }[] + | undefined + expect(missing?.map(({ worktreeId }) => worktreeId)).toEqual([staleId]) + expect(missing?.map(({ worktreeId }) => worktreeId)).not.toContain(configuredMainId) + }) + + it('keeps a linked worktree whose persisted symlink spelling still exists', async () => { + const aliasId = `${REPO_ID}::/workspace/alias/linked` + const staleId = `${REPO_ID}::/workspace/stale` + const scan = scanExpectation([aliasId, staleId]) + const repo = scan.repo.expectedRepo! + const prune = vi.fn( + ( + _scan: NativeLocalWorktreeMetadataScanExpectation, + _missing: readonly { worktreeId: string }[] + ) => [] as string[] + ) + + await pruneMetadataMissingFromAuthoritativeLocalScan({ + store: { + getRepos: () => [repo], + pruneSessionlessMissingLocalWorktreeMetadataForRepo: prune + } as never, + repo, + gitWorktrees: [worktree('/workspace/real/linked')], + scan, + scanGeneration: getLocalWorktreeScanGeneration(REPO_ID), + pathsExistOrAreUnverifiable: async (pathValues) => + new Map(pathValues.map((pathValue) => [pathValue, pathValue === '/workspace/alias/linked'])) + }) + + expect(prune.mock.calls[0]?.[1].map(({ worktreeId }) => worktreeId)).toEqual([staleId]) + }) + + it('does not rematerialize a removed lineage parent metadata row', async () => { + const staleId = `${REPO_ID}::/workspace/stale` + store.captureNativeLocalWorktreeMetadataScanExpectation.mockReturnValue( + scanExpectation([staleId]) + ) + store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([staleId]) + store.getAllWorktreeLineage.mockReturnValue({ + [`${REPO_ID}::/workspace/live-child`]: { + worktreeId: `${REPO_ID}::/workspace/live-child`, + worktreeInstanceId: 'child-instance', + parentWorktreeId: staleId, + parentWorktreeInstanceId: 'stale-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 0 + } + }) + listWorktreesMock.mockResolvedValue([worktree(REPO_PATH), worktree('/workspace/live')]) + + await listDetected() + + expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).toHaveBeenCalledTimes(1) + expect(store.setWorktreeMeta.mock.calls.some(([worktreeId]) => worktreeId === staleId)).toBe( + false + ) + }) +}) diff --git a/src/main/ipc/worktrees-test-harness.ts b/src/main/ipc/worktrees-test-harness.ts index 0eacf52e472..1888156a692 100644 --- a/src/main/ipc/worktrees-test-harness.ts +++ b/src/main/ipc/worktrees-test-harness.ts @@ -7,6 +7,7 @@ import { resetRetirementCollisionKeyCacheForTests } from '../worktree-name-retir import { resetSshProviderAuthorities } from '../ssh/ssh-provider-authority' import { createWorktreeRuntimeStub, type WorktreeRuntimeStub } from './worktrees-test-runtime-stub' import { handlers, mainWindow, store } from './worktrees-test-ipc-surface' +import { configureMetadataPruningStoreMocks } from './worktrees-test-metadata-pruning-store' import { ORIGINAL_PLATFORM, setPlatform, @@ -129,10 +130,12 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub { store.getWorktreeMeta, store.getWorktreeMetaForHost, store.getAllWorktreeMeta, + store.captureNativeLocalWorktreeMetadataScanExpectation, store.setWorktreeMeta, store.setWorktreeMetaForHost, store.getProjectHostSetups, store.removeWorktreeMeta, + store.pruneSessionlessMissingLocalWorktreeMetadataForRepo, store.removeWorkspaceSessionStateForWorktree, store.getAllWorktreeLineage, store.removeWorktreeLineage, @@ -187,12 +190,13 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub { store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null }) store.getProjects.mockReturnValue([]) store.getSparsePresets.mockReturnValue([]) - store.getSettings.mockReturnValue({ + const settings = { branchPrefix: 'none', nestWorkspaces: false, refreshLocalBaseRefOnWorktreeCreate: false, workspaceDir: '/workspace' - }) + } + store.getSettings.mockReturnValue(settings) store.getWorktreeMeta.mockReturnValue(undefined) // Host-qualified accessors delegate by default so payload assertions stay on one spy; // host routing itself is covered by worktree-identity-persistence.test.ts. @@ -200,6 +204,7 @@ export function setupWorktreeHandlers(): WorktreeRuntimeStub { store.getWorktreeMeta(args[0] as string) ) store.getAllWorktreeMeta.mockReturnValue({}) + configureMetadataPruningStoreMocks(store, settings) store.getRetiredWorktreeNameRegistry.mockReturnValue({ exhaustedTiers: 0, names: [] }) resetRetirementCollisionKeyCacheForTests() store.setWorktreeMeta.mockReturnValue({}) diff --git a/src/main/ipc/worktrees-test-ipc-surface.ts b/src/main/ipc/worktrees-test-ipc-surface.ts index cc4b497b1bc..a858aacf6b8 100644 --- a/src/main/ipc/worktrees-test-ipc-surface.ts +++ b/src/main/ipc/worktrees-test-ipc-surface.ts @@ -24,10 +24,12 @@ export type TestStore = { getWorktreeMeta: KeyedStoreMock getWorktreeMetaForHost: StoreMock getAllWorktreeMeta: StoreMock + captureNativeLocalWorktreeMetadataScanExpectation: StoreMock setWorktreeMeta: KeyedStoreWriteMock setWorktreeMetaForHost: StoreMock getProjectHostSetups: StoreMock removeWorktreeMeta: KeyedStoreMock + pruneSessionlessMissingLocalWorktreeMetadataForRepo: StoreMock removeWorkspaceSessionStateForWorktree: KeyedStoreMock getAllWorktreeLineage: StoreMock removeWorktreeLineage: KeyedStoreMock @@ -58,10 +60,12 @@ export const store: TestStore = { getWorktreeMeta: vi.fn(), getWorktreeMetaForHost: vi.fn(), getAllWorktreeMeta: vi.fn(), + captureNativeLocalWorktreeMetadataScanExpectation: vi.fn(), setWorktreeMeta: vi.fn(), setWorktreeMetaForHost: vi.fn(), getProjectHostSetups: vi.fn(), removeWorktreeMeta: vi.fn(), + pruneSessionlessMissingLocalWorktreeMetadataForRepo: vi.fn(), removeWorkspaceSessionStateForWorktree: vi.fn(), getAllWorktreeLineage: vi.fn(), removeWorktreeLineage: vi.fn(), diff --git a/src/main/ipc/worktrees-test-metadata-pruning-store.ts b/src/main/ipc/worktrees-test-metadata-pruning-store.ts new file mode 100644 index 00000000000..dd60acaa027 --- /dev/null +++ b/src/main/ipc/worktrees-test-metadata-pruning-store.ts @@ -0,0 +1,35 @@ +import type { TestStore } from './worktrees-test-ipc-surface' + +export function configureMetadataPruningStoreMocks( + store: TestStore, + expectedSettings: unknown +): void { + store.captureNativeLocalWorktreeMetadataScanExpectation.mockImplementation( + (...args: unknown[]) => { + const repo = args[0] as { + id: string + path: string + connectionId?: string | null + executionHostId?: string | null + kind?: string + } + return { + repo: { + id: repo.id, + path: repo.path, + connectionId: repo.connectionId, + executionHostId: repo.executionHostId, + kind: repo.kind === 'folder' ? 'folder' : 'git', + expectedRepo: repo + }, + routing: { + expectedProject: undefined, + expectedProjectUpdatedAt: undefined, + expectedSettings + }, + metadata: [] + } + } + ) + store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mockReturnValue([]) +} diff --git a/src/main/ipc/worktrees-windows.test.ts b/src/main/ipc/worktrees-windows.test.ts index b22d7decd84..3826f5493a8 100644 --- a/src/main/ipc/worktrees-windows.test.ts +++ b/src/main/ipc/worktrees-windows.test.ts @@ -166,6 +166,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { getSettings: vi.fn(), getWorktreeMeta: vi.fn(), getAllWorktreeMeta: vi.fn(), + captureNativeLocalWorktreeMetadataScanExpectation: vi.fn(), setWorktreeMeta: vi.fn(), removeWorktreeMeta: vi.fn(), addRetiredWorktreeName: vi.fn(), @@ -211,6 +212,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => { store.getSettings.mockReset() store.getWorktreeMeta.mockReset() store.getAllWorktreeMeta.mockReset() + store.captureNativeLocalWorktreeMetadataScanExpectation.mockReset() store.setWorktreeMeta.mockReset() store.removeWorktreeMeta.mockReset() store.addRetiredWorktreeName.mockReset() @@ -254,6 +256,20 @@ describe('registerWorktreeHandlers – Windows path handling', () => { resolveSetupRunnerShellMock.mockReturnValue(undefined) store.getWorktreeMeta.mockReturnValue(undefined) store.getAllWorktreeMeta.mockReturnValue({}) + store.captureNativeLocalWorktreeMetadataScanExpectation.mockImplementation((repo) => ({ + repo: { + id: repo.id, + path: repo.path, + kind: 'git', + expectedRepo: repo + }, + routing: { + expectedProject: undefined, + expectedProjectUpdatedAt: undefined, + expectedSettings: store.getSettings() + }, + metadata: [] + })) store.getRetiredWorktreeNameRegistry.mockReturnValue({ exhaustedTiers: 0, names: [] }) store.setWorktreeMeta.mockReturnValue({}) resolveLocalGitUsernameMock.mockResolvedValue('') diff --git a/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts b/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts new file mode 100644 index 00000000000..c5ceb1acafb --- /dev/null +++ b/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts @@ -0,0 +1,141 @@ +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' +import { isFolderRepo } from '../../../../shared/repo-kind' +import type { Repo } from '../../../../shared/repo-types' +import { + FOLDER_WORKSPACE_INSTANCE_SEPARATOR, + splitWorktreeId +} from '../../../../shared/worktree/id' +import type { GitWorktreeInfo } from '../../../../shared/worktree/types' +import { isWslUncPath } from '../../../../shared/wsl-paths' +import type { Store } from '../../../persistence/loading-store/store' +import type { NativeLocalWorktreeMetadataScanExpectation } from '../../../persistence/tracking-repos/missing-local-worktree-metadata-pruning' +import { pruneWorkspaceCleanupScanSnapshots } from '../../../workspace-cleanup-scan-snapshot' +import { pruneWorkspaceSpaceAnalysisSnapshots } from '../../../workspace-space-analysis-snapshot' +import { isLocalWorktreeScanGenerationCurrent } from '../../../local-worktree-scan-generation' +import { localWorktreePathsExistOrAreUnverifiable } from '../../../local-worktree-path-presence' +import { worktreeRetentionPathComparisonKey } from '../../../worktree-retention-path-comparison' + +type AuthoritativeLocalMetadataPruneArgs = { + store: Store + repo: Repo + gitWorktrees: readonly GitWorktreeInfo[] + scan: NativeLocalWorktreeMetadataScanExpectation + scanGeneration: number + platform?: NodeJS.Platform + pathsExistOrAreUnverifiable?: ( + pathValues: readonly string[], + options?: { signal?: AbortSignal } + ) => Promise> + isCallerCurrent?: () => boolean + signal?: AbortSignal +} + +export type AuthoritativeLocalMetadataPruneResult = Readonly<{ + removedWorktreeIds: readonly string[] + preservedMetadataCandidateIds: ReadonlySet + scanGenerationCurrent: boolean +}> + +export async function pruneMetadataMissingFromAuthoritativeLocalScan({ + store, + repo, + gitWorktrees, + scan, + scanGeneration, + platform = process.platform, + pathsExistOrAreUnverifiable = localWorktreePathsExistOrAreUnverifiable, + isCallerCurrent = () => true, + signal +}: AuthoritativeLocalMetadataPruneArgs): Promise { + const capturedCandidateIds = scan.metadata.map(({ worktreeId }) => worktreeId) + const result = ( + removedWorktreeIds: readonly string[], + scanGenerationCurrent: boolean + ): AuthoritativeLocalMetadataPruneResult => { + const removedIds = new Set(removedWorktreeIds) + return { + removedWorktreeIds, + preservedMetadataCandidateIds: new Set( + capturedCandidateIds.filter((worktreeId) => !removedIds.has(worktreeId)) + ), + scanGenerationCurrent + } + } + const generationCurrent = () => isLocalWorktreeScanGenerationCurrent(repo.id, scanGeneration) + const repoOwners = store.getRepos().filter((candidate) => candidate.id === repo.id) + if ( + !generationCurrent() || + !isCallerCurrent() || + gitWorktrees.length === 0 || + repoOwners.length !== 1 || + repo.id !== scan.repo.id || + repo.path !== scan.repo.path || + repo.connectionId || + isFolderRepo(repo) || + getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID || + (platform === 'win32' && + (isWslUncPath(repo.path) || gitWorktrees.some(({ path }) => isWslUncPath(path)))) + ) { + return result([], generationCurrent()) + } + + const livePathKeys = new Set([ + // Why: Git can canonicalize a symlinked main checkout differently from the configured path. + worktreeRetentionPathComparisonKey(repo.path, platform), + ...gitWorktrees.map((worktree) => worktreeRetentionPathComparisonKey(worktree.path, platform)) + ]) + const probeCandidates = scan.metadata.flatMap((metadata) => { + const { worktreeId } = metadata + const parsed = splitWorktreeId(worktreeId) + const nativeAbsolute = parsed + ? platform === 'win32' + ? isWindowsAbsolutePathLike(parsed.worktreePath) + : parsed.worktreePath.startsWith('/') + : false + if ( + parsed?.repoId !== repo.id || + !nativeAbsolute || + isWslUncPath(parsed.worktreePath) || + worktreeId.includes(FOLDER_WORKSPACE_INSTANCE_SEPARATOR) || + livePathKeys.has(worktreeRetentionPathComparisonKey(parsed.worktreePath, platform)) + ) { + return [] + } + return [{ metadata, pathValue: parsed.worktreePath }] + }) + const presenceByPath = await pathsExistOrAreUnverifiable( + probeCandidates.map(({ pathValue }) => pathValue), + { signal } + ) + if (!generationCurrent() || !isCallerCurrent()) { + return result([], generationCurrent()) + } + const missingMetadata = probeCandidates.flatMap(({ metadata, pathValue }) => + presenceByPath.get(pathValue) === false ? [metadata] : [] + ) + if (missingMetadata.length === 0) { + return result([], generationCurrent()) + } + + if (!generationCurrent() || !isCallerCurrent()) { + return result([], generationCurrent()) + } + const removedIds = store.pruneSessionlessMissingLocalWorktreeMetadataForRepo( + scan, + missingMetadata + ) + if (removedIds.length > 0) { + const snapshotDirectory = store.getProfileStorageDirectory() + const targets: { + worktreeId: string + executionHostId: typeof LOCAL_EXECUTION_HOST_ID + }[] = removedIds.map((worktreeId) => ({ + worktreeId, + executionHostId: LOCAL_EXECUTION_HOST_ID + })) + void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets) + void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets) + } + return result(removedIds, generationCurrent()) +} diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts index d855e582161..45ccd4183bb 100644 --- a/src/main/ipc/worktrees/listing/detected-provider-listing.ts +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -4,7 +4,6 @@ import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' import type { DetectedWorktreeListResult, GitWorktreeInfo } from '../../../../shared/worktree/types' import { isFolderRepo } from '../../../../shared/repo-kind' import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' -import { pruneLineageForMissingRepoWorktrees } from '../../../worktree-lineage-pruning' import type { DirectSshDetectedWorktreeRequest } from '../../../../shared/detected-worktree-provider-contract' import { isAdmissibleDirectSshAuthority } from '../../../../shared/ssh-retained-payload-admission' import type { ListDesktopLineageForHostArgs } from '../../../../shared/host-lineage-contract' @@ -20,8 +19,10 @@ import { import { isFolderWorkspaceIdForRepo } from '../folder-workspace-model' import { hasConflictingStoredWorktreeOwner } from './worktree-host-ownership' import { + applyFreshDetectedWorktreeScanSideEffects, listDetectedGitWorktrees, - rememberLocalWorktreeRoots + type DetectedWorktreeMetadataPrune, + type DetectedWorktreeSideEffectToken } from './detected-worktree-scan-cache' import { loggedWorktreeListFailures, warnOnce } from './worktree-listing-diagnostics' import { readAllWorktreeMetaForHost } from '../../../persistence/host-qualified-worktree-meta' @@ -48,6 +49,8 @@ export async function listDetectedWorktreesForCapturedRepo( try { let gitWorktrees: GitWorktreeInfo[] let freshScan = true + let sideEffectToken: DetectedWorktreeSideEffectToken | undefined + let metadataPrune: DetectedWorktreeMetadataPrune | undefined if (isFolderRepo(repo)) { if (!isCurrent()) { return null @@ -97,6 +100,8 @@ export async function listDetectedWorktreesForCapturedRepo( const scan = await listDetectedGitWorktrees(store, repo) gitWorktrees = scan.gitWorktrees freshScan = scan.fresh + sideEffectToken = scan.sideEffectToken + metadataPrune = scan.metadataPrune } const aborted = abortedResult() if (aborted) { @@ -115,8 +120,18 @@ export async function listDetectedWorktreesForCapturedRepo( } } if (freshScan) { - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) + await applyFreshDetectedWorktreeScanSideEffects(store, repo, gitWorktrees, metadataPrune, { + isCurrent: () => isCurrent() && !providerAbort?.signal.aborted, + sideEffectToken, + signal: providerAbort?.signal + }) + const aborted = abortedResult() + if (aborted) { + return aborted + } + if (!isCurrent()) { + return null + } } loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) return { diff --git a/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts index 55b3d75e3ed..fea200f1a8b 100644 --- a/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts +++ b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts @@ -4,7 +4,19 @@ import type { Repo } from '../../../../shared/repo-types' import { getLocalProjectWorktreeGitOptions } from '../../../project-runtime-git-options' import { isFolderRepo } from '../../../../shared/repo-kind' import { listRepoWorktrees } from '../../../repo-worktrees' -import { registerWorktreeRootsForRepo } from '../../registered-worktree-roots-cache' +import { + getRegisteredWorktreeRootsRevision, + registerWorktreeRootsForRepo +} from '../../registered-worktree-roots-cache' +import type { NativeLocalWorktreeMetadataScanExpectation } from '../../../persistence/tracking-repos/missing-local-worktree-metadata-pruning' +import { + bumpLocalWorktreeScanGeneration, + getLocalWorktreeScanGeneration, + isLocalWorktreeScanGenerationCurrent, + resetLocalWorktreeScanGenerationsForTests +} from '../../../local-worktree-scan-generation' +import { pruneLineageForMissingRepoWorktrees } from '../../../worktree-lineage-pruning' +import { pruneMetadataMissingFromAuthoritativeLocalScan } from './authoritative-local-worktree-metadata-pruning' // Why: absorb renderer polling bursts while bounding external worktree-change lag to one short refresh window. export const DETECTED_WORKTREE_SCAN_CACHE_TTL_MS = 5_000 @@ -17,17 +29,31 @@ export type DetectedWorktreeScanCacheEntry = { export type DetectedWorktreeScan = { invalidated: boolean promise: Promise + sideEffectToken: DetectedWorktreeSideEffectToken + metadataPrune?: DetectedWorktreeMetadataPrune } +export type DetectedWorktreeSideEffectToken = Readonly<{ + generation: number + authorizedRootsRevision: number +}> + +export type DetectedWorktreeMetadataPrune = Readonly<{ + expectation: NativeLocalWorktreeMetadataScanExpectation +}> + export type DetectedWorktreeScanResult = { gitWorktrees: GitWorktreeInfo[] fresh: boolean + sideEffectToken?: DetectedWorktreeSideEffectToken + metadataPrune?: DetectedWorktreeMetadataPrune } export const detectedWorktreeScanCache = new Map() export const detectedWorktreeScanInFlight = new Map() export function invalidateDetectedWorktreeScanCache(repoId: string): void { + bumpLocalWorktreeScanGeneration(repoId) const keyPrefix = `${repoId}\0` for (const key of new Set([ ...detectedWorktreeScanCache.keys(), @@ -53,6 +79,7 @@ export function __resetDetectedWorktreeScanCacheForTests(): void { } detectedWorktreeScanCache.clear() detectedWorktreeScanInFlight.clear() + resetLocalWorktreeScanGenerationsForTests() } export function __getDetectedWorktreeScanCacheStatsForTests(): { @@ -88,21 +115,47 @@ export async function listDetectedGitWorktrees( return { gitWorktrees: await inFlight.promise, fresh: false } } + // Why: capture before invoking Git because listing can mutate synchronously before its first await. + // WSL listings can use UNC paths while legacy metadata keeps Linux paths; v1 cannot prove + // those aliases equivalent, so only native-host scans carry destructive expectations. + const generation = getLocalWorktreeScanGeneration(repo.id) + const authorizedRootsRevision = getRegisteredWorktreeRootsRevision(repo.id) + const metadataPruneExpectation = localWorktreeGitOptions.wslDistro + ? undefined + : store.captureNativeLocalWorktreeMetadataScanExpectation(repo) const scan: DetectedWorktreeScan = { invalidated: false, - promise: listRepoWorktrees(repo, localWorktreeGitOptions) + promise: listRepoWorktrees(repo, localWorktreeGitOptions), + sideEffectToken: { generation, authorizedRootsRevision }, + ...(metadataPruneExpectation + ? { + metadataPrune: { + expectation: metadataPruneExpectation + } + } + : {}) } detectedWorktreeScanInFlight.set(cacheKey, scan) try { const gitWorktrees = await scan.promise + const routingUnchanged = + getDetectedWorktreeScanCacheKey(repo.id, getLocalProjectWorktreeGitOptions(store, repo)) === + cacheKey // Why: a create/remove notification can invalidate mid-scan; don't let that stale scan repopulate the cache afterward. - if (!scan.invalidated) { + const generationCurrent = isLocalWorktreeScanGenerationCurrent(repo.id, generation) + if (!scan.invalidated && routingUnchanged && generationCurrent) { detectedWorktreeScanCache.set(cacheKey, { worktrees: gitWorktrees, expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS }) } - return { gitWorktrees, fresh: !scan.invalidated } + const fresh = !scan.invalidated && routingUnchanged && generationCurrent + return { + gitWorktrees, + fresh, + ...(fresh ? { sideEffectToken: scan.sideEffectToken } : {}), + ...(fresh && scan.metadataPrune ? { metadataPrune: scan.metadataPrune } : {}) + } } finally { if (detectedWorktreeScanInFlight.get(cacheKey) === scan) { detectedWorktreeScanInFlight.delete(cacheKey) @@ -110,6 +163,63 @@ export async function listDetectedGitWorktrees( } } +export async function applyFreshDetectedWorktreeScanSideEffects( + store: Store, + repo: Repo, + gitWorktrees: GitWorktreeInfo[], + metadataPrune?: DetectedWorktreeMetadataPrune, + options: { + isCurrent?: () => boolean + sideEffectToken?: DetectedWorktreeSideEffectToken + signal?: AbortSignal + } = {} +): Promise { + const { isCurrent = () => true, sideEffectToken, signal } = options + const generationCurrent = () => + sideEffectToken === undefined || + isLocalWorktreeScanGenerationCurrent(repo.id, sideEffectToken.generation) + if (!generationCurrent() || !isCurrent()) { + return false + } + let preservedMetadataCandidateIds: ReadonlySet | undefined + if (metadataPrune) { + if (!sideEffectToken) { + return false + } + const pruneResult = await pruneMetadataMissingFromAuthoritativeLocalScan({ + store, + repo, + gitWorktrees, + scan: metadataPrune.expectation, + scanGeneration: sideEffectToken.generation, + isCallerCurrent: isCurrent, + signal + }) + if (!pruneResult.scanGenerationCurrent || !generationCurrent() || !isCurrent()) { + return false + } + preservedMetadataCandidateIds = pruneResult.preservedMetadataCandidateIds + } + if (!generationCurrent() || !isCurrent()) { + return false + } + + if ( + sideEffectToken && + getRegisteredWorktreeRootsRevision(repo.id) !== sideEffectToken.authorizedRootsRevision + ) { + return false + } + rememberLocalWorktreeRoots(store, repo, gitWorktrees) + pruneLineageForMissingRepoWorktrees( + store, + repo, + gitWorktrees, + preservedMetadataCandidateIds ? { preservedMetadataCandidateIds } : undefined + ) + return true +} + export function getDetectedWorktreeScanCacheKey( repoId: string, localWorktreeGitOptions: { wslDistro?: string } = {} diff --git a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts index ac98130b5eb..4a882ff24b7 100644 --- a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -2,7 +2,6 @@ import { ipcMain } from 'electron' import { isFolderRepo } from '../../../../shared/repo-kind' import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { getSshGitProvider } from '../../../providers/ssh-git-dispatch' -import { pruneLineageForMissingRepoWorktrees } from '../../../worktree-lineage-pruning' import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../../shared/worktree/retired-name-registry' import { getRetiredNameRegistryForRepo } from '../../../worktree-name-retirement' import { @@ -13,8 +12,10 @@ import { } from './ssh-worktree-fallback' import { listVisibleFolderWorkspaces } from './folder-workspace-catalog' import { + applyFreshDetectedWorktreeScanSideEffects, listDetectedGitWorktrees, - rememberLocalWorktreeRoots + type DetectedWorktreeMetadataPrune, + type DetectedWorktreeSideEffectToken } from './detected-worktree-scan-cache' import { loggedUnavailableSshGitProviders, @@ -88,6 +89,8 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo try { let gitWorktrees let freshScan = true + let sideEffectToken: DetectedWorktreeSideEffectToken | undefined + let metadataPrune: DetectedWorktreeMetadataPrune | undefined if (isFolderRepo(repo)) { return listVisibleFolderWorkspaces(store, repo) } else if (repo.connectionId) { @@ -116,10 +119,19 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo const scan = await listDetectedGitWorktrees(store, repo) gitWorktrees = scan.gitWorktrees freshScan = scan.fresh + sideEffectToken = scan.sideEffectToken + metadataPrune = scan.metadataPrune } if (freshScan) { - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) + await applyFreshDetectedWorktreeScanSideEffects( + store, + repo, + gitWorktrees, + metadataPrune, + { + sideEffectToken + } + ) } loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) const metadata = metadataForRepo(repo) @@ -169,6 +181,8 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo try { let gitWorktrees let freshScan = true + let sideEffectToken: DetectedWorktreeSideEffectToken | undefined + let metadataPrune: DetectedWorktreeMetadataPrune | undefined if (isFolderRepo(repo)) { return listVisibleFolderWorkspaces(store, repo) } else if (repo.connectionId) { @@ -197,10 +211,13 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo const scan = await listDetectedGitWorktrees(store, repo) gitWorktrees = scan.gitWorktrees freshScan = scan.fresh + sideEffectToken = scan.sideEffectToken + metadataPrune = scan.metadataPrune } if (freshScan) { - rememberLocalWorktreeRoots(store, repo, gitWorktrees) - pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees) + await applyFreshDetectedWorktreeScanSideEffects(store, repo, gitWorktrees, metadataPrune, { + sideEffectToken + }) } loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) const metadata = allMeta ?? readAllWorktreeMetaForHost(store, getRepoExecutionHostId(repo)) diff --git a/src/main/local-worktree-path-presence.test.ts b/src/main/local-worktree-path-presence.test.ts new file mode 100644 index 00000000000..d54284f7c6b --- /dev/null +++ b/src/main/local-worktree-path-presence.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { statMock } = vi.hoisted(() => ({ + statMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ stat: statMock })) + +import { + LOCAL_WORKTREE_PATH_PROBE_TIMEOUT_MS, + localWorktreePathExistsOrIsUnverifiable, + localWorktreePathsExistOrAreUnverifiable +} from './local-worktree-path-presence' + +function fsError(code: string): NodeJS.ErrnoException { + return Object.assign(new Error(code), { code }) +} + +function installFakeAbortSignalTimeout(): AbortController[] { + const controllers: AbortController[] = [] + vi.spyOn(AbortSignal, 'timeout').mockImplementation((timeoutMs) => { + const controller = new AbortController() + controllers.push(controller) + setTimeout( + () => controller.abort(new DOMException('The operation timed out.', 'TimeoutError')), + timeoutMs + ) + return controller.signal + }) + return controllers +} + +describe('local worktree path presence', () => { + beforeEach(() => { + statMock.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('returns one result per distinct path and only treats ENOENT/ENOTDIR as missing', async () => { + statMock.mockImplementation(async (pathValue: string) => { + if (pathValue === '/missing') { + throw fsError('ENOENT') + } + if (pathValue === '/not-a-directory') { + throw fsError('ENOTDIR') + } + if (pathValue === '/permission-denied') { + throw fsError('EACCES') + } + return {} + }) + + const result = await localWorktreePathsExistOrAreUnverifiable([ + '/present', + '/missing', + '/not-a-directory', + '/permission-denied', + '/present' + ]) + + expect([...result.entries()]).toEqual([ + ['/present', true], + ['/missing', false], + ['/not-a-directory', false], + ['/permission-denied', true] + ]) + expect(statMock).toHaveBeenCalledTimes(4) + }) + + it('does not allocate a deadline when there are no paths to probe', async () => { + const timeout = vi.spyOn(AbortSignal, 'timeout') + + await expect(localWorktreePathsExistOrAreUnverifiable([])).resolves.toEqual(new Map()) + + expect(timeout).not.toHaveBeenCalled() + expect(statMock).not.toHaveBeenCalled() + }) + + it('globally bounds stat calls across simultaneous bulk probes', async () => { + let active = 0 + let maximumActive = 0 + const pending: (() => void)[] = [] + statMock.mockImplementation( + () => + new Promise((resolve) => { + active += 1 + maximumActive = Math.max(maximumActive, active) + pending.push(() => { + active -= 1 + resolve({}) + }) + }) + ) + + const first = localWorktreePathsExistOrAreUnverifiable([ + '/first-1', + '/first-2', + '/first-3', + '/first-4' + ]) + const second = localWorktreePathsExistOrAreUnverifiable([ + '/second-1', + '/second-2', + '/second-3', + '/second-4' + ]) + + let resolved = 0 + while (resolved < 8) { + while (pending.length === 0) { + await Promise.resolve() + } + pending.shift()?.() + resolved += 1 + await Promise.resolve() + } + + await Promise.all([first, second]) + + expect(maximumActive).toBe(2) + expect(statMock).toHaveBeenCalledTimes(8) + }) + + it('uses the same fail-closed classification for a single probe', async () => { + statMock.mockRejectedValueOnce(fsError('ENOENT')) + statMock.mockRejectedValueOnce(fsError('ENOTDIR')) + statMock.mockRejectedValueOnce(fsError('EIO')) + + await expect(localWorktreePathExistsOrIsUnverifiable('/missing')).resolves.toBe(false) + await expect(localWorktreePathExistsOrIsUnverifiable('/parent-missing')).resolves.toBe(false) + await expect(localWorktreePathExistsOrIsUnverifiable('/unverifiable')).resolves.toBe(true) + }) + + it('settles timed-out batches without exceeding or leaking the global stat bound', async () => { + vi.useFakeTimers() + const timeoutControllers = installFakeAbortSignalTimeout() + const releaseStats: (() => void)[] = [] + let returnMissing = false + statMock.mockImplementation(() => + returnMissing + ? Promise.reject(fsError('ENOENT')) + : new Promise((resolve) => releaseStats.push(() => resolve({}))) + ) + + try { + const firstPaths = ['/first-1', '/first-2', '/first-3', '/first-4'] + const first = localWorktreePathsExistOrAreUnverifiable(firstPaths) + let firstSettled = false + void first.then(() => { + firstSettled = true + }) + await vi.advanceTimersByTimeAsync(0) + + expect(statMock).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(LOCAL_WORKTREE_PATH_PROBE_TIMEOUT_MS - 1) + expect(firstSettled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await expect(first).resolves.toEqual( + new Map(firstPaths.map((pathValue) => [pathValue, true])) + ) + expect(statMock).toHaveBeenCalledTimes(2) + + const secondPaths = ['/second-1', '/second-2', '/second-3'] + const second = localWorktreePathsExistOrAreUnverifiable(secondPaths) + await vi.advanceTimersByTimeAsync(LOCAL_WORKTREE_PATH_PROBE_TIMEOUT_MS) + await expect(second).resolves.toEqual( + new Map(secondPaths.map((pathValue) => [pathValue, true])) + ) + expect(statMock).toHaveBeenCalledTimes(2) + + returnMissing = true + releaseStats.splice(0).forEach((release) => release()) + await vi.advanceTimersByTimeAsync(0) + + await expect(localWorktreePathsExistOrAreUnverifiable(['/after-release'])).resolves.toEqual( + new Map([['/after-release', false]]) + ) + expect(statMock).toHaveBeenCalledTimes(3) + } finally { + timeoutControllers.forEach((controller) => controller.abort()) + releaseStats.splice(0).forEach((release) => release()) + await Promise.resolve() + await Promise.resolve() + } + }) + + it('settles promptly and fail closed when the caller aborts hung probes', async () => { + vi.useFakeTimers() + const timeoutControllers = installFakeAbortSignalTimeout() + const releaseStats: (() => void)[] = [] + statMock.mockImplementation( + () => new Promise((resolve) => releaseStats.push(() => resolve({}))) + ) + const controller = new AbortController() + const paths = ['/abort-1', '/abort-2', '/abort-3'] + + try { + const result = localWorktreePathsExistOrAreUnverifiable(paths, { + signal: controller.signal + }) + await vi.advanceTimersByTimeAsync(0) + expect(statMock).toHaveBeenCalledTimes(2) + + controller.abort() + + await expect(result).resolves.toEqual(new Map(paths.map((pathValue) => [pathValue, true]))) + expect(vi.getTimerCount()).toBeGreaterThan(0) + } finally { + timeoutControllers.forEach((timeoutController) => timeoutController.abort()) + releaseStats.splice(0).forEach((release) => release()) + await Promise.resolve() + await Promise.resolve() + } + }) +}) diff --git a/src/main/local-worktree-path-presence.ts b/src/main/local-worktree-path-presence.ts new file mode 100644 index 00000000000..673305d9513 --- /dev/null +++ b/src/main/local-worktree-path-presence.ts @@ -0,0 +1,72 @@ +import { stat } from 'node:fs/promises' +import { mapWithConcurrency } from '../shared/map-with-concurrency' +import { PrioritySemaphore } from '../shared/priority-semaphore' + +// Why: share one bound across polling callers so slow mounts cannot multiply libuv work. +const LOCAL_WORKTREE_PATH_PROBE_CONCURRENCY = 2 +export const LOCAL_WORKTREE_PATH_PROBE_TIMEOUT_MS = 2_000 +const localWorktreePathProbeSemaphore = new PrioritySemaphore(LOCAL_WORKTREE_PATH_PROBE_CONCURRENCY) + +function probeDeadline(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(LOCAL_WORKTREE_PATH_PROBE_TIMEOUT_MS) + return signal ? AbortSignal.any([signal, timeout]) : timeout +} + +async function probeLocalWorktreePath(pathValue: string, signal: AbortSignal): Promise { + let release: () => void + try { + release = await localWorktreePathProbeSemaphore.acquire(0, signal) + } catch { + return true + } + if (signal.aborted) { + release() + return true + } + const presence = stat(pathValue) + .then( + () => true, + (error) => { + const code = (error as NodeJS.ErrnoException | undefined)?.code + return code !== 'ENOENT' && code !== 'ENOTDIR' + } + ) + .finally(release) + if (signal.aborted) { + return true + } + return new Promise((resolve) => { + const finish = (value: boolean): void => { + signal.removeEventListener('abort', onAbort) + resolve(value) + } + const onAbort = (): void => finish(true) + signal.addEventListener('abort', onAbort, { once: true }) + void presence.then(finish) + }) +} + +/** A non-absence filesystem error cannot prove a native-local worktree disappeared. */ +export function localWorktreePathExistsOrIsUnverifiable( + pathValue: string, + options: { signal?: AbortSignal } = {} +): Promise { + return probeLocalWorktreePath(pathValue, probeDeadline(options.signal)) +} + +export async function localWorktreePathsExistOrAreUnverifiable( + pathValues: readonly string[], + options: { signal?: AbortSignal } = {} +): Promise> { + const uniquePaths = [...new Set(pathValues)] + if (uniquePaths.length === 0) { + return new Map() + } + const signal = probeDeadline(options.signal) + const results = await mapWithConcurrency( + uniquePaths, + LOCAL_WORKTREE_PATH_PROBE_CONCURRENCY, + async (pathValue) => [pathValue, await probeLocalWorktreePath(pathValue, signal)] as const + ) + return new Map(results) +} diff --git a/src/main/local-worktree-scan-generation.ts b/src/main/local-worktree-scan-generation.ts new file mode 100644 index 00000000000..c8cc3bd0ff9 --- /dev/null +++ b/src/main/local-worktree-scan-generation.ts @@ -0,0 +1,25 @@ +const generationByRepoId = new Map() +let generationSequence = 0 + +export function getLocalWorktreeScanGeneration(repoId: string): number { + const existing = generationByRepoId.get(repoId) + if (existing !== undefined) { + return existing + } + const generation = ++generationSequence + generationByRepoId.set(repoId, generation) + return generation +} + +export function bumpLocalWorktreeScanGeneration(repoId: string): void { + generationByRepoId.set(repoId, ++generationSequence) +} + +export function isLocalWorktreeScanGenerationCurrent(repoId: string, generation: number): boolean { + return getLocalWorktreeScanGeneration(repoId) === generation +} + +export function resetLocalWorktreeScanGenerationsForTests(): void { + generationSequence += 1 + generationByRepoId.clear() +} diff --git a/src/main/memory/hydrate-local-pty-registry.test.ts b/src/main/memory/hydrate-local-pty-registry.test.ts index 7b9a90bfb73..a7a03293832 100644 --- a/src/main/memory/hydrate-local-pty-registry.test.ts +++ b/src/main/memory/hydrate-local-pty-registry.test.ts @@ -11,7 +11,10 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveFolderWorkspaceHost } from '../../shared/folder-workspace-execution-host' +import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { Repo } from '../../shared/repo-types' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { SessionInfo } from '../daemon/types' import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' import type { Store } from '../persistence' @@ -23,7 +26,6 @@ import type { } from './pty-registry' const LARGE_SESSION_COUNT = 150_000 - // Why: the hydrator pulls the daemon provider through this module-level // getter. Stubbing it lets us drive the offline / throwing / live paths // without spinning up real sockets. @@ -32,32 +34,53 @@ vi.mock('../daemon/daemon-init', () => ({ getDaemonProvider: () => getDaemonProviderMock() })) -// Why: the hydrator builds its worktreeId → connectionId map through -// listRepoWorktrees(repo). The git I/O is out of scope for this unit; the mock -// also lets count tests prove repos without live sessions launch no Git work. -const listRepoWorktreesMock = vi.fn() -vi.mock('../repo-worktrees', () => ({ - listRepoWorktrees: (repo: unknown) => listRepoWorktreesMock(repo) +const getLocalProjectWorktreeGitOptionsMock = vi.fn() +vi.mock('../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: (store: unknown, repo: unknown) => + getLocalProjectWorktreeGitOptionsMock(store, repo) })) -// Why: hydrateLocalPtyRegistryAtBoot accepts `Pick` and -// the hydrator only reads `id` + `connectionId` off each Repo, but Repo's -// required fields (path, displayName, badgeColor, addedAt) still have to be -// present at the type level. Filling them with placeholder values keeps the -// test schema-compliant without coupling to anything the hydrator doesn't -// touch. +// Why: the hydrator builds its worktreeId → connectionId map through +// listLocalRepoWorktreesStrict(repo). The git I/O is out of scope for this +// unit; the mock also lets count tests prove repos without live sessions +// launch no Git work. +const listLocalRepoWorktreesStrictMock = vi.fn() +vi.mock('../repo-worktrees', () => ({ + listLocalRepoWorktreesStrict: ( + repo: unknown, + options?: { signal?: AbortSignal; wslDistro?: string } + ) => listLocalRepoWorktreesStrictMock(repo, options) +})) + +// The runtime option helper accepts Store; mocks keep this fixture scoped to hydration reads. function makeStore( - repos: { id: string; connectionId?: string | null }[] = [] -): Pick { + repos: { + id: string + connectionId?: string | null + executionHostId?: Repo['executionHostId'] + kind?: Repo['kind'] + path?: string + }[] = [], + worktreeMeta: Record = {} +): Store { const built: Repo[] = repos.map((r) => ({ id: r.id, - path: `/tmp/${r.id}`, + path: r.path ?? `/tmp/${r.id}`, displayName: r.id, badgeColor: '#000000', addedAt: 0, - connectionId: r.connectionId ?? null + connectionId: r.connectionId ?? null, + executionHostId: r.executionHostId ?? null, + kind: r.kind })) - return { getRepos: () => built } + return { + getRepos: () => built, + getAllWorktreeMeta: () => worktreeMeta, + getAllWorktreeMetaForHost: (hostId) => + Object.fromEntries( + Object.entries(worktreeMeta).filter(([, meta]) => !meta.hostId || meta.hostId === hostId) + ) + } as Store } function makeProvider(sessions: SessionInfo[]): Pick { @@ -66,6 +89,12 @@ function makeProvider(sessions: SessionInfo[]): Pick[]): { + getAllAdapters: () => Pick[] +} { + return { getAllAdapters: () => adapters } +} + function makeLocalSessions(repoId: string, worktreePath: string, count: number): SessionInfo[] { const sessions: SessionInfo[] = [] for (let index = 0; index < count; index += 1) { @@ -98,6 +127,8 @@ function createDeferred(): { // a coherent pair. async function loadFresh(): Promise<{ hydrate: typeof HydrateFn + deadlineMs: number + gitEnumerationConcurrency: number listRegisteredPtys: typeof ListFn registerPty: typeof RegisterFn unregisterPty: typeof UnregisterFn @@ -107,6 +138,8 @@ async function loadFresh(): Promise<{ const registryMod = await import('./pty-registry') return { hydrate: hydrateMod.hydrateLocalPtyRegistryAtBoot, + deadlineMs: hydrateMod.LOCAL_PTY_REGISTRY_BOOT_HYDRATION_DEADLINE_MS, + gitEnumerationConcurrency: hydrateMod.LOCAL_PTY_REGISTRY_GIT_ENUMERATION_CONCURRENCY, listRegisteredPtys: registryMod.listRegisteredPtys, registerPty: registryMod.registerPty, unregisterPty: registryMod.unregisterPty @@ -116,7 +149,8 @@ async function loadFresh(): Promise<{ describe('hydrateLocalPtyRegistryAtBoot', () => { beforeEach(() => { getDaemonProviderMock.mockReset() - listRepoWorktreesMock.mockReset() + getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({}) + listLocalRepoWorktreesStrictMock.mockReset() }) it('no-op when daemon provider is null at first call (retries on later activation)', async () => { @@ -126,19 +160,19 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { await hydrate(makeStore([{ id: 'repo-a' }])) expect(listRegisteredPtys()).toHaveLength(0) - expect(listRepoWorktreesMock).not.toHaveBeenCalled() + expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled() // Why: the design says the hasHydrated guard must stay false until a // provider is obtained, so a later macOS dock re-activation can retry. // Provider becomes available; second call should now perform the pass. const provider = makeProvider([]) getDaemonProviderMock.mockReturnValue(provider) - listRepoWorktreesMock.mockResolvedValue([]) + listLocalRepoWorktreesStrictMock.mockResolvedValue([]) await hydrate(makeStore([{ id: 'repo-a' }])) expect(provider.listSessions).toHaveBeenCalledTimes(1) - expect(listRepoWorktreesMock).not.toHaveBeenCalled() + expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled() }) it('catches provider.listSessions rejection and does not throw', async () => { @@ -147,7 +181,9 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { listSessions: vi.fn().mockRejectedValue(new Error('socket EPIPE')) } getDaemonProviderMock.mockReturnValue(provider) - listRepoWorktreesMock.mockResolvedValue([{ path: '/local/Triton', isMainWorktree: true }]) + listLocalRepoWorktreesStrictMock.mockResolvedValue([ + { path: '/local/Triton', isMainWorktree: true } + ]) // Why: the renderer-side step-2 merge fallback covers this case. The // hydrator must not surface the failure to the caller — it logs and @@ -157,7 +193,234 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { warnSpy.mockRestore() expect(listRegisteredPtys()).toHaveLength(0) - expect(listRepoWorktreesMock).not.toHaveBeenCalled() + expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled() + }) + + it('coalesces concurrent calls onto one inventory attempt', async () => { + const { hydrate } = await loadFresh() + const inventory = createDeferred() + const provider = { listSessions: vi.fn(() => inventory.promise) } + getDaemonProviderMock.mockReturnValue(provider) + + const first = hydrate(makeStore()) + const second = hydrate(makeStore()) + + expect(first).toBe(second) + expect(provider.listSessions).toHaveBeenCalledOnce() + + inventory.resolve([]) + await first + }) + + it('retries after worktree enumeration throws', async () => { + const { hydrate, listRegisteredPtys } = await loadFresh() + const ptyId = 'repo-a::/local/repo-a@@00000001' + const provider = makeProvider([ + { sessionId: ptyId, pid: 4001, cwd: '/local/repo-a' } as unknown as SessionInfo + ]) + getDaemonProviderMock.mockReturnValue(provider) + listLocalRepoWorktreesStrictMock + .mockRejectedValueOnce(new Error('git unavailable')) + .mockResolvedValue([ + { path: '/local/repo-a', head: '', branch: '', isBare: false, isMainWorktree: true } + ]) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await hydrate(makeStore([{ id: 'repo-a' }])) + expect(listRegisteredPtys()).toHaveLength(0) + expect(listLocalRepoWorktreesStrictMock).toHaveBeenCalledOnce() + + await hydrate(makeStore([{ id: 'repo-a' }])) + warnSpy.mockRestore() + + expect(listRegisteredPtys()).toEqual([expect.objectContaining({ ptyId })]) + expect(listLocalRepoWorktreesStrictMock).toHaveBeenCalledTimes(2) + }) + + it('forwards the WSL project runtime and live deadline signal to strict enumeration', async () => { + const { hydrate } = await loadFresh() + const ptyId = 'repo-a::/mnt/c/repo-a@@00000001' + const store = makeStore([{ id: 'repo-a', path: '/mnt/c/repo-a' }]) + getDaemonProviderMock.mockReturnValue( + makeProvider([ + { sessionId: ptyId, pid: 4001, cwd: '/mnt/c/repo-a' } as unknown as SessionInfo + ]) + ) + getLocalProjectWorktreeGitOptionsMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + listLocalRepoWorktreesStrictMock.mockResolvedValue([ + { path: '/mnt/c/repo-a', head: '', branch: '', isBare: false, isMainWorktree: true } + ]) + + await hydrate(store) + + const [repo, options] = listLocalRepoWorktreesStrictMock.mock.calls[0] as [ + Repo, + { signal: AbortSignal; wslDistro?: string } + ] + expect(getLocalProjectWorktreeGitOptionsMock).toHaveBeenCalledWith(store, repo) + expect(options.wslDistro).toBe('Ubuntu') + expect(options.signal).toBeInstanceOf(AbortSignal) + expect(options.signal.aborted).toBe(false) + }) + + it('resolves by the boot deadline and lets a later call retry stalled enumeration', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { hydrate, deadlineMs, listRegisteredPtys } = await loadFresh() + const ptyId = 'repo-a::/local/repo-a@@00000001' + const provider = makeProvider([ + { sessionId: ptyId, pid: 4001, cwd: '/local/repo-a' } as unknown as SessionInfo + ]) + const signals: AbortSignal[] = [] + getDaemonProviderMock.mockReturnValue(provider) + listLocalRepoWorktreesStrictMock + .mockImplementationOnce((_repo: Repo, options?: { signal?: AbortSignal }) => { + if (options?.signal) { + signals.push(options.signal) + } + return new Promise(() => {}) + }) + .mockImplementation(async (_repo: Repo, options?: { signal?: AbortSignal }) => { + if (options?.signal) { + signals.push(options.signal) + } + return [ + { + path: '/local/repo-a', + head: '', + branch: '', + isBare: false, + isMainWorktree: true + } + ] + }) + + let settled = false + const first = hydrate(makeStore([{ id: 'repo-a' }])).then(() => { + settled = true + }) + await vi.advanceTimersByTimeAsync(deadlineMs - 1) + expect(settled).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + expect(settled).toBe(true) + await first + expect(signals[0]?.aborted).toBe(true) + + await hydrate(makeStore([{ id: 'repo-a' }])) + + expect(listLocalRepoWorktreesStrictMock).toHaveBeenCalledTimes(2) + expect(listRegisteredPtys()).toEqual([expect.objectContaining({ ptyId })]) + expect(vi.getTimerCount()).toBe(0) + } finally { + warnSpy.mockRestore() + vi.clearAllTimers() + vi.useRealTimers() + } + }) + + it('bounds concurrent Git worktree enumeration', async () => { + const { hydrate, gitEnumerationConcurrency } = await loadFresh() + const repoCount = 6 + const gate = createDeferred() + let active = 0 + let maxActive = 0 + const sessions = Array.from({ length: repoCount }, (_, index) => { + const repoId = `repo-${index}` + return { + sessionId: `${repoId}::/local/${repoId}@@0000000${index}`, + pid: 4000 + index, + cwd: `/local/${repoId}` + } as unknown as SessionInfo + }) + getDaemonProviderMock.mockReturnValue(makeProvider(sessions)) + listLocalRepoWorktreesStrictMock.mockImplementation(async (repo: Repo) => { + active += 1 + maxActive = Math.max(maxActive, active) + await gate.promise + active -= 1 + return [ + { path: `/local/${repo.id}`, head: '', branch: '', isBare: false, isMainWorktree: true } + ] + }) + + const hydration = hydrate( + makeStore(Array.from({ length: repoCount }, (_, index) => ({ id: `repo-${index}` }))) + ) + let startedBeforeRelease = 0 + try { + await vi.waitFor(() => + expect(listLocalRepoWorktreesStrictMock).toHaveBeenCalledTimes(gitEnumerationConcurrency) + ) + startedBeforeRelease = listLocalRepoWorktreesStrictMock.mock.calls.length + } finally { + gate.resolve(undefined) + await hydration + } + + expect(startedBeforeRelease).toBe(gitEnumerationConcurrency) + expect(maxActive).toBe(gitEnumerationConcurrency) + }) + + it('registers successful adapter rows but retries a failed adapter', async () => { + const { hydrate, listRegisteredPtys } = await loadFresh() + const sessionA = { + sessionId: 'repo-a::/local/repo-a@@00000001', + pid: 4001, + cwd: '/local/repo-a' + } as unknown as SessionInfo + const sessionB = { + sessionId: 'repo-b::/local/repo-b@@00000002', + pid: 4002, + cwd: '/local/repo-b' + } as unknown as SessionInfo + const adapterA = makeProvider([sessionA]) + const adapterB = { + listSessions: vi + .fn() + .mockRejectedValueOnce(new Error('legacy unavailable')) + .mockRejectedValueOnce(new Error('legacy unavailable')) + .mockResolvedValue([sessionB]) + } + getDaemonProviderMock.mockReturnValue(makeProviderGroup([adapterA, adapterB])) + listLocalRepoWorktreesStrictMock.mockImplementation(async (repo: Repo) => [ + { path: `/local/${repo.id}`, head: '', branch: '', isBare: false, isMainWorktree: true } + ]) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const store = makeStore([{ id: 'repo-a' }, { id: 'repo-b' }]) + + await hydrate(store) + expect(listRegisteredPtys().map((pty) => pty.ptyId)).toEqual([sessionA.sessionId]) + + await hydrate(store) + warnSpy.mockRestore() + + expect( + listRegisteredPtys() + .map((pty) => pty.ptyId) + .sort() + ).toEqual([sessionA.sessionId, sessionB.sessionId].sort()) + }) + + it('retries an all-adapter inventory failure and latches only the complete retry', async () => { + const { hydrate } = await loadFresh() + const firstAdapter = { + listSessions: vi.fn().mockRejectedValueOnce(new Error('current down')).mockResolvedValue([]) + } + const secondAdapter = { + listSessions: vi.fn().mockRejectedValueOnce(new Error('legacy down')).mockResolvedValue([]) + } + getDaemonProviderMock.mockReturnValue(makeProviderGroup([firstAdapter, secondAdapter])) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await hydrate(makeStore()) + await hydrate(makeStore()) + await hydrate(makeStore()) + warnSpy.mockRestore() + + expect(firstAdapter.listSessions).toHaveBeenCalledTimes(2) + expect(secondAdapter.listSessions).toHaveBeenCalledTimes(2) }) it('does not clobber a pre-existing registry pid with a null pid from listSessions', async () => { @@ -183,7 +446,7 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { { sessionId: ptyId, pid: null, cwd: '/local/Triton' } as unknown as SessionInfo ]) getDaemonProviderMock.mockReturnValue(provider) - listRepoWorktreesMock.mockResolvedValue([ + listLocalRepoWorktreesStrictMock.mockResolvedValue([ { path: '/local/Triton', head: '', branch: '', isBare: false, isMainWorktree: true } ]) @@ -193,7 +456,7 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { expect(entry).toBeDefined() expect(entry!.pid).toBe(12345) expect(entry!.paneKey).toBe('tab-1:1') - expect(listRepoWorktreesMock).not.toHaveBeenCalled() + expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled() }) it('does not clobber a pty:spawn registration that arrives during worktree enumeration', async () => { @@ -207,10 +470,10 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { createDeferred< { path: string; head: string; branch: string; isBare: boolean; isMainWorktree: boolean }[] >() - listRepoWorktreesMock.mockReturnValue(worktrees.promise) + listLocalRepoWorktreesStrictMock.mockReturnValue(worktrees.promise) const hydration = hydrate(makeStore([{ id: 'repo-a', connectionId: null }])) - await vi.waitFor(() => expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(listLocalRepoWorktreesStrictMock).toHaveBeenCalledTimes(1)) registerPty({ ptyId, worktreeId: 'repo-a::/local/Triton', @@ -244,10 +507,10 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { createDeferred< { path: string; head: string; branch: string; isBare: boolean; isMainWorktree: boolean }[] >() - listRepoWorktreesMock.mockReturnValue(worktrees.promise) + listLocalRepoWorktreesStrictMock.mockReturnValue(worktrees.promise) const hydration = hydrate(makeStore([{ id: 'repo-a', connectionId: null }])) - await vi.waitFor(() => expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(listLocalRepoWorktreesStrictMock).toHaveBeenCalledTimes(1)) unregisterPty(ptyId) worktrees.resolve([ { path: '/local/Triton', head: '', branch: '', isBare: false, isMainWorktree: true } @@ -266,7 +529,7 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { { sessionId: ptyId, pid: 999, cwd: '/remote/Stingray' } as unknown as SessionInfo ]) getDaemonProviderMock.mockReturnValue(provider) - listRepoWorktreesMock.mockResolvedValue([ + listLocalRepoWorktreesStrictMock.mockResolvedValue([ { path: '/remote/Stingray', head: '', branch: '', isBare: false, isMainWorktree: true } ]) @@ -276,7 +539,24 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { // visible to the local process sampler. Mirrors the spawn-time gate // around `registerPty` in `pty.ts`'s `pty:spawn` handler. expect(listRegisteredPtys()).toHaveLength(0) - expect(listRepoWorktreesMock).not.toHaveBeenCalled() + expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled() + }) + + it('skips paired-runtime repos even when connectionId is absent', async () => { + const { hydrate, listRegisteredPtys } = await loadFresh() + const provider = makeProvider([ + { + sessionId: 'repo-runtime::/runtime/Stingray@@feedface', + pid: 999, + cwd: '/runtime/Stingray' + } as unknown as SessionInfo + ]) + getDaemonProviderMock.mockReturnValue(provider) + + await hydrate(makeStore([{ id: 'repo-runtime', executionHostId: 'runtime:environment-1' }])) + + expect(listRegisteredPtys()).toHaveLength(0) + expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled() }) it('registers a local session whose worktree is in the store with the daemon-published pid', async () => { @@ -287,7 +567,7 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { { sessionId: ptyId, pid: 4242, cwd: '/local/Triton' } as unknown as SessionInfo ]) getDaemonProviderMock.mockReturnValue(provider) - listRepoWorktreesMock.mockResolvedValue([ + listLocalRepoWorktreesStrictMock.mockResolvedValue([ { path: '/local/Triton', head: '', branch: '', isBare: false, isMainWorktree: true } ]) @@ -299,6 +579,221 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { expect(entry!.worktreeId).toBe('repo-a::/local/Triton') }) + it('matches Windows worktree path spelling while preserving the daemon worktree id', async () => { + const { hydrate, listRegisteredPtys } = await loadFresh() + const worktreeId = 'repo-a::C:/Users/Neil/Orca' + const ptyId = `${worktreeId}@@cafebabe` + getDaemonProviderMock.mockReturnValue( + makeProvider([ + { sessionId: ptyId, pid: 4242, cwd: 'C:/Users/Neil/Orca' } as unknown as SessionInfo + ]) + ) + listLocalRepoWorktreesStrictMock.mockResolvedValue([ + { + path: 'c:\\users\\neil\\orca', + head: '', + branch: '', + isBare: false, + isMainWorktree: true + } + ]) + + await hydrate(makeStore([{ id: 'repo-a', path: 'C:\\Users\\Neil\\Orca' }])) + + expect(listRegisteredPtys()).toEqual([expect.objectContaining({ ptyId, worktreeId })]) + }) + + it('fails closed when live worktrees collide on one normalized key', async () => { + const { hydrate, listRegisteredPtys } = await loadFresh() + const ptyId = 'repo-a::C:/Users/Neil/Orca@@cafebabe' + getDaemonProviderMock.mockReturnValue( + makeProvider([ + { sessionId: ptyId, pid: 4242, cwd: 'C:/Users/Neil/Orca' } as unknown as SessionInfo + ]) + ) + listLocalRepoWorktreesStrictMock.mockResolvedValue([ + { + path: 'C:/Users/Neil/Orca', + head: '', + branch: '', + isBare: false, + isMainWorktree: true + }, + { + path: 'c:\\users\\neil\\orca', + head: '', + branch: '', + isBare: false, + isMainWorktree: false + } + ]) + + await hydrate(makeStore([{ id: 'repo-a', path: 'C:/Users/Neil/Orca' }])) + + expect(listRegisteredPtys()).toHaveLength(0) + }) + + it('matches local WSL UNC aliases without treating WSL as a remote host', async () => { + const { hydrate, listRegisteredPtys } = await loadFresh() + const worktreeId = 'repo-a::\\\\wsl$\\Ubuntu\\home\\neil\\orca' + const ptyId = `${worktreeId}@@cafebabe` + getDaemonProviderMock.mockReturnValue( + makeProvider([ + { + sessionId: ptyId, + pid: 4242, + cwd: '\\\\wsl$\\Ubuntu\\home\\neil\\orca' + } as unknown as SessionInfo + ]) + ) + listLocalRepoWorktreesStrictMock.mockResolvedValue([ + { + path: '\\\\wsl.localhost\\ubuntu\\home\\neil\\orca', + head: '', + branch: '', + isBare: false, + isMainWorktree: true + } + ]) + + await hydrate(makeStore([{ id: 'repo-a', path: '\\\\wsl$\\Ubuntu\\home\\neil\\orca' }])) + + expect(listRegisteredPtys()).toEqual([expect.objectContaining({ ptyId, worktreeId })]) + }) + + it('preserves an exact repo-backed folder instance id from local-host metadata', async () => { + const { hydrate, listRegisteredPtys } = await loadFresh() + const instanceId = + 'folder-repo::/workspace/folder::workspace:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const ptyId = `${instanceId}@@cafebabe` + getDaemonProviderMock.mockReturnValue( + makeProvider([ + { sessionId: ptyId, pid: 4242, cwd: '/workspace/folder' } as unknown as SessionInfo + ]) + ) + + await hydrate( + makeStore([{ id: 'folder-repo', kind: 'folder', path: '/workspace/folder' }], { + [instanceId]: { hostId: 'local' } as WorktreeMeta + }) + ) + + expect(listRegisteredPtys()).toEqual([ + expect.objectContaining({ ptyId, worktreeId: instanceId }) + ]) + expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled() + }) + + it('accepts legacy unhosted folder-instance metadata for a uniquely local repo', async () => { + const { hydrate, listRegisteredPtys } = await loadFresh() + const instanceId = + 'folder-repo::/workspace/folder::workspace:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const ptyId = `${instanceId}@@cafebabe` + getDaemonProviderMock.mockReturnValue( + makeProvider([ + { sessionId: ptyId, pid: 4242, cwd: '/workspace/folder' } as unknown as SessionInfo + ]) + ) + + await hydrate( + makeStore([{ id: 'folder-repo', kind: 'folder', path: '/workspace/folder' }], { + [instanceId]: {} as WorktreeMeta + }) + ) + + expect(listRegisteredPtys()).toEqual([expect.objectContaining({ ptyId })]) + }) + + it('rejects legacy folder-instance metadata when the repo id also has a remote owner', async () => { + const { hydrate, listRegisteredPtys } = await loadFresh() + const instanceId = + 'folder-repo::/workspace/folder::workspace:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + getDaemonProviderMock.mockReturnValue( + makeProvider([ + { + sessionId: `${instanceId}@@cafebabe`, + pid: 4242, + cwd: '/workspace/folder' + } as unknown as SessionInfo + ]) + ) + + await hydrate( + makeStore( + [ + { id: 'folder-repo', kind: 'folder', path: '/workspace/folder' }, + { + id: 'folder-repo', + kind: 'folder', + path: '/workspace/folder', + executionHostId: 'runtime:environment-1' + } + ], + { [instanceId]: {} as WorktreeMeta } + ) + ) + + expect(listRegisteredPtys()).toHaveLength(0) + }) + + it('accepts explicit local folder-instance metadata beside a same-id runtime owner', async () => { + const { hydrate, listRegisteredPtys } = await loadFresh() + const instanceId = + 'folder-repo::/workspace/folder::workspace:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const ptyId = `${instanceId}@@cafebabe` + getDaemonProviderMock.mockReturnValue( + makeProvider([ + { sessionId: ptyId, pid: 4242, cwd: '/workspace/folder' } as unknown as SessionInfo + ]) + ) + + await hydrate( + makeStore( + [ + { id: 'folder-repo', kind: 'folder', path: '/workspace/folder' }, + { + id: 'folder-repo', + kind: 'folder', + path: '/workspace/folder', + executionHostId: 'runtime:environment-1' + } + ], + { [instanceId]: { hostId: 'local' } as WorktreeMeta } + ) + ) + + expect(listRegisteredPtys()).toEqual([expect.objectContaining({ ptyId })]) + }) + + it('keeps true folder workspace PTY ids as an accepted hydration gap', async () => { + const { hydrate, listRegisteredPtys } = await loadFresh() + const workspace = { + id: 'folder-workspace-1', + executionHostId: 'local' + } as FolderWorkspace + getDaemonProviderMock.mockReturnValue( + makeProvider([ + { + sessionId: 'folder:folder-workspace-1@@cafebabe', + pid: 4242, + cwd: '/workspace/folder' + } as unknown as SessionInfo + ]) + ) + + expect( + resolveFolderWorkspaceHost( + { folderWorkspaces: [workspace], projectGroups: [], repos: [] }, + workspace.id + ) + ).toEqual({ kind: 'local' }) + + await hydrate(makeStore()) + + expect(listRegisteredPtys()).toHaveLength(0) + expect(listLocalRepoWorktreesStrictMock).not.toHaveBeenCalled() + }) + it('does not register a daemon session whose worktree was removed', async () => { const { hydrate, listRegisteredPtys } = await loadFresh() const provider = makeProvider([ @@ -309,13 +804,13 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { } as unknown as SessionInfo ]) getDaemonProviderMock.mockReturnValue(provider) - listRepoWorktreesMock.mockResolvedValue([ + listLocalRepoWorktreesStrictMock.mockResolvedValue([ { path: '/local/current', head: '', branch: '', isBare: false, isMainWorktree: true } ]) await hydrate(makeStore([{ id: 'repo-a', connectionId: null }])) - expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1) + expect(listLocalRepoWorktreesStrictMock).toHaveBeenCalledTimes(1) expect(listRegisteredPtys()).toHaveLength(0) }) @@ -334,7 +829,7 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { ) ) getDaemonProviderMock.mockReturnValue(provider) - listRepoWorktreesMock.mockImplementation(async (repo: Repo) => [ + listLocalRepoWorktreesStrictMock.mockImplementation(async (repo: Repo) => [ { path: `/local/${repo.id}`, head: '', @@ -346,8 +841,8 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { await hydrate(makeStore(repos)) - expect(listRepoWorktreesMock).toHaveBeenCalledTimes(activeRepoIds.length) - expect(listRepoWorktreesMock.mock.calls.map(([repo]) => (repo as Repo).id)).toEqual( + expect(listLocalRepoWorktreesStrictMock).toHaveBeenCalledTimes(activeRepoIds.length) + expect(listLocalRepoWorktreesStrictMock.mock.calls.map(([repo]) => (repo as Repo).id)).toEqual( activeRepoIds ) expect(listRegisteredPtys()).toHaveLength(activeRepoIds.length) @@ -374,13 +869,13 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { .mockResolvedValueOnce([sessionA]) .mockResolvedValue([sessionA, sessionB]) getDaemonProviderMock.mockReturnValue({ listSessions }) - listRepoWorktreesMock.mockImplementation(async (repo: Repo) => [ + listLocalRepoWorktreesStrictMock.mockImplementation(async (repo: Repo) => [ { path: `/local/${repo.id}`, head: '', branch: '', isBare: false, isMainWorktree: true } ]) await hydrate(makeStore([{ id: 'repo-a' }, { id: 'repo-b' }])) - expect(listRepoWorktreesMock.mock.calls.map(([repo]) => (repo as Repo).id)).toEqual([ + expect(listLocalRepoWorktreesStrictMock.mock.calls.map(([repo]) => (repo as Repo).id)).toEqual([ 'repo-a', 'repo-b' ]) @@ -398,7 +893,7 @@ describe('hydrateLocalPtyRegistryAtBoot', () => { const sessions = makeLocalSessions('repo-a', '/local/Triton', LARGE_SESSION_COUNT) const provider = makeProvider(sessions) getDaemonProviderMock.mockReturnValue(provider) - listRepoWorktreesMock.mockResolvedValue([ + listLocalRepoWorktreesStrictMock.mockResolvedValue([ { path: '/local/Triton', head: '', branch: '', isBare: false, isMainWorktree: true } ]) diff --git a/src/main/memory/hydrate-local-pty-registry.ts b/src/main/memory/hydrate-local-pty-registry.ts index 3388eeebed9..b384439f0f2 100644 --- a/src/main/memory/hydrate-local-pty-registry.ts +++ b/src/main/memory/hydrate-local-pty-registry.ts @@ -1,146 +1,279 @@ -/** - * Boot-time hydration of `pty-registry` from the live daemon. - * - * Why: on warm reattach (fresh Orca process, still-running daemon) the renderer - * hasn't re-mounted every pane, so `pty:spawn` never fired for those sessions - * and they surfaced as "REMOTE" despite being local. Fill the gap once at boot, - * registering only sessions whose repo has no `connectionId` — mirroring the - * spawn-time gate in `src/main/ipc/pty.ts`. - */ - -import { getDaemonProvider } from '../daemon/daemon-init' -import { DaemonPtyRouter } from '../daemon/daemon-pty-router' -import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider' -import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' -import type { SessionInfo } from '../daemon/types' -import { listRegisteredPtys, registerPty } from './pty-registry' -import { listRepoWorktrees } from '../repo-worktrees' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' import { parsePtySessionId } from '../../shared/pty-session-id-format' -import { splitWorktreeId } from '../../shared/worktree/id' +import { isFolderRepo } from '../../shared/repo-kind' +import type { Repo } from '../../shared/repo-types' +import { splitWorktreeId, worktreeIdComparisonKey } from '../../shared/worktree/id' +import { getDaemonProvider } from '../daemon/daemon-init' +import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' +import type { DaemonPtyRouter } from '../daemon/daemon-pty-router' +import type { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider' +import type { SessionInfo } from '../daemon/types' +import { isFolderWorkspaceIdForRepo } from '../ipc/worktrees/folder-workspace-model' import type { Store } from '../persistence' +import { readAllWorktreeMetaForHost } from '../persistence/host-qualified-worktree-meta' +import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import { listLocalRepoWorktreesStrict } from '../repo-worktrees' +import { listRegisteredPtys, registerPty } from './pty-registry' + +type HydrationStore = Store + +type DaemonInventory = { + complete: boolean + sessions: SessionInfo[] +} + +type LocalRepoCatalog = { + byId: Map + ownerCountById: Map +} + +// Why: matches existing local Git/read startup budgets while allowing for Defender-heavy repos. +export const LOCAL_PTY_REGISTRY_BOOT_HYDRATION_DEADLINE_MS = 5_000 +export const LOCAL_PTY_REGISTRY_GIT_ENUMERATION_CONCURRENCY = 4 -// Why: macOS dock reactivation reruns setup; avoid repeating Git I/O and daemon RPC. let hasHydrated = false +let hydrationInFlight: Promise | null = null -/** - * Read the live daemon session list and register every local session the - * registry doesn't already know about. - * - * Why: failures here are a coverage degradation (the renderer-side union still - * covers the gap), not a correctness regression, so they're swallowed. - */ -export async function hydrateLocalPtyRegistryAtBoot(store: Pick): Promise { - try { - if (hasHydrated) { - return - } - const provider = getDaemonProvider() - if (!provider) { - // Why: leave hasHydrated false so a later activation can retry once the daemon is up. - return - } - // Why: flip only after a provider exists; retrying a failed RPC on the same socket won't help. - hasHydrated = true +export function hydrateLocalPtyRegistryAtBoot(store: HydrationStore): Promise { + if (hasHydrated) { + return Promise.resolve() + } + if (hydrationInFlight) { + return hydrationInFlight + } - // Why: enumerate Git worktrees only for live-session repos. - const reposById = new Map(store.getRepos().map((repo) => [repo.id, repo])) - // Why: verify via live git that a referenced worktree still exists, to avoid resurrecting removed ones. - const liveLocalWorktreeIds = new Set() - const resolvedRepoIds = new Set() - - let sessionInfos = await collectSessionInfos(provider) - let alreadyRegistered = new Set(listRegisteredPtys().map((p) => p.ptyId)) - - // Why: re-read live sessions each pass to avoid resurrecting exited sessions. - for (;;) { - const newlyReferencedRepos = new Map[number]>() - for (const info of sessionInfos) { - if (alreadyRegistered.has(info.sessionId)) { - continue - } - const { worktreeId } = parsePtySessionId(info.sessionId) - const parsedWorktreeId = worktreeId ? splitWorktreeId(worktreeId) : null - if (!parsedWorktreeId || resolvedRepoIds.has(parsedWorktreeId.repoId)) { - continue - } - const repo = reposById.get(parsedWorktreeId.repoId) - if (!repo || (repo.connectionId ?? null)) { - // Why: unknown and SSH repos cannot be proven local, so do not enumerate them. - resolvedRepoIds.add(parsedWorktreeId.repoId) - continue - } - newlyReferencedRepos.set(repo.id, repo) + const controller = new AbortController() + const deadline = setTimeout(() => { + controller.abort(new Error('Boot-time pty-registry hydration deadline expired')) + }, LOCAL_PTY_REGISTRY_BOOT_HYDRATION_DEADLINE_MS) + let attempt!: Promise + attempt = waitForPromiseWithSignal( + hydrateLocalPtyRegistry(store, controller.signal), + controller.signal + ) + .then((complete) => { + if (complete) { + hasHydrated = true } - if (newlyReferencedRepos.size === 0) { - break + }) + .catch((error) => { + console.warn( + '[memory] Boot-time pty-registry hydration failed:', + error instanceof Error ? error.message : String(error) + ) + }) + .finally(() => { + clearTimeout(deadline) + if (hydrationInFlight === attempt) { + hydrationInFlight = null } + }) + hydrationInFlight = attempt + return attempt +} - for (const repo of newlyReferencedRepos.values()) { - resolvedRepoIds.add(repo.id) - const worktrees = await listRepoWorktrees(repo) - for (const wt of worktrees) { - liveLocalWorktreeIds.add(`${repo.id}::${wt.path}`) - } - } +async function hydrateLocalPtyRegistry( + store: HydrationStore, + signal: AbortSignal +): Promise { + throwIfSignalAborted(signal) + const provider = getDaemonProvider() + if (!provider) { + return false + } - sessionInfos = await collectSessionInfos(provider) - alreadyRegistered = new Set(listRegisteredPtys().map((p) => p.ptyId)) - } - for (const info of sessionInfos) { - // Why: pty:spawn is the authoritative pid writer; don't overwrite its entry with a stale listSessions() pid. + const repoCatalog = getLocalRepoCatalog(store.getRepos()) + const reposById = repoCatalog.byId + const verifiedFolderWorktreeIds = getVerifiedFolderWorktreeIds(store, repoCatalog) + const liveGitWorktreeIdsByKey = new Map() + const resolvedRepoIds = new Set() + let inventory = await collectSessionInfos(provider, signal) + let complete = inventory.complete + let alreadyRegistered = new Set(listRegisteredPtys().map((pty) => pty.ptyId)) + + for (;;) { + const referencedRepos = new Map() + for (const info of inventory.sessions) { if (alreadyRegistered.has(info.sessionId)) { continue } - const { worktreeId } = parsePtySessionId(info.sessionId) - if (!worktreeId) { + const parsed = splitWorktreeId(parsePtySessionId(info.sessionId).worktreeId ?? '') + if (!parsed || resolvedRepoIds.has(parsed.repoId)) { continue } - // Why: register only proven-local worktrees, matching the spawn-time gate. - if (!liveLocalWorktreeIds.has(worktreeId)) { + const repo = reposById.get(parsed.repoId) + if (!repo || isFolderRepo(repo)) { + resolvedRepoIds.add(parsed.repoId) continue } - registerPty({ - ptyId: info.sessionId, - worktreeId, - sessionId: info.sessionId, - paneKey: null, - pid: - typeof info.pid === 'number' && Number.isFinite(info.pid) && info.pid > 0 - ? info.pid - : null - }) + referencedRepos.set(repo.id, repo) } - } catch (err) { - console.warn( - '[memory] Boot-time pty-registry hydration failed:', - err instanceof Error ? err.message : String(err) + if (referencedRepos.size === 0) { + break + } + for (const repo of referencedRepos.values()) { + resolvedRepoIds.add(repo.id) + } + + const worktreeResults = await mapSettledWithConcurrency( + [...referencedRepos.values()], + LOCAL_PTY_REGISTRY_GIT_ENUMERATION_CONCURRENCY, + async (repo) => { + throwIfSignalAborted(signal) + const worktrees = await waitForPromiseWithSignal( + listLocalRepoWorktreesStrict(repo, { + ...getLocalProjectWorktreeGitOptions(store, repo), + signal + }), + signal + ) + throwIfSignalAborted(signal) + return { repo, worktrees } + } ) + throwIfSignalAborted(signal) + + for (const result of worktreeResults) { + if (result.status === 'rejected') { + complete = false + console.warn( + '[memory] Worktree enumeration failed during pty-registry hydration:', + result.reason instanceof Error ? result.reason.message : String(result.reason) + ) + continue + } + const { repo, worktrees } = result.value + for (const worktree of worktrees) { + const worktreeId = `${repo.id}::${worktree.path}` + const key = worktreeIdComparisonKey(worktreeId) + if (key) { + const existing = liveGitWorktreeIdsByKey.get(key) + liveGitWorktreeIdsByKey.set( + key, + existing === undefined || existing === worktreeId ? worktreeId : null + ) + } + } + } + + inventory = await collectSessionInfos(provider, signal) + complete = complete && inventory.complete + alreadyRegistered = new Set(listRegisteredPtys().map((pty) => pty.ptyId)) + } + + throwIfSignalAborted(signal) + for (const info of inventory.sessions) { + throwIfSignalAborted(signal) + if (alreadyRegistered.has(info.sessionId)) { + continue + } + const { worktreeId } = parsePtySessionId(info.sessionId) + if (!worktreeId || !isVerifiedLocalWorktree(worktreeId)) { + continue + } + registerPty({ + ptyId: info.sessionId, + worktreeId, + sessionId: info.sessionId, + paneKey: null, + pid: + typeof info.pid === 'number' && Number.isFinite(info.pid) && info.pid > 0 ? info.pid : null + }) + } + return complete + + function isVerifiedLocalWorktree(worktreeId: string): boolean { + if (verifiedFolderWorktreeIds.has(worktreeId)) { + return true + } + const key = worktreeIdComparisonKey(worktreeId) + return key !== null && typeof liveGitWorktreeIdsByKey.get(key) === 'string' } } -async function collectSessionInfos( - provider: DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider -): Promise { - // Why: fan listSessions across current + legacy adapters so no daemon protocol version is missed. - const adapters: readonly DaemonPtyAdapter[] = - provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider - ? provider.getAllAdapters() - : [provider] - const out: SessionInfo[] = [] - for (const adapter of adapters) { - try { - const sessions = await adapter.listSessions() - // Why: session count can exceed the JS argument limit, so avoid push(...sessions). - for (const session of sessions) { - out.push(session) - } - } catch (err) { - // Why: one adapter's socket being unreachable is normal; don't abort the others. - console.warn( - '[memory] listSessions failed for one adapter during hydration:', - err instanceof Error ? err.message : String(err) - ) +function getLocalRepoCatalog(repos: Repo[]): LocalRepoCatalog { + const byId = new Map() + const ownerCountById = new Map() + const ambiguousIds = new Set() + for (const repo of repos) { + ownerCountById.set(repo.id, (ownerCountById.get(repo.id) ?? 0) + 1) + if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { + continue + } + if (byId.has(repo.id)) { + ambiguousIds.add(repo.id) + } else { + byId.set(repo.id, repo) } } - return out + for (const repoId of ambiguousIds) { + byId.delete(repoId) + } + return { byId, ownerCountById } +} + +function getVerifiedFolderWorktreeIds( + store: HydrationStore, + repoCatalog: LocalRepoCatalog +): Set { + const verified = new Set() + const metadata = readAllWorktreeMetaForHost(store, LOCAL_EXECUTION_HOST_ID) + for (const [worktreeId, meta] of Object.entries(metadata)) { + const parsed = splitWorktreeId(worktreeId) + const repo = parsed ? repoCatalog.byId.get(parsed.repoId) : undefined + const hasLocalAuthority = + meta.hostId === LOCAL_EXECUTION_HOST_ID || + (meta.hostId === undefined && repoCatalog.ownerCountById.get(parsed?.repoId ?? '') === 1) + if ( + repo && + isFolderRepo(repo) && + hasLocalAuthority && + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) { + verified.add(worktreeId) + } + } + return verified +} + +async function collectSessionInfos( + provider: DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider, + signal: AbortSignal +): Promise { + const adapters = + 'getAllAdapters' in provider && typeof provider.getAllAdapters === 'function' + ? provider.getAllAdapters() + : [provider] + const results = await Promise.all( + adapters.map(async (adapter) => { + try { + throwIfSignalAborted(signal) + const sessions = await waitForPromiseWithSignal( + adapter.listSessions(), + signal + ) + throwIfSignalAborted(signal) + return { complete: true, sessions } + } catch (error) { + throwIfSignalAborted(signal) + console.warn( + '[memory] listSessions failed for one adapter during hydration:', + error instanceof Error ? error.message : String(error) + ) + return { complete: false, sessions: [] } + } + }) + ) + const sessions: SessionInfo[] = [] + for (const result of results) { + for (const session of result.sessions) { + sessions.push(session) + } + } + return { + complete: results.length > 0 && results.every((result) => result.complete), + sessions + } } diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 4af2a87c45d..3b10ca985e9 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -190,7 +190,7 @@ async function startOrcadRuntime( // Codex-home and Claude-auth preparation are left unset: both are desktop account // flows. A launch that needs one fails with its own message rather than silently // spawning an unauthenticated agent. - registerHeadlessPtyRuntime(runtime, undefined, () => store.getSettings(), undefined, store) + await registerHeadlessPtyRuntime(runtime, undefined, () => store.getSettings(), undefined, store) // Why: same post-registration reconciliation `--serve` performs. Skipping it leaves // restored orchestration rows claiming an authority this host never took over. diff --git a/src/main/persistence-initial-load.test.ts b/src/main/persistence-initial-load.test.ts index e1926a67877..6d0c0f11d5e 100644 --- a/src/main/persistence-initial-load.test.ts +++ b/src/main/persistence-initial-load.test.ts @@ -16,6 +16,10 @@ import { makeProjectHostSetup } from './persistence-test-harness' import { TEST_LEAF_1 } from './persistence-session-fixtures' +import { + getLocalWorktreeScanGeneration, + isLocalWorktreeScanGenerationCurrent +} from './local-worktree-scan-generation' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -304,12 +308,13 @@ describe('Store', () => { it('updates and persists a project Windows runtime preference', async () => { const project = makeProject({ - id: 'project-1', + id: 'repo:r1', sourceRepoIds: ['r1'], localWindowsRuntimePreference: { kind: 'inherit-global' } }) writeDataFile({ ...getDefaultPersistedState(testState.dir), + repos: [makeRepo({ id: 'r1' })], projects: [project], projectHostSetups: [ makeProjectHostSetup({ @@ -320,12 +325,14 @@ describe('Store', () => { ] }) const store = await createStore() + const scanGeneration = getLocalWorktreeScanGeneration('r1') - const updated = store.updateProject('project-1', { + const updated = store.updateProject(project.id, { localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }) expect(updated?.localWindowsRuntimePreference).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) + expect(isLocalWorktreeScanGenerationCurrent('r1', scanGeneration)).toBe(false) store.flush() const reloaded = await createStore() expect(reloaded.getProjects()[0]?.localWindowsRuntimePreference).toEqual({ diff --git a/src/main/persistence-loading-store-extraction.test.ts b/src/main/persistence-loading-store-extraction.test.ts index dafb6deadee..e48971d6c5c 100644 --- a/src/main/persistence-loading-store-extraction.test.ts +++ b/src/main/persistence-loading-store-extraction.test.ts @@ -2,14 +2,22 @@ import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'v import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { getDefaultWorkspaceSession } from '../shared/constants' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../shared/constants' import type { PersistedState } from '../shared/persisted-state-types' +import type * as StartupDiagnosticsModule from './startup/startup-diagnostics' import type { Store as PersistenceStore } from './persistence/loading-store/store' -import { createStore, dataFile, makeRepo, testState } from './persistence-test-harness' +import { + createStore, + dataFile, + makeRepo, + testState, + writeDataFile +} from './persistence-test-harness' -const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ +const { trackMock, getCohortAtEmitMock, logStartupDiagnosticMock } = vi.hoisted(() => ({ trackMock: vi.fn(), - getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 2 })) + getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 2 })), + logStartupDiagnosticMock: vi.fn() })) vi.mock('electron', () => ({ @@ -33,6 +41,10 @@ vi.mock('./ssh/ssh-config-parser', () => ({ loadUserSshConfig: vi.fn(() => ({ hosts: [] })), sshConfigHostsToTargets: vi.fn(() => []) })) +vi.mock('./startup/startup-diagnostics', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, logStartupDiagnostic: logStartupDiagnosticMock } +}) describe('loading Store extraction seams', () => { beforeEach(() => { @@ -40,9 +52,70 @@ describe('loading Store extraction seams', () => { }) afterEach(() => { + vi.unstubAllEnvs() + logStartupDiagnosticMock.mockReset() rmSync(testState.dir, { recursive: true, force: true }) }) + it('does not serialize the workspace session when startup diagnostics are disabled', () => { + const sentinel = 'startup-diagnostics-workspace-session-sentinel-disabled' + vi.stubEnv('ORCA_STARTUP_DIAGNOSTICS', '') + const state = getDefaultPersistedState(testState.dir) + state.workspaceSession = { ...state.workspaceSession, activeTabId: sentinel } + writeDataFile(state) + + const stringifySpy = vi.spyOn(JSON, 'stringify') + const store = createStore() + store.freezeWrites() + + const workspaceSessionStringifyCalls = stringifySpy.mock.calls.filter( + ([value]) => + value && + typeof value === 'object' && + (value as { activeTabId?: unknown }).activeTabId === sentinel + ) + stringifySpy.mockRestore() + + expect(store.getWorkspaceSession().activeTabId).toBe(sentinel) + expect(workspaceSessionStringifyCalls).toHaveLength(0) + expect( + logStartupDiagnosticMock.mock.calls.some(([event]) => event === 'persistence-load-done') + ).toBe(false) + }) + + it('reports the unchanged workspace-session byte count when startup diagnostics are enabled', () => { + const sentinel = 'startup-diagnostics-workspace-session-sentinel-enabled' + vi.stubEnv('ORCA_STARTUP_DIAGNOSTICS', '1') + const state = getDefaultPersistedState(testState.dir) + state.workspaceSession = { ...state.workspaceSession, activeTabId: sentinel } + writeDataFile(state) + + const stringifySpy = vi.spyOn(JSON, 'stringify') + const store = createStore() + store.freezeWrites() + + const workspaceSessionStringifyCalls = stringifySpy.mock.calls.filter( + ([value]) => + value && + typeof value === 'object' && + (value as { activeTabId?: unknown }).activeTabId === sentinel + ) + stringifySpy.mockRestore() + + expect(store.getWorkspaceSession().activeTabId).toBe(sentinel) + expect(workspaceSessionStringifyCalls).toHaveLength(1) + const loadDoneCall = logStartupDiagnosticMock.mock.calls.find( + ([event]) => event === 'persistence-load-done' + ) + expect(loadDoneCall).toBeDefined() + const details = loadDoneCall?.[1] as Record | undefined + expect(details).toEqual({ + t: expect.any(Number), + repos: state.repos.length, + workspaceSessionBytes: Buffer.byteLength(JSON.stringify(store.getWorkspaceSession())) + }) + }) + it('accepts the first JSON-parseable backup even when an older backup has richer state', async () => { mkdirSync(testState.dir, { recursive: true }) writeFileSync(dataFile(), '{{corrupt-primary', 'utf-8') diff --git a/src/main/persistence-pane-identity-migration.test.ts b/src/main/persistence-pane-identity-migration.test.ts index 606e1599fdf..c9a6fc4e592 100644 --- a/src/main/persistence-pane-identity-migration.test.ts +++ b/src/main/persistence-pane-identity-migration.test.ts @@ -507,6 +507,7 @@ describe('Store', () => { ) }) + // ~9s locally; the 30s default timeout leaves too little headroom on a loaded CI shard. it('loads legacy pane aliases from very large persisted split layouts', async () => { const leafCount = 130_000 writeDataFile({ @@ -561,7 +562,7 @@ describe('Store', () => { (entry) => entry.ptyId === 'large-pty' && entry.legacyPaneKey === `tab1:${leafCount}` ) ).toBe(true) - }) + }, 120_000) it('converts unambiguous dev migration rows into persisted aliases', async () => { const stablePaneKey = makePaneKey('tab1', TEST_LEAF_1) diff --git a/src/main/persistence-repo-lifecycle.test.ts b/src/main/persistence-repo-lifecycle.test.ts index 53b759a7d01..1653f78297e 100644 --- a/src/main/persistence-repo-lifecycle.test.ts +++ b/src/main/persistence-repo-lifecycle.test.ts @@ -14,6 +14,10 @@ import { makeTerminalTab, makeWorktreeLineage } from './persistence-test-harness' +import { + getLocalWorktreeScanGeneration, + isLocalWorktreeScanGenerationCurrent +} from './local-worktree-scan-generation' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -79,6 +83,23 @@ describe('Store', () => { expect(fetched!.gitUsername).toBe('') }) + it('invalidates local scans across add, remove, and same-id re-add', async () => { + const store = await createStore() + const repoId = 'scan-lifecycle' + const beforeAdd = getLocalWorktreeScanGeneration(repoId) + + store.addRepo(makeRepo({ id: repoId })) + expect(isLocalWorktreeScanGenerationCurrent(repoId, beforeAdd)).toBe(false) + + const beforeRemove = getLocalWorktreeScanGeneration(repoId) + store.removeProject(repoId) + expect(isLocalWorktreeScanGenerationCurrent(repoId, beforeRemove)).toBe(false) + + const beforeReAdd = getLocalWorktreeScanGeneration(repoId) + store.addRepo(makeRepo({ id: repoId, path: '/replacement' })) + expect(isLocalWorktreeScanGenerationCurrent(repoId, beforeReAdd)).toBe(false) + }) + it('setResolvedRepoGitUsername persists the enriched username for hydration', async () => { const store = await createStore() store.addRepo(makeRepo()) @@ -369,6 +390,24 @@ describe('Store', () => { // ── 6b. removeProjectForHost is host-scoped ─────────────────────────── + it('invalidates local scans when one host registration is removed', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'shared', path: '/local/repo' })) + store.addRepo( + makeRepo({ + id: 'shared', + path: '/remote/repo', + connectionId: 'ssh-old', + executionHostId: 'ssh:ssh-old' + }) + ) + const beforeRemove = getLocalWorktreeScanGeneration('shared') + + store.removeProjectForHost('shared', 'ssh:ssh-old') + + expect(isLocalWorktreeScanGenerationCurrent('shared', beforeRemove)).toBe(false) + }) + it('removeProjectForHost removes only the target host row for a shared repo id', async () => { const store = await createStore() // Same repo id on both local and an SSH host. diff --git a/src/main/persistence-settings-update.test.ts b/src/main/persistence-settings-update.test.ts index 330386a48a2..dc3a3c7ebb5 100644 --- a/src/main/persistence-settings-update.test.ts +++ b/src/main/persistence-settings-update.test.ts @@ -14,6 +14,10 @@ import { makeRepo, makeTerminalTab } from './persistence-test-harness' +import { + getLocalWorktreeScanGeneration, + isLocalWorktreeScanGenerationCurrent +} from './local-worktree-scan-generation' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -227,6 +231,21 @@ describe('Store', () => { expect(listener).not.toHaveBeenCalled() }) + it('invalidates local worktree scans when the global Windows runtime changes', async () => { + writeDataFile({ repos: [makeRepo()] }) + const store = await createStore() + const generation = getLocalWorktreeScanGeneration('r1') + + store.updateSettings({ theme: 'dark' }) + expect(isLocalWorktreeScanGenerationCurrent('r1', generation)).toBe(true) + + store.updateSettings({ + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + }) + + expect(isLocalWorktreeScanGenerationCurrent('r1', generation)).toBe(false) + }) + it('migrates missing terminal scrollback rows to the row default and writes back rows only', async () => { writeDataFile({ settings: {} }) diff --git a/src/main/persistence-update-repo.test.ts b/src/main/persistence-update-repo.test.ts index d2266103615..a89378d6c71 100644 --- a/src/main/persistence-update-repo.test.ts +++ b/src/main/persistence-update-repo.test.ts @@ -11,6 +11,10 @@ import { makeProject, makeProjectHostSetup } from './persistence-test-harness' +import { + getLocalWorktreeScanGeneration, + isLocalWorktreeScanGenerationCurrent +} from './local-worktree-scan-generation' // Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config. const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({ @@ -75,6 +79,25 @@ describe('Store', () => { expect(store.getRepo('r1')!.displayName).toBe('renamed') }) + it('invalidates local worktree scans across a git-folder-git update round trip', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'scan-round-trip', kind: 'git' })) + + const initialGeneration = getLocalWorktreeScanGeneration('scan-round-trip') + expect(isLocalWorktreeScanGenerationCurrent('scan-round-trip', initialGeneration)).toBe(true) + + const folder = store.updateRepo('scan-round-trip', { kind: 'folder' }) + expect(folder?.kind).toBe('folder') + expect(isLocalWorktreeScanGenerationCurrent('scan-round-trip', initialGeneration)).toBe(false) + + const folderGeneration = getLocalWorktreeScanGeneration('scan-round-trip') + expect(isLocalWorktreeScanGenerationCurrent('scan-round-trip', folderGeneration)).toBe(true) + + const git = store.updateRepo('scan-round-trip', { kind: 'git' }) + expect(git?.kind).toBe('git') + expect(isLocalWorktreeScanGenerationCurrent('scan-round-trip', folderGeneration)).toBe(false) + }) + it('updateRepo targets one host when repo ids collide', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'shared', path: '/local/repo' })) diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index c4ded8da753..20614061bdd 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -41,6 +41,7 @@ import { export type SettingsMutationOperations = { state: PersistedState + bumpLocalWorktreeScanGeneration: (repoId: string) => void removeRetainedBlob: ( slot: Parameters[0] ) => void @@ -242,6 +243,16 @@ export function updateSettings( }), ...(mergedTelemetry !== undefined ? { telemetry: mergedTelemetry } : {}) } + if ( + !Object.is( + previousSettings.localWindowsRuntimeDefault, + operations.state.settings.localWindowsRuntimeDefault + ) + ) { + for (const repoId of new Set(operations.state.repos.map(({ id }) => id))) { + operations.bumpLocalWorktreeScanGeneration(repoId) + } + } operations.scheduleSave() const changedUpdates = {} as Partial & Record for (const key of Object.keys(sanitizedUpdates) as (keyof GlobalSettings)[]) { diff --git a/src/main/persistence/loading-store/loaded-state-parsing.ts b/src/main/persistence/loading-store/loaded-state-parsing.ts index 2f28c771508..5bd6e572ab1 100644 --- a/src/main/persistence/loading-store/loaded-state-parsing.ts +++ b/src/main/persistence/loading-store/loaded-state-parsing.ts @@ -42,13 +42,17 @@ import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-setting import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings' import { normalizeLoadedProfileState } from './normalize-loaded-profile-state' +type PersistenceStartupDetails = Record | (() => Record) + function logPersistenceStartupMilestone( event: string, - details: Record = {} + details: PersistenceStartupDetails = {} ): void { - if (isStartupDiagnosticsEnabled()) { - logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) + if (!isStartupDiagnosticsEnabled()) { + return } + const resolvedDetails = typeof details === 'function' ? details() : details + logStartupDiagnostic(event, { t: Math.round(performance.now()), ...resolvedDetails }) } import type { StoreRuntimeState } from './store-runtime-state' @@ -279,10 +283,10 @@ export class LoadedStateParsingOperations { migrated.githubCache = readGithubCacheSnapshot(this.runtime.dataFile) ?? migrated.githubCache } - logPersistenceStartupMilestone('persistence-load-done', { + logPersistenceStartupMilestone('persistence-load-done', () => ({ repos: migrated.repos.length, workspaceSessionBytes: Buffer.byteLength(JSON.stringify(migrated.workspaceSession)) - }) + })) return migrated } } diff --git a/src/main/persistence/loading-store/metadata-lineage-batch-pruning.test.ts b/src/main/persistence/loading-store/metadata-lineage-batch-pruning.test.ts new file mode 100644 index 00000000000..9e7b71734b6 --- /dev/null +++ b/src/main/persistence/loading-store/metadata-lineage-batch-pruning.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import type { Repo } from '../../../shared/repo-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import type * as WriteSchedulingModule from './write-scheduling' +import { MetadataLineageOperations } from './metadata-lineage-operations' + +const { scheduleSaveMock } = vi.hoisted(() => ({ scheduleSaveMock: vi.fn() })) + +vi.mock('./write-scheduling', async (importOriginal) => ({ + ...(await importOriginal()), + scheduleSave: scheduleSaveMock +})) + +const REPO: Repo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 +} + +function makeMeta(worktreeId: string): WorktreeMeta { + return { + instanceId: `instance-${worktreeId}`, + hostId: 'local', + displayName: worktreeId, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } +} + +describe('MetadataLineageOperations batch metadata pruning', () => { + it('schedules one save for thousands of removals and none for a no-op retry', () => { + const state = getDefaultPersistedState('/home/test') + state.repos = [REPO] + const staleIds = Array.from( + { length: 2_709 }, + (_, index) => `${REPO.id}::/workspace/stale-${index}` + ) + for (const worktreeId of staleIds) { + state.worktreeMeta[worktreeId] = makeMeta(worktreeId) + } + const operations = new MetadataLineageOperations({ state } as never, {} as never, {} as never) + const scan = operations.captureNativeLocalWorktreeMetadataScanExpectation(REPO) + + expect( + operations.pruneSessionlessMissingLocalWorktreeMetadataForRepo(scan, scan.metadata) + ).toHaveLength(staleIds.length) + expect(scheduleSaveMock).toHaveBeenCalledTimes(1) + + expect( + operations.pruneSessionlessMissingLocalWorktreeMetadataForRepo(scan, scan.metadata) + ).toEqual([]) + expect(scheduleSaveMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/persistence/loading-store/metadata-lineage-operations.ts b/src/main/persistence/loading-store/metadata-lineage-operations.ts index f12af5d1583..7d4867ce476 100644 --- a/src/main/persistence/loading-store/metadata-lineage-operations.ts +++ b/src/main/persistence/loading-store/metadata-lineage-operations.ts @@ -1,6 +1,7 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import type { WorkspaceLineage, WorktreeLineage } from '../../../shared/worktree/lineage-types' import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import type { Repo } from '../../../shared/repo-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner' @@ -28,6 +29,12 @@ import { setWorktreeMetaForHost as setWorktreeMetaForHostOperation } from './worktree-identity-metadata' import { mergeWorktreeMetaForWrite } from './worktree-meta-write-normalization' +import { + captureNativeLocalWorktreeMetadataScanExpectation as captureNativeLocalWorktreeMetadataScanExpectationOperation, + pruneSessionlessMissingLocalWorktreeMetadataForRepo as pruneSessionlessMissingLocalWorktreeMetadataForRepoOperation, + type LocalWorktreeMetadataPruneExpectation, + type NativeLocalWorktreeMetadataScanExpectation +} from '../tracking-repos/missing-local-worktree-metadata-pruning' type MetadataLineageOperationsRuntime = Pick @@ -89,6 +96,15 @@ export class MetadataLineageOperations { ) } + captureNativeLocalWorktreeMetadataScanExpectation( + repo: Repo + ): NativeLocalWorktreeMetadataScanExpectation { + return captureNativeLocalWorktreeMetadataScanExpectationOperation( + this[metadataLineageOperationsContext].runtime.state, + repo + ) + } + setWorktreeMeta(worktreeId: string, meta: Partial): WorktreeMeta { const state = this[metadataLineageOperationsContext].runtime.state const stored = state.worktreeMeta[worktreeId] @@ -184,6 +200,21 @@ export class MetadataLineageOperations { scheduleSave(this[metadataLineageOperationsContext].scheduling) } + pruneSessionlessMissingLocalWorktreeMetadataForRepo( + scan: NativeLocalWorktreeMetadataScanExpectation, + missingMetadata: readonly LocalWorktreeMetadataPruneExpectation[] + ): string[] { + const removed = pruneSessionlessMissingLocalWorktreeMetadataForRepoOperation( + this[metadataLineageOperationsContext].runtime.state, + scan, + missingMetadata + ) + if (removed.length > 0) { + scheduleSave(this[metadataLineageOperationsContext].scheduling) + } + return removed + } + getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined { return this[metadataLineageOperationsContext].runtime.state.worktreeLineageById[worktreeId] } diff --git a/src/main/persistence/loading-store/profile-preferences.ts b/src/main/persistence/loading-store/profile-preferences.ts index 228229d2584..0ec4e383c34 100644 --- a/src/main/persistence/loading-store/profile-preferences.ts +++ b/src/main/persistence/loading-store/profile-preferences.ts @@ -17,6 +17,7 @@ import { import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' import { scheduleSave } from './write-scheduling' +import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation' type ProfilePreferencesRuntime = Pick< StoreRuntimeState, @@ -155,6 +156,7 @@ export function getSettingsMutationOperations( ): SettingsMutationOperations { return { state: owner[profilePreferencesContext].runtime.state, + bumpLocalWorktreeScanGeneration, removeRetainedBlob: (slot) => owner[profilePreferencesContext].runtime.protectedSecrets.removeRetainedBlob(slot), scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling), diff --git a/src/main/persistence/loading-store/project-collection-operations.ts b/src/main/persistence/loading-store/project-collection-operations.ts index e7100478037..ecb1130072f 100644 --- a/src/main/persistence/loading-store/project-collection-operations.ts +++ b/src/main/persistence/loading-store/project-collection-operations.ts @@ -28,6 +28,7 @@ import { } from './repo-lifecycle-operations' import { scheduleSave } from './write-scheduling' import { removeWorkspaceLineageForFolderParent } from './metadata-lineage-operations' +import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation' type ProjectCollectionOperationsRuntime = Pick< StoreRuntimeState, @@ -159,6 +160,7 @@ export function getProjectHostOperations( new ProjectHostPersistenceOperations({ state: owner[projectCollectionOperationsContext].runtime.state, gitUsernameCache: owner[projectCollectionOperationsContext].runtime.gitUsernameCache, + bumpLocalWorktreeScanGeneration, hydrateRepo: (repo) => hydrateRepo(owner[projectCollectionOperationsContext].repos, repo), updateRepoBackedProjectHostSetup: (setup, repo, updates) => updateRepoBackedProjectHostSetup( diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index bdd3718d9a6..095090ee1ca 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -15,6 +15,7 @@ import { pruneWorktreeStateForRepo as pruneWorktreeStateForRepoOperation } from import { hydrateRepo as hydrateRepoOperation } from '../tracking-repos/repo-hydration' import { RepoUpdatePersistenceOperations } from '../tracking-repos/repo-update-operations' import { ProjectHostSetupPersistenceOperations } from '../tracking-repos/project-host-setup-update' +import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation' import type { StoreRuntimeState } from './store-runtime-state' import type { WriteSchedulingOperations } from './write-scheduling' @@ -43,6 +44,7 @@ export class RepoLifecycleOperations { } addRepo(repo: Repo): void { + bumpLocalWorktreeScanGeneration(repo.id) getRepoOrderOperations(this).addRepo(repo) } @@ -55,9 +57,15 @@ export class RepoLifecycleOperations { } removeProject(id: string): void { + const repoRemoved = this[repoLifecycleOperationsContext].runtime.state.repos.some( + (repo) => repo.id === id + ) this[repoLifecycleOperationsContext].runtime.state.repos = this[ repoLifecycleOperationsContext ].runtime.state.repos.filter((r) => r.id !== id) + if (repoRemoved) { + bumpLocalWorktreeScanGeneration(id) + } syncProjectHostSetupCompatibilityState(this) // Why: presets are repo-scoped and unreachable once the repo is gone, so drop them with it. delete this[repoLifecycleOperationsContext].runtime.state.sparsePresetsByRepo[id] @@ -77,9 +85,15 @@ export class RepoLifecycleOperations { } removeProjectForHost(id: string, hostId: ExecutionHostId): void { + const repoRemoved = this[repoLifecycleOperationsContext].runtime.state.repos.some( + (repo) => repo.id === id && getRepoExecutionHostId(repo) === hostId + ) this[repoLifecycleOperationsContext].runtime.state.repos = this[ repoLifecycleOperationsContext ].runtime.state.repos.filter((r) => !(r.id === id && getRepoExecutionHostId(r) === hostId)) + if (repoRemoved) { + bumpLocalWorktreeScanGeneration(id) + } const idStillPresent = this[repoLifecycleOperationsContext].runtime.state.repos.some( (r) => r.id === id ) @@ -204,6 +218,7 @@ export function getRepoUpdateOperations( owner[repoLifecycleOperationsContext].runtime.repoUpdateOperations ??= new RepoUpdatePersistenceOperations({ state: owner[repoLifecycleOperationsContext].runtime.state, + bumpLocalWorktreeScanGeneration, syncProjectHostSetupCompatibilityState: () => syncProjectHostSetupCompatibilityState(owner), scheduleSave: () => scheduleSave(owner[repoLifecycleOperationsContext].scheduling), hydrateRepo: (repo) => hydrateRepo(owner, repo) diff --git a/src/main/persistence/loading-store/worktree-identity-metadata.ts b/src/main/persistence/loading-store/worktree-identity-metadata.ts index 396c2ffae85..21fea4ac15c 100644 --- a/src/main/persistence/loading-store/worktree-identity-metadata.ts +++ b/src/main/persistence/loading-store/worktree-identity-metadata.ts @@ -36,11 +36,18 @@ function resolveAliasIdentityKey(state: PersistedState, alias: string): string | } /** Drop identity metadata no alias points at any more. */ -export function pruneUnreferencedWorktreeIdentityMeta(state: PersistedState): boolean { +export function pruneUnreferencedWorktreeIdentityMeta( + state: PersistedState, + candidates?: ReadonlySet +): boolean { const referenced = new Set(Object.values(state.worktreeIdentityAliases ?? {}).flat()) let changed = false - for (const identityKey of Object.keys(state.worktreeMetaByIdentity ?? {})) { - if (!referenced.has(identityKey)) { + const identityKeys = candidates ?? Object.keys(state.worktreeMetaByIdentity ?? {}) + for (const identityKey of identityKeys) { + if ( + Object.hasOwn(state.worktreeMetaByIdentity ?? {}, identityKey) && + !referenced.has(identityKey) + ) { delete state.worktreeMetaByIdentity?.[identityKey] changed = true } diff --git a/src/main/persistence/restoring-sessions/pane-identity-migration.ts b/src/main/persistence/restoring-sessions/pane-identity-migration.ts index e07e9995286..98d97a649a3 100644 --- a/src/main/persistence/restoring-sessions/pane-identity-migration.ts +++ b/src/main/persistence/restoring-sessions/pane-identity-migration.ts @@ -1,5 +1,5 @@ import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' -import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' import { agentHookServer } from '../../agent-hooks/server' @@ -17,23 +17,69 @@ export function findWorktreeIdForTab( return undefined } +export type TerminalTabLookup = { + get(tabId: string): TerminalTab | undefined +} + +/** Resolves tabs on demand while retaining first-match ordering and scan progress. */ +export function createLazyTerminalTabLookup(session: WorkspaceSessionState): TerminalTabLookup { + const tabsByWorktree = Object.entries(session.tabsByWorktree ?? {}) + const tabsById = new Map() + let worktreeIndex = 0 + let tabIndex = 0 + let exhausted = false + + return { + get(requestedTabId: string): TerminalTab | undefined { + if (tabsById.has(requestedTabId)) { + return tabsById.get(requestedTabId) + } + if (exhausted) { + return undefined + } + + while (worktreeIndex < tabsByWorktree.length) { + const tabs = tabsByWorktree[worktreeIndex][1] + while (tabIndex < tabs.length) { + const currentIndex = tabIndex + tabIndex += 1 + // Array#some, used by the prior lookup, skips sparse holes. + if (!(currentIndex in tabs)) { + continue + } + const tab = tabs[currentIndex] + const tabId = tab.id + // Preserve first-match behavior when persisted IDs collide. + if (!tabsById.has(tabId)) { + tabsById.set(tabId, tab) + } + if (tabId === requestedTabId) { + return tabsById.get(requestedTabId) + } + } + worktreeIndex += 1 + tabIndex = 0 + } + + exhausted = true + return undefined + } + } +} + /** Bridges a tab's legacy numeric pane keys to stable ones; returns the alias rows worth persisting. */ export function registerLegacyPaneKeyAliasesForTab(args: { - session: WorkspaceSessionState tabId: string + tab: TerminalTab | undefined inputLayout: TerminalLayoutSnapshot normalizedLayout: TerminalLayoutSnapshot leafIdByInputLeafId: Map }): LegacyPaneKeyAliasEntry[] { - const worktreeId = findWorktreeIdForTab(args.session, args.tabId) - const tab = worktreeId - ? args.session.tabsByWorktree?.[worktreeId]?.find((entry) => entry.id === args.tabId) - : undefined const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] const registeredLegacyPaneKeys = new Set() const hasLeafPtyBindings = Object.keys(args.inputLayout.ptyIdsByLeafId ?? {}).length > 0 const fallbackPtyId = - !hasLeafPtyBindings && typeof tab?.ptyId === 'string' ? tab.ptyId : undefined + !hasLeafPtyBindings && typeof args.tab?.ptyId === 'string' ? args.tab.ptyId : undefined const registerLegacyAlias = (inputLeafId: string, leafId: string, ptyId?: string): boolean => { if (!isTerminalLeafId(leafId)) { return false @@ -80,7 +126,7 @@ export function registerLegacyPaneKeyAliasesForTab(args: { ) } } - if (tab?.ptyId && !hasLeafPtyBindings) { + if (args.tab?.ptyId && !hasLeafPtyBindings) { const fallbackLeafId = args.normalizedLayout.activeLeafId ?? firstLayoutLeafId(args.normalizedLayout.root) let paneKey: string | undefined @@ -96,9 +142,9 @@ export function registerLegacyPaneKeyAliasesForTab(args: { if (registeredLegacyPaneKeys.has(legacyPaneKey)) { continue } - agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, tab.ptyId) + agentHookServer.registerPaneKeyAlias(legacyPaneKey, paneKey, args.tab.ptyId) legacyPaneKeyAliasEntries.push({ - ptyId: tab.ptyId, + ptyId: args.tab.ptyId, legacyPaneKey, stablePaneKey: paneKey, updatedAt: Date.now() diff --git a/src/main/persistence/restoring-sessions/session-worktree-ownership.test.ts b/src/main/persistence/restoring-sessions/session-worktree-ownership.test.ts new file mode 100644 index 00000000000..1e84cf4c506 --- /dev/null +++ b/src/main/persistence/restoring-sessions/session-worktree-ownership.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { + collectPersistedSessionWorktreeOwners, + collectWorkspaceSessionWorktreeOwners +} from './session-worktree-ownership' + +const TARGET = 'repo-1::/workspace/target' +const OTHER = 'repo-1::/workspace/other' + +function sessionWith(field: keyof WorkspaceSessionState, value: unknown): WorkspaceSessionState { + return { ...getDefaultWorkspaceSession(), [field]: value } as WorkspaceSessionState +} + +function collected(session: WorkspaceSessionState): string[] { + return [...collectWorkspaceSessionWorktreeOwners(session, new Set([TARGET]))] +} + +const OWNER_KEYED_FIELD_VALUES = [ + ['tabsByWorktree', []], + ['openFilesByWorktree', []], + ['activeFileIdByWorktree', null], + ['browserTabsByWorktree', []], + ['activeBrowserTabIdByWorktree', null], + ['clientHostedBrowserPagesByWorktree', []], + ['activeTabTypeByWorktree', 'terminal'], + ['activeTabIdByWorktree', null], + ['unifiedTabs', []], + ['tabGroups', []], + ['tabGroupLayouts', { type: 'leaf', groupId: 'group-1' }], + ['activeGroupIdByWorktree', 'group-1'], + ['lastVisitedAtByWorktreeId', 0], + ['defaultTerminalTabsAppliedByWorktreeId', true] +] as const satisfies readonly [keyof WorkspaceSessionState, unknown][] + +describe('workspace session worktree ownership', () => { + it.each(OWNER_KEYED_FIELD_VALUES)( + 'treats a key in %s as ownership even when its bucket is empty or null', + (field, value) => { + expect(collected(sessionWith(field, { [TARGET]: value }))).toEqual([TARGET]) + } + ) + + it.each([ + ['active worktree', sessionWith('activeWorktreeId', TARGET)], + ['active workspace', sessionWith('activeWorkspaceKey', worktreeWorkspaceKey(TARGET))], + ['shutdown owner', sessionWith('activeWorktreeIdsOnShutdown', [TARGET])], + [ + 'host-qualified recency owner', + sessionWith('lastVisitedAtByWorktreeId', { [`ssh:builder|${TARGET}`]: 1 }) + ] + ])('collects the %s', (_label, session) => { + expect(collected(session)).toEqual([TARGET]) + }) + + it.each([ + ['terminal tab', 'tabsByWorktree', { [OTHER]: [{ worktreeId: TARGET }] }], + ['open file', 'openFilesByWorktree', { [OTHER]: [{ worktreeId: TARGET }] }], + ['unified tab', 'unifiedTabs', { [OTHER]: [{ worktreeId: TARGET }] }], + ['tab group', 'tabGroups', { [OTHER]: [{ worktreeId: TARGET }] }], + ['browser workspace', 'browserTabsByWorktree', { [OTHER]: [{ worktreeId: TARGET }] }], + [ + 'browser workspace document', + 'browserTabsByWorktree', + { [OTHER]: [{ worktreeId: OTHER, docLocation: { worktreeId: TARGET } }] } + ], + ['browser page', 'browserPagesByWorkspace', { page: [{ worktreeId: TARGET }] }], + [ + 'browser page document', + 'browserPagesByWorkspace', + { page: [{ worktreeId: OTHER, docLocation: { worktreeId: TARGET } }] } + ], + [ + 'browser close intent', + 'clientHostedBrowserCloseIntentsByEnvironment', + { environment: [{ worktreeId: TARGET }] } + ], + ['sleeping agent', 'sleepingAgentSessionsByPaneKey', { pane: { worktreeId: TARGET } }], + ['terminal tombstone', 'terminalSurfaceTombstonesByPaneKey', { pane: { worktreeId: TARGET } }], + [ + 'closed-terminal tombstone', + 'closedTerminalTabTombstonesByTabId', + { tab: { worktreeId: TARGET } } + ] + ] as const)( + 'collects a %s value even when its enclosing key names something else', + (_label, field, value) => { + expect(collected(sessionWith(field, value))).toEqual([TARGET]) + } + ) + + it('collects ownership from every persisted host partition', () => { + const state = getDefaultPersistedState('/home/test') + state.workspaceSessionsByHostId = { + 'ssh:builder': sessionWith('tabsByWorktree', { [TARGET]: [] }), + 'runtime:environment': sessionWith('activeWorktreeIdsOnShutdown', [OTHER]) + } + + expect( + [...collectPersistedSessionWorktreeOwners(state, new Set([TARGET, OTHER]))].sort() + ).toEqual([OTHER, TARGET]) + }) + + it('prefers an exact candidate id over workspace-key prefix parsing', () => { + const prefixedId = 'folder::/workspace/target' + const session = sessionWith('tabsByWorktree', { [prefixedId]: [] }) + + expect([...collectWorkspaceSessionWorktreeOwners(session, new Set([prefixedId]))]).toEqual([ + prefixedId + ]) + }) + + it('matches canonically equivalent Unicode worktree owners', () => { + const candidateId = 'repo-1::/workspace/Café'.normalize('NFC') + const ownerId = candidateId.normalize('NFD') + const session = sessionWith('activeWorktreeId', ownerId) + + expect([...collectWorkspaceSessionWorktreeOwners(session, new Set([candidateId]))]).toEqual([ + candidateId + ]) + }) + + it('matches Windows worktree owners across drive case and slash spelling', () => { + const candidateId = 'repo-1::D:/Agentic/game2' + const session = sessionWith('activeWorktreeId', 'repo-1::d:\\agentic\\game2') + + expect([...collectWorkspaceSessionWorktreeOwners(session, new Set([candidateId]))]).toEqual([ + candidateId + ]) + }) + + it('matches macOS /private/tmp session owners to /tmp metadata', () => { + const candidateId = 'repo-1::/private/tmp/orca/target' + const session = sessionWith('activeWorktreeId', 'repo-1::/tmp/orca/target') + + expect([ + ...collectWorkspaceSessionWorktreeOwners(session, new Set([candidateId]), 'darwin') + ]).toEqual([candidateId]) + }) + + it('preserves every candidate that shares an owner comparison key', () => { + const nfcId = 'repo-1::/workspace/Café'.normalize('NFC') + const nfdId = nfcId.normalize('NFD') + const session = sessionWith('activeWorktreeId', nfcId) + + expect( + [...collectWorkspaceSessionWorktreeOwners(session, new Set([nfcId, nfdId]))].sort() + ).toEqual([nfcId, nfdId].sort()) + }) + + it('ignores fields whose keys cannot identify a worktree owner', () => { + const session = { + ...getDefaultWorkspaceSession(), + activeRepoId: TARGET, + activeTabId: TARGET, + terminalLayoutsByTabId: { [TARGET]: { root: null } }, + remoteSessionIdsByTabId: { [TARGET]: TARGET }, + terminalPtyIncarnationsByPaneKey: { [TARGET]: TARGET }, + terminalTopologyRevisionByRepoId: { [TARGET]: 1 } + } as unknown as WorkspaceSessionState + + expect(collected(session)).toEqual([]) + }) +}) diff --git a/src/main/persistence/restoring-sessions/session-worktree-ownership.ts b/src/main/persistence/restoring-sessions/session-worktree-ownership.ts new file mode 100644 index 00000000000..dc572e79278 --- /dev/null +++ b/src/main/persistence/restoring-sessions/session-worktree-ownership.ts @@ -0,0 +1,246 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../../shared/worktree/host-qualified-identity' +import { worktreeRetentionIdComparisonKey } from '../../worktree-retention-path-comparison' + +type WorkspaceSessionWorktreeReferenceKind = + | 'none' + | 'direct' + | 'owner-keyed' + | 'owner-keyed-row-arrays' + | 'owner-keyed-browser-row-arrays' + | 'worktree-id-array' + | 'row-record' + | 'row-arrays' + | 'browser-row-arrays' + +/** Every persisted session field must state how, if at all, it can name a worktree owner. */ +export const WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND = { + activeRepoId: 'none', + activeWorkspaceKey: 'direct', + activeWorkspaceExecutionHostId: 'none', + activeWorktreeId: 'direct', + activeTabId: 'none', + tabsByWorktree: 'owner-keyed-row-arrays', + terminalLayoutsByTabId: 'none', + activeWorktreeIdsOnShutdown: 'worktree-id-array', + openFilesByWorktree: 'owner-keyed-row-arrays', + activeFileIdByWorktree: 'owner-keyed', + markdownFrontmatterVisible: 'none', + browserTabsByWorktree: 'owner-keyed-browser-row-arrays', + browserPagesByWorkspace: 'browser-row-arrays', + activeBrowserTabIdByWorktree: 'owner-keyed', + clientHostedBrowserPagesByWorktree: 'owner-keyed', + clientHostedBrowserCloseIntentsByEnvironment: 'row-arrays', + activeTabTypeByWorktree: 'owner-keyed', + browserUrlHistory: 'none', + activeTabIdByWorktree: 'owner-keyed', + unifiedTabs: 'owner-keyed-row-arrays', + tabGroups: 'owner-keyed-row-arrays', + tabGroupLayouts: 'owner-keyed', + activeGroupIdByWorktree: 'owner-keyed', + activeConnectionIdsAtShutdown: 'none', + remoteSessionIdsByTabId: 'none', + lastVisitedAtByWorktreeId: 'owner-keyed', + defaultTerminalTabsAppliedByWorktreeId: 'owner-keyed', + sleepingAgentSessionsByPaneKey: 'row-record', + terminalPtyIncarnationsByPaneKey: 'none', + terminalTopologyRevisionByRepoId: 'none', + terminalSurfaceTombstonesByPaneKey: 'row-record', + closedTerminalTabTombstonesByTabId: 'row-record' +} as const satisfies Record + +type WorktreeRow = { worktreeId?: string | null } +type BrowserWorktreeRow = WorktreeRow & { + docLocation?: { worktreeId?: string | null } | null +} + +export type WorktreeOwnerCandidateCollector = Readonly<{ + owners: Set + addOwner: (ownerKey: string | null | undefined) => void +}> + +export function getPersistedWorktreeOwnerId(ownerKey: string | null | undefined): string | null { + if (!ownerKey) { + return null + } + if (isWorktreeHostIdentity(ownerKey)) { + return getWorktreeIdFromHostIdentity(ownerKey) || null + } + const scope = parseWorkspaceKey(ownerKey) + if (scope?.type === 'folder') { + return null + } + return scope?.type === 'worktree' ? scope.worktreeId : ownerKey +} + +export function createWorktreeOwnerCandidateCollector( + candidateIds: ReadonlySet, + platform: NodeJS.Platform = process.platform, + owners: Set = new Set() +): WorktreeOwnerCandidateCollector { + const candidateIdsByComparisonKey = new Map() + for (const candidateId of candidateIds) { + const comparisonKey = worktreeRetentionIdComparisonKey(candidateId, platform) + if (!comparisonKey) { + continue + } + const matches = candidateIdsByComparisonKey.get(comparisonKey) ?? [] + matches.push(candidateId) + candidateIdsByComparisonKey.set(comparisonKey, matches) + } + return { + owners, + addOwner: (ownerKey) => { + const worktreeId = + ownerKey && candidateIds.has(ownerKey) ? ownerKey : getPersistedWorktreeOwnerId(ownerKey) + if (!worktreeId) { + return + } + const comparisonKey = worktreeRetentionIdComparisonKey(worktreeId, platform) + const equivalentCandidates = comparisonKey + ? candidateIdsByComparisonKey.get(comparisonKey) + : undefined + if (equivalentCandidates) { + for (const candidateId of equivalentCandidates) { + owners.add(candidateId) + } + } else if (candidateIds.has(worktreeId)) { + owners.add(worktreeId) + } + } + } +} + +function collectRecordKeys( + value: unknown, + add: (ownerKey: string | null | undefined) => void +): void { + for (const ownerKey of Object.keys((value ?? {}) as Record)) { + add(ownerKey) + } +} + +function collectWorktreeRows( + rows: unknown, + add: (ownerKey: string | null | undefined) => void +): void { + if (!Array.isArray(rows)) { + return + } + for (const row of rows as WorktreeRow[]) { + add(row?.worktreeId) + } +} + +function collectBrowserRows( + rows: unknown, + add: (ownerKey: string | null | undefined) => void +): void { + if (!Array.isArray(rows)) { + return + } + for (const row of rows as BrowserWorktreeRow[]) { + add(row?.worktreeId) + add(row?.docLocation?.worktreeId) + } +} + +function collectSessionFieldOwners( + value: unknown, + kind: WorkspaceSessionWorktreeReferenceKind, + add: (ownerKey: string | null | undefined) => void +): void { + switch (kind) { + case 'none': + return + case 'direct': + add(typeof value === 'string' ? value : null) + return + case 'owner-keyed': + collectRecordKeys(value, add) + return + case 'owner-keyed-row-arrays': + case 'owner-keyed-browser-row-arrays': + for (const [ownerKey, rows] of Object.entries((value ?? {}) as Record)) { + add(ownerKey) + if (kind === 'owner-keyed-browser-row-arrays') { + collectBrowserRows(rows, add) + } else { + collectWorktreeRows(rows, add) + } + } + return + case 'worktree-id-array': + for (const ownerKey of (value ?? []) as string[]) { + add(ownerKey) + } + return + case 'row-record': + for (const row of Object.values((value ?? {}) as Record)) { + add(row?.worktreeId) + } + return + case 'row-arrays': + case 'browser-row-arrays': + for (const rows of Object.values((value ?? {}) as Record)) { + if (kind === 'browser-row-arrays') { + collectBrowserRows(rows, add) + } else { + collectWorktreeRows(rows, add) + } + } + } +} + +export function collectWorkspaceSessionWorktreeOwners( + session: WorkspaceSessionState, + candidateIds: ReadonlySet, + platform: NodeJS.Platform = process.platform, + owners: Set = new Set() +): Set { + const collector = createWorktreeOwnerCandidateCollector(candidateIds, platform, owners) + addWorkspaceSessionWorktreeOwners(session, collector) + return owners +} + +function addWorkspaceSessionWorktreeOwners( + session: WorkspaceSessionState, + collector: WorktreeOwnerCandidateCollector +): void { + for (const field of Object.keys( + WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND + ) as (keyof WorkspaceSessionState)[]) { + collectSessionFieldOwners( + session[field], + WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND[field], + collector.addOwner + ) + } +} + +export function addPersistedSessionWorktreeOwners( + state: Pick, + collector: WorktreeOwnerCandidateCollector +): void { + addWorkspaceSessionWorktreeOwners(state.workspaceSession, collector) + for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) { + if (session) { + addWorkspaceSessionWorktreeOwners(session, collector) + } + } +} + +export function collectPersistedSessionWorktreeOwners( + state: Pick, + candidateIds: ReadonlySet, + platform: NodeJS.Platform = process.platform +): Set { + const collector = createWorktreeOwnerCandidateCollector(candidateIds, platform) + addPersistedSessionWorktreeOwners(state, collector) + return collector.owners +} diff --git a/src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts b/src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts new file mode 100644 index 00000000000..9cebe041544 --- /dev/null +++ b/src/main/persistence/restoring-sessions/workspace-pane-normalization-index.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { createLazyTerminalTabLookup } from './pane-identity-migration' +import { normalizeWorkspaceSessionPaneIdentities } from './workspace-pane-normalization' + +const { registerPaneKeyAliasMock } = vi.hoisted(() => ({ + registerPaneKeyAliasMock: vi.fn() +})) + +vi.mock('../../agent-hooks/server', () => ({ + agentHookServer: { + registerPaneKeyAlias: registerPaneKeyAliasMock + } +})) + +const STABLE_LEAF_ID = '00000000-0000-4000-8000-000000000001' + +function terminalTab(id: string, worktreeId: string, ptyId: string | null): TerminalTab { + return { + id, + worktreeId, + ptyId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function workspaceSession( + tabsByWorktree: Record, + terminalLayoutsByTabId: Record +): WorkspaceSessionState { + return { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree, + terminalLayoutsByTabId + } +} + +function legacyLayout(): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId: 'pane:1' }, + activeLeafId: 'pane:1', + expandedLeafId: null + } +} + +function stableLayout(): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId: STABLE_LEAF_ID }, + activeLeafId: STABLE_LEAF_ID, + expandedLeafId: null + } +} + +describe('workspace pane normalization tab index', () => { + beforeEach(() => { + registerPaneKeyAliasMock.mockReset() + }) + + it('keeps the first tab when duplicate ids occur across worktrees', () => { + const result = normalizeWorkspaceSessionPaneIdentities( + workspaceSession( + { + first: [terminalTab('duplicate-tab', 'first', 'first-pty')], + second: [terminalTab('duplicate-tab', 'second', 'second-pty')] + }, + { 'duplicate-tab': legacyLayout() } + ) + ) + + expect(result.legacyPaneKeyAliasEntries).not.toHaveLength(0) + expect(result.legacyPaneKeyAliasEntries.every((entry) => entry.ptyId === 'first-pty')).toBe( + true + ) + }) + + it('keeps missing tab ids without inventing persisted aliases', () => { + const result = normalizeWorkspaceSessionPaneIdentities( + workspaceSession({}, { 'missing-tab': legacyLayout() }) + ) + + expect(result.legacyPaneKeyAliasEntries).toEqual([]) + }) + + it('reads each tab id once regardless of the number of layouts', () => { + const tabCount = 256 + let idReads = 0 + const tabs = Array.from({ length: tabCount }, (_, index) => { + const id = `tab-${index}` + const tab = terminalTab(id, 'worktree', null) + Object.defineProperty(tab, 'id', { + enumerable: true, + get: () => { + idReads += 1 + return id + } + }) + return tab + }) + const layouts = Object.fromEntries( + Array.from({ length: tabCount }, (_, index) => [`tab-${index}`, stableLayout()]) + ) + + normalizeWorkspaceSessionPaneIdentities(workspaceSession({ worktree: tabs }, layouts)) + + expect(idReads).toBe(tabCount) + }) + + it('does not inspect tabs after an early match', () => { + const first = terminalTab('first', 'worktree', 'first-pty') + const later = terminalTab('later', 'worktree', 'later-pty') + Object.defineProperty(later, 'id', { + enumerable: true, + get: () => { + throw new Error('later tab should not be inspected') + } + }) + + const lookup = createLazyTerminalTabLookup(workspaceSession({ worktree: [first, later] }, {})) + + expect(lookup.get('first')).toBe(first) + }) + + it('resumes scanning and makes later misses constant-time', () => { + const reads: string[] = [] + const tabs = ['first', 'second', 'third'].map((id) => { + const tab = terminalTab(id, 'worktree', null) + Object.defineProperty(tab, 'id', { + enumerable: true, + get: () => { + reads.push(id) + return id + } + }) + return tab + }) + const lookup = createLazyTerminalTabLookup(workspaceSession({ worktree: tabs }, {})) + + expect(lookup.get('first')).toBe(tabs[0]) + expect(reads).toEqual(['first']) + expect(lookup.get('second')).toBe(tabs[1]) + expect(reads).toEqual(['first', 'second']) + expect(lookup.get('missing')).toBeUndefined() + expect(reads).toEqual(['first', 'second', 'third']) + expect(lookup.get('missing')).toBeUndefined() + expect(reads).toEqual(['first', 'second', 'third']) + }) + + it('skips sparse tab-array holes', () => { + const tabs: TerminalTab[] = [] + tabs.length = 3 + tabs[1] = terminalTab('target', 'worktree', null) + const lookup = createLazyTerminalTabLookup(workspaceSession({ worktree: tabs }, {})) + + expect(lookup.get('target')).toBe(tabs[1]) + expect(lookup.get('missing')).toBeUndefined() + }) + + it('does not read tab ids when every layout is skipped', () => { + const tab = terminalTab('skipped', 'worktree', null) + let idReads = 0 + Object.defineProperty(tab, 'id', { + enumerable: true, + get: () => { + idReads += 1 + return 'skipped' + } + }) + + normalizeWorkspaceSessionPaneIdentities( + workspaceSession({ worktree: [tab] }, { skipped: legacyLayout() }), + {}, + { skipAliasTabIds: new Set(['skipped']) } + ) + + expect(idReads).toBe(0) + }) +}) diff --git a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts index a977134bd0e..2bfafebae9f 100644 --- a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts +++ b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts @@ -10,7 +10,10 @@ import { import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { findCrossHostPaneTabIds, withoutPaneTabIds } from './cross-host-pane-tab-ids' -import { registerLegacyPaneKeyAliasesForTab } from './pane-identity-migration' +import { + createLazyTerminalTabLookup, + registerLegacyPaneKeyAliasesForTab +} from './pane-identity-migration' import { normalizeTerminalLayoutSnapshotForPersistence } from './terminal-layout-normalization' import { legacyMigrationUnsupportedRowsToAliasEntries, @@ -40,6 +43,7 @@ export function normalizeWorkspaceSessionPaneIdentities( const migrationUnsupportedEntries: MigrationUnsupportedPtyEntry[] = [] const legacyPaneKeyAliasEntries: LegacyPaneKeyAliasEntry[] = [] const terminalLayoutsByTabId: Record = {} + let tabsById: ReturnType | null = null for (const [tabId, layout] of Object.entries(session.terminalLayoutsByTabId ?? {})) { const normalized = normalizeTerminalLayoutSnapshotForPersistence( layout, @@ -48,9 +52,10 @@ export function normalizeWorkspaceSessionPaneIdentities( terminalLayoutsByTabId[tabId] = normalized.snapshot leafIdByInputLeafIdByTabId.set(tabId, normalized.leafIdByInputLeafId) if (!options.skipAliasTabIds?.has(tabId)) { + tabsById ??= createLazyTerminalTabLookup(session) const tabAliasEntries = registerLegacyPaneKeyAliasesForTab({ - session, tabId, + tab: tabsById.get(tabId), inputLayout: layout, normalizedLayout: normalized.snapshot, leafIdByInputLeafId: normalized.leafIdByInputLeafId diff --git a/src/main/persistence/tracking-repos/local-worktree-metadata-batch-pruning.bench.test.ts b/src/main/persistence/tracking-repos/local-worktree-metadata-batch-pruning.bench.test.ts new file mode 100644 index 00000000000..0f5a85a80c6 --- /dev/null +++ b/src/main/persistence/tracking-repos/local-worktree-metadata-batch-pruning.bench.test.ts @@ -0,0 +1,106 @@ +// Opt in: ORCA_LOCAL_METADATA_PRUNE_BENCH=1 pnpm test src/main/persistence/tracking-repos/local-worktree-metadata-batch-pruning.bench.test.ts +import { performance } from 'node:perf_hooks' +import { describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Repo } from '../../../shared/repo-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import { removeWorkspaceSessionOwner } from '../restoring-sessions/session-owner-removal' +import { + pruneUnreferencedWorktreeIdentityMeta, + removeWorktreeMetadataForHost +} from '../loading-store/worktree-identity-metadata' +import { + captureNativeLocalWorktreeMetadataScanExpectation, + pruneSessionlessMissingLocalWorktreeMetadataForRepo +} from './missing-local-worktree-metadata-pruning' + +const describeBench = process.env.ORCA_LOCAL_METADATA_PRUNE_BENCH ? describe : describe.skip +const ROW_COUNT = 2_709 +const REPO: Repo = { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 +} + +function makeMeta(index: number): WorktreeMeta { + return { + instanceId: `instance-${index}`, + hostId: 'local', + displayName: `stale-${index}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: index, + lastActivityAt: 0 + } +} + +function makeState(): { state: PersistedState; staleIds: string[] } { + const state = getDefaultPersistedState('/home/test') + state.repos = [REPO] + state.worktreeMetaByIdentity = {} + state.worktreeIdentityAliases = {} + const staleIds = Array.from( + { length: ROW_COUNT }, + (_, index) => `${REPO.id}::/workspace/stale-${index}` + ) + for (const [index, worktreeId] of staleIds.entries()) { + const meta = makeMeta(index) + const identityKey = `identity-${index}` + state.worktreeMeta[worktreeId] = meta + state.worktreeMetaByIdentity[identityKey] = meta + state.worktreeIdentityAliases[`local|${worktreeId}`] = [identityKey] + } + return { state, staleIds } +} + +describeBench('authoritative local metadata batch pruning', () => { + it('measures the reported 2,709-row sequential and batch shapes', () => { + const legacy = makeState() + const batch = makeState() + const cloneSpy = vi.spyOn(globalThis, 'structuredClone') + const legacyStartedAt = performance.now() + for (const worktreeId of legacy.staleIds) { + removeWorktreeMetadataForHost(legacy.state, worktreeId, undefined) + delete legacy.state.worktreeMeta[worktreeId] + legacy.state.workspaceSession = removeWorkspaceSessionOwner( + legacy.state.workspaceSession, + worktreeId + )! + } + pruneUnreferencedWorktreeIdentityMeta(legacy.state) + const legacyMs = performance.now() - legacyStartedAt + const legacySessionClones = cloneSpy.mock.calls.length + cloneSpy.mockClear() + + const batchStartedAt = performance.now() + const scan = captureNativeLocalWorktreeMetadataScanExpectation(batch.state, REPO) + const removed = pruneSessionlessMissingLocalWorktreeMetadataForRepo( + batch.state, + scan, + scan.metadata + ) + const batchMs = performance.now() - batchStartedAt + const batchSessionClones = cloneSpy.mock.calls.length + cloneSpy.mockRestore() + + expect(removed).toHaveLength(ROW_COUNT) + expect(Object.keys(legacy.state.worktreeMeta)).toHaveLength(0) + expect(Object.keys(batch.state.worktreeMeta)).toHaveLength(0) + expect(legacySessionClones).toBe(ROW_COUNT) + expect(batchSessionClones).toBe(0) + + console.log( + `[bench] rows=${ROW_COUNT} metadata+session=${legacyMs.toFixed(2)}ms -> ${batchMs.toFixed(2)}ms; ` + + `gitScans=${ROW_COUNT} -> 1; sessionClones=${legacySessionClones} -> ${batchSessionClones}; ` + + `minimumSaveSchedules=${ROW_COUNT * 2} -> 1` + ) + }) +}) diff --git a/src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.test.ts b/src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.test.ts new file mode 100644 index 00000000000..85c883fe666 --- /dev/null +++ b/src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultPersistedState } from '../../../shared/constants' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Repo } from '../../../shared/repo-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import { + captureNativeLocalWorktreeMetadataScanExpectation, + pruneSessionlessMissingLocalWorktreeMetadataForRepo +} from './missing-local-worktree-metadata-pruning' + +const REPO_ID = 'repo-1' +const WORKTREE_ID = `${REPO_ID}::/workspace/stale` +const LOCAL_ALIAS = `local|${WORKTREE_ID}` +const IDENTITY_KEY = 'local-identity' + +function makeRepo(id = REPO_ID): Repo { + return { + id, + path: `/workspace/${id}`, + displayName: id, + badgeColor: '#000', + addedAt: 0 + } +} + +function makeMeta(worktreeId = WORKTREE_ID): WorktreeMeta { + return { + instanceId: `instance-${worktreeId}`, + hostId: 'local', + displayName: worktreeId, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } +} + +function makeState(): PersistedState { + const state = getDefaultPersistedState('/home/test') + state.repos = [makeRepo()] + return state +} + +function makeCanonicalOnlyState(): { + state: PersistedState + meta: WorktreeMeta +} { + const state = makeState() + const meta = makeMeta() + state.worktreeMetaByIdentity = { [IDENTITY_KEY]: meta } + state.worktreeIdentityAliases = { [LOCAL_ALIAS]: [IDENTITY_KEY] } + return { state, meta } +} + +function pruneCaptured(state: PersistedState): string[] { + const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!) + return pruneSessionlessMissingLocalWorktreeMetadataForRepo(state, scan, scan.metadata) +} + +describe('local worktree metadata scan expectations', () => { + it('captures canonical-only local metadata and removes its orphaned identity row', () => { + const { state } = makeCanonicalOnlyState() + + expect(pruneCaptured(state)).toEqual([WORKTREE_ID]) + expect(state.worktreeMeta[WORKTREE_ID]).toBeUndefined() + expect(state.worktreeIdentityAliases?.[LOCAL_ALIAS]).toBeUndefined() + expect(state.worktreeMetaByIdentity?.[IDENTITY_KEY]).toBeUndefined() + }) + + it('removes equal legacy and canonical projections after a persistence round trip', () => { + const state = makeState() + const meta = makeMeta() + state.worktreeMeta[WORKTREE_ID] = structuredClone(meta) + state.worktreeMetaByIdentity = { [IDENTITY_KEY]: structuredClone(meta) } + state.worktreeIdentityAliases = { [LOCAL_ALIAS]: [IDENTITY_KEY] } + + expect(pruneCaptured(state)).toEqual([WORKTREE_ID]) + expect(state.worktreeMeta[WORKTREE_ID]).toBeUndefined() + expect(state.worktreeIdentityAliases[LOCAL_ALIAS]).toBeUndefined() + expect(state.worktreeMetaByIdentity[IDENTITY_KEY]).toBeUndefined() + }) + + it('does not sweep an unrelated unaliased canonical row during pruning', () => { + const { state, meta } = makeCanonicalOnlyState() + const unrelated = makeMeta(`${REPO_ID}::/workspace/unrelated`) + state.worktreeMetaByIdentity!.unrelated = unrelated + + expect(pruneCaptured(state)).toEqual([WORKTREE_ID]) + expect(state.worktreeMetaByIdentity).toEqual({ unrelated }) + expect(state.worktreeMetaByIdentity?.[IDENTITY_KEY]).not.toBeDefined() + expect(meta).not.toBe(unrelated) + }) + + it('allocates expectations only for the scanned repository', () => { + const state = makeState() + const otherRepo = makeRepo('repo-2') + const otherId = `${otherRepo.id}::/workspace/other` + const canonicalId = `${REPO_ID}::/workspace/canonical` + const canonicalKey = 'canonical-key' + state.repos.push(otherRepo) + state.worktreeMeta[WORKTREE_ID] = makeMeta() + state.worktreeMeta[otherId] = makeMeta(otherId) + state.worktreeMetaByIdentity = { + [canonicalKey]: makeMeta(canonicalId), + 'other-key': makeMeta(otherId) + } + state.worktreeIdentityAliases = { + [`local|${canonicalId}`]: [canonicalKey], + [`local|${otherId}`]: ['other-key'] + } + + const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!) + + expect(scan.metadata.map(({ worktreeId }) => worktreeId).sort()).toEqual( + [WORKTREE_ID, canonicalId].sort() + ) + }) + + it('preserves metadata when an alias array is replaced with identical contents', () => { + const { state } = makeCanonicalOnlyState() + const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!) + state.worktreeIdentityAliases![LOCAL_ALIAS] = [IDENTITY_KEY] + + expect(pruneSessionlessMissingLocalWorktreeMetadataForRepo(state, scan, scan.metadata)).toEqual( + [] + ) + expect(state.worktreeMetaByIdentity?.[IDENTITY_KEY]).toBeDefined() + }) + + it('preserves metadata when the captured alias array is mutated in place', () => { + const { state } = makeCanonicalOnlyState() + const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!) + state.worktreeIdentityAliases![LOCAL_ALIAS]!.push('new-identity') + state.worktreeMetaByIdentity!['new-identity'] = makeMeta() + + expect(pruneSessionlessMissingLocalWorktreeMetadataForRepo(state, scan, scan.metadata)).toEqual( + [] + ) + }) + + it.each([ + [ + 'replaced', + (state: PersistedState) => { + state.worktreeMetaByIdentity![IDENTITY_KEY] = { + ...state.worktreeMetaByIdentity![IDENTITY_KEY]! + } + } + ], + [ + 'host-mutated', + (state: PersistedState) => { + state.worktreeMetaByIdentity![IDENTITY_KEY]!.hostId = 'ssh:builder' + } + ], + [ + 'instance-mutated', + (state: PersistedState) => { + state.worktreeMetaByIdentity![IDENTITY_KEY]!.instanceId = 'new-instance' + } + ], + [ + 'content-mutated', + (state: PersistedState) => { + state.worktreeMetaByIdentity![IDENTITY_KEY]!.comment = 'new comment' + } + ] + ] as const)('preserves metadata when a canonical row is %s', (_label, mutate) => { + const { state } = makeCanonicalOnlyState() + const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!) + mutate(state) + + expect(pruneSessionlessMissingLocalWorktreeMetadataForRepo(state, scan, scan.metadata)).toEqual( + [] + ) + }) + + it.each([ + [ + 'added', + (state: PersistedState) => { + state.worktreeIdentityAliases![`ssh:builder|${WORKTREE_ID}`] = [IDENTITY_KEY] + } + ], + [ + 'removed', + (state: PersistedState) => { + delete state.worktreeIdentityAliases![LOCAL_ALIAS] + } + ] + ] as const)('preserves metadata when an alias is %s during the scan', (_label, mutate) => { + const { state } = makeCanonicalOnlyState() + const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!) + mutate(state) + + expect(pruneSessionlessMissingLocalWorktreeMetadataForRepo(state, scan, scan.metadata)).toEqual( + [] + ) + expect(state.worktreeMetaByIdentity?.[IDENTITY_KEY]).toBeDefined() + }) + + it('preserves a legacy row created after a canonical-only scan capture', () => { + const { state } = makeCanonicalOnlyState() + const scan = captureNativeLocalWorktreeMetadataScanExpectation(state, state.repos[0]!) + state.worktreeMeta[WORKTREE_ID] = makeMeta() + + expect(pruneSessionlessMissingLocalWorktreeMetadataForRepo(state, scan, scan.metadata)).toEqual( + [] + ) + expect(state.worktreeMeta[WORKTREE_ID]).toBeDefined() + }) + + it.each([WORKTREE_ID, `bogus|${WORKTREE_ID}`, `ssh:builder|${WORKTREE_ID}`])( + 'preserves metadata with an unqualified or foreign alias: %s', + (alias) => { + const state = makeState() + state.worktreeMeta[WORKTREE_ID] = makeMeta() + state.worktreeMetaByIdentity = { [IDENTITY_KEY]: state.worktreeMeta[WORKTREE_ID]! } + state.worktreeIdentityAliases = { [alias]: [IDENTITY_KEY] } + + expect(pruneCaptured(state)).toEqual([]) + expect(state.worktreeMeta[WORKTREE_ID]).toBeDefined() + } + ) + + it('preserves an unqualified alias whose worktree path contains a pipe', () => { + const state = makeState() + const pipedId = `${REPO_ID}::/workspace/a|b` + state.worktreeMeta[pipedId] = makeMeta(pipedId) + state.worktreeMetaByIdentity = { [IDENTITY_KEY]: state.worktreeMeta[pipedId]! } + state.worktreeIdentityAliases = { [pipedId]: [IDENTITY_KEY] } + + expect(pruneCaptured(state)).toEqual([]) + expect(state.worktreeMeta[pipedId]).toBeDefined() + expect(state.worktreeIdentityAliases[pipedId]).toEqual([IDENTITY_KEY]) + }) +}) diff --git a/src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.ts b/src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.ts new file mode 100644 index 00000000000..fca925d0986 --- /dev/null +++ b/src/main/persistence/tracking-repos/local-worktree-metadata-scan-expectation.ts @@ -0,0 +1,243 @@ +import { isDeepStrictEqual } from 'node:util' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { Project } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import { getRepoKind } from '../../../shared/repo-kind' +import { splitWorktreeId } from '../../../shared/worktree/id' +import { + composeWorktreeHostIdentity, + getWorktreeIdFromHostIdentity, + isWorktreeHostIdentity +} from '../../../shared/worktree/host-qualified-identity' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' + +type CapturedLocalRepoIdentity = Readonly< + Pick & { + kind: ReturnType + expectedRepo: Repo | undefined + } +> + +type CapturedLocalRoutingIdentity = Readonly<{ + expectedProject: Project | undefined + expectedProjectUpdatedAt: number | undefined + expectedSettings: GlobalSettings +}> + +type MetadataRowExpectation = Readonly<{ + expectedPresent: boolean + expectedMeta: WorktreeMeta | undefined + expectedSerialized: string | null | undefined + expectedInstanceId: string | undefined + expectedHostId: WorktreeMeta['hostId'] +}> + +type MetadataAliasExpectation = Readonly<{ + alias: string + expectedAlias: string[] + expectedIdentityKeys: readonly string[] + expectedIdentityRows: readonly (MetadataRowExpectation & { identityKey: string })[] +}> + +export type LocalWorktreeMetadataPruneExpectation = Readonly<{ + worktreeId: string + expectedLegacy: MetadataRowExpectation + expectedAliases: readonly MetadataAliasExpectation[] +}> + +export type NativeLocalWorktreeMetadataScanExpectation = Readonly<{ + repo: CapturedLocalRepoIdentity + routing: CapturedLocalRoutingIdentity + metadata: readonly LocalWorktreeMetadataPruneExpectation[] +}> + +type MetadataAliasEntry = readonly [alias: string, identityKeys: string[]] + +function serializeMetadataRow(meta: WorktreeMeta | undefined): string | null | undefined { + if (!meta) { + return undefined + } + try { + return JSON.stringify(meta) + } catch { + return null + } +} + +function captureRow( + record: Readonly> | undefined, + key: string +): MetadataRowExpectation { + const expectedPresent = Object.hasOwn(record ?? {}, key) + const expectedMeta = record?.[key] + return { + expectedPresent, + expectedMeta, + expectedSerialized: serializeMetadataRow(expectedMeta), + expectedInstanceId: expectedMeta?.instanceId, + expectedHostId: expectedMeta?.hostId + } +} + +export function indexMetadataAliasesForWorktreeIds( + state: PersistedState, + worktreeIds: ReadonlySet +): Map { + const indexed = new Map() + for (const [alias, identityKeys] of Object.entries(state.worktreeIdentityAliases ?? {})) { + const matches = new Set() + if (worktreeIds.has(alias)) { + matches.add(alias) + } + const separator = alias.indexOf('|') + const suffix = separator === -1 ? alias : alias.slice(separator + 1) + if (worktreeIds.has(suffix)) { + matches.add(suffix) + } + for (const worktreeId of matches) { + const entries = indexed.get(worktreeId) ?? [] + entries.push([alias, identityKeys]) + indexed.set(worktreeId, entries) + } + } + return indexed +} + +export function captureNativeLocalWorktreeMetadataScanExpectation( + state: PersistedState, + repo: Repo +): NativeLocalWorktreeMetadataScanExpectation { + const matchingRepos = state.repos.filter((entry) => entry.id === repo.id) + const expectedRepo = matchingRepos.length === 1 ? matchingRepos[0] : undefined + const expectedProject = state.projects.find((project) => project.sourceRepoIds.includes(repo.id)) + const candidateIds = new Set() + for (const [worktreeId, meta] of Object.entries(state.worktreeMeta)) { + if ( + splitWorktreeId(worktreeId)?.repoId === repo.id && + (!meta.hostId || meta.hostId === LOCAL_EXECUTION_HOST_ID) + ) { + candidateIds.add(worktreeId) + } + } + for (const alias of Object.keys(state.worktreeIdentityAliases ?? {})) { + if (!isWorktreeHostIdentity(alias)) { + continue + } + const worktreeId = getWorktreeIdFromHostIdentity(alias) + if ( + alias === composeWorktreeHostIdentity(LOCAL_EXECUTION_HOST_ID, worktreeId) && + splitWorktreeId(worktreeId)?.repoId === repo.id + ) { + candidateIds.add(worktreeId) + } + } + const aliasesByWorktreeId = indexMetadataAliasesForWorktreeIds(state, candidateIds) + return { + repo: { + id: repo.id, + path: repo.path, + connectionId: repo.connectionId, + executionHostId: repo.executionHostId, + kind: getRepoKind(repo), + expectedRepo + }, + routing: { + expectedProject, + expectedProjectUpdatedAt: expectedProject?.updatedAt, + expectedSettings: state.settings + }, + metadata: [...candidateIds].map((worktreeId) => ({ + worktreeId, + expectedLegacy: captureRow(state.worktreeMeta, worktreeId), + expectedAliases: (aliasesByWorktreeId.get(worktreeId) ?? []).map(([alias, identityKeys]) => ({ + alias, + expectedAlias: identityKeys, + expectedIdentityKeys: [...identityKeys], + expectedIdentityRows: identityKeys.map((identityKey) => ({ + identityKey, + ...captureRow(state.worktreeMetaByIdentity, identityKey) + })) + })) + })) + } +} + +function rowStillMatches( + record: Readonly> | undefined, + key: string, + expected: MetadataRowExpectation +): boolean { + const current = record?.[key] + return ( + Object.hasOwn(record ?? {}, key) === expected.expectedPresent && + current === expected.expectedMeta && + expected.expectedSerialized !== null && + serializeMetadataRow(current) === expected.expectedSerialized && + current?.instanceId === expected.expectedInstanceId && + current?.hostId === expected.expectedHostId + ) +} + +function aliasesStillMatch( + state: PersistedState, + expected: LocalWorktreeMetadataPruneExpectation, + currentAliases: readonly MetadataAliasEntry[] +): boolean { + if (currentAliases.length !== expected.expectedAliases.length) { + return false + } + const currentByAlias = new Map(currentAliases) + return expected.expectedAliases.every((aliasExpectation) => { + const current = currentByAlias.get(aliasExpectation.alias) + return Boolean( + current && + current === aliasExpectation.expectedAlias && + current.length === aliasExpectation.expectedIdentityKeys.length && + current.every((key, index) => key === aliasExpectation.expectedIdentityKeys[index]) && + aliasExpectation.expectedIdentityRows.every(({ identityKey, ...row }) => + rowStillMatches(state.worktreeMetaByIdentity, identityKey, row) + ) + ) + }) +} + +export function removeRevalidatedLocalWorktreeMetadata( + state: PersistedState, + expected: LocalWorktreeMetadataPruneExpectation, + currentAliases: readonly MetadataAliasEntry[], + removedIdentityKeys?: Set +): boolean { + if ( + !rowStillMatches(state.worktreeMeta, expected.worktreeId, expected.expectedLegacy) || + !aliasesStillMatch(state, expected, currentAliases) + ) { + return false + } + const localAlias = composeWorktreeHostIdentity(LOCAL_EXECUTION_HOST_ID, expected.worktreeId) + if (currentAliases.some(([alias]) => alias !== localAlias)) { + return false + } + const legacy = state.worktreeMeta[expected.worktreeId] + const identityKeys = state.worktreeIdentityAliases?.[localAlias] + if (identityKeys && identityKeys.length !== 1) { + return false + } + const canonical = identityKeys?.[0] ? state.worktreeMetaByIdentity?.[identityKeys[0]] : undefined + if ( + (legacy?.hostId && legacy.hostId !== LOCAL_EXECUTION_HOST_ID) || + (canonical?.hostId && canonical.hostId !== LOCAL_EXECUTION_HOST_ID) || + (identityKeys && !canonical) || + (legacy && canonical && !isDeepStrictEqual(legacy, canonical)) || + (!legacy && !canonical) + ) { + return false + } + if (identityKeys?.[0]) { + removedIdentityKeys?.add(identityKeys[0]) + } + delete state.worktreeMeta[expected.worktreeId] + delete state.worktreeIdentityAliases?.[localAlias] + return true +} diff --git a/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts b/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts new file mode 100644 index 00000000000..cdad14034e2 --- /dev/null +++ b/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts @@ -0,0 +1,477 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants' +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Project } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' +import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import { + captureNativeLocalWorktreeMetadataScanExpectation, + pruneSessionlessMissingLocalWorktreeMetadataForRepo +} from './missing-local-worktree-metadata-pruning' + +const REPO_ID = 'repo-1' +const LIVE_ID = `${REPO_ID}::/workspace/live` + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: REPO_ID, + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + ...overrides + } +} + +function makeMeta(worktreeId: string): WorktreeMeta { + return { + instanceId: `instance-${worktreeId}`, + hostId: 'local', + displayName: worktreeId, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } +} + +function makeState(repo = makeRepo()): PersistedState { + const state = getDefaultPersistedState('/home/test') + state.repos = [repo] + return state +} + +function capture(state: PersistedState, repo = state.repos[0]!) { + return captureNativeLocalWorktreeMetadataScanExpectation(state, repo) +} + +function pruneCaptured( + state: PersistedState, + scan: ReturnType, + ids: readonly string[], + platform?: NodeJS.Platform +): string[] { + const wanted = new Set(ids) + return pruneSessionlessMissingLocalWorktreeMetadataForRepo( + state, + scan, + scan.metadata.filter(({ worktreeId }) => wanted.has(worktreeId)), + platform + ) +} + +describe('pruneSessionlessMissingLocalWorktreeMetadataForRepo', () => { + it('removes 2,709 sessionless rows in one batch without cloning or changing session state', () => { + const state = makeState() + const staleIds = Array.from( + { length: 2_709 }, + (_, index) => `${REPO_ID}::/workspace/stale-${index}` + ) + const allIds = [LIVE_ID, ...staleIds] + state.worktreeMetaByIdentity = {} + state.worktreeIdentityAliases = {} + for (const [index, worktreeId] of allIds.entries()) { + const meta = makeMeta(worktreeId) + const identityKey = `identity-${index}` + state.worktreeMeta[worktreeId] = meta + state.worktreeMetaByIdentity[identityKey] = meta + state.worktreeIdentityAliases[`local|${worktreeId}`] = [identityKey] + } + state.worktreeLineageById[staleIds[0]!] = { worktreeId: staleIds[0] } as never + state.workspaceLineageByChildKey[worktreeWorkspaceKey(staleIds[0]!)] = { + childWorkspaceKey: worktreeWorkspaceKey(staleIds[0]!) + } as never + state.workspaceSession.terminalTopologyRevisionByRepoId = { [REPO_ID]: 7 } + const hostSession = { + ...getDefaultWorkspaceSession(), + terminalTopologyRevisionByRepoId: { [REPO_ID]: 11 } + } + state.workspaceSessionsByHostId = { 'runtime:mirror': hostSession } + const scan = capture(state) + const session = state.workspaceSession + const sessionSnapshot = structuredClone(session) + const hostSessionSnapshot = structuredClone(hostSession) + const cloneSpy = vi.spyOn(globalThis, 'structuredClone') + + const removed = pruneCaptured(state, scan, staleIds) + + expect(removed).toHaveLength(staleIds.length) + expect(cloneSpy).not.toHaveBeenCalled() + cloneSpy.mockRestore() + expect(Object.keys(state.worktreeMeta)).toEqual([LIVE_ID]) + expect(Object.keys(state.worktreeMetaByIdentity ?? {})).toEqual(['identity-0']) + expect(Object.keys(state.worktreeIdentityAliases ?? {})).toEqual([`local|${LIVE_ID}`]) + expect(state.worktreeLineageById[staleIds[0]!]).toBeUndefined() + expect(state.workspaceLineageByChildKey[worktreeWorkspaceKey(staleIds[0]!)]).toBeUndefined() + expect(state.workspaceSession).toBe(session) + expect(state.workspaceSession).toEqual(sessionSnapshot) + expect(state.workspaceSessionsByHostId?.['runtime:mirror']).toBe(hostSession) + expect(state.workspaceSessionsByHostId?.['runtime:mirror']).toEqual(hostSessionSnapshot) + }) + + it('fails closed when metadata is mutated or replaced after the scan starts', () => { + const inPlaceState = makeState() + const inPlaceId = `${REPO_ID}::/workspace/in-place` + inPlaceState.worktreeMeta[inPlaceId] = makeMeta(inPlaceId) + const inPlaceScan = capture(inPlaceState) + inPlaceState.worktreeMeta[inPlaceId]!.instanceId = 'replacement-instance' + + expect(pruneCaptured(inPlaceState, inPlaceScan, [inPlaceId])).toEqual([]) + + const replacedState = makeState() + const replacedId = `${REPO_ID}::/workspace/replaced` + const original = makeMeta(replacedId) + replacedState.worktreeMeta[replacedId] = original + const replacedScan = capture(replacedState) + replacedState.worktreeMeta[replacedId] = { ...original } + + expect(pruneCaptured(replacedState, replacedScan, [replacedId])).toEqual([]) + expect(replacedState.worktreeMeta[replacedId]).not.toBe(original) + }) + + it('fails closed when an identical repo row is removed and re-added during the scan', () => { + const state = makeState() + const worktreeId = `${REPO_ID}::/workspace/re-added-repo` + state.worktreeMeta[worktreeId] = makeMeta(worktreeId) + const scan = capture(state) + const replacement = { ...state.repos[0]! } + state.repos.splice(0, 1, replacement) + + expect(pruneCaptured(state, scan, [worktreeId])).toEqual([]) + expect(state.repos[0]).toBe(replacement) + expect(state.worktreeMeta[worktreeId]).toBeDefined() + }) + + it('uses the effective Git kind when the raw legacy repo omitted kind', () => { + const state = makeState() + const worktreeId = `${REPO_ID}::/workspace/legacy-git-kind` + const rawRepo = state.repos[0]! + state.worktreeMeta[worktreeId] = makeMeta(worktreeId) + const scan = capture(state, { ...rawRepo, kind: 'git' }) + + expect(rawRepo.kind).toBeUndefined() + expect(pruneCaptured(state, scan, [worktreeId])).toEqual([worktreeId]) + }) + + it.each([ + 'tabsByWorktree', + 'browserTabsByWorktree', + 'openFilesByWorktree', + 'activeFileIdByWorktree', + 'activeBrowserTabIdByWorktree', + 'clientHostedBrowserPagesByWorktree', + 'activeTabTypeByWorktree', + 'activeTabIdByWorktree', + 'unifiedTabs', + 'tabGroups', + 'tabGroupLayouts', + 'activeGroupIdByWorktree', + 'lastVisitedAtByWorktreeId', + 'defaultTerminalTabsAppliedByWorktreeId' + ] as const)('preserves an owner named by workspaceSession.%s', (field) => { + const state = makeState() + const worktreeId = `${REPO_ID}::/workspace/${field}` + state.worktreeMeta[worktreeId] = makeMeta(worktreeId) + const scan = capture(state) + const ownerKey = field === 'lastVisitedAtByWorktreeId' ? `local|${worktreeId}` : worktreeId + ;(state.workspaceSession as unknown as Record)[field] = { + [ownerKey]: [] + } + + expect(pruneCaptured(state, scan, [worktreeId])).toEqual([]) + expect(state.worktreeMeta[worktreeId]).toBeDefined() + }) + + it('preserves scalar, nested, remote-partition, mobile, and relay recovery owners', () => { + const ownerMutations: ((state: PersistedState, worktreeId: string) => void)[] = [ + (state, worktreeId) => { + state.workspaceSession.activeWorktreeId = worktreeId + }, + (state, worktreeId) => { + state.workspaceSession.activeWorkspaceKey = worktreeWorkspaceKey(worktreeId) + }, + (state, worktreeId) => { + state.workspaceSession.activeWorktreeIdsOnShutdown = [worktreeId] + }, + (state, worktreeId) => { + state.ui.lastActiveWorktreeId = worktreeId + }, + (state, worktreeId) => { + state.workspaceSession.sleepingAgentSessionsByPaneKey = { + pane: { worktreeId } as never + } + }, + (state, worktreeId) => { + state.workspaceSession.terminalSurfaceTombstonesByPaneKey = { + pane: { worktreeId } as never + } + }, + (state, worktreeId) => { + state.workspaceSession.closedTerminalTabTombstonesByTabId = { + tab: { worktreeId, closedAt: 1 } + } + }, + (state, worktreeId) => { + state.workspaceSession.clientHostedBrowserCloseIntentsByEnvironment = { + runtime: [{ worktreeId, browserPageId: 'page', closedAt: 1 }] + } + }, + (state, worktreeId) => { + state.workspaceSession.browserPagesByWorkspace = { + browser: [{ worktreeId } as never] + } + }, + (state, worktreeId) => { + state.workspaceSessionsByHostId = { + 'ssh:builder': { + ...getDefaultWorkspaceSession(), + lastVisitedAtByWorktreeId: { [worktreeId]: 1 } + } + } + }, + (state, worktreeId) => { + state.mobileClientTabSelectionsByDeviceId = { + phone: { [worktreeId]: {} as never } + } + }, + (state, worktreeId) => { + state.sshRemotePtyLeases = [ + { + targetId: 'builder', + ptyId: 'pty', + worktreeId, + state: 'detached', + createdAt: 1, + updatedAt: 1 + } + ] + }, + (state, worktreeId) => { + state.migrationUnsupportedPtyEntries = [ + { + ptyId: 'pty', + worktreeId, + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 1 + } + ] + }, + (state, worktreeId) => { + state.automations = [ + { + enabled: true, + workspaceMode: 'existing', + workspaceId: worktreeId + } as never + ] + }, + (state, worktreeId) => { + state.automationRuns = [{ status: 'dispatching', workspaceId: worktreeId } as never] + } + ] + + for (const [index, mutate] of ownerMutations.entries()) { + const state = makeState() + const worktreeId = `${REPO_ID}::/workspace/owner-${index}` + state.worktreeMeta[worktreeId] = makeMeta(worktreeId) + const scan = capture(state) + mutate(state, worktreeId) + expect(pruneCaptured(state, scan, [worktreeId])).toEqual([]) + } + }) + + it('preserves canonically equivalent session and top-level owners', () => { + const candidateId = `${REPO_ID}::/workspace/Café`.normalize('NFC') + const ownerId = candidateId.normalize('NFD') + const ownerMutations: ((state: PersistedState) => void)[] = [ + (state) => { + state.workspaceSession.activeWorktreeId = ownerId + }, + (state) => { + state.ui.lastActiveWorktreeId = ownerId + } + ] + + for (const mutate of ownerMutations) { + const state = makeState() + state.worktreeMeta[candidateId] = makeMeta(candidateId) + const scan = capture(state) + mutate(state) + + expect(pruneCaptured(state, scan, [candidateId])).toEqual([]) + expect(state.worktreeMeta[candidateId]).toBeDefined() + } + }) + + it('removes a row on a later pass after its persisted owner is cleared', () => { + const state = makeState() + const worktreeId = `${REPO_ID}::/workspace/two-phase` + state.worktreeMeta[worktreeId] = makeMeta(worktreeId) + state.workspaceSession.lastVisitedAtByWorktreeId = { [worktreeId]: 1 } + const scan = capture(state) + + expect(pruneCaptured(state, scan, [worktreeId])).toEqual([]) + delete state.workspaceSession.lastVisitedAtByWorktreeId![worktreeId] + expect(pruneCaptured(state, scan, [worktreeId])).toEqual([worktreeId]) + }) + + it('treats dotfile visibility and cleanup dismissals as non-owning preferences', () => { + const state = makeState() + const worktreeId = `${REPO_ID}::/workspace/non-owning-preferences` + state.worktreeMeta[worktreeId] = makeMeta(worktreeId) + const showDotfilesByWorktree = { [worktreeId]: true } + const workspaceCleanup = { + dismissals: { + [worktreeId]: { + worktreeId, + dismissedAt: 1, + fingerprint: 'fingerprint', + classifierVersion: 1 + } + } + } + state.ui.showDotfilesByWorktree = showDotfilesByWorktree + state.ui.workspaceCleanup = workspaceCleanup + const scan = capture(state) + + expect(pruneCaptured(state, scan, [worktreeId])).toEqual([worktreeId]) + expect(state.ui.showDotfilesByWorktree).toBe(showDotfilesByWorktree) + expect(state.ui.workspaceCleanup).toBe(workspaceCleanup) + }) + + it('preserves foreign aliases, ambiguous local aliases, and divergent projections', () => { + const foreignState = makeState() + const foreignId = `${REPO_ID}::/workspace/foreign-alias` + const localMeta = makeMeta(foreignId) + const remoteMeta = { ...localMeta, hostId: 'ssh:builder' as const } + foreignState.worktreeMeta[foreignId] = localMeta + foreignState.worktreeMetaByIdentity = { local: localMeta, remote: remoteMeta } + foreignState.worktreeIdentityAliases = { + [`local|${foreignId}`]: ['local'], + [`ssh:builder|${foreignId}`]: ['remote'] + } + const foreignScan = capture(foreignState) + + expect(pruneCaptured(foreignState, foreignScan, [foreignId])).toEqual([]) + expect(foreignState.worktreeMetaByIdentity).toEqual({ local: localMeta, remote: remoteMeta }) + + const ambiguousState = makeState() + const ambiguousId = `${REPO_ID}::/workspace/ambiguous` + const first = makeMeta(ambiguousId) + ambiguousState.worktreeMeta[ambiguousId] = first + ambiguousState.worktreeMetaByIdentity = { first, second: { ...first } } + ambiguousState.worktreeIdentityAliases = { [`local|${ambiguousId}`]: ['first', 'second'] } + expect(pruneCaptured(ambiguousState, capture(ambiguousState), [ambiguousId])).toEqual([]) + + const divergentState = makeState() + const divergentId = `${REPO_ID}::/workspace/divergent` + const legacy = makeMeta(divergentId) + const canonical = { ...legacy, comment: 'newer canonical comment' } + divergentState.worktreeMeta[divergentId] = legacy + divergentState.worktreeMetaByIdentity = { canonical } + divergentState.worktreeIdentityAliases = { [`local|${divergentId}`]: ['canonical'] } + const divergentScan = capture(divergentState) + expect(pruneCaptured(divergentState, divergentScan, [divergentId])).toEqual([]) + }) + + it('fails closed for repo replacement, host collisions, remote, runtime, and folder repos', () => { + const mutations: ((state: PersistedState) => void)[] = [ + (state) => { + state.repos[0] = makeRepo({ path: '/workspace/replacement' }) + }, + (state) => { + state.repos.push(makeRepo({ connectionId: 'builder', executionHostId: 'ssh:builder' })) + }, + (state) => { + state.repos[0] = makeRepo({ connectionId: 'builder', executionHostId: 'ssh:builder' }) + }, + (state) => { + state.repos[0] = makeRepo({ executionHostId: 'runtime:environment' }) + }, + (state) => { + state.repos[0] = makeRepo({ kind: 'folder' }) + } + ] + for (const mutate of mutations) { + const state = makeState() + const worktreeId = `${REPO_ID}::/workspace/stale` + state.worktreeMeta[worktreeId] = makeMeta(worktreeId) + const scan = capture(state) + mutate(state) + expect(pruneCaptured(state, scan, [worktreeId])).toEqual([]) + } + }) + + it('fails closed when project runtime routing changes away and back during the scan', () => { + const state = makeState() + const worktreeId = `${REPO_ID}::/workspace/project-routing-race` + const project: Project = { + id: 'project-1', + displayName: 'project', + badgeColor: '#000', + localWindowsRuntimePreference: { kind: 'windows-host' }, + sourceRepoIds: [REPO_ID], + createdAt: 1, + updatedAt: 1 + } + state.projects = [project] + state.worktreeMeta[worktreeId] = makeMeta(worktreeId) + const scan = capture(state) + project.localWindowsRuntimePreference = { kind: 'wsl', distro: 'Ubuntu' } + project.updatedAt += 1 + project.localWindowsRuntimePreference = { kind: 'windows-host' } + project.updatedAt += 1 + + expect(pruneCaptured(state, scan, [worktreeId])).toEqual([]) + expect(state.worktreeMeta[worktreeId]).toBeDefined() + }) + + it('fails closed when global runtime routing changes away and back during the scan', () => { + const state = makeState() + const worktreeId = `${REPO_ID}::/workspace/settings-routing-race` + const originalDefault = state.settings.localWindowsRuntimeDefault + state.worktreeMeta[worktreeId] = makeMeta(worktreeId) + const scan = capture(state) + state.settings = { + ...state.settings, + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + } + state.settings = { + ...state.settings, + localWindowsRuntimeDefault: originalDefault + } + + expect(state.settings.localWindowsRuntimeDefault).toEqual(originalDefault) + expect(pruneCaptured(state, scan, [worktreeId])).toEqual([]) + expect(state.worktreeMeta[worktreeId]).toBeDefined() + }) + + it('rejects malformed, folder-instance, WSL, and non-native Windows candidate paths', () => { + const state = makeState() + const foreignWindowsId = `${REPO_ID}::C:\\workspace\\stale` + const ids = [ + `${REPO_ID}::relative/path`, + `${REPO_ID}::`, + `wrong::/workspace/stale`, + `${REPO_ID}::/workspace/folder::workspace:11111111-1111-4111-8111-111111111111`, + `${REPO_ID}::\\\\wsl.localhost\\Ubuntu\\home\\gone`, + `${REPO_ID}::/home/user/wsl-legacy`, + foreignWindowsId + ] + for (const worktreeId of ids) { + state.worktreeMeta[worktreeId] = makeMeta(worktreeId) + } + const scan = capture(state) + + expect(pruneCaptured(state, scan, ids.slice(0, -2))).toEqual([]) + expect(pruneCaptured(state, scan, [ids.at(-2)!], 'win32')).toEqual([]) + expect(pruneCaptured(state, scan, [foreignWindowsId], 'linux')).toEqual([]) + }) +}) diff --git a/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.ts b/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.ts new file mode 100644 index 00000000000..3ab380bdd8e --- /dev/null +++ b/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.ts @@ -0,0 +1,153 @@ +import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { getRepoKind } from '../../../shared/repo-kind' +import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../../shared/worktree/id' +import { isWslUncPath } from '../../../shared/wsl-paths' +import { isFinalAutomationRunStatus } from '../../../shared/automations-types' +import { pruneUnreferencedWorktreeIdentityMeta } from '../loading-store/worktree-identity-metadata' +import { + addPersistedSessionWorktreeOwners, + createWorktreeOwnerCandidateCollector +} from '../restoring-sessions/session-worktree-ownership' +import { + indexMetadataAliasesForWorktreeIds, + removeRevalidatedLocalWorktreeMetadata, + type LocalWorktreeMetadataPruneExpectation, + type NativeLocalWorktreeMetadataScanExpectation +} from './local-worktree-metadata-scan-expectation' + +export { captureNativeLocalWorktreeMetadataScanExpectation } from './local-worktree-metadata-scan-expectation' +export type { + LocalWorktreeMetadataPruneExpectation, + NativeLocalWorktreeMetadataScanExpectation +} from './local-worktree-metadata-scan-expectation' + +function collectPersistedWorkspaceOwners( + state: PersistedState, + candidateIds: ReadonlySet, + platform: NodeJS.Platform +): Set { + const collector = createWorktreeOwnerCandidateCollector(candidateIds, platform) + addPersistedSessionWorktreeOwners(state, collector) + const add = collector.addOwner + add(state.ui.lastActiveWorktreeId) + for (const selections of Object.values(state.mobileClientTabSelectionsByDeviceId ?? {})) { + for (const worktreeId of Object.keys(selections)) { + add(worktreeId) + } + } + for (const lease of state.sshRemotePtyLeases) { + add(lease.worktreeId) + } + for (const entry of state.migrationUnsupportedPtyEntries) { + add(entry.worktreeId) + } + for (const automation of state.automations) { + if (automation.enabled && automation.workspaceMode === 'existing') { + add(automation.workspaceId) + } + } + for (const run of state.automationRuns) { + if (!isFinalAutomationRunStatus(run.status)) { + add(run.workspaceId) + } + } + return collector.owners +} + +function repoStillMatches( + state: PersistedState, + expected: NativeLocalWorktreeMetadataScanExpectation['repo'] +): boolean { + const owners = state.repos.filter((repo) => repo.id === expected.id) + const current = owners[0] + return Boolean( + owners.length === 1 && + current && + current === expected.expectedRepo && + current.path === expected.path && + current.connectionId === expected.connectionId && + current.executionHostId === expected.executionHostId && + getRepoKind(current) === expected.kind && + expected.kind === 'git' && + !current.connectionId && + getRepoExecutionHostId(current) === LOCAL_EXECUTION_HOST_ID + ) +} + +function routingStillMatches( + state: PersistedState, + expected: NativeLocalWorktreeMetadataScanExpectation +): boolean { + const currentProject = state.projects.find((project) => + project.sourceRepoIds.includes(expected.repo.id) + ) + return ( + currentProject === expected.routing.expectedProject && + currentProject?.updatedAt === expected.routing.expectedProjectUpdatedAt && + state.settings === expected.routing.expectedSettings + ) +} + +function isValidCandidateId( + repoId: string, + worktreeId: string, + platform: NodeJS.Platform +): boolean { + const parsed = splitWorktreeId(worktreeId) + return Boolean( + parsed?.repoId === repoId && + parsed.worktreePath.length > 0 && + !isWslUncPath(parsed.worktreePath) && + (platform === 'win32' + ? isWindowsAbsolutePathLike(parsed.worktreePath) + : parsed.worktreePath.startsWith('/')) && + !worktreeId.includes(FOLDER_WORKSPACE_INSTANCE_SEPARATOR) + ) +} + +export function pruneSessionlessMissingLocalWorktreeMetadataForRepo( + state: PersistedState, + scan: NativeLocalWorktreeMetadataScanExpectation, + missingMetadata: readonly LocalWorktreeMetadataPruneExpectation[], + platform = process.platform +): string[] { + if ( + missingMetadata.length === 0 || + !repoStillMatches(state, scan.repo) || + !routingStillMatches(state, scan) + ) { + return [] + } + + const candidateIds = new Set(missingMetadata.map(({ worktreeId }) => worktreeId)) + const sessionOwners = collectPersistedWorkspaceOwners(state, candidateIds, platform) + const aliasesByWorktreeId = indexMetadataAliasesForWorktreeIds(state, candidateIds) + const removedIdentityKeys = new Set() + const removedIds: string[] = [] + for (const expectation of missingMetadata) { + const { worktreeId } = expectation + if ( + !isValidCandidateId(scan.repo.id, worktreeId, platform) || + sessionOwners.has(worktreeId) || + !removeRevalidatedLocalWorktreeMetadata( + state, + expectation, + aliasesByWorktreeId.get(worktreeId) ?? [], + removedIdentityKeys + ) + ) { + continue + } + delete state.worktreeLineageById[worktreeId] + delete state.workspaceLineageByChildKey[worktreeWorkspaceKey(worktreeId)] + removedIds.push(worktreeId) + } + if (removedIds.length > 0) { + // Why: a global sweep could delete unrelated unaliased rows from another repo or host. + pruneUnreferencedWorktreeIdentityMeta(state, removedIdentityKeys) + } + return removedIds +} diff --git a/src/main/persistence/tracking-repos/project-host-operations.ts b/src/main/persistence/tracking-repos/project-host-operations.ts index c6a8441f58a..605c258f4f7 100644 --- a/src/main/persistence/tracking-repos/project-host-operations.ts +++ b/src/main/persistence/tracking-repos/project-host-operations.ts @@ -19,6 +19,7 @@ import { repoGitUsernameCacheKey } from './repo-hydration' export type ProjectHostMutationOperations = { state: PersistedState gitUsernameCache: Map + bumpLocalWorktreeScanGeneration: (repoId: string) => void hydrateRepo: (repo: Repo) => Repo updateRepoBackedProjectHostSetup: ( setup: ProjectHostSetup, @@ -87,6 +88,9 @@ export class ProjectHostPersistenceOperations { return null } if ('localWindowsRuntimePreference' in updates) { + for (const repoId of project.sourceRepoIds) { + this.operations.bumpLocalWorktreeScanGeneration(repoId) + } if (updates.localWindowsRuntimePreference === undefined) { delete project.localWindowsRuntimePreference } else { diff --git a/src/main/persistence/tracking-repos/repo-update-operations.ts b/src/main/persistence/tracking-repos/repo-update-operations.ts index f39e0e1e26f..6ad6f23903b 100644 --- a/src/main/persistence/tracking-repos/repo-update-operations.ts +++ b/src/main/persistence/tracking-repos/repo-update-operations.ts @@ -9,6 +9,7 @@ import { sanitizeRepoUpdatesForPersistence } from './repo-sanitization' export type RepoUpdateMutationOperations = { state: PersistedState + bumpLocalWorktreeScanGeneration: (repoId: string) => void syncProjectHostSetupCompatibilityState: () => void scheduleSave: () => void hydrateRepo: (repo: Repo) => Repo @@ -21,6 +22,10 @@ export class RepoUpdatePersistenceOperations { return this.operations.state } + private bumpLocalWorktreeScanGeneration(repoId: string): void { + this.operations.bumpLocalWorktreeScanGeneration(repoId) + } + private syncProjectHostSetupCompatibilityState(): void { this.operations.syncProjectHostSetupCompatibilityState() } @@ -177,6 +182,7 @@ export class RepoUpdatePersistenceOperations { } } Object.assign(repo, sanitizedUpdates) + this.bumpLocalWorktreeScanGeneration(id) this.syncProjectHostSetupCompatibilityState() this.scheduleSave() return this.hydrateRepo(repo) diff --git a/src/main/providers/ssh-git-provider-worktree.test.ts b/src/main/providers/ssh-git-provider-worktree.test.ts index 10f36acc8fd..6ddff0a1f68 100644 --- a/src/main/providers/ssh-git-provider-worktree.test.ts +++ b/src/main/providers/ssh-git-provider-worktree.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshGitProvider } from './ssh-git-provider' -import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness' +import { + createMockMux, + waitForRequestCount, + type MockMultiplexer +} from './ssh-git-provider-test-harness' + +function methodNotFound(method: string): Error & { code: number } { + return Object.assign(new Error(`Method not found: ${method}`), { code: -32601 }) +} + +const CLEAN_STATUS = { entries: [], conflictOperation: 'unknown' } describe('SshGitProvider', () => { let mux: MockMultiplexer @@ -67,6 +77,40 @@ describe('SshGitProvider', () => { expect(result).toEqual(cleanResult) }) + it('keeps supported clean checks on the preferred relay RPC', async () => { + let resolveProbe!: (result: { clean: boolean }) => void + let resolveRemaining!: (result: { clean: boolean }) => void + const probe = new Promise<{ clean: boolean }>((resolve) => { + resolveProbe = resolve + }) + const remaining = new Promise<{ clean: boolean }>((resolve) => { + resolveRemaining = resolve + }) + let preferredRequestCount = 0 + mux.request.mockImplementation((method) => { + expect(method).toBe('git.worktreeIsClean') + preferredRequestCount += 1 + return preferredRequestCount === 1 ? probe : remaining + }) + const checks = Array.from({ length: 10 }, (_, index) => + provider.worktreeIsClean(`/repo/worktree-${index}`) + ) + + await waitForRequestCount(mux.request, 1) + expect(mux.request).toHaveBeenCalledOnce() + resolveProbe({ clean: true }) + await waitForRequestCount(mux.request, 10) + expect(mux.request).toHaveBeenCalledTimes(10) + resolveRemaining({ clean: true }) + await expect(Promise.all(checks)).resolves.toEqual( + Array.from({ length: 10 }, () => ({ clean: true })) + ) + expect( + mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean') + ).toHaveLength(10) + expect(mux.request.mock.calls.some(([method]) => method === 'git.status')).toBe(false) + }) + it('worktreeIsClean can ignore untracked files', async () => { const cleanResult = { clean: true } mux.request.mockResolvedValue(cleanResult) @@ -113,10 +157,7 @@ describe('SshGitProvider', () => { }) it('worktreeIsClean falls back to git.status for old relays', async () => { - const methodNotFound = Object.assign(new Error('Method not found: git.worktreeIsClean'), { - code: -32601 - }) - mux.request.mockRejectedValueOnce(methodNotFound).mockResolvedValueOnce({ + mux.request.mockRejectedValueOnce(methodNotFound('git.worktreeIsClean')).mockResolvedValueOnce({ entries: [{ path: 'scratch.txt', status: 'untracked', area: 'untracked' }], conflictOperation: 'unknown' }) @@ -140,26 +181,180 @@ describe('SshGitProvider', () => { } }) - it('worktreeIsClean filters untracked entries in old-relay fallback', async () => { - const methodNotFound = Object.assign(new Error('Method not found: git.worktreeIsClean'), { - code: -32601 + it('probes an old relay once across sequential clean checks', async () => { + mux.request.mockImplementation(async (method) => { + if (method === 'git.worktreeIsClean') { + throw methodNotFound(method) + } + return CLEAN_STATUS }) - mux.request.mockRejectedValueOnce(methodNotFound).mockResolvedValueOnce({ - entries: [{ path: 'scratch.txt', status: 'untracked', area: 'untracked' }], - conflictOperation: 'unknown' + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const worktreePaths = Array.from({ length: 10 }, (_, index) => `/repo/worktree-${index}`) + + try { + for (const worktreePath of worktreePaths) { + await expect(provider.worktreeIsClean(worktreePath)).resolves.toEqual({ clean: true }) + } + + expect( + mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean') + ).toHaveLength(1) + expect(mux.request.mock.calls.filter(([method]) => method === 'git.status')).toHaveLength(10) + expect(mux.request).toHaveBeenCalledTimes(11) + expect(warnSpy).toHaveBeenCalledOnce() + } finally { + warnSpy.mockRestore() + } + }) + + it('shares one old-relay probe across concurrent clean checks', async () => { + let rejectProbe!: (error: Error) => void + const probe = new Promise((_resolve, reject) => { + rejectProbe = reject + }) + mux.request.mockImplementation((method) => { + if (method === 'git.worktreeIsClean') { + return probe + } + return Promise.resolve(CLEAN_STATUS) + }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const checks = Array.from({ length: 10 }, (_, index) => + provider.worktreeIsClean(`/repo/worktree-${index}`) + ) + + try { + await waitForRequestCount(mux.request, 1) + rejectProbe(methodNotFound('git.worktreeIsClean')) + await expect(Promise.all(checks)).resolves.toEqual( + Array.from({ length: 10 }, () => ({ clean: true })) + ) + + expect( + mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean') + ).toHaveLength(1) + expect(mux.request.mock.calls.filter(([method]) => method === 'git.status')).toHaveLength(10) + expect(mux.request).toHaveBeenCalledTimes(11) + expect(warnSpy).toHaveBeenCalledOnce() + } finally { + warnSpy.mockRestore() + } + }) + + it('retries the preferred clean RPC after a transport failure', async () => { + const transportError = Object.assign(new Error('Method not found: git.worktreeIsClean'), { + code: -32602 + }) + mux.request.mockRejectedValueOnce(transportError).mockResolvedValueOnce({ clean: true }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + await expect(provider.worktreeIsClean('/repo/first')).rejects.toBe(transportError) + await expect(provider.worktreeIsClean('/repo/second')).resolves.toEqual({ clean: true }) + expect(mux.request.mock.calls.map(([method]) => method)).toEqual([ + 'git.worktreeIsClean', + 'git.worktreeIsClean' + ]) + expect(warnSpy).not.toHaveBeenCalled() + } finally { + warnSpy.mockRestore() + } + }) + + it('lets concurrent waiters retry after a transient probe failure', async () => { + const transportError = new Error('connection closed') + let rejectProbe!: (error: Error) => void + let resolveRetries!: (result: { clean: boolean }) => void + const probe = new Promise((_resolve, reject) => { + rejectProbe = reject + }) + const retries = new Promise<{ clean: boolean }>((resolve) => { + resolveRetries = resolve + }) + let preferredRequestCount = 0 + mux.request.mockImplementation((method) => { + expect(method).toBe('git.worktreeIsClean') + preferredRequestCount += 1 + return preferredRequestCount === 1 ? probe : retries + }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const checks = Array.from({ length: 3 }, (_, index) => + provider.worktreeIsClean(`/repo/worktree-${index}`) + ) + const settledChecks = Promise.allSettled(checks) + + try { + await waitForRequestCount(mux.request, 1) + rejectProbe(transportError) + await waitForRequestCount(mux.request, 3) + resolveRetries({ clean: true }) + + const results = await settledChecks + expect(results[0]).toEqual({ status: 'rejected', reason: transportError }) + expect(results.slice(1)).toEqual([ + { status: 'fulfilled', value: { clean: true } }, + { status: 'fulfilled', value: { clean: true } } + ]) + await expect(provider.worktreeIsClean('/repo/later')).resolves.toEqual({ clean: true }) + expect(mux.request).toHaveBeenCalledTimes(4) + expect(warnSpy).not.toHaveBeenCalled() + } finally { + warnSpy.mockRestore() + } + }) + + it('re-probes clean support after the provider is replaced', async () => { + mux.request.mockImplementation(async (method) => { + if (method === 'git.worktreeIsClean') { + throw methodNotFound(method) + } + return CLEAN_STATUS }) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { + await expect(provider.worktreeIsClean('/repo/first')).resolves.toEqual({ clean: true }) + const replacement = new SshGitProvider('conn-1', mux as never) + await expect(replacement.worktreeIsClean('/repo/second')).resolves.toEqual({ clean: true }) + + expect( + mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean') + ).toHaveLength(2) + expect(warnSpy).toHaveBeenCalledTimes(2) + } finally { + warnSpy.mockRestore() + } + }) + + it('worktreeIsClean filters untracked entries in old-relay fallback', async () => { + mux.request.mockImplementation(async (method) => { + if (method === 'git.worktreeIsClean') { + throw methodNotFound(method) + } + return { + entries: [{ path: 'scratch.txt', status: 'untracked', area: 'untracked' }], + conflictOperation: 'unknown' + } + }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + await expect(provider.worktreeIsClean('/home/user/feat')).resolves.toEqual({ + clean: false, + stdout: 'untracked untracked: scratch.txt' + }) await expect( provider.worktreeIsClean('/home/user/feat', { includeUntracked: false }) ).resolves.toEqual({ clean: true }) expect(mux.request).toHaveBeenNthCalledWith( - 2, + 3, 'git.status', { worktreePath: '/home/user/feat' }, { signal: expect.any(AbortSignal) } ) + expect( + mux.request.mock.calls.filter(([method]) => method === 'git.worktreeIsClean') + ).toHaveLength(1) } finally { warnSpy.mockRestore() } @@ -183,11 +378,7 @@ describe('SshGitProvider', () => { }) it('forceDeletePreservedBranch maps old relays to the reconnect message', async () => { - const methodNotFound = Object.assign( - new Error('Method not found: git.forceDeletePreservedBranch'), - { code: -32601 } - ) - mux.request.mockRejectedValueOnce(methodNotFound) + mux.request.mockRejectedValueOnce(methodNotFound('git.forceDeletePreservedBranch')) await expect( provider.forceDeletePreservedBranch('/home/user/repo', 'you/fix-auth', 'abc123') diff --git a/src/main/providers/ssh-git-worktree-provider.ts b/src/main/providers/ssh-git-worktree-provider.ts index ce509ccf847..d5cf9b5a5e1 100644 --- a/src/main/providers/ssh-git-worktree-provider.ts +++ b/src/main/providers/ssh-git-worktree-provider.ts @@ -1,9 +1,12 @@ import type { GitStatusResult } from '../../shared/git-status-types' import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import type { GitWorktreeInfo } from '../../shared/worktree/types' +import { CapabilityProbeCache } from '../../shared/capability-probe-cache' import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors' import { SshGitReviewHeadProvider } from './ssh-git-review-head-provider' +const WORKTREE_IS_CLEAN_CAPABILITY = 'git.worktreeIsClean' as const + function formatStatusEntriesForCleanCheck(entries: GitStatusResult['entries']): string | undefined { if (entries.length === 0) { return undefined @@ -20,6 +23,10 @@ function filterUntrackedPorcelainStatus(stdout: string | undefined): string | un export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { private loggedWorktreeIsCleanFallback = false + // Why: reconnect replaces this provider, so an upgraded relay is naturally re-probed. + private readonly worktreeIsCleanCapabilityCache = new CapabilityProbeCache< + typeof WORKTREE_IS_CLEAN_CAPABILITY + >(Number.POSITIVE_INFINITY) async listWorktrees( repoPath: string, @@ -67,37 +74,39 @@ export class SshGitWorktreeProvider extends SshGitReviewHeadProvider { worktreePath: string, options: { includeUntracked?: boolean } = {} ): Promise<{ clean: boolean; stdout?: string }> { - try { - const result = (await this.mux.request('git.worktreeIsClean', { - worktreePath, - ...(options.includeUntracked === false ? { includeUntracked: false } : {}) - })) as { clean: boolean; stdout?: string } - if (options.includeUntracked === false) { - if (!result.clean && result.stdout === undefined) { - return result + return this.worktreeIsCleanCapabilityCache.runWithFallback( + WORKTREE_IS_CLEAN_CAPABILITY, + async () => { + const result = (await this.mux.request('git.worktreeIsClean', { + worktreePath, + ...(options.includeUntracked === false ? { includeUntracked: false } : {}) + })) as { clean: boolean; stdout?: string } + if (options.includeUntracked === false) { + if (!result.clean && result.stdout === undefined) { + return result + } + const trackedStdout = filterUntrackedPorcelainStatus(result.stdout) + return { clean: !trackedStdout, ...(trackedStdout ? { stdout: trackedStdout } : {}) } } - const trackedStdout = filterUntrackedPorcelainStatus(result.stdout) - return { clean: !trackedStdout, ...(trackedStdout ? { stdout: trackedStdout } : {}) } - } - return result - } catch (error) { - if (!isJsonRpcMethodNotFoundError(error)) { - throw error - } - if (!this.loggedWorktreeIsCleanFallback) { - this.loggedWorktreeIsCleanFallback = true - console.warn( - '[ssh-git] Relay does not implement git.worktreeIsClean; falling back to git.status clean check' - ) - } - const status = await this.getStatus(worktreePath) - const entries = - options.includeUntracked === false - ? status.entries.filter((entry) => entry.area !== 'untracked') - : status.entries - const clean = entries.length === 0 - return { clean, stdout: formatStatusEntriesForCleanCheck(entries) } - } + return result + }, + async () => { + if (!this.loggedWorktreeIsCleanFallback) { + this.loggedWorktreeIsCleanFallback = true + console.warn( + '[ssh-git] Relay does not implement git.worktreeIsClean; falling back to git.status clean check' + ) + } + const status = await this.getStatus(worktreePath) + const entries = + options.includeUntracked === false + ? status.entries.filter((entry) => entry.area !== 'untracked') + : status.entries + const clean = entries.length === 0 + return { clean, stdout: formatStatusEntriesForCleanCheck(entries) } + }, + isJsonRpcMethodNotFoundError + ) } async refreshLocalBaseRefForWorktreeCreate(args: { diff --git a/src/main/repo-worktrees.test.ts b/src/main/repo-worktrees.test.ts index 3c6910991f0..d0c0ea5c617 100644 --- a/src/main/repo-worktrees.test.ts +++ b/src/main/repo-worktrees.test.ts @@ -1,18 +1,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { listWorktreesMock } = vi.hoisted(() => ({ - listWorktreesMock: vi.fn() +const { listWorktreesMock, listWorktreesStrictMock } = vi.hoisted(() => ({ + listWorktreesMock: vi.fn(), + listWorktreesStrictMock: vi.fn() })) vi.mock('./git/worktree', () => ({ - listWorktrees: listWorktreesMock + listWorktrees: listWorktreesMock, + listWorktreesStrict: listWorktreesStrictMock })) -import { createFolderWorktree, isRepoRoot, listRepoWorktrees } from './repo-worktrees' +import { + createFolderWorktree, + isRepoRoot, + listLocalRepoWorktreesStrict, + listRepoWorktrees +} from './repo-worktrees' describe('repo-worktrees', () => { beforeEach(() => { listWorktreesMock.mockReset() + listWorktreesStrictMock.mockReset() }) it('creates a stable synthetic worktree for folder repos', () => { @@ -56,6 +64,29 @@ describe('repo-worktrees', () => { expect(listWorktreesMock).not.toHaveBeenCalled() }) + it('returns the synthetic folder worktree for strict local listing', async () => { + const result = await listLocalRepoWorktreesStrict({ + id: 'repo-1', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + }) + + expect(result).toEqual([ + createFolderWorktree({ + id: 'repo-1', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' + }) + ]) + expect(listWorktreesStrictMock).not.toHaveBeenCalled() + }) + it('delegates to git worktree listing for git repos', async () => { listWorktreesMock.mockResolvedValue([ { path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true } @@ -78,6 +109,46 @@ describe('repo-worktrees', () => { expect(result).toHaveLength(1) }) + it('delegates strict local listing with the signal and WSL options', async () => { + listWorktreesStrictMock.mockResolvedValue([ + { path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true } + ]) + const signal = new AbortController().signal + + const result = await listLocalRepoWorktreesStrict( + { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + kind: 'git' + }, + { signal, wslDistro: 'Ubuntu' } + ) + + expect(listWorktreesStrictMock).toHaveBeenCalledWith('/workspace/repo', { + signal, + wslDistro: 'Ubuntu' + }) + expect(result).toHaveLength(1) + }) + + it('rejects strict listing for remote repos', async () => { + await expect( + listLocalRepoWorktreesStrict({ + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1', + kind: 'git' + }) + ).rejects.toThrow('remote repository') + expect(listWorktreesStrictMock).not.toHaveBeenCalled() + }) + it('treats Windows repo root casing differences as the same local root', () => { const repos = [ { diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index f4123c18889..14751c17861 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -1,6 +1,6 @@ import type { Repo } from '../shared/repo-types' import type { GitWorktreeInfo } from '../shared/worktree/types' -import { listWorktrees } from './git/worktree' +import { listWorktrees, listWorktreesStrict } from './git/worktree' import { isFolderRepo } from '../shared/repo-kind' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { areWorktreePathsEqual } from './ipc/worktree-logic' @@ -51,3 +51,18 @@ export async function listRepoWorktrees( ? await listWorktrees(repo.path, options) : await listWorktrees(repo.path) } + +export async function listLocalRepoWorktreesStrict( + repo: Repo, + options?: LocalRepoWorktreeListOptions +): Promise { + if (repo.connectionId) { + throw new Error('Cannot list worktrees for a remote repository') + } + if (isFolderRepo(repo)) { + return [createFolderWorktree(repo)] + } + return hasLocalRepoWorktreeListOptions(options) + ? await listWorktreesStrict(repo.path, options) + : await listWorktreesStrict(repo.path) +} diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index 9c26b12292d..b80d7adee41 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -121,6 +121,7 @@ describe('startup ordering', () => { expect(desktopStartup).toContain( 'openWindow: () => openMainWindow({ revealOnDidFinishLoad: true })' ) + expect(desktopStartup).toContain('bindServices: bindTerminalRuntimeStartupServices') expect(desktopStartup).toContain('shellPathReady,') expect(desktopStartup).toContain('startServices: startTerminalRuntimeStartupServices') expect(barrier).toContain('managedWslCliStartupBarrierReady') diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts new file mode 100644 index 00000000000..2263cdbf94f --- /dev/null +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('headless PTY registry hydration ordering', () => { + it('uses exactly one deferred-or-immediate desktop hydration path', () => { + const source = readFileSync( + join(process.cwd(), 'src/main/window/attach-main-window-services.ts'), + 'utf8' + ) + const start = source.indexOf('const localPtyProviderStartupReady =') + const end = source.indexOf('registerSshHandlers(', start) + const hydration = source.slice(start, end) + + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + expect(hydration).toContain('if (localPtyProviderStartupReady)') + expect(hydration).toContain('.then(() => hydrateLocalPtyRegistryAtBoot(store))') + expect(hydration).toContain('} else {\n void hydrateLocalPtyRegistryAtBoot(store)') + expect(hydration.match(/hydrateLocalPtyRegistryAtBoot\(store\)/g)).toHaveLength(2) + }) + + it('hydrates Electron serve after provider and handler readiness but before RPC', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const serve = source.indexOf('if (serveOptions) {') + const provider = source.indexOf('await localPtyProviderStartupReady', serve) + const handlersAndHydration = source.indexOf('await registerHeadlessPtyRuntime(', provider) + const rpc = source.indexOf('await runtimeRpc.start()', handlersAndHydration) + const readiness = source.indexOf('await printServeReady(serveOptions)', rpc) + + expect(serve).toBeGreaterThanOrEqual(0) + expect(provider).toBeGreaterThan(serve) + expect(handlersAndHydration).toBeGreaterThan(provider) + expect(rpc).toBeGreaterThan(handlersAndHydration) + expect(readiness).toBeGreaterThan(rpc) + }) + + it('hydrates orcad after Store and daemon readiness but before RPC and publication', () => { + const source = readFileSync(join(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') + const store = source.indexOf('const store = new Store(') + const daemon = source.indexOf('await startOrcadDaemon()', store) + const handlersAndHydration = source.indexOf('await registerHeadlessPtyRuntime(', daemon) + const rpc = source.indexOf('await rpc.start()', handlersAndHydration) + const readiness = source.indexOf('await new ServeReadinessPublisher().publish(', rpc) + + expect(store).toBeGreaterThanOrEqual(0) + expect(daemon).toBeGreaterThan(store) + expect(handlersAndHydration).toBeGreaterThan(daemon) + expect(rpc).toBeGreaterThan(handlersAndHydration) + expect(readiness).toBeGreaterThan(rpc) + }) +}) diff --git a/src/main/startup/serve-desktop-activation-wiring.test.ts b/src/main/startup/serve-desktop-activation-wiring.test.ts index 239a7618567..0aea295c657 100644 --- a/src/main/startup/serve-desktop-activation-wiring.test.ts +++ b/src/main/startup/serve-desktop-activation-wiring.test.ts @@ -21,12 +21,19 @@ describe('serve desktop activation wiring', () => { ) const serveIndex = source.indexOf('if (serveOptions) {', appReadyIndex) const ptyReadyIndex = source.indexOf('await localPtyStartupReady', serveIndex) - const headlessRegistrationIndex = source.indexOf('registerHeadlessPtyRuntime(', serveIndex) + const providerReadyIndex = source.indexOf('await localPtyProviderStartupReady', serveIndex) + const headlessRegistrationIndex = source.indexOf( + 'await registerHeadlessPtyRuntime(', + serveIndex + ) + const rpcIndex = source.indexOf('await runtimeRpc.start()', serveIndex) expect(startupIndex).toBeGreaterThanOrEqual(0) expect(startupIndex).toBeLessThan(serveIndex) expect(ptyReadyIndex).toBeGreaterThan(serveIndex) - expect(headlessRegistrationIndex).toBeGreaterThan(ptyReadyIndex) + expect(providerReadyIndex).toBeGreaterThan(ptyReadyIndex) + expect(headlessRegistrationIndex).toBeGreaterThan(providerReadyIndex) + expect(headlessRegistrationIndex).toBeLessThan(rpcIndex) expect(source).not.toContain( 'if (!isServeMode) {\n startDesktopFirstWindowStartupServices()' ) diff --git a/src/main/startup/windows-desktop-shell-path-startup.test.ts b/src/main/startup/windows-desktop-shell-path-startup.test.ts index 3778e0b2688..d38b24aa05e 100644 --- a/src/main/startup/windows-desktop-shell-path-startup.test.ts +++ b/src/main/startup/windows-desktop-shell-path-startup.test.ts @@ -10,6 +10,29 @@ function deferred(): { promise: Promise; resolve: () => void } { } describe('Windows desktop shell PATH startup', () => { + it('binds the services promise before opening the window while shell PATH is unresolved', () => { + const shellPath = deferred() + const events: string[] = [] + const bindServices = vi.fn(() => events.push('bind')) + const openWindow = vi.fn(() => events.push('open')) + const options = { + bindServices, + openWindow, + shellPathReady: shellPath.promise, + startServices: vi.fn(() => ({ + firstWindowReady: Promise.resolve(), + localPtyReady: Promise.resolve(), + localPtyProviderReady: Promise.resolve() + })) + } + + startWindowsDesktopBeforeShellPathReady(options) + + expect(events).toEqual(['bind', 'open']) + expect(bindServices).toHaveBeenCalledOnce() + expect(options.startServices).not.toHaveBeenCalled() + }) + it('opens the first window while shell PATH hydration remains unresolved', async () => { const shellPath = deferred() const window = { visible: true } @@ -21,6 +44,7 @@ describe('Windows desktop shell PATH startup', () => { })) const startup = startWindowsDesktopBeforeShellPathReady({ + bindServices: vi.fn(), openWindow, shellPathReady: shellPath.promise, startServices diff --git a/src/main/startup/windows-desktop-shell-path-startup.ts b/src/main/startup/windows-desktop-shell-path-startup.ts index 3933a79b64b..3fda3fad168 100644 --- a/src/main/startup/windows-desktop-shell-path-startup.ts +++ b/src/main/startup/windows-desktop-shell-path-startup.ts @@ -5,6 +5,7 @@ export type WindowsDesktopStartupServices = { } type WindowsDesktopShellPathStartupOptions = { + bindServices: (services: Promise) => void openWindow: () => TWindow shellPathReady: Promise startServices: () => WindowsDesktopStartupServices @@ -14,5 +15,6 @@ export function startWindowsDesktopBeforeShellPathReady( options: WindowsDesktopShellPathStartupOptions ): { window: TWindow; services: Promise } { const services = options.shellPathReady.then(options.startServices) + options.bindServices(services) return { window: options.openWindow(), services } } diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 8a340d840ec..ae83f5b7712 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -271,8 +271,8 @@ describe('attachMainWindowServices', () => { expect(mainWindow.webContents.reload).toHaveBeenCalledTimes(1) }) - it('retries local PTY registry hydration after local startup services are ready', async () => { - const localStartup = deferred() + it('hydrates once after the local PTY provider barrier resolves', async () => { + const providerStartup = deferred() const store = createStore() attachMainWindowServices( @@ -281,18 +281,17 @@ describe('attachMainWindowServices', () => { createRuntime() as never, undefined, undefined, - { awaitLocalPtyStartup: () => localStartup.promise } + { awaitLocalPtyProviderStartup: () => providerStartup.promise } ) - expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledTimes(1) - expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledWith(store) + expect(hydrateLocalPtyRegistryAtBootMock).not.toHaveBeenCalled() - localStartup.resolve() - await localStartup.promise + providerStartup.resolve() + await providerStartup.promise await Promise.resolve() - expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledTimes(2) - expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenLastCalledWith(store) + expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledOnce() + expect(hydrateLocalPtyRegistryAtBootMock).toHaveBeenCalledWith(store) }) it('passes injected update quit cleanup to the auto-updater', async () => { diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 7e079f612b1..128ff2e0e4e 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -106,11 +106,9 @@ export function attachMainWindowServices( scheduleHistoryGc(async () => { return getKnownWorktreeIdsForHistoryGc(store) }) - // Why: daemon PTYs survive renderer restarts, so at boot they're unregistered; hydrate so they aren't mislabeled REMOTE (idempotent, safe to re-run). - void hydrateLocalPtyRegistryAtBoot(store) - const localPtyStartupReady = options?.awaitLocalPtyStartup?.() - if (localPtyStartupReady) { - void localPtyStartupReady + const localPtyProviderStartupReady = options?.awaitLocalPtyProviderStartup?.() + if (localPtyProviderStartupReady) { + void localPtyProviderStartupReady .then(() => hydrateLocalPtyRegistryAtBoot(store)) .catch((error) => { console.warn( @@ -118,6 +116,8 @@ export function attachMainWindowServices( error instanceof Error ? error.message : String(error) ) }) + } else { + void hydrateLocalPtyRegistryAtBoot(store) } registerSshHandlers(store, () => mainWindow, runtime) registerRemoteWorkspaceHandlers(store, () => mainWindow) diff --git a/src/main/worktree-lineage-pruning.test.ts b/src/main/worktree-lineage-pruning.test.ts index db393e864d5..22b34f70104 100644 --- a/src/main/worktree-lineage-pruning.test.ts +++ b/src/main/worktree-lineage-pruning.test.ts @@ -57,6 +57,82 @@ function createStore( } describe('pruneLineageForMissingRepoWorktrees', () => { + it('keeps lineage stored under another spelling of a live worktree path', () => { + const parentId = 'repo-1::/repo' + const childId = 'repo-1::/repo/./child' + const edge = lineage(childId, parentId) + const worktreeLineageById = { [childId]: edge } + const workspaceLineageByChildKey = { + [worktreeWorkspaceKey(childId)]: workspaceLineage(childId, parentId) + } + const metaById = { + [parentId]: { instanceId: edge.parentWorktreeInstanceId } as WorktreeMeta + } + const store = createStore(worktreeLineageById, workspaceLineageByChildKey, metaById) + + pruneLineageForMissingRepoWorktrees( + store as never, + repo, + [ + { path: '/repo', head: 'a', branch: 'main', isBare: false, isMainWorktree: true }, + { path: '/repo/child/', head: 'b', branch: 'child', isBare: false, isMainWorktree: false } + ], + { platform: 'linux' } + ) + + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + expect(store.removeWorkspaceLineage).not.toHaveBeenCalled() + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(worktreeLineageById[childId]).toBe(edge) + }) + + it('keeps captured metadata lineage not removed after Git canonicalizes paths', () => { + const parentId = 'repo-1::/alias/parent' + const childId = 'repo-1::/alias/child' + const edge = lineage(childId, parentId) + const worktreeLineageById = { [childId]: edge } + const workspaceLineageByChildKey = { + [worktreeWorkspaceKey(childId)]: workspaceLineage(childId, parentId) + } + const metaById = { + [childId]: { instanceId: edge.worktreeInstanceId } as WorktreeMeta, + [parentId]: { instanceId: edge.parentWorktreeInstanceId } as WorktreeMeta + } + const store = createStore(worktreeLineageById, workspaceLineageByChildKey, metaById) + + pruneLineageForMissingRepoWorktrees( + store as never, + repo, + [ + { + path: '/canonical/parent', + head: 'a', + branch: 'parent', + isBare: false, + isMainWorktree: false + }, + { + path: '/canonical/child', + head: 'b', + branch: 'child', + isBare: false, + isMainWorktree: false + } + ], + { + platform: 'linux', + preservedMetadataCandidateIds: new Set([parentId, childId]) + } + ) + + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + expect(store.removeWorkspaceLineage).not.toHaveBeenCalled() + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(worktreeLineageById[childId]).toBe(edge) + expect(workspaceLineageByChildKey[worktreeWorkspaceKey(childId)]).toBeDefined() + expect(metaById[parentId].instanceId).toBe(edge.parentWorktreeInstanceId) + }) + it('refuses an empty scan when the repo still has registered lineage', () => { const parentId = 'repo-1::/repo/parent' const childId = 'repo-1::/repo/child' @@ -78,7 +154,7 @@ describe('pruneLineageForMissingRepoWorktrees', () => { expect(workspaceLineageByChildKey[worktreeWorkspaceKey(childId)]).toBe(workspaceEdge) }) - it('prunes missing children and rotates missing parents after a trusted non-empty scan', () => { + it('prunes and rotates missing IDs absent from preserved metadata candidates', () => { const liveParentId = 'repo-1::/repo/live-parent' const missingChildId = 'repo-1::/repo/missing-child' const liveChildId = 'repo-1::/repo/live-child' @@ -99,25 +175,31 @@ describe('pruneLineageForMissingRepoWorktrees', () => { } const store = createStore(worktreeLineageById, workspaceLineageByChildKey, metaById) - pruneLineageForMissingRepoWorktrees(store as never, repo, [ - { - path: '/repo/live-parent', - head: 'a', - branch: 'main', - isBare: false, - isMainWorktree: false - }, - { - path: '/repo/live-child', - head: 'b', - branch: 'child', - isBare: false, - isMainWorktree: false - } - ]) + pruneLineageForMissingRepoWorktrees( + store as never, + repo, + [ + { + path: '/repo/live-parent', + head: 'a', + branch: 'main', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo/live-child', + head: 'b', + branch: 'child', + isBare: false, + isMainWorktree: false + } + ], + { preservedMetadataCandidateIds: new Set([liveParentId, liveChildId]) } + ) expect(store.removeWorktreeLineage).toHaveBeenCalledWith(missingChildId) expect(store.removeWorktreeLineage).not.toHaveBeenCalledWith(liveChildId) + expect(store.removeWorkspaceLineage).toHaveBeenCalledWith(worktreeWorkspaceKey(missingChildId)) expect(store.setWorktreeMeta).toHaveBeenCalledWith(missingParentId, { instanceId: expect.any(String) }) @@ -126,4 +208,25 @@ describe('pruneLineageForMissingRepoWorktrees', () => { missingParentEdge.parentWorktreeInstanceId ) }) + + it('does not rematerialize metadata already removed for a missing parent', () => { + const liveChildId = 'repo-1::/repo/live-child' + const removedParentId = 'repo-1::/repo/removed-parent' + const edge = lineage(liveChildId, removedParentId) + const worktreeLineageById = { [liveChildId]: edge } + const store = createStore(worktreeLineageById, {}, {}) + + pruneLineageForMissingRepoWorktrees(store as never, repo, [ + { + path: '/repo/live-child', + head: 'child-head', + branch: 'child', + isBare: false, + isMainWorktree: false + } + ]) + + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + expect(worktreeLineageById[liveChildId]).toBe(edge) + }) }) diff --git a/src/main/worktree-lineage-pruning.ts b/src/main/worktree-lineage-pruning.ts index 34a99ef6e95..6040f25c03d 100644 --- a/src/main/worktree-lineage-pruning.ts +++ b/src/main/worktree-lineage-pruning.ts @@ -4,6 +4,8 @@ import type { WorkspaceLineage, WorktreeLineage } from '../shared/worktree/linea import type { GitWorktreeInfo } from '../shared/worktree/types' import { getRepoExecutionHostId } from '../shared/execution-host' import { isWorkspaceKey, parseWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' +import { splitWorktreeId } from '../shared/worktree/id' +import { worktreeRetentionPathComparisonKey } from './worktree-retention-path-comparison' import type { Store } from './persistence' function worktreeIdBelongsToRepo(worktreeId: string, repoPrefix: string): boolean { @@ -37,8 +39,13 @@ function hasStoredRepoLineage( export function pruneLineageForMissingRepoWorktrees( store: Store, repo: Repo, - gitWorktrees: GitWorktreeInfo[] + gitWorktrees: GitWorktreeInfo[], + options: { + platform?: NodeJS.Platform + preservedMetadataCandidateIds?: ReadonlySet + } = {} ): void { + const { platform = process.platform, preservedMetadataCandidateIds } = options if ( typeof store.getAllWorktreeLineage !== 'function' || typeof store.removeWorktreeLineage !== 'function' @@ -56,6 +63,24 @@ export function pruneLineageForMissingRepoWorktrees( return } const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`)) + const livePathKeys = new Set( + [repo.path, ...gitWorktrees.map(({ path }) => path)].map((pathValue) => + worktreeRetentionPathComparisonKey(pathValue, platform) + ) + ) + const isLive = (worktreeId: string): boolean => { + if (liveIds.has(worktreeId) || preservedMetadataCandidateIds?.has(worktreeId)) { + return true + } + const worktreePath = splitWorktreeId(worktreeId)?.worktreePath + if (!worktreePath) { + return false + } + if (livePathKeys.has(worktreeRetentionPathComparisonKey(worktreePath, platform))) { + return true + } + return false + } const expectedHostId = getRepoExecutionHostId(repo) const repoOwners = store.getRepos().filter((candidate) => candidate.id === repo.id) const canMutateWorktree = (worktreeId: string): boolean => { @@ -68,7 +93,7 @@ export function pruneLineageForMissingRepoWorktrees( childScope?.type === 'worktree' && worktreeIdBelongsToRepo(childScope.worktreeId, repoPrefix) && canMutateWorktree(childScope.worktreeId) && - !liveIds.has(childScope.worktreeId) && + !isLive(childScope.worktreeId) && isWorkspaceKey(childWorkspaceKey) ) { store.removeWorkspaceLineage?.(childWorkspaceKey) @@ -78,7 +103,7 @@ export function pruneLineageForMissingRepoWorktrees( if ( worktreeIdBelongsToRepo(childId, repoPrefix) && canMutateWorktree(childId) && - !liveIds.has(childId) + !isLive(childId) ) { // Why: a proven-missing path must not transfer its lineage to a future checkout at that path. store.removeWorktreeLineage(childId) @@ -87,10 +112,10 @@ export function pruneLineageForMissingRepoWorktrees( if ( worktreeIdBelongsToRepo(lineage.parentWorktreeId, repoPrefix) && canMutateWorktree(lineage.parentWorktreeId) && - !liveIds.has(lineage.parentWorktreeId) + !isLive(lineage.parentWorktreeId) ) { const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId) - if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) { + if (parentMeta?.instanceId === lineage.parentWorktreeInstanceId) { // Why: rotate a proven-missing parent's identity once so path reuse cannot validate old lineage. store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() }) } diff --git a/src/main/worktree-retention-path-comparison.ts b/src/main/worktree-retention-path-comparison.ts new file mode 100644 index 00000000000..2ff3fd02990 --- /dev/null +++ b/src/main/worktree-retention-path-comparison.ts @@ -0,0 +1,24 @@ +import { splitWorktreeId } from '../shared/worktree/id' +import { worktreePathComparisonKey } from './ipc/worktree-path-comparison' + +/** Comparison keys used only to retain state; canonical equivalence must never select a delete target. */ +export function worktreeRetentionPathComparisonKey( + pathValue: string, + platform: NodeJS.Platform +): string { + return worktreePathComparisonKey(pathValue.normalize('NFC'), platform) +} + +export function worktreeRetentionIdComparisonKey( + worktreeId: string, + platform: NodeJS.Platform +): string | null { + const parsed = splitWorktreeId(worktreeId) + if (!parsed?.repoId || !parsed.worktreePath) { + return null + } + return JSON.stringify([ + parsed.repoId, + worktreeRetentionPathComparisonKey(parsed.worktreePath, platform) + ]) +} diff --git a/src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx b/src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx index f17dfbb7613..648b33357d4 100644 --- a/src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx +++ b/src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { act, cleanup, render } from '@testing-library/react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DiffCommentCard } from './DiffCommentCard' @@ -44,6 +44,7 @@ describe('DiffCommentCard content resize', () => { afterEach(() => { cleanup() + vi.restoreAllMocks() vi.unstubAllGlobals() }) @@ -101,4 +102,52 @@ describe('DiffCommentCard content resize', () => { expect(constructObserver).not.toHaveBeenCalled() expect(onContentResize).not.toHaveBeenCalled() }) + + it('requests rail reflow only when the editing textarea height changes', () => { + const onContentResize = vi.fn() + let scrollHeight = 60 + vi.spyOn(HTMLTextAreaElement.prototype, 'scrollHeight', 'get').mockImplementation( + () => scrollHeight + ) + const view = render( + true} + /> + ) + + fireEvent.click(view.getByRole('button', { name: 'Edit note' })) + expect(onContentResize).toHaveBeenCalledOnce() + + const textarea = view.getByRole('textbox') + expect(textarea.style.height).toBe('60px') + fireEvent.change(textarea, { target: { value: 'A saved note!' } }) + expect(onContentResize).toHaveBeenCalledOnce() + + for (let index = 0; index < 50; index++) { + fireEvent.change(textarea, { target: { value: `A saved note! ${index}` } }) + } + expect(textarea.style.height).toBe('60px') + expect(onContentResize).toHaveBeenCalledOnce() + + scrollHeight = 400 + fireEvent.change(textarea, { target: { value: 'A much taller saved note' } }) + expect(textarea.style.height).toBe('240px') + expect(onContentResize).toHaveBeenCalledTimes(2) + + scrollHeight = 480 + fireEvent.change(textarea, { target: { value: 'A still taller saved note' } }) + expect(textarea.style.height).toBe('240px') + expect(onContentResize).toHaveBeenCalledTimes(2) + + scrollHeight = 80 + fireEvent.change(textarea, { target: { value: 'A shorter saved note' } }) + expect(textarea.style.height).toBe('80px') + expect(onContentResize).toHaveBeenCalledTimes(3) + + fireEvent.click(view.getByRole('button', { name: 'Cancel' })) + expect(onContentResize).toHaveBeenCalledTimes(4) + }) }) diff --git a/src/renderer/src/components/diff-comments/DiffCommentCard.tsx b/src/renderer/src/components/diff-comments/DiffCommentCard.tsx index 9e7ddf229ce..6becfe1fb34 100644 --- a/src/renderer/src/components/diff-comments/DiffCommentCard.tsx +++ b/src/renderer/src/components/diff-comments/DiffCommentCard.tsx @@ -35,6 +35,13 @@ type Props = { headerActions?: ReactNode } +function resizeDiffCommentTextarea(textarea: HTMLTextAreaElement): boolean { + const previousHeight = textarea.style.height + textarea.style.height = 'auto' + textarea.style.height = `${Math.min(textarea.scrollHeight, 240)}px` + return textarea.style.height !== previousHeight +} + export function DiffCommentCard({ lineNumber, startLine, @@ -116,8 +123,7 @@ export function DiffCommentCard({ if (!el) { return } - el.style.height = 'auto' - el.style.height = `${Math.min(el.scrollHeight, 240)}px` + resizeDiffCommentTextarea(el) el.focus() el.setSelectionRange(el.value.length, el.value.length) onContentResizeRef.current?.() @@ -281,10 +287,10 @@ export function DiffCommentCard({ value={draft} onChange={(e) => { setDraft(e.target.value) - const el = e.currentTarget - el.style.height = 'auto' - el.style.height = `${Math.min(el.scrollHeight, 240)}px` - onContentResizeRef.current?.() + // Why: rich-review layout measures every note; skip it when this card stayed put. + if (resizeDiffCommentTextarea(e.currentTarget)) { + onContentResizeRef.current?.() + } }} onKeyDown={(e) => { if (e.key === 'Escape') { diff --git a/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx b/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx new file mode 100644 index 00000000000..4a5600192d3 --- /dev/null +++ b/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx @@ -0,0 +1,297 @@ +// @vitest-environment happy-dom +import { cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' +import type { FileContent } from './editor-panel-content-types' + +const classifiers = vi.hoisted(() => ({ + getUnsupportedMessage: vi.fn<(content: string) => string | null>(), + exceedsSizeLimit: vi.fn<(content: string) => boolean>() +})) + +vi.mock('./markdown-rich-mode', () => ({ + getMarkdownRichModeEligibility: ({ + content, + sizeOverridden + }: { + content: string + sizeOverridden: boolean + }) => ({ + exceedsSizeLimit: !sizeOverridden && classifiers.exceedsSizeLimit(content), + unsupportedMessage: classifiers.getUnsupportedMessage(content) + }) +})) + +vi.mock('./editor-lazy-views', () => { + const view = (name: string) => () =>
+ return { + MonacoEditor: view('source'), + DiffViewer: view('diff'), + CombinedDiffViewer: view('combined-diff'), + RichMarkdownEditor: view('rich-editor'), + MarkdownPreview: view('preview'), + ImageViewer: view('image'), + ImageDiffViewer: view('image-diff'), + MermaidViewer: view('mermaid'), + CsvViewer: view('csv'), + IpynbViewer: view('notebook') + } +}) + +vi.mock('./RichMarkdownErrorBoundary', () => ({ + RichMarkdownErrorBoundary: ({ children }: { children: React.ReactNode }) => children +})) + +vi.mock('./useMarkdownDocuments', () => ({ + useMarkdownDocuments: () => ({ + markdownDocuments: [], + onOpenDocLink: () => {}, + previewProps: { markdownDocuments: [], onOpenDocument: async () => {} }, + mdSave: async () => true + }) +})) + +vi.mock('./useEditorConflictNavigation', () => ({ + useEditorConflictNavigation: () => () => undefined +})) + +vi.mock('@/store', () => { + const state = { + markdownRichModeSizeOverride: {}, + setMarkdownRichModeSizeOverride: () => {}, + reloadOpenCheckRunDetailsTab: () => {} + } + return { + useAppStore: Object.assign( + (selector: (storeState: typeof state) => unknown) => selector(state), + { getState: () => state } + ) + } +}) + +import { EditorContent } from './EditorContent' +import { getEditorPanelRenderModel } from './editor-panel-render-model' + +function openFile( + language: 'markdown' | 'typescript' = 'markdown', + mode: 'edit' | 'markdown-preview' = 'edit' +): OpenFile { + const extension = language === 'markdown' ? 'md' : 'ts' + return { + id: `/repo/notes.${extension}`, + filePath: `/repo/notes.${extension}`, + relativePath: `notes.${extension}`, + worktreeId: 'wt-1', + language, + mode, + isDirty: false + } +} + +function renderEditPath({ + content, + language = 'markdown', + viewMode = 'rich', + mode = 'edit' +}: { + content: string + language?: 'markdown' | 'typescript' + viewMode?: 'source' | 'rich' | 'preview' + mode?: 'edit' | 'markdown-preview' +}) { + const activeFile = openFile(language, mode) + const fileContents = { + [activeFile.id]: { content: '# Saved', isBinary: false } + } + const editorDrafts = { [activeFile.id]: content } + const model = getEditorPanelRenderModel({ + activeFile, + fileContents, + editorDrafts, + gitStatusEntries: undefined, + gitBranchEntries: undefined, + markdownViewMode: { [activeFile.id]: viewMode }, + markdownRichModeSizeOverride: {}, + isChangesMode: false, + canOpenWorkspaceFileBrowser: true + }) + const view = render( + + ) + + return { model, view } +} + +function getGuardedRenderModel({ + activeFileOverrides, + fileContent = { content: '# Saved', isBinary: false }, + includeFileContent = true, + isChangesMode = false +}: { + activeFileOverrides?: Partial + fileContent?: FileContent + includeFileContent?: boolean + isChangesMode?: boolean +}) { + const activeFile = { ...openFile(), ...activeFileOverrides } + return getEditorPanelRenderModel({ + activeFile, + fileContents: includeFileContent ? { [activeFile.id]: fileContent } : {}, + editorDrafts: { [activeFile.id]: '# Draft' }, + gitStatusEntries: undefined, + gitBranchEntries: undefined, + markdownViewMode: { [activeFile.id]: 'rich' }, + markdownRichModeSizeOverride: {}, + isChangesMode, + canOpenWorkspaceFileBrowser: true + }) +} + +beforeEach(() => { + classifiers.getUnsupportedMessage.mockImplementation((content) => + content.includes('[reference]:') ? 'Reference links require source mode.' : null + ) + classifiers.exceedsSizeLimit.mockImplementation((content) => content.includes('oversized')) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('inline Markdown render classification', () => { + it('classifies each rich draft once, including after content changes', () => { + const first = renderEditPath({ content: '# First draft' }) + + expect(first.view.container.innerHTML).toContain('data-editor-view="rich-editor"') + expect(classifiers.getUnsupportedMessage).toHaveBeenCalledTimes(1) + expect(classifiers.getUnsupportedMessage).toHaveBeenLastCalledWith('# First draft') + expect(classifiers.exceedsSizeLimit).toHaveBeenCalledTimes(1) + expect(classifiers.exceedsSizeLimit).toHaveBeenLastCalledWith('# First draft') + + first.view.unmount() + const second = renderEditPath({ content: '# Changed draft' }) + + expect(second.view.container.innerHTML).toContain('data-editor-view="rich-editor"') + expect(classifiers.getUnsupportedMessage).toHaveBeenCalledTimes(2) + expect(classifiers.getUnsupportedMessage).toHaveBeenLastCalledWith('# Changed draft') + expect(classifiers.exceedsSizeLimit).toHaveBeenCalledTimes(2) + expect(classifiers.exceedsSizeLimit).toHaveBeenLastCalledWith('# Changed draft') + }) + + it.each([ + { + name: 'source Markdown', + args: { content: '# Source', viewMode: 'source' as const }, + expectedView: 'source', + canExport: false + }, + { + name: 'Markdown preview', + args: { content: '# Preview', mode: 'markdown-preview' as const }, + expectedView: 'preview', + canExport: true + }, + { + name: 'TypeScript', + args: { content: 'const value = 1', language: 'typescript' as const }, + expectedView: 'source', + canExport: false + } + ])('skips rich eligibility scans for $name', ({ args, expectedView, canExport }) => { + const result = renderEditPath(args) + + expect(result.view.container.innerHTML).toContain(`data-editor-view="${expectedView}"`) + expect(result.model.canExportMarkdownToPdf).toBe(canExport) + expect(classifiers.getUnsupportedMessage).not.toHaveBeenCalled() + expect(classifiers.exceedsSizeLimit).not.toHaveBeenCalled() + }) + + it.each([ + { name: 'Changes mode', args: { isChangesMode: true } }, + { name: 'content that is still loading', args: { includeFileContent: false } }, + { + name: 'binary content', + args: { fileContent: { content: '', isBinary: true } } + }, + { + name: 'content with a load error', + args: { fileContent: { content: '', isBinary: false, loadError: 'Read failed' } } + }, + { + name: 'read-only Markdown', + args: { activeFileOverrides: { readOnly: true } } + }, + { + name: 'an unresolved conflict', + args: { + activeFileOverrides: { + conflict: { + kind: 'conflict-editable' as const, + conflictKind: 'both_modified' as const, + conflictStatus: 'unresolved' as const, + conflictStatusSource: 'git' as const + } + } + } + }, + { + name: 'a conflict placeholder', + args: { + activeFileOverrides: { + conflict: { + kind: 'conflict-placeholder' as const, + conflictKind: 'both_deleted' as const, + conflictStatus: 'unresolved' as const, + conflictStatusSource: 'git' as const + } + } + } + } + ])('skips rich eligibility scans for $name', ({ args }) => { + const model = getGuardedRenderModel(args) + + expect(model.inlineMarkdownRenderState).toBeNull() + expect(classifiers.getUnsupportedMessage).not.toHaveBeenCalled() + expect(classifiers.exceedsSizeLimit).not.toHaveBeenCalled() + }) + + it('preserves unsupported and oversized rich-mode fallbacks', () => { + const unsupported = renderEditPath({ content: '[reference]: https://example.com' }) + + expect(unsupported.model.canExportMarkdownToPdf).toBe(false) + expect(unsupported.view.getByText('Reference links require source mode.')).toBeTruthy() + expect(unsupported.view.queryByText('Open anyway')).toBeNull() + unsupported.view.unmount() + + const oversized = renderEditPath({ content: '# oversized' }) + + expect(oversized.model.canExportMarkdownToPdf).toBe(false) + expect(oversized.view.getByText(/File is larger than the .* rich editing limit/)).toBeTruthy() + expect(oversized.view.getByText('Open anyway')).toBeTruthy() + }) +}) diff --git a/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx index 53eec01d0d8..00ded9527d2 100644 --- a/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx +++ b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx @@ -184,6 +184,7 @@ function props(activeFile: OpenFile, content: string) { isCsv: false, isNotebook: false, mdViewMode: 'rich' as const, + inlineMarkdownRenderState: null, isChangesMode: false, sideBySide: false, pendingEditorReveal: null, @@ -326,6 +327,10 @@ describe('EditorContent Monaco lifecycle boundary', () => { resolvedLanguage="markdown" isMarkdown mdViewMode="rich" + inlineMarkdownRenderState={{ + renderMode: 'rich-editor', + richModeUnsupportedMessage: null + }} /> ) diff --git a/src/renderer/src/components/editor/EditorContent.test.tsx b/src/renderer/src/components/editor/EditorContent.test.tsx index 130c92abe35..8086851a81c 100644 --- a/src/renderer/src/components/editor/EditorContent.test.tsx +++ b/src/renderer/src/components/editor/EditorContent.test.tsx @@ -67,6 +67,7 @@ describe('EditorContent', () => { isCsv={false} isNotebook mdViewMode="rich" + inlineMarkdownRenderState={null} isChangesMode={false} sideBySide={false} pendingEditorReveal={null} @@ -108,6 +109,7 @@ describe('EditorContent', () => { isCsv={false} isNotebook={false} mdViewMode="rich" + inlineMarkdownRenderState={null} isChangesMode={false} sideBySide={false} pendingEditorReveal={null} @@ -153,6 +155,7 @@ describe('EditorContent', () => { isCsv={false} isNotebook={false} mdViewMode="rich" + inlineMarkdownRenderState={null} isChangesMode={false} sideBySide={false} pendingEditorReveal={null} @@ -200,6 +203,7 @@ describe('EditorContent', () => { isCsv={false} isNotebook={false} mdViewMode="preview" + inlineMarkdownRenderState={null} isChangesMode={false} sideBySide={false} pendingEditorReveal={null} diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index ca12823e846..9fab1cd5665 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -12,6 +12,7 @@ import type { FileContent } from './editor-panel-content-types' import { translate } from '@/i18n/i18n' import { useEditorConflictNavigation } from './useEditorConflictNavigation' import { useMarkdownDocuments } from './useMarkdownDocuments' +import type { MarkdownRenderState } from './markdown-render-mode' const noopCloseMarkdownTableOfContents = (): void => {} @@ -47,6 +48,7 @@ export function EditorContent({ isCsv, isNotebook, mdViewMode, + inlineMarkdownRenderState, isChangesMode, sideBySide, showMarkdownTableOfContents = false, @@ -74,6 +76,7 @@ export function EditorContent({ isCsv: boolean isNotebook: boolean mdViewMode: MarkdownViewMode + inlineMarkdownRenderState: MarkdownRenderState | null isChangesMode: boolean sideBySide: boolean showMarkdownTableOfContents?: boolean @@ -238,6 +241,7 @@ export function EditorContent({ isCsv={isCsv} isNotebook={isNotebook} mdViewMode={mdViewMode} + inlineMarkdownRenderState={inlineMarkdownRenderState} isChangesMode={isChangesMode} sideBySide={sideBySide} showMarkdownTableOfContents={showMarkdownTableOfContents} diff --git a/src/renderer/src/components/editor/EditorEditFileSurface.tsx b/src/renderer/src/components/editor/EditorEditFileSurface.tsx index d0b83539ee6..06a7bb3f909 100644 --- a/src/renderer/src/components/editor/EditorEditFileSurface.tsx +++ b/src/renderer/src/components/editor/EditorEditFileSurface.tsx @@ -17,6 +17,7 @@ import type { FileContent } from './editor-panel-content-types' import { ExternalFileChangeBanner } from './ExternalFileChangeBanner' import type { useMarkdownDocuments } from './useMarkdownDocuments' import { EditorMarkdownFileSurface } from './EditorMarkdownFileSurface' +import type { MarkdownRenderState } from './markdown-render-mode' const noopEditorContentChange = (_content: string): void => {} const noopEditorSave = async (_content: string): Promise => false @@ -39,6 +40,7 @@ export function EditorEditFileSurface({ isCsv, isNotebook, mdViewMode, + inlineMarkdownRenderState, isChangesMode, sideBySide, showMarkdownTableOfContents, @@ -69,6 +71,7 @@ export function EditorEditFileSurface({ isCsv: boolean isNotebook: boolean mdViewMode: MarkdownViewMode + inlineMarkdownRenderState: MarkdownRenderState | null isChangesMode: boolean sideBySide: boolean showMarkdownTableOfContents: boolean @@ -210,6 +213,7 @@ export function EditorEditFileSurface({ editorViewStateKey={editorViewStateKey} currentContent={currentContent} mdViewMode={mdViewMode} + inlineMarkdownRenderState={inlineMarkdownRenderState} showMarkdownTableOfContents={showMarkdownTableOfContents} showMarkdownFrontmatter={showMarkdownFrontmatter} onCloseMarkdownTableOfContents={onCloseMarkdownTableOfContents} diff --git a/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx b/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx index 6d5cfea1bdf..fda81b98462 100644 --- a/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx +++ b/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx @@ -5,10 +5,8 @@ import { Button } from '@/components/ui/button' import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' import { formatBytes } from '../status-bar/workspace-space-format' import { MarkdownPreview, RichMarkdownEditor } from './editor-lazy-views' -import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit' import { extractFrontMatter, prependFrontMatter } from './markdown-frontmatter' -import { getMarkdownRenderMode } from './markdown-render-mode' -import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode' +import type { MarkdownRenderState } from './markdown-render-mode' import { RichMarkdownErrorBoundary } from './RichMarkdownErrorBoundary' import type { useMarkdownDocuments } from './useMarkdownDocuments' @@ -20,6 +18,7 @@ export function EditorMarkdownFileSurface({ editorViewStateKey, currentContent, mdViewMode, + inlineMarkdownRenderState, showMarkdownTableOfContents, showMarkdownFrontmatter, onCloseMarkdownTableOfContents, @@ -35,6 +34,7 @@ export function EditorMarkdownFileSurface({ editorViewStateKey: string currentContent: string mdViewMode: MarkdownViewMode + inlineMarkdownRenderState: MarkdownRenderState | null showMarkdownTableOfContents: boolean showMarkdownFrontmatter: boolean onCloseMarkdownTableOfContents: () => void @@ -45,18 +45,15 @@ export function EditorMarkdownFileSurface({ handleDirtyStateHint: (dirty: boolean) => void monacoEditor: React.JSX.Element }): React.JSX.Element { - const sizeOverridden = useAppStore((s) => s.markdownRichModeSizeOverride[activeFile.id] === true) const setSizeOverride = useAppStore((s) => s.setMarkdownRichModeSizeOverride) - const richModeUnsupportedMessage = getMarkdownRichModeUnsupportedMessage(currentContent) - const renderMode = getMarkdownRenderMode({ - exceedsRichModeSizeLimit: !sizeOverridden && exceedsMarkdownRichModeSizeLimit(currentContent), - hasRichModeUnsupportedContent: richModeUnsupportedMessage !== null, - viewMode: mdViewMode - }) if (activeFile.conflict?.conflictStatus === 'unresolved') { return
{monacoEditor}
} + if (!inlineMarkdownRenderState) { + return
{monacoEditor}
+ } + const { renderMode, richModeUnsupportedMessage } = inlineMarkdownRenderState if (renderMode === 'source' && mdViewMode === 'rich') { // Why: only a size fallback is recoverable — unsupported syntax would round-trip badly, so it gets no override. const isSizeFallback = richModeUnsupportedMessage === null diff --git a/src/renderer/src/components/editor/EditorPanelShell.tsx b/src/renderer/src/components/editor/EditorPanelShell.tsx index 62b399c3b3c..dd15231cefe 100644 --- a/src/renderer/src/components/editor/EditorPanelShell.tsx +++ b/src/renderer/src/components/editor/EditorPanelShell.tsx @@ -149,6 +149,7 @@ export function EditorPanelShell({ isCsv={model.isCsv} isNotebook={model.isNotebook} mdViewMode={model.mdViewMode} + inlineMarkdownRenderState={model.inlineMarkdownRenderState} isChangesMode={model.isDiffSurface && !model.isSingleDiff} sideBySide={sideBySide} pendingEditorReveal={pendingEditorReveal} diff --git a/src/renderer/src/components/editor/editor-panel-render-model.ts b/src/renderer/src/components/editor/editor-panel-render-model.ts index 2dc9fe87382..db03e65efe1 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -12,9 +12,8 @@ import { getEditorHeaderOpenFileState } from './editor-header' import type { EditorToggleValue } from './EditorViewToggle' import type { FileContent } from './editor-panel-content-types' import { canUseChangesModeForFile } from './editor-panel-file-mode' -import { getMarkdownRenderMode } from './markdown-render-mode' -import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode' -import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit' +import { getMarkdownRenderMode, type MarkdownRenderState } from './markdown-render-mode' +import { getMarkdownRichModeEligibility } from './markdown-rich-mode' type StoreState = ReturnType @@ -129,17 +128,36 @@ export function getEditorPanelRenderModel({ const shouldShowMarkdownExportAction = viewerLanguage === 'markdown' && (activeFile.mode === 'edit' || activeFile.mode === 'markdown-preview') - const inlineMarkdownRenderMode = - activeFile.mode === 'edit' && inlineMarkdownContent !== null - ? getMarkdownRenderMode({ - exceedsRichModeSizeLimit: - markdownRichModeSizeOverride[activeFile.id] !== true && - exceedsMarkdownRichModeSizeLimit(inlineMarkdownContent), - hasRichModeUnsupportedContent: - getMarkdownRichModeUnsupportedMessage(inlineMarkdownContent) !== null, - viewMode: mdViewMode + const inlineFileContent = activeFile.mode === 'edit' ? fileContents[activeFile.id] : undefined + const canRenderInlineMarkdown = + viewerLanguage === 'markdown' && + activeFile.mode === 'edit' && + inlineMarkdownContent !== null && + !isChangesMode && + inlineFileContent !== undefined && + inlineFileContent.isBinary !== true && + !inlineFileContent.loadError && + activeFile.conflict?.kind !== 'conflict-placeholder' && + activeFile.conflict?.conflictStatus !== 'unresolved' + let inlineMarkdownRenderState: MarkdownRenderState | null = null + if (canRenderInlineMarkdown) { + const shouldClassifyRichMode = mdViewMode === 'rich' + const richModeEligibility = shouldClassifyRichMode + ? getMarkdownRichModeEligibility({ + content: inlineMarkdownContent, + sizeOverridden: markdownRichModeSizeOverride[activeFile.id] === true }) : null + const richModeUnsupportedMessage = richModeEligibility?.unsupportedMessage ?? null + inlineMarkdownRenderState = { + renderMode: getMarkdownRenderMode({ + exceedsRichModeSizeLimit: richModeEligibility?.exceedsSizeLimit ?? false, + hasRichModeUnsupportedContent: richModeUnsupportedMessage !== null, + viewMode: mdViewMode + }), + richModeUnsupportedMessage + } + } const canExportMarkdownToPdf = shouldShowMarkdownExportAction && ((activeFile.mode === 'markdown-preview' && @@ -149,8 +167,8 @@ export function getEditorPanelRenderModel({ (activeFile.mode === 'edit' && fileContents[activeFile.id] !== undefined && !isChangesMode && - inlineMarkdownRenderMode !== null && - inlineMarkdownRenderMode !== 'source' && + inlineMarkdownRenderState !== null && + inlineMarkdownRenderState.renderMode !== 'source' && fileContents[activeFile.id]?.isBinary !== true && !fileContents[activeFile.id]?.loadError && activeFile.conflict?.conflictStatus !== 'unresolved')) @@ -180,6 +198,7 @@ export function getEditorPanelRenderModel({ isMarkdownTableOfContentsDisabled: hasViewModeToggle && mdViewMode === 'source', shouldShowMarkdownExportAction, canExportMarkdownToPdf, + inlineMarkdownRenderState, canShowMarkdownTableOfContents: viewerLanguage === 'markdown' && (hasViewModeToggle || activeFile.mode === 'markdown-preview'), diff --git a/src/renderer/src/components/editor/markdown-render-mode.ts b/src/renderer/src/components/editor/markdown-render-mode.ts index b8cbc499986..f188dcbb814 100644 --- a/src/renderer/src/components/editor/markdown-render-mode.ts +++ b/src/renderer/src/components/editor/markdown-render-mode.ts @@ -2,6 +2,11 @@ import type { MarkdownViewMode } from '@/store/slices/editor' export type MarkdownRenderMode = 'source' | 'rich-editor' | 'preview' +export type MarkdownRenderState = { + renderMode: MarkdownRenderMode + richModeUnsupportedMessage: string | null +} + export function getMarkdownRenderMode({ exceedsRichModeSizeLimit, hasRichModeUnsupportedContent, diff --git a/src/renderer/src/components/editor/markdown-rich-mode.test.ts b/src/renderer/src/components/editor/markdown-rich-mode.test.ts index e95b6f49c81..25f2e3bd869 100644 --- a/src/renderer/src/components/editor/markdown-rich-mode.test.ts +++ b/src/renderer/src/components/editor/markdown-rich-mode.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode' +import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' +import { + getMarkdownRichModeEligibility, + getMarkdownRichModeUnsupportedMessage +} from './markdown-rich-mode' afterEach(() => { vi.restoreAllMocks() @@ -46,6 +50,31 @@ describe('getMarkdownRichModeUnsupportedMessage', () => { ).toBeNull() }) + it('allows bare lowercase placeholders in prose', () => { + expect( + getMarkdownRichModeUnsupportedMessage( + 'Use --host runtime:, --output , and --project .\n' + ) + ).toBeNull() + }) + + it('allows bare lowercase placeholders in large documents', () => { + const content = `${'a'.repeat(50_001)}\nUse --host runtime: and --project .\n` + + expect(getMarkdownRichModeUnsupportedMessage(content)).toBeNull() + }) + + it('still blocks large documents with actual html or jsx', () => { + const prefix = 'a'.repeat(50_001) + + expect(getMarkdownRichModeUnsupportedMessage(`${prefix}\ntext\n`)).not.toBeNull() + expect(getMarkdownRichModeUnsupportedMessage(`${prefix}\ntext\n`)).not.toBeNull() + expect(getMarkdownRichModeUnsupportedMessage(`${prefix}\n\n`)).not.toBeNull() + expect( + getMarkdownRichModeUnsupportedMessage(`${prefix}\n\n`) + ).not.toBeNull() + }) + it('allows block html and mdx-like tags by preserving them as passthrough nodes', () => { expect(getMarkdownRichModeUnsupportedMessage('\n')).toBeNull() expect(getMarkdownRichModeUnsupportedMessage('
block
\n')).toBeNull() @@ -90,4 +119,17 @@ describe('getMarkdownRichModeUnsupportedMessage', () => { ) expect(usedGlobalHtmlFragmentMatch).toBe(false) }) + + it('keeps unsupported content blocked when it also exceeds the size limit', () => { + const content = `${'a'.repeat(RICH_MARKDOWN_MAX_SIZE_BYTES + 1)}` + + expect(getMarkdownRichModeEligibility({ content, sizeOverridden: false })).toEqual({ + exceedsSizeLimit: true, + unsupportedMessage: expect.any(String) + }) + expect(getMarkdownRichModeEligibility({ content, sizeOverridden: true })).toEqual({ + exceedsSizeLimit: false, + unsupportedMessage: expect.any(String) + }) + }) }) diff --git a/src/renderer/src/components/editor/markdown-rich-mode.ts b/src/renderer/src/components/editor/markdown-rich-mode.ts index 694a8a7ee5f..4764892a4ea 100644 --- a/src/renderer/src/components/editor/markdown-rich-mode.ts +++ b/src/renderer/src/components/editor/markdown-rich-mode.ts @@ -1,5 +1,7 @@ +import { defaultSchema } from 'rehype-sanitize' import { getRichMarkdownRoundTripOutput } from './markdown-round-trip' import { extractFrontMatter } from './markdown-frontmatter' +import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit' import { translate } from '@/i18n/i18n' export type MarkdownRichModeUnsupportedReason = @@ -14,6 +16,13 @@ type UnsupportedMatch = { pattern: RegExp } +export type MarkdownRichModeEligibility = { + exceedsSizeLimit: boolean + unsupportedMessage: string | null +} + +const KNOWN_MARKDOWN_HTML_TAG_NAMES = new Set(defaultSchema.tagNames ?? []) + const UNSUPPORTED_PATTERNS: UnsupportedMatch[] = [ { reason: 'html-or-jsx', @@ -66,7 +75,7 @@ export function getMarkdownRichModeUnsupportedMessage(content: string): string | // opinion when HTML is detected, to verify the HTML survives the round-trip // before blocking the user from rich mode. const htmlMatcher = UNSUPPORTED_PATTERNS.find((m) => m.reason === 'html-or-jsx') - const hasHtml = htmlMatcher && htmlMatcher.pattern.test(contentWithoutCode) + const hasHtml = htmlMatcher && hasHtmlOrJsx(contentWithoutCode, htmlMatcher.pattern) for (const matcher of UNSUPPORTED_PATTERNS) { if (matcher.reason === 'html-or-jsx') { @@ -91,6 +100,44 @@ export function getMarkdownRichModeUnsupportedMessage(content: string): string | return null } +export function getMarkdownRichModeEligibility({ + content, + sizeOverridden +}: { + content: string + sizeOverridden: boolean +}): MarkdownRichModeEligibility { + const exceedsSizeLimit = !sizeOverridden && exceedsMarkdownRichModeSizeLimit(content) + return { + exceedsSizeLimit, + unsupportedMessage: getMarkdownRichModeUnsupportedMessage(content) + } +} + +function hasHtmlOrJsx(content: string, pattern: RegExp): boolean { + for (const match of content.matchAll(new RegExp(pattern, 'g'))) { + if (isHtmlOrJsxFragment(match[0])) { + return true + } + } + return false +} + +function isHtmlOrJsxFragment(fragment: string): boolean { + if (fragment.startsWith('