From f70d048b8ec0e1b6b84f7db7c2c67131339ba57d Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Fri, 4 Sep 2026 18:48:32 +0800 Subject: [PATCH] feat(runtime-host): expose extensible resource snapshots Generated-by: OpenAI Codex --- .github/workflows/windows-recovery.yml | 1 + .../licenses/npm/THIRD_PARTY_NOTICES.txt | 29 ++ .../__tests__/runtime-host-management.test.ts | 2 + apps/desktop/src/main/runtime-host-boot.ts | 1 + .../src/main/runtime-host-management.ts | 14 +- apps/desktop/src/preload/bridge-contract.d.ts | 4 + apps/desktop/src/preload/preload.ts | 3 + .../features/runtime-host-management/index.ts | 1 + .../features/runtime-host-management/ports.ts | 7 + .../ui/runtime-host-resource-dialog.tsx | 358 +++++++++++++++++ ...create-runtime-host-management-services.ts | 7 + .../runtime-host-management-dialog.tsx | 8 +- .../renderer/styles/settings/runtime-host.css | 9 + docs/astryx-surface-file-inventory.md | 3 +- docs/astryx-surface-file-inventory.paths | 1 + package-lock.json | 27 ++ packages/cli/THIRD_PARTY_NOTICES.txt | 29 ++ packages/runtime-host/package.json | 1 + .../access-credential-grant-migration.test.ts | 18 +- .../src/__tests__/connection-session.test.ts | 6 +- .../src/__tests__/host-resources.test.ts | 180 +++++++++ .../__tests__/operation-dispatcher.test.ts | 1 + .../src/protocol/host-resources.ts | 378 ++++++++++++++++++ packages/runtime-host/src/protocol/index.ts | 5 +- .../runtime-host/src/protocol/operations.ts | 4 + .../src/server/access-credential-store.ts | 5 + .../runtime-host/src/server/host-kernel.ts | 6 + .../src/server/host-resource-collector.ts | 277 +++++++++++++ .../src/server/host-resource-probe-main.ts | 47 +++ .../src/server/host-resource-probe.ts | 99 +++++ .../src/server/operation-dispatcher.ts | 2 + 31 files changed, 1524 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-resource-dialog.tsx create mode 100644 packages/runtime-host/src/__tests__/host-resources.test.ts create mode 100644 packages/runtime-host/src/protocol/host-resources.ts create mode 100644 packages/runtime-host/src/server/host-resource-collector.ts create mode 100644 packages/runtime-host/src/server/host-resource-probe-main.ts create mode 100644 packages/runtime-host/src/server/host-resource-probe.ts diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 5ffd068e0f..aa7598430b 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -91,6 +91,7 @@ on: - 'packages/runtime-host/src/protocol/host-status.ts' - 'packages/runtime-host/src/protocol/skill-catalog.ts' - 'packages/runtime-host/src/server/access-credential-store.ts' + - 'packages/runtime-host/src/server/host-resource-probe.ts' - 'packages/runtime-host/src/server/skill-catalog-repository.ts' - 'packages/runtime-host/src/server/skill-catalog-transaction.ts' - 'packages/runtime/src/__tests__/runtime-continuation-crash.test.ts' diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 565ca48843..ff0943aa45 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -13620,6 +13620,35 @@ SOFTWARE. ================================================================================ +Package: systeminformation@5.33.8 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/sebhildebrandt/systeminformation.git + +--- LICENSE --- +The MIT License (MIT) + +Copyright (c) 2014-2026 Sebastian Hildebrandt + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + Package: tiny-typed-emitter@2.1.0 Declared license: MIT Selected license: MIT diff --git a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts index be45c0213a..a72a25fd0c 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -736,6 +736,7 @@ test('publishes update progress and waits for the managed profile to reconnect', assert.fail('published update must not inspect the development target'), resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.3.0' }), currentHostEpoch: () => 'host-before-update', + liveHost: () => undefined, awaitUpdatedConnection: async (...args) => { connectionCompletions.push(args); if (failConnection) throw new Error('authentication required'); @@ -1502,6 +1503,7 @@ function unusedUpdateDependencies() { assert.fail('published update must not inspect the development target'), resolveUpdatePackage: () => ({ kind: 'npm', specifier: 'maka-agent@1.2.3' } as const), currentHostEpoch: () => undefined, + liveHost: () => undefined, awaitUpdatedConnection: async () => undefined, sendProgress: () => undefined, }; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 20cbacff8d..7caee1cf13 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -704,6 +704,7 @@ const runtimeHostManagement = createDesktopRuntimeHostManagement({ resolveUpdatePackage: runtimeHostSetupPackage.resolve, currentHostEpoch: (profileId) => runtimeHostManager?.current(profileId)?.candidate?.client.hostEpoch, + liveHost: (profileId) => runtimeHostManager?.current(profileId)?.candidate?.client, awaitUpdatedConnection: async ( profileId, expectedHostId, diff --git a/apps/desktop/src/main/runtime-host-management.ts b/apps/desktop/src/main/runtime-host-management.ts index dfd8ae7803..a7fa881c88 100644 --- a/apps/desktop/src/main/runtime-host-management.ts +++ b/apps/desktop/src/main/runtime-host-management.ts @@ -18,7 +18,10 @@ */ import type { IpcMain } from 'electron'; -import { decodeRuntimeHostOwnerConnectionCode } from '@maka/runtime-host/client'; +import { + decodeRuntimeHostOwnerConnectionCode, + type RuntimeHostConnection, +} from '@maka/runtime-host/client'; import { RUNTIME_HOST_OPERATOR_ACCESS_MANAGEMENT_CAPABILITY, RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY, @@ -138,6 +141,7 @@ export function createDesktopRuntimeHostManagement(input: { | DesktopRuntimeHostSetupPackage | Promise; readonly currentHostEpoch: (profileId: string) => string | undefined; + readonly liveHost: (profileId: string) => Pick | undefined; readonly awaitUpdatedConnection: ( profileId: string, expectedHostId: string, @@ -1015,6 +1019,11 @@ export function createDesktopRuntimeHostManagement(input: { ); }; + const getResources = (profileIdValue: unknown) => { + const host = input.liveHost(requireProfileId(profileIdValue)); + return host?.request('host.resources.query', {}, 15_000); + }; + const channels = { run: 'runtime-host-management:run', update: 'runtime-host-management:update', @@ -1028,6 +1037,7 @@ export function createDesktopRuntimeHostManagement(input: { reconcileUpdate: 'runtime-host-management:reconcile-update', getDirectPeer: 'runtime-host-management:get-direct-peer', configureDirectPeer: 'runtime-host-management:configure-direct-peer', + getResources: 'runtime-host-management:get-resources', } as const; input.ipcMain.handle( channels.run, @@ -1078,6 +1088,8 @@ export function createDesktopRuntimeHostManagement(input: { reconcileUpdate(profileId)); input.ipcMain.handle(channels.getDirectPeer, (_event, profileId: unknown) => getDirectPeer(profileId)); + input.ipcMain.handle(channels.getResources, (_event, profileId: unknown) => + getResources(profileId)); input.ipcMain.handle( channels.configureDirectPeer, ( diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 117a5f6827..70b940e6b6 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -596,6 +596,9 @@ export interface DesktopRuntimeHostManagementProgress { | import('@maka/runtime-host/operator').RuntimeHostServiceUpdatePhase; } +export type DesktopRuntimeHostResources = + import('@maka/runtime-host/protocol').HostResourcesResult; + export interface DesktopRuntimeHostDirectPeerSnapshot { readonly state: 'unsupported' | 'not_configured' | 'disabled' | 'enabled'; readonly peerId?: string; @@ -877,6 +880,7 @@ export interface MakaBridge { policy: import('@maka/runtime-host/operator').RuntimeHostManagedUpdatePolicy, ): Promise; reconcileUpdate(profileId: string): Promise; + getResources(profileId: string): Promise; getDirectPeer(profileId: string): Promise; configureDirectPeer( profileId: string, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 83c8b6645f..bcb2babbf9 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1686,6 +1686,9 @@ const makaBridge = { reconcileUpdate(profileId: string) { return ipcRenderer.invoke('runtime-host-management:reconcile-update', profileId); }, + getResources(profileId: string) { + return ipcRenderer.invoke('runtime-host-management:get-resources', profileId); + }, getDirectPeer(profileId: string) { return ipcRenderer.invoke('runtime-host-management:get-direct-peer', profileId); }, diff --git a/apps/desktop/src/renderer/features/runtime-host-management/index.ts b/apps/desktop/src/renderer/features/runtime-host-management/index.ts index 7b16b77ad4..2e7cd2244e 100644 --- a/apps/desktop/src/renderer/features/runtime-host-management/index.ts +++ b/apps/desktop/src/renderer/features/runtime-host-management/index.ts @@ -25,6 +25,7 @@ export { PeerMeshPeerIdButton } from './ui/peer-mesh-peer-id-button.js'; export { RuntimeHostAddComputerMenu } from './ui/runtime-host-add-computer-menu.js'; export { RuntimeHostConnectionCodeButton } from './ui/runtime-host-connection-code-button.js'; export { RuntimeHostConnectionCodeDialog } from './ui/runtime-host-connection-code-dialog.js'; +export { RuntimeHostResourceDialog } from './ui/runtime-host-resource-dialog.js'; export { RuntimeHostPairingRecoveryButton, RuntimeHostProfileMoreMenu, diff --git a/apps/desktop/src/renderer/features/runtime-host-management/ports.ts b/apps/desktop/src/renderer/features/runtime-host-management/ports.ts index 606d21dd91..fd121577a0 100644 --- a/apps/desktop/src/renderer/features/runtime-host-management/ports.ts +++ b/apps/desktop/src/renderer/features/runtime-host-management/ports.ts @@ -20,6 +20,7 @@ import type { RuntimeHostPeerMeshManagementAction } from '@maka/runtime-host/operator'; import type { RuntimeHostWebRtcStunPolicy } from '@maka/runtime-host/operator'; import type { + HostResourcesResult, PeerMeshInvitationResult, PeerMeshQueryResult, } from '@maka/runtime-host/protocol'; @@ -103,9 +104,15 @@ export interface RuntimeHostConnectionCodeServices { writeClipboardText(value: string): Promise; } +export interface RuntimeHostResourceServices { + query(profileId: string): Promise; + schedule(callback: () => void, delayMs: number): () => void; +} + export interface RuntimeHostManagementServices { readonly peerMesh: PeerMeshServices; readonly profilePairing: RuntimeHostProfilePairingServices; readonly connectionCodes: RuntimeHostConnectionCodeServices; + readonly resources: RuntimeHostResourceServices; readonly supportsWsl: boolean; } diff --git a/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-resource-dialog.tsx b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-resource-dialog.tsx new file mode 100644 index 0000000000..79d48ab8b0 --- /dev/null +++ b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-resource-dialog.tsx @@ -0,0 +1,358 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useState } from 'react'; +import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; +import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; +import { + HOST_RESOURCE_KINDS, + availableHostResource, + deriveHostResourceUtilization, + type HostResourcesResult, +} from '@maka/runtime-host/protocol'; +import type { UiLocale } from '@maka/core/ui-locale'; +import { Button, Spinner, Text, formatBytes, useUiLocale } from '@maka/ui'; +import { useRuntimeHostManagementServices } from '../services-context.js'; + +const POLL_INTERVAL_MILLISECONDS = 2_000; + +type HostResourceState = + | { readonly kind: 'loading' } + | { readonly kind: 'unavailable' } + | { + readonly kind: 'ready'; + readonly current: HostResourcesResult; + readonly previous?: HostResourcesResult; + }; + +export function RuntimeHostResourceDialog(props: { + readonly profileId: string; + readonly hostName: string; +}) { + const [open, setOpen] = useState(false); + const copy = hostResourceCopy(useUiLocale()); + return ( + <> +
+
+ {open ? ( + + + )} + content={( + + + + )} + footer={( + +
+
+
+ )} + /> +
+ ) : null} + + ); +} + +function RuntimeHostResourceFacts(props: { + readonly profileId: string; + readonly copy: ReturnType; +}) { + const services = useRuntimeHostManagementServices().resources; + const [state, setState] = useState({ kind: 'loading' }); + + useEffect(() => { + let disposed = false; + let cancelSchedule: (() => void) | undefined; + const poll = async () => { + try { + const snapshot = await services.query(props.profileId); + if (disposed) return; + setState((current) => + snapshot + ? { + kind: 'ready', + current: snapshot, + ...(current.kind === 'ready' ? { previous: current.current } : {}), + } + : { kind: 'unavailable' }, + ); + } catch { + if (!disposed) setState({ kind: 'unavailable' }); + } finally { + if (!disposed) cancelSchedule = services.schedule(poll, POLL_INTERVAL_MILLISECONDS); + } + }; + void poll(); + return () => { + disposed = true; + cancelSchedule?.(); + }; + }, [props.profileId, services]); + + if (state.kind !== 'ready') { + return ( +
+ {state.kind === 'loading' ? ( +
+ + {props.copy.measuring} +
+ ) : ( + {props.copy.unavailable} + )} +
+ ); + } + + const cpu = availableHostResource(state.current, HOST_RESOURCE_KINDS.cpu); + const memory = availableHostResource(state.current, HOST_RESOURCE_KINDS.memory); + const graphics = availableHostResource(state.current, HOST_RESOURCE_KINDS.graphics); + const network = availableHostResource(state.current, HOST_RESOURCE_KINDS.network); + const storage = availableHostResource(state.current, HOST_RESOURCE_KINDS.storage); + const utilization = deriveHostResourceUtilization(state.current, state.previous); + const effectiveMemory = memory?.capacity.effectiveTotalBytes; + const usedMemory = memory + ? Math.max(0, memory.capacity.effectiveTotalBytes - memory.observation.availableBytes) + : undefined; + const graphicsSummary = !graphics || graphics.detection === 'unknown' + ? props.copy.graphicsUnknown + : graphics.detection === 'not_detected' + ? props.copy.noGraphicsAdapter + : graphics.devices + .map((device) => { + const name = [device.vendor, device.model].filter(Boolean).join(' '); + const graphicsMemory = device.memory.kind === 'dedicated' + ? formatBytes(device.memory.bytes) + : device.memory.kind === 'shared' + ? props.copy.sharedMemory + : props.copy.memoryUnknown; + const load = device.utilizationPercent === undefined + ? undefined + : formatPercent(device.utilizationPercent); + return [name, graphicsMemory, load].filter(Boolean).join(' · '); + }) + .join('\n'); + const storageSummary = storage?.volumes.length + ? [...storage.volumes] + .sort( + (left, right) => storageMountPriority(left.mount) - storageMountPriority(right.mount), + ) + .slice(0, 8) + .map((volume) => + props.copy.storageVolume( + volume.mount, + formatBytes(volume.availableBytes), + formatBytes(volume.totalBytes), + volume.filesystem, + ), + ) + .join('\n') + : props.copy.unavailable; + + return ( +
+
+ + + + + + +
+
+ ); +} + +function ResourceFact(props: { + readonly label: string; + readonly value: string; + readonly wide?: boolean; +}) { + return ( +
+
{props.label}
+
{props.value}
+
+ ); +} + +function storageMountPriority(mount: string): number { + if (mount === '/' || /^[A-Za-z]:\\?$/u.test(mount)) return 0; + if (/^\/mnt\/[A-Za-z](?:\/|$)/u.test(mount)) return 1; + return 2; +} + +function formatPercent(value: number): string { + return `${value.toFixed(value >= 10 ? 0 : 1)}%`; +} + +const HOST_RESOURCE_COPY = { + 'zh-CN': { + title: '主机资源', + open: '查看主机资源', + done: '完成', + unavailable: 'Host 连接后即可读取资源信息', + cpu: 'CPU', + cpuUsage: 'CPU 使用率', + memory: '内存', + graphics: '显示适配器', + storage: '磁盘', + noGraphicsAdapter: '未检测到', + graphicsUnknown: '无法确认', + sharedMemory: '共享内存', + memoryUnknown: '显存未知', + network: '网络吞吐', + measuring: '正在采样…', + logicalProcessors: (count: number, available: number) => + count === available + ? `${count} 个逻辑处理器` + : `${available} / ${count} 个逻辑处理器可用`, + networkRate: (interfaceName: string, received: string, transmitted: string) => + `${interfaceName} · ↓ ${received}/s · ↑ ${transmitted}/s`, + storageVolume: ( + mount: string, + available: string, + total: string, + filesystem: string | undefined, + ) => `${mount} · ${available} 可用 / ${total}${filesystem ? ` · ${filesystem}` : ''}`, + }, + 'zh-TW': { + title: '主機資源', + open: '檢視主機資源', + done: '完成', + unavailable: 'Host 連線後即可讀取資源資訊', + cpu: 'CPU', + cpuUsage: 'CPU 使用率', + memory: '記憶體', + graphics: '顯示卡', + storage: '儲存空間', + noGraphicsAdapter: '未偵測到', + graphicsUnknown: '無法判定', + sharedMemory: '共享記憶體', + memoryUnknown: '顯示記憶體未知', + network: '網路輸送量', + measuring: '正在測量…', + logicalProcessors: (count: number, available: number) => + count === available + ? `${count} 個邏輯處理器` + : `${available} / ${count} 個邏輯處理器可用`, + networkRate: (interfaceName: string, received: string, transmitted: string) => + `${interfaceName} · ↓ ${received}/s · ↑ ${transmitted}/s`, + storageVolume: ( + mount: string, + available: string, + total: string, + filesystem: string | undefined, + ) => `${mount} · ${available} 可用 / ${total}${filesystem ? ` · ${filesystem}` : ''}`, + }, + en: { + title: 'Host resources', + open: 'View Host resources', + done: 'Done', + unavailable: 'Resource information is available while the Host is connected', + cpu: 'CPU', + cpuUsage: 'CPU usage', + memory: 'Memory', + graphics: 'Graphics adapters', + storage: 'Storage', + noGraphicsAdapter: 'None detected', + graphicsUnknown: 'Unable to determine', + sharedMemory: 'Shared memory', + memoryUnknown: 'Memory unknown', + network: 'Network throughput', + measuring: 'Measuring…', + logicalProcessors: (count: number, available: number) => + count === available + ? `${count} logical processors` + : `${available} of ${count} logical processors available`, + networkRate: (interfaceName: string, received: string, transmitted: string) => + `${interfaceName} · ↓ ${received}/s · ↑ ${transmitted}/s`, + storageVolume: ( + mount: string, + available: string, + total: string, + filesystem: string | undefined, + ) => + `${mount} · ${available} available / ${total}${filesystem ? ` · ${filesystem}` : ''}`, + }, +} satisfies Record; + +function hostResourceCopy(locale: UiLocale) { + return HOST_RESOURCE_COPY[locale]; +} diff --git a/apps/desktop/src/renderer/platform/desktop/create-runtime-host-management-services.ts b/apps/desktop/src/renderer/platform/desktop/create-runtime-host-management-services.ts index 78b96bde9e..df388ab234 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-runtime-host-management-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-runtime-host-management-services.ts @@ -39,6 +39,13 @@ export function createDesktopRuntimeHostManagementServices( readClipboardText: () => navigator.clipboard.readText(), writeClipboardText: (value) => navigator.clipboard.writeText(value), }, + resources: { + query: (profileId) => bridge.runtimeHostManagement.getResources(profileId), + schedule: (callback, delayMs) => { + const timer = window.setTimeout(callback, delayMs); + return () => window.clearTimeout(timer); + }, + }, peerMesh: { getConnectivityPolicy: () => bridge.runtimeHostPeerMesh.getConnectivityPolicy(), setConnectivityPolicy: (policy) => bridge.runtimeHostPeerMesh.setConnectivityPolicy(policy), diff --git a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx index 586eb442ad..fafbab3b46 100644 --- a/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx +++ b/apps/desktop/src/renderer/settings/runtime-host-management-dialog.tsx @@ -58,7 +58,10 @@ import { RuntimeHostProjectDirectoryEditor, type ProjectDirectoryRootDraft, } from './runtime-host-project-directory-editor.js'; -import { RuntimeHostConnectionCodeButton } from '../features/runtime-host-management'; +import { + RuntimeHostConnectionCodeButton, + RuntimeHostResourceDialog, +} from '../features/runtime-host-management'; type RuntimeHostManagementConfirmation = | { readonly kind: 'uninstall'; readonly allowInterruptActiveTasks: boolean } @@ -775,6 +778,9 @@ export function RuntimeHostManagementDialog(props: { ) : null} + {target ? ( + + ) : null} {serviceInstalled && fullManagement && target?.directPeerManagement ? (
diff --git a/apps/desktop/src/renderer/styles/settings/runtime-host.css b/apps/desktop/src/renderer/styles/settings/runtime-host.css index d4fd7d7d05..81e318c123 100644 --- a/apps/desktop/src/renderer/styles/settings/runtime-host.css +++ b/apps/desktop/src/renderer/styles/settings/runtime-host.css @@ -121,6 +121,15 @@ font: var(--maka-text-body); } +.settingsRuntimeHostResources { + display: grid; + gap: var(--space-3); +} + +.settingsRuntimeHostResources dd { + white-space: pre-line; +} + .settingsRuntimeHostManagementDirectoryRoots { display: grid; gap: var(--space-2); diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 5b7e3fdb67..43be274009 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 247 files — blocker 0, reimplementation 0, polish 1, aligned 246. +**Totals:** 248 files — blocker 0, reimplementation 0, polish 1, aligned 247. ## Exclusions (explicit) @@ -58,6 +58,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-connection-code-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, FormLayout, IconButton, Layout, LayoutContent, LayoutFooter, TextArea, Tooltip | aligned — uses Astryx (Button, Dialog, DialogHeader, FormLayout, IconButton, Layout, LayoutContent, LayoutFooter) | aligned | | `apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx` | dialog-overlay | Badge, Banner, Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, MoreMenu, SegmentedControl, SegmentedControlItem, Selector, Switch, Text, TextArea, TextInput, Tooltip | aligned — uses Astryx (Badge, Banner, Button, Dialog, DialogHeader, HStack, Layout, LayoutContent) | aligned | | `apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-profile-pairing-actions.tsx` | other | Button, MoreMenu | aligned — uses Astryx (Button, MoreMenu) | aligned | +| `apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-resource-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, Layout, LayoutContent, LayoutFooter, Spinner, Text | aligned — uses Astryx (Button, Dialog, DialogHeader, Layout, LayoutContent, LayoutFooter, Spinner, Text) | aligned | | `apps/desktop/src/renderer/features/session-collaboration/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx` | dialog-overlay | Badge, Banner, Button, Dialog, DialogHeader, FormLayout, HStack, Layout, LayoutContent, LayoutFooter, List, ListItem, TextArea, Tooltip | aligned — uses Astryx (Badge, Banner, Button, Dialog, DialogHeader, FormLayout, HStack, Layout) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 279c0dc561..d720817716 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -29,6 +29,7 @@ apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-conne apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-connection-code-dialog.tsx apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-profile-pairing-actions.tsx +apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-resource-dialog.tsx apps/desktop/src/renderer/features/session-collaboration/services-context.tsx apps/desktop/src/renderer/features/session-collaboration/turn-request-inbox-context.tsx apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx diff --git a/package-lock.json b/package-lock.json index 9e54dcfa20..321c4a5390 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15908,6 +15908,32 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/systeminformation": { + "version": "5.33.8", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.8.tgz", + "integrity": "sha512-v4F6OGYGh7wDvV68YmjOmZwGixV9A/GQ7d2b84t0UF4CaOy9jipNWIJDkHqYDYiTPuiojqlwVQd0hfUKOUN7tQ==", + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=10.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, "node_modules/tar": { "version": "7.5.22", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", @@ -17229,6 +17255,7 @@ "@maka/core": "0.1.0", "@maka/runtime": "0.1.0", "@maka/storage": "0.1.0", + "systeminformation": "^5.33.8", "ws": "^8.21.3", "yaml": "^2.9.0", "zod": "^4.5.4" diff --git a/packages/cli/THIRD_PARTY_NOTICES.txt b/packages/cli/THIRD_PARTY_NOTICES.txt index 062aa20310..de25d90b90 100644 --- a/packages/cli/THIRD_PARTY_NOTICES.txt +++ b/packages/cli/THIRD_PARTY_NOTICES.txt @@ -6969,6 +6969,35 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ +Package: systeminformation@5.33.8 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/sebhildebrandt/systeminformation.git + +--- LICENSE --- +The MIT License (MIT) + +Copyright (c) 2014-2026 Sebastian Hildebrandt + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + Package: turndown@7.2.4 Declared license: MIT Selected license: MIT diff --git a/packages/runtime-host/package.json b/packages/runtime-host/package.json index dee2266520..862fbc33c1 100644 --- a/packages/runtime-host/package.json +++ b/packages/runtime-host/package.json @@ -32,6 +32,7 @@ "@maka/core": "0.1.0", "@maka/runtime": "0.1.0", "@maka/storage": "0.1.0", + "systeminformation": "^5.33.8", "ws": "^8.21.3", "yaml": "^2.9.0", "zod": "^4.5.4" diff --git a/packages/runtime-host/src/__tests__/access-credential-grant-migration.test.ts b/packages/runtime-host/src/__tests__/access-credential-grant-migration.test.ts index 0f94946fce..aac8e62468 100644 --- a/packages/runtime-host/src/__tests__/access-credential-grant-migration.test.ts +++ b/packages/runtime-host/src/__tests__/access-credential-grant-migration.test.ts @@ -55,10 +55,10 @@ function storedCredential(operationGrants: readonly string[]): Record { +test('renamed and split operations carry their stored authority to successors', async () => { const path = await writeAccessFile({ schemaVersion: 3, - credentials: [storedCredential(['host.status', 'task.ledger.query'])], + credentials: [storedCredential(['host.status', 'host.diagnostics.query', 'task.ledger.query'])], sessionGrants: [], turnAccessRequests: [], }); @@ -66,8 +66,18 @@ test('a renamed operation carries its stored authority to the successor', async const file = await readAccessCredentialFile(path); const credential = file.credentials[0]; assert.ok(credential); - assert.deepEqual(credential.grants, ['host.status', 'session.todo.query']); - assert.deepEqual(effectiveOperationGrants(credential), ['host.status', 'session.todo.query']); + assert.deepEqual(credential.grants, [ + 'host.status', + 'host.diagnostics.query', + 'host.resources.query', + 'session.todo.query', + ]); + assert.deepEqual(effectiveOperationGrants(credential), [ + 'host.status', + 'host.diagnostics.query', + 'host.resources.query', + 'session.todo.query', + ]); assert.deepEqual(unresolvedPersistedGrants(file), []); }); diff --git a/packages/runtime-host/src/__tests__/connection-session.test.ts b/packages/runtime-host/src/__tests__/connection-session.test.ts index ff6b96769b..67a35a2b28 100644 --- a/packages/runtime-host/src/__tests__/connection-session.test.ts +++ b/packages/runtime-host/src/__tests__/connection-session.test.ts @@ -1552,7 +1552,7 @@ function statusResponse(requestId: string): ResponseFrame { const UNUSED_HOST_DIAGNOSTICS_HANDLER: Pick< OperationHandlerMap, - 'host.diagnostics.query' | 'host.upgrade.prepare' + 'host.diagnostics.query' | 'host.resources.query' | 'host.upgrade.prepare' > = { 'host.diagnostics.query': async () => ({ ok: false, @@ -1562,6 +1562,10 @@ const UNUSED_HOST_DIAGNOSTICS_HANDLER: Pick< ok: false, error: { code: 'internal_failure', message: 'not used' }, }), + 'host.resources.query': async () => ({ + ok: false, + error: { code: 'internal_failure', message: 'not used' }, + }), }; function largeFailureResponse(requestId: string): ResponseFrame { diff --git a/packages/runtime-host/src/__tests__/host-resources.test.ts b/packages/runtime-host/src/__tests__/host-resources.test.ts new file mode 100644 index 0000000000..d9d4324810 --- /dev/null +++ b/packages/runtime-host/src/__tests__/host-resources.test.ts @@ -0,0 +1,180 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + HOST_RESOURCE_KINDS, + HOST_RESOURCE_OPERATION_SPECS, + availableHostResource, + deriveHostResourceUtilization, + type HostResourceEnvelope, + type HostResourceJsonObject, + type HostResourcesResult, +} from '../protocol/host-resources.js'; +import { + createHostResourceCollector, + type HostResourceSystemInformation, +} from '../server/host-resource-collector.js'; + +test('resource snapshots preserve unknown kinds while validating known resources', async () => { + const provider: HostResourceSystemInformation = { + async graphics() { + return { + controllers: [ + { + vendor: 'Example', + model: 'Accelerator', + bus: 'PCIe', + vram: 8_192, + vramDynamic: false, + utilizationGpu: 25, + }, + ], + displays: [], + }; + }, + async networkStats() { + throw new Error('network probe unavailable'); + }, + async fsSize() { + return [ + { + fs: '/dev/test', + type: 'ext4', + size: 10_000, + used: 4_000, + available: 6_000, + use: 40, + mount: '/', + rw: true, + }, + ]; + }, + }; + const collected = await createHostResourceCollector(provider).snapshot('host-epoch'); + const unknown: HostResourceEnvelope = { + kind: 'example.vendor.npu', + schemaVersion: 7, + status: 'available', + payload: { capacity: { tops: 40 } }, + }; + const decoded = HOST_RESOURCE_OPERATION_SPECS['host.resources.query'].decodeOutput({ + ...collected, + resources: [...collected.resources, unknown], + }); + + assert.deepEqual(decoded.resources.at(-1), unknown); + assert.deepEqual( + decoded.resources.find((resource) => resource.kind === HOST_RESOURCE_KINDS.network), + { + kind: HOST_RESOURCE_KINDS.network, + schemaVersion: 1, + status: 'unavailable', + reason: 'probe_failed', + }, + ); + assert.equal( + availableHostResource(decoded, HOST_RESOURCE_KINDS.graphics)?.devices[0]?.memory.kind, + 'dedicated', + ); + assert.deepEqual(availableHostResource(decoded, HOST_RESOURCE_KINDS.storage)?.volumes[0], { + mount: '/', + filesystem: 'ext4', + totalBytes: 10_000, + availableBytes: 6_000, + }); + assert.throws( + () => + HOST_RESOURCE_OPERATION_SPECS['host.resources.query'].decodeOutput({ + ...collected, + resources: collected.resources.map((resource) => + resource.kind === HOST_RESOURCE_KINDS.cpu + ? { ...resource, status: 'available', payload: {} } + : resource, + ), + }), + /Invalid maka\.system\.cpu resource/u, + ); + assert.throws( + () => + HOST_RESOURCE_OPERATION_SPECS['host.resources.query'].decodeOutput({ + ...collected, + resources: [...collected.resources, collected.resources[0]], + }), + /Duplicate Runtime Host resource kind/u, + ); +}); + +test('utilization derives rates from same-epoch cumulative observations', () => { + const previous = snapshot('host-epoch', 1_000, 100, 200, 1_000, 2_000); + const current = snapshot('host-epoch', 3_000, 300, 500, 5_000, 8_000); + assert.deepEqual(deriveHostResourceUtilization(current, previous), { + memoryPercent: 75, + cpuPercent: 66.66666666666666, + receivedBytesPerSecond: 2_000, + transmittedBytesPerSecond: 3_000, + }); + + const replacement = snapshot('replacement', 5_000, 500, 700, 9_000, 12_000); + assert.deepEqual(deriveHostResourceUtilization(replacement, current), { + memoryPercent: 75, + }); +}); + +function snapshot( + hostEpoch: string, + monotonicTimeMilliseconds: number, + busyTimeMilliseconds: number, + totalTimeMilliseconds: number, + receivedBytes: number, + transmittedBytes: number, +): HostResourcesResult { + return { + hostEpoch, + observedAt: '2026-09-04T00:00:00.000Z', + monotonicTimeMilliseconds, + resources: [ + available(HOST_RESOURCE_KINDS.cpu, { + capacity: { + model: 'CPU', + logicalProcessors: 4, + availableParallelism: 2, + }, + observation: { busyTimeMilliseconds, totalTimeMilliseconds }, + }), + available(HOST_RESOURCE_KINDS.memory, { + capacity: { visibleTotalBytes: 2_000, effectiveTotalBytes: 1_000 }, + observation: { availableBytes: 250 }, + }), + available(HOST_RESOURCE_KINDS.network, { + observation: { + interfaceName: 'eth0', + monotonicTimeMilliseconds, + receivedBytes, + transmittedBytes, + }, + }), + ], + }; +} + +function available(kind: string, payload: HostResourceJsonObject): HostResourceEnvelope { + return { kind, schemaVersion: 1, status: 'available', payload }; +} diff --git a/packages/runtime-host/src/__tests__/operation-dispatcher.test.ts b/packages/runtime-host/src/__tests__/operation-dispatcher.test.ts index 6271d9390f..0700049bef 100644 --- a/packages/runtime-host/src/__tests__/operation-dispatcher.test.ts +++ b/packages/runtime-host/src/__tests__/operation-dispatcher.test.ts @@ -191,6 +191,7 @@ function validHandlers(): OperationHandlerMap { return { 'host.status': unavailable, 'host.diagnostics.query': unavailable, + 'host.resources.query': unavailable, 'host.upgrade.prepare': unavailable, ...createUnavailableHostCoreOperationHandlers(), ...createUnavailableDomainOperationHandlers(), diff --git a/packages/runtime-host/src/protocol/host-resources.ts b/packages/runtime-host/src/protocol/host-resources.ts new file mode 100644 index 0000000000..1c271e58a0 --- /dev/null +++ b/packages/runtime-host/src/protocol/host-resources.ts @@ -0,0 +1,378 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { z } from 'zod'; +import { requireEncodedByteLimit, requireExactRecord } from './codec.js'; +import { invalidProtocolFrame } from './errors.js'; +import { defineOperation } from './operation-spec.js'; + +export const HOST_RESOURCE_RESULT_MAX_BYTES = 128 * 1024; +export const HOST_RESOURCE_MAX_ENTRIES = 64; + +export const HOST_RESOURCE_KINDS = { + cpu: 'maka.system.cpu', + memory: 'maka.system.memory', + graphics: 'maka.system.graphics', + network: 'maka.system.network', + storage: 'maka.system.storage', +} as const; + +export type HostResourceJsonValue = + | null + | boolean + | number + | string + | readonly HostResourceJsonValue[] + | HostResourceJsonObject; + +export interface HostResourceJsonObject { + readonly [key: string]: HostResourceJsonValue; +} + +export type HostResourceUnavailableReason = 'unsupported' | 'permission_denied' | 'probe_failed'; + +export type HostResourceEnvelope = + | { + readonly kind: string; + readonly schemaVersion: number; + readonly status: 'available'; + readonly payload: HostResourceJsonObject; + } + | { + readonly kind: string; + readonly schemaVersion: number; + readonly status: 'unavailable'; + readonly reason: HostResourceUnavailableReason; + }; + +const POSITIVE_COUNT = z.number().int().positive().safe(); +const COUNT = z.number().int().nonnegative().safe(); +const PERCENTAGE = z.number().min(0).max(100); +const NON_EMPTY_TEXT = z.string().min(1).max(512); + +const CPU_RESOURCE_V1_SCHEMA = z + .strictObject({ + capacity: z.strictObject({ + model: NON_EMPTY_TEXT, + logicalProcessors: POSITIVE_COUNT, + availableParallelism: POSITIVE_COUNT, + }), + observation: z.strictObject({ + busyTimeMilliseconds: COUNT, + totalTimeMilliseconds: COUNT, + }), + }) + .refine( + (value) => + value.capacity.availableParallelism <= value.capacity.logicalProcessors && + value.observation.busyTimeMilliseconds <= value.observation.totalTimeMilliseconds, + 'CPU resource counters exceed capacity', + ); + +const MEMORY_RESOURCE_V1_SCHEMA = z + .strictObject({ + capacity: z.strictObject({ + visibleTotalBytes: POSITIVE_COUNT, + effectiveTotalBytes: POSITIVE_COUNT, + }), + observation: z.strictObject({ availableBytes: COUNT }), + }) + .refine( + (value) => + value.capacity.effectiveTotalBytes <= value.capacity.visibleTotalBytes && + value.observation.availableBytes <= value.capacity.effectiveTotalBytes, + 'memory resource counters exceed capacity', + ); + +const GRAPHICS_RESOURCE_V1_SCHEMA = z + .strictObject({ + detection: z.enum(['detected', 'not_detected', 'unknown']), + devices: z + .array( + z.strictObject({ + id: z.string().min(1).max(128), + model: NON_EMPTY_TEXT, + vendor: z.string().min(1).max(256).optional(), + memory: z.discriminatedUnion('kind', [ + z.strictObject({ kind: z.literal('dedicated'), bytes: POSITIVE_COUNT }), + z.strictObject({ kind: z.literal('shared') }), + z.strictObject({ kind: z.literal('unknown') }), + ]), + utilizationPercent: PERCENTAGE.optional(), + }), + ) + .max(32), + }) + .refine( + (value) => (value.detection === 'detected') === value.devices.length > 0, + 'graphics adapter detection does not match its devices', + ); + +const NETWORK_RESOURCE_V1_SCHEMA = z.strictObject({ + observation: z.strictObject({ + interfaceName: NON_EMPTY_TEXT, + monotonicTimeMilliseconds: z.number().nonnegative().finite(), + receivedBytes: COUNT, + transmittedBytes: COUNT, + }), +}); + +const STORAGE_RESOURCE_V1_SCHEMA = z.strictObject({ + volumes: z + .array( + z + .strictObject({ + mount: z + .string() + .min(1) + .max(2 * 1024), + filesystem: z.string().min(1).max(128).optional(), + totalBytes: POSITIVE_COUNT, + availableBytes: COUNT, + }) + .refine( + (volume) => volume.availableBytes <= volume.totalBytes, + 'storage availability exceeds capacity', + ), + ) + .max(64), +}); + +export type HostCpuResourceV1 = z.infer; +export type HostMemoryResourceV1 = z.infer; +export type HostGraphicsResourceV1 = z.infer; +export type HostNetworkResourceV1 = z.infer; +export type HostStorageResourceV1 = z.infer; + +export interface HostResourcePayloads { + readonly [HOST_RESOURCE_KINDS.cpu]: HostCpuResourceV1; + readonly [HOST_RESOURCE_KINDS.memory]: HostMemoryResourceV1; + readonly [HOST_RESOURCE_KINDS.graphics]: HostGraphicsResourceV1; + readonly [HOST_RESOURCE_KINDS.network]: HostNetworkResourceV1; + readonly [HOST_RESOURCE_KINDS.storage]: HostStorageResourceV1; +} + +export type HostResourceKind = keyof HostResourcePayloads; + +export interface HostResourcesResult { + readonly hostEpoch: string; + readonly observedAt: string; + readonly monotonicTimeMilliseconds: number; + readonly resources: readonly HostResourceEnvelope[]; +} + +export type HostResourcesQueryInput = Record; + +export const HOST_RESOURCE_OPERATION_SPECS = { + 'host.resources.query': defineOperation({ + mode: 'query', + availability: 'bootstrap', + errors: ['host_draining', 'internal_failure'] as const, + decodeInput: (value) => { + requireExactRecord(value, 'host.resources.query input', []); + return {}; + }, + decodeOutput: decodeHostResourcesResult, + }), +} as const; + +export function availableHostResource( + snapshot: HostResourcesResult | undefined, + kind: K, +): HostResourcePayloads[K] | undefined { + const resource = snapshot?.resources.find( + (candidate) => + candidate.kind === kind && candidate.schemaVersion === 1 && candidate.status === 'available', + ); + return resource?.status === 'available' + ? (resource.payload as HostResourcePayloads[K]) + : undefined; +} + +export interface HostResourceUtilization { + readonly cpuPercent?: number; + readonly memoryPercent?: number; + readonly receivedBytesPerSecond?: number; + readonly transmittedBytesPerSecond?: number; +} + +export function deriveHostResourceUtilization( + current: HostResourcesResult, + previous?: HostResourcesResult, +): HostResourceUtilization { + const memory = availableHostResource(current, HOST_RESOURCE_KINDS.memory); + const utilization: HostResourceUtilization = { + ...(memory + ? { + memoryPercent: percentage( + memory.capacity.effectiveTotalBytes - memory.observation.availableBytes, + memory.capacity.effectiveTotalBytes, + ), + } + : {}), + }; + if (!previous || previous.hostEpoch !== current.hostEpoch) return utilization; + const elapsedMilliseconds = + current.monotonicTimeMilliseconds - previous.monotonicTimeMilliseconds; + if (elapsedMilliseconds <= 0) return utilization; + + const cpu = availableHostResource(current, HOST_RESOURCE_KINDS.cpu); + const previousCpu = availableHostResource(previous, HOST_RESOURCE_KINDS.cpu); + if (cpu && previousCpu) { + const busyDelta = + cpu.observation.busyTimeMilliseconds - previousCpu.observation.busyTimeMilliseconds; + const totalDelta = + cpu.observation.totalTimeMilliseconds - previousCpu.observation.totalTimeMilliseconds; + if (busyDelta >= 0 && totalDelta > 0) { + Object.assign(utilization, { cpuPercent: percentage(busyDelta, totalDelta) }); + } + } + + const network = availableHostResource(current, HOST_RESOURCE_KINDS.network); + const previousNetwork = availableHostResource(previous, HOST_RESOURCE_KINDS.network); + if ( + network && + previousNetwork && + network.observation.interfaceName === previousNetwork.observation.interfaceName + ) { + const networkElapsedMilliseconds = + network.observation.monotonicTimeMilliseconds - + previousNetwork.observation.monotonicTimeMilliseconds; + if (networkElapsedMilliseconds <= 0) return utilization; + const elapsedSeconds = networkElapsedMilliseconds / 1_000; + assignRate( + utilization, + 'receivedBytesPerSecond', + network.observation.receivedBytes - previousNetwork.observation.receivedBytes, + elapsedSeconds, + ); + assignRate( + utilization, + 'transmittedBytesPerSecond', + network.observation.transmittedBytes - previousNetwork.observation.transmittedBytes, + elapsedSeconds, + ); + } + return utilization; +} + +const ENVELOPE_SCHEMA = z.discriminatedUnion('status', [ + z.strictObject({ + kind: z.string().min(1).max(128), + schemaVersion: POSITIVE_COUNT, + status: z.literal('available'), + payload: z.unknown(), + }), + z.strictObject({ + kind: z.string().min(1).max(128), + schemaVersion: POSITIVE_COUNT, + status: z.literal('unavailable'), + reason: z.enum(['unsupported', 'permission_denied', 'probe_failed']), + }), +]); + +const RESULT_SCHEMA = z.strictObject({ + hostEpoch: z.string().min(1).max(128), + observedAt: z.iso.datetime(), + monotonicTimeMilliseconds: z.number().nonnegative().finite(), + resources: z.array(ENVELOPE_SCHEMA).max(HOST_RESOURCE_MAX_ENTRIES), +}); + +const KNOWN_PAYLOAD_SCHEMAS: Readonly> = { + [HOST_RESOURCE_KINDS.cpu]: CPU_RESOURCE_V1_SCHEMA, + [HOST_RESOURCE_KINDS.memory]: MEMORY_RESOURCE_V1_SCHEMA, + [HOST_RESOURCE_KINDS.graphics]: GRAPHICS_RESOURCE_V1_SCHEMA, + [HOST_RESOURCE_KINDS.network]: NETWORK_RESOURCE_V1_SCHEMA, + [HOST_RESOURCE_KINDS.storage]: STORAGE_RESOURCE_V1_SCHEMA, +}; + +function decodeHostResourcesResult(value: unknown): HostResourcesResult { + requireEncodedByteLimit(value, 'host.resources.query result', HOST_RESOURCE_RESULT_MAX_BYTES); + const result = parse(RESULT_SCHEMA, value, 'Runtime Host resources'); + if (new Set(result.resources.map((resource) => resource.kind)).size !== result.resources.length) { + throw invalidProtocolFrame('Duplicate Runtime Host resource kind'); + } + return { + ...result, + resources: result.resources.map((resource): HostResourceEnvelope => { + if (resource.status === 'unavailable') return resource; + const payload = decodeJsonObject(resource.payload); + const schema = + resource.schemaVersion === 1 + ? KNOWN_PAYLOAD_SCHEMAS[resource.kind as HostResourceKind] + : undefined; + return { + ...resource, + payload: schema + ? (parse(schema, payload, `${resource.kind} resource`) as HostResourceJsonObject) + : payload, + }; + }), + }; +} + +function decodeJsonObject(value: unknown): HostResourceJsonObject { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw invalidProtocolFrame('Invalid Runtime Host resource payload'); + } + return decodeJsonValue(value, 0) as HostResourceJsonObject; +} + +function decodeJsonValue(value: unknown, depth: number): HostResourceJsonValue { + if (depth > 8) throw invalidProtocolFrame('Runtime Host resource payload is too deeply nested'); + if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (Array.isArray(value)) { + if (value.length > 256) throw invalidProtocolFrame('Invalid Runtime Host resource payload'); + return value.map((child) => decodeJsonValue(child, depth + 1)); + } + if (!value || typeof value !== 'object') { + throw invalidProtocolFrame('Invalid Runtime Host resource payload'); + } + const entries = Object.entries(value); + if (entries.length > 128) throw invalidProtocolFrame('Invalid Runtime Host resource payload'); + return Object.fromEntries( + entries.map(([key, child]) => { + if (!key || key.length > 128) { + throw invalidProtocolFrame('Invalid Runtime Host resource payload'); + } + return [key, decodeJsonValue(child, depth + 1)]; + }), + ); +} + +function parse(schema: z.ZodType, value: unknown, label: string): T { + const result = schema.safeParse(value); + if (!result.success) throw invalidProtocolFrame(`Invalid ${label}`); + return result.data; +} + +function percentage(numerator: number, denominator: number): number { + if (denominator <= 0) return 0; + return Math.max(0, Math.min(100, (numerator / denominator) * 100)); +} + +function assignRate( + target: HostResourceUtilization, + key: 'receivedBytesPerSecond' | 'transmittedBytesPerSecond', + delta: number, + elapsedSeconds: number, +): void { + if (delta >= 0) Object.assign(target, { [key]: delta / elapsedSeconds }); +} diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 9fada32cd6..b58467c6ee 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -75,6 +75,7 @@ export * from './configuration-change.js'; export * from './connection-catalog-change.js'; export * from './goal.js'; export * from './hosted-execution.js'; +export * from './host-resources.js'; export * from './plan.js'; export * from './peer-mesh.js'; export * from './project-catalog.js'; @@ -100,7 +101,9 @@ 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 = 111 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 112 as const; +// 112: Owners can query the Host execution environment through an extensible, +// bounded resource-envelope contract. Older Hosts do not implement the query. // 111: Client Capability tool schemas may use draft-07 tuple additionalItems. // Older Hosts reject the keyword, so peers must agree before capabilities are admitted. // 110: Runtime Host is the sole schema-migration authority for its State Root. diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 0d52b1445e..d8c1ba0f5f 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -31,6 +31,7 @@ import { EXTERNAL_SESSION_OPERATION_SPECS } from './external-session.js'; import { CLIENT_CAPABILITY_OPERATION_SPECS } from './client-capability.js'; import { invalidProtocolFrame } from './errors.js'; import { HOST_BOOTSTRAP_OPERATION_SPECS } from './host-status.js'; +import { HOST_RESOURCE_OPERATION_SPECS } from './host-resources.js'; import { HOSTED_EXECUTION_OPERATION_SPECS } from './hosted-execution.js'; import { GOAL_OPERATION_SPECS } from './goal.js'; import { INTERACTION_OPERATION_SPECS } from './interaction.js'; @@ -161,6 +162,7 @@ export * from './agent-graph.js'; export * from './execution-inspect.js'; export * from './client-capability.js'; export * from './goal.js'; +export * from './host-resources.js'; export * from './memory.js'; export * from './network-proxy.js'; export * from './oauth.js'; @@ -185,6 +187,7 @@ export * from './workspace.js'; export const HOST_OPERATION_SPECS = composeOperationSpecMaps( HOST_BOOTSTRAP_OPERATION_SPECS, + HOST_RESOURCE_OPERATION_SPECS, PEER_MESH_OPERATION_SPECS, HOSTED_EXECUTION_OPERATION_SPECS, ACCESS_AUTHORITY_OPERATION_SPECS, @@ -276,6 +279,7 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'goal.control', 'goal.query', 'host.diagnostics.query', + 'host.resources.query', 'host.status', 'interaction.answer', 'interaction.query', diff --git a/packages/runtime-host/src/server/access-credential-store.ts b/packages/runtime-host/src/server/access-credential-store.ts index 4532bf0bec..973e0f398a 100644 --- a/packages/runtime-host/src/server/access-credential-store.ts +++ b/packages/runtime-host/src/server/access-credential-store.ts @@ -70,6 +70,11 @@ const PERSISTED_GRANT_MIGRATIONS: ReadonlyMap = 'session.turns.query', { kind: 'replace', successors: ['session.turns.query', 'session.turn_landmarks.query'] }, ], + // Resource inventory is a dedicated facet of the existing Host diagnostics authority. + [ + 'host.diagnostics.query', + { kind: 'replace', successors: ['host.diagnostics.query', 'host.resources.query'] }, + ], // TaskLedger became SessionTodo; the query carried its authority over. ['task.ledger.query', { kind: 'replace', successors: ['session.todo.query'] }], // Retired with the Claude subscription provider, whose client identity the diff --git a/packages/runtime-host/src/server/host-kernel.ts b/packages/runtime-host/src/server/host-kernel.ts index 9655398dda..cd6df6702b 100644 --- a/packages/runtime-host/src/server/host-kernel.ts +++ b/packages/runtime-host/src/server/host-kernel.ts @@ -94,6 +94,7 @@ import { import { HostResidencyRegistry } from './host-residency-registry.js'; import type { PeerMeshNode } from '../peer-mesh/node.js'; import { createPeerMeshOperationHandlers } from './peer-mesh-authority.js'; +import { createHostResourceCollector } from './host-resource-collector.js'; const DEFAULT_IDLE_GRACE_MS = 30_000; const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000; @@ -207,6 +208,7 @@ export class RuntimeHostKernel { >(); readonly #operationDrainWaiters = new Set<() => void>(); readonly #residencies = new HostResidencyRegistry(); + readonly #resourceCollector = createHostResourceCollector(); readonly #lifecycle: RuntimeHostLifecycle; readonly #handshakeTimeoutMs: number; readonly #shutdownGraceMs: number; @@ -700,6 +702,10 @@ export class RuntimeHostKernel { .map((entry) => collapseHomePath(entry, homedir(), process.platform)), }, }), + 'host.resources.query': async () => ({ + ok: true, + result: await this.#resourceCollector.snapshot(this.hostEpoch), + }), 'host.upgrade.prepare': async (input) => { if (input.expectedHostEpoch !== this.hostEpoch) { return { diff --git a/packages/runtime-host/src/server/host-resource-collector.ts b/packages/runtime-host/src/server/host-resource-collector.ts new file mode 100644 index 0000000000..a8f39de30b --- /dev/null +++ b/packages/runtime-host/src/server/host-resource-collector.ts @@ -0,0 +1,277 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { existsSync } from 'node:fs'; +import { availableParallelism, cpus, totalmem } from 'node:os'; +import { performance } from 'node:perf_hooks'; +import { + HOST_RESOURCE_KINDS, + type HostCpuResourceV1, + type HostGraphicsResourceV1, + type HostMemoryResourceV1, + type HostNetworkResourceV1, + type HostResourceEnvelope, + type HostResourceJsonObject, + type HostResourcesResult, + type HostStorageResourceV1, +} from '../protocol/host-resources.js'; +import { + createIsolatedHostResourceSystemInformation, + type HostResourceSystemInformation, +} from './host-resource-probe.js'; + +export type { HostResourceSystemInformation } from './host-resource-probe.js'; + +const SLOW_RESOURCE_CACHE_MILLISECONDS = 10_000; +// Leave process-tree termination and scheduling headroom before the Host's +// 10-second forced-shutdown boundary. +const RESOURCE_PROBE_TIMEOUT_MILLISECONDS = 7_000; + +export interface HostResourceCollector { + snapshot(hostEpoch: string): Promise; +} + +export function createHostResourceCollector( + provider: HostResourceSystemInformation = createIsolatedHostResourceSystemInformation( + RESOURCE_PROBE_TIMEOUT_MILLISECONDS, + ), +): HostResourceCollector { + const collectGraphics = createResourceProbe({ + collect: () => probeGraphics(provider), + cacheMilliseconds: SLOW_RESOURCE_CACHE_MILLISECONDS, + }); + const collectStorage = createResourceProbe({ + collect: () => probeStorage(provider), + cacheMilliseconds: SLOW_RESOURCE_CACHE_MILLISECONDS, + }); + const collectNetwork = createResourceProbe({ + collect: () => probeNetwork(provider), + }); + + return { + async snapshot(hostEpoch): Promise { + const [graphics, network, storage] = await Promise.all([ + collectGraphics(), + collectNetwork(), + collectStorage(), + ]); + const cpu = collectCpu(); + const memory = collectMemory(); + const monotonicTimeMilliseconds = Math.floor(performance.now()); + return { + hostEpoch, + observedAt: new Date().toISOString(), + monotonicTimeMilliseconds, + resources: [cpu, memory, graphics, network, storage], + }; + }, + }; +} + +function createResourceProbe(input: { + readonly collect: () => Promise; + readonly cacheMilliseconds?: number; +}): () => Promise { + let cached: { readonly expiresAt: number; readonly resource: HostResourceEnvelope } | undefined; + let inFlight: Promise | undefined; + return async () => { + const now = performance.now(); + if (cached && cached.expiresAt > now) return cached.resource; + if (!inFlight) { + const probe = input.collect().then((resource) => { + if (input.cacheMilliseconds) { + cached = { + expiresAt: performance.now() + input.cacheMilliseconds, + resource, + }; + } + return resource; + }); + const tracked = probe.finally(() => { + if (inFlight === tracked) inFlight = undefined; + }); + inFlight = tracked; + } + return inFlight; + }; +} + +function collectCpu(): HostResourceEnvelope { + const processors = cpus(); + const totals = processors.reduce( + (sum, processor) => { + const busy = + processor.times.user + processor.times.nice + processor.times.sys + processor.times.irq; + return { + busy: sum.busy + busy, + total: sum.total + busy + processor.times.idle, + }; + }, + { busy: 0, total: 0 }, + ); + const payload: HostCpuResourceV1 = { + capacity: { + model: processors[0]?.model.trim() || 'Unknown CPU', + logicalProcessors: Math.max(1, processors.length), + availableParallelism: Math.max(1, availableParallelism()), + }, + observation: { + busyTimeMilliseconds: Math.max(0, Math.floor(totals.busy)), + totalTimeMilliseconds: Math.max(0, Math.floor(totals.total)), + }, + }; + return availableResource(HOST_RESOURCE_KINDS.cpu, payload); +} + +function collectMemory(): HostResourceEnvelope { + const visibleTotalBytes = totalmem(); + const constrainedBytes = process.constrainedMemory(); + const effectiveTotalBytes = + constrainedBytes > 0 ? Math.min(visibleTotalBytes, constrainedBytes) : visibleTotalBytes; + const availableBytes = Math.min(effectiveTotalBytes, process.availableMemory()); + const payload: HostMemoryResourceV1 = { + capacity: { + visibleTotalBytes, + effectiveTotalBytes, + }, + observation: { + availableBytes: Math.max(0, availableBytes), + }, + }; + return availableResource(HOST_RESOURCE_KINDS.memory, payload); +} + +async function probeGraphics( + provider: HostResourceSystemInformation, +): Promise { + try { + const graphics = await provider.graphics(); + const devices = graphics.controllers.slice(0, 32).map((controller, index) => { + const memoryMegabytes = finitePositive(controller.memoryTotal) + ? controller.memoryTotal + : finitePositive(controller.vram) + ? controller.vram + : undefined; + return { + id: `gpu-${index}`, + model: + controller.model.trim() || controller.name?.trim() || `Graphics adapter ${index + 1}`, + ...(controller.vendor.trim() ? { vendor: controller.vendor.trim() } : {}), + memory: controller.vramDynamic + ? ({ kind: 'shared' } as const) + : memoryMegabytes === undefined + ? ({ kind: 'unknown' } as const) + : ({ + kind: 'dedicated', + bytes: Math.floor(memoryMegabytes * 1024 * 1024), + } as const), + ...(finitePercentage(controller.utilizationGpu) + ? { utilizationPercent: controller.utilizationGpu } + : {}), + }; + }); + const payload: HostGraphicsResourceV1 = { + detection: + devices.length > 0 + ? 'detected' + : process.platform === 'linux' && existsSync('/dev/dxg') + ? 'unknown' + : 'not_detected', + devices, + }; + return availableResource(HOST_RESOURCE_KINDS.graphics, payload); + } catch { + const payload: HostGraphicsResourceV1 = { detection: 'unknown', devices: [] }; + return availableResource(HOST_RESOURCE_KINDS.graphics, payload); + } +} + +async function probeNetwork( + provider: HostResourceSystemInformation, +): Promise { + try { + const { interfaceName, stats } = await provider.networkStats(); + if (!interfaceName) { + return unavailableResource(HOST_RESOURCE_KINDS.network, 'unsupported'); + } + const observed = stats[0]; + if ( + stats.length !== 1 || + !observed || + observed.iface.toLowerCase() !== interfaceName.toLowerCase() || + observed.operstate !== 'up' + ) { + return unavailableResource(HOST_RESOURCE_KINDS.network, 'probe_failed'); + } + const payload: HostNetworkResourceV1 = { + observation: { + interfaceName, + monotonicTimeMilliseconds: Math.floor(performance.now()), + receivedBytes: safeCounter(observed.rx_bytes), + transmittedBytes: safeCounter(observed.tx_bytes), + }, + }; + return availableResource(HOST_RESOURCE_KINDS.network, payload); + } catch { + return unavailableResource(HOST_RESOURCE_KINDS.network, 'probe_failed'); + } +} + +async function probeStorage( + provider: HostResourceSystemInformation, +): Promise { + try { + const volumes = (await provider.fsSize()) + .filter((volume) => finitePositive(volume.size) && volume.mount.trim()) + .slice(0, 64) + .map((volume) => ({ + mount: volume.mount.trim(), + ...(volume.type.trim() ? { filesystem: volume.type.trim() } : {}), + totalBytes: Math.floor(volume.size), + availableBytes: Math.max(0, Math.min(Math.floor(volume.available), volume.size)), + })); + const payload: HostStorageResourceV1 = { volumes }; + return availableResource(HOST_RESOURCE_KINDS.storage, payload); + } catch { + return unavailableResource(HOST_RESOURCE_KINDS.storage, 'probe_failed'); + } +} + +function availableResource(kind: string, payload: HostResourceJsonObject): HostResourceEnvelope { + return { kind, schemaVersion: 1, status: 'available', payload }; +} + +function unavailableResource( + kind: string, + reason: 'unsupported' | 'permission_denied' | 'probe_failed', +): HostResourceEnvelope { + return { kind, schemaVersion: 1, status: 'unavailable', reason }; +} + +function finitePositive(value: number | null | undefined): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function finitePercentage(value: number | undefined): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100; +} + +function safeCounter(value: number): number { + return Number.isSafeInteger(value) && value >= 0 ? value : 0; +} diff --git a/packages/runtime-host/src/server/host-resource-probe-main.ts b/packages/runtime-host/src/server/host-resource-probe-main.ts new file mode 100644 index 0000000000..d6421c7c17 --- /dev/null +++ b/packages/runtime-host/src/server/host-resource-probe-main.ts @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as systemInformation from 'systeminformation'; + +async function main(): Promise { + const [kind, extra] = process.argv.slice(2); + if (extra !== undefined) throw new Error('Unexpected Runtime Host resource probe argument'); + const result = + kind === 'graphics' + ? await systemInformation.graphics() + : kind === 'storage' + ? await systemInformation.fsSize() + : kind === 'network' + ? await networkStats() + : undefined; + if (result === undefined) throw new Error('Invalid Runtime Host resource probe'); + process.stdout.write(JSON.stringify(result)); +} + +async function networkStats() { + const interfaceName = await systemInformation.networkInterfaceDefault(); + return { + interfaceName, + stats: interfaceName ? await systemInformation.networkStats(interfaceName) : [], + }; +} + +void main().catch(() => { + process.exitCode = 1; +}); diff --git a/packages/runtime-host/src/server/host-resource-probe.ts b/packages/runtime-host/src/server/host-resource-probe.ts new file mode 100644 index 0000000000..daf83052fc --- /dev/null +++ b/packages/runtime-host/src/server/host-resource-probe.ts @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { terminateChildProcessTree } from '@maka/runtime/process-tree-terminator'; +import type * as systemInformation from 'systeminformation'; + +const PROBE_ENTRYPOINT = fileURLToPath(new URL('./host-resource-probe-main.js', import.meta.url)); +const PROBE_OUTPUT_MAX_BYTES = 2 * 1024 * 1024; + +export interface HostResourceSystemInformation { + graphics(): Promise; + networkStats(): Promise<{ + readonly interfaceName: string; + readonly stats: systemInformation.Systeminformation.NetworkStatsData[]; + }>; + fsSize(): Promise; +} + +export function createIsolatedHostResourceSystemInformation( + timeoutMilliseconds: number, +): HostResourceSystemInformation { + return { + graphics: () => invokeProbe('graphics', timeoutMilliseconds), + networkStats: () => invokeProbe('network', timeoutMilliseconds), + fsSize: () => invokeProbe('storage', timeoutMilliseconds), + }; +} + +function invokeProbe( + kind: 'graphics' | 'network' | 'storage', + timeoutMilliseconds: number, +): Promise { + const child = spawn(process.execPath, [PROBE_ENTRYPOINT, kind], { + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }); + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let bytes = 0; + let settled = false; + const timer = setTimeout( + () => terminate(new Error('Runtime Host resource probe timed out')), + timeoutMilliseconds, + ); + const finish = (outcome: { readonly value: T } | { readonly error: Error }) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if ('value' in outcome) resolve(outcome.value); + else reject(outcome.error); + }; + const terminate = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + void terminateChildProcessTree(child, 'SIGKILL').finally(() => reject(error)); + }; + child.stdout.on('data', (chunk: Buffer) => { + bytes += chunk.byteLength; + if (bytes > PROBE_OUTPUT_MAX_BYTES) { + terminate(new Error('Runtime Host resource probe output is too large')); + return; + } + chunks.push(chunk); + }); + child.once('error', (error) => finish({ error })); + child.once('close', (code) => { + if (settled) return; + if (code !== 0) { + finish({ error: new Error('Runtime Host resource probe failed') }); + return; + } + try { + finish({ value: JSON.parse(Buffer.concat(chunks).toString('utf8')) as T }); + } catch { + finish({ error: new Error('Runtime Host resource probe returned invalid output') }); + } + }); + }); +} diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts index ff6f237f9f..cba2b5569d 100644 --- a/packages/runtime-host/src/server/operation-dispatcher.ts +++ b/packages/runtime-host/src/server/operation-dispatcher.ts @@ -33,6 +33,7 @@ import { type ResponseFrameFor, } from '../protocol/index.js'; import { HOST_BOOTSTRAP_OPERATION_SPECS } from '../protocol/host-status.js'; +import { HOST_RESOURCE_OPERATION_SPECS } from '../protocol/host-resources.js'; import { ACCESS_AUTHORITY_OPERATION_SPECS } from '../protocol/access-authority.js'; import { SESSION_COLLABORATION_OPERATION_SPECS } from '../protocol/session-collaboration.js'; import { PEER_MESH_OPERATION_SPECS } from '../protocol/peer-mesh.js'; @@ -109,6 +110,7 @@ export type OperationHandlerMap = { */ const HOST_CORE_SPEC_OBJECTS = [ HOST_BOOTSTRAP_OPERATION_SPECS, + HOST_RESOURCE_OPERATION_SPECS, ACCESS_AUTHORITY_OPERATION_SPECS, SESSION_COLLABORATION_OPERATION_SPECS, PEER_MESH_OPERATION_SPECS,