mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
feat(mobile): gate a server workspace on that server's own features
A targeted status.get now relays to the server, so a server workspace's route provides that server's status to every screen under it and its action sheet, while whether the phone can reach the server stays the desktop's answer. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -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 { workspaceRouteExecutionHost } from '../transport/execution-host-scoped-rpc-client'
|
||||
import { useWorkspaceServer } from '../transport/workspace-server'
|
||||
import { ActionSheetContent } from '../components/ActionSheetModal'
|
||||
import { BottomDrawer } from '../components/BottomDrawer'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
@@ -25,6 +25,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
actions,
|
||||
catalog,
|
||||
client,
|
||||
connState,
|
||||
existingWorktreePaths,
|
||||
hostCapabilities,
|
||||
hostId,
|
||||
@@ -33,6 +34,13 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
state
|
||||
} = controller
|
||||
const actionTarget = state.actionTarget
|
||||
// A server row's screens are offered by what that server supports, not the desktop.
|
||||
const actionServer = useWorkspaceServer({
|
||||
client,
|
||||
connState,
|
||||
desktopCapabilities: hostCapabilities,
|
||||
executionHost: actionTarget?.hostId
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -165,12 +173,9 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
|
||||
hostId,
|
||||
worktreeId: actionTarget.worktreeId,
|
||||
worktreeName: actionTarget.displayName || actionTarget.repo,
|
||||
executionHost: workspaceRouteExecutionHost(
|
||||
client,
|
||||
hostCapabilities,
|
||||
actionTarget.hostId
|
||||
),
|
||||
hostCapabilities,
|
||||
executionHost: actionServer.routeHost,
|
||||
hostCapabilities:
|
||||
actionServer.server?.gates.hostCapabilities ?? hostCapabilities,
|
||||
navigate: actions.navigateFromHostList,
|
||||
onDone: () => state.setActionTarget(null)
|
||||
}),
|
||||
|
||||
@@ -39,6 +39,9 @@ vi.mock('react-native', () => ({
|
||||
vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => ({ client: null, state: 'disconnected' })
|
||||
}))
|
||||
vi.mock('../transport/workspace-server', () => ({
|
||||
useWorkspaceServer: () => ({ routeHost: undefined, server: null })
|
||||
}))
|
||||
vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params }))
|
||||
|
||||
vi.mock('../agent-history/MobileAgentSessionHistoryPanel', () => ({
|
||||
|
||||
@@ -41,6 +41,9 @@ vi.mock('react-native', () => ({
|
||||
vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => ({ client: null, state: 'disconnected' })
|
||||
}))
|
||||
vi.mock('../transport/workspace-server', () => ({
|
||||
useWorkspaceServer: () => ({ routeHost: undefined, server: null })
|
||||
}))
|
||||
vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params }))
|
||||
|
||||
vi.mock('../files/MobileFileExplorerPanel', () => ({
|
||||
|
||||
@@ -42,6 +42,9 @@ vi.mock('react-native', () => ({
|
||||
vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => ({ client: null, state: 'disconnected' })
|
||||
}))
|
||||
vi.mock('../transport/workspace-server', () => ({
|
||||
useWorkspaceServer: () => ({ routeHost: undefined, server: null })
|
||||
}))
|
||||
vi.mock('expo-router', () => ({
|
||||
useLocalSearchParams: () => dependencies.params,
|
||||
useRouter: () => ({
|
||||
|
||||
@@ -60,6 +60,9 @@ vi.mock('react-native', () => ({
|
||||
vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => ({ client: null, state: 'disconnected' })
|
||||
}))
|
||||
vi.mock('../transport/workspace-server', () => ({
|
||||
useWorkspaceServer: () => ({ routeHost: undefined, server: null })
|
||||
}))
|
||||
vi.mock('expo-router', () => ({
|
||||
Redirect: 'Redirect',
|
||||
useLocalSearchParams: () => dependencies.params,
|
||||
|
||||
@@ -7,15 +7,13 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
const route = vi.hoisted(() => {
|
||||
const state: {
|
||||
params: Record<string, string>
|
||||
hostCapabilities: string[]
|
||||
statusPending: boolean
|
||||
client: unknown
|
||||
seen: (string | undefined)[]
|
||||
shellRoutes: { params?: Record<string, string> }[]
|
||||
redirects: { params: Record<string, string | undefined> }[]
|
||||
} = {
|
||||
params: {},
|
||||
hostCapabilities: [],
|
||||
statusPending: false,
|
||||
client: null,
|
||||
seen: [],
|
||||
shellRoutes: [],
|
||||
redirects: []
|
||||
@@ -38,13 +36,12 @@ vi.mock('react-native', () => ({
|
||||
Pressable: 'Pressable'
|
||||
}))
|
||||
vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => ({ client: {}, state: 'connected' })
|
||||
useHostClient: () => ({ client: route.client, state: 'connected' })
|
||||
}))
|
||||
vi.mock('../components/host-protocol-gates-context', () => ({
|
||||
useOptionalHostProtocolGates: () => ({
|
||||
hostCapabilities: route.hostCapabilities,
|
||||
statusPending: route.statusPending
|
||||
})
|
||||
// The server's status is read for real; only the desktop's persisted records are stubbed.
|
||||
vi.mock('../transport/host-app-version-store', () => ({ recordHostAppVersion: async () => {} }))
|
||||
vi.mock('../transport/host-descriptor-recorder', () => ({
|
||||
recordHostDescriptorFromStatus: () => {}
|
||||
}))
|
||||
vi.mock('./route-handoff', () => ({ useRouteHandoff: () => ({ replace: () => {} }) }))
|
||||
// The native switch always hands the page its route, so the census reads what the page is told.
|
||||
@@ -72,14 +69,58 @@ vi.mock('../agent-history/MobileAgentSessionHistoryPanel', () => ({
|
||||
}))
|
||||
|
||||
import { MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY } from '../../../src/shared/mobile-desktop-relay-contract'
|
||||
import {
|
||||
HostStatusGatesContext,
|
||||
useOptionalHostProtocolGates
|
||||
} from '../components/host-protocol-gates-context'
|
||||
import type { HostStatusGates } from '../transport/host-status-gates'
|
||||
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { useWorkspaceClient } from '../transport/use-workspace-client'
|
||||
import { useWorkspaceExecutionHost } from './workspace-execution-host'
|
||||
import { WorkspaceRoute } from './workspace-route'
|
||||
|
||||
const RELAYS = [MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY]
|
||||
const VAULT = 'aiVault.v1'
|
||||
|
||||
/** What a screen under the route sees: its server, its client and its feature gates. */
|
||||
const below: { client: RpcClient | null; capabilities: readonly string[] | null } = {
|
||||
client: null,
|
||||
capabilities: null
|
||||
}
|
||||
|
||||
function Probe(): null {
|
||||
route.seen.push(useWorkspaceExecutionHost())
|
||||
below.client = useWorkspaceClient('host-1').client
|
||||
below.capabilities = useOptionalHostProtocolGates()?.hostCapabilities ?? null
|
||||
return null
|
||||
}
|
||||
|
||||
function desktopGates(hostCapabilities: string[], statusPending = false): HostStatusGates {
|
||||
return {
|
||||
hostCapabilities,
|
||||
floatingWorkspaceEnabled: false,
|
||||
desktopAppVersion: null,
|
||||
compatVerdict: { kind: 'ok' },
|
||||
hostProtocolWindow: { protocolVersion: undefined, minCompatibleMobileVersion: undefined },
|
||||
statusPending,
|
||||
statusReadable: true
|
||||
}
|
||||
}
|
||||
|
||||
/** A desktop whose own status is settled as given; a status it is asked for is the server's. */
|
||||
function desktop(serverCapabilities: string[] = []): FakeSession {
|
||||
const session = new FakeSession('connected')
|
||||
session.sendRequest.mockImplementation(async (method: string) => ({
|
||||
id: 'reply',
|
||||
ok: true,
|
||||
result: method === 'status.get' ? { capabilities: serverCapabilities } : {},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}))
|
||||
route.client = session
|
||||
return session
|
||||
}
|
||||
|
||||
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' })
|
||||
@@ -87,13 +128,15 @@ const WORKSPACE_ROUTE_FILES = readdirSync(HOST_ROUTES, { recursive: true, encodi
|
||||
.filter((entry) => /(^|\/)\[worktreeId\](\.web)?\.tsx$/.test(entry))
|
||||
.sort()
|
||||
|
||||
async function render(element: ReactElement): Promise<ReactTestRenderer> {
|
||||
async function render(element: ReactElement, gates: HostStatusGates): Promise<ReactTestRenderer> {
|
||||
route.seen.length = 0
|
||||
route.shellRoutes.length = 0
|
||||
route.redirects.length = 0
|
||||
below.client = null
|
||||
below.capabilities = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
await act(async () => {
|
||||
renderer = create(element)
|
||||
renderer = create(createElement(HostStatusGatesContext.Provider, { value: gates }, element))
|
||||
})
|
||||
if (!renderer) {
|
||||
throw new Error('did not render')
|
||||
@@ -120,54 +163,78 @@ describe('every workspace route', () => {
|
||||
// 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
|
||||
desktop()
|
||||
const Screen = (await import(/* @vite-ignore */ `${HOST_ROUTES}${file}`)).default
|
||||
await render(createElement(Screen))
|
||||
await render(createElement(Screen), desktopGates(RELAYS))
|
||||
if (route.redirects.length > 0) {
|
||||
expect(route.redirects.map((href) => href.params.executionHost?.trim())).toEqual([
|
||||
'runtime:vm'
|
||||
])
|
||||
return
|
||||
}
|
||||
expect(route.seen).toEqual(['runtime:vm'])
|
||||
expect([...new Set(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)
|
||||
describe('a server workspace', () => {
|
||||
const screen = createElement(WorkspaceRoute, null, createElement(Probe))
|
||||
|
||||
it('says so once the desktop has answered, instead of waiting forever', async () => {
|
||||
it('runs every call on that server', async () => {
|
||||
route.params = { hostId: 'host-1', executionHost: 'runtime:vm' }
|
||||
route.hostCapabilities = []
|
||||
route.statusPending = false
|
||||
const renderer = await render(createElement(WorkspaceRoute, null, child))
|
||||
const session = desktop()
|
||||
await render(screen, desktopGates(RELAYS))
|
||||
await below.client?.sendRequest('files.readDir', { worktree: 'id:wt' })
|
||||
expect(session.sendRequest).toHaveBeenCalledWith(
|
||||
'files.readDir',
|
||||
{ worktree: 'id:wt' },
|
||||
{ executionHost: 'runtime:vm' }
|
||||
)
|
||||
})
|
||||
|
||||
it('gates its features on that server’s own status, not the desktop’s', async () => {
|
||||
route.params = { hostId: 'host-1', executionHost: 'runtime:vm' }
|
||||
const session = desktop([])
|
||||
await render(screen, desktopGates([...RELAYS, VAULT]))
|
||||
await vi.waitFor(() => expect(below.capabilities).toEqual([]))
|
||||
expect(session.sendRequest).toHaveBeenCalledWith('status.get', undefined, {
|
||||
executionHost: 'runtime:vm'
|
||||
})
|
||||
})
|
||||
|
||||
it('says so once the desktop has answered that it cannot reach the server', async () => {
|
||||
route.params = { hostId: 'host-1', executionHost: 'runtime:vm' }
|
||||
const session = desktop()
|
||||
const renderer = await render(screen, desktopGates([]))
|
||||
expect(route.seen).toEqual([])
|
||||
expect(texts(renderer)).toContain(
|
||||
"Your phone can't reach this workspace's server through this desktop."
|
||||
)
|
||||
expect(session.sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits while the desktop has not answered, and opens a reachable one', async () => {
|
||||
it('waits, never on the desktop, while the desktop has not answered', 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'])
|
||||
const session = desktop()
|
||||
await render(screen, desktopGates([], true))
|
||||
expect([...new Set(route.seen)]).toEqual(['runtime:vm'])
|
||||
expect(below.client).toBeNull()
|
||||
expect(session.sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('never stands in for the desktop’s own workspaces', async () => {
|
||||
describe('the desktop’s own workspace', () => {
|
||||
it('keeps the desktop’s client and gates', async () => {
|
||||
route.params = { hostId: 'host-1' }
|
||||
route.hostCapabilities = []
|
||||
route.statusPending = false
|
||||
await render(createElement(WorkspaceRoute, null, child))
|
||||
expect(route.seen).toEqual([undefined])
|
||||
const session = desktop()
|
||||
await render(
|
||||
createElement(WorkspaceRoute, null, createElement(Probe)),
|
||||
desktopGates([...RELAYS, VAULT])
|
||||
)
|
||||
expect([...new Set(route.seen)]).toEqual([undefined])
|
||||
expect(below.client).toBe(session)
|
||||
expect(below.capabilities).toEqual([...RELAYS, VAULT])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,18 +3,24 @@ 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 {
|
||||
HostStatusGatesContext,
|
||||
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'
|
||||
import { WorkspaceServerClientContext } from '../transport/use-workspace-client'
|
||||
import { useWorkspaceServer } from '../transport/workspace-server'
|
||||
|
||||
const NO_CAPABILITIES: readonly string[] = []
|
||||
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; a server
|
||||
* this phone can't reach (older desktop or shell, unreadable status) gets one explicit dead end.
|
||||
* route pushed below it stays on that server, and every feature gate below reads that server's own
|
||||
* status. 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<{
|
||||
@@ -24,21 +30,31 @@ export function WorkspaceRoute({ children }: { children: ReactNode }) {
|
||||
const hostId = firstParam(params.hostId)
|
||||
const executionHost = normalizeExecutionHostId(firstParam(params.executionHost)) ?? undefined
|
||||
const { client, state } = useHostClient(hostId)
|
||||
const gates = useOptionalHostProtocolGates()
|
||||
const desktopGates = useOptionalHostProtocolGates()
|
||||
const { routeHost, server } = useWorkspaceServer({
|
||||
client,
|
||||
connState: state,
|
||||
desktopCapabilities: desktopGates?.hostCapabilities ?? NO_CAPABILITIES,
|
||||
executionHost
|
||||
})
|
||||
// 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
|
||||
) {
|
||||
if (state === 'connected' && desktopGates && !desktopGates.statusPending && routeHost === null) {
|
||||
return <PageRouteUnavailableScreen hostId={hostId} message={UNREACHABLE_SERVER_MESSAGE} />
|
||||
}
|
||||
return (
|
||||
const screens = (
|
||||
<WorkspaceExecutionHostContext.Provider value={executionHost}>
|
||||
{children}
|
||||
</WorkspaceExecutionHostContext.Provider>
|
||||
)
|
||||
return server ? (
|
||||
<WorkspaceServerClientContext.Provider value={server.client}>
|
||||
<HostStatusGatesContext.Provider value={server.gates}>
|
||||
{screens}
|
||||
</HostStatusGatesContext.Provider>
|
||||
</WorkspaceServerClientContext.Provider>
|
||||
) : (
|
||||
screens
|
||||
)
|
||||
}
|
||||
|
||||
/** Wraps a workspace route's default export so everything under it knows the workspace's server. */
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const route = vi.hoisted(() => {
|
||||
const state: { capabilities: string[] } = {
|
||||
capabilities: []
|
||||
}
|
||||
return state
|
||||
})
|
||||
const desktop = vi.hoisted(() => {
|
||||
const state: { client: unknown } = { client: null }
|
||||
return state
|
||||
})
|
||||
|
||||
vi.mock('../components/host-protocol-gates-context', () => ({
|
||||
useOptionalHostProtocolGates: () => ({ hostCapabilities: route.capabilities })
|
||||
}))
|
||||
vi.mock('./client-context', () => ({
|
||||
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 { 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'
|
||||
|
||||
async function workspaceClientFor(
|
||||
params: Record<string, string>,
|
||||
capabilities: string[]
|
||||
): Promise<{ client: RpcClient | null; session: FakeSession }> {
|
||||
route.capabilities = capabilities
|
||||
const session = new FakeSession('connected')
|
||||
desktop.client = session
|
||||
const held: { client: RpcClient | null } = { client: null }
|
||||
function Probe(): null {
|
||||
held.client = useWorkspaceClient('host-1').client
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
create(
|
||||
createElement(
|
||||
WorkspaceExecutionHostContext.Provider,
|
||||
{ value: normalizeExecutionHostId(params.executionHost) ?? undefined },
|
||||
createElement(Probe)
|
||||
)
|
||||
)
|
||||
})
|
||||
return { client: held.client, session }
|
||||
}
|
||||
|
||||
describe('a workspace screen’s client', () => {
|
||||
it('runs every call of a server workspace on that server', async () => {
|
||||
const { client, session } = await workspaceClientFor({ executionHost: 'runtime:vm' }, [
|
||||
MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY
|
||||
])
|
||||
await client?.sendRequest('files.readDir', { worktree: 'id:wt' })
|
||||
expect(session.sendRequest).toHaveBeenCalledWith(
|
||||
'files.readDir',
|
||||
{ worktree: 'id:wt' },
|
||||
{ executionHost: 'runtime:vm' }
|
||||
)
|
||||
})
|
||||
|
||||
it('never falls back to the desktop for a server workspace it cannot reach yet', async () => {
|
||||
const { client, session } = await workspaceClientFor({ executionHost: 'runtime:vm' }, [])
|
||||
expect(client).toBeNull()
|
||||
expect(session.sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the desktop’s own client for the desktop’s own workspaces', async () => {
|
||||
const { client, session } = await workspaceClientFor({}, [
|
||||
MOBILE_DESKTOP_RELAY_RUNTIME_CAPABILITY
|
||||
])
|
||||
expect(client).toBe(session)
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,13 @@
|
||||
import { useOptionalHostProtocolGates } from '../components/host-protocol-gates-context'
|
||||
import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host'
|
||||
import { createContext, useContext } from 'react'
|
||||
import { useHostClient } from './client-context'
|
||||
import { rpcClientForExecutionHost } from './execution-host-scoped-rpc-client'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
|
||||
const NO_CAPABILITIES: readonly string[] = []
|
||||
// Provided by `WorkspaceRoute` for a server's workspace (null until reachable); absent otherwise.
|
||||
export const WorkspaceServerClientContext = createContext<RpcClient | null | undefined>(undefined)
|
||||
|
||||
/** `useHostClient` for a workspace screen: its calls run where the workspace does. */
|
||||
export function useWorkspaceClient(hostId: string | undefined): ReturnType<typeof useHostClient> {
|
||||
const host = useHostClient(hostId)
|
||||
const hostCapabilities = useOptionalHostProtocolGates()?.hostCapabilities ?? NO_CAPABILITIES
|
||||
const executionHost = useWorkspaceExecutionHost()
|
||||
return {
|
||||
...host,
|
||||
client: host.client && rpcClientForExecutionHost(host.client, hostCapabilities, executionHost)
|
||||
}
|
||||
const serverClient = useContext(WorkspaceServerClientContext)
|
||||
return serverClient === undefined ? host : { ...host, client: serverClient }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import {
|
||||
scopeRpcClientToExecutionHost,
|
||||
workspaceRouteExecutionHost
|
||||
} from './execution-host-scoped-rpc-client'
|
||||
import { useHostStatusGates, type HostStatusGates } from './host-status-gates'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { ConnectionState } from './types'
|
||||
|
||||
/**
|
||||
* Where a workspace runs, as seen through the paired desktop. Whether a server is reachable stays
|
||||
* the desktop's answer; what it supports is the server's own status, read through the desktop.
|
||||
* `server` is null for the desktop's own workspaces (local, SSH, folder).
|
||||
*/
|
||||
export function useWorkspaceServer(args: {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
desktopCapabilities: readonly string[]
|
||||
executionHost: ExecutionHostId | undefined
|
||||
}): {
|
||||
routeHost: ExecutionHostId | undefined | null
|
||||
server: { client: RpcClient | null; gates: HostStatusGates } | null
|
||||
} {
|
||||
const { client, connState, desktopCapabilities, executionHost } = args
|
||||
const routeHost = workspaceRouteExecutionHost(client, desktopCapabilities, executionHost)
|
||||
const serverClient = client && routeHost ? scopeRpcClientToExecutionHost(client, routeHost) : null
|
||||
// No hostId: the desktop's recorded version and descriptor must not take the server's.
|
||||
const gates = useHostStatusGates({ hostId: undefined, client: serverClient, connState })
|
||||
return { routeHost, server: routeHost === undefined ? null : { client: serverClient, gates } }
|
||||
}
|
||||
@@ -438,14 +438,11 @@ describe('mobile desktop relay: phone -> desktop -> server', () => {
|
||||
const phone = await connectPhone(desktop.server, 'iPhone')
|
||||
const opensBefore = passthroughOpens.count
|
||||
|
||||
for (const method of ['settings.get', 'status.get']) {
|
||||
phone.send(`${method}:targeted`, method, {})
|
||||
phone.send(`${method}:untargeted`, method, {}, null)
|
||||
const targeted = await phone.next(`${method}:targeted`)
|
||||
const untargeted = await phone.next(`${method}:untargeted`)
|
||||
expect(isOk(targeted)).toBe(true)
|
||||
expect(targeted.result).toEqual(untargeted.result)
|
||||
}
|
||||
phone.send('settings:targeted', 'settings.get', {})
|
||||
phone.send('settings:untargeted', 'settings.get', {}, null)
|
||||
const targeted = await phone.next('settings:targeted')
|
||||
expect(isOk(targeted)).toBe(true)
|
||||
expect(targeted.result).toEqual((await phone.next('settings:untargeted')).result)
|
||||
expect(passthroughOpens.count).toBe(opensBefore)
|
||||
// An execution-host method under the same target still relays.
|
||||
phone.send('list', 'terminal.list', {})
|
||||
@@ -453,6 +450,20 @@ describe('mobile desktop relay: phone -> desktop -> server', () => {
|
||||
expect(passthroughOpens.count).toBe(opensBefore + 1)
|
||||
})
|
||||
|
||||
it('answers a targeted status from the server, so a server workspace gates on its own features', async () => {
|
||||
const { host, desktop } = await startTopology()
|
||||
const phone = await connectPhone(desktop.server, 'iPhone')
|
||||
const StatusSchema = z.looseObject({ runtimeId: z.string(), capabilities: z.array(z.string()) })
|
||||
|
||||
phone.send('status:server', 'status.get', {})
|
||||
phone.send('status:desktop', 'status.get', {}, null)
|
||||
const server = StatusSchema.parse((await phone.next('status:server')).result)
|
||||
const own = StatusSchema.parse((await phone.next('status:desktop')).result)
|
||||
expect(server.runtimeId).toBe(host.runtime.getRuntimeId())
|
||||
expect(own.runtimeId).toBe(desktop.runtime.getRuntimeId())
|
||||
expect(server.capabilities).toEqual(host.runtime.getStatus().capabilities)
|
||||
})
|
||||
|
||||
it('refuses binary-frame methods and unknown servers without running anything on the desktop', async () => {
|
||||
const { desktop, desktopSpawns } = await startTopology()
|
||||
const phone = await connectPhone(desktop.server, 'iPhone')
|
||||
|
||||
@@ -17,6 +17,10 @@ describe('mobile RPC method routing census', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('sends a targeted status to the server, whose features a server workspace gates on', () => {
|
||||
expect(MOBILE_RPC_METHOD_ROUTES.get('status.get')).toBe('execution-host')
|
||||
})
|
||||
|
||||
it('tags nothing outside the allowlist', () => {
|
||||
const stale = [...MOBILE_RPC_METHOD_ROUTES.keys()].filter(
|
||||
(method) => !MOBILE_RPC_METHOD_ALLOWLIST.has(method)
|
||||
|
||||
@@ -147,6 +147,8 @@ export const EXECUTION_HOST_METHODS = [
|
||||
'repo.searchRefs',
|
||||
'repo.sparsePresets',
|
||||
'repo.update',
|
||||
// A targeted status is the server's own, so a server workspace gates on what that server supports.
|
||||
'status.get',
|
||||
'session.tabs.activate',
|
||||
'session.tabs.close',
|
||||
'session.tabs.closeLifecycle',
|
||||
|
||||
@@ -28,7 +28,6 @@ export const PAIRED_DESKTOP_METHODS = [
|
||||
// The Mac republishes host changes it already observes.
|
||||
'runtime.clientEvents.subscribe',
|
||||
'runtime.clientEvents.unsubscribe',
|
||||
'status.get',
|
||||
'diagnostics.memory',
|
||||
'stats.summary',
|
||||
'ui.get',
|
||||
|
||||
Reference in New Issue
Block a user