fix(mobile): never open an unreachable server workspace, and prove the relay in e2e

A server workspace the phone can't reach now gets one explicit screen at its route
and no row tap or action-sheet entry opens it; native routes read the route's host,
a census pins every workspace route, and the e2e reads files and git on the server.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-10-08 03:33:46 -04:00
parent cf07784936
commit c679558074
25 changed files with 424 additions and 140 deletions
+13 -6
View File
@@ -1,4 +1,8 @@
import { withWorkspaceRoute } from '../../../../src/navigation/workspace-route'
import {
useWorkspaceExecutionHost,
workspaceRouteParams
} from '../../../../src/navigation/workspace-execution-host'
import { useLocalSearchParams } from 'expo-router'
import { MobileDiffReviewRouteScreen } from '../../../../src/session/MobileDiffReviewRouteScreen'
import { firstReviewParam } from '../../../../src/session/mobile-diff-review-screen-model'
@@ -32,20 +36,23 @@ export default withWorkspaceRoute(function MobileDiffReviewScreen() {
scope?: string | string[]
file?: string | string[]
area?: string | string[]
executionHost?: string | string[]
}>()
const hostId = firstReviewParam(params.hostId)
const worktreeId = firstReviewParam(params.worktreeId)
const executionHost = useWorkspaceExecutionHost()
const native = <MobileDiffReviewRouteScreen />
// The four query params are read by the screen itself, so they are carried across whole rather
// than re-derived here; each is omitted when empty, because the screen's own normalizers treat an
// absent scope, file or area differently from one named nothing.
const routeParams = Object.fromEntries(
(['name', 'scope', 'file', 'area', 'executionHost'] as const)
.map((key) => [key, firstReviewParam(params[key])] as const)
.filter(([, value]) => value !== '')
)
const routeParams: Record<string, string> = {
...Object.fromEntries(
(['name', 'scope', 'file', 'area'] as const)
.map((key) => [key, firstReviewParam(params[key])] as const)
.filter(([, value]) => value !== '')
),
...workspaceRouteParams(executionHost)
}
const route =
hostId && worktreeId
? shellScreenRoute({
+13 -6
View File
@@ -1,4 +1,8 @@
import { withWorkspaceRoute } from '../../../../src/navigation/workspace-route'
import {
useWorkspaceExecutionHost,
workspaceRouteParams
} from '../../../../src/navigation/workspace-execution-host'
import { useCallback } from 'react'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { MobileSessionRouteScreen } from '../../../../src/session/MobileSessionRouteScreen'
@@ -39,10 +43,10 @@ export default withWorkspaceRoute(function MobileSessionScreen() {
created?: string | string[]
warning?: string | string[]
paneKey?: string | string[]
executionHost?: string | string[]
}>()
const hostId = firstParam(params.hostId)
const worktreeId = firstParam(params.worktreeId)
const executionHost = useWorkspaceExecutionHost()
const router = useRouter()
const native = <MobileSessionRouteScreen />
const paneKey = firstParam(params.paneKey) ?? ''
@@ -66,11 +70,14 @@ export default withWorkspaceRoute(function MobileSessionScreen() {
// the create flow sets to `1`, `warning` is the host's own text, `name` is a label the screen
// otherwise derives from the workspace, and `paneKey` empty is exactly what the notification hook
// writes back to say the tap is spent.
const routeParams = Object.fromEntries(
(['name', 'created', 'warning', 'paneKey', 'executionHost'] as const)
.map((key) => [key, firstParam(params[key])] as const)
.filter(([, value]) => value !== '')
)
const routeParams: Record<string, string> = {
...Object.fromEntries(
(['name', 'created', 'warning', 'paneKey'] as const)
.map((key) => [key, firstParam(params[key])] as const)
.filter(([, value]) => value !== '')
),
...workspaceRouteParams(executionHost)
}
const route =
hostId && worktreeId
? shellScreenRoute({
@@ -33,6 +33,28 @@ describe('the worktree row action sheet', () => {
])
})
it('opens a server workspace on its server, and offers nothing when that server is unreachable', () => {
const forHost = (executionHost: 'runtime:vm' | null) => {
const targets: string[] = []
const actions = buildWorktreeNavigationActions({
hostId: 'host-a',
worktreeId: 'wt-1',
worktreeName: 'w',
executionHost,
hostCapabilities: [MOBILE_AI_VAULT_CAPABILITY],
navigate: (target) => targets.push(target),
onDone: () => {}
})
actions.forEach((action) => action.onPress())
return targets
}
expect(forHost('runtime:vm')).toEqual([
'/h/host-a/source-control/wt-1?name=w&origin=host&executionHost=runtime%3Avm',
'/h/host-a/agent-history/wt-1?name=w&executionHost=runtime%3Avm'
])
expect(forHost(null)).toEqual([])
})
it('offers only source control on a host that does not', () => {
const actions = buildWorktreeNavigationActions({
hostId: 'host-a',
@@ -10,8 +10,8 @@ type Args = {
hostId: string
worktreeId: string
worktreeName: string
/** The row's server when it runs on one, so the opened screen works there. */
executionHost?: ExecutionHostId
/** The row's server when it runs on one, so the opened screen works there; null when unreachable. */
executionHost?: ExecutionHostId | null
hostCapabilities: readonly string[]
navigate: (target: string) => void
onDone: () => void
@@ -23,6 +23,10 @@ type Args = {
// navigate to a screen that would call a missing RPC method. Extracted from the
// host index action sheet to keep that file under its max-lines budget.
export function buildWorktreeNavigationActions(args: Args): ActionSheetAction[] {
if (args.executionHost === null) {
return []
}
const executionHost = args.executionHost
const actions: ActionSheetAction[] = [
{
label: 'Source Control',
@@ -32,7 +36,7 @@ export function buildWorktreeNavigationActions(args: Args): ActionSheetAction[]
args.navigate(
workspaceRouteHref(
`/h/${encodeURIComponent(args.hostId)}/source-control/${encodeURIComponent(args.worktreeId)}?${params.toString()}`,
args.executionHost
executionHost
)
)
args.onDone()
@@ -49,7 +53,7 @@ export function buildWorktreeNavigationActions(args: Args): ActionSheetAction[]
args.navigate(
workspaceRouteHref(
`/h/${encodeURIComponent(args.hostId)}/agent-history/${encodeURIComponent(args.worktreeId)}?${params.toString()}`,
args.executionHost
executionHost
)
)
args.onDone()
+3 -1
View File
@@ -10,6 +10,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'
import { ChevronLeft, X } from 'lucide-react-native'
import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
import { useForceReconnect } from '../transport/client-context'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import { connectionRetryAction } from '../transport/connection-retry-action'
import { getWorktreeLabel } from '../session/worktree-label'
@@ -44,7 +45,8 @@ export function MobileFileExplorerPanel(props: {
}) {
const { hostId, worktreeId, name, embedded, onRequestClose } = props
const router = useWorkspaceRouteHandoff()
const { client, state: connState, forceReconnect } = useWorkspaceClient(hostId)
const { client, state: connState } = useWorkspaceClient(hostId)
const forceReconnect = useForceReconnect()
const scopeRef = useRef('')
const scope = `${hostId}:${worktreeId}`
scopeRef.current = scope
+3 -1
View File
@@ -5,6 +5,7 @@ import { ChevronLeft, Save } from 'lucide-react-native'
import { useRouteHandoff } from '../navigation/route-handoff'
import { getWorktreeLabel } from '../session/worktree-label'
import { colors, spacing } from '../theme/mobile-theme'
import { useForceReconnect } from '../transport/client-context'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import { connectionRetryAction } from '../transport/connection-retry-action'
import {
@@ -37,7 +38,8 @@ type Props = {
export function MobileFilePreviewScreen({ route }: Props) {
const router = useRouteHandoff()
const previewParams = route.ok ? route.params : null
const { client, state: connState, forceReconnect } = useWorkspaceClient(previewParams?.hostId)
const { client, state: connState } = useWorkspaceClient(previewParams?.hostId)
const forceReconnect = useForceReconnect()
const [preview, setPreview] = useState<MobileFilePreviewResult>(() =>
route.ok ? { status: 'loading', message: 'Loading preview...' } : previewError(route.message)
)
@@ -2,7 +2,7 @@ import { openExternalLink } from '../platform/external-link'
import { Pressable, Text, View } from 'react-native'
import { Check, Moon } from 'lucide-react-native'
import { buildWorktreeNavigationActions } from '../agent-history/worktree-navigation-actions'
import { canTargetExecutionHost } from '../transport/execution-host-scoped-rpc-client'
import { workspaceRouteExecutionHost } from '../transport/execution-host-scoped-rpc-client'
import { ActionSheetContent } from '../components/ActionSheetModal'
import { BottomDrawer } from '../components/BottomDrawer'
import { ConfirmModal } from '../components/ConfirmModal'
@@ -165,11 +165,11 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
hostId,
worktreeId: actionTarget.worktreeId,
worktreeName: actionTarget.displayName || actionTarget.repo,
executionHost:
client &&
canTargetExecutionHost(client, hostCapabilities, actionTarget.hostId)
? actionTarget.hostId
: undefined,
executionHost: workspaceRouteExecutionHost(
client,
hostCapabilities,
actionTarget.hostId
),
hostCapabilities,
navigate: actions.navigateFromHostList,
onDone: () => state.setActionTarget(null)
@@ -290,6 +290,16 @@ describe("a desktop's server workspaces on the phone", () => {
expect(screen.notice()).toBe('Update Orca on ThinkPad to open its workspaces from your phone.')
})
it('opens nothing for a listed server workspace once the phone can no longer reach it', async () => {
const screen = await mountScreen(desktop(), RELAYS)
const oldShell = desktop({ carriesExecutionHost: false })
await screen.swapClient(oldShell)
expect(screen.rows().map((entry) => entry.worktreeId)).toContain('runtime:vm-wt')
screen.open('runtime:vm-wt')
expect(screen.navigations).toEqual([])
expect(calledMethods(oldShell)).not.toContain('worktree.activate')
})
it('pins a server workspace on that server, as the desktop does', async () => {
const client = desktop()
const screen = await mountScreen(client, RELAYS)
@@ -7,8 +7,8 @@ import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle'
import { isPageHostRemovalUnavailable } from '../transport/page-host-removal-refusal'
import type { RpcClient } from '../transport/rpc-client'
import {
canTargetExecutionHost,
rpcClientForExecutionHost
rpcClientForExecutionHost,
workspaceRouteExecutionHost
} from '../transport/execution-host-scoped-rpc-client'
import { serverUpdateNeededNotice } from '../host-route-notice'
import type { ConnectionState } from '../transport/types'
@@ -231,6 +231,10 @@ export function useHostWorktreeActions(args: {
setServerNotice(serverUpdateNeededNotice(server.label))
return
}
const routeHost = workspaceRouteExecutionHost(client, hostCapabilities, item.hostId)
if (routeHost === null) {
return
}
setOptimisticActiveWorktreeIdentity(getWorktreeRowIdentity(item))
const rowClient = clientForRow(item)
if (rowClient && connState === 'connected') {
@@ -245,10 +249,7 @@ export function useHostWorktreeActions(args: {
// `?? ''` and not a cast: the hook takes `hostId` optional and every other member guards it,
// so an absent one builds `/h//session/...` — a pathname the shell's segment rule refuses —
// rather than the string "undefined", which it would accept as a host named undefined.
const executionHost =
client && canTargetExecutionHost(client, hostCapabilities, item.hostId)
? `&executionHost=${encodeURIComponent(item.hostId)}`
: ''
const executionHost = routeHost ? `&executionHost=${encodeURIComponent(routeHost)}` : ''
const target = `/h/${encodeURIComponent(hostId ?? '')}/session/${encodeURIComponent(item.worktreeId)}?name=${encodeURIComponent(item.displayName || item.repo)}${executionHost}`
navigateFromHostList(target)
},
@@ -26,13 +26,19 @@ import { colors, radii, spacing, typography } from '../theme/mobile-theme'
* `.web.tsx` sibling, and there a bare replace navigates inside the WebView to a route the page
* does not carry instead of leaving it. The seam posts the target to the shell.
*/
export function PageRouteUnavailableScreen({ hostId }: { hostId: string }) {
export function PageRouteUnavailableScreen({
hostId,
message = 'This workspace screen is not available on this host.'
}: {
hostId: string
message?: string
}) {
const router = useRouteHandoff()
const target = hostId === '' ? '/' : hostStackHostRoute(hostId)
const label = hostId === '' ? 'Back to hosts' : 'Back to workspaces'
return (
<View style={styles.root} testID="mobile-web-page-route-unavailable">
<Text style={styles.message}>This workspace screen is not available on this host.</Text>
<Text style={styles.message}>{message}</Text>
<Pressable
style={({ pressed }) => [styles.button, pressed && styles.pressed]}
accessibilityRole="button"
@@ -35,6 +35,10 @@ vi.mock('react-native', () => ({
View: 'View'
}))
// The workspace route reads the desktop's connection to decide whether a server is reachable.
vi.mock('../transport/client-context', () => ({
useHostClient: () => ({ client: null, state: 'disconnected' })
}))
vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params }))
vi.mock('../agent-history/MobileAgentSessionHistoryPanel', () => ({
@@ -37,6 +37,10 @@ vi.mock('react-native', () => ({
View: 'View'
}))
// The workspace route reads the desktop's connection to decide whether a server is reachable.
vi.mock('../transport/client-context', () => ({
useHostClient: () => ({ client: null, state: 'disconnected' })
}))
vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params }))
vi.mock('../files/MobileFileExplorerPanel', () => ({
@@ -38,6 +38,10 @@ vi.mock('react-native', () => ({
View: 'View'
}))
// The workspace route reads the desktop's connection to decide whether a server is reachable.
vi.mock('../transport/client-context', () => ({
useHostClient: () => ({ client: null, state: 'disconnected' })
}))
vi.mock('expo-router', () => ({
useLocalSearchParams: () => dependencies.params,
useRouter: () => ({
@@ -56,6 +56,10 @@ vi.mock('react-native', () => ({
View: 'View'
}))
// The workspace route reads the desktop's connection to decide whether a server is reachable.
vi.mock('../transport/client-context', () => ({
useHostClient: () => ({ client: null, state: 'disconnected' })
}))
vi.mock('expo-router', () => ({
Redirect: 'Redirect',
useLocalSearchParams: () => dependencies.params,
@@ -10,24 +10,22 @@ const route = vi.hoisted(() => {
navigate: (href: unknown) => opened.push(['navigate', href]),
back: () => opened.push(['back'])
}
const state: { params: Record<string, string>; opened: unknown[]; router: typeof router } = {
params: {},
const state: { opened: unknown[]; router: typeof router } = {
opened,
router
}
return state
})
vi.mock('expo-router', () => ({ useLocalSearchParams: () => route.params }))
vi.mock('./route-handoff', () => ({ useRouteHandoff: () => route.router }))
import { useWorkspaceRouteHandoff } from './workspace-route-handoff'
import { WorkspaceRoute } from './workspace-route'
import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
import { WorkspaceExecutionHostContext } from './workspace-execution-host'
async function workspaceRouterFor(
params: Record<string, string>
): Promise<ReturnType<typeof useWorkspaceRouteHandoff>> {
route.params = params
route.opened.length = 0
const held: { router: ReturnType<typeof useWorkspaceRouteHandoff> | null } = { router: null }
function Probe(): null {
@@ -35,7 +33,13 @@ async function workspaceRouterFor(
return null
}
await act(async () => {
create(createElement(WorkspaceRoute, null, createElement(Probe)))
create(
createElement(
WorkspaceExecutionHostContext.Provider,
{ value: normalizeExecutionHostId(params.executionHost) ?? undefined },
createElement(Probe)
)
)
})
if (!held.router) {
throw new Error('probe did not render')
@@ -1,56 +0,0 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
const seen = vi.hoisted(() => {
const hosts: (string | undefined)[] = []
return hosts
})
vi.mock('expo-router', () => ({
useLocalSearchParams: () => ({
hostId: 'host-1',
worktreeId: 'wt-1',
relativePath: 'README.md',
executionHost: 'runtime:vm'
})
}))
// Each workspace screen stands in as a probe reporting the server its subtree names.
vi.mock('../files/MobileFileExplorerPanel', () => ({ MobileFileExplorerPanel: Probe }))
vi.mock('../files/MobileFilePreviewScreen', () => ({ MobileFilePreviewScreen: Probe }))
vi.mock('../source-control/MobileSourceControlPanel', () => ({ MobileSourceControlPanel: Probe }))
vi.mock('../session/MobileDiffReviewRouteScreen', () => ({ MobileDiffReviewRouteScreen: Probe }))
vi.mock('../session/MobileSessionRouteScreen', () => ({ MobileSessionRouteScreen: Probe }))
vi.mock('../agent-history/MobileAgentSessionHistoryPanel', () => ({
MobileAgentSessionHistoryPanel: Probe
}))
import { useWorkspaceExecutionHost } from './workspace-execution-host'
function Probe(): null {
seen.push(useWorkspaceExecutionHost())
return null
}
const SCREENS = {
files: () => import('../../app/h/[hostId]/files/[worktreeId].web'),
'file preview': () => import('../../app/h/[hostId]/files/preview/[worktreeId].web'),
'source control': () => import('../../app/h/[hostId]/source-control/[worktreeId].web'),
review: () => import('../../app/h/[hostId]/review/[worktreeId].web'),
session: () => import('../../app/h/[hostId]/session/[worktreeId].web'),
'agent history': () => import('../../app/h/[hostId]/agent-history/[worktreeId].web')
}
describe('a workspace route', () => {
it.each(Object.entries(SCREENS))(
'names its server to everything under the %s screen',
async (_name, load) => {
const Screen = (await load()).default
seen.length = 0
await act(async () => {
create(createElement(Screen))
})
expect(seen).toEqual(['runtime:vm'])
}
)
})
@@ -0,0 +1,173 @@
import { readdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { createElement, type ReactElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
const route = vi.hoisted(() => {
const state: {
params: Record<string, string>
hostCapabilities: string[]
statusPending: boolean
seen: (string | undefined)[]
shellRoutes: { params?: Record<string, string> }[]
redirects: { params: Record<string, string | undefined> }[]
} = {
params: {},
hostCapabilities: [],
statusPending: false,
seen: [],
shellRoutes: [],
redirects: []
}
return state
})
vi.mock('expo-router', () => ({
useLocalSearchParams: () => route.params,
useRouter: () => ({ setParams: () => {}, replace: () => {} }),
Redirect: ({ href }: { href: { params: Record<string, string | undefined> } }) => {
route.redirects.push(href)
return null
}
}))
vi.mock('react-native', () => ({
StyleSheet: { create: (styles: unknown) => styles },
View: 'View',
Text: 'Text',
Pressable: 'Pressable'
}))
vi.mock('../transport/client-context', () => ({
useHostClient: () => ({ client: {}, state: 'connected' })
}))
vi.mock('../components/host-protocol-gates-context', () => ({
useOptionalHostProtocolGates: () => ({
hostCapabilities: route.hostCapabilities,
statusPending: route.statusPending
})
}))
vi.mock('./route-handoff', () => ({ useRouteHandoff: () => ({ replace: () => {} }) }))
// The native switch always hands the page its route, so the census reads what the page is told.
vi.mock('../mobile-web-shell/shell-switch-decision', () => ({
useShellSwitchDecision: (shellRoute: unknown) =>
shellRoute ? { kind: 'shell', route: shellRoute } : { kind: 'native' }
}))
vi.mock('../mobile-web-shell/ShellSwitchPendingScreen', () => ({
ShellSwitchPendingScreen: () => null
}))
vi.mock('../mobile-web-shell/MobileWebShellScreen', () => ({
MobileWebShellScreen: ({ route: shellRoute }: { route: { params?: Record<string, string> } }) => {
route.shellRoutes.push(shellRoute)
return createElement(Probe)
}
}))
// Each workspace screen stands in as a probe reporting the server its subtree names.
vi.mock('../files/MobileFileExplorerPanel', () => ({ MobileFileExplorerPanel: Probe }))
vi.mock('../files/MobileFilePreviewScreen', () => ({ MobileFilePreviewScreen: Probe }))
vi.mock('../source-control/MobileSourceControlPanel', () => ({ MobileSourceControlPanel: Probe }))
vi.mock('../session/MobileDiffReviewRouteScreen', () => ({ MobileDiffReviewRouteScreen: Probe }))
vi.mock('../session/MobileSessionRouteScreen', () => ({ MobileSessionRouteScreen: Probe }))
vi.mock('../agent-history/MobileAgentSessionHistoryPanel', () => ({
MobileAgentSessionHistoryPanel: Probe
}))
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
import { useWorkspaceExecutionHost } from './workspace-execution-host'
import { WorkspaceRoute } from './workspace-route'
function Probe(): null {
route.seen.push(useWorkspaceExecutionHost())
return null
}
const HOST_ROUTES = fileURLToPath(new URL('../../app/h/[hostId]/', import.meta.url))
// Every screen keyed by a workspace, native and page, found on disk so a new one is counted.
const WORKSPACE_ROUTE_FILES = readdirSync(HOST_ROUTES, { recursive: true, encoding: 'utf8' })
.map((entry) => entry.replaceAll('\\', '/'))
.filter((entry) => /(^|\/)\[worktreeId\](\.web)?\.tsx$/.test(entry))
.sort()
async function render(element: ReactElement): Promise<ReactTestRenderer> {
route.seen.length = 0
route.shellRoutes.length = 0
route.redirects.length = 0
let renderer: ReactTestRenderer | null = null
await act(async () => {
renderer = create(element)
})
if (!renderer) {
throw new Error('did not render')
}
return renderer
}
function texts(renderer: ReactTestRenderer): string[] {
return renderer.root
.findAll(() => true)
.flatMap((node) => node.children.filter((child) => typeof child === 'string'))
}
describe('every workspace route', () => {
it('is found on disk', () => {
expect(WORKSPACE_ROUTE_FILES.length).toBeGreaterThanOrEqual(14)
})
it.each(WORKSPACE_ROUTE_FILES)('keeps %s on the server its route names', async (file) => {
route.params = {
hostId: 'host-1',
worktreeId: 'wt-1',
relativePath: 'README.md',
// Spelled loosely, so a screen reading the raw param instead of its route is caught.
executionHost: ' runtime:vm '
}
route.hostCapabilities = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY]
route.statusPending = false
const Screen = (await import(/* @vite-ignore */ `${HOST_ROUTES}${file}`)).default
await render(createElement(Screen))
if (route.redirects.length > 0) {
expect(route.redirects.map((href) => href.params.executionHost?.trim())).toEqual([
'runtime:vm'
])
return
}
expect(route.seen).toEqual(['runtime:vm'])
for (const shellRoute of route.shellRoutes) {
expect(shellRoute.params?.executionHost).toBe('runtime:vm')
}
})
})
describe('a server workspace the phone cannot reach', () => {
const child = createElement(Probe)
it('says so once the desktop has answered, instead of waiting forever', async () => {
route.params = { hostId: 'host-1', executionHost: 'runtime:vm' }
route.hostCapabilities = []
route.statusPending = false
const renderer = await render(createElement(WorkspaceRoute, null, child))
expect(route.seen).toEqual([])
expect(texts(renderer)).toContain(
"Your phone can't reach this workspace's server through this desktop."
)
})
it('waits while the desktop has not answered, and opens a reachable one', async () => {
route.params = { hostId: 'host-1', executionHost: 'runtime:vm' }
route.hostCapabilities = []
route.statusPending = true
await render(createElement(WorkspaceRoute, null, child))
expect(route.seen).toEqual(['runtime:vm'])
route.hostCapabilities = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY]
route.statusPending = false
await render(createElement(WorkspaceRoute, null, child))
expect(route.seen).toEqual(['runtime:vm'])
})
it('never stands in for the desktop’s own workspaces', async () => {
route.params = { hostId: 'host-1' }
route.hostCapabilities = []
route.statusPending = false
await render(createElement(WorkspaceRoute, null, child))
expect(route.seen).toEqual([undefined])
})
})
+25 -2
View File
@@ -3,14 +3,37 @@ import { useLocalSearchParams } from 'expo-router'
import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
import { firstParam } from './route-param-reader'
import { WorkspaceExecutionHostContext } from './workspace-execution-host'
import { useOptionalHostProtocolGates } from '../components/host-protocol-gates-context'
import { PageRouteUnavailableScreen } from '../mobile-web-shell/PageRouteUnavailableScreen'
import { useHostClient } from '../transport/client-context'
import { workspaceRouteExecutionHost } from '../transport/execution-host-scoped-rpc-client'
const UNREACHABLE_SERVER_MESSAGE =
"Your phone can't reach this workspace's server through this desktop."
/**
* A workspace route's server, read once from its `executionHost` param: every call made and every
* route pushed below it stays on that server. Absent for the desktop's own workspaces.
* route pushed below it stays on that server. Absent for the desktop's own workspaces; a server
* this phone can't reach (older desktop or shell, unreadable status) gets one explicit dead end.
*/
export function WorkspaceRoute({ children }: { children: ReactNode }) {
const params = useLocalSearchParams<{ executionHost?: string | string[] }>()
const params = useLocalSearchParams<{
hostId?: string | string[]
executionHost?: string | string[]
}>()
const hostId = firstParam(params.hostId)
const executionHost = normalizeExecutionHostId(firstParam(params.executionHost)) ?? undefined
const { client, state } = useHostClient(hostId)
const gates = useOptionalHostProtocolGates()
// Why: once the desktop has answered, a server it can't reach for this phone is a dead end, not a spinner.
if (
state === 'connected' &&
gates &&
!gates.statusPending &&
workspaceRouteExecutionHost(client, gates.hostCapabilities, executionHost) === null
) {
return <PageRouteUnavailableScreen hostId={hostId} message={UNREACHABLE_SERVER_MESSAGE} />
}
return (
<WorkspaceExecutionHostContext.Provider value={executionHost}>
{children}
@@ -4,6 +4,7 @@ import { MobileDiffReviewScreenView } from '../components/MobileDiffReviewScreen
import { firstReviewParam, normalizeReviewFilterParam } from './mobile-diff-review-screen-model'
import { normalizeReviewAreaParam } from './mobile-diff-review-positioning'
import { useMobileDiffReviewController } from './use-mobile-diff-review-controller'
import { useForceReconnect } from '../transport/client-context'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import { useHostProtocolGates } from '../components/HostProtocolGate'
import { useWorkspaceRouteHandoff } from '../navigation/workspace-route-handoff'
@@ -42,7 +43,8 @@ export function MobileDiffReviewRouteScreen() {
// Not `useRouter`: inside the shell's page the session screen is native, so that replace has to
// be handed back to the app rather than posted into a document that does not render it.
const router = useWorkspaceRouteHandoff()
const { client, state: connState, forceReconnect } = useWorkspaceClient(hostId)
const { client, state: connState } = useWorkspaceClient(hostId)
const forceReconnect = useForceReconnect()
const openSession = useCallback(() => {
const query = name ? `?${new URLSearchParams({ name }).toString()}` : ''
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useForceReconnect } from '../transport/client-context'
import { useWorkspaceClient } from '../transport/use-workspace-client'
import { getWorktreeLabel } from '../session/worktree-label'
import { useMobileGitRequests } from './use-mobile-git-requests'
@@ -32,8 +33,6 @@ import {
type MobileBranchEntryView
} from './mobile-source-control-screen-state'
type MobileGitLocalBranches = RuntimeGitLocalBranches
export type MobileSourceControlStateParams = {
hostId: string
worktreeId: string
@@ -61,12 +60,13 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
onOpenHistory
} = params
const insets = useSafeAreaInsets()
const { client, state: connState, forceReconnect } = useWorkspaceClient(hostId)
const { client, state: connState } = useWorkspaceClient(hostId)
const forceReconnect = useForceReconnect()
const [busyAction, setBusyAction] = useState<string | null>(null)
const [commitMessage, setCommitMessage] = useState('')
const [generatingMessage, setGeneratingMessage] = useState(false)
const [showBranchPicker, setShowBranchPicker] = useState(false)
const [localBranches, setLocalBranches] = useState<MobileGitLocalBranches | null>(null)
const [localBranches, setLocalBranches] = useState<RuntimeGitLocalBranches | null>(null)
const [createdPrUrl, setCreatedPrUrl] = useState<string | null>(null)
const [createdPrWarning, setCreatedPrWarning] = useState<string | null>(null)
const [discardTarget, setDiscardTarget] = useState<MobileGitStatusEntry | null>(null)
@@ -2,8 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
import {
canTargetExecutionHost,
scopeRpcClientToExecutionHost
scopeRpcClientToExecutionHost,
workspaceRouteExecutionHost
} from './execution-host-scoped-rpc-client'
import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams'
import type { RpcClient } from './rpc-client'
@@ -151,9 +151,11 @@ describe('executionHost on the envelope', () => {
})
function fakeClient(carries?: boolean) {
const sendRequest = vi.fn(
async (): Promise<RpcResponse> => ({ id: 'reply', ok: true, result: null })
)
const sendRequest = vi.fn(async (): Promise<RpcResponse> => ({
id: 'reply',
ok: true,
result: null
}))
const subscribe = vi.fn(() => () => {})
const close = vi.fn()
const client = {
@@ -194,14 +196,15 @@ describe('scopeRpcClientToExecutionHost', () => {
expect(scopeRpcClientToExecutionHost(client, 'runtime:env-2')).not.toBe(view)
})
it('targets only server workspaces on a relaying desktop over a transport that keeps the field', () => {
it('routes a server workspace only through a relaying desktop over a transport that keeps the field', () => {
const relays = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY]
expect(canTargetExecutionHost(fakeClient(), relays, SERVER)).toBe(true)
expect(canTargetExecutionHost(fakeClient(true), relays, SERVER)).toBe(true)
expect(canTargetExecutionHost(fakeClient(false), relays, SERVER)).toBe(false)
expect(canTargetExecutionHost(fakeClient(), [], SERVER)).toBe(false)
expect(canTargetExecutionHost(fakeClient(), relays, 'local')).toBe(false)
expect(canTargetExecutionHost(fakeClient(), relays, 'ssh:box')).toBe(false)
expect(canTargetExecutionHost(fakeClient(), relays, undefined)).toBe(false)
expect(workspaceRouteExecutionHost(fakeClient(), relays, SERVER)).toBe(SERVER)
expect(workspaceRouteExecutionHost(fakeClient(true), relays, SERVER)).toBe(SERVER)
expect(workspaceRouteExecutionHost(fakeClient(false), relays, SERVER)).toBeNull()
expect(workspaceRouteExecutionHost(fakeClient(), [], SERVER)).toBeNull()
expect(workspaceRouteExecutionHost(null, relays, SERVER)).toBeNull()
expect(workspaceRouteExecutionHost(fakeClient(), relays, 'local')).toBeUndefined()
expect(workspaceRouteExecutionHost(fakeClient(), relays, 'ssh:box')).toBeUndefined()
expect(workspaceRouteExecutionHost(fakeClient(), relays, undefined)).toBeUndefined()
})
})
@@ -15,13 +15,19 @@ export function relaysToServers(client: RpcClient, hostCapabilities: readonly st
)
}
/** Local and SSH workspaces are the desktop's own; only a server's are named, and only if reachable. */
export function canTargetExecutionHost(
client: RpcClient,
/**
* The `executionHost` a route into a workspace carries: none for the desktop's own (local, SSH),
* the server for a reachable one, and null for one this phone cannot reach, which must not open.
*/
export function workspaceRouteExecutionHost(
client: RpcClient | null,
hostCapabilities: readonly string[],
executionHost: ExecutionHostId | undefined
): executionHost is `runtime:${string}` {
return executionHost?.startsWith('runtime:') === true && relaysToServers(client, hostCapabilities)
): ExecutionHostId | undefined | null {
if (!executionHost?.startsWith('runtime:')) {
return undefined
}
return client && relaysToServers(client, hostCapabilities) ? executionHost : null
}
/**
@@ -73,10 +79,9 @@ export function rpcClientForExecutionHost(
hostCapabilities: readonly string[],
executionHost: ExecutionHostId | undefined
): RpcClient | null {
if (!executionHost?.startsWith('runtime:')) {
const routeHost = workspaceRouteExecutionHost(client, hostCapabilities, executionHost)
if (routeHost === undefined) {
return client
}
return canTargetExecutionHost(client, hostCapabilities, executionHost)
? scopeRpcClientToExecutionHost(client, executionHost)
: null
return routeHost && scopeRpcClientToExecutionHost(client, routeHost)
}
@@ -3,8 +3,7 @@ import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
const route = vi.hoisted(() => {
const state: { params: Record<string, string>; capabilities: string[] } = {
params: {},
const state: { capabilities: string[] } = {
capabilities: []
}
return state
@@ -14,18 +13,16 @@ const desktop = vi.hoisted(() => {
return state
})
vi.mock('expo-router', () => ({ useLocalSearchParams: () => route.params }))
vi.mock('react-native', () => ({}))
vi.mock('../components/host-protocol-gates-context', () => ({
useOptionalHostProtocolGates: () => ({ hostCapabilities: route.capabilities })
}))
vi.mock('./client-context', () => ({
useHostClient: () => ({ client: desktop.client, clientId: 'client-1', state: 'connected' }),
useForceReconnect: () => () => undefined
useHostClient: () => ({ client: desktop.client, clientId: 'client-1', state: 'connected' })
}))
import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
import { WorkspaceRoute } from '../navigation/workspace-route'
import { WorkspaceExecutionHostContext } from '../navigation/workspace-execution-host'
import { FakeSession } from './mobile-endpoint-supervisor-test-fakes'
import type { RpcClient } from './rpc-client'
import { useWorkspaceClient } from './use-workspace-client'
@@ -34,7 +31,6 @@ async function workspaceClientFor(
params: Record<string, string>,
capabilities: string[]
): Promise<{ client: RpcClient | null; session: FakeSession }> {
route.params = params
route.capabilities = capabilities
const session = new FakeSession('connected')
desktop.client = session
@@ -44,7 +40,13 @@ async function workspaceClientFor(
return null
}
await act(async () => {
create(createElement(WorkspaceRoute, null, createElement(Probe)))
create(
createElement(
WorkspaceExecutionHostContext.Provider,
{ value: normalizeExecutionHostId(params.executionHost) ?? undefined },
createElement(Probe)
)
)
})
return { client: held.client, session }
}
+2 -6
View File
@@ -1,21 +1,17 @@
import { useOptionalHostProtocolGates } from '../components/host-protocol-gates-context'
import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host'
import { useForceReconnect, useHostClient } from './client-context'
import { useHostClient } from './client-context'
import { rpcClientForExecutionHost } from './execution-host-scoped-rpc-client'
const NO_CAPABILITIES: readonly string[] = []
/** `useHostClient` for a workspace screen: its calls run where the workspace does. */
export function useWorkspaceClient(
hostId: string | undefined
): ReturnType<typeof useHostClient> & { forceReconnect: ReturnType<typeof useForceReconnect> } {
export function useWorkspaceClient(hostId: string | undefined): ReturnType<typeof useHostClient> {
const host = useHostClient(hostId)
const hostCapabilities = useOptionalHostProtocolGates()?.hostCapabilities ?? NO_CAPABILITIES
const executionHost = useWorkspaceExecutionHost()
const forceReconnect = useForceReconnect()
return {
...host,
forceReconnect,
client: host.client && rpcClientForExecutionHost(host.client, hostCapabilities, executionHost)
}
}
+57 -2
View File
@@ -3,6 +3,7 @@
* the server's workspaces through the desktop by naming them with `executionHost: runtime:<env>`.
* The desktop relays as the phone's own delegated device on the server.
*/
import { execFileSync } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { expect, test } from './helpers/orca-app'
@@ -22,6 +23,8 @@ const SubscribedSchema = z.looseObject({ type: z.literal('subscribed'), streamId
const HostsSchema = z.object({
hosts: z.array(z.looseObject({ hostId: z.string(), relay: z.string() }))
})
const DirectorySchema = z.array(z.looseObject({ name: z.string() }))
const GitStatusSchema = z.looseObject({ entries: z.array(z.looseObject({ path: z.string() })) })
const HostWorktreesSchema = z.looseObject({
worktrees: z.array(
z.looseObject({ worktreeId: z.string(), path: z.string(), hostId: z.string() })
@@ -132,6 +135,15 @@ test('phone paired with a desktop lists, opens and types into a workspace on its
const serverFolder = testInfo.outputPath('server-folder')
mkdirSync(serverFolder, { recursive: true })
writeFileSync(path.join(serverFolder, 'README.md'), 'server workspace\n')
// A git repo on the server too, so Source Control has a change to read there.
const serverRepo = testInfo.outputPath('server-repo')
mkdirSync(serverRepo, { recursive: true })
writeFileSync(path.join(serverRepo, 'NOTES.md'), 'first\n')
const git = (...args: string[]) => execFileSync('git', args, { cwd: serverRepo, stdio: 'pipe' })
git('init')
git('add', 'NOTES.md')
git('-c', 'user.name=E2E', '-c', 'user.email=e2e@test.local', 'commit', '-m', 'init')
writeFileSync(path.join(serverRepo, 'NOTES.md'), 'changed on the server\n')
const { host, desktop, phone, dispose } = await launchPhoneMirrorTopology(
{ phoneTo: 'desktop', pinnedServePort: true },
@@ -139,6 +151,7 @@ test('phone paired with a desktop lists, opens and types into a workspace on its
)
try {
await host.client.call('repo.add', { path: serverFolder, kind: 'folder' })
await host.client.call('repo.add', { path: serverRepo, kind: 'git' })
await new RuntimeClient(desktop.userDataDir, 5_000).call('repo.add', {
path: testRepoPath,
kind: 'git'
@@ -181,12 +194,45 @@ test('phone paired with a desktop lists, opens and types into a workspace on its
expect(hosts).toContainEqual(expect.objectContaining({ hostId: serverHostId, relay: 'ready' }))
let listed: Awaited<ReturnType<typeof hostWorktrees>> = null
await expect
.poll(async () => (listed = await hostWorktrees(socket, serverHostId))?.stale)
.toBe(false)
.poll(async () => {
listed = await hostWorktrees(socket, serverHostId)
return listed?.stale === false && listed.worktrees.some((row) => row.path === serverRepo)
})
.toBe(true)
const serverRow = listed!.worktrees.find((row) => row.path === serverFolder)
const serverRepoRow = listed!.worktrees.find((row) => row.path === serverRepo)
expect(serverRow, 'the phone lists the server workspace through the desktop').toBeDefined()
expect(new Set(listed!.worktrees.map((row) => row.hostId))).toEqual(new Set([serverHostId]))
// Files and Source Control read the server's workspace there, and never on the desktop.
const readDir = (worktreeId: string) => ({
method: 'files.readDir',
params: { worktree: `id:${worktreeId}`, relativePath: '' }
})
const gitStatus = (worktreeId: string) => ({
method: 'git.status',
params: { worktree: `id:${worktreeId}` }
})
const entries = await call(
socket,
{ id: 'files', ...readDir(serverRow!.worktreeId), executionHost: serverHostId },
DirectorySchema
)
expect(entries.map((entry) => entry.name)).toContain('README.md')
const changes = await call(
socket,
{ id: 'git', ...gitStatus(serverRepoRow!.worktreeId), executionHost: serverHostId },
GitStatusSchema
)
expect(changes.entries.map((entry) => entry.path)).toContain('NOTES.md')
for (const [id, request] of [
['files-desktop', readDir(serverRow!.worktreeId)],
['git-desktop', gitStatus(serverRepoRow!.worktreeId)]
] as const) {
socket.send(id, request.method, request.params)
expect((await reply(socket, id)).ok, `${request.method} untargeted`).toBe(false)
}
const relayed = await openTerminal(socket, serverRow!.worktreeId, serverHostId)
await relayed.runMarker('MIRROR')
const relayedMs: number[] = []
@@ -229,6 +275,15 @@ test('phone paired with a desktop lists, opens and types into a workspace on its
const stopped = await reply(socket, 'ps-stopped', 60_000)
expect(stopped.ok).toBe(false)
expect(stopped.error?.code).toBe('remote_runtime_unavailable')
for (const [id, request] of [
['files-stopped', readDir(serverRow!.worktreeId)],
['git-stopped', gitStatus(serverRepoRow!.worktreeId)]
] as const) {
socket.send(id, request.method, request.params, serverHostId)
const failed = await reply(socket, id, 60_000)
expect(failed.ok, `${request.method} while the server is down`).toBe(false)
expect(failed.error?.code).toBe('remote_runtime_unavailable')
}
}
})
// Back up: the listing is fresh again, and the same phone socket reaches the server.