From 33f59ad99edb4305581f064b4dea7e18c7915c1b Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 9 Jul 2026 17:47:28 -0400 Subject: [PATCH 01/11] fix(rate-limits): recover WSL Codex usage RPC refresh (#7567) (#7658) * fix(codex): launch WSL usage RPC through interactive shell (#7567) * fix(codex): harden WSL usage RPC shell startup --------- Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> --- src/main/rate-limits/codex-fetcher.test.ts | 97 +++++++++++++++++++--- src/main/rate-limits/codex-fetcher.ts | 21 ++++- 2 files changed, 102 insertions(+), 16 deletions(-) diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index daaec0ce7fd..77532918702 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -536,13 +536,23 @@ describe('fetchCodexRateLimits', () => { const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0] expect(spawnFile).toBe('wsl.exe') - const bashCommand = spawnArgs.at(-1) as string - expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') - expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') - expect(bashCommand).toContain( - "export CODEX_HOME='/home/alice/.local/share/orca/account/home'" + expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'sh', '-c']) + const shellCommand = spawnArgs.at(-1) as string + expect(shellCommand).toContain('_orca_wsl_shell=\\$(getent passwd') + expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "\\$_orca_wsl_shell" -ilc') + expect(shellCommand).toContain( + 'exec 3<&0\nexec 4>&1\nexec /dev/null\n_orca_wsl_shell=' ) - expect(bashCommand).toContain("exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'") + expect(shellCommand).toContain('mkdir -p "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain('cd "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain( + "export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''" + ) + expect(shellCommand).toContain( + "exec codex '\\''-s'\\'' '\\''read-only'\\'' '\\''-a'\\'' '\\''untrusted'\\'' '\\''app-server'\\'' <&3 >&4 3<&- 4>&-" + ) + expect(shellCommand.match(/<&3 >&4 3<&- 4>&-/g)).toHaveLength(3) + expect(shellCommand.match(/exec codex [^\n]+<&3 >&4 3<&- 4>&-/g)).toHaveLength(3) expect(spawnOptions).toEqual( expect.objectContaining({ cwd: expect.stringContaining('rate-limit-pty-cwd'), @@ -557,6 +567,62 @@ describe('fetchCodexRateLimits', () => { } }) + it('routes Windows host Codex homes through the host RPC path', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + rpcChild.stdin.write.mockImplementation((line: string) => { + const msg = JSON.parse(line) as { id?: number; method?: string } + if (msg.method === 'initialize') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) + ) + }, 0) + } + if (msg.method === 'account/rateLimits/read') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: msg.id, + result: { rateLimits: { primary: { usedPercent: 13 } } } + })}\n` + ) + ) + }, 0) + } + }) + + try { + const resultPromise = fetchCodexRateLimits({ codexHomePath: 'C:\\Users\\alice\\.codex' }) + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + await resultPromise + + const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0] + expect(spawnFile).toBe('codex') + expect(spawnArgs).toEqual(['-s', 'read-only', '-a', 'untrusted', 'app-server']) + expect(spawnOptions).toEqual( + expect.objectContaining({ + env: expect.objectContaining({ CODEX_HOME: 'C:\\Users\\alice\\.codex' }) + }) + ) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('runs rate-limit PTY fallback through WSL when RPC cannot read usage', async () => { const originalPlatform = process.platform const originalCodexHome = process.env.CODEX_HOME @@ -588,13 +654,20 @@ describe('fetchCodexRateLimits', () => { const [spawnFile, spawnArgs, spawnOptions] = ptySpawnMock.mock.calls[0] expect(spawnFile).toBe('wsl.exe') - const bashCommand = spawnArgs.at(-1) as string - expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') - expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') - expect(bashCommand).toContain( - "export CODEX_HOME='/home/alice/.local/share/orca/account/home'" + expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'sh', '-c']) + const shellCommand = spawnArgs.at(-1) as string + expect(shellCommand).toContain('_orca_wsl_shell=\\$(getent passwd') + expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "\\$_orca_wsl_shell" -ilc') + expect(shellCommand).not.toContain('exec 3<&0') + expect(shellCommand).not.toContain('exec /dev/null') + expect(shellCommand).not.toContain('<&3 >&4 3<&- 4>&-') + expect(shellCommand).toContain('mkdir -p "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain('cd "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain( + "export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''" ) - expect(bashCommand).toContain('exec codex ') + expect(shellCommand).toContain('exec codex ') expect(spawnOptions).toEqual( expect.objectContaining({ cwd: expect.stringContaining('rate-limit-pty-cwd'), diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 28ef1b710fd..c75be611fe4 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -17,6 +17,10 @@ import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' import { parseWslUncPath } from '../../shared/wsl-paths' import { extractCodexAuthError, isCodexAuthError } from '../../shared/codex-auth-errors' +import { + buildWslLoginShellCommand, + escapeWslShCommandForWindows +} from '../../shared/wsl-login-shell-command' import { getHiddenRateLimitWslCwdSetupCommands, resolveHiddenRateLimitPtyCwd @@ -112,7 +116,8 @@ function shellQuote(value: string): string { function buildWslCodexCommand( codexHomePath: string, - args: string[] + args: string[], + options?: { isolateRpcStdio?: boolean } ): { command: string args: string[] @@ -124,11 +129,19 @@ function buildWslCodexCommand( const script = [ ...getHiddenRateLimitWslCwdSetupCommands(), `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`, - `exec codex ${args.map(shellQuote).join(' ')}` + `exec codex ${args.map(shellQuote).join(' ')}${ + options?.isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : '' + }` ].join(' && ') + const loginShellCommand = buildWslLoginShellCommand(script) + // Why: keep the outer sh non-login and hide RPC pipes before the configured + // shell startup can read input or print banners. + const command = options?.isolateRpcStdio + ? ['exec 3<&0', 'exec 4>&1', 'exec /dev/null', loginShellCommand].join('\n') + : loginShellCommand return { command: 'wsl.exe', - args: ['-d', wslInfo.distro, '--', 'bash', '-lc', script] + args: ['-d', wslInfo.distro, '--', 'sh', '-c', escapeWslShCommandForWindows(command)] } } @@ -411,7 +424,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise Date: Thu, 9 Jul 2026 14:52:03 -0700 Subject: [PATCH 02/11] Disable experimental new worktree card style by default (#7977) --- src/cli/handlers/agent-hooks.test.ts | 14 ++++++------- src/cli/handlers/agent-hooks.ts | 16 +-------------- src/main/persistence.test.ts | 20 ++++++++++++++++--- src/main/persistence.ts | 30 +--------------------------- 4 files changed, 26 insertions(+), 54 deletions(-) diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index 5ed9578685a..4ae4c962d59 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -92,32 +92,32 @@ describe('agent hooks CLI handler', () => { rmSync(userDataPath, { recursive: true, force: true }) }) - it('keeps the fresh-profile new card style default when creating offline settings', async () => { + it('keeps new card style off when creating offline settings for a fresh profile', async () => { await runAgentHooksOff(userDataPath) const persisted = readDataFile(userDataPath) - expect(persisted.settings.experimentalNewWorktreeCardStyle).toBe(true) + expect(persisted.settings.experimentalNewWorktreeCardStyle).toBe(false) expect(persisted.settings.agentStatusHooksEnabled).toBe(false) }) - it('defaults missing new card style on while offline-updated onboarding is open', async () => { + it('keeps missing new card style off when updating offline settings', async () => { const existing = getDefaultPersistedState(userDataPath) delete existing.settings.experimentalNewWorktreeCardStyle writeDataFile(userDataPath, existing) await runAgentHooksOff(userDataPath) - expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(true) + expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(false) }) - it('preserves an existing explicit new card style opt-out when updating offline settings', async () => { + it('preserves an existing explicit new card style opt-in when updating offline settings', async () => { const existing = getDefaultPersistedState(userDataPath) - existing.settings.experimentalNewWorktreeCardStyle = false + existing.settings.experimentalNewWorktreeCardStyle = true writeDataFile(userDataPath, existing) await runAgentHooksOff(userDataPath) - expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(false) + expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(true) }) }) diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index 2419be2733f..2b83f5c15ea 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -31,16 +31,7 @@ function isRecord(value: unknown): value is Record { function readPersistedState(dataPath: string): PersistedState { if (!existsSync(dataPath)) { - const defaults = getDefaultPersistedState(homedir()) - return { - ...defaults, - settings: { - ...defaults.settings, - // Why: offline CLI can create the first profile before desktop load; - // match the Store fresh-install default instead of pinning old cards. - experimentalNewWorktreeCardStyle: true - } - } + return getDefaultPersistedState(homedir()) } try { const parsed = JSON.parse(readFileSync(dataPath, 'utf-8')) @@ -83,14 +74,9 @@ function readEnabledFromDisk(): boolean { function updateEnabledOnDisk(enabled: boolean): string { const dataPath = getDataPath() const state = readPersistedState(dataPath) - const experimentalNewWorktreeCardStyle = - state.settings?.experimentalNewWorktreeCardStyle ?? state.onboarding?.closedAt === null state.settings = { ...getDefaultPersistedState(homedir()).settings, ...state.settings, - // Why: offline CLI can run before Store.load(); mirror its open-onboarding - // default without overriding a saved user opt-out. - experimentalNewWorktreeCardStyle, agentStatusHooksEnabled: enabled } writePersistedState(dataPath, state) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 22f20157d8f..366c9d4256e 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -527,7 +527,7 @@ describe('Store', () => { expect(settings.experimentalActivity).toBe(false) expect(settings.experimentalActivityDefaultedOffForAllUsers).toBe(true) expect(settings.experimentalTerminalAttention).toBe(false) - expect(settings.experimentalNewWorktreeCardStyle).toBe(true) + expect(settings.experimentalNewWorktreeCardStyle).toBe(false) expect(settings.floatingTerminalEnabled).toBe(true) expect(settings.floatingTerminalDefaultedForAllUsers).toBe(true) expect(settings.notifications.customSoundPath).toBeNull() @@ -658,7 +658,7 @@ describe('Store', () => { expect(store.getUI().setupGuideSidebarDismissed).toBe(false) }) - it('defaults new worktree card style on while onboarding is open', async () => { + it('keeps new worktree card style off while onboarding is open', async () => { writeDataFile({ settings: {}, onboarding: { @@ -673,7 +673,7 @@ describe('Store', () => { const store = await createStore() - expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(true) + expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) }) it('preserves explicit new worktree card style opt-out while onboarding is open', async () => { @@ -696,6 +696,20 @@ describe('Store', () => { expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) }) + it('preserves explicit new worktree card style opt-in on load', async () => { + writeDataFile({ + schemaVersion: 1, + settings: { + experimentalNewWorktreeCardStyle: true + }, + ui: {} + }) + + const store = await createStore() + + expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(true) + }) + it('keeps new worktree card style off for existing users backfilled as completed', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 67fdd0c8215..0af7920b1a2 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1350,10 +1350,6 @@ function resolveSetupGuideSidebarDismissedOnLoad( return onboarding.closedAt !== null || persistedDismissed === true } -function shouldDefaultNewWorktreeCardStyleOn(onboarding: OnboardingState): boolean { - return onboarding.closedAt === null -} - // Why: read a settings field that was removed from GlobalSettings but can // still exist on disk. One-shot use for the inline-agents migration. function readDeprecatedExperimentFlag(parsed: PersistedState | undefined): boolean { @@ -3067,16 +3063,6 @@ export class Store { if (!parsed.onboarding) { this.loadNeedsSave = true } - const defaultNewWorktreeCardStyle = - shouldDefaultNewWorktreeCardStyleOn(normalizedOnboarding) - const migratedExperimentalNewWorktreeCardStyle = - parsed.settings?.experimentalNewWorktreeCardStyle ?? defaultNewWorktreeCardStyle - if ( - parsed.settings?.experimentalNewWorktreeCardStyle === undefined && - defaultNewWorktreeCardStyle - ) { - this.loadNeedsSave = true - } const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups) const loadedCompactWorktreeCards = parsed.settings?.compactWorktreeCards ?? @@ -3130,9 +3116,6 @@ export class Store { ...migratedTerminalTuiScrollSensitivity.settings, experimentalActivity: migratedExperimentalActivity, experimentalActivityDefaultedOffForAllUsers: true, - // Why: open first-run onboarding is the local fresh-install signal; - // closed/backfilled onboarding identifies existing profiles. - experimentalNewWorktreeCardStyle: migratedExperimentalNewWorktreeCardStyle, // Why: compact worktree cards graduated from Experimental; preserve // the old opt-in for profiles written during the rollout. compactWorktreeCards: loadedCompactWorktreeCards, @@ -3417,18 +3400,7 @@ export class Store { } if (result === null) { - const defaults = getDefaultPersistedState(homedir()) - const isFreshDefaultProfile = - !fileExistedOnLoad && shouldDefaultNewWorktreeCardStyleOn(defaults.onboarding) - result = { - ...defaults, - settings: { - ...defaults.settings, - // Why: a corrupt existing data file also falls back to defaults; only - // the absent-file path is a true fresh install. - experimentalNewWorktreeCardStyle: isFreshDefaultProfile - } - } + result = getDefaultPersistedState(homedir()) } const workspaceSession = pruneWorkspaceSessionBrowserHistory( From 6964bfb23bdad69ca16d5e00b4c68ffb601bd662 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:53:22 +0000 Subject: [PATCH 03/11] release: v1.4.131-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e6ad02b2d04..7112a4f39c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.131-rc.1", + "version": "1.4.131-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 20ed7642113bfc6e63526b6307f7126dadba3ab1 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:04:06 -0700 Subject: [PATCH 04/11] Fix WSL source control path separators (#7966) --- src/main/git/status-wsl-pathspecs.test.ts | 87 +++++++++++++++++++++++ src/main/git/status.ts | 58 ++++++++++----- 2 files changed, 127 insertions(+), 18 deletions(-) create mode 100644 src/main/git/status-wsl-pathspecs.test.ts diff --git a/src/main/git/status-wsl-pathspecs.test.ts b/src/main/git/status-wsl-pathspecs.test.ts new file mode 100644 index 00000000000..e802fb72b5b --- /dev/null +++ b/src/main/git/status-wsl-pathspecs.test.ts @@ -0,0 +1,87 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileAsyncBuffer: vi.fn(), + gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => env, + gitStreamStdout: vi.fn() +})) + +import { + bulkDiscardChanges, + bulkStageFiles, + bulkUnstageFiles, + discardChanges, + stageFile, + unstageFile +} from './status' + +const worktreePath = path.resolve('repo') +const windowsRelativePath = 'tests\\breakgit' +const wslOptions = { wslDistro: 'Ubuntu' } + +describe('WSL git pathspecs', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'tests/breakgit\0', stderr: '' }) + }) + + it('uses POSIX separators for tracked file mutations executed inside WSL', async () => { + await stageFile(worktreePath, windowsRelativePath, wslOptions) + await unstageFile(worktreePath, windowsRelativePath, wslOptions) + await bulkStageFiles(worktreePath, [windowsRelativePath], wslOptions) + await bulkUnstageFiles(worktreePath, [windowsRelativePath], wslOptions) + await discardChanges(worktreePath, windowsRelativePath, wslOptions) + await bulkDiscardChanges(worktreePath, [windowsRelativePath], wslOptions) + + const pathspecs = gitExecFileAsyncMock.mock.calls.flatMap(([args]) => + (args as string[]).filter((arg) => arg.startsWith(':(literal)')) + ) + expect(pathspecs).toEqual(Array(8).fill(':(literal)tests/breakgit')) + }) + + it('uses POSIX separators when discarding untracked files inside WSL', async () => { + const repo = await mkdtemp(path.join(tmpdir(), 'orca-wsl-pathspec-')) + const targetPath = path.resolve(repo, windowsRelativePath) + await mkdir(path.dirname(targetPath), { recursive: true }) + await writeFile(targetPath, 'untracked') + + try { + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error('not tracked')) + .mockResolvedValue({ stdout: '', stderr: '' }) + await discardChanges(repo, windowsRelativePath, wslOptions) + + expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([ + ['ls-files', '--error-unmatch', '--', ':(literal)tests/breakgit'], + ['clean', '-ffdx', '--', ':(literal)tests/breakgit'] + ]) + + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + await bulkDiscardChanges(repo, [windowsRelativePath], wslOptions) + + expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([ + ['ls-files', '-z', '--', ':(literal)tests/breakgit'], + ['clean', '-ffdx', '--', ':(literal)tests/breakgit'] + ]) + } finally { + await rm(repo, { recursive: true, force: true }) + } + }) + + it('preserves backslashes for host Git where they can be literal filename characters', async () => { + await stageFile(worktreePath, windowsRelativePath) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['add', '--', ':(literal)tests\\breakgit'], { + cwd: worktreePath + }) + }) +}) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 862c8c9b76b..6ec73ef8a3b 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1837,7 +1837,7 @@ export async function stageFile( clearGitReadInvalidationState() try { await gitExecFileAsync( - ['add', '--', literalPathspec(filePath)], + ['add', '--', literalPathspec(filePath, options)], gitOptionsForWorktree(worktreePath, options) ) } finally { @@ -1855,7 +1855,7 @@ export async function unstageFile( ): Promise { clearGitReadInvalidationState() try { - await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath)], { + await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath, options)], { ...gitOptionsForWorktree(worktreePath, options) }) } finally { @@ -1961,9 +1961,12 @@ export async function discardChanges( let tracked = false try { - await gitExecFileAsync(['ls-files', '--error-unmatch', '--', literalPathspec(filePath)], { - ...gitOptionsForWorktree(worktreePath, options) - }) + await gitExecFileAsync( + ['ls-files', '--error-unmatch', '--', literalPathspec(filePath, options)], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) tracked = true } catch { // File is not tracked by git @@ -1971,7 +1974,7 @@ export async function discardChanges( if (tracked) { await gitExecFileAsync( - ['restore', '--worktree', '--source=HEAD', '--', literalPathspec(filePath)], + ['restore', '--worktree', '--source=HEAD', '--', literalPathspec(filePath, options)], { ...gitOptionsForWorktree(worktreePath, options) } @@ -1991,9 +1994,11 @@ function normalizeGitPathForCompare(filePath: string): string { return filePath.replace(/\\/g, '/').replace(/\/+$/, '') } -function literalPathspec(filePath: string): string { - // Why: source-control selections are concrete paths, not user-authored Git globs. - return `:(literal)${filePath}` +function literalPathspec(filePath: string, options: GitRuntimeOptions): string { + // Why: Windows validation produces backslashes, but Git running inside WSL + // needs POSIX paths. Host paths stay untouched so POSIX filenames remain literal. + const runtimePath = options.wslDistro ? filePath.replace(/\\/g, '/') : filePath + return `:(literal)${runtimePath}` } function isTrackedPathSpec(filePath: string, trackedPaths: readonly string[]): boolean { @@ -2013,7 +2018,7 @@ async function listTrackedPathSpecs( for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) const { stdout } = await gitExecFileAsync( - ['ls-files', '-z', '--', ...chunk.map(literalPathspec)], + ['ls-files', '-z', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], { ...gitOptionsForWorktree(worktreePath, options) } @@ -2038,9 +2043,12 @@ async function cleanUntrackedPaths( const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) if (chunk.length > 0) { // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. - await gitExecFileAsync(['clean', '-ffdx', '--', ...chunk.map(literalPathspec)], { - ...gitOptionsForWorktree(worktreePath, options) - }) + await gitExecFileAsync( + ['clean', '-ffdx', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) } } } @@ -2082,7 +2090,13 @@ export async function bulkDiscardChanges( for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) await gitExecFileAsync( - ['restore', '--worktree', '--source=HEAD', '--', ...chunk.map(literalPathspec)], + [ + 'restore', + '--worktree', + '--source=HEAD', + '--', + ...chunk.map((filePath) => literalPathspec(filePath, options)) + ], { ...gitOptionsForWorktree(worktreePath, options) } @@ -2125,7 +2139,7 @@ export async function bulkStageFiles( for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) await gitExecFileAsync( - ['add', '--', ...chunk.map(literalPathspec)], + ['add', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], gitOptionsForWorktree(worktreePath, options) ) } @@ -2149,9 +2163,17 @@ export async function bulkUnstageFiles( try { for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - await gitExecFileAsync(['restore', '--staged', '--', ...chunk.map(literalPathspec)], { - ...gitOptionsForWorktree(worktreePath, options) - }) + await gitExecFileAsync( + [ + 'restore', + '--staged', + '--', + ...chunk.map((filePath) => literalPathspec(filePath, options)) + ], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) } } finally { clearGitReadInvalidationState() From e537953d8f90d57c763fab0ad8f9af57ca2e1521 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:06:30 -0700 Subject: [PATCH 05/11] Fix GitLab auth diagnostic waking WSL (#7967) --- src/main/git/runner-wsl-gh-fallback.test.ts | 23 +++++++++++++++++++++ src/main/git/runner.ts | 2 ++ src/main/gitlab/client-mr.test.ts | 3 +++ src/main/gitlab/client.ts | 5 ++++- 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 3aae182432c..c7710858838 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -352,6 +352,29 @@ describe('ghExecFileAsync WSL fallback', () => { ) }) + it('does not wake the default WSL distro for host-only GitLab diagnostics', async () => { + getDefaultWslDistroMock.mockReturnValue('Ubuntu') + execFileMock + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' })) + }) + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(null, { stdout: 'Logged in to gitlab.com', stderr: '' }) + }) + + await expect( + glabExecFileAsync(['auth', 'status'], { allowDefaultWslFallback: false }) + ).rejects.toThrow('spawn glab ENOENT') + + expect(execFileMock).toHaveBeenCalledTimes(1) + expect(execFileMock).toHaveBeenCalledWith( + 'glab', + ['auth', 'status'], + expect.objectContaining({ cwd: undefined }), + expect.any(Function) + ) + }) + it('still retries idempotent glab transient failures', async () => { execFileMock .mockImplementationOnce((_binary, _args, _options, callback) => { diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index c9009845369..fca0db2ef65 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -1480,6 +1480,7 @@ type GlabExecOptions = Omit & { cwd?: string wslDistro?: string idempotent?: boolean + allowDefaultWslFallback?: boolean } /** @@ -1544,6 +1545,7 @@ export async function glabExecFileAsync( resolved.wsl === null && !options.cwd && !options.wslDistro && + options.allowDefaultWslFallback !== false && isHostCommandMissing(err, 'glab') ) { const wslResolved = resolveDefaultWslCli('glab', args) diff --git a/src/main/gitlab/client-mr.test.ts b/src/main/gitlab/client-mr.test.ts index 21747de6a26..a090ec7963b 100644 --- a/src/main/gitlab/client-mr.test.ts +++ b/src/main/gitlab/client-mr.test.ts @@ -245,6 +245,9 @@ describe('gitlab client — MR operations', () => { hosts: ['gitlab.com'], activeHost: 'gitlab.com' }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { + allowDefaultWslFallback: false + }) }) }) diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index ec4a178ea64..b6b32dfb8c3 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -97,7 +97,10 @@ export async function diagnoseAuth(): Promise { ? 'GLAB_TOKEN' : null try { - const { stdout, stderr } = await glabExecFileAsync(['auth', 'status']) + // Why: a host-global diagnostic must not wake an unrelated default WSL distro. + const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { + allowDefaultWslFallback: false + }) const output = `${stdout}\n${stderr}` const hosts = parseGlabAuthStatusHosts(output) return { From 73f1bbfc94d0c771a816e964a962bee7f4f85b58 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:56:25 -0700 Subject: [PATCH 06/11] mobile: recover from wedged Tailscale tunnels and say 'check Tailscale' when it's the likely culprit (#7980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wedged Tailscale tunnel (known iOS failure mode) produces no AppState or network-type transition, so no revival nudge ever fires and the reconnect loop parked permanently at its give-up cap — users had to toggle Tailscale off/on just to force a transition (#7824). - rpc-client: past the give-up cap, drop to a 90s trickle dial instead of parking so the session self-heals once the tunnel recovers. - host screen: nudge the shared client on focus so opening the host retries immediately instead of waiting out a backoff/trickle timer. - connection-health: warning/unreachable verdicts on 100.64/10 or *.ts.net endpoints now carry a 'check Tailscale' hint, shown on the home host list and the in-session status line after ~3 failed attempts. - troubleshoot: 'Cannot reach ' now says to check Tailscale, adds a dedicated Tailscale section, and stops telling Tailscale users to disable their VPN (that advice killed their only route to the host); sections extracted to troubleshoot-common-issues.tsx to stay under the max-lines cap. Co-authored-by: Orca --- mobile/app/h/[hostId]/index.tsx | 11 +++ .../app/h/[hostId]/session/[worktreeId].tsx | 20 +++-- mobile/app/index.tsx | 7 +- mobile/app/troubleshoot.tsx | 76 ++---------------- .../src/diagnostics/host-reachability.test.ts | 24 +++++- mobile/src/diagnostics/host-reachability.ts | 12 +++ .../troubleshoot-common-issues.tsx | 73 +++++++++++++++++ .../src/transport/connection-health.test.ts | 80 +++++++++++++++++++ mobile/src/transport/connection-health.ts | 40 ++++++++-- mobile/src/transport/rpc-client.test.ts | 32 +++++--- mobile/src/transport/rpc-client.ts | 76 +++++++++++------- 11 files changed, 328 insertions(+), 123 deletions(-) create mode 100644 mobile/src/diagnostics/troubleshoot-common-issues.tsx create mode 100644 mobile/src/transport/connection-health.test.ts diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 16baabe9177..71bda7b64dc 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -552,6 +552,17 @@ export function HostScreen({ } }, [connState, client]) + useFocusEffect( + useCallback(() => { + // Why: opening the host is a strong user signal — reset a backed-off or + // trickling reconnect loop (and probe a possibly half-open socket) + // immediately instead of waiting out its timer. Deps stay empty so this + // fires per focus transition, not per connection-state change; nudging + // on every reconnecting↔connecting flip would defeat the backoff. + clientRef.current?.notifyForeground() + }, []) + ) + useFocusEffect( useCallback(() => { // The embedded sidebar drives its own polling below; focus never fires diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index c4701a302db..b556170b4c7 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -62,7 +62,10 @@ import { useReconnectAttempt, useLastConnectedAt } from '../../../../src/transport/client-context' -import { classifyConnection } from '../../../../src/transport/connection-health' +import { + classifyConnection, + verdictDisplayLabel +} from '../../../../src/transport/connection-health' import { useResponsiveLayout } from '../../../../src/layout/responsive-layout' import { type ActivePanel, @@ -1000,6 +1003,9 @@ export default function SessionScreen() { const terminalCwdRef = useRef>(new Map()) const initialModesSeenRef = useRef>(new Set()) const deviceTokenRef = useRef(null) + // Why: state (not a ref) — the connection verdict needs a re-render once + // the endpoint loads so the Tailscale hint can appear. + const [hostEndpoint, setHostEndpoint] = useState(null) const clientRef = useRef(null) const connStateRef = useRef(connState) // Why: measured once from TerminalWebView on mount, then passed with every @@ -2453,6 +2459,7 @@ export default function SessionScreen() { const host = hosts.find((h) => h.id === hostId) if (host) { deviceTokenRef.current = host.deviceToken + setHostEndpoint(host.endpoint) } }) return () => { @@ -4265,13 +4272,14 @@ export default function SessionScreen() { void handleCreateTerminal() }, [client, creating, creatingBrowser, creatingMarkdown, showEmptyState, worktreeId]) - // Why: the reconnect loop parks at its give-up cap; without an in-session - // affordance the only recovery is leaving the screen or restarting the - // app (issue #5049). Surface tap-to-retry once the verdict escalates. + // Why: the reconnect loop slows to a 90s trickle at its give-up cap; + // surface tap-to-retry once the verdict escalates so recovery doesn't + // wait out the trickle timer (issue #5049). const connectionVerdict = classifyConnection({ state: connState, reconnectAttempts, - lastConnectedAt + lastConnectedAt, + endpoint: hostEndpoint }) const showConnectionRetry = connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable' @@ -4284,7 +4292,7 @@ export default function SessionScreen() { ? '1 tab' : `${visibleTabs.length} tabs` : showConnectionRetry - ? `${connectionVerdict.label} — tap to retry` + ? `${verdictDisplayLabel(connectionVerdict)} — tap to retry` : MOBILE_SESSION_STATUS_LABELS[connState] // Why: keep safe-area padding in layout at all times, then visually translate diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index acaf97b7646..eaee0e5f935 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -34,7 +34,7 @@ import { useForceReconnect, usePrimeHosts } from '../src/transport/client-context' -import { classifyConnection } from '../src/transport/connection-health' +import { classifyConnection, verdictDisplayLabel } from '../src/transport/connection-health' import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications' import type { ConnectionState, HostProfile } from '../src/transport/types' import { triggerMediumImpact } from '../src/platform/haptics' @@ -827,7 +827,8 @@ export default function HomeScreen() { const verdict = classifyConnection({ state, reconnectAttempts: attempts, - lastConnectedAt + lastConnectedAt, + endpoint: item.endpoint }) const isError = verdict.kind === 'warning' || @@ -859,7 +860,7 @@ export default function HomeScreen() { - {verdict.label} + {verdictDisplayLabel(verdict)} {connected && info ? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}` : ''} diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx index 0a1a63b7c27..e9d7a34eef3 100644 --- a/mobile/app/troubleshoot.tsx +++ b/mobile/app/troubleshoot.tsx @@ -14,11 +14,6 @@ import { ChevronLeft, ChevronDown, ChevronUp, - WifiOff, - Shield, - Monitor, - Clock, - Globe, Activity, CheckCircle2, XCircle, @@ -30,7 +25,12 @@ import { startDiagnosticFetchTimeout, type DiagnosticFetchTimeout } from '../src/diagnostics/diagnostic-fetch-timeout' -import { formatEndpoint, testHostReachability } from '../src/diagnostics/host-reachability' +import { + formatEndpoint, + testHostReachability, + unreachableHostDetail +} from '../src/diagnostics/host-reachability' +import { troubleshootCommonIssues } from '../src/diagnostics/troubleshoot-common-issues' type DiagnosticStatus = 'idle' | 'running' | 'done' @@ -40,66 +40,6 @@ type CheckResult = { detail: string } -type TroubleshootSection = { - id: string - icon: React.ReactNode - title: string - steps: string[] -} - -const sections: TroubleshootSection[] = [ - { - id: 'wifi', - icon: , - title: 'Different WiFi Networks', - steps: [ - 'Both devices must be on the same local network.', - 'Ethernet and WiFi must share the same subnet.', - 'Try reconnecting WiFi on both devices.' - ] - }, - { - id: 'firewall', - icon: , - title: 'Firewall Blocking Port 6768', - steps: [ - 'macOS: System Settings → Network → Firewall — allow Orca.', - 'Windows: Defender Firewall → Allow app — enable Orca for Private networks.', - 'Linux: sudo ufw allow 6768', - 'Corporate/school networks may block P2P — try a personal hotspot.' - ] - }, - { - id: 'desktop', - icon: , - title: 'Desktop App Not Running', - steps: [ - 'Orca must be open on your desktop to accept connections.', - 'Try restarting Orca — the companion server starts on launch.', - 'After an update, you may need to re-pair via QR code.' - ] - }, - { - id: 'timeout', - icon: , - title: 'Connection Timeout', - steps: [ - 'Check WiFi signal strength on your phone.', - 'Go back to the host list and tap your host to retry.', - 'Restart both apps if timeouts persist.' - ] - }, - { - id: 'vpn', - icon: , - title: 'VPN Interference', - steps: [ - 'VPNs can route local traffic through a remote server.', - 'Disable the VPN or enable split tunneling / "Allow LAN".' - ] - } -] - function StatusIcon({ status }: { status: CheckResult['status'] }) { switch (status) { case 'pass': @@ -211,7 +151,7 @@ export default function TroubleshootScreen() { status: reachable ? 'pass' : 'fail', detail: reachable ? `Reachable at ${formatEndpoint(host.endpoint)}` - : `Cannot reach ${formatEndpoint(host.endpoint)}` + : unreachableHostDetail(host.endpoint) }) setChecks([...results]) } @@ -295,7 +235,7 @@ export default function TroubleshootScreen() { Common issues - {sections.map((section, i) => ( + {troubleshootCommonIssues.map((section, i) => ( {i > 0 && } { + it('points at Tailscale for tailnet CGNAT endpoints', () => { + expect(unreachableHostDetail('ws://100.65.9.106:6768')).toBe( + 'Cannot reach 100.65.9.106:6768 — check Tailscale' + ) + }) + + it('points at Tailscale for MagicDNS endpoints', () => { + expect(unreachableHostDetail('ws://my-desktop.tailnet-1234.ts.net:6768')).toBe( + 'Cannot reach my-desktop.tailnet-1234.ts.net:6768 — check Tailscale' + ) + }) + + it('stays generic for LAN endpoints', () => { + expect(unreachableHostDetail('ws://192.168.1.50:6768')).toBe('Cannot reach 192.168.1.50:6768') + }) + + it('does not treat non-CGNAT 100.x addresses as Tailscale', () => { + expect(unreachableHostDetail('ws://100.20.1.5:6768')).toBe('Cannot reach 100.20.1.5:6768') + }) +}) describe('testHostReachability', () => { it('returns false without leaving timers when WebSocket rejects a malformed endpoint', async () => { diff --git a/mobile/src/diagnostics/host-reachability.ts b/mobile/src/diagnostics/host-reachability.ts index 7ae292126e8..495d65a9764 100644 --- a/mobile/src/diagnostics/host-reachability.ts +++ b/mobile/src/diagnostics/host-reachability.ts @@ -1,3 +1,5 @@ +import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' + const HOST_REACHABILITY_TIMEOUT_MS = 4000 // Why: troubleshooting needs a cheap endpoint probe without completing the @@ -56,3 +58,13 @@ export function formatEndpoint(endpoint: string): string { return endpoint } } + +// Why: an unreachable 100.x/*.ts.net host almost always means the phone's +// Tailscale tunnel is down or wedged (known iOS failure mode, fixed by +// toggling the VPN) — point at that instead of a bare "Cannot reach". +export function unreachableHostDetail(endpoint: string): string { + if (isTailscaleEndpoint(endpoint)) { + return `Cannot reach ${formatEndpoint(endpoint)} — check Tailscale` + } + return `Cannot reach ${formatEndpoint(endpoint)}` +} diff --git a/mobile/src/diagnostics/troubleshoot-common-issues.tsx b/mobile/src/diagnostics/troubleshoot-common-issues.tsx new file mode 100644 index 00000000000..5dc91eb65fc --- /dev/null +++ b/mobile/src/diagnostics/troubleshoot-common-issues.tsx @@ -0,0 +1,73 @@ +import { WifiOff, Shield, Monitor, Clock, Globe } from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' + +export type TroubleshootSection = { + id: string + icon: React.ReactNode + title: string + steps: string[] +} + +export const troubleshootCommonIssues: TroubleshootSection[] = [ + { + id: 'wifi', + icon: , + title: 'Different WiFi Networks', + steps: [ + 'Both devices must be on the same local network (unless connected through Tailscale).', + 'Ethernet and WiFi must share the same subnet.', + 'Try reconnecting WiFi on both devices.' + ] + }, + { + id: 'firewall', + icon: , + title: 'Firewall Blocking Port 6768', + steps: [ + 'macOS: System Settings → Network → Firewall — allow Orca.', + 'Windows: Defender Firewall → Allow app — enable Orca for Private networks.', + 'Linux: sudo ufw allow 6768', + 'Corporate/school networks may block P2P — try a personal hotspot.' + ] + }, + { + id: 'desktop', + icon: , + title: 'Desktop App Not Running', + steps: [ + 'Orca must be open on your desktop to accept connections.', + 'Try restarting Orca — the companion server starts on launch.', + 'After an update, you may need to re-pair via QR code.' + ] + }, + { + id: 'timeout', + icon: , + title: 'Connection Timeout', + steps: [ + 'Check WiFi signal strength on your phone.', + 'Go back to the host list and tap your host to retry.', + 'Restart both apps if timeouts persist.' + ] + }, + { + id: 'tailscale', + icon: , + title: 'Tailscale Host Unreachable', + steps: [ + 'Host addresses like 100.x.x.x or *.ts.net connect through Tailscale — keep it ON.', + 'iOS/Android can silently wedge the tunnel: toggle Tailscale off and back on in the Tailscale app.', + 'Check the desktop is awake and shows as connected in your tailnet.', + 'Update the Tailscale app — recent releases fix reconnect bugs.' + ] + }, + { + id: 'vpn', + icon: , + title: 'Other VPN Interference', + steps: [ + 'Non-Tailscale VPNs can route local traffic through a remote server.', + 'Disable that VPN or enable split tunneling / "Allow LAN".' + ] + } +] diff --git a/mobile/src/transport/connection-health.test.ts b/mobile/src/transport/connection-health.test.ts new file mode 100644 index 00000000000..22e752a6335 --- /dev/null +++ b/mobile/src/transport/connection-health.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { classifyConnection, verdictDisplayLabel } from './connection-health' + +describe('classifyConnection Tailscale hint', () => { + const base = { + state: 'reconnecting' as const, + lastConnectedAt: null, + nowMs: 1_000_000 + } + + it('adds the hint to the warning verdict for a tailnet CGNAT endpoint', () => { + const verdict = classifyConnection({ + ...base, + reconnectAttempts: 3, + endpoint: 'ws://100.65.9.106:6768' + }) + expect(verdict).toMatchObject({ kind: 'warning', hint: 'check Tailscale' }) + }) + + it('adds the hint to the unreachable verdict for a MagicDNS endpoint', () => { + const verdict = classifyConnection({ + ...base, + reconnectAttempts: 12, + endpoint: 'ws://my-desktop.tailnet-1234.ts.net:6768' + }) + expect(verdict).toMatchObject({ + kind: 'unreachable', + reason: 'never-connected', + hint: 'check Tailscale' + }) + }) + + it('keeps plain labels for LAN endpoints', () => { + const warning = classifyConnection({ + ...base, + reconnectAttempts: 3, + endpoint: 'ws://192.168.1.50:6768' + }) + expect(warning.kind).toBe('warning') + expect('hint' in warning && warning.hint).toBeFalsy() + }) + + it('keeps plain labels when no endpoint is provided', () => { + const verdict = classifyConnection({ ...base, reconnectAttempts: 3 }) + expect(verdict.kind).toBe('warning') + expect('hint' in verdict && verdict.hint).toBeFalsy() + }) + + it('never hints on healthy states', () => { + const verdict = classifyConnection({ + state: 'connected', + reconnectAttempts: 0, + lastConnectedAt: 999_000, + endpoint: 'ws://100.65.9.106:6768', + nowMs: 1_000_000 + }) + expect(verdict).toEqual({ kind: 'normal', label: 'Connected' }) + }) +}) + +describe('verdictDisplayLabel', () => { + it('appends the hint to warning and unreachable labels', () => { + expect( + verdictDisplayLabel({ kind: 'warning', label: "Can't connect", hint: 'check Tailscale' }) + ).toBe("Can't connect — check Tailscale") + expect( + verdictDisplayLabel({ + kind: 'unreachable', + label: "Can't reach desktop", + reason: 'stale', + hint: 'check Tailscale' + }) + ).toBe("Can't reach desktop — check Tailscale") + }) + + it('returns the bare label without a hint', () => { + expect(verdictDisplayLabel({ kind: 'warning', label: "Can't connect" })).toBe("Can't connect") + expect(verdictDisplayLabel({ kind: 'normal', label: 'Connected' })).toBe('Connected') + }) +}) diff --git a/mobile/src/transport/connection-health.ts b/mobile/src/transport/connection-health.ts index 4ce81beffd2..d3b8251fc1b 100644 --- a/mobile/src/transport/connection-health.ts +++ b/mobile/src/transport/connection-health.ts @@ -1,3 +1,4 @@ +import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' import type { ConnectionState } from './types' // Why: thresholds for escalating connection UX from neutral @@ -11,7 +12,8 @@ import type { ConnectionState } from './types' // reuse the 60s cap). Combined with the never-connected / // stale-since-last-connect heuristic below, this is the trigger to // surface a "re-pair?" affordance. MUST stay aligned with -// rpc-client.ts GIVE_UP_AFTER_ATTEMPTS. +// rpc-client.ts GIVE_UP_AFTER_ATTEMPTS (past which the loop slows +// to a 90s trickle instead of parking). // - STALE_SINCE_LAST_CONNECT_MS: 60s → if we WERE connected this // session but haven't been for ≥ 1 minute despite the retry loop // spinning, treat the same as never-connected. Catches the case @@ -20,10 +22,21 @@ const WARNING_ATTEMPTS = 3 const UNREACHABLE_ATTEMPTS = 12 const STALE_SINCE_LAST_CONNECT_MS = 60_000 +// Why: a repeatedly-unreachable 100.x/*.ts.net endpoint almost always means +// the phone's Tailscale tunnel is down or wedged (a known iOS failure mode +// that only a manual toggle fixes) — not that the desktop moved. Say so +// instead of leaving the user staring at a generic "Can't connect". +const TAILSCALE_HINT = 'check Tailscale' + export type ConnectionVerdict = | { kind: 'normal'; label: string } - | { kind: 'warning'; label: string } // "Can't connect" - | { kind: 'unreachable'; label: string; reason: 'never-connected' | 'stale' } + | { kind: 'warning'; label: string; hint?: string } // "Can't connect" + | { + kind: 'unreachable' + label: string + reason: 'never-connected' | 'stale' + hint?: string + } | { kind: 'auth-failed'; label: string } // Why: the rpc-client's lastConnectedAt is a one-shot timestamp; we have @@ -33,10 +46,14 @@ export function classifyConnection(args: { state: ConnectionState reconnectAttempts: number lastConnectedAt: number | null + // Optional pinned host endpoint — enables the Tailscale hint on + // warning/unreachable verdicts. Callers without it get plain labels. + endpoint?: string | null nowMs?: number }): ConnectionVerdict { const { state, reconnectAttempts, lastConnectedAt } = args const now = args.nowMs ?? Date.now() + const hint = isTailscaleEndpoint(args.endpoint) ? TAILSCALE_HINT : undefined if (state === 'auth-failed') { return { kind: 'auth-failed', label: 'Auth failed' } @@ -60,21 +77,32 @@ export function classifyConnection(args: { return { kind: 'unreachable', label: "Can't reach desktop", - reason: 'never-connected' + reason: 'never-connected', + hint } } if (now - lastConnectedAt >= STALE_SINCE_LAST_CONNECT_MS) { return { kind: 'unreachable', label: "Can't reach desktop", - reason: 'stale' + reason: 'stale', + hint } } } if (reconnectAttempts >= WARNING_ATTEMPTS) { - return { kind: 'warning', label: "Can't connect" } + return { kind: 'warning', label: "Can't connect", hint } } return { kind: 'normal', label: 'Reconnecting…' } } + +// Why: single place that turns a verdict into display text so every screen +// renders the Tailscale hint the same way. +export function verdictDisplayLabel(verdict: ConnectionVerdict): string { + if ((verdict.kind === 'warning' || verdict.kind === 'unreachable') && verdict.hint) { + return `${verdict.label} — ${verdict.hint}` + } + return verdict.label +} diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index e096ee659c9..b2adb18c4ce 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -618,28 +618,42 @@ describe('mobile rpc-client connection timeout', () => { return { client, socket } } - it('repro: a parked reconnect loop never retries on its own', async () => { + // Why 520_000ms: the 12 fast attempts cost Σ(RECONNECT_DELAYS) 360.5s of + // backoff plus 13 × 12s connect timeouts ≈ 516.5s, so 520s lands just + // past the give-up cap with the first trickle timer armed. + const PAST_GIVE_UP_CAP_MS = 520_000 + + it('keeps trickle-retrying after the give-up cap instead of parking', async () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') openAndAuthenticate(mockSockets[0]!) mockSockets[0]!.close() - await vi.runAllTimersAsync() + await vi.advanceTimersByTimeAsync(PAST_GIVE_UP_CAP_MS) expect(client.getState()).toBe('reconnecting') - expect(client.getReconnectAttempt()).toBe(12) + expect(client.getReconnectAttempt()).toBeGreaterThanOrEqual(12) - // Stuck: arbitrary additional time produces no further attempts. + // A wedged VPN produces no revival nudge (issue #7824) — the loop must + // keep dialing on its own at the 90s trickle cadence. const socketsBefore = mockSockets.length - await vi.advanceTimersByTimeAsync(600_000) - expect(mockSockets.length).toBe(socketsBefore) + await vi.advanceTimersByTimeAsync(102_000) + expect(mockSockets.length).toBeGreaterThan(socketsBefore) + + // Once the tunnel heals, a trickle dial restores the session without + // any user action. 75s lands inside the next dial's 12s connect window + // (the prior dial failed mid-advance above, re-arming the 90s timer). + await vi.advanceTimersByTimeAsync(75_000) + openAndAuthenticate(mockSockets[mockSockets.length - 1]!) + expect(client.getState()).toBe('connected') + expect(client.getReconnectAttempt()).toBe(0) client.close() }) - it('restarts a parked reconnect loop on foreground', async () => { + it('restarts a backed-off reconnect loop on foreground without waiting out the trickle', async () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') openAndAuthenticate(mockSockets[0]!) mockSockets[0]!.close() - await vi.runAllTimersAsync() + await vi.advanceTimersByTimeAsync(PAST_GIVE_UP_CAP_MS) expect(client.getReconnectAttempt()).toBe(12) const socketsBefore = mockSockets.length @@ -921,7 +935,7 @@ describe('mobile rpc-client connection timeout', () => { () => null, (error: Error) => error ) - await vi.runAllTimersAsync() + await vi.advanceTimersByTimeAsync(520_000) expect(client.getState()).toBe('reconnecting') expect(client.getReconnectAttempt()).toBe(12) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 2cd484de4b8..b9d0c990261 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -103,16 +103,22 @@ export type RpcClient = { // time across all 12 attempts is ≈ 6 minutes before the give-up cap // fires (0.5+1+2+4+8+15+30+60+60+60+60+60 ≈ 360s). const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000] -// Why: cap auto-retry once we're clearly unreachable for a long time. +// Why: cap fast auto-retry once we're clearly unreachable for a long time. // With the tiered backoff above this is ≈ 6 minutes of continuous -// failure before we stop and surface the re-pair banner. The longer +// failure before the UI surfaces the re-pair banner. The longer // runway tolerates flaky AP-isolation routers and laptop sleep cycles // that briefly drop the LAN path. MUST stay aligned with // connection-health.ts UNREACHABLE_ATTEMPTS so the "unreachable" -// verdict matches the moment the loop actually pauses — if these -// drift the user sees "Reconnecting…" while the loop is silently -// parked. +// verdict matches the moment the loop slows to the trickle cadence. const GIVE_UP_AFTER_ATTEMPTS = 12 +// Why: past the cap the loop must never park permanently. A wedged +// Tailscale/VPN tunnel produces no AppState or network-type transition +// (still Wi-Fi, still "online"), so no revival nudge ever fires — users +// had to toggle Tailscale off/on just to force one. A slow trickle dial +// self-heals once the tunnel recovers while staying cheap: one TCP +// attempt per 90s, foreground-only (iOS/Android suspend JS timers in +// the background). +const TRICKLE_RECONNECT_DELAY_MS = 90_000 // Why: a single `unauthorized`/`e2ee_error` is not proof the pairing is dead. // Issue #5200: a tablet showed "Auth failed" and forced a needless re-pair // while the desktop still listed it as paired with a valid token — a transient @@ -280,9 +286,11 @@ export function connect( if (intentionallyClosed) { return Promise.reject(new Error('Client closed')) } - if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS && !reconnectTimer) { - // Why: after the retry cap there is no future state transition to - // release callers waiting before their per-request timeout starts. + if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) { + // Why: past the retry cap the loop only trickles every 90s — callers + // must fail fast rather than hang on a host that's been unreachable + // for minutes. A trickle dial that succeeds flips state to 'connected' + // and later requests go through normally. return Promise.reject(new Error('Connection retry limit reached')) } return new Promise((resolve, reject) => { @@ -773,27 +781,35 @@ export function connect( } function scheduleReconnect() { - // Why: spinning reconnect forever drains battery and floods logs + // Why: spinning fast reconnects forever drains battery and floods logs // when the host is genuinely unreachable (wrong IP, port closed, - // host moved). Cap at GIVE_UP_AFTER_ATTEMPTS — the UI surfaces a - // "Can't reach desktop, re-pair?" banner at this point and the - // user can tap Retry (forceReconnect creates a fresh client, - // resetting the counter) or Re-pair. Without an explicit cap the - // worst-case is a phone left on the home screen burning a socket - // open every 4s indefinitely. - if (reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) { - console.log('[net] reconnect-paused', { - attempt: reconnectAttempt, - reason: 'give-up-cap', - endpoint: redactedEndpoint(endpoint) - }) + // host moved). Past GIVE_UP_AFTER_ATTEMPTS the UI surfaces a + // "Can't reach desktop, re-pair?" banner and the loop drops to the + // 90s trickle cadence instead of parking — a permanently parked loop + // could only be revived by an AppState/network transition, which a + // wedged VPN tunnel never produces. + const pastGiveUpCap = reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS + let delay: number + if (pastGiveUpCap) { + // Why: the counter holds at the cap — connection-health thresholds and + // the "Can't reach desktop" verdict key off attempts >= 12, and a + // successful open resets it to 0 anyway. + delay = TRICKLE_RECONNECT_DELAY_MS rejectConnectWaiters('Connection retry limit reached') - return + } else { + delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]! + reconnectAttempt++ } - const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]! - reconnectAttempt++ - console.log('[net] scheduleReconnect', { delayMs: delay, attempt: reconnectAttempt }) - emitLog('info', `Reconnect scheduled in ${delay}ms`, `Attempt ${reconnectAttempt}`) + console.log('[net] scheduleReconnect', { + delayMs: delay, + attempt: reconnectAttempt, + trickle: pastGiveUpCap + }) + emitLog( + 'info', + `Reconnect scheduled in ${delay}ms`, + pastGiveUpCap ? `Attempt ${reconnectAttempt} (slow retry)` : `Attempt ${reconnectAttempt}` + ) reconnectTimer = setTimeout(() => { reconnectTimer = null openConnection() @@ -1176,10 +1192,10 @@ export function connect( return } if (state === 'reconnecting') { - // Why: while backgrounded the retry loop may have parked at the - // give-up cap or be sitting on a 60s backoff timer. Returning to - // the foreground is a strong user signal — restart with a fresh - // attempt budget immediately instead of requiring an app restart. + // Why: while backgrounded the retry loop may be sitting on a 60s + // backoff or 90s trickle timer. Returning to the foreground is a + // strong user signal — restart with a fresh attempt budget + // immediately instead of waiting out the timer. console.log('[net] foreground — restarting reconnect loop', { attempt: reconnectAttempt, hadTimer: !!reconnectTimer From 69befad13ecc902047bf447067db923e9f76c1eb Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:09:09 -0700 Subject: [PATCH 07/11] Harden layout validation, watcher lifecycle, and connection robustness (#7924) * Harden layout validation, watcher lifecycle, and connection robustness - Throw instead of silently skipping when the packaged daemon-entry is missing, preventing layout regressions from passing build checks. - Terminate idle parcel-watcher processes to reclaim native handles and avoid crash-prone native node module teardowns on shutdown. - Bind the persisted WS fallback port first to prevent orphaning active mobile pairings when the preferred port becomes free again. - Cap concurrent disk reads for restored dirty tab verification at three to prevent startup connection bottlenecks on remote SSH workspaces. * Queue file IDs instead of snapshots in restored conflict scans This avoids using stale file snapshots (e.g., outdated disk signatures) if a tab is saved, closed, or re-baselined while waiting in the queue behind the concurrency limit. The live state is now fetched from the store and validated immediately before initiating the disk read. --- config/electron-builder.config.cjs | 11 ++- .../scripts/electron-builder-config.test.mjs | 9 ++ .../scripts/verify-packaged-daemon-entry.cjs | 28 ++++-- .../verify-packaged-daemon-entry.test.mjs | 57 ++++++++++++ package.json | 4 +- src/main/index.ts | 4 +- src/main/ipc/parcel-watcher-process.test.ts | 86 +++++++++++++++++- src/main/ipc/parcel-watcher-process.ts | 26 ++++++ .../runtime/rpc/ws-fallback-port-store.ts | 4 +- src/main/runtime/rpc/ws-transport.test.ts | 71 +++++++++++++++ src/main/runtime/rpc/ws-transport.ts | 62 ++++++------- src/main/runtime/runtime-rpc.ts | 6 +- .../editor-restored-tab-conflict-scan.test.ts | 88 +++++++++++++++++++ .../editor-restored-tab-conflict-scan.ts | 48 +++++++++- 14 files changed, 453 insertions(+), 51 deletions(-) create mode 100644 config/scripts/verify-packaged-daemon-entry.test.mjs diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 0148b3cbafd..61f38c081c4 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -2,7 +2,10 @@ const { chmodSync, existsSync, readdirSync } = require('node:fs') const { execFileSync } = require('node:child_process') const { join, resolve } = require('node:path') const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs') -const { verifyPackagedDaemonEntryBoots } = require('./scripts/verify-packaged-daemon-entry.cjs') +const { + assertPackagedDaemonEntryExists, + verifyPackagedDaemonEntryBoots +} = require('./scripts/verify-packaged-daemon-entry.cjs') const { createPackagedRuntimeNodeModuleResources, prunePackagedRuntimeNodeModules, @@ -141,8 +144,12 @@ module.exports = { if (context.arch === hostArchEnum || context.arch === 4) { verifyPackagedDaemonEntryBoots(resourcesDir) } else { + // Why: a cross-arch slice can't be booted by the host Node, but the + // unpacked entry must still exist — its absence is a layout regression + // regardless of arch, so only the boot is skipped, not the check. + assertPackagedDaemonEntryExists(resourcesDir) console.log( - `[verify-packaged-daemon-entry] skipped cross-arch slice (target ${context.arch}, host ${process.arch})` + `[verify-packaged-daemon-entry] skipped boot on cross-arch slice (target ${context.arch}, host ${process.arch})` ) } chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 850b8810f9b..cd46c8003fa 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -353,6 +353,15 @@ describe('electron-builder config', () => { const launcherPath = join(resourcesDir, 'bin', 'orca-ide') await mkdir(join(resourcesDir, 'bin'), { recursive: true }) await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true }) + // Why: afterPack now fails hard when the unpacked daemon entry is + // missing, so the fixture must carry one like a real package layout. + const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main') + await mkdir(unpackedMainDir, { recursive: true }) + await writeFile( + join(unpackedMainDir, 'daemon-entry.js'), + 'console.error("Usage: daemon-entry "); process.exit(1)\n', + 'utf8' + ) await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 }) await electronBuilderConfig.afterPack({ diff --git a/config/scripts/verify-packaged-daemon-entry.cjs b/config/scripts/verify-packaged-daemon-entry.cjs index 6ad91285ca3..8f5e802825c 100644 --- a/config/scripts/verify-packaged-daemon-entry.cjs +++ b/config/scripts/verify-packaged-daemon-entry.cjs @@ -2,6 +2,24 @@ const { existsSync } = require('node:fs') const { spawnSync } = require('node:child_process') const { join } = require('node:path') +// Why: `asarUnpack` in config/electron-builder.config.cjs lists +// out/main/daemon-entry.js on every platform, and the packaged daemon fork +// (src/main/daemon/daemon-init.ts) resolves exactly this unpacked path. A +// missing entry means the package layout regressed, so the check throws +// instead of skipping — a silent skip false-passed exactly the layout bug +// this gate exists to catch. +function assertPackagedDaemonEntryExists(resourcesDir) { + const entryPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js') + if (!existsSync(entryPath)) { + throw new Error( + `[verify-packaged-daemon-entry] missing unpacked daemon entry at ${entryPath} — ` + + `asarUnpack expects out/main/daemon-entry.js on every platform, so the packaged ` + + `daemon cannot be forked from this layout` + ) + } + return entryPath +} + // Why: v1.4.129-rc.1 shipped a terminal daemon that could not load (an electron // `require` leaked into its bundle) while every build check passed. This boots // the PACKAGED daemon-entry under plain Node against the asar-unpacked layout, @@ -14,13 +32,7 @@ const { join } = require('node:path') // /resources elsewhere). execPath defaults to the packaging Node. function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) { const execPath = options.execPath || process.execPath - const entryPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js') - if (!existsSync(entryPath)) { - // Why: some targets/layouts do not unpack here; skip rather than fail so - // the hook stays safe across platforms it has not verified. - console.log(`[verify-packaged-daemon-entry] skipped — no unpacked entry at ${entryPath}`) - return - } + const entryPath = assertPackagedDaemonEntryExists(resourcesDir) const result = spawnSync(execPath, [entryPath], { encoding: 'utf8', timeout: 10_000 }) if (result.error) { @@ -43,4 +55,4 @@ function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) { console.log('[verify-packaged-daemon-entry] OK — packaged daemon-entry loads under plain Node') } -module.exports = { verifyPackagedDaemonEntryBoots } +module.exports = { assertPackagedDaemonEntryExists, verifyPackagedDaemonEntryBoots } diff --git a/config/scripts/verify-packaged-daemon-entry.test.mjs b/config/scripts/verify-packaged-daemon-entry.test.mjs new file mode 100644 index 00000000000..8705953ba61 --- /dev/null +++ b/config/scripts/verify-packaged-daemon-entry.test.mjs @@ -0,0 +1,57 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + assertPackagedDaemonEntryExists, + verifyPackagedDaemonEntryBoots +} = require('./verify-packaged-daemon-entry.cjs') + +describe('verify-packaged-daemon-entry', () => { + let resourcesDir + + beforeEach(() => { + resourcesDir = mkdtempSync(join(tmpdir(), 'orca-daemon-entry-verify-')) + }) + + afterEach(() => { + rmSync(resourcesDir, { recursive: true, force: true }) + }) + + function writePackagedEntry(source) { + const entryDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main') + mkdirSync(entryDir, { recursive: true }) + writeFileSync(join(entryDir, 'daemon-entry.js'), source) + } + + // Why: a silent skip on a missing entry false-passed exactly the packaged + // layout regression this gate exists to catch (rc.1 daemon-load incident). + it('throws when the unpacked daemon entry is missing', () => { + expect(() => assertPackagedDaemonEntryExists(resourcesDir)).toThrow( + /missing unpacked daemon entry/ + ) + expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow( + /missing unpacked daemon entry/ + ) + }) + + it('passes when the packaged entry loads and reaches argv parsing', () => { + writePackagedEntry('console.error("Usage: daemon-entry "); process.exit(1)\n') + expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).not.toThrow() + }) + + it('fails when the packaged entry cannot resolve its module graph', () => { + writePackagedEntry('require("orca-module-that-does-not-exist")\n') + expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow( + /failed to load under plain Node/ + ) + }) + + it('fails when the packaged entry never reaches argv parsing', () => { + writePackagedEntry('process.exit(0)\n') + expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow(/did not reach argv parsing/) + }) +}) diff --git a/package.json b/package.json index 7112a4f39c7..a24b1d1d669 100644 --- a/package.json +++ b/package.json @@ -69,8 +69,8 @@ "build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir", "build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win", "build:icons": "bash resources/icon-source/generate.sh", - "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac", - "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", + "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac", + "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb", "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headless", "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", diff --git a/src/main/index.ts b/src/main/index.ts index 9e80bcc2944..8c2a5a4e160 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2047,7 +2047,9 @@ app.whenReady().then(async () => { // a random OS-assigned port — breaking deterministic mobile pairing/repro // scripts against the dev instance. Pin the first dev instance to 6769 so // ws://127.0.0.1:6769 is stable; a second dev instance still falls back via - // ws-transport's EADDRINUSE handler. + // ws-transport's EADDRINUSE handler. Note: once an instance has ever fallen + // back, the persisted fallback port is re-bound in preference to 6769 + // (STA-1511) until mobile-ws-fallback-port.json is removed from userData. const devWsPort = is.dev && !isE2E ? 6769 : undefined let serveOptions: ServeOptions | null = null try { diff --git a/src/main/ipc/parcel-watcher-process.test.ts b/src/main/ipc/parcel-watcher-process.test.ts index cb5caf2eaec..4df3b666fbb 100644 --- a/src/main/ipc/parcel-watcher-process.test.ts +++ b/src/main/ipc/parcel-watcher-process.test.ts @@ -103,8 +103,13 @@ describe('subscribeViaWatcherProcess', () => { it('resolves unsubscribe on the child ack', async () => { const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) const child = currentChild() - const id = ackSubscribe(child) + const id = ackSubscribe(child, 0) const subscription = await promise + // Why: hold a second subscription so this unsubscribe exercises the ack + // path instead of the last-subscriber idle kill. + const keepAlivePromise = subscribeViaWatcherProcess('/other', vi.fn(), {}) + ackSubscribe(child) + await keepAlivePromise const unsubPromise = subscription.unsubscribe() expect(child.sent.at(-1)).toEqual({ op: 'unsubscribe', id }) @@ -115,8 +120,11 @@ describe('subscribeViaWatcherProcess', () => { it('resolves a pending unsubscribe when the child dies', async () => { const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) const child = currentChild() - ackSubscribe(child) + ackSubscribe(child, 0) const subscription = await promise + const keepAlivePromise = subscribeViaWatcherProcess('/other', vi.fn(), {}) + ackSubscribe(child) + await keepAlivePromise const unsubPromise = subscription.unsubscribe() child.connected = false @@ -187,6 +195,80 @@ describe('subscribeViaWatcherProcess', () => { ) }) + it('kills the idle child after the last unsubscribe and respawns on the next subscribe', async () => { + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const first = currentChild() + ackSubscribe(first) + const subscription = await promise + + await expect(subscription.unsubscribe()).resolves.toBeUndefined() + expect(first.kill).toHaveBeenCalledTimes(1) + + // Why: the deliberate idle kill must not count as a crash — no respawn + // and no crash-fuse advance when the killed child's exit event lands. + first.connected = false + first.emit('exit', null, 'SIGTERM') + expect(forkMock).toHaveBeenCalledTimes(1) + + const respawnPromise = subscribeViaWatcherProcess('/repo2', vi.fn(), {}) + expect(forkMock).toHaveBeenCalledTimes(2) + const second = currentChild() + expect(second).not.toBe(first) + expect(second.sent[0]).toMatchObject({ op: 'subscribe', dir: '/repo2' }) + ackSubscribe(second) + await expect(respawnPromise).resolves.toMatchObject({ unsubscribe: expect.any(Function) }) + }) + + it('kills the idle child when the last pending subscribe fails', async () => { + const promise = subscribeViaWatcherProcess('/gone', vi.fn(), {}) + const first = currentChild() + first.emit('message', { + op: 'subscribe-failed', + id: first.sent[0].id, + message: 'Error opening directory' + }) + await expect(promise).rejects.toThrow('Error opening directory') + expect(first.kill).toHaveBeenCalledTimes(1) + + const respawnPromise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + expect(forkMock).toHaveBeenCalledTimes(2) + ackSubscribe(currentChild()) + await expect(respawnPromise).resolves.toMatchObject({ unsubscribe: expect.any(Function) }) + }) + + it('keeps the child alive when a subscribe fails while other subscriptions remain', async () => { + const firstPromise = subscribeViaWatcherProcess('/a', vi.fn(), {}) + const child = currentChild() + ackSubscribe(child) + await firstPromise + + const failingPromise = subscribeViaWatcherProcess('/gone', vi.fn(), {}) + child.emit('message', { op: 'subscribe-failed', id: child.sent.at(-1)!.id, message: 'boom' }) + await expect(failingPromise).rejects.toThrow('boom') + expect(child.kill).not.toHaveBeenCalled() + }) + + it('keeps the child alive while other subscriptions remain', async () => { + const firstPromise = subscribeViaWatcherProcess('/a', vi.fn(), {}) + const child = currentChild() + ackSubscribe(child) + const first = await firstPromise + const secondPromise = subscribeViaWatcherProcess('/b', vi.fn(), {}) + ackSubscribe(child) + const second = await secondPromise + + const firstUnsub = first.unsubscribe() + expect(child.kill).not.toHaveBeenCalled() + expect(child.sent.at(-1)).toMatchObject({ op: 'unsubscribe' }) + + // The last unsubscribe kills the idle child, which also completes the + // still-pending unsubscribe ack from the first record. + const secondUnsub = second.unsubscribe() + expect(child.kill).toHaveBeenCalledTimes(1) + await expect(firstUnsub).resolves.toBeUndefined() + await expect(secondUnsub).resolves.toBeUndefined() + }) + it('uses the in-process watcher under vitest', async () => { vi.stubEnv('VITEST', 'true') const unsubscribe = vi.fn().mockResolvedValue(undefined) diff --git a/src/main/ipc/parcel-watcher-process.ts b/src/main/ipc/parcel-watcher-process.ts index 25e52794a8d..58ad6097011 100644 --- a/src/main/ipc/parcel-watcher-process.ts +++ b/src/main/ipc/parcel-watcher-process.ts @@ -183,6 +183,9 @@ function handleChildMessage(message: WatcherToHostMessage): void { } else { record.callback(new Error(message.message), []) } + // Why: a failed last subscribe empties the records map without any + // unsubscribe ever being called — tear down the idle child here too. + killWatcherChildIfIdle() return } if (message.op === 'events') { @@ -243,6 +246,25 @@ function handleChildGone(proc: ChildProcess): void { } } +// Why: the last record gone leaves the child idle until app shutdown. Killing +// it releases native handles without running watcher.node's crash-prone async +// teardown (same rationale as disposeWatcherProcess) and reclaims the process; +// the next subscribe forks a fresh child. `child = null` before kill so the +// exit event neither counts as a crash nor respawns. Process death also +// completes any still-pending unsubscribe acks. +function killWatcherChildIfIdle(): void { + const proc = child + if (!proc || records.size > 0) { + return + } + child = null + for (const resolve of pendingUnsubscribes.values()) { + resolve() + } + pendingUnsubscribes.clear() + proc.kill() +} + function makeSubscription(record: SubscriptionRecord): WatcherProcessSubscription { return { unsubscribe: (): Promise => { @@ -253,6 +275,10 @@ function makeSubscription(record: SubscriptionRecord): WatcherProcessSubscriptio if (!proc?.connected) { return Promise.resolve() } + if (records.size === 0) { + killWatcherChildIfIdle() + return Promise.resolve() + } return new Promise((resolve) => { pendingUnsubscribes.set(record.id, resolve) sendToChild(proc, { op: 'unsubscribe', id: record.id }) diff --git a/src/main/runtime/rpc/ws-fallback-port-store.ts b/src/main/runtime/rpc/ws-fallback-port-store.ts index 93871cad631..beedcea8cf2 100644 --- a/src/main/runtime/rpc/ws-fallback-port-store.ts +++ b/src/main/runtime/rpc/ws-fallback-port-store.ts @@ -5,7 +5,9 @@ import { join } from 'node:path' // assigns a random port. Paired mobile devices store ws://ip:port endpoints, // so a port that changes on every restart permanently orphans those pairings // (STA-1511). Persist the assigned fallback so the same instance re-binds the -// same port next launch. +// same port next launch — the transport binds a persisted fallback BEFORE the +// preferred port, so pairings survive even when the preferred port is free +// again. const FALLBACK_PORT_FILE = 'mobile-ws-fallback-port.json' diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index a44469d1fcc..391e5e18284 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -454,6 +454,77 @@ describe('WebSocketTransport', () => { return port } + it('binds the persisted fallback port even when the preferred port is free', async () => { + // Why: regression for the STA-1511 follow-up — devices paired while the + // fallback port was active store ws://ip:. A later launch that + // finds the preferred port free must still bind the fallback, or those + // pairings go permanently dead until the user re-pairs. + const preferredPort = await reserveFreePort() + const fallbackPort = await reserveFreePort() + + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort + }) + transports.push(transport) + await transport.start() + expect(transport.resolvedPort).toBe(fallbackPort) + }) + + it('binds the preferred port when the persisted fallback is taken', async () => { + const fallbackHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) + transports.push(fallbackHolder) + await fallbackHolder.start() + const takenFallbackPort = fallbackHolder.resolvedPort + const preferredPort = await reserveFreePort() + + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort: takenFallbackPort + }) + transports.push(transport) + await transport.start() + expect(transport.resolvedPort).toBe(preferredPort) + }) + + it('falls through to the preferred port when the fallback bind fails with a non-EADDRINUSE error', async () => { + // Why: a persisted fallback can land in an OS-reserved range on a later + // launch (Windows Hyper-V excluded ports → EACCES). That must degrade to + // the preferred port instead of disabling the transport for the session. + const preferredPort = await reserveFreePort() + const fallbackPort = await reserveFreePort() + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort + }) + transports.push(transport) + const withListen = transport as unknown as { tryListen(port: number): Promise } + const realTryListen = withListen.tryListen.bind(transport) + withListen.tryListen = (port: number) => + port === fallbackPort + ? Promise.reject(Object.assign(new Error('listen EACCES'), { code: 'EACCES' })) + : realTryListen(port) + + await transport.start() + expect(transport.resolvedPort).toBe(preferredPort) + }) + + it('still throws when the preferred port fails with a non-EADDRINUSE error', async () => { + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: await reserveFreePort() + }) + transports.push(transport) + const withListen = transport as unknown as { tryListen(port: number): Promise } + withListen.tryListen = () => + Promise.reject(Object.assign(new Error('listen EACCES'), { code: 'EACCES' })) + + await expect(transport.start()).rejects.toThrow('listen EACCES') + }) + it('retries the persisted fallback port before an OS-assigned one', async () => { const holder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) transports.push(holder) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 7b1d8b8ead0..8b88c7a820e 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -48,10 +48,11 @@ export type WebSocketTransportOptions = { // Why: the pairing server can also serve the browser client, so users do // not need a second dev/static server once the web bundle is built. staticRoot?: string - // Why: when the preferred port is taken, an unstable OS-assigned port would - // change on every restart and permanently orphan existing mobile pairings - // (their stored ws://ip:port endpoint goes dead — STA-1511). Callers pass - // the previously assigned fallback port so it is retried first. + // Why: paired mobile devices store the full ws://ip:port endpoint. Once a + // fallback port has been assigned and persisted, devices paired while it was + // active point at it, so it must be bound FIRST on later launches — binding + // the (now free) preferred port instead would strand those pairings + // (STA-1511). Callers pass the previously assigned fallback port here. fallbackPort?: number } @@ -149,39 +150,40 @@ export class WebSocketTransport implements RpcTransport { return } - // Why: when the preferred port is occupied (e.g. another Orca instance is - // already running), fall back to an OS-assigned port so mobile pairing - // still works. The QR code reads resolvedPort after start, so it will - // advertise the correct port regardless. A persisted fallback port is - // retried before port 0 so paired devices keep a stable endpoint across - // restarts (STA-1511). - let port = this.port - try { - await this.tryListen(port) - return - } catch (error: unknown) { - if (!isEAddressInUse(error) || port === 0) { - throw error - } - } - if ( - this.fallbackPort !== undefined && - this.fallbackPort !== 0 && - this.fallbackPort !== this.port - ) { + // Why: a persisted fallback port is bound FIRST — devices paired while it + // was active store ws://ip: and would be permanently stranded if + // a later launch grabbed the (now free) preferred port instead (STA-1511). + // Without a persisted fallback the preferred port is tried first. On + // EADDRINUSE each candidate falls through to the next, ending at port 0 + // (OS-assigned) so mobile pairing still works when everything is taken. + // The QR code reads resolvedPort after start, so it always advertises the + // port that actually bound. + const persistedFallbackPort = + this.fallbackPort !== undefined && this.fallbackPort !== 0 && this.fallbackPort !== this.port + ? this.fallbackPort + : undefined + const candidatePorts = + persistedFallbackPort !== undefined ? [persistedFallbackPort, this.port] : [this.port] + for (const port of candidatePorts) { try { - console.warn( - `[ws-transport] Port ${port} is in use, retrying previous fallback port ${this.fallbackPort}` - ) - await this.tryListen(this.fallbackPort) + await this.tryListen(port) return } catch (error: unknown) { - if (!isEAddressInUse(error)) { + // Why: a persisted fallback can become unbindable for reasons beyond + // EADDRINUSE (e.g. Windows reserves dynamic-range ports for Hyper-V + // after a reboot → EACCES). Any fallback failure must degrade to the + // next candidate — aborting would disable the transport every launch + // while the store still names that port. Only preferred-port failures + // other than EADDRINUSE are fatal. + if (port !== persistedFallbackPort && (!isEAddressInUse(error) || port === 0)) { throw error } + console.warn( + `[ws-transport] Failed to bind port ${port} (${error instanceof Error ? error.message : String(error)}), trying next candidate` + ) } } - console.warn(`[ws-transport] Port ${port} is in use, falling back to OS-assigned port`) + console.warn('[ws-transport] All configured ports failed to bind, using an OS-assigned port') await this.tryListen(0) } diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 8e05aaeec02..f8b3c75c7cf 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -705,8 +705,10 @@ export class OrcaRuntimeRpcServer { port: this.wsPort, staticRoot: this.webClientRoot, // Why: keep the fallback port stable across restarts so paired - // devices' stored endpoints stay valid (STA-1511). wsPort 0 means - // the caller explicitly wants a random port (E2E) — don't pin it. + // devices' stored endpoints stay valid (STA-1511) — the transport + // binds a persisted fallback before the preferred port. wsPort 0 + // means the caller explicitly wants a random port (E2E) — don't + // pin it. ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}) }) this.wsTransport = wsTransport diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts index 8c50887a090..b0a3816688a 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts @@ -250,6 +250,43 @@ describe('attachRestoredTabConflictScan', () => { } }) + it('caps concurrent verification reads and drains the queue without dropping tabs', async () => { + // Why: a restored session with many dirty tabs must not fire one disk + // read per tab at once — on SSH/remote runtimes that competes with + // connection recovery. The cap is 3; the rest queue and all complete. + const pendingReads: ((value: { content: string; isBinary: boolean }) => void)[] = [] + mocks.readRuntimeFileContent.mockImplementation( + () => + new Promise<{ content: string; isBinary: boolean }>((resolve) => { + pendingReads.push(resolve) + }) + ) + const store = createEditorStore() + for (let i = 0; i < 6; i++) { + openRestoredDirtyTab(store, `/repo/file-${i}.ts`, 'original baseline') + } + + const detach = attachRestoredTabConflictScan(store) + try { + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(3) + + pendingReads.shift()!({ content: 'original baseline', isBinary: false }) + await vi.advanceTimersByTimeAsync(10) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(4) + + while (pendingReads.length > 0) { + pendingReads.shift()!({ content: 'original baseline', isBinary: false }) + await vi.advanceTimersByTimeAsync(10) + } + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(6) + for (const file of store.getState().openFiles) { + expect(file.pendingDiskBaselineVerification).toBeUndefined() + } + } finally { + detach() + } + }) + it('does not mark a tab that was saved while the read was in flight', async () => { let resolveRead: (value: { content: string; isBinary: boolean }) => void = () => {} mocks.readRuntimeFileContent.mockReturnValue( @@ -270,4 +307,55 @@ describe('attachRestoredTabConflictScan', () => { detach() } }) + + it('verifies a queued tab against its live baseline, not the stale queued snapshot', async () => { + // Why: ids are paths, so a tab re-baselined (saved / reopened) while it + // waits behind the concurrency cap must be compared against its current + // baseline. Queuing the OpenFile snapshot would mark the live tab using the + // old lastKnownDiskSignature. + const pending: { + filePath: string + resolve: (value: { content: string; isBinary: boolean }) => void + }[] = [] + mocks.readRuntimeFileContent.mockImplementation( + ({ filePath }: { filePath: string }) => + new Promise<{ content: string; isBinary: boolean }>((resolve) => { + pending.push({ filePath, resolve }) + }) + ) + const store = createEditorStore() + // Three reads fill the concurrency cap; the fourth waits in the queue. + for (let i = 0; i < 4; i++) { + openRestoredDirtyTab(store, `/repo/file-${i}.ts`, 'stale baseline') + } + + const detach = attachRestoredTabConflictScan(store) + try { + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(3) + + // The queued tab is re-baselined to match current disk while it waits. + store + .getState() + .setLastKnownDiskSignature('/repo/file-3.ts', getDiskBaselineSignature('current disk')) + + // Drain the in-flight reads; each frees a slot so the queued tab dispatches. + for (const filePath of ['/repo/file-0.ts', '/repo/file-1.ts', '/repo/file-2.ts']) { + pending + .find((p) => p.filePath === filePath)! + .resolve({ content: 'stale baseline', isBinary: false }) + await vi.advanceTimersByTimeAsync(10) + } + + pending + .find((p) => p.filePath === '/repo/file-3.ts')! + .resolve({ content: 'current disk', isBinary: false }) + await vi.advanceTimersByTimeAsync(10) + + const tab = store.getState().openFiles.find((f) => f.id === '/repo/file-3.ts') + expect(tab?.externalMutation).toBeUndefined() + expect(tab?.pendingDiskBaselineVerification).toBeUndefined() + } finally { + detach() + } + }) }) diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts index bc4029321f4..26d7e4db0ca 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts @@ -29,13 +29,23 @@ type AppStoreApi = Pick, 'getState' | 'subscribe'> const VERIFY_RETRY_MS = 2_000 const VERIFY_SLOW_RETRY_MS = 15_000 const VERIFY_FAST_ATTEMPTS = 30 +// Why: a session restored with many dirty tabs would otherwise fire one disk +// read per tab simultaneously — on SSH/remote runtimes that's N concurrent +// 15s-timeout RPCs competing with connection recovery at startup. Verifies +// beyond the cap queue and start as slots free up; none are dropped. +const MAX_CONCURRENT_VERIFY_READS = 3 export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { - // Why: dedupes in-flight verifications; the store's pending flag is the - // durable "needs verification" signal. + // Why: dedupes queued + in-flight verifications; the store's pending flag is + // the durable "needs verification" signal. const inFlightFileIds = new Set() const attemptsByFileId = new Map() const retryTimers = new Set>() + // Why: queue ids, not OpenFile snapshots. Ids are paths, so a snapshot can go + // stale (close/reopen or save at the same path) while it waits for a slot; + // the live file is re-read at dispatch instead. + const verifyQueue: string[] = [] + let activeVerifyReads = 0 let disposed = false // Why: distinguishes "file was deleted while the app was closed" (a @@ -144,6 +154,36 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { } } + const pumpVerifyQueue = (): void => { + while (!disposed && activeVerifyReads < MAX_CONCURRENT_VERIFY_READS && verifyQueue.length > 0) { + const fileId = verifyQueue.shift()! + // Why: re-read the live file when the slot opens. A queued id may now + // resolve to a reopened or saved same-path tab, so re-validate it against + // the current state before spending a disk read; skipping frees the dedupe + // marker so a later scan can re-queue it if it needs verification again. + const file = store.getState().openFiles.find((f) => f.id === fileId) + if ( + !file || + !file.pendingDiskBaselineVerification || + !file.isDirty || + !file.lastKnownDiskSignature || + file.externalMutation === 'changed' || + !canAutoSaveOpenFile(file) + ) { + inFlightFileIds.delete(fileId) + continue + } + activeVerifyReads += 1 + const onSettled = (): void => { + activeVerifyReads -= 1 + pumpVerifyQueue() + } + // Why: verify() never rejects by design, but a rejection here must still + // release the slot or the queue stalls permanently. + void verify(file).then(onSettled, onSettled) + } + } + const scan = (): void => { if (disposed) { return @@ -160,8 +200,9 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { continue } inFlightFileIds.add(file.id) - void verify(file) + verifyQueue.push(file.id) } + pumpVerifyQueue() } let previousOpenFiles = store.getState().openFiles @@ -182,5 +223,6 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { clearTimeout(timer) } retryTimers.clear() + verifyQueue.length = 0 } } From 9c111fd7aa8f5bd270b5e66313cbf3747d71e8e7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:20:07 -0700 Subject: [PATCH 08/11] mobile: per-host connection log screen with copy-diagnostics (#7984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rpc-client has always emitted a detailed connection lifecycle log (dials, timeouts, close codes, handshake steps, retries) via onLog, but only the pairing screen wired it up — for long-lived host connections everything went to console.log, invisible to users. Debugging reports like #7824/#6928 meant asking reporters for facts the app already knew. - connection-log-buffer: bounded (200/host) module-level ring buffer with referentially-stable snapshots for useSyncExternalStore; survives client swaps and provider remounts. - client-context: wire onLog for every shared host client. - connection-log screen: live per-host log (reuses the pairing ConnectionLog component), host picker, and a Copy Diagnostics button that bundles app/platform versions, endpoint (flagged if Tailscale), state, attempt count, last-connected, and the event log into one shareable blob. - troubleshoot: 'View connection log' entry point. Co-authored-by: Orca --- mobile/app/_layout.tsx | 1 + mobile/app/connection-log.tsx | 221 ++++++++++++++++++ mobile/app/troubleshoot.tsx | 12 + .../connection-diagnostics-report.test.ts | 55 +++++ .../connection-diagnostics-report.ts | 58 +++++ mobile/src/transport/client-context.tsx | 8 +- .../transport/connection-log-buffer.test.ts | 56 +++++ mobile/src/transport/connection-log-buffer.ts | 84 +++++++ 8 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 mobile/app/connection-log.tsx create mode 100644 mobile/src/diagnostics/connection-diagnostics-report.test.ts create mode 100644 mobile/src/diagnostics/connection-diagnostics-report.ts create mode 100644 mobile/src/transport/connection-log-buffer.test.ts create mode 100644 mobile/src/transport/connection-log-buffer.ts diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index f47ec591057..d1c5ebff14a 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -177,6 +177,7 @@ export default function RootLayout() { + diff --git a/mobile/app/connection-log.tsx b/mobile/app/connection-log.tsx new file mode 100644 index 00000000000..cc19497e080 --- /dev/null +++ b/mobile/app/connection-log.tsx @@ -0,0 +1,221 @@ +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react' +import { View, Text, StyleSheet, Pressable, Platform } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import * as Clipboard from 'expo-clipboard' +import Constants from 'expo-constants' +import { ChevronLeft, Copy, Check } from 'lucide-react-native' +import { colors, spacing, typography } from '../src/theme/mobile-theme' +import { ConnectionLog } from '../src/components/ConnectionLog' +import { loadHosts } from '../src/transport/host-store' +import { connectionLogStore } from '../src/transport/connection-log-buffer' +import { + useHostClient, + useLastConnectedAt, + useReconnectAttempt +} from '../src/transport/client-context' +import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report' +import type { ConnectionLogEntry, HostProfile } from '../src/transport/types' + +// Why: getSnapshot must be referentially stable when there's no data — +// a fresh [] per call would make useSyncExternalStore re-render forever. +const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = [] + +// Why: reading the log is most needed while a host is failing, so this +// screen also *acquires* the host client — opening it kicks a dial and the +// log fills live instead of showing a stale tail. +export default function ConnectionLogScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + const [hosts, setHosts] = useState([]) + const [selectedId, setSelectedId] = useState(null) + const [copied, setCopied] = useState(false) + + useEffect(() => { + let stale = false + void loadHosts().then((loaded) => { + if (stale) { + return + } + setHosts(loaded) + setSelectedId((prev) => prev ?? loaded[0]?.id ?? null) + }) + return () => { + stale = true + } + }, []) + + const selected = hosts.find((h) => h.id === selectedId) ?? null + const { state } = useHostClient(selected?.id) + const reconnectAttempts = useReconnectAttempt(selected?.id) + const lastConnectedAt = useLastConnectedAt(selected?.id) + + const subscribe = useCallback( + (listener: () => void) => + selectedId ? connectionLogStore.subscribe(selectedId, listener) : () => {}, + [selectedId] + ) + const getSnapshot = useCallback( + () => (selectedId ? connectionLogStore.get(selectedId) : EMPTY_ENTRIES), + [selectedId] + ) + const entries = useSyncExternalStore(subscribe, getSnapshot) + + const copyDiagnostics = useCallback(async () => { + if (!selected) { + return + } + const report = buildConnectionDiagnosticsReport({ + hostName: selected.name, + endpoint: selected.endpoint, + state, + reconnectAttempts, + lastConnectedAt, + platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(), + appVersion: Constants.expoConfig?.version ?? 'unknown', + entries + }) + await Clipboard.setStringAsync(report) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }, [selected, state, reconnectAttempts, lastConnectedAt, entries]) + + return ( + + + router.back()}> + + + Connection log + + + {hosts.length > 1 && ( + + {hosts.map((host) => ( + setSelectedId(host.id)} + > + + {host.name} + + + ))} + + )} + + {selected ? ( + <> + + + {state} + {reconnectAttempts > 0 ? ` · attempt ${reconnectAttempts}` : ''} + + void copyDiagnostics()}> + {copied ? ( + + ) : ( + + )} + {copied ? 'Copied' : 'Copy diagnostics'} + + + {entries.length > 0 ? ( + + ) : ( + + No connection events yet this session. Events appear as the app dials this host. + + )} + + ) : ( + No paired hosts. + )} + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + hostPicker: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + marginBottom: spacing.md + }, + hostChip: { + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.md, + borderRadius: 16, + backgroundColor: colors.bgRaised + }, + hostChipActive: { + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + hostChipText: { + fontSize: typography.metaSize, + color: colors.textSecondary, + maxWidth: 160 + }, + hostChipTextActive: { + color: colors.textPrimary, + fontWeight: '600' + }, + statusRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.sm + }, + statusText: { + fontSize: typography.metaSize, + color: colors.textSecondary + }, + copyButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs + 2, + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.md, + borderRadius: 8, + backgroundColor: colors.bgRaised + }, + copyButtonText: { + fontSize: typography.metaSize, + fontWeight: '600', + color: colors.textPrimary + }, + emptyText: { + fontSize: typography.metaSize, + color: colors.textMuted, + lineHeight: 18 + } +}) diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx index e9d7a34eef3..5ae53e0084c 100644 --- a/mobile/app/troubleshoot.tsx +++ b/mobile/app/troubleshoot.tsx @@ -16,6 +16,7 @@ import { ChevronUp, Activity, CheckCircle2, + ScrollText, XCircle, AlertTriangle } from 'lucide-react-native' @@ -213,6 +214,17 @@ export default function TroubleshootScreen() { + [ + styles.diagnosticButton, + pressed && styles.diagnosticButtonPressed + ]} + onPress={() => router.push('/connection-log')} + > + + View connection log + + {checks.length > 0 && ( {checks.map((check, i) => ( diff --git a/mobile/src/diagnostics/connection-diagnostics-report.test.ts b/mobile/src/diagnostics/connection-diagnostics-report.test.ts new file mode 100644 index 00000000000..d2326ffa022 --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-report.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { buildConnectionDiagnosticsReport } from './connection-diagnostics-report' + +const NOW = Date.UTC(2026, 6, 9, 22, 0, 0) + +describe('buildConnectionDiagnosticsReport', () => { + it('summarizes a failing Tailscale host with its log', () => { + const report = buildConnectionDiagnosticsReport({ + hostName: 'Host 1', + endpoint: 'ws://100.65.9.106:6768', + state: 'reconnecting', + reconnectAttempts: 12, + lastConnectedAt: NOW - 5 * 60_000, + platform: 'ios 26.5.1', + appVersion: '0.0.29', + entries: [ + { + id: 'log-1', + ts: NOW - 60_000, + level: 'error', + message: 'WebSocket connect timeout', + detail: 'No TCP/WS handshake within 12s — endpoint unreachable?' + } + ], + nowMs: NOW + }) + + expect(report).toContain('App: Orca Mobile 0.0.29 · ios 26.5.1') + expect(report).toContain('Endpoint: 100.65.9.106:6768 (Tailscale)') + expect(report).toContain('State: reconnecting (reconnect attempts: 12)') + expect(report).toContain('(5m 0s ago)') + expect(report).toContain( + '[error] WebSocket connect timeout — No TCP/WS handshake within 12s — endpoint unreachable?' + ) + }) + + it('marks never-connected sessions and empty logs', () => { + const report = buildConnectionDiagnosticsReport({ + hostName: 'Host 2', + endpoint: 'ws://192.168.1.50:6768', + state: 'connecting', + reconnectAttempts: 0, + lastConnectedAt: null, + platform: 'android 15', + appVersion: '0.0.29', + entries: [], + nowMs: NOW + }) + + expect(report).toContain('Endpoint: 192.168.1.50:6768') + expect(report).not.toContain('(Tailscale)') + expect(report).toContain('Last connected: never this session') + expect(report).toContain('No connection events recorded this session.') + }) +}) diff --git a/mobile/src/diagnostics/connection-diagnostics-report.ts b/mobile/src/diagnostics/connection-diagnostics-report.ts new file mode 100644 index 00000000000..c762c69799e --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-report.ts @@ -0,0 +1,58 @@ +import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' +import type { ConnectionLogEntry, ConnectionState } from '../transport/types' +import { formatEndpoint } from './host-reachability' + +// Why: one shareable text blob answering everything we historically had to +// ask reporters one message at a time (endpoint type, state, attempt count, +// last-connected, versions, and the reconnect lifecycle log). +export function buildConnectionDiagnosticsReport(args: { + hostName: string + endpoint: string + state: ConnectionState + reconnectAttempts: number + lastConnectedAt: number | null + platform: string + appVersion: string + entries: readonly ConnectionLogEntry[] + nowMs?: number +}): string { + const now = args.nowMs ?? Date.now() + const lines: string[] = [] + lines.push('Orca Mobile connection diagnostics') + lines.push(`Generated: ${new Date(now).toISOString()}`) + lines.push(`App: Orca Mobile ${args.appVersion} · ${args.platform}`) + lines.push(`Host: ${args.hostName}`) + lines.push( + `Endpoint: ${formatEndpoint(args.endpoint)}${isTailscaleEndpoint(args.endpoint) ? ' (Tailscale)' : ''}` + ) + lines.push(`State: ${args.state} (reconnect attempts: ${args.reconnectAttempts})`) + lines.push( + args.lastConnectedAt == null + ? 'Last connected: never this session' + : `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)` + ) + lines.push('') + if (args.entries.length === 0) { + lines.push('No connection events recorded this session.') + } else { + lines.push(`Connection log (${args.entries.length} events, oldest first):`) + for (const entry of args.entries) { + const detail = entry.detail ? ` — ${entry.detail}` : '' + lines.push(`${new Date(entry.ts).toISOString()} [${entry.level}] ${entry.message}${detail}`) + } + } + return lines.join('\n') +} + +function formatAgo(ms: number): string { + const seconds = Math.max(0, Math.round(ms / 1000)) + if (seconds < 60) { + return `${seconds}s` + } + const minutes = Math.floor(seconds / 60) + if (minutes < 60) { + return `${minutes}m ${seconds % 60}s` + } + const hours = Math.floor(minutes / 60) + return `${hours}h ${minutes % 60}m` +} diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 2b8461c582f..41f1b5092e8 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -20,6 +20,7 @@ import { type ReactNode } from 'react' import { connect, type RpcClient } from './rpc-client' +import { connectionLogStore } from './connection-log-buffer' import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers' import { loadHosts } from './host-store' import type { ConnectionState, HostProfile } from './types' @@ -150,7 +151,12 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { let client: RpcClient try { - client = connect(host.endpoint, host.deviceToken, host.publicKeyB64) + client = connect(host.endpoint, host.deviceToken, host.publicKeyB64, { + // Why: retain reconnect lifecycle events for the Connection Log + // screen — without this the reasons a host is stuck live only in + // console.log, which users can't see or share. + onLog: (entry) => connectionLogStore.append(hostId, entry) + }) } catch { // Why: connect() can throw synchronously if the public key is // malformed or the endpoint URL is invalid. Notify so the UI diff --git a/mobile/src/transport/connection-log-buffer.test.ts b/mobile/src/transport/connection-log-buffer.test.ts new file mode 100644 index 00000000000..e7ce662534b --- /dev/null +++ b/mobile/src/transport/connection-log-buffer.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { createConnectionLogStore } from './connection-log-buffer' +import type { ConnectionLogEntry } from './types' + +function entry(id: number): ConnectionLogEntry { + return { id: `log-${id}`, ts: 1_000 + id, level: 'info', message: `event ${id}` } +} + +describe('connection log buffer', () => { + it('keeps entries per host without cross-talk', () => { + const store = createConnectionLogStore() + store.append('host-a', entry(1)) + store.append('host-b', entry(2)) + + expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1']) + expect(store.get('host-b').map((e) => e.id)).toEqual(['log-2']) + }) + + it('drops the oldest entries past the cap', () => { + const store = createConnectionLogStore(3) + for (let i = 1; i <= 5; i++) { + store.append('host-a', entry(i)) + } + + expect(store.get('host-a').map((e) => e.id)).toEqual(['log-3', 'log-4', 'log-5']) + }) + + it('returns a stable snapshot reference until the next append', () => { + const store = createConnectionLogStore() + store.append('host-a', entry(1)) + + const first = store.get('host-a') + expect(store.get('host-a')).toBe(first) + + store.append('host-a', entry(2)) + expect(store.get('host-a')).not.toBe(first) + // Empty hosts must also be referentially stable (useSyncExternalStore). + expect(store.get('host-b')).toBe(store.get('host-b')) + }) + + it('notifies only the host being appended to and stops after unsubscribe', () => { + const store = createConnectionLogStore() + const onA = vi.fn() + const onB = vi.fn() + const unsubA = store.subscribe('host-a', onA) + store.subscribe('host-b', onB) + + store.append('host-a', entry(1)) + expect(onA).toHaveBeenCalledTimes(1) + expect(onB).not.toHaveBeenCalled() + + unsubA() + store.append('host-a', entry(2)) + expect(onA).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/transport/connection-log-buffer.ts b/mobile/src/transport/connection-log-buffer.ts new file mode 100644 index 00000000000..1b223ad8e1f --- /dev/null +++ b/mobile/src/transport/connection-log-buffer.ts @@ -0,0 +1,84 @@ +import type { ConnectionLogEntry } from './types' + +// Why: the rpc-client's onLog entries were only wired during pairing; for +// long-lived host connections everything went to console.log, invisible to +// users. This buffer retains the recent lifecycle events per host so a +// "Connection log" screen (and copy-diagnostics) can show why a connection +// is stuck without a debug build. Module-level so the log survives client +// swaps (forceReconnect) and provider remounts (hot reload); bounded so an +// all-night reconnect loop can't grow memory unbounded. +const MAX_ENTRIES_PER_HOST = 200 + +export type ConnectionLogStore = { + append: (hostId: string, entry: ConnectionLogEntry) => void + get: (hostId: string) => readonly ConnectionLogEntry[] + subscribe: (hostId: string, listener: () => void) => () => void +} + +export function createConnectionLogStore( + maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST +): ConnectionLogStore { + const entriesByHost = new Map() + const listenersByHost = new Map void>>() + // Why: useSyncExternalStore compares snapshots by reference — getSnapshot + // must return the SAME array until the data actually changes, or React + // loops re-rendering. Cache per host; invalidate on append. + const snapshotByHost = new Map() + const EMPTY: readonly ConnectionLogEntry[] = [] + + return { + append(hostId, entry) { + let entries = entriesByHost.get(hostId) + if (!entries) { + entries = [] + entriesByHost.set(hostId, entries) + } + entries.push(entry) + if (entries.length > maxEntriesPerHost) { + entries.splice(0, entries.length - maxEntriesPerHost) + } + snapshotByHost.delete(hostId) + const listeners = listenersByHost.get(hostId) + if (listeners) { + for (const listener of listeners) { + listener() + } + } + }, + + get(hostId) { + const cached = snapshotByHost.get(hostId) + if (cached) { + return cached + } + const entries = entriesByHost.get(hostId) + if (!entries || entries.length === 0) { + return EMPTY + } + const snapshot = Object.freeze([...entries]) + snapshotByHost.set(hostId, snapshot) + return snapshot + }, + + subscribe(hostId, listener) { + let listeners = listenersByHost.get(hostId) + if (!listeners) { + listeners = new Set() + listenersByHost.set(hostId, listeners) + } + listeners.add(listener) + return () => { + const set = listenersByHost.get(hostId) + if (!set) { + return + } + set.delete(listener) + if (set.size === 0) { + listenersByHost.delete(hostId) + } + } + } + } +} + +export const connectionLogStore = createConnectionLogStore() From e7ee15f4b24b62a540cb8478dd590c39b5f9a34a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:24:20 -0700 Subject: [PATCH 09/11] Improve workspace cleanup list (#7053) * Improve workspace cleanup list Co-authored-by: Orca * Address workspace cleanup review feedback Co-authored-by: Orca * Fix workspace cleanup perf findings Co-authored-by: Orca * Avoid stale cleanup progress cache Co-authored-by: Orca * Complete workspace cleanup perf fixes Co-authored-by: Orca * Fix worktree list option forwarding Co-authored-by: Orca * Address workspace cleanup review nits Co-authored-by: Orca * Fix workspace cleanup removal review findings - Fail a queued removal that now needs a force the user never approved (confirm-time approvedCandidates snapshot compared in preflight) - Reword the 120s removal timeout to say removal continues in background - Wire suppressPreservedBranchToast into cleanup removals - Stop statting a repo after the first activity metadata timeout - Document the WSL 9P best-effort stat gap; drop unused locale key Co-authored-by: Orca * Split workspace-cleanup slice test to satisfy max-lines Rebasing onto latest main pushed the combined store-slice test over the 800-line cap. Extract shared fixtures into a test harness and split the suite into scan-progress and removal-preflight files instead of adding a forbidden max-lines suppression. --------- Co-authored-by: Orca Co-authored-by: Brennan Benson --- .../ipc/workspace-cleanup-activity.test.ts | 163 ++++ src/main/ipc/workspace-cleanup-activity.ts | 126 +++ src/main/ipc/workspace-cleanup-scan.ts | 97 +- src/main/ipc/workspace-cleanup.test.ts | 232 ++++- src/main/repo-worktrees.test.ts | 22 +- src/main/repo-worktrees.ts | 8 +- src/relay/git-handler.test.ts | 24 +- src/relay/git-handler.ts | 12 +- .../src/components/sidebar/WorktreeCard.tsx | 10 +- .../WorkspaceCleanupDialog.tsx | 832 +++++++----------- ...rkspace-cleanup-background-removal.test.ts | 389 ++++++++ .../workspace-cleanup-background-removal.ts | 216 +++++ .../workspace-cleanup-candidate-labels.ts | 176 ++++ ...rkspace-cleanup-candidate-row-data.test.ts | 59 ++ .../workspace-cleanup-candidate-row-data.ts | 204 +++++ ...orkspace-cleanup-candidate-row-details.tsx | 107 +++ .../workspace-cleanup-candidate-row.test.tsx | 65 ++ .../workspace-cleanup-candidate-row.tsx | 342 +++++++ ...rkspace-cleanup-removal-candidates.test.ts | 16 + .../workspace-cleanup-removal-candidates.ts | 13 + .../workspace-cleanup-status-pill.tsx | 26 + src/renderer/src/i18n/locales/en.json | 62 +- src/renderer/src/i18n/locales/es.json | 62 +- src/renderer/src/i18n/locales/ja.json | 62 +- src/renderer/src/i18n/locales/ko.json | 62 +- src/renderer/src/i18n/locales/zh.json | 62 +- .../src/store/slices/store-cascades.test.ts | 69 ++ ...orkspace-cleanup-removal-preflight.test.ts | 431 +++++++++ .../workspace-cleanup-scan-progress.test.ts | 598 +++++++++++++ .../workspace-cleanup-slice-test-harness.ts | 110 +++ .../store/slices/workspace-cleanup.test.ts | 726 --------------- .../src/store/slices/workspace-cleanup.ts | 203 ++++- .../src/store/slices/worktree-helpers.ts | 4 +- src/renderer/src/store/slices/worktrees.ts | 28 +- 34 files changed, 4263 insertions(+), 1355 deletions(-) create mode 100644 src/main/ipc/workspace-cleanup-activity.test.ts create mode 100644 src/main/ipc/workspace-cleanup-activity.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-details.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.ts create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx create mode 100644 src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts create mode 100644 src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts create mode 100644 src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts delete mode 100644 src/renderer/src/store/slices/workspace-cleanup.test.ts diff --git a/src/main/ipc/workspace-cleanup-activity.test.ts b/src/main/ipc/workspace-cleanup-activity.test.ts new file mode 100644 index 00000000000..72f20291f4a --- /dev/null +++ b/src/main/ipc/workspace-cleanup-activity.test.ts @@ -0,0 +1,163 @@ +import path from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { Repo, Worktree } from '../../shared/types' +import { resolveWorkspaceCleanupActivityWorktree } from './workspace-cleanup-activity' + +const REPO: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 1 +} + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'repo-1::/repo-feature', + repoId: 'repo-1', + path: '/repo-feature', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false, + displayName: 'feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + workspaceStatus: 'in-progress', + ...overrides + } +} + +describe('resolveWorkspaceCleanupActivityWorktree', () => { + it('uses local worktree filesystem metadata when persisted activity is missing', async () => { + const statPath = vi.fn(async (targetPath: string) => ({ + mtimeMs: targetPath.endsWith('.git') ? 20_000 : 10_000 + })) + + const worktree = await resolveWorkspaceCleanupActivityWorktree(REPO, makeWorktree(), statPath) + + expect(statPath).toHaveBeenCalledWith('/repo-feature') + expect(statPath).toHaveBeenCalledWith(path.join('/repo-feature', '.git')) + expect(worktree.lastActivityAt).toBe(20_000) + }) + + it('uses linked worktree gitdir metadata when the .git pointer is stale', async () => { + const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature') + const gitDirHeadPath = path.join(gitDirPath, 'HEAD') + const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const statPath = vi.fn(async (targetPath: string) => { + const mtimes: Record = { + '/repo-feature': 10_000, + [path.join('/repo-feature', '.git')]: 20_000, + [gitDirPath]: 50_000, + [gitDirHeadPath]: 60_000, + [gitDirLogsHeadPath]: 70_000 + } + return { mtimeMs: mtimes[targetPath] ?? 0 } + }) + const readTextFile = vi.fn(async () => `gitdir: ${gitDirPath}\n`) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree(), + statPath, + readTextFile + ) + + expect(readTextFile).toHaveBeenCalledWith(path.join('/repo-feature', '.git')) + expect(statPath).toHaveBeenCalledWith(gitDirPath) + expect(statPath).toHaveBeenCalledWith(gitDirHeadPath) + expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(worktree.lastActivityAt).toBe(70_000) + }) + + it('resolves relative linked worktree gitdir pointers from the worktree path', async () => { + const gitDirPath = path.resolve('/repo-feature', '.repo/gitdir') + const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const statPath = vi.fn(async (targetPath: string) => ({ + mtimeMs: targetPath === gitDirLogsHeadPath ? 40_000 : 10_000 + })) + const readTextFile = vi.fn(async () => 'gitdir: .repo/gitdir\n') + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree(), + statPath, + readTextFile + ) + + expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(worktree.lastActivityAt).toBe(40_000) + }) + + it('converts WSL linked worktree gitdir pointers before reading metadata', async () => { + const worktreePath = String.raw`\\wsl.localhost\Ubuntu\home\me\repo-feature` + const gitDirPath = String.raw`\\wsl.localhost\Ubuntu\home\me\repo\.git\worktrees\repo-feature` + const gitDirHeadPath = path.join(gitDirPath, 'HEAD') + const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const statPath = vi.fn(async (targetPath: string) => { + const mtimes: Record = { + [worktreePath]: 10_000, + [path.join(worktreePath, '.git')]: 20_000, + [gitDirPath]: 50_000, + [gitDirHeadPath]: 60_000, + [gitDirLogsHeadPath]: 70_000 + } + return { mtimeMs: mtimes[targetPath] ?? 0 } + }) + const readTextFile = vi.fn(async () => 'gitdir: /home/me/repo/.git/worktrees/repo-feature\n') + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree({ path: worktreePath }), + statPath, + readTextFile + ) + + expect(readTextFile).toHaveBeenCalledWith(path.join(worktreePath, '.git')) + expect(statPath).toHaveBeenCalledWith(gitDirPath) + expect(statPath).toHaveBeenCalledWith(gitDirHeadPath) + expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(statPath).not.toHaveBeenCalledWith('/home/me/repo/.git/worktrees/repo-feature') + expect(worktree.lastActivityAt).toBe(70_000) + }) + + it('keeps persisted activity when it is newer than local metadata', async () => { + const statPath = vi.fn(async () => ({ mtimeMs: 10_000 })) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree({ lastActivityAt: 30_000 }), + statPath + ) + + expect(worktree.lastActivityAt).toBe(30_000) + }) + + it('does not stat remote worktree paths', async () => { + const statPath = vi.fn(async () => ({ mtimeMs: 20_000 })) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + { ...REPO, connectionId: 'ssh-1' }, + makeWorktree({ createdAt: 10_000 }), + statPath + ) + + expect(statPath).not.toHaveBeenCalled() + expect(worktree.lastActivityAt).toBe(10_000) + }) +}) diff --git a/src/main/ipc/workspace-cleanup-activity.ts b/src/main/ipc/workspace-cleanup-activity.ts new file mode 100644 index 00000000000..9026b4464d9 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-activity.ts @@ -0,0 +1,126 @@ +import { lstat, readFile } from 'node:fs/promises' +import path from 'node:path' +import type { Repo, Worktree } from '../../shared/types' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' + +type StatPath = (targetPath: string) => Promise<{ mtimeMs: number }> +type ReadTextFile = (targetPath: string) => Promise + +export function getPersistedWorkspaceCleanupActivityAt( + worktree: Pick +): number { + const persistedActivityAt = Number.isFinite(worktree.lastActivityAt) ? worktree.lastActivityAt : 0 + const createdAt = Number.isFinite(worktree.createdAt) ? (worktree.createdAt ?? 0) : 0 + return Math.max(persistedActivityAt, createdAt) +} + +export function resolvePersistedWorkspaceCleanupActivityWorktree(worktree: Worktree): Worktree { + const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) + if (persistedActivityAt <= worktree.lastActivityAt) { + return worktree + } + return { ...worktree, lastActivityAt: persistedActivityAt } +} + +export async function resolveWorkspaceCleanupActivityWorktree( + repo: Repo, + worktree: Worktree, + statPath: StatPath = statLocalPath, + readTextFile: ReadTextFile = readLocalTextFile +): Promise { + const activityAt = await resolveWorkspaceCleanupActivityAt(repo, worktree, statPath, readTextFile) + if (activityAt <= worktree.lastActivityAt) { + return worktree + } + return { ...worktree, lastActivityAt: activityAt } +} + +async function statLocalPath(targetPath: string): Promise<{ mtimeMs: number }> { + const stats = await lstat(targetPath) + return { mtimeMs: Number(stats.mtimeMs) } +} + +async function readLocalTextFile(targetPath: string): Promise { + return readFile(targetPath, 'utf8') +} + +async function resolveWorkspaceCleanupActivityAt( + repo: Repo, + worktree: Worktree, + statPath: StatPath, + readTextFile: ReadTextFile +): Promise { + const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) + if (repo.connectionId) { + return persistedActivityAt + } + + const filesystemActivityAt = await getNewestLocalWorktreeStatMtime( + worktree.path, + statPath, + readTextFile + ) + return Math.max(persistedActivityAt, filesystemActivityAt) +} + +// Why: best-effort only. Win32 stat over \\wsl.localhost (9P) can falsely +// report ENOENT (see wslUncDirectoryExists), so a failed stat degrades to the +// persisted activity timestamp instead of blocking or mislabeling the row. +async function getNewestLocalWorktreeStatMtime( + worktreePath: string, + statPath: StatPath, + readTextFile: ReadTextFile +): Promise { + const gitPath = path.join(worktreePath, '.git') + const gitDirPath = await readLocalWorktreeGitDir(worktreePath, gitPath, readTextFile) + const gitDirMtimePromises = gitDirPath + ? [ + readMtime(gitDirPath, statPath), + readMtime(path.join(gitDirPath, 'HEAD'), statPath), + readMtime(path.join(gitDirPath, 'logs', 'HEAD'), statPath) + ] + : [] + const mtimes = await Promise.all([ + readMtime(worktreePath, statPath), + readMtime(gitPath, statPath), + ...gitDirMtimePromises + ]) + return Math.max(0, ...mtimes) +} + +async function readLocalWorktreeGitDir( + worktreePath: string, + gitPath: string, + readTextFile: ReadTextFile +): Promise { + try { + const contents = await readTextFile(gitPath) + const match = /^gitdir:\s*(.+)\s*$/im.exec(contents) + if (!match) { + return null + } + const gitDir = match[1]?.trim() + if (!gitDir) { + return null + } + // Why: linked worktrees keep mutable git state outside the worktree; the + // pointer file mtime alone can miss recent external commits. + const wslWorktree = parseWslUncPath(worktreePath) + if (wslWorktree && gitDir.startsWith('/')) { + return toWindowsWslPath(gitDir, wslWorktree.distro) + } + return path.isAbsolute(gitDir) ? gitDir : path.resolve(worktreePath, gitDir) + } catch { + return null + } +} + +async function readMtime(targetPath: string, statPath: StatPath): Promise { + try { + const stats = await statPath(targetPath) + return Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : 0 + } catch { + return 0 + } +} diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index 33866e6cd73..a9b6ff704ca 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -3,7 +3,7 @@ import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import type { IGitProvider } from '../providers/types' import { isFolderRepo } from '../../shared/repo-kind' -import type { GitWorktreeInfo, Repo } from '../../shared/types' +import type { GitWorktreeInfo, Repo, Worktree } from '../../shared/types' import { mergeWorktree } from './worktree-logic' import { splitWorktreeId } from '../../shared/worktree-id' import type { @@ -13,6 +13,11 @@ import type { WorkspaceCleanupScanProgress, WorkspaceCleanupScanResult } from '../../shared/workspace-cleanup' +import { + getPersistedWorkspaceCleanupActivityAt, + resolvePersistedWorkspaceCleanupActivityWorktree, + resolveWorkspaceCleanupActivityWorktree +} from './workspace-cleanup-activity' import { buildWorkspaceCleanupCandidate, buildWorkspaceCleanupCandidateFromError, @@ -124,48 +129,92 @@ async function scanRepoWorkspaces( return { scannedAt, candidates, errors: [] } } - const worktrees = gitWorktrees - .map((gitWorktree) => { - const worktreeId = `${repo.id}::${gitWorktree.path}` - const meta = store.getWorktreeMeta(worktreeId) - return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) - }) - .filter((worktree) => { - if (targetWorktreeId) { - return worktree.id === targetWorktreeId - } - return ( - !repoIsFolder && - !worktree.isMainWorktree && - isWorkspaceInactiveForCleanup(worktree, scannedAt) + const mergedWorktrees = gitWorktrees.map((gitWorktree) => { + const worktreeId = `${repo.id}::${gitWorktree.path}` + const meta = store.getWorktreeMeta(worktreeId) + return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + }) + const candidateWorktrees = targetWorktreeId + ? mergedWorktrees.filter((worktree) => worktree.id === targetWorktreeId) + : mergedWorktrees.filter((worktree) => + shouldResolveBroadWorkspaceCleanupActivity(repoIsFolder, worktree, scannedAt) ) - }) - - onWorktreesDiscovered?.(worktrees.length) - - const candidates = await mapWorkspaceCleanupWithConcurrency( - worktrees, + // Why: fs stat has no cancellation, so on a hung network/WSL mount every + // timed-out row would abandon more threadpool work. After the first timeout, + // stop statting this repo and use persisted activity only. + let activityStatsUnavailable = false + const candidatesWithSkipped = await mapWorkspaceCleanupWithConcurrency( + candidateWorktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { + // Why: externally-created worktrees can miss Orca activity stamps; local + // filesystem metadata is a conservative guard before suggesting deletion. + const worktreeWithActivity = activityStatsUnavailable + ? resolvePersistedWorkspaceCleanupActivityWorktree(worktree) + : await resolveCleanupActivityWithTimeout(repo, worktree, () => { + activityStatsUnavailable = true + }) + if (!targetWorktreeId && !isWorkspaceInactiveForCleanup(worktreeWithActivity, scannedAt)) { + return null + } + onWorktreesDiscovered?.(1) const candidate = await buildWorkspaceCleanupCandidate({ repo, - worktree, + worktree: worktreeWithActivity, scannedAt, provider, - skipGit: skipGitWorktreeIds.has(worktree.id), + skipGit: skipGitWorktreeIds.has(worktreeWithActivity.id), forceGitCheck: Boolean(targetWorktreeId) }).catch((error) => { console.error('Workspace cleanup candidate scan failed', error) - return buildWorkspaceCleanupCandidateFromError(repo, worktree, scannedAt) + return buildWorkspaceCleanupCandidateFromError(repo, worktreeWithActivity, scannedAt) }) onCandidateScanned?.(candidate) return candidate } ) + const candidates = candidatesWithSkipped.filter( + (candidate): candidate is WorkspaceCleanupCandidate => candidate !== null + ) return { scannedAt, candidates, errors } } +async function resolveCleanupActivityWithTimeout( + repo: Repo, + worktree: Worktree, + onActivityStatsUnavailable: () => void +): Promise { + try { + return await withWorkspaceCleanupTimeout( + () => resolveWorkspaceCleanupActivityWorktree(repo, worktree), + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + 'Timed out reading worktree activity.' + ) + } catch (error) { + onActivityStatsUnavailable() + console.warn('Workspace cleanup activity scan failed', error) + return resolvePersistedWorkspaceCleanupActivityWorktree(worktree) + } +} + +function shouldResolveBroadWorkspaceCleanupActivity( + repoIsFolder: boolean, + worktree: Worktree, + scannedAt: number +): boolean { + if (repoIsFolder || worktree.isMainWorktree) { + return false + } + return isWorkspaceInactiveForCleanup( + { + isArchived: worktree.isArchived, + lastActivityAt: getPersistedWorkspaceCleanupActivityAt(worktree) + }, + scannedAt + ) +} + async function listCleanupGitWorktrees( repo: Repo, repoIsFolder: boolean diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index af084713a0b..1d02e59db29 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -1,3 +1,4 @@ +import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ipcMain } from 'electron' import type { Store } from '../persistence' @@ -11,6 +12,8 @@ import type { import type { WorkspaceCleanupScanProgress } from '../../shared/workspace-cleanup' const { + lstatMock, + readFileMock, listRepoWorktreesMock, getStatusMock, gitExecFileAsyncMock, @@ -18,6 +21,8 @@ const { getSshPtyProviderMock, listRegisteredPtysMock } = vi.hoisted(() => ({ + lstatMock: vi.fn(), + readFileMock: vi.fn(), listRepoWorktreesMock: vi.fn(), getStatusMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), @@ -26,6 +31,11 @@ const { listRegisteredPtysMock: vi.fn() })) +vi.mock('node:fs/promises', () => ({ + lstat: lstatMock, + readFile: readFileMock +})) + vi.mock('electron', () => ({ ipcMain: { handle: vi.fn(), @@ -92,6 +102,22 @@ function buildWorktreeIds(repoId: string, count: number): string[] { return worktreeIds } +function makeWorktreeMeta(overrides: Partial = {}): WorktreeMeta { + return { + displayName: 'Feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW, + ...overrides + } +} + function makeStore( options: { baseRef?: string @@ -123,6 +149,8 @@ describe('workspace cleanup scan', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(NOW) + lstatMock.mockReset() + readFileMock.mockReset() listRepoWorktreesMock.mockReset() getStatusMock.mockReset() gitExecFileAsyncMock.mockReset() @@ -130,6 +158,8 @@ describe('workspace cleanup scan', () => { getSshPtyProviderMock.mockReset() listRegisteredPtysMock.mockReset() listRegisteredPtysMock.mockReturnValue([]) + lstatMock.mockResolvedValue({ mtimeMs: 0 }) + readFileMock.mockRejectedValue(new Error('not a gitdir pointer')) vi.mocked(ipcMain.handle).mockReset() vi.mocked(ipcMain.removeHandler).mockReset() listRepoWorktreesMock.mockResolvedValue([ @@ -199,7 +229,7 @@ describe('workspace cleanup scan', () => { expect(progress[0]).toMatchObject({ scanId: 'scan-1', scannedWorktreeCount: 0, - totalWorktreeCount: 2, + totalWorktreeCount: 1, candidates: [] }) const candidateProgress = progress.filter( @@ -222,6 +252,55 @@ describe('workspace cleanup scan', () => { }) }) + it('emits ready rows while another activity metadata read is stalled', async () => { + listRepoWorktreesMock.mockResolvedValue([ + { + path: '/repo-feature-a', + head: 'abc123', + branch: 'refs/heads/feature-a', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-feature-b', + head: 'def456', + branch: 'refs/heads/feature-b', + isBare: false, + isMainWorktree: false + } + ]) + lstatMock.mockImplementation((targetPath: string) => { + if (targetPath.startsWith('/repo-feature-a')) { + return new Promise(() => undefined) + } + return Promise.resolve({ mtimeMs: 0 }) + }) + const progress: WorkspaceCleanupScanProgress[] = [] + + const scanPromise = scanWorkspaceCleanup( + makeStore(), + { scanId: 'scan-1' }, + { onProgress: (event) => progress.push(event) } + ) + + await vi.waitFor(() => { + expect( + progress.some((event) => event.candidates[0]?.worktreeId === 'repo-1::/repo-feature-b') + ).toBe(true) + }) + expect(progress.at(-1)).toMatchObject({ + scannedWorktreeCount: 1, + totalWorktreeCount: 1 + }) + + await vi.advanceTimersByTimeAsync(8_000) + const result = await scanPromise + + expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual( + expect.arrayContaining(['repo-1::/repo-feature-a', 'repo-1::/repo-feature-b']) + ) + }) + it('keeps raw scan errors out of renderer-facing results', async () => { listRepoWorktreesMock.mockRejectedValue(new Error('fatal: path /Users/alice/private failed')) @@ -273,18 +352,10 @@ describe('workspace cleanup scan', () => { it('uses direct metadata lookup for focused disconnected remote preflight', async () => { const targetWorktreeId = 'repo-1::/remote/repo-feature' - const targetMeta: WorktreeMeta = { + const targetMeta = makeWorktreeMeta({ displayName: 'Remote Feature', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000 - } + }) const getWorktreeMeta = vi.fn((worktreeId: string) => worktreeId === targetWorktreeId ? targetMeta : undefined ) @@ -313,6 +384,35 @@ describe('workspace cleanup scan', () => { }) }) + it('stats only the requested worktree during focused local preflight scans', async () => { + listRepoWorktreesMock.mockResolvedValue([ + { + path: '/repo-feature-a', + head: 'abc123', + branch: 'refs/heads/feature-a', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-feature-b', + head: 'def456', + branch: 'refs/heads/feature-b', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await scanWorkspaceCleanup(makeStore(), { + worktreeId: 'repo-1::/repo-feature-b' + }) + + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]?.worktreeId).toBe('repo-1::/repo-feature-b') + expect(lstatMock).toHaveBeenCalledTimes(2) + expect(lstatMock).toHaveBeenCalledWith('/repo-feature-b') + expect(lstatMock).toHaveBeenCalledWith(path.join('/repo-feature-b', '.git')) + }) + it('scans connected remote workspaces through the SSH git provider', async () => { const provider = { listWorktrees: vi.fn().mockResolvedValue([ @@ -385,6 +485,116 @@ describe('workspace cleanup scan', () => { expect(result.candidates).toEqual([]) }) + it('stats only broad-scan rows that remain possible cleanup candidates', async () => { + listRepoWorktreesMock.mockResolvedValue([ + { + path: '/repo', + head: 'main123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/repo-old', + head: 'old123', + branch: 'refs/heads/old', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-recent', + head: 'recent123', + branch: 'refs/heads/recent', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-new-created', + head: 'created123', + branch: 'refs/heads/created', + isBare: false, + isMainWorktree: false + } + ]) + const metadataByWorktreeId: Record = { + 'repo-1::/repo-old': makeWorktreeMeta({ + lastActivityAt: NOW - 40 * 24 * 60 * 60 * 1000, + baseRef: 'origin/main' + }), + 'repo-1::/repo-recent': makeWorktreeMeta({ + lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000, + baseRef: 'origin/main' + }), + 'repo-1::/repo-new-created': makeWorktreeMeta({ + createdAt: NOW - 1_000, + lastActivityAt: NOW - 40 * 24 * 60 * 60 * 1000, + baseRef: 'origin/main' + }) + } + const store = { + ...makeStore(), + getWorktreeMeta: (worktreeId: string) => metadataByWorktreeId[worktreeId] + } as unknown as Store + + const result = await scanWorkspaceCleanup(store) + + expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual([ + 'repo-1::/repo-old' + ]) + expect(lstatMock).toHaveBeenCalledTimes(2) + expect(lstatMock).toHaveBeenCalledWith('/repo-old') + expect(lstatMock).toHaveBeenCalledWith(path.join('/repo-old', '.git')) + expect(getStatusMock).toHaveBeenCalledTimes(1) + }) + + it('falls back when broad-scan activity metadata stalls', async () => { + lstatMock.mockReturnValue(new Promise(() => undefined)) + const progress: unknown[] = [] + + const scanPromise = scanWorkspaceCleanup( + makeStore(), + { scanId: 'scan-1' }, + { onProgress: (event) => progress.push(event) } + ) + await vi.advanceTimersByTimeAsync(8_000) + + const result = await scanPromise + + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]?.worktreeId).toBe('repo-1::/repo-feature') + expect(progress[0]).toMatchObject({ + scanId: 'scan-1', + scannedWorktreeCount: 0, + totalWorktreeCount: 1 + }) + expect(progress.at(-1)).toMatchObject({ + scanId: 'scan-1', + scannedWorktreeCount: 1, + totalWorktreeCount: 1 + }) + }) + + it('stops statting a repo after the first activity metadata timeout', async () => { + listRepoWorktreesMock.mockResolvedValue( + ['/repo-a', '/repo-b', '/repo-c', '/repo-d'].map((worktreePath, index) => ({ + path: worktreePath, + head: `head-${index}`, + branch: `refs/heads/branch-${index}`, + isBare: false, + isMainWorktree: false + })) + ) + lstatMock.mockReturnValue(new Promise(() => undefined)) + + const scanPromise = scanWorkspaceCleanup(makeStore()) + await vi.advanceTimersByTimeAsync(8_000) + const result = await scanPromise + + expect(result.candidates).toHaveLength(4) + expect(lstatMock).not.toHaveBeenCalledWith('/repo-d') + expect(lstatMock).not.toHaveBeenCalledWith(path.join('/repo-d', '.git')) + }) + it('includes focused remove preflight rows even when they are recent', async () => { const result = await scanWorkspaceCleanup( makeStore({ diff --git a/src/main/repo-worktrees.test.ts b/src/main/repo-worktrees.test.ts index fd905bacf08..3c6910991f0 100644 --- a/src/main/repo-worktrees.test.ts +++ b/src/main/repo-worktrees.test.ts @@ -60,17 +60,21 @@ describe('repo-worktrees', () => { listWorktreesMock.mockResolvedValue([ { path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true } ]) + const signal = new AbortController().signal - const result = await listRepoWorktrees({ - id: 'repo-1', - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - kind: 'git' - }) + const result = await listRepoWorktrees( + { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + kind: 'git' + }, + { signal } + ) - expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo') + expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { signal }) expect(result).toHaveLength(1) }) diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index e02ba35f9c7..4f45e7d8564 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -9,6 +9,10 @@ type LocalRepoWorktreeListOptions = { signal?: AbortSignal } +function hasLocalRepoWorktreeListOptions(options: LocalRepoWorktreeListOptions | undefined) { + return options?.wslDistro !== undefined || options?.signal !== undefined +} + export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean { return repos.some( (repo) => !repo.connectionId && areWorktreePathsEqual(repo.path, resolvedTarget) @@ -30,7 +34,7 @@ export function createFolderWorktree(repo: Repo): GitWorktreeInfo { export async function listRepoWorktrees( repo: Repo, - options: LocalRepoWorktreeListOptions = {} + options?: LocalRepoWorktreeListOptions ): Promise { if (isFolderRepo(repo)) { return [createFolderWorktree(repo)] @@ -42,7 +46,7 @@ export async function listRepoWorktrees( // local git against a server path. return provider ? await provider.listWorktrees(repo.path) : [] } - return options.wslDistro + return hasLocalRepoWorktreeListOptions(options) ? await listWorktrees(repo.path, options) : await listWorktrees(repo.path) } diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 0847c2ae8e7..07b84db43f9 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -24,7 +24,11 @@ type GitBufferSpyTarget = { } type GitSpyTarget = { - git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> + git( + args: string[], + cwd: string, + opts?: { signal?: AbortSignal } + ): Promise<{ stdout: string; stderr: string }> } function deferredRelayBuffer(content: string): { @@ -1882,6 +1886,24 @@ describe('GitHandler', () => { expect(result[0].isMainWorktree).toBe(true) }) + it('passes request cancellation to the git worktree list subprocess', async () => { + const controller = new AbortController() + const gitSpy = vi + .spyOn(handler as unknown as GitSpyTarget, 'git') + .mockRejectedValue(new Error('aborted')) + + const result = await dispatcher.callRequest( + 'git.listWorktrees', + { repoPath: tmpDir }, + { isStale: () => false, signal: controller.signal } + ) + + expect(result).toEqual([]) + expect(gitSpy).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], tmpDir, { + signal: controller.signal + }) + }) + it.skipIf(process.platform === 'win32')( 'normalizes the main worktree path for a separate-git-dir repo', async () => { diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 746dbffd803..2d4bb2bb09b 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -216,7 +216,7 @@ export class GitHandler { this.dispatcher.onRequest('git.rebaseFromBase', (p) => this.rebaseFromBase(p)) this.dispatcher.onRequest('git.branchDiff', (p) => this.branchDiff(p)) this.dispatcher.onRequest('git.commitDiff', (p) => this.commitDiff(p)) - this.dispatcher.onRequest('git.listWorktrees', (p) => this.listWorktrees(p)) + this.dispatcher.onRequest('git.listWorktrees', (p, context) => this.listWorktrees(p, context)) this.dispatcher.onRequest('git.addWorktree', (p) => this.addWorktree(p)) this.dispatcher.onRequest('git.removeWorktree', (p) => this.removeWorktree(p)) this.dispatcher.onRequest('git.worktreeIsClean', (p) => this.worktreeIsClean(p)) @@ -1263,10 +1263,12 @@ export class GitHandler { return normalized } - private async listWorktrees(params: Record) { + private async listWorktrees(params: Record, context?: RequestContext) { const repoPath = params.repoPath as string try { - const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath) + const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath, { + signal: context?.signal + }) return this.normalizeMainWorktreePath( repoPath, parseWorktreeList(stdout, { nulDelimited: true }) @@ -1280,7 +1282,9 @@ export class GitHandler { // Why: `-z` keeps newline-containing SSH worktree paths intact, but older // Git rejects it. Fall back to the original line-block parser there. try { - const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath) + const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { + signal: context?.signal + }) return this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout)) } catch { return [] diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 003878d3acf..e0692a7f3bc 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -606,6 +606,10 @@ const WorktreeCard = React.memo(function WorktreeCard({ const legacyCardTitleDisplay = coerceWorktreeCardVisibleTitle(worktree.displayName) const visibleCardTitle = newCardStyle ? cardTitleDisplay : legacyCardTitleDisplay const isDeleting = deleteState?.isDeleting ?? false + const isQueuedForDeletion = deleteState?.phase === 'queued' + const deleteLabel = isQueuedForDeletion + ? translate('auto.components.sidebar.WorktreeCard.ef18787206', 'Queued for deletion') + : translate('auto.components.sidebar.WorktreeCard.691ccfd622', 'Deleting…') const deleteModifierPressed = useWorkspaceDeleteModifierPressed() const showStatus = cardProps.includes('status') @@ -1877,8 +1881,10 @@ const WorktreeCard = React.memo(function WorktreeCard({ {isDeleting && (
- - {translate('auto.components.sidebar.WorktreeCard.691ccfd622', 'Deleting…')} + {!isQueuedForDeletion ? ( + + ) : null} + {deleteLabel}
)} diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index efedfb65665..91a552735ef 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -4,8 +4,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AlertTriangle, - Check, - EyeOff, Loader2, RefreshCcw, Search, @@ -38,6 +36,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' +import { Progress } from '@/components/ui/progress' import RepoMultiCombobox from '@/components/ui/repo-multi-combobox' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -48,16 +47,13 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { isGitRepoKind } from '../../../../shared/repo-kind' import { canQueueWorkspaceCleanupCandidate, - type WorkspaceCleanupBlocker, type WorkspaceCleanupCandidate, type WorkspaceCleanupScanError, type WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup' import { filterWorkspaceCleanupCandidates, - getWorkspaceCleanupGitLabel, getWorkspaceCleanupReviewInfo, - hasWorkspaceCleanupLocalContext, sortWorkspaceCleanupCandidates, type WorkspaceCleanupContextFilter, type WorkspaceCleanupFilters, @@ -68,11 +64,25 @@ import { type WorkspaceCleanupSortKey, type WorkspaceCleanupTimeFilter } from './workspace-cleanup-presentation' +import { + startWorkspaceCleanupBackgroundRemoval, + type WorkspaceCleanupRemovalProgress +} from './workspace-cleanup-background-removal' +import { CandidateRow } from './workspace-cleanup-candidate-row' +import { + getCandidateStatus, + getContextPillLabel, + getDirtyGitLabel, + getReviewPillTone, + shouldShowGitMetadataChip +} from './workspace-cleanup-candidate-row-data' +import { StatusPill } from './workspace-cleanup-status-pill' import { resolveWorkspaceCleanupActiveView, type WorkspaceCleanupView, type WorkspaceCleanupViewCounts } from './workspace-cleanup-view-selection' +import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' import { translate } from '@/i18n/i18n' const DEFAULT_FILTERS: WorkspaceCleanupFilters = { @@ -91,25 +101,6 @@ const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = { title: null } -const BLOCKER_LABELS: Record = { - 'main-worktree': 'Main workspace', - 'folder-repo': 'Folder project', - pinned: 'Pinned', - 'active-workspace': 'Active workspace', - 'running-terminal': 'Running terminal process', - 'terminal-liveness-unknown': 'Terminal liveness unknown', - 'dirty-editor-buffer': 'Unsaved editor buffer', - 'volatile-local-context': 'Volatile local context', - 'recent-visible-context': 'Recently visited tabs', - 'live-agent': 'Active agent', - 'ssh-disconnected': 'Remote unavailable', - 'git-status-error': 'Git status unavailable', - 'dirty-files': 'Changed files', - 'unpushed-commits': 'Unpushed commits', - 'unknown-base': 'Could not verify unpushed commits', - dismissed: 'Ignored' -} - function formatRelativeTime(timestamp: number): string { if (!timestamp) { return 'Never' @@ -204,13 +195,29 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { const dismissCandidates = useAppStore((s) => s.dismissWorkspaceCleanupCandidates) const resetDismissals = useAppStore((s) => s.resetWorkspaceCleanupDismissals) const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates) + const markWorktreesQueuedForDeletion = useAppStore((s) => s.markWorktreesQueuedForDeletion) + const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState) + const deletingWorktreeIds = useAppStore( + useShallow( + (s) => + new Set( + Object.entries(s.deleteStateByWorktreeId) + .filter(([, state]) => state.isDeleting) + .map(([worktreeId]) => worktreeId) + ) + ) + ) const open = activeModal === 'workspace-cleanup' const openRef = useRef(open) const [selectedIds, setSelectedIds] = useState>(() => new Set()) + const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()) const [activeView, setActiveView] = useState('ready') const [confirming, setConfirming] = useState(false) - const [removing, setRemoving] = useState(false) + const [confirmCandidates, setConfirmCandidates] = useState([]) + const [removalProgress, setRemovalProgress] = useState( + null + ) const [rowFailures, setRowFailures] = useState>({}) const [repoSelection, setRepoSelection] = useState>(() => new Set()) const [filters, setFilters] = useState(DEFAULT_FILTERS) @@ -220,6 +227,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { const autoScanAttemptedForOpenRef = useRef(false) const latestReadyToastScanAtRef = useRef(null) const wasOpenRef = useRef(false) + const removalInFlightRef = useRef(false) const mountedRef = useMountedRef() const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos]) const eligibleRepoIds = useMemo(() => eligibleRepos.map((repo) => repo.id), [eligibleRepos]) @@ -289,21 +297,21 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { if (!wasOpenRef.current) { wasOpenRef.current = true autoScanAttemptedForOpenRef.current = false - setActiveView('ready') - setConfirming(false) - setRowFailures({}) - setFilters(DEFAULT_FILTERS) - setSortKey('activity') - setSortDirection('asc') - if (scan) { - setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates)) + if (!removalInFlightRef.current) { + setActiveView('ready') + setConfirming(false) + setRowFailures({}) + setFilters(DEFAULT_FILTERS) + setSortKey('activity') + setSortDirection('asc') + setSelectedIds(new Set()) } } if (!loading && !autoScanAttemptedForOpenRef.current) { autoScanAttemptedForOpenRef.current = true startWorkspaceCleanupScan({ notifyWhenReady: true }) } - }, [loading, open, scan, startWorkspaceCleanupScan]) + }, [loading, open, startWorkspaceCleanupScan]) useEffect(() => { if (!open) { @@ -337,14 +345,17 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { }, [candidates, effectiveRepoSelection, eligibleRepoIds.length]) useEffect(() => { - if (!scan || selectedDefaultsScanAtRef.current === scan.scannedAt) { + if (loading || !scan || selectedDefaultsScanAtRef.current === scan.scannedAt) { return } selectedDefaultsScanAtRef.current = scan.scannedAt - setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates)) + if (removalInFlightRef.current) { + return + } + setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates, deletingWorktreeIds)) setConfirming(false) setRowFailures({}) - }, [scan]) + }, [deletingWorktreeIds, loading, scan]) const visibleCandidates = useMemo(() => { const rows = filteredCandidates.filter((candidate) => !candidate.blockers.includes('dismissed')) @@ -426,9 +437,11 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { .map((id) => byId.get(id)) .filter( (candidate): candidate is WorkspaceCleanupCandidate => - candidate != null && canQueueWorkspaceCleanupCandidate(candidate) + candidate != null && + canQueueWorkspaceCleanupCandidate(candidate) && + !deletingWorktreeIds.has(candidate.worktreeId) ) - }, [activeRows, selectedIds]) + }, [activeRows, deletingWorktreeIds, selectedIds]) useEffect(() => { if (!open || confirming) { return @@ -436,18 +449,20 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { // Why: destructive selection must stay scoped to the rows the user can // currently review after tier/filter changes. setSelectedIds((current) => { - const next = new Set([...current].filter((id) => activeRowIds.has(id))) + const next = new Set( + [...current].filter((id) => activeRowIds.has(id) && !deletingWorktreeIds.has(id)) + ) return next.size === current.size ? current : next }) - }, [activeRowIds, confirming, open]) + }, [activeRowIds, confirming, deletingWorktreeIds, open]) const handleOpenChange = useCallback( (nextOpen: boolean) => { - if (!nextOpen && !removing) { + if (!nextOpen) { closeModal() } }, - [closeModal, removing] + [closeModal] ) const refresh = useCallback(() => { @@ -483,65 +498,106 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { [dismissCandidates, mountedRef] ) - const confirmRemove = useCallback(async () => { - if (selectedCandidates.length === 0) { + const toggleExpandedRow = useCallback((worktreeId: string) => { + setExpandedRowIds((current) => toggleSetMember(current, worktreeId)) + }, []) + + const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => { + const nextCandidates = filterWorkspaceCleanupRemovalCandidates( + candidates, + useAppStore.getState().deleteStateByWorktreeId + ) + if (nextCandidates.length === 0) { return } - setRemoving(true) - setRowFailures({}) - try { - const result = await removeCandidates( - selectedCandidates.map((candidate) => candidate.worktreeId) - ) - const nextFailures: Record = {} - for (const failure of result.failures) { - nextFailures[failure.worktreeId] = failure.message - } - if (mountedRef.current) { - setRowFailures(nextFailures) - setSelectedIds((current) => { - const next = new Set(current) - for (const id of result.removedIds) { - next.delete(id) - } - return next - }) - } - if (result.removedIds.length > 0) { - if (mountedRef.current) { - toast.success( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0f00612b6d', - 'Removed {{value0}} workspace{{value1}}', - { - value0: result.removedIds.length, - value1: result.removedIds.length === 1 ? '' : 's' - } - ) - ) - } - } - if (result.failures.length > 0) { - if (mountedRef.current) { - toast.error( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.41d594d01e', - '{{value0}} workspace{{value1}} could not be removed', - { value0: result.failures.length, value1: result.failures.length === 1 ? '' : 's' } - ) - ) - } - } else { - if (mountedRef.current) { - setConfirming(false) - } - } - } finally { - if (mountedRef.current) { - setRemoving(false) - } + setConfirmCandidates(nextCandidates) + setConfirming(true) + }, []) + + const cancelConfirmRemove = useCallback(() => { + if (removalProgress) { + closeModal() + return } - }, [mountedRef, removeCandidates, selectedCandidates]) + setConfirming(false) + setConfirmCandidates([]) + }, [closeModal, removalProgress]) + + const confirmRemove = useCallback(() => { + if (confirmCandidates.length === 0 || removalInFlightRef.current) { + return + } + const removableCandidates = filterWorkspaceCleanupRemovalCandidates( + confirmCandidates, + useAppStore.getState().deleteStateByWorktreeId + ) + if (removableCandidates.length === 0) { + setConfirming(false) + setConfirmCandidates([]) + return + } + removalInFlightRef.current = true + setRowFailures({}) + markWorktreesQueuedForDeletion(removableCandidates.map((candidate) => candidate.worktreeId)) + startWorkspaceCleanupBackgroundRemoval({ + candidates: removableCandidates, + removeCandidates, + onProgress: (progress) => { + if (mountedRef.current) { + setRemovalProgress(progress) + } + }, + onResult: (result) => { + const nextFailures: Record = {} + for (const failure of result.failures) { + nextFailures[failure.worktreeId] = failure.message + const deleteState = useAppStore.getState().deleteStateByWorktreeId[failure.worktreeId] + if ( + deleteState?.isDeleting && + deleteState.error === null && + deleteState.phase === 'queued' + ) { + // Why: candidates that fail before removal starts would otherwise + // stay marked "Queued for deletion" in the sidebar indefinitely. + clearWorktreeDeleteState(failure.worktreeId) + } + } + if (mountedRef.current) { + setRowFailures(nextFailures) + setSelectedIds((current) => { + const next = new Set(current) + for (const id of result.removedIds) { + next.delete(id) + } + return next + }) + } + if (mountedRef.current) { + setRemovalProgress(null) + setConfirming(false) + setConfirmCandidates([]) + } + removalInFlightRef.current = false + }, + onError: () => { + for (const candidate of removableCandidates) { + clearWorktreeDeleteState(candidate.worktreeId) + } + if (mountedRef.current) { + setRemovalProgress(null) + setConfirming(false) + setConfirmCandidates([]) + } + removalInFlightRef.current = false + } + }) + }, [ + clearWorktreeDeleteState, + confirmCandidates, + markWorktreesQueuedForDeletion, + mountedRef, + removeCandidates + ]) const selectedCount = selectedCandidates.length @@ -594,7 +650,6 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { 'Close' )} onClick={() => closeModal()} - disabled={removing} > @@ -649,8 +704,8 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { - ) : ( - - - ) -} - -function getCandidateStatus(candidate: WorkspaceCleanupCandidate): { - label: string - tone: 'neutral' | 'ready' | 'review' | 'destructive' -} { - if (candidate.blockers.includes('dismissed')) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7', - 'Ignored' - ), - tone: 'neutral' - } - } - if (candidate.tier === 'ready') { - return { label: candidate.reasons.includes('archived') ? 'Archived' : 'Clean', tone: 'ready' } - } - if (candidate.blockers.length > 0) { - return { label: BLOCKER_LABELS[candidate.blockers[0]], tone: 'neutral' } - } - if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9623a5107d', - 'Unpushed commits' - ), - tone: 'review' - } - } - if (candidate.git.clean === false) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e97e4580c7', - 'Dirty' - ), - tone: 'review' - } - } - if (candidate.tier === 'review') { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0a2e3c7cba', - 'Review' - ), - tone: 'review' - } - } - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.c4f4782c02', - 'Not suggested' - ), - tone: 'neutral' - } -} - -function formatGitStatus(candidate: WorkspaceCleanupCandidate): string { - const label = getWorkspaceCleanupGitLabel(candidate) - switch (label) { - case 'Clean': - return 'Clean git' - case 'Dirty': - return 'Dirty git' - case 'Unpushed': - return 'Unpushed commits' - case 'Unknown': - return 'Git unknown' - } - return 'Git unknown' -} - -function formatBranchSafetyDetails(candidate: WorkspaceCleanupCandidate): string[] { - const details: string[] = [] - if (candidate.git.upstreamAhead !== null) { - details.push( - candidate.git.upstreamAhead === 0 - ? 'No unpushed commits' - : `${candidate.git.upstreamAhead} unpushed commit${ - candidate.git.upstreamAhead === 1 ? '' : 's' - }` - ) - } - return details -} - -function formatContextDetails(candidate: WorkspaceCleanupCandidate): string | null { - const parts: string[] = [] - if (candidate.localContext.terminalTabCount > 0) { - parts.push( - `${candidate.localContext.terminalTabCount} terminal tab${ - candidate.localContext.terminalTabCount === 1 ? '' : 's' - }` - ) - } - if (candidate.localContext.cleanEditorTabCount > 0) { - parts.push( - `${candidate.localContext.cleanEditorTabCount} editor tab${ - candidate.localContext.cleanEditorTabCount === 1 ? '' : 's' - }` - ) - } - if (candidate.localContext.browserTabCount > 0) { - parts.push( - `${candidate.localContext.browserTabCount} browser tab${ - candidate.localContext.browserTabCount === 1 ? '' : 's' - }` - ) - } - if (candidate.localContext.diffCommentCount > 0) { - parts.push( - `${candidate.localContext.diffCommentCount} diff note${ - candidate.localContext.diffCommentCount === 1 ? '' : 's' - }` - ) - } - if (candidate.localContext.retainedDoneAgentCount > 0) { - parts.push( - `${candidate.localContext.retainedDoneAgentCount} completed agent${ - candidate.localContext.retainedDoneAgentCount === 1 ? '' : 's' - }` - ) - } - return parts.length > 0 ? parts.join(', ') : null -} - function ConfirmRemove({ candidates, reviewInfoByWorktreeId, - removing, + progress, onCancel, onConfirm }: { candidates: WorkspaceCleanupCandidate[] reviewInfoByWorktreeId: ReadonlyMap - removing: boolean + progress: WorkspaceCleanupRemovalProgress | null onCancel: () => void onConfirm: () => void }): React.JSX.Element { const count = candidates.length - const noun = count === 1 ? 'workspace' : 'workspaces' + const deleting = progress !== null + const progressValue = progress + ? Math.min(100, Math.max(0, (progress.processedCount / progress.totalCount) * 100)) + : 0 return ( <> -
-
- -
-
- - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.cbf2f664e2', - 'Delete' - )}{' '} - {count} {noun}? - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.38ca0b1400', - "This permanently deletes their local files. You can't undo this." +
+
+
+ {deleting ? ( + + ) : ( + )} - +
+
+ + {deleting + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deletingCount', + 'Deleting workspaces: {{value0}}', + { value0: count } + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deleteCount', + 'Delete workspaces: {{value0}}?', + { value0: count } + )} + + + {deleting + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1d3503357d', + 'You can close this and come back while deletion continues.' + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.38ca0b1400', + "This permanently deletes their local files. You can't undo this." + )} + +
+
+ {progress ? ( +
+
+ + + {formatWorkspaceCleanupRemovalProgress(progress)} + +
+ +
+ ) : null}
- {count} {noun}{' '} {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.dba753e94f', - 'to delete' + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.selectedForDeletionCount', + 'Selected for deletion: {{value0}}', + { value0: count } )}
@@ -1541,20 +1316,27 @@ function ConfirmRemove({
- - + {!deleting ? ( + + ) : null} ) @@ -1572,6 +1354,7 @@ function ConfirmRemoveRow({ const dirtyLabel = getDirtyGitLabel(candidate) const branchDiffersFromName = candidate.branch !== candidate.displayName const contextPillLabel = getContextPillLabel(candidate) + const showGitMetadataChip = shouldShowGitMetadataChip(candidate) const status = getCandidateStatus(candidate) return (
@@ -1589,7 +1372,9 @@ function ConfirmRemoveRow({ {reviewInfo.label} ) : null} {contextPillLabel ? {contextPillLabel} : null} - {dirtyLabel ? {dirtyLabel} : null} + {dirtyLabel && showGitMetadataChip ? ( + {dirtyLabel} + ) : null}
{candidate.repoName} @@ -1607,55 +1392,6 @@ function ConfirmRemoveRow({ ) } -function getDirtyGitLabel(candidate: WorkspaceCleanupCandidate): string | null { - if (candidate.blockers.includes('unpushed-commits')) { - if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { - return `${candidate.git.upstreamAhead} unpushed commit${ - candidate.git.upstreamAhead === 1 ? '' : 's' - }` - } - return 'Unpushed commits' - } - if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { - return `${candidate.git.upstreamAhead} unpushed commit${ - candidate.git.upstreamAhead === 1 ? '' : 's' - }` - } - if (candidate.git.clean === false) { - return 'Uncommitted changes' - } - if ( - candidate.git.clean == null || - candidate.blockers.includes('unknown-base') || - candidate.blockers.includes('git-status-error') - ) { - return 'Git status unknown' - } - return null -} - -function getReviewPillTone( - reviewInfo: WorkspaceCleanupReviewInfo -): 'neutral' | 'ready' | 'review' | 'destructive' { - if (reviewInfo.state === 'open' || reviewInfo.state === 'draft') { - return 'review' - } - return 'neutral' -} - -function getContextPillLabel(candidate: WorkspaceCleanupCandidate): string | null { - if (!hasWorkspaceCleanupLocalContext(candidate)) { - return null - } - const count = - candidate.localContext.terminalTabCount + - candidate.localContext.cleanEditorTabCount + - candidate.localContext.browserTabCount + - candidate.localContext.diffCommentCount + - candidate.localContext.retainedDoneAgentCount - return `${count} context` -} - function hasActiveWorkspaceCleanupFilters(filters: WorkspaceCleanupFilters): boolean { return ( filters.query.trim() !== '' || @@ -1682,11 +1418,14 @@ function hasActiveWorkspaceCleanupPanelControls( } function getDefaultSelectedWorkspaceCleanupIds( - candidates: readonly WorkspaceCleanupCandidate[] + candidates: readonly WorkspaceCleanupCandidate[], + deletingWorktreeIds: ReadonlySet = new Set() ): Set { return new Set( candidates - .filter((candidate) => candidate.selectedByDefault) + .filter( + (candidate) => candidate.selectedByDefault && !deletingWorktreeIds.has(candidate.worktreeId) + ) .map((candidate) => candidate.worktreeId) ) } @@ -1703,21 +1442,40 @@ function formatWorkspaceCleanupReadyToastDescription( return `${inactiveCount} inactive ${inactiveNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.` } +function formatWorkspaceCleanupRemovalProgress(progress: WorkspaceCleanupRemovalProgress): string { + const deletedText = translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4c2990886e', + '{{value0}}/{{value1}} deleted', + { + value0: progress.removedCount, + value1: progress.totalCount + } + ) + if (progress.failedCount === 0) { + return deletedText + } + return translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.86ba852118', + '{{value0}}, {{value1}} failed', + { + value0: deletedText, + value1: progress.failedCount + } + ) +} + function formatWorkspaceCleanupProgress(progress: WorkspaceCleanupScanProgress | null): string { - if (!progress || progress.totalWorktreeCount === 0) { + if (!progress || progress.scannedWorktreeCount === 0) { return translate( 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4cc5b73efe', 'Finding inactive workspaces...' ) } - const noun = progress.totalWorktreeCount === 1 ? 'workspace' : 'workspaces' return translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.5bf2e88480', - '{{value0}}/{{value1}} {{value2}} scanned', + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7b7bde5181', + 'Checked workspaces so far: {{value0}}', { - value0: progress.scannedWorktreeCount, - value1: progress.totalWorktreeCount, - value2: noun + value0: progress.scannedWorktreeCount } ) } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts new file mode 100644 index 00000000000..ec3a4114a97 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts @@ -0,0 +1,389 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { + startWorkspaceCleanupBackgroundRemoval, + type WorkspaceCleanupBackgroundRemovalArgs +} from './workspace-cleanup-background-removal' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + success: vi.fn() + } +})) + +async function settleBackgroundRemoval(): Promise { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve() + } +} + +describe('startWorkspaceCleanupBackgroundRemoval', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('reports an empty result without starting removal when there are no candidates', async () => { + const removeCandidates = vi.fn() + const onProgress = vi.fn() + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [], + removeCandidates, + onProgress, + onResult + }) + await settleBackgroundRemoval() + + expect(removeCandidates).not.toHaveBeenCalled() + expect(onProgress).not.toHaveBeenCalled() + expect(onResult).toHaveBeenCalledWith({ removedIds: [], failures: [] }) + expect(toast.success).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }) + + it('reports deletion progress while the slow removal promise is pending', async () => { + let resolveRemoval: ( + result: Awaited> + ) => void + const removeCandidates = vi.fn( + () => + new Promise>>( + (resolve) => { + resolveRemoval = resolve + } + ) + ) + const onProgress = vi.fn() + const onResult = vi.fn() + const candidate = makeCandidate() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates, + onProgress, + onResult + }) + + expect(removeCandidates).toHaveBeenCalledWith([candidate.worktreeId], { + approvedCandidates: [candidate] + }) + expect(onProgress).toHaveBeenCalledWith({ + totalCount: 1, + processedCount: 0, + removedCount: 0, + failedCount: 0 + }) + expect(onResult).not.toHaveBeenCalled() + + resolveRemoval!({ removedIds: [candidate.worktreeId], failures: [] }) + await settleBackgroundRemoval() + + expect(onProgress).toHaveBeenLastCalledWith({ + totalCount: 1, + processedCount: 1, + removedCount: 1, + failedCount: 0 + }) + expect(toast.success).toHaveBeenCalled() + expect(onResult).toHaveBeenCalledWith({ removedIds: [candidate.worktreeId], failures: [] }) + }) + + it('removes candidates one at a time for per-row progress', async () => { + const first = makeCandidate() + const second = makeCandidate({ + worktreeId: 'repo-1::/repo/beta', + displayName: 'beta', + branch: 'beta', + path: '/repo/beta' + }) + const removeCandidates = vi + .fn() + .mockResolvedValueOnce({ removedIds: [first.worktreeId], failures: [] }) + .mockResolvedValueOnce({ removedIds: [second.worktreeId], failures: [] }) + const onProgress = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [first, second], + removeCandidates, + onProgress + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(1, [first.worktreeId], { + approvedCandidates: [first] + }) + expect(removeCandidates).toHaveBeenNthCalledWith(2, [second.worktreeId], { + approvedCandidates: [second] + }) + expect(onProgress).toHaveBeenLastCalledWith({ + totalCount: 2, + processedCount: 2, + removedCount: 2, + failedCount: 0 + }) + }) + + it('removes nested candidates before their parent workspace', async () => { + const parent = makeCandidate({ + worktreeId: 'repo-1::/repo/parent', + displayName: 'parent', + branch: 'parent', + path: '/repo/parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::/repo/parent/child', + displayName: 'child', + branch: 'child', + path: '/repo/parent/child' + }) + const removeCandidates = vi.fn(async (worktreeIds: readonly string[]) => ({ + removedIds: [...worktreeIds], + failures: [] + })) + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child], + removeCandidates, + onProgress: vi.fn() + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(1, [child.worktreeId], { + approvedCandidates: [child] + }) + expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], { + approvedCandidates: [parent] + }) + }) + + it('skips an ancestor after a nested workspace removal fails', async () => { + const parent = makeCandidate({ + worktreeId: 'repo-1::C:\\repo\\parent', + displayName: 'parent', + branch: 'parent', + path: 'C:\\repo\\parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::C:\\repo\\parent\\child', + displayName: 'child', + branch: 'child', + path: 'C:\\repo\\parent\\child' + }) + const removeCandidates = vi.fn().mockResolvedValueOnce({ + removedIds: [], + failures: [{ worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' }] + }) + const onProgress = vi.fn() + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child], + removeCandidates, + onProgress, + onResult + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenCalledTimes(1) + expect(removeCandidates).toHaveBeenCalledWith([child.worktreeId], { + approvedCandidates: [child] + }) + expect(onProgress).toHaveBeenLastCalledWith({ + totalCount: 2, + processedCount: 2, + removedCount: 0, + failedCount: 2 + }) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [], + failures: [ + { worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' }, + { + worktreeId: parent.worktreeId, + displayName: parent.displayName, + message: 'Skipped because a nested workspace could not be removed.' + } + ] + }) + }) + + it('does not skip same-path ancestors from another connection after a nested failure', async () => { + const failedChild = makeCandidate({ + worktreeId: 'repo-1::/repo/parent/child', + displayName: 'child', + branch: 'child', + path: '/repo/parent/child', + connectionId: 'ssh-a' + }) + const unrelatedParent = makeCandidate({ + worktreeId: 'repo-2::/repo/parent', + repoId: 'repo-2', + repoName: 'Repo 2', + displayName: 'parent', + branch: 'parent', + path: '/repo/parent', + connectionId: 'ssh-b' + }) + const removeCandidates = vi + .fn() + .mockResolvedValueOnce({ + removedIds: [], + failures: [ + { + worktreeId: failedChild.worktreeId, + displayName: failedChild.displayName, + message: 'busy' + } + ] + }) + .mockResolvedValueOnce({ removedIds: [unrelatedParent.worktreeId], failures: [] }) + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [unrelatedParent, failedChild], + removeCandidates, + onProgress: vi.fn(), + onResult + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(1, [failedChild.worktreeId], { + approvedCandidates: [failedChild] + }) + expect(removeCandidates).toHaveBeenNthCalledWith(2, [unrelatedParent.worktreeId], { + approvedCandidates: [unrelatedParent] + }) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [unrelatedParent.worktreeId], + failures: [{ worktreeId: failedChild.worktreeId, displayName: 'child', message: 'busy' }] + }) + }) + + it('reports removal failures after dismissing the pending toast', async () => { + const candidate = makeCandidate() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates: vi.fn().mockResolvedValue({ + removedIds: [], + failures: [ + { worktreeId: candidate.worktreeId, displayName: candidate.displayName, message: 'busy' } + ] + }), + onProgress: vi.fn() + }) + await settleBackgroundRemoval() + + expect(toast.error).toHaveBeenCalledWith( + 'Workspaces not removed: 1', + expect.objectContaining({ description: 'busy' }) + ) + }) + + it('times out a stalled row removal and continues reporting progress', async () => { + vi.useFakeTimers() + const candidate = makeCandidate() + const onProgress = vi.fn() + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates: vi.fn( + () => + new Promise< + Awaited> + >(() => undefined) + ), + onProgress, + onResult, + removalTimeoutMs: 5 + }) + + await vi.advanceTimersByTimeAsync(5) + await settleBackgroundRemoval() + + expect(onProgress).toHaveBeenLastCalledWith({ + totalCount: 1, + processedCount: 1, + removedCount: 0, + failedCount: 1 + }) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [], + failures: [ + { + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: + 'Removing alpha is taking longer than expected. It will keep running in the background.' + } + ] + }) + }) + + it('keeps removal outcome toasts when the result callback throws', async () => { + const candidate = makeCandidate() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates: vi.fn().mockResolvedValue({ + removedIds: [candidate.worktreeId], + failures: [] + }), + onProgress: vi.fn(), + onResult: () => { + throw new Error('callback failed') + }, + onError: vi.fn() + }) + await settleBackgroundRemoval() + + expect(toast.success).toHaveBeenCalledWith('Removed workspaces: 1') + expect(toast.error).not.toHaveBeenCalledWith( + 'Workspace cleanup failed', + expect.objectContaining({ description: 'callback failed' }) + ) + }) + + it('shows every failure message in the failure toast description', async () => { + const first = makeCandidate() + const second = makeCandidate({ + worktreeId: 'repo-1::/repo/beta', + displayName: 'beta', + branch: 'beta', + path: '/repo/beta' + }) + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [first, second], + removeCandidates: vi + .fn() + .mockResolvedValueOnce({ + removedIds: [], + failures: [ + { worktreeId: first.worktreeId, displayName: first.displayName, message: 'busy' } + ] + }) + .mockResolvedValueOnce({ + removedIds: [], + failures: [ + { worktreeId: second.worktreeId, displayName: second.displayName, message: 'dirty' } + ] + }), + onProgress: vi.fn() + }) + await settleBackgroundRemoval() + + expect(toast.error).toHaveBeenCalledWith( + 'Workspaces not removed: 2', + expect.objectContaining({ description: 'busy; dirty' }) + ) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts new file mode 100644 index 00000000000..a8d14923cba --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts @@ -0,0 +1,216 @@ +import { toast } from 'sonner' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + isPathInsideOrEqual, + normalizeRuntimePathForComparison +} from '../../../../shared/cross-platform-path' +import type { + WorkspaceCleanupFailure, + WorkspaceCleanupRemoveOptions, + WorkspaceCleanupRemoveResult +} from '@/store/slices/workspace-cleanup' +import { translate } from '@/i18n/i18n' + +const DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS = 120_000 + +export type WorkspaceCleanupRemovalProgress = { + totalCount: number + processedCount: number + removedCount: number + failedCount: number +} + +export type WorkspaceCleanupBackgroundRemovalArgs = { + candidates: readonly WorkspaceCleanupCandidate[] + removeCandidates: ( + worktreeIds: readonly string[], + options?: WorkspaceCleanupRemoveOptions + ) => Promise + onProgress: (progress: WorkspaceCleanupRemovalProgress) => void + onResult?: (result: WorkspaceCleanupRemoveResult) => void + onError?: (error: unknown) => void + removalTimeoutMs?: number +} + +export function startWorkspaceCleanupBackgroundRemoval({ + candidates, + removeCandidates, + onProgress, + onResult, + onError, + removalTimeoutMs = DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS +}: WorkspaceCleanupBackgroundRemovalArgs): void { + if (candidates.length === 0) { + try { + onResult?.({ removedIds: [], failures: [] }) + } catch (callbackError) { + console.error('Workspace cleanup result callback failed', callbackError) + } + return + } + + const count = candidates.length + const removedIds: string[] = [] + const failures: WorkspaceCleanupFailure[] = [] + const failedCandidates: WorkspaceCleanupCandidate[] = [] + let processedCount = 0 + + const emitProgress = (): void => { + onProgress({ + totalCount: count, + processedCount, + removedCount: removedIds.length, + failedCount: failures.length + }) + } + + emitProgress() + + // Why: keep the store's nested-worktree delete invariant even though progress + // is emitted per row; children must be removed before parent workspaces. + const candidatesInRemovalOrder = [...candidates].sort((a, b) => b.path.length - a.path.length) + + void (async () => { + for (const candidate of candidatesInRemovalOrder) { + if ( + failedCandidates.some((failedCandidate) => + isStrictWorkspaceCleanupDescendant(candidate, failedCandidate) + ) + ) { + failedCandidates.push(candidate) + failures.push({ + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: translate( + 'auto.components.workspace.cleanup.backgroundRemoval.skippedAncestor', + 'Skipped because a nested workspace could not be removed.' + ) + }) + processedCount += 1 + emitProgress() + continue + } + try { + const result = await withWorkspaceCleanupRemovalTimeout( + removeCandidates([candidate.worktreeId], { approvedCandidates: [candidate] }), + candidate, + removalTimeoutMs + ) + removedIds.push(...result.removedIds) + failures.push(...result.failures) + if (result.failures.length > 0) { + failedCandidates.push(candidate) + } + } catch (error: unknown) { + failedCandidates.push(candidate) + failures.push({ + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: error instanceof Error ? error.message : String(error) + }) + } finally { + processedCount += 1 + emitProgress() + } + } + + const result = { removedIds, failures } + try { + onResult?.(result) + } catch (callbackError) { + console.error('Workspace cleanup result callback failed', callbackError) + } + + if (result.removedIds.length > 0) { + toast.success( + translate( + 'auto.components.workspace.cleanup.backgroundRemoval.removed', + 'Removed workspaces: {{value0}}', + { + value0: result.removedIds.length + } + ) + ) + } + + if (result.failures.length > 0) { + toast.error( + translate( + 'auto.components.workspace.cleanup.backgroundRemoval.failed', + 'Workspaces not removed: {{value0}}', + { + value0: result.failures.length + } + ), + { + description: result.failures.map((failure) => failure.message).join('; ') + } + ) + } + })().catch((error: unknown) => { + onError?.(error) + toast.error( + translate( + 'auto.components.workspace.cleanup.backgroundRemoval.error', + 'Workspace cleanup failed' + ), + { + description: error instanceof Error ? error.message : String(error) + } + ) + }) +} + +async function withWorkspaceCleanupRemovalTimeout( + promise: Promise, + candidate: WorkspaceCleanupCandidate, + timeoutMs: number +): Promise { + if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) { + return promise + } + + let timeout: ReturnType | null = null + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + // Why: the underlying removal cannot be cancelled from the renderer, + // so the row stays "Deleting" and this message must not claim the + // removal stopped. + reject( + new Error( + translate( + 'auto.components.workspace.cleanup.backgroundRemoval.timedOut', + 'Removing {{value0}} is taking longer than expected. It will keep running in the background.', + { value0: candidate.displayName } + ) + ) + ) + }, timeoutMs) + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +function isStrictWorkspaceCleanupDescendant( + parent: WorkspaceCleanupCandidate, + child: WorkspaceCleanupCandidate +): boolean { + return ( + parent.connectionId === child.connectionId && + isStrictWorkspaceCleanupDescendantPath(parent.path, child.path) + ) +} + +function isStrictWorkspaceCleanupDescendantPath(parentPath: string, childPath: string): boolean { + return ( + normalizeRuntimePathForComparison(parentPath) !== + normalizeRuntimePathForComparison(childPath) && isPathInsideOrEqual(parentPath, childPath) + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts new file mode 100644 index 00000000000..78b98f9b4d0 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts @@ -0,0 +1,176 @@ +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupBlocker } from '../../../../shared/workspace-cleanup' + +type ContextDetailKind = 'terminal' | 'editor' | 'browser' | 'diff' | 'agent' + +export function getWorkspaceCleanupBlockerLabel(blocker: WorkspaceCleanupBlocker): string { + switch (blocker) { + case 'main-worktree': + return translate( + 'auto.components.workspace.cleanup.candidateRow.mainWorkspaceBlocker', + 'Main workspace' + ) + case 'folder-repo': + return translate( + 'auto.components.workspace.cleanup.candidateRow.folderProjectBlocker', + 'Folder project' + ) + case 'pinned': + return translate('auto.components.workspace.cleanup.candidateRow.pinnedBlocker', 'Pinned') + case 'active-workspace': + return translate( + 'auto.components.workspace.cleanup.candidateRow.activeWorkspaceBlocker', + 'Active workspace' + ) + case 'running-terminal': + return translate( + 'auto.components.workspace.cleanup.candidateRow.runningTerminalBlocker', + 'Running terminal process' + ) + case 'terminal-liveness-unknown': + return translate( + 'auto.components.workspace.cleanup.candidateRow.terminalLivenessUnknownBlocker', + 'Terminal liveness unknown' + ) + case 'dirty-editor-buffer': + return translate( + 'auto.components.workspace.cleanup.candidateRow.dirtyEditorBufferBlocker', + 'Unsaved editor buffer' + ) + case 'volatile-local-context': + return translate( + 'auto.components.workspace.cleanup.candidateRow.volatileLocalContextBlocker', + 'Volatile local context' + ) + case 'recent-visible-context': + return translate( + 'auto.components.workspace.cleanup.candidateRow.recentVisibleContextBlocker', + 'Recently visited tabs' + ) + case 'live-agent': + return translate( + 'auto.components.workspace.cleanup.candidateRow.liveAgentBlocker', + 'Active agent' + ) + case 'ssh-disconnected': + return translate( + 'auto.components.workspace.cleanup.candidateRow.sshDisconnectedBlocker', + 'Remote unavailable' + ) + case 'git-status-error': + return translate( + 'auto.components.workspace.cleanup.candidateRow.gitStatusErrorBlocker', + 'Git status unavailable' + ) + case 'dirty-files': + return translate( + 'auto.components.workspace.cleanup.candidateRow.dirtyFilesBlocker', + 'Changed files' + ) + case 'unpushed-commits': + return getUnpushedCommitsLabel() + case 'unknown-base': + return translate( + 'auto.components.workspace.cleanup.candidateRow.unknownBaseBlocker', + 'Could not verify unpushed commits' + ) + case 'dismissed': + return translate('auto.components.workspace.cleanup.candidateRow.dismissedBlocker', 'Ignored') + } +} + +export function formatWorkspaceCleanupGitStatusLabel(label: string): string { + switch (label) { + case 'Clean': + return translate('auto.components.workspace.cleanup.candidateRow.cleanGit', 'Clean git') + case 'Dirty': + return translate('auto.components.workspace.cleanup.candidateRow.dirtyGit', 'Dirty git') + case 'Unpushed': + return getUnpushedCommitsLabel() + case 'Unknown': + return translate('auto.components.workspace.cleanup.candidateRow.gitUnknown', 'Git unknown') + } + return translate('auto.components.workspace.cleanup.candidateRow.gitUnknown', 'Git unknown') +} + +export function getNoUnpushedCommitsLabel(): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.noUnpushedCommits', + 'No unpushed commits' + ) +} + +export function getUnpushedCommitsLabel(): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.unpushedCommits', + 'Unpushed commits' + ) +} + +export function formatUnpushedCommitCount(count: number): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.unpushedCommitsCount', + 'Unpushed commits: {{value0}}', + { value0: count } + ) +} + +export function getUncommittedChangesLabel(): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.uncommittedChanges', + 'Uncommitted changes' + ) +} + +export function getGitStatusUnknownLabel(): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.gitStatusUnknown', + 'Git status unknown' + ) +} + +export function formatWorkspaceCleanupContextDetail( + kind: ContextDetailKind, + count: number +): string { + switch (kind) { + case 'terminal': + return translate( + 'auto.components.workspace.cleanup.candidateRow.terminalTabsCount', + 'Terminal tabs: {{value0}}', + { value0: count } + ) + case 'editor': + return translate( + 'auto.components.workspace.cleanup.candidateRow.editorTabsCount', + 'Editor tabs: {{value0}}', + { value0: count } + ) + case 'browser': + return translate( + 'auto.components.workspace.cleanup.candidateRow.browserTabsCount', + 'Browser tabs: {{value0}}', + { value0: count } + ) + case 'diff': + return translate( + 'auto.components.workspace.cleanup.candidateRow.diffNotesCount', + 'Diff notes: {{value0}}', + { value0: count } + ) + case 'agent': + return translate( + 'auto.components.workspace.cleanup.candidateRow.completedAgentsCount', + 'Completed agents: {{value0}}', + { value0: count } + ) + } +} + +export function formatWorkspaceCleanupContextCount(count: number): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.contextCount', + 'Context: {{value0}}', + { value0: count } + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts new file mode 100644 index 00000000000..3cd5309ac54 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { getDirtyGitLabel, shouldShowGitMetadataChip } from './workspace-cleanup-candidate-row-data' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' + +describe('workspace cleanup candidate row data', () => { + it('does not duplicate git status blockers as a separate git icon label', () => { + const gitStatusError = makeCandidate({ + blockers: ['git-status-error'], + git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + }) + const unknownBase = makeCandidate({ + blockers: ['unknown-base'], + git: { clean: true, upstreamAhead: null, upstreamBehind: null, checkedAt: 1 } + }) + + expect(getDirtyGitLabel(gitStatusError)).toBeNull() + expect(shouldShowGitMetadataChip(gitStatusError)).toBe(false) + expect(getDirtyGitLabel(unknownBase)).toBeNull() + expect(shouldShowGitMetadataChip(unknownBase)).toBe(false) + }) + + it('keeps the git metadata chip for ordinary clean rows', () => { + expect( + shouldShowGitMetadataChip( + makeCandidate({ + git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } + }) + ) + ).toBe(true) + }) + + it('suppresses the git metadata chip when the status pill already names git risk', () => { + expect( + shouldShowGitMetadataChip( + makeCandidate({ + blockers: ['unpushed-commits'], + git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + }) + ) + ).toBe(false) + expect( + shouldShowGitMetadataChip( + makeCandidate({ + tier: 'review', + blockers: [], + git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + }) + ) + ).toBe(false) + expect( + shouldShowGitMetadataChip( + makeCandidate({ + blockers: ['dirty-files'], + git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } + }) + ) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts new file mode 100644 index 00000000000..9e819d90017 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts @@ -0,0 +1,204 @@ +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + getWorkspaceCleanupGitLabel, + hasWorkspaceCleanupLocalContext, + type WorkspaceCleanupReviewInfo +} from './workspace-cleanup-presentation' +import { + formatUnpushedCommitCount, + formatWorkspaceCleanupContextCount, + formatWorkspaceCleanupContextDetail, + formatWorkspaceCleanupGitStatusLabel, + getGitStatusUnknownLabel, + getNoUnpushedCommitsLabel, + getUncommittedChangesLabel, + getUnpushedCommitsLabel, + getWorkspaceCleanupBlockerLabel +} from './workspace-cleanup-candidate-labels' + +export type StatusPillTone = 'neutral' | 'ready' | 'review' | 'destructive' + +export function getWorkspaceCleanupBlockerLabels(candidate: WorkspaceCleanupCandidate): string[] { + return candidate.blockers.map((blocker) => getWorkspaceCleanupBlockerLabel(blocker)) +} + +export function getCandidateStatus(candidate: WorkspaceCleanupCandidate): { + label: string + tone: StatusPillTone +} { + if (candidate.blockers.includes('dismissed')) { + return { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7', + 'Ignored' + ), + tone: 'neutral' + } + } + if (candidate.tier === 'ready') { + return { + label: candidate.reasons.includes('archived') + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.archivedStatus', + 'Archived' + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.readyStatus', + 'Ready' + ), + tone: 'ready' + } + } + if (candidate.blockers.length > 0) { + return { label: getWorkspaceCleanupBlockerLabel(candidate.blockers[0]), tone: 'neutral' } + } + if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { + return { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9623a5107d', + 'Unpushed commits' + ), + tone: 'review' + } + } + if (candidate.git.clean === false) { + return { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e97e4580c7', + 'Dirty' + ), + tone: 'review' + } + } + if (candidate.tier === 'review') { + return { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0a2e3c7cba', + 'Review' + ), + tone: 'review' + } + } + return { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.c4f4782c02', + 'Not suggested' + ), + tone: 'neutral' + } +} + +export function formatGitStatus(candidate: WorkspaceCleanupCandidate): string { + return formatWorkspaceCleanupGitStatusLabel(getWorkspaceCleanupGitLabel(candidate)) +} + +export function formatBranchSafetyDetails(candidate: WorkspaceCleanupCandidate): string[] { + const details: string[] = [] + if (candidate.git.upstreamAhead !== null) { + details.push( + candidate.git.upstreamAhead === 0 + ? getNoUnpushedCommitsLabel() + : formatUnpushedCommitCount(candidate.git.upstreamAhead) + ) + } + return details +} + +export function formatContextDetails(candidate: WorkspaceCleanupCandidate): string | null { + const parts: string[] = [] + if (candidate.localContext.terminalTabCount > 0) { + parts.push( + formatWorkspaceCleanupContextDetail('terminal', candidate.localContext.terminalTabCount) + ) + } + if (candidate.localContext.cleanEditorTabCount > 0) { + parts.push( + formatWorkspaceCleanupContextDetail('editor', candidate.localContext.cleanEditorTabCount) + ) + } + if (candidate.localContext.browserTabCount > 0) { + parts.push( + formatWorkspaceCleanupContextDetail('browser', candidate.localContext.browserTabCount) + ) + } + if (candidate.localContext.diffCommentCount > 0) { + parts.push(formatWorkspaceCleanupContextDetail('diff', candidate.localContext.diffCommentCount)) + } + if (candidate.localContext.retainedDoneAgentCount > 0) { + parts.push( + formatWorkspaceCleanupContextDetail('agent', candidate.localContext.retainedDoneAgentCount) + ) + } + return parts.length > 0 ? parts.join(', ') : null +} + +export function getDirtyGitLabel(candidate: WorkspaceCleanupCandidate): string | null { + if ( + candidate.blockers.includes('unknown-base') || + candidate.blockers.includes('git-status-error') + ) { + return null + } + if (candidate.blockers.includes('unpushed-commits')) { + if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { + return formatUnpushedCommitCount(candidate.git.upstreamAhead) + } + return getUnpushedCommitsLabel() + } + if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { + return formatUnpushedCommitCount(candidate.git.upstreamAhead) + } + if (candidate.git.clean === false) { + return getUncommittedChangesLabel() + } + if (candidate.git.clean == null) { + return getGitStatusUnknownLabel() + } + return null +} + +export function shouldShowGitMetadataChip(candidate: WorkspaceCleanupCandidate): boolean { + return ( + !candidate.blockers.includes('unknown-base') && + !candidate.blockers.includes('git-status-error') && + !hasGitStatusPill(candidate) + ) +} + +function hasGitStatusPill(candidate: WorkspaceCleanupCandidate): boolean { + if ( + candidate.blockers.includes('dirty-files') || + candidate.blockers.includes('unpushed-commits') + ) { + return true + } + if (candidate.blockers.length > 0 || candidate.tier === 'ready') { + return false + } + return (candidate.git.upstreamAhead ?? 0) > 0 || candidate.git.clean === false +} + +export function getReviewPillTone(reviewInfo: WorkspaceCleanupReviewInfo): StatusPillTone { + if (reviewInfo.state === 'open' || reviewInfo.state === 'draft') { + return 'review' + } + return 'neutral' +} + +export function getContextPillLabel(candidate: WorkspaceCleanupCandidate): string | null { + if (!hasWorkspaceCleanupLocalContext(candidate)) { + return null + } + return formatWorkspaceCleanupContextCount(getContextCount(candidate)) +} + +export function getContextCount(candidate: WorkspaceCleanupCandidate): number { + return ( + candidate.localContext.terminalTabCount + + candidate.localContext.cleanEditorTabCount + + candidate.localContext.browserTabCount + + candidate.localContext.diffCommentCount + + candidate.localContext.retainedDoneAgentCount + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-details.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-details.tsx new file mode 100644 index 00000000000..8862d2ad1e4 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-details.tsx @@ -0,0 +1,107 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { formatGitStatus } from './workspace-cleanup-candidate-row-data' + +type CandidateRowDetailsProps = { + blockers: string[] + branchSafetyDetails: string[] + candidate: WorkspaceCleanupCandidate + contextDetails: string | null + expanded: boolean +} + +export function CandidateRowDetails({ + blockers, + branchSafetyDetails, + candidate, + contextDetails, + expanded +}: CandidateRowDetailsProps): React.JSX.Element { + return ( +
+
+
+
+ + + + {branchSafetyDetails.slice(0, 1).map((detail) => ( + + ))} + {contextDetails ? ( + + ) : null} + {blockers.length > 0 ? ( + + ) : null} +
+
+ {candidate.path} +
+
+
+
+ ) +} + +function DetailLine({ + label, + mono = false, + value +}: { + label: string + mono?: boolean + value: string +}): React.JSX.Element { + return ( +
+ + {label} + + {value} +
+ ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx new file mode 100644 index 00000000000..b65e495a7ea --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CandidateRow } from './workspace-cleanup-candidate-row' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +describe('CandidateRow', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('hides selection and remove controls while the workspace is already deleting', () => { + const candidate = makeCandidate() + + act(() => { + root?.render( + + ) + }) + + expect(container?.querySelector(`[aria-label="Select ${candidate.displayName}"]`)).toBeNull() + expect(container?.querySelector(`[aria-label="Remove ${candidate.displayName}"]`)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx new file mode 100644 index 00000000000..04fb02d28c2 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx @@ -0,0 +1,342 @@ +import React from 'react' +import { + AlertTriangle, + Check, + ChevronDown, + Clock3, + EyeOff, + FileWarning, + GitBranch, + GitPullRequest, + Search, + SquareTerminal, + Trash2, + type LucideIcon +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { + canQueueWorkspaceCleanupCandidate, + type WorkspaceCleanupCandidate +} from '../../../../shared/workspace-cleanup' +import { + getWorkspaceCleanupGitLabel, + type WorkspaceCleanupReviewInfo +} from './workspace-cleanup-presentation' +import { CandidateRowDetails } from './workspace-cleanup-candidate-row-details' +import { + formatBranchSafetyDetails, + formatContextDetails, + formatGitStatus, + getCandidateStatus, + getContextCount, + getDirtyGitLabel, + getReviewPillTone, + getWorkspaceCleanupBlockerLabels, + shouldShowGitMetadataChip, + type StatusPillTone +} from './workspace-cleanup-candidate-row-data' +import { StatusPill } from './workspace-cleanup-status-pill' + +type CandidateRowProps = { + candidate: WorkspaceCleanupCandidate + expanded: boolean + failure?: string + last: boolean + lastActivityLabel: string + removing?: boolean + reviewInfo: WorkspaceCleanupReviewInfo + selected: boolean + onIgnore: (candidate: WorkspaceCleanupCandidate) => void + onRemove: (candidate: WorkspaceCleanupCandidate) => void + onToggleExpanded: (worktreeId: string) => void + onToggleSelected: (worktreeId: string) => void + onView: (candidate: WorkspaceCleanupCandidate) => void +} + +function MetadataIconChip({ + icon: Icon, + label, + value, + tone = 'neutral' +}: { + icon: LucideIcon + label: string + value?: string + tone?: StatusPillTone +}): React.JSX.Element { + return ( + + + + + + + {label} + + + ) +} + +export function CandidateRow({ + candidate, + expanded, + failure, + last, + lastActivityLabel, + removing = false, + reviewInfo, + selected, + onIgnore, + onRemove, + onToggleExpanded, + onToggleSelected, + onView +}: CandidateRowProps): React.JSX.Element { + const selectable = canQueueWorkspaceCleanupCandidate(candidate) && !removing + const ignored = candidate.blockers.includes('dismissed') + const blockers = getWorkspaceCleanupBlockerLabels(candidate) + const contextDetails = formatContextDetails(candidate) + const branchSafetyDetails = formatBranchSafetyDetails(candidate) + const status = getCandidateStatus(candidate) + const dirtyLabel = getDirtyGitLabel(candidate) + const showGitMetadataChip = shouldShowGitMetadataChip(candidate) + const contextCount = getContextCount(candidate) + const hasExpandableDetails = + blockers.length > 0 || + candidate.path.length > 0 || + candidate.branch.length > 0 || + contextDetails !== null || + branchSafetyDetails.length > 0 + + return ( +
+
+ {selectable ? ( + + ) : ( + +
+ ) +} + +function formatCompactActivityLabel(label: string): string { + if (label === 'Just now') { + return 'now' + } + return label.replace(/ ago$/, '') +} + +function getReviewTooltip(reviewInfo: WorkspaceCleanupReviewInfo): string { + const parts = [reviewInfo.label] + if (reviewInfo.state) { + parts.push(reviewInfo.state) + } + if (reviewInfo.title) { + parts.push(reviewInfo.title) + } + return parts.filter(Boolean).join(' · ') +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts new file mode 100644 index 00000000000..5b5f000c19c --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' +import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' + +describe('workspace cleanup removal candidates', () => { + it('excludes workspaces already being deleted', () => { + const deleting = makeCandidate({ worktreeId: 'repo-1::/repo/deleting' }) + const ready = makeCandidate({ worktreeId: 'repo-1::/repo/ready' }) + + expect( + filterWorkspaceCleanupRemovalCandidates([deleting, ready], { + [deleting.worktreeId]: { isDeleting: true } + }) + ).toEqual([ready]) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.ts new file mode 100644 index 00000000000..36c70257929 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.ts @@ -0,0 +1,13 @@ +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import type { WorktreeDeleteState } from '@/store/slices/worktrees' + +type DeletionFlagState = Pick + +export function filterWorkspaceCleanupRemovalCandidates( + candidates: readonly WorkspaceCleanupCandidate[], + deleteStateByWorktreeId: Record +): WorkspaceCleanupCandidate[] { + return candidates.filter( + (candidate) => deleteStateByWorktreeId[candidate.worktreeId]?.isDeleting !== true + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx new file mode 100644 index 00000000000..1e5786fda49 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import type { StatusPillTone } from './workspace-cleanup-candidate-row-data' + +export function StatusPill({ + children, + tone = 'neutral' +}: { + children: React.ReactNode + tone?: StatusPillTone +}): React.JSX.Element { + return ( + + {children} + + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 570c20c6c54..177a31b2850 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -210,7 +210,8 @@ }, "workspace": { "cleanup": { - "9d6e531da6": "Workspace no longer exists." + "9d6e531da6": "Workspace no longer exists.", + "changedSinceConfirmation": "Workspace changed after confirmation. Refresh to review it before removing." } }, "worktrees": { @@ -2339,7 +2340,61 @@ "gitFilter": "Git", "contextFilter": "Context", "sortBy": "Sort by", - "sortDirection": "Direction" + "sortDirection": "Direction", + "1d3503357d": "You can close this and come back while deletion continues.", + "4c2990886e": "{{value0}}/{{value1}} deleted", + "86ba852118": "{{value0}}, {{value1}} failed", + "7b7bde5181": "Checked workspaces so far: {{value0}}", + "deletingCount": "Deleting workspaces: {{value0}}", + "deleteCount": "Delete workspaces: {{value0}}?", + "selectedForDeletionCount": "Selected for deletion: {{value0}}", + "deleteButtonCount": "Delete {{value0}}", + "archivedStatus": "Archived", + "readyStatus": "Ready" + }, + "backgroundRemoval": { + "removed": "Removed workspaces: {{value0}}", + "failed": "Workspaces not removed: {{value0}}", + "error": "Workspace cleanup failed", + "skippedAncestor": "Skipped because a nested workspace could not be removed.", + "timedOut": "Removing {{value0}} is taking longer than expected. It will keep running in the background." + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "Commits", + "contextLabel": "Context", + "flagsLabel": "Flags", + "collapseDetails": "Collapse details", + "expandDetails": "Expand details", + "cleanGit": "Clean git", + "dirtyGit": "Dirty git", + "unpushedCommits": "Unpushed commits", + "gitUnknown": "Git unknown", + "gitStatusUnknown": "Git status unknown", + "noUnpushedCommits": "No unpushed commits", + "unpushedCommitsCount": "Unpushed commits: {{value0}}", + "uncommittedChanges": "Uncommitted changes", + "terminalTabsCount": "Terminal tabs: {{value0}}", + "editorTabsCount": "Editor tabs: {{value0}}", + "browserTabsCount": "Browser tabs: {{value0}}", + "diffNotesCount": "Diff notes: {{value0}}", + "completedAgentsCount": "Completed agents: {{value0}}", + "contextCount": "Context: {{value0}}", + "mainWorkspaceBlocker": "Main workspace", + "folderProjectBlocker": "Folder project", + "pinnedBlocker": "Pinned", + "activeWorkspaceBlocker": "Active workspace", + "runningTerminalBlocker": "Running terminal process", + "terminalLivenessUnknownBlocker": "Terminal liveness unknown", + "dirtyEditorBufferBlocker": "Unsaved editor buffer", + "volatileLocalContextBlocker": "Volatile local context", + "recentVisibleContextBlocker": "Recently visited tabs", + "liveAgentBlocker": "Active agent", + "sshDisconnectedBlocker": "Remote unavailable", + "gitStatusErrorBlocker": "Git status unavailable", + "dirtyFilesBlocker": "Changed files", + "unknownBaseBlocker": "Could not verify unpushed commits", + "dismissedBlocker": "Ignored" } } }, @@ -4027,7 +4082,8 @@ "runtimeHostProject": "Project on Orca server", "automationCreated": "Created by automation", "branchIdentity": "Branch", - "branchFolderPathIdentity": "Branch or folder path" + "branchFolderPathIdentity": "Branch or folder path", + "ef18787206": "Queued for deletion" }, "WorktreeCardAgents": { "1b0a156717": "Agents" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2543c3ce6d3..e4c1f062caf 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -210,7 +210,8 @@ }, "workspace": { "cleanup": { - "9d6e531da6": "El espacio de trabajo ya no existe." + "9d6e531da6": "El espacio de trabajo ya no existe.", + "changedSinceConfirmation": "El espacio de trabajo cambió después de la confirmación. Actualiza para revisarlo antes de eliminarlo." } }, "worktrees": { @@ -2339,7 +2340,61 @@ "gitFilter": "Git", "contextFilter": "Contexto", "sortBy": "Ordenar por", - "sortDirection": "Dirección" + "sortDirection": "Dirección", + "1d3503357d": "Puedes cerrar esto y volver mientras la eliminación continúa.", + "4c2990886e": "{{value0}}/{{value1}} eliminados", + "86ba852118": "{{value0}}, {{value1}} fallidos", + "7b7bde5181": "Espacios de trabajo comprobados hasta ahora: {{value0}}", + "deletingCount": "Eliminando espacios de trabajo: {{value0}}", + "deleteCount": "¿Eliminar espacios de trabajo: {{value0}}?", + "selectedForDeletionCount": "Seleccionados para eliminar: {{value0}}", + "deleteButtonCount": "Eliminar {{value0}}", + "archivedStatus": "Archivado", + "readyStatus": "Listo" + }, + "backgroundRemoval": { + "removed": "Espacios de trabajo eliminados: {{value0}}", + "failed": "Espacios de trabajo no eliminados: {{value0}}", + "error": "Error en la limpieza del espacio de trabajo", + "skippedAncestor": "Se omitió porque no se pudo eliminar un espacio de trabajo anidado.", + "timedOut": "La eliminación de {{value0}} está tardando más de lo esperado. Continuará ejecutándose en segundo plano." + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "Confirmaciones", + "contextLabel": "Contexto", + "flagsLabel": "Indicadores", + "collapseDetails": "Contraer detalles", + "expandDetails": "Expandir detalles", + "cleanGit": "Git limpio", + "dirtyGit": "Git con cambios", + "unpushedCommits": "Confirmaciones sin enviar", + "gitUnknown": "Git desconocido", + "gitStatusUnknown": "Estado de Git desconocido", + "noUnpushedCommits": "Sin confirmaciones sin enviar", + "unpushedCommitsCount": "Confirmaciones sin enviar: {{value0}}", + "uncommittedChanges": "Cambios sin confirmar", + "terminalTabsCount": "Pestañas de terminal: {{value0}}", + "editorTabsCount": "Pestañas de editor: {{value0}}", + "browserTabsCount": "Pestañas de navegador: {{value0}}", + "diffNotesCount": "Notas de diff: {{value0}}", + "completedAgentsCount": "Agentes completados: {{value0}}", + "contextCount": "Contexto: {{value0}}", + "mainWorkspaceBlocker": "Espacio de trabajo principal", + "folderProjectBlocker": "Proyecto de carpeta", + "pinnedBlocker": "Fijado", + "activeWorkspaceBlocker": "Espacio de trabajo activo", + "runningTerminalBlocker": "Proceso de terminal en ejecución", + "terminalLivenessUnknownBlocker": "Estado del terminal desconocido", + "dirtyEditorBufferBlocker": "Búfer del editor sin guardar", + "volatileLocalContextBlocker": "Contexto local volátil", + "recentVisibleContextBlocker": "Pestañas visitadas recientemente", + "liveAgentBlocker": "Agente activo", + "sshDisconnectedBlocker": "Remoto no disponible", + "gitStatusErrorBlocker": "Estado de Git no disponible", + "dirtyFilesBlocker": "Archivos modificados", + "unknownBaseBlocker": "No se pudieron verificar confirmaciones sin enviar", + "dismissedBlocker": "Ignorado" } } }, @@ -4004,7 +4059,8 @@ "runtimeHostProject": "Proyecto en servidor de Orca", "automationCreated": "Creado por automatización", "branchIdentity": "Rama", - "branchFolderPathIdentity": "Rama o ruta de carpeta" + "branchFolderPathIdentity": "Rama o ruta de carpeta", + "ef18787206": "En cola para eliminación" }, "WorktreeCardAgents": { "1b0a156717": "Agents" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9727ae4adc4..01e1c9d6973 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -210,7 +210,8 @@ }, "workspace": { "cleanup": { - "9d6e531da6": "ワークスペースはもう存在しません。" + "9d6e531da6": "ワークスペースはもう存在しません。", + "changedSinceConfirmation": "確認後にワークスペースが変更されました。削除する前に更新して確認してください。" } }, "worktrees": { @@ -2339,7 +2340,61 @@ "gitFilter": "Git", "contextFilter": "コンテクスト", "sortBy": "並べ替え", - "sortDirection": "方向" + "sortDirection": "方向", + "1d3503357d": "削除の続行中にこれを閉じて後で戻れます。", + "4c2990886e": "{{value0}}/{{value1}} 件を削除済み", + "86ba852118": "{{value0}}、{{value1}} 件が失敗", + "7b7bde5181": "これまでに確認したワークスペース: {{value0}}", + "deletingCount": "削除中のワークスペース: {{value0}}", + "deleteCount": "ワークスペースを削除しますか: {{value0}}?", + "selectedForDeletionCount": "削除対象として選択済み: {{value0}}", + "deleteButtonCount": "{{value0}} 件を削除", + "archivedStatus": "アーカイブ済み", + "readyStatus": "準備完了" + }, + "backgroundRemoval": { + "removed": "削除済みワークスペース: {{value0}}", + "failed": "削除できなかったワークスペース: {{value0}}", + "error": "ワークスペースのクリーンアップに失敗しました", + "skippedAncestor": "ネストされたワークスペースを削除できなかったためスキップしました。", + "timedOut": "{{value0}} の削除に予想より時間がかかっています。バックグラウンドで継続されます。" + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "コミット", + "contextLabel": "コンテキスト", + "flagsLabel": "フラグ", + "collapseDetails": "詳細を折りたたむ", + "expandDetails": "詳細を展開", + "cleanGit": "Git はクリーン", + "dirtyGit": "Git に変更あり", + "unpushedCommits": "未プッシュのコミット", + "gitUnknown": "Git 不明", + "gitStatusUnknown": "Git 状態不明", + "noUnpushedCommits": "未プッシュのコミットなし", + "unpushedCommitsCount": "未プッシュのコミット: {{value0}}", + "uncommittedChanges": "未コミットの変更", + "terminalTabsCount": "ターミナルタブ: {{value0}}", + "editorTabsCount": "エディタタブ: {{value0}}", + "browserTabsCount": "ブラウザタブ: {{value0}}", + "diffNotesCount": "差分メモ: {{value0}}", + "completedAgentsCount": "完了済みエージェント: {{value0}}", + "contextCount": "コンテキスト: {{value0}}", + "mainWorkspaceBlocker": "メインワークスペース", + "folderProjectBlocker": "フォルダープロジェクト", + "pinnedBlocker": "ピン留め済み", + "activeWorkspaceBlocker": "アクティブなワークスペース", + "runningTerminalBlocker": "実行中のターミナルプロセス", + "terminalLivenessUnknownBlocker": "ターミナルの状態不明", + "dirtyEditorBufferBlocker": "未保存のエディタバッファ", + "volatileLocalContextBlocker": "揮発性のローカルコンテキスト", + "recentVisibleContextBlocker": "最近表示したタブ", + "liveAgentBlocker": "アクティブなエージェント", + "sshDisconnectedBlocker": "リモート利用不可", + "gitStatusErrorBlocker": "Git 状態を利用できません", + "dirtyFilesBlocker": "変更済みファイル", + "unknownBaseBlocker": "未プッシュのコミットを確認できませんでした", + "dismissedBlocker": "無視済み" } } }, @@ -3985,7 +4040,8 @@ "runtimeHostProject": "Orca サーバー上のプロジェクト", "automationCreated": "自動化により作成", "branchIdentity": "ブランチ", - "branchFolderPathIdentity": "ブランチまたはフォルダパス" + "branchFolderPathIdentity": "ブランチまたはフォルダパス", + "ef18787206": "削除待ち" }, "WorktreeCardAgents": { "1b0a156717": "エージェント" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index bff86bf9af6..203fa2c5789 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -210,7 +210,8 @@ }, "workspace": { "cleanup": { - "9d6e531da6": "워크스페이스가 더 이상 존재하지 않습니다." + "9d6e531da6": "워크스페이스가 더 이상 존재하지 않습니다.", + "changedSinceConfirmation": "확인 후 워크스페이스가 변경되었습니다. 제거하기 전에 새로 고쳐 검토하세요." } }, "worktrees": { @@ -2339,7 +2340,61 @@ "gitFilter": "Git", "contextFilter": "문맥", "sortBy": "정렬 기준", - "sortDirection": "방향" + "sortDirection": "방향", + "1d3503357d": "삭제가 계속되는 동안 이 창을 닫고 나중에 돌아올 수 있습니다.", + "4c2990886e": "{{value0}}/{{value1}}개 삭제됨", + "86ba852118": "{{value0}}, {{value1}}개 실패", + "7b7bde5181": "지금까지 확인한 워크스페이스: {{value0}}", + "deletingCount": "삭제 중인 워크스페이스: {{value0}}", + "deleteCount": "워크스페이스 삭제: {{value0}}?", + "selectedForDeletionCount": "삭제 대상으로 선택됨: {{value0}}", + "deleteButtonCount": "{{value0}}개 삭제", + "archivedStatus": "보관됨", + "readyStatus": "준비됨" + }, + "backgroundRemoval": { + "removed": "삭제된 워크스페이스: {{value0}}", + "failed": "삭제되지 않은 워크스페이스: {{value0}}", + "error": "워크스페이스 정리 실패", + "skippedAncestor": "중첩된 워크스페이스를 제거할 수 없어 건너뛰었습니다.", + "timedOut": "{{value0}} 제거가 예상보다 오래 걸리고 있습니다. 백그라운드에서 계속 실행됩니다." + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "커밋", + "contextLabel": "문맥", + "flagsLabel": "플래그", + "collapseDetails": "세부 정보 접기", + "expandDetails": "세부 정보 펼치기", + "cleanGit": "Git 깨끗함", + "dirtyGit": "Git 변경 있음", + "unpushedCommits": "푸시되지 않은 커밋", + "gitUnknown": "Git 알 수 없음", + "gitStatusUnknown": "Git 상태 알 수 없음", + "noUnpushedCommits": "푸시되지 않은 커밋 없음", + "unpushedCommitsCount": "푸시되지 않은 커밋: {{value0}}", + "uncommittedChanges": "커밋되지 않은 변경 사항", + "terminalTabsCount": "터미널 탭: {{value0}}", + "editorTabsCount": "편집기 탭: {{value0}}", + "browserTabsCount": "브라우저 탭: {{value0}}", + "diffNotesCount": "Diff 메모: {{value0}}", + "completedAgentsCount": "완료된 에이전트: {{value0}}", + "contextCount": "문맥: {{value0}}", + "mainWorkspaceBlocker": "기본 워크스페이스", + "folderProjectBlocker": "폴더 프로젝트", + "pinnedBlocker": "고정됨", + "activeWorkspaceBlocker": "활성 워크스페이스", + "runningTerminalBlocker": "실행 중인 터미널 프로세스", + "terminalLivenessUnknownBlocker": "터미널 상태 알 수 없음", + "dirtyEditorBufferBlocker": "저장되지 않은 편집기 버퍼", + "volatileLocalContextBlocker": "휘발성 로컬 문맥", + "recentVisibleContextBlocker": "최근 방문한 탭", + "liveAgentBlocker": "활성 에이전트", + "sshDisconnectedBlocker": "원격을 사용할 수 없음", + "gitStatusErrorBlocker": "Git 상태를 사용할 수 없음", + "dirtyFilesBlocker": "변경된 파일", + "unknownBaseBlocker": "푸시되지 않은 커밋을 확인할 수 없음", + "dismissedBlocker": "무시됨" } } }, @@ -3985,7 +4040,8 @@ "runtimeHostProject": "Orca 서버의 프로젝트", "automationCreated": "자동화로 생성됨", "branchIdentity": "브랜치", - "branchFolderPathIdentity": "브랜치 또는 폴더 경로" + "branchFolderPathIdentity": "브랜치 또는 폴더 경로", + "ef18787206": "삭제 대기 중" }, "WorktreeCardAgents": { "1b0a156717": "에이전트" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a0b21163a78..cda02d6db46 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -210,7 +210,8 @@ }, "workspace": { "cleanup": { - "9d6e531da6": "工作区不再存在。" + "9d6e531da6": "工作区不再存在。", + "changedSinceConfirmation": "工作区在确认后发生了更改。请刷新并在删除前重新检查。" } }, "worktrees": { @@ -2339,7 +2340,61 @@ "gitFilter": "Git", "contextFilter": "上下文", "sortBy": "排序依据", - "sortDirection": "方向" + "sortDirection": "方向", + "1d3503357d": "删除继续期间,你可以关闭此窗口并稍后回来。", + "4c2990886e": "已删除 {{value0}}/{{value1}}", + "86ba852118": "{{value0}},{{value1}} 个失败", + "7b7bde5181": "到目前为止已检查工作区:{{value0}}", + "deletingCount": "正在删除工作区:{{value0}}", + "deleteCount": "删除工作区:{{value0}}?", + "selectedForDeletionCount": "已选择删除:{{value0}}", + "deleteButtonCount": "删除 {{value0}}", + "archivedStatus": "已归档", + "readyStatus": "就绪" + }, + "backgroundRemoval": { + "removed": "已删除工作区:{{value0}}", + "failed": "未删除工作区:{{value0}}", + "error": "工作区清理失败", + "skippedAncestor": "由于无法删除嵌套工作区,已跳过。", + "timedOut": "删除 {{value0}} 的时间比预期长。它将在后台继续运行。" + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "提交", + "contextLabel": "上下文", + "flagsLabel": "标记", + "collapseDetails": "收起详情", + "expandDetails": "展开详情", + "cleanGit": "Git 干净", + "dirtyGit": "Git 有更改", + "unpushedCommits": "未推送提交", + "gitUnknown": "Git 未知", + "gitStatusUnknown": "Git 状态未知", + "noUnpushedCommits": "没有未推送提交", + "unpushedCommitsCount": "未推送提交:{{value0}}", + "uncommittedChanges": "未提交的更改", + "terminalTabsCount": "终端标签页:{{value0}}", + "editorTabsCount": "编辑器标签页:{{value0}}", + "browserTabsCount": "浏览器标签页:{{value0}}", + "diffNotesCount": "Diff 备注:{{value0}}", + "completedAgentsCount": "已完成智能体:{{value0}}", + "contextCount": "上下文:{{value0}}", + "mainWorkspaceBlocker": "主工作区", + "folderProjectBlocker": "文件夹项目", + "pinnedBlocker": "已固定", + "activeWorkspaceBlocker": "活动工作区", + "runningTerminalBlocker": "正在运行的终端进程", + "terminalLivenessUnknownBlocker": "终端状态未知", + "dirtyEditorBufferBlocker": "未保存的编辑器缓冲区", + "volatileLocalContextBlocker": "易变本地上下文", + "recentVisibleContextBlocker": "最近访问的标签页", + "liveAgentBlocker": "活动智能体", + "sshDisconnectedBlocker": "远程不可用", + "gitStatusErrorBlocker": "Git 状态不可用", + "dirtyFilesBlocker": "已更改文件", + "unknownBaseBlocker": "无法验证未推送提交", + "dismissedBlocker": "已忽略" } } }, @@ -3985,7 +4040,8 @@ "runtimeHostProject": "Orca 服务器上的项目", "automationCreated": "由自动化创建", "branchIdentity": "分支", - "branchFolderPathIdentity": "分支或文件夹路径" + "branchFolderPathIdentity": "分支或文件夹路径", + "ef18787206": "已排队删除" }, "WorktreeCardAgents": { "1b0a156717": "智能体" diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index dd4b531fbd8..e8f38d53cfe 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -216,6 +216,37 @@ describe('removeWorktree cascade', () => { }) }) + it('can suppress preserved branch warning toasts for batched cleanup removal', async () => { + const store = createTestStore() + const worktreeId = 'repo1::/path/wt1' + mockApi.worktrees.remove.mockResolvedValueOnce({ + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }) + + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: '/path/wt1', + displayName: 'Review cleanup' + }) + ] + } + }) + + const result = await store + .getState() + .removeWorktree(worktreeId, false, { suppressPreservedBranchToast: true }) + + expect(result).toEqual({ + ok: true, + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }) + expect(toast.warning).not.toHaveBeenCalled() + }) + it('sets delete state with dirty/untracked error and canForceDelete=true on failure', async () => { const store = createTestStore() const worktreeId = 'repo1::/path/wt1' @@ -270,6 +301,44 @@ describe('removeWorktree cascade', () => { }) }) + it('marks multiple worktrees queued for deletion in one optimistic state update', () => { + const store = createTestStore() + const first = 'repo1::/path/wt1' + const second = 'repo1::/path/wt2' + + seedStore(store, { + deleteStateByWorktreeId: { + [first]: { isDeleting: false, error: 'old failure', canForceDelete: true } + } + }) + + store.getState().markWorktreesQueuedForDeletion([first, second, first]) + + expect(store.getState().deleteStateByWorktreeId).toMatchObject({ + [first]: { isDeleting: true, phase: 'queued', error: null, canForceDelete: false }, + [second]: { isDeleting: true, phase: 'queued', error: null, canForceDelete: false } + }) + }) + + it('keeps active deletion state when cleanup queues stale rows', () => { + const store = createTestStore() + const active = 'repo1::/path/deleting' + const queued = 'repo1::/path/queued' + + seedStore(store, { + deleteStateByWorktreeId: { + [active]: { isDeleting: true, phase: 'deleting', error: null, canForceDelete: false } + } + }) + + store.getState().markWorktreesQueuedForDeletion([active, queued]) + + expect(store.getState().deleteStateByWorktreeId).toMatchObject({ + [active]: { isDeleting: true, phase: 'deleting', error: null, canForceDelete: false }, + [queued]: { isDeleting: true, phase: 'queued', error: null, canForceDelete: false } + }) + }) + it('offers force delete for Electron-wrapped local dirty preflight errors', async () => { const store = createTestStore() const worktreeId = 'repo1::/workspace/feature-wt' diff --git a/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts new file mode 100644 index 00000000000..745e1a32298 --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts @@ -0,0 +1,431 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' +import { enrichWorkspaceCleanupCandidates } from './workspace-cleanup' +import { + NOW, + WORKTREE_ID, + createCleanupTestStore, + installWorkspaceCleanupApi, + makeCandidate, + makeState +} from './workspace-cleanup-slice-test-harness' + +describe('workspace cleanup removal and protection', () => { + it('preflights cleanup removals concurrently and deletes nested workspaces globally deepest first', async () => { + let activePreflights = 0 + let maxActivePreflights = 0 + let activeDeletes = 0 + let maxActiveDeletes = 0 + const deleteOrder: string[] = [] + const candidates = [ + makeCandidate({ + worktreeId: 'repo-a::/repo/parent', + repoId: 'repo-a', + path: '/repo/parent', + displayName: 'parent' + }), + makeCandidate({ + worktreeId: 'repo-b::/repo/parent/child', + repoId: 'repo-b', + path: '/repo/parent/child', + displayName: 'child' + }), + makeCandidate({ + worktreeId: 'repo-c::/other', + repoId: 'repo-c', + path: '/other', + displayName: 'other', + git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }, + blockers: ['git-status-error'] + }) + ] + const candidateById = new Map(candidates.map((candidate) => [candidate.worktreeId, candidate])) + const scan = vi.fn(async (args?: { worktreeId?: string }) => { + activePreflights += 1 + maxActivePreflights = Math.max(maxActivePreflights, activePreflights) + await new Promise((resolve) => setTimeout(resolve, 5)) + activePreflights -= 1 + return { + scannedAt: NOW, + candidates: args?.worktreeId ? [candidateById.get(args.worktreeId)!] : [], + errors: [] + } satisfies WorkspaceCleanupScanResult + }) + installWorkspaceCleanupApi(scan) + + const removeWorktree = vi.fn(async (worktreeId: string) => { + deleteOrder.push(worktreeId) + activeDeletes += 1 + maxActiveDeletes = Math.max(maxActiveDeletes, activeDeletes) + await new Promise((resolve) => setTimeout(resolve, 5)) + activeDeletes -= 1 + return { ok: true as const } + }) + const store = createCleanupTestStore(removeWorktree) + store.setState({ + workspaceCleanupScan: { scannedAt: NOW, candidates, errors: [] } + } as Partial) + + await expect( + store + .getState() + .removeWorkspaceCleanupCandidates(candidates.map((candidate) => candidate.worktreeId)) + ).resolves.toEqual({ + removedIds: expect.arrayContaining(candidates.map((candidate) => candidate.worktreeId)), + failures: [] + }) + + expect(maxActivePreflights).toBeGreaterThan(1) + expect(maxActiveDeletes).toBe(1) + expect(deleteOrder).toEqual([ + 'repo-b::/repo/parent/child', + 'repo-a::/repo/parent', + 'repo-c::/other' + ]) + expect(removeWorktree).toHaveBeenCalledWith('repo-c::/other', true, { + suppressPreservedBranchToast: true + }) + expect(store.getState().workspaceCleanupScan?.candidates).toEqual([]) + }) + + it('demotes an active suggested workspace when it was not viewed from cleanup', async () => { + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate()], + makeState({ activeWorktreeId: WORKTREE_ID }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.blockers).toContain('active-workspace') + }) + + it('keeps a viewed active workspace visible but not removable', async () => { + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate()], + makeState({ + activeWorktreeId: WORKTREE_ID, + workspaceCleanupViewedCandidates: { + [WORKTREE_ID]: { + viewedAt: Date.now(), + fingerprint: 'fingerprint-1', + wasSuggested: true + } + } + }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.selectedByDefault).toBe(false) + expect(candidate.blockers).toContain('active-workspace') + }) + + it('does not preserve the cleanup view exception after the row changes', async () => { + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate({ fingerprint: 'fingerprint-2' })], + makeState({ + activeWorktreeId: WORKTREE_ID, + workspaceCleanupViewedCandidates: { + [WORKTREE_ID]: { + viewedAt: Date.now(), + fingerprint: 'fingerprint-1', + wasSuggested: true + } + } + }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.blockers).toContain('active-workspace') + }) + + it('protects recently visible old workspaces with open context', async () => { + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate()], + makeState({ + openFiles: [ + { + id: 'file-1', + worktreeId: WORKTREE_ID, + filePath: '/tmp/old-workspace/src/app.ts', + relativePath: 'src/app.ts', + language: 'typescript', + isDirty: false + } + ] as AppState['openFiles'], + lastVisitedAtByWorktreeId: { + [WORKTREE_ID]: Date.now() + } + }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.selectedByDefault).toBe(false) + expect(candidate.blockers).toContain('recent-visible-context') + }) + + it('uses current renderer state after async delete preflight scan resolves', async () => { + let resolveScan: (value: WorkspaceCleanupScanResult) => void + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + ;(globalThis as { window: unknown }).window = { + api: { + workspaceCleanup: { + scan: vi.fn( + (): Promise => + new Promise((resolve) => { + resolveScan = resolve + }) + ), + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ + hasKillableProcesses: false + }) + } + } + } + + const removal = store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID]) + store.setState({ activeWorktreeId: WORKTREE_ID }) + resolveScan!({ scannedAt: NOW, candidates: [makeCandidate()], errors: [] }) + + await expect(removal).resolves.toEqual({ + removedIds: [WORKTREE_ID], + failures: [] + }) + expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false, { + suppressPreservedBranchToast: true + }) + }) + + it('defers git checks for locally active workspaces on initial scans', async () => { + const scan = vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [], + errors: [] + } satisfies WorkspaceCleanupScanResult) + ;(globalThis as { window: unknown }).window = { + api: { + workspaceCleanup: { + scan, + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ + hasKillableProcesses: false + }) + } + } + } + + const store = createCleanupTestStore() + store.setState({ + activeWorktreeId: WORKTREE_ID, + tabsByWorktree: { + 'repo1::/tmp/terminal-workspace': [ + { id: 'tab-1', title: 'zsh' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + await store.getState().scanWorkspaceCleanup() + + expect(scan).toHaveBeenCalledWith( + { + skipGitWorktreeIds: expect.arrayContaining([WORKTREE_ID, 'repo1::/tmp/terminal-workspace']) + }, + expect.any(Function) + ) + }) + + it('does not defer git checks for focused remove preflights', async () => { + const scan = vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [makeCandidate()], + errors: [] + } satisfies WorkspaceCleanupScanResult) + ;(globalThis as { window: unknown }).window = { + api: { + workspaceCleanup: { + scan, + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ + hasKillableProcesses: false + }) + } + } + } + + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + store.setState({ activeWorktreeId: 'repo1::/tmp/other-workspace' } as Partial) + + await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID]) + + expect(scan).toHaveBeenCalledWith({ worktreeId: WORKTREE_ID }) + }) + + it('lets explicitly selected not-suggested workspaces reach the removal path', async () => { + const scan = vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [makeCandidate()], + errors: [] + } satisfies WorkspaceCleanupScanResult) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + ;(globalThis as { window: unknown }).window = { + api: { + workspaceCleanup: { + scan, + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ + hasKillableProcesses: true + }) + } + } + } + + const store = createCleanupTestStore(removeWorktree) + + store.setState({ + tabsByWorktree: { + [WORKTREE_ID]: [{ id: 'tab-1', title: 'zsh' }] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + await expect(store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])).resolves.toEqual( + { + removedIds: [WORKTREE_ID], + failures: [] + } + ) + expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false, { + suppressPreservedBranchToast: true + }) + }) + + it('fails a queued removal that now needs a force the user never approved', async () => { + const approvedCandidate = makeCandidate() + const dirtySinceConfirmation = makeCandidate({ + tier: 'review', + blockers: ['dirty-files'], + git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW } + }) + const scan = vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [dirtySinceConfirmation], + errors: [] + } satisfies WorkspaceCleanupScanResult) + installWorkspaceCleanupApi(scan) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + await expect( + store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [approvedCandidate] + }) + ).resolves.toEqual({ + removedIds: [], + failures: [ + { + worktreeId: WORKTREE_ID, + displayName: 'old-workspace', + message: 'Workspace changed after confirmation. Refresh to review it before removing.' + } + ] + }) + expect(removeWorktree).not.toHaveBeenCalled() + }) + + it('still force-removes rows whose approved candidate already carried git risk', async () => { + const approvedCandidate = makeCandidate({ + tier: 'review', + blockers: ['dirty-files'], + git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW } + }) + const scan = vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [approvedCandidate], + errors: [] + } satisfies WorkspaceCleanupScanResult) + installWorkspaceCleanupApi(scan) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + await expect( + store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [approvedCandidate] + }) + ).resolves.toEqual({ removedIds: [WORKTREE_ID], failures: [] }) + expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, true, { + suppressPreservedBranchToast: true + }) + }) + + it('protects old workspaces when an agent process is still foregrounded', async () => { + ;(globalThis as { window: unknown }).window = { + api: { + pty: { + hasChildProcesses: vi.fn().mockResolvedValue(true), + getForegroundProcess: vi.fn().mockResolvedValue('codex') + } + } + } + + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate()], + makeState({ + tabsByWorktree: { + [WORKTREE_ID]: [{ id: 'tab-1', title: 'zsh' }] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.selectedByDefault).toBe(false) + expect(candidate.blockers).toContain('running-terminal') + }) + + it('does not let an idle title in another tab mask a running agent process', async () => { + ;(globalThis as { window: unknown }).window = { + api: { + pty: { + hasChildProcesses: vi.fn(async (ptyId: string) => ptyId === 'pty-running'), + getForegroundProcess: vi.fn(async (ptyId: string) => + ptyId === 'pty-running' ? 'codex' : 'zsh' + ) + } + } + } + + const [candidate] = await enrichWorkspaceCleanupCandidates( + [makeCandidate()], + makeState({ + tabsByWorktree: { + [WORKTREE_ID]: [ + { id: 'tab-running', title: 'zsh' }, + { id: 'tab-idle', title: 'Codex done' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { + 'tab-running': ['pty-running'], + 'tab-idle': ['pty-idle'] + } + }), + { applyDismissals: false } + ) + + expect(candidate.tier).toBe('protected') + expect(candidate.selectedByDefault).toBe(false) + expect(candidate.blockers).toContain('running-terminal') + }) +}) diff --git a/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts b/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts new file mode 100644 index 00000000000..f38a10f86e5 --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts @@ -0,0 +1,598 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import type { + WorkspaceCleanupScanProgress, + WorkspaceCleanupScanResult +} from '../../../../shared/workspace-cleanup' +import { + NOW, + WORKTREE_ID, + createCleanupTestStore, + deferred, + installWorkspaceCleanupApi, + makeCandidate +} from './workspace-cleanup-slice-test-harness' + +describe('workspace cleanup scan progress', () => { + it('joins duplicate broad cleanup scans', async () => { + const pending = deferred() + const scan = vi.fn().mockReturnValue(pending.promise) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const first = store.getState().scanWorkspaceCleanup() + const second = store.getState().scanWorkspaceCleanup() + + expect(scan).toHaveBeenCalledTimes(1) + expect(store.getState().workspaceCleanupLoading).toBe(true) + + const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } + pending.resolve(result) + + await expect(Promise.all([first, second])).resolves.toEqual([result, result]) + expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(1) + expect(store.getState().workspaceCleanupLoading).toBe(false) + }) + + it('does not leave cleanup loading stuck when a reopen joins a just-settled scan', async () => { + const pending = deferred() + const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } + const scan = vi.fn().mockReturnValue(pending.promise) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + let joinedScan: Promise | null = null + + const unsubscribe = store.subscribe((state, previousState) => { + if ( + previousState.workspaceCleanupLoading && + !state.workspaceCleanupLoading && + joinedScan === null + ) { + joinedScan = state.scanWorkspaceCleanup() + } + }) + + const firstScan = store.getState().scanWorkspaceCleanup() + pending.resolve(result) + + await expect(firstScan).resolves.toEqual(result) + await expect(joinedScan).resolves.toEqual(result) + unsubscribe() + + expect(scan).toHaveBeenCalledTimes(1) + expect(store.getState().workspaceCleanupLoading).toBe(false) + }) + + it('shows scanned cleanup candidates before the final broad scan resolves', async () => { + const pending = deferred() + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const partialCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/partial' }) + const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const scanPromise = store.getState().scanWorkspaceCleanup() + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [partialCandidate], + errors: [] + }) + + expect(store.getState().workspaceCleanupLoading).toBe(true) + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress).toMatchObject({ + scannedWorktreeCount: 1, + totalWorktreeCount: 2 + }) + }) + expect(store.getState().workspaceCleanupScan?.candidates).toEqual([partialCandidate]) + + pending.resolve({ + scannedAt: NOW, + candidates: [partialCandidate, finalCandidate], + errors: [] + }) + + await expect(scanPromise).resolves.toEqual({ + scannedAt: NOW, + candidates: [partialCandidate, finalCandidate], + errors: [] + }) + expect(store.getState().workspaceCleanupLoading).toBe(false) + expect(store.getState().workspaceCleanupProgress).toMatchObject({ + scannedWorktreeCount: 2, + totalWorktreeCount: 2 + }) + }) + + it('does not re-probe previously enriched rows during cumulative progress updates', async () => { + const pending = deferred() + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) + const laterCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/later' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const hasChildProcesses = vi.fn().mockResolvedValue(false) + const getForegroundProcess = vi.fn().mockResolvedValue('zsh') + ;( + globalThis.window as unknown as { + api: { + pty?: { + hasChildProcesses: typeof hasChildProcesses + getForegroundProcess: typeof getForegroundProcess + } + } + } + ).api.pty = { hasChildProcesses, getForegroundProcess } + const store = createCleanupTestStore() + store.setState({ + tabsByWorktree: { + 'repo1::/tmp/terminal': [ + { id: 'tab-1', title: 'zsh' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + const scanPromise = store.getState().scanWorkspaceCleanup() + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [terminalCandidate], + errors: [], + candidateMode: 'append' + }) + + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1) + }) + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 2, + totalWorktreeCount: 2, + candidates: [laterCandidate], + errors: [], + candidateMode: 'append' + }) + + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2) + }) + expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(2) + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + + pending.resolve({ + scannedAt: NOW, + candidates: [terminalCandidate, laterCandidate], + errors: [] + }) + await scanPromise + + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + }) + + it('updates count-only append progress without replacing existing candidate rows', async () => { + const pending = deferred() + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const candidate = makeCandidate({ worktreeId: 'repo1::/tmp/alpha' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const scanPromise = store.getState().scanWorkspaceCleanup() + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [candidate], + errors: [], + candidateMode: 'append' + }) + + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1) + }) + const candidatesAfterFirstAppend = store.getState().workspaceCleanupScan?.candidates + + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 2, + totalWorktreeCount: 2, + candidates: [], + errors: [], + candidateMode: 'append' + }) + + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2) + }) + expect(store.getState().workspaceCleanupScan?.candidates).toBe(candidatesAfterFirstAppend) + + pending.resolve({ + scannedAt: NOW, + candidates: [candidate], + errors: [] + }) + await scanPromise + }) + + it('keeps append progress rows when terminal enrichment resolves out of order', async () => { + const pending = deferred() + const terminalProbe = deferred() + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) + const laterCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/later' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const hasChildProcesses = vi.fn().mockReturnValue(terminalProbe.promise) + const getForegroundProcess = vi.fn().mockResolvedValue('zsh') + ;( + globalThis.window as unknown as { + api: { + pty?: { + hasChildProcesses: typeof hasChildProcesses + getForegroundProcess: typeof getForegroundProcess + } + } + } + ).api.pty = { hasChildProcesses, getForegroundProcess } + const store = createCleanupTestStore() + store.setState({ + tabsByWorktree: { + 'repo1::/tmp/terminal': [ + { id: 'tab-1', title: 'zsh' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + const scanPromise = store.getState().scanWorkspaceCleanup() + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [terminalCandidate], + errors: [], + candidateMode: 'append' + }) + await vi.waitFor(() => { + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + }) + + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 2, + totalWorktreeCount: 2, + candidates: [laterCandidate], + errors: [], + candidateMode: 'append' + }) + await Promise.resolve() + expect(store.getState().workspaceCleanupProgress).toBeNull() + + terminalProbe.resolve(false) + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2) + }) + expect( + store.getState().workspaceCleanupScan?.candidates.map((candidate) => candidate.worktreeId) + ).toEqual(['repo1::/tmp/terminal', 'repo1::/tmp/later']) + + pending.resolve({ + scannedAt: NOW, + candidates: [terminalCandidate, laterCandidate], + errors: [] + }) + await scanPromise + }) + + it('publishes the final scan result without waiting for queued progress', async () => { + const pending = deferred() + const terminalProbe = deferred() + let scanSettled = false + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) + const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const hasChildProcesses = vi.fn().mockReturnValue(terminalProbe.promise) + const getForegroundProcess = vi.fn().mockResolvedValue('zsh') + ;( + globalThis.window as unknown as { + api: { + pty?: { + hasChildProcesses: typeof hasChildProcesses + getForegroundProcess: typeof getForegroundProcess + } + } + } + ).api.pty = { hasChildProcesses, getForegroundProcess } + const store = createCleanupTestStore() + store.setState({ + tabsByWorktree: { + 'repo1::/tmp/terminal': [ + { id: 'tab-1', title: 'zsh' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + const scanPromise = store + .getState() + .scanWorkspaceCleanup() + .finally(() => { + scanSettled = true + }) + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [terminalCandidate], + errors: [], + candidateMode: 'append' + }) + await vi.waitFor(() => { + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + }) + + pending.resolve({ + scannedAt: NOW, + candidates: [finalCandidate], + errors: [] + }) + await scanPromise + expect(scanSettled).toBe(true) + + terminalProbe.resolve(false) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect( + store.getState().workspaceCleanupScan?.candidates.map((candidate) => candidate.worktreeId) + ).toEqual(['repo1::/tmp/final']) + expect(store.getState().workspaceCleanupProgress).toMatchObject({ + scannedWorktreeCount: 1, + totalWorktreeCount: 1 + }) + }) + + it('ignores stale progress without replacing the active scan progress queue', async () => { + const firstPending = deferred() + const secondPending = deferred() + const terminalProbe = deferred() + const progressCallbacks: ((progress: WorkspaceCleanupScanProgress) => void)[] = [] + let secondScanSettled = false + const firstCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/first' }) + const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) + const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' }) + const scan = vi.fn((args, progressCallback) => { + progressCallbacks.push(progressCallback) + return args?.skipGitWorktreeIds?.includes('second') + ? secondPending.promise + : firstPending.promise + }) + installWorkspaceCleanupApi(scan) + const hasChildProcesses = vi.fn().mockReturnValue(terminalProbe.promise) + const getForegroundProcess = vi.fn().mockResolvedValue('zsh') + ;( + globalThis.window as unknown as { + api: { + pty?: { + hasChildProcesses: typeof hasChildProcesses + getForegroundProcess: typeof getForegroundProcess + } + } + } + ).api.pty = { hasChildProcesses, getForegroundProcess } + const store = createCleanupTestStore() + store.setState({ + tabsByWorktree: { + 'repo1::/tmp/terminal': [ + { id: 'tab-1', title: 'zsh' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + const firstScan = store.getState().scanWorkspaceCleanup({ skipGitWorktreeIds: ['first'] }) + const secondScan = store + .getState() + .scanWorkspaceCleanup({ skipGitWorktreeIds: ['second'] }) + .finally(() => { + secondScanSettled = true + }) + + progressCallbacks[1]?.({ + scanId: 'scan-2', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [terminalCandidate], + errors: [], + candidateMode: 'append' + }) + await vi.waitFor(() => { + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + }) + progressCallbacks[0]?.({ + scanId: 'scan-1', + scannedAt: NOW - 1, + scannedWorktreeCount: 1, + totalWorktreeCount: 1, + candidates: [firstCandidate], + errors: [], + candidateMode: 'append' + }) + + secondPending.resolve({ + scannedAt: NOW, + candidates: [terminalCandidate, finalCandidate], + errors: [] + }) + await Promise.resolve() + expect(secondScanSettled).toBe(false) + + terminalProbe.resolve(false) + await secondScan + firstPending.resolve({ scannedAt: NOW - 1, candidates: [firstCandidate], errors: [] }) + await firstScan + + expect( + store.getState().workspaceCleanupScan?.candidates.map((candidate) => candidate.worktreeId) + ).toEqual(['repo1::/tmp/terminal', 'repo1::/tmp/final']) + }) + + it('does not let an in-flight broad scan revive removed cleanup rows', async () => { + const firstBroadScan = deferred() + const secondBroadScan = deferred() + const candidate = makeCandidate() + const refreshedCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/refreshed' }) + const broadScans = [firstBroadScan, secondBroadScan] + const scan = vi.fn((args?: { worktreeId?: string }) => { + if (args?.worktreeId) { + return Promise.resolve({ scannedAt: NOW, candidates: [candidate], errors: [] }) + } + const nextBroadScan = broadScans.shift() + if (!nextBroadScan) { + throw new Error('unexpected broad scan') + } + return nextBroadScan.promise + }) + installWorkspaceCleanupApi(scan) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + store.setState({ + workspaceCleanupScan: { scannedAt: NOW - 1, candidates: [candidate], errors: [] } + } as Partial) + + const pendingRefresh = store.getState().scanWorkspaceCleanup() + await store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId]) + const replacementRefresh = store.getState().scanWorkspaceCleanup() + + expect(scan).toHaveBeenCalledTimes(3) + + secondBroadScan.resolve({ scannedAt: NOW, candidates: [refreshedCandidate], errors: [] }) + await expect(replacementRefresh).resolves.toMatchObject({ + candidates: [refreshedCandidate] + }) + + firstBroadScan.resolve({ scannedAt: NOW - 1, candidates: [candidate], errors: [] }) + await pendingRefresh + + expect(store.getState().workspaceCleanupLoading).toBe(false) + expect(store.getState().workspaceCleanupScan?.candidates).toEqual([refreshedCandidate]) + }) + + it('does not join broad cleanup scans with different explicit args', async () => { + const firstPending = deferred() + const secondPending = deferred() + const firstResult = { + scannedAt: NOW, + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/first' })], + errors: [] + } + const secondResult = { + scannedAt: NOW + 1, + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/second' })], + errors: [] + } + const scan = vi.fn((args?: { skipGitWorktreeIds?: string[] }) => + args?.skipGitWorktreeIds?.includes('repo1::/tmp/first') + ? firstPending.promise + : secondPending.promise + ) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const first = store + .getState() + .scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/first'] }) + const second = store + .getState() + .scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/second'] }) + + expect(scan).toHaveBeenCalledTimes(2) + secondPending.resolve(secondResult) + await second + expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult) + + firstPending.resolve(firstResult) + await expect(Promise.all([first, second])).resolves.toEqual([firstResult, secondResult]) + expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult) + }) + + it('keeps stale cleanup results visible after a broad refresh failure', async () => { + const previous = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } + const scan = vi + .fn() + .mockResolvedValueOnce(previous) + .mockRejectedValueOnce(new Error('scan failed')) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + await store.getState().scanWorkspaceCleanup() + await expect(store.getState().scanWorkspaceCleanup()).rejects.toThrow('scan failed') + + expect(store.getState().workspaceCleanupScan).toMatchObject(previous) + expect(store.getState().workspaceCleanupError).toBe('scan failed') + expect(store.getState().workspaceCleanupLoading).toBe(false) + }) + + it('keeps focused cleanup preflight scans separate from broad scans', async () => { + const broad = deferred() + const scan = vi.fn((args?: { worktreeId?: string }) => { + if (args?.worktreeId) { + return Promise.resolve({ + scannedAt: NOW + 1, + candidates: [makeCandidate({ worktreeId: args.worktreeId })], + errors: [] + } satisfies WorkspaceCleanupScanResult) + } + return broad.promise + }) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const broadScan = store.getState().scanWorkspaceCleanup() + const focusedScan = await store.getState().scanWorkspaceCleanup({ worktreeId: WORKTREE_ID }) + + expect(scan).toHaveBeenCalledTimes(2) + expect(focusedScan.candidates[0]?.worktreeId).toBe(WORKTREE_ID) + expect(store.getState().workspaceCleanupScan).toBeNull() + + broad.resolve({ scannedAt: NOW, candidates: [], errors: [] }) + await broadScan + expect(store.getState().workspaceCleanupScan?.scannedAt).toBe(NOW) + }) +}) diff --git a/src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts b/src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts new file mode 100644 index 00000000000..8e0f0ca83dc --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts @@ -0,0 +1,110 @@ +import { create } from 'zustand' +import { vi } from 'vitest' +import type { AppState } from '../types' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { createWorkspaceCleanupSlice } from './workspace-cleanup' + +export const WORKTREE_ID = 'repo1::/tmp/old-workspace' +export const NOW = 1_700_000_000_000 + +export function makeCandidate( + overrides: Partial = {} +): WorkspaceCleanupCandidate { + return { + worktreeId: WORKTREE_ID, + repoId: 'repo1', + repoName: 'Repo 1', + connectionId: null, + displayName: 'old-workspace', + branch: 'old-workspace', + path: '/tmp/old-workspace', + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'], + blockers: [], + lastActivityAt: NOW - 30 * 24 * 60 * 60 * 1000, + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: 0, + newestDiffCommentAt: null, + retainedDoneAgentCount: 0 + }, + git: { + clean: true, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: NOW + }, + fingerprint: 'fingerprint-1', + ...overrides + } +} + +export function makeState(overrides: Partial = {}): AppState { + return { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + openFiles: [], + editorDrafts: {}, + browserTabsByWorktree: {}, + retainedAgentsByPaneKey: {}, + activeWorktreeId: null, + agentStatusByPaneKey: {}, + runtimePaneTitlesByTabId: {}, + lastVisitedAtByWorktreeId: {}, + workspaceCleanupDismissals: {}, + workspaceCleanupViewedCandidates: {}, + ...overrides + } as AppState +} + +export function createCleanupTestStore(removeWorktree: ReturnType = vi.fn()) { + return create()( + (...a) => + ({ + tabsByWorktree: {}, + ptyIdsByTabId: {}, + openFiles: [], + editorDrafts: {}, + browserTabsByWorktree: {}, + retainedAgentsByPaneKey: {}, + activeWorktreeId: null, + agentStatusByPaneKey: {}, + runtimePaneTitlesByTabId: {}, + lastVisitedAtByWorktreeId: {}, + removeWorktree, + ...createWorkspaceCleanupSlice(...a) + }) as unknown as AppState + ) +} + +export function installWorkspaceCleanupApi(scan: ReturnType) { + ;(globalThis as { window: unknown }).window = { + api: { + workspaceCleanup: { + scan, + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ + hasKillableProcesses: false + }) + } + } + } +} + +export function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve + reject = innerReject + }) + return { promise, resolve, reject } +} diff --git a/src/renderer/src/store/slices/workspace-cleanup.test.ts b/src/renderer/src/store/slices/workspace-cleanup.test.ts deleted file mode 100644 index 3a5199e2adc..00000000000 --- a/src/renderer/src/store/slices/workspace-cleanup.test.ts +++ /dev/null @@ -1,726 +0,0 @@ -import { create } from 'zustand' -import { describe, expect, it, vi } from 'vitest' -import type { AppState } from '../types' -import type { - WorkspaceCleanupCandidate, - WorkspaceCleanupScanProgress, - WorkspaceCleanupScanResult -} from '../../../../shared/workspace-cleanup' -import { createWorkspaceCleanupSlice, enrichWorkspaceCleanupCandidates } from './workspace-cleanup' - -const WORKTREE_ID = 'repo1::/tmp/old-workspace' -const NOW = 1_700_000_000_000 - -function makeCandidate( - overrides: Partial = {} -): WorkspaceCleanupCandidate { - return { - worktreeId: WORKTREE_ID, - repoId: 'repo1', - repoName: 'Repo 1', - connectionId: null, - displayName: 'old-workspace', - branch: 'old-workspace', - path: '/tmp/old-workspace', - tier: 'ready', - selectedByDefault: true, - reasons: ['idle-clean'], - blockers: [], - lastActivityAt: NOW - 30 * 24 * 60 * 60 * 1000, - localContext: { - terminalTabCount: 0, - cleanEditorTabCount: 0, - browserTabCount: 0, - diffCommentCount: 0, - newestDiffCommentAt: null, - retainedDoneAgentCount: 0 - }, - git: { - clean: true, - upstreamAhead: 0, - upstreamBehind: 0, - checkedAt: NOW - }, - fingerprint: 'fingerprint-1', - ...overrides - } -} - -function makeState(overrides: Partial = {}): AppState { - return { - tabsByWorktree: {}, - ptyIdsByTabId: {}, - openFiles: [], - editorDrafts: {}, - browserTabsByWorktree: {}, - retainedAgentsByPaneKey: {}, - activeWorktreeId: null, - agentStatusByPaneKey: {}, - runtimePaneTitlesByTabId: {}, - lastVisitedAtByWorktreeId: {}, - workspaceCleanupDismissals: {}, - workspaceCleanupViewedCandidates: {}, - ...overrides - } as AppState -} - -function createCleanupTestStore(removeWorktree = vi.fn()) { - return create()( - (...a) => - ({ - tabsByWorktree: {}, - ptyIdsByTabId: {}, - openFiles: [], - editorDrafts: {}, - browserTabsByWorktree: {}, - retainedAgentsByPaneKey: {}, - activeWorktreeId: null, - agentStatusByPaneKey: {}, - runtimePaneTitlesByTabId: {}, - lastVisitedAtByWorktreeId: {}, - removeWorktree, - ...createWorkspaceCleanupSlice(...a) - }) as unknown as AppState - ) -} - -function installWorkspaceCleanupApi(scan: ReturnType) { - ;(globalThis as { window: unknown }).window = { - api: { - workspaceCleanup: { - scan, - dismiss: vi.fn().mockResolvedValue(undefined), - clearDismissals: vi.fn().mockResolvedValue(undefined), - hasKillableLocalProcesses: vi.fn().mockResolvedValue({ - hasKillableProcesses: false - }) - } - } - } -} - -function deferred(): { - promise: Promise - resolve: (value: T) => void - reject: (error: unknown) => void -} { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((innerResolve, innerReject) => { - resolve = innerResolve - reject = innerReject - }) - return { promise, resolve, reject } -} - -describe('workspace cleanup viewed rows', () => { - it('joins duplicate broad cleanup scans', async () => { - const pending = deferred() - const scan = vi.fn().mockReturnValue(pending.promise) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - - const first = store.getState().scanWorkspaceCleanup() - const second = store.getState().scanWorkspaceCleanup() - - expect(scan).toHaveBeenCalledTimes(1) - expect(store.getState().workspaceCleanupLoading).toBe(true) - - const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } - pending.resolve(result) - - await expect(Promise.all([first, second])).resolves.toEqual([result, result]) - expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(1) - expect(store.getState().workspaceCleanupLoading).toBe(false) - }) - - it('does not leave cleanup loading stuck when a reopen joins a just-settled scan', async () => { - const pending = deferred() - const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } - const scan = vi.fn().mockReturnValue(pending.promise) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - let joinedScan: Promise | null = null - - const unsubscribe = store.subscribe((state, previousState) => { - if ( - previousState.workspaceCleanupLoading && - !state.workspaceCleanupLoading && - joinedScan === null - ) { - joinedScan = state.scanWorkspaceCleanup() - } - }) - - const firstScan = store.getState().scanWorkspaceCleanup() - pending.resolve(result) - - await expect(firstScan).resolves.toEqual(result) - await expect(joinedScan).resolves.toEqual(result) - unsubscribe() - - expect(scan).toHaveBeenCalledTimes(1) - expect(store.getState().workspaceCleanupLoading).toBe(false) - }) - - it('shows scanned cleanup candidates before the final broad scan resolves', async () => { - const pending = deferred() - let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined - const partialCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/partial' }) - const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' }) - const scan = vi.fn((_args, progressCallback) => { - onProgress = progressCallback - return pending.promise - }) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - - const scanPromise = store.getState().scanWorkspaceCleanup() - onProgress?.({ - scanId: 'scan-1', - scannedAt: NOW, - scannedWorktreeCount: 1, - totalWorktreeCount: 2, - candidates: [partialCandidate], - errors: [] - }) - - expect(store.getState().workspaceCleanupLoading).toBe(true) - await vi.waitFor(() => { - expect(store.getState().workspaceCleanupProgress).toMatchObject({ - scannedWorktreeCount: 1, - totalWorktreeCount: 2 - }) - }) - expect(store.getState().workspaceCleanupScan?.candidates).toEqual([partialCandidate]) - - pending.resolve({ - scannedAt: NOW, - candidates: [partialCandidate, finalCandidate], - errors: [] - }) - - await expect(scanPromise).resolves.toEqual({ - scannedAt: NOW, - candidates: [partialCandidate, finalCandidate], - errors: [] - }) - expect(store.getState().workspaceCleanupLoading).toBe(false) - expect(store.getState().workspaceCleanupProgress).toMatchObject({ - scannedWorktreeCount: 2, - totalWorktreeCount: 2 - }) - }) - - it('does not re-probe previously enriched rows during cumulative progress updates', async () => { - const pending = deferred() - let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined - const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) - const laterCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/later' }) - const scan = vi.fn((_args, progressCallback) => { - onProgress = progressCallback - return pending.promise - }) - installWorkspaceCleanupApi(scan) - const hasChildProcesses = vi.fn().mockResolvedValue(false) - const getForegroundProcess = vi.fn().mockResolvedValue('zsh') - ;( - globalThis.window as unknown as { - api: { - pty?: { - hasChildProcesses: typeof hasChildProcesses - getForegroundProcess: typeof getForegroundProcess - } - } - } - ).api.pty = { hasChildProcesses, getForegroundProcess } - const store = createCleanupTestStore() - store.setState({ - tabsByWorktree: { - 'repo1::/tmp/terminal': [ - { id: 'tab-1', title: 'zsh' } - ] as AppState['tabsByWorktree'][string] - }, - ptyIdsByTabId: { 'tab-1': ['pty-1'] } - } as Partial) - - const scanPromise = store.getState().scanWorkspaceCleanup() - onProgress?.({ - scanId: 'scan-1', - scannedAt: NOW, - scannedWorktreeCount: 1, - totalWorktreeCount: 2, - candidates: [terminalCandidate], - errors: [], - candidateMode: 'append' - }) - - await vi.waitFor(() => { - expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1) - }) - expect(hasChildProcesses).toHaveBeenCalledTimes(1) - expect(getForegroundProcess).toHaveBeenCalledTimes(1) - - onProgress?.({ - scanId: 'scan-1', - scannedAt: NOW, - scannedWorktreeCount: 2, - totalWorktreeCount: 2, - candidates: [laterCandidate], - errors: [], - candidateMode: 'append' - }) - - await vi.waitFor(() => { - expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2) - }) - expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(2) - expect(hasChildProcesses).toHaveBeenCalledTimes(1) - expect(getForegroundProcess).toHaveBeenCalledTimes(1) - - pending.resolve({ - scannedAt: NOW, - candidates: [terminalCandidate, laterCandidate], - errors: [] - }) - await scanPromise - - expect(hasChildProcesses).toHaveBeenCalledTimes(1) - expect(getForegroundProcess).toHaveBeenCalledTimes(1) - }) - - it('does not join broad cleanup scans with different explicit args', async () => { - const firstPending = deferred() - const secondPending = deferred() - const firstResult = { - scannedAt: NOW, - candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/first' })], - errors: [] - } - const secondResult = { - scannedAt: NOW + 1, - candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/second' })], - errors: [] - } - const scan = vi.fn((args?: { skipGitWorktreeIds?: string[] }) => - args?.skipGitWorktreeIds?.includes('repo1::/tmp/first') - ? firstPending.promise - : secondPending.promise - ) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - - const first = store - .getState() - .scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/first'] }) - const second = store - .getState() - .scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/second'] }) - - expect(scan).toHaveBeenCalledTimes(2) - secondPending.resolve(secondResult) - await second - expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult) - - firstPending.resolve(firstResult) - await expect(Promise.all([first, second])).resolves.toEqual([firstResult, secondResult]) - expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult) - }) - - it('keeps stale cleanup results visible after a broad refresh failure', async () => { - const previous = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } - const scan = vi - .fn() - .mockResolvedValueOnce(previous) - .mockRejectedValueOnce(new Error('scan failed')) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - - await store.getState().scanWorkspaceCleanup() - await expect(store.getState().scanWorkspaceCleanup()).rejects.toThrow('scan failed') - - expect(store.getState().workspaceCleanupScan).toMatchObject(previous) - expect(store.getState().workspaceCleanupError).toBe('scan failed') - expect(store.getState().workspaceCleanupLoading).toBe(false) - }) - - it('keeps focused cleanup preflight scans separate from broad scans', async () => { - const broad = deferred() - const scan = vi.fn((args?: { worktreeId?: string }) => { - if (args?.worktreeId) { - return Promise.resolve({ - scannedAt: NOW + 1, - candidates: [makeCandidate({ worktreeId: args.worktreeId })], - errors: [] - } satisfies WorkspaceCleanupScanResult) - } - return broad.promise - }) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - - const broadScan = store.getState().scanWorkspaceCleanup() - const focusedScan = await store.getState().scanWorkspaceCleanup({ worktreeId: WORKTREE_ID }) - - expect(scan).toHaveBeenCalledTimes(2) - expect(focusedScan.candidates[0]?.worktreeId).toBe(WORKTREE_ID) - expect(store.getState().workspaceCleanupScan).toBeNull() - - broad.resolve({ scannedAt: NOW, candidates: [], errors: [] }) - await broadScan - expect(store.getState().workspaceCleanupScan?.scannedAt).toBe(NOW) - }) - - it('preflights cleanup removals concurrently and deletes nested workspaces globally deepest first', async () => { - let activePreflights = 0 - let maxActivePreflights = 0 - let activeDeletes = 0 - let maxActiveDeletes = 0 - const deleteOrder: string[] = [] - const candidates = [ - makeCandidate({ - worktreeId: 'repo-a::/repo/parent', - repoId: 'repo-a', - path: '/repo/parent', - displayName: 'parent' - }), - makeCandidate({ - worktreeId: 'repo-b::/repo/parent/child', - repoId: 'repo-b', - path: '/repo/parent/child', - displayName: 'child' - }), - makeCandidate({ - worktreeId: 'repo-c::/other', - repoId: 'repo-c', - path: '/other', - displayName: 'other', - git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }, - blockers: ['git-status-error'] - }) - ] - const candidateById = new Map(candidates.map((candidate) => [candidate.worktreeId, candidate])) - const scan = vi.fn(async (args?: { worktreeId?: string }) => { - activePreflights += 1 - maxActivePreflights = Math.max(maxActivePreflights, activePreflights) - await new Promise((resolve) => setTimeout(resolve, 5)) - activePreflights -= 1 - return { - scannedAt: NOW, - candidates: args?.worktreeId ? [candidateById.get(args.worktreeId)!] : [], - errors: [] - } satisfies WorkspaceCleanupScanResult - }) - installWorkspaceCleanupApi(scan) - - const removeWorktree = vi.fn(async (worktreeId: string) => { - deleteOrder.push(worktreeId) - activeDeletes += 1 - maxActiveDeletes = Math.max(maxActiveDeletes, activeDeletes) - await new Promise((resolve) => setTimeout(resolve, 5)) - activeDeletes -= 1 - return { ok: true as const } - }) - const store = createCleanupTestStore(removeWorktree) - store.setState({ - workspaceCleanupScan: { scannedAt: NOW, candidates, errors: [] } - } as Partial) - - await expect( - store - .getState() - .removeWorkspaceCleanupCandidates(candidates.map((candidate) => candidate.worktreeId)) - ).resolves.toEqual({ - removedIds: expect.arrayContaining(candidates.map((candidate) => candidate.worktreeId)), - failures: [] - }) - - expect(maxActivePreflights).toBeGreaterThan(1) - expect(maxActiveDeletes).toBe(1) - expect(deleteOrder).toEqual([ - 'repo-b::/repo/parent/child', - 'repo-a::/repo/parent', - 'repo-c::/other' - ]) - expect(removeWorktree).toHaveBeenCalledWith('repo-c::/other', true) - expect(store.getState().workspaceCleanupScan?.candidates).toEqual([]) - }) - - it('demotes an active suggested workspace when it was not viewed from cleanup', async () => { - const [candidate] = await enrichWorkspaceCleanupCandidates( - [makeCandidate()], - makeState({ activeWorktreeId: WORKTREE_ID }), - { applyDismissals: false } - ) - - expect(candidate.tier).toBe('protected') - expect(candidate.blockers).toContain('active-workspace') - }) - - it('keeps a viewed active workspace visible but not removable', async () => { - const [candidate] = await enrichWorkspaceCleanupCandidates( - [makeCandidate()], - makeState({ - activeWorktreeId: WORKTREE_ID, - workspaceCleanupViewedCandidates: { - [WORKTREE_ID]: { - viewedAt: Date.now(), - fingerprint: 'fingerprint-1', - wasSuggested: true - } - } - }), - { applyDismissals: false } - ) - - expect(candidate.tier).toBe('protected') - expect(candidate.selectedByDefault).toBe(false) - expect(candidate.blockers).toContain('active-workspace') - }) - - it('does not preserve the cleanup view exception after the row changes', async () => { - const [candidate] = await enrichWorkspaceCleanupCandidates( - [makeCandidate({ fingerprint: 'fingerprint-2' })], - makeState({ - activeWorktreeId: WORKTREE_ID, - workspaceCleanupViewedCandidates: { - [WORKTREE_ID]: { - viewedAt: Date.now(), - fingerprint: 'fingerprint-1', - wasSuggested: true - } - } - }), - { applyDismissals: false } - ) - - expect(candidate.tier).toBe('protected') - expect(candidate.blockers).toContain('active-workspace') - }) - - it('protects recently visible old workspaces with open context', async () => { - const [candidate] = await enrichWorkspaceCleanupCandidates( - [makeCandidate()], - makeState({ - openFiles: [ - { - id: 'file-1', - worktreeId: WORKTREE_ID, - filePath: '/tmp/old-workspace/src/app.ts', - relativePath: 'src/app.ts', - language: 'typescript', - isDirty: false - } - ] as AppState['openFiles'], - lastVisitedAtByWorktreeId: { - [WORKTREE_ID]: Date.now() - } - }), - { applyDismissals: false } - ) - - expect(candidate.tier).toBe('protected') - expect(candidate.selectedByDefault).toBe(false) - expect(candidate.blockers).toContain('recent-visible-context') - }) - - it('uses current renderer state after async delete preflight scan resolves', async () => { - let resolveScan: (value: WorkspaceCleanupScanResult) => void - const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) - const store = createCleanupTestStore(removeWorktree) - - ;(globalThis as { window: unknown }).window = { - api: { - workspaceCleanup: { - scan: vi.fn( - (): Promise => - new Promise((resolve) => { - resolveScan = resolve - }) - ), - dismiss: vi.fn().mockResolvedValue(undefined), - clearDismissals: vi.fn().mockResolvedValue(undefined), - hasKillableLocalProcesses: vi.fn().mockResolvedValue({ - hasKillableProcesses: false - }) - } - } - } - - const removal = store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID]) - store.setState({ activeWorktreeId: WORKTREE_ID }) - resolveScan!({ scannedAt: NOW, candidates: [makeCandidate()], errors: [] }) - - await expect(removal).resolves.toEqual({ - removedIds: [WORKTREE_ID], - failures: [] - }) - expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false) - }) - - it('defers git checks for locally active workspaces on initial scans', async () => { - const scan = vi.fn().mockResolvedValue({ - scannedAt: NOW, - candidates: [], - errors: [] - } satisfies WorkspaceCleanupScanResult) - ;(globalThis as { window: unknown }).window = { - api: { - workspaceCleanup: { - scan, - dismiss: vi.fn().mockResolvedValue(undefined), - clearDismissals: vi.fn().mockResolvedValue(undefined), - hasKillableLocalProcesses: vi.fn().mockResolvedValue({ - hasKillableProcesses: false - }) - } - } - } - - const store = createCleanupTestStore() - store.setState({ - activeWorktreeId: WORKTREE_ID, - tabsByWorktree: { - 'repo1::/tmp/terminal-workspace': [ - { id: 'tab-1', title: 'zsh' } - ] as AppState['tabsByWorktree'][string] - }, - ptyIdsByTabId: { 'tab-1': ['pty-1'] } - } as Partial) - - await store.getState().scanWorkspaceCleanup() - - expect(scan).toHaveBeenCalledWith( - { - skipGitWorktreeIds: expect.arrayContaining([WORKTREE_ID, 'repo1::/tmp/terminal-workspace']) - }, - expect.any(Function) - ) - }) - - it('does not defer git checks for focused remove preflights', async () => { - const scan = vi.fn().mockResolvedValue({ - scannedAt: NOW, - candidates: [makeCandidate()], - errors: [] - } satisfies WorkspaceCleanupScanResult) - ;(globalThis as { window: unknown }).window = { - api: { - workspaceCleanup: { - scan, - dismiss: vi.fn().mockResolvedValue(undefined), - clearDismissals: vi.fn().mockResolvedValue(undefined), - hasKillableLocalProcesses: vi.fn().mockResolvedValue({ - hasKillableProcesses: false - }) - } - } - } - - const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) - const store = createCleanupTestStore(removeWorktree) - store.setState({ activeWorktreeId: 'repo1::/tmp/other-workspace' } as Partial) - - await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID]) - - expect(scan).toHaveBeenCalledWith({ worktreeId: WORKTREE_ID }) - }) - - it('lets explicitly selected not-suggested workspaces reach the removal path', async () => { - const scan = vi.fn().mockResolvedValue({ - scannedAt: NOW, - candidates: [makeCandidate()], - errors: [] - } satisfies WorkspaceCleanupScanResult) - const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) - ;(globalThis as { window: unknown }).window = { - api: { - workspaceCleanup: { - scan, - dismiss: vi.fn().mockResolvedValue(undefined), - clearDismissals: vi.fn().mockResolvedValue(undefined), - hasKillableLocalProcesses: vi.fn().mockResolvedValue({ - hasKillableProcesses: true - }) - } - } - } - - const store = createCleanupTestStore(removeWorktree) - - store.setState({ - tabsByWorktree: { - [WORKTREE_ID]: [{ id: 'tab-1', title: 'zsh' }] as AppState['tabsByWorktree'][string] - }, - ptyIdsByTabId: { 'tab-1': ['pty-1'] } - } as Partial) - - await expect(store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])).resolves.toEqual( - { - removedIds: [WORKTREE_ID], - failures: [] - } - ) - expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false) - }) - - it('protects old workspaces when an agent process is still foregrounded', async () => { - ;(globalThis as { window: unknown }).window = { - api: { - pty: { - hasChildProcesses: vi.fn().mockResolvedValue(true), - getForegroundProcess: vi.fn().mockResolvedValue('codex') - } - } - } - - const [candidate] = await enrichWorkspaceCleanupCandidates( - [makeCandidate()], - makeState({ - tabsByWorktree: { - [WORKTREE_ID]: [{ id: 'tab-1', title: 'zsh' }] as AppState['tabsByWorktree'][string] - }, - ptyIdsByTabId: { 'tab-1': ['pty-1'] } - }), - { applyDismissals: false } - ) - - expect(candidate.tier).toBe('protected') - expect(candidate.selectedByDefault).toBe(false) - expect(candidate.blockers).toContain('running-terminal') - }) - - it('does not let an idle title in another tab mask a running agent process', async () => { - ;(globalThis as { window: unknown }).window = { - api: { - pty: { - hasChildProcesses: vi.fn(async (ptyId: string) => ptyId === 'pty-running'), - getForegroundProcess: vi.fn(async (ptyId: string) => - ptyId === 'pty-running' ? 'codex' : 'zsh' - ) - } - } - } - - const [candidate] = await enrichWorkspaceCleanupCandidates( - [makeCandidate()], - makeState({ - tabsByWorktree: { - [WORKTREE_ID]: [ - { id: 'tab-running', title: 'zsh' }, - { id: 'tab-idle', title: 'Codex done' } - ] as AppState['tabsByWorktree'][string] - }, - ptyIdsByTabId: { - 'tab-running': ['pty-running'], - 'tab-idle': ['pty-idle'] - } - }), - { applyDismissals: false } - ) - - expect(candidate.tier).toBe('protected') - expect(candidate.selectedByDefault).toBe(false) - expect(candidate.blockers).toContain('running-terminal') - }) -}) diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index 502cead194a..52106424121 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -35,6 +35,12 @@ export type WorkspaceCleanupRemoveResult = { failures: WorkspaceCleanupFailure[] } +export type WorkspaceCleanupRemoveOptions = { + // Why: rows are removed long after the confirm click; the confirm-time + // candidate records how much git risk the user actually approved. + approvedCandidates?: readonly WorkspaceCleanupCandidate[] +} + type WorkspaceCleanupViewedCandidate = { viewedAt: number fingerprint: string @@ -55,7 +61,8 @@ export type WorkspaceCleanupSlice = { ) => Promise resetWorkspaceCleanupDismissals: () => Promise removeWorkspaceCleanupCandidates: ( - worktreeIds: readonly string[] + worktreeIds: readonly string[], + options?: WorkspaceCleanupRemoveOptions ) => Promise } @@ -78,10 +85,23 @@ let inFlightWorkspaceCleanupScan: { promise: Promise } | null = null let latestWorkspaceCleanupScanToken = 0 +let finalizedWorkspaceCleanupScanToken = 0 +let workspaceCleanupProgressQueue: { + scanToken: number + promise: Promise +} | null = null let workspaceCleanupEnrichmentCache: { scanToken: number entries: Map } | null = null +// Why: cleanup progress can append thousands of rows; keep one scan-local +// index so each streamed row does not rebuild a map of every previous row. +let workspaceCleanupProgressCandidateIndex: { + scanToken: number + scanId: string + candidates: WorkspaceCleanupCandidate[] + indexesByWorktreeId: Map +} | null = null const SHELL_PROCESS_NAMES = new Set([ 'bash', @@ -160,10 +180,13 @@ export const createWorkspaceCleanupSlice: StateCreator { const scan = await window.api.workspaceCleanup.scan(scanArgs, (progress) => { - void applyWorkspaceCleanupProgress(progress, scanToken, get, set) + enqueueWorkspaceCleanupProgress(progress, scanToken, get, set) }) const enriched = await enrichWorkspaceCleanupCandidatesForScan( scan.candidates, @@ -172,10 +195,13 @@ export const createWorkspaceCleanupSlice: StateCreator { + removeWorkspaceCleanupCandidates: async (worktreeIds, options) => { const removedIds: string[] = [] const failures: WorkspaceCleanupFailure[] = [] + const approvedCandidatesByWorktreeId = new Map( + (options?.approvedCandidates ?? []).map((candidate) => [candidate.worktreeId, candidate]) + ) const preflights = await mapWithConcurrency( worktreeIds, WORKSPACE_CLEANUP_PREFLIGHT_CONCURRENCY, - (worktreeId) => preflightWorkspaceCleanupCandidate(worktreeId, get) + (worktreeId) => + preflightWorkspaceCleanupCandidate( + worktreeId, + get, + approvedCandidatesByWorktreeId.get(worktreeId) + ) ) const candidatesToRemove: WorkspaceCleanupCandidate[] = [] @@ -290,7 +324,10 @@ export const createWorkspaceCleanupSlice: StateCreator b.path.length - a.path.length)) { const result = await get().removeWorktree( candidate.worktreeId, - shouldForceWorkspaceCleanupRemoval(candidate) + shouldForceWorkspaceCleanupRemoval(candidate), + // Why: cleanup reports outcomes in its own summary toasts; per-row + // preserved-branch warnings would stack one toast per removed row. + { suppressPreservedBranchToast: true } ) if (result.ok) { removedIds.push(candidate.worktreeId) @@ -304,8 +341,10 @@ export const createWorkspaceCleanupSlice: StateCreator 0) { + invalidateWorkspaceCleanupScanProgress() const removedIdSet = new Set(removedIds) set((state) => ({ + workspaceCleanupLoading: false, workspaceCleanupScan: state.workspaceCleanupScan ? { ...state.workspaceCleanupScan, @@ -327,6 +366,43 @@ function getWorkspaceCleanupScanKey(args: WorkspaceCleanupScanArgs): string { }) } +function invalidateWorkspaceCleanupScanProgress(): void { + latestWorkspaceCleanupScanToken += 1 + finalizedWorkspaceCleanupScanToken = 0 + inFlightWorkspaceCleanupScan = null + workspaceCleanupProgressQueue = null + workspaceCleanupEnrichmentCache = null + workspaceCleanupProgressCandidateIndex = null +} + +function enqueueWorkspaceCleanupProgress( + progress: WorkspaceCleanupScanProgress, + scanToken: number, + getState: () => AppState, + setState: ( + partial: Partial | ((state: AppState) => Partial), + replace?: false + ) => void +): void { + if ( + scanToken !== latestWorkspaceCleanupScanToken || + scanToken === finalizedWorkspaceCleanupScanToken + ) { + return + } + const previous = + workspaceCleanupProgressQueue?.scanToken === scanToken + ? workspaceCleanupProgressQueue.promise + : Promise.resolve() + const promise = previous + .catch(() => undefined) + .then(() => applyWorkspaceCleanupProgress(progress, scanToken, getState, setState)) + .catch((error: unknown) => { + console.error('Workspace cleanup progress update failed', error) + }) + workspaceCleanupProgressQueue = { scanToken, promise } +} + async function applyWorkspaceCleanupProgress( progress: WorkspaceCleanupScanProgress, scanToken: number, @@ -336,6 +412,12 @@ async function applyWorkspaceCleanupProgress( replace?: false ) => void ): Promise { + if ( + scanToken !== latestWorkspaceCleanupScanToken || + scanToken === finalizedWorkspaceCleanupScanToken + ) { + return + } const state = getState() const previousCandidates = progress.candidateMode === 'append' && @@ -347,11 +429,23 @@ async function applyWorkspaceCleanupProgress( state, scanToken ) - const candidates = - progress.candidateMode === 'append' - ? appendWorkspaceCleanupProgressCandidates(previousCandidates, enrichedProgressCandidates) - : enrichedProgressCandidates - if (scanToken !== latestWorkspaceCleanupScanToken) { + if ( + scanToken !== latestWorkspaceCleanupScanToken || + scanToken === finalizedWorkspaceCleanupScanToken + ) { + return + } + const candidates = mergeWorkspaceCleanupProgressCandidates({ + previousCandidates, + nextCandidates: enrichedProgressCandidates, + progress, + scanToken + }) + if ( + scanToken !== latestWorkspaceCleanupScanToken || + scanToken === finalizedWorkspaceCleanupScanToken + ) { + workspaceCleanupProgressCandidateIndex = null return } setState((state) => { @@ -387,30 +481,74 @@ async function enrichWorkspaceCleanupCandidatesForScan( ) } -function appendWorkspaceCleanupProgressCandidates( - previousCandidates: readonly WorkspaceCleanupCandidate[], +function mergeWorkspaceCleanupProgressCandidates({ + previousCandidates, + nextCandidates, + progress, + scanToken +}: { + previousCandidates: readonly WorkspaceCleanupCandidate[] nextCandidates: readonly WorkspaceCleanupCandidate[] -): WorkspaceCleanupCandidate[] { - if (nextCandidates.length === 0) { - return [...previousCandidates] + progress: WorkspaceCleanupScanProgress + scanToken: number +}): WorkspaceCleanupCandidate[] { + if (progress.candidateMode !== 'append') { + workspaceCleanupProgressCandidateIndex = null + return [...nextCandidates] } - const indexesByWorktreeId = new Map( - previousCandidates.map((candidate, index) => [candidate.worktreeId, index]) + if (nextCandidates.length === 0) { + return previousCandidates as WorkspaceCleanupCandidate[] + } + + const indexCache = getWorkspaceCleanupProgressCandidateIndex( + previousCandidates, + progress.scanId, + scanToken ) - const merged = [...previousCandidates] + const merged = [...indexCache.candidates] for (const candidate of nextCandidates) { - const existingIndex = indexesByWorktreeId.get(candidate.worktreeId) + const existingIndex = indexCache.indexesByWorktreeId.get(candidate.worktreeId) if (existingIndex === undefined) { - indexesByWorktreeId.set(candidate.worktreeId, merged.length) + indexCache.indexesByWorktreeId.set(candidate.worktreeId, merged.length) merged.push(candidate) continue } merged[existingIndex] = candidate } + workspaceCleanupProgressCandidateIndex = { + scanToken, + scanId: progress.scanId, + candidates: merged, + indexesByWorktreeId: indexCache.indexesByWorktreeId + } return merged } +function getWorkspaceCleanupProgressCandidateIndex( + candidates: readonly WorkspaceCleanupCandidate[], + scanId: string, + scanToken: number +): { + candidates: WorkspaceCleanupCandidate[] + indexesByWorktreeId: Map +} { + if ( + workspaceCleanupProgressCandidateIndex?.scanToken === scanToken && + workspaceCleanupProgressCandidateIndex.scanId === scanId && + workspaceCleanupProgressCandidateIndex.candidates === candidates + ) { + return workspaceCleanupProgressCandidateIndex + } + + return { + candidates: [...candidates], + indexesByWorktreeId: new Map( + candidates.map((candidate, index) => [candidate.worktreeId, index]) + ) + } +} + async function mapWithConcurrency( items: readonly T[], limit: number, @@ -681,7 +819,8 @@ function applyDismissal( async function preflightWorkspaceCleanupCandidate( worktreeId: string, - getState: () => AppState + getState: () => AppState, + approvedCandidate?: WorkspaceCleanupCandidate ): Promise< | { ok: true; candidate: WorkspaceCleanupCandidate } | { ok: false; failure: WorkspaceCleanupFailure } @@ -715,6 +854,26 @@ async function preflightWorkspaceCleanupCandidate( } } } + // Why: this row may be removed minutes after the confirm click. If it now + // needs a force removal the user never approved (new dirt, unpushed work, + // or a git error since confirmation), fail it instead of force-deleting. + if ( + approvedCandidate && + shouldForceWorkspaceCleanupRemoval(candidate) && + !shouldForceWorkspaceCleanupRemoval(approvedCandidate) + ) { + return { + ok: false, + failure: { + worktreeId, + displayName: candidate.displayName, + message: translate( + 'auto.store.slices.workspace.cleanup.changedSinceConfirmation', + 'Workspace changed after confirmation. Refresh to review it before removing.' + ) + } + } + } return { ok: true, candidate } } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 85efa92dcc1..1f2ce461ba2 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -30,6 +30,7 @@ export { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' export type WorktreeDeleteState = { isDeleting: boolean + phase?: 'deleting' | 'queued' error: string | null canForceDelete: boolean } @@ -181,9 +182,10 @@ export type WorktreeSlice = { // 'forget-local' drops the workspace from Orca only (no remote Git/FS work) // for workspaces pinned to a removed/disconnected SSH host. Reuses the same // renderer-side teardown/purge as a normal remove. - options?: { mode?: 'remove' | 'forget-local' } + options?: { mode?: 'remove' | 'forget-local'; suppressPreservedBranchToast?: boolean } ) => Promise<({ ok: true } & RemoveWorktreeResult) | { ok: false; error: string }> markWorktreesDeleting: (worktreeIds: readonly string[]) => void + markWorktreesQueuedForDeletion: (worktreeIds: readonly string[]) => void forceDeletePreservedBranch: ( worktreeId: string, branchName: string, diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 36d70b5d319..d6fb11a315b 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -3151,6 +3151,7 @@ export const createWorktreeSlice: StateCreator ...s.deleteStateByWorktreeId, [worktreeId]: { isDeleting: true, + phase: 'deleting', error: null, canForceDelete: false } @@ -3508,7 +3509,7 @@ export const createWorktreeSlice: StateCreator // prune effect cannot be the only stale-draft cleanup path. clearSessionCommitDraftForWorktree(worktreeId) const preservedBranch = removalResult?.preservedBranch - if (preservedBranch) { + if (preservedBranch && options?.suppressPreservedBranchToast !== true) { showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => { void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead) }) @@ -3548,6 +3549,31 @@ export const createWorktreeSlice: StateCreator } nextDeleteState[worktreeId] = { isDeleting: true, + phase: 'deleting', + error: null, + canForceDelete: false + } + changed = true + } + return changed ? { deleteStateByWorktreeId: nextDeleteState } : {} + }) + }, + + markWorktreesQueuedForDeletion: (worktreeIds) => { + if (worktreeIds.length === 0) { + return + } + set((s) => { + const nextDeleteState = { ...s.deleteStateByWorktreeId } + let changed = false + for (const worktreeId of new Set(worktreeIds)) { + const current = nextDeleteState[worktreeId] + if (current?.isDeleting && current.error === null && !current.canForceDelete) { + continue + } + nextDeleteState[worktreeId] = { + isDeleting: true, + phase: 'queued', error: null, canForceDelete: false } From 5b1b8cc61f5f984048d60b2c52469e78e4aa195a Mon Sep 17 00:00:00 2001 From: BingZ Date: Fri, 10 Jul 2026 08:08:35 +0800 Subject: [PATCH 10/11] fix(preflight): resolve WSL/SSH agent paths past shell aliases (#7867) * fix(preflight): resolve WSL/SSH agent paths past shell aliases LeanCTX and similar tools wrap claude/codex as interactive shell aliases. command -v then returns alias text, which fails absolute-path detection and hides installed agents (#7816). Prefer bash type -P, then zsh type -p, then command -v for dash/sh fallback in WSL agent discovery, WSL isCommandOnPath, and remote relay probes. * fix(preflight): harden alias-safe PATH lookup chain Require non-empty results between type -P, type -p, and command -v so bash type -p empty success cannot skip later lookups. * fix(preflight): resolve agent executables directly from PATH --------- Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> --- src/main/ipc/preflight-command-exec.test.ts | 56 ++++ src/main/ipc/preflight-command-exec.ts | 23 +- .../ipc/preflight-wsl-agent-detection.test.ts | 16 + src/main/ipc/preflight-wsl-agent-detection.ts | 13 +- src/relay/preflight-handler.test.ts | 43 ++- src/relay/preflight-handler.ts | 6 +- src/shared/posix-command-path-lookup.test.ts | 312 ++++++++++++++++++ src/shared/posix-command-path-lookup.ts | 65 ++++ 8 files changed, 503 insertions(+), 31 deletions(-) create mode 100644 src/main/ipc/preflight-command-exec.test.ts create mode 100644 src/shared/posix-command-path-lookup.test.ts create mode 100644 src/shared/posix-command-path-lookup.ts diff --git a/src/main/ipc/preflight-command-exec.test.ts b/src/main/ipc/preflight-command-exec.test.ts new file mode 100644 index 00000000000..204b8a5f6e1 --- /dev/null +++ b/src/main/ipc/preflight-command-exec.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' + +const { runPreflightCommandInWslMock } = vi.hoisted(() => ({ + runPreflightCommandInWslMock: vi.fn() +})) + +vi.mock('./preflight-wsl-command', () => ({ + runPreflightCommandInWsl: runPreflightCommandInWslMock +})) + +import { isCommandOnPath } from './preflight-command-exec' + +describe('isCommandOnPath', () => { + const sentinel = '__ORCA_PREFLIGHT_COMMAND_PATH__' + + beforeEach(() => { + runPreflightCommandInWslMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('uses the shared literal lookup and accepts a sentinel-prefixed POSIX path', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + runPreflightCommandInWslMock.mockResolvedValue({ + stdout: `shell startup chatter\n${sentinel}/home/user/.local/bin/codex\n`, + stderr: '' + }) + + const found = await isCommandOnPath('codex', { distro: 'Ubuntu' }) + + expect(found).toBe(true) + expect(runPreflightCommandInWslMock).toHaveBeenCalledOnce() + const [, command] = runPreflightCommandInWslMock.mock.calls[0] as [{ distro: string }, string] + expect(command).toContain( + buildPosixCommandPathLookupScript({ kind: 'literal', value: 'codex' }) + ) + expect(command).toContain( + ['if [ -n "$resolved" ]; then', `printf '${sentinel}%s\\n' "$resolved"`, 'fi'].join('\n') + ) + }) + + it.each([ + ['/absolute/startup/chatter', false], + [`${sentinel}relative/path`, false], + ['codex', false], + ["alias codex='codex --wrapped'", false] + ])('parses WSL lookup output %s as available: %s', async (stdout, expected) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + runPreflightCommandInWslMock.mockResolvedValue({ stdout: `${stdout}\n`, stderr: '' }) + + await expect(isCommandOnPath('codex', { distro: 'Ubuntu' })).resolves.toBe(expected) + }) +}) diff --git a/src/main/ipc/preflight-command-exec.ts b/src/main/ipc/preflight-command-exec.ts index b3c3ac990b7..b5fa8dee40a 100644 --- a/src/main/ipc/preflight-command-exec.ts +++ b/src/main/ipc/preflight-command-exec.ts @@ -1,12 +1,14 @@ import { execFile } from 'node:child_process' import path from 'node:path' import { promisify } from 'node:util' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' import { buildLocalPreflightEnv } from './preflight-local-env' import { runPreflightCommandInWsl } from './preflight-wsl-command' import type { WslPreflightTarget } from './preflight-wsl-agent-detection' const execFileAsync = promisify(execFile) export const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000 +const WSL_COMMAND_PATH_SENTINEL = '__ORCA_PREFLIGHT_COMMAND_PATH__' export type PreflightCommandResult = { stdout: string; stderr: string } @@ -81,8 +83,27 @@ export async function isCommandOnPath( const finder = process.platform === 'win32' ? 'where' : 'which' try { const { stdout } = wslTarget - ? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`) + ? // Why: preflight must validate the executable on PATH, not a shell alias or function. + await execCommandInWsl( + wslTarget, + [ + buildPosixCommandPathLookupScript({ kind: 'literal', value: command }), + 'if [ -n "$resolved" ]; then', + `printf '${WSL_COMMAND_PATH_SENTINEL}%s\\n' "$resolved"`, + 'fi' + ].join('\n') + ) : await execLocalPreflightCommand(finder, [command]) + if (wslTarget) { + // Why: WSL startup chatter can contain unrelated absolute paths. + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith(WSL_COMMAND_PATH_SENTINEL)) + .map((line) => line.slice(WSL_COMMAND_PATH_SENTINEL.length)) + .some((line) => path.posix.isAbsolute(line)) + } + return stdout .split(/\r?\n/) .map((line) => line.trim()) diff --git a/src/main/ipc/preflight-wsl-agent-detection.test.ts b/src/main/ipc/preflight-wsl-agent-detection.test.ts index 5c544ef5c63..a1f9d7cfda5 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.test.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.test.ts @@ -16,6 +16,8 @@ vi.mock('child_process', () => { }) import { detectWslCommandsOnPath } from './preflight-wsl-agent-detection' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' +import { escapeWslShCommandForWindows } from '../../shared/wsl-login-shell-command' function lastShCommandPayload(): string { const call = execFileAsyncMock.mock.calls.at(-1) @@ -47,6 +49,20 @@ describe('detectWslCommandsOnPath', () => { expect(payload).toContain('fi\ndone') }) + it('uses the shared alias- and function-neutral PATH lookup', async () => { + execFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['claude', 'codex']) + + const payload = lastShCommandPayload() + const lookupScript = buildPosixCommandPathLookupScript({ + kind: 'shell-variable', + name: 'cmd' + }) + expect(payload).toContain(escapeWslShCommandForWindows(lookupScript)) + expect(payload).not.toContain('type -P') + }) + it('parses detected commands from prefixed stdout', async () => { execFileAsyncMock.mockResolvedValue({ stdout: diff --git a/src/main/ipc/preflight-wsl-agent-detection.ts b/src/main/ipc/preflight-wsl-agent-detection.ts index ed7d3f5399b..be1f216ed9c 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import path from 'node:path' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' import { buildWslLoginShellCommand, escapeWslShCommandForWindows @@ -24,13 +25,15 @@ export async function detectWslCommandsOnPath( } const commandList = uniqueCommands.map(shellQuote).join(' ') - // Why: join with newlines, not spaces. zsh treats `fi done` as a parse error - // (it needs a separator before `done`); the login shell may be zsh, so a - // space-joined script silently fails for every agent. Newlines are valid - // statement separators in every POSIX shell and zsh. + const lookupScript = buildPosixCommandPathLookupScript({ + kind: 'shell-variable', + name: 'cmd' + }) + // Newlines keep the loop valid in zsh and every POSIX shell used here. const script = [ `for cmd in ${commandList}; do`, - 'if resolved=$(command -v "$cmd" 2>/dev/null); then', + lookupScript, + 'if [ -n "$resolved" ]; then', `printf '${WSL_AGENT_DETECTION_PREFIX}%s\\t%s\\n' "$cmd" "$resolved";`, 'fi', 'done' diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 8833ddcb97f..80bc2ba490a 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' const { execFileAsyncMock } = vi.hoisted(() => ({ execFileAsyncMock: vi.fn() @@ -38,7 +39,8 @@ function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] { return [ mode, [ - `if resolved=$(command -v ${command} 2>/dev/null); then`, + buildPosixCommandPathLookupScript({ kind: 'literal', value: command }), + 'if [ -n "$resolved" ]; then', 'printf \'__ORCA_AGENT_PATH__%s\\n\' "$resolved"', 'fi' ].join('\n') @@ -77,14 +79,14 @@ describe('buildCommandLookupSpec', () => { it('falls back to sh for POSIX probes without a configured shell', () => { expect(buildCommandLookupSpec('codex', 'linux', {}, null)).toEqual({ file: '/bin/sh', - args: lookupArgs("'codex'") + args: lookupArgs('codex') }) }) it('uses the configured remote shell for POSIX probes', () => { expect(buildCommandLookupSpec('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual({ file: '/bin/zsh', - args: lookupArgs("'codex'", '-ilc') + args: lookupArgs('codex', '-ilc') }) }) @@ -93,7 +95,7 @@ describe('buildCommandLookupSpec', () => { buildCommandLookupSpec("agent'cli", 'linux', { SHELL: '/bin/bash' }, '/bin/bash') ).toEqual({ file: '/bin/bash', - args: lookupArgs("'agent'\\''cli'", '-ilc') + args: lookupArgs("agent'cli", '-ilc') }) }) }) @@ -101,8 +103,8 @@ describe('buildCommandLookupSpec', () => { describe('buildCommandLookupSpecs', () => { it('falls back to inherited PATH after a trusted configured POSIX shell', () => { expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual([ - { file: '/bin/zsh', args: lookupArgs("'codex'", '-ilc') }, - { file: '/bin/sh', args: lookupArgs("'codex'") } + { file: '/bin/zsh', args: lookupArgs('codex', '-ilc') }, + { file: '/bin/sh', args: lookupArgs('codex') } ]) }) @@ -115,15 +117,15 @@ describe('buildCommandLookupSpecs', () => { '/opt/homebrew/bin/zsh' ) ).toEqual([ - { file: '/opt/homebrew/bin/zsh', args: lookupArgs("'codex'", '-ilc') }, - { file: '/bin/sh', args: lookupArgs("'codex'") } + { file: '/opt/homebrew/bin/zsh', args: lookupArgs('codex', '-ilc') }, + { file: '/bin/sh', args: lookupArgs('codex') } ]) }) it('allows conservative system shell paths when account lookup is unavailable', () => { expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/usr/bin/bash' }, null)[0]).toEqual({ file: '/usr/bin/bash', - args: lookupArgs("'codex'", '-ilc') + args: lookupArgs('codex', '-ilc') }) }) @@ -136,14 +138,14 @@ describe('buildCommandLookupSpecs', () => { it('ignores untrusted temp shell paths even when the basename is supported', () => { expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/tmp/zsh' }, '/bin/bash')).toEqual([ - { file: '/bin/sh', args: lookupArgs("'codex'") } + { file: '/bin/sh', args: lookupArgs('codex') } ]) }) it('ignores untrusted home-bin shell paths even when the basename is supported', () => { expect( buildCommandLookupSpecs('codex', 'linux', { SHELL: '/home/test/bin/bash' }, '/bin/bash') - ).toEqual([{ file: '/bin/sh', args: lookupArgs("'codex'") }]) + ).toEqual([{ file: '/bin/sh', args: lookupArgs('codex') }]) }) }) @@ -160,17 +162,12 @@ describe('isCommandOnPathForRelay', () => { accountLoginShell: '/bin/zsh' }) ).resolves.toBe(true) - expect(execFileAsyncMock).toHaveBeenNthCalledWith( - 1, - '/bin/zsh', - lookupArgs("'codex'", '-ilc'), - { - encoding: 'utf-8', - env: expect.objectContaining({ SHELL: '/bin/zsh' }), - timeout: 5000 - } - ) - expect(execFileAsyncMock).toHaveBeenNthCalledWith(2, '/bin/sh', lookupArgs("'codex'"), { + expect(execFileAsyncMock).toHaveBeenNthCalledWith(1, '/bin/zsh', lookupArgs('codex', '-ilc'), { + encoding: 'utf-8', + env: expect.objectContaining({ SHELL: '/bin/zsh' }), + timeout: 5000 + }) + expect(execFileAsyncMock).toHaveBeenNthCalledWith(2, '/bin/sh', lookupArgs('codex'), { encoding: 'utf-8', env: expect.objectContaining({ SHELL: '/bin/zsh' }), timeout: 5000 @@ -203,7 +200,7 @@ describe('isCommandOnPathForRelay', () => { }) ).resolves.toBe(true) expect(execFileAsyncMock).toHaveBeenCalledTimes(1) - expect(execFileAsyncMock).toHaveBeenCalledWith('/bin/sh', lookupArgs("'codex'"), { + expect(execFileAsyncMock).toHaveBeenCalledWith('/bin/sh', lookupArgs('codex'), { encoding: 'utf-8', env: expect.objectContaining({ SHELL: '/tmp/zsh' }), timeout: 5000 diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index 27e7713343c..101c796137c 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -7,6 +7,7 @@ import { buildRelayCommandEnv } from './relay-command-env' import { isPwshAvailable } from '../main/pwsh' import { isWslAvailable, listWslDistros } from '../main/wsl' import { isGitBashAvailable } from '../main/git-bash' +import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' const execFileAsync = promisify(execFile) @@ -219,9 +220,10 @@ function buildPosixCommandLookupSpec(command: string, shell: string): CommandLoo } function buildShCommandLookupScript(command: string): string { - const quotedCommand = shellQuote(command) + // Why: login shells may define aliases or functions that mask the PATH executable. return [ - `if resolved=$(command -v ${quotedCommand} 2>/dev/null); then`, + buildPosixCommandPathLookupScript({ kind: 'literal', value: command }), + 'if [ -n "$resolved" ]; then', `printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`, 'fi' ].join('\n') diff --git a/src/shared/posix-command-path-lookup.test.ts b/src/shared/posix-command-path-lookup.test.ts new file mode 100644 index 00000000000..1ec2a398d32 --- /dev/null +++ b/src/shared/posix-command-path-lookup.test.ts @@ -0,0 +1,312 @@ +import { execFileSync } from 'node:child_process' +import { + accessSync, + chmodSync, + constants, + existsSync, + mkdtempSync, + mkdirSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, delimiter, dirname, isAbsolute, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { buildPosixCommandPathLookupScript } from './posix-command-path-lookup' +import { buildWslLoginShellCommand, escapeWslShCommandForWindows } from './wsl-login-shell-command' + +type ShellCase = { + name: string + path: string | null +} + +const isWindows = process.platform === 'win32' +const WSL_TEST_COMMAND_TIMEOUT_MS = 10_000 +let wslShAvailable: boolean | null = null +const shellCases: ShellCase[] = [ + { name: 'sh', path: executablePath(['/bin/sh']) }, + { name: 'bash', path: executablePath(['/bin/bash', '/usr/bin/bash']) }, + { name: 'zsh', path: executablePath(['/bin/zsh', '/usr/bin/zsh']) }, + { name: 'dash', path: executablePath(['/bin/dash', '/usr/bin/dash']) } +] + +describe('buildPosixCommandPathLookupScript', () => { + for (const shell of shellCases) { + it.skipIf(isWindows || shell.path === null)( + `resolves without mutating alias and function masks in ${shell.name}`, + () => { + const commandName = basename(process.execPath) + const script = [ + `${commandName}() { printf '%s\\n' masked-function; }`, + `alias ${commandName}='printf "%s\\n" masked-alias'`, + buildPosixCommandPathLookupScript({ kind: 'literal', value: commandName }), + `printf '%s\\n' "$resolved"`, + `alias ${commandName} >/dev/null`, + `unalias ${commandName}`, + `${commandName}` + ].join('\n') + + const resolved = execFileSync(shell.path!, ['-c', script], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? ''}` + } + }) + .trim() + .split('\n') + + expect(isAbsolute(resolved[0])).toBe(true) + expect(realpathSync(resolved[0])).toBe(realpathSync(process.execPath)) + expect(resolved[1]).toBe('masked-function') + } + ) + } + + it.skipIf(isWindows || executablePath(['/bin/sh']) === null)( + 'resolves a command held in a validated shell variable', + () => { + const commandName = basename(process.execPath) + const script = [ + `cmd='${commandName}'`, + `${commandName}() { printf '%s\\n' masked-function; }`, + `alias ${commandName}='printf "%s\\n" masked-alias'`, + buildPosixCommandPathLookupScript({ kind: 'shell-variable', name: 'cmd' }), + `printf '%s\\n' "$resolved"` + ].join('\n') + + const resolved = execFileSync('/bin/sh', ['-c', script], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? ''}` + } + }).trim() + + expect(isAbsolute(resolved)).toBe(true) + expect(realpathSync(resolved)).toBe(realpathSync(process.execPath)) + } + ) + + it.skipIf(isWindows || executablePath(['/bin/bash', '/usr/bin/bash']) === null)( + 'resolves past a readonly bash function mask', + () => { + const commandName = basename(process.execPath) + const script = [ + `${commandName}() { printf '%s\\n' masked-function; }`, + `readonly -f ${commandName}`, + buildPosixCommandPathLookupScript({ kind: 'literal', value: commandName }), + `printf '%s\\n' "$resolved"` + ].join('\n') + + const resolved = execFileSync( + executablePath(['/bin/bash', '/usr/bin/bash'])!, + ['-c', script], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? ''}` + } + } + ).trim() + + expect(realpathSync(resolved)).toBe(realpathSync(process.execPath)) + } + ) + + it.skipIf(isWindows || executablePath(['/bin/sh']) === null)( + 'prefers the first external executable even when its name is a shell builtin', + () => { + withExecutableFixture('printf', (directory, executable, root) => { + const secondDirectory = join(root, 'second-bin') + const secondExecutable = join(secondDirectory, 'printf') + mkdirSync(secondDirectory) + writeFileSync(secondExecutable, '#!/bin/sh\nexit 0\n') + chmodSync(secondExecutable, 0o755) + const script = [ + buildPosixCommandPathLookupScript({ kind: 'literal', value: 'printf' }), + `printf '%s\\n' "$resolved"` + ].join('\n') + const resolved = execFileSync('/bin/sh', ['-c', script], { + encoding: 'utf8', + env: { ...process.env, PATH: `${directory}:${secondDirectory}` } + }).trim() + + expectResolvedExecutable(resolved, executable) + }) + } + ) + + it.skipIf(isWindows || executablePath(['/bin/sh']) === null)( + 'makes relative and trailing-empty PATH matches absolute', + () => { + withExecutableFixture('relative-agent', (directory, executable, root) => { + const relativeDirectory = basename(directory) + const script = [ + buildPosixCommandPathLookupScript({ kind: 'literal', value: 'relative-agent' }), + `printf '%s\\n' "$resolved"` + ].join('\n') + const relativeResolved = execFileSync('/bin/sh', ['-c', script], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, PATH: `${relativeDirectory}:` } + }).trim() + const trailingResolved = execFileSync('/bin/sh', ['-c', script], { + cwd: directory, + encoding: 'utf8', + env: { ...process.env, PATH: '/missing:' } + }).trim() + const emptyResolved = execFileSync('/bin/sh', ['-c', script], { + cwd: directory, + encoding: 'utf8', + env: { ...process.env, PATH: ':/missing' } + }).trim() + + expectResolvedExecutable(relativeResolved, executable) + expectResolvedExecutable(trailingResolved, executable) + expectResolvedExecutable(emptyResolved, executable) + }) + } + ) + + it.skipIf(isWindows || executablePath(['/bin/sh']) === null)( + 'handles leading-dash names and explicit relative paths', + () => { + withExecutableFixture('-agent', (directory, executable) => { + const script = [ + buildPosixCommandPathLookupScript({ kind: 'shell-variable', name: 'cmd' }), + `printf '%s\\n' "$resolved"`, + buildPosixCommandPathLookupScript({ kind: 'literal', value: './-agent' }), + `printf '%s\\n' "$resolved"` + ].join('\n') + const output = execFileSync('/bin/sh', ['-c', script], { + cwd: directory, + encoding: 'utf8', + env: { ...process.env, PATH: directory, cmd: '-agent' } + }) + .trim() + .split('\n') + + expectResolvedExecutable(output[0], executable) + expectResolvedExecutable(output[1], executable) + }) + } + ) + + it.skipIf(isWindows || executablePath(['/bin/sh']) === null)( + 'keeps set -e callers running when the command is absent', + () => { + const script = [ + 'set -e', + buildPosixCommandPathLookupScript({ + kind: 'literal', + value: '__orca_missing_command_path_lookup__' + }), + `printf '%s\\n' survived` + ].join('\n') + + expect(execFileSync('/bin/sh', ['-c', script], { encoding: 'utf8' }).trim()).toBe('survived') + } + ) + + it.each(['', '$cmd', 'cmd-name', 'cmd; echo injected'])( + 'rejects an unsafe shell variable name: %s', + (name) => { + expect(() => buildPosixCommandPathLookupScript({ kind: 'shell-variable', name })).toThrow( + 'Invalid shell variable name' + ) + } + ) + + it('quotes literal targets before assigning them in the generated shell fragment', () => { + const script = buildPosixCommandPathLookupScript({ + kind: 'literal', + value: "agent'; echo injected; '" + }) + + expect(script).toContain(`_orca_lookup_command='agent'\\''; echo injected; '\\'''`) + }) + + it.skipIf(!canRunWslSh())( + 'resolves through the Windows-to-WSL login-shell boundary with inline masks', + () => { + const lookup = buildPosixCommandPathLookupScript({ kind: 'literal', value: 'sh' }) + const command = buildWslLoginShellCommand( + [ + `sh() { printf '%s\\n' masked-function; }`, + `alias sh='printf masked-alias'`, + lookup, + `printf '%s' "$resolved"` + ].join('\n') + ) + const resolved = execFileSync( + 'wsl.exe', + ['--', 'sh', '-lc', escapeWslShCommandForWindows(command)], + { encoding: 'utf8', timeout: WSL_TEST_COMMAND_TIMEOUT_MS } + ).trim() + + expect(resolved).toMatch(/^\/.+\/sh$/) + }, + 30_000 + ) +}) + +function expectResolvedExecutable(resolved: string, executable: string): void { + expect(isAbsolute(resolved)).toBe(true) + expect(realpathSync(resolved)).toBe(realpathSync(executable)) +} + +function canRunWslSh(): boolean { + if (!isWindows) { + return false + } + if (wslShAvailable !== null) { + return wslShAvailable + } + try { + execFileSync('wsl.exe', ['--', 'sh', '-lc', 'true'], { + timeout: WSL_TEST_COMMAND_TIMEOUT_MS + }) + wslShAvailable = true + } catch { + wslShAvailable = false + } + return wslShAvailable +} + +function withExecutableFixture( + name: string, + run: (directory: string, executable: string, root: string) => void +): void { + const root = mkdtempSync(join(tmpdir(), 'orca-path-lookup-')) + const directory = join(root, 'bin') + const executable = join(directory, name) + try { + mkdirSync(directory) + writeFileSync(executable, '#!/bin/sh\nexit 0\n') + chmodSync(executable, 0o755) + run(directory, executable, root) + } finally { + rmSync(root, { force: true, recursive: true }) + } +} + +function executablePath(candidates: readonly string[]): string | null { + if (isWindows) { + return null + } + for (const candidate of candidates) { + if (!existsSync(candidate)) { + continue + } + try { + accessSync(candidate, constants.X_OK) + return candidate + } catch { + // Keep checking alternate standard locations when this entry is not executable. + } + } + return null +} diff --git a/src/shared/posix-command-path-lookup.ts b/src/shared/posix-command-path-lookup.ts new file mode 100644 index 00000000000..fb29be82294 --- /dev/null +++ b/src/shared/posix-command-path-lookup.ts @@ -0,0 +1,65 @@ +export type PosixCommandPathLookupTarget = + | { kind: 'literal'; value: string } + | { kind: 'shell-variable'; name: string } + +const SHELL_VARIABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ + +export function buildPosixCommandPathLookupScript(target: PosixCommandPathLookupTarget): string { + const commandAssignment = buildCommandAssignment(target) + // Shell command resolution can be masked by aliases, functions, and builtins, so inspect PATH. + return [ + `_orca_lookup_command=${commandAssignment}`, + 'resolved=', + 'case "$_orca_lookup_command" in', + ' */*)', + ' case "$_orca_lookup_command" in', + ' /*) _orca_lookup_candidate=$_orca_lookup_command ;;', + ' *) _orca_lookup_candidate=${PWD%/}/$_orca_lookup_command ;;', + ' esac', + ' if [ -x "$_orca_lookup_candidate" ] && [ ! -d "$_orca_lookup_candidate" ]; then', + ' resolved=$_orca_lookup_candidate', + ' fi', + ' ;;', + ' *)', + ' _orca_lookup_remaining=${PATH-}', + ' while :; do', + ' case "$_orca_lookup_remaining" in', + ' *:*)', + ' _orca_lookup_component=${_orca_lookup_remaining%%:*}', + ' _orca_lookup_remaining=${_orca_lookup_remaining#*:}', + ' _orca_lookup_has_more=1', + ' ;;', + ' *)', + ' _orca_lookup_component=$_orca_lookup_remaining', + ' _orca_lookup_has_more=', + ' ;;', + ' esac', + ' [ -n "$_orca_lookup_component" ] || _orca_lookup_component=.', + ' case "$_orca_lookup_component" in', + ' /*) _orca_lookup_candidate=$_orca_lookup_component/$_orca_lookup_command ;;', + ' *) _orca_lookup_candidate=${PWD%/}/$_orca_lookup_component/$_orca_lookup_command ;;', + ' esac', + ' if [ -x "$_orca_lookup_candidate" ] && [ ! -d "$_orca_lookup_candidate" ]; then', + ' resolved=$_orca_lookup_candidate', + ' break', + ' fi', + ' [ -n "$_orca_lookup_has_more" ] || break', + ' done', + ' ;;', + 'esac' + ].join('\n') +} + +function buildCommandAssignment(target: PosixCommandPathLookupTarget): string { + if (target.kind === 'literal') { + return shellQuote(target.value) + } + if (!SHELL_VARIABLE_NAME_PATTERN.test(target.name)) { + throw new Error(`Invalid shell variable name: ${target.name}`) + } + return `\${${target.name}-}` +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} From 4ead072a16b668c46615323021741190bfba1230 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:16:49 -0700 Subject: [PATCH 11/11] Fix Codex WSL runtime status hooks (#7969) * Fix Codex WSL runtime status hooks * Harden Codex WSL hook restart handling * Harden Codex WSL hook path handling * Fix Codex hook CLI type boundary --- config/tsconfig.cli.json | 1 + src/main/codex/codex-wsl-hook-install-plan.ts | 87 ++++++ .../codex/hook-service-wsl-runtime.test.ts | 295 ++++++++++++++++++ src/main/codex/hook-service.ts | 258 ++++++++++++++- src/main/index.ts | 14 +- 5 files changed, 641 insertions(+), 14 deletions(-) create mode 100644 src/main/codex/codex-wsl-hook-install-plan.ts create mode 100644 src/main/codex/hook-service-wsl-runtime.test.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 0fd87a36f7c..8d92fa208ca 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -14,6 +14,7 @@ "../src/main/codex/codex-config-path-reference-rewrite.ts", "../src/main/codex/codex-home-paths.ts", "../src/main/codex/codex-hook-identity.ts", + "../src/main/codex/codex-wsl-hook-install-plan.ts", "../src/main/codex/config-settings-promotion.ts", "../src/main/codex/config-toml-line-scan.ts", "../src/main/codex/config-toml-trust.ts", diff --git a/src/main/codex/codex-wsl-hook-install-plan.ts b/src/main/codex/codex-wsl-hook-install-plan.ts new file mode 100644 index 00000000000..e7878777bff --- /dev/null +++ b/src/main/codex/codex-wsl-hook-install-plan.ts @@ -0,0 +1,87 @@ +import { execFileSync } from 'node:child_process' +import { win32 as pathWin32 } from 'node:path' +import { parseWslUncPath } from '../../shared/wsl-paths' + +export type CodexWslRuntimeHookTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +export type CodexWslRuntimeHookInstallPlan = { + configPath: string + tomlPath: string + scriptPath: string + commandScriptPath: string + trustConfigPath: string +} + +export type CanonicalizeWslLinuxPath = (distro: string, linuxPath: string) => string | null + +function trimTrailingSlash(value: string): string { + return value.length > 1 ? value.replace(/\/+$/, '') : value +} + +function toDefaultWslLinuxPath(windowsPath: string): string { + const driveMatch = windowsPath.match(/^([A-Za-z]):[/\\](.*)$/) + if (!driveMatch) { + return windowsPath + } + return `/mnt/${driveMatch[1].toLowerCase()}/${driveMatch[2].replace(/\\/g, '/')}` +} + +function canonicalizeWslLinuxPath(distro: string, linuxPath: string): string | null { + if (process.platform !== 'win32') { + return linuxPath + } + try { + const canonicalPath = execFileSync( + 'wsl.exe', + ['-d', distro, '--', 'readlink', '-f', '--', linuxPath], + { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000 + } + ).trim() + return canonicalPath.startsWith('/') ? canonicalPath : null + } catch { + return null + } +} + +export function createCodexWslRuntimeHookInstallPlan( + runtimeHomePath: string | null | undefined, + target?: CodexWslRuntimeHookTarget, + canonicalize: CanonicalizeWslLinuxPath = canonicalizeWslLinuxPath +): CodexWslRuntimeHookInstallPlan | null { + if (!runtimeHomePath) { + return null + } + + const wslInfo = parseWslUncPath(runtimeHomePath) + if (!wslInfo && target?.runtime !== 'wsl') { + return null + } + const distro = wslInfo?.distro || (target?.runtime === 'wsl' ? target.wslDistro?.trim() : null) + if (!distro) { + return null + } + + const logicalLinuxRuntimeHome = wslInfo?.linuxPath ?? toDefaultWslLinuxPath(runtimeHomePath) + if (!logicalLinuxRuntimeHome.startsWith('/')) { + return null + } + // Why: Codex canonicalizes hook sources inside WSL; resolving there keeps + // trust keys valid when HOME or the runtime directory crosses a symlink. + const linuxRuntimeHome = trimTrailingSlash( + canonicalize(distro, logicalLinuxRuntimeHome) ?? logicalLinuxRuntimeHome + ) + + return { + configPath: pathWin32.join(runtimeHomePath, 'hooks.json'), + tomlPath: pathWin32.join(runtimeHomePath, 'config.toml'), + scriptPath: pathWin32.join(runtimeHomePath, '.orca', 'agent-hooks', 'codex-hook.sh'), + commandScriptPath: `${linuxRuntimeHome}/.orca/agent-hooks/codex-hook.sh`, + trustConfigPath: `${linuxRuntimeHome}/hooks.json` + } +} diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts new file mode 100644 index 00000000000..2bbf2bdd20d --- /dev/null +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -0,0 +1,295 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { dirname, join, win32 as pathWin32 } from 'node:path' + +import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils' +import { + computeTrustKey, + computeTrustedHash, + readHookTrustEntries, + type CodexTrustEntry +} from './config-toml-trust' +import { + _internals, + createCodexWslRuntimeHookInstallPlan, + type CodexWslRuntimeHookInstallPlan +} from './hook-service' + +type HooksConfig = { + hooks: Record +} + +const managedEvents = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PermissionRequest', + 'PostToolUse', + 'Stop' +] as const + +let tempRoots: string[] = [] + +afterEach(() => { + for (const root of tempRoots) { + rmSync(root, { recursive: true, force: true }) + } + tempRoots = [] +}) + +function createTestPlan(): CodexWslRuntimeHookInstallPlan { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-wsl-hooks-')) + tempRoots.push(root) + const linuxHome = '/home/alice/.local/share/orca/codex-runtime-home/home' + return { + configPath: join(root, 'hooks.json'), + tomlPath: join(root, 'config.toml'), + scriptPath: join(root, '.orca', 'agent-hooks', 'codex-hook.sh'), + commandScriptPath: `${linuxHome}/.orca/agent-hooks/codex-hook.sh`, + trustConfigPath: `${linuxHome}/hooks.json` + } +} + +function getManagedTrustEntry( + plan: CodexWslRuntimeHookInstallPlan, + command: string +): CodexTrustEntry { + return { + sourcePath: plan.trustConfigPath, + eventLabel: 'user_prompt_submit', + groupIndex: 0, + handlerIndex: 0, + command, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + } +} + +describe('Codex WSL runtime hook install', () => { + it('plans WSL hook files with Linux command and trust paths', () => { + const runtimeHome = + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' + + expect( + createCodexWslRuntimeHookInstallPlan(runtimeHome, undefined, (_distro, path) => path) + ).toEqual({ + configPath: pathWin32.join(runtimeHome, 'hooks.json'), + tomlPath: pathWin32.join(runtimeHome, 'config.toml'), + scriptPath: pathWin32.join(runtimeHome, '.orca', 'agent-hooks', 'codex-hook.sh'), + commandScriptPath: + '/home/alice/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh', + trustConfigPath: '/home/alice/.local/share/orca/codex-runtime-home/home/hooks.json' + }) + }) + + it('plans WSL hooks when the distro home is mounted on a Windows drive', () => { + const runtimeHome = 'D:\\wsl-home\\.local\\share\\orca\\codex-runtime-home\\home' + + expect( + createCodexWslRuntimeHookInstallPlan( + runtimeHome, + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + (_distro, path) => path + ) + ).toEqual({ + configPath: pathWin32.join(runtimeHome, 'hooks.json'), + tomlPath: pathWin32.join(runtimeHome, 'config.toml'), + scriptPath: pathWin32.join(runtimeHome, '.orca', 'agent-hooks', 'codex-hook.sh'), + commandScriptPath: + '/mnt/d/wsl-home/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh', + trustConfigPath: '/mnt/d/wsl-home/.local/share/orca/codex-runtime-home/home/hooks.json' + }) + }) + + it('uses WSL-canonical paths for hook commands and trust keys', () => { + const runtimeHome = + '\\\\wsl.localhost\\Ubuntu\\home\\alias\\.local\\share\\orca\\codex-runtime-home\\home' + const canonicalHome = '/home/alice/.local/share/orca/codex-runtime-home/home' + + const plan = createCodexWslRuntimeHookInstallPlan( + runtimeHome, + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + (distro, linuxPath) => { + expect(distro).toBe('Ubuntu') + expect(linuxPath).toBe('/home/alias/.local/share/orca/codex-runtime-home/home') + return canonicalHome + } + ) + + expect(plan?.commandScriptPath).toBe(`${canonicalHome}/.orca/agent-hooks/codex-hook.sh`) + expect(plan?.trustConfigPath).toBe(`${canonicalHome}/hooks.json`) + expect(plan?.configPath).toBe(pathWin32.join(runtimeHome, 'hooks.json')) + }) + + it('generates a POSIX hook that bridges WSL loopback failures through Windows curl', () => { + const script = _internals.getManagedScript('posix') + expect(script).toContain('load_hook_endpoint()') + expect(script).toContain('"set ORCA_AGENT_HOOK_TOKEN="*)') + expect(script).toContain('post_codex_hook()') + expect(script).toContain('is_wsl_runtime()') + expect(script).toContain('WSL_DISTRO_NAME') + expect(script).toContain('windows_curl=$(command -v curl.exe 2>/dev/null || true)') + expect(script).toContain('--data-urlencode "payload@-"') + expect(script).toContain('if post_codex_hook curl >/dev/null 2>&1; then') + expect(script).toContain('post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true') + }) + + it.skipIf(process.platform === 'win32')( + 'refreshes stale hook coordinates from a Windows endpoint file', + () => { + const plan = createTestPlan() + const root = dirname(plan.configPath) + const endpointPath = join(root, 'endpoint.cmd') + const binDir = join(root, 'bin') + const curlPath = join(binDir, 'curl') + const capturePath = join(root, 'curl-args.txt') + mkdirSync(binDir, { recursive: true }) + writeFileSync( + endpointPath, + [ + 'set ORCA_AGENT_HOOK_PORT=43210', + 'set ORCA_AGENT_HOOK_TOKEN=fresh-token', + 'set ORCA_AGENT_HOOK_ENV=development', + 'set ORCA_AGENT_HOOK_VERSION=1', + '' + ].join('\r\n'), + 'utf-8' + ) + writeFileSync( + curlPath, + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$ORCA_TEST_CAPTURE"\ncat >> "$ORCA_TEST_CAPTURE"\n', + 'utf-8' + ) + chmodSync(curlPath, 0o755) + mkdirSync(dirname(plan.scriptPath), { recursive: true }) + writeFileSync(plan.scriptPath, _internals.getManagedScript('posix'), 'utf-8') + + const result = spawnSync('/bin/sh', [plan.scriptPath], { + encoding: 'utf-8', + input: '{"hook_event_name":"UserPromptSubmit","prompt":"restart"}', + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + ORCA_AGENT_HOOK_ENDPOINT: endpointPath, + ORCA_AGENT_HOOK_PORT: '1', + ORCA_AGENT_HOOK_TOKEN: 'stale-token', + ORCA_PANE_KEY: 'pane-1', + ORCA_TEST_CAPTURE: capturePath + } + }) + + expect(result.status).toBe(0) + const posted = readFileSync(capturePath, 'utf-8') + expect(posted).toContain('http://127.0.0.1:43210/hook/codex') + expect(posted).toContain('X-Orca-Agent-Hook-Token: fresh-token') + expect(posted).not.toContain('stale-token') + } + ) + + it.skipIf(process.platform === 'win32')( + 'uses the Windows curl discovered from the WSL PATH after loopback fails', + () => { + const plan = createTestPlan() + const root = dirname(plan.configPath) + const binDir = join(root, 'bin') + const capturePath = join(root, 'windows-curl-args.txt') + mkdirSync(binDir, { recursive: true }) + writeFileSync(join(binDir, 'curl'), '#!/bin/sh\nexit 7\n', 'utf-8') + writeFileSync( + join(binDir, 'curl.exe'), + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$ORCA_TEST_CAPTURE"\ncat >> "$ORCA_TEST_CAPTURE"\n', + 'utf-8' + ) + chmodSync(join(binDir, 'curl'), 0o755) + chmodSync(join(binDir, 'curl.exe'), 0o755) + mkdirSync(dirname(plan.scriptPath), { recursive: true }) + writeFileSync(plan.scriptPath, _internals.getManagedScript('posix'), 'utf-8') + + const result = spawnSync('/bin/sh', [plan.scriptPath], { + encoding: 'utf-8', + input: '{"hook_event_name":"Stop"}', + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + WSL_DISTRO_NAME: 'Ubuntu', + ORCA_AGENT_HOOK_PORT: '43210', + ORCA_AGENT_HOOK_TOKEN: 'token', + ORCA_PANE_KEY: 'pane-1', + ORCA_TEST_CAPTURE: capturePath + } + }) + + expect(result.status).toBe(0) + const posted = readFileSync(capturePath, 'utf-8') + expect(posted).toContain('http://127.0.0.1:43210/hook/codex') + expect(posted).toContain('X-Orca-Agent-Hook-Token: token') + } + ) + + it('installs trusted WSL hooks and removes only Orca entries when disabled', () => { + const plan = createTestPlan() + const userCommand = '/bin/sh /home/alice/user-hook.sh' + writeFileSync( + plan.configPath, + `${JSON.stringify({ + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: userCommand }] }], + PreCompact: [ + { + hooks: [ + { + type: 'command', + command: + "if [ -x '/old/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/old/.orca/agent-hooks/codex-hook.sh'; fi" + } + ] + } + ] + } + })}\n`, + 'utf-8' + ) + const unrelatedTrustKey = '/home/alice/custom/hooks.json:stop:0:0' + writeFileSync( + plan.tomlPath, + `[hooks.state."${unrelatedTrustKey}"]\nenabled = false\ntrusted_hash = "sha256:user"\n`, + 'utf-8' + ) + + expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed') + + const installed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig + expect(Object.keys(installed.hooks).sort()).toEqual([...managedEvents].sort()) + const managedCommand = installed.hooks.UserPromptSubmit[0]?.hooks?.[0]?.command + expect(managedCommand).toBe( + `if [ -r '${plan.commandScriptPath}' ]; then /bin/sh '${plan.commandScriptPath}'; fi` + ) + expect(installed.hooks.UserPromptSubmit[1]?.hooks?.[0]?.command).toBe(userCommand) + expect(readFileSync(plan.scriptPath, 'utf-8')).toContain('command -v curl.exe') + + const managedTrustEntry = getManagedTrustEntry(plan, managedCommand!) + const trustEntries = readHookTrustEntries(plan.tomlPath) + expect(trustEntries.get(computeTrustKey(managedTrustEntry))).toEqual({ + enabled: true, + trustedHash: computeTrustedHash(managedTrustEntry) + }) + expect(trustEntries.get(unrelatedTrustKey)).toEqual({ + enabled: false, + trustedHash: 'sha256:user' + }) + + expect(_internals.refreshWslRuntimeUserHooks(plan).state).toBe('not_installed') + + const refreshed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig + expect(refreshed.hooks).toEqual({ + UserPromptSubmit: [{ hooks: [{ type: 'command', command: userCommand }] }] + }) + const refreshedTrustEntries = readHookTrustEntries(plan.tomlPath) + expect(refreshedTrustEntries.has(computeTrustKey(managedTrustEntry))).toBe(false) + expect(refreshedTrustEntries.get(unrelatedTrustKey)).toEqual({ + enabled: false, + trustedHash: 'sha256:user' + }) + }) +}) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index ef812ab7ff0..31e552df55c 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -43,6 +43,11 @@ import { } from './config-toml-trust' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { + createCodexWslRuntimeHookInstallPlan, + type CodexWslRuntimeHookInstallPlan, + type CodexWslRuntimeHookTarget +} from './codex-wsl-hook-install-plan' import { CODEX_HOOK_EVENT_LABEL, createCodexHookTrustEntry, @@ -123,6 +128,16 @@ function getManagedCommand(scriptPath: string): string { : wrapPosixHookCommand(scriptPath) } +export { createCodexWslRuntimeHookInstallPlan } +export type { CodexWslRuntimeHookInstallPlan } + +function wrapReadablePosixHookCommand(scriptPath: string): string { + const quoted = `'${scriptPath.replaceAll("'", "'\\''")}'` + // Why: WSL runtime hooks are written from Windows through UNC, where the + // executable bit is not reliable. /bin/sh only needs the script to be readable. + return `if [ -r ${quoted} ]; then /bin/sh ${quoted}; fi` +} + function getSystemConfigPath(): string { return join(getSystemCodexHomePath(), 'hooks.json') } @@ -662,6 +677,52 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { } } +function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstallPlan): void { + try { + const existingEntries = readHookTrustEntries(plan.tomlPath) + const command = wrapReadablePosixHookCommand(plan.commandScriptPath) + const managedEventLabels = new Set( + CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) + ) + const canonicalConfigPath = getCodexCanonicalTrustPath(plan.trustConfigPath) + const ourKeys: string[] = [] + for (const [key, state] of existingEntries) { + const parts = parseTrustKey(key) + if (parts === null) { + continue + } + if (getCodexCanonicalTrustPath(parts.sourcePath) !== canonicalConfigPath) { + continue + } + if (!managedEventLabels.has(parts.eventLabel)) { + continue + } + const expectedEntry: CodexTrustEntry = { + sourcePath: plan.trustConfigPath, + eventLabel: parts.eventLabel, + groupIndex: parts.groupIndex, + handlerIndex: parts.handlerIndex, + command, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + } + const recognizedHashes = new Set([ + computeTrustedHash(expectedEntry), + computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) + ]) + if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { + ourKeys.push(key) + } + } + if (ourKeys.length > 0) { + removeHookTrustEntries(plan.tomlPath, ourKeys) + } + } catch (error) { + // Why: removing disabled WSL status hooks should be best-effort like the + // host cleanup path; stale trust is inert once hooks.json no longer points at us. + console.warn('[codex-hook-service] failed to clean WSL trust entries', error) + } +} + function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { return [ @@ -686,8 +747,30 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: see claude/hook-service.ts for rationale. Sourcing refreshes // PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps // reporting after a restart. + 'load_hook_endpoint() {', + ' endpoint_path="$1"', + ' case "$endpoint_path" in', + ' *.cmd)', + // Why: Windows passes endpoint.cmd into WSL through WSLENV path translation. + // Parse only Orca's known assignments; cmd.exe `set` lines are not shell syntax. + ' endpoint_cr=$(printf "\\r")', + ' while IFS= read -r endpoint_line || [ -n "$endpoint_line" ]; do', + ' endpoint_line=${endpoint_line%"$endpoint_cr"}', + ' case "$endpoint_line" in', + ' "set ORCA_AGENT_HOOK_PORT="*) ORCA_AGENT_HOOK_PORT=${endpoint_line#*=} ;;', + ' "set ORCA_AGENT_HOOK_TOKEN="*) ORCA_AGENT_HOOK_TOKEN=${endpoint_line#*=} ;;', + ' "set ORCA_AGENT_HOOK_ENV="*) ORCA_AGENT_HOOK_ENV=${endpoint_line#*=} ;;', + ' "set ORCA_AGENT_HOOK_VERSION="*) ORCA_AGENT_HOOK_VERSION=${endpoint_line#*=} ;;', + ' esac', + ' done < "$endpoint_path"', + ' ;;', + ' *)', + ' . "$endpoint_path" 2>/dev/null || :', + ' ;;', + ' esac', + '}', 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', - ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + ' load_hook_endpoint "$ORCA_AGENT_HOOK_ENDPOINT"', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', @@ -696,6 +779,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if [ -z "$payload" ]; then', ' exit 0', 'fi', + 'post_codex_hook() {', + ' curl_bin="$1"', + ' connect_timeout="${2:-0.5}"', + ' max_time="${3:-1.5}"', // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. @@ -703,23 +790,164 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl // command line (EDR command-line false positives). Wire body is identical. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' printf \'%s\' "$payload" | "$curl_bin" -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', + ' --connect-timeout "$connect_timeout" --max-time "$max_time" \\', + ' --noproxy "127.0.0.1" \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload@-"', + '}', + 'is_wsl_runtime() {', + ' [ -n "$WSL_DISTRO_NAME" ] && return 0', + ' grep -qiE "microsoft|wsl" /proc/sys/kernel/osrelease /proc/version 2>/dev/null', + '}', + 'if post_codex_hook curl >/dev/null 2>&1; then', + ' exit 0', + 'fi', + 'if is_wsl_runtime; then', + ' windows_curl=$(command -v curl.exe 2>/dev/null || true)', + ' if [ -n "$windows_curl" ] && [ -x "$windows_curl" ]; then', + ' post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true', + ' fi', + 'fi', 'exit 0', '' ].join('\n') } +function installManagedHooksIntoWslRuntime( + plan: CodexWslRuntimeHookInstallPlan +): AgentHookInstallStatus { + const config = readHooksJson(plan.configPath) + if (!config) { + return { + agent: 'codex', + state: 'error', + configPath: plan.configPath, + managedHooksPresent: false, + detail: 'Could not parse Codex hooks.json' + } + } + + const isManagedCommand = createManagedCommandMatcher('codex-hook.sh') + const command = wrapReadablePosixHookCommand(plan.commandScriptPath) + const nextHooks = { ...config.hooks } + const managedEvents = new Set(CODEX_EVENTS) + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (managedEvents.has(eventName) || !Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + const trustEntries: CodexTrustEntry[] = [] + for (const eventName of CODEX_EVENTS) { + const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + const definition: HookDefinition = { + hooks: [buildManagedCommandHook(command)] + } + nextHooks[eventName] = [definition, ...cleaned] + trustEntries.push({ + sourcePath: plan.trustConfigPath, + eventLabel: CODEX_EVENT_LABEL[eventName], + groupIndex: 0, + handlerIndex: 0, + command, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) + } + + config.hooks = nextHooks + writeManagedScript(plan.scriptPath, getManagedScript('posix')) + writeCodexHooksJson(plan.configPath, nextHooks) + try { + // Why: WSL runtime homes may carry user hook approvals we did not rebuild + // here; only upsert Orca's entries instead of sweeping the whole source. + upsertHookTrustEntries(plan.tomlPath, trustEntries) + } catch (error) { + return { + agent: 'codex', + state: 'error', + configPath: plan.configPath, + managedHooksPresent: true, + detail: `Hooks installed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` + } + } + + return { + agent: 'codex', + state: 'installed', + configPath: plan.configPath, + managedHooksPresent: true, + detail: null + } +} + +function refreshWslRuntimeUserHooks(plan: CodexWslRuntimeHookInstallPlan): AgentHookInstallStatus { + const config = readHooksJson(plan.configPath) + if (!config) { + return { + agent: 'codex', + state: 'error', + configPath: plan.configPath, + managedHooksPresent: false, + detail: 'Could not parse Codex hooks.json' + } + } + + const isManagedCommand = createManagedCommandMatcher('codex-hook.sh') + const nextHooks = { ...config.hooks } + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + writeCodexHooksJson(plan.configPath, nextHooks) + removeWslRuntimeManagedHookTrustEntries(plan) + return { + agent: 'codex', + state: 'not_installed', + configPath: plan.configPath, + managedHooksPresent: false, + detail: null + } +} + export class CodexHookService { + installForRuntimeHome( + runtimeHomePath: string | null | undefined, + target?: CodexWslRuntimeHookTarget + ): AgentHookInstallStatus | null { + const wslPlan = createCodexWslRuntimeHookInstallPlan(runtimeHomePath, target) + return wslPlan ? installManagedHooksIntoWslRuntime(wslPlan) : null + } + + refreshRuntimeUserHooksForRuntimeHome( + runtimeHomePath: string | null | undefined, + target?: CodexWslRuntimeHookTarget + ): AgentHookInstallStatus | null { + const wslPlan = createCodexWslRuntimeHookInstallPlan(runtimeHomePath, target) + return wslPlan ? refreshWslRuntimeUserHooks(wslPlan) : null + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() @@ -1155,3 +1383,9 @@ export class CodexHookService { } export const codexHookService = new CodexHookService() + +export const _internals = { + getManagedScript, + installManagedHooksIntoWslRuntime, + refreshWslRuntimeUserHooks +} diff --git a/src/main/index.ts b/src/main/index.ts index 8c2a5a4e160..0eb2d9d9a4f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -122,6 +122,7 @@ import { } from './codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' import { codexHookService } from './codex/hook-service' +import { getDefaultWslDistro } from './wsl' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' import { @@ -694,13 +695,22 @@ async function startServeAgentHookServer(): Promise { function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) + const hookTarget = + target?.runtime === 'wsl' + ? { + runtime: 'wsl' as const, + wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() + } + : target const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings()) try { // Why: launch prep is reachable after startup via PTY/runtime paths; honor // the persisted off switch so those launches cannot reinstall removed hooks. const status = hooksEnabled - ? codexHookService.install() - : codexHookService.refreshRuntimeUserHooks() + ? (codexHookService.installForRuntimeHome(runtimeHomePath, hookTarget) ?? + codexHookService.install()) + : (codexHookService.refreshRuntimeUserHooksForRuntimeHome(runtimeHomePath, hookTarget) ?? + codexHookService.refreshRuntimeUserHooks()) if (status.state === 'error') { console.warn( `[codex-hook-service] failed to ${