You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/04 14:13:32 UTC
(maka) branch main updated: feat(runtime-host): expose extensible resource snapshots (#4774)
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new b3a8c44a52 feat(runtime-host): expose extensible resource snapshots (#4774)
b3a8c44a52 is described below
commit b3a8c44a529feb0480a14f1445746bf145585b17
Author: Wang <m4...@gmail.com>
AuthorDate: Fri Sep 4 22:13:27 2026 +0800
feat(runtime-host): expose extensible resource snapshots (#4774)
Generated-by: OpenAI Codex
---
.github/workflows/windows-recovery.yml | 1 +
.../resources/licenses/npm/THIRD_PARTY_NOTICES.txt | 29 ++
.../main/__tests__/runtime-host-management.test.ts | 2 +
apps/desktop/src/main/runtime-host-boot.ts | 1 +
apps/desktop/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 +
.../settings/runtime-host-management-dialog.tsx | 8 +-
.../src/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 ++++++++++
.../src/__tests__/operation-dispatcher.test.ts | 1 +
.../runtime-host/src/protocol/host-resources.ts | 378 +++++++++++++++++++++
packages/runtime-host/src/protocol/index.ts | 5 +-
packages/runtime-host/src/protocol/operations.ts | 4 +
.../src/server/access-credential-store.ts | 5 +
packages/runtime-host/src/server/host-kernel.ts | 6 +
.../src/server/host-resource-collector.ts | 277 +++++++++++++++
.../src/server/host-resource-probe-main.ts | 47 +++
.../runtime-host/src/server/host-resource-probe.ts | 99 ++++++
.../src/server/operation-dispatcher.ts | 2 +
31 files changed, 1524 insertions(+), 9 deletions(-)
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<DesktopRuntimeHostSetupPackage>;
readonly currentHostEpoch: (profileId: string) => string | undefined;
+ readonly liveHost: (profileId: string) => Pick<RuntimeHostConnection, 'request'> | 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<DesktopRuntimeHostUpdatePolicySnapshot>;
reconcileUpdate(profileId: string): Promise<DesktopRuntimeHostUpdateReconciliationResponse>;
+ getResources(profileId: string): Promise<DesktopRuntimeHostResources | undefined>;
getDirectPeer(profileId: string): Promise<DesktopRuntimeHostDirectPeerSnapshot>;
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<void>;
}
+export interface RuntimeHostResourceServices {
+ query(profileId: string): Promise<HostResourcesResult | undefined>;
+ 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 (
+ <>
+ <div className="settingsRuntimeHostUpdatePolicyActions">
+ <Button
+ variant="secondary"
+ size="sm"
+ label={copy.open}
+ onClick={() => setOpen(true)}
+ />
+ </div>
+ {open ? (
+ <Dialog
+ isOpen
+ onOpenChange={setOpen}
+ purpose="form"
+ width={640}
+ maxHeight="calc(100dvh - 64px)"
+ >
+ <Layout
+ header={(
+ <DialogHeader
+ title={copy.title}
+ subtitle={props.hostName}
+ onOpenChange={setOpen}
+ />
+ )}
+ content={(
+ <LayoutContent padding={4}>
+ <RuntimeHostResourceFacts profileId={props.profileId} copy={copy} />
+ </LayoutContent>
+ )}
+ footer={(
+ <LayoutFooter hasDivider>
+ <div className="settingsRuntimeHostManagementActions">
+ <Button variant="secondary" label={copy.done} onClick={() => setOpen(false)} />
+ </div>
+ </LayoutFooter>
+ )}
+ />
+ </Dialog>
+ ) : null}
+ </>
+ );
+}
+
+function RuntimeHostResourceFacts(props: {
+ readonly profileId: string;
+ readonly copy: ReturnType<typeof hostResourceCopy>;
+}) {
+ const services = useRuntimeHostManagementServices().resources;
+ const [state, setState] = useState<HostResourceState>({ 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 (
+ <section className="settingsRuntimeHostResources">
+ {state.kind === 'loading' ? (
+ <div className="settingsRuntimeHostSetupProgress" role="status">
+ <Spinner size="sm" />
+ <Text type="supporting">{props.copy.measuring}</Text>
+ </div>
+ ) : (
+ <Text type="supporting" color="secondary">{props.copy.unavailable}</Text>
+ )}
+ </section>
+ );
+ }
+
+ 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 (
+ <section className="settingsRuntimeHostResources">
+ <dl className="settingsRuntimeHostManagementFacts">
+ <ResourceFact
+ label={props.copy.cpu}
+ value={cpu
+ ? `${cpu.capacity.model} · ${props.copy.logicalProcessors(
+ cpu.capacity.logicalProcessors,
+ cpu.capacity.availableParallelism,
+ )}`
+ : props.copy.unavailable}
+ wide
+ />
+ <ResourceFact
+ label={props.copy.cpuUsage}
+ value={!cpu
+ ? props.copy.unavailable
+ : utilization.cpuPercent === undefined
+ ? props.copy.measuring
+ : formatPercent(utilization.cpuPercent)}
+ />
+ <ResourceFact
+ label={props.copy.memory}
+ value={usedMemory === undefined || effectiveMemory === undefined
+ ? props.copy.unavailable
+ : `${formatBytes(usedMemory)} / ${formatBytes(effectiveMemory)} · ${formatPercent(
+ utilization.memoryPercent ?? 0,
+ )}`}
+ />
+ <ResourceFact label={props.copy.graphics} value={graphicsSummary} wide />
+ <ResourceFact label={props.copy.storage} value={storageSummary} wide />
+ <ResourceFact
+ label={props.copy.network}
+ value={
+ !network
+ ? props.copy.unavailable
+ : utilization.receivedBytesPerSecond === undefined ||
+ utilization.transmittedBytesPerSecond === undefined
+ ? props.copy.measuring
+ : props.copy.networkRate(
+ network.observation.interfaceName,
+ formatBytes(utilization.receivedBytesPerSecond),
+ formatBytes(utilization.transmittedBytesPerSecond),
+ )
+ }
+ />
+ </dl>
+ </section>
+ );
+}
+
+function ResourceFact(props: {
+ readonly label: string;
+ readonly value: string;
+ readonly wide?: boolean;
+}) {
+ return (
+ <div className={props.wide ? 'settingsRuntimeHostManagementFactWide' : undefined}>
+ <dt>{props.label}</dt>
+ <dd>{props.value}</dd>
+ </div>
+ );
+}
+
+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<UiLocale, unknown>;
+
+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: {
<Fact label={copy.stateRoot} value={service.stateRoot} wide />
) : null}
</dl>
+ {target ? (
+ <RuntimeHostResourceDialog profileId={target.id} hostName={target.name} />
+ ) : null}
{serviceInstalled && fullManagement && target?.directPeerManagement ? (
<section className="settingsRuntimeHostDirectPeer">
<div className="settingsRuntimeHostUpdatePolicyHeading">
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<string, un
};
}
-test('a renamed operation carries its stored authority to the successor', async () => {
+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<typeof CPU_RESOURCE_V1_SCHEMA>;
+export type HostMemoryResourceV1 = z.infer<typeof MEMORY_RESOURCE_V1_SCHEMA>;
+export type HostGraphicsResourceV1 = z.infer<typeof GRAPHICS_RESOURCE_V1_SCHEMA>;
+export type HostNetworkResourceV1 = z.infer<typeof NETWORK_RESOURCE_V1_SCHEMA>;
+export type HostStorageResourceV1 = z.infer<typeof STORAGE_RESOURCE_V1_SCHEMA>;
+
+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<string, never>;
+
+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<K extends HostResourceKind>(
+ 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<Record<HostResourceKind, z.ZodType>> = {
+ [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<T>(schema: z.ZodType<T>, 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<string, PersistedGrantMigration> =
'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<HostResourcesResult>;
+}
+
+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<HostResourcesResult> {
+ 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<HostResourceEnvelope>;
+ readonly cacheMilliseconds?: number;
+}): () => Promise<HostResourceEnvelope> {
+ let cached: { readonly expiresAt: number; readonly resource: HostResourceEnvelope } | undefined;
+ let inFlight: Promise<HostResourceEnvelope> | 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<HostResourceEnvelope> {
+ 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<HostResourceEnvelope> {
+ 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<HostResourceEnvelope> {
+ 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<void> {
+ 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<systemInformation.Systeminformation.GraphicsData>;
+ networkStats(): Promise<{
+ readonly interfaceName: string;
+ readonly stats: systemInformation.Systeminformation.NetworkStatsData[];
+ }>;
+ fsSize(): Promise<systemInformation.Systeminformation.FsSizeData[]>;
+}
+
+export function createIsolatedHostResourceSystemInformation(
+ timeoutMilliseconds: number,
+): HostResourceSystemInformation {
+ return {
+ graphics: () => invokeProbe('graphics', timeoutMilliseconds),
+ networkStats: () => invokeProbe('network', timeoutMilliseconds),
+ fsSize: () => invokeProbe('storage', timeoutMilliseconds),
+ };
+}
+
+function invokeProbe<T>(
+ kind: 'graphics' | 'network' | 'storage',
+ timeoutMilliseconds: number,
+): Promise<T> {
+ const child = spawn(process.execPath, [PROBE_ENTRYPOINT, kind], {
+ detached: process.platform !== 'win32',
+ stdio: ['ignore', 'pipe', 'ignore'],
+ windowsHide: true,
+ });
+ return new Promise<T>((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,
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org