mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/renderer-heap-highwater
This commit is contained in:
@@ -9,13 +9,14 @@ import {
|
||||
Alert
|
||||
} from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
|
||||
import { loadHosts } from '../../../src/transport/host-store'
|
||||
import { useHostClient } from '../../../src/transport/client-context'
|
||||
import type { RpcSuccess } from '../../../src/transport/types'
|
||||
import { colors, spacing } from '../../../src/theme/mobile-theme'
|
||||
import { styles } from './accounts-screen-styles'
|
||||
import { useNow } from '../../../src/hooks/use-now'
|
||||
import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
|
||||
import {
|
||||
type AccountsSnapshot,
|
||||
@@ -40,14 +41,16 @@ export default function AccountsScreen() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
|
||||
const [clockEnabled, setClockEnabled] = useState(false)
|
||||
|
||||
// Why: the reset countdown must stay fresh while the screen sits open —
|
||||
// snapshot pushes only arrive when the desktop's rate-limit poll completes.
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 60_000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setClockEnabled(true)
|
||||
return () => setClockEnabled(false)
|
||||
}, [])
|
||||
)
|
||||
// Why: snapshot pushes only arrive when the desktop's rate-limit poll completes.
|
||||
const now = useNow(60_000, clockEnabled)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
useForceReconnect
|
||||
} from '../../../src/transport/client-context'
|
||||
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
|
||||
import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh'
|
||||
import {
|
||||
useLastConnectedAt,
|
||||
useReconnectAttempt
|
||||
@@ -61,7 +62,7 @@ import { buildWorktreeNavigationActions } from '../../../src/agent-history/workt
|
||||
import { floatingWorkspaceSessionPath } from '../../../src/session/floating-workspace'
|
||||
import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
||||
import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
||||
import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen'
|
||||
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
|
||||
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
|
||||
import { MobileSearchField } from '../../../src/components/MobileSearchField'
|
||||
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
|
||||
@@ -70,7 +71,6 @@ import { setCachedRepos } from '../../../src/cache/repo-cache'
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
|
||||
import { leaveHostRoute } from '../../../src/host-route-exit'
|
||||
import { useHostStatusGates } from '../../../src/transport/host-status-gates'
|
||||
import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
|
||||
import {
|
||||
createInitialHostRouteActionState,
|
||||
@@ -141,7 +141,8 @@ export function HostScreen({
|
||||
const lastConnectedAt = useLastConnectedAt(hostId)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
const fetchWorktreesInFlightRef = useRef(false)
|
||||
const fetchRepoMetadataInFlightRef = useRef(false)
|
||||
const fetchRepoMetadataInFlightRef = useRef(new WeakSet<RpcClient>())
|
||||
const fetchRepoMetadataPendingRef = useRef(new WeakSet<RpcClient>())
|
||||
const repoMetadataFetchedAtRef = useRef(0)
|
||||
const newWorktreeModalRef = useRef<{ open: () => void }>(null)
|
||||
const newWorktreeModalVisibleRef = useRef(false)
|
||||
@@ -176,11 +177,7 @@ export function HostScreen({
|
||||
const [showGroupPicker, setShowGroupPicker] = useState(false)
|
||||
const [showFilterModal, setShowFilterModal] = useState(false)
|
||||
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
|
||||
const { hostCapabilities, floatingWorkspaceEnabled, compatVerdict } = useHostStatusGates({
|
||||
hostId,
|
||||
client,
|
||||
connState
|
||||
})
|
||||
const { hostCapabilities, floatingWorkspaceEnabled } = useHostProtocolGates()
|
||||
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
|
||||
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
|
||||
const [routeActionState, setRouteActionState] = useState(() =>
|
||||
@@ -356,48 +353,54 @@ export function HostScreen({
|
||||
}, [hostId])
|
||||
|
||||
const fetchRepoMetadata = useCallback(
|
||||
async (options: { force?: boolean } = {}) => {
|
||||
async (options: { force?: boolean; queueIfInFlight?: boolean } = {}) => {
|
||||
if (!client || connState !== 'connected' || !hostId) {
|
||||
return
|
||||
}
|
||||
if (fetchRepoMetadataInFlightRef.current) {
|
||||
if (fetchRepoMetadataInFlightRef.current.has(client)) {
|
||||
if (options.queueIfInFlight) {
|
||||
fetchRepoMetadataPendingRef.current.add(client)
|
||||
}
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
if (!options.force && now - repoMetadataFetchedAtRef.current < REPO_METADATA_REFRESH_MS) {
|
||||
return
|
||||
}
|
||||
fetchRepoMetadataInFlightRef.current = true
|
||||
fetchRepoMetadataInFlightRef.current.add(client)
|
||||
const requestClient = client,
|
||||
requestHostId = hostId
|
||||
try {
|
||||
const repoResponse = await requestClient.sendRequest('repo.list')
|
||||
if (clientRef.current !== requestClient || hostId !== requestHostId || !repoResponse.ok) {
|
||||
return
|
||||
}
|
||||
const repoResult = (repoResponse as RpcSuccess).result as { repos: RepoSummary[] }
|
||||
repoMetadataFetchedAtRef.current = Date.now()
|
||||
setCachedRepos(requestHostId, repoResult.repos)
|
||||
setRepoColorsByName(
|
||||
new Map(
|
||||
repoResult.repos.map((repo) => [
|
||||
repo.displayName,
|
||||
repo.badgeColor || repoColor(repo.displayName)
|
||||
])
|
||||
)
|
||||
)
|
||||
setRepoIconsByName(
|
||||
new Map(
|
||||
repoResult.repos.flatMap((repo) =>
|
||||
repo.repoIcon ? [[repo.displayName, repo.repoIcon] as const] : []
|
||||
do {
|
||||
fetchRepoMetadataPendingRef.current.delete(requestClient)
|
||||
const repoResponse = await requestClient.sendRequest('repo.list')
|
||||
if (clientRef.current !== requestClient || hostId !== requestHostId || !repoResponse.ok) {
|
||||
return
|
||||
}
|
||||
const repoResult = (repoResponse as RpcSuccess).result as { repos: RepoSummary[] }
|
||||
repoMetadataFetchedAtRef.current = Date.now()
|
||||
setCachedRepos(requestHostId, repoResult.repos)
|
||||
setRepoColorsByName(
|
||||
new Map(
|
||||
repoResult.repos.map((repo) => [
|
||||
repo.displayName,
|
||||
repo.badgeColor || repoColor(repo.displayName)
|
||||
])
|
||||
)
|
||||
)
|
||||
)
|
||||
setRepoIdsByName(new Map(repoResult.repos.map((repo) => [repo.displayName, repo.id])))
|
||||
setRepoIconsByName(
|
||||
new Map(
|
||||
repoResult.repos.flatMap((repo) =>
|
||||
repo.repoIcon ? [[repo.displayName, repo.repoIcon] as const] : []
|
||||
)
|
||||
)
|
||||
)
|
||||
setRepoIdsByName(new Map(repoResult.repos.map((repo) => [repo.displayName, repo.id])))
|
||||
} while (fetchRepoMetadataPendingRef.current.has(requestClient))
|
||||
} catch {
|
||||
// Repo metadata is decorative; the next throttled refresh can retry.
|
||||
// Repo metadata is decorative; the next refresh can retry.
|
||||
} finally {
|
||||
fetchRepoMetadataInFlightRef.current = false
|
||||
fetchRepoMetadataInFlightRef.current.delete(requestClient)
|
||||
}
|
||||
},
|
||||
[client, connState, hostId]
|
||||
@@ -494,39 +497,29 @@ export function HostScreen({
|
||||
}, [])
|
||||
)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// The embedded sidebar isn't a routed screen (focus never fires); it polls via the mount effect below.
|
||||
if (embedded || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
void fetchWorktrees()
|
||||
void fetchRepoMetadata()
|
||||
// Pull desktop's shared view settings on focus so desktop changes show up without a manual refresh.
|
||||
void syncViewSettingsFromDesktop()
|
||||
// Why: React Navigation keeps prior screens mounted; only poll while this route is visible.
|
||||
const interval = setInterval(() => {
|
||||
void fetchWorktrees()
|
||||
void fetchRepoMetadata()
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
|
||||
)
|
||||
|
||||
// Why: the embedded sidebar is never the focused route, so useFocusEffect never polls; mirror it from a mount effect.
|
||||
useEffect(() => {
|
||||
if (!embedded || connState !== 'connected') {
|
||||
const startWorktreeRefresh = useCallback(() => {
|
||||
if (!client || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
void fetchWorktrees()
|
||||
void fetchRepoMetadata()
|
||||
void syncViewSettingsFromDesktop()
|
||||
const interval = setInterval(() => {
|
||||
void fetchWorktrees()
|
||||
void fetchRepoMetadata()
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
|
||||
return startHostWorktreeRefresh({ client, fetchWorktrees, fetchRepoMetadata })
|
||||
}, [client, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// The embedded sidebar isn't a routed screen (focus never fires); it refreshes via the mount effect below.
|
||||
if (!embedded) {
|
||||
return startWorktreeRefresh()
|
||||
}
|
||||
}, [embedded, startWorktreeRefresh])
|
||||
)
|
||||
|
||||
// Why: the embedded sidebar is never the focused route, so wire its refresh lifecycle from a mount effect.
|
||||
useEffect(() => {
|
||||
if (embedded) {
|
||||
return startWorktreeRefresh()
|
||||
}
|
||||
}, [embedded, startWorktreeRefresh])
|
||||
|
||||
// Why (#8498): steady-state polls miss the transition INTO 'connected' after background/sleep, when the cache is stalest.
|
||||
const { refreshing, onRefresh } = useWorktreeResync({
|
||||
@@ -745,15 +738,17 @@ export function HostScreen({
|
||||
const toggleCollapsed = useCallback(
|
||||
(key: string) => {
|
||||
const next = new Set(viewStateRef.current.collapsedGroups)
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
if (!next.delete(key)) {
|
||||
next.add(key)
|
||||
}
|
||||
persistViewSettings({ collapsedGroups: [...next] })
|
||||
},
|
||||
[persistViewSettings]
|
||||
)
|
||||
const toggleWorktreeLineage = useCallback(
|
||||
(item: Worktree) => toggleCollapsed(getMobileWorkspaceLineageGroupKey(item.worktreeId)),
|
||||
[toggleCollapsed]
|
||||
)
|
||||
const { sections, rawSections, uniqueRepos, uniqueRepoColors } = useWorkspaceSections({
|
||||
displayWorktrees,
|
||||
sortMode,
|
||||
@@ -780,10 +775,6 @@ export function HostScreen({
|
||||
)
|
||||
}
|
||||
|
||||
if (compatVerdict.kind === 'blocked') {
|
||||
return <ProtocolBlockScreen verdict={compatVerdict} />
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.topChrome}>
|
||||
@@ -1200,9 +1191,7 @@ export function HostScreen({
|
||||
hideRepo={groupMode === 'repo'}
|
||||
onPress={openWorktreeSession}
|
||||
onLongPress={item.workspaceKind === 'folder-workspace' ? undefined : setActionTarget}
|
||||
onToggleLineage={(row) =>
|
||||
toggleCollapsed(getMobileWorkspaceLineageGroupKey(row.worktreeId))
|
||||
}
|
||||
onToggleLineage={toggleWorktreeLineage}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -2706,14 +2706,25 @@ export default function SessionScreen() {
|
||||
if (connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
void fetchSessionTabs()
|
||||
void fetchTerminals()
|
||||
// Why: live subscription keeps stream ownership, but the fallback list poll should stop while this route is hidden.
|
||||
const interval = setInterval(() => {
|
||||
const refreshOnForeground = () => {
|
||||
if (AppState.currentState !== 'active') {
|
||||
return
|
||||
}
|
||||
void fetchSessionTabs()
|
||||
void fetchTerminals()
|
||||
}, 2000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
const appStateSubscription = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
refreshOnForeground()
|
||||
}
|
||||
})
|
||||
// Why: live subscription keeps stream ownership, but the fallback list poll should stop while this route is hidden or backgrounded.
|
||||
const interval = setInterval(refreshOnForeground, 2000)
|
||||
refreshOnForeground()
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
appStateSubscription.remove()
|
||||
}
|
||||
}, [connState, fetchSessionTabs, fetchTerminals])
|
||||
)
|
||||
|
||||
@@ -4514,14 +4525,24 @@ export default function SessionScreen() {
|
||||
>
|
||||
<Plus size={16} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
{quickCommandsSupported === true ? (
|
||||
<QuickCommandsTabButton
|
||||
disabled={
|
||||
creating || creatingBrowser || creatingMarkdown || connState !== 'connected'
|
||||
{/* Why: stable placement matters, while old hosts must stay gated because they strip agentPrompt. */}
|
||||
<QuickCommandsTabButton
|
||||
disabled={
|
||||
creating || creatingBrowser || creatingMarkdown || connState !== 'connected'
|
||||
}
|
||||
onPress={() => {
|
||||
if (quickCommandsSupported === true) {
|
||||
setShowQuickCommands(true)
|
||||
return
|
||||
}
|
||||
onPress={() => setShowQuickCommands(true)}
|
||||
/>
|
||||
) : null}
|
||||
showToast(
|
||||
quickCommandsSupported === false
|
||||
? 'Desktop update required for quick commands'
|
||||
: 'Checking desktop capabilities — try again in a moment',
|
||||
1600
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
|
||||
+18
-15
@@ -10,6 +10,7 @@ import {
|
||||
loadHostSidebarWidth,
|
||||
saveHostSidebarWidth
|
||||
} from '../../src/storage/preferences'
|
||||
import { HostProtocolGate } from '../../src/components/HostProtocolGate'
|
||||
import { HostScreen } from './[hostId]/index'
|
||||
|
||||
// Keep at least this much room for the detail pane when resizing the sidebar.
|
||||
@@ -138,23 +139,25 @@ export default function HostGroupLayout() {
|
||||
// changes so a fold/rotation doesn't remount the navigator and reset the
|
||||
// navigation stack — only the sidebar pane toggles in and out.
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{showSidebar && sidebarOpen ? (
|
||||
<View style={[styles.sidebar, { width: sidebarWidth }]}>
|
||||
<HostScreen
|
||||
embedded
|
||||
hostId={hostId}
|
||||
action={action}
|
||||
onHideSidebar={canCollapseSidebar ? hideSidebar : undefined}
|
||||
/>
|
||||
{/* Dedicated drag handle straddling the right border — see resizer note. */}
|
||||
<View style={styles.resizeHandle} {...resizer.panHandlers} />
|
||||
<HostProtocolGate hostId={hostId}>
|
||||
<View style={styles.row}>
|
||||
{showSidebar && sidebarOpen ? (
|
||||
<View style={[styles.sidebar, { width: sidebarWidth }]}>
|
||||
<HostScreen
|
||||
embedded
|
||||
hostId={hostId}
|
||||
action={action}
|
||||
onHideSidebar={canCollapseSidebar ? hideSidebar : undefined}
|
||||
/>
|
||||
{/* Dedicated drag handle straddling the right border — see resizer note. */}
|
||||
<View style={styles.resizeHandle} {...resizer.panHandlers} />
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.detail}>
|
||||
<HostStack animation={showSidebar ? 'none' : 'default'} />
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.detail}>
|
||||
<HostStack animation={showSidebar ? 'none' : 'default'} />
|
||||
</View>
|
||||
</View>
|
||||
</HostProtocolGate>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+11
-12
@@ -28,6 +28,7 @@ import { loadHosts } from '../src/transport/host-store'
|
||||
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
|
||||
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
|
||||
import {
|
||||
useAllHostClients,
|
||||
useCloseHost,
|
||||
@@ -140,11 +141,11 @@ function clientKey(client: RpcClient): number {
|
||||
|
||||
function fetchStats(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
setStats: (s: StatsSummary) => void,
|
||||
disposed: () => boolean
|
||||
) {
|
||||
client
|
||||
.sendRequest('stats.summary')
|
||||
sendSingleFlightRequest(client, hostId, 'stats.summary')
|
||||
.then((response) => {
|
||||
if (disposed()) {
|
||||
return
|
||||
@@ -182,9 +183,8 @@ function fetchWorktreeInfo(
|
||||
})
|
||||
}
|
||||
|
||||
client
|
||||
// Why: worktree.ps defaults to 200 and silently truncates; request all so counts are accurate.
|
||||
.sendRequest('worktree.ps', { limit: 10000 })
|
||||
// Why: worktree.ps defaults to 200 and silently truncates; request all so counts are accurate.
|
||||
sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: 10000 })
|
||||
.then((response) => {
|
||||
if (disposed()) {
|
||||
return
|
||||
@@ -225,8 +225,7 @@ function fetchAccountsSnapshot(
|
||||
) => void,
|
||||
disposed: () => boolean
|
||||
) {
|
||||
client
|
||||
.sendRequest('accounts.list')
|
||||
sendSingleFlightRequest(client, hostId, 'accounts.list')
|
||||
.then((response) => {
|
||||
if (disposed()) {
|
||||
return
|
||||
@@ -248,9 +247,9 @@ function fetchTaskProviders(
|
||||
disposed: () => boolean
|
||||
) {
|
||||
Promise.all([
|
||||
client.sendRequest('settings.get'),
|
||||
client.sendRequest('preflight.check'),
|
||||
client.sendRequest('linear.status')
|
||||
sendSingleFlightRequest(client, hostId, 'settings.get'),
|
||||
sendSingleFlightRequest(client, hostId, 'preflight.check'),
|
||||
sendSingleFlightRequest(client, hostId, 'linear.status')
|
||||
])
|
||||
.then(([settingsResponse, preflightResponse, linearResponse]) => {
|
||||
if (disposed()) {
|
||||
@@ -405,7 +404,7 @@ export default function HomeScreen() {
|
||||
})
|
||||
for (const entry of allClientsRef.current) {
|
||||
if (entry.client.getState() === 'connected') {
|
||||
fetchStats(entry.client, setStats, () => stale)
|
||||
fetchStats(entry.client, entry.hostId, setStats, () => stale)
|
||||
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
|
||||
fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale)
|
||||
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale)
|
||||
@@ -512,7 +511,7 @@ export default function HomeScreen() {
|
||||
}
|
||||
if (!statsFetched) {
|
||||
statsFetched = true
|
||||
fetchStats(entry.client, setStats, () => false)
|
||||
fetchStats(entry.client, entry.hostId, setStats, () => false)
|
||||
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
|
||||
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
View
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useFocusEffect, useRouter } from 'expo-router'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
@@ -18,6 +18,7 @@ import { useAllHostClients } from '../src/transport/client-context'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { BottomDrawer } from '../src/components/BottomDrawer'
|
||||
import { VoiceModelList } from '../src/components/VoiceModelList'
|
||||
import { useDictationSetupPoller } from '../src/dictation/use-dictation-setup-poller'
|
||||
import {
|
||||
deleteDictationModel,
|
||||
downloadDictationModel,
|
||||
@@ -58,44 +59,45 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busyAction, setBusyAction] = useState<ModelBusyAction | null>(null)
|
||||
const [modelDrawerOpen, setModelDrawerOpen] = useState(false)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const [routeFocused, setRouteFocused] = useState(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setRouteFocused(true)
|
||||
return () => setRouteFocused(false)
|
||||
}, [])
|
||||
)
|
||||
|
||||
const refresh = useCallback(async (): Promise<boolean | undefined> => {
|
||||
if (!client) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
try {
|
||||
setSetup(await fetchDictationSetup(client))
|
||||
const next = await fetchDictationSetup(client)
|
||||
setSetup(next)
|
||||
setError(null)
|
||||
return next.models.some(isModelInFlight)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load voice settings')
|
||||
return undefined
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [client])
|
||||
|
||||
// Initial load once a connected client is available.
|
||||
useEffect(() => {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
void refresh().finally(() => setLoading(false))
|
||||
}, [client, refresh])
|
||||
const polling = setup?.models.some(isModelInFlight) ?? false
|
||||
const refreshSetup = useDictationSetupPoller({
|
||||
visible: routeFocused && client !== null,
|
||||
polling,
|
||||
refresh,
|
||||
intervalMs: POLL_INTERVAL_MS
|
||||
})
|
||||
|
||||
// Poll only while a model is downloading/extracting; stop otherwise.
|
||||
useEffect(() => {
|
||||
const inFlight = setup?.models.some(isModelInFlight) ?? false
|
||||
if (inFlight && client) {
|
||||
pollRef.current = setInterval(() => void refresh(), POLL_INTERVAL_MS)
|
||||
return () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
}
|
||||
if (routeFocused && client && setup === null) {
|
||||
setLoading(true)
|
||||
}
|
||||
return undefined
|
||||
}, [setup, client, refresh])
|
||||
}, [routeFocused, client, setup])
|
||||
|
||||
const handleToggleEnabled = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
@@ -109,10 +111,10 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
|
||||
setSetup(await setDictationConfig(client, { enabled }))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Could not update')
|
||||
void refresh()
|
||||
void refreshSetup()
|
||||
}
|
||||
},
|
||||
[client, refresh]
|
||||
[client, refreshSetup]
|
||||
)
|
||||
|
||||
const handleSelectMode = useCallback(
|
||||
@@ -126,10 +128,10 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
|
||||
setSetup(await setDictationConfig(client, { dictationMode }))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Could not update')
|
||||
void refresh()
|
||||
void refreshSetup()
|
||||
}
|
||||
},
|
||||
[client, refresh]
|
||||
[client, refreshSetup]
|
||||
)
|
||||
|
||||
const handleUseModel = useCallback(
|
||||
@@ -160,14 +162,14 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
|
||||
setError(null)
|
||||
try {
|
||||
await downloadDictationModel(client, model.id)
|
||||
await refresh()
|
||||
await refreshSetup()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Download failed')
|
||||
} finally {
|
||||
setBusyAction(null)
|
||||
}
|
||||
},
|
||||
[client, refresh]
|
||||
[client, refreshSetup]
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(
|
||||
|
||||
Generated
+50
-83
@@ -1494,6 +1494,12 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@eslint-community/eslint-utils@4.10.1':
|
||||
resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
peerDependencies:
|
||||
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1':
|
||||
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
@@ -1942,56 +1948,48 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.52.0':
|
||||
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
|
||||
@@ -2064,56 +2062,48 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.71.0':
|
||||
resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==}
|
||||
@@ -2575,42 +2565,36 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.1.3':
|
||||
resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.1.3':
|
||||
resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.1.3':
|
||||
resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==}
|
||||
@@ -3167,11 +3151,11 @@ packages:
|
||||
resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==}
|
||||
engines: {node: '>= 5.10.0'}
|
||||
|
||||
brace-expansion@1.1.15:
|
||||
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
|
||||
brace-expansion@1.1.16:
|
||||
resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
|
||||
brace-expansion@5.0.7:
|
||||
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
braces@3.0.3:
|
||||
@@ -4175,8 +4159,8 @@ packages:
|
||||
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
flatted@3.4.2:
|
||||
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
||||
flatted@3.4.3:
|
||||
resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==}
|
||||
|
||||
flow-enums-runtime@0.0.6:
|
||||
resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==}
|
||||
@@ -4910,28 +4894,24 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
@@ -5910,8 +5890,8 @@ packages:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shell-quote@1.8.4:
|
||||
resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==}
|
||||
shell-quote@1.10.0:
|
||||
resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
@@ -6585,6 +6565,18 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@7.5.13:
|
||||
resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==}
|
||||
engines: {node: '>=8.3.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: ^5.0.2
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.21.0:
|
||||
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -8098,6 +8090,11 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@eslint-community/eslint-utils@4.10.1(eslint@9.39.4)':
|
||||
dependencies:
|
||||
eslint: 9.39.4
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)':
|
||||
dependencies:
|
||||
eslint: 9.39.4
|
||||
@@ -9674,7 +9671,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/yargs-parser': 21.0.3
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)':
|
||||
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
|
||||
@@ -9694,7 +9691,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.59.2
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
'@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
|
||||
'@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.59.2
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.4
|
||||
@@ -9711,15 +9708,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/project-service@8.59.2(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
debug: 4.4.3
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/scope-manager@8.59.2':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
@@ -9729,10 +9717,6 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)':
|
||||
dependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
'@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
@@ -9762,21 +9746,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.59.2(typescript@6.0.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
|
||||
'@typescript-eslint/types': 8.59.2
|
||||
'@typescript-eslint/visitor-keys': 8.59.2
|
||||
debug: 4.4.3
|
||||
minimatch: 10.2.5
|
||||
semver: 7.7.4
|
||||
tinyglobby: 0.2.17
|
||||
ts-api-utils: 2.5.0(typescript@6.0.3)
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
|
||||
@@ -10236,12 +10205,12 @@ snapshots:
|
||||
dependencies:
|
||||
big-integer: 1.6.52
|
||||
|
||||
brace-expansion@1.1.15:
|
||||
brace-expansion@1.1.16:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@5.0.6:
|
||||
brace-expansion@5.0.7:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -10839,11 +10808,11 @@ snapshots:
|
||||
|
||||
eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)
|
||||
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
|
||||
eslint: 9.39.4
|
||||
eslint-config-prettier: 9.1.2(eslint@9.39.4)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
|
||||
eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3)
|
||||
eslint-plugin-node: 11.1.0(eslint@9.39.4)
|
||||
eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8)
|
||||
@@ -10867,7 +10836,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
@@ -10890,7 +10859,7 @@ snapshots:
|
||||
eslint-utils: 2.1.0
|
||||
regexpp: 3.2.0
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
@@ -10901,7 +10870,7 @@ snapshots:
|
||||
doctrine: 2.1.0
|
||||
eslint: 9.39.4
|
||||
eslint-import-resolver-node: 0.3.10
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
|
||||
hasown: 2.0.3
|
||||
is-core-module: 2.16.2
|
||||
is-glob: 4.0.3
|
||||
@@ -10998,7 +10967,7 @@ snapshots:
|
||||
|
||||
eslint@9.39.4:
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
|
||||
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.4)
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@eslint/config-array': 0.21.2
|
||||
'@eslint/config-helpers': 0.4.2
|
||||
@@ -11519,10 +11488,10 @@ snapshots:
|
||||
|
||||
flat-cache@4.0.1:
|
||||
dependencies:
|
||||
flatted: 3.4.2
|
||||
flatted: 3.4.3
|
||||
keyv: 4.5.4
|
||||
|
||||
flatted@3.4.2: {}
|
||||
flatted@3.4.3: {}
|
||||
|
||||
flow-enums-runtime@0.0.6: {}
|
||||
|
||||
@@ -12933,7 +12902,7 @@ snapshots:
|
||||
serialize-error: 2.1.0
|
||||
source-map: 0.5.7
|
||||
throat: 5.0.0
|
||||
ws: 7.5.11
|
||||
ws: 7.5.13
|
||||
yargs: 17.7.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -12967,11 +12936,11 @@ snapshots:
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.6
|
||||
brace-expansion: 5.0.7
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.15
|
||||
brace-expansion: 1.1.16
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -13342,7 +13311,7 @@ snapshots:
|
||||
|
||||
react-devtools-core@6.1.5:
|
||||
dependencies:
|
||||
shell-quote: 1.8.4
|
||||
shell-quote: 1.10.0
|
||||
ws: 7.5.11
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -13769,7 +13738,7 @@ snapshots:
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
shell-quote@1.8.4: {}
|
||||
shell-quote@1.10.0: {}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
dependencies:
|
||||
@@ -14071,10 +14040,6 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
ts-api-utils@2.5.0(typescript@6.0.3):
|
||||
dependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
ts-declaration-location@1.0.7(typescript@5.9.3):
|
||||
dependencies:
|
||||
picomatch: 4.0.4
|
||||
@@ -14405,6 +14370,8 @@ snapshots:
|
||||
|
||||
ws@7.5.11: {}
|
||||
|
||||
ws@7.5.13: {}
|
||||
|
||||
ws@8.21.0: {}
|
||||
|
||||
xcode@3.0.1:
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { HostProtocolGate, useHostProtocolGates } from './HostProtocolGate'
|
||||
|
||||
const nativeTestState = vi.hoisted(() => ({
|
||||
openUrl: vi.fn(),
|
||||
platform: { OS: 'ios' as 'ios' | 'android' }
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Linking: { openURL: nativeTestState.openUrl },
|
||||
Platform: nativeTestState.platform,
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: <T>(styles: T) => styles },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
router: { replace: vi.fn() }
|
||||
}))
|
||||
|
||||
// Why: mock only client acquisition; the gate must exercise the real
|
||||
// useHostStatusGates → evaluateCompat → ProtocolBlockScreen wiring.
|
||||
const hostClient = vi.hoisted(() => ({
|
||||
current: { client: null as RpcClient | null, state: 'disconnected' as string }
|
||||
}))
|
||||
vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => hostClient.current
|
||||
}))
|
||||
|
||||
function clientWithStatus(result: Record<string, unknown>): RpcClient {
|
||||
return { sendRequest: vi.fn().mockResolvedValue({ ok: true, result }) } as unknown as RpcClient
|
||||
}
|
||||
|
||||
function GateConsumer() {
|
||||
const { hostCapabilities } = useHostProtocolGates()
|
||||
return createElement('GateStatus', null, hostCapabilities.join(','))
|
||||
}
|
||||
|
||||
function gateElement() {
|
||||
return createElement(
|
||||
HostProtocolGate,
|
||||
{ hostId: 'host-1' },
|
||||
createElement('HostContent', null, createElement(GateConsumer))
|
||||
)
|
||||
}
|
||||
|
||||
async function renderGate(): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
await act(async () => {
|
||||
renderer = create(gateElement())
|
||||
await Promise.resolve()
|
||||
})
|
||||
return renderer as unknown as ReactTestRenderer
|
||||
}
|
||||
|
||||
function renderedText(renderer: ReactTestRenderer): string {
|
||||
return JSON.stringify(renderer.toJSON())
|
||||
}
|
||||
|
||||
describe('HostProtocolGate', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
nativeTestState.openUrl.mockClear()
|
||||
nativeTestState.platform.OS = 'ios'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('replaces the host UI with the block screen when mobile is too old', async () => {
|
||||
// Why: blocked warns to console; keep test output clean without hiding other errors.
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
hostClient.current = {
|
||||
client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 999 }),
|
||||
state: 'connected'
|
||||
}
|
||||
renderer = await renderGate()
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('Update Orca Mobile')
|
||||
expect(output).toContain('Open App Store')
|
||||
expect(output).not.toContain('HostContent')
|
||||
})
|
||||
|
||||
it('routes Android mobile updates to GitHub Releases', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
nativeTestState.platform.OS = 'android'
|
||||
hostClient.current = {
|
||||
client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 999 }),
|
||||
state: 'connected'
|
||||
}
|
||||
renderer = await renderGate()
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('Update Orca Mobile')
|
||||
expect(output).toContain('Update Orca Mobile from GitHub Releases')
|
||||
expect(output).toContain('Open GitHub Releases')
|
||||
expect(output).not.toContain('mobile app store')
|
||||
expect(output).not.toContain('HostContent')
|
||||
act(() => renderer?.root.findAllByType('Pressable')[0]?.props.onPress())
|
||||
expect(nativeTestState.openUrl).toHaveBeenCalledWith(
|
||||
'https://github.com/stablyai/orca/releases'
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces the host UI with the block screen when desktop is too old', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
hostClient.current = {
|
||||
client: clientWithStatus({ protocolVersion: 0, minCompatibleMobileVersion: 0 }),
|
||||
state: 'connected'
|
||||
}
|
||||
renderer = await renderGate()
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('Update Orca on your computer')
|
||||
expect(output).toContain('Open GitHub Releases')
|
||||
expect(output).not.toContain('HostContent')
|
||||
})
|
||||
|
||||
it('renders the host UI when the verdict is ok', async () => {
|
||||
const client = clientWithStatus({
|
||||
protocolVersion: 5,
|
||||
minCompatibleMobileVersion: 0,
|
||||
capabilities: ['browser.screencast.v1']
|
||||
})
|
||||
hostClient.current = {
|
||||
client,
|
||||
state: 'connected'
|
||||
}
|
||||
renderer = await renderGate()
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('HostContent')
|
||||
expect(output).toContain('browser.screencast.v1')
|
||||
expect(output).not.toContain('Update Orca')
|
||||
expect(client.sendRequest).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the host UI while the host connection is still pending', async () => {
|
||||
hostClient.current = { client: null, state: 'connecting' }
|
||||
renderer = await renderGate()
|
||||
expect(renderedText(renderer)).toContain('HostContent')
|
||||
})
|
||||
|
||||
it('does not mount host routes before a connected host passes the compatibility probe', async () => {
|
||||
const client = {
|
||||
sendRequest: vi.fn().mockReturnValue(new Promise(() => {}))
|
||||
} as unknown as RpcClient
|
||||
hostClient.current = { client, state: 'connected' }
|
||||
renderer = await renderGate()
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('Checking host compatibility')
|
||||
expect(output).not.toContain('HostContent')
|
||||
expect(client.sendRequest).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps an already-validated host route mounted while reconnect status is pending', async () => {
|
||||
const client = {
|
||||
sendRequest: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { protocolVersion: 5, minCompatibleMobileVersion: 0 }
|
||||
})
|
||||
.mockReturnValueOnce(new Promise(() => {}))
|
||||
} as unknown as RpcClient
|
||||
hostClient.current = { client, state: 'connected' }
|
||||
renderer = await renderGate()
|
||||
|
||||
await act(async () => {
|
||||
hostClient.current = { client, state: 'disconnected' }
|
||||
renderer?.update(gateElement())
|
||||
})
|
||||
await act(async () => {
|
||||
hostClient.current = { client, state: 'connected' }
|
||||
renderer?.update(gateElement())
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(renderedText(renderer)).toContain('HostContent')
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('fails open when a connected host cannot answer the status probe', async () => {
|
||||
hostClient.current = {
|
||||
client: {
|
||||
sendRequest: vi.fn().mockResolvedValue({ ok: false, error: { message: 'unavailable' } })
|
||||
} as unknown as RpcClient,
|
||||
state: 'connected'
|
||||
}
|
||||
renderer = await renderGate()
|
||||
expect(renderedText(renderer)).toContain('HostContent')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createContext, useContext, useRef, type ReactNode } from 'react'
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native'
|
||||
import { useHostClient } from '../transport/client-context'
|
||||
import { useHostStatusGates, type HostStatusGates } from '../transport/host-status-gates'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { ProtocolBlockScreen } from './ProtocolBlockScreen'
|
||||
|
||||
type Props = {
|
||||
hostId: string | undefined
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const HostStatusGatesContext = createContext<HostStatusGates | null>(null)
|
||||
|
||||
export function useHostProtocolGates(): HostStatusGates {
|
||||
const gates = useContext(HostStatusGatesContext)
|
||||
if (!gates) {
|
||||
throw new Error('useHostProtocolGates must be used inside <HostProtocolGate>')
|
||||
}
|
||||
return gates
|
||||
}
|
||||
|
||||
// Why: single choke point above every /h/[hostId] route so a blocked verdict replaces the
|
||||
// whole host UI (sidebar + detail stack) while the host list and other hosts stay usable.
|
||||
export function HostProtocolGate({ hostId, children }: Props) {
|
||||
const { client, state } = useHostClient(hostId)
|
||||
const gates = useHostStatusGates({ hostId, client, connState: state })
|
||||
const { compatVerdict, statusPending } = gates
|
||||
const resolvedHostIdRef = useRef<string | null>(null)
|
||||
const hostKey = hostId ?? null
|
||||
if (state === 'connected' && client && !statusPending) {
|
||||
resolvedHostIdRef.current = hostKey
|
||||
}
|
||||
if (statusPending && resolvedHostIdRef.current !== hostKey) {
|
||||
// Why: child routes may call newer RPCs on mount, so wait until compatibility is known.
|
||||
return (
|
||||
<View style={styles.pending}>
|
||||
<ActivityIndicator
|
||||
color={colors.textSecondary}
|
||||
accessibilityLabel="Checking host compatibility"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (compatVerdict.kind === 'blocked') {
|
||||
return <ProtocolBlockScreen verdict={compatVerdict} />
|
||||
}
|
||||
// Why: the host sidebar needs the same status fields; sharing the result avoids a second status.get per route.
|
||||
return <HostStatusGatesContext.Provider value={gates}>{children}</HostStatusGatesContext.Provider>
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pending: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgBase
|
||||
}
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Switch, Text, View } from 'react-native'
|
||||
import { Check, Download } from 'lucide-react-native'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { triggerError, triggerSuccess } from '../platform/haptics'
|
||||
import { useDictationSetupPoller } from '../dictation/use-dictation-setup-poller'
|
||||
import {
|
||||
downloadDictationModel,
|
||||
fetchDictationSetup,
|
||||
@@ -37,40 +38,34 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
|
||||
const [setup, setSetup] = useState<MobileSpeechSetup | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const refresh = useCallback(async (): Promise<boolean | undefined> => {
|
||||
if (!client) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
try {
|
||||
setSetup(await fetchDictationSetup(client))
|
||||
const next = await fetchDictationSetup(client)
|
||||
setSetup(next)
|
||||
setError(null)
|
||||
return next.models.some(isModelInFlight)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load')
|
||||
return undefined
|
||||
}
|
||||
}, [client])
|
||||
|
||||
const polling = setup?.models.some(isModelInFlight) ?? false
|
||||
const refreshSetup = useDictationSetupPoller({
|
||||
visible: visible && client !== null,
|
||||
polling,
|
||||
refresh,
|
||||
intervalMs: POLL_INTERVAL_MS
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setError(null)
|
||||
void refresh()
|
||||
}
|
||||
}, [visible, refresh])
|
||||
|
||||
// Poll only while something is downloading/extracting; stop otherwise.
|
||||
useEffect(() => {
|
||||
const inFlight = setup?.models.some(isModelInFlight) ?? false
|
||||
if (visible && inFlight && client) {
|
||||
pollRef.current = setInterval(() => void refresh(), POLL_INTERVAL_MS)
|
||||
return () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}, [visible, setup, client, refresh])
|
||||
}, [visible])
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (model: MobileSpeechModel) => {
|
||||
@@ -81,7 +76,7 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
|
||||
setError(null)
|
||||
try {
|
||||
await downloadDictationModel(client, model.id)
|
||||
await refresh()
|
||||
await refreshSetup()
|
||||
} catch (err) {
|
||||
triggerError()
|
||||
setError(err instanceof Error ? err.message : 'Download failed')
|
||||
@@ -89,7 +84,7 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
|
||||
setBusy(null)
|
||||
}
|
||||
},
|
||||
[client, refresh]
|
||||
[client, refreshSetup]
|
||||
)
|
||||
|
||||
const handleUseModel = useCallback(
|
||||
|
||||
@@ -12,14 +12,13 @@ type Props = {
|
||||
|
||||
export function ProtocolBlockScreen({ verdict }: Props) {
|
||||
const isMobileTooOld = verdict.reason === 'mobile-too-old'
|
||||
// Why: Android APKs ship through GitHub Releases until a Play Store listing exists.
|
||||
const mobileUpdateTarget =
|
||||
Platform.OS === 'ios'
|
||||
? { label: 'Open App Store', url: IOS_APP_STORE_URL, storeName: 'the App Store' }
|
||||
: { label: null, url: null, storeName: 'your mobile app store' }
|
||||
: { label: 'Open GitHub Releases', url: RELEASES_URL, storeName: 'GitHub Releases' }
|
||||
const primaryAction = isMobileTooOld
|
||||
? mobileUpdateTarget.url && mobileUpdateTarget.label
|
||||
? { label: mobileUpdateTarget.label, url: mobileUpdateTarget.url }
|
||||
: null
|
||||
? { label: mobileUpdateTarget.label, url: mobileUpdateTarget.url }
|
||||
: { label: 'Open GitHub Releases', url: RELEASES_URL }
|
||||
|
||||
const title = isMobileTooOld ? 'Update Orca Mobile' : 'Update Orca on your computer'
|
||||
@@ -34,18 +33,14 @@ export function ProtocolBlockScreen({ verdict }: Props) {
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.body}>{body}</Text>
|
||||
{/* Why: desktop updates come from GitHub; mobile update links depend
|
||||
on the native store available for this platform. */}
|
||||
{primaryAction ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}
|
||||
onPress={() => {
|
||||
void Linking.openURL(primaryAction.url)
|
||||
}}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>{primaryAction.label}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}
|
||||
onPress={() => {
|
||||
void Linking.openURL(primaryAction.url)
|
||||
}}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>{primaryAction.label}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.secondaryButton, pressed && styles.pressed]}
|
||||
onPress={() => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react'
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
@@ -18,7 +19,7 @@ type Props = {
|
||||
|
||||
// One inline agent row: state dot → identity → last message/prompt → time ago.
|
||||
// Mirrors desktop DashboardAgentRow's compact in-card layout.
|
||||
export function WorktreeAgentRow({ agent, depth, now, unvisited }: Props) {
|
||||
function WorktreeAgentRowComponent({ agent, depth, now, unvisited }: Props) {
|
||||
const dotState = agentDotState(agent, now)
|
||||
const label = agentDisplayLabel(agent, now)
|
||||
const ts = formatTimeAgo(agent.stateStartedAt, now)
|
||||
@@ -37,6 +38,8 @@ export function WorktreeAgentRow({ agent, depth, now, unvisited }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
export const WorktreeAgentRow = memo(WorktreeAgentRowComponent)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
createElement,
|
||||
Fragment,
|
||||
useCallback,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type SetStateAction
|
||||
} from 'react'
|
||||
import { Text } from 'react-native'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import { WorktreeAgentRow } from './WorktreeAgentRow'
|
||||
import { WorktreeListRow, type WorktreeListRowItem } from './WorktreeListRow'
|
||||
|
||||
const { agentSpinnerRender, agentStateDotRender } = vi.hoisted(() => ({
|
||||
agentSpinnerRender: vi.fn(),
|
||||
agentStateDotRender: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: <T>(styles: T) => styles },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Bell: 'Bell',
|
||||
ChevronDown: 'ChevronDown',
|
||||
ChevronRight: 'ChevronRight',
|
||||
GitBranch: 'GitBranch',
|
||||
GitPullRequest: 'GitPullRequest'
|
||||
}))
|
||||
|
||||
vi.mock('../platform/haptics', () => ({ triggerMediumImpact: vi.fn() }))
|
||||
vi.mock('./AgentSpinner', () => ({
|
||||
AgentSpinner: (props: unknown) => {
|
||||
agentSpinnerRender(props)
|
||||
return null
|
||||
}
|
||||
}))
|
||||
vi.mock('./AgentStateDot', () => ({
|
||||
AgentStateDot: (props: unknown) => {
|
||||
agentStateDotRender(props)
|
||||
return null
|
||||
}
|
||||
}))
|
||||
vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
|
||||
vi.mock('./MobileRepoIcon', () => ({ MobileRepoIcon: () => null }))
|
||||
vi.mock('./WorktreeAgentList', () => ({ WorktreeAgentList: () => null }))
|
||||
vi.mock('./WorktreeMetaGlyphs', () => ({
|
||||
prStateColor: () => '#000000',
|
||||
WorktreeMetaGlyphs: () => null
|
||||
}))
|
||||
|
||||
type TestItem = WorktreeListRowItem & {
|
||||
status: 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
lastOutputAt: number
|
||||
}
|
||||
|
||||
const stableRepoIcon = { type: 'emoji', emoji: 'o' } as const
|
||||
let updateSibling: Dispatch<SetStateAction<number>> = () => undefined
|
||||
|
||||
function ListRowHarness({ item, now }: { item: TestItem; now: number }) {
|
||||
const [sibling, setSibling] = useState(0)
|
||||
updateSibling = setSibling
|
||||
const onPress = useCallback(() => undefined, [])
|
||||
const onLongPress = useCallback(() => undefined, [])
|
||||
const onToggleLineage = useCallback(() => undefined, [])
|
||||
|
||||
// Sibling state changes re-render the harness without changing the row's props,
|
||||
// exercising the row's React.memo bailout.
|
||||
return createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(Text, null, sibling),
|
||||
createElement(WorktreeListRow, {
|
||||
item,
|
||||
isReadOnly: false,
|
||||
now,
|
||||
repoColor: '#000000',
|
||||
repoIcon: stableRepoIcon,
|
||||
hideRepo: false,
|
||||
status: item.status,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onToggleLineage
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function agent(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktreeAgentRow {
|
||||
return {
|
||||
paneKey: 'agent-1',
|
||||
parentPaneKey: null,
|
||||
state: 'working',
|
||||
agentType: null,
|
||||
prompt: 'Fix the list',
|
||||
taskTitle: null,
|
||||
displayName: null,
|
||||
lastAssistantMessage: null,
|
||||
toolName: null,
|
||||
toolInput: null,
|
||||
interrupted: false,
|
||||
stateStartedAt: 1_000,
|
||||
updatedAt: 1_000,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const baseItem: TestItem = {
|
||||
worktreeId: 'worktree-1',
|
||||
repo: 'orca',
|
||||
branch: 'feature/mobile-list',
|
||||
displayName: 'mobile-list',
|
||||
liveTerminalCount: 1,
|
||||
preview: 'Waiting',
|
||||
unread: false,
|
||||
linkedPR: null,
|
||||
agents: [agent()],
|
||||
status: 'active',
|
||||
lastOutputAt: 1_000
|
||||
}
|
||||
|
||||
describe('memoized worktree rows', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
agentSpinnerRender.mockClear()
|
||||
agentStateDotRender.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('skips an unrelated parent render but updates for live item fields and time', async () => {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(ListRowHarness, { item: baseItem, now: 2_000 }))
|
||||
})
|
||||
expect(agentSpinnerRender).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => updateSibling((value) => value + 1))
|
||||
expect(agentSpinnerRender).toHaveBeenCalledTimes(1)
|
||||
|
||||
let expectedRenders = 1
|
||||
const liveUpdates: TestItem[] = [
|
||||
{ ...baseItem, preview: 'Running tests' },
|
||||
{ ...baseItem, unread: true },
|
||||
{ ...baseItem, lastOutputAt: 2_000 },
|
||||
{ ...baseItem, agents: [agent({ state: 'waiting', updatedAt: 2_000 })] },
|
||||
{ ...baseItem, status: 'working' }
|
||||
]
|
||||
for (const liveUpdate of liveUpdates) {
|
||||
await act(async () =>
|
||||
renderer!.update(createElement(ListRowHarness, { item: liveUpdate, now: 2_000 }))
|
||||
)
|
||||
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
|
||||
await act(async () =>
|
||||
renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 2_000 }))
|
||||
)
|
||||
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
|
||||
}
|
||||
|
||||
await act(async () =>
|
||||
renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 32_000 }))
|
||||
)
|
||||
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
|
||||
})
|
||||
|
||||
it('memoizes agent rows without hiding agent updates', async () => {
|
||||
const firstAgent = agent()
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(WorktreeAgentRow, {
|
||||
agent: firstAgent,
|
||||
depth: 0,
|
||||
now: 2_000,
|
||||
unvisited: false
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(agentStateDotRender).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
createElement(WorktreeAgentRow, {
|
||||
agent: firstAgent,
|
||||
depth: 0,
|
||||
now: 2_000,
|
||||
unvisited: false
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(agentStateDotRender).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
createElement(WorktreeAgentRow, {
|
||||
agent: agent({ state: 'done', updatedAt: 2_000 }),
|
||||
depth: 0,
|
||||
now: 2_000,
|
||||
unvisited: false
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(agentStateDotRender).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react'
|
||||
import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import type { RepoIcon } from '../../../src/shared/repo-icon'
|
||||
@@ -57,7 +58,7 @@ type Props<T extends WorktreeListRowItem> = {
|
||||
onToggleLineage?: (item: T) => void
|
||||
}
|
||||
|
||||
export function WorktreeListRow<T extends WorktreeListRowItem>({
|
||||
function WorktreeListRowComponent<T extends WorktreeListRowItem>({
|
||||
item,
|
||||
isReadOnly,
|
||||
now,
|
||||
@@ -195,6 +196,8 @@ export function WorktreeListRow<T extends WorktreeListRowItem>({
|
||||
)
|
||||
}
|
||||
|
||||
export const WorktreeListRow = memo(WorktreeListRowComponent) as typeof WorktreeListRowComponent
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
worktreeRow: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DictationSetupPollController } from './dictation-setup-poll-controller'
|
||||
|
||||
const POLL_INTERVAL_MS = 1500
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
// Why: the refresh mock wraps its result in `.finally()` and the resume path chains
|
||||
// runRefresh → requestRefresh → runRefresh, so the follow-up refresh is several microtask
|
||||
// hops deep — drain generously rather than a fixed two ticks.
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
return {
|
||||
promise: new Promise<T>((next) => {
|
||||
resolve = next
|
||||
}),
|
||||
resolve
|
||||
}
|
||||
}
|
||||
|
||||
describe('DictationSetupPollController', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not refresh while hidden, unfocused, or backgrounded', async () => {
|
||||
const refresh = vi.fn().mockResolvedValue(true)
|
||||
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
|
||||
poller.setPolling(true)
|
||||
|
||||
poller.setForeground(true)
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
|
||||
poller.setVisible(true)
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
await flushPromises()
|
||||
poller.setVisible(false)
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
|
||||
poller.setVisible(true)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
await flushPromises()
|
||||
poller.setForeground(false)
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
|
||||
poller.dispose()
|
||||
})
|
||||
|
||||
it('keeps slow refreshes single-flight and waits a full delay after each response', async () => {
|
||||
const requests = [deferred<boolean>(), deferred<boolean>(), deferred<boolean>()]
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
const refresh = vi.fn(() => {
|
||||
const request = requests[refresh.mock.calls.length - 1]
|
||||
active += 1
|
||||
maxActive = Math.max(maxActive, active)
|
||||
return request.promise.finally(() => {
|
||||
active -= 1
|
||||
})
|
||||
})
|
||||
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
|
||||
poller.setPolling(true)
|
||||
poller.setVisible(true)
|
||||
poller.setForeground(true)
|
||||
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
|
||||
requests[0].resolve(true)
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
|
||||
requests[1].resolve(true)
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS - 1)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(refresh).toHaveBeenCalledTimes(3)
|
||||
expect(maxActive).toBe(1)
|
||||
|
||||
requests[2].resolve(false)
|
||||
await flushPromises()
|
||||
poller.dispose()
|
||||
})
|
||||
|
||||
it('coalesces an immediate resume refresh behind a slow request', async () => {
|
||||
const requests = [deferred<boolean>(), deferred<boolean>()]
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
const refresh = vi.fn(() => {
|
||||
const request = requests[refresh.mock.calls.length - 1]
|
||||
active += 1
|
||||
maxActive = Math.max(maxActive, active)
|
||||
return request.promise.finally(() => {
|
||||
active -= 1
|
||||
})
|
||||
})
|
||||
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
|
||||
poller.setPolling(true)
|
||||
poller.setVisible(true)
|
||||
poller.setForeground(true)
|
||||
|
||||
poller.setForeground(false)
|
||||
poller.setForeground(true)
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
|
||||
requests[0].resolve(true)
|
||||
await flushPromises()
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
expect(maxActive).toBe(1)
|
||||
|
||||
requests[1].resolve(false)
|
||||
await flushPromises()
|
||||
poller.dispose()
|
||||
})
|
||||
|
||||
it('refreshes immediately when visibility or foreground eligibility resumes', async () => {
|
||||
const refresh = vi.fn().mockResolvedValue(true)
|
||||
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
|
||||
poller.setPolling(true)
|
||||
poller.setVisible(true)
|
||||
poller.setForeground(true)
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
await flushPromises()
|
||||
|
||||
poller.setForeground(false)
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
|
||||
poller.setForeground(true)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
await flushPromises()
|
||||
|
||||
poller.setVisible(false)
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
|
||||
poller.setVisible(true)
|
||||
expect(refresh).toHaveBeenCalledTimes(3)
|
||||
|
||||
poller.dispose()
|
||||
})
|
||||
|
||||
it('stops after setup leaves the download or extraction lifecycle', async () => {
|
||||
const refresh = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false)
|
||||
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
|
||||
poller.setPolling(true)
|
||||
poller.setVisible(true)
|
||||
poller.setForeground(true)
|
||||
await flushPromises()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
poller.dispose()
|
||||
})
|
||||
|
||||
it('does not resurrect polling when an in-flight refresh resolves true after setPolling(false)', async () => {
|
||||
const request = deferred<boolean>()
|
||||
const refresh = vi.fn(() => request.promise)
|
||||
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
|
||||
poller.setPolling(true)
|
||||
poller.setVisible(true)
|
||||
poller.setForeground(true)
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
|
||||
// Explicit stop lands while the read is still on the wire.
|
||||
poller.setPolling(false)
|
||||
// The stale read then resolves "keep polling" — the fence must drop it, not restart the poll.
|
||||
request.resolve(true)
|
||||
await flushPromises()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
poller.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
type PollState = {
|
||||
visible: boolean
|
||||
foreground: boolean
|
||||
polling: boolean
|
||||
}
|
||||
|
||||
type RefreshResult = boolean | undefined
|
||||
|
||||
export class DictationSetupPollController {
|
||||
private state: PollState = { visible: false, foreground: false, polling: false }
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
private inFlight = false
|
||||
private immediateRefreshPending = false
|
||||
private refreshWaiters: Array<() => void> = []
|
||||
private disposed = false
|
||||
// Why: an explicit setPolling is a newer lifecycle intent than a read that was already on the wire.
|
||||
// Bumped on every setPolling so an in-flight refresh resolving after an explicit stop/start can be
|
||||
// fenced out instead of clobbering that intent (e.g. a late `true` resurrecting a just-stopped poll).
|
||||
private pollingRevision = 0
|
||||
|
||||
constructor(
|
||||
private readonly refresh: () => Promise<RefreshResult>,
|
||||
private readonly intervalMs: number
|
||||
) {}
|
||||
|
||||
setVisible(visible: boolean): void {
|
||||
this.update({ visible })
|
||||
}
|
||||
|
||||
setForeground(foreground: boolean): void {
|
||||
this.update({ foreground })
|
||||
}
|
||||
|
||||
setPolling(polling: boolean): void {
|
||||
this.pollingRevision += 1
|
||||
this.update({ polling })
|
||||
}
|
||||
|
||||
refreshNow(): Promise<void> {
|
||||
if (this.disposed || !this.isEligible()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.refreshWaiters.push(resolve)
|
||||
this.requestRefresh(true)
|
||||
})
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
this.immediateRefreshPending = false
|
||||
this.clearTimer()
|
||||
this.resolveRefreshWaiters()
|
||||
}
|
||||
|
||||
private update(next: Partial<PollState>): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
const wasEligible = this.isEligible()
|
||||
const wasPolling = this.state.polling
|
||||
this.state = { ...this.state, ...next }
|
||||
|
||||
if (!this.isEligible()) {
|
||||
this.immediateRefreshPending = false
|
||||
this.clearTimer()
|
||||
return
|
||||
}
|
||||
if (!wasEligible) {
|
||||
this.requestRefresh(true)
|
||||
return
|
||||
}
|
||||
if (!this.state.polling) {
|
||||
this.clearTimer()
|
||||
return
|
||||
}
|
||||
if (!wasPolling) {
|
||||
this.scheduleRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
private isEligible(): boolean {
|
||||
return this.state.visible && this.state.foreground
|
||||
}
|
||||
|
||||
private requestRefresh(immediate: boolean): void {
|
||||
if (this.inFlight) {
|
||||
this.immediateRefreshPending ||= immediate
|
||||
return
|
||||
}
|
||||
this.clearTimer()
|
||||
this.inFlight = true
|
||||
void this.runRefresh()
|
||||
}
|
||||
|
||||
private async runRefresh(): Promise<void> {
|
||||
// Snapshot the lifecycle intent this read is answering; an explicit setPolling during the read makes
|
||||
// its result stale.
|
||||
const revisionAtStart = this.pollingRevision
|
||||
let shouldContinue: RefreshResult
|
||||
try {
|
||||
shouldContinue = await this.refresh()
|
||||
} catch {
|
||||
// A transient read failure preserves the current lifecycle for a later retry.
|
||||
shouldContinue = undefined
|
||||
} finally {
|
||||
this.inFlight = false
|
||||
}
|
||||
|
||||
// Fence: only let the read drive polling if no explicit setPolling superseded it mid-flight, so a
|
||||
// late `true` can't resurrect a poll the caller just stopped (nor a late `false` cancel a restart).
|
||||
if (shouldContinue !== undefined && this.pollingRevision === revisionAtStart) {
|
||||
this.state.polling = shouldContinue
|
||||
}
|
||||
if (this.disposed || !this.isEligible()) {
|
||||
this.resolveRefreshWaiters()
|
||||
return
|
||||
}
|
||||
if (this.immediateRefreshPending) {
|
||||
this.immediateRefreshPending = false
|
||||
this.requestRefresh(true)
|
||||
return
|
||||
}
|
||||
this.resolveRefreshWaiters()
|
||||
if (this.state.polling) {
|
||||
this.scheduleRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRefresh(): void {
|
||||
if (this.timer !== null || this.inFlight || !this.isEligible() || !this.state.polling) {
|
||||
return
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
this.requestRefresh(false)
|
||||
}, this.intervalMs)
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (this.timer !== null) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
private resolveRefreshWaiters(): void {
|
||||
const waiters = this.refreshWaiters.splice(0)
|
||||
for (const resolve of waiters) {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { AppState } from 'react-native'
|
||||
import { DictationSetupPollController } from './dictation-setup-poll-controller'
|
||||
|
||||
type PollerOptions = {
|
||||
visible: boolean
|
||||
polling: boolean
|
||||
refresh: () => Promise<boolean | undefined>
|
||||
intervalMs: number
|
||||
}
|
||||
|
||||
export function useDictationSetupPoller({
|
||||
visible,
|
||||
polling,
|
||||
refresh,
|
||||
intervalMs
|
||||
}: PollerOptions): () => Promise<void> {
|
||||
const refreshRef = useRef(refresh)
|
||||
refreshRef.current = refresh
|
||||
const poller = useMemo(
|
||||
() => new DictationSetupPollController(() => refreshRef.current(), intervalMs),
|
||||
[intervalMs]
|
||||
)
|
||||
|
||||
useEffect(() => () => poller.dispose(), [poller])
|
||||
|
||||
useEffect(() => {
|
||||
void poller.refreshNow()
|
||||
}, [poller, refresh])
|
||||
|
||||
useEffect(() => {
|
||||
poller.setPolling(polling)
|
||||
}, [poller, polling])
|
||||
|
||||
useEffect(() => {
|
||||
poller.setVisible(visible)
|
||||
if (!visible) {
|
||||
poller.setForeground(false)
|
||||
return undefined
|
||||
}
|
||||
|
||||
poller.setForeground(AppState.currentState === 'active')
|
||||
const subscription = AppState.addEventListener('change', (state) => {
|
||||
poller.setForeground(state === 'active')
|
||||
})
|
||||
return () => {
|
||||
subscription.remove()
|
||||
poller.setVisible(false)
|
||||
poller.setForeground(false)
|
||||
}
|
||||
}, [poller, visible])
|
||||
|
||||
return useCallback(() => poller.refreshNow(), [poller])
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
|
||||
const appState = vi.hoisted(() => ({
|
||||
current: 'active',
|
||||
listener: null as ((nextState: string) => void) | null,
|
||||
remove: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: {
|
||||
get currentState(): string {
|
||||
return appState.current
|
||||
},
|
||||
addEventListener: (_event: string, listener: (nextState: string) => void) => {
|
||||
appState.listener = listener
|
||||
return { remove: appState.remove }
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import { useNow } from './use-now'
|
||||
|
||||
describe('useNow', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let latest = 0
|
||||
let consoleSpy: MockInstance
|
||||
|
||||
function Harness({ enabled = true }: { enabled?: boolean }): null {
|
||||
latest = useNow(1_000, enabled)
|
||||
return null
|
||||
}
|
||||
|
||||
function changeAppState(nextState: string): void {
|
||||
act(() => {
|
||||
appState.current = nextState
|
||||
appState.listener?.(nextState)
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
appState.current = 'active'
|
||||
appState.listener = null
|
||||
appState.remove.mockClear()
|
||||
latest = 0
|
||||
const original = console.error
|
||||
consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.useRealTimers()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('ticks while active, pauses in the background, and refreshes immediately on resume', () => {
|
||||
expect(latest).toBe(1_000)
|
||||
|
||||
act(() => vi.advanceTimersByTime(1_000))
|
||||
expect(latest).toBe(2_000)
|
||||
|
||||
changeAppState('background')
|
||||
act(() => vi.advanceTimersByTime(5_000))
|
||||
expect(latest).toBe(2_000)
|
||||
|
||||
changeAppState('active')
|
||||
expect(latest).toBe(7_000)
|
||||
|
||||
act(() => vi.advanceTimersByTime(1_000))
|
||||
expect(latest).toBe(8_000)
|
||||
})
|
||||
|
||||
it('stops while disabled and refreshes immediately when re-enabled', () => {
|
||||
act(() => renderer?.update(createElement(Harness, { enabled: false })))
|
||||
act(() => vi.advanceTimersByTime(5_000))
|
||||
expect(latest).toBe(1_000)
|
||||
|
||||
act(() => renderer?.update(createElement(Harness, { enabled: true })))
|
||||
expect(latest).toBe(6_000)
|
||||
})
|
||||
|
||||
it('removes the shared AppState listener after the last caller unmounts', () => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
expect(appState.remove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,52 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { AppState, type AppStateStatus } from 'react-native'
|
||||
|
||||
// One shared interval per caller, mirroring desktop's useNow: relative
|
||||
// timestamps ("Xm") need a periodic re-render to stay honest. The worktree list
|
||||
// owns a single tick that drives every visible agent row, rather than each row
|
||||
// running its own interval.
|
||||
export function useNow(intervalMs = 30_000): number {
|
||||
const appStateListeners = new Set<() => void>()
|
||||
let currentAppState: AppStateStatus | null = AppState.currentState
|
||||
let appStateSubscription: ReturnType<typeof AppState.addEventListener> | null = null
|
||||
|
||||
function subscribeToAppState(listener: () => void): () => void {
|
||||
appStateListeners.add(listener)
|
||||
if (!appStateSubscription) {
|
||||
currentAppState = AppState.currentState
|
||||
appStateSubscription = AppState.addEventListener('change', (nextState) => {
|
||||
currentAppState = nextState
|
||||
appStateListeners.forEach((notify) => notify())
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
appStateListeners.delete(listener)
|
||||
if (appStateListeners.size === 0) {
|
||||
appStateSubscription?.remove()
|
||||
appStateSubscription = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isAppActive(): boolean {
|
||||
return (appStateSubscription ? currentAppState : AppState.currentState) === 'active'
|
||||
}
|
||||
|
||||
// A list-level caller's single tick drives every visible relative-time label.
|
||||
export function useNow(intervalMs = 30_000, enabled = true): number {
|
||||
const appActive = useSyncExternalStore(subscribeToAppState, isAppActive, isAppActive)
|
||||
const running = appActive && enabled
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const wasRunningRef = useRef(running)
|
||||
|
||||
useEffect(() => {
|
||||
const resumed = running && !wasRunningRef.current
|
||||
wasRunningRef.current = running
|
||||
if (!running) {
|
||||
return
|
||||
}
|
||||
if (resumed) {
|
||||
setNow(Date.now())
|
||||
}
|
||||
const id = setInterval(() => setNow(Date.now()), intervalMs)
|
||||
return () => clearInterval(id)
|
||||
}, [intervalMs])
|
||||
}, [intervalMs, running])
|
||||
|
||||
return now
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export const MOBILE_SESSION_STATUS_LABELS: Record<ConnectionState, string> = {
|
||||
connected: 'Connected',
|
||||
disconnected: 'Disconnected',
|
||||
reconnecting: 'Reconnecting',
|
||||
'auth-failed': 'Auth failed'
|
||||
'auth-failed': 'Pairing invalid'
|
||||
}
|
||||
|
||||
export const TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY = 64
|
||||
|
||||
@@ -29,6 +29,26 @@ describe('mobile session startup', () => {
|
||||
expect(autoCreateEffect).toContain('void handleCreateTerminal()')
|
||||
})
|
||||
|
||||
it('stops fallback list polling in the background and reconciles on resume', () => {
|
||||
const pollEffect = sliceBetween(
|
||||
"const refreshOnForeground = () => {\n if (AppState.currentState !== 'active')",
|
||||
'// Why: pick up Settings → Terminal text size on return'
|
||||
)
|
||||
|
||||
expect(pollEffect).toContain(
|
||||
"if (AppState.currentState !== 'active') {\n return\n }\n void fetchSessionTabs()"
|
||||
)
|
||||
expect(pollEffect).toContain('void fetchTerminals()')
|
||||
expect(pollEffect).toContain("AppState.addEventListener('change'")
|
||||
expect(pollEffect).toContain("if (state === 'active') {\n refreshOnForeground()")
|
||||
expect(pollEffect).toContain('const interval = setInterval(refreshOnForeground, 2000)')
|
||||
expect(pollEffect.lastIndexOf('\n refreshOnForeground()')).toBeGreaterThan(
|
||||
pollEffect.indexOf("AppState.addEventListener('change'")
|
||||
)
|
||||
expect(pollEffect).toContain('clearInterval(interval)')
|
||||
expect(pollEffect).toContain('appStateSubscription.remove()')
|
||||
})
|
||||
|
||||
it('loads session tabs without waiting for desktop activation', () => {
|
||||
const startupEffect = sliceBetween(
|
||||
'void (async () => {',
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const fileUrl = new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url)
|
||||
const source = readFileSync(fileUrl, 'utf8')
|
||||
const sourceFile = ts.createSourceFile(
|
||||
fileUrl.href,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX
|
||||
)
|
||||
|
||||
function findQuickCommandsTabButtons(): ts.JsxSelfClosingElement[] {
|
||||
const matches: ts.JsxSelfClosingElement[] = []
|
||||
|
||||
function visit(node: ts.Node): void {
|
||||
if (
|
||||
ts.isJsxSelfClosingElement(node) &&
|
||||
node.tagName.getText(sourceFile) === 'QuickCommandsTabButton'
|
||||
) {
|
||||
matches.push(node)
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return matches
|
||||
}
|
||||
|
||||
function getQuickCommandsTabSource(): string {
|
||||
const start = source.indexOf('accessibilityLabel="New tab"')
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const end = source.indexOf('{/* Content-row host', start)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
describe('quick-commands tab stability', () => {
|
||||
it('keeps the button mounted while preserving the capability gate', () => {
|
||||
const tabSource = getQuickCommandsTabSource()
|
||||
const buttons = findQuickCommandsTabButtons()
|
||||
|
||||
expect(buttons).toHaveLength(1)
|
||||
const tabBar = buttons[0].parent
|
||||
expect(ts.isJsxElement(tabBar)).toBe(true)
|
||||
if (!ts.isJsxElement(tabBar)) {
|
||||
return
|
||||
}
|
||||
expect(tabBar.openingElement.tagName.getText(sourceFile)).toBe('View')
|
||||
const style = tabBar.openingElement.attributes.properties.find(
|
||||
(attribute): attribute is ts.JsxAttribute =>
|
||||
ts.isJsxAttribute(attribute) && attribute.name.getText(sourceFile) === 'style'
|
||||
)
|
||||
expect(style?.initializer?.getText(sourceFile)).toBe('{styles.tabBar}')
|
||||
expect(tabSource).toContain('if (quickCommandsSupported === true)')
|
||||
expect(tabSource).toContain('setShowQuickCommands(true)')
|
||||
expect(tabSource).toContain('Desktop update required for quick commands')
|
||||
expect(tabSource).toContain('Checking desktop capabilities — try again in a moment')
|
||||
})
|
||||
|
||||
it('only presents the sheet after support is confirmed', () => {
|
||||
expect(source).toContain('visible={showQuickCommands && quickCommandsSupported === true}')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyConnection, verdictDisplayLabel } from './connection-health'
|
||||
|
||||
describe('classifyConnection auth-failed verdict', () => {
|
||||
it('tells the user to re-pair instead of showing a generic auth error', () => {
|
||||
const verdict = classifyConnection({
|
||||
state: 'auth-failed',
|
||||
reconnectAttempts: 0,
|
||||
lastConnectedAt: null,
|
||||
nowMs: 1_000_000
|
||||
})
|
||||
expect(verdict.kind).toBe('auth-failed')
|
||||
expect(verdictDisplayLabel(verdict)).toBe('Pairing invalid — re-pair with your desktop')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyConnection Tailscale hint', () => {
|
||||
const base = {
|
||||
state: 'reconnecting' as const,
|
||||
|
||||
@@ -55,8 +55,10 @@ export function classifyConnection(args: {
|
||||
const now = args.nowMs ?? Date.now()
|
||||
const hint = isTailscaleEndpoint(args.endpoint) ? TAILSCALE_HINT : undefined
|
||||
|
||||
// Why: auth-failed means the desktop no longer recognizes this pairing (e.g. it
|
||||
// lost its device registry) — retrying can't fix it, only re-pairing can, so say so.
|
||||
if (state === 'auth-failed') {
|
||||
return { kind: 'auth-failed', label: 'Auth failed' }
|
||||
return { kind: 'auth-failed', label: 'Pairing invalid — re-pair with your desktop' }
|
||||
}
|
||||
|
||||
// Connected / connecting / handshaking are normal.
|
||||
|
||||
@@ -8,6 +8,7 @@ export type HostStatusGates = {
|
||||
hostCapabilities: string[]
|
||||
floatingWorkspaceEnabled: boolean
|
||||
compatVerdict: CompatVerdict
|
||||
statusPending: boolean
|
||||
}
|
||||
|
||||
type LoadedHostStatusGates = HostStatusGates & {
|
||||
@@ -42,6 +43,14 @@ export function useHostStatusGates(args: {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
setLoaded({
|
||||
hostId,
|
||||
client: requestClient,
|
||||
hostCapabilities: [],
|
||||
floatingWorkspaceEnabled: false,
|
||||
compatVerdict: { kind: 'ok' },
|
||||
statusPending: false
|
||||
})
|
||||
return
|
||||
}
|
||||
const status = (response as RpcSuccess).result as DesktopStatus & {
|
||||
@@ -56,7 +65,8 @@ export function useHostStatusGates(args: {
|
||||
client: requestClient,
|
||||
hostCapabilities: status.capabilities ?? [],
|
||||
floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true,
|
||||
compatVerdict: verdict
|
||||
compatVerdict: verdict,
|
||||
statusPending: false
|
||||
})
|
||||
if (verdict.kind === 'blocked') {
|
||||
// Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints.
|
||||
@@ -68,7 +78,17 @@ export function useHostStatusGates(args: {
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Why: sendRequest can throw on transport tear-down; the fail-closed return below keeps gated actions hidden.
|
||||
// Why: a transient status failure must not trap navigation; conservative feature gates remain disabled.
|
||||
if (!cancelled) {
|
||||
setLoaded({
|
||||
hostId,
|
||||
client: requestClient,
|
||||
hostCapabilities: [],
|
||||
floatingWorkspaceEnabled: false,
|
||||
compatVerdict: { kind: 'ok' },
|
||||
statusPending: false
|
||||
})
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
@@ -87,12 +107,14 @@ export function useHostStatusGates(args: {
|
||||
return {
|
||||
hostCapabilities: EMPTY_HOST_CAPABILITIES,
|
||||
floatingWorkspaceEnabled: false,
|
||||
compatVerdict: { kind: 'ok' }
|
||||
compatVerdict: { kind: 'ok' },
|
||||
statusPending: connState === 'connected' && client !== null
|
||||
}
|
||||
}
|
||||
return {
|
||||
hostCapabilities: loaded.hostCapabilities,
|
||||
floatingWorkspaceEnabled: loaded.floatingWorkspaceEnabled,
|
||||
compatVerdict: loaded.compatVerdict
|
||||
compatVerdict: loaded.compatVerdict,
|
||||
statusPending: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { RpcResponse } from './types'
|
||||
import { sendSingleFlightRequest } from './request-single-flight'
|
||||
|
||||
function makeResponse(id: string): RpcResponse {
|
||||
return { id, ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
const response = makeResponse('request-1')
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function rpcClient(sendRequest: RpcClient['sendRequest']): RpcClient {
|
||||
return { sendRequest } as RpcClient
|
||||
}
|
||||
|
||||
describe('sendSingleFlightRequest', () => {
|
||||
it('coalesces triggers that arrive during an in-flight read into one trailing follow-up', async () => {
|
||||
const leading = deferred<RpcResponse>()
|
||||
const trailing = deferred<RpcResponse>()
|
||||
const leadingResponse = makeResponse('leading')
|
||||
const trailingResponse = makeResponse('trailing')
|
||||
const sendRequest = vi
|
||||
.fn<() => Promise<RpcResponse>>()
|
||||
.mockReturnValueOnce(leading.promise)
|
||||
.mockReturnValueOnce(trailing.promise)
|
||||
const client = rpcClient(sendRequest)
|
||||
|
||||
const first = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 })
|
||||
// Two more triggers arrive while the read is on the wire: no duplicate now, and they share ONE
|
||||
// trailing follow-up (not the older in-flight response).
|
||||
const second = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 })
|
||||
const third = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 })
|
||||
|
||||
expect(second).toBe(third)
|
||||
expect(second).not.toBe(first)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
|
||||
leading.resolve(leadingResponse)
|
||||
expect(await first).toBe(leadingResponse)
|
||||
|
||||
// The leading read settled → the coalesced follow-up fires exactly once.
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
trailing.resolve(trailingResponse)
|
||||
expect(await second).toBe(trailingResponse)
|
||||
expect(await third).toBe(trailingResponse)
|
||||
})
|
||||
|
||||
it('starts a fresh request once nothing is in flight', async () => {
|
||||
const leading = deferred<RpcResponse>()
|
||||
const sendRequest = vi
|
||||
.fn<() => Promise<RpcResponse>>()
|
||||
.mockReturnValueOnce(leading.promise)
|
||||
.mockResolvedValueOnce(response)
|
||||
const client = rpcClient(sendRequest)
|
||||
|
||||
const first = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 })
|
||||
leading.resolve(response)
|
||||
await first
|
||||
|
||||
const next = sendSingleFlightRequest(client, 'host-1', 'worktree.ps', { limit: 10000 })
|
||||
expect(next).not.toBe(first)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
await next
|
||||
})
|
||||
|
||||
it('rejects the leading caller on failure but still runs a queued follow-up', async () => {
|
||||
const leading = deferred<RpcResponse>()
|
||||
const failure = new Error('request failed')
|
||||
const sendRequest = vi
|
||||
.fn<() => Promise<RpcResponse>>()
|
||||
.mockReturnValueOnce(leading.promise)
|
||||
.mockResolvedValueOnce(response)
|
||||
const client = rpcClient(sendRequest)
|
||||
|
||||
const first = sendSingleFlightRequest(client, 'host-1', 'accounts.list')
|
||||
const second = sendSingleFlightRequest(client, 'host-1', 'accounts.list')
|
||||
|
||||
leading.reject(failure)
|
||||
await expect(first).rejects.toBe(failure)
|
||||
// A trigger that arrived mid-flight is not poisoned by the leading failure: its follow-up runs.
|
||||
await expect(second).resolves.toBe(response)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('clears a failed leading request so the next call retries', async () => {
|
||||
const failure = new Error('request failed')
|
||||
const sendRequest = vi
|
||||
.fn<() => Promise<RpcResponse>>()
|
||||
.mockRejectedValueOnce(failure)
|
||||
.mockResolvedValueOnce(response)
|
||||
const client = rpcClient(sendRequest)
|
||||
|
||||
await expect(sendSingleFlightRequest(client, 'host-1', 'accounts.list')).rejects.toBe(failure)
|
||||
await expect(sendSingleFlightRequest(client, 'host-1', 'accounts.list')).resolves.toBe(response)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not share requests across clients, hosts, or request kinds', async () => {
|
||||
const pending = deferred<RpcResponse>()
|
||||
const firstSend = vi.fn(() => pending.promise)
|
||||
const secondSend = vi.fn(() => pending.promise)
|
||||
const firstClient = rpcClient(firstSend)
|
||||
const secondClient = rpcClient(secondSend)
|
||||
|
||||
const requests = [
|
||||
sendSingleFlightRequest(firstClient, 'host-1', 'settings.get'),
|
||||
sendSingleFlightRequest(firstClient, 'host-2', 'settings.get'),
|
||||
sendSingleFlightRequest(firstClient, 'host-1', 'preflight.check'),
|
||||
sendSingleFlightRequest(secondClient, 'host-1', 'settings.get')
|
||||
]
|
||||
|
||||
expect(firstSend).toHaveBeenCalledTimes(3)
|
||||
expect(secondSend).toHaveBeenCalledTimes(1)
|
||||
pending.resolve(response)
|
||||
await Promise.all(requests)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { RpcResponse } from './types'
|
||||
|
||||
type Deferred = {
|
||||
promise: Promise<RpcResponse>
|
||||
resolve: (value: RpcResponse) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}
|
||||
|
||||
type SingleFlightEntry = {
|
||||
// The request currently on the wire for this (client, host, kind).
|
||||
current: Promise<RpcResponse>
|
||||
// At most one trailing follow-up: every trigger that arrives while `current` is in flight coalesces
|
||||
// here so a refresh requested mid-read still re-reads the latest state (latest params win) instead of
|
||||
// being silently answered by the older in-flight response.
|
||||
followUp: { deferred: Deferred; params: unknown } | null
|
||||
}
|
||||
|
||||
const inFlightRequests = new WeakMap<RpcClient, Map<string, Map<string, SingleFlightEntry>>>()
|
||||
|
||||
function makeDeferred(): Deferred {
|
||||
let resolve!: (value: RpcResponse) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<RpcResponse>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
export function sendSingleFlightRequest(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
requestKind: string,
|
||||
params?: unknown
|
||||
): Promise<RpcResponse> {
|
||||
let requestsByHost = inFlightRequests.get(client)
|
||||
if (!requestsByHost) {
|
||||
requestsByHost = new Map()
|
||||
inFlightRequests.set(client, requestsByHost)
|
||||
}
|
||||
let requestsByKind = requestsByHost.get(hostId)
|
||||
if (!requestsByKind) {
|
||||
requestsByKind = new Map()
|
||||
requestsByHost.set(hostId, requestsByKind)
|
||||
}
|
||||
|
||||
const send = (): Promise<RpcResponse> => {
|
||||
try {
|
||||
return client.sendRequest(requestKind, params)
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
const existing = requestsByKind.get(requestKind)
|
||||
if (existing) {
|
||||
// A read is already on the wire: don't fire a duplicate now, but don't drop this trigger either.
|
||||
// Record (or refresh) a single trailing follow-up that runs once the current read settles.
|
||||
if (existing.followUp) {
|
||||
existing.followUp.params = params
|
||||
} else {
|
||||
existing.followUp = { deferred: makeDeferred(), params }
|
||||
}
|
||||
return existing.followUp.deferred.promise
|
||||
}
|
||||
|
||||
const entry: SingleFlightEntry = { current: send(), followUp: null }
|
||||
requestsByKind.set(requestKind, entry)
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (requestsByKind.get(requestKind) !== entry) {
|
||||
return
|
||||
}
|
||||
requestsByKind.delete(requestKind)
|
||||
if (requestsByKind.size === 0) {
|
||||
requestsByHost.delete(hostId)
|
||||
}
|
||||
if (requestsByHost.size === 0) {
|
||||
inFlightRequests.delete(client)
|
||||
}
|
||||
}
|
||||
|
||||
// Chain each settled request into either its queued follow-up (delivering the fresh result to every
|
||||
// caller that awaited it) or entry teardown. Recurses so triggers arriving during a follow-up queue
|
||||
// the next one.
|
||||
const onSettled = (): void => {
|
||||
const followUp = entry.followUp
|
||||
if (!followUp) {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
entry.followUp = null
|
||||
let next: Promise<RpcResponse>
|
||||
try {
|
||||
next = client.sendRequest(requestKind, followUp.params)
|
||||
} catch (error) {
|
||||
next = Promise.reject(error)
|
||||
}
|
||||
entry.current = next
|
||||
next.then(
|
||||
(response) => followUp.deferred.resolve(response),
|
||||
(error) => followUp.deferred.reject(error)
|
||||
)
|
||||
void next.then(onSettled, onSettled)
|
||||
}
|
||||
|
||||
void entry.current.then(onSettled, onSettled)
|
||||
return entry.current
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Why: separate from rpc-client.test.ts — that file sits at its max-lines cap,
|
||||
// and the silent-4001 mapping (desktop lost its device registry / regenerated
|
||||
// its keypair, so the encrypted e2ee_error never decrypts) is its own scenario.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { connect } from './rpc-client'
|
||||
|
||||
vi.mock('./e2ee', () => ({
|
||||
generateKeyPair: () => ({
|
||||
publicKey: new Uint8Array(32),
|
||||
secretKey: new Uint8Array(32)
|
||||
}),
|
||||
deriveSharedKey: () => new Uint8Array(32),
|
||||
publicKeyFromBase64: () => new Uint8Array(32),
|
||||
publicKeyToBase64: () => 'client-public-key',
|
||||
encrypt: (plaintext: string) => `encrypted:${plaintext}`,
|
||||
decrypt: (raw: string) => (raw === 'undecryptable' ? null : raw.replace(/^encrypted:/, '')),
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
static CLOSING = 2
|
||||
static CLOSED = 3
|
||||
|
||||
readonly CONNECTING = MockWebSocket.CONNECTING
|
||||
readonly OPEN = MockWebSocket.OPEN
|
||||
readonly CLOSING = MockWebSocket.CLOSING
|
||||
readonly CLOSED = MockWebSocket.CLOSED
|
||||
|
||||
readyState = MockWebSocket.CONNECTING
|
||||
onopen: (() => void) | null = null
|
||||
onclose: ((event?: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
sent: string[] = []
|
||||
close = vi.fn(() => {
|
||||
if (this.readyState === MockWebSocket.CLOSED) {
|
||||
return
|
||||
}
|
||||
this.readyState = MockWebSocket.CLOSED
|
||||
this.onclose?.()
|
||||
})
|
||||
|
||||
constructor(readonly endpoint: string) {
|
||||
mockSockets.push(this)
|
||||
}
|
||||
|
||||
send(payload: string): void {
|
||||
this.sent.push(payload)
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.readyState = MockWebSocket.OPEN
|
||||
this.onopen?.()
|
||||
}
|
||||
|
||||
receive(payload: unknown): void {
|
||||
this.onmessage?.({ data: payload })
|
||||
}
|
||||
|
||||
serverClose(code: number, reason = ''): void {
|
||||
this.readyState = MockWebSocket.CLOSED
|
||||
this.onclose?.({ code, reason, wasClean: true })
|
||||
}
|
||||
}
|
||||
|
||||
const mockSockets: MockWebSocket[] = []
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
|
||||
function lastSocket(): MockWebSocket {
|
||||
return mockSockets[mockSockets.length - 1]!
|
||||
}
|
||||
|
||||
describe('unauthorized close-code mapping (silent 4001)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mockSockets.length = 0
|
||||
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
globalThis.WebSocket = originalWebSocket
|
||||
})
|
||||
|
||||
it('counts a bare 4001 close against the auth retry budget and latches auth-failed', async () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
|
||||
// A desktop with a regenerated keypair can't send a decryptable e2ee_error —
|
||||
// the phone only ever sees the 4001 close. Three of those must latch.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (i > 0) {
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
}
|
||||
const socket = lastSocket()
|
||||
socket.open()
|
||||
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
socket.serverClose(4001, 'Unauthorized')
|
||||
}
|
||||
|
||||
expect(client.getState()).toBe('auth-failed')
|
||||
expect(mockSockets).toHaveLength(3)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000)
|
||||
expect(mockSockets).toHaveLength(3)
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('recovers when a 4001 close was transient and the next handshake succeeds', async () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
const first = mockSockets[0]!
|
||||
first.open()
|
||||
first.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
first.serverClose(4001, 'Unauthorized')
|
||||
expect(client.getState()).toBe('reconnecting')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
const next = lastSocket()
|
||||
next.open()
|
||||
next.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
next.receive('encrypted:{"type":"e2ee_authenticated"}')
|
||||
expect(client.getState()).toBe('connected')
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('shares one budget between decrypted e2ee_error rejections and 4001 closes', async () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (i > 0) {
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
}
|
||||
const socket = lastSocket()
|
||||
socket.open()
|
||||
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
if (i < 2) {
|
||||
socket.receive('encrypted:{"type":"e2ee_error","error":{"code":"unauthorized"}}')
|
||||
} else {
|
||||
socket.serverClose(4001, 'Unauthorized')
|
||||
}
|
||||
}
|
||||
|
||||
expect(client.getState()).toBe('auth-failed')
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('keeps the generic reconnect loop for non-4001 closes', async () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (i > 0) {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
}
|
||||
const socket = lastSocket()
|
||||
socket.open()
|
||||
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
socket.serverClose(1006)
|
||||
}
|
||||
|
||||
expect(client.getState()).toBe('reconnecting')
|
||||
|
||||
client.close()
|
||||
})
|
||||
})
|
||||
@@ -98,6 +98,8 @@ const GIVE_UP_AFTER_ATTEMPTS = 12
|
||||
const TRICKLE_RECONNECT_DELAY_MS = 90_000
|
||||
// Why: one unauthorized isn't proof the pairing is dead (issue #5200) — retry the handshake this many times before latching auth-failed.
|
||||
const AUTH_RETRY_BUDGET = 3
|
||||
// Why: a desktop that regenerated its E2EE keypair sends an e2ee_error we can't decrypt — the 4001 close code is the only surviving auth-failure signal.
|
||||
const UNAUTHORIZED_CLOSE_CODE = 4001
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const CONNECT_TIMEOUT_MS = 12_000
|
||||
const HANDSHAKE_TIMEOUT_MS = 5_000
|
||||
@@ -603,7 +605,7 @@ export function connect(
|
||||
})
|
||||
lastWsClosedAt = closeAt
|
||||
currentWsOpenedAt = null
|
||||
handleSocketClosed(openingWs)
|
||||
handleSocketClosed(openingWs, { closeCode: e?.code })
|
||||
}
|
||||
|
||||
ws.onerror = (event) => {
|
||||
@@ -623,7 +625,10 @@ export function connect(
|
||||
}
|
||||
}
|
||||
|
||||
function handleSocketClosed(closedWs: WebSocket, opts: { timedOut?: boolean } = {}) {
|
||||
function handleSocketClosed(
|
||||
closedWs: WebSocket,
|
||||
opts: { timedOut?: boolean; closeCode?: number } = {}
|
||||
) {
|
||||
if (ws !== closedWs) {
|
||||
console.log('[net] handleSocketClosed STALE — ignoring (ws already swapped)', {
|
||||
state,
|
||||
@@ -648,6 +653,16 @@ export function connect(
|
||||
rejectAllPending('Connection closed', { deliveryUnknown: true })
|
||||
return
|
||||
}
|
||||
// Why: a bare 4001 close means the desktop rejected our pairing but the encrypted
|
||||
// e2ee_error never arrived (or was undecryptable) — count it against the auth
|
||||
// retry budget instead of looping the generic reconnect forever.
|
||||
if (opts.closeCode === UNAUTHORIZED_CLOSE_CODE) {
|
||||
console.log('[net] handleSocketClosed — unauthorized close code', {
|
||||
attempt: reconnectAttempt
|
||||
})
|
||||
handleAuthRejection('Unauthorized — pairing may be revoked')
|
||||
return
|
||||
}
|
||||
console.log('[net] handleSocketClosed → reconnect', {
|
||||
timedOut: !!opts.timedOut,
|
||||
pendingCount: pending.size,
|
||||
|
||||
@@ -10,7 +10,7 @@ export function useWorktreeResync(args: {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
fetchWorktrees: (opts?: { allowDuringModal?: boolean }) => Promise<void>
|
||||
fetchRepoMetadata: () => Promise<void>
|
||||
fetchRepoMetadata: (options?: { force?: boolean; queueIfInFlight?: boolean }) => Promise<void>
|
||||
}): { refreshing: boolean; onRefresh: () => Promise<void> } {
|
||||
const { client, connState, fetchWorktrees, fetchRepoMetadata } = args
|
||||
|
||||
@@ -22,9 +22,11 @@ export function useWorktreeResync(args: {
|
||||
prevConnStateRef.current = connState
|
||||
if (prev !== 'connected' && connState === 'connected' && client) {
|
||||
void fetchWorktrees({ allowDuringModal: true })
|
||||
void fetchRepoMetadata()
|
||||
}
|
||||
}, [connState, client, fetchWorktrees, fetchRepoMetadata])
|
||||
// Why: repo metadata refetch on reconnect is owned by startHostWorktreeRefresh (mount +
|
||||
// reposChanged + stream replay); this effect only refetches worktrees, so fetchRepoMetadata
|
||||
// is intentionally not a dependency here.
|
||||
}, [connState, client, fetchWorktrees])
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
// Why (#8498): let the user force a fresh snapshot instead of the possibly-poisoned cache.
|
||||
@@ -35,7 +37,7 @@ export function useWorktreeResync(args: {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await fetchWorktrees({ allowDuringModal: true })
|
||||
await fetchRepoMetadata()
|
||||
await fetchRepoMetadata({ force: true })
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { startHostWorktreeRefresh } from './host-worktree-refresh'
|
||||
|
||||
const appState = vi.hoisted(() => ({
|
||||
currentState: 'active',
|
||||
listener: null as ((state: string) => void) | null,
|
||||
remove: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: {
|
||||
get currentState() {
|
||||
return appState.currentState
|
||||
},
|
||||
addEventListener: (_event: string, listener: (state: string) => void) => {
|
||||
appState.listener = listener
|
||||
return { remove: appState.remove }
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
describe('startHostWorktreeRefresh', () => {
|
||||
let eventListener: ((payload: unknown) => void) | null
|
||||
let fetchWorktrees: ReturnType<typeof vi.fn>
|
||||
let fetchRepoMetadata: ReturnType<typeof vi.fn>
|
||||
let unsubscribe: ReturnType<typeof vi.fn>
|
||||
let stop: (() => void) | null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
appState.currentState = 'active'
|
||||
appState.listener = null
|
||||
appState.remove.mockClear()
|
||||
eventListener = null
|
||||
fetchWorktrees = vi.fn().mockResolvedValue(undefined)
|
||||
fetchRepoMetadata = vi.fn().mockResolvedValue(undefined)
|
||||
unsubscribe = vi.fn()
|
||||
stop = null
|
||||
})
|
||||
|
||||
function start(): void {
|
||||
const client = {
|
||||
subscribe: vi.fn(
|
||||
(_method: string, _params: unknown, listener: (payload: unknown) => void) => {
|
||||
eventListener = listener
|
||||
return unsubscribe
|
||||
}
|
||||
)
|
||||
} as unknown as RpcClient
|
||||
stop = startHostWorktreeRefresh({ client, fetchWorktrees, fetchRepoMetadata })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
stop?.()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('keeps the worktree poll active but skips ticks while backgrounded', async () => {
|
||||
start()
|
||||
expect(fetchWorktrees).toHaveBeenCalledTimes(1)
|
||||
|
||||
appState.currentState = 'background'
|
||||
await vi.advanceTimersByTimeAsync(6_000)
|
||||
expect(fetchWorktrees).toHaveBeenCalledTimes(1)
|
||||
|
||||
appState.currentState = 'active'
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
expect(fetchWorktrees).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('refreshes both snapshots immediately on foreground return', () => {
|
||||
start()
|
||||
fetchWorktrees.mockClear()
|
||||
fetchRepoMetadata.mockClear()
|
||||
|
||||
appState.currentState = 'active'
|
||||
appState.listener?.('active')
|
||||
|
||||
expect(fetchWorktrees).toHaveBeenCalledWith({ allowDuringModal: true })
|
||||
expect(fetchRepoMetadata).toHaveBeenCalledWith({ queueIfInFlight: true })
|
||||
})
|
||||
|
||||
it('polls repo metadata on the interval while foregrounded and skips it while backgrounded', async () => {
|
||||
start()
|
||||
// Mount force-fetch.
|
||||
expect(fetchRepoMetadata).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Foregrounded: repo.list rides the interval as a convergence safety-net for desktop
|
||||
// Settings edits that never emit a runtime reposChanged (the callee self-throttles).
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
expect(fetchWorktrees).toHaveBeenCalledTimes(4)
|
||||
expect(fetchRepoMetadata).toHaveBeenCalledTimes(4)
|
||||
|
||||
// Backgrounded: neither snapshot is polled.
|
||||
fetchWorktrees.mockClear()
|
||||
fetchRepoMetadata.mockClear()
|
||||
appState.currentState = 'background'
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
expect(fetchWorktrees).not.toHaveBeenCalled()
|
||||
expect(fetchRepoMetadata).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('force-refreshes repo metadata on reposChanged', () => {
|
||||
start()
|
||||
fetchRepoMetadata.mockClear()
|
||||
|
||||
eventListener?.({ type: 'reposChanged' })
|
||||
|
||||
expect(fetchRepoMetadata).toHaveBeenCalledOnce()
|
||||
expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true })
|
||||
})
|
||||
|
||||
it('refreshes worktrees on worktreesChanged and both snapshots after stream replay', () => {
|
||||
start()
|
||||
fetchWorktrees.mockClear()
|
||||
fetchRepoMetadata.mockClear()
|
||||
|
||||
eventListener?.({ type: 'worktreesChanged', repoId: 'repo-1' })
|
||||
expect(fetchWorktrees).toHaveBeenCalledTimes(1)
|
||||
|
||||
eventListener?.({ type: 'ready', subscriptionId: 'events-1' })
|
||||
eventListener?.({ type: 'ready', subscriptionId: 'events-2' })
|
||||
expect(fetchWorktrees).toHaveBeenCalledTimes(2)
|
||||
expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { AppState } from 'react-native'
|
||||
import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
const WORKTREE_REFRESH_MS = 3000
|
||||
|
||||
type WorktreeRefreshOptions = { allowDuringModal?: boolean }
|
||||
type RepoRefreshOptions = { force?: boolean; queueIfInFlight?: boolean }
|
||||
|
||||
type HostWorktreeRefreshArgs = {
|
||||
client: RpcClient
|
||||
fetchWorktrees: (options?: WorktreeRefreshOptions) => Promise<void>
|
||||
fetchRepoMetadata: (options?: RepoRefreshOptions) => Promise<void>
|
||||
}
|
||||
|
||||
export function startHostWorktreeRefresh({
|
||||
client,
|
||||
fetchWorktrees,
|
||||
fetchRepoMetadata
|
||||
}: HostWorktreeRefreshArgs): () => void {
|
||||
let stale = false
|
||||
let eventStreamReady = false
|
||||
|
||||
const refreshOnForeground = (): void => {
|
||||
if (AppState.currentState !== 'active') {
|
||||
return
|
||||
}
|
||||
void fetchWorktrees({ allowDuringModal: true })
|
||||
void fetchRepoMetadata({ queueIfInFlight: true })
|
||||
}
|
||||
|
||||
const appStateSubscription = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
refreshOnForeground()
|
||||
}
|
||||
})
|
||||
const interval = setInterval(() => {
|
||||
if (AppState.currentState !== 'active') {
|
||||
return
|
||||
}
|
||||
void fetchWorktrees()
|
||||
// Why: desktop Settings repo edits (icon/color/name, repo removal) notify only the
|
||||
// renderer IPC, not the runtime clientEvents stream, so `reposChanged` never reaches
|
||||
// mobile. Keep a periodic repo.list as the convergence safety-net; fetchRepoMetadata
|
||||
// self-throttles to REPO_METADATA_REFRESH_MS (60s), so this is ~1 request/min while
|
||||
// foregrounded — the AppState gate is what removes the waste (both stop while backgrounded).
|
||||
void fetchRepoMetadata()
|
||||
}, WORKTREE_REFRESH_MS)
|
||||
const unsubscribe = client.subscribe(
|
||||
'runtime.clientEvents.subscribe',
|
||||
null,
|
||||
(payload: unknown) => {
|
||||
if (stale || !payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
const event = payload as RuntimeClientEventStreamMessage | { type: 'error' }
|
||||
if (event.type === 'ready') {
|
||||
const replayedAfterReconnect = eventStreamReady
|
||||
eventStreamReady = true
|
||||
if (replayedAfterReconnect) {
|
||||
// Why: client events are not queued while disconnected, so re-read both snapshots after replay.
|
||||
void fetchWorktrees()
|
||||
void fetchRepoMetadata({ force: true, queueIfInFlight: true })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === 'end' || event.type === 'error') {
|
||||
eventStreamReady = false
|
||||
return
|
||||
}
|
||||
if (event.type === 'reposChanged') {
|
||||
void fetchRepoMetadata({ force: true, queueIfInFlight: true })
|
||||
} else if (event.type === 'worktreesChanged') {
|
||||
void fetchWorktrees()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
void fetchWorktrees()
|
||||
void fetchRepoMetadata({ force: true, queueIfInFlight: true })
|
||||
|
||||
return () => {
|
||||
stale = true
|
||||
clearInterval(interval)
|
||||
appStateSubscription.remove()
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
+8
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.151-rc.0",
|
||||
"version": "1.4.151-rc.1",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
@@ -114,7 +114,7 @@
|
||||
"@xterm/addon-serialize": "0.15.0-beta.287",
|
||||
"@xterm/headless": "6.1.0-beta.287",
|
||||
"agent-browser": "~0.27.0",
|
||||
"electron-updater": "^6.8.3",
|
||||
"electron-updater": "^6.8.9",
|
||||
"i18next": "^26.3.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"node-pty": "^1.1.0",
|
||||
@@ -176,9 +176,10 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dompurify": "^3.4.11",
|
||||
"dompurify": "^3.4.12",
|
||||
"electron": "^43.1.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-builder-squirrel-windows": "^26.15.3",
|
||||
"electron-vite": "^5.0.0",
|
||||
"emoji-picker-react": "^4.19.1",
|
||||
"happy-dom": "^20.9.0",
|
||||
@@ -213,7 +214,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"shadcn": "^4.7.0",
|
||||
"shadcn": "^4.13.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
@@ -251,7 +252,8 @@
|
||||
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"monaco-editor>dompurify": "3.4.11"
|
||||
"@modelcontextprotocol/sdk>@hono/node-server": "2.0.10",
|
||||
"monaco-editor>dompurify": "3.4.12"
|
||||
},
|
||||
"supportedArchitectures": {
|
||||
"os": [
|
||||
|
||||
Generated
+460
-855
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
||||
import { types } from 'node:util'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildGuestOverlayScript } from './grab-guest-script'
|
||||
import { clampGrabPayload } from './browser-grab-payload'
|
||||
|
||||
describe('buildGuestOverlayScript', () => {
|
||||
it('returns a non-empty string for arm action', () => {
|
||||
@@ -158,3 +161,199 @@ describe('buildGuestOverlayScript', () => {
|
||||
expect(script).not.toContain('ariaLabelledBy.split(/\\s+/)')
|
||||
})
|
||||
})
|
||||
|
||||
// Regression coverage for issue #9947: Zone.js swaps the global Promise for a
|
||||
// non-native thenable, which executeJavaScript won't unwrap — so a page-global
|
||||
// `new Promise(...)` crossed as its raw `__zone_symbol__*` wrapper, not { page, target }.
|
||||
describe('awaitClick under a Zone.js-patched global Promise', () => {
|
||||
type Executor<T> = (resolve: (value: T) => void, reject: (reason: unknown) => void) => void
|
||||
|
||||
// Native promises are kept off the instance so its own enumerable keys match
|
||||
// Zone.js exactly: ['__zone_symbol__state', '__zone_symbol__value'].
|
||||
const nativeOf = new WeakMap<object, Promise<unknown>>()
|
||||
|
||||
/** A non-native thenable that mimics Zone.js's ZoneAwarePromise wrapper. */
|
||||
class ZoneAwarePromiseLike<T = unknown> {
|
||||
__zone_symbol__state: unknown = null
|
||||
__zone_symbol__value: unknown = undefined
|
||||
|
||||
constructor(executor: Executor<T>) {
|
||||
nativeOf.set(
|
||||
this,
|
||||
new Promise<T>((res, rej) => {
|
||||
executor(
|
||||
(value) => {
|
||||
this.__zone_symbol__state = true
|
||||
this.__zone_symbol__value = value
|
||||
res(value)
|
||||
},
|
||||
(reason) => {
|
||||
this.__zone_symbol__state = false
|
||||
this.__zone_symbol__value = reason
|
||||
rej(reason)
|
||||
}
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Why: real Zone.js defines `get [Symbol.toStringTag]() { return 'Promise' }`,
|
||||
// so an Object.prototype.toString check is fooled into seeing a promise. The
|
||||
// boundary below deliberately uses the brand-based V8 check instead, and the
|
||||
// control test asserts this tag does NOT let the wrapper masquerade as native.
|
||||
get [Symbol.toStringTag](): string {
|
||||
return 'Promise'
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line unicorn/no-thenable -- intentionally a non-native thenable modeling Zone.js's ZoneAwarePromise
|
||||
then(
|
||||
onFulfilled?: ((value: unknown) => unknown) | null,
|
||||
onRejected?: ((reason: unknown) => unknown) | null
|
||||
): Promise<unknown> {
|
||||
const native = nativeOf.get(this) as Promise<unknown>
|
||||
return native.then(onFulfilled ?? undefined, onRejected ?? undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models Electron's boundary: it awaits only genuine V8 promises and
|
||||
* serializes anything else — including non-native thenables — by value.
|
||||
* `util.types.isPromise` is V8's brand-based IsPromise (what Electron uses):
|
||||
* realm-independent and unforgeable, unlike an Object.prototype.toString /
|
||||
* Symbol.toStringTag check that a ZoneAwarePromise would defeat.
|
||||
*/
|
||||
async function crossExecuteJavaScriptBoundary(completionValue: unknown): Promise<unknown> {
|
||||
if (types.isPromise(completionValue)) {
|
||||
return await (completionValue as Promise<unknown>)
|
||||
}
|
||||
return { ...(completionValue as Record<string, unknown>) }
|
||||
}
|
||||
|
||||
const validPayload = (): Record<string, unknown> => ({
|
||||
// A minimal but structurally valid payload — page + target are what
|
||||
// clampGrabPayload requires and what the bug stripped away.
|
||||
page: { title: 'Angular App' },
|
||||
target: { tagName: 'button' },
|
||||
nearbyText: [],
|
||||
ancestorPath: [],
|
||||
screenshot: null
|
||||
})
|
||||
|
||||
function armGrabHarness(options?: {
|
||||
extractPayload?: () => unknown
|
||||
getCurrentElement?: () => unknown
|
||||
}): {
|
||||
window: { __orcaGrab: Record<string, unknown> }
|
||||
click: () => void
|
||||
contextmenu: () => void
|
||||
cancel: () => void
|
||||
} {
|
||||
const handlers: Record<string, (event: unknown) => void> = {}
|
||||
const noopEvent = {
|
||||
preventDefault(): void {},
|
||||
stopPropagation(): void {},
|
||||
stopImmediatePropagation(): void {}
|
||||
}
|
||||
const grab: Record<string, unknown> = {
|
||||
host: {
|
||||
addEventListener(type: string, fn: (event: unknown) => void): void {
|
||||
handlers[type] = fn
|
||||
},
|
||||
removeEventListener(): void {}
|
||||
},
|
||||
extractPayload: options?.extractPayload ?? validPayload,
|
||||
getCurrentElement: options?.getCurrentElement ?? ((): unknown => ({})),
|
||||
freezeHighlight(): void {},
|
||||
cleanup(): void {}
|
||||
}
|
||||
const window = { __orcaGrab: grab }
|
||||
return {
|
||||
window,
|
||||
click: () => handlers.click?.(noopEvent),
|
||||
contextmenu: () => handlers.contextmenu?.(noopEvent),
|
||||
// cancelAwait is installed on __orcaGrab by the script itself at runtime.
|
||||
cancel: () => (window.__orcaGrab.cancelAwait as (() => void) | undefined)?.()
|
||||
}
|
||||
}
|
||||
|
||||
const runAwaitClick = (harness: ReturnType<typeof armGrabHarness>): unknown =>
|
||||
runInNewContext(buildGuestOverlayScript('awaitClick'), {
|
||||
window: harness.window,
|
||||
Promise: ZoneAwarePromiseLike,
|
||||
Error
|
||||
})
|
||||
|
||||
it('returns the payload through a native async promise, not the page-global Promise', () => {
|
||||
const script = buildGuestOverlayScript('awaitClick')
|
||||
expect(script).toContain('(async function()')
|
||||
expect(script).toContain('return await new Promise(')
|
||||
})
|
||||
|
||||
it('resolves { page, target } across the boundary despite ZoneAwarePromise', async () => {
|
||||
const harness = armGrabHarness()
|
||||
const completion = runAwaitClick(harness)
|
||||
|
||||
// The async IIFE hands Electron an intrinsic promise even though the global
|
||||
// Promise is a non-native thenable — so the boundary unwraps it.
|
||||
expect(types.isPromise(completion)).toBe(true)
|
||||
|
||||
harness.click()
|
||||
const received = await crossExecuteJavaScriptBoundary(completion)
|
||||
|
||||
expect(received).toHaveProperty('page')
|
||||
expect(received).toHaveProperty('target')
|
||||
expect(received).not.toHaveProperty('__zone_symbol__value')
|
||||
expect(clampGrabPayload(received)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('resolves the context-menu marker across the boundary despite ZoneAwarePromise', async () => {
|
||||
const harness = armGrabHarness()
|
||||
const completion = runAwaitClick(harness)
|
||||
|
||||
harness.contextmenu()
|
||||
const received = (await crossExecuteJavaScriptBoundary(completion)) as Record<string, unknown>
|
||||
|
||||
expect(received).toHaveProperty('__orcaContextMenu', true)
|
||||
expect(received.payload).toHaveProperty('page')
|
||||
expect(clampGrabPayload(received.payload)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('resolves the teardown cancel marker across the boundary despite ZoneAwarePromise', async () => {
|
||||
const harness = armGrabHarness()
|
||||
const completion = runAwaitClick(harness)
|
||||
|
||||
harness.cancel()
|
||||
const received = await crossExecuteJavaScriptBoundary(completion)
|
||||
|
||||
expect(received).toEqual({ __orcaCancelled: true })
|
||||
})
|
||||
|
||||
it('rejects across the boundary when selection fails despite ZoneAwarePromise', async () => {
|
||||
// getCurrentElement -> null drives onClick's reject(new Error('cancelled')),
|
||||
// which must surface as a rejected intrinsic promise (not a serialized value)
|
||||
// so the controller classifies it as a cancellation rather than a payload.
|
||||
const harness = armGrabHarness({ getCurrentElement: () => null })
|
||||
const completion = runAwaitClick(harness)
|
||||
|
||||
harness.click()
|
||||
await expect(crossExecuteJavaScriptBoundary(completion)).rejects.toThrow('cancelled')
|
||||
})
|
||||
|
||||
it('control: a bare page-global new Promise would cross as the raw wrapper', async () => {
|
||||
// Proves the harness detects the regression: without the async wrapper the
|
||||
// completion value is the ZoneAwarePromise itself, which the boundary
|
||||
// serializes to __zone_symbol__* fields with no page/target.
|
||||
const bare = runInNewContext('new Promise(function(r){ r({ page: {}, target: {} }); })', {
|
||||
Promise: ZoneAwarePromiseLike
|
||||
})
|
||||
// Symbol.toStringTag='Promise' fools a toString check — exactly why the
|
||||
// boundary must use the brand-based IsPromise, which still rejects it.
|
||||
expect(Object.prototype.toString.call(bare)).toBe('[object Promise]')
|
||||
expect(types.isPromise(bare)).toBe(false)
|
||||
|
||||
const received = await crossExecuteJavaScriptBoundary(bare)
|
||||
expect(received).not.toHaveProperty('page')
|
||||
expect(received).toHaveProperty('__zone_symbol__value')
|
||||
expect(clampGrabPayload(received)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -823,80 +823,90 @@ const ARM_SCRIPT = `(function() {
|
||||
})()`
|
||||
|
||||
// awaitClick: resolve when the user clicks the overlay; stopPropagation + pointer-events:all keep the click off the page.
|
||||
const AWAIT_CLICK_SCRIPT = `new Promise(function(resolve, reject) {
|
||||
'use strict';
|
||||
var grab = window.__orcaGrab;
|
||||
if (!grab) {
|
||||
reject(new Error('Grab not armed'));
|
||||
return;
|
||||
}
|
||||
|
||||
function extractSelectedPayload(el) {
|
||||
try {
|
||||
return grab.extractPayload(el);
|
||||
} catch (error) {
|
||||
grab.cleanup();
|
||||
reject(error instanceof Error ? error : new Error('Failed to extract element context'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
var el = grab.getCurrentElement();
|
||||
if (!el) {
|
||||
grab.cleanup();
|
||||
reject(new Error('cancelled'));
|
||||
const AWAIT_CLICK_SCRIPT = `(async function() {
|
||||
// Why: hand the click result to executeJavaScript through a native (intrinsic)
|
||||
// Promise. On pages that replace the global Promise with a non-native thenable
|
||||
// — e.g. Angular Zone.js's ZoneAwarePromise — a bare \`new Promise(...)\` is not
|
||||
// recognized as a promise by Electron, so its raw wrapper object (exposing
|
||||
// __zone_symbol__state/__value instead of { page, target }) crosses the boundary
|
||||
// and main rejects it as an invalid payload structure. An async function's
|
||||
// promise comes from the engine intrinsic that page code cannot reassign, so
|
||||
// Electron always unwraps it to the resolved payload.
|
||||
return await new Promise(function(resolve, reject) {
|
||||
'use strict';
|
||||
var grab = window.__orcaGrab;
|
||||
if (!grab) {
|
||||
reject(new Error('Grab not armed'));
|
||||
return;
|
||||
}
|
||||
var payload = extractSelectedPayload(el);
|
||||
if (!payload) return;
|
||||
// Why: freeze the highlight instead of removing it so the user sees
|
||||
// which element was selected while the copy menu is shown. Teardown
|
||||
// happens later when the renderer calls setGrabMode(false) or re-arms.
|
||||
grab.freezeHighlight();
|
||||
resolve(payload);
|
||||
}
|
||||
|
||||
function onContext(e) {
|
||||
// Why: right-click resolves with the payload wrapped in a context-menu
|
||||
// marker so the renderer can show the full action dropdown instead of
|
||||
// auto-copying. This gives users a deliberate path to screenshot and
|
||||
// other secondary actions while keeping left-click as the fast copy path.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
var el = grab.getCurrentElement();
|
||||
if (!el) {
|
||||
grab.cleanup();
|
||||
reject(new Error('cancelled'));
|
||||
return;
|
||||
function extractSelectedPayload(el) {
|
||||
try {
|
||||
return grab.extractPayload(el);
|
||||
} catch (error) {
|
||||
grab.cleanup();
|
||||
reject(error instanceof Error ? error : new Error('Failed to extract element context'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
var payload = extractSelectedPayload(el);
|
||||
if (!payload) return;
|
||||
grab.freezeHighlight();
|
||||
resolve({ __orcaContextMenu: true, payload: payload });
|
||||
}
|
||||
|
||||
grab.host.addEventListener('click', onClick, true);
|
||||
grab.host.addEventListener('contextmenu', onContext, true);
|
||||
function onClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
var el = grab.getCurrentElement();
|
||||
if (!el) {
|
||||
grab.cleanup();
|
||||
reject(new Error('cancelled'));
|
||||
return;
|
||||
}
|
||||
var payload = extractSelectedPayload(el);
|
||||
if (!payload) return;
|
||||
// Why: freeze the highlight instead of removing it so the user sees
|
||||
// which element was selected while the copy menu is shown. Teardown
|
||||
// happens later when the renderer calls setGrabMode(false) or re-arms.
|
||||
grab.freezeHighlight();
|
||||
resolve(payload);
|
||||
}
|
||||
|
||||
// Store cancel hook so teardown can settle the Promise
|
||||
grab.cancelAwait = function() {
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
grab.cleanup();
|
||||
// Why: teardown cancellation is a normal user flow; resolving a marker
|
||||
// avoids a noisy guest-console Error while main still treats it as cancel.
|
||||
resolve({ __orcaCancelled: true });
|
||||
};
|
||||
})`
|
||||
function onContext(e) {
|
||||
// Why: right-click resolves with the payload wrapped in a context-menu
|
||||
// marker so the renderer can show the full action dropdown instead of
|
||||
// auto-copying. This gives users a deliberate path to screenshot and
|
||||
// other secondary actions while keeping left-click as the fast copy path.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
var el = grab.getCurrentElement();
|
||||
if (!el) {
|
||||
grab.cleanup();
|
||||
reject(new Error('cancelled'));
|
||||
return;
|
||||
}
|
||||
var payload = extractSelectedPayload(el);
|
||||
if (!payload) return;
|
||||
grab.freezeHighlight();
|
||||
resolve({ __orcaContextMenu: true, payload: payload });
|
||||
}
|
||||
|
||||
grab.host.addEventListener('click', onClick, true);
|
||||
grab.host.addEventListener('contextmenu', onContext, true);
|
||||
|
||||
// Store cancel hook so teardown can settle the Promise
|
||||
grab.cancelAwait = function() {
|
||||
grab.host.removeEventListener('click', onClick, true);
|
||||
grab.host.removeEventListener('contextmenu', onContext, true);
|
||||
grab.cleanup();
|
||||
// Why: teardown cancellation is a normal user flow; resolving a marker
|
||||
// avoids a noisy guest-console Error while main still treats it as cancel.
|
||||
resolve({ __orcaCancelled: true });
|
||||
};
|
||||
});
|
||||
})()`
|
||||
|
||||
const FINALIZE_SCRIPT = `(function() {
|
||||
'use strict';
|
||||
@@ -933,8 +943,9 @@ const TEARDOWN_SCRIPT = `(function() {
|
||||
'use strict';
|
||||
var grab = window.__orcaGrab;
|
||||
if (!grab) return true;
|
||||
// If there's an active awaitClick Promise, cancel it so the
|
||||
// executeJavaScript call in main rejects and settles the grab op.
|
||||
// If there's an active awaitClick Promise, cancel it: cancelAwait resolves
|
||||
// it with the __orcaCancelled marker so the executeJavaScript call in main
|
||||
// settles the grab op as a cancellation.
|
||||
if (grab.cancelAwait) {
|
||||
grab.cancelAwait();
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { readFileSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { connect, type Socket } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { getDaemonSocketPath } from './daemon-spawner'
|
||||
import { encodeNdjson } from './ndjson'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import { PROTOCOL_VERSION } from './types'
|
||||
|
||||
function unusedSubprocess(): SubprocessHandle {
|
||||
throw new Error('Test must not create a PTY')
|
||||
}
|
||||
|
||||
describe('daemon authenticated client activity', () => {
|
||||
let dir: string
|
||||
let socketPath: string
|
||||
let tokenPath: string
|
||||
let server: DaemonServer
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'daemon-client-activity-'))
|
||||
socketPath = getDaemonSocketPath(dir)
|
||||
tokenPath = join(dir, 'daemon.token')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.shutdown()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function connectHello(role: 'control' | 'stream', clientId: string): Promise<Socket> {
|
||||
const socket = connect(socketPath)
|
||||
await new Promise<void>((resolve) => socket.once('connect', resolve))
|
||||
socket.write(
|
||||
encodeNdjson({
|
||||
type: 'hello',
|
||||
version: PROTOCOL_VERSION,
|
||||
token: readFileSync(tokenPath, 'utf8').trim(),
|
||||
clientId,
|
||||
role
|
||||
})
|
||||
)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once('data', (data) => {
|
||||
const response = JSON.parse(data.toString().trim()) as { ok?: boolean; error?: string }
|
||||
if (response.ok) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(response.error ?? 'hello rejected'))
|
||||
}
|
||||
})
|
||||
socket.once('error', reject)
|
||||
})
|
||||
return socket
|
||||
}
|
||||
|
||||
it('excludes control-only health probes and reports one complete app pair', async () => {
|
||||
const onAuthenticatedClientPair = vi.fn()
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
spawnSubprocess: unusedSubprocess,
|
||||
onAuthenticatedClientPair
|
||||
})
|
||||
await server.start()
|
||||
|
||||
await connectHello('control', 'resolver-health-check')
|
||||
expect(onAuthenticatedClientPair).not.toHaveBeenCalled()
|
||||
|
||||
await connectHello('control', 'app-client')
|
||||
expect(onAuthenticatedClientPair).not.toHaveBeenCalled()
|
||||
await connectHello('stream', 'app-client')
|
||||
expect(onAuthenticatedClientPair).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -97,4 +97,17 @@ describe('daemon-entry parseArgs', () => {
|
||||
it('still requires --socket and --token when --log-file is given', () => {
|
||||
expect(() => parseArgs(['--log-file', '/tmp/daemon.log'])).toThrow('Usage:')
|
||||
})
|
||||
|
||||
it('parses the GUI-only --login-session-watch flag and omits it when absent', () => {
|
||||
expect(
|
||||
parseArgs(['--socket', '/tmp/t.sock', '--token', '/tmp/t.token', '--login-session-watch'])
|
||||
).toEqual({
|
||||
socketPath: '/tmp/t.sock',
|
||||
tokenPath: '/tmp/t.token',
|
||||
loginSessionWatch: true
|
||||
})
|
||||
expect(parseArgs(['--socket', '/tmp/t.sock', '--token', '/tmp/t.token'])).not.toHaveProperty(
|
||||
'loginSessionWatch'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,19 +6,27 @@
|
||||
* Signals readiness to parent via IPC: { type: 'ready' }
|
||||
* Shuts down cleanly on SIGTERM.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { startDaemon, type DaemonHandle } from './daemon-main'
|
||||
import { createPtySubprocess } from './pty-subprocess'
|
||||
import { warmWindowsConptyOnce } from './windows-conpty-warmup'
|
||||
import { warmPwshAvailabilityCache } from '../pwsh'
|
||||
import { createDaemonFileLog, createNoopDaemonFileLog } from './daemon-file-log'
|
||||
import { PROTOCOL_VERSION } from './types'
|
||||
import { prepareMacosTccLoginShell } from '../providers/macos-tcc-login-shell'
|
||||
import {
|
||||
prepareMacosTccLoginShell,
|
||||
probeMacosLoginSessionAlive
|
||||
} from '../providers/macos-tcc-login-shell'
|
||||
import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch'
|
||||
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
|
||||
|
||||
export type ParsedDaemonArgs = {
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
pidPath?: string
|
||||
launchNonce?: string
|
||||
/** GUI-spawned daemons only — headless serve/SSH daemons must survive session loss. */
|
||||
loginSessionWatch?: boolean
|
||||
/** Optional — absent for adopted old daemons and tests, which log nothing. */
|
||||
logFilePath?: string
|
||||
}
|
||||
@@ -29,6 +37,7 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
|
||||
let logFilePath = ''
|
||||
let pidPath = ''
|
||||
let launchNonce = ''
|
||||
let loginSessionWatch = false
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--socket' && argv[i + 1]) {
|
||||
@@ -46,6 +55,8 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
|
||||
} else if (argv[i] === '--launch-nonce' && argv[i + 1]) {
|
||||
launchNonce = argv[i + 1]
|
||||
i++
|
||||
} else if (argv[i] === '--login-session-watch') {
|
||||
loginSessionWatch = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +72,7 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs {
|
||||
socketPath,
|
||||
tokenPath,
|
||||
...(pidPath ? { pidPath, launchNonce } : {}),
|
||||
...(loginSessionWatch ? { loginSessionWatch } : {}),
|
||||
...(logFilePath ? { logFilePath } : {})
|
||||
}
|
||||
}
|
||||
@@ -73,7 +85,7 @@ async function main(): Promise<void> {
|
||||
// an otherwise healthy detached daemon. Swallow it: stderr is diagnostic only.
|
||||
process.stderr.on('error', () => {})
|
||||
|
||||
const { socketPath, tokenPath, pidPath, launchNonce, logFilePath } = parseArgs(
|
||||
const { socketPath, tokenPath, pidPath, launchNonce, loginSessionWatch, logFilePath } = parseArgs(
|
||||
process.argv.slice(2)
|
||||
)
|
||||
const startedAtMs = Date.now() - process.uptime() * 1000
|
||||
@@ -123,6 +135,7 @@ async function main(): Promise<void> {
|
||||
})
|
||||
|
||||
let daemon: DaemonHandle | null = null
|
||||
let deathWatch: MacosLoginSessionDeathWatch | null = null
|
||||
let shuttingDown = false
|
||||
// Bound the wait so a wedged native shutdown can't leave the daemon running
|
||||
// forever on SIGTERM/SIGINT (it would then survive a real quit, not just updates).
|
||||
@@ -134,6 +147,7 @@ async function main(): Promise<void> {
|
||||
return
|
||||
}
|
||||
shuttingDown = true
|
||||
deathWatch?.stop()
|
||||
daemonLog.log('shutdown', { reason })
|
||||
try {
|
||||
if (daemon) {
|
||||
@@ -157,6 +171,63 @@ async function main(): Promise<void> {
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'))
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'))
|
||||
|
||||
// Why: a dead macOS login session cannot be fabricated without root (PAM owns
|
||||
// audit-session teardown), so e2e drives the oracles from a verdict file:
|
||||
// 'alive' → accepted/healthy, 'dead' → rejected/unhealthy, 'hang' →
|
||||
// timeout-inconclusive/unhealthy (the fail-safe path), else inconclusive.
|
||||
const e2eProbeFile = process.env.ORCA_E2E_LOGIN_SESSION_PROBE_FILE
|
||||
const readE2eVerdict = (): string => {
|
||||
try {
|
||||
return readFileSync(e2eProbeFile as string, 'utf8').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
deathWatch =
|
||||
loginSessionWatch && process.platform === 'darwin'
|
||||
? new MacosLoginSessionDeathWatch({
|
||||
probeLoginSession: e2eProbeFile
|
||||
? async () => {
|
||||
const verdict = readE2eVerdict()
|
||||
if (verdict === 'alive') {
|
||||
return { ok: true, conclusive: true, reason: 'accepted' }
|
||||
}
|
||||
if (verdict === 'dead') {
|
||||
return { ok: false, conclusive: true, reason: 'rejected' }
|
||||
}
|
||||
return { ok: false, conclusive: false, reason: 'timeout' }
|
||||
}
|
||||
: probeMacosLoginSessionAlive,
|
||||
readResolverHealth: e2eProbeFile
|
||||
? async () => {
|
||||
const verdict = readE2eVerdict()
|
||||
return verdict === 'dead' || verdict === 'hang' ? 'unhealthy' : 'healthy'
|
||||
}
|
||||
: readCurrentProcessMacSystemResolverHealth,
|
||||
...(e2eProbeFile
|
||||
? {
|
||||
timing: {
|
||||
periodicProbeMs: 2_000,
|
||||
rejectionRecheckMs: 500,
|
||||
ptyExitDebounceMs: 200,
|
||||
clientActivityMinGapMs: 1_000,
|
||||
minProbeGapMs: 100
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
log: daemonLog,
|
||||
onRetire: (details) => {
|
||||
shuttingDown = true
|
||||
daemonLog.log('login-session-dead-retire', details)
|
||||
daemonLog.close()
|
||||
// Why: crash-style exit (no PTY teardown) keeps session meta unclean so the
|
||||
// replacement daemon cold-restores scrollback; stale socket/pid files ride
|
||||
// the existing dead-endpoint recovery.
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
daemon = await startDaemon({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
@@ -165,14 +236,22 @@ async function main(): Promise<void> {
|
||||
...(pidPath ? { startedAtMs } : {}),
|
||||
log: daemonLog,
|
||||
preparePtySpawn: runMacosLoginPreflight,
|
||||
...(deathWatch
|
||||
? {
|
||||
onPtySessionExit: () => deathWatch.notifyPtyExit(),
|
||||
onAuthenticatedClientPair: () => deathWatch.notifyClientActivity()
|
||||
}
|
||||
: {}),
|
||||
spawnSubprocess: (opts) => createPtySubprocess(opts),
|
||||
onIdleShutdown: () => {
|
||||
deathWatch?.stop()
|
||||
shuttingDown = true
|
||||
daemonLog.log('shutdown', { reason: 'idle' })
|
||||
daemonLog.close()
|
||||
process.exit(0)
|
||||
}
|
||||
})
|
||||
deathWatch?.start()
|
||||
|
||||
// Signal readiness to parent via IPC (if available)
|
||||
if (process.send) {
|
||||
|
||||
@@ -1086,7 +1086,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
|
||||
it('respawns instead of reusing a healthy daemon launched from another app path', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true })
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
@@ -1137,6 +1137,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
'/fake/socket',
|
||||
'--token',
|
||||
'/fake/token',
|
||||
'--login-session-watch',
|
||||
'--log-file',
|
||||
join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log')
|
||||
]),
|
||||
@@ -1513,6 +1514,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
|
||||
await launcher('/fake/socket', '/fake/token')
|
||||
|
||||
const launchedArgsWithoutWatch = forkMock.mock.calls.at(-1)?.[1] as string[]
|
||||
expect(launchedArgsWithoutWatch).not.toContain('--login-session-watch')
|
||||
|
||||
expect(offMock).toHaveBeenCalledWith('message', expect.any(Function))
|
||||
expect(offMock).toHaveBeenCalledWith('error', expect.any(Function))
|
||||
expect(offMock).toHaveBeenCalledWith('exit', expect.any(Function))
|
||||
|
||||
@@ -321,7 +321,10 @@ async function shouldPreserveDaemonWithLiveSessions(
|
||||
return true
|
||||
}
|
||||
|
||||
function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
function createOutOfProcessLauncher(
|
||||
runtimeDir: string,
|
||||
macosLoginSessionWatch = false
|
||||
): DaemonLauncher {
|
||||
return async (socketPath, tokenPath, suppliedPidPath, suppliedLaunchNonce) => {
|
||||
const entryPath = getDaemonEntryPath()
|
||||
const pidPath = suppliedPidPath ?? getDaemonPidPath(runtimeDir)
|
||||
@@ -448,6 +451,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
pidPath,
|
||||
'--launch-nonce',
|
||||
launchNonce,
|
||||
...(macosLoginSessionWatch ? ['--login-session-watch'] : []),
|
||||
...daemonLogArgs()
|
||||
],
|
||||
{
|
||||
@@ -625,7 +629,10 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void> {
|
||||
export async function initDaemonPtyProvider(
|
||||
signal?: AbortSignal,
|
||||
options: { macosLoginSessionWatch?: boolean } = {}
|
||||
): Promise<void> {
|
||||
logDaemonMilestone('daemon-init-start')
|
||||
// Why: e2e coverage for the startup PTY gate (#5232) needs a daemon init that deterministically outlasts the first-window timeout.
|
||||
const e2eInitDelayMs = Number(process.env.ORCA_E2E_DAEMON_INIT_DELAY_MS)
|
||||
@@ -636,7 +643,7 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void>
|
||||
|
||||
const newSpawner = new DaemonSpawner({
|
||||
runtimeDir,
|
||||
launcher: createOutOfProcessLauncher(runtimeDir)
|
||||
launcher: createOutOfProcessLauncher(runtimeDir, options.macosLoginSessionWatch ?? false)
|
||||
})
|
||||
|
||||
// Why: assign the module-level spawner/adapter only after both succeed, so a failed ensureRunning() leaves no stale spawner.
|
||||
|
||||
@@ -11,6 +11,8 @@ export type DaemonStartOptions = {
|
||||
protocolVersion?: number
|
||||
spawnSubprocess: DaemonServerOptions['spawnSubprocess']
|
||||
preparePtySpawn?: DaemonServerOptions['preparePtySpawn']
|
||||
onPtySessionExit?: DaemonServerOptions['onPtySessionExit']
|
||||
onAuthenticatedClientPair?: DaemonServerOptions['onAuthenticatedClientPair']
|
||||
log?: DaemonFileLog
|
||||
onIdleShutdown?: () => void
|
||||
initialAdoptionTestConfig?: DaemonServerOptions['initialAdoptionTestConfig']
|
||||
@@ -30,6 +32,10 @@ export async function startDaemon(opts: DaemonStartOptions): Promise<DaemonHandl
|
||||
...(opts.protocolVersion !== undefined ? { protocolVersion: opts.protocolVersion } : {}),
|
||||
spawnSubprocess: opts.spawnSubprocess,
|
||||
...(opts.preparePtySpawn ? { preparePtySpawn: opts.preparePtySpawn } : {}),
|
||||
...(opts.onPtySessionExit ? { onPtySessionExit: opts.onPtySessionExit } : {}),
|
||||
...(opts.onAuthenticatedClientPair
|
||||
? { onAuthenticatedClientPair: opts.onAuthenticatedClientPair }
|
||||
: {}),
|
||||
...(opts.log ? { log: opts.log } : {}),
|
||||
...(opts.onIdleShutdown ? { onIdleShutdown: opts.onIdleShutdown } : {}),
|
||||
...(opts.initialAdoptionTestConfig
|
||||
|
||||
@@ -57,6 +57,9 @@ export type DaemonServerOptions = {
|
||||
}
|
||||
ptySpawnHealthCheck?: () => Promise<void>
|
||||
preparePtySpawn?: () => Promise<void>
|
||||
// Why: login-session death detection (#7936) probes on PTY-exit bursts and fresh app connections.
|
||||
onPtySessionExit?: (sessionId: string) => void
|
||||
onAuthenticatedClientPair?: () => void
|
||||
log?: DaemonFileLog
|
||||
spawnSubprocess: (opts: {
|
||||
sessionId: string
|
||||
@@ -101,6 +104,7 @@ export class DaemonServer {
|
||||
private startedAtMs: number | null
|
||||
private protocolVersion: number
|
||||
private onIdleShutdown: () => void
|
||||
private onAuthenticatedClientPair: () => void
|
||||
private ptySpawnHealthCheck: () => Promise<void>
|
||||
private preparePtySpawn: () => Promise<void>
|
||||
private log: DaemonFileLog
|
||||
@@ -180,7 +184,11 @@ export class DaemonServer {
|
||||
now: () => Date.now()
|
||||
}
|
||||
this.token = randomUUID()
|
||||
this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess })
|
||||
this.onAuthenticatedClientPair = opts.onAuthenticatedClientPair ?? (() => {})
|
||||
this.host = new TerminalHost({
|
||||
spawnSubprocess: opts.spawnSubprocess,
|
||||
...(opts.onPtySessionExit ? { onSessionReaped: opts.onPtySessionExit } : {})
|
||||
})
|
||||
this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth
|
||||
this.preparePtySpawn = opts.preparePtySpawn ?? (() => Promise.resolve())
|
||||
this.stopStreamBacklogProbe = startDaemonStreamBacklogProbe(() => ({
|
||||
@@ -481,6 +489,8 @@ export class DaemonServer {
|
||||
}
|
||||
this.setupStreamSocket(socket, client)
|
||||
client.authenticatedPairEstablished = true
|
||||
// Why: one-shot health probes authenticate only a control socket; they are not fresh app activity.
|
||||
this.onAuthenticatedClientPair()
|
||||
// A complete app connection (unlike a probe) re-owns the endpoint and cancels pending retirement.
|
||||
this.initialAdoptionDeadlineMs = null
|
||||
this.retirementRequested = false
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { LoginPreflightOutcome } from '../providers/macos-tcc-login-shell'
|
||||
import { createNoopDaemonFileLog } from './daemon-file-log'
|
||||
import {
|
||||
MacosLoginSessionDeathWatch,
|
||||
type MacosLoginSessionDeathWatchOptions
|
||||
} from './macos-login-session-death-watch'
|
||||
import type { SystemResolverHealth } from './types'
|
||||
|
||||
const ACCEPTED: LoginPreflightOutcome = { ok: true, conclusive: true, reason: 'accepted' }
|
||||
const REJECTED: LoginPreflightOutcome = { ok: false, conclusive: true, reason: 'rejected' }
|
||||
const INCONCLUSIVE: LoginPreflightOutcome = { ok: false, conclusive: false, reason: 'timeout' }
|
||||
|
||||
type FakeTimer = { at: number; callback: () => void; cleared: boolean }
|
||||
|
||||
class FakeClock {
|
||||
private timers: FakeTimer[] = []
|
||||
private nowMs = 0
|
||||
|
||||
setTimeout = (callback: () => void, delayMs: number): unknown => {
|
||||
const timer: FakeTimer = { at: this.nowMs + delayMs, callback, cleared: false }
|
||||
this.timers.push(timer)
|
||||
return timer
|
||||
}
|
||||
|
||||
clearTimeout = (handle: unknown): void => {
|
||||
;(handle as FakeTimer).cleared = true
|
||||
}
|
||||
|
||||
now = (): number => this.nowMs
|
||||
|
||||
async advance(ms: number): Promise<void> {
|
||||
const target = this.nowMs + ms
|
||||
for (;;) {
|
||||
const due = this.timers
|
||||
.filter((t) => !t.cleared && t.at <= target)
|
||||
.sort((a, b) => a.at - b.at)[0]
|
||||
if (!due) {
|
||||
break
|
||||
}
|
||||
this.nowMs = due.at
|
||||
due.cleared = true
|
||||
due.callback()
|
||||
// Why: probes are async; let their promise chains settle before firing the next timer.
|
||||
await drainMicrotasks()
|
||||
}
|
||||
this.nowMs = target
|
||||
}
|
||||
|
||||
pendingCount(): number {
|
||||
return this.timers.filter((t) => !t.cleared).length
|
||||
}
|
||||
}
|
||||
|
||||
async function drainMicrotasks(): Promise<void> {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function createWatch(
|
||||
overrides: Partial<MacosLoginSessionDeathWatchOptions> & {
|
||||
outcomes?: (LoginPreflightOutcome | null)[]
|
||||
} = {}
|
||||
): {
|
||||
watch: MacosLoginSessionDeathWatch
|
||||
clock: FakeClock
|
||||
onRetire: ReturnType<typeof vi.fn>
|
||||
probe: ReturnType<typeof vi.fn>
|
||||
setResolverHealth: (health: SystemResolverHealth) => void
|
||||
} {
|
||||
const clock = new FakeClock()
|
||||
const onRetire = vi.fn()
|
||||
const outcomes = overrides.outcomes ?? []
|
||||
// Why length-check, not `??`: an explicit null outcome (wrapper not applicable) must reach the watch.
|
||||
const probe = vi.fn(async () => (outcomes.length ? outcomes.shift()! : ACCEPTED))
|
||||
let resolverHealth: SystemResolverHealth = 'unhealthy'
|
||||
const watch = new MacosLoginSessionDeathWatch({
|
||||
probeLoginSession: overrides.probeLoginSession ?? probe,
|
||||
readResolverHealth: overrides.readResolverHealth ?? (async () => resolverHealth),
|
||||
onRetire: overrides.onRetire ?? onRetire,
|
||||
log: overrides.log ?? createNoopDaemonFileLog(),
|
||||
clock,
|
||||
timing: {
|
||||
periodicProbeMs: 120_000,
|
||||
rejectionRecheckMs: 10_000,
|
||||
ptyExitDebounceMs: 2_000,
|
||||
clientActivityMinGapMs: 30_000,
|
||||
minProbeGapMs: 5_000,
|
||||
...overrides.timing
|
||||
}
|
||||
})
|
||||
return {
|
||||
watch,
|
||||
clock,
|
||||
onRetire,
|
||||
probe,
|
||||
setResolverHealth: (health) => {
|
||||
resolverHealth = health
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('MacosLoginSessionDeathWatch', () => {
|
||||
it('retires after consecutive conclusive rejections once armed, with a degraded resolver', async () => {
|
||||
const { watch, clock, onRetire } = createWatch({
|
||||
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED]
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
|
||||
await clock.advance(120_000) // periodic → rejection 1
|
||||
await clock.advance(10_000) // recheck → rejection 2
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
await clock.advance(10_000) // recheck → rejection 3 → retire
|
||||
expect(onRetire).toHaveBeenCalledWith({
|
||||
cause: 'pam-rejections',
|
||||
rejections: 3,
|
||||
resolverHealth: 'unhealthy'
|
||||
})
|
||||
})
|
||||
|
||||
it('never retires when the session was never conclusively accepted', async () => {
|
||||
const { watch, clock, onRetire } = createWatch({
|
||||
outcomes: [REJECTED, REJECTED, REJECTED, REJECTED, REJECTED]
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await clock.advance(120_000)
|
||||
}
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets the rejection streak on a conclusive acceptance', async () => {
|
||||
const { watch, clock, onRetire } = createWatch({
|
||||
outcomes: [ACCEPTED, REJECTED, REJECTED, ACCEPTED, REJECTED, REJECTED]
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000) // rejection 1
|
||||
await clock.advance(10_000) // rejection 2
|
||||
await clock.advance(10_000) // acceptance → reset
|
||||
await clock.advance(120_000) // rejection 1
|
||||
await clock.advance(10_000) // rejection 2
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the rejection streak across interleaved inconclusive probes', async () => {
|
||||
const { watch, clock, onRetire } = createWatch({
|
||||
outcomes: [ACCEPTED, REJECTED, INCONCLUSIVE, REJECTED, INCONCLUSIVE, REJECTED]
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000) // rejection 1
|
||||
await clock.advance(10_000) // inconclusive timeout — rejection streak holds
|
||||
await clock.advance(120_000) // rejection 2
|
||||
await clock.advance(10_000) // inconclusive
|
||||
await clock.advance(120_000) // rejection 3 → retire
|
||||
expect(onRetire).toHaveBeenCalledTimes(1)
|
||||
expect(onRetire.mock.calls[0][0].rejections).toBe(3)
|
||||
expect(onRetire.mock.calls[0][0].cause).toBe('pam-rejections')
|
||||
})
|
||||
|
||||
it('keeps repeated inconclusive timeouts on the bounded periodic cadence', async () => {
|
||||
const readResolverHealth = vi.fn(async () => 'unhealthy' as const)
|
||||
const { watch, clock, onRetire, probe } = createWatch({
|
||||
outcomes: [ACCEPTED, ...Array.from({ length: 10 }, () => INCONCLUSIVE)],
|
||||
readResolverHealth
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await clock.advance(120_000)
|
||||
}
|
||||
expect(probe).toHaveBeenCalledTimes(11)
|
||||
expect(readResolverHealth).not.toHaveBeenCalled()
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['healthy', 'unknown'] as const)(
|
||||
'suppresses retirement while resolver health is %s, then retires on explicit degradation',
|
||||
async (initialResolverHealth) => {
|
||||
const { watch, clock, onRetire, probe, setResolverHealth } = createWatch({
|
||||
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED]
|
||||
})
|
||||
setResolverHealth(initialResolverHealth)
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000)
|
||||
await clock.advance(10_000)
|
||||
await clock.advance(10_000) // threshold reached but resolver did not corroborate death
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
const probesAtSuppression = probe.mock.calls.length
|
||||
setResolverHealth('unhealthy')
|
||||
await clock.advance(10_000)
|
||||
expect(probe).toHaveBeenCalledTimes(probesAtSuppression)
|
||||
await clock.advance(110_000) // suppressed states return to the bounded periodic cadence
|
||||
expect(onRetire).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('debounces a sustained PTY-exit burst into one trailing probe', async () => {
|
||||
const { watch, clock, probe } = createWatch({ outcomes: [ACCEPTED, ACCEPTED] })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(60_000)
|
||||
const before = probe.mock.calls.length
|
||||
watch.notifyPtyExit()
|
||||
await clock.advance(1_500)
|
||||
watch.notifyPtyExit()
|
||||
await clock.advance(1_500)
|
||||
watch.notifyPtyExit()
|
||||
await clock.advance(1_999)
|
||||
expect(probe).toHaveBeenCalledTimes(before)
|
||||
await clock.advance(1)
|
||||
expect(probe.mock.calls.length).toBe(before + 1)
|
||||
})
|
||||
|
||||
it('probes on client activity only after the min gap', async () => {
|
||||
const { watch, clock, probe } = createWatch({
|
||||
outcomes: [ACCEPTED, ACCEPTED, ACCEPTED]
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
const after = probe.mock.calls.length
|
||||
watch.notifyClientActivity() // too soon after startup probe, so retain one deferred probe
|
||||
await clock.advance(29_999)
|
||||
expect(probe.mock.calls.length).toBe(after)
|
||||
await clock.advance(1)
|
||||
expect(probe.mock.calls.length).toBe(after + 1)
|
||||
})
|
||||
|
||||
it('defers a PTY-exit trigger that lands inside the global probe gap', async () => {
|
||||
const { watch, clock, probe } = createWatch({ outcomes: [ACCEPTED, ACCEPTED] })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
|
||||
watch.notifyPtyExit()
|
||||
await clock.advance(4_999)
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
await clock.advance(1)
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retains one follow-up when a logout signal arrives during a probe', async () => {
|
||||
let resolveStartup!: (outcome: LoginPreflightOutcome) => void
|
||||
const startup = new Promise<LoginPreflightOutcome>((resolve) => {
|
||||
resolveStartup = resolve
|
||||
})
|
||||
const probe = vi
|
||||
.fn<MacosLoginSessionDeathWatchOptions['probeLoginSession']>()
|
||||
.mockReturnValueOnce(startup)
|
||||
.mockResolvedValue(ACCEPTED)
|
||||
const { watch, clock } = createWatch({ probeLoginSession: probe })
|
||||
watch.start()
|
||||
watch.notifyPtyExit()
|
||||
await clock.advance(2_000)
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
|
||||
resolveStartup(ACCEPTED)
|
||||
await drainMicrotasks()
|
||||
await clock.advance(2_999)
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
await clock.advance(1)
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retains client activity that arrives during an armed periodic probe', async () => {
|
||||
let resolvePeriodic!: (outcome: LoginPreflightOutcome) => void
|
||||
const periodic = new Promise<LoginPreflightOutcome>((resolve) => {
|
||||
resolvePeriodic = resolve
|
||||
})
|
||||
const probe = vi
|
||||
.fn<MacosLoginSessionDeathWatchOptions['probeLoginSession']>()
|
||||
.mockResolvedValueOnce(ACCEPTED)
|
||||
.mockReturnValueOnce(periodic)
|
||||
.mockResolvedValue(ACCEPTED)
|
||||
const { watch, clock } = createWatch({ probeLoginSession: probe })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000)
|
||||
|
||||
watch.notifyClientActivity()
|
||||
resolvePeriodic(ACCEPTED)
|
||||
await drainMicrotasks()
|
||||
await clock.advance(29_999)
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
await clock.advance(1)
|
||||
expect(probe).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('disables itself when the wrapper machinery does not apply', async () => {
|
||||
const { watch, clock, probe } = createWatch({ outcomes: [null] })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
await clock.advance(600_000)
|
||||
watch.notifyPtyExit()
|
||||
watch.notifyClientActivity()
|
||||
await clock.advance(600_000)
|
||||
expect(probe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stop() cancels all pending timers', async () => {
|
||||
const { watch, clock } = createWatch({ outcomes: [ACCEPTED] })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
watch.notifyPtyExit()
|
||||
watch.stop()
|
||||
expect(clock.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('stop() aborts an in-flight subprocess probe', async () => {
|
||||
let probeSignal: AbortSignal | undefined
|
||||
const probe = vi.fn((signal?: AbortSignal) => {
|
||||
probeSignal = signal
|
||||
return new Promise<LoginPreflightOutcome>(() => {})
|
||||
})
|
||||
const { watch } = createWatch({ probeLoginSession: probe })
|
||||
watch.start()
|
||||
expect(probeSignal?.aborted).toBe(false)
|
||||
|
||||
watch.stop()
|
||||
|
||||
expect(probeSignal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('stop() prevents an in-flight resolver check from retiring the daemon', async () => {
|
||||
let resolveHealth!: (health: SystemResolverHealth) => void
|
||||
let resolverSignal: AbortSignal | undefined
|
||||
const resolverHealth = new Promise<SystemResolverHealth>((resolve) => {
|
||||
resolveHealth = resolve
|
||||
})
|
||||
const { watch, clock, onRetire } = createWatch({
|
||||
outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED],
|
||||
readResolverHealth: (signal) => {
|
||||
resolverSignal = signal
|
||||
return resolverHealth
|
||||
}
|
||||
})
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000)
|
||||
await clock.advance(10_000)
|
||||
await clock.advance(10_000) // retirement is now waiting on resolver health
|
||||
|
||||
watch.stop()
|
||||
expect(resolverSignal?.aborted).toBe(true)
|
||||
resolveHealth('unhealthy')
|
||||
await drainMicrotasks()
|
||||
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs and reschedules when a probe throws instead of surfacing a rejection', async () => {
|
||||
const outcomes: (() => Promise<LoginPreflightOutcome>)[] = [
|
||||
async () => ACCEPTED,
|
||||
async () => {
|
||||
throw new Error('launchctl exploded')
|
||||
},
|
||||
async () => ACCEPTED
|
||||
]
|
||||
const probe = vi.fn(() => (outcomes.shift() ?? (async () => ACCEPTED))())
|
||||
const { watch, clock, onRetire } = createWatch({ probeLoginSession: probe })
|
||||
watch.start()
|
||||
await drainMicrotasks()
|
||||
await clock.advance(120_000) // throwing probe
|
||||
await clock.advance(120_000) // rescheduled probe still runs
|
||||
expect(probe).toHaveBeenCalledTimes(3)
|
||||
expect(onRetire).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { LoginPreflightOutcome } from '../providers/macos-tcc-login-shell'
|
||||
import type { DaemonFileLog } from './daemon-file-log'
|
||||
import type { SystemResolverHealth } from './types'
|
||||
|
||||
// Why: three conclusive PAM verdicts spread over recheck intervals keep a transient
|
||||
// rejection storm (PAM db reload, OS update) from retiring a daemon whose login
|
||||
// session is still alive; a real logout rejects conclusively on every probe.
|
||||
const REQUIRED_CONSECUTIVE_REJECTIONS = 3
|
||||
const PERIODIC_PROBE_MS = 120_000
|
||||
const REJECTION_RECHECK_MS = 10_000
|
||||
const PTY_EXIT_DEBOUNCE_MS = 2_000
|
||||
// Why: a client hello right after login is the fastest death signal for a stale
|
||||
// daemon, but steady reconnects must not turn hellos into a PAM probe storm.
|
||||
const CLIENT_ACTIVITY_MIN_GAP_MS = 30_000
|
||||
const MIN_PROBE_GAP_MS = 5_000
|
||||
|
||||
type WatchClock = {
|
||||
setTimeout(callback: () => void, delayMs: number): unknown
|
||||
clearTimeout(handle: unknown): void
|
||||
now(): number
|
||||
}
|
||||
|
||||
export type MacosLoginSessionDeathWatchOptions = {
|
||||
/** Fresh PAM probe (cache-bypassing); null when the login wrapper doesn't apply on this host. */
|
||||
probeLoginSession: (signal?: AbortSignal) => Promise<LoginPreflightOutcome | null>
|
||||
readResolverHealth: (signal?: AbortSignal) => Promise<SystemResolverHealth>
|
||||
onRetire: (details: {
|
||||
cause: 'pam-rejections'
|
||||
rejections: number
|
||||
resolverHealth: SystemResolverHealth
|
||||
}) => void
|
||||
log: DaemonFileLog
|
||||
/** Direct-construction seam for deterministic tests; production uses real timers. */
|
||||
clock?: WatchClock
|
||||
timing?: Partial<{
|
||||
periodicProbeMs: number
|
||||
rejectionRecheckMs: number
|
||||
ptyExitDebounceMs: number
|
||||
clientActivityMinGapMs: number
|
||||
minProbeGapMs: number
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects that the macOS GUI login session this daemon was born into has died
|
||||
* (full logout / WindowServer session teardown) and retires the daemon so the
|
||||
* next app start cold-starts a replacement inside the live session (#7936).
|
||||
*
|
||||
* A daemon in a dead login session is unsalvageable: its PAM context can no
|
||||
* longer host `login(1)` spawns ("Login incorrect" zombies) and its Mach
|
||||
* bootstrap namespace has lost the system DNS resolver, so every terminal it
|
||||
* hosts has no egress. Retirement is the only converging heal.
|
||||
*
|
||||
* The oracle is the existing TCC login-shell PAM probe: it conclusively accepts
|
||||
* while the session is valid — including fast-user-switched-away sessions — and
|
||||
* conclusively rejects once the session is destroyed. Retirement additionally
|
||||
* requires the in-process system resolver to be explicitly degraded, so an
|
||||
* inconclusive probe or PAM anomaly alone can never kill a healthy daemon.
|
||||
*/
|
||||
export class MacosLoginSessionDeathWatch {
|
||||
private readonly probeLoginSession: MacosLoginSessionDeathWatchOptions['probeLoginSession']
|
||||
private readonly readResolverHealth: MacosLoginSessionDeathWatchOptions['readResolverHealth']
|
||||
private readonly onRetire: MacosLoginSessionDeathWatchOptions['onRetire']
|
||||
private readonly log: DaemonFileLog
|
||||
private readonly clock: WatchClock
|
||||
private readonly periodicProbeMs: number
|
||||
private readonly rejectionRecheckMs: number
|
||||
private readonly ptyExitDebounceMs: number
|
||||
private readonly clientActivityMinGapMs: number
|
||||
private readonly minProbeGapMs: number
|
||||
|
||||
// Why: retire only a daemon that once proved its session could host login(1);
|
||||
// a host where the wrapper never worked has no death signal to trust.
|
||||
private armed = false
|
||||
private consecutiveRejections = 0
|
||||
private lastProbeStartedAtMs: number | null = null
|
||||
private probeInFlight = false
|
||||
private stopped = false
|
||||
private retired = false
|
||||
private scheduledProbeTimer: unknown | null = null
|
||||
private scheduledProbeAtMs: number | null = null
|
||||
private ptyExitDebounceTimer: unknown | null = null
|
||||
private pendingProbeTrigger: string | null = null
|
||||
private probeAbortController: AbortController | null = null
|
||||
|
||||
constructor(opts: MacosLoginSessionDeathWatchOptions) {
|
||||
this.probeLoginSession = opts.probeLoginSession
|
||||
this.readResolverHealth = opts.readResolverHealth
|
||||
this.onRetire = opts.onRetire
|
||||
this.log = opts.log
|
||||
this.clock = opts.clock ?? {
|
||||
setTimeout: (callback, delayMs) => {
|
||||
const timer = setTimeout(callback, delayMs)
|
||||
timer.unref()
|
||||
return timer
|
||||
},
|
||||
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
||||
now: () => Date.now()
|
||||
}
|
||||
this.periodicProbeMs = opts.timing?.periodicProbeMs ?? PERIODIC_PROBE_MS
|
||||
this.rejectionRecheckMs = opts.timing?.rejectionRecheckMs ?? REJECTION_RECHECK_MS
|
||||
this.ptyExitDebounceMs = opts.timing?.ptyExitDebounceMs ?? PTY_EXIT_DEBOUNCE_MS
|
||||
this.clientActivityMinGapMs = opts.timing?.clientActivityMinGapMs ?? CLIENT_ACTIVITY_MIN_GAP_MS
|
||||
this.minProbeGapMs = opts.timing?.minProbeGapMs ?? MIN_PROBE_GAP_MS
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
void this.runProbe('startup')
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true
|
||||
this.probeAbortController?.abort()
|
||||
this.probeAbortController = null
|
||||
if (this.scheduledProbeTimer !== null) {
|
||||
this.clock.clearTimeout(this.scheduledProbeTimer)
|
||||
this.scheduledProbeTimer = null
|
||||
this.scheduledProbeAtMs = null
|
||||
}
|
||||
if (this.ptyExitDebounceTimer !== null) {
|
||||
this.clock.clearTimeout(this.ptyExitDebounceTimer)
|
||||
this.ptyExitDebounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** A mass PTY-exit burst is what a logout's SIGHUP sweep looks like from inside the daemon. */
|
||||
notifyPtyExit(): void {
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
if (this.ptyExitDebounceTimer !== null) {
|
||||
this.clock.clearTimeout(this.ptyExitDebounceTimer)
|
||||
}
|
||||
this.ptyExitDebounceTimer = this.clock.setTimeout(() => {
|
||||
this.ptyExitDebounceTimer = null
|
||||
void this.runProbe('pty-exit')
|
||||
}, this.ptyExitDebounceMs)
|
||||
}
|
||||
|
||||
notifyClientActivity(): void {
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
if (this.probeInFlight) {
|
||||
if (this.armed) {
|
||||
this.retainPendingProbe('client-hello')
|
||||
}
|
||||
return
|
||||
}
|
||||
const elapsedSinceProbe =
|
||||
this.lastProbeStartedAtMs === null ? null : this.clock.now() - this.lastProbeStartedAtMs
|
||||
if (elapsedSinceProbe !== null && elapsedSinceProbe < this.clientActivityMinGapMs) {
|
||||
// Why: dropping a post-login hello here can defer stale-daemon recovery to the two-minute backstop.
|
||||
this.scheduleProbeNoLaterThan(this.clientActivityMinGapMs - elapsedSinceProbe, 'client-hello')
|
||||
return
|
||||
}
|
||||
void this.runProbe('client-hello')
|
||||
}
|
||||
|
||||
private scheduleProbe(delayMs: number, trigger: string): void {
|
||||
if (this.stopped) {
|
||||
return
|
||||
}
|
||||
if (this.scheduledProbeTimer !== null) {
|
||||
this.clock.clearTimeout(this.scheduledProbeTimer)
|
||||
}
|
||||
this.scheduledProbeAtMs = this.clock.now() + delayMs
|
||||
this.scheduledProbeTimer = this.clock.setTimeout(() => {
|
||||
this.scheduledProbeTimer = null
|
||||
this.scheduledProbeAtMs = null
|
||||
void this.runProbe(trigger)
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
private scheduleNextProbe(delayMs: number): void {
|
||||
this.scheduleProbe(delayMs, 'periodic')
|
||||
}
|
||||
|
||||
private scheduleProbeNoLaterThan(delayMs: number, trigger: string): void {
|
||||
const requestedAtMs = this.clock.now() + delayMs
|
||||
if (this.scheduledProbeAtMs !== null && this.scheduledProbeAtMs <= requestedAtMs) {
|
||||
return
|
||||
}
|
||||
this.scheduleProbe(delayMs, trigger)
|
||||
}
|
||||
|
||||
private probeGapMs(trigger: string): number {
|
||||
return trigger === 'client-hello' ? this.clientActivityMinGapMs : this.minProbeGapMs
|
||||
}
|
||||
|
||||
private retainPendingProbe(trigger: string): void {
|
||||
if (
|
||||
this.pendingProbeTrigger === null ||
|
||||
this.probeGapMs(trigger) < this.probeGapMs(this.pendingProbeTrigger)
|
||||
) {
|
||||
this.pendingProbeTrigger = trigger
|
||||
}
|
||||
}
|
||||
|
||||
private async runProbe(trigger: string): Promise<void> {
|
||||
if (this.stopped || this.retired) {
|
||||
return
|
||||
}
|
||||
if (this.probeInFlight) {
|
||||
// Why: the current probe may describe the pre-logout state; retain one follow-up without polling.
|
||||
this.retainPendingProbe(trigger)
|
||||
return
|
||||
}
|
||||
const elapsedSinceProbe =
|
||||
this.lastProbeStartedAtMs === null ? null : this.clock.now() - this.lastProbeStartedAtMs
|
||||
if (
|
||||
trigger !== 'startup' &&
|
||||
elapsedSinceProbe !== null &&
|
||||
elapsedSinceProbe < this.minProbeGapMs
|
||||
) {
|
||||
this.scheduleProbeNoLaterThan(this.minProbeGapMs - elapsedSinceProbe, trigger)
|
||||
return
|
||||
}
|
||||
this.probeInFlight = true
|
||||
this.lastProbeStartedAtMs = this.clock.now()
|
||||
const abortController = new AbortController()
|
||||
this.probeAbortController = abortController
|
||||
try {
|
||||
const outcome = await this.probeLoginSession(abortController.signal)
|
||||
if (this.stopped || this.retired) {
|
||||
return
|
||||
}
|
||||
if (outcome === null) {
|
||||
// Why: no wrapper machinery means no PAM oracle — watching would only ever misfire.
|
||||
this.log.log('login-session-watch-disabled', { trigger })
|
||||
this.stop()
|
||||
return
|
||||
}
|
||||
if (!outcome.conclusive) {
|
||||
// Why: repeated timeouts are still ambiguous on slow/offline PAM hosts;
|
||||
// never turn an inconclusive probe into authority to orphan live PTYs.
|
||||
this.scheduleNextProbe(this.periodicProbeMs)
|
||||
return
|
||||
}
|
||||
if (outcome.ok) {
|
||||
if (!this.armed) {
|
||||
this.log.log('login-session-watch-armed', { trigger })
|
||||
}
|
||||
this.armed = true
|
||||
this.consecutiveRejections = 0
|
||||
this.scheduleNextProbe(this.periodicProbeMs)
|
||||
return
|
||||
}
|
||||
if (!this.armed) {
|
||||
// Session never hosted login(1) here; the preflight already degraded spawns.
|
||||
this.scheduleNextProbe(this.periodicProbeMs)
|
||||
return
|
||||
}
|
||||
this.consecutiveRejections++
|
||||
this.log.log('login-session-probe-rejected', {
|
||||
trigger,
|
||||
rejections: this.consecutiveRejections
|
||||
})
|
||||
if (this.consecutiveRejections < REQUIRED_CONSECUTIVE_REJECTIONS) {
|
||||
this.scheduleNextProbe(this.rejectionRecheckMs)
|
||||
return
|
||||
}
|
||||
await this.retireIfResolverDegraded(() => {
|
||||
this.consecutiveRejections = REQUIRED_CONSECUTIVE_REJECTIONS - 1
|
||||
}, abortController.signal)
|
||||
} catch (error) {
|
||||
// Why: a probe failure is diagnostic only; an escaped rejection would trip the
|
||||
// daemon's fatal unhandled-error path and kill live terminals.
|
||||
this.log.log('login-session-probe-error', { message: (error as Error)?.message })
|
||||
this.scheduleNextProbe(this.periodicProbeMs)
|
||||
} finally {
|
||||
this.probeInFlight = false
|
||||
if (this.probeAbortController === abortController) {
|
||||
this.probeAbortController = null
|
||||
}
|
||||
const pendingTrigger = this.pendingProbeTrigger
|
||||
this.pendingProbeTrigger = null
|
||||
if (!this.stopped && !this.retired && pendingTrigger !== null) {
|
||||
const elapsed = this.clock.now() - (this.lastProbeStartedAtMs ?? this.clock.now())
|
||||
this.scheduleProbeNoLaterThan(
|
||||
Math.max(0, this.probeGapMs(pendingTrigger) - elapsed),
|
||||
pendingTrigger
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async retireIfResolverDegraded(
|
||||
holdAtThreshold: () => void,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
const resolverHealth = await this.readResolverHealth(signal)
|
||||
if (this.stopped || this.retired) {
|
||||
return
|
||||
}
|
||||
if (resolverHealth !== 'unhealthy') {
|
||||
// Why: only explicit resolver degradation corroborates session death; unknown
|
||||
// probe failures must preserve terminals and avoid a permanent fast retry loop.
|
||||
this.log.log('login-session-retire-suppressed', {
|
||||
cause: 'pam-rejections',
|
||||
resolverHealth
|
||||
})
|
||||
holdAtThreshold()
|
||||
this.scheduleNextProbe(this.periodicProbeMs)
|
||||
return
|
||||
}
|
||||
this.retired = true
|
||||
this.onRetire({
|
||||
cause: 'pam-rejections',
|
||||
rejections: this.consecutiveRejections,
|
||||
resolverHealth
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export type TerminalHostOptions = {
|
||||
terminalWindowsWslDistro?: string | null
|
||||
terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe'
|
||||
}) => SubprocessHandle
|
||||
// Why: login-session death detection (#7936) needs subprocess exits even when no client is attached.
|
||||
onSessionReaped?: (sessionId: string) => void
|
||||
// Why: graceful shutdown checkpoints must finish in-process before teardown.
|
||||
onFinalCheckpoint?: (
|
||||
sessionId: string,
|
||||
|
||||
@@ -883,6 +883,8 @@ describe('TerminalHost', () => {
|
||||
})
|
||||
|
||||
it('does not list exited sessions', async () => {
|
||||
const onSessionReaped = vi.fn()
|
||||
host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn, onSessionReaped })
|
||||
await host.createOrAttach({
|
||||
sessionId: 'session-1',
|
||||
cols: 80,
|
||||
@@ -892,6 +894,7 @@ describe('TerminalHost', () => {
|
||||
|
||||
lastSubprocess._onExitCb?.(0)
|
||||
expect(host.listSessions()).toEqual([])
|
||||
expect(onSessionReaped).toHaveBeenCalledWith('session-1')
|
||||
})
|
||||
|
||||
it('never force-kills an exited session (recycled-pid SIGKILL safety)', async () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ export class TerminalHost {
|
||||
private sessionTeardown = new TerminalSessionTeardown(this.sessions)
|
||||
private killedTombstones: TerminalHostTombstones
|
||||
private spawnSubprocess: TerminalHostOptions['spawnSubprocess']
|
||||
private onSessionReaped: TerminalHostOptions['onSessionReaped']
|
||||
private onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint']
|
||||
private maxTombstones: number
|
||||
private creationFenced = false
|
||||
@@ -36,6 +37,7 @@ export class TerminalHost {
|
||||
|
||||
constructor(opts: TerminalHostOptions) {
|
||||
this.spawnSubprocess = opts.spawnSubprocess
|
||||
this.onSessionReaped = opts.onSessionReaped
|
||||
this.onFinalCheckpoint = opts.onFinalCheckpoint
|
||||
this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES
|
||||
this.killedTombstones = new TerminalHostTombstones(this.maxTombstones)
|
||||
@@ -119,6 +121,7 @@ export class TerminalHost {
|
||||
}
|
||||
session.dispose()
|
||||
this.sessions.delete(sessionId)
|
||||
this.onSessionReaped?.(sessionId)
|
||||
}
|
||||
|
||||
signal(sessionId: string, sig: string): void {
|
||||
|
||||
+29
-2
@@ -253,6 +253,7 @@ let runtimeRpc: OrcaRuntimeRpcServer | null = null
|
||||
const serveReadinessPublisher = new ServeReadinessPublisher()
|
||||
let desktopRelayService: DesktopRelayService | null = null
|
||||
let desktopRelayStatus: RelayBrokerStatus = 'offline'
|
||||
let pendingUnpairedDeviceAuthFailure = false
|
||||
// Why: gates whether headless serve installs the offscreen browser backend (and advertises browser pane support).
|
||||
let headlessBrowserDisplayAvailable = false
|
||||
|
||||
@@ -673,7 +674,11 @@ function startTerminalRuntimeStartupServices(): Promise<void> {
|
||||
// Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer.
|
||||
startDaemonPtyProvider: async (signal) => {
|
||||
logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' })
|
||||
await initDaemonPtyProvider(signal)
|
||||
// Why: only GUI-spawned macOS daemons watch for login-session death; a headless
|
||||
// serve daemon must survive its spawning session ending (SSH disconnect).
|
||||
await initDaemonPtyProvider(signal, {
|
||||
macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode
|
||||
})
|
||||
logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' })
|
||||
},
|
||||
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect.
|
||||
@@ -2281,7 +2286,29 @@ app.whenReady().then(async () => {
|
||||
: {}),
|
||||
webClientRoot: getBundledWebClientRoot()
|
||||
})
|
||||
registerMobileHandlers(runtimeRpc, { getRelayStatus: () => desktopRelayStatus })
|
||||
registerMobileHandlers(runtimeRpc, {
|
||||
getRelayStatus: () => desktopRelayStatus,
|
||||
consumePendingUnpairedDeviceAuthFailure: (webContentsId) => {
|
||||
if (
|
||||
!mainWindow ||
|
||||
mainWindow.isDestroyed() ||
|
||||
mainWindow.webContents.id !== webContentsId ||
|
||||
!pendingUnpairedDeviceAuthFailure
|
||||
) {
|
||||
return false
|
||||
}
|
||||
pendingUnpairedDeviceAuthFailure = false
|
||||
return true
|
||||
}
|
||||
})
|
||||
// Why: repeated direct auth failures otherwise look like a client that never connects; point users to re-pairing.
|
||||
runtimeRpc.setOnUnpairedDeviceAuthFailure(() => {
|
||||
// Why: runtime startup races renderer mount; retain the one-shot until the listener consumes it.
|
||||
pendingUnpairedDeviceAuthFailure = true
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('mobile:unpairedDeviceAuthFailure')
|
||||
}
|
||||
})
|
||||
|
||||
startTerminalRuntimeStartupServices()
|
||||
app.on('activate', requestDesktopActivation)
|
||||
|
||||
@@ -273,6 +273,25 @@ describe('registerMobileHandlers', () => {
|
||||
expect(handlers.get('mobile:getRelayStatus')?.()).toEqual({ status: 'registered' })
|
||||
})
|
||||
|
||||
it('consumes a pending auth-failure notification only from a window renderer', () => {
|
||||
const consumePendingUnpairedDeviceAuthFailure = vi.fn(() => true)
|
||||
registerMobileHandlers({} as never, { consumePendingUnpairedDeviceAuthFailure })
|
||||
|
||||
expect(
|
||||
handlers.get('mobile:consumePendingUnpairedDeviceAuthFailure')?.({
|
||||
sender: { id: 42, isDestroyed: () => false, getType: () => 'window' }
|
||||
})
|
||||
).toBe(true)
|
||||
expect(consumePendingUnpairedDeviceAuthFailure).toHaveBeenCalledWith(42)
|
||||
|
||||
expect(
|
||||
handlers.get('mobile:consumePendingUnpairedDeviceAuthFailure')?.({
|
||||
sender: { id: 99, isDestroyed: () => false, getType: () => 'webview' }
|
||||
})
|
||||
).toBe(false)
|
||||
expect(consumePendingUnpairedDeviceAuthFailure).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('inspects and repairs the current packaged Windows websocket port', async () => {
|
||||
const runPowerShell = vi
|
||||
.fn()
|
||||
|
||||
@@ -63,6 +63,7 @@ export type MobileHandlerDependencies = {
|
||||
firewallEnvironment?: WindowsMobileFirewallEnvironment
|
||||
openWindowsNetworkSettings?: () => Promise<void>
|
||||
getRelayStatus?: () => RelayBrokerStatus
|
||||
consumePendingUnpairedDeviceAuthFailure?: (webContentsId: number) => boolean
|
||||
}
|
||||
|
||||
export function registerMobileHandlers(
|
||||
@@ -252,6 +253,13 @@ export function registerMobileHandlers(
|
||||
ipcMain.handle('mobile:getRelayStatus', () => ({
|
||||
status: dependencies.getRelayStatus?.() ?? 'offline'
|
||||
}))
|
||||
|
||||
ipcMain.handle('mobile:consumePendingUnpairedDeviceAuthFailure', (event) => {
|
||||
if (!isWindowRenderer(event)) {
|
||||
return false
|
||||
}
|
||||
return dependencies.consumePendingUnpairedDeviceAuthFailure?.(event.sender.id) ?? false
|
||||
})
|
||||
}
|
||||
|
||||
function isWindowRenderer(event: IpcMainInvokeEvent): boolean {
|
||||
|
||||
@@ -2306,6 +2306,127 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('hydrates detected worktrees with instance-validated legacy lineage after an update', async () => {
|
||||
const parentPath = '/workspace/assigned-issues'
|
||||
const childPath = '/workspace/issue-9276-nested-ssh-runtime-routing'
|
||||
const parentId = `repo-1::${parentPath}`
|
||||
const childId = `repo-1::${childPath}`
|
||||
const metaById: Record<string, { instanceId: string }> = {
|
||||
[parentId]: { instanceId: 'parent-instance' },
|
||||
[childId]: { instanceId: 'child-instance' }
|
||||
}
|
||||
store.getWorktreeMeta.mockImplementation((id: string) => metaById[id])
|
||||
store.setWorktreeMeta.mockImplementation((id: string, updates: object) => ({
|
||||
...metaById[id],
|
||||
...updates
|
||||
}))
|
||||
store.getAllWorktreeLineage.mockReturnValue({
|
||||
[childId]: {
|
||||
worktreeId: childId,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'cli',
|
||||
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
|
||||
createdAt: 1
|
||||
}
|
||||
})
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: childPath,
|
||||
head: 'child-head',
|
||||
branch: 'refs/heads/child',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
},
|
||||
{
|
||||
path: parentPath,
|
||||
head: 'parent-head',
|
||||
branch: 'refs/heads/parent',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = (await handlers['worktrees:listDetected'](null, {
|
||||
repoId: 'repo-1'
|
||||
})) as { worktrees: (Worktree & { lineage?: unknown; parentWorktreeId?: string | null })[] }
|
||||
|
||||
expect(result.worktrees).toEqual([
|
||||
expect.objectContaining({
|
||||
id: childId,
|
||||
parentWorktreeId: parentId,
|
||||
lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: parentId,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [childId],
|
||||
lineage: null
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('hydrates folder-repo detected rows with instance-validated legacy lineage', async () => {
|
||||
const folderRepo = {
|
||||
id: 'repo-1',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder' as const
|
||||
}
|
||||
const parentId = `${folderRepo.id}::${folderRepo.path}`
|
||||
const childId = `${parentId}::workspace:child-instance`
|
||||
const metaById: Record<string, Record<string, unknown>> = {
|
||||
[parentId]: makeWorktreeMeta({
|
||||
instanceId: 'parent-instance',
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1'
|
||||
}),
|
||||
[childId]: makeWorktreeMeta({
|
||||
instanceId: 'child-instance',
|
||||
projectId: 'repo:repo-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'repo-1'
|
||||
})
|
||||
}
|
||||
store.getRepos.mockReturnValue([folderRepo])
|
||||
store.getRepo.mockReturnValue(folderRepo)
|
||||
store.getAllWorktreeMeta.mockReturnValue(metaById)
|
||||
store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId])
|
||||
store.getAllWorktreeLineage.mockReturnValue({
|
||||
[childId]: {
|
||||
worktreeId: childId,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'cli',
|
||||
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
|
||||
createdAt: 1
|
||||
}
|
||||
})
|
||||
|
||||
const result = (await handlers['worktrees:listDetected'](null, {
|
||||
repoId: folderRepo.id
|
||||
})) as { worktrees: (Worktree & { lineage?: unknown; parentWorktreeId?: string | null })[] }
|
||||
|
||||
expect(result.worktrees).toEqual([
|
||||
expect.objectContaining({
|
||||
id: parentId,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [childId],
|
||||
lineage: null
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: childId,
|
||||
parentWorktreeId: parentId,
|
||||
lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' })
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('hides agent scratch created inside a linked checkout from desktop listings', async () => {
|
||||
const linkedCheckoutPath = '/workspace/feature-x'
|
||||
const scratchPath = `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa`
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../shared/workspace-scope'
|
||||
import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports'
|
||||
import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection'
|
||||
import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage'
|
||||
import { deleteWorktreeHistoryDir } from '../terminal-history'
|
||||
import type {
|
||||
AutomationWorkspaceProvenance,
|
||||
@@ -738,7 +739,7 @@ function buildDetectedGitWorktrees(
|
||||
repo.path,
|
||||
...liveWorktrees.map((worktree) => worktree.path)
|
||||
])
|
||||
return liveWorktrees.map((gitWorktree) => {
|
||||
const detected = liveWorktrees.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
let meta = store.getWorktreeMeta(worktreeId)
|
||||
const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||||
@@ -766,6 +767,7 @@ function buildDetectedGitWorktrees(
|
||||
agentScratchWorktreePathMatcher
|
||||
})
|
||||
})
|
||||
return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
|
||||
}
|
||||
|
||||
function stampAndMergeVisibleDetectedWorktree(
|
||||
@@ -959,7 +961,7 @@ function buildDisconnectedDetectedWorktrees(
|
||||
repo.path,
|
||||
...worktrees.map((worktree) => worktree.path)
|
||||
])
|
||||
return worktrees.map((worktree) => {
|
||||
const detected = worktrees.map((worktree) => {
|
||||
const meta = store.getWorktreeMeta(worktree.id)
|
||||
const detected = toDetectedWorktree({
|
||||
repo,
|
||||
@@ -972,6 +974,7 @@ function buildDisconnectedDetectedWorktrees(
|
||||
})
|
||||
return applyMetadataFallbackVisibility(detected)
|
||||
})
|
||||
return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
|
||||
}
|
||||
|
||||
export function registerWorktreeHandlers(
|
||||
@@ -1149,7 +1152,10 @@ export function registerWorktreeHandlers(
|
||||
repoId: repo.id,
|
||||
authoritative: true,
|
||||
source: 'git',
|
||||
worktrees: buildFolderDetectedWorktrees(store, repo)
|
||||
worktrees: projectResolvedWorktreeLineage(
|
||||
buildFolderDetectedWorktrees(store, repo),
|
||||
store.getAllWorktreeLineage?.() ?? {}
|
||||
)
|
||||
}
|
||||
} else if (repo.connectionId) {
|
||||
const provider = getSshGitProvider(repo.connectionId)
|
||||
|
||||
@@ -132,6 +132,23 @@ resolver #1
|
||||
expect(child.listenerCount('close')).toBe(0)
|
||||
})
|
||||
|
||||
it('kills scutil and removes listeners when its owner stops', async () => {
|
||||
mockPlatform('darwin')
|
||||
const child = createMockScutilProcess()
|
||||
vi.mocked(spawn).mockReturnValue(child)
|
||||
const abortController = new AbortController()
|
||||
|
||||
const healthPromise = readCurrentProcessMacSystemResolverHealth(abortController.signal)
|
||||
abortController.abort()
|
||||
|
||||
await expect(healthPromise).resolves.toBe('unknown')
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
|
||||
expect(child.stdout.listenerCount('data')).toBe(0)
|
||||
expect(child.stderr.listenerCount('data')).toBe(0)
|
||||
expect(child.listenerCount('error')).toBe(0)
|
||||
expect(child.listenerCount('close')).toBe(0)
|
||||
})
|
||||
|
||||
it('removes scutil listeners when the child closes normally', async () => {
|
||||
mockPlatform('darwin')
|
||||
const child = createMockScutilProcess()
|
||||
|
||||
@@ -16,8 +16,10 @@ export function classifyMacSystemResolverHealth(scutilOutput: string): SystemRes
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export async function readCurrentProcessMacSystemResolverHealth(): Promise<SystemResolverHealth> {
|
||||
if (process.platform !== 'darwin') {
|
||||
export async function readCurrentProcessMacSystemResolverHealth(
|
||||
signal?: AbortSignal
|
||||
): Promise<SystemResolverHealth> {
|
||||
if (process.platform !== 'darwin' || signal?.aborted) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
@@ -35,6 +37,10 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise<Syste
|
||||
const onStderrData = (chunk: string): void => {
|
||||
stderr += chunk
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
child.kill('SIGKILL')
|
||||
finish()
|
||||
}
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
@@ -48,6 +54,7 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise<Syste
|
||||
child.stderr.off('data', onStderrData)
|
||||
child.off('error', finish)
|
||||
child.off('close', finish)
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
resolve(classifyMacSystemResolverHealth(`${stdout}\n${stderr}`))
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
@@ -62,5 +69,6 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise<Syste
|
||||
child.stderr.on('data', onStderrData)
|
||||
child.on('error', finish)
|
||||
child.on('close', finish)
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9703,6 +9703,110 @@ describe('Store', () => {
|
||||
|
||||
// ── Live Claude PTY session ids (STA-1246) ─────────────────────────
|
||||
|
||||
describe('mobileClientTabSelectionsByDeviceId', () => {
|
||||
it('persists device tab selections across reloads and drops malformed payloads', async () => {
|
||||
const store = await createStore()
|
||||
store.setMobileClientTabSelections({
|
||||
'device-a': {
|
||||
'repo-1::/tmp/wt': { activeTabId: 'tab-1', activeGroupId: 'g1', activeTabIdByGroupId: {} }
|
||||
}
|
||||
})
|
||||
store.flush()
|
||||
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getMobileClientTabSelections()['device-a']?.['repo-1::/tmp/wt']).toEqual({
|
||||
activeTabId: 'tab-1',
|
||||
activeGroupId: 'g1',
|
||||
activeTabIdByGroupId: {}
|
||||
})
|
||||
|
||||
writeDataFile({ mobileClientTabSelectionsByDeviceId: { 'device-a': 'corrupt' } })
|
||||
const corrupted = await createStore()
|
||||
expect(corrupted.getMobileClientTabSelections()).toEqual({})
|
||||
})
|
||||
|
||||
it('prunes selections for a removed repo worktree', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
store.setMobileClientTabSelections({
|
||||
'device-a': {
|
||||
'r1::/tmp/wt': {
|
||||
activeTabId: 'tab-1',
|
||||
activeGroupId: null,
|
||||
activeTabIdByGroupId: {}
|
||||
},
|
||||
'other-repo::/tmp/wt': {
|
||||
activeTabId: 'tab-2',
|
||||
activeGroupId: null,
|
||||
activeTabIdByGroupId: {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
store.removeProject('r1')
|
||||
store.flush()
|
||||
|
||||
expect(store.getMobileClientTabSelections()['device-a']).toEqual({
|
||||
'other-repo::/tmp/wt': {
|
||||
activeTabId: 'tab-2',
|
||||
activeGroupId: null,
|
||||
activeTabIdByGroupId: {}
|
||||
}
|
||||
})
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getMobileClientTabSelections()['device-a']).toEqual({
|
||||
'other-repo::/tmp/wt': {
|
||||
activeTabId: 'tab-2',
|
||||
activeGroupId: null,
|
||||
activeTabIdByGroupId: {}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('prunes selections when a folder workspace is removed directly or with its group', async () => {
|
||||
const store = await createStore()
|
||||
const directGroup = store.createProjectGroup({
|
||||
name: 'Direct',
|
||||
parentPath: '/tmp/direct',
|
||||
createdFrom: 'manual'
|
||||
})
|
||||
const directWorkspace = store.createFolderWorkspace({
|
||||
projectGroupId: directGroup.id,
|
||||
name: 'Direct workspace'
|
||||
})
|
||||
const cascadeGroup = store.createProjectGroup({
|
||||
name: 'Cascade',
|
||||
parentPath: '/tmp/cascade',
|
||||
createdFrom: 'manual'
|
||||
})
|
||||
const cascadeWorkspace = store.createFolderWorkspace({
|
||||
projectGroupId: cascadeGroup.id,
|
||||
name: 'Cascade workspace'
|
||||
})
|
||||
store.setMobileClientTabSelections({
|
||||
'device-a': {
|
||||
[folderWorkspaceKey(directWorkspace.id)]: {
|
||||
activeTabId: 'tab-direct',
|
||||
activeGroupId: null,
|
||||
activeTabIdByGroupId: {}
|
||||
},
|
||||
[folderWorkspaceKey(cascadeWorkspace.id)]: {
|
||||
activeTabId: 'tab-cascade',
|
||||
activeGroupId: null,
|
||||
activeTabIdByGroupId: {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
store.removeFolderWorkspace(directWorkspace.id)
|
||||
store.deleteProjectGroup(cascadeGroup.id)
|
||||
store.flush()
|
||||
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getMobileClientTabSelections()).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('claudeLivePtySessionIds', () => {
|
||||
it('persists added ids across reloads and removes them durably', async () => {
|
||||
const store = await createStore()
|
||||
@@ -10360,6 +10464,22 @@ describe('Store.migrateWorktreeIdentity', () => {
|
||||
expect(store.getWorktreeLineage(CHILD)?.parentWorktreeId).toBe(NEW)
|
||||
})
|
||||
|
||||
it('moves persisted mobile selections across reloads', async () => {
|
||||
const store = await createStore()
|
||||
store.setMobileClientTabSelections({
|
||||
'device-a': {
|
||||
[OLD]: { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} }
|
||||
}
|
||||
})
|
||||
|
||||
store.migrateWorktreeIdentity(OLD, NEW)
|
||||
store.flush()
|
||||
|
||||
expect(store.getMobileClientTabSelections()['device-a']?.[OLD]).toBeUndefined()
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getMobileClientTabSelections()['device-a']?.[NEW]?.activeTabId).toBe('tab-1')
|
||||
})
|
||||
|
||||
it('accumulates prior ids across chained renames', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorktreeMeta(OLD, { displayName: 'Cunner' })
|
||||
|
||||
@@ -48,6 +48,7 @@ import type {
|
||||
ProjectGroup,
|
||||
FolderWorkspace,
|
||||
SparsePreset,
|
||||
PersistedMobileClientTabSelections,
|
||||
WorktreeMeta,
|
||||
WorktreeLineage,
|
||||
WorkspaceLineage,
|
||||
@@ -77,6 +78,7 @@ import {
|
||||
} from '../shared/task-source-context'
|
||||
import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types'
|
||||
import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files'
|
||||
import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence'
|
||||
import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement'
|
||||
import {
|
||||
removeRepoFromHostWorkspaceSessions,
|
||||
@@ -3034,6 +3036,9 @@ export class Store {
|
||||
normalizedProjectGroups
|
||||
),
|
||||
worktreeLineageById: parsed.worktreeLineageById ?? {},
|
||||
mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections(
|
||||
parsed.mobileClientTabSelectionsByDeviceId
|
||||
),
|
||||
workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey(
|
||||
parsed.workspaceLineageByChildKey
|
||||
),
|
||||
@@ -3910,8 +3915,10 @@ export class Store {
|
||||
? { ...repo, projectGroupId: null }
|
||||
: repo
|
||||
)
|
||||
const removedFolderWorkspaceKeys = new Set<string>()
|
||||
for (const workspace of this.state.folderWorkspaces ?? []) {
|
||||
if (deletedGroupIds.has(workspace.projectGroupId)) {
|
||||
removedFolderWorkspaceKeys.add(folderWorkspaceKey(workspace.id))
|
||||
this.state.workspaceSession = removeWorkspaceSessionOwner(
|
||||
this.state.workspaceSession,
|
||||
folderWorkspaceKey(workspace.id)
|
||||
@@ -3922,6 +3929,7 @@ export class Store {
|
||||
this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter(
|
||||
(workspace) => !deletedGroupIds.has(workspace.projectGroupId)
|
||||
)
|
||||
this.pruneMobileClientTabSelections((worktreeId) => removedFolderWorkspaceKeys.has(worktreeId))
|
||||
this.scheduleSave()
|
||||
return true
|
||||
}
|
||||
@@ -4071,6 +4079,7 @@ export class Store {
|
||||
folderWorkspaceKey(id)
|
||||
)!
|
||||
this.removeWorkspaceLineageForFolderParent(id)
|
||||
this.pruneMobileClientTabSelections((worktreeId) => worktreeId === folderWorkspaceKey(id))
|
||||
this.scheduleSave()
|
||||
return true
|
||||
}
|
||||
@@ -4249,6 +4258,22 @@ export class Store {
|
||||
delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey]
|
||||
}
|
||||
}
|
||||
this.pruneMobileClientTabSelections(belongsToHost)
|
||||
}
|
||||
|
||||
private pruneMobileClientTabSelections(matchesWorktreeId: (worktreeId: string) => boolean): void {
|
||||
for (const [clientNavigationId, selectionsByWorktree] of Object.entries(
|
||||
this.state.mobileClientTabSelectionsByDeviceId ?? {}
|
||||
)) {
|
||||
for (const worktreeId of Object.keys(selectionsByWorktree)) {
|
||||
if (matchesWorktreeId(worktreeId)) {
|
||||
delete selectionsByWorktree[worktreeId]
|
||||
}
|
||||
}
|
||||
if (Object.keys(selectionsByWorktree).length === 0) {
|
||||
delete this.state.mobileClientTabSelectionsByDeviceId?.[clientNavigationId]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateRepo(
|
||||
@@ -4488,6 +4513,17 @@ export class Store {
|
||||
|
||||
// ── Sparse Presets ─────────────────────────────────────────────────
|
||||
|
||||
// ── Mobile client tab selections ──────────────────────────────────
|
||||
|
||||
getMobileClientTabSelections(): PersistedMobileClientTabSelections {
|
||||
return this.state.mobileClientTabSelectionsByDeviceId ?? {}
|
||||
}
|
||||
|
||||
setMobileClientTabSelections(next: PersistedMobileClientTabSelections): void {
|
||||
this.state.mobileClientTabSelectionsByDeviceId = next
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
getSparsePresets(repoId: string): SparsePreset[] {
|
||||
return [...(this.state.sparsePresetsByRepo[repoId] ?? [])].sort((left, right) =>
|
||||
left.name.localeCompare(right.name)
|
||||
@@ -5035,6 +5071,11 @@ export class Store {
|
||||
for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) {
|
||||
changed = migrateSession(session) || changed
|
||||
}
|
||||
for (const selectionsByWorktree of Object.values(
|
||||
this.state.mobileClientTabSelectionsByDeviceId ?? {}
|
||||
)) {
|
||||
changed = moveKey(selectionsByWorktree) || changed
|
||||
}
|
||||
const showDotfiles = this.state.ui?.showDotfilesByWorktree
|
||||
if (showDotfiles) {
|
||||
changed = moveKey(showDotfiles) || changed
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { execFileMock, existsSyncMock, stdinEndMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
existsSyncMock: vi.fn(),
|
||||
stdinEndMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
vi.mock('node:fs', () => ({ existsSync: existsSyncMock }))
|
||||
|
||||
import { runMacosLoginSessionPtyProbe } from './macos-login-session-pty-probe'
|
||||
|
||||
type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void
|
||||
|
||||
describe('runMacosLoginSessionPtyProbe', () => {
|
||||
beforeEach(() => {
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
execFileMock.mockReset()
|
||||
stdinEndMock.mockReset()
|
||||
})
|
||||
|
||||
it('runs login under expect-owned PTY and requires its marker plus a clean exit', async () => {
|
||||
const abortController = new AbortController()
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(null, '^D\b\bORCA_LOGIN_PREFLIGHT_OK', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
|
||||
await expect(
|
||||
runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024, abortController.signal)
|
||||
).resolves.toEqual({ ok: true, conclusive: true, reason: 'accepted' })
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'/usr/bin/expect',
|
||||
[
|
||||
'-c',
|
||||
expect.stringContaining(
|
||||
'spawn -noecho /usr/bin/login -flpq $env(ORCA_LOGIN_PROBE_USERNAME)'
|
||||
)
|
||||
],
|
||||
expect.objectContaining({
|
||||
cwd: '/Users/ada',
|
||||
env: expect.objectContaining({ ORCA_LOGIN_PROBE_USERNAME: 'ada' }),
|
||||
killSignal: 'SIGKILL',
|
||||
maxBuffer: 1_024,
|
||||
signal: abortController.signal,
|
||||
timeout: 4_000
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(stdinEndMock).toHaveBeenCalledOnce()
|
||||
expect(execFileMock.mock.calls[0]?.[1]?.[1]).toContain('send "\\004"; expect eof')
|
||||
})
|
||||
|
||||
it('treats a natural exit without the marker as a conclusive rejection', async () => {
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(null, 'Login incorrect\r\nlogin: ', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
|
||||
await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({
|
||||
ok: false,
|
||||
conclusive: true,
|
||||
reason: 'rejected'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a timeout or output overflow inconclusive', async () => {
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({
|
||||
ok: false,
|
||||
conclusive: false,
|
||||
reason: 'timeout'
|
||||
})
|
||||
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(
|
||||
Object.assign(new Error('stdout maxBuffer length exceeded'), {
|
||||
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
|
||||
}),
|
||||
'',
|
||||
''
|
||||
)
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({
|
||||
ok: false,
|
||||
conclusive: false,
|
||||
reason: 'error'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not mistake an expect wrapper failure for a PAM rejection', async () => {
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(Object.assign(new Error('expect Tcl error'), { code: 1 }), '', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
|
||||
await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({
|
||||
ok: false,
|
||||
conclusive: false,
|
||||
reason: 'error'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails safe without spawning when expect is unavailable', async () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
|
||||
await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({
|
||||
ok: false,
|
||||
conclusive: false,
|
||||
reason: 'error'
|
||||
})
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { execFile, type ExecFileException } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
|
||||
const MACOS_EXPECT_PATH = '/usr/bin/expect'
|
||||
const LOGIN_PREFLIGHT_MARKER = 'ORCA_LOGIN_PREFLIGHT_OK'
|
||||
const LOGIN_PROBE_USERNAME_ENV = 'ORCA_LOGIN_PROBE_USERNAME'
|
||||
// Why: expect owns the PTY without adding a long-lived native handle to the daemon.
|
||||
const EXPECT_LOGIN_PROBE_SCRIPT =
|
||||
'log_user 1; ' +
|
||||
'spawn -noecho /usr/bin/login -flpq $env(ORCA_LOGIN_PROBE_USERNAME) /usr/bin/printf ORCA_LOGIN_PREFLIGHT_OK; ' +
|
||||
'send "\\004"; expect eof; wait; exit 0'
|
||||
|
||||
export type LoginPreflightOutcome = {
|
||||
ok: boolean
|
||||
conclusive: boolean
|
||||
reason: 'accepted' | 'rejected' | 'timeout' | 'error'
|
||||
}
|
||||
|
||||
export function classifyLoginPreflightError(error: ExecFileException): LoginPreflightOutcome {
|
||||
// Why: a probe killed by our bound proves nothing about PAM and must not stick.
|
||||
if (error.killed || error.code === 'ETIMEDOUT') {
|
||||
return { ok: false, conclusive: false, reason: 'timeout' }
|
||||
}
|
||||
// Why: a natural nonzero exit is login(1)'s conclusive rejection verdict.
|
||||
if (typeof error.code === 'number') {
|
||||
return { ok: false, conclusive: true, reason: 'rejected' }
|
||||
}
|
||||
return { ok: false, conclusive: false, reason: 'error' }
|
||||
}
|
||||
|
||||
/** Runs the login-session oracle under a real PTY when the pipe probe cannot decide. */
|
||||
export function runMacosLoginSessionPtyProbe(
|
||||
username: string,
|
||||
accountHome: string,
|
||||
timeoutMs: number,
|
||||
maxOutputBytes: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<LoginPreflightOutcome> {
|
||||
if (!existsSync(MACOS_EXPECT_PATH)) {
|
||||
return Promise.resolve({ ok: false, conclusive: false, reason: 'error' })
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const child = execFile(
|
||||
MACOS_EXPECT_PATH,
|
||||
['-c', EXPECT_LOGIN_PROBE_SCRIPT],
|
||||
{
|
||||
cwd: accountHome,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, [LOGIN_PROBE_USERNAME_ENV]: username },
|
||||
killSignal: 'SIGKILL',
|
||||
maxBuffer: maxOutputBytes,
|
||||
signal,
|
||||
timeout: timeoutMs
|
||||
},
|
||||
(error, stdout) => {
|
||||
if (error !== null) {
|
||||
// Why: a nonzero expect exit can be its own Tcl/PTY failure, not PAM authority.
|
||||
resolve(
|
||||
error.killed || error.code === 'ETIMEDOUT'
|
||||
? { ok: false, conclusive: false, reason: 'timeout' }
|
||||
: { ok: false, conclusive: false, reason: 'error' }
|
||||
)
|
||||
return
|
||||
}
|
||||
resolve(
|
||||
stdout.includes(LOGIN_PREFLIGHT_MARKER)
|
||||
? { ok: true, conclusive: true, reason: 'accepted' }
|
||||
: { ok: false, conclusive: true, reason: 'rejected' }
|
||||
)
|
||||
}
|
||||
)
|
||||
child.stdin?.end()
|
||||
} catch {
|
||||
resolve({ ok: false, conclusive: false, reason: 'error' })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,23 +1,34 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as LoginSessionPtyProbe from './macos-login-session-pty-probe'
|
||||
|
||||
const { existsSyncMock, userInfoMock, execFileMock, stdinEndMock } = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
userInfoMock: vi.fn(),
|
||||
execFileMock: vi.fn(),
|
||||
stdinEndMock: vi.fn()
|
||||
}))
|
||||
const { existsSyncMock, userInfoMock, execFileMock, stdinEndMock, ptyProbeMock } = vi.hoisted(
|
||||
() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
userInfoMock: vi.fn(),
|
||||
execFileMock: vi.fn(),
|
||||
stdinEndMock: vi.fn(),
|
||||
ptyProbeMock: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('node:fs', () => ({ existsSync: existsSyncMock }))
|
||||
vi.mock('node:os', () => ({ userInfo: userInfoMock }))
|
||||
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
|
||||
vi.mock('./macos-login-session-pty-probe', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof LoginSessionPtyProbe>()),
|
||||
runMacosLoginSessionPtyProbe: ptyProbeMock
|
||||
}))
|
||||
|
||||
import {
|
||||
prepareMacosTccLoginShell,
|
||||
probeMacosLoginSessionAlive,
|
||||
resetMacosLoginShellPreflightForTests,
|
||||
wrapShellSpawnForMacosTccAttribution
|
||||
} from './macos-tcc-login-shell'
|
||||
|
||||
type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void
|
||||
const ACCEPTED_OUTCOME = { ok: true, conclusive: true, reason: 'accepted' } as const
|
||||
const REJECTED_OUTCOME = { ok: false, conclusive: true, reason: 'rejected' } as const
|
||||
|
||||
describe('wrapShellSpawnForMacosTccAttribution', () => {
|
||||
let origPlatform: PropertyDescriptor | undefined
|
||||
@@ -39,6 +50,7 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
ptyProbeMock.mockResolvedValue({ ok: true, conclusive: true, reason: 'accepted' })
|
||||
resetMacosLoginShellPreflightForTests()
|
||||
})
|
||||
|
||||
@@ -340,3 +352,200 @@ describe('wrapShellSpawnForMacosTccAttribution', () => {
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('probeMacosLoginSessionAlive', () => {
|
||||
let origPlatform: PropertyDescriptor | undefined
|
||||
let origDisable: string | undefined
|
||||
|
||||
function setPlatform(value: string): void {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
origPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
origDisable = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
|
||||
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
|
||||
existsSyncMock.mockReturnValue(true)
|
||||
userInfoMock.mockReturnValue({ username: 'ada', homedir: '/Users/ada' })
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(null, 'ORCA_LOGIN_PREFLIGHT_OK', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
ptyProbeMock.mockResolvedValue({ ok: true, conclusive: true, reason: 'accepted' })
|
||||
resetMacosLoginShellPreflightForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (origPlatform) {
|
||||
Object.defineProperty(process, 'platform', origPlatform)
|
||||
}
|
||||
if (origDisable === undefined) {
|
||||
delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL
|
||||
} else {
|
||||
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = origDisable
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('re-probes even after a cached acceptance', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1)
|
||||
const outcome = await probeMacosLoginSessionAlive()
|
||||
expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' })
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reuses an in-flight startup warmup instead of spawning a duplicate probe', async () => {
|
||||
setPlatform('darwin')
|
||||
let finishPreflight!: ExecFileCallback
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
finishPreflight = callback
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
|
||||
const warmup = prepareMacosTccLoginShell()
|
||||
const freshProbe = probeMacosLoginSessionAlive()
|
||||
expect(execFileMock).toHaveBeenCalledOnce()
|
||||
|
||||
finishPreflight(null, 'ORCA_LOGIN_PREFLIGHT_OK', '')
|
||||
await expect(Promise.all([warmup, freshProbe])).resolves.toEqual([
|
||||
ACCEPTED_OUTCOME,
|
||||
ACCEPTED_OUTCOME
|
||||
])
|
||||
expect(execFileMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not let a spawn-path probe overwrite a newer death verdict', async () => {
|
||||
setPlatform('darwin')
|
||||
const callbacks: ExecFileCallback[] = []
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callbacks.push(callback)
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
|
||||
const freshProbe = probeMacosLoginSessionAlive()
|
||||
const spawnProbe = prepareMacosTccLoginShell()
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
callbacks[0](Object.assign(new Error('login incorrect'), { code: 1 }), '', '')
|
||||
await expect(freshProbe).resolves.toEqual(REJECTED_OUTCOME)
|
||||
callbacks[1](null, 'ORCA_LOGIN_PREFLIGHT_OK', '')
|
||||
await expect(spawnProbe).resolves.toEqual(ACCEPTED_OUTCOME)
|
||||
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
|
||||
})
|
||||
|
||||
it('flips the spawn wrapper off when a fresh probe conclusively rejects (dead login session)', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(Object.assign(new Error('login incorrect'), { code: 1 }), '', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
const outcome = await probeMacosLoginSessionAlive()
|
||||
expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' })
|
||||
// The dead-session daemon must stop minting login(1) prompt zombies (#7936).
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
|
||||
})
|
||||
|
||||
it('does not overwrite the cached verdict on an inconclusive probe', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' })
|
||||
const outcome = await probeMacosLoginSessionAlive()
|
||||
expect(outcome).toEqual({ ok: false, conclusive: false, reason: 'timeout' })
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
})
|
||||
|
||||
it('returns null off macOS and when disabled', async () => {
|
||||
setPlatform('linux')
|
||||
expect(await probeMacosLoginSessionAlive()).toBeNull()
|
||||
setPlatform('darwin')
|
||||
process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1'
|
||||
expect(await probeMacosLoginSessionAlive()).toBeNull()
|
||||
expect(execFileMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('escalates an inconclusive pipe probe to a PTY probe and accepts its verdict', async () => {
|
||||
setPlatform('darwin')
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
const outcome = await probeMacosLoginSessionAlive()
|
||||
expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' })
|
||||
expect(execFileMock).toHaveBeenCalledOnce()
|
||||
expect(ptyProbeMock).toHaveBeenCalledWith('ada', '/Users/ada', 4_000, 1_024, undefined)
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
})
|
||||
|
||||
it('treats a PTY-probe rejection as conclusive and flips the wrapper off', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: true, reason: 'rejected' })
|
||||
const outcome = await probeMacosLoginSessionAlive()
|
||||
expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' })
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh')
|
||||
})
|
||||
|
||||
it('stays inconclusive when both pipe and PTY probes time out', async () => {
|
||||
setPlatform('darwin')
|
||||
await prepareMacosTccLoginShell()
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(Object.assign(new Error('killed'), { killed: true }), '', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' })
|
||||
const outcome = await probeMacosLoginSessionAlive()
|
||||
expect(outcome).toEqual({ ok: false, conclusive: false, reason: 'timeout' })
|
||||
// Inconclusive must not disturb the cached acceptance.
|
||||
expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login')
|
||||
})
|
||||
|
||||
it('does not start a PTY fallback after the watch cancels its pipe probe', async () => {
|
||||
setPlatform('darwin')
|
||||
const abortController = new AbortController()
|
||||
abortController.abort()
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(Object.assign(new Error('aborted'), { code: 'ABORT_ERR' }), '', '')
|
||||
return { stdin: { end: stdinEndMock } }
|
||||
}
|
||||
)
|
||||
|
||||
await expect(probeMacosLoginSessionAlive(abortController.signal)).resolves.toEqual({
|
||||
ok: false,
|
||||
conclusive: false,
|
||||
reason: 'error'
|
||||
})
|
||||
expect(ptyProbeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { execFile, type ExecFileException } from 'node:child_process'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { userInfo } from 'node:os'
|
||||
import {
|
||||
classifyLoginPreflightError,
|
||||
runMacosLoginSessionPtyProbe,
|
||||
type LoginPreflightOutcome
|
||||
} from './macos-login-session-pty-probe'
|
||||
|
||||
export type { LoginPreflightOutcome } from './macos-login-session-pty-probe'
|
||||
|
||||
const MACOS_LOGIN_PATH = '/usr/bin/login'
|
||||
const MACOS_ENV_PATH = '/usr/bin/env'
|
||||
const MACOS_PRINTF_PATH = '/usr/bin/printf'
|
||||
const LOGIN_PREFLIGHT_TIMEOUT_MS = 500
|
||||
// Why: the death-watch probe runs off the spawn path, so it can afford a bound
|
||||
// that outlasts a PAM stack answering slowly rather than misreading it as a hang.
|
||||
const LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS = 4_000
|
||||
const LOGIN_PREFLIGHT_MARKER = 'ORCA_LOGIN_PREFLIGHT_OK'
|
||||
const LOGIN_PREFLIGHT_MAX_BUFFER_BYTES = 1024
|
||||
const LOGIN_PREFLIGHT_RETRY_BASE_MS = 5_000
|
||||
@@ -23,15 +33,11 @@ const DISABLE_ENV_VAR = 'ORCA_DISABLE_MACOS_LOGIN_SHELL'
|
||||
* reject) that may be cached; an inconclusive probe (our own timeout/SIGKILL,
|
||||
* maxBuffer, or spawn error) proves nothing about PAM and must not stick.
|
||||
*/
|
||||
export type LoginPreflightOutcome = {
|
||||
ok: boolean
|
||||
conclusive: boolean
|
||||
reason: 'accepted' | 'rejected' | 'timeout' | 'error'
|
||||
}
|
||||
|
||||
let cachedLoginPreflightResult: boolean | null = null
|
||||
let loginPreflightInFlight: Promise<LoginPreflightOutcome> | null = null
|
||||
let transientLoginPreflightFailure: { failureCount: number; retryAtMs: number } | null = null
|
||||
let loginPreflightCacheEpoch = 0
|
||||
let loginSessionProbeInFlight = false
|
||||
|
||||
function isDisabledByEnv(): boolean {
|
||||
const value = process.env[DISABLE_ENV_VAR]
|
||||
@@ -45,25 +51,15 @@ function loginPreflightRetryDelayMs(failureCount: number): number {
|
||||
)
|
||||
}
|
||||
|
||||
function classifyPreflightError(error: ExecFileException): LoginPreflightOutcome {
|
||||
// Why: our SIGKILL timeout cap (and maxBuffer, which also kills) is an
|
||||
// environmental slow-path, not a PAM verdict — retry, don't cache (F1).
|
||||
if (error.killed || error.code === 'ETIMEDOUT') {
|
||||
return { ok: false, conclusive: false, reason: 'timeout' }
|
||||
}
|
||||
// A numeric exit code means login(1) ran to completion and rejected the user
|
||||
// (it exits immediately on EOF-driven rejection); that verdict is cacheable.
|
||||
if (typeof error.code === 'number') {
|
||||
return { ok: false, conclusive: true, reason: 'rejected' }
|
||||
}
|
||||
// Spawn/EOF/other failure: inconclusive, fail open for this spawn but retry.
|
||||
return { ok: false, conclusive: false, reason: 'error' }
|
||||
}
|
||||
|
||||
// Fidelity limit: the probe runs over pipes while production shells run under a
|
||||
// real PTY, so a tty-sensitive PAM stack could diverge. It fails safe — a probe
|
||||
// pass with a prod failure only degrades to today's direct spawn (no wrapper).
|
||||
function runLoginPreflight(username: string, accountHome: string): Promise<LoginPreflightOutcome> {
|
||||
function runLoginPreflight(
|
||||
username: string,
|
||||
accountHome: string,
|
||||
timeoutMs = LOGIN_PREFLIGHT_TIMEOUT_MS,
|
||||
signal?: AbortSignal
|
||||
): Promise<LoginPreflightOutcome> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const child = execFile(
|
||||
@@ -78,7 +74,8 @@ function runLoginPreflight(username: string, accountHome: string): Promise<Login
|
||||
// captured diagnostics without blocking the PTY host's event loop.
|
||||
killSignal: 'SIGKILL',
|
||||
maxBuffer: LOGIN_PREFLIGHT_MAX_BUFFER_BYTES,
|
||||
timeout: LOGIN_PREFLIGHT_TIMEOUT_MS
|
||||
signal,
|
||||
timeout: timeoutMs
|
||||
},
|
||||
(error, stdout) => {
|
||||
if (error === null) {
|
||||
@@ -91,7 +88,7 @@ function runLoginPreflight(username: string, accountHome: string): Promise<Login
|
||||
)
|
||||
return
|
||||
}
|
||||
resolve(classifyPreflightError(error))
|
||||
resolve(classifyLoginPreflightError(error))
|
||||
}
|
||||
)
|
||||
// Why: login(1) must see immediate EOF, not an interactive pipe, so a PAM
|
||||
@@ -121,15 +118,17 @@ function loginPreflightSucceeds(
|
||||
return Promise.resolve(cached)
|
||||
}
|
||||
if (!loginPreflightInFlight) {
|
||||
const cacheEpoch = loginPreflightCacheEpoch
|
||||
// Why: simultaneous pane restores share one PAM child instead of multiplying
|
||||
// subprocesses at exactly the point terminal startup is already busiest.
|
||||
loginPreflightInFlight = runLoginPreflight(username, accountHome).then((outcome) => {
|
||||
// Why: cache only a conclusive PAM verdict; a killed/timed-out probe is
|
||||
// environmental and must be retried next spawn, not stuck forever (F1).
|
||||
if (outcome.conclusive) {
|
||||
const mayUpdateCache = !loginSessionProbeInFlight && cacheEpoch === loginPreflightCacheEpoch
|
||||
if (outcome.conclusive && mayUpdateCache) {
|
||||
cachedLoginPreflightResult = outcome.ok
|
||||
transientLoginPreflightFailure = null
|
||||
} else {
|
||||
} else if (!outcome.conclusive && mayUpdateCache) {
|
||||
const failureCount = (transientLoginPreflightFailure?.failureCount ?? 0) + 1
|
||||
transientLoginPreflightFailure = {
|
||||
failureCount,
|
||||
@@ -192,6 +191,64 @@ export function resetMacosLoginShellPreflightForTests(): void {
|
||||
cachedLoginPreflightResult = null
|
||||
loginPreflightInFlight = null
|
||||
transientLoginPreflightFailure = null
|
||||
loginPreflightCacheEpoch = 0
|
||||
loginSessionProbeInFlight = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh PAM probe for login-session death detection (#7936): bypasses the
|
||||
* cached verdict and the transient backoff, and writes any conclusive verdict
|
||||
* back into the cache — so a daemon whose login session died stops wrapping
|
||||
* spawns in `login(1)` (which would only mint "Login incorrect" zombies) even
|
||||
* before retirement completes. Escalates to a PTY-hosted probe when the pipe
|
||||
* probe is inconclusive, since a dead session's PAM stack may only misbehave
|
||||
* under a real tty. Returns null when the wrapper doesn't apply.
|
||||
*/
|
||||
export async function probeMacosLoginSessionAlive(
|
||||
signal?: AbortSignal
|
||||
): Promise<LoginPreflightOutcome | null> {
|
||||
if (process.platform !== 'darwin' || isDisabledByEnv() || !existsSync(MACOS_LOGIN_PATH)) {
|
||||
return null
|
||||
}
|
||||
let username: string
|
||||
let accountHome: string
|
||||
try {
|
||||
const account = userInfo()
|
||||
username = account.username
|
||||
accountHome = account.homedir
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!username || !accountHome) {
|
||||
return null
|
||||
}
|
||||
// Why: reuse the startup warmup when present, and fence older spawn-path results from restoring a stale verdict.
|
||||
const existingPreflight = loginPreflightInFlight
|
||||
loginSessionProbeInFlight = true
|
||||
loginPreflightCacheEpoch++
|
||||
let outcome: LoginPreflightOutcome
|
||||
try {
|
||||
outcome = await (existingPreflight ??
|
||||
runLoginPreflight(username, accountHome, LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS, signal))
|
||||
if (!outcome.conclusive && !signal?.aborted) {
|
||||
outcome = await runMacosLoginSessionPtyProbe(
|
||||
username,
|
||||
accountHome,
|
||||
LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS,
|
||||
LOGIN_PREFLIGHT_MAX_BUFFER_BYTES,
|
||||
signal
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
// Why: invalidate spawn probes started during this fresh check before they can overwrite its newer verdict.
|
||||
loginPreflightCacheEpoch++
|
||||
loginSessionProbeInFlight = false
|
||||
}
|
||||
if (outcome.conclusive) {
|
||||
cachedLoginPreflightResult = outcome.ok
|
||||
transientLoginPreflightFailure = null
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
PersistedMobileClientTabSelection,
|
||||
PersistedMobileClientTabSelections
|
||||
} from '../../shared/types'
|
||||
|
||||
function normalizeClientSessionTabSelection(
|
||||
raw: unknown
|
||||
): PersistedMobileClientTabSelection | null {
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||||
return null
|
||||
}
|
||||
const candidate = raw as Partial<PersistedMobileClientTabSelection>
|
||||
const activeTabId = typeof candidate.activeTabId === 'string' ? candidate.activeTabId : null
|
||||
const activeGroupId = typeof candidate.activeGroupId === 'string' ? candidate.activeGroupId : null
|
||||
const activeTabIdByGroupId: Record<string, string> = {}
|
||||
if (
|
||||
typeof candidate.activeTabIdByGroupId === 'object' &&
|
||||
candidate.activeTabIdByGroupId &&
|
||||
!Array.isArray(candidate.activeTabIdByGroupId)
|
||||
) {
|
||||
for (const [groupId, tabId] of Object.entries(candidate.activeTabIdByGroupId)) {
|
||||
if (typeof tabId === 'string') {
|
||||
activeTabIdByGroupId[groupId] = tabId
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!activeTabId && !activeGroupId && Object.keys(activeTabIdByGroupId).length === 0) {
|
||||
return null
|
||||
}
|
||||
return { activeTabId, activeGroupId, activeTabIdByGroupId }
|
||||
}
|
||||
|
||||
// Why: this state comes off disk (and, for remote runtimes, another machine); a bad payload must degrade to "no selection", not throw.
|
||||
export function normalizePersistedMobileClientTabSelections(
|
||||
raw: unknown
|
||||
): PersistedMobileClientTabSelections {
|
||||
const normalized: PersistedMobileClientTabSelections = {}
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||||
return normalized
|
||||
}
|
||||
for (const [clientNavigationId, selectionsByWorktree] of Object.entries(raw)) {
|
||||
if (
|
||||
typeof selectionsByWorktree !== 'object' ||
|
||||
selectionsByWorktree === null ||
|
||||
Array.isArray(selectionsByWorktree)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const entries: Record<string, PersistedMobileClientTabSelection> = {}
|
||||
for (const [worktreeId, selection] of Object.entries(selectionsByWorktree)) {
|
||||
const normalizedSelection = normalizeClientSessionTabSelection(selection)
|
||||
if (normalizedSelection) {
|
||||
entries[worktreeId] = normalizedSelection
|
||||
}
|
||||
}
|
||||
if (Object.keys(entries).length > 0) {
|
||||
normalized[clientNavigationId] = entries
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
|
||||
import type { PersistedMobileClientTabSelections } from '../../shared/types'
|
||||
import {
|
||||
activateClientSessionTabSelection,
|
||||
ClientSessionTabSelectionStore,
|
||||
deriveClientSessionTabSelection,
|
||||
projectClientSessionTabSelection
|
||||
} from './client-session-tab-selection'
|
||||
import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence'
|
||||
|
||||
function snapshot(activeTabId = 'terminal-a::leaf-a'): RuntimeMobileSessionTabsResult {
|
||||
const tabs = [
|
||||
@@ -138,4 +140,147 @@ describe('client session-tab selection', () => {
|
||||
expect(projected.activeGroupId).toBe('group-left')
|
||||
expect(projected.tabs.find((tab) => tab.isActive)?.id).toBe('terminal-a::leaf-a')
|
||||
})
|
||||
|
||||
it('persists activations and restores them across a store rebuild (host restart)', () => {
|
||||
const persisted: PersistedMobileClientTabSelections[] = []
|
||||
const store = new ClientSessionTabSelectionStore()
|
||||
store.setPersistListener((state) => persisted.push(state))
|
||||
|
||||
store.activate(snapshot(), 'device-a', 'browser-unified')
|
||||
|
||||
expect(persisted).toHaveLength(1)
|
||||
expect(persisted[0]?.['device-a']?.['wt-1']?.activeTabId).toBe('browser-unified')
|
||||
|
||||
const restarted = new ClientSessionTabSelectionStore()
|
||||
restarted.hydrate(persisted[0]!)
|
||||
const projected = restarted.project(snapshot(), 'device-a')
|
||||
|
||||
expect(projected.activeTabId).toBe('browser-unified')
|
||||
expect(projected.tabs.find((tab) => tab.isActive)?.id).toBe('browser-unified')
|
||||
expect(restarted.project(snapshot(), 'device-b').activeTabId).toBe('terminal-a::leaf-a')
|
||||
})
|
||||
|
||||
it('persists forgetClient and forgetWorktree removals', () => {
|
||||
const persisted: PersistedMobileClientTabSelections[] = []
|
||||
const store = new ClientSessionTabSelectionStore()
|
||||
store.activate(snapshot(), 'device-a', 'browser-unified')
|
||||
store.setPersistListener((state) => persisted.push(state))
|
||||
|
||||
store.forgetWorktree('wt-1')
|
||||
expect(persisted.at(-1)).toEqual({})
|
||||
|
||||
store.activate(snapshot(), 'device-a', 'browser-unified')
|
||||
store.forgetClient('device-a')
|
||||
expect(persisted.at(-1)).toEqual({})
|
||||
// Why: forgetting state that is already gone must not rewrite the persisted file.
|
||||
const writes = persisted.length
|
||||
store.forgetClient('device-a')
|
||||
store.forgetWorktree('wt-1')
|
||||
expect(persisted.length).toBe(writes)
|
||||
})
|
||||
|
||||
it('moves persisted selections when a worktree identity changes', () => {
|
||||
const persisted: PersistedMobileClientTabSelections[] = []
|
||||
const store = new ClientSessionTabSelectionStore()
|
||||
store.activate(snapshot(), 'device-a', 'browser-unified')
|
||||
store.setPersistListener((state) => persisted.push(state))
|
||||
|
||||
store.migrateWorktree('wt-1', 'wt-renamed')
|
||||
|
||||
expect(persisted).toEqual([
|
||||
{
|
||||
'device-a': {
|
||||
'wt-renamed': {
|
||||
activeTabId: 'browser-unified',
|
||||
activeGroupId: 'group-right',
|
||||
activeTabIdByGroupId: {
|
||||
'group-left': 'terminal-a',
|
||||
'group-right': 'browser-unified'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
expect(store.project({ ...snapshot(), worktree: 'wt-renamed' }, 'device-a').activeTabId).toBe(
|
||||
'browser-unified'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not persist topology-only projections from unrelated worktrees', () => {
|
||||
const persisted: PersistedMobileClientTabSelections[] = []
|
||||
const store = new ClientSessionTabSelectionStore()
|
||||
store.setPersistListener((state) => persisted.push(state))
|
||||
|
||||
store.project({ ...snapshot(), worktree: 'listed-only' }, 'device-a')
|
||||
store.activate(snapshot(), 'device-a', 'browser-unified')
|
||||
|
||||
expect(persisted).toEqual([
|
||||
{
|
||||
'device-a': {
|
||||
'wt-1': {
|
||||
activeTabId: 'browser-unified',
|
||||
activeGroupId: 'group-right',
|
||||
activeTabIdByGroupId: { 'group-right': 'browser-unified' }
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
store.forgetWorktree('listed-only')
|
||||
expect(persisted).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not let an empty snapshot wipe a hydrated selection before tabs arrive', () => {
|
||||
const store = new ClientSessionTabSelectionStore()
|
||||
store.hydrate({
|
||||
'device-a': {
|
||||
'wt-1': { activeTabId: 'browser-unified', activeGroupId: null, activeTabIdByGroupId: {} }
|
||||
}
|
||||
})
|
||||
|
||||
const empty = {
|
||||
...snapshot(),
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabGroups: [],
|
||||
tabs: []
|
||||
}
|
||||
expect(store.project(empty, 'device-a').activeTabId).toBeNull()
|
||||
|
||||
expect(store.project(snapshot(), 'device-a').activeTabId).toBe('browser-unified')
|
||||
})
|
||||
|
||||
it('drops malformed persisted payloads instead of hydrating them', () => {
|
||||
expect(
|
||||
normalizePersistedMobileClientTabSelections({
|
||||
'device-a': {
|
||||
'wt-1': { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: { g: 'tab' } },
|
||||
'wt-bad': { activeTabId: 42, activeGroupId: null, activeTabIdByGroupId: { g: 7 } }
|
||||
},
|
||||
'device-bad': 'nope',
|
||||
'device-empty': {}
|
||||
})
|
||||
).toEqual({
|
||||
'device-a': {
|
||||
'wt-1': { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: { g: 'tab' } }
|
||||
}
|
||||
})
|
||||
expect(normalizePersistedMobileClientTabSelections(null)).toEqual({})
|
||||
expect(normalizePersistedMobileClientTabSelections('garbage')).toEqual({})
|
||||
expect(normalizePersistedMobileClientTabSelections([{ 'wt-1': {} }])).toEqual({})
|
||||
expect(
|
||||
normalizePersistedMobileClientTabSelections({
|
||||
'device-array': [{ activeTabId: 'tab-1' }],
|
||||
'device-selection-array': { 'wt-1': ['tab-1'] },
|
||||
'device-group-array': {
|
||||
'wt-1': { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: ['tab-1'] }
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
'device-group-array': {
|
||||
'wt-1': { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@ import type {
|
||||
RuntimeMobileSessionClientTab,
|
||||
RuntimeMobileSessionTabsResult
|
||||
} from '../../shared/runtime-types'
|
||||
import type { PersistedMobileClientTabSelections } from '../../shared/types'
|
||||
import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence'
|
||||
|
||||
export type ClientSessionTabSelection = {
|
||||
activeTabId: string | null
|
||||
@@ -12,6 +14,8 @@ export type ClientSessionTabSelection = {
|
||||
type StoredClientSessionTabSelection = {
|
||||
selection: ClientSessionTabSelection
|
||||
revision: number
|
||||
// Why: listAll projects every worktree; only hydrated or user-activated selections belong on disk.
|
||||
shouldPersist: boolean
|
||||
}
|
||||
|
||||
function emptyClientSessionTabSelection(): ClientSessionTabSelection {
|
||||
@@ -126,6 +130,43 @@ export function projectClientSessionTabSelection(
|
||||
|
||||
export class ClientSessionTabSelectionStore {
|
||||
private statesByClient = new Map<string, Map<string, StoredClientSessionTabSelection>>()
|
||||
private persistListener: ((state: PersistedMobileClientTabSelections) => void) | null = null
|
||||
|
||||
// Why: selections previously died with the process, so a host restart snapped every phone back to the first tab (deterministic-topology fallback).
|
||||
hydrate(persisted: PersistedMobileClientTabSelections): void {
|
||||
for (const [clientNavigationId, selectionsByWorktree] of Object.entries(
|
||||
normalizePersistedMobileClientTabSelections(persisted)
|
||||
)) {
|
||||
const statesByWorktree = this.getStatesByWorktree(clientNavigationId)
|
||||
for (const [worktreeId, selection] of Object.entries(selectionsByWorktree)) {
|
||||
statesByWorktree.set(worktreeId, { selection, revision: 0, shouldPersist: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setPersistListener(listener: (state: PersistedMobileClientTabSelections) => void): void {
|
||||
this.persistListener = listener
|
||||
}
|
||||
|
||||
serialize(): PersistedMobileClientTabSelections {
|
||||
const persisted: PersistedMobileClientTabSelections = {}
|
||||
for (const [clientNavigationId, statesByWorktree] of this.statesByClient) {
|
||||
const entries: Record<string, ClientSessionTabSelection> = {}
|
||||
for (const [worktreeId, state] of statesByWorktree) {
|
||||
if (state.shouldPersist) {
|
||||
entries[worktreeId] = state.selection
|
||||
}
|
||||
}
|
||||
if (Object.keys(entries).length > 0) {
|
||||
persisted[clientNavigationId] = entries
|
||||
}
|
||||
}
|
||||
return persisted
|
||||
}
|
||||
|
||||
private persistNow(): void {
|
||||
this.persistListener?.(this.serialize())
|
||||
}
|
||||
|
||||
private getStatesByWorktree(
|
||||
clientNavigationId: string
|
||||
@@ -149,12 +190,22 @@ export class ClientSessionTabSelectionStore {
|
||||
const state = statesByWorktree.get(snapshot.worktree) ?? {
|
||||
// Why: host focus is private navigation; a new paired device starts from deterministic topology instead of inheriting it.
|
||||
selection: emptyClientSessionTabSelection(),
|
||||
revision: 0
|
||||
revision: 0,
|
||||
shouldPersist: false
|
||||
}
|
||||
if (snapshot.tabs.length === 0) {
|
||||
// Why: an empty snapshot has no topology to project; writing it back would wipe a restart-hydrated selection before tabs arrive.
|
||||
return {
|
||||
...snapshot,
|
||||
publicationEpoch: `${snapshot.publicationEpoch}:client-navigation`,
|
||||
snapshotVersion: snapshot.snapshotVersion + state.revision
|
||||
}
|
||||
}
|
||||
const projected = projectClientSessionTabSelection(snapshot, state.selection)
|
||||
statesByWorktree.set(snapshot.worktree, {
|
||||
selection: projected.selection,
|
||||
revision: state.revision
|
||||
revision: state.revision,
|
||||
shouldPersist: state.shouldPersist
|
||||
})
|
||||
return {
|
||||
...projected.snapshot,
|
||||
@@ -171,25 +222,60 @@ export class ClientSessionTabSelectionStore {
|
||||
const statesByWorktree = this.getStatesByWorktree(clientNavigationId)
|
||||
const state = statesByWorktree.get(snapshot.worktree) ?? {
|
||||
selection: emptyClientSessionTabSelection(),
|
||||
revision: 0
|
||||
revision: 0,
|
||||
shouldPersist: false
|
||||
}
|
||||
const nextSelection = activateClientSessionTabSelection(snapshot, state.selection, activeTabId)
|
||||
statesByWorktree.set(snapshot.worktree, {
|
||||
selection: activateClientSessionTabSelection(snapshot, state.selection, activeTabId),
|
||||
revision: state.revision + 1
|
||||
selection: nextSelection,
|
||||
revision: state.revision + 1,
|
||||
shouldPersist: true
|
||||
})
|
||||
this.persistNow()
|
||||
return this.project(snapshot, clientNavigationId)
|
||||
}
|
||||
|
||||
forgetClient(clientNavigationId: string): void {
|
||||
this.statesByClient.delete(clientNavigationId)
|
||||
const statesByWorktree = this.statesByClient.get(clientNavigationId)
|
||||
const hadPersistedState = [...(statesByWorktree?.values() ?? [])].some(
|
||||
(state) => state.shouldPersist
|
||||
)
|
||||
if (this.statesByClient.delete(clientNavigationId) && hadPersistedState) {
|
||||
this.persistNow()
|
||||
}
|
||||
}
|
||||
|
||||
migrateWorktree(oldWorktreeId: string, newWorktreeId: string): void {
|
||||
if (oldWorktreeId === newWorktreeId) {
|
||||
return
|
||||
}
|
||||
let changed = false
|
||||
for (const statesByWorktree of this.statesByClient.values()) {
|
||||
const state = statesByWorktree.get(oldWorktreeId)
|
||||
if (!state) {
|
||||
continue
|
||||
}
|
||||
statesByWorktree.set(newWorktreeId, state)
|
||||
statesByWorktree.delete(oldWorktreeId)
|
||||
changed = state.shouldPersist || changed
|
||||
}
|
||||
if (changed) {
|
||||
this.persistNow()
|
||||
}
|
||||
}
|
||||
|
||||
forgetWorktree(worktreeId: string): void {
|
||||
let changed = false
|
||||
for (const [clientNavigationId, statesByWorktree] of this.statesByClient) {
|
||||
const state = statesByWorktree.get(worktreeId)
|
||||
changed = Boolean(state?.shouldPersist) || changed
|
||||
statesByWorktree.delete(worktreeId)
|
||||
if (statesByWorktree.size === 0) {
|
||||
this.statesByClient.delete(clientNavigationId)
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this.persistNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import WebSocket from 'ws'
|
||||
import { parsePairingCode } from '../../shared/pairing'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
|
||||
import type { PersistedMobileClientTabSelections } from '../../shared/types'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto'
|
||||
import { OrcaRuntimeRpcServer } from './runtime-rpc'
|
||||
@@ -578,4 +579,43 @@ describe('paired runtime navigation isolation', () => {
|
||||
expect(serverOne.hostSelections.tabId).toBe('host-tab')
|
||||
expect(serverTwo.hostSelections.tabId).toBe('host-tab')
|
||||
})
|
||||
|
||||
it('restores a device tab selection after a runtime restart', async () => {
|
||||
const persisted: { state: PersistedMobileClientTabSelections } = { state: {} }
|
||||
const makeStoreWithSelections = () => ({
|
||||
...makeStore(),
|
||||
getMobileClientTabSelections: () => persisted.state,
|
||||
setMobileClientTabSelections: (next: PersistedMobileClientTabSelections) => {
|
||||
persisted.state = next
|
||||
}
|
||||
})
|
||||
|
||||
const first = new OrcaRuntimeService(makeStoreWithSelections() as never)
|
||||
first.attachWindow(1)
|
||||
first.markGraphReady(1)
|
||||
seedSessionTabs(first)
|
||||
await first.activateMobileSessionTab(`id:${SESSION_WORKTREE_ID}`, 'client-a-tab', undefined, {
|
||||
notifyClients: false,
|
||||
clientNavigationId: 'device-a',
|
||||
navigation: 'caller'
|
||||
})
|
||||
expect(persisted.state['device-a']?.[SESSION_WORKTREE_ID]?.activeTabId).toBe('client-a-tab')
|
||||
|
||||
const restarted = new OrcaRuntimeService(makeStoreWithSelections() as never)
|
||||
restarted.attachWindow(1)
|
||||
restarted.markGraphReady(1)
|
||||
seedSessionTabs(restarted)
|
||||
const remembered = await restarted.listMobileSessionTabs(
|
||||
`id:${SESSION_WORKTREE_ID}`,
|
||||
'device-a'
|
||||
)
|
||||
expect(remembered.activeTabId).toBe('client-a-tab')
|
||||
expect(remembered.tabs.find((tab) => tab.isActive)?.id).toBe('client-a-tab')
|
||||
// Why: an unknown device must still start from deterministic topology, not inherit another device's restored state.
|
||||
const freshDevice = await restarted.listMobileSessionTabs(
|
||||
`id:${SESSION_WORKTREE_ID}`,
|
||||
'device-b'
|
||||
)
|
||||
expect(freshDevice.activeTabId).toBe('host-tab')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join, win32 } from 'node:path'
|
||||
import { basename, join, win32 } from 'node:path'
|
||||
import { ipcMain } from 'electron'
|
||||
import type {
|
||||
FolderWorkspace,
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
OrcaRuntimeService,
|
||||
recentTerminalPathCandidatesIncludePath,
|
||||
recentTerminalOutputIncludesPath,
|
||||
resolveWorktreeScanCacheTtlMs,
|
||||
type RuntimeTerminalAgentStatusEvent
|
||||
} from './orca-runtime'
|
||||
import { RecentPtyOutputBuffer } from './recent-pty-output-buffer'
|
||||
@@ -24853,19 +24854,34 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('emits only instance-validated lineage parents in mobile summaries', async () => {
|
||||
it('emits only instance- and boundary-validated lineage parents in mobile summaries', async () => {
|
||||
// Regression: shipped mobile clients trust parentWorktreeId blindly, so worktree.ps must not emit stale same-path lineage.
|
||||
const parentPath = '/tmp/worktree-parent'
|
||||
const validChildPath = '/tmp/worktree-child-valid'
|
||||
const staleChildPath = '/tmp/worktree-child-stale'
|
||||
const parentPath = join(tmpdir(), 'worktree-parent')
|
||||
const validChildPath = join(tmpdir(), 'worktree-child-valid')
|
||||
const staleChildPath = join(tmpdir(), 'worktree-child-stale')
|
||||
const crossHostChildPath = join(tmpdir(), 'worktree-child-cross-host')
|
||||
const parentId = `${TEST_REPO_ID}::${parentPath}`
|
||||
const validChildId = `${TEST_REPO_ID}::${validChildPath}`
|
||||
const staleChildId = `${TEST_REPO_ID}::${staleChildPath}`
|
||||
const crossHostChildId = `${TEST_REPO_ID}::${crossHostChildPath}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }),
|
||||
[validChildId]: makeWorktreeMeta({ instanceId: 'child-instance' }),
|
||||
[parentId]: makeWorktreeMeta({
|
||||
instanceId: 'parent-instance',
|
||||
hostId: 'local',
|
||||
projectId: 'project-a'
|
||||
}),
|
||||
[validChildId]: makeWorktreeMeta({
|
||||
instanceId: 'child-instance',
|
||||
hostId: 'local',
|
||||
projectId: 'project-a'
|
||||
}),
|
||||
// The stale child path was reused by a replacement checkout.
|
||||
[staleChildId]: makeWorktreeMeta({ instanceId: 'replacement-instance' })
|
||||
[staleChildId]: makeWorktreeMeta({ instanceId: 'replacement-instance' }),
|
||||
[crossHostChildId]: makeWorktreeMeta({
|
||||
instanceId: 'cross-host-child-instance',
|
||||
hostId: 'runtime:other-host',
|
||||
projectId: 'project-a'
|
||||
})
|
||||
}
|
||||
const makeLineage = (childId: string, worktreeInstanceId: string): WorktreeLineage => ({
|
||||
worktreeId: childId,
|
||||
@@ -24878,7 +24894,8 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
const lineageById: Record<string, WorktreeLineage> = {
|
||||
[validChildId]: makeLineage(validChildId, 'child-instance'),
|
||||
[staleChildId]: makeLineage(staleChildId, 'old-child-instance')
|
||||
[staleChildId]: makeLineage(staleChildId, 'old-child-instance'),
|
||||
[crossHostChildId]: makeLineage(crossHostChildId, 'cross-host-child-instance')
|
||||
}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
@@ -24892,10 +24909,10 @@ describe('OrcaRuntimeService', () => {
|
||||
getWorktreeLineage: (worktreeId: string) => lineageById[worktreeId]
|
||||
}
|
||||
vi.mocked(listWorktrees).mockResolvedValue(
|
||||
[parentPath, validChildPath, staleChildPath].map((path) => ({
|
||||
[parentPath, validChildPath, staleChildPath, crossHostChildPath].map((path) => ({
|
||||
path,
|
||||
head: 'abc',
|
||||
branch: `feature/${path.split('/').pop()}`,
|
||||
branch: `feature/${basename(path)}`,
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}))
|
||||
@@ -24917,6 +24934,13 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
expect(staleSummary?.lineageWorktreeInstanceId).toBeUndefined()
|
||||
expect(staleSummary?.parentWorktreeInstanceId).toBeUndefined()
|
||||
const crossHostSummary = worktrees.find((worktree) => worktree.worktreeId === crossHostChildId)
|
||||
expect(crossHostSummary).toMatchObject({
|
||||
parentWorktreeId: null,
|
||||
worktreeInstanceId: 'cross-host-child-instance'
|
||||
})
|
||||
expect(crossHostSummary?.lineageWorktreeInstanceId).toBeUndefined()
|
||||
expect(crossHostSummary?.parentWorktreeInstanceId).toBeUndefined()
|
||||
expect(worktrees.find((worktree) => worktree.worktreeId === parentId)).toMatchObject({
|
||||
childWorktreeIds: [validChildId]
|
||||
})
|
||||
@@ -28050,6 +28074,80 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
boundary: 'repository',
|
||||
childRepoId: 'repo-child',
|
||||
parentRepoId: 'repo-parent',
|
||||
childMeta: {},
|
||||
parentMeta: {}
|
||||
},
|
||||
{
|
||||
boundary: 'known host',
|
||||
childRepoId: TEST_REPO_ID,
|
||||
parentRepoId: TEST_REPO_ID,
|
||||
childMeta: { hostId: 'runtime:child-host' as const },
|
||||
parentMeta: { hostId: 'runtime:parent-host' as const }
|
||||
},
|
||||
{
|
||||
boundary: 'known project',
|
||||
childRepoId: TEST_REPO_ID,
|
||||
parentRepoId: TEST_REPO_ID,
|
||||
childMeta: { projectId: 'project-child' },
|
||||
parentMeta: { projectId: 'project-parent' }
|
||||
}
|
||||
])('rejects manual lineage writes across a $boundary boundary', async (scenario) => {
|
||||
const repos = [...new Set([scenario.childRepoId, scenario.parentRepoId])].map((id) => ({
|
||||
id,
|
||||
path: join(tmpdir(), id),
|
||||
displayName: id,
|
||||
badgeColor: 'blue' as const,
|
||||
addedAt: 1
|
||||
}))
|
||||
const childRepoPath = repos.find((repo) => repo.id === scenario.childRepoId)!.path
|
||||
const parentRepoPath = repos.find((repo) => repo.id === scenario.parentRepoId)!.path
|
||||
const childPath = join(childRepoPath, 'child')
|
||||
const parentPath = join(parentRepoPath, 'parent')
|
||||
const childId = `${scenario.childRepoId}::${childPath}`
|
||||
const parentId = `${scenario.parentRepoId}::${parentPath}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[childId]: makeWorktreeMeta({ instanceId: 'child-instance', ...scenario.childMeta }),
|
||||
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance', ...scenario.parentMeta })
|
||||
}
|
||||
const setWorktreeLineage = vi.fn()
|
||||
const setWorkspaceLineage = vi.fn()
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => repos,
|
||||
getRepo: (id: string) => repos.find((repo) => repo.id === id),
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
metaById[worktreeId] = { ...metaById[worktreeId], ...meta }
|
||||
return metaById[worktreeId]
|
||||
},
|
||||
getWorktreeLineage: () => undefined,
|
||||
setWorktreeLineage,
|
||||
setWorkspaceLineage
|
||||
}
|
||||
vi.mocked(listWorktrees).mockImplementation(async (repoPath) => [
|
||||
...(repoPath === childRepoPath ? [makeWorktreeInfo(childPath)] : []),
|
||||
...(repoPath === parentRepoPath ? [makeWorktreeInfo(parentPath)] : [])
|
||||
])
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
await expect(
|
||||
runtime.updateManagedWorktreeMeta(`id:${childId}`, {
|
||||
lineage: { parentWorktree: `id:${parentId}` }
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Parent worktree must belong to the same repository, execution host, and project.'
|
||||
)
|
||||
|
||||
expect(setWorktreeLineage).not.toHaveBeenCalled()
|
||||
expect(setWorkspaceLineage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears workspace lineage when manually removing a parent', async () => {
|
||||
const childPath = '/tmp/worktree-child'
|
||||
const childId = `${TEST_REPO_ID}::${childPath}`
|
||||
@@ -28376,6 +28474,110 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(removeWorktreeLineage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hydrates runtime detected lists with instance-validated legacy lineage', async () => {
|
||||
const parentPath = join(tmpdir(), 'worktree-parent')
|
||||
const childPath = join(tmpdir(), 'worktree-child')
|
||||
const parentId = `${TEST_REPO_ID}::${parentPath}`
|
||||
const childId = `${TEST_REPO_ID}::${childPath}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }),
|
||||
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' })
|
||||
}
|
||||
const lineageById: Record<string, WorktreeLineage> = {
|
||||
[childId]: {
|
||||
worktreeId: childId,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'cli',
|
||||
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
getAllWorktreeLineage: () => lineageById
|
||||
} as never)
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
makeWorktreeInfo(childPath),
|
||||
makeWorktreeInfo(parentPath)
|
||||
])
|
||||
|
||||
const result = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`)
|
||||
|
||||
expect(result.worktrees).toEqual([
|
||||
expect.objectContaining({
|
||||
id: childId,
|
||||
parentWorktreeId: parentId,
|
||||
lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' })
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: parentId,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [childId],
|
||||
lineage: null
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('hydrates folder-repo detected rows with instance-validated legacy lineage', async () => {
|
||||
const folderRepo = {
|
||||
id: 'folder-repo',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: 'blue' as const,
|
||||
addedAt: 1,
|
||||
kind: 'folder' as const
|
||||
}
|
||||
const parentId = `${folderRepo.id}::${folderRepo.path}`
|
||||
const childId = `${parentId}::workspace:child-instance`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }),
|
||||
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' })
|
||||
}
|
||||
const lineageById: Record<string, WorktreeLineage> = {
|
||||
[childId]: {
|
||||
worktreeId: childId,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'cli',
|
||||
capture: { source: 'explicit-cli-flag', confidence: 'explicit' },
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getRepos: () => [folderRepo],
|
||||
getRepo: (id: string) => (id === folderRepo.id ? folderRepo : undefined),
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta }
|
||||
return metaById[worktreeId]
|
||||
},
|
||||
getAllWorktreeLineage: () => lineageById
|
||||
} as never)
|
||||
|
||||
const result = await runtime.listDetectedManagedWorktrees(`id:${folderRepo.id}`)
|
||||
|
||||
expect(result.worktrees).toEqual([
|
||||
expect.objectContaining({
|
||||
id: parentId,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [childId],
|
||||
lineage: null
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: childId,
|
||||
parentWorktreeId: parentId,
|
||||
lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' })
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('hides agent scratch created inside a linked checkout from runtime listings', async () => {
|
||||
const linkedCheckoutPath = '/tmp/worktree-a'
|
||||
const scratchPath = `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa`
|
||||
@@ -34979,3 +35181,83 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveWorktreeScanCacheTtlMs', () => {
|
||||
const BASE_TTL_MS = 30_000
|
||||
const SCRATCH_TTL_MS = 5 * 60_000
|
||||
|
||||
it('keeps the base TTL for ordinary local repos', () => {
|
||||
expect(
|
||||
resolveWorktreeScanCacheTtlMs({ path: '/Users/dev/projects/app', connectionId: '' })
|
||||
).toBe(BASE_TTL_MS)
|
||||
})
|
||||
|
||||
it('extends the TTL for agent-scratch repo roots', () => {
|
||||
expect(
|
||||
resolveWorktreeScanCacheTtlMs({
|
||||
path: '/Users/dev/.codex-tmp/foragent-capsule-b1-repo-zP9Az6',
|
||||
connectionId: ''
|
||||
})
|
||||
).toBe(SCRATCH_TTL_MS)
|
||||
expect(
|
||||
resolveWorktreeScanCacheTtlMs({
|
||||
path: '/Users/dev/.claude/skills/obsidian-second-brain',
|
||||
connectionId: ''
|
||||
})
|
||||
).toBe(SCRATCH_TTL_MS)
|
||||
})
|
||||
|
||||
it('never extends the TTL for SSH repos', () => {
|
||||
// Why: scratch classification reads local path conventions; a remote path
|
||||
// that merely looks similar must keep normal freshness.
|
||||
expect(
|
||||
resolveWorktreeScanCacheTtlMs({
|
||||
path: '/home/dev/.codex-tmp/capsule',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
).toBe(BASE_TTL_MS)
|
||||
})
|
||||
|
||||
it('keeps a scratch repo scan cached past the base TTL while normal repos rescan', async () => {
|
||||
// Why: the whole fix lives in the cache-stamp call site; pin the wiring so
|
||||
// a revert to the flat TTL fails CI, not just the pure-function tests.
|
||||
vi.useFakeTimers()
|
||||
// Why: the shared listWorktrees stub keeps call history across this file's
|
||||
// tests; absolute counts need a clean baseline.
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
try {
|
||||
const scratchPath = '/tmp/.codex-tmp/capsule-a'
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getRepos: () => [
|
||||
{ id: 'repo-1', path: '/tmp/repo', displayName: 'repo', badgeColor: 'blue', addedAt: 1 },
|
||||
{
|
||||
id: 'repo-scratch',
|
||||
path: scratchPath,
|
||||
displayName: 'capsule',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1
|
||||
}
|
||||
]
|
||||
} as never)
|
||||
const internals = runtime as unknown as { listResolvedWorktrees: () => Promise<unknown> }
|
||||
const scanCallsFor = (path: string): number =>
|
||||
vi.mocked(listWorktrees).mock.calls.filter((call) => call[0] === path).length
|
||||
|
||||
await internals.listResolvedWorktrees()
|
||||
expect(scanCallsFor('/tmp/repo')).toBe(1)
|
||||
expect(scanCallsFor(scratchPath)).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(BASE_TTL_MS + 1_000)
|
||||
await internals.listResolvedWorktrees()
|
||||
expect(scanCallsFor('/tmp/repo')).toBe(2)
|
||||
expect(scanCallsFor(scratchPath)).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(SCRATCH_TTL_MS)
|
||||
await internals.listResolvedWorktrees()
|
||||
expect(scanCallsFor(scratchPath)).toBe(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -301,6 +301,10 @@ import {
|
||||
parseWorkspaceKey,
|
||||
worktreeWorkspaceKey
|
||||
} from '../../shared/workspace-scope'
|
||||
import {
|
||||
projectResolvedWorktreeLineage,
|
||||
sharesResolvedWorktreeLineageBoundary
|
||||
} from '../../shared/resolved-worktree-lineage'
|
||||
import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree'
|
||||
import type {
|
||||
FolderWorkspacePathStatus,
|
||||
@@ -314,6 +318,7 @@ import {
|
||||
} from '../../shared/worktree-ownership'
|
||||
import {
|
||||
createAgentScratchWorktreePathMatcher,
|
||||
isAgentScratchRepoRootPath,
|
||||
type AgentScratchWorktreePathMatcher
|
||||
} from '../../shared/agent-scratch-worktrees'
|
||||
import {
|
||||
@@ -952,6 +957,8 @@ type RuntimeStore = {
|
||||
deleteAutomation?: Store['deleteAutomation']
|
||||
getSparsePresets?: Store['getSparsePresets']
|
||||
saveSparsePreset?: Store['saveSparsePreset']
|
||||
getMobileClientTabSelections?: Store['getMobileClientTabSelections']
|
||||
setMobileClientTabSelections?: Store['setMobileClientTabSelections']
|
||||
getSettings(): {
|
||||
workspaceDir: string
|
||||
nestWorkspaces: boolean
|
||||
@@ -2879,6 +2886,14 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
) {
|
||||
this.store = store
|
||||
// Why: per-device tab selections must survive host restarts, or every phone snaps back to the first tab on return.
|
||||
const persistedClientTabSelections = store?.getMobileClientTabSelections?.()
|
||||
if (persistedClientTabSelections) {
|
||||
this.clientSessionTabSelections.hydrate(persistedClientTabSelections)
|
||||
}
|
||||
this.clientSessionTabSelections.setPersistListener((state) => {
|
||||
this.store?.setMobileClientTabSelections?.(state)
|
||||
})
|
||||
if (stats) {
|
||||
this.stats = stats
|
||||
this.agentDetector = new AgentDetector(stats)
|
||||
@@ -16544,13 +16559,15 @@ export class OrcaRuntimeService {
|
||||
|
||||
async listDetectedManagedWorktrees(repoSelector: string): Promise<DetectedWorktreeListResult> {
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
const store = this.requireStore()
|
||||
if (isFolderRepo(repo)) {
|
||||
const worktrees = listRuntimeFolderWorkspaces(this.requireStore(), repo)
|
||||
const worktrees = listRuntimeFolderWorkspaces(store, repo)
|
||||
const detected = worktrees.map((worktree) => this.toRuntimeDetectedWorktree(repo, worktree))
|
||||
return {
|
||||
repoId: repo.id,
|
||||
authoritative: true,
|
||||
source: 'git',
|
||||
worktrees: worktrees.map((worktree) => this.toRuntimeDetectedWorktree(repo, worktree))
|
||||
worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
|
||||
}
|
||||
}
|
||||
let scan: RuntimeWorktreeScanResult
|
||||
@@ -16568,7 +16585,7 @@ export class OrcaRuntimeService {
|
||||
])
|
||||
const detected = scan.worktrees.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
const meta = this.store?.getWorktreeMeta(worktreeId)
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||||
const detectedWorktree = this.toRuntimeDetectedWorktree(
|
||||
repo,
|
||||
@@ -16584,7 +16601,7 @@ export class OrcaRuntimeService {
|
||||
repoId: repo.id,
|
||||
authoritative: scan.ok,
|
||||
source: scan.ok ? 'git' : 'metadata-fallback',
|
||||
worktrees: detected
|
||||
worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23141,6 +23158,12 @@ export class OrcaRuntimeService {
|
||||
if (childWorktreeId === parentWorktreeId) {
|
||||
throw new RuntimeLineageError('LINEAGE_PARENT_CYCLE', 'A worktree cannot parent itself.')
|
||||
}
|
||||
if (!sharesResolvedWorktreeLineageBoundary(child, parent)) {
|
||||
throw new RuntimeLineageError(
|
||||
'LINEAGE_PARENT_CONTEXT_CONFLICT',
|
||||
'Parent worktree must belong to the same repository, execution host, and project.'
|
||||
)
|
||||
}
|
||||
const instanceByWorktreeId = new Map(
|
||||
this.resolvedWorktreeCache?.worktrees.map((worktree) => [
|
||||
worktree.id,
|
||||
@@ -23711,7 +23734,10 @@ export class OrcaRuntimeService {
|
||||
})
|
||||
})
|
||||
)
|
||||
const worktrees = this.attachLineageToResolvedWorktrees(perRepoWorktrees.flat())
|
||||
const worktrees = projectResolvedWorktreeLineage(
|
||||
perRepoWorktrees.flat(),
|
||||
this.store?.getAllWorktreeLineage?.() ?? {}
|
||||
)
|
||||
// Why: short TTL avoids shelling out on every frequent poll while still catching worktree changes made outside Orca.
|
||||
if (generation === this.resolvedWorktreeGeneration) {
|
||||
this.resolvedWorktreeCache = {
|
||||
@@ -23723,41 +23749,6 @@ export class OrcaRuntimeService {
|
||||
return { worktrees, platformByRepoId }
|
||||
}
|
||||
|
||||
private attachLineageToResolvedWorktrees(worktrees: ResolvedWorktree[]): ResolvedWorktree[] {
|
||||
const lineageById = this.store?.getAllWorktreeLineage?.() ?? {}
|
||||
const worktreeById = new Map(worktrees.map((worktree) => [worktree.id, worktree]))
|
||||
const validLineageByChildId = new Map<string, WorktreeLineage>()
|
||||
const childIdsByParentId = new Map<string, string[]>()
|
||||
|
||||
for (const [childId, lineage] of Object.entries(lineageById)) {
|
||||
const child = worktreeById.get(childId)
|
||||
const parent = worktreeById.get(lineage.parentWorktreeId)
|
||||
if (
|
||||
!child ||
|
||||
!parent ||
|
||||
child.instanceId !== lineage.worktreeInstanceId ||
|
||||
parent.instanceId !== lineage.parentWorktreeInstanceId
|
||||
) {
|
||||
// Why: worktree IDs are path-derived, so instance checks keep replacement checkouts off stale same-path lineage.
|
||||
continue
|
||||
}
|
||||
validLineageByChildId.set(childId, lineage)
|
||||
const children = childIdsByParentId.get(lineage.parentWorktreeId) ?? []
|
||||
children.push(childId)
|
||||
childIdsByParentId.set(lineage.parentWorktreeId, children)
|
||||
}
|
||||
|
||||
return worktrees.map((worktree) => {
|
||||
const lineage = validLineageByChildId.get(worktree.id) ?? null
|
||||
return {
|
||||
...worktree,
|
||||
parentWorktreeId: lineage?.parentWorktreeId ?? null,
|
||||
childWorktreeIds: childIdsByParentId.get(worktree.id) ?? [],
|
||||
lineage
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private pruneLineageForMissingRepoWorktrees(repo: Repo, gitWorktrees: GitWorktreeInfo[]): void {
|
||||
const store = this.store
|
||||
if (
|
||||
@@ -23843,7 +23834,7 @@ export class OrcaRuntimeService {
|
||||
generation,
|
||||
runtimeKey,
|
||||
result,
|
||||
expiresAt: Date.now() + WORKTREE_SCAN_CACHE_TTL_MS
|
||||
expiresAt: Date.now() + resolveWorktreeScanCacheTtlMs(repo)
|
||||
})
|
||||
}
|
||||
return result
|
||||
@@ -23946,6 +23937,7 @@ export class OrcaRuntimeService {
|
||||
|
||||
/** Like {@link notifyBranchRenamed} but carries old->new worktree id so the renderer re-keys instead of treating the id change as a deletion. */
|
||||
notifyWorktreeFolderRenamed(repoId: string, oldWorktreeId: string, newWorktreeId: string): void {
|
||||
this.clientSessionTabSelections.migrateWorktree(oldWorktreeId, newWorktreeId)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.invalidateWorktreeScanCacheForRepo(repoId)
|
||||
this.notifier?.worktreesChanged(repoId, { oldWorktreeId, newWorktreeId })
|
||||
@@ -29242,7 +29234,17 @@ const DEFAULT_WORKTREE_PS_LIMIT = 200
|
||||
const DISCONNECTED_PTY_RECORD_MAX = 128
|
||||
const RESOLVED_WORKTREE_CACHE_TTL_MS = 1000
|
||||
const WORKTREE_SCAN_CACHE_TTL_MS = 30_000
|
||||
// Why: agent-scratch repos don't need 30s freshness — the steady-state scan
|
||||
// fan-out was measured at ~128 git execs/min on real installs, mostly against
|
||||
// these (crash-cluster diagnostics, 2026-07).
|
||||
const WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS = 5 * 60_000
|
||||
const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000
|
||||
|
||||
export function resolveWorktreeScanCacheTtlMs(repo: Pick<Repo, 'path' | 'connectionId'>): number {
|
||||
return !repo.connectionId && isAgentScratchRepoRootPath(repo.path)
|
||||
? WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS
|
||||
: WORKTREE_SCAN_CACHE_TTL_MS
|
||||
}
|
||||
const PTY_CONTROLLER_LIST_TIMEOUT_MS = 3000
|
||||
// Why: the renderer waits 15s; leave room for the verified failure response and release the spawn fence before its caller times out.
|
||||
const WORKTREE_TERMINAL_SLEEP_TIMEOUT_MS = 12_000
|
||||
|
||||
@@ -113,6 +113,26 @@ describe('E2EEChannel', () => {
|
||||
expect(ctx.onReady).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an auth frame encrypted to a stale desktop key', () => {
|
||||
const ctx = setup()
|
||||
ctx.channel.handleRawMessage(
|
||||
JSON.stringify({
|
||||
type: 'e2ee_hello',
|
||||
publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey)
|
||||
})
|
||||
)
|
||||
const staleServer = generateKeyPair()
|
||||
const staleSharedKey = deriveSharedKey(ctx.clientKeys.secretKey, staleServer.publicKey)
|
||||
|
||||
ctx.channel.handleRawMessage(
|
||||
encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), staleSharedKey)
|
||||
)
|
||||
|
||||
expect(ctx.onError).toHaveBeenCalledOnce()
|
||||
expect(ctx.onError).toHaveBeenCalledWith(4001, 'Unauthorized')
|
||||
expect(ctx.onReady).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects malformed JSON', () => {
|
||||
const ctx = setup()
|
||||
ctx.channel.handleRawMessage('not json')
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// Why: the E2EE channel sits between the WebSocket transport and the RPC handler.
|
||||
// It owns the handshake state machine and transparent encrypt/decrypt so the RPC
|
||||
// handler only sees plaintext JSON, identical to the Unix socket path.
|
||||
// Why: this channel keeps E2EE framing out of RPC handlers, which consume plaintext across transports.
|
||||
import type { WebSocket } from 'ws'
|
||||
import { deriveSharedKey, encrypt, decrypt, encryptBytes, decryptBytes } from './e2ee-crypto'
|
||||
import {
|
||||
@@ -53,9 +51,7 @@ export class E2EEChannel {
|
||||
private readonly requireV2: boolean
|
||||
private v2Session: DesktopMobileE2EEV2Session | null = null
|
||||
private v2OutboundQueue: WsOutboundBackpressureQueue<V2OutboundItem> | null = null
|
||||
// Why: the RPC handler is set after the channel is ready, so the channel
|
||||
// can forward decrypted messages. Kept as a callback rather than constructor
|
||||
// param because the handler needs the encrypt function for replies.
|
||||
// Why: the handler is set after readiness because its reply closure needs this channel's encryption state.
|
||||
private messageHandler:
|
||||
| ((
|
||||
plaintext: string,
|
||||
@@ -173,8 +169,10 @@ export class E2EEChannel {
|
||||
}
|
||||
|
||||
private trackDecryptFailure(): void {
|
||||
this.consecutiveFailures++
|
||||
if (this.consecutiveFailures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) {
|
||||
// Why: a wrong key cannot recover on this socket; close so the client uses its bounded auth retry budget.
|
||||
if (this.state === 'awaiting_auth') {
|
||||
this.onError(4001, 'Unauthorized')
|
||||
} else if (++this.consecutiveFailures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) {
|
||||
this.onError(4003, 'Too many decryption failures')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,97 @@ describe('MobileSocketWiring', () => {
|
||||
expect(wiring.connectionCount).toBe(0)
|
||||
})
|
||||
|
||||
it('closes an unknown-token socket even when reporting the failure throws', () => {
|
||||
const desktop = generateKeyPair()
|
||||
const phone = generateKeyPair()
|
||||
const ws = new FakeSocket()
|
||||
const transport = new FakeTransport()
|
||||
const notificationError = new Error('renderer exited')
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const onUnpairedDeviceAuthFailure = vi.fn(() => {
|
||||
throw notificationError
|
||||
})
|
||||
const wiring = new MobileSocketWiring({
|
||||
deviceRegistry: registryFor('device-1', 'valid-token'),
|
||||
e2eeKeypair: {
|
||||
publicKey: desktop.publicKey,
|
||||
secretKey: desktop.secretKey,
|
||||
publicKeyB64: Buffer.from(desktop.publicKey).toString('base64')
|
||||
},
|
||||
onText: vi.fn(),
|
||||
onBinary: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onUnpairedDeviceAuthFailure
|
||||
})
|
||||
wiring.attachTransport(transport)
|
||||
|
||||
transport.receive(
|
||||
ws,
|
||||
JSON.stringify({
|
||||
type: 'e2ee_hello',
|
||||
publicKeyB64: Buffer.from(phone.publicKey).toString('base64')
|
||||
})
|
||||
)
|
||||
const sharedKey = deriveSharedKey(phone.secretKey, desktop.publicKey)
|
||||
expect(() =>
|
||||
transport.receive(
|
||||
ws,
|
||||
encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'stale-token' }), sharedKey)
|
||||
)
|
||||
).not.toThrow()
|
||||
|
||||
expect(onUnpairedDeviceAuthFailure).toHaveBeenCalledOnce()
|
||||
expect(onUnpairedDeviceAuthFailure).toHaveBeenCalledWith({ transport: 'direct' })
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[mobile] Failed to report unpaired-device auth failure:',
|
||||
notificationError
|
||||
)
|
||||
expect(transport.setClientId).not.toHaveBeenCalled()
|
||||
expect(ws.close).toHaveBeenCalledWith(4001, 'Unauthorized')
|
||||
expect(wiring.channelCount).toBe(0)
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('reports auth encrypted to a stale desktop key on the direct path', () => {
|
||||
const currentDesktop = generateKeyPair()
|
||||
const staleDesktop = generateKeyPair()
|
||||
const phone = generateKeyPair()
|
||||
const ws = new FakeSocket()
|
||||
const transport = new FakeTransport()
|
||||
const onUnpairedDeviceAuthFailure = vi.fn()
|
||||
const wiring = new MobileSocketWiring({
|
||||
deviceRegistry: registryFor('device-1', 'valid-token'),
|
||||
e2eeKeypair: {
|
||||
publicKey: currentDesktop.publicKey,
|
||||
secretKey: currentDesktop.secretKey,
|
||||
publicKeyB64: Buffer.from(currentDesktop.publicKey).toString('base64')
|
||||
},
|
||||
onText: vi.fn(),
|
||||
onBinary: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onUnpairedDeviceAuthFailure
|
||||
})
|
||||
wiring.attachTransport(transport)
|
||||
|
||||
transport.receive(
|
||||
ws,
|
||||
JSON.stringify({
|
||||
type: 'e2ee_hello',
|
||||
publicKeyB64: Buffer.from(phone.publicKey).toString('base64')
|
||||
})
|
||||
)
|
||||
const staleSharedKey = deriveSharedKey(phone.secretKey, staleDesktop.publicKey)
|
||||
transport.receive(
|
||||
ws,
|
||||
encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), staleSharedKey)
|
||||
)
|
||||
|
||||
expect(onUnpairedDeviceAuthFailure).toHaveBeenCalledOnce()
|
||||
expect(onUnpairedDeviceAuthFailure).toHaveBeenCalledWith({ transport: 'direct' })
|
||||
expect(transport.setClientId).not.toHaveBeenCalled()
|
||||
expect(ws.close).toHaveBeenCalledWith(4001, 'Unauthorized')
|
||||
})
|
||||
|
||||
it('rejects a relay socket whose immutable relayDeviceId differs from E2EE identity', () => {
|
||||
const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1))
|
||||
const phone = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2))
|
||||
|
||||
@@ -50,6 +50,8 @@ type MobileSocketWiringOptions = {
|
||||
onBinary: (socket: AuthenticatedMobileSocket, bytes: Uint8Array<ArrayBufferLike>) => void
|
||||
onClose: (socket: AuthenticatedMobileSocket | null, hasOtherConnections: boolean) => void
|
||||
onReady?: (socket: AuthenticatedMobileSocket) => void
|
||||
// Why: stale keys and missing registry entries both fail before RPC can explain the re-pair action.
|
||||
onUnpairedDeviceAuthFailure?: (metadata: MobileSocketTransportMetadata) => void
|
||||
}
|
||||
|
||||
function toAuthenticatedDevice(device: DeviceEntry): E2EEAuthenticatedDevice {
|
||||
@@ -67,6 +69,7 @@ export class MobileSocketWiring {
|
||||
private readonly onBinary: MobileSocketWiringOptions['onBinary']
|
||||
private readonly onClose: MobileSocketWiringOptions['onClose']
|
||||
private readonly onReady: MobileSocketWiringOptions['onReady']
|
||||
private readonly onUnpairedDeviceAuthFailure: MobileSocketWiringOptions['onUnpairedDeviceAuthFailure']
|
||||
private readonly channels = new Map<WebSocket, E2EEChannel>()
|
||||
private readonly connectionIds = new Map<WebSocket, string>()
|
||||
private readonly authenticatedSockets = new Map<WebSocket, AuthenticatedMobileSocket>()
|
||||
@@ -79,6 +82,7 @@ export class MobileSocketWiring {
|
||||
this.onBinary = options.onBinary
|
||||
this.onClose = options.onClose
|
||||
this.onReady = options.onReady
|
||||
this.onUnpairedDeviceAuthFailure = options.onUnpairedDeviceAuthFailure
|
||||
}
|
||||
|
||||
attachTransport(
|
||||
@@ -151,9 +155,18 @@ export class MobileSocketWiring {
|
||||
this.onReady?.(socket)
|
||||
},
|
||||
onError: (code, reason) => {
|
||||
const reportUnpairedDevice = code === 4001 && reason === 'Unauthorized'
|
||||
this.channels.get(ws)?.destroy()
|
||||
this.channels.delete(ws)
|
||||
ws.close(code, reason)
|
||||
if (reportUnpairedDevice) {
|
||||
try {
|
||||
this.onUnpairedDeviceAuthFailure?.(metadata)
|
||||
} catch (error) {
|
||||
// Why: renderer teardown can make UI delivery throw; auth cleanup must remain authoritative.
|
||||
console.error('[mobile] Failed to report unpaired-device auth failure:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
channel.onMessage((plaintext, reply, sendBinary) => {
|
||||
|
||||
@@ -7,20 +7,46 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('RemoteRuntimeServerHeartbeat', () => {
|
||||
it('still reaps a client that misses a probe while another remains alive', async () => {
|
||||
vi.useFakeTimers()
|
||||
let now = 1_000
|
||||
const responsiveSocket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket
|
||||
const deadSocket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket
|
||||
const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now)
|
||||
heartbeat.noteAlive(responsiveSocket)
|
||||
heartbeat.noteAlive(deadSocket)
|
||||
// start() probes immediately: both are pinged now (probe #1) and cleared to await a pong.
|
||||
heartbeat.start(() => [responsiveSocket, deadSocket])
|
||||
// Only the responsive socket pongs the immediate probe.
|
||||
heartbeat.noteAlive(responsiveSocket)
|
||||
|
||||
now += 100
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(responsiveSocket.ping).toHaveBeenCalledTimes(2)
|
||||
expect(responsiveSocket.terminate).not.toHaveBeenCalled()
|
||||
expect(deadSocket.ping).toHaveBeenCalledTimes(1)
|
||||
expect(deadSocket.terminate).toHaveBeenCalledTimes(1)
|
||||
heartbeat.stop()
|
||||
})
|
||||
|
||||
it('grants clients a fresh probe after the server event loop resumes', async () => {
|
||||
vi.useFakeTimers()
|
||||
let now = 1_000
|
||||
const socket = { ping: vi.fn(), terminate: vi.fn() } as unknown as WebSocket
|
||||
const heartbeat = new RemoteRuntimeServerHeartbeat(100, () => now)
|
||||
heartbeat.noteAlive(socket)
|
||||
// start() probes immediately (ping #1); the socket pongs it.
|
||||
heartbeat.start(() => [socket])
|
||||
heartbeat.noteAlive(socket)
|
||||
|
||||
now += 100
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await vi.advanceTimersByTimeAsync(100) // ping #2, socket pongs
|
||||
heartbeat.noteAlive(socket)
|
||||
now += 3_600_000
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await vi.advanceTimersByTimeAsync(100) // resumed-from-pause: re-grants a probe (ping #3), no reap
|
||||
|
||||
expect(socket.ping).toHaveBeenCalledTimes(2)
|
||||
expect(socket.ping).toHaveBeenCalledTimes(3)
|
||||
expect(socket.terminate).not.toHaveBeenCalled()
|
||||
|
||||
now += 100
|
||||
|
||||
@@ -22,6 +22,11 @@ export class RemoteRuntimeServerHeartbeat {
|
||||
this.lastTickAt = this.now()
|
||||
this.timer = setInterval(() => this.sweep(getClients()), this.intervalMs)
|
||||
this.timer.unref?.()
|
||||
// Why: the interval's first tick is a full intervalMs (~15s) out, so arming on the first accepted
|
||||
// connection would leave that socket unprobed for the whole window. Sweep once now so the first
|
||||
// liveness ping goes out immediately; seeded-alive sockets are pinged (not reaped) and have until
|
||||
// the next tick to pong. WS pong is answered at the protocol level, so a live socket always survives.
|
||||
this.sweep(getClients())
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { UnpairedDeviceAuthThrottle } from './unpaired-device-auth-throttle'
|
||||
|
||||
function throttleAt(clock: { time: number }, onTrigger = vi.fn()) {
|
||||
const throttle = new UnpairedDeviceAuthThrottle({
|
||||
onTrigger,
|
||||
failureThreshold: 3,
|
||||
windowMs: 60_000,
|
||||
now: () => clock.time
|
||||
})
|
||||
return { throttle, onTrigger }
|
||||
}
|
||||
|
||||
describe('UnpairedDeviceAuthThrottle', () => {
|
||||
it('stays silent below the failure threshold', () => {
|
||||
const clock = { time: 0 }
|
||||
const { throttle, onTrigger } = throttleAt(clock)
|
||||
throttle.recordFailure()
|
||||
clock.time += 1000
|
||||
throttle.recordFailure()
|
||||
expect(onTrigger).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fires once when the threshold is reached inside the window', () => {
|
||||
const clock = { time: 0 }
|
||||
const { throttle, onTrigger } = throttleAt(clock)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
throttle.recordFailure()
|
||||
clock.time += 500
|
||||
}
|
||||
expect(onTrigger).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('never fires twice in one session even as failures continue', () => {
|
||||
const clock = { time: 0 }
|
||||
const { throttle, onTrigger } = throttleAt(clock)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
throttle.recordFailure()
|
||||
clock.time += 500
|
||||
}
|
||||
expect(onTrigger).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('ignores failures that fall outside the window', () => {
|
||||
const clock = { time: 0 }
|
||||
const { throttle, onTrigger } = throttleAt(clock)
|
||||
throttle.recordFailure()
|
||||
clock.time += 61_000
|
||||
throttle.recordFailure()
|
||||
clock.time += 61_000
|
||||
throttle.recordFailure()
|
||||
expect(onTrigger).not.toHaveBeenCalled()
|
||||
|
||||
// A real retry burst after the stray singles still triggers.
|
||||
clock.time += 61_000
|
||||
throttle.recordFailure()
|
||||
clock.time += 100
|
||||
throttle.recordFailure()
|
||||
clock.time += 100
|
||||
throttle.recordFailure()
|
||||
expect(onTrigger).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
// Why: a desktop that lost its device registry (pre-v1.4.106 pairing-path bug)
|
||||
// rejects previously-paired phones with 4001 forever while both ends stay
|
||||
// silent. This gate turns that repeated failure pattern into ONE user-facing
|
||||
// signal per runtime session, without firing on a single stray probe.
|
||||
|
||||
const DEFAULT_FAILURE_THRESHOLD = 3
|
||||
const DEFAULT_WINDOW_MS = 60_000
|
||||
|
||||
export type UnpairedDeviceAuthThrottleOptions = {
|
||||
onTrigger: () => void
|
||||
// Failures within windowMs needed before onTrigger fires (default 3 in 60s).
|
||||
failureThreshold?: number
|
||||
windowMs?: number
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export class UnpairedDeviceAuthThrottle {
|
||||
private readonly onTrigger: () => void
|
||||
private readonly failureThreshold: number
|
||||
private readonly windowMs: number
|
||||
private readonly now: () => number
|
||||
private readonly failureTimestamps: number[] = []
|
||||
private triggered = false
|
||||
|
||||
constructor(options: UnpairedDeviceAuthThrottleOptions) {
|
||||
this.onTrigger = options.onTrigger
|
||||
this.failureThreshold = options.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD
|
||||
this.windowMs = options.windowMs ?? DEFAULT_WINDOW_MS
|
||||
this.now = options.now ?? Date.now
|
||||
}
|
||||
|
||||
recordFailure(): void {
|
||||
if (this.triggered) {
|
||||
return
|
||||
}
|
||||
const now = this.now()
|
||||
this.failureTimestamps.push(now)
|
||||
while (this.failureTimestamps.length > 0 && now - this.failureTimestamps[0]! > this.windowMs) {
|
||||
this.failureTimestamps.shift()
|
||||
}
|
||||
if (this.failureTimestamps.length >= this.failureThreshold) {
|
||||
this.triggered = true
|
||||
this.failureTimestamps.length = 0
|
||||
this.onTrigger()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -14,6 +15,30 @@ function makeTls() {
|
||||
return loadOrCreateTlsCertificate(userDataPath)
|
||||
}
|
||||
|
||||
function heartbeatLifecycle(transport: WebSocketTransport) {
|
||||
return transport as unknown as {
|
||||
heartbeat: {
|
||||
timer: ReturnType<typeof setInterval> | null
|
||||
alive: WeakSet<WebSocket>
|
||||
}
|
||||
heartbeatConnections: Set<WebSocket>
|
||||
wss: { clients: Set<WebSocket> }
|
||||
handleConnection(ws: WebSocket): void
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHeartbeatLifecycle(
|
||||
transport: WebSocketTransport,
|
||||
connectionCount: number,
|
||||
armed: boolean
|
||||
): Promise<void> {
|
||||
await vi.waitFor(() => {
|
||||
const lifecycle = heartbeatLifecycle(transport)
|
||||
expect(lifecycle.heartbeatConnections.size).toBe(connectionCount)
|
||||
expect(lifecycle.heartbeat.timer !== null).toBe(armed)
|
||||
})
|
||||
}
|
||||
|
||||
describe('WebSocketTransport', () => {
|
||||
const transports: WebSocketTransport[] = []
|
||||
|
||||
@@ -70,6 +95,68 @@ describe('WebSocketTransport', () => {
|
||||
await transport.stop()
|
||||
})
|
||||
|
||||
it('arms heartbeat only while accepted connections exist', async () => {
|
||||
const { transport } = await createTransport()
|
||||
await transport.start()
|
||||
|
||||
const lifecycle = heartbeatLifecycle(transport)
|
||||
expect(lifecycle.heartbeat.timer).toBeNull()
|
||||
expect(lifecycle.heartbeatConnections.size).toBe(0)
|
||||
|
||||
const firstClient = await connectWs(transport)
|
||||
await waitForHeartbeatLifecycle(transport, 1, true)
|
||||
const firstServerSocket = Array.from(lifecycle.wss.clients)[0]
|
||||
expect(firstServerSocket).toBeDefined()
|
||||
// Note: arming probes immediately, so `alive` membership is racy here (the client's protocol-level
|
||||
// pong re-adds the socket right after the arm sweep clears it). Assert the arm/disarm lifecycle only.
|
||||
const firstTimer = lifecycle.heartbeat.timer
|
||||
|
||||
const secondClient = await connectWs(transport)
|
||||
await waitForHeartbeatLifecycle(transport, 2, true)
|
||||
expect(lifecycle.heartbeat.timer).toBe(firstTimer)
|
||||
|
||||
firstClient.close()
|
||||
await waitForHeartbeatLifecycle(transport, 1, true)
|
||||
expect(lifecycle.heartbeat.timer).toBe(firstTimer)
|
||||
|
||||
secondClient.close()
|
||||
await waitForHeartbeatLifecycle(transport, 0, false)
|
||||
|
||||
const thirdClient = await connectWs(transport)
|
||||
await waitForHeartbeatLifecycle(transport, 1, true)
|
||||
expect(lifecycle.heartbeat.timer).not.toBe(firstTimer)
|
||||
|
||||
thirdClient.close()
|
||||
await waitForHeartbeatLifecycle(transport, 0, false)
|
||||
})
|
||||
|
||||
it('finalizes heartbeat membership once when error and close race', () => {
|
||||
const transport = new WebSocketTransport({ host: '127.0.0.1', port: 0 })
|
||||
transports.push(transport)
|
||||
const lifecycle = heartbeatLifecycle(transport)
|
||||
const socket = Object.assign(new EventEmitter(), {
|
||||
OPEN: WebSocket.OPEN,
|
||||
readyState: WebSocket.OPEN,
|
||||
close: vi.fn(),
|
||||
ping: vi.fn(),
|
||||
terminate: vi.fn()
|
||||
}) as unknown as WebSocket
|
||||
const closeHandler = vi.fn()
|
||||
transport.onConnectionClose(closeHandler)
|
||||
|
||||
lifecycle.handleConnection(socket)
|
||||
expect(lifecycle.heartbeatConnections.size).toBe(1)
|
||||
expect(lifecycle.heartbeat.timer).not.toBeNull()
|
||||
|
||||
socket.emit('error', new Error('connection reset'))
|
||||
socket.emit('close')
|
||||
|
||||
expect(closeHandler).toHaveBeenCalledTimes(1)
|
||||
expect(socket.close).toHaveBeenCalledTimes(1)
|
||||
expect(lifecycle.heartbeatConnections.size).toBe(0)
|
||||
expect(lifecycle.heartbeat.timer).toBeNull()
|
||||
})
|
||||
|
||||
it('handles request/response round-trip', async () => {
|
||||
const { transport } = await createTransport((msg, reply) => {
|
||||
const request = JSON.parse(msg)
|
||||
|
||||
@@ -61,6 +61,7 @@ export class WebSocketTransport implements RpcTransport {
|
||||
| null = null
|
||||
// Why: maps each socket to its authenticated clientId so close can report which device disconnected.
|
||||
private wsClientIds = new Map<WebSocket, string>()
|
||||
private heartbeatConnections = new Set<WebSocket>()
|
||||
private preAuthTimers = new WeakMap<WebSocket, ReturnType<typeof setTimeout>>()
|
||||
|
||||
constructor({
|
||||
@@ -199,7 +200,6 @@ export class WebSocketTransport implements RpcTransport {
|
||||
|
||||
this.httpServer = httpServer
|
||||
this.wss = wss
|
||||
this.heartbeat.start(() => this.wss?.clients ?? [])
|
||||
}
|
||||
|
||||
// Why: force-terminate soon after the 1013 close since a half-open phone may never ack and would hold the descriptor past the WS cap; the 'error' listener absorbs a reset while closing.
|
||||
@@ -217,6 +217,7 @@ export class WebSocketTransport implements RpcTransport {
|
||||
this.wss = null
|
||||
this.httpServer = null
|
||||
this.heartbeat.stop()
|
||||
this.heartbeatConnections.clear()
|
||||
|
||||
if (wss) {
|
||||
for (const client of wss.clients) {
|
||||
@@ -280,6 +281,10 @@ export class WebSocketTransport implements RpcTransport {
|
||||
ws.off('close', finalizeConnection)
|
||||
ws.off('error', onError)
|
||||
this.clearPreAuthTimer(ws)
|
||||
this.heartbeatConnections.delete(ws)
|
||||
if (this.heartbeatConnections.size === 0) {
|
||||
this.heartbeat.stop()
|
||||
}
|
||||
const clientId = this.wsClientIds.get(ws) ?? null
|
||||
this.wsClientIds.delete(ws)
|
||||
const hasOtherConnections =
|
||||
@@ -287,6 +292,13 @@ export class WebSocketTransport implements RpcTransport {
|
||||
this.connectionCloseHandler?.(clientId, ws, hasOtherConnections)
|
||||
}
|
||||
|
||||
// Why: seed before arming so a fresh first socket survives its initial sweep.
|
||||
this.heartbeatConnections.add(ws)
|
||||
this.heartbeat.noteAlive(ws)
|
||||
if (this.heartbeatConnections.size === 1) {
|
||||
this.heartbeat.start(() => this.wss?.clients ?? [])
|
||||
}
|
||||
|
||||
const preAuthTimer = setTimeout(() => {
|
||||
if (!this.wsClientIds.has(ws)) {
|
||||
// Why: a silent auto-ponging client would otherwise hold a finite mobile slot forever without starting the E2EE handshake.
|
||||
@@ -298,9 +310,6 @@ export class WebSocketTransport implements RpcTransport {
|
||||
}
|
||||
this.preAuthTimers.set(ws, preAuthTimer)
|
||||
|
||||
// Why: seed alive so the first heartbeat tick doesn't reap a fresh socket before its first pong.
|
||||
this.heartbeat.noteAlive(ws)
|
||||
|
||||
ws.on('pong', onPong)
|
||||
ws.on('message', onMessage)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { readWsFallbackPort, writeWsFallbackPort } from './rpc/ws-fallback-port-
|
||||
import type { WebSocket } from 'ws'
|
||||
import { DeviceRegistry, type DeviceEntry, type DeviceScope } from './device-registry'
|
||||
import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair'
|
||||
import { UnpairedDeviceAuthThrottle } from './rpc/unpaired-device-auth-throttle'
|
||||
import {
|
||||
MobileSocketWiring,
|
||||
type AuthenticatedMobileSocket,
|
||||
@@ -437,6 +438,8 @@ export class OrcaRuntimeRpcServer {
|
||||
private transports: RuntimeTransportMetadata[] = []
|
||||
private mobileSocketWiring: MobileSocketWiring | null = null
|
||||
private mobileRelayPairingProvider: MobileRelayPairingProvider | null = null
|
||||
private onUnpairedDeviceAuthFailure: (() => void) | null = null
|
||||
private unpairedDeviceAuthThrottle: UnpairedDeviceAuthThrottle | null = null
|
||||
private readonly binaryStreamHandlers = new Map<
|
||||
string,
|
||||
Map<number, (frame: TerminalStreamFrame) => void>
|
||||
@@ -521,6 +524,11 @@ export class OrcaRuntimeRpcServer {
|
||||
return updated
|
||||
}
|
||||
|
||||
// Why: only the desktop shell can surface UI; headless serve leaves this unset.
|
||||
setOnUnpairedDeviceAuthFailure(callback: (() => void) | null): void {
|
||||
this.onUnpairedDeviceAuthFailure = callback
|
||||
}
|
||||
|
||||
setMobileRelayPairingProvider(provider: MobileRelayPairingProvider | null): void {
|
||||
this.mobileRelayPairingProvider = provider
|
||||
}
|
||||
@@ -888,6 +896,10 @@ export class OrcaRuntimeRpcServer {
|
||||
...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}),
|
||||
...(this.preferPinnedWsPort ? { preferPinnedPort: true } : {})
|
||||
})
|
||||
// Why: session-scoped (recreated per start) so each desktop launch may notify once.
|
||||
this.unpairedDeviceAuthThrottle = new UnpairedDeviceAuthThrottle({
|
||||
onTrigger: () => this.onUnpairedDeviceAuthFailure?.()
|
||||
})
|
||||
const mobileSocketWiring = new MobileSocketWiring({
|
||||
deviceRegistry: pairingIdentity.deviceRegistry,
|
||||
e2eeKeypair: pairingIdentity.e2eeKeypair,
|
||||
@@ -924,6 +936,12 @@ export class OrcaRuntimeRpcServer {
|
||||
if (!hasOtherConnections) {
|
||||
this.runtime.onClientDisconnected(socket.device.deviceToken)
|
||||
}
|
||||
},
|
||||
// Why: relay attempts are authorized upstream; only direct failures should prompt local re-pairing.
|
||||
onUnpairedDeviceAuthFailure: (metadata) => {
|
||||
if (metadata.transport === 'direct') {
|
||||
this.unpairedDeviceAuthThrottle?.recordFailure()
|
||||
}
|
||||
}
|
||||
})
|
||||
mobileSocketWiring.attachTransport(wsTransport)
|
||||
|
||||
@@ -3279,6 +3279,10 @@ export type PreloadApi = {
|
||||
isWebSocketReady: () => Promise<{ ready: boolean; endpoint: string | null }>
|
||||
getRelayStatus: () => Promise<{ status: MobileRelayStatus }>
|
||||
onRelayStatusChanged: (callback: (status: MobileRelayStatus) => void) => () => void
|
||||
/** Consumes an auth-failure notification that arrived before the renderer listener mounted. */
|
||||
consumePendingUnpairedDeviceAuthFailure?: () => Promise<boolean>
|
||||
/** Fires (throttled, once per session) when an unpaired phone repeatedly fails direct-transport auth. */
|
||||
onUnpairedDeviceAuthFailure?: (callback: () => void) => () => void
|
||||
}
|
||||
speech: {
|
||||
getCatalog: () => Promise<SpeechModelManifest[]>
|
||||
|
||||
@@ -4368,6 +4368,16 @@ const api = {
|
||||
callback(status)
|
||||
ipcRenderer.on('mobile:relayStatusChanged', listener)
|
||||
return () => ipcRenderer.removeListener('mobile:relayStatusChanged', listener)
|
||||
},
|
||||
|
||||
consumePendingUnpairedDeviceAuthFailure: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke('mobile:consumePendingUnpairedDeviceAuthFailure'),
|
||||
|
||||
/** Fires (throttled, once per session) when an unpaired phone repeatedly fails direct-transport auth. */
|
||||
onUnpairedDeviceAuthFailure: (callback: () => void): (() => void) => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('mobile:unpairedDeviceAuthFailure', listener)
|
||||
return () => ipcRenderer.removeListener('mobile:unpairedDeviceAuthFailure', listener)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -47,12 +47,14 @@ describe('AgentStateDot', () => {
|
||||
})
|
||||
|
||||
it.each(['permission', 'waiting'] satisfies AgentDotState[])(
|
||||
'renders %s as an amber attention dot',
|
||||
'renders %s as an amber question glyph',
|
||||
(state) => {
|
||||
const classNames = renderDotClassNames(state)
|
||||
const markup = renderMarkup(state)
|
||||
|
||||
expect(classNames).toContain('bg-amber-500')
|
||||
expect(classNames).not.toContain('bg-red-500')
|
||||
expect(markup).toContain('lucide-message-circle-question-mark')
|
||||
expect(markup).toContain('text-amber-500')
|
||||
expect(markup).not.toContain('bg-amber-500')
|
||||
expect(markup).not.toContain('data-agent-spinner')
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { CircleCheck } from 'lucide-react'
|
||||
import { CircleCheck, MessageCircleQuestion } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
|
||||
|
||||
@@ -95,6 +95,17 @@ export const AgentStateDot = React.memo(function AgentStateDot({
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'permission' || state === 'waiting') {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
|
||||
aria-label={agentStateLabel(state)}
|
||||
>
|
||||
<MessageCircleQuestion className={cn('text-amber-500', icon)} aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn('inline-flex shrink-0 items-center justify-center', box, className)}
|
||||
@@ -104,11 +115,9 @@ export const AgentStateDot = React.memo(function AgentStateDot({
|
||||
className={cn(
|
||||
'block rounded-full',
|
||||
inner,
|
||||
state === 'permission' || state === 'waiting'
|
||||
? 'bg-amber-500'
|
||||
: state === 'blocked' || state === 'interrupted' || state === 'failed'
|
||||
? 'bg-red-500'
|
||||
: 'bg-neutral-500/40'
|
||||
state === 'blocked' || state === 'interrupted' || state === 'failed'
|
||||
? 'bg-red-500'
|
||||
: 'bg-neutral-500/40'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -12,13 +12,15 @@ type DetachedHeadBadgeProps = {
|
||||
label?: 'sidebar' | 'source-control'
|
||||
side?: React.ComponentProps<typeof TooltipContent>['side']
|
||||
className?: string
|
||||
tabIndex?: number
|
||||
}
|
||||
|
||||
export function DetachedHeadBadge({
|
||||
display,
|
||||
label = 'source-control',
|
||||
side = 'right',
|
||||
className
|
||||
className,
|
||||
tabIndex
|
||||
}: DetachedHeadBadgeProps): React.JSX.Element {
|
||||
const visibleLabel = label === 'sidebar' ? display.sidebarLabel : display.sourceControlLabel
|
||||
|
||||
@@ -27,6 +29,8 @@ export function DetachedHeadBadge({
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
aria-label={display.tooltip}
|
||||
tabIndex={tabIndex}
|
||||
className={cn(
|
||||
'h-[18px] shrink-0 gap-1 rounded px-1.5 text-[10px] font-medium leading-none',
|
||||
'border-[color:color-mix(in_srgb,var(--git-decoration-modified)_30%,transparent)] bg-[color:color-mix(in_srgb,var(--git-decoration-modified)_8%,transparent)] text-[color:var(--git-decoration-modified)]',
|
||||
@@ -34,7 +38,7 @@ export function DetachedHeadBadge({
|
||||
)}
|
||||
>
|
||||
<GitCommitHorizontal className="size-2.5" />
|
||||
{visibleLabel}
|
||||
<span className="min-w-0 truncate">{visibleLabel}</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} sideOffset={8}>
|
||||
|
||||
@@ -60,6 +60,30 @@ describe('AgentKanbanCard', () => {
|
||||
expect(screen.queryByText(/\d+d/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the question glyph once when a summary is available', () => {
|
||||
const attentionCard = card({
|
||||
bucket: 'attention',
|
||||
dotState: 'waiting',
|
||||
askSummary: 'Approve deploy?'
|
||||
})
|
||||
const { container, rerender } = render(
|
||||
<AgentKanbanCard card={attentionCard} now={2_000} onOpenTerminal={vi.fn()} />
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('state-dot')).not.toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.lucide-message-circle-question-mark')).toHaveLength(1)
|
||||
|
||||
rerender(
|
||||
<AgentKanbanCard
|
||||
card={{ ...attentionCard, askSummary: undefined }}
|
||||
now={2_000}
|
||||
onOpenTerminal={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('state-dot')).toBeInTheDocument()
|
||||
expect(container.querySelector('.lucide-message-circle-question-mark')).toBeNull()
|
||||
})
|
||||
|
||||
it('skips structured-clone rerenders until visible card data or its age changes', () => {
|
||||
const onOpenTerminal = vi.fn()
|
||||
const initial = card({ startedAt: 1_000 })
|
||||
|
||||
@@ -98,7 +98,8 @@ export const AgentKanbanCard = memo(
|
||||
>
|
||||
{card.worktreeName}
|
||||
</span>
|
||||
<AgentStateDot state={card.dotState} className="ml-auto" />
|
||||
{/* The summary pill already carries the attention glyph. */}
|
||||
{card.askSummary ? null : <AgentStateDot state={card.dotState} className="ml-auto" />}
|
||||
</div>
|
||||
|
||||
{card.lastUserMessage || card.lastAgentMessage ? (
|
||||
|
||||
@@ -247,12 +247,13 @@ describe('DashboardAgentRow', () => {
|
||||
expect(classes.every((className) => !/\bgroup-hover:/.test(className))).toBe(true)
|
||||
})
|
||||
|
||||
it('renders waiting rows with the amber permission color', () => {
|
||||
it('renders waiting rows with the amber question glyph', () => {
|
||||
const markup = renderRow(makeAgent({}, { state: 'waiting' }))
|
||||
const tokens = classTokens(markup)
|
||||
|
||||
expect(markup).toContain('aria-label="Waiting for input"')
|
||||
expect(tokens).toContain('bg-amber-500')
|
||||
expect(markup).toContain('lucide-message-circle-question-mark')
|
||||
expect(tokens).toContain('text-amber-500')
|
||||
expect(tokens).not.toContain('bg-red-500')
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { DiffSection } from './diff-section-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { LargeDiffFallback } from './LargeDiffFallback'
|
||||
import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options'
|
||||
import { monacoFindOptions } from './monaco-find-options'
|
||||
|
||||
const ImageDiffViewer = lazy(() => import('./ImageDiffViewer'))
|
||||
|
||||
@@ -198,11 +199,7 @@ export function DiffSectionBody({
|
||||
renderOverviewRuler: false,
|
||||
scrollbar: combinedDiffSectionScrollbarOptions,
|
||||
hideUnchangedRegions: { enabled: true },
|
||||
find: {
|
||||
addExtraSpaceOnTop: false,
|
||||
autoFindInSelection: 'never',
|
||||
seedSearchStringFromSelection: 'never'
|
||||
}
|
||||
find: monacoFindOptions
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { DiffViewerProps } from './diff-viewer-props'
|
||||
import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options'
|
||||
import { useDiffEditorRegistration } from './diff-navigation-context'
|
||||
import { preserveDiffViewStateAcrossModelSwaps } from './diff-model-swap-view-state'
|
||||
import { monacoFindOptions } from './monaco-find-options'
|
||||
|
||||
export default function DiffViewer({
|
||||
modelKey,
|
||||
@@ -429,11 +430,7 @@ export default function DiffViewer({
|
||||
renderOverviewRuler: true,
|
||||
scrollbar: diffEditorScrollbarOptions,
|
||||
padding: { top: 0 },
|
||||
find: {
|
||||
addExtraSpaceOnTop: false,
|
||||
autoFindInSelection: 'never',
|
||||
seedSearchStringFromSelection: 'never'
|
||||
}
|
||||
find: monacoFindOptions
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable max-lines -- Why: MarkdownPreview keeps rendering, link interception, search, and viewport state together so preview behavior stays coherent. */
|
||||
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: search match state is synchronized with DOM highlights inserted into the rendered markdown body. */
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
Plus,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import type { Components } from 'react-markdown'
|
||||
import type { Components, Options as ReactMarkdownOptions } from 'react-markdown'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useAppStore } from '@/store'
|
||||
@@ -328,6 +329,53 @@ const markdownPreviewSanitizeSchema = {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: react-markdown's <Markdown> has no internal memoization — it rebuilds the whole
|
||||
// unified remark→rehype pipeline and re-parses the document on EVERY render. These plugin
|
||||
// lists are fully static, so hoist them to module scope; a fresh array identity per render
|
||||
// would otherwise defeat the memoized body below.
|
||||
type MarkdownPluginList = NonNullable<ReactMarkdownOptions['remarkPlugins']>
|
||||
const MARKDOWN_REMARK_PLUGINS: MarkdownPluginList = [
|
||||
remarkGfm,
|
||||
remarkBreaks,
|
||||
remarkFrontmatter,
|
||||
remarkMath,
|
||||
remarkMarkdownDocLinks
|
||||
]
|
||||
// Why: sanitize raw HTML before KaTeX/highlight expand it, so their generated markup needn't be whitelisted in the schema.
|
||||
const MARKDOWN_REHYPE_PLUGINS: MarkdownPluginList = [
|
||||
rehypeRaw,
|
||||
[rehypeSanitize, markdownPreviewSanitizeSchema],
|
||||
rehypeSlug,
|
||||
rehypeHighlight,
|
||||
rehypeKatex
|
||||
]
|
||||
|
||||
// Why: render the body through a memoized wrapper so the expensive pipeline only re-runs when
|
||||
// the rendered content or the components map changes. Find state (query/match index) and
|
||||
// body-unrelated toolbar re-renders touch neither prop, so keystrokes in Find no longer
|
||||
// re-parse+re-highlight the whole doc. (Inline-annotation/review state — attentionReviewCommentId,
|
||||
// copiedReviewNoteId, activeAnnotationBlockKey — deliberately rebuilds `components`, so those
|
||||
// re-renders still re-run the pipeline; that's required to update the live annotation markup.)
|
||||
const MarkdownBody = memo(function MarkdownBody({
|
||||
content,
|
||||
components
|
||||
}: {
|
||||
content: string
|
||||
components: Components
|
||||
}) {
|
||||
return (
|
||||
<Markdown
|
||||
components={components}
|
||||
// Why: react-markdown filters file:// after sanitize; click handlers need the target to authorize and open it.
|
||||
urlTransform={markdownPreviewUrlTransform}
|
||||
remarkPlugins={MARKDOWN_REMARK_PLUGINS}
|
||||
rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
|
||||
>
|
||||
{content}
|
||||
</Markdown>
|
||||
)
|
||||
})
|
||||
|
||||
function parseLineTarget(hash: string): { line: number; column?: number } | null {
|
||||
if (!hash) {
|
||||
return null
|
||||
@@ -1947,28 +1995,7 @@ export default function MarkdownPreview({
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
<Markdown
|
||||
components={components}
|
||||
// Why: react-markdown filters file:// after sanitize; click handlers need the target to authorize and open it.
|
||||
urlTransform={markdownPreviewUrlTransform}
|
||||
remarkPlugins={[
|
||||
remarkGfm,
|
||||
remarkBreaks,
|
||||
remarkFrontmatter,
|
||||
remarkMath,
|
||||
remarkMarkdownDocLinks
|
||||
]}
|
||||
// Why: sanitize raw HTML before KaTeX/highlight expand it, so their generated markup needn't be whitelisted in the schema.
|
||||
rehypePlugins={[
|
||||
rehypeRaw,
|
||||
[rehypeSanitize, markdownPreviewSanitizeSchema],
|
||||
rehypeSlug,
|
||||
rehypeHighlight,
|
||||
rehypeKatex
|
||||
]}
|
||||
>
|
||||
{renderedContent}
|
||||
</Markdown>
|
||||
<MarkdownBody content={renderedContent} components={components} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
isMonacoAutoHeightCapped
|
||||
} from './monaco-auto-height'
|
||||
import { installMonacoE2EProbe } from './monaco-e2e-probe'
|
||||
import { monacoFindOptions } from './monaco-find-options'
|
||||
|
||||
type MonacoEditorProps = {
|
||||
fileId: string
|
||||
@@ -848,11 +849,7 @@ export default function MonacoEditor({
|
||||
smoothScrolling: true,
|
||||
cursorSmoothCaretAnimation: 'off',
|
||||
padding: { top: 0 },
|
||||
find: {
|
||||
addExtraSpaceOnTop: false,
|
||||
autoFindInSelection: 'never',
|
||||
seedSearchStringFromSelection: 'never'
|
||||
},
|
||||
find: monacoFindOptions,
|
||||
// Why: Monaco owns its rendered line surface, so align its selection-clipboard with the app opt-out (the global DOM hook can't).
|
||||
selectionClipboard: settings?.primarySelectionMiddleClickPaste ?? isLinuxUserAgent()
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { monacoFindOptions } from './monaco-find-options'
|
||||
|
||||
describe('monacoFindOptions', () => {
|
||||
it('seeds Find only from an explicit selection without changing its layout or scope', () => {
|
||||
expect(monacoFindOptions).toEqual({
|
||||
addExtraSpaceOnTop: false,
|
||||
autoFindInSelection: 'never',
|
||||
seedSearchStringFromSelection: 'selection'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { editor } from 'monaco-editor'
|
||||
|
||||
export const monacoFindOptions = {
|
||||
addExtraSpaceOnTop: false,
|
||||
autoFindInSelection: 'never',
|
||||
seedSearchStringFromSelection: 'selection'
|
||||
} satisfies editor.IEditorFindOptions
|
||||
@@ -4,6 +4,8 @@ import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { EmulatorDeviceFrame } from './emulator-device-frame'
|
||||
import { resetStaleDocumentVisibilityForTesting } from '../terminal-pane/stale-document-visibility'
|
||||
import { EMULATOR_STREAM_PARK_DELAY_MS } from './use-emulator-stream-window-visibility'
|
||||
|
||||
// Why: a backgrounded but still-attached emulator must stop streaming frames.
|
||||
// The perf contract is that no frame stream is started (no per-frame IPC / MJPEG
|
||||
@@ -56,9 +58,20 @@ afterEach(() => {
|
||||
delete (URL as Partial<typeof URL>).createObjectURL
|
||||
delete (URL as Partial<typeof URL>).revokeObjectURL
|
||||
delete (window as { api?: unknown }).api
|
||||
setDocumentVisibility('visible')
|
||||
resetStaleDocumentVisibilityForTesting()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function setDocumentVisibility(state: 'visible' | 'hidden'): void {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => state
|
||||
})
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
}
|
||||
|
||||
async function renderFrame(isActive: boolean): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -102,3 +115,71 @@ describe('EmulatorDeviceFrame visibility gating', () => {
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('EmulatorDeviceFrame window-visibility gating', () => {
|
||||
it('parks the stream after the window stays hidden past the park delay, and resumes when visible', async () => {
|
||||
vi.useFakeTimers()
|
||||
await renderFrame(true)
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Hiding the window does not tear down immediately — a short grace covers a
|
||||
// quick Cmd+Tab round-trip.
|
||||
await act(async () => {
|
||||
setDocumentVisibility('hidden')
|
||||
})
|
||||
expect(stopFrameStream).not.toHaveBeenCalled()
|
||||
|
||||
// Once the grace elapses, the stream parks at the source.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS)
|
||||
})
|
||||
expect(stopFrameStream).toHaveBeenCalledWith({ streamId: 'stream-1' })
|
||||
|
||||
// Returning to the window resumes immediately; the session was never detached.
|
||||
await act(async () => {
|
||||
setDocumentVisibility('visible')
|
||||
})
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not tear down on a quick hide/show within the park delay', async () => {
|
||||
vi.useFakeTimers()
|
||||
await renderFrame(true)
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
setDocumentVisibility('hidden')
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS - 100)
|
||||
})
|
||||
await act(async () => {
|
||||
setDocumentVisibility('visible')
|
||||
})
|
||||
// The park timer was cancelled by the return-to-visible, so the stream never
|
||||
// stopped and no reconnect was needed.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS)
|
||||
})
|
||||
expect(stopFrameStream).not.toHaveBeenCalled()
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps streaming while hidden when occlusion state is proven stale (display-sleep wedge)', async () => {
|
||||
vi.useFakeTimers()
|
||||
await renderFrame(true)
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Window reports hidden, but real user input proves the occlusion tracker is
|
||||
// wedged; the stream must keep running instead of freezing on a black frame.
|
||||
await act(async () => {
|
||||
setDocumentVisibility('hidden')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }))
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS * 2)
|
||||
})
|
||||
expect(stopFrameStream).not.toHaveBeenCalled()
|
||||
expect(startFrameStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,7 @@ import { EmulatorScreenSurface } from './emulator-screen-surface'
|
||||
import { useEmulatorControlStream } from './use-emulator-control-stream'
|
||||
import { useEmulatorPaneSize } from './use-emulator-pane-size'
|
||||
import { useEmulatorScreenKeyboard } from './use-emulator-screen-keyboard'
|
||||
import { useEmulatorStreamWindowVisible } from './use-emulator-stream-window-visibility'
|
||||
|
||||
type EmulatorDeviceFrameProps = {
|
||||
previewUrl?: string
|
||||
@@ -335,9 +336,13 @@ export function EmulatorDeviceFrame({
|
||||
setStreamError(true)
|
||||
}, [])
|
||||
|
||||
// Why: hidden panes still receive emulator frames, including over SSH, so
|
||||
// parking the stream avoids background decode/IPC churn while staying attached.
|
||||
const showStream = isActive && isLive && Boolean(previewUrl)
|
||||
// Why: a hidden/occluded window (or a background tab) still receives emulator
|
||||
// frames, including over SSH; parking the stream avoids background decode/IPC
|
||||
// churn while staying attached. isActive covers the background-tab case;
|
||||
// windowVisibleForStream additionally parks when the whole window is hidden
|
||||
// (minimize / occlusion / display sleep), which no tab gate catches.
|
||||
const windowVisibleForStream = useEmulatorStreamWindowVisible()
|
||||
const showStream = isActive && isLive && windowVisibleForStream && Boolean(previewUrl)
|
||||
const streamAspectRatio = streamSize ? streamSize.width / streamSize.height : 9 / 19
|
||||
// Why: serve-sim may keep portrait-sized pixels for portrait-locked apps; the
|
||||
// physical frame still follows the last successful rotate request.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState, useSyncExternalStore } from 'react'
|
||||
import { isWindowVisible } from '@/lib/window-visibility-interval'
|
||||
import {
|
||||
isDocumentVisibilityProvenStale,
|
||||
registerStaleDocumentVisibilityRecovery
|
||||
} from '../terminal-pane/stale-document-visibility'
|
||||
|
||||
// Why: after display sleep macOS can wedge document.visibilityState at 'hidden'
|
||||
// with no further visibilitychange event; honor the terminal occlusion-staleness
|
||||
// latch so a window the user is actually looking at is never treated as hidden —
|
||||
// otherwise the emulator freezes on a black frame with no recovery (same bug class
|
||||
// as the 78MB terminal drop that motivated the latch).
|
||||
function getWindowVisibleSnapshot(): boolean {
|
||||
return isWindowVisible() || isDocumentVisibilityProvenStale()
|
||||
}
|
||||
|
||||
function subscribeWindowVisible(onChange: () => void): () => void {
|
||||
const handler = (): void => onChange()
|
||||
document.addEventListener('visibilitychange', handler)
|
||||
// Why: the stale latch flips visibility to proven-visible without emitting a
|
||||
// visibilitychange, so recompute when it fires too.
|
||||
const unregister = registerStaleDocumentVisibilityRecovery(handler)
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handler)
|
||||
unregister()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: parking is delayed so a quick Cmd+Tab / app-switch round-trip does not tear
|
||||
// down and renegotiate the device stream (MJPEG reconnect or scrcpy H.264 keyframe),
|
||||
// which is heavier than a terminal resync and flashes the "Connecting…" UI. Re-showing
|
||||
// restores immediately.
|
||||
export const EMULATOR_STREAM_PARK_DELAY_MS = 500
|
||||
|
||||
/**
|
||||
* Reactive "is this window visible enough to keep the emulator device stream
|
||||
* running" signal. Returns true while the window is visible (or occlusion state is
|
||||
* proven stale) and defers the visible→hidden transition by `parkDelayMs`.
|
||||
*/
|
||||
export function useEmulatorStreamWindowVisible(
|
||||
parkDelayMs = EMULATOR_STREAM_PARK_DELAY_MS
|
||||
): boolean {
|
||||
const rawVisible = useSyncExternalStore(
|
||||
subscribeWindowVisible,
|
||||
getWindowVisibleSnapshot,
|
||||
getWindowVisibleSnapshot
|
||||
)
|
||||
const [effectiveVisible, setEffectiveVisible] = useState(rawVisible)
|
||||
useEffect(() => {
|
||||
if (rawVisible) {
|
||||
// Restore immediately so returning to the window resumes without delay.
|
||||
setEffectiveVisible(true)
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(() => setEffectiveVisible(false), parkDelayMs)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [rawVisible, parkDelayMs])
|
||||
return effectiveVisible
|
||||
}
|
||||
-157
@@ -1,157 +0,0 @@
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
|
||||
import { HostedReviewHeaderLink } from './hosted-review-header-chrome'
|
||||
|
||||
const { openHttpLinkMock } = vi.hoisted(() => ({ openHttpLinkMock: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/http-link-routing', () => ({
|
||||
openHttpLink: openHttpLinkMock,
|
||||
registerHttpLinkStoreAccessor: vi.fn()
|
||||
}))
|
||||
|
||||
function makeReview(overrides: Partial<HostedReviewInfo> = {}): HostedReviewInfo {
|
||||
return {
|
||||
provider: 'github',
|
||||
number: 2192,
|
||||
title: 'Open PR in checks',
|
||||
state: 'open',
|
||||
url: 'https://github.com/stablyai/orca/pull/2192',
|
||||
status: 'pending',
|
||||
updatedAt: '2026-05-17T00:00:00Z',
|
||||
mergeable: 'UNKNOWN',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
type MinimalClickEvent = Pick<
|
||||
React.MouseEvent<HTMLButtonElement>,
|
||||
'nativeEvent' | 'stopPropagation'
|
||||
>
|
||||
type ClickModifiers = Partial<Pick<MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>>
|
||||
|
||||
function clickEvent(modifiers: ClickModifiers = {}): MinimalClickEvent {
|
||||
return {
|
||||
nativeEvent: {
|
||||
metaKey: modifiers.metaKey ?? false,
|
||||
ctrlKey: modifiers.ctrlKey ?? false,
|
||||
shiftKey: modifiers.shiftKey ?? false
|
||||
} as MouseEvent,
|
||||
stopPropagation: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
openHttpLinkMock.mockReset()
|
||||
})
|
||||
|
||||
describe('HostedReviewHeaderLink', () => {
|
||||
it('opens GitHub PRs in the Checks tab instead of rendering an external link', () => {
|
||||
const onOpenHostedReviewInChecks = vi.fn()
|
||||
const element = HostedReviewHeaderLink({
|
||||
review: makeReview(),
|
||||
onOpenHostedReviewInChecks
|
||||
})
|
||||
const markup = renderToStaticMarkup(element)
|
||||
|
||||
expect(markup).toContain('<button')
|
||||
expect(markup).toContain('PR #2192')
|
||||
expect(markup).toContain('underline decoration-border underline-offset-2')
|
||||
expect(markup).not.toContain('href=')
|
||||
expect(markup).not.toContain('target="_blank"')
|
||||
expect(markup).not.toContain('system browser')
|
||||
expect(markup).not.toContain('⌘+click')
|
||||
|
||||
const event = clickEvent()
|
||||
;(element.props.onClick as (event: MinimalClickEvent) => void)(event)
|
||||
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1)
|
||||
expect(openHttpLinkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each<[string, ClickModifiers]>([
|
||||
['Cmd-click', { metaKey: true }],
|
||||
['Ctrl-click', { ctrlKey: true }],
|
||||
['Shift+Cmd-click', { metaKey: true, shiftKey: true }],
|
||||
['Shift+Ctrl-click', { ctrlKey: true, shiftKey: true }]
|
||||
])('opens GitHub PRs in the Checks tab on %s', (_label, modifiers) => {
|
||||
const onOpenHostedReviewInChecks = vi.fn()
|
||||
const element = HostedReviewHeaderLink({
|
||||
review: makeReview(),
|
||||
onOpenHostedReviewInChecks
|
||||
})
|
||||
|
||||
const event = clickEvent(modifiers)
|
||||
;(element.props.onClick as (event: MinimalClickEvent) => void)(event)
|
||||
|
||||
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1)
|
||||
expect(openHttpLinkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens GitLab MRs in the Checks tab instead of rendering an external link', () => {
|
||||
const onOpenHostedReviewInChecks = vi.fn()
|
||||
const element = HostedReviewHeaderLink({
|
||||
review: makeReview({
|
||||
provider: 'gitlab',
|
||||
number: 31,
|
||||
url: 'https://gitlab.com/acme/widgets/-/merge_requests/31'
|
||||
}),
|
||||
onOpenHostedReviewInChecks
|
||||
})
|
||||
const markup = renderToStaticMarkup(element)
|
||||
|
||||
expect(markup).toContain('<button')
|
||||
expect(markup).not.toContain('href=')
|
||||
expect(markup).toContain('MR #31')
|
||||
|
||||
const event = clickEvent()
|
||||
;(element.props.onClick as (event: MinimalClickEvent) => void)(event)
|
||||
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1)
|
||||
expect(openHttpLinkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each<[string, ClickModifiers]>([
|
||||
['Cmd-click', { metaKey: true }],
|
||||
['Ctrl-click', { ctrlKey: true }],
|
||||
['Shift+Cmd-click', { metaKey: true, shiftKey: true }],
|
||||
['Shift+Ctrl-click', { ctrlKey: true, shiftKey: true }]
|
||||
])('opens GitLab MRs in the Checks tab on %s', (_label, modifiers) => {
|
||||
const onOpenHostedReviewInChecks = vi.fn()
|
||||
const element = HostedReviewHeaderLink({
|
||||
review: makeReview({
|
||||
provider: 'gitlab',
|
||||
number: 31,
|
||||
url: 'https://gitlab.com/acme/widgets/-/merge_requests/31'
|
||||
}),
|
||||
onOpenHostedReviewInChecks
|
||||
})
|
||||
|
||||
const event = clickEvent(modifiers)
|
||||
;(element.props.onClick as (event: MinimalClickEvent) => void)(event)
|
||||
|
||||
expect(event.stopPropagation).toHaveBeenCalledTimes(1)
|
||||
expect(onOpenHostedReviewInChecks).toHaveBeenCalledTimes(1)
|
||||
expect(openHttpLinkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps other provider reviews as external hosted-review links', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<HostedReviewHeaderLink
|
||||
review={makeReview({
|
||||
provider: 'bitbucket',
|
||||
number: 31,
|
||||
url: 'https://bitbucket.org/acme/widgets/pull-requests/31'
|
||||
})}
|
||||
onOpenHostedReviewInChecks={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(markup).toContain('<a')
|
||||
expect(markup).toContain('href="https://bitbucket.org/acme/widgets/pull-requests/31"')
|
||||
expect(markup).toContain('target="_blank"')
|
||||
expect(markup).toContain('PR #31')
|
||||
})
|
||||
})
|
||||
@@ -45,7 +45,6 @@ import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DetachedHeadBadge } from '@/components/DetachedHeadBadge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -265,7 +264,6 @@ import {
|
||||
resolveCreatePrIntentRemoteStep,
|
||||
type CreatePrIntentRunToken
|
||||
} from './source-control-create-pr-intent-flow'
|
||||
import { resolveVisibleCreatePrHeaderAction } from './source-control-create-pr-intent-state'
|
||||
import { resolveBlockedCreateReviewNoticeMessage } from './source-control-create-review-blocked-action'
|
||||
import {
|
||||
buildLoadingHostedReviewCreationEligibility,
|
||||
@@ -290,7 +288,6 @@ import {
|
||||
} from './source-control-hosted-review-push-target'
|
||||
import { buildSourceControlManualReviewUrlFromContext } from './source-control-manual-review-url'
|
||||
import { parseRemoteRepo } from './source-control-remote-repo'
|
||||
export { HostedReviewHeaderLink } from './hosted-review-header-chrome'
|
||||
import {
|
||||
createRunningCommitMessageGenerationRecord,
|
||||
getCommitMessageGenerationRecordKey,
|
||||
@@ -809,7 +806,6 @@ function SourceControlInner(): React.JSX.Element {
|
||||
const activeRepoConnectionId = activeRepo?.connectionId ?? null
|
||||
const activeRepoExecutionHostId = activeRepo?.executionHostId ?? null
|
||||
const gitIdentityDisplay = activeWorktree ? getWorktreeGitIdentityDisplay(activeWorktree) : null
|
||||
const detachedHeadDisplay = gitIdentityDisplay?.kind === 'detached' ? gitIdentityDisplay : null
|
||||
const branchName = gitIdentityDisplay?.kind === 'branch' ? gitIdentityDisplay.branchName : ''
|
||||
const entries = useAppStore((s) =>
|
||||
activeWorktreeId
|
||||
@@ -2806,11 +2802,6 @@ function SourceControlInner(): React.JSX.Element {
|
||||
]
|
||||
)
|
||||
|
||||
const openHostedReviewInChecks = useCallback(() => {
|
||||
setRightSidebarOpen(true)
|
||||
setRightSidebarTab('checks')
|
||||
}, [setRightSidebarOpen, setRightSidebarTab])
|
||||
|
||||
const handleBranchChangedByPullRequestGeneration = useCallback(async (): Promise<void> => {
|
||||
// Why: AI PR detail generation may rebase before summarizing, so refresh status if HEAD moved before the user submits the draft.
|
||||
await refreshActiveGitStatusAfterMutation()
|
||||
@@ -4239,10 +4230,6 @@ function SourceControlInner(): React.JSX.Element {
|
||||
(!createPrHeaderAction.disabled || isCreatingPr || prGenerating)
|
||||
? createPrHeaderAction
|
||||
: null
|
||||
const visibleCreatePrHeaderAction = resolveVisibleCreatePrHeaderAction({
|
||||
createPrHeaderAction
|
||||
})
|
||||
|
||||
const dropdownItems: DropdownEntry[] = useMemo(
|
||||
() =>
|
||||
resolveDropdownItems({
|
||||
@@ -4710,19 +4697,6 @@ function SourceControlInner(): React.JSX.Element {
|
||||
runCreatePrIntent
|
||||
])
|
||||
|
||||
const handleCreatePrHeaderClick = useCallback((): void => {
|
||||
if (!createPrHeaderAction || createPrHeaderAction.disabled) {
|
||||
return
|
||||
}
|
||||
if (createPrHeaderAction.kind === 'create_pr') {
|
||||
void handleCreatePullRequest()
|
||||
return
|
||||
}
|
||||
if (createPrHeaderAction.kind === 'create_pr_intent') {
|
||||
void runCreatePrIntent()
|
||||
}
|
||||
}, [createPrHeaderAction, handleCreatePullRequest, runCreatePrIntent])
|
||||
|
||||
const branchCompareInFlightRef = useRef(false)
|
||||
const branchCompareRerunRef = useRef(false)
|
||||
const branchCompareRunPromiseRef = useRef<Promise<void> | null>(null)
|
||||
@@ -5452,16 +5426,11 @@ function SourceControlInner(): React.JSX.Element {
|
||||
<>
|
||||
<div ref={setSourceControlRoot} className="relative flex h-full flex-col overflow-hidden">
|
||||
<SourceControlHeaderToolbar
|
||||
gitIdentityDisplay={gitIdentityDisplay}
|
||||
filterQuery={filterQuery}
|
||||
filterExpanded={filterExpanded}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
onFilterExpandedChange={setFilterExpanded}
|
||||
visibleCreatePrHeaderAction={visibleCreatePrHeaderAction}
|
||||
hostedReview={hostedReview}
|
||||
isCreatePrIntentInFlight={isCreatePrIntentInFlight}
|
||||
isCreatingPr={isCreatingPr || prGenerating}
|
||||
onCreatePrHeaderClick={handleCreatePrHeaderClick}
|
||||
onOpenHostedReviewInChecks={openHostedReviewInChecks}
|
||||
sourceControlViewMode={sourceControlViewMode}
|
||||
viewModeToggleDisabled={settings === null}
|
||||
onToggleViewMode={handleToggleSourceControlViewMode}
|
||||
@@ -5476,12 +5445,6 @@ function SourceControlInner(): React.JSX.Element {
|
||||
manualReviewUrl={manualReviewUrl}
|
||||
/>
|
||||
|
||||
{detachedHeadDisplay && (
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<DetachedHeadBadge display={detachedHeadDisplay} side="bottom" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Why: hidden when count is 0 — notes are created from the diff view, so an empty Notes shelf here is pure chrome. */}
|
||||
{activeWorktreeId && worktreePath && diffCommentCount > 0 && (
|
||||
<div className="border-b border-border">
|
||||
|
||||
+107
@@ -6,6 +6,7 @@ import type {
|
||||
WorkspaceLineage
|
||||
} from '../../../../shared/types'
|
||||
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { getAttachedWorktreesForFolderWorkspace } from './folder-workspace-attached-worktrees'
|
||||
|
||||
function makeFolder(id = 'folder-1'): FolderWorkspace {
|
||||
@@ -166,4 +167,110 @@ describe('getAttachedWorktreesForFolderWorkspace', () => {
|
||||
[nested.id]
|
||||
)
|
||||
})
|
||||
|
||||
it('includes an exact inline-only legacy descendant under an attached root', () => {
|
||||
const parent = makeWorktree({
|
||||
id: 'repo-1::/parent',
|
||||
instanceId: 'parent'
|
||||
})
|
||||
const nested = makeWorktree({
|
||||
id: 'repo-1::/nested',
|
||||
instanceId: 'nested'
|
||||
})
|
||||
const inlineNested = {
|
||||
...nested,
|
||||
lineage: makeWorktreeLineage(nested, parent)
|
||||
} as Worktree
|
||||
|
||||
const result = getAttachedWorktreesForFolderWorkspace({
|
||||
activeWorkspaceKey: folderWorkspaceKey('folder-1'),
|
||||
activeWorktreeId: null,
|
||||
folderWorkspaces: [makeFolder()],
|
||||
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
|
||||
worktreeLineageById: {},
|
||||
worktreesByRepo: { 'repo-1': [parent, inlineNested] }
|
||||
})
|
||||
|
||||
expect(result.lineageChildrenByParentId.get(parent.id)?.map((worktree) => worktree.id)).toEqual(
|
||||
[nested.id]
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a stale side-map entry authoritative over valid inline lineage', () => {
|
||||
const parent = makeWorktree({ id: 'repo-1::/parent', instanceId: 'parent' })
|
||||
const nested = makeWorktree({ id: 'repo-1::/nested', instanceId: 'nested' })
|
||||
const inlineNested = {
|
||||
...nested,
|
||||
lineage: makeWorktreeLineage(nested, parent)
|
||||
} as Worktree
|
||||
|
||||
const result = getAttachedWorktreesForFolderWorkspace({
|
||||
activeWorkspaceKey: folderWorkspaceKey('folder-1'),
|
||||
activeWorktreeId: null,
|
||||
folderWorkspaces: [makeFolder()],
|
||||
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
|
||||
worktreeLineageById: {
|
||||
[nested.id]: {
|
||||
...makeWorktreeLineage(nested, parent),
|
||||
parentWorktreeInstanceId: 'stale-parent'
|
||||
}
|
||||
},
|
||||
worktreesByRepo: { 'repo-1': [parent, inlineNested] }
|
||||
})
|
||||
|
||||
expect(result.lineageChildrenByParentId.size).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects nested descendants across host or project boundaries', () => {
|
||||
const parent = makeWorktree({
|
||||
id: 'repo-1::/parent',
|
||||
instanceId: 'parent',
|
||||
hostId: LOCAL_EXECUTION_HOST_ID,
|
||||
projectId: 'project-1'
|
||||
})
|
||||
const hostChild = makeWorktree({
|
||||
id: 'repo-1::/host-child',
|
||||
instanceId: 'host-child',
|
||||
hostId: toSshExecutionHostId('other')
|
||||
})
|
||||
const projectChild = makeWorktree({
|
||||
id: 'repo-1::/project-child',
|
||||
instanceId: 'project-child',
|
||||
projectId: 'project-2'
|
||||
})
|
||||
|
||||
const result = getAttachedWorktreesForFolderWorkspace({
|
||||
activeWorkspaceKey: folderWorkspaceKey('folder-1'),
|
||||
activeWorktreeId: null,
|
||||
folderWorkspaces: [makeFolder()],
|
||||
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
|
||||
worktreeLineageById: {
|
||||
[hostChild.id]: makeWorktreeLineage(hostChild, parent),
|
||||
[projectChild.id]: makeWorktreeLineage(projectChild, parent)
|
||||
},
|
||||
worktreesByRepo: { 'repo-1': [parent, hostChild, projectChild] }
|
||||
})
|
||||
|
||||
expect(result.lineageChildrenByParentId.size).toBe(0)
|
||||
})
|
||||
|
||||
it('does not attach cyclic legacy descendants', () => {
|
||||
const parent = makeWorktree({ id: 'repo-1::/parent', instanceId: 'parent' })
|
||||
const nested = makeWorktree({ id: 'repo-1::/nested', instanceId: 'nested' })
|
||||
|
||||
const result = getAttachedWorktreesForFolderWorkspace({
|
||||
activeWorkspaceKey: folderWorkspaceKey('folder-1'),
|
||||
activeWorktreeId: null,
|
||||
folderWorkspaces: [makeFolder()],
|
||||
workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) },
|
||||
worktreeLineageById: {
|
||||
[parent.id]: makeWorktreeLineage(parent, nested),
|
||||
[nested.id]: makeWorktreeLineage(nested, parent)
|
||||
},
|
||||
worktreesByRepo: { 'repo-1': [parent, nested] }
|
||||
})
|
||||
|
||||
expect(result.lineageChildrenByParentId.size).toBe(0)
|
||||
expect(result.rootChildWorktrees.map((worktree) => worktree.id)).toEqual([parent.id])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
WorkspaceLineage
|
||||
} from '../../../../shared/types'
|
||||
import { compareWorktreeDisplayName } from '@/lib/worktree-display-name-order'
|
||||
import { getProjectedWorktreeLineageChildrenByParentId } from '../sidebar/worktree-lineage-projection'
|
||||
|
||||
export type AttachedWorktreeResolution = {
|
||||
folderWorkspace: FolderWorkspace | null
|
||||
@@ -90,39 +91,30 @@ export function getLineageChildrenByParentId(
|
||||
worktreeById: Map<string, Worktree>,
|
||||
rootWorktreeIds: ReadonlySet<string>
|
||||
): Map<string, Worktree[]> {
|
||||
const descendantsByParentId = new Map<string, Worktree[]>()
|
||||
const projectedChildrenByParentId = getProjectedWorktreeLineageChildrenByParentId(
|
||||
lineageById,
|
||||
worktreeById
|
||||
)
|
||||
const includedIds = new Set(rootWorktreeIds)
|
||||
let added = true
|
||||
|
||||
while (added) {
|
||||
added = false
|
||||
for (const lineage of Object.values(lineageById)) {
|
||||
const parent = worktreeById.get(lineage.parentWorktreeId)
|
||||
const child = worktreeById.get(lineage.worktreeId)
|
||||
if (!isValidLineageChild(parent, child, lineage, includedIds)) {
|
||||
const queue = [...rootWorktreeIds]
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
for (const child of projectedChildrenByParentId.get(queue[index]) ?? []) {
|
||||
if (child.isArchived || includedIds.has(child.id)) {
|
||||
continue
|
||||
}
|
||||
includedIds.add(child.id)
|
||||
added = true
|
||||
queue.push(child.id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const worktreeId of includedIds) {
|
||||
const child = worktreeById.get(worktreeId)
|
||||
if (!child) {
|
||||
continue
|
||||
const descendantsByParentId = new Map<string, Worktree[]>()
|
||||
for (const parentId of includedIds) {
|
||||
const children = (projectedChildrenByParentId.get(parentId) ?? []).filter(
|
||||
(child) => includedIds.has(child.id) && !child.isArchived
|
||||
)
|
||||
if (children.length > 0) {
|
||||
descendantsByParentId.set(parentId, children)
|
||||
}
|
||||
const lineage = lineageById[child.id]
|
||||
if (!lineage || !includedIds.has(lineage.parentWorktreeId)) {
|
||||
continue
|
||||
}
|
||||
const parent = worktreeById.get(lineage.parentWorktreeId)
|
||||
if (!isCurrentLineagePair(parent, child, lineage)) {
|
||||
continue
|
||||
}
|
||||
const children = descendantsByParentId.get(parent.id) ?? []
|
||||
children.push(child)
|
||||
descendantsByParentId.set(parent.id, children)
|
||||
}
|
||||
|
||||
for (const children of descendantsByParentId.values()) {
|
||||
@@ -160,39 +152,6 @@ function getLineageChildWorktree(
|
||||
return worktree
|
||||
}
|
||||
|
||||
function isValidLineageChild(
|
||||
parent: Worktree | undefined,
|
||||
child: Worktree | undefined,
|
||||
lineage: WorktreeLineage,
|
||||
includedIds: ReadonlySet<string>
|
||||
): child is Worktree {
|
||||
if (
|
||||
!parent ||
|
||||
!child ||
|
||||
parent.isArchived ||
|
||||
child.isArchived ||
|
||||
!includedIds.has(parent.id) ||
|
||||
includedIds.has(child.id)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return isCurrentLineagePair(parent, child, lineage)
|
||||
}
|
||||
|
||||
function isCurrentLineagePair(
|
||||
parent: Worktree | undefined,
|
||||
child: Worktree,
|
||||
lineage: WorktreeLineage
|
||||
): parent is Worktree {
|
||||
return Boolean(
|
||||
parent &&
|
||||
!parent.isArchived &&
|
||||
!child.isArchived &&
|
||||
child.instanceId === lineage.worktreeInstanceId &&
|
||||
parent.instanceId === lineage.parentWorktreeInstanceId
|
||||
)
|
||||
}
|
||||
|
||||
function sortWorktreesByRecentActivity(left: Worktree, right: Worktree): number {
|
||||
return (
|
||||
getWorktreeActivityTime(right) - getWorktreeActivityTime(left) ||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import React from 'react'
|
||||
import { GitMerge } from 'lucide-react'
|
||||
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { PullRequestIcon } from './checks-panel-content'
|
||||
|
||||
function hostedReviewStateClass(review: HostedReviewInfo): string {
|
||||
if (review.state === 'merged') {
|
||||
return 'text-purple-500/80'
|
||||
}
|
||||
if (review.state === 'open') {
|
||||
return 'text-emerald-500/80'
|
||||
}
|
||||
if (review.state === 'closed') {
|
||||
return 'text-muted-foreground/60'
|
||||
}
|
||||
return 'text-muted-foreground/50'
|
||||
}
|
||||
|
||||
export function HostedReviewIcon({
|
||||
review,
|
||||
className
|
||||
}: {
|
||||
review: HostedReviewInfo
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
const Icon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon
|
||||
return <Icon className={cn(className, hostedReviewStateClass(review))} />
|
||||
}
|
||||
|
||||
function hostedReviewLabel(review: HostedReviewInfo): string {
|
||||
return `${review.provider === 'gitlab' ? 'MR' : 'PR'} #${review.number}`
|
||||
}
|
||||
|
||||
export function HostedReviewHeaderLink({
|
||||
review,
|
||||
onOpenHostedReviewInChecks
|
||||
}: {
|
||||
review: HostedReviewInfo
|
||||
onOpenHostedReviewInChecks: () => void
|
||||
}): React.JSX.Element {
|
||||
const label = hostedReviewLabel(review)
|
||||
const className =
|
||||
'shrink-0 border-0 bg-transparent p-0 text-left font-medium leading-none text-foreground underline decoration-border underline-offset-2 opacity-80 hover:text-foreground hover:decoration-foreground'
|
||||
|
||||
if (review.provider === 'github' || review.provider === 'gitlab') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// Why: GitHub PR and GitLab MR details live in Orca's Checks tab; keep
|
||||
// the sidebar workflow in-app instead of opening the browser.
|
||||
onOpenHostedReviewInChecks()
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={review.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={className}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user