diff --git a/src/main/ipc/runtime-environment-mobile-relay-hosts.test.ts b/src/main/ipc/runtime-environment-mobile-relay-hosts.test.ts index 99ac27a765d..3e6ace75f71 100644 --- a/src/main/ipc/runtime-environment-mobile-relay-hosts.test.ts +++ b/src/main/ipc/runtime-environment-mobile-relay-hosts.test.ts @@ -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[] = [] + const snapshots: Record[] = [] + 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 () => { diff --git a/src/main/ipc/runtime-environment-mobile-relay-hosts.ts b/src/main/ipc/runtime-environment-mobile-relay-hosts.ts index a18bfbfa37f..a8c288ed4b9 100644 --- a/src/main/ipc/runtime-environment-mobile-relay-hosts.ts +++ b/src/main/ipc/runtime-environment-mobile-relay-hosts.ts @@ -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() + 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}` +} diff --git a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts index ff7727f8c6e..e0653cd196c 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay-hosts.ts @@ -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 + } /** Null when the id names no configured server; a phone-supplied endpoint is never used. */ resolve: (environmentId: string) => Promise /** A call as the desktop itself, over its own connection to the server. */ diff --git a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts index 9a9542d41f0..a9ceacc9a28 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.integration.test.ts @@ -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 } diff --git a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.test.ts index 46e9a86c5f2..18da82cb2e8 100644 --- a/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.test.ts +++ b/src/main/runtime/mobile-desktop-relay/mobile-desktop-relay.test.ts @@ -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', diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.integration.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.integration.test.ts new file mode 100644 index 00000000000..fb8e79db761 --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.integration.test.ts @@ -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)[] = [] + 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([ + [ + 'box', + { name: 'Box', pairing: parsePairingCode(pairingUrl(box, 'runtime'))!, capable: true } + ], + [ + 'old', + { name: 'ThinkPad', pairing: parsePairingCode(pairingUrl(old, 'runtime'))!, capable: false } + ] + ]) + const contact = new Map([ + ['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: [] } }) + }) +}) diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts new file mode 100644 index 00000000000..26aa8d967c8 --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts @@ -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[]): RuntimeRpcResponse { + 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() + 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) => 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) + }) +}) diff --git a/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts new file mode 100644 index 00000000000..cc32e3febfb --- /dev/null +++ b/src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts @@ -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() + private readonly refreshing = new Map>() + + constructor( + private readonly options: { + hosts: MobileDesktopRelayHosts + hostLabelOverrides: () => ReadonlyMap + 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 { + 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 { + 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 { + 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' +} diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 55ee61e8200..2b2d8758681 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -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 + } // 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 diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index ae92fd9fa63..9dcd0c58699 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -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) => boolean | void registerBinaryStreamHandler?: ( diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 3370db3f2de..bfebacd246e 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -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 ] diff --git a/src/main/runtime/rpc/methods/mobile-relay-hosts.ts b/src/main/runtime/rpc/methods/mobile-relay-hosts.ts new file mode 100644 index 00000000000..9e438d70c76 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-relay-hosts.ts @@ -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 } + }) +] diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 56216da2a44..1446fb4b436 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -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, diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index c6e43823a80..a5b552cbdbf 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -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', diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts index c736f0e9577..becea09e246 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts @@ -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', diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index 258ebee25d7..9db3697217d 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -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 }[] { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts index 08af258f624..cda5643b93b 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts @@ -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 = Promise.resolve() protected mobileRelayPairingOfferInFlight: { generation: number diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index e4f084b0e89..8612765730d 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -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) } diff --git a/src/renderer/src/store/slices/runtime-status-snapshot.ts b/src/renderer/src/store/slices/runtime-status-snapshot.ts index a9fffe7c9f2..7266148f8c2 100644 --- a/src/renderer/src/store/slices/runtime-status-snapshot.ts +++ b/src/renderer/src/store/slices/runtime-status-snapshot.ts @@ -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) diff --git a/src/shared/mobile-relay-hosts-contract.ts b/src/shared/mobile-relay-hosts-contract.ts new file mode 100644 index 00000000000..fdea3b6e7fc --- /dev/null +++ b/src/shared/mobile-relay-hosts-contract.ts @@ -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 & { 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( + environmentId: string, + rows: readonly Row[] +): (Row & { hostId: `runtime:${string}` })[] { + const hostId = toRuntimeExecutionHostId(environmentId) + return rows.map((row) => ({ ...row, hostId })) +} diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 4398f8abbf5..0f9e207e010 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -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, diff --git a/src/shared/runtime-host-status.ts b/src/shared/runtime-host-status.ts index 60721849b1f..d3df230e01b 100644 --- a/src/shared/runtime-host-status.ts +++ b/src/shared/runtime-host-status.ts @@ -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.