From 5afbb8a044336c6dcb921e6821e6734b8dc193db Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 8 Oct 2026 01:08:07 -0400 Subject: [PATCH] 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 --- ...ime-environment-mobile-relay-hosts.test.ts | 63 ++++- .../runtime-environment-mobile-relay-hosts.ts | 41 +++- .../mobile-desktop-relay-hosts.ts | 16 ++ .../mobile-desktop-relay.integration.test.ts | 1 + .../mobile-desktop-relay.test.ts | 1 + ...ile-relay-host-catalog.integration.test.ts | 201 ++++++++++++++++ .../mobile-relay-host-catalog.test.ts | 227 ++++++++++++++++++ .../mobile-relay-host-catalog.ts | 166 +++++++++++++ src/main/runtime/rpc/core.ts | 9 + .../runtime/rpc/dispatcher-stream-options.ts | 1 + src/main/runtime/rpc/methods/index.ts | 2 + .../runtime/rpc/methods/mobile-relay-hosts.ts | 21 ++ .../runtime/rpc/rpc-streaming-dispatcher.ts | 2 + .../runtime-rpc-mobile-method-allowlist.ts | 2 + ...ntime-rpc-mobile-paired-desktop-methods.ts | 3 + .../runtime-rpc/runtime-rpc-pairing.ts | 8 + .../runtime/runtime-rpc/runtime-rpc-state.ts | 2 + .../runtime-rpc-websocket-dispatch.ts | 1 + .../store/slices/runtime-status-snapshot.ts | 15 +- src/shared/mobile-relay-hosts-contract.ts | 52 ++++ .../rpc-params-catalog.generated.ts | 3 + src/shared/runtime-host-status.ts | 16 ++ 22 files changed, 840 insertions(+), 13 deletions(-) create mode 100644 src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.integration.test.ts create mode 100644 src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.test.ts create mode 100644 src/main/runtime/mobile-desktop-relay/mobile-relay-host-catalog.ts create mode 100644 src/main/runtime/rpc/methods/mobile-relay-hosts.ts create mode 100644 src/shared/mobile-relay-hosts-contract.ts 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.