diff --git a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts index 9f0f32e0c8..a7b183a614 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-ssh-terminal.test.ts @@ -711,6 +711,17 @@ test('sends a Mesh invitation only after the authenticated remote operator reque result: { localPeerId: 'peer-b', available: true, + transit: { + meshId: null, + allowedMemberCount: 0, + activeReservationCount: 0, + activeCircuitCount: 0, + maxReservationCount: 32, + maxCircuitCount: 8, + maxCircuitsPerPeer: 2, + maxCircuitDurationSeconds: 7_200, + maxCircuitBytes: 256 * 1024 * 1024, + }, meshes: [ { meshId: 'mesh-id', diff --git a/apps/desktop/src/main/runtime-host-peer-mesh-management.ts b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts index 6ce22d79a7..f56c8f2046 100644 --- a/apps/desktop/src/main/runtime-host-peer-mesh-management.ts +++ b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts @@ -24,7 +24,7 @@ import { type PeerMeshInvitationResult, type PeerMeshQueryResult, } from '@maka/runtime-host/protocol'; -import { projectPeerMeshStatus } from '@maka/runtime-host/server'; +import { projectPeerMeshQuery } from '@maka/runtime-host/server'; import type { DesktopRuntimeHostPeerMeshTarget, } from '../preload/bridge-contract.js'; @@ -52,7 +52,12 @@ export function createDesktopRuntimeHostPeerMeshManagement(input: { ): Promise => { const target = requireTarget(targetValue); const action = requireAction(actionValue); - const meshId = actionNeedsMesh(action) ? requireIdentifier(meshIdValue, 'Mesh ID') : undefined; + const meshId = + action === 'transit' && meshIdValue === null + ? null + : actionNeedsMesh(action) + ? requireIdentifier(meshIdValue, 'Mesh ID') + : undefined; const peerId = action === 'remove' ? requireIdentifier(peerIdValue, 'Peer ID') : undefined; const invitation = action === 'join' ? requireInvitation(invitationValue) : undefined; if (target.kind === 'desktop') { @@ -80,7 +85,7 @@ export function createDesktopRuntimeHostPeerMeshManagement(input: { rootId: managed.profile.rootId, deploymentId: managed.deployment.deploymentId, }, - ...(meshId ? { meshId } : {}), + ...(meshId !== undefined ? { meshId } : {}), ...(peerId ? { peerId } : {}), ...(invitation ? { invitation: JSON.stringify(invitation) } : {}), }); @@ -101,7 +106,7 @@ export function createDesktopRuntimeHostPeerMeshManagement(input: { async function executeLocal( mesh: PeerMeshNode | undefined, action: PeerMeshAction, - meshId: string | undefined, + meshId: string | null | undefined, peerId: string | undefined, invitation: ReturnType | undefined, ): Promise { @@ -109,11 +114,7 @@ async function executeLocal( if (action === 'status') return { available: false, meshes: [] }; throw new Error('This Desktop build does not include Direct peer support'); } - const snapshot = (): PeerMeshQueryResult => ({ - available: true, - localPeerId: mesh.localPeerId(), - meshes: mesh.status().map(projectPeerMeshStatus), - }); + const snapshot = (): PeerMeshQueryResult => projectPeerMeshQuery(mesh); switch (action) { case 'status': return snapshot(); @@ -139,11 +140,14 @@ async function executeLocal( case 'reconcile': await mesh.reconcile(); return snapshot(); + case 'transit': + await mesh.setTransitMesh(meshId ?? null); + return snapshot(); } } -function requiredValue(value: T | undefined, label: string): T { - if (value === undefined) throw new Error(`${label} is required`); +function requiredValue(value: T | null | undefined, label: string): NonNullable { + if (value === undefined || value === null) throw new Error(`${label} is required`); return value; } @@ -168,13 +172,23 @@ function requireTarget(value: unknown): DesktopRuntimeHostPeerMeshTarget { function requireAction(value: unknown): PeerMeshAction { if ( value === 'status' || value === 'create' || value === 'invite' || value === 'join' || - value === 'remove' || value === 'leave' || value === 'close' || value === 'reconcile' + value === 'remove' || + value === 'leave' || + value === 'close' || + value === 'reconcile' || + value === 'transit' ) return value; throw new Error('Peer Mesh action is invalid'); } function actionNeedsMesh(action: PeerMeshAction): boolean { - return action === 'invite' || action === 'remove' || action === 'leave' || action === 'close'; + return ( + action === 'invite' || + action === 'remove' || + action === 'leave' || + action === 'close' || + action === 'transit' + ); } function requireIdentifier(value: unknown, label: string): string { diff --git a/apps/desktop/src/main/runtime-host-ssh-terminal.ts b/apps/desktop/src/main/runtime-host-ssh-terminal.ts index 2fa1307997..8222ce8e9d 100644 --- a/apps/desktop/src/main/runtime-host-ssh-terminal.ts +++ b/apps/desktop/src/main/runtime-host-ssh-terminal.ts @@ -188,7 +188,7 @@ export interface DesktopRuntimeHostSshPeerMeshManagementInput { readonly operatorPath: string; readonly action: RuntimeHostPeerMeshManagementAction; readonly expectedTarget: DesktopRuntimeHostSshManagementInput['expectedTarget']; - readonly meshId?: string; + readonly meshId?: string | null; readonly peerId?: string; readonly invitation?: string; readonly signal?: AbortSignal; @@ -1365,7 +1365,11 @@ function runtimeHostPeerMeshManagementRemoteCommand( 'mesh', input.action, '--framed', - ...(input.meshId ? ['--mesh', input.meshId] : []), + ...(typeof input.meshId === 'string' + ? ['--mesh', input.meshId] + : input.meshId === null + ? ['--off'] + : []), ...(input.peerId ? ['--peer', input.peerId] : []), ...managedServiceTargetArgs(input.expectedTarget), ].map(quotePosix).join(' '); diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index a639c67fab..a4b8502734 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -746,7 +746,11 @@ export interface MakaBridge { execute( target: DesktopRuntimeHostPeerMeshTarget, action: DesktopRuntimeHostPeerMeshAction, - input?: { readonly meshId?: string; readonly peerId?: string; readonly invitation?: string }, + input?: { + readonly meshId?: string | null; + readonly peerId?: string; + readonly invitation?: string; + }, ): Promise; }; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 9f59cfad0a..8a899d7ab5 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1380,7 +1380,11 @@ const makaBridge = { execute( target: import('./bridge-contract.js').DesktopRuntimeHostPeerMeshTarget, action: import('./bridge-contract.js').DesktopRuntimeHostPeerMeshAction, - input: { readonly meshId?: string; readonly peerId?: string; readonly invitation?: string } = {}, + input: { + readonly meshId?: string | null; + readonly peerId?: string; + readonly invitation?: string; + } = {}, ) { return ipcRenderer.invoke( 'runtime-host-peer-mesh:execute', diff --git a/apps/desktop/src/renderer/settings/runtime-host-peer-mesh-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-peer-mesh-dialog.tsx index 35054fc1a2..7c93a7ba8a 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-peer-mesh-dialog.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-peer-mesh-dialog.tsx @@ -28,12 +28,23 @@ import { Button, MoreMenu, redactSecrets, + Switch, Text, TextArea, useToast, useUiLocale, } from '@maka/ui'; -import { ArrowLeft, Copy, ICON_SIZE, KeyRound, Network, Plus, RefreshCcw } from '@maka/ui/icons'; +import { + ArrowLeft, + Copy, + HelpCircle, + ICON_SIZE, + KeyRound, + Network, + Plus, + RefreshCcw, + Workflow, +} from '@maka/ui/icons'; import type { DesktopRuntimeHostPeerMeshTarget } from '../../preload/bridge-contract.js'; type PeerMeshDialogView = @@ -158,6 +169,22 @@ export function RuntimeHostPeerMeshDialog(props: { } } + async function setTransit(meshId: string, enabled: boolean): Promise { + setWorking(true); + setError(undefined); + try { + const result = await window.maka.runtimeHostPeerMesh.execute(props.target, 'transit', { + meshId: enabled ? meshId : null, + }); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + setSnapshot(result); + } catch (failure) { + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } finally { + setWorking(false); + } + } + async function copyInvitation(): Promise { if (view.kind !== 'invitation') return; try { @@ -209,6 +236,7 @@ export function RuntimeHostPeerMeshDialog(props: { onJoin={() => setView({ kind: 'join' })} onCreate={() => void run('create')} onRefresh={() => void run('reconcile')} + onSetTransit={(meshId, enabled) => void setTransit(meshId, enabled)} /> )} @@ -260,6 +288,7 @@ function Overview(props: { readonly onJoin: () => void; readonly onCreate: () => void; readonly onRefresh: () => void; + readonly onSetTransit: (meshId: string, enabled: boolean) => void; }) { const { snapshot, copy } = props; if (!snapshot) { @@ -357,12 +386,14 @@ function Overview(props: { props.onInvite(mesh.meshId)} onRemove={(peerId) => props.onRemove(mesh.meshId, peerId)} onLeave={() => props.onLeave(mesh.meshId)} onClose={() => props.onClose(mesh.meshId)} + onSetTransit={(enabled) => props.onSetTransit(mesh.meshId, enabled)} /> ))} @@ -449,14 +480,17 @@ function InvitationView(props: { function MeshCard(props: { readonly mesh: PeerMeshProjection; + readonly transit: PeerMeshQueryResult['transit']; readonly copy: ReturnType; readonly working: boolean; readonly onInvite: () => void; readonly onRemove: (peerId: string) => void; readonly onLeave: () => void; readonly onClose: () => void; + readonly onSetTransit: (enabled: boolean) => void; }) { const { mesh, copy } = props; + const transitEnabled = props.transit?.meshId === mesh.meshId; return (
@@ -509,6 +543,56 @@ function MeshCard(props: { {copy.revision(mesh.revision)} · {copy.memberCount(mesh.members.length)} {mesh.pendingInvitationCount > 0 ? ` · ${copy.pending(mesh.pendingInvitationCount)}` : ''} + {!mesh.closed ? ( +
+
+ +
+
+ + {copy.transit} + + + +
+ + {copy.transitHelp} + +
+
+ +
+ ) : null} + {transitEnabled && props.transit ? ( +
+ + + +
+ ) : null}
{copy.members} @@ -561,6 +645,19 @@ function MeshCard(props: { ); } +function TransitMetric(props: { readonly label: string; readonly value: string }) { + return ( +
+ + {props.label} + + + {props.value} + +
+ ); +} + function isSnapshot(value: unknown): value is PeerMeshQueryResult { return Boolean(value && typeof value === 'object' && 'available' in value && 'meshes' in value); } @@ -615,6 +712,18 @@ function peerMeshCopy(locale: string) { revision: (value: number) => `版本 ${value}`, memberCount: (value: number) => `${value} 个成员`, pending: (value: number) => `${value} 个待使用邀请`, + transit: '成员转发', + transitHelp: '允许此 Mesh 的成员通过本机建立连接;会使用本机带宽。', + transitToggle: '为此 Mesh 提供转发', + transitStatus: '成员转发状态', + transitLimitsLabel: '成员转发限制', + transitLimits: (value: PeerMeshQueryResult['transit']) => + value + ? `固定上限:每个成员 ${value.maxCircuitsPerPeer} 条连接,每条最长 ${formatHours(value.maxCircuitDurationSeconds)},最多 ${formatMebibytes(value.maxCircuitBytes)}。一次只能为一个 Mesh 开启。` + : '成员转发使用固定资源上限,一次只能为一个 Mesh 开启。', + allowedMembers: '允许成员', + reservations: 'Reservation', + circuits: '连接', routeState: { local: '本机', route_available: '路径可用', @@ -669,6 +778,18 @@ function peerMeshCopy(locale: string) { revision: (value: number) => `Revision ${value}`, memberCount: (value: number) => `${value} members`, pending: (value: number) => `${value} pending invites`, + transit: 'Member transit', + transitHelp: 'Let members of this Mesh connect through this device using its bandwidth.', + transitToggle: 'Provide transit for this Mesh', + transitStatus: 'Member transit status', + transitLimitsLabel: 'Member transit limits', + transitLimits: (value: PeerMeshQueryResult['transit']) => + value + ? `Fixed limits: ${value.maxCircuitsPerPeer} circuits per member, ${formatHours(value.maxCircuitDurationSeconds)} per circuit, and ${formatMebibytes(value.maxCircuitBytes)}. Only one Mesh can be served at a time.` + : 'Member transit uses fixed resource limits. Only one Mesh can be served at a time.', + allowedMembers: 'Allowed members', + reservations: 'Reservations', + circuits: 'Circuits', routeState: { local: 'Local', route_available: 'Route known', @@ -703,3 +824,11 @@ function peerMeshCopy(locale: string) { memberActions: (peerId: string) => `Actions for ${peerId}`, }; } + +function formatHours(seconds: number): string { + return `${seconds / 3_600}h`; +} + +function formatMebibytes(bytes: number): string { + return `${bytes / (1024 * 1024)} MiB`; +} diff --git a/apps/desktop/src/renderer/styles/settings/runtime-host.css b/apps/desktop/src/renderer/styles/settings/runtime-host.css index 95b3cebcba..ef26471204 100644 --- a/apps/desktop/src/renderer/styles/settings/runtime-host.css +++ b/apps/desktop/src/renderer/styles/settings/runtime-host.css @@ -402,6 +402,67 @@ flex: 0 0 auto; } +.settingsPeerMeshTransit { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3); + border-radius: var(--radius-control); + background: var(--foreground-2); +} + +.settingsPeerMeshTransitIdentity, +.settingsPeerMeshTransitTitle { + display: flex; + min-width: 0; + align-items: center; + gap: var(--space-2); +} + +.settingsPeerMeshTransitIdentity > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.settingsPeerMeshTransitIcon { + display: grid; + width: 32px; + height: 32px; + flex: 0 0 auto; + place-items: center; + border-radius: var(--radius-control); + color: var(--accent); + background: oklch(from var(--accent) l c h / 0.1); +} + +.settingsPeerMeshTransitHelp { + display: inline-flex; + cursor: help; +} + +.settingsPeerMeshTransitHelp svg { + color: var(--foreground-secondary); +} + +.settingsPeerMeshTransitMetrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-2); +} + +.settingsPeerMeshTransitMetrics > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; + padding: var(--space-2) var(--space-3); + border: var(--border-width-hairline) solid var(--border-soft); + border-radius: var(--radius-control); +} + .settingsPeerMeshCardIdentity { min-width: 0; } @@ -514,6 +575,10 @@ .settingsPeerMeshToolbar > div:last-child { align-self: stretch; } + + .settingsPeerMeshTransitMetrics { + grid-template-columns: 1fr; + } } @media (max-height: 620px) { diff --git a/native/runtime-host-peer/src/bindings.rs b/native/runtime-host-peer/src/bindings.rs index cf5132e239..41dd5f5049 100644 --- a/native/runtime-host-peer/src/bindings.rs +++ b/native/runtime-host-peer/src/bindings.rs @@ -72,6 +72,11 @@ pub struct PeerTransitSnapshot { pub allowed_peer_count: u32, pub active_reservation_count: u32, pub active_circuit_count: u32, + pub max_reservation_count: u32, + pub max_circuit_count: u32, + pub max_circuits_per_peer: u32, + pub max_circuit_duration_seconds: u32, + pub max_circuit_bytes: u32, } #[napi(object)] @@ -124,6 +129,11 @@ impl PeerEndpoint { allowed_peer_count: snapshot.allowed_peer_count as u32, active_reservation_count: snapshot.active_reservation_count as u32, active_circuit_count: snapshot.active_circuit_count as u32, + max_reservation_count: snapshot.max_reservation_count as u32, + max_circuit_count: snapshot.max_circuit_count as u32, + max_circuits_per_peer: snapshot.max_circuits_per_peer as u32, + max_circuit_duration_seconds: snapshot.max_circuit_duration_seconds as u32, + max_circuit_bytes: snapshot.max_circuit_bytes as u32, } } diff --git a/native/runtime-host-peer/src/engine.rs b/native/runtime-host-peer/src/engine.rs index 619836e94b..c5ad3f9c74 100644 --- a/native/runtime-host-peer/src/engine.rs +++ b/native/runtime-host-peer/src/engine.rs @@ -138,11 +138,31 @@ pub struct TransitPolicy { pub relays: Vec, } -#[derive(Clone, Default)] +#[derive(Clone)] pub struct TransitSnapshot { pub allowed_peer_count: usize, pub active_reservation_count: usize, pub active_circuit_count: usize, + pub max_reservation_count: usize, + pub max_circuit_count: usize, + pub max_circuits_per_peer: usize, + pub max_circuit_duration_seconds: u64, + pub max_circuit_bytes: u64, +} + +impl Default for TransitSnapshot { + fn default() -> Self { + Self { + allowed_peer_count: 0, + active_reservation_count: 0, + active_circuit_count: 0, + max_reservation_count: MAX_TRANSIT_RESERVATIONS, + max_circuit_count: MAX_TRANSIT_CIRCUITS, + max_circuits_per_peer: MAX_TRANSIT_CIRCUITS_PER_PEER, + max_circuit_duration_seconds: MAX_TRANSIT_CIRCUIT_DURATION.as_secs(), + max_circuit_bytes: MAX_TRANSIT_CIRCUIT_BYTES, + } + } } #[derive(Debug, Clone)] @@ -1711,6 +1731,7 @@ fn publish_transit_snapshot(transit: &TransitRuntime) { allowed_peer_count, active_reservation_count: transit.reservations.len(), active_circuit_count: transit.circuits.values().sum(), + ..TransitSnapshot::default() }; } } diff --git a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts index e014f1219b..b99cedecb4 100644 --- a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts @@ -75,6 +75,15 @@ describe('Runtime Host operator commands', () => { parseRuntimeHostCommand(['service', 'mesh', 'join', '--invitation', 'secret', ...base]).kind, 'error', ); + assert.deepEqual(parseRuntimeHostCommand(['service', 'mesh', 'transit', '--off', ...base]), { + kind: 'runtime-host-service-peer-mesh', + action: 'transit', + json: false, + managedRootId: target.rootId, + operatorDeploymentId: target.deploymentId, + expectedTarget: target, + meshId: null, + }); }); test('parses and emits the stable framed managed activation contract', async () => { @@ -380,6 +389,7 @@ describe('Runtime Host operator commands', () => { 'peer.mesh.query', 'peer.mesh.reconcile', 'peer.mesh.remove', + 'peer.mesh.transit.set', ], ); }); diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 7a7f1881fa..0dd8e4c268 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -503,7 +503,7 @@ export async function runMakaCli( operatorDeploymentId: command.operatorDeploymentId, cliPath: process.argv[1] ?? '', expectedTarget: command.expectedTarget, - ...(command.meshId ? { meshId: command.meshId } : {}), + ...(command.meshId !== undefined ? { meshId: command.meshId } : {}), ...(command.peerId ? { peerId: command.peerId } : {}), }); } diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index d2bc06b030..f34ded564c 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -172,13 +172,22 @@ export type RuntimeHostCliCommand = } | { kind: 'runtime-host-service-peer-mesh'; - action: 'status' | 'create' | 'invite' | 'join' | 'remove' | 'leave' | 'close' | 'reconcile'; + action: + | 'status' + | 'create' + | 'invite' + | 'join' + | 'remove' + | 'leave' + | 'close' + | 'reconcile' + | 'transit'; json: boolean; framed?: true; managedRootId: string; operatorDeploymentId: string; expectedTarget: RuntimeHostManagedServiceTarget; - meshId?: string; + meshId?: string | null; peerId?: string; } | { @@ -1010,15 +1019,16 @@ function parseServicePeerMeshCommand(argv: string[]): RuntimeHostCliCommand { action !== 'remove' && action !== 'leave' && action !== 'close' && - action !== 'reconcile' + action !== 'reconcile' && + action !== 'transit' ) { return error( action ? `Unexpected runtime-host service mesh command: ${action}` - : 'runtime-host service mesh requires status, create, invite, join, remove, leave, close, or reconcile', + : 'runtime-host service mesh requires status, create, invite, join, remove, leave, close, reconcile, or transit', ); } - let meshId: string | undefined; + let meshId: string | null | undefined; let peerId: string | undefined; const options = parseManagedServiceOptions(argv.slice(1), { allowConfiguration: false, @@ -1035,6 +1045,12 @@ function parseServicePeerMeshCommand(argv: string[]): RuntimeHostCliCommand { peerId = value; }, }, + flagOptions: { + '--off': () => { + if (meshId !== undefined) return error('mesh transit accepts either --mesh or --off'); + meshId = null; + }, + }, }); if ('kind' in options) return options; if (!options.managedRootId || !options.operatorDeploymentId || !options.expectedTarget) { @@ -1044,12 +1060,11 @@ function parseServicePeerMeshCommand(argv: string[]): RuntimeHostCliCommand { } const needsMesh = action === 'invite' || action === 'remove' || action === 'leave' || action === 'close'; - if (needsMesh !== (meshId !== undefined)) { - return error( - needsMesh - ? `runtime-host service mesh ${action} requires --mesh` - : '--mesh is only valid with mesh invite, remove, leave, or close', - ); + if (needsMesh && typeof meshId !== 'string') { + return error(`runtime-host service mesh ${action} requires --mesh`); + } + if (!needsMesh && action !== 'transit' && typeof meshId === 'string') { + return error('--mesh is only valid with mesh invite, remove, leave, close, or transit'); } if ((action === 'remove') !== (peerId !== undefined)) { return error( @@ -1058,6 +1073,12 @@ function parseServicePeerMeshCommand(argv: string[]): RuntimeHostCliCommand { : '--peer is only valid with mesh remove', ); } + if (meshId === null && action !== 'transit') { + return error('--off is only valid with mesh transit'); + } + if (action === 'transit' && meshId === undefined) { + return error('runtime-host service mesh transit requires --mesh or --off'); + } return { kind: 'runtime-host-service-peer-mesh', action, @@ -1066,7 +1087,7 @@ function parseServicePeerMeshCommand(argv: string[]): RuntimeHostCliCommand { managedRootId: options.managedRootId, operatorDeploymentId: options.operatorDeploymentId, expectedTarget: options.expectedTarget, - ...(meshId ? { meshId } : {}), + ...(meshId !== undefined ? { meshId } : {}), ...(peerId ? { peerId } : {}), }; } diff --git a/packages/cli/src/runtime-host-peer-mesh-management-command.ts b/packages/cli/src/runtime-host-peer-mesh-management-command.ts index f03c06eb51..e6f22f963e 100644 --- a/packages/cli/src/runtime-host-peer-mesh-management-command.ts +++ b/packages/cli/src/runtime-host-peer-mesh-management-command.ts @@ -59,7 +59,7 @@ export interface RuntimeHostPeerMeshManagementCliOptions { readonly operatorDeploymentId: string; readonly cliPath: string; readonly expectedTarget: RuntimeHostManagedServiceTarget; - readonly meshId?: string; + readonly meshId?: string | null; readonly peerId?: string; } @@ -160,7 +160,7 @@ async function executePeerMeshAction( kind: 'result', action: 'invite', result: await request('peer.mesh.invite', { - meshId: requiredOption(options.meshId, 'Mesh ID'), + meshId: requiredMeshId(options.meshId), }), }; case 'join': @@ -176,7 +176,7 @@ async function executePeerMeshAction( kind: 'result', action: 'remove', result: await request('peer.mesh.remove', { - meshId: requiredOption(options.meshId, 'Mesh ID'), + meshId: requiredMeshId(options.meshId), peerId: requiredOption(options.peerId, 'Peer ID'), }), }; @@ -185,7 +185,7 @@ async function executePeerMeshAction( kind: 'result', action: 'leave', result: await request('peer.mesh.leave', { - meshId: requiredOption(options.meshId, 'Mesh ID'), + meshId: requiredMeshId(options.meshId), }), }; case 'close': @@ -193,7 +193,7 @@ async function executePeerMeshAction( kind: 'result', action: 'close', result: await request('peer.mesh.close', { - meshId: requiredOption(options.meshId, 'Mesh ID'), + meshId: requiredMeshId(options.meshId), }), }; case 'reconcile': @@ -202,6 +202,14 @@ async function executePeerMeshAction( action: 'reconcile', result: await request('peer.mesh.reconcile', {}), }; + case 'transit': + return { + kind: 'result', + action: 'transit', + result: await request('peer.mesh.transit.set', { + meshId: requiredOption(options.meshId, 'Mesh ID'), + }), + }; } } @@ -210,6 +218,11 @@ function requiredOption(value: T | undefined, label: string): T { return value; } +function requiredMeshId(value: string | null | undefined): string { + if (typeof value !== 'string') throw new Error('Mesh ID is required'); + return value; +} + async function readJoinInvitation( options: RuntimeHostPeerMeshManagementCliOptions, deps: RuntimeHostPeerMeshManagementCliDeps, diff --git a/packages/runtime-host/src/__tests__/peer-listener.test.ts b/packages/runtime-host/src/__tests__/peer-listener.test.ts index e5c6d39e93..2a5d900895 100644 --- a/packages/runtime-host/src/__tests__/peer-listener.test.ts +++ b/packages/runtime-host/src/__tests__/peer-listener.test.ts @@ -131,9 +131,13 @@ function peerWith(streams: RuntimeHostPeerNativeStream[]): RuntimeHostPeerClient verifyIdentity: () => false, transitSnapshot: () => ({ allowedPeerCount: 0, - trustedRelayCount: 0, activeReservationCount: 0, activeCircuitCount: 0, + maxReservationCount: 32, + maxCircuitCount: 8, + maxCircuitsPerPeer: 2, + maxCircuitDurationSeconds: 7_200, + maxCircuitBytes: 256 * 1024 * 1024, }), configureTransit: async () => undefined, connect: async () => { diff --git a/packages/runtime-host/src/__tests__/peer-mesh.test.ts b/packages/runtime-host/src/__tests__/peer-mesh.test.ts index 00944c0c74..a8d6f586f2 100644 --- a/packages/runtime-host/src/__tests__/peer-mesh.test.ts +++ b/packages/runtime-host/src/__tests__/peer-mesh.test.ts @@ -255,7 +255,7 @@ test('reconciles one selected Mesh into signed transit routes and native policy' await authority.setTransitMesh(meshId); await authority.reconcile(); await memberB.reconcile(); - assert.equal(authority.status()[0]?.transitEnabled, true); + assert.equal(authority.transitMeshId(), meshId); assert.deepEqual(authorityPeer.transitPolicy.allowedPeerIds, ['peer-b', 'peer-c', 'peer-d']); assert.deepEqual(memberBPeer.transitPolicy.relayCandidates, [ { peerId: 'peer-a', addresses: ['/memory/peer-a/p2p/peer-a'] }, @@ -287,6 +287,7 @@ test('reconciles one selected Mesh into signed transit routes and native policy' await authority.closeMesh(meshId); assert.deepEqual(authority.status(), []); + assert.equal(authority.transitMeshId(), null); assert.deepEqual(authorityPeer.transitPolicy.allowedPeerIds, []); } finally { await Promise.allSettled([ @@ -511,6 +512,11 @@ class MemoryPeerClient implements PeerMeshTransport { allowedPeerCount: this.transitPolicy.allowedPeerIds.length, activeReservationCount: 0, activeCircuitCount: 0, + maxReservationCount: 32, + maxCircuitCount: 8, + maxCircuitsPerPeer: 2, + maxCircuitDurationSeconds: 7_200, + maxCircuitBytes: 256 * 1024 * 1024, }; } diff --git a/packages/runtime-host/src/__tests__/peer-native.test.ts b/packages/runtime-host/src/__tests__/peer-native.test.ts index 9b3628c2be..72e704c0d5 100644 --- a/packages/runtime-host/src/__tests__/peer-native.test.ts +++ b/packages/runtime-host/src/__tests__/peer-native.test.ts @@ -68,7 +68,7 @@ module.exports = { peerId: 'client', listenAddresses: [], activeCoordinationRelays: [], - transitSnapshot: { allowedPeerCount: 0, activeReservationCount: 0, activeCircuitCount: 0 }, + transitSnapshot: { allowedPeerCount: 0, activeReservationCount: 0, activeCircuitCount: 0, maxReservationCount: 32, maxCircuitCount: 8, maxCircuitsPerPeer: 2, maxCircuitDurationSeconds: 7_200, maxCircuitBytes: 256 * 1024 * 1024 }, connect: ({ requestId, peerId, routeHints, coordinationRelays, transitRelayPeerIds }) => { stats.requests.push({ requestId, peerId, routeHints, coordinationRelays, transitRelayPeerIds }); if (peerId === 'ready') return Promise.resolve(stream); @@ -214,7 +214,7 @@ module.exports = { peerId: 'peer', listenAddresses: [], activeCoordinationRelays: [], - transitSnapshot: { allowedPeerCount: 0, activeReservationCount: 0, activeCircuitCount: 0 }, + transitSnapshot: { allowedPeerCount: 0, activeReservationCount: 0, activeCircuitCount: 0, maxReservationCount: 32, maxCircuitCount: 8, maxCircuitsPerPeer: 2, maxCircuitDurationSeconds: 7_200, maxCircuitBytes: 256 * 1024 * 1024 }, connect: async () => stream, connectMeshControl: async () => stream, configureTransit: async () => {}, diff --git a/packages/runtime-host/src/operator/peer-mesh-management-frame.ts b/packages/runtime-host/src/operator/peer-mesh-management-frame.ts index af49d8ba50..919ec54439 100644 --- a/packages/runtime-host/src/operator/peer-mesh-management-frame.ts +++ b/packages/runtime-host/src/operator/peer-mesh-management-frame.ts @@ -38,6 +38,7 @@ const ACTION_SCHEMA = z.enum([ 'leave', 'close', 'reconcile', + 'transit', ]); const FRAME_SCHEMA = z.union([ z.object({ kind: z.literal('input'), action: z.literal('join') }).strict(), @@ -64,12 +65,21 @@ export type RuntimeHostPeerMeshManagementAction = | 'remove' | 'leave' | 'close' - | 'reconcile'; + | 'reconcile' + | 'transit'; export type RuntimeHostPeerMeshManagementFrame = | { readonly kind: 'input'; readonly action: 'join' } | { readonly kind: 'result'; - readonly action: 'status' | 'create' | 'join' | 'remove' | 'leave' | 'close' | 'reconcile'; + readonly action: + | 'status' + | 'create' + | 'join' + | 'remove' + | 'leave' + | 'close' + | 'reconcile' + | 'transit'; readonly result: PeerMeshQueryResult; } | { @@ -129,6 +139,12 @@ function decodeFrame(value: unknown): RuntimeHostPeerMeshManagementFrame { action: 'reconcile', result: decodePeerMeshQueryResult(frame.result), }; + case 'transit': + return { + kind: 'result', + action: 'transit', + result: decodePeerMeshQueryResult(frame.result), + }; case 'invite': return { kind: 'result', diff --git a/packages/runtime-host/src/peer-mesh/node.ts b/packages/runtime-host/src/peer-mesh/node.ts index 3cb92a65b2..f9fa7dfe05 100644 --- a/packages/runtime-host/src/peer-mesh/node.ts +++ b/packages/runtime-host/src/peer-mesh/node.ts @@ -135,6 +135,7 @@ export interface PeerMeshNode { leave(meshId: string, signal?: AbortSignal): Promise; closeMesh(meshId: string): Promise; setTransitMesh(meshId: string | null): Promise; + transitMeshId(): string | null; transitSnapshot(): RuntimeHostPeerTransitSnapshot; resolveRoutes(peerId: string): | { @@ -154,7 +155,6 @@ export interface PeerMeshStatus { readonly roster: SignedPeerMeshRosterV1; readonly pendingInvitationCount: number; readonly memberRoutes: readonly PeerMeshMemberRouteStatus[]; - readonly transitEnabled: boolean; } export interface PeerMeshMemberRouteStatus { @@ -248,9 +248,7 @@ class PeerMeshNodeImpl implements PeerMeshNode { return Object.freeze( stored.meshes .filter((state) => isActiveMembership(state, identity.peerId)) - .map((state) => - peerMeshStatus(state, identity, stored.routes, stored.transitMeshId, this.#now()), - ), + .map((state) => peerMeshStatus(state, identity, stored.routes, this.#now())), ); } @@ -299,7 +297,6 @@ class PeerMeshNodeImpl implements PeerMeshNode { findMesh(stored.meshes, state.roster.roster.meshId)!, identity, stored.routes, - stored.transitMeshId, now, ); }); @@ -445,12 +442,7 @@ class PeerMeshNodeImpl implements PeerMeshNode { await this.#refreshLocalRoute(); await this.#reconcileTransit(); const stored = this.#store.read(); - return peerMeshStatus( - findMesh(stored.meshes, invitation.meshId)!, - identity, - stored.routes, - stored.transitMeshId, - ); + return peerMeshStatus(findMesh(stored.meshes, invitation.meshId)!, identity, stored.routes); } finally { await stream.close().catch(() => undefined); } @@ -548,6 +540,11 @@ class PeerMeshNodeImpl implements PeerMeshNode { return this.#peer.transitSnapshot(); } + transitMeshId(): string | null { + this.#assertOpen(); + return this.#store.read().transitMeshId; + } + resolveRoutes(peerId: string) { this.#assertOpen(); const now = this.#now(); @@ -956,12 +953,7 @@ class PeerMeshNodeImpl implements PeerMeshNode { await this.#refreshLocalRoute(); await this.#reconcileTransit(); const stored = this.#store.read(); - return peerMeshStatus( - findMesh(stored.meshes, meshId)!, - this.#peer.identity(), - stored.routes, - stored.transitMeshId, - ); + return peerMeshStatus(findMesh(stored.meshes, meshId)!, this.#peer.identity(), stored.routes); } #acceptIncoming(stream: RuntimeHostPeerNativeStream): void { @@ -1336,7 +1328,6 @@ function peerMeshStatus( state: PeerMeshStateV1, identity: ReturnType, routes: readonly SignedPeerMeshRouteRecordV1[] = [], - transitMeshId: string | null = null, now = Date.now(), ): PeerMeshStatus { return Object.freeze({ @@ -1349,7 +1340,6 @@ function peerMeshStatus( (invitation) => invitation.status === 'pending' && invitation.expiresAt > now, ).length : 0, - transitEnabled: state.roster.roster.meshId === transitMeshId, memberRoutes: Object.freeze( state.roster.roster.members.map((peerId) => { if (peerId === identity.peerId) return Object.freeze({ peerId, state: 'local' as const }); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 3947a24737..253c42fb20 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -94,7 +94,8 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 65 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 66 as const; +// 66: Peer Mesh queries expose one canonical transit selection and runtime metrics. // 65: live `tool_start` frames may carry optional `intent` / `argsPreview` // keys. Older Clients decode the event with a strict allowed-key list and tear // the connection down on unknown keys, so the pair must be refused up front. diff --git a/packages/runtime-host/src/protocol/peer-mesh.ts b/packages/runtime-host/src/protocol/peer-mesh.ts index 310388542a..337f2cd8b3 100644 --- a/packages/runtime-host/src/protocol/peer-mesh.ts +++ b/packages/runtime-host/src/protocol/peer-mesh.ts @@ -66,6 +66,19 @@ export interface PeerMeshQueryResult { readonly available: boolean; readonly localPeerId?: string; readonly meshes: readonly PeerMeshProjection[]; + readonly transit?: PeerMeshTransitProjection; +} + +export interface PeerMeshTransitProjection { + readonly meshId: string | null; + readonly allowedMemberCount: number; + readonly activeReservationCount: number; + readonly activeCircuitCount: number; + readonly maxReservationCount: number; + readonly maxCircuitCount: number; + readonly maxCircuitsPerPeer: number; + readonly maxCircuitDurationSeconds: number; + readonly maxCircuitBytes: number; } export interface PeerMeshTargetInput { @@ -82,6 +95,10 @@ export interface PeerMeshRemoveInput extends PeerMeshTargetInput { readonly peerId: string; } +export interface PeerMeshTransitSetInput { + readonly meshId: string | null; +} + export interface PeerMeshInvitationResult { readonly invitation: PeerMeshInvitationV1; readonly snapshot: PeerMeshQueryResult; @@ -152,6 +169,13 @@ export const PEER_MESH_OPERATION_SPECS = { decodeInput: decodeEmptyInput, decodeOutput: decodePeerMeshQueryResult, }), + 'peer.mesh.transit.set': defineOperation({ + mode: 'command', + availability: 'ready', + errors: MUTATION_ERRORS, + decodeInput: decodePeerMeshTransitSetInput, + decodeOutput: decodePeerMeshQueryResult, + }), } as const; function decodeEmptyInput(value: unknown): Record { @@ -236,6 +260,16 @@ function decodePeerMeshRemoveInput(value: unknown): PeerMeshRemoveInput { }; } +function decodePeerMeshTransitSetInput(value: unknown): PeerMeshTransitSetInput { + const record = requireExactRecord(value, 'Peer Mesh transit input', ['meshId']); + return { + meshId: + record.meshId === null + ? null + : requireString(record.meshId, 'Peer Mesh meshId', MESH_ID_MAX_BYTES), + }; +} + export function decodePeerMeshQueryResult(value: unknown): PeerMeshQueryResult { const record = requireRecord(value, 'Peer Mesh query result'); assertExactKeys( @@ -243,7 +277,7 @@ export function decodePeerMeshQueryResult(value: unknown): PeerMeshQueryResult { 'Peer Mesh query result', record.localPeerId === undefined ? ['available', 'meshes'] - : ['available', 'localPeerId', 'meshes'], + : ['available', 'localPeerId', 'meshes', 'transit'], ); if ( typeof record.available !== 'boolean' || @@ -262,6 +296,7 @@ export function decodePeerMeshQueryResult(value: unknown): PeerMeshQueryResult { ? {} : { localPeerId: requireString(localPeerId, 'Peer Mesh localPeerId', PEER_ID_MAX_BYTES), + transit: decodePeerMeshTransitProjection(record.transit), }), meshes: Object.freeze(record.meshes.map(decodePeerMeshProjection)), }; @@ -307,6 +342,37 @@ export function decodePeerMeshProjection(value: unknown): PeerMeshProjection { }; } +function decodePeerMeshTransitProjection(value: unknown): PeerMeshTransitProjection { + const record = requireExactRecord(value, 'Peer Mesh transit projection', [ + 'meshId', + 'allowedMemberCount', + 'activeReservationCount', + 'activeCircuitCount', + 'maxReservationCount', + 'maxCircuitCount', + 'maxCircuitsPerPeer', + 'maxCircuitDurationSeconds', + 'maxCircuitBytes', + ]); + return { + meshId: + record.meshId === null + ? null + : requireString(record.meshId, 'Peer Mesh transit meshId', MESH_ID_MAX_BYTES), + allowedMemberCount: requireCount(record.allowedMemberCount, 'allowedMemberCount'), + activeReservationCount: requireCount(record.activeReservationCount, 'activeReservationCount'), + activeCircuitCount: requireCount(record.activeCircuitCount, 'activeCircuitCount'), + maxReservationCount: requireCount(record.maxReservationCount, 'maxReservationCount'), + maxCircuitCount: requireCount(record.maxCircuitCount, 'maxCircuitCount'), + maxCircuitsPerPeer: requireCount(record.maxCircuitsPerPeer, 'maxCircuitsPerPeer'), + maxCircuitDurationSeconds: requireCount( + record.maxCircuitDurationSeconds, + 'maxCircuitDurationSeconds', + ), + maxCircuitBytes: requireCount(record.maxCircuitBytes, 'maxCircuitBytes'), + }; +} + function decodePeerMeshMemberProjection(value: unknown): PeerMeshMemberProjection { const record = requireRecord(value, 'Peer Mesh member route'); assertExactKeys( diff --git a/packages/runtime-host/src/server/index.ts b/packages/runtime-host/src/server/index.ts index 8a058addf3..a1ed9f5283 100644 --- a/packages/runtime-host/src/server/index.ts +++ b/packages/runtime-host/src/server/index.ts @@ -27,6 +27,7 @@ export { startExecutionRuntimeHostService } from './execution-service.js'; export { runRuntimeHostProcessLifecycle } from './process-lifecycle.js'; export { createPeerMeshOperationHandlers, + projectPeerMeshQuery, projectPeerMeshStatus, } from './peer-mesh-authority.js'; export { installRuntimeHostLogCapture } from '../process-diagnostics.js'; diff --git a/packages/runtime-host/src/server/peer-mesh-authority.ts b/packages/runtime-host/src/server/peer-mesh-authority.ts index 68daa152ef..d1b26e0994 100644 --- a/packages/runtime-host/src/server/peer-mesh-authority.ts +++ b/packages/runtime-host/src/server/peer-mesh-authority.ts @@ -33,20 +33,14 @@ export type PeerMeshOperationHandlers = Pick< | 'peer.mesh.leave' | 'peer.mesh.close' | 'peer.mesh.reconcile' + | 'peer.mesh.transit.set' >; export function createPeerMeshOperationHandlers( mesh: PeerMeshNode | undefined, options: { readonly requestDrain?: () => void } = {}, ): PeerMeshOperationHandlers { - const query = (): PeerMeshQueryResult => - mesh - ? { - available: true, - localPeerId: mesh.localPeerId(), - meshes: mesh.status().map(projectPeerMeshStatus), - } - : { available: false, meshes: [] }; + const query = (): PeerMeshQueryResult => projectPeerMeshQuery(mesh); const unavailable = (): OperationOutcome => ({ ok: false, @@ -144,6 +138,13 @@ export function createPeerMeshOperationHandlers( return { ok: true, result: query() }; }); }, + 'peer.mesh.transit.set': async (input) => { + if (!mesh) return unavailable(); + return mutate(async () => { + await mesh.setTransitMesh(input.meshId); + return { ok: true, result: query() }; + }); + }, }; } @@ -158,3 +159,30 @@ export function projectPeerMeshStatus(status: PeerMeshStatus): PeerMeshProjectio pendingInvitationCount: status.pendingInvitationCount, }); } + +export function projectPeerMeshQuery(mesh: PeerMeshNode | undefined): PeerMeshQueryResult { + if (!mesh) return { available: false, meshes: [] }; + return Object.freeze({ + available: true, + localPeerId: mesh.localPeerId(), + meshes: Object.freeze(mesh.status().map(projectPeerMeshStatus)), + transit: projectTransitSnapshot(mesh.transitMeshId(), mesh.transitSnapshot()), + }); +} + +function projectTransitSnapshot( + meshId: string | null, + snapshot: ReturnType, +) { + return Object.freeze({ + meshId, + allowedMemberCount: snapshot.allowedPeerCount, + activeReservationCount: snapshot.activeReservationCount, + activeCircuitCount: snapshot.activeCircuitCount, + maxReservationCount: snapshot.maxReservationCount, + maxCircuitCount: snapshot.maxCircuitCount, + maxCircuitsPerPeer: snapshot.maxCircuitsPerPeer, + maxCircuitDurationSeconds: snapshot.maxCircuitDurationSeconds, + maxCircuitBytes: snapshot.maxCircuitBytes, + }); +} diff --git a/packages/runtime-host/src/transport/peer-native.ts b/packages/runtime-host/src/transport/peer-native.ts index 368f4e34da..15a613ba62 100644 --- a/packages/runtime-host/src/transport/peer-native.ts +++ b/packages/runtime-host/src/transport/peer-native.ts @@ -95,6 +95,11 @@ export interface RuntimeHostPeerTransitSnapshot { readonly allowedPeerCount: number; readonly activeReservationCount: number; readonly activeCircuitCount: number; + readonly maxReservationCount: number; + readonly maxCircuitCount: number; + readonly maxCircuitsPerPeer: number; + readonly maxCircuitDurationSeconds: number; + readonly maxCircuitBytes: number; } export interface RuntimeHostPeerTransitRelayCandidate { @@ -498,7 +503,17 @@ function isPeerTransitSnapshot(value: unknown): value is RuntimeHostPeerTransitS 'activeReservationCount' in value && isCount(value.activeReservationCount) && 'activeCircuitCount' in value && - isCount(value.activeCircuitCount) + isCount(value.activeCircuitCount) && + 'maxReservationCount' in value && + isCount(value.maxReservationCount) && + 'maxCircuitCount' in value && + isCount(value.maxCircuitCount) && + 'maxCircuitsPerPeer' in value && + isCount(value.maxCircuitsPerPeer) && + 'maxCircuitDurationSeconds' in value && + isCount(value.maxCircuitDurationSeconds) && + 'maxCircuitBytes' in value && + isCount(value.maxCircuitBytes) ); }