feat(mobile): list the desktop's configured servers and their last workspace rows for the phone

The phone needs the same host list the desktop shows, including servers that are offline or
too old to relay to. mobileRelay.hosts.list/worktrees answer from the desktop's own status and
its own worktree.ps fetches, cached per server and fenced by pairing, never from relayed replies.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-10-08 01:08:07 -04:00
parent 2ab98feb32
commit 5afbb8a044
22 changed files with 840 additions and 13 deletions
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => {
const environments: { id: string; name: string }[] = []
return { environments, resolveManaged: vi.fn(), call: vi.fn() }
const environments: Record<string, unknown>[] = []
const snapshots: Record<string, unknown>[] = []
return { environments, snapshots, resolveManaged: vi.fn(), call: vi.fn() }
})
vi.mock('electron', () => ({ app: { getPath: () => '/user-data' } }))
@@ -17,6 +18,9 @@ vi.mock('../../shared/runtime-environments', () => ({
vi.mock('./runtime-environment-managed-tunnel', () => ({
resolveManagedRuntimeEnvironment: mocks.resolveManaged
}))
vi.mock('./runtime-environment-request-connections', () => ({
getRuntimeEnvironmentStatusSnapshots: () => mocks.snapshots
}))
vi.mock('./runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: mocks.call
}))
@@ -37,6 +41,61 @@ describe('runtime environment mobile relay hosts', () => {
runtimeId: 'runtime-a'
})
mocks.call.mockReset()
mocks.snapshots = []
})
it("lists configured servers with the desktop's own status, ignoring another pairing's snapshot", () => {
mocks.environments = [
{ id: 'env-1', name: 'VM', createdAt: 1, pairingRevision: 7, runtimeId: 'runtime-a' },
{ id: 'env-2', name: 'Box', createdAt: 2, runtimeId: null, source: 'ephemeral-vm' }
]
const status = { runtimeId: 'runtime-a', capabilities: [] }
mocks.snapshots = [
{
environmentId: 'env-1',
pairingRevision: 7,
checkedAt: 5,
status,
verification: 'verified',
transport: 'ready'
},
{
environmentId: 'env-2',
pairingRevision: 1,
checkedAt: 5,
status,
verification: 'verified',
transport: 'ready'
},
{
environmentId: 'removed',
pairingRevision: 1,
checkedAt: 5,
status,
verification: 'verified',
transport: 'ready'
}
]
const listing = createRuntimeEnvironmentMobileRelayHosts().list()
expect(listing.environments).toEqual([
{
id: 'env-1',
name: 'VM',
source: undefined,
orcadDeployment: undefined,
fence: '7\0runtime-a'
},
{
id: 'env-2',
name: 'Box',
source: 'ephemeral-vm',
orcadDeployment: undefined,
fence: '2\0null'
}
])
expect([...listing.statusByEnvironmentId.keys()]).toEqual(['env-1'])
expect(listing.statusByEnvironmentId.get('env-1')).toMatchObject({ status, checkedAt: 5 })
expect(mocks.resolveManaged).not.toHaveBeenCalled()
})
it('resolves a configured server by exact id, fenced by pairing revision and runtime identity', async () => {
@@ -1,8 +1,16 @@
import { app } from 'electron'
import { listEnvironments } from '../../shared/runtime-environment-store'
import { getPreferredPairingOffer } from '../../shared/runtime-environments'
import {
getPreferredPairingOffer,
type KnownRuntimeEnvironment
} from '../../shared/runtime-environments'
import {
runtimeEnvironmentStatusFromSnapshot,
type RuntimeEnvironmentStatus
} from '../../shared/runtime-host-status'
import type { MobileDesktopRelayHosts } from '../runtime/mobile-desktop-relay/mobile-desktop-relay-hosts'
import { resolveManagedRuntimeEnvironment } from './runtime-environment-managed-tunnel'
import { getRuntimeEnvironmentStatusSnapshots } from './runtime-environment-request-connections'
import { callRuntimeEnvironment } from './runtime-environment-transport-routing'
const retirementListeners = new Set<(environmentId: string) => void>()
@@ -19,6 +27,27 @@ export function createRuntimeEnvironmentMobileRelayHosts(): MobileDesktopRelayHo
// Why userData: configured servers live where the runtime-environment handlers keep them.
const userDataPath = app.getPath('userData')
return {
list: () => {
const environments = listEnvironments(userDataPath)
const statusByEnvironmentId = new Map<string, RuntimeEnvironmentStatus>()
for (const snapshot of getRuntimeEnvironmentStatusSnapshots()) {
const environment = environments.find((entry) => entry.id === snapshot.environmentId)
// Why: as in the renderer, a snapshot from an earlier pairing says nothing about this one.
if (environment && pairingRevision(environment) === snapshot.pairingRevision) {
statusByEnvironmentId.set(environment.id, runtimeEnvironmentStatusFromSnapshot(snapshot))
}
}
return {
environments: environments.map((environment) => ({
id: environment.id,
name: environment.name,
source: environment.source,
orcadDeployment: environment.orcadDeployment,
fence: relayHostFence(environment)
})),
statusByEnvironmentId
}
},
resolve: async (environmentId) => {
if (!listEnvironments(userDataPath).some((entry) => entry.id === environmentId)) {
return null
@@ -26,7 +55,7 @@ export function createRuntimeEnvironmentMobileRelayHosts(): MobileDesktopRelayHo
const environment = await resolveManagedRuntimeEnvironment(userDataPath, environmentId)
return {
environmentId,
fence: `${environment.pairingRevision ?? environment.createdAt}\0${environment.runtimeId}`,
fence: relayHostFence(environment),
pairing: getPreferredPairingOffer(environment)
}
},
@@ -38,3 +67,11 @@ export function createRuntimeEnvironmentMobileRelayHosts(): MobileDesktopRelayHo
}
}
}
function pairingRevision(environment: KnownRuntimeEnvironment): number {
return environment.pairingRevision ?? environment.createdAt
}
function relayHostFence(environment: KnownRuntimeEnvironment): string {
return `${pairingRevision(environment)}\0${environment.runtimeId}`
}
@@ -1,4 +1,6 @@
import type { PairingOffer } from '../../../shared/pairing'
import type { RuntimeEnvironmentSource } from '../../../shared/runtime-environments'
import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
/** A configured server the desktop can relay to. */
@@ -10,8 +12,22 @@ export type MobileDesktopRelayHost = {
pairing: PairingOffer
}
/** A configured server as the desktop's sidebar sees it, without contacting it. */
export type MobileDesktopRelayHostListing = {
id: string
name: string
source?: RuntimeEnvironmentSource
orcadDeployment?: { sshTargetId: string } | null
fence: string
}
/** The desktop's configured servers, as the relay sees them. */
export type MobileDesktopRelayHosts = {
/** Every configured server, with the desktop's own last status for those it has one for. */
list: () => {
environments: MobileDesktopRelayHostListing[]
statusByEnvironmentId: ReadonlyMap<string, RuntimeEnvironmentStatus>
}
/** Null when the id names no configured server; a phone-supplied endpoint is never used. */
resolve: (environmentId: string) => Promise<MobileDesktopRelayHost | null>
/** A call as the desktop itself, over its own connection to the server. */
@@ -146,6 +146,7 @@ describe('mobile desktop relay: phone -> desktop -> server', () => {
const syncedNames: string[][] = []
const state = { capable: true, retire: (_id: string) => {} }
const hosts: MobileDesktopRelayHosts = {
list: () => ({ environments: [], statusByEnvironmentId: new Map() }),
resolve: async (environmentId) =>
environmentId === 'env-1'
? { environmentId, fence: 'pairing-1', pairing: desktopOnHost }
@@ -56,6 +56,7 @@ function relayWithPhone(
const syncCalls: unknown[] = []
const server = { capable: true }
const hosts: MobileDesktopRelayHosts = {
list: () => ({ environments: [], statusByEnvironmentId: new Map() }),
resolve: async (environmentId) => ({
environmentId,
fence: 'f',
@@ -0,0 +1,201 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import { DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY } from '../../../shared/delegated-mobile-device-contract'
import { parsePairingCode, type PairingOffer } from '../../../shared/pairing'
import { RUNTIME_PROTOCOL_VERSION } from '../../../shared/protocol-version'
import { sendRemoteRuntimeRequest } from '../../../shared/remote-runtime-client'
import {
runtimeEnvironmentStatusFromSnapshot,
type RuntimeHostStatusSnapshot
} from '../../../shared/runtime-host-status'
import { OrcaRuntimeService } from '../orca-runtime'
import { OrcaRuntimeRpcServer } from '../runtime-rpc'
import {
authenticateMobileWsSession,
createEncryptedWsResponseReader,
sendEncryptedWsRequest
} from '../runtime-rpc-mobile-ws-test-harness'
import { makeStore } from '../runtime-rpc-worktree-store-fixtures'
import type { MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts'
vi.mock('../../git/worktree', () => {
const worktrees = [
{
path: '/tmp/worktree-a',
head: 'abc',
branch: 'feature/foo',
isBare: false,
isMainWorktree: false
}
]
return {
listWorktrees: vi.fn().mockResolvedValue(worktrees),
listWorktreesStrict: vi.fn().mockResolvedValue(worktrees)
}
})
type Contact = 'live' | 'unreachable'
describe('mobile relay hosts: phone -> desktop lists the servers it shows', () => {
const cleanups: (() => Promise<void> | void)[] = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0).toReversed()) {
await cleanup()
}
})
async function startRuntime() {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture store carries the surface these RPCs read, as in the sibling relay tests.
const runtime = new OrcaRuntimeService(makeStore() as never)
const server = new OrcaRuntimeRpcServer({
runtime,
userDataPath: mkdtempSync(join(tmpdir(), 'orca-relay-hosts-')),
enableWebSocket: true,
wsPort: 0
})
await server.start()
cleanups.push(() => server.stop())
return server
}
function pairingUrl(server: OrcaRuntimeRpcServer, scope: 'mobile' | 'runtime'): string {
const offer = server.createPairingOffer({ address: '127.0.0.1', name: scope, scope })
if (!offer.available) {
throw new Error('pairing unavailable')
}
return offer.pairingUrl
}
/** The desktop's status snapshot for a server: what its status owner last heard. */
function snapshot(
environmentId: string,
contact: Contact,
capable: boolean
): RuntimeHostStatusSnapshot {
return {
environmentId,
pairingRevision: 1,
sequence: 1,
checkedAt: 1,
status: {
runtimeId: environmentId,
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
capabilities: capable ? [DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY] : []
},
verification: contact === 'live' ? 'verified' : 'unavailable',
transport: contact === 'live' ? 'ready' : 'disconnected'
}
}
async function startTopology() {
const box = await startRuntime()
const old = await startRuntime()
const desktop = await startRuntime()
const servers = new Map<string, { name: string; pairing: PairingOffer; capable: boolean }>([
[
'box',
{ name: 'Box', pairing: parsePairingCode(pairingUrl(box, 'runtime'))!, capable: true }
],
[
'old',
{ name: 'ThinkPad', pairing: parsePairingCode(pairingUrl(old, 'runtime'))!, capable: false }
]
])
const contact = new Map<string, Contact>([
['box', 'live'],
['old', 'live']
])
const hosts: MobileDesktopRelayHosts = {
list: () => ({
environments: [...servers].map(([id, { name }]) => ({ id, name, fence: 'pairing-1' })),
statusByEnvironmentId: new Map(
[...servers].map(([id, { capable }]) => [
id,
runtimeEnvironmentStatusFromSnapshot(snapshot(id, contact.get(id)!, capable))
])
)
}),
resolve: async (environmentId) => {
const server = servers.get(environmentId)
return server ? { environmentId, fence: 'pairing-1', pairing: server.pairing } : null
},
call: (host, method, params) =>
sendRemoteRuntimeRequest(servers.get(host.environmentId)!.pairing, method, params, 5_000),
onEnvironmentRetired: () => () => {}
}
desktop.setMobileDesktopRelayHosts(hosts)
const session = await authenticateMobileWsSession(pairingUrl(desktop, 'mobile'))
const replies = createEncryptedWsResponseReader(session)
cleanups.push(() => {
replies.dispose()
session.ws.close()
})
let sequence = 0
const phoneCall = async (method: string, params?: unknown) => {
const id = `phone-${++sequence}`
sendEncryptedWsRequest(session, { id, method, params })
return z.object({ ok: z.literal(true), result: z.unknown() }).parse(await replies.next(id))
.result
}
return { box, contact, phoneCall }
}
it('lists cached rows for an unreachable server and live rows for an update-needed one', async () => {
const { box, contact, phoneCall } = await startTopology()
await expect(phoneCall('mobileRelay.hosts.list')).resolves.toEqual({
hosts: [
{ hostId: 'runtime:box', label: 'Box', health: 'available', relay: 'ready' },
{ hostId: 'runtime:old', label: 'ThinkPad', health: 'available', relay: 'update-needed' }
]
})
const warm = await phoneCall('mobileRelay.hosts.worktrees', { hostId: 'runtime:box' })
expect(warm).toMatchObject({
worktrees: [
{ worktreeId: 'repo-1::/tmp/worktree-a', hostId: 'runtime:box', branch: 'feature/foo' }
],
totalCount: 1,
stale: false
})
// The update-needed server still answers the desktop, so its rows are live.
await expect(
phoneCall('mobileRelay.hosts.worktrees', { hostId: 'runtime:old' })
).resolves.toMatchObject({
worktrees: [{ worktreeId: 'repo-1::/tmp/worktree-a', hostId: 'runtime:old' }],
stale: false
})
await box.stop()
contact.set('box', 'unreachable')
await expect(phoneCall('mobileRelay.hosts.list')).resolves.toMatchObject({
hosts: [{ hostId: 'runtime:box', relay: 'unavailable' }, { hostId: 'runtime:old' }]
})
const asRecord = z.record(z.string(), z.unknown())
const offline = asRecord.parse(
await phoneCall('mobileRelay.hosts.worktrees', { hostId: 'runtime:box' })
)
// The last rows exactly as fetched, provenance kept.
expect(offline).toEqual({ ...asRecord.parse(warm), stale: true })
})
it('answers an empty list on a desktop that relays nowhere', async () => {
const desktop = await startRuntime()
const session = await authenticateMobileWsSession(pairingUrl(desktop, 'mobile'))
const replies = createEncryptedWsResponseReader(session)
cleanups.push(() => {
replies.dispose()
session.ws.close()
})
sendEncryptedWsRequest(session, { id: 'list', method: 'mobileRelay.hosts.list' })
await expect(replies.next('list')).resolves.toMatchObject({ ok: true, result: { hosts: [] } })
})
})
@@ -0,0 +1,227 @@
import { describe, expect, it, vi } from 'vitest'
import { DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY } from '../../../shared/delegated-mobile-device-contract'
import { RUNTIME_PROTOCOL_VERSION } from '../../../shared/protocol-version'
import {
runtimeEnvironmentStatusFromSnapshot,
type RuntimeHostStatusSnapshot
} from '../../../shared/runtime-host-status'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type { RuntimeStatus } from '../../../shared/runtime-types'
import type { MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts'
import { MobileRelayHostCatalog } from './mobile-relay-host-catalog'
function answer(capable: boolean): RuntimeStatus {
return {
runtimeId: 'runtime-a',
rendererGraphEpoch: 0,
graphStatus: 'ready',
authoritativeWindowId: null,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
capabilities: capable ? [DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY] : []
}
}
function snapshot(
environmentId: string,
contact: 'live' | 'unreachable' | 'never-answered',
capable = true
): RuntimeHostStatusSnapshot {
return {
environmentId,
pairingRevision: 1,
sequence: 1,
checkedAt: 1,
status: contact === 'never-answered' ? null : answer(capable),
verification: contact === 'live' ? 'verified' : 'unavailable',
transport: contact === 'live' ? 'ready' : 'disconnected'
}
}
function psReply(rows: Record<string, unknown>[]): RuntimeRpcResponse<unknown> {
return {
id: 'ps',
ok: true,
result: { worktrees: rows, totalCount: rows.length, truncated: false },
_meta: { runtimeId: 'runtime-a' }
}
}
function fakeHosts(
environments: { id: string; name: string; snapshot: RuntimeHostStatusSnapshot }[]
) {
const state = { environments, fence: 'fence-1' }
const call = vi.fn<MobileDesktopRelayHosts['call']>()
const hosts: MobileDesktopRelayHosts = {
list: () => ({
environments: state.environments.map(({ id, name }) => ({ id, name, fence: state.fence })),
statusByEnvironmentId: new Map(
state.environments.map((entry) => [
entry.id,
runtimeEnvironmentStatusFromSnapshot(entry.snapshot)
])
)
}),
resolve: async (environmentId) => ({
environmentId,
fence: state.fence,
pairing: { v: 2, endpoint: 'ws://server', deviceToken: 'desktop', publicKeyB64: 'k' }
}),
call,
onEnvironmentRetired: () => () => {}
}
return { hosts, call, state }
}
function catalog(hosts: MobileDesktopRelayHosts, labels = new Map()) {
let now = 1_000
return {
catalog: new MobileRelayHostCatalog({
hosts,
hostLabelOverrides: () => labels,
now: () => now
}),
advance: (ms: number) => {
now += ms
}
}
}
describe('mobile relay host catalog', () => {
it("lists every configured server with the desktop's health and whether it can relay", () => {
const { hosts, call } = fakeHosts([
{ id: 'ready', name: 'Box', snapshot: snapshot('ready', 'live') },
{ id: 'old', name: 'ThinkPad', snapshot: snapshot('old', 'live', false) },
{ id: 'down', name: 'VM', snapshot: snapshot('down', 'unreachable') },
{ id: 'old-down', name: 'Pi', snapshot: snapshot('old-down', 'unreachable', false) },
{ id: 'new', name: 'Fresh', snapshot: snapshot('new', 'never-answered') }
])
call.mockResolvedValue(psReply([]))
const { catalog: hostCatalog } = catalog(hosts, new Map([['runtime:ready', 'Build box']]))
expect(hostCatalog.list().hosts).toEqual([
{ hostId: 'runtime:ready', label: 'Build box', health: 'available', relay: 'ready' },
{ hostId: 'runtime:old', label: 'ThinkPad', health: 'available', relay: 'update-needed' },
{ hostId: 'runtime:down', label: 'VM', health: 'connecting', relay: 'unavailable' },
// A build's capabilities outlive contact, so an old server stays update-needed offline.
{ hostId: 'runtime:old-down', label: 'Pi', health: 'connecting', relay: 'update-needed' },
{ hostId: 'runtime:new', label: 'Fresh', health: 'connecting', relay: 'unavailable' }
])
})
it('encodes an environment id into the host id the desktop uses', () => {
const { hosts } = fakeHosts([
{ id: 'a b', name: 'Spaced', snapshot: snapshot('a b', 'unreachable') }
])
expect(catalog(hosts).catalog.list().hosts[0]?.hostId).toBe('runtime:a%20b')
})
it("serves a server's rows fetched as the desktop, re-stamped with the server's host id", async () => {
const { hosts, call } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }
])
call.mockResolvedValue(
psReply([
{ worktreeId: 'w-local', hostId: 'local', status: 'working' },
{ worktreeId: 'w-ssh', hostId: 'ssh:devbox', status: 'inactive' },
{ worktreeId: 'w-legacy', status: 'done' }
])
)
const { catalog: hostCatalog } = catalog(hosts)
await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({
worktrees: [
{ worktreeId: 'w-local', hostId: 'runtime:env', status: 'working' },
{ worktreeId: 'w-ssh', hostId: 'runtime:env', status: 'inactive' },
{ worktreeId: 'w-legacy', hostId: 'runtime:env', status: 'done' }
],
totalCount: 3,
truncated: false,
fetchedAt: 1_000,
stale: false
})
expect(call).toHaveBeenCalledWith(
expect.objectContaining({ environmentId: 'env' }),
'worktree.ps',
{
supportsWorktreeVisibilitySourceDefaults: true
}
)
})
it('keeps the last rows, unchanged and marked stale, once the server stops answering', async () => {
const { hosts, call, state } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }
])
call.mockResolvedValue(psReply([{ worktreeId: 'w', hostId: 'local', status: 'working' }]))
const { catalog: hostCatalog, advance } = catalog(hosts)
hostCatalog.list()
await vi.waitFor(() => expect(call).toHaveBeenCalledTimes(1))
advance(5_000)
state.environments = [{ id: 'env', name: 'Box', snapshot: snapshot('env', 'unreachable') }]
const offline = await hostCatalog.worktrees('runtime:env')
// Never re-fetched while the desktop itself cannot reach it, and never rewritten as idle.
expect(call).toHaveBeenCalledTimes(1)
expect(offline).toEqual({
worktrees: [{ worktreeId: 'w', hostId: 'runtime:env', status: 'working' }],
totalCount: 1,
truncated: false,
fetchedAt: 1_000,
stale: true
})
// A failed fetch on a server that looks reachable also serves the last rows as stale.
state.environments = [{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }]
call.mockRejectedValueOnce(new Error('socket closed'))
await expect(hostCatalog.worktrees('runtime:env')).resolves.toMatchObject({
fetchedAt: 1_000,
stale: true
})
})
it('lists an update-needed server live, since the desktop itself can still reach it', async () => {
const { hosts, call } = fakeHosts([
{ id: 'old', name: 'ThinkPad', snapshot: snapshot('old', 'live', false) }
])
call.mockResolvedValue(psReply([{ worktreeId: 'w', hostId: 'local' }]))
await expect(catalog(hosts).catalog.worktrees('runtime:old')).resolves.toMatchObject({
worktrees: [{ worktreeId: 'w', hostId: 'runtime:old' }],
stale: false
})
})
it('has no rows for a server never fetched, an unknown host, or rows from before a re-pair', async () => {
const { hosts, call, state } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'unreachable') }
])
const { catalog: hostCatalog } = catalog(hosts)
await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({ worktrees: null })
await expect(hostCatalog.worktrees('runtime:other')).resolves.toEqual({ worktrees: null })
await expect(hostCatalog.worktrees('ssh:env')).resolves.toEqual({ worktrees: null })
state.environments = [{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }]
call.mockResolvedValue(psReply([{ worktreeId: 'w', hostId: 'local' }]))
await hostCatalog.worktrees('runtime:env')
state.fence = 'fence-2'
state.environments = [{ id: 'env', name: 'Box', snapshot: snapshot('env', 'unreachable') }]
await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({ worktrees: null })
})
it('shares one in-flight fetch per server between listing and a rows request', async () => {
const { hosts, call } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }
])
let answerFetch: (response: RuntimeRpcResponse<unknown>) => void = () => {}
call.mockReturnValue(new Promise((resolve) => (answerFetch = resolve)))
const { catalog: hostCatalog } = catalog(hosts)
hostCatalog.list()
hostCatalog.list()
const rows = hostCatalog.worktrees('runtime:env')
await vi.waitFor(() => expect(call).toHaveBeenCalledTimes(1))
answerFetch(psReply([{ worktreeId: 'w', hostId: 'local' }]))
await expect(rows).resolves.toMatchObject({ stale: false })
expect(call).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,166 @@
import { z } from 'zod'
import { DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY } from '../../../shared/delegated-mobile-device-contract'
import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
import { buildExecutionHostRegistry } from '../../../shared/execution-host-registry'
import {
stampServerWorktreeRows,
type MobileRelayHost,
type MobileRelayHostRelay,
type MobileRelayHostsListResult,
type MobileRelayHostWorktreesResult,
type MobileRelayServerWorktreeRow
} from '../../../shared/mobile-relay-hosts-contract'
import { lastVerifiedRuntimeStatus } from '../../../shared/runtime-host-status'
import type { RuntimeStatus } from '../../../shared/runtime-types'
import type { MobileDesktopRelayHosts } from './mobile-desktop-relay-hosts'
const WorktreePsReplySchema = z.object({
worktrees: z.array(z.looseObject({})),
totalCount: z.number(),
truncated: z.boolean()
})
type CachedServerWorktrees = {
fence: string
fetchedAt: number
worktrees: MobileRelayServerWorktreeRow[]
totalCount: number
truncated: boolean
}
type DescribedHost = MobileRelayHost & { environmentId: string; fence: string }
/**
* The configured servers the desktop shows, for the phone: health from the desktop's own status
* (the sidebar's mapping), and each server's workspace list as the desktop itself last fetched it,
* so a server that is offline or too old to relay to still lists. Replies relayed to a phone are
* never read here.
*/
export class MobileRelayHostCatalog {
private readonly cached = new Map<string, CachedServerWorktrees>()
private readonly refreshing = new Map<string, Promise<boolean>>()
constructor(
private readonly options: {
hosts: MobileDesktopRelayHosts
hostLabelOverrides: () => ReadonlyMap<ExecutionHostId, string>
now?: () => number
}
) {}
list(): MobileRelayHostsListResult {
const hosts = this.describe()
for (const host of hosts) {
// Why: keeps the last list warm while the server answers, for when it stops answering.
if (host.health === 'available') {
void this.refresh(host)
}
}
return {
hosts: hosts.map(({ hostId, label, health, relay }) => ({ hostId, label, health, relay }))
}
}
async worktrees(hostId: string): Promise<MobileRelayHostWorktreesResult> {
const parsed = parseExecutionHostId(hostId)
const host =
parsed?.kind === 'runtime'
? this.describe().find((entry) => entry.environmentId === parsed.environmentId)
: undefined
if (!host) {
return { worktrees: null }
}
const refreshed = host.health === 'available' && (await this.refresh(host))
const cached = this.cached.get(host.environmentId)
// Why: rows from before a re-pair may belong to a different server.
if (!cached || cached.fence !== host.fence) {
return { worktrees: null }
}
const { fence: _fence, ...rows } = cached
return { ...rows, stale: !refreshed }
}
private describe(): DescribedHost[] {
const { environments, statusByEnvironmentId } = this.options.hosts.list()
const fences = new Map(environments.map((environment) => [environment.id, environment.fence]))
return buildExecutionHostRegistry({
repos: [],
settings: null,
hostSource: 'configured-only',
runtimeEnvironments: environments,
runtimeStatusByEnvironmentId: statusByEnvironmentId,
hostLabelOverrides: this.options.hostLabelOverrides()
}).flatMap((entry) => {
const parsed = parseExecutionHostId(entry.id)
const fence = parsed?.kind === 'runtime' ? fences.get(parsed.environmentId) : undefined
if (parsed?.kind !== 'runtime' || fence === undefined) {
return []
}
const answer = lastVerifiedRuntimeStatus(statusByEnvironmentId.get(parsed.environmentId))
return [
{
hostId: parsed.id,
environmentId: parsed.environmentId,
fence,
label: entry.label,
health: entry.health,
relay: relayVerdict(entry.health, answer)
}
]
})
}
private refresh(host: DescribedHost): Promise<boolean> {
const inFlight = this.refreshing.get(host.environmentId)
if (inFlight) {
return inFlight
}
const refresh = this.fetchAsDesktop(host).finally(() =>
this.refreshing.delete(host.environmentId)
)
this.refreshing.set(host.environmentId, refresh)
return refresh
}
private async fetchAsDesktop(host: DescribedHost): Promise<boolean> {
try {
const resolved = await this.options.hosts.resolve(host.environmentId)
if (!resolved) {
return false
}
const response = await this.options.hosts.call(resolved, 'worktree.ps', {
supportsWorktreeVisibilitySourceDefaults: true
})
const reply = response.ok ? WorktreePsReplySchema.safeParse(response.result) : null
if (!reply?.success) {
return false
}
this.cached.set(host.environmentId, {
// Why the listing's fence, taken before the fetch: a re-pair mid-fetch must not adopt these rows.
fence: host.fence,
fetchedAt: (this.options.now ?? Date.now)(),
worktrees: stampServerWorktreeRows(host.environmentId, reply.data.worktrees),
totalCount: reply.data.totalCount,
truncated: reply.data.truncated
})
return true
} catch {
// An unreachable server keeps its last rows; they are served as stale.
return false
}
}
}
function relayVerdict(
health: MobileRelayHost['health'],
answer: RuntimeStatus | null
): MobileRelayHostRelay {
if (!answer) {
return 'unavailable'
}
// Why: a build's capabilities do not expire, so an old server stays update-needed while offline.
if (!(answer.capabilities ?? []).includes(DELEGATED_MOBILE_DEVICES_RUNTIME_CAPABILITY)) {
return 'update-needed'
}
return health === 'available' ? 'ready' : 'unavailable'
}
+9
View File
@@ -9,6 +9,10 @@ import type {
PairingProvisionRelayParams
} from '../../../shared/mobile-relay-credential-contract'
import type { RuntimeCapability } from '../../../shared/protocol-version'
import type {
MobileRelayHostsListResult,
MobileRelayHostWorktreesResult
} from '../../../shared/mobile-relay-hosts-contract'
import type {
DelegatedPhone,
DelegatedMobileDeviceSyncResult
@@ -117,6 +121,11 @@ export type RpcContext = {
// Why: federation pins the authenticated saved-environment caller without exposing its token to handlers or storage.
authenticatedCallerFingerprint?: string
pairing?: PairingRpcContext
// Why: set only on a desktop that relays; it lists the configured servers it shows.
mobileRelayHosts?: {
list(): MobileRelayHostsListResult
worktrees(hostId: string): Promise<MobileRelayHostWorktreesResult>
}
// Why: set only for an authenticated runtime-scope device and bound to it, so it manages only its own phones.
delegatedMobileDevices?: {
sync(phones: readonly DelegatedPhone[]): DelegatedMobileDeviceSyncResult
@@ -15,6 +15,7 @@ export type RpcDispatchStreamingOptions = {
clientCapabilities?: readonly RuntimeCapability[]
updateClientCapabilities?: (capabilities: readonly RuntimeCapability[]) => void
pairing?: PairingRpcContext
mobileRelayHosts?: RpcContext['mobileRelayHosts']
delegatedMobileDevices?: RpcContext['delegatedMobileDevices']
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void
registerBinaryStreamHandler?: (
+2
View File
@@ -46,6 +46,7 @@ import { MOBILE_WEB_BUNDLE_METHODS } from './mobile-web-bundle'
import { RUNTIME_CLIENT_CAPABILITY_METHODS } from './runtime-client-capabilities'
import { EMULATOR_METHODS } from './emulator'
import { PAIRING_METHODS } from './pairing'
import { MOBILE_RELAY_HOSTS_METHODS } from './mobile-relay-hosts'
import { UPDATER_METHODS } from './updater'
import { AGENT_SESSION_METHODS } from './agent-session'
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
@@ -116,5 +117,6 @@ export const ALL_RPC_METHODS = [
...CLIENT_UI_METHODS,
...EMULATOR_METHODS,
...PAIRING_METHODS,
...MOBILE_RELAY_HOSTS_METHODS,
...UPDATER_METHODS
]
@@ -0,0 +1,21 @@
import { defineMethod } from '../core'
import {
MOBILE_RELAY_HOST_WORKTREES_METHOD,
MOBILE_RELAY_HOSTS_LIST_METHOD,
MobileRelayHostWorktreesParamsSchema
} from '../../../../shared/mobile-relay-hosts-contract'
export const MOBILE_RELAY_HOSTS_METHODS = [
defineMethod({
name: MOBILE_RELAY_HOSTS_LIST_METHOD,
params: null,
// Why empty rather than an error: a desktop that relays nowhere has no servers to show.
handler: (_params, ctx) => ctx.mobileRelayHosts?.list() ?? { hosts: [] }
}),
defineMethod({
name: MOBILE_RELAY_HOST_WORKTREES_METHOD,
params: MobileRelayHostWorktreesParamsSchema,
handler: async (params, ctx) =>
(await ctx.mobileRelayHosts?.worktrees(params.hostId)) ?? { worktrees: null }
})
]
@@ -150,6 +150,7 @@ export class RpcStreamingDispatcher {
recordMutationReceipt: mutation?.recordReceipt,
orchestrationMutation: mutation?.identity,
pairing: options?.pairing,
mobileRelayHosts: options?.mobileRelayHosts,
delegatedMobileDevices: options?.delegatedMobileDevices,
sendBinary: options?.sendBinary,
registerBinaryStreamHandler: options?.registerBinaryStreamHandler,
@@ -200,6 +201,7 @@ export class RpcStreamingDispatcher {
clientCapabilities: options?.clientCapabilities,
updateClientCapabilities: options?.updateClientCapabilities,
pairing: options?.pairing,
mobileRelayHosts: options?.mobileRelayHosts,
delegatedMobileDevices: options?.delegatedMobileDevices,
sendBinary: options?.sendBinary,
registerBinaryStreamHandler: options?.registerBinaryStreamHandler,
@@ -178,6 +178,8 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'linear.updateIssue',
'markdown.readTab',
'markdown.saveTab',
'mobileRelay.hosts.list',
'mobileRelay.hosts.worktrees',
'mobileWeb.bundle.chunk',
'mobileWeb.bundle.manifest',
'mobileWeb.bundle.range',
@@ -12,6 +12,9 @@ export const PAIRED_DESKTOP_METHODS = [
'notifications.unsubscribe',
'pairing.getEndpoints',
'pairing.provisionRelay',
// The configured servers the desktop shows, and their workspace lists as it last fetched them.
'mobileRelay.hosts.list',
'mobileRelay.hosts.worktrees',
'speech.dictation.cancel',
'speech.dictation.chunk',
'speech.dictation.finish',
@@ -9,6 +9,8 @@ import { resolveAdvertisedPairingEndpoint } from '../pairing-endpoint'
import { RuntimeRpcNetworkExposure } from './runtime-rpc-network-exposure'
import { MobileDesktopRelay } from '../mobile-desktop-relay/mobile-desktop-relay'
import type { MobileDesktopRelayHosts } from '../mobile-desktop-relay/mobile-desktop-relay-hosts'
import { MobileRelayHostCatalog } from '../mobile-desktop-relay/mobile-relay-host-catalog'
import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides'
import { DELEGATED_PHONE_NAME_MAX_CHARS } from '../../../shared/delegated-mobile-device-contract'
import { allocateTerminalSubscriptionStreamId } from '../rpc/methods/terminal/terminal-subscription-stream-id'
import {
@@ -93,6 +95,12 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure {
allocateStreamId: allocateTerminalSubscriptionStreamId
})
: null
this.mobileRelayHostCatalog = hosts
? new MobileRelayHostCatalog({
hosts,
hostLabelOverrides: () => getHostDisplayLabelOverrides(this.runtime.getClientSettings())
})
: null
}
private listRelayedPhones(): { phoneKey: string; name: string }[] {
@@ -10,6 +10,7 @@ import type { E2EEKeypair } from '../e2ee-keypair'
import type { UnpairedDeviceAuthThrottle } from '../rpc/unpaired-device-auth-throttle'
import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring'
import type { MobileDesktopRelay } from '../mobile-desktop-relay/mobile-desktop-relay'
import type { MobileRelayHostCatalog } from '../mobile-desktop-relay/mobile-relay-host-catalog'
import { RelayRevokeOutbox } from '../relay/relay-revoke-outbox'
import { PushUnregisterOutbox } from '../push/push-unregister-outbox'
import { RuntimeBinaryMessageRouter } from '../runtime-binary-message-router'
@@ -73,6 +74,7 @@ export class RuntimeRpcState {
protected mobileRelayPairingProvider: MobileRelayPairingProvider | null = null
// Why: only a desktop with configured servers relays; headless serve leaves this unset.
protected mobileDesktopRelay: MobileDesktopRelay | null = null
protected mobileRelayHostCatalog: MobileRelayHostCatalog | null = null
protected mobileRelayPairingOfferQueue: Promise<void> = Promise.resolve()
protected mobileRelayPairingOfferInFlight: {
generation: number
@@ -211,6 +211,7 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcMobileDevices {
}
: undefined,
pairing: pairingContext,
mobileRelayHosts: this.mobileRelayHostCatalog ?? undefined,
delegatedMobileDevices:
device.scope === 'runtime'
? { sync: (phones) => this.syncDelegatedMobileDevices(device.deviceId, phones) }
@@ -1,4 +1,7 @@
import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status'
import {
runtimeEnvironmentStatusFromSnapshot,
type RuntimeHostStatusSnapshot
} from '../../../../shared/runtime-host-status'
import type { AppState } from '../types'
import type { RuntimeEnvironmentStatus } from './runtime-status-types'
import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach'
@@ -21,17 +24,11 @@ export function applyRuntimeHostStatusSnapshot(
return
}
const entry: RuntimeEnvironmentStatus = {
snapshot,
checkedAt: snapshot.checkedAt,
...runtimeEnvironmentStatusFromSnapshot(snapshot),
connectionGeneration: previous?.connectionGeneration,
hostContactEpoch: previous?.hostContactEpoch,
status: snapshot.verification === 'verified' && !snapshot.retired ? snapshot.status : null,
remoteControl: snapshot.remoteControl
hostContactEpoch: previous?.hostContactEpoch
}
if (entry.status) {
if (snapshot.remoteControl) {
entry.status = { ...entry.status, remoteControl: snapshot.remoteControl }
}
state.setRuntimeEnvironmentStatus(snapshot.environmentId, entry)
if (previous?.status == null) {
void ensureBrowserClientHostsForRestoredPages(state)
+52
View File
@@ -0,0 +1,52 @@
import { z } from 'zod'
import { toRuntimeExecutionHostId } from './execution-host'
import type { ExecutionHostHealth } from './execution-host-health'
// Why: both are answered by the paired desktop itself; the phone lists servers the desktop shows.
export const MOBILE_RELAY_HOSTS_LIST_METHOD = 'mobileRelay.hosts.list'
export const MOBILE_RELAY_HOST_WORKTREES_METHOD = 'mobileRelay.hosts.worktrees'
/** Whether the desktop can relay the phone to this server now; `update-needed` is the server's build. */
export type MobileRelayHostRelay = 'ready' | 'update-needed' | 'unavailable'
export type MobileRelayHost = {
hostId: `runtime:${string}`
label: string
health: ExecutionHostHealth
relay: MobileRelayHostRelay
}
export type MobileRelayHostsListResult = { hosts: MobileRelayHost[] }
export const MobileRelayHostWorktreesParamsSchema = z
.object({ hostId: z.string().min(1).max(1024) })
.strict()
/** One of a server's own `worktree.ps` rows (RuntimeWorktreePsSummary), carrying the server's host id. */
export type MobileRelayServerWorktreeRow = Record<string, unknown> & { hostId: `runtime:${string}` }
/**
* The server's own `worktree.ps` rows as the desktop last fetched them. `stale` means this call
* could not refresh them; rows are never rewritten beyond their host id.
*/
export type MobileRelayHostWorktreesResult =
| {
worktrees: MobileRelayServerWorktreeRow[]
totalCount: number
truncated: boolean
fetchedAt: number
stale: boolean
}
| { worktrees: null }
/**
* A server's rows name hosts relative to that server (`local`, its own `ssh:` targets). Like the
* desktop sidebar, the phone shows every one of them under the server, so all become its host id.
*/
export function stampServerWorktreeRows<Row extends { hostId?: string }>(
environmentId: string,
rows: readonly Row[]
): (Row & { hostId: `runtime:${string}` })[] {
const hostId = toRuntimeExecutionHostId(environmentId)
return rows.map((row) => ({ ...row, hostId }))
}
+3
View File
@@ -23,6 +23,7 @@ import {
PairingGetEndpointsParamsSchema,
PairingProvisionRelayParamsSchema
} from '../mobile-relay-credential-contract'
import { MobileRelayHostWorktreesParamsSchema } from '../mobile-relay-hosts-contract'
import { MobileWebBundleChunkParamsSchema } from '../mobile-web-bundle/bundle-rpc-contract'
import { OrcadMigrationSnapshotChunkRequestSchema } from '../orcad-migration-scrollback'
import { OrcadTerminalCensusParamsSchema } from '../orcad-terminal-census'
@@ -1030,6 +1031,8 @@ export const RPC_PARAMS_BY_METHOD = {
'managedServer.update': ManagedServerUpdate,
'markdown.readTab': ActivateTab,
'markdown.saveTab': SaveMarkdownTab,
'mobileRelay.hosts.list': null,
'mobileRelay.hosts.worktrees': MobileRelayHostWorktreesParamsSchema,
'mobileWeb.bundle.chunk': MobileWebBundleChunkParamsSchema,
'mobileWeb.bundle.manifest': null,
'mobileWeb.bundle.range': MobileWebBundleChunkParamsSchema,
+16
View File
@@ -42,6 +42,22 @@ export type RuntimeEnvironmentStatus = {
hostContactEpoch?: number
}
/** The store entry a status snapshot stands for: its status counts only while verified and current. */
export function runtimeEnvironmentStatusFromSnapshot(
snapshot: RuntimeHostStatusSnapshot
): RuntimeEnvironmentStatus {
const status = snapshot.verification === 'verified' && !snapshot.retired ? snapshot.status : null
return {
snapshot,
checkedAt: snapshot.checkedAt,
status:
status && snapshot.remoteControl
? { ...status, remoteControl: snapshot.remoteControl }
: status,
remoteControl: snapshot.remoteControl
}
}
/**
* The last status the host actually answered with. The snapshot retains it across an
* unverifiable probe, so this survives a loss of contact; the entry's own `status` does not.