mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +00:00
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:
@@ -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,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?: (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user