mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
Merge branch 'feat/mobile-remote-workspaces' into feat/mobile-remote-tab-actions
This commit is contained in:
@@ -539,10 +539,10 @@ describe('the Phase C budget', () => {
|
||||
key
|
||||
).toBeGreaterThanOrEqual(measured)
|
||||
}
|
||||
// 1 to 10 per route, which is why four per route was a bound rather than a fit and why the
|
||||
// 1 to 11 per route, which is why four per route was a bound rather than a fit and why the
|
||||
// envelope cannot be a line through the measurement either.
|
||||
expect(Math.min(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(1)
|
||||
expect(Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(10)
|
||||
expect(Math.max(...MOBILE_WEB_APP_BUNDLE_ROUTE_SCRIPT_SPREAD)).toBe(11)
|
||||
})
|
||||
|
||||
it('sits exactly one margin over the swept tree and grants the worst route beyond it', () => {
|
||||
@@ -614,13 +614,13 @@ describe('the Phase C budget', () => {
|
||||
it('fails the build when the derived ceiling passes what the phone will accept', async () => {
|
||||
// The shell hands back null for a manifest over its own ceiling, so a derived ceiling above
|
||||
// that ships a green build no device can open. At the 42 images the tree carries, the envelope
|
||||
// plus 42 plus the document crosses 256 at 30 routes, which Phase C reaches. The crossing came
|
||||
// plus 42 plus the document crosses 256 at 28 routes, which Phase C reaches. The crossing came
|
||||
// in from 50 with the envelope: it grants the worst swept route to each one past the sweep,
|
||||
// where `4r + 16` granted four, so re-measuring a tree whose routes share more moves it out.
|
||||
expect(await readMobileWebBundleMaxAssets()).toBe(MOBILE_WEB_BUNDLE_MAX_ASSETS)
|
||||
expect(assertAssetCeilingFitsShell(29, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(251)
|
||||
expect(() => assertAssetCeilingFitsShell(30, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow(
|
||||
/261 .*256/
|
||||
expect(assertAssetCeilingFitsShell(27, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toBe(249)
|
||||
expect(() => assertAssetCeilingFitsShell(28, 42, MOBILE_WEB_BUNDLE_MAX_ASSETS)).toThrow(
|
||||
/260 .*256/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,9 +50,9 @@ export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024
|
||||
* A chunk is emitted per distinct set of importers, not per route, so a route's marginal cost is
|
||||
* what it fails to share rather than what it weighs. Re-measured on this head by building
|
||||
* `routes.slice(0, n)` for every n, which is what the fence below is derived from rather than
|
||||
* fitted to. The spread it shows is 1 to 10: `pr` and `web` add one script each, `session` adds ten.
|
||||
* fitted to. The spread it shows is 1 to 11: `pr` and `web` add one script each, `tasks` adds eleven.
|
||||
* The root `./_layout.tsx` (the page's web sibling of the native root) sorts first; with it the
|
||||
* swept tree reads 74 scripts at 16 routes.
|
||||
* swept tree reads 81 scripts at 16 routes.
|
||||
*
|
||||
* This table is the fence's only input, so a route added to the tree stales it and the pins beside
|
||||
* the fence fail until it is re-measured. That is the point: the bound is re-derived, never bumped.
|
||||
@@ -60,20 +60,20 @@ export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024
|
||||
export const MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP = [
|
||||
['./_layout.tsx', 3],
|
||||
['./h/[hostId]/[...page].tsx', 7],
|
||||
['./h/[hostId]/accounts.tsx', 9],
|
||||
['./h/[hostId]/agent-history/[worktreeId].tsx', 14],
|
||||
['./h/[hostId]/accounts.tsx', 10],
|
||||
['./h/[hostId]/agent-history/[worktreeId].tsx', 15],
|
||||
['./h/[hostId]/edit.tsx', 19],
|
||||
['./h/[hostId]/files/[worktreeId].tsx', 24],
|
||||
['./h/[hostId]/files/preview/[worktreeId].tsx', 32],
|
||||
['./h/[hostId]/files/[worktreeId].tsx', 23],
|
||||
['./h/[hostId]/files/preview/[worktreeId].tsx', 31],
|
||||
['./h/[hostId]/history/[worktreeId].tsx', 34],
|
||||
['./h/[hostId]/index.tsx', 39],
|
||||
['./h/[hostId]/pr/[worktreeId].tsx', 40],
|
||||
['./h/[hostId]/review/[worktreeId].tsx', 48],
|
||||
['./h/[hostId]/session/[worktreeId].tsx', 58],
|
||||
['./h/[hostId]/source-control/[worktreeId].tsx', 63],
|
||||
['./h/[hostId]/tasks.tsx', 71],
|
||||
['./h/[hostId]/web.tsx', 72],
|
||||
['./h/_layout.tsx', 74]
|
||||
['./h/[hostId]/index.tsx', 41],
|
||||
['./h/[hostId]/pr/[worktreeId].tsx', 42],
|
||||
['./h/[hostId]/review/[worktreeId].tsx', 51],
|
||||
['./h/[hostId]/session/[worktreeId].tsx', 61],
|
||||
['./h/[hostId]/source-control/[worktreeId].tsx', 66],
|
||||
['./h/[hostId]/tasks.tsx', 77],
|
||||
['./h/[hostId]/web.tsx', 78],
|
||||
['./h/_layout.tsx', 81]
|
||||
]
|
||||
|
||||
const sweptScripts = MOBILE_WEB_APP_BUNDLE_SCRIPT_SWEEP.map(([, scripts]) => scripts)
|
||||
@@ -191,7 +191,7 @@ export async function readMobileWebBundleMaxAssets() {
|
||||
* shells return null for a manifest over MOBILE_WEB_BUNDLE_MAX_ASSETS rather than dropping the
|
||||
* extra assets, so a route count that pushes the chunk envelope plus images plus the document past
|
||||
* it would pass this build and fail on the device with nothing to read. At today's 42 images that
|
||||
* is 29 routes, inside what Phase C adds, which is why this is a build failure and not a comment.
|
||||
* is 28 routes, inside what Phase C adds, which is why this is a build failure and not a comment.
|
||||
* The envelope grants the worst swept route to each one past the sweep, so re-measuring a tree
|
||||
* whose routes share more moves that crossing out again.
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The desktop's server-list feed loads react-native; its timer never runs without a subscriber.
|
||||
vi.mock('react-native', () => ({}))
|
||||
vi.mock('expo-router', () => ({ useFocusEffect: () => {} }))
|
||||
vi.mock('../cache/worktree-cache', () => ({
|
||||
getCachedWorktrees: () => null,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The desktop's server-list feed loads react-native; its timer never runs without a subscriber.
|
||||
vi.mock('react-native', () => ({}))
|
||||
vi.mock('../cache/worktree-cache', () => ({
|
||||
getCachedWorktrees: () => null,
|
||||
setCachedWorktrees: () => {}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The desktop's server-list feed loads react-native; its timer never runs without a subscriber.
|
||||
vi.mock('react-native', () => ({}))
|
||||
vi.mock('expo-router', () => ({ useFocusEffect: () => {} }))
|
||||
vi.mock('../cache/worktree-cache', () => ({ setCachedWorktrees: () => {} }))
|
||||
vi.mock('../storage/preferences', () => ({ savePinnedIds: async () => {} }))
|
||||
|
||||
@@ -32,7 +32,8 @@ vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => ({ client: null, state: 'disconnected' })
|
||||
}))
|
||||
vi.mock('../transport/workspace-server', () => ({
|
||||
useWorkspaceServer: () => ({ routeHost: undefined, server: null })
|
||||
useWorkspaceServer: () => ({ routeHost: undefined, server: null }),
|
||||
useWorkspaceServerConnState: ({ connState }: { connState: string }) => connState
|
||||
}))
|
||||
vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params }))
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => ({ client: null, state: 'disconnected' })
|
||||
}))
|
||||
vi.mock('../transport/workspace-server', () => ({
|
||||
useWorkspaceServer: () => ({ routeHost: undefined, server: null })
|
||||
useWorkspaceServer: () => ({ routeHost: undefined, server: null }),
|
||||
useWorkspaceServerConnState: ({ connState }: { connState: string }) => connState
|
||||
}))
|
||||
vi.mock('expo-router', () => ({ useLocalSearchParams: () => dependencies.params }))
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => ({ client: null, state: 'disconnected' })
|
||||
}))
|
||||
vi.mock('../transport/workspace-server', () => ({
|
||||
useWorkspaceServer: () => ({ routeHost: undefined, server: null })
|
||||
useWorkspaceServer: () => ({ routeHost: undefined, server: null }),
|
||||
useWorkspaceServerConnState: ({ connState }: { connState: string }) => connState
|
||||
}))
|
||||
vi.mock('expo-router', () => ({
|
||||
useLocalSearchParams: () => dependencies.params,
|
||||
|
||||
@@ -42,7 +42,8 @@ vi.mock('../transport/client-context', () => ({
|
||||
useHostClient: () => ({ client: null, state: 'disconnected' })
|
||||
}))
|
||||
vi.mock('../transport/workspace-server', () => ({
|
||||
useWorkspaceServer: () => ({ routeHost: undefined, server: null })
|
||||
useWorkspaceServer: () => ({ routeHost: undefined, server: null }),
|
||||
useWorkspaceServerConnState: ({ connState }: { connState: string }) => connState
|
||||
}))
|
||||
vi.mock('expo-router', () => ({
|
||||
Redirect: 'Redirect',
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock('expo-router', () => ({
|
||||
}
|
||||
}))
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: { currentState: 'active' },
|
||||
StyleSheet: { create: (styles: unknown) => styles },
|
||||
View: 'View',
|
||||
Text: 'Text',
|
||||
@@ -46,7 +47,7 @@ vi.mock('../transport/host-descriptor-recorder', () => ({
|
||||
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) =>
|
||||
shellSwitchDecision: (shellRoute: unknown) =>
|
||||
shellRoute ? { kind: 'shell', route: shellRoute } : { kind: 'native' }
|
||||
}))
|
||||
vi.mock('../mobile-web-shell/ShellSwitchPendingScreen', () => ({
|
||||
@@ -73,7 +74,10 @@ import {
|
||||
HostStatusGatesContext,
|
||||
useOptionalHostProtocolGates
|
||||
} from '../components/host-protocol-gates-context'
|
||||
import type { ExecutionHostHealth } from '../../../src/shared/execution-host-health'
|
||||
import { MOBILE_RELAY_HOSTS_LIST_METHOD } from '../../../src/shared/mobile-relay-hosts-contract'
|
||||
import type { HostStatusGates } from '../transport/host-status-gates'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { useWorkspaceClient } from '../transport/use-workspace-client'
|
||||
@@ -84,14 +88,21 @@ 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 } = {
|
||||
const below: {
|
||||
client: RpcClient | null
|
||||
state: ConnectionState | null
|
||||
capabilities: readonly string[] | null
|
||||
} = {
|
||||
client: null,
|
||||
state: null,
|
||||
capabilities: null
|
||||
}
|
||||
|
||||
function Probe(): null {
|
||||
route.seen.push(useWorkspaceExecutionHost())
|
||||
below.client = useWorkspaceClient('host-1').client
|
||||
const workspace = useWorkspaceClient('host-1')
|
||||
below.client = workspace.client
|
||||
below.state = workspace.state
|
||||
below.capabilities = useOptionalHostProtocolGates()?.hostCapabilities ?? null
|
||||
return null
|
||||
}
|
||||
@@ -108,13 +119,25 @@ function desktopGates(hostCapabilities: string[], statusPending = false): HostSt
|
||||
}
|
||||
}
|
||||
|
||||
/** The server's health as the desktop reports it in `mobileRelay.hosts.list`. */
|
||||
const serverHealth: { current: ExecutionHostHealth } = { current: 'available' }
|
||||
|
||||
/** 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 } : {},
|
||||
result:
|
||||
method === 'status.get'
|
||||
? { capabilities: serverCapabilities }
|
||||
: method === MOBILE_RELAY_HOSTS_LIST_METHOD
|
||||
? {
|
||||
hosts: [
|
||||
{ hostId: 'runtime:vm', label: 'vm', health: serverHealth.current, relay: 'ready' }
|
||||
]
|
||||
}
|
||||
: {},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}))
|
||||
route.client = session
|
||||
@@ -133,7 +156,9 @@ async function render(element: ReactElement, gates: HostStatusGates): Promise<Re
|
||||
route.shellRoutes.length = 0
|
||||
route.redirects.length = 0
|
||||
below.client = null
|
||||
below.state = null
|
||||
below.capabilities = null
|
||||
serverHealth.current = 'available'
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
await act(async () => {
|
||||
renderer = create(createElement(HostStatusGatesContext.Provider, { value: gates }, element))
|
||||
@@ -204,6 +229,45 @@ describe('a server workspace', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reads as disconnected while the desktop reports its server down, and recovers', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
|
||||
try {
|
||||
route.params = { hostId: 'host-1', executionHost: 'runtime:vm' }
|
||||
desktop()
|
||||
await render(screen, desktopGates(RELAYS))
|
||||
expect(below.state).toBe('connected')
|
||||
serverHealth.current = 'connecting'
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000)
|
||||
})
|
||||
expect(below.state).toBe('connecting')
|
||||
serverHealth.current = 'error'
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000)
|
||||
})
|
||||
expect(below.state).toBe('disconnected')
|
||||
serverHealth.current = 'available'
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000)
|
||||
})
|
||||
expect(below.state).toBe('connected')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('opens disconnected, not loading, when its server is already down', async () => {
|
||||
route.params = { hostId: 'host-1', executionHost: 'runtime:vm' }
|
||||
desktop()
|
||||
serverHealth.current = 'disconnected'
|
||||
await act(async () => {
|
||||
create(
|
||||
createElement(HostStatusGatesContext.Provider, { value: desktopGates(RELAYS) }, screen)
|
||||
)
|
||||
})
|
||||
expect(below.state).toBe('disconnected')
|
||||
})
|
||||
|
||||
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()
|
||||
@@ -235,6 +299,10 @@ describe('the desktop’s own workspace', () => {
|
||||
)
|
||||
expect([...new Set(route.seen)]).toEqual([undefined])
|
||||
expect(below.client).toBe(session)
|
||||
expect(below.state).toBe('connected')
|
||||
expect(below.capabilities).toEqual([...RELAYS, VAULT])
|
||||
expect(session.sendRequest.mock.calls.map(([method]) => method)).not.toContain(
|
||||
MOBILE_RELAY_HOSTS_LIST_METHOD
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMemo, type ReactNode } from 'react'
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { firstParam } from './route-param-reader'
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
} from '../components/host-protocol-gates-context'
|
||||
import { PageRouteUnavailableScreen } from '../mobile-web-shell/PageRouteUnavailableScreen'
|
||||
import { useHostClient } from '../transport/client-context'
|
||||
import { WorkspaceServerClientContext } from '../transport/use-workspace-client'
|
||||
import { useWorkspaceServer } from '../transport/workspace-server'
|
||||
import { WorkspaceServerContext } from '../transport/use-workspace-client'
|
||||
import { useWorkspaceServer, useWorkspaceServerConnState } from '../transport/workspace-server'
|
||||
|
||||
const NO_CAPABILITIES: readonly string[] = []
|
||||
const UNREACHABLE_SERVER_MESSAGE =
|
||||
@@ -37,6 +37,13 @@ export function WorkspaceRoute({ children }: { children: ReactNode }) {
|
||||
desktopCapabilities: desktopGates?.hostCapabilities ?? NO_CAPABILITIES,
|
||||
executionHost
|
||||
})
|
||||
const serverConnState = useWorkspaceServerConnState({ client, connState: state, routeHost })
|
||||
const serverClient = server ? server.client : undefined
|
||||
const serverContext = useMemo(
|
||||
() =>
|
||||
serverClient === undefined ? undefined : { client: serverClient, state: serverConnState },
|
||||
[serverClient, serverConnState]
|
||||
)
|
||||
// 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' && desktopGates && !desktopGates.statusPending && routeHost === null) {
|
||||
return <PageRouteUnavailableScreen hostId={hostId} message={UNREACHABLE_SERVER_MESSAGE} />
|
||||
@@ -46,12 +53,12 @@ export function WorkspaceRoute({ children }: { children: ReactNode }) {
|
||||
{children}
|
||||
</WorkspaceExecutionHostContext.Provider>
|
||||
)
|
||||
return server ? (
|
||||
<WorkspaceServerClientContext.Provider value={server.client}>
|
||||
return server && serverContext ? (
|
||||
<WorkspaceServerContext.Provider value={serverContext}>
|
||||
<HostStatusGatesContext.Provider value={server.gates}>
|
||||
{screens}
|
||||
</HostStatusGatesContext.Provider>
|
||||
</WorkspaceServerClientContext.Provider>
|
||||
</WorkspaceServerContext.Provider>
|
||||
) : (
|
||||
screens
|
||||
)
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { useHostClient } from './client-context'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { ConnectionState } from './types'
|
||||
|
||||
// Provided by `WorkspaceRoute` for a server's workspace (null until reachable); absent otherwise.
|
||||
export const WorkspaceServerClientContext = createContext<RpcClient | null | undefined>(undefined)
|
||||
// Provided by `WorkspaceRoute` for a server's workspace (client null until reachable); absent otherwise.
|
||||
export const WorkspaceServerContext = createContext<
|
||||
{ client: RpcClient | null; state: ConnectionState } | 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 serverClient = useContext(WorkspaceServerClientContext)
|
||||
return serverClient === undefined ? host : { ...host, client: serverClient }
|
||||
const server = useContext(WorkspaceServerContext)
|
||||
return server === undefined ? host : { ...host, ...server }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import { relayHostsFeed } from '../worktree/relay-hosts-feed'
|
||||
import { serverHostHealth } from '../worktree/server-workspaces'
|
||||
import {
|
||||
scopeRpcClientToExecutionHost,
|
||||
workspaceRouteExecutionHost
|
||||
@@ -28,3 +31,41 @@ export function useWorkspaceServer(args: {
|
||||
const gates = useHostStatusGates({ hostId: undefined, client: serverClient, connState })
|
||||
return { routeHost, server: routeHost === undefined ? null : { client: serverClient, gates } }
|
||||
}
|
||||
|
||||
const noSubscription = (): (() => void) => () => {}
|
||||
|
||||
/**
|
||||
* The connection state a server workspace's screens see: the desktop's, unless the desktop reports
|
||||
* that server down. Desktop-owned workspaces (`routeHost` absent) keep the desktop's state untouched.
|
||||
*/
|
||||
export function useWorkspaceServerConnState(args: {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
routeHost: ExecutionHostId | undefined | null
|
||||
}): ConnectionState {
|
||||
const { client, connState, routeHost } = args
|
||||
// The same feed the host list reads, so a row's badge and its screens agree.
|
||||
const feed = client && routeHost ? relayHostsFeed(client) : null
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => (feed ? feed.subscribe(listener) : noSubscription()),
|
||||
[feed]
|
||||
)
|
||||
const readHosts = () => feed?.getSnapshot() ?? null
|
||||
const hosts = useSyncExternalStore(subscribe, readHosts, readHosts)
|
||||
if (connState !== 'connected' || !routeHost || !hosts) {
|
||||
return connState
|
||||
}
|
||||
const host = hosts.find((entry) => entry.hostId === routeHost)
|
||||
// A server the desktop no longer lists cannot be reached through it.
|
||||
switch (host ? serverHostHealth(host) : 'disconnected') {
|
||||
case 'local':
|
||||
case 'available':
|
||||
return 'connected'
|
||||
case 'connecting':
|
||||
return 'connecting'
|
||||
case 'blocked':
|
||||
case 'disconnected':
|
||||
case 'error':
|
||||
return 'disconnected'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('react-native', () => ({ AppState: { currentState: 'active' } }))
|
||||
|
||||
import { MOBILE_RELAY_HOSTS_LIST_METHOD } from '../../../src/shared/mobile-relay-hosts-contract'
|
||||
import { FakeSession } from '../transport/mobile-endpoint-supervisor-test-fakes'
|
||||
import { relayHostsFeed } from './relay-hosts-feed'
|
||||
|
||||
function desktop(health: () => string): FakeSession {
|
||||
const session = new FakeSession('connected')
|
||||
session.sendRequest.mockImplementation(async () => ({
|
||||
id: 'reply',
|
||||
ok: true,
|
||||
result: { hosts: [{ hostId: 'runtime:vm', label: 'vm', health: health(), relay: 'ready' }] },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}))
|
||||
return session
|
||||
}
|
||||
|
||||
function listCalls(session: FakeSession): number {
|
||||
return session.sendRequest.mock.calls.filter(
|
||||
([method]) => method === MOBILE_RELAY_HOSTS_LIST_METHOD
|
||||
).length
|
||||
}
|
||||
|
||||
describe('the desktop’s server list', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('is read once per poll however many readers share it', async () => {
|
||||
const session = desktop(() => 'available')
|
||||
const feed = relayHostsFeed(session)
|
||||
// The host list reads on its own poll; an open workspace subscribes a second later.
|
||||
await feed.read()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
const unsubscribe = feed.subscribe(() => {})
|
||||
const unsubscribeAgain = feed.subscribe(() => {})
|
||||
expect(listCalls(session)).toBe(1)
|
||||
// Its timed read waits out the host list's, then both readers share each one.
|
||||
await vi.advanceTimersByTimeAsync(1999)
|
||||
expect(listCalls(session)).toBe(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(listCalls(session)).toBe(2)
|
||||
await vi.advanceTimersByTimeAsync(3000)
|
||||
expect(listCalls(session)).toBe(3)
|
||||
unsubscribe()
|
||||
unsubscribeAgain()
|
||||
await vi.advanceTimersByTimeAsync(9000)
|
||||
expect(listCalls(session)).toBe(3)
|
||||
})
|
||||
|
||||
it('joins a read already in flight', async () => {
|
||||
const session = desktop(() => 'available')
|
||||
const feed = relayHostsFeed(session)
|
||||
const [first, second] = await Promise.all([feed.read(), feed.read()])
|
||||
expect(second).toBe(first)
|
||||
expect(listCalls(session)).toBe(1)
|
||||
})
|
||||
|
||||
it('forgets its answer on a drop, and reads again on reconnect', async () => {
|
||||
let health = 'available'
|
||||
const session = desktop(() => health)
|
||||
const feed = relayHostsFeed(session)
|
||||
const unsubscribe = feed.subscribe(() => {})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(feed.getSnapshot()?.[0]?.health).toBe('available')
|
||||
session.publishState('reconnecting')
|
||||
expect(feed.getSnapshot()).toBeNull()
|
||||
await vi.advanceTimersByTimeAsync(9000)
|
||||
expect(listCalls(session)).toBe(1)
|
||||
health = 'error'
|
||||
session.publishState('connected')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(feed.getSnapshot()?.[0]?.health).toBe('error')
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
// The paired desktop's servers (`mobileRelay.hosts.list`), one feed per desktop client: the host
|
||||
// list and every open server workspace share one poll and one answer, so a row's badge and its
|
||||
// workspace screen never disagree and two readers never double the desktop's load.
|
||||
|
||||
import { AppState } from 'react-native'
|
||||
import type { MobileRelayHost } from '../../../src/shared/mobile-relay-hosts-contract'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { relayHostsListRead } from './server-workspace-operations'
|
||||
|
||||
// The host list's own poll cadence; a read by any reader pushes the next timed one back.
|
||||
const RELAY_HOSTS_POLL_MS = 3000
|
||||
|
||||
export type RelayHostsFeed = {
|
||||
subscribe: (listener: () => void) => () => void
|
||||
/** The last answer on this connection; null before one lands. */
|
||||
getSnapshot: () => readonly MobileRelayHost[] | null
|
||||
/** Reads now, or joins the read in flight; null when the desktop did not answer. */
|
||||
read: () => Promise<readonly MobileRelayHost[] | null>
|
||||
}
|
||||
|
||||
const feeds = new WeakMap<RpcClient, RelayHostsFeed>()
|
||||
|
||||
function createFeed(client: RpcClient): RelayHostsFeed {
|
||||
let snapshot: readonly MobileRelayHost[] | null = null
|
||||
let inFlight: Promise<readonly MobileRelayHost[] | null> | null = null
|
||||
let lastReadAt = Number.NEGATIVE_INFINITY
|
||||
// Bumped on every drop, so a read from an earlier connection never lands.
|
||||
let generation = 0
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
const publish = (next: readonly MobileRelayHost[] | null): void => {
|
||||
// Why: an unchanged poll keeps its identity, so readers do not re-render every tick.
|
||||
if (JSON.stringify(next) === JSON.stringify(snapshot)) {
|
||||
return
|
||||
}
|
||||
snapshot = next
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
const stopTimer = (): void => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
// Timed reads run only while someone subscribes and the desktop is connected.
|
||||
const schedule = (): void => {
|
||||
stopTimer()
|
||||
if (listeners.size === 0 || client.getState() !== 'connected') {
|
||||
return
|
||||
}
|
||||
const delay = lastReadAt + RELAY_HOSTS_POLL_MS - Date.now()
|
||||
if (delay <= 0) {
|
||||
tick()
|
||||
return
|
||||
}
|
||||
timer = setTimeout(tick, delay)
|
||||
}
|
||||
const tick = (): void => {
|
||||
timer = null
|
||||
if (AppState.currentState === 'active') {
|
||||
void read()
|
||||
return
|
||||
}
|
||||
timer = setTimeout(tick, RELAY_HOSTS_POLL_MS)
|
||||
}
|
||||
const fetchHosts = async (readGeneration: number): Promise<readonly MobileRelayHost[] | null> => {
|
||||
try {
|
||||
const listed = relayHostsListRead.interpret(await relayHostsListRead.request(client))
|
||||
// A reply from before a drop is not this connection's answer.
|
||||
if (!listed.accepted || readGeneration !== generation) {
|
||||
return null
|
||||
}
|
||||
publish(listed.value)
|
||||
return listed.value
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const read = (): Promise<readonly MobileRelayHost[] | null> => {
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
lastReadAt = Date.now()
|
||||
const readGeneration = generation
|
||||
inFlight = fetchHosts(readGeneration).finally(() => {
|
||||
if (readGeneration === generation) {
|
||||
inFlight = null
|
||||
}
|
||||
})
|
||||
schedule()
|
||||
return inFlight
|
||||
}
|
||||
|
||||
client.onStateChange((state) => {
|
||||
if (state !== 'connected') {
|
||||
generation += 1
|
||||
inFlight = null
|
||||
lastReadAt = Number.NEGATIVE_INFINITY
|
||||
publish(null)
|
||||
}
|
||||
schedule()
|
||||
})
|
||||
return {
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
schedule()
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (listeners.size === 0) {
|
||||
stopTimer()
|
||||
}
|
||||
}
|
||||
},
|
||||
getSnapshot: () => snapshot,
|
||||
read
|
||||
}
|
||||
}
|
||||
|
||||
export function relayHostsFeed(client: RpcClient): RelayHostsFeed {
|
||||
let feed = feeds.get(client)
|
||||
if (!feed) {
|
||||
feed = createFeed(client)
|
||||
feeds.set(client, feed)
|
||||
}
|
||||
return feed
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
import type { ExecutionHostHealth } from '../../../src/shared/execution-host-health'
|
||||
import type { MobileRelayHost } from '../../../src/shared/mobile-relay-hosts-contract'
|
||||
import type { RpcOperationSender } from '../transport/rpc-operation-sender'
|
||||
import { relayHostsListRead, relayHostWorktreesRead } from './server-workspace-operations'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { relayHostsFeed } from './relay-hosts-feed'
|
||||
import { relayHostWorktreesRead } from './server-workspace-operations'
|
||||
import type { Worktree } from './workspace-list-types'
|
||||
import { areWorktreeListsEqual } from './worktree-list-snapshot'
|
||||
|
||||
@@ -60,13 +62,12 @@ export type FetchedServerWorkspaces = {
|
||||
|
||||
/** The servers' rows through the desktop's one listing path; null when it did not answer. */
|
||||
export async function fetchServerWorkspaces(
|
||||
client: RpcOperationSender
|
||||
client: RpcClient
|
||||
): Promise<FetchedServerWorkspaces | null> {
|
||||
const listed = relayHostsListRead.interpret(await relayHostsListRead.request(client))
|
||||
if (!listed.accepted) {
|
||||
const hosts = await relayHostsFeed(client).read()
|
||||
if (!hosts) {
|
||||
return null
|
||||
}
|
||||
const hosts = listed.value
|
||||
return { hosts, rows: await Promise.all(hosts.map((host) => fetchHostRows(client, host.hostId))) }
|
||||
}
|
||||
|
||||
|
||||
@@ -27,15 +27,14 @@ const ReposSchema = z.object({
|
||||
const ServerTabsSchema = z.looseObject({ tabs: z.array(z.looseObject({ id: z.string() })) })
|
||||
const SubscribedSchema = z.looseObject({ type: z.literal('subscribed'), streamId: z.number() })
|
||||
const HostsSchema = z.object({
|
||||
hosts: z.array(z.looseObject({ hostId: z.string(), relay: z.string() }))
|
||||
hosts: z.array(z.looseObject({ hostId: z.string(), health: 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() })
|
||||
),
|
||||
stale: z.boolean()
|
||||
)
|
||||
})
|
||||
|
||||
let pollId = 0
|
||||
@@ -48,6 +47,18 @@ async function hostWorktrees(socket: PairedMobileSocket, hostId: string) {
|
||||
return listed.success ? listed.data : null
|
||||
}
|
||||
|
||||
let hostsId = 0
|
||||
/** The server as the desktop reports it to the phone: its health and whether it relays. */
|
||||
async function listedHost(socket: PairedMobileSocket, hostId: string) {
|
||||
hostsId += 1
|
||||
const { hosts } = await call(
|
||||
socket,
|
||||
{ id: `hosts-${hostsId}`, method: 'mobileRelay.hosts.list', params: {} },
|
||||
HostsSchema
|
||||
)
|
||||
return hosts.find((entry) => entry.hostId === hostId)
|
||||
}
|
||||
|
||||
async function reply(socket: PairedMobileSocket, id: string, timeout = 30_000): Promise<Frame> {
|
||||
let found: Frame | undefined
|
||||
await expect
|
||||
@@ -202,7 +213,7 @@ test('phone paired with a desktop lists, opens and types into a workspace on its
|
||||
await expect
|
||||
.poll(async () => {
|
||||
listed = await hostWorktrees(socket, serverHostId)
|
||||
return listed?.stale === false && listed.worktrees.some((row) => row.path === serverRepo)
|
||||
return listed?.worktrees.some((row) => row.path === serverRepo) ?? false
|
||||
})
|
||||
.toBe(true)
|
||||
const serverRow = listed!.worktrees.find((row) => row.path === serverFolder)
|
||||
@@ -264,18 +275,15 @@ test('phone paired with a desktop lists, opens and types into a workspace on its
|
||||
|
||||
await host.restartServeProcess({
|
||||
betweenProcesses: async () => {
|
||||
// The server is down: its workspace stays listed, marked stale, never synthesized away.
|
||||
// The server is down: the desktop says so, and its workspace stays listed from its last rows.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const stale = await hostWorktrees(socket, serverHostId)
|
||||
return (
|
||||
stale?.stale === true && stale.worktrees.some((row) => row.path === serverFolder)
|
||||
)
|
||||
},
|
||||
{ timeout: 60_000, intervals: [1_000] }
|
||||
)
|
||||
.toBe(true)
|
||||
.poll(async () => (await listedHost(socket, serverHostId))?.relay, {
|
||||
timeout: 60_000,
|
||||
intervals: [1_000]
|
||||
})
|
||||
.toBe('unavailable')
|
||||
const lastRows = await hostWorktrees(socket, serverHostId)
|
||||
expect(lastRows?.worktrees.map((row) => row.path)).toContain(serverFolder)
|
||||
// A relayed call fails as unavailable, and never runs on the desktop instead.
|
||||
socket.send('ps-stopped', 'worktree.ps', { limit: 1_000 }, serverHostId)
|
||||
const stopped = await reply(socket, 'ps-stopped', 60_000)
|
||||
@@ -292,13 +300,13 @@ test('phone paired with a desktop lists, opens and types into a workspace on its
|
||||
}
|
||||
}
|
||||
})
|
||||
// Back up: the listing is fresh again, and the same phone socket reaches the server.
|
||||
// Back up: the desktop reports it available again, and the same phone socket reaches it.
|
||||
await expect
|
||||
.poll(async () => (await hostWorktrees(socket, serverHostId))?.stale, {
|
||||
.poll(async () => (await listedHost(socket, serverHostId))?.health, {
|
||||
timeout: 90_000,
|
||||
intervals: [2_000]
|
||||
})
|
||||
.toBe(false)
|
||||
.toBe('available')
|
||||
const reopened = await call(
|
||||
socket,
|
||||
{ id: 'ps-restarted', ...list, executionHost: serverHostId },
|
||||
|
||||
Reference in New Issue
Block a user