mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
Merge origin/main into brennanb2025/check-clis-before-hooks
Reconcile presence-gated Codex launch prep with main's #7969 WSL runtime-home status-hook install: - WSL runtime-home installs (installForRuntimeHome/refreshRuntimeUserHooksForRuntimeHome) run unconditionally and synchronously — never presence-gated, since the Windows-side PATH probe cannot see a codex CLI living inside the WSL distro (gating it would regress #7969). - Non-WSL launches keep the presence gate plus the always-run #7896 approval promotion from this branch's review fixes. - Keep both new tsconfig.cli entries and the added imports (getDefaultWslDistro, codexLaunchNeedsDirectApprovalPromotion).
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 <socket>"); process.exit(1)\n',
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
|
||||
|
||||
await electronBuilderConfig.afterPack({
|
||||
|
||||
@@ -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')
|
||||
// <appOutDir>/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 }
|
||||
|
||||
@@ -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 <socket>"); 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/)
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,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",
|
||||
|
||||
@@ -177,6 +177,7 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="notifications" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="about" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="h" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
|
||||
@@ -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<HostProfile[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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 (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Connection log</Text>
|
||||
</View>
|
||||
|
||||
{hosts.length > 1 && (
|
||||
<View style={styles.hostPicker}>
|
||||
{hosts.map((host) => (
|
||||
<Pressable
|
||||
key={host.id}
|
||||
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
|
||||
onPress={() => setSelectedId(host.id)}
|
||||
>
|
||||
<Text
|
||||
style={[styles.hostChipText, host.id === selectedId && styles.hostChipTextActive]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{host.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{selected ? (
|
||||
<>
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={styles.statusText}>
|
||||
{state}
|
||||
{reconnectAttempts > 0 ? ` · attempt ${reconnectAttempts}` : ''}
|
||||
</Text>
|
||||
<Pressable style={styles.copyButton} onPress={() => void copyDiagnostics()}>
|
||||
{copied ? (
|
||||
<Check size={14} color={colors.statusGreen} />
|
||||
) : (
|
||||
<Copy size={14} color={colors.textSecondary} />
|
||||
)}
|
||||
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy diagnostics'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{entries.length > 0 ? (
|
||||
<ConnectionLog entries={[...entries]} title={selected.name} />
|
||||
) : (
|
||||
<Text style={styles.emptyText}>
|
||||
No connection events yet this session. Events appear as the app dials this host.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.emptyText}>No paired hosts.</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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<Map<string, string>>(new Map())
|
||||
const initialModesSeenRef = useRef<Set<string>>(new Set())
|
||||
const deviceTokenRef = useRef<string | null>(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<string | null>(null)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
const connStateRef = useRef<ConnectionState>(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
|
||||
|
||||
@@ -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() {
|
||||
<View style={styles.hostMeta}>
|
||||
<StatusDot state={state} verdict={verdict} />
|
||||
<Text style={[styles.hostMetaItem, isError && { color: colors.statusRed }]}>
|
||||
{verdict.label}
|
||||
{verdictDisplayLabel(verdict)}
|
||||
{connected && info
|
||||
? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}`
|
||||
: ''}
|
||||
|
||||
+20
-68
@@ -14,13 +14,9 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
WifiOff,
|
||||
Shield,
|
||||
Monitor,
|
||||
Clock,
|
||||
Globe,
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
ScrollText,
|
||||
XCircle,
|
||||
AlertTriangle
|
||||
} from 'lucide-react-native'
|
||||
@@ -30,7 +26,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 +41,6 @@ type CheckResult = {
|
||||
detail: string
|
||||
}
|
||||
|
||||
type TroubleshootSection = {
|
||||
id: string
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
steps: string[]
|
||||
}
|
||||
|
||||
const sections: TroubleshootSection[] = [
|
||||
{
|
||||
id: 'wifi',
|
||||
icon: <WifiOff size={16} color={colors.textSecondary} />,
|
||||
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: <Shield size={16} color={colors.textSecondary} />,
|
||||
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: <Monitor size={16} color={colors.textSecondary} />,
|
||||
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: <Clock size={16} color={colors.textSecondary} />,
|
||||
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: <Globe size={16} color={colors.textSecondary} />,
|
||||
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 +152,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])
|
||||
}
|
||||
@@ -273,6 +214,17 @@ export default function TroubleshootScreen() {
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.diagnosticButton,
|
||||
pressed && styles.diagnosticButtonPressed
|
||||
]}
|
||||
onPress={() => router.push('/connection-log')}
|
||||
>
|
||||
<ScrollText size={16} color={colors.textPrimary} />
|
||||
<Text style={styles.diagnosticButtonLabel}>View connection log</Text>
|
||||
</Pressable>
|
||||
|
||||
{checks.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
{checks.map((check, i) => (
|
||||
@@ -295,7 +247,7 @@ export default function TroubleshootScreen() {
|
||||
<Text style={styles.sectionHeading}>Common issues</Text>
|
||||
|
||||
<View style={styles.section}>
|
||||
{sections.map((section, i) => (
|
||||
{troubleshootCommonIssues.map((section, i) => (
|
||||
<View key={section.id}>
|
||||
{i > 0 && <View style={styles.separator} />}
|
||||
<Pressable
|
||||
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
@@ -1,5 +1,27 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { testHostReachability } from './host-reachability'
|
||||
import { testHostReachability, unreachableHostDetail } from './host-reachability'
|
||||
|
||||
describe('unreachableHostDetail', () => {
|
||||
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 () => {
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
|
||||
@@ -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: <WifiOff size={16} color={colors.textSecondary} />,
|
||||
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: <Shield size={16} color={colors.textSecondary} />,
|
||||
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: <Monitor size={16} color={colors.textSecondary} />,
|
||||
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: <Clock size={16} color={colors.textSecondary} />,
|
||||
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: <Globe size={16} color={colors.textSecondary} />,
|
||||
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: <Shield size={16} color={colors.textSecondary} />,
|
||||
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".'
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, ConnectionLogEntry[]>()
|
||||
const listenersByHost = new Map<string, Set<() => 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<string, readonly ConnectionLogEntry[]>()
|
||||
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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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",
|
||||
@@ -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",
|
||||
|
||||
@@ -102,33 +102,33 @@ 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)
|
||||
})
|
||||
|
||||
it('awaits offline hook application before formatting the command result', async () => {
|
||||
|
||||
@@ -31,16 +31,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
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'))
|
||||
@@ -88,14 +79,9 @@ type OfflineSettingsUpdateResult = {
|
||||
function updateEnabledOnDisk(enabled: boolean): OfflineSettingsUpdateResult {
|
||||
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)
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
}
|
||||
@@ -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<string, { hooks?: { command?: string }[] }[]>
|
||||
}
|
||||
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
+246
-12
@@ -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<CodexEventLabel>(
|
||||
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<string>(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()
|
||||
@@ -1163,3 +1391,9 @@ export class CodexHookService {
|
||||
}
|
||||
|
||||
export const codexHookService = new CodexHookService()
|
||||
|
||||
export const _internals = {
|
||||
getManagedScript,
|
||||
installManagedHooksIntoWslRuntime,
|
||||
refreshWslRuntimeUserHooks
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1480,6 +1480,7 @@ type GlabExecOptions = Omit<GitExecOptions, 'cwd'> & {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
+40
-18
@@ -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<void> {
|
||||
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()
|
||||
|
||||
@@ -245,6 +245,9 @@ describe('gitlab client — MR operations', () => {
|
||||
hosts: ['gitlab.com'],
|
||||
activeHost: 'gitlab.com'
|
||||
})
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], {
|
||||
allowDefaultWslFallback: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -97,7 +97,10 @@ export async function diagnoseAuth(): Promise<GitLabAuthDiagnostic> {
|
||||
? '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 {
|
||||
|
||||
+44
-17
@@ -124,6 +124,7 @@ import {
|
||||
import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection'
|
||||
import { codexHookService } from './codex/hook-service'
|
||||
import { codexLaunchNeedsDirectApprovalPromotion } from './codex/codex-launch-hook-upkeep'
|
||||
import { getDefaultWslDistro } from './wsl'
|
||||
import { ClaudeAccountService } from './claude-accounts/service'
|
||||
import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service'
|
||||
import {
|
||||
@@ -698,25 +699,48 @@ async function startServeAgentHookServer(): Promise<void> {
|
||||
// the system home (#7896), so it must keep running on every Codex launch. Shared
|
||||
// so both the awaited commit-message path and the fire-and-forget sync PTY launch
|
||||
// path run it; enabled installs stay presence-gated per the persisted off switch.
|
||||
async function maintainCodexLaunchHooks(): Promise<void> {
|
||||
async function maintainCodexLaunchHooks(
|
||||
runtimeHomePath: string | null,
|
||||
target?: CodexAccountSelectionTarget
|
||||
): Promise<void> {
|
||||
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.
|
||||
let launchStatus: AgentHookInstallStatus | undefined
|
||||
if (hooksEnabled) {
|
||||
const statuses = await installManagedAgentHooks(store?.getSettings(), {
|
||||
shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32',
|
||||
onInstallError: recordManagedHookInstallFailure,
|
||||
agents: ['codex']
|
||||
})
|
||||
launchStatus = statuses.find((status) => status.agent === 'codex')
|
||||
// Why: presence-gating can skip the managed install; #7896 approval
|
||||
// promotion must still run on every Codex launch, so promote directly when
|
||||
// install() itself never ran (a completed install() already promoted).
|
||||
if (codexLaunchNeedsDirectApprovalPromotion(launchStatus)) {
|
||||
codexHookService.promoteRuntimeHookApprovals()
|
||||
// Why: WSL runtime-home installs (#7969) run codex inside the distro, where
|
||||
// the Windows-side PATH probe can't see it — that path is never presence-
|
||||
// gated. It is also synchronous (no await before it), so it still completes
|
||||
// before spawn even on the fire-and-forget sync launch path.
|
||||
const runtimeHomeStatus = codexHookService.installForRuntimeHome(runtimeHomePath, hookTarget)
|
||||
if (runtimeHomeStatus) {
|
||||
launchStatus = runtimeHomeStatus
|
||||
} else {
|
||||
const statuses = await installManagedAgentHooks(store?.getSettings(), {
|
||||
shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32',
|
||||
onInstallError: recordManagedHookInstallFailure,
|
||||
agents: ['codex']
|
||||
})
|
||||
launchStatus = statuses.find((status) => status.agent === 'codex')
|
||||
// Why: presence-gating can skip the managed install; #7896 approval
|
||||
// promotion must still run on every Codex launch, so promote directly when
|
||||
// install() itself never ran (a completed install() already promoted).
|
||||
if (codexLaunchNeedsDirectApprovalPromotion(launchStatus)) {
|
||||
codexHookService.promoteRuntimeHookApprovals()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
launchStatus = codexHookService.refreshRuntimeUserHooks()
|
||||
launchStatus =
|
||||
codexHookService.refreshRuntimeUserHooksForRuntimeHome(runtimeHomePath, hookTarget) ??
|
||||
codexHookService.refreshRuntimeUserHooks()
|
||||
}
|
||||
if (launchStatus?.state === 'error') {
|
||||
console.warn(
|
||||
@@ -742,7 +766,7 @@ async function prepareCodexRuntimeHomeForLaunch(
|
||||
target?: CodexAccountSelectionTarget
|
||||
): Promise<string | null> {
|
||||
const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target)
|
||||
await maintainCodexLaunchHooks()
|
||||
await maintainCodexLaunchHooks(runtimeHomePath, target)
|
||||
return runtimeHomePath
|
||||
}
|
||||
|
||||
@@ -750,8 +774,9 @@ function getSelectedCodexHomePath(target?: CodexAccountSelectionTarget): string
|
||||
const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target)
|
||||
// Why: the sync PTY spawn path can't await; run per-launch hook upkeep in the
|
||||
// background so #7896 approval promotion and user-hook refresh keep happening
|
||||
// on every Codex launch without blocking spawn.
|
||||
void maintainCodexLaunchHooks()
|
||||
// on every Codex launch without blocking spawn. The WSL runtime-home install is
|
||||
// synchronous, so it still lands before spawn even here.
|
||||
void maintainCodexLaunchHooks(runtimeHomePath, target)
|
||||
return runtimeHomePath
|
||||
}
|
||||
|
||||
@@ -2086,7 +2111,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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void> => {
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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> = {}): 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<string, number> = {
|
||||
'/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<string, number> = {
|
||||
[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)
|
||||
})
|
||||
})
|
||||
@@ -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<string>
|
||||
|
||||
export function getPersistedWorkspaceCleanupActivityAt(
|
||||
worktree: Pick<Worktree, 'createdAt' | 'lastActivityAt'>
|
||||
): 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<Worktree> {
|
||||
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<string> {
|
||||
return readFile(targetPath, 'utf8')
|
||||
}
|
||||
|
||||
async function resolveWorkspaceCleanupActivityAt(
|
||||
repo: Repo,
|
||||
worktree: Worktree,
|
||||
statPath: StatPath,
|
||||
readTextFile: ReadTextFile
|
||||
): Promise<number> {
|
||||
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<number> {
|
||||
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<string | null> {
|
||||
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<number> {
|
||||
try {
|
||||
const stats = await statPath(targetPath)
|
||||
return Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -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<Worktree> {
|
||||
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
|
||||
|
||||
@@ -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> = {}): 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<string, WorktreeMeta> = {
|
||||
'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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-29
@@ -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(
|
||||
|
||||
@@ -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\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('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'),
|
||||
|
||||
@@ -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', '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<Provi
|
||||
|
||||
const codexArgs = ['-s', 'read-only', '-a', 'untrusted', 'app-server']
|
||||
const wslCodex = options?.codexHomePath
|
||||
? buildWslCodexCommand(options.codexHomePath, codexArgs)
|
||||
? buildWslCodexCommand(options.codexHomePath, codexArgs, { isolateRpcStdio: true })
|
||||
: null
|
||||
// Why: cold WSL process startup plus Codex app-server initialization can
|
||||
// exceed the host RPC budget, causing a false "unavailable" on app launch.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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<GitWorktreeInfo[]> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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:<fallback>. 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<void> }
|
||||
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<void> }
|
||||
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)
|
||||
|
||||
@@ -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:<fallback> 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
private async listWorktrees(params: Record<string, unknown>, 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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,13 +29,23 @@ type AppStoreApi = Pick<StoreApi<AppState>, '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<string>()
|
||||
const attemptsByFileId = new Map<string, number>()
|
||||
const retryTimers = new Set<ReturnType<typeof setTimeout>>()
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/50 backdrop-blur-[1px]">
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full bg-background px-3 py-1 text-[11px] font-medium text-foreground shadow-sm border border-border/50">
|
||||
<LoaderCircle className="size-3.5 animate-spin text-muted-foreground" />
|
||||
{translate('auto.components.sidebar.WorktreeCard.691ccfd622', 'Deleting…')}
|
||||
{!isQueuedForDeletion ? (
|
||||
<LoaderCircle className="size-3.5 animate-spin text-muted-foreground" />
|
||||
) : null}
|
||||
{deleteLabel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+389
@@ -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<void> {
|
||||
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<ReturnType<WorkspaceCleanupBackgroundRemovalArgs['removeCandidates']>>
|
||||
) => void
|
||||
const removeCandidates = vi.fn(
|
||||
() =>
|
||||
new Promise<Awaited<ReturnType<WorkspaceCleanupBackgroundRemovalArgs['removeCandidates']>>>(
|
||||
(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<ReturnType<WorkspaceCleanupBackgroundRemovalArgs['removeCandidates']>>
|
||||
>(() => 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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
+216
@@ -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<WorkspaceCleanupRemoveResult>
|
||||
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<WorkspaceCleanupRemoveResult>,
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
timeoutMs: number
|
||||
): Promise<WorkspaceCleanupRemoveResult> {
|
||||
if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) {
|
||||
return promise
|
||||
}
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<WorkspaceCleanupRemoveResult>((_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)
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
+59
@@ -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)
|
||||
})
|
||||
})
|
||||
+204
@@ -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
|
||||
)
|
||||
}
|
||||
+107
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'grid overflow-hidden transition-[grid-template-rows,margin-top,opacity] duration-200 ease-out motion-reduce:transition-none',
|
||||
expanded ? 'mt-2 grid-rows-[1fr] opacity-100' : 'mt-0 grid-rows-[0fr] opacity-0'
|
||||
)}
|
||||
aria-hidden={!expanded}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div className="pl-1">
|
||||
<div className="grid gap-x-4 gap-y-1.5 text-xs text-muted-foreground sm:grid-cols-2">
|
||||
<DetailLine
|
||||
label={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
|
||||
'Repo'
|
||||
)}
|
||||
value={candidate.repoName}
|
||||
/>
|
||||
<DetailLine
|
||||
label={translate('auto.components.workspace.cleanup.candidateRow.gitLabel', 'Git')}
|
||||
value={formatGitStatus(candidate)}
|
||||
/>
|
||||
<DetailLine
|
||||
label={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.bef0adef9b',
|
||||
'Branch'
|
||||
)}
|
||||
value={candidate.branch}
|
||||
mono
|
||||
/>
|
||||
{branchSafetyDetails.slice(0, 1).map((detail) => (
|
||||
<DetailLine
|
||||
key={detail}
|
||||
label={translate(
|
||||
'auto.components.workspace.cleanup.candidateRow.commitsLabel',
|
||||
'Commits'
|
||||
)}
|
||||
value={detail}
|
||||
/>
|
||||
))}
|
||||
{contextDetails ? (
|
||||
<DetailLine
|
||||
label={translate(
|
||||
'auto.components.workspace.cleanup.candidateRow.contextLabel',
|
||||
'Context'
|
||||
)}
|
||||
value={contextDetails}
|
||||
/>
|
||||
) : null}
|
||||
{blockers.length > 0 ? (
|
||||
<DetailLine
|
||||
label={translate(
|
||||
'auto.components.workspace.cleanup.candidateRow.flagsLabel',
|
||||
'Flags'
|
||||
)}
|
||||
value={blockers.slice(0, 2).join(', ')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 min-w-0 truncate font-mono text-[11px] text-muted-foreground">
|
||||
{candidate.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailLine({
|
||||
label,
|
||||
mono = false,
|
||||
value
|
||||
}: {
|
||||
label: string
|
||||
mono?: boolean
|
||||
value: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="shrink-0 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground/80">
|
||||
{label}
|
||||
</span>
|
||||
<span className={cn('min-w-0 truncate', mono && 'font-mono text-[11px]')}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+65
@@ -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(
|
||||
<CandidateRow
|
||||
candidate={candidate}
|
||||
expanded={false}
|
||||
last
|
||||
lastActivityLabel="1d ago"
|
||||
removing
|
||||
reviewInfo={{
|
||||
hasReview: false,
|
||||
label: null,
|
||||
provider: null,
|
||||
state: null,
|
||||
title: null
|
||||
}}
|
||||
selected
|
||||
onIgnore={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onToggleExpanded={vi.fn()}
|
||||
onToggleSelected={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
expect(container?.querySelector(`[aria-label="Select ${candidate.displayName}"]`)).toBeNull()
|
||||
expect(container?.querySelector(`[aria-label="Remove ${candidate.displayName}"]`)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-5 shrink-0 items-center gap-1 rounded-full border px-1.5 text-[11px] font-medium',
|
||||
'border-border bg-background text-muted-foreground',
|
||||
tone === 'ready' &&
|
||||
'border-[color:color-mix(in_srgb,var(--git-decoration-added)_45%,transparent)] bg-[color:color-mix(in_srgb,var(--git-decoration-added)_10%,transparent)] text-[var(--git-decoration-added)]',
|
||||
tone === 'review' && 'bg-muted text-foreground',
|
||||
tone === 'destructive' && 'border-destructive/30 text-destructive'
|
||||
)}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className="size-3" aria-hidden="true" />
|
||||
{value ? <span>{value}</span> : null}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'group w-full border-b border-border/60 px-3 py-2.5 text-left text-foreground transition-colors hover:bg-accent/40',
|
||||
selected && 'bg-accent/30',
|
||||
last && 'border-b-0'
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-x-2.5 gap-y-1">
|
||||
{selectable ? (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={selected}
|
||||
aria-label={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.bbb1ab6a6f',
|
||||
'Select {{value0}}',
|
||||
{ value0: candidate.displayName }
|
||||
)}
|
||||
onClick={() => onToggleSelected(candidate.worktreeId)}
|
||||
className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border border-border bg-background text-primary hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{selected ? <Check className="size-3" strokeWidth={3} /> : null}
|
||||
</button>
|
||||
) : (
|
||||
<div className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="min-w-0 truncate text-sm font-medium">{candidate.displayName}</span>
|
||||
<StatusPill tone={status.tone}>{status.label}</StatusPill>
|
||||
<MetadataIconChip
|
||||
icon={Clock3}
|
||||
label={`${translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.352f15d6fc',
|
||||
'Last active'
|
||||
)} ${lastActivityLabel}`}
|
||||
value={formatCompactActivityLabel(lastActivityLabel)}
|
||||
/>
|
||||
{dirtyLabel && showGitMetadataChip ? (
|
||||
<MetadataIconChip icon={FileWarning} label={dirtyLabel} tone="destructive" />
|
||||
) : showGitMetadataChip ? (
|
||||
<MetadataIconChip
|
||||
icon={GitBranch}
|
||||
label={formatGitStatus(candidate)}
|
||||
tone={getWorkspaceCleanupGitLabel(candidate) === 'Clean' ? 'ready' : 'review'}
|
||||
/>
|
||||
) : null}
|
||||
{contextDetails ? (
|
||||
<MetadataIconChip
|
||||
icon={SquareTerminal}
|
||||
label={contextDetails}
|
||||
value={String(contextCount)}
|
||||
/>
|
||||
) : null}
|
||||
{reviewInfo.label ? (
|
||||
<MetadataIconChip
|
||||
icon={GitPullRequest}
|
||||
label={getReviewTooltip(reviewInfo)}
|
||||
value={reviewInfo.label}
|
||||
tone={getReviewPillTone(reviewInfo)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{failure ? (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-destructive">
|
||||
<AlertTriangle className="size-3.5" />
|
||||
{failure}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasExpandableDetails ? (
|
||||
<CandidateRowDetails
|
||||
blockers={blockers}
|
||||
branchSafetyDetails={branchSafetyDetails}
|
||||
candidate={candidate}
|
||||
contextDetails={contextDetails}
|
||||
expanded={expanded}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{hasExpandableDetails ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={
|
||||
expanded
|
||||
? translate(
|
||||
'auto.components.workspace.cleanup.candidateRow.collapseDetails',
|
||||
'Collapse details'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.workspace.cleanup.candidateRow.expandDetails',
|
||||
'Expand details'
|
||||
)
|
||||
}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => onToggleExpanded(candidate.worktreeId)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('size-3.5 transition-transform', expanded && 'rotate-180')}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{expanded
|
||||
? translate(
|
||||
'auto.components.workspace.cleanup.candidateRow.collapseDetails',
|
||||
'Collapse details'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.workspace.cleanup.candidateRow.expandDetails',
|
||||
'Expand details'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1bffc07ba7',
|
||||
'View {{value0}}',
|
||||
{ value0: candidate.displayName }
|
||||
)}
|
||||
onClick={() => onView(candidate)}
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.ee81adfcef',
|
||||
'View'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{!ignored ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.a9957007eb',
|
||||
'Ignore {{value0}}',
|
||||
{ value0: candidate.displayName }
|
||||
)}
|
||||
onClick={() => onIgnore(candidate)}
|
||||
>
|
||||
<EyeOff className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4d0b72481c',
|
||||
'Ignore'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{selectable ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.3828408538',
|
||||
'Remove {{value0}}',
|
||||
{ value0: candidate.displayName }
|
||||
)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => onRemove(candidate)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9cc26c019d',
|
||||
'Remove'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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(' · ')
|
||||
}
|
||||
+16
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import type { WorktreeDeleteState } from '@/store/slices/worktrees'
|
||||
|
||||
type DeletionFlagState = Pick<WorktreeDeleteState, 'isDeleting'>
|
||||
|
||||
export function filterWorkspaceCleanupRemovalCandidates(
|
||||
candidates: readonly WorkspaceCleanupCandidate[],
|
||||
deleteStateByWorktreeId: Record<string, DeletionFlagState | undefined>
|
||||
): WorkspaceCleanupCandidate[] {
|
||||
return candidates.filter(
|
||||
(candidate) => deleteStateByWorktreeId[candidate.worktreeId]?.isDeleting !== true
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-5 items-center rounded-full border px-2 text-[11px] font-medium',
|
||||
tone === 'neutral' && 'border-border bg-background text-muted-foreground',
|
||||
tone === 'ready' &&
|
||||
'border-status-success-border bg-status-success-background text-status-success',
|
||||
tone === 'review' && 'border-border bg-muted text-foreground',
|
||||
tone === 'destructive' && 'border-destructive/30 text-destructive'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "エージェント"
|
||||
|
||||
@@ -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": "에이전트"
|
||||
|
||||
@@ -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": "智能体"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<AppState>)
|
||||
|
||||
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<WorkspaceCleanupScanResult> =>
|
||||
new Promise<WorkspaceCleanupScanResult>((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<AppState>)
|
||||
|
||||
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<AppState>)
|
||||
|
||||
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<AppState>)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<WorkspaceCleanupScanResult>()
|
||||
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<WorkspaceCleanupScanResult>()
|
||||
const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
|
||||
const scan = vi.fn().mockReturnValue(pending.promise)
|
||||
installWorkspaceCleanupApi(scan)
|
||||
const store = createCleanupTestStore()
|
||||
let joinedScan: Promise<WorkspaceCleanupScanResult> | 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<WorkspaceCleanupScanResult>()
|
||||
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<WorkspaceCleanupScanResult>()
|
||||
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<AppState>)
|
||||
|
||||
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<WorkspaceCleanupScanResult>()
|
||||
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<WorkspaceCleanupScanResult>()
|
||||
const terminalProbe = deferred<boolean>()
|
||||
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<AppState>)
|
||||
|
||||
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<WorkspaceCleanupScanResult>()
|
||||
const terminalProbe = deferred<boolean>()
|
||||
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<AppState>)
|
||||
|
||||
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<WorkspaceCleanupScanResult>()
|
||||
const secondPending = deferred<WorkspaceCleanupScanResult>()
|
||||
const terminalProbe = deferred<boolean>()
|
||||
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<AppState>)
|
||||
|
||||
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<WorkspaceCleanupScanResult>()
|
||||
const secondBroadScan = deferred<WorkspaceCleanupScanResult>()
|
||||
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<AppState>)
|
||||
|
||||
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<WorkspaceCleanupScanResult>()
|
||||
const secondPending = deferred<WorkspaceCleanupScanResult>()
|
||||
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<WorkspaceCleanupScanResult>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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> = {}
|
||||
): 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> = {}): AppState {
|
||||
return {
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
browserTabsByWorktree: {},
|
||||
retainedAgentsByPaneKey: {},
|
||||
activeWorktreeId: null,
|
||||
agentStatusByPaneKey: {},
|
||||
runtimePaneTitlesByTabId: {},
|
||||
lastVisitedAtByWorktreeId: {},
|
||||
workspaceCleanupDismissals: {},
|
||||
workspaceCleanupViewedCandidates: {},
|
||||
...overrides
|
||||
} as AppState
|
||||
}
|
||||
|
||||
export function createCleanupTestStore(removeWorktree: ReturnType<typeof vi.fn> = vi.fn()) {
|
||||
return create<AppState>()(
|
||||
(...a) =>
|
||||
({
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
browserTabsByWorktree: {},
|
||||
retainedAgentsByPaneKey: {},
|
||||
activeWorktreeId: null,
|
||||
agentStatusByPaneKey: {},
|
||||
runtimePaneTitlesByTabId: {},
|
||||
lastVisitedAtByWorktreeId: {},
|
||||
removeWorktree,
|
||||
...createWorkspaceCleanupSlice(...a)
|
||||
}) as unknown as AppState
|
||||
)
|
||||
}
|
||||
|
||||
export function installWorkspaceCleanupApi(scan: ReturnType<typeof vi.fn>) {
|
||||
;(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<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (error: unknown) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((innerResolve, innerReject) => {
|
||||
resolve = innerResolve
|
||||
reject = innerReject
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
@@ -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> = {}
|
||||
): 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> = {}): 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<AppState>()(
|
||||
(...a) =>
|
||||
({
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
browserTabsByWorktree: {},
|
||||
retainedAgentsByPaneKey: {},
|
||||
activeWorktreeId: null,
|
||||
agentStatusByPaneKey: {},
|
||||
runtimePaneTitlesByTabId: {},
|
||||
lastVisitedAtByWorktreeId: {},
|
||||
removeWorktree,
|
||||
...createWorkspaceCleanupSlice(...a)
|
||||
}) as unknown as AppState
|
||||
)
|
||||
}
|
||||
|
||||
function installWorkspaceCleanupApi(scan: ReturnType<typeof vi.fn>) {
|
||||
;(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<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (error: unknown) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<T>((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<WorkspaceCleanupScanResult>()
|
||||
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<WorkspaceCleanupScanResult>()
|
||||
const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
|
||||
const scan = vi.fn().mockReturnValue(pending.promise)
|
||||
installWorkspaceCleanupApi(scan)
|
||||
const store = createCleanupTestStore()
|
||||
let joinedScan: Promise<WorkspaceCleanupScanResult> | 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<WorkspaceCleanupScanResult>()
|
||||
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<WorkspaceCleanupScanResult>()
|
||||
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<AppState>)
|
||||
|
||||
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<WorkspaceCleanupScanResult>()
|
||||
const secondPending = deferred<WorkspaceCleanupScanResult>()
|
||||
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<WorkspaceCleanupScanResult>()
|
||||
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<AppState>)
|
||||
|
||||
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<WorkspaceCleanupScanResult> =>
|
||||
new Promise<WorkspaceCleanupScanResult>((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<AppState>)
|
||||
|
||||
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<AppState>)
|
||||
|
||||
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<AppState>)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<void>
|
||||
resetWorkspaceCleanupDismissals: () => Promise<void>
|
||||
removeWorkspaceCleanupCandidates: (
|
||||
worktreeIds: readonly string[]
|
||||
worktreeIds: readonly string[],
|
||||
options?: WorkspaceCleanupRemoveOptions
|
||||
) => Promise<WorkspaceCleanupRemoveResult>
|
||||
}
|
||||
|
||||
@@ -78,10 +85,23 @@ let inFlightWorkspaceCleanupScan: {
|
||||
promise: Promise<WorkspaceCleanupScanResult>
|
||||
} | null = null
|
||||
let latestWorkspaceCleanupScanToken = 0
|
||||
let finalizedWorkspaceCleanupScanToken = 0
|
||||
let workspaceCleanupProgressQueue: {
|
||||
scanToken: number
|
||||
promise: Promise<void>
|
||||
} | null = null
|
||||
let workspaceCleanupEnrichmentCache: {
|
||||
scanToken: number
|
||||
entries: Map<string, WorkspaceCleanupEnrichmentCacheEntry>
|
||||
} | 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<string, number>
|
||||
} | null = null
|
||||
|
||||
const SHELL_PROCESS_NAMES = new Set([
|
||||
'bash',
|
||||
@@ -160,10 +180,13 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
|
||||
workspaceCleanupError: null
|
||||
})
|
||||
const scanToken = ++latestWorkspaceCleanupScanToken
|
||||
finalizedWorkspaceCleanupScanToken = 0
|
||||
workspaceCleanupProgressQueue = null
|
||||
workspaceCleanupEnrichmentCache = { scanToken, entries: new Map() }
|
||||
workspaceCleanupProgressCandidateIndex = null
|
||||
const promise = (async () => {
|
||||
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<AppState, [], [], Workspa
|
||||
)
|
||||
const result = { ...scan, candidates: enriched }
|
||||
if (scanToken === latestWorkspaceCleanupScanToken) {
|
||||
finalizedWorkspaceCleanupScanToken = scanToken
|
||||
workspaceCleanupEnrichmentCache = null
|
||||
workspaceCleanupProgressCandidateIndex = null
|
||||
set({
|
||||
workspaceCleanupScan: result,
|
||||
workspaceCleanupProgress: {
|
||||
scanId: get().workspaceCleanupProgress?.scanId ?? '',
|
||||
scanId: get().workspaceCleanupProgress?.scanId ?? scanArgs.scanId ?? '',
|
||||
scannedAt: result.scannedAt,
|
||||
scannedWorktreeCount: result.candidates.length,
|
||||
totalWorktreeCount: result.candidates.length,
|
||||
@@ -266,14 +292,22 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
|
||||
await window.api.workspaceCleanup.clearDismissals()
|
||||
},
|
||||
|
||||
removeWorkspaceCleanupCandidates: async (worktreeIds) => {
|
||||
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<AppState, [], [], Workspa
|
||||
for (const candidate of [...candidatesToRemove].sort((a, b) => 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<AppState, [], [], Workspa
|
||||
}
|
||||
|
||||
if (removedIds.length > 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<AppState> | ((state: AppState) => Partial<AppState>),
|
||||
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<void> {
|
||||
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<string, number>
|
||||
} {
|
||||
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<T, R>(
|
||||
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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3151,6 +3151,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
...s.deleteStateByWorktreeId,
|
||||
[worktreeId]: {
|
||||
isDeleting: true,
|
||||
phase: 'deleting',
|
||||
error: null,
|
||||
canForceDelete: false
|
||||
}
|
||||
@@ -3508,7 +3509,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
// 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<AppState, [], [], WorktreeSlice>
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "'\\''")}'`
|
||||
}
|
||||
Reference in New Issue
Block a user