From 302f9179e4683d74d46bc32ff49d0418f7daddea Mon Sep 17 00:00:00 2001 From: Shridhar Damale Date: Mon, 11 May 2026 19:25:35 +0530 Subject: [PATCH 1/7] feat(cli): add resource profiles to nemoclaw onboard and nemoclaw resources command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `nemoclaw resources [--json]` global command for hardware inventory and integrate resource profile selection into the onboard flow. Hardware inventory: - CPU cores, model, k8s node allocatable - RAM total, swap, k8s node allocatable - NVIDIA GPU name, count, VRAM - Available resource profiles from blueprint.yaml (with resolved values) Onboard integration: - Profile picker after messaging channels, before sandbox creation - Profiles use percentage values (e.g. "25%") resolved against detected hardware at runtime — same profile works on any machine - appendResourceFlags passes resolved --cpu-limit/--memory-limit to openshell sandbox create - Graceful degradation: silently skips if OpenShell lacks resource flags - Non-interactive: NEMOCLAW_RESOURCE_PROFILE env var selects profile, NEMOCLAW_CPU_LIMIT/NEMOCLAW_RAM_LIMIT override individual fields (supports both "25%" and absolute "4" / "8Gi" formats) Blueprint changes: - Added resource_profiles section with 4 profiles using percentages - Schema validates pattern: percentage or Kubernetes quantity Signed-off-by: Shridhar Damale Co-authored-by: Cursor --- nemoclaw-blueprint/blueprint.yaml | 21 ++ schemas/blueprint.schema.json | 15 ++ src/lib/cli/command-display.ts | 1 + src/lib/cli/command-registry.test.ts | 1 + src/lib/cli/command-registry.ts | 1 + src/lib/cli/oclif-dispatch.ts | 1 + src/lib/commands/resources.ts | 44 ++++ src/lib/onboard.ts | 89 +++++++ src/lib/resources-cmd.ts | 334 +++++++++++++++++++++++++++ 9 files changed, 507 insertions(+) create mode 100644 src/lib/commands/resources.ts create mode 100644 src/lib/resources-cmd.ts diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index 2ea05cd152..0276625f54 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -35,6 +35,27 @@ components: name: "openclaw" forward_ports: - 18789 + resource_profiles: + creator: + cpu_request: "25%" + cpu_limit: "50%" + memory_request: "25%" + memory_limit: "50%" + gamer: + cpu_request: "12%" + cpu_limit: "25%" + memory_request: "12%" + memory_limit: "25%" + game-developer: + cpu_request: "25%" + cpu_limit: "50%" + memory_request: "25%" + memory_limit: "50%" + developer: + cpu_request: "37%" + cpu_limit: "75%" + memory_request: "37%" + memory_limit: "75%" inference: profiles: diff --git a/schemas/blueprint.schema.json b/schemas/blueprint.schema.json index ad4f498b89..3f10b6607b 100644 --- a/schemas/blueprint.schema.json +++ b/schemas/blueprint.schema.json @@ -62,6 +62,21 @@ "forward_ports": { "type": "array", "items": { "type": "integer", "minimum": 1, "maximum": 65535 } + }, + "resource_profiles": { + "type": "object", + "description": "Named resource profiles for sandbox CPU/memory allocation. Values can be absolute Kubernetes quantities (e.g. '4', '8Gi') or percentages of detected hardware (e.g. '25%').", + "additionalProperties": { + "type": "object", + "required": ["cpu_request", "cpu_limit", "memory_request", "memory_limit"], + "properties": { + "cpu_request": { "type": "string", "minLength": 1, "pattern": "^([1-9]\\d?%|100%|\\d+(\\.\\d+)?[mKMGTPE]?i?)$" }, + "cpu_limit": { "type": "string", "minLength": 1, "pattern": "^([1-9]\\d?%|100%|\\d+(\\.\\d+)?[mKMGTPE]?i?)$" }, + "memory_request": { "type": "string", "minLength": 1, "pattern": "^([1-9]\\d?%|100%|\\d+(\\.\\d+)?[mKMGTPE]?i?)$" }, + "memory_limit": { "type": "string", "minLength": 1, "pattern": "^([1-9]\\d?%|100%|\\d+(\\.\\d+)?[mKMGTPE]?i?)$" } + }, + "additionalProperties": false + } } } }, diff --git a/src/lib/cli/command-display.ts b/src/lib/cli/command-display.ts index be378ee7f5..6ac44cc830 100644 --- a/src/lib/cli/command-display.ts +++ b/src/lib/cli/command-display.ts @@ -13,6 +13,7 @@ export type CommandGroup = | "Credentials" | "Backup" | "Upgrade" + | "Resources" | "Cleanup"; export interface CommandDisplayEntry { diff --git a/src/lib/cli/command-registry.test.ts b/src/lib/cli/command-registry.test.ts index 9813d7f521..39dc89b482 100644 --- a/src/lib/cli/command-registry.test.ts +++ b/src/lib/cli/command-registry.test.ts @@ -248,6 +248,7 @@ describe("command-registry", () => { "Credentials", "Backup", "Upgrade", + "Resources", "Cleanup", ]); }); diff --git a/src/lib/cli/command-registry.ts b/src/lib/cli/command-registry.ts index bd2af9772a..b7ec831f8b 100644 --- a/src/lib/cli/command-registry.ts +++ b/src/lib/cli/command-registry.ts @@ -44,6 +44,7 @@ export const GROUP_ORDER: readonly CommandGroup[] = [ "Credentials", "Backup", "Upgrade", + "Resources", "Cleanup", ] as const; diff --git a/src/lib/cli/oclif-dispatch.ts b/src/lib/cli/oclif-dispatch.ts index 42bc3e8d88..9679d5ca92 100644 --- a/src/lib/cli/oclif-dispatch.ts +++ b/src/lib/cli/oclif-dispatch.ts @@ -105,6 +105,7 @@ const GLOBAL_ROUTES: Readonly> = { "backup-all": "backup-all", "upgrade-sandboxes": "upgrade-sandboxes", gc: "gc", + resources: "resources", }; export function resolveGlobalOclifDispatch(cmd: string, args: string[]): DispatchResult { diff --git a/src/lib/commands/resources.ts b/src/lib/commands/resources.ts new file mode 100644 index 0000000000..b82104bc27 --- /dev/null +++ b/src/lib/commands/resources.ts @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { CommandDisplayEntry } from "../cli/command-display"; + +import { printHardwareResources } from "../resources-cmd"; +import { NemoClawCommand } from "../cli/nemoclaw-oclif-command"; + +export default class ResourcesCommand extends NemoClawCommand { + static id = "resources"; + static strict = true; + static enableJsonFlag = true; + static summary = "Show hardware inventory (CPU cores, RAM, GPU VRAM)"; + static description = + "Display available hardware resources including CPU core count and model, " + + "total system RAM and swap, Kubernetes node allocatable capacity (when a " + + "gateway is running), and NVIDIA GPU name and VRAM. Supports --json for " + + "machine-readable output."; + static usage = ["resources [--json]"]; + static examples = [ + "<%= config.bin %> resources", + "<%= config.bin %> resources --json", + ]; + static flags = {}; + + static display: readonly CommandDisplayEntry[] = [ + { + usage: "nemoclaw resources", + description: "Show hardware inventory (CPU cores, RAM, GPU VRAM)", + flags: "(--json)", + group: "Resources", + scope: "global", + order: 900, + }, + ]; + + public async run(): Promise { + await this.parse(ResourcesCommand); + const result = printHardwareResources(this.jsonEnabled()); + if (this.jsonEnabled()) { + return result; + } + } +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 2985e19daf..1b3ebf505f 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -308,6 +308,7 @@ const providerModels: typeof import("./inference/provider-models") = require("./ const sandboxCreateStream: typeof import("./sandbox-create-stream") = require("./sandbox-create-stream"); const validationRecovery: typeof import("./validation-recovery") = require("./validation-recovery"); const webSearch: typeof import("./inference/web-search") = require("./inference/web-search"); +const { loadResourceProfiles, appendResourceFlags } = require("./resources-cmd"); import type { AgentDefinition } from "./agent/defs"; import type { CurlProbeResult } from "./http-probe"; @@ -4783,6 +4784,7 @@ async function createSandbox( agent: AgentDefinition | null = null, controlUiPort: number | null = null, gpuPassthrough: boolean = false, + resourceProfile: { cpu_request: string; cpu_limit: string; memory_request: string; memory_limit: string } | null = null, ) { step(6, 8, "Creating sandbox"); @@ -5392,6 +5394,14 @@ async function createSandbox( createArgs.push("--gpu"); } + // Append CPU/memory resource flags from selected profile (graceful degradation). + if (resourceProfile) { + const applied = appendResourceFlags(createArgs, resourceProfile, getOpenshellBinary()); + if (!applied) { + note(" OpenShell does not support resource flags — sandbox will use default limits."); + } + } + // Create OpenShell providers for messaging credentials so they flow through // the provider/placeholder system instead of raw env vars. The L7 proxy // rewrites Authorization headers (Bearer/Bot) and URL-path segments @@ -10204,6 +10214,84 @@ async function onboard(opts: OnboardOptions = {}): Promise { console.error(" Inference selection is incomplete; cannot create sandbox."); process.exit(1); } + + // ── Resource profile selection ───────────────────────────── + const availableProfiles = loadResourceProfiles(); + const profileNames = Object.keys(availableProfiles); + let selectedProfile: { cpu_request: string; cpu_limit: string; memory_request: string; memory_limit: string } | null = null; + + const hasResourceEnvOverrides = !!( + process.env.NEMOCLAW_CPU_REQUEST || process.env.NEMOCLAW_CPU_LIMIT || + process.env.NEMOCLAW_RAM_REQUEST || process.env.NEMOCLAW_RAM_LIMIT + ); + if (process.env.NEMOCLAW_RESOURCE_PROFILE) { + const envProfile = process.env.NEMOCLAW_RESOURCE_PROFILE; + if (profileNames.length > 0 && availableProfiles[envProfile]) { + selectedProfile = { ...availableProfiles[envProfile] }; + note(` Resource profile (env): ${envProfile}`); + } else { + console.error(` Unknown resource profile: '${envProfile}'`); + console.error(` Valid profiles: ${profileNames.join(", ")}`); + process.exit(1); + } + } else if (profileNames.length > 0 && !isNonInteractive() && !hasResourceEnvOverrides) { + const { getHardwareResources, resolveResourceValue } = require("./resources-cmd"); + const hw = getHardwareResources(); + console.log(""); + console.log(" Resource profiles:"); + profileNames.forEach((name: string, i: number) => { + const p = availableProfiles[name]; + console.log(` ${i + 1}) ${name} (cpu=${p.cpu_request}/${p.cpu_limit}, ram=${p.memory_request}/${p.memory_limit})`); + }); + console.log(` ${profileNames.length + 1}) custom (enter values manually)`); + console.log(` ${profileNames.length + 2}) No profile (default resources)`); + const choice = await promptOrDefault( + ` Choose [${profileNames.length + 2}]: `, + null, + String(profileNames.length + 2), + ); + const idx = parseInt(choice.trim(), 10) - 1; + if (idx >= 0 && idx < profileNames.length) { + selectedProfile = availableProfiles[profileNames[idx]]; + console.log(` Using profile: ${profileNames[idx]}`); + } else if (idx === profileNames.length) { + console.log(""); + console.log(` Available: ${hw.cpu.cores} CPU cores, ${hw.memory.totalMB} MB RAM`); + console.log(" Enter values as percentages (e.g. 25%) or absolutes (e.g. 4, 8Gi)"); + console.log(""); + const cpuReq = (await prompt(` CPU min (request) [25%]: `)).trim() || "25%"; + const cpuLim = (await prompt(` CPU max (limit) [50%]: `)).trim() || "50%"; + const ramReq = (await prompt(` RAM min (request) [25%]: `)).trim() || "25%"; + const ramLim = (await prompt(` RAM max (limit) [50%]: `)).trim() || "50%"; + selectedProfile = { + cpu_request: cpuReq, + cpu_limit: cpuLim, + memory_request: ramReq, + memory_limit: ramLim, + }; + try { + const cpuTotal = hw.cpu.cores; + const memTotal = hw.memory.totalMB; + const resolvedCpu = resolveResourceValue(cpuLim, cpuTotal, "cpu"); + const resolvedRam = resolveResourceValue(ramLim, memTotal, "memory"); + console.log(` Resolved: CPU limit=${resolvedCpu} cores, RAM limit=${resolvedRam}`); + } catch (e: unknown) { + console.error(` ${(e as Error).message}`); + process.exit(1); + } + } + } + + if (process.env.NEMOCLAW_CPU_REQUEST || process.env.NEMOCLAW_CPU_LIMIT || + process.env.NEMOCLAW_RAM_REQUEST || process.env.NEMOCLAW_RAM_LIMIT) { + selectedProfile = selectedProfile || { cpu_request: "", cpu_limit: "", memory_request: "", memory_limit: "" }; + if (process.env.NEMOCLAW_CPU_REQUEST) selectedProfile.cpu_request = process.env.NEMOCLAW_CPU_REQUEST; + if (process.env.NEMOCLAW_CPU_LIMIT) selectedProfile.cpu_limit = process.env.NEMOCLAW_CPU_LIMIT; + if (process.env.NEMOCLAW_RAM_REQUEST) selectedProfile.memory_request = process.env.NEMOCLAW_RAM_REQUEST; + if (process.env.NEMOCLAW_RAM_LIMIT) selectedProfile.memory_limit = process.env.NEMOCLAW_RAM_LIMIT; + note(` Resource overrides (env): cpu=${selectedProfile.cpu_request}/${selectedProfile.cpu_limit}, ram=${selectedProfile.memory_request}/${selectedProfile.memory_limit}`); + } + sandboxName = await createSandbox( gpu, model, @@ -10216,6 +10304,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { agent, opts.controlUiPort || null, gpuPassthrough, + selectedProfile, ); webSearchConfig = nextWebSearchConfig; // Persist model and provider after the sandbox entry exists in the registry. diff --git a/src/lib/resources-cmd.ts b/src/lib/resources-cmd.ts new file mode 100644 index 0000000000..c1169d2297 --- /dev/null +++ b/src/lib/resources-cmd.ts @@ -0,0 +1,334 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Hardware resource discovery for NemoClaw. + * + * Provides `nemoclaw resources` — a read-only inventory of CPU, RAM, GPU, + * and Kubernetes allocatable capacity. Used by the NemoClaw Installer to + * auto-select profiles and models based on available hardware. + */ + +import * as os from "os"; +import * as fs from "fs"; +import * as path from "path"; +import { spawnSync, execSync } from "child_process"; +import * as YAML from "yaml"; + +const GATEWAY_NAME = "nemoclaw"; + +function getGatewayContainer(): string { + return process.env.NEMOCLAW_GATEWAY_CONTAINER || `openshell-cluster-${GATEWAY_NAME}`; +} + +// ── Types ──────────────────────────────────────────────────────── + +export interface ResourceProfile { + cpu_request: string; + cpu_limit: string; + memory_request: string; + memory_limit: string; +} + +export interface HardwareResources { + cpu: { cores: number; model: string; allocatable?: string }; + memory: { totalMB: number; swapMB: number; allocatableMB?: number }; + gpu: { type: string; name: string; count: number; vramMB: number } | null; + profiles: Record | null; +} + +// ── Implementation ─────────────────────────────────────────────── + +/** + * Query system hardware resources. Returns CPU, memory, and GPU info. + * Also attempts to read Kubernetes node allocatable capacity from the + * gateway's k3s cluster (returns undefined fields if gateway is not running). + */ +export function getHardwareResources(): HardwareResources { + const cpus = os.cpus(); + const cpuModel = cpus[0]?.model?.trim() || "unknown"; + + let totalMB = 0; + let swapMB = 0; + try { + const meminfo = execSync("cat /proc/meminfo", { + encoding: "utf-8", + timeout: 3000, + stdio: ["ignore", "pipe", "ignore"], + }); + const totalMatch = meminfo.match(/MemTotal:\s+(\d+)/); + const swapMatch = meminfo.match(/SwapTotal:\s+(\d+)/); + if (totalMatch) totalMB = Math.round(parseInt(totalMatch[1], 10) / 1024); + if (swapMatch) swapMB = Math.round(parseInt(swapMatch[1], 10) / 1024); + } catch { + // Non-Linux or /proc unreadable — fall back to os.totalmem() + totalMB = Math.round(os.totalmem() / 1024 / 1024); + } + + // Kubernetes allocatable (best-effort — only works if gateway is running) + let allocatableCpu: string | undefined; + let allocatableMemMB: number | undefined; + try { + const container = getGatewayContainer(); + const result = spawnSync("docker", [ + "exec", container, "kubectl", "get", "nodes", "-o", "json", + ], { encoding: "utf-8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] }); + if (result.status === 0 && result.stdout) { + const nodes = JSON.parse(result.stdout); + const alloc = nodes.items?.[0]?.status?.allocatable; + if (alloc) { + allocatableCpu = alloc.cpu; + const memStr: string = alloc.memory || ""; + const kiMatch = memStr.match(/^(\d+)Ki$/); + if (kiMatch) allocatableMemMB = Math.round(parseInt(kiMatch[1], 10) / 1024); + } + } + } catch { + // Gateway not running — skip k8s allocatable + } + + // GPU detection via nvidia-smi + let gpu: HardwareResources["gpu"] = null; + try { + const nvOut = execSync( + "nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits", + { encoding: "utf-8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }, + ).trim(); + if (nvOut) { + const lines = nvOut.split("\n").filter(Boolean); + const [name, vramStr] = lines[0].split(",").map((s: string) => s.trim()); + gpu = { + type: "nvidia", + name: name || "unknown", + count: lines.length, + vramMB: parseInt(vramStr, 10) || 0, + }; + } + } catch { + // nvidia-smi not available + } + + // Resource profiles from blueprint.yaml (CPU/RAM only) + let profiles: Record | null = null; + try { + const blueprintPath = path.join(__dirname, "..", "..", "nemoclaw-blueprint", "blueprint.yaml"); + if (fs.existsSync(blueprintPath)) { + const content = fs.readFileSync(blueprintPath, "utf-8"); + const blueprint = YAML.parse(content); + const raw = blueprint?.components?.sandbox?.resource_profiles; + if (raw && typeof raw === "object") { + profiles = {}; + for (const [name, p] of Object.entries(raw)) { + const prof = p as Record; + profiles[name] = { + cpu_request: String(prof.cpu_request || ""), + cpu_limit: String(prof.cpu_limit || ""), + memory_request: String(prof.memory_request || ""), + memory_limit: String(prof.memory_limit || ""), + }; + } + } + } + } catch { + // blueprint.yaml missing or unparseable — skip profiles + } + + return { + cpu: { cores: cpus.length, model: cpuModel, allocatable: allocatableCpu }, + memory: { totalMB, swapMB, allocatableMB: allocatableMemMB }, + gpu, + profiles, + }; +} + +/** + * Print hardware resources. JSON mode writes to stdout for machine parsing. + * Human mode writes a formatted table to stdout via console.log. + */ +export function printHardwareResources(json: boolean): void { + const hw = getHardwareResources(); + if (json) { + process.stdout.write(JSON.stringify(hw) + "\n"); + return; + } + console.log(""); + console.log(" Hardware Resources"); + console.log(" " + "\u2500".repeat(44)); + console.log(` CPU: ${hw.cpu.cores} cores (${hw.cpu.model})`); + if (hw.cpu.allocatable) { + console.log(` k8s allocatable: ${hw.cpu.allocatable}`); + } + console.log(` RAM: ${hw.memory.totalMB} MB + ${hw.memory.swapMB} MB swap`); + if (hw.memory.allocatableMB) { + console.log(` k8s allocatable: ${hw.memory.allocatableMB} MB`); + } + if (hw.gpu) { + console.log(` GPU: ${hw.gpu.name}`); + console.log(` VRAM: ${hw.gpu.vramMB} MB (${hw.gpu.count} device${hw.gpu.count > 1 ? "s" : ""})`); + } else { + console.log(" GPU: not detected"); + } + if (hw.profiles && Object.keys(hw.profiles).length > 0) { + console.log(""); + console.log(" Resource Profiles:"); + for (const [name, p] of Object.entries(hw.profiles)) { + const resolved = resolveProfile(p, hw); + const cpuStr = p.cpu_limit.endsWith("%") + ? `${p.cpu_limit} \u2192 ${resolved.cpu_limit} cores` + : `${p.cpu_limit} cores`; + const ramStr = p.memory_limit.endsWith("%") + ? `${p.memory_limit} \u2192 ${resolved.memory_limit}` + : p.memory_limit; + console.log(` ${name}: cpu=${cpuStr}, ram=${ramStr}`); + } + } + console.log(" " + "\u2500".repeat(44)); + console.log(""); +} + +/** + * Resolve a resource value that may be a percentage (e.g. "25%") or an + * absolute Kubernetes quantity (e.g. "4", "8Gi"). Percentages are resolved + * against the provided total. + * + * @param value - raw value from profile or env var (e.g. "25%" or "4") + * @param total - total available (cores for CPU, MB for memory) + * @param unit - "cpu" (returns integer string) or "memory" (returns "XGi") + */ +/** + * Resolve a resource value that may be a percentage or absolute quantity. + * Throws on invalid percentages so callers can surface clear errors. + */ +export function resolveResourceValue( + value: string, + total: number, + unit: "cpu" | "memory", +): string { + if (!value) return ""; + const trimmed = value.trim(); + if (trimmed.endsWith("%")) { + // Strict validation: only accept integers 1-100 followed by % + if (!/^(?:[1-9]\d?|100)%$/.test(trimmed)) { + throw new Error(`Invalid percentage '${trimmed}': must be an integer between 1% and 100%`); + } + const pct = parseInt(trimmed.slice(0, -1), 10); + if (unit === "cpu") { + return String(Math.max(1, Math.floor(total * pct / 100))); + } + // Memory: use Mi for precision on smaller machines, Gi for larger + const resultMB = Math.floor(total * pct / 100); + if (resultMB < 4096) { + return `${Math.max(128, resultMB)}Mi`; + } + const resultGi = Math.max(1, Math.floor(resultMB / 1024)); + return `${resultGi}Gi`; + } + // Absolute value — pass through as-is + return trimmed; +} + +/** + * Resolve all percentage values in a profile to absolute Kubernetes quantities. + * Returns a new profile with resolved values. + */ +/** + * Parse a Kubernetes CPU quantity to whole cores. + * Handles plain integers ("16") and millicores ("7500m" → 7.5 → 7). + */ +function parseCpuQuantity(value: string): number | null { + const trimmed = value.trim(); + if (trimmed.endsWith("m")) { + const millis = parseInt(trimmed.slice(0, -1), 10); + if (isNaN(millis)) return null; + return Math.floor(millis / 1000); + } + const cores = parseInt(trimmed, 10); + return isNaN(cores) ? null : cores; +} + +/** + * Resolve profile percentages to absolutes. Prefers k8s allocatable capacity + * when available (accounts for kubelet/system reservations); falls back to + * host totals when gateway is not running. + */ +export function resolveProfile(profile: ResourceProfile, hw: HardwareResources): ResourceProfile { + const cpuTotal = hw.cpu.allocatable ? (parseCpuQuantity(hw.cpu.allocatable) ?? hw.cpu.cores) : hw.cpu.cores; + const memTotalMB = hw.memory.allocatableMB ?? hw.memory.totalMB; + return { + cpu_request: resolveResourceValue(profile.cpu_request, cpuTotal, "cpu"), + cpu_limit: resolveResourceValue(profile.cpu_limit, cpuTotal, "cpu"), + memory_request: resolveResourceValue(profile.memory_request, memTotalMB, "memory"), + memory_limit: resolveResourceValue(profile.memory_limit, memTotalMB, "memory"), + }; +} + +/** + * Append resource flags to an openshell sandbox create args array. + * Resolves percentage values against detected hardware before passing. + * Gracefully degrades: checks `openshell sandbox create --help` for flag + * support and skips silently if the installed OpenShell doesn't have them. + */ +export function appendResourceFlags( + args: string[], + profile: ResourceProfile, + openshellBinary = "openshell", +): boolean { + try { + const result = spawnSync(openshellBinary, ["sandbox", "create", "--help"], { + encoding: "utf-8", + timeout: 5000, + stdio: ["ignore", "pipe", "ignore"], + }); + if (result.status !== 0 || !result.stdout?.includes("--cpu-request")) { + return false; + } + } catch { + return false; + } + // Resolve percentages to absolute values (throws on invalid %) + const hw = getHardwareResources(); + const resolved = resolveProfile(profile, hw); + if (resolved.cpu_request) args.push("--cpu-request", resolved.cpu_request); + if (resolved.cpu_limit) args.push("--cpu-limit", resolved.cpu_limit); + if (resolved.memory_request) args.push("--memory-request", resolved.memory_request); + if (resolved.memory_limit) args.push("--memory-limit", resolved.memory_limit); + return true; +} + +/** + * Load resource profiles from blueprint.yaml. Returns empty object if + * the file doesn't exist or has no profiles section. + */ +export function loadResourceProfiles(): Record { + try { + const blueprintPath = path.join(__dirname, "..", "..", "nemoclaw-blueprint", "blueprint.yaml"); + if (!fs.existsSync(blueprintPath)) return {}; + const content = fs.readFileSync(blueprintPath, "utf-8"); + const blueprint = YAML.parse(content); + const raw = blueprint?.components?.sandbox?.resource_profiles; + if (!raw || typeof raw !== "object") return {}; + const profiles: Record = {}; + for (const [name, p] of Object.entries(raw)) { + const prof = p as Record; + if (prof.cpu_request && prof.cpu_limit && prof.memory_request && prof.memory_limit) { + profiles[name] = { + cpu_request: String(prof.cpu_request), + cpu_limit: String(prof.cpu_limit), + memory_request: String(prof.memory_request), + memory_limit: String(prof.memory_limit), + }; + } + } + return profiles; + } catch { + return {}; + } +} + +/** + * Dispatcher for the `nemoclaw resources` command. + */ +export function runResourcesCommand(argv: string[]): void { + const json = argv.includes("--json"); + printHardwareResources(json); +} From 61d610f2d366b2d951db1ee1bfdc04202173cf86 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 13 May 2026 18:49:37 -0700 Subject: [PATCH 2/7] fix(cli): address resource profile review feedback --- nemoclaw-blueprint/blueprint.yaml | 8 +- src/lib/onboard.ts | 144 ++---------------- src/lib/onboard/build-estimate.ts | 29 ++++ src/lib/onboard/resource-profile-selection.ts | 138 +++++++++++++++++ src/lib/resources-cmd.ts | 104 ++++++------- 5 files changed, 225 insertions(+), 198 deletions(-) create mode 100644 src/lib/onboard/build-estimate.ts create mode 100644 src/lib/onboard/resource-profile-selection.ts diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index 14c7e64966..7b557cedf4 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -47,10 +47,10 @@ components: memory_request: "12%" memory_limit: "25%" game-developer: - cpu_request: "25%" - cpu_limit: "50%" - memory_request: "25%" - memory_limit: "50%" + cpu_request: "30%" + cpu_limit: "60%" + memory_request: "30%" + memory_limit: "60%" developer: cpu_request: "37%" cpu_limit: "75%" diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 9681edb6c7..962631d7cc 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -41,6 +41,8 @@ const dockerGpuSandboxCreate: typeof import("./onboard/docker-gpu-sandbox-create const dockerDriverGatewayLaunch: typeof import("./onboard/docker-driver-gateway-launch") = require("./onboard/docker-driver-gateway-launch"); const { findReadableNvidiaCdiSpecFiles, getDockerCdiSpecDirs, parseDockerCdiSpecDirs }: typeof import("./onboard/docker-cdi") = require("./onboard/docker-cdi"); const { buildSandboxGpuCreateArgs, getSandboxReadyTimeoutSecs }: typeof import("./onboard/sandbox-gpu-create") = require("./onboard/sandbox-gpu-create"); +const { formatSandboxBuildEstimateNote }: typeof import("./onboard/build-estimate") = require("./onboard/build-estimate"); +const { selectResourceProfileForSandbox }: typeof import("./onboard/resource-profile-selection") = require("./onboard/resource-profile-selection"); const { isValidProxyHost, isValidProxyPort, @@ -310,8 +312,7 @@ const providerModels: typeof import("./inference/provider-models") = require("./ const sandboxCreateStream: typeof import("./sandbox/create-stream") = require("./sandbox/create-stream"); const validationRecovery: typeof import("./validation-recovery") = require("./validation-recovery"); const webSearch: typeof import("./inference/web-search") = require("./inference/web-search"); -const resourcesCmd: typeof import("./resources-cmd") = require("./resources-cmd"); -const { appendResourceFlags, getHardwareResources, loadResourceProfiles, resolveResourceValue } = resourcesCmd; +const { appendResourceFlags }: typeof import("./resources-cmd") = require("./resources-cmd"); const openshellInstallFlow: typeof import("./onboard/openshell-install") = require("./onboard/openshell-install"); const openshellPinFlow: typeof import("./onboard/openshell-pin") = @@ -323,7 +324,6 @@ import type { AgentDefinition } from "./agent/defs"; import type { CurlProbeResult } from "./adapters/http/probe"; import type { GatewayReuseState } from "./state/gateway"; import type { GatewayInference } from "./inference/config"; -import type { ResourceProfile } from "./resources-cmd"; import type { GpuInfo, ValidationResult } from "./inference/local"; import { hydrateMessagingChannelConfig, @@ -4942,25 +4942,6 @@ type OnboardConfigSummary = { * rendered as "Note: " so it stays visually * distinct. */ -function formatSandboxBuildEstimateNote(host: ReturnType): string | null { - if (host.isContainerRuntimeUnderProvisioned) { - return ( - "Container runtime is under-provisioned; the sandbox build may take 30+ minutes " + - "or stall. See preflight warning above." - ); - } - const cpus = host.dockerCpus; - const memBytes = host.dockerMemTotalBytes; - if (typeof cpus === "number" && typeof memBytes === "number") { - const memGiB = memBytes / 1024 ** 3; - if (cpus >= 8 && memGiB >= 16) { - return "Sandbox build typically takes 3–8 minutes on this host."; - } - return "Sandbox build typically takes 5–15 minutes on this host."; - } - return null; -} - function formatOnboardConfigSummary({ provider, model, @@ -5011,116 +4992,6 @@ function formatOnboardConfigSummary({ ].join("\n"); } -function hasResourceEnvOverrides(): boolean { - return !!( - process.env.NEMOCLAW_CPU_REQUEST || - process.env.NEMOCLAW_CPU_LIMIT || - process.env.NEMOCLAW_RAM_REQUEST || - process.env.NEMOCLAW_RAM_LIMIT - ); -} - -function applyResourceEnvOverrides(selectedProfile: ResourceProfile | null): ResourceProfile | null { - if (!hasResourceEnvOverrides()) return selectedProfile; - const nextProfile = selectedProfile || { - cpu_request: "", - cpu_limit: "", - memory_request: "", - memory_limit: "", - }; - if (process.env.NEMOCLAW_CPU_REQUEST) nextProfile.cpu_request = process.env.NEMOCLAW_CPU_REQUEST; - if (process.env.NEMOCLAW_CPU_LIMIT) nextProfile.cpu_limit = process.env.NEMOCLAW_CPU_LIMIT; - if (process.env.NEMOCLAW_RAM_REQUEST) nextProfile.memory_request = process.env.NEMOCLAW_RAM_REQUEST; - if (process.env.NEMOCLAW_RAM_LIMIT) nextProfile.memory_limit = process.env.NEMOCLAW_RAM_LIMIT; - note( - ` Resource overrides (env): cpu=${nextProfile.cpu_request}/${nextProfile.cpu_limit}, ram=${nextProfile.memory_request}/${nextProfile.memory_limit}`, - ); - return nextProfile; -} - -function exitWithResourceProfileError(message: string): never { - console.error(` ${message}`); - process.exit(1); -} - -function printResolvedResourceProfile(profile: ResourceProfile, cpuTotal: number, memTotal: number): void { - const resolvedCpuRequest = resolveResourceValue(profile.cpu_request, cpuTotal, "cpu"); - const resolvedCpuLimit = resolveResourceValue(profile.cpu_limit, cpuTotal, "cpu"); - const resolvedMemoryRequest = resolveResourceValue(profile.memory_request, memTotal, "memory"); - const resolvedMemoryLimit = resolveResourceValue(profile.memory_limit, memTotal, "memory"); - console.log( - ` Resolved: CPU request=${resolvedCpuRequest} cores, CPU limit=${resolvedCpuLimit} cores, RAM request=${resolvedMemoryRequest}, RAM limit=${resolvedMemoryLimit}`, - ); -} - -async function selectResourceProfileForSandbox(): Promise { - const availableProfiles = loadResourceProfiles(); - const profileNames = Object.keys(availableProfiles); - let selectedProfile: ResourceProfile | null = null; - - if (process.env.NEMOCLAW_RESOURCE_PROFILE) { - const envProfile = process.env.NEMOCLAW_RESOURCE_PROFILE; - if (profileNames.length > 0 && availableProfiles[envProfile]) { - selectedProfile = { ...availableProfiles[envProfile] }; - note(` Resource profile (env): ${envProfile}`); - } else { - console.error(` Unknown resource profile: '${envProfile}'`); - console.error(` Valid profiles: ${profileNames.join(", ")}`); - process.exit(1); - } - } else if (profileNames.length > 0 && !isNonInteractive() && !hasResourceEnvOverrides()) { - const hw = getHardwareResources(); - console.log(""); - console.log(" Resource profiles:"); - profileNames.forEach((name: string, i: number) => { - const p = availableProfiles[name]; - console.log( - ` ${i + 1}) ${name} (cpu=${p.cpu_request}/${p.cpu_limit}, ram=${p.memory_request}/${p.memory_limit})`, - ); - }); - console.log(` ${profileNames.length + 1}) custom (enter values manually)`); - console.log(` ${profileNames.length + 2}) No profile (default resources)`); - const choice = await promptOrDefault( - ` Choose [${profileNames.length + 2}]: `, - null, - String(profileNames.length + 2), - ); - const trimmedChoice = choice.trim(); - const idx = Number.parseInt(trimmedChoice, 10) - 1; - if (!/^\d+$/.test(trimmedChoice) || idx < 0 || idx > profileNames.length + 1) { - exitWithResourceProfileError( - `Invalid resource profile selection '${choice}'. Choose a number from 1 to ${profileNames.length + 2}.`, - ); - } - if (idx >= 0 && idx < profileNames.length) { - selectedProfile = availableProfiles[profileNames[idx]]; - console.log(` Using profile: ${profileNames[idx]}`); - } else if (idx === profileNames.length) { - console.log(""); - console.log(` Available: ${hw.cpu.cores} CPU cores, ${hw.memory.totalMB} MB RAM`); - console.log(" Enter values as percentages (e.g. 25%) or absolutes (e.g. 4, 8Gi)"); - console.log(""); - const cpuReq = (await prompt(` CPU min (request) [25%]: `)).trim() || "25%"; - const cpuLim = (await prompt(` CPU max (limit) [50%]: `)).trim() || "50%"; - const ramReq = (await prompt(` RAM min (request) [25%]: `)).trim() || "25%"; - const ramLim = (await prompt(` RAM max (limit) [50%]: `)).trim() || "50%"; - selectedProfile = { - cpu_request: cpuReq, - cpu_limit: cpuLim, - memory_request: ramReq, - memory_limit: ramLim, - }; - try { - printResolvedResourceProfile(selectedProfile, hw.cpu.cores, hw.memory.totalMB); - } catch (e: unknown) { - exitWithResourceProfileError((e as Error).message); - } - } - } - - return applyResourceEnvOverrides(selectedProfile); -} - async function createSandbox( gpu: ReturnType, model: string, @@ -5133,7 +5004,7 @@ async function createSandbox( agent: AgentDefinition | null = null, controlUiPort: number | null = null, sandboxGpuConfig: SandboxGpuConfig | null = null, - resourceProfile: ResourceProfile | null = null, + resourceProfile: import("./resources-cmd").ResourceProfile | null = null, ) { step(6, 8, "Creating sandbox"); @@ -10850,7 +10721,12 @@ async function onboard(opts: OnboardOptions = {}): Promise { console.error(" Inference selection is incomplete; cannot create sandbox."); process.exit(1); } - const selectedProfile = await selectResourceProfileForSandbox(); + const selectedProfile = await selectResourceProfileForSandbox({ + isNonInteractive, + note, + prompt, + promptOrDefault, + }); if (fresh) { stopStaleDashboardListenersForSandbox(registry.listSandboxes().sandboxes, sandboxName); } diff --git a/src/lib/onboard/build-estimate.ts b/src/lib/onboard/build-estimate.ts new file mode 100644 index 0000000000..475e8b4b81 --- /dev/null +++ b/src/lib/onboard/build-estimate.ts @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type SandboxBuildEstimateHost = { + isContainerRuntimeUnderProvisioned?: boolean; + dockerCpus?: number | null; + dockerMemTotalBytes?: number | null; +}; + +export function formatSandboxBuildEstimateNote( + host: SandboxBuildEstimateHost, +): string | null { + if (host.isContainerRuntimeUnderProvisioned) { + return ( + "Container runtime is under-provisioned; the sandbox build may take 30+ minutes " + + "or stall. See preflight warning above." + ); + } + const cpus = host.dockerCpus; + const memBytes = host.dockerMemTotalBytes; + if (typeof cpus === "number" && typeof memBytes === "number") { + const memGiB = memBytes / 1024 ** 3; + if (cpus >= 8 && memGiB >= 16) { + return "Sandbox build typically takes 3–8 minutes on this host."; + } + return "Sandbox build typically takes 5–15 minutes on this host."; + } + return null; +} diff --git a/src/lib/onboard/resource-profile-selection.ts b/src/lib/onboard/resource-profile-selection.ts new file mode 100644 index 0000000000..0e413bfcba --- /dev/null +++ b/src/lib/onboard/resource-profile-selection.ts @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + getHardwareResources, + loadResourceProfiles, + resolveResourceValue, + type ResourceProfile, +} from "../resources-cmd"; + +export type ResourceProfileSelectionDeps = { + isNonInteractive: () => boolean; + note: (message: string) => void; + prompt: (question: string) => Promise; + promptOrDefault: ( + question: string, + envVar: string | null, + defaultValue: string, + ) => Promise; + env?: NodeJS.ProcessEnv; +}; + +function hasResourceEnvOverrides(env: NodeJS.ProcessEnv): boolean { + return !!( + env.NEMOCLAW_CPU_REQUEST || + env.NEMOCLAW_CPU_LIMIT || + env.NEMOCLAW_RAM_REQUEST || + env.NEMOCLAW_RAM_LIMIT + ); +} + +function applyResourceEnvOverrides( + selectedProfile: ResourceProfile | null, + deps: ResourceProfileSelectionDeps, +): ResourceProfile | null { + const env = deps.env ?? process.env; + if (!hasResourceEnvOverrides(env)) return selectedProfile; + const nextProfile = selectedProfile || { + cpu_request: "", + cpu_limit: "", + memory_request: "", + memory_limit: "", + }; + if (env.NEMOCLAW_CPU_REQUEST) nextProfile.cpu_request = env.NEMOCLAW_CPU_REQUEST; + if (env.NEMOCLAW_CPU_LIMIT) nextProfile.cpu_limit = env.NEMOCLAW_CPU_LIMIT; + if (env.NEMOCLAW_RAM_REQUEST) nextProfile.memory_request = env.NEMOCLAW_RAM_REQUEST; + if (env.NEMOCLAW_RAM_LIMIT) nextProfile.memory_limit = env.NEMOCLAW_RAM_LIMIT; + deps.note( + ` Resource overrides (env): cpu=${nextProfile.cpu_request}/${nextProfile.cpu_limit}, ram=${nextProfile.memory_request}/${nextProfile.memory_limit}`, + ); + return nextProfile; +} + +function exitWithResourceProfileError(message: string): never { + console.error(` ${message}`); + process.exit(1); +} + +function printResolvedResourceProfile(profile: ResourceProfile, cpuTotal: number, memTotal: number): void { + const resolvedCpuRequest = resolveResourceValue(profile.cpu_request, cpuTotal, "cpu"); + const resolvedCpuLimit = resolveResourceValue(profile.cpu_limit, cpuTotal, "cpu"); + const resolvedMemoryRequest = resolveResourceValue(profile.memory_request, memTotal, "memory"); + const resolvedMemoryLimit = resolveResourceValue(profile.memory_limit, memTotal, "memory"); + console.log( + ` Resolved: CPU request=${resolvedCpuRequest} cores, CPU limit=${resolvedCpuLimit} cores, RAM request=${resolvedMemoryRequest}, RAM limit=${resolvedMemoryLimit}`, + ); +} + +export async function selectResourceProfileForSandbox( + deps: ResourceProfileSelectionDeps, +): Promise { + const env = deps.env ?? process.env; + const availableProfiles = loadResourceProfiles(); + const profileNames = Object.keys(availableProfiles); + let selectedProfile: ResourceProfile | null = null; + + if (env.NEMOCLAW_RESOURCE_PROFILE) { + const envProfile = env.NEMOCLAW_RESOURCE_PROFILE; + if (Object.prototype.hasOwnProperty.call(availableProfiles, envProfile)) { + selectedProfile = { ...availableProfiles[envProfile] }; + deps.note(` Resource profile (env): ${envProfile}`); + } else { + console.error(` Unknown resource profile: '${envProfile}'`); + console.error(` Valid profiles: ${profileNames.join(", ")}`); + process.exit(1); + } + } else if (profileNames.length > 0 && !deps.isNonInteractive() && !hasResourceEnvOverrides(env)) { + const hw = getHardwareResources(); + console.log(""); + console.log(" Resource profiles:"); + profileNames.forEach((name: string, i: number) => { + const p = availableProfiles[name]; + console.log( + ` ${i + 1}) ${name} (cpu=${p.cpu_request}/${p.cpu_limit}, ram=${p.memory_request}/${p.memory_limit})`, + ); + }); + console.log(` ${profileNames.length + 1}) custom (enter values manually)`); + console.log(` ${profileNames.length + 2}) No profile (default resources)`); + const choice = await deps.promptOrDefault( + ` Choose [${profileNames.length + 2}]: `, + null, + String(profileNames.length + 2), + ); + const trimmedChoice = choice.trim(); + const idx = Number.parseInt(trimmedChoice, 10) - 1; + if (!/^\d+$/.test(trimmedChoice) || idx < 0 || idx > profileNames.length + 1) { + exitWithResourceProfileError( + `Invalid resource profile selection '${choice}'. Choose a number from 1 to ${profileNames.length + 2}.`, + ); + } + if (idx >= 0 && idx < profileNames.length) { + selectedProfile = availableProfiles[profileNames[idx]]; + console.log(` Using profile: ${profileNames[idx]}`); + } else if (idx === profileNames.length) { + console.log(""); + console.log(` Available: ${hw.cpu.cores} CPU cores, ${hw.memory.totalMB} MB RAM`); + console.log(" Enter values as percentages (e.g. 25%) or absolutes (e.g. 4, 8Gi)"); + console.log(""); + const cpuReq = (await deps.prompt(` CPU min (request) [25%]: `)).trim() || "25%"; + const cpuLim = (await deps.prompt(` CPU max (limit) [50%]: `)).trim() || "50%"; + const ramReq = (await deps.prompt(` RAM min (request) [25%]: `)).trim() || "25%"; + const ramLim = (await deps.prompt(` RAM max (limit) [50%]: `)).trim() || "50%"; + selectedProfile = { + cpu_request: cpuReq, + cpu_limit: cpuLim, + memory_request: ramReq, + memory_limit: ramLim, + }; + try { + printResolvedResourceProfile(selectedProfile, hw.cpu.cores, hw.memory.totalMB); + } catch (e: unknown) { + exitWithResourceProfileError((e as Error).message); + } + } + } + + return applyResourceEnvOverrides(selectedProfile, deps); +} diff --git a/src/lib/resources-cmd.ts b/src/lib/resources-cmd.ts index 500a8c040e..00d160e3c3 100644 --- a/src/lib/resources-cmd.ts +++ b/src/lib/resources-cmd.ts @@ -23,6 +23,33 @@ function getGatewayContainer(): string { return process.env.NEMOCLAW_GATEWAY_CONTAINER || `openshell-cluster-${GATEWAY_NAME}`; } +function getBlueprintPath(): string { + return path.join(__dirname, "..", "..", "nemoclaw-blueprint", "blueprint.yaml"); +} + +function parseResourceProfilesFromBlueprint( + blueprintPath: string, +): Record { + if (!fs.existsSync(blueprintPath)) return {}; + const content = fs.readFileSync(blueprintPath, "utf-8"); + const blueprint = YAML.parse(content); + const raw = blueprint?.components?.sandbox?.resource_profiles; + if (!raw || typeof raw !== "object") return {}; + const profiles: Record = {}; + for (const [name, p] of Object.entries(raw)) { + const prof = p as Record; + if (prof.cpu_request && prof.cpu_limit && prof.memory_request && prof.memory_limit) { + profiles[name] = { + cpu_request: String(prof.cpu_request), + cpu_limit: String(prof.cpu_limit), + memory_request: String(prof.memory_request), + memory_limit: String(prof.memory_limit), + }; + } + } + return profiles; +} + // ── Types ──────────────────────────────────────────────────────── export interface ResourceProfile { @@ -114,24 +141,8 @@ export function getHardwareResources(): HardwareResources { // Resource profiles from blueprint.yaml (CPU/RAM only) let profiles: Record | null = null; try { - const blueprintPath = path.join(__dirname, "..", "..", "nemoclaw-blueprint", "blueprint.yaml"); - if (fs.existsSync(blueprintPath)) { - const content = fs.readFileSync(blueprintPath, "utf-8"); - const blueprint = YAML.parse(content); - const raw = blueprint?.components?.sandbox?.resource_profiles; - if (raw && typeof raw === "object") { - profiles = {}; - for (const [name, p] of Object.entries(raw)) { - const prof = p as Record; - profiles[name] = { - cpu_request: String(prof.cpu_request || ""), - cpu_limit: String(prof.cpu_limit || ""), - memory_request: String(prof.memory_request || ""), - memory_limit: String(prof.memory_limit || ""), - }; - } - } - } + const parsedProfiles = parseResourceProfilesFromBlueprint(getBlueprintPath()); + profiles = Object.keys(parsedProfiles).length > 0 ? parsedProfiles : null; } catch { // blueprint.yaml missing or unparseable — skip profiles } @@ -148,11 +159,11 @@ export function getHardwareResources(): HardwareResources { * Print hardware resources. JSON mode writes to stdout for machine parsing. * Human mode writes a formatted table to stdout via console.log. */ -export function printHardwareResources(json: boolean): void { +export function printHardwareResources(json: boolean): HardwareResources { const hw = getHardwareResources(); if (json) { process.stdout.write(JSON.stringify(hw) + "\n"); - return; + return hw; } console.log(""); console.log(" Hardware Resources"); @@ -187,17 +198,9 @@ export function printHardwareResources(json: boolean): void { } console.log(" " + "\u2500".repeat(44)); console.log(""); + return hw; } -/** - * Resolve a resource value that may be a percentage (e.g. "25%") or an - * absolute Kubernetes quantity (e.g. "4", "8Gi"). Percentages are resolved - * against the provided total. - * - * @param value - raw value from profile or env var (e.g. "25%" or "4") - * @param total - total available (cores for CPU, MB for memory) - * @param unit - "cpu" (returns integer string) or "memory" (returns "XGi") - */ /** * Resolve a resource value that may be a percentage or absolute quantity. * Throws on invalid percentages so callers can surface clear errors. @@ -230,10 +233,6 @@ export function resolveResourceValue( return trimmed; } -/** - * Resolve all percentage values in a profile to absolute Kubernetes quantities. - * Returns a new profile with resolved values. - */ /** * Parse a Kubernetes CPU quantity to whole cores. * Handles plain integers ("16") and millicores ("7500m" → 7.5 → 7). @@ -288,14 +287,17 @@ export function appendResourceFlags( } catch { return false; } - // Resolve percentages to absolute values (throws on invalid %) - const hw = getHardwareResources(); - const resolved = resolveProfile(profile, hw); - if (resolved.cpu_request) args.push("--cpu-request", resolved.cpu_request); - if (resolved.cpu_limit) args.push("--cpu-limit", resolved.cpu_limit); - if (resolved.memory_request) args.push("--memory-request", resolved.memory_request); - if (resolved.memory_limit) args.push("--memory-limit", resolved.memory_limit); - return true; + try { + const hw = getHardwareResources(); + const resolved = resolveProfile(profile, hw); + if (resolved.cpu_request) args.push("--cpu-request", resolved.cpu_request); + if (resolved.cpu_limit) args.push("--cpu-limit", resolved.cpu_limit); + if (resolved.memory_request) args.push("--memory-request", resolved.memory_request); + if (resolved.memory_limit) args.push("--memory-limit", resolved.memory_limit); + return true; + } catch { + return false; + } } /** @@ -304,25 +306,7 @@ export function appendResourceFlags( */ export function loadResourceProfiles(): Record { try { - const blueprintPath = path.join(__dirname, "..", "..", "nemoclaw-blueprint", "blueprint.yaml"); - if (!fs.existsSync(blueprintPath)) return {}; - const content = fs.readFileSync(blueprintPath, "utf-8"); - const blueprint = YAML.parse(content); - const raw = blueprint?.components?.sandbox?.resource_profiles; - if (!raw || typeof raw !== "object") return {}; - const profiles: Record = {}; - for (const [name, p] of Object.entries(raw)) { - const prof = p as Record; - if (prof.cpu_request && prof.cpu_limit && prof.memory_request && prof.memory_limit) { - profiles[name] = { - cpu_request: String(prof.cpu_request), - cpu_limit: String(prof.cpu_limit), - memory_request: String(prof.memory_request), - memory_limit: String(prof.memory_limit), - }; - } - } - return profiles; + return parseResourceProfilesFromBlueprint(getBlueprintPath()); } catch { return {}; } From 3e390deda55b513311724dca3462206a980a9c25 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 13 May 2026 20:08:17 -0700 Subject: [PATCH 3/7] fix(cli): register resources command for docs parity --- src/commands/resources.ts | 16 ++++++++++++++++ src/lib/cli/command-registry.test.ts | 19 ++++++++++--------- 2 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 src/commands/resources.ts diff --git a/src/commands/resources.ts b/src/commands/resources.ts new file mode 100644 index 0000000000..f22c8445e6 --- /dev/null +++ b/src/commands/resources.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { withCommandDisplay } from "../lib/cli/command-display"; +import Command from "../lib/commands/resources"; + +export default withCommandDisplay(Command, [ + { + usage: "nemoclaw resources", + description: "Show hardware inventory (CPU cores, RAM, GPU VRAM)", + flags: "[--json]", + group: "Resources", + scope: "global", + order: 900, + }, +]); diff --git a/src/lib/cli/command-registry.test.ts b/src/lib/cli/command-registry.test.ts index ee654fc448..56d4a99d46 100644 --- a/src/lib/cli/command-registry.test.ts +++ b/src/lib/cli/command-registry.test.ts @@ -17,10 +17,10 @@ import type { CommandDef } from "./command-registry"; describe("command-registry", () => { describe("COMMANDS array", () => { - it("should contain exactly 58 commands", () => { - // 26 global (21 visible + 5 hidden help/version aliases) + it("should contain exactly 59 commands", () => { + // 27 global (22 visible + 5 hidden help/version aliases) // 32 sandbox (26 visible + 6 hidden shields/config) - expect(COMMANDS).toHaveLength(58); + expect(COMMANDS).toHaveLength(59); }); it("should have no duplicate usage strings", () => { @@ -39,9 +39,9 @@ describe("command-registry", () => { }); describe("globalCommands()", () => { - it("should return exactly 26 entries", () => { - // 21 visible + 5 hidden (help, --help, -h, --version, -v) - expect(globalCommands()).toHaveLength(26); + it("should return exactly 27 entries", () => { + // 22 visible + 5 hidden (help, --help, -h, --version, -v) + expect(globalCommands()).toHaveLength(27); }); it("every entry has scope global", () => { @@ -65,10 +65,10 @@ describe("command-registry", () => { }); describe("visibleCommands()", () => { - it("should exclude 11 hidden commands (47 visible)", () => { + it("should exclude 11 hidden commands (48 visible)", () => { // 5 hidden global (help, --help, -h, --version, -v) + // 6 hidden sandbox (shields×3, config get/set/rotate-token) - expect(visibleCommands()).toHaveLength(47); + expect(visibleCommands()).toHaveLength(48); }); it("no visible command has hidden=true", () => { @@ -146,7 +146,7 @@ describe("command-registry", () => { }); describe("globalCommandTokens()", () => { - it("returns the exact set of 22 tokens matching the global dispatch commands", () => { + it("returns the exact set of 23 tokens matching the global dispatch commands", () => { const tokens = globalCommandTokens(); const expected = new Set([ "onboard", @@ -166,6 +166,7 @@ describe("command-registry", () => { "upgrade-sandboxes", "gc", "inference", + "resources", "help", "--help", "-h", From 25ff8aa6cd0f6bfcf64de781faf6181678a04014 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 13 May 2026 22:07:21 -0700 Subject: [PATCH 4/7] test(cli): cover resource profile commands --- src/lib/commands/resources.test.ts | 38 +++++ .../resource-profile-selection.test.ts | 138 +++++++++++++++ src/lib/resources-cmd.test.ts | 159 ++++++++++++++++++ 3 files changed, 335 insertions(+) create mode 100644 src/lib/commands/resources.test.ts create mode 100644 src/lib/onboard/resource-profile-selection.test.ts create mode 100644 src/lib/resources-cmd.test.ts diff --git a/src/lib/commands/resources.test.ts b/src/lib/commands/resources.test.ts new file mode 100644 index 0000000000..b25acaf291 --- /dev/null +++ b/src/lib/commands/resources.test.ts @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import ResourcesCommand from "../../../dist/lib/commands/resources.js"; + +const rootDir = process.cwd(); + +describe("ResourcesCommand", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("returns the hardware resource object in JSON mode", async () => { + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + try { + const result = await ResourcesCommand.run(["--json"], rootDir); + expect(result).toEqual(expect.objectContaining({ + cpu: expect.objectContaining({ cores: expect.any(Number), model: expect.any(String) }), + memory: expect.objectContaining({ totalMB: expect.any(Number), swapMB: expect.any(Number) }), + })); + expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('"cpu"')); + } finally { + writeSpy.mockRestore(); + } + }); + + it("prints human-readable output without returning data in text mode", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + await expect(ResourcesCommand.run([], rootDir)).resolves.toBeUndefined(); + expect(logSpy).toHaveBeenCalledWith(" Hardware Resources"); + } finally { + logSpy.mockRestore(); + } + }); +}); diff --git a/src/lib/onboard/resource-profile-selection.test.ts b/src/lib/onboard/resource-profile-selection.test.ts new file mode 100644 index 0000000000..7a5285b7d8 --- /dev/null +++ b/src/lib/onboard/resource-profile-selection.test.ts @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { selectResourceProfileForSandbox } from "../../../dist/lib/onboard/resource-profile-selection.js"; +import type { ResourceProfileSelectionDeps } from "./resource-profile-selection"; + +function makeDeps(overrides: Partial = {}): ResourceProfileSelectionDeps { + return { + isNonInteractive: vi.fn(() => false), + note: vi.fn(), + prompt: vi.fn(), + promptOrDefault: vi.fn(), + env: {}, + ...overrides, + }; +} + +describe("selectResourceProfileForSandbox", () => { + let exitSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as never); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it("selects a named resource profile from the environment", async () => { + const deps = makeDeps({ env: { NEMOCLAW_RESOURCE_PROFILE: "developer" } as NodeJS.ProcessEnv }); + + await expect(selectResourceProfileForSandbox(deps)).resolves.toEqual({ + cpu_request: "37%", + cpu_limit: "75%", + memory_request: "37%", + memory_limit: "75%", + }); + + expect(deps.note).toHaveBeenCalledWith(" Resource profile (env): developer"); + expect(deps.promptOrDefault).not.toHaveBeenCalled(); + }); + + it("rejects unknown environment-selected profiles", async () => { + const deps = makeDeps({ env: { NEMOCLAW_RESOURCE_PROFILE: "missing" } as NodeJS.ProcessEnv }); + + await expect(selectResourceProfileForSandbox(deps)).rejects.toThrow("process.exit(1)"); + + expect(errorSpy).toHaveBeenCalledWith(" Unknown resource profile: 'missing'"); + }); + + it("applies CPU and RAM env overrides without prompting", async () => { + const deps = makeDeps({ + env: { + NEMOCLAW_CPU_REQUEST: "2", + NEMOCLAW_CPU_LIMIT: "4", + NEMOCLAW_RAM_REQUEST: "4Gi", + NEMOCLAW_RAM_LIMIT: "8Gi", + } as NodeJS.ProcessEnv, + isNonInteractive: vi.fn(() => true), + }); + + await expect(selectResourceProfileForSandbox(deps)).resolves.toEqual({ + cpu_request: "2", + cpu_limit: "4", + memory_request: "4Gi", + memory_limit: "8Gi", + }); + + expect(deps.note).toHaveBeenCalledWith(" Resource overrides (env): cpu=2/4, ram=4Gi/8Gi"); + expect(deps.promptOrDefault).not.toHaveBeenCalled(); + }); + + it("returns a menu-selected profile", async () => { + const deps = makeDeps({ promptOrDefault: vi.fn().mockResolvedValue("2") }); + + await expect(selectResourceProfileForSandbox(deps)).resolves.toEqual({ + cpu_request: "12%", + cpu_limit: "25%", + memory_request: "12%", + memory_limit: "25%", + }); + + expect(deps.promptOrDefault).toHaveBeenCalledWith(" Choose [6]: ", null, "6"); + }); + + it("fails fast for non-numeric or out-of-range menu choices", async () => { + const deps = makeDeps({ promptOrDefault: vi.fn().mockResolvedValue("99") }); + + await expect(selectResourceProfileForSandbox(deps)).rejects.toThrow("process.exit(1)"); + + expect(errorSpy).toHaveBeenCalledWith(" Invalid resource profile selection '99'. Choose a number from 1 to 6."); + }); + + it("collects a custom profile and validates requests and limits", async () => { + const deps = makeDeps({ + promptOrDefault: vi.fn().mockResolvedValue("5"), + prompt: vi + .fn() + .mockResolvedValueOnce("25%") + .mockResolvedValueOnce("50%") + .mockResolvedValueOnce("25%") + .mockResolvedValueOnce("50%"), + }); + + await expect(selectResourceProfileForSandbox(deps)).resolves.toEqual({ + cpu_request: "25%", + cpu_limit: "50%", + memory_request: "25%", + memory_limit: "50%", + }); + + expect(deps.prompt).toHaveBeenCalledTimes(4); + }); + + it("exits when custom profile validation fails", async () => { + const deps = makeDeps({ + promptOrDefault: vi.fn().mockResolvedValue("5"), + prompt: vi + .fn() + .mockResolvedValueOnce("101%") + .mockResolvedValueOnce("50%") + .mockResolvedValueOnce("25%") + .mockResolvedValueOnce("50%"), + }); + + await expect(selectResourceProfileForSandbox(deps)).rejects.toThrow("process.exit(1)"); + + expect(errorSpy).toHaveBeenCalledWith(" Invalid percentage '101%': must be an integer between 1% and 100%"); + }); +}); diff --git a/src/lib/resources-cmd.test.ts b/src/lib/resources-cmd.test.ts new file mode 100644 index 0000000000..4a11d9ce46 --- /dev/null +++ b/src/lib/resources-cmd.test.ts @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + appendResourceFlags, + getHardwareResources, + loadResourceProfiles, + printHardwareResources, + resolveProfile, + resolveResourceValue, +} from "../../dist/lib/resources-cmd.js"; + +const tempDirs: string[] = []; + +function makeExecutable(contents: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-resources-test-")); + tempDirs.push(dir); + const file = path.join(dir, "openshell-fake"); + fs.writeFileSync(file, contents, { mode: 0o755 }); + return file; +} + +describe("resources-cmd", () => { + afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("resolves percentage and absolute resource values", () => { + expect(resolveResourceValue("25%", 16, "cpu")).toBe("4"); + expect(resolveResourceValue("50%", 8192, "memory")).toBe("4Gi"); + expect(resolveResourceValue("10%", 1024, "memory")).toBe("128Mi"); + expect(resolveResourceValue("750m", 16, "cpu")).toBe("750m"); + expect(resolveResourceValue("8Gi", 8192, "memory")).toBe("8Gi"); + }); + + it("rejects malformed percentages before they reach OpenShell", () => { + expect(() => resolveResourceValue("0%", 16, "cpu")).toThrow("integer between 1% and 100%"); + expect(() => resolveResourceValue("101%", 16, "cpu")).toThrow("integer between 1% and 100%"); + expect(() => resolveResourceValue("12.5%", 16, "cpu")).toThrow("integer between 1% and 100%"); + }); + + it("resolves profiles against Kubernetes allocatable capacity when available", () => { + const resolved = resolveProfile( + { + cpu_request: "50%", + cpu_limit: "100%", + memory_request: "25%", + memory_limit: "50%", + }, + { + cpu: { cores: 16, model: "test-cpu", allocatable: "7500m" }, + memory: { totalMB: 32768, swapMB: 0, allocatableMB: 16384 }, + gpu: null, + profiles: null, + }, + ); + + expect(resolved).toEqual({ + cpu_request: "3", + cpu_limit: "7", + memory_request: "4Gi", + memory_limit: "8Gi", + }); + }); + + it("loads resource profiles from the blueprint", () => { + const profiles = loadResourceProfiles(); + + expect(profiles.developer).toEqual({ + cpu_request: "37%", + cpu_limit: "75%", + memory_request: "37%", + memory_limit: "75%", + }); + expect(profiles["game-developer"].cpu_limit).toBe("60%"); + }); + + it("returns hardware resources and includes parsed blueprint profiles", () => { + const hw = getHardwareResources(); + + expect(hw.cpu.cores).toBeGreaterThan(0); + expect(hw.cpu.model).toEqual(expect.any(String)); + expect(hw.memory.totalMB).toBeGreaterThan(0); + expect(hw.profiles?.creator.cpu_request).toBe("25%"); + }); + + it("prints JSON and returns the hardware object in JSON mode", () => { + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + try { + const hw = printHardwareResources(true); + expect(hw.memory.totalMB).toBeGreaterThan(0); + expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining('"memory"')); + } finally { + writeSpy.mockRestore(); + } + }); + + it("appends resolved OpenShell resource flags when supported", () => { + const openshell = makeExecutable("#!/usr/bin/env sh\necho '--cpu-request --cpu-limit --memory-request --memory-limit'\n"); + const args = ["sandbox", "create"]; + + const applied = appendResourceFlags( + args, + { cpu_request: "2", cpu_limit: "4", memory_request: "1Gi", memory_limit: "2Gi" }, + openshell, + ); + + expect(applied).toBe(true); + expect(args).toEqual([ + "sandbox", + "create", + "--cpu-request", + "2", + "--cpu-limit", + "4", + "--memory-request", + "1Gi", + "--memory-limit", + "2Gi", + ]); + }); + + it("gracefully skips resource flags when OpenShell does not support them", () => { + const openshell = makeExecutable("#!/usr/bin/env sh\necho 'usage: openshell sandbox create'\n"); + const args = ["sandbox", "create"]; + + expect( + appendResourceFlags( + args, + { cpu_request: "25%", cpu_limit: "50%", memory_request: "25%", memory_limit: "50%" }, + openshell, + ), + ).toBe(false); + expect(args).toEqual(["sandbox", "create"]); + }); + + it("gracefully skips resource flags when profile resolution fails", () => { + const openshell = makeExecutable("#!/usr/bin/env sh\necho '--cpu-request --cpu-limit'\n"); + const args = ["sandbox", "create"]; + + expect( + appendResourceFlags( + args, + { cpu_request: "bogus%", cpu_limit: "50%", memory_request: "25%", memory_limit: "50%" }, + openshell, + ), + ).toBe(false); + expect(args).toEqual(["sandbox", "create"]); + }); +}); From b69418260a4d73b49af6e06c473cd9ff4c5c8139 Mon Sep 17 00:00:00 2001 From: Shridhar Damale Date: Thu, 14 May 2026 14:49:45 +0530 Subject: [PATCH 5/7] fix(cli): align resource profiles with OpenShell flags --- docs/reference/commands.md | 9 +-- nemoclaw-blueprint/blueprint.yaml | 24 +++---- schemas/blueprint.schema.json | 10 ++- .../resource-profile-selection.test.ts | 53 +++++++-------- src/lib/onboard/resource-profile-selection.ts | 57 ++++++---------- src/lib/resources-cmd.test.ts | 51 +++++++------- src/lib/resources-cmd.ts | 67 ++++++++++--------- 7 files changed, 119 insertions(+), 152 deletions(-) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index c6dc8def82..8ec4c05808 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -67,7 +67,6 @@ Use `--json` for machine-readable CPU, memory, GPU, Kubernetes allocatable-capac $ nemoclaw resources [--json] ``` -`nemoclaw resources` reads `NEMOCLAW_GATEWAY_CONTAINER` only as an advanced override for the OpenShell gateway container name used when querying Kubernetes allocatable capacity. If the gateway is not running, Kubernetes allocatable fields are omitted and host CPU/RAM totals are still shown. ### `nemoclaw onboard` @@ -1154,11 +1153,9 @@ These flags toggle optional behaviors during onboarding; set them before running | `NEMOCLAW_OVERLAY_SNAPSHOTTER` | snapshotter name | Selects the containerd overlay snapshotter for sandbox builds. Empty (default) preserves containerd's choice. | | `NEMOCLAW_SKIP_TELEGRAM_REACHABILITY` | `1` to enable | Skips the Telegram bot reachability probe during onboard (useful in restricted networks). | | `NEMOCLAW_CONFIG_ACCEPT_NEW_PATH` | `1` to enable | Accepts a new sandbox config path without an interactive prompt when the stored path differs from the discovered one. | -| `NEMOCLAW_RESOURCE_PROFILE` | profile name | Selects a sandbox CPU/RAM resource profile from the blueprint during onboarding. Unknown names fail fast. | -| `NEMOCLAW_CPU_REQUEST` | percentage or Kubernetes CPU quantity | Overrides the selected profile's CPU request. Percentages resolve against detected capacity. | -| `NEMOCLAW_CPU_LIMIT` | percentage or Kubernetes CPU quantity | Overrides the selected profile's CPU limit. Percentages resolve against detected capacity. | -| `NEMOCLAW_RAM_REQUEST` | percentage or Kubernetes memory quantity | Overrides the selected profile's memory request. Percentages resolve against detected capacity. | -| `NEMOCLAW_RAM_LIMIT` | percentage or Kubernetes memory quantity | Overrides the selected profile's memory limit. Percentages resolve against detected capacity. | +| `NEMOCLAW_RESOURCE_PROFILE` | profile name or `default` | Selects a sandbox CPU/RAM resource profile from the blueprint during onboarding. `default` means no resource preference, so NemoClaw passes no OpenShell CPU or memory flags. Unknown names fail fast. | +| `NEMOCLAW_CPU` | percentage or Kubernetes CPU quantity | Overrides the selected profile's CPU size passed to OpenShell `--cpu`. Percentages resolve against detected capacity. | +| `NEMOCLAW_RAM` | percentage or Kubernetes memory quantity | Overrides the selected profile's memory size passed to OpenShell `--memory`. Percentages resolve against detected capacity. | | `NEMOCLAW_SANDBOX_GPU` | `auto`, `1`, or `0` | Controls sandbox GPU passthrough during onboarding. `auto` enables GPU passthrough when an NVIDIA GPU is detected, `1` requires GPU passthrough, and `0` forces CPU-only sandbox creation. | | `NEMOCLAW_SANDBOX_GPU_DEVICE` | OpenShell GPU device selector | Selects the GPU device passed with `openshell sandbox create --gpu-device`. Setting this value enables sandbox GPU passthrough unless `NEMOCLAW_SANDBOX_GPU=0` is also set, which is rejected. | | `NEMOCLAW_DOCKER_GPU_PATCH` | `0` to disable, anything else to keep the default | Controls the Linux Docker-driver GPU sandbox compatibility patch. Set to `0` only as an escape hatch when the patch fails and you need onboarding to continue without patching the GPU sandbox container. | diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index 7b557cedf4..d47eba7ebc 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -37,25 +37,17 @@ components: - 18789 resource_profiles: creator: - cpu_request: "25%" - cpu_limit: "50%" - memory_request: "25%" - memory_limit: "50%" + cpu: "50%" + memory: "50%" gamer: - cpu_request: "12%" - cpu_limit: "25%" - memory_request: "12%" - memory_limit: "25%" + cpu: "25%" + memory: "25%" game-developer: - cpu_request: "30%" - cpu_limit: "60%" - memory_request: "30%" - memory_limit: "60%" + cpu: "60%" + memory: "60%" developer: - cpu_request: "37%" - cpu_limit: "75%" - memory_request: "37%" - memory_limit: "75%" + cpu: "75%" + memory: "75%" inference: profiles: diff --git a/schemas/blueprint.schema.json b/schemas/blueprint.schema.json index 3f10b6607b..8627d11de5 100644 --- a/schemas/blueprint.schema.json +++ b/schemas/blueprint.schema.json @@ -65,15 +65,13 @@ }, "resource_profiles": { "type": "object", - "description": "Named resource profiles for sandbox CPU/memory allocation. Values can be absolute Kubernetes quantities (e.g. '4', '8Gi') or percentages of detected hardware (e.g. '25%').", + "description": "Named resource profiles for sandbox CPU/memory sizing. Values can be absolute Kubernetes quantities (e.g. '4', '8Gi') or percentages of detected hardware (e.g. '25%').", "additionalProperties": { "type": "object", - "required": ["cpu_request", "cpu_limit", "memory_request", "memory_limit"], + "required": ["cpu", "memory"], "properties": { - "cpu_request": { "type": "string", "minLength": 1, "pattern": "^([1-9]\\d?%|100%|\\d+(\\.\\d+)?[mKMGTPE]?i?)$" }, - "cpu_limit": { "type": "string", "minLength": 1, "pattern": "^([1-9]\\d?%|100%|\\d+(\\.\\d+)?[mKMGTPE]?i?)$" }, - "memory_request": { "type": "string", "minLength": 1, "pattern": "^([1-9]\\d?%|100%|\\d+(\\.\\d+)?[mKMGTPE]?i?)$" }, - "memory_limit": { "type": "string", "minLength": 1, "pattern": "^([1-9]\\d?%|100%|\\d+(\\.\\d+)?[mKMGTPE]?i?)$" } + "cpu": { "type": "string", "minLength": 1, "pattern": "^([1-9]\\d?%|100%|\\d+(\\.\\d+)?[mKMGTPE]?i?)$" }, + "memory": { "type": "string", "minLength": 1, "pattern": "^([1-9]\\d?%|100%|\\d+(\\.\\d+)?[mKMGTPE]?i?)$" } }, "additionalProperties": false } diff --git a/src/lib/onboard/resource-profile-selection.test.ts b/src/lib/onboard/resource-profile-selection.test.ts index 7a5285b7d8..9e6c3f7d6b 100644 --- a/src/lib/onboard/resource-profile-selection.test.ts +++ b/src/lib/onboard/resource-profile-selection.test.ts @@ -38,16 +38,23 @@ describe("selectResourceProfileForSandbox", () => { const deps = makeDeps({ env: { NEMOCLAW_RESOURCE_PROFILE: "developer" } as NodeJS.ProcessEnv }); await expect(selectResourceProfileForSandbox(deps)).resolves.toEqual({ - cpu_request: "37%", - cpu_limit: "75%", - memory_request: "37%", - memory_limit: "75%", + cpu: "75%", + memory: "75%", }); expect(deps.note).toHaveBeenCalledWith(" Resource profile (env): developer"); expect(deps.promptOrDefault).not.toHaveBeenCalled(); }); + it("treats the default environment profile as no resource preference", async () => { + const deps = makeDeps({ env: { NEMOCLAW_RESOURCE_PROFILE: "default" } as NodeJS.ProcessEnv }); + + await expect(selectResourceProfileForSandbox(deps)).resolves.toBeNull(); + + expect(deps.note).toHaveBeenCalledWith(" Resource profile (env): default (OpenShell defaults)"); + expect(deps.promptOrDefault).not.toHaveBeenCalled(); + }); + it("rejects unknown environment-selected profiles", async () => { const deps = makeDeps({ env: { NEMOCLAW_RESOURCE_PROFILE: "missing" } as NodeJS.ProcessEnv }); @@ -59,22 +66,18 @@ describe("selectResourceProfileForSandbox", () => { it("applies CPU and RAM env overrides without prompting", async () => { const deps = makeDeps({ env: { - NEMOCLAW_CPU_REQUEST: "2", - NEMOCLAW_CPU_LIMIT: "4", - NEMOCLAW_RAM_REQUEST: "4Gi", - NEMOCLAW_RAM_LIMIT: "8Gi", + NEMOCLAW_CPU: "4", + NEMOCLAW_RAM: "8Gi", } as NodeJS.ProcessEnv, isNonInteractive: vi.fn(() => true), }); await expect(selectResourceProfileForSandbox(deps)).resolves.toEqual({ - cpu_request: "2", - cpu_limit: "4", - memory_request: "4Gi", - memory_limit: "8Gi", + cpu: "4", + memory: "8Gi", }); - expect(deps.note).toHaveBeenCalledWith(" Resource overrides (env): cpu=2/4, ram=4Gi/8Gi"); + expect(deps.note).toHaveBeenCalledWith(" Resource overrides (env): cpu=4, ram=8Gi"); expect(deps.promptOrDefault).not.toHaveBeenCalled(); }); @@ -82,10 +85,8 @@ describe("selectResourceProfileForSandbox", () => { const deps = makeDeps({ promptOrDefault: vi.fn().mockResolvedValue("2") }); await expect(selectResourceProfileForSandbox(deps)).resolves.toEqual({ - cpu_request: "12%", - cpu_limit: "25%", - memory_request: "12%", - memory_limit: "25%", + cpu: "25%", + memory: "25%", }); expect(deps.promptOrDefault).toHaveBeenCalledWith(" Choose [6]: ", null, "6"); @@ -99,25 +100,21 @@ describe("selectResourceProfileForSandbox", () => { expect(errorSpy).toHaveBeenCalledWith(" Invalid resource profile selection '99'. Choose a number from 1 to 6."); }); - it("collects a custom profile and validates requests and limits", async () => { + it("collects a custom profile and validates CPU and RAM", async () => { const deps = makeDeps({ promptOrDefault: vi.fn().mockResolvedValue("5"), prompt: vi .fn() .mockResolvedValueOnce("25%") - .mockResolvedValueOnce("50%") - .mockResolvedValueOnce("25%") - .mockResolvedValueOnce("50%"), + .mockResolvedValueOnce("25%"), }); await expect(selectResourceProfileForSandbox(deps)).resolves.toEqual({ - cpu_request: "25%", - cpu_limit: "50%", - memory_request: "25%", - memory_limit: "50%", + cpu: "25%", + memory: "25%", }); - expect(deps.prompt).toHaveBeenCalledTimes(4); + expect(deps.prompt).toHaveBeenCalledTimes(2); }); it("exits when custom profile validation fails", async () => { @@ -126,9 +123,7 @@ describe("selectResourceProfileForSandbox", () => { prompt: vi .fn() .mockResolvedValueOnce("101%") - .mockResolvedValueOnce("50%") - .mockResolvedValueOnce("25%") - .mockResolvedValueOnce("50%"), + .mockResolvedValueOnce("25%"), }); await expect(selectResourceProfileForSandbox(deps)).rejects.toThrow("process.exit(1)"); diff --git a/src/lib/onboard/resource-profile-selection.ts b/src/lib/onboard/resource-profile-selection.ts index 0e413bfcba..8527365736 100644 --- a/src/lib/onboard/resource-profile-selection.ts +++ b/src/lib/onboard/resource-profile-selection.ts @@ -21,12 +21,7 @@ export type ResourceProfileSelectionDeps = { }; function hasResourceEnvOverrides(env: NodeJS.ProcessEnv): boolean { - return !!( - env.NEMOCLAW_CPU_REQUEST || - env.NEMOCLAW_CPU_LIMIT || - env.NEMOCLAW_RAM_REQUEST || - env.NEMOCLAW_RAM_LIMIT - ); + return !!(env.NEMOCLAW_CPU || env.NEMOCLAW_RAM); } function applyResourceEnvOverrides( @@ -35,18 +30,11 @@ function applyResourceEnvOverrides( ): ResourceProfile | null { const env = deps.env ?? process.env; if (!hasResourceEnvOverrides(env)) return selectedProfile; - const nextProfile = selectedProfile || { - cpu_request: "", - cpu_limit: "", - memory_request: "", - memory_limit: "", - }; - if (env.NEMOCLAW_CPU_REQUEST) nextProfile.cpu_request = env.NEMOCLAW_CPU_REQUEST; - if (env.NEMOCLAW_CPU_LIMIT) nextProfile.cpu_limit = env.NEMOCLAW_CPU_LIMIT; - if (env.NEMOCLAW_RAM_REQUEST) nextProfile.memory_request = env.NEMOCLAW_RAM_REQUEST; - if (env.NEMOCLAW_RAM_LIMIT) nextProfile.memory_limit = env.NEMOCLAW_RAM_LIMIT; + const nextProfile = selectedProfile ? { ...selectedProfile } : { cpu: "", memory: "" }; + if (env.NEMOCLAW_CPU) nextProfile.cpu = env.NEMOCLAW_CPU; + if (env.NEMOCLAW_RAM) nextProfile.memory = env.NEMOCLAW_RAM; deps.note( - ` Resource overrides (env): cpu=${nextProfile.cpu_request}/${nextProfile.cpu_limit}, ram=${nextProfile.memory_request}/${nextProfile.memory_limit}`, + ` Resource overrides (env): cpu=${nextProfile.cpu}, ram=${nextProfile.memory}`, ); return nextProfile; } @@ -57,12 +45,10 @@ function exitWithResourceProfileError(message: string): never { } function printResolvedResourceProfile(profile: ResourceProfile, cpuTotal: number, memTotal: number): void { - const resolvedCpuRequest = resolveResourceValue(profile.cpu_request, cpuTotal, "cpu"); - const resolvedCpuLimit = resolveResourceValue(profile.cpu_limit, cpuTotal, "cpu"); - const resolvedMemoryRequest = resolveResourceValue(profile.memory_request, memTotal, "memory"); - const resolvedMemoryLimit = resolveResourceValue(profile.memory_limit, memTotal, "memory"); + const resolvedCpu = resolveResourceValue(profile.cpu, cpuTotal, "cpu"); + const resolvedMemory = resolveResourceValue(profile.memory, memTotal, "memory"); console.log( - ` Resolved: CPU request=${resolvedCpuRequest} cores, CPU limit=${resolvedCpuLimit} cores, RAM request=${resolvedMemoryRequest}, RAM limit=${resolvedMemoryLimit}`, + ` Resolved: CPU=${resolvedCpu}, RAM=${resolvedMemory}`, ); } @@ -71,17 +57,20 @@ export async function selectResourceProfileForSandbox( ): Promise { const env = deps.env ?? process.env; const availableProfiles = loadResourceProfiles(); - const profileNames = Object.keys(availableProfiles); + const profileNames = Object.keys(availableProfiles).filter((name) => name !== "default"); let selectedProfile: ResourceProfile | null = null; if (env.NEMOCLAW_RESOURCE_PROFILE) { const envProfile = env.NEMOCLAW_RESOURCE_PROFILE; - if (Object.prototype.hasOwnProperty.call(availableProfiles, envProfile)) { + if (envProfile === "default") { + selectedProfile = null; + deps.note(" Resource profile (env): default (OpenShell defaults)"); + } else if (Object.prototype.hasOwnProperty.call(availableProfiles, envProfile)) { selectedProfile = { ...availableProfiles[envProfile] }; deps.note(` Resource profile (env): ${envProfile}`); } else { console.error(` Unknown resource profile: '${envProfile}'`); - console.error(` Valid profiles: ${profileNames.join(", ")}`); + console.error(` Valid profiles: ${["default", ...profileNames].join(", ")}`); process.exit(1); } } else if (profileNames.length > 0 && !deps.isNonInteractive() && !hasResourceEnvOverrides(env)) { @@ -91,11 +80,11 @@ export async function selectResourceProfileForSandbox( profileNames.forEach((name: string, i: number) => { const p = availableProfiles[name]; console.log( - ` ${i + 1}) ${name} (cpu=${p.cpu_request}/${p.cpu_limit}, ram=${p.memory_request}/${p.memory_limit})`, + ` ${i + 1}) ${name} (cpu=${p.cpu}, ram=${p.memory})`, ); }); console.log(` ${profileNames.length + 1}) custom (enter values manually)`); - console.log(` ${profileNames.length + 2}) No profile (default resources)`); + console.log(` ${profileNames.length + 2}) No profile (OpenShell defaults)`); const choice = await deps.promptOrDefault( ` Choose [${profileNames.length + 2}]: `, null, @@ -109,22 +98,18 @@ export async function selectResourceProfileForSandbox( ); } if (idx >= 0 && idx < profileNames.length) { - selectedProfile = availableProfiles[profileNames[idx]]; + selectedProfile = { ...availableProfiles[profileNames[idx]] }; console.log(` Using profile: ${profileNames[idx]}`); } else if (idx === profileNames.length) { console.log(""); console.log(` Available: ${hw.cpu.cores} CPU cores, ${hw.memory.totalMB} MB RAM`); console.log(" Enter values as percentages (e.g. 25%) or absolutes (e.g. 4, 8Gi)"); console.log(""); - const cpuReq = (await deps.prompt(` CPU min (request) [25%]: `)).trim() || "25%"; - const cpuLim = (await deps.prompt(` CPU max (limit) [50%]: `)).trim() || "50%"; - const ramReq = (await deps.prompt(` RAM min (request) [25%]: `)).trim() || "25%"; - const ramLim = (await deps.prompt(` RAM max (limit) [50%]: `)).trim() || "50%"; + const cpu = (await deps.prompt(` CPU [25%]: `)).trim() || "25%"; + const memory = (await deps.prompt(` RAM [25%]: `)).trim() || "25%"; selectedProfile = { - cpu_request: cpuReq, - cpu_limit: cpuLim, - memory_request: ramReq, - memory_limit: ramLim, + cpu, + memory, }; try { printResolvedResourceProfile(selectedProfile, hw.cpu.cores, hw.memory.totalMB); diff --git a/src/lib/resources-cmd.test.ts b/src/lib/resources-cmd.test.ts index 4a11d9ce46..9900cff158 100644 --- a/src/lib/resources-cmd.test.ts +++ b/src/lib/resources-cmd.test.ts @@ -36,6 +36,7 @@ describe("resources-cmd", () => { it("resolves percentage and absolute resource values", () => { expect(resolveResourceValue("25%", 16, "cpu")).toBe("4"); + expect(resolveResourceValue("25%", 3.5, "cpu")).toBe("875m"); expect(resolveResourceValue("50%", 8192, "memory")).toBe("4Gi"); expect(resolveResourceValue("10%", 1024, "memory")).toBe("128Mi"); expect(resolveResourceValue("750m", 16, "cpu")).toBe("750m"); @@ -51,10 +52,8 @@ describe("resources-cmd", () => { it("resolves profiles against Kubernetes allocatable capacity when available", () => { const resolved = resolveProfile( { - cpu_request: "50%", - cpu_limit: "100%", - memory_request: "25%", - memory_limit: "50%", + cpu: "50%", + memory: "25%", }, { cpu: { cores: 16, model: "test-cpu", allocatable: "7500m" }, @@ -65,10 +64,8 @@ describe("resources-cmd", () => { ); expect(resolved).toEqual({ - cpu_request: "3", - cpu_limit: "7", - memory_request: "4Gi", - memory_limit: "8Gi", + cpu: "3750m", + memory: "4Gi", }); }); @@ -76,12 +73,10 @@ describe("resources-cmd", () => { const profiles = loadResourceProfiles(); expect(profiles.developer).toEqual({ - cpu_request: "37%", - cpu_limit: "75%", - memory_request: "37%", - memory_limit: "75%", + cpu: "75%", + memory: "75%", }); - expect(profiles["game-developer"].cpu_limit).toBe("60%"); + expect(profiles["game-developer"].cpu).toBe("60%"); }); it("returns hardware resources and includes parsed blueprint profiles", () => { @@ -90,7 +85,7 @@ describe("resources-cmd", () => { expect(hw.cpu.cores).toBeGreaterThan(0); expect(hw.cpu.model).toEqual(expect.any(String)); expect(hw.memory.totalMB).toBeGreaterThan(0); - expect(hw.profiles?.creator.cpu_request).toBe("25%"); + expect(hw.profiles?.creator.cpu).toBe("50%"); }); it("prints JSON and returns the hardware object in JSON mode", () => { @@ -104,13 +99,13 @@ describe("resources-cmd", () => { } }); - it("appends resolved OpenShell resource flags when supported", () => { - const openshell = makeExecutable("#!/usr/bin/env sh\necho '--cpu-request --cpu-limit --memory-request --memory-limit'\n"); + it("appends resolved OpenShell CPU and memory flags when supported", () => { + const openshell = makeExecutable("#!/usr/bin/env sh\necho '--cpu --memory'\n"); const args = ["sandbox", "create"]; const applied = appendResourceFlags( args, - { cpu_request: "2", cpu_limit: "4", memory_request: "1Gi", memory_limit: "2Gi" }, + { cpu: "4", memory: "2Gi" }, openshell, ); @@ -118,17 +113,21 @@ describe("resources-cmd", () => { expect(args).toEqual([ "sandbox", "create", - "--cpu-request", - "2", - "--cpu-limit", + "--cpu", "4", - "--memory-request", - "1Gi", - "--memory-limit", + "--memory", "2Gi", ]); }); + it("does not use old request/limit resource flags", () => { + const openshell = makeExecutable("#!/usr/bin/env sh\necho '--cpu-request --cpu-limit --memory-request --memory-limit'\n"); + const args = ["sandbox", "create"]; + + expect(appendResourceFlags(args, { cpu: "4", memory: "2Gi" }, openshell)).toBe(false); + expect(args).toEqual(["sandbox", "create"]); + }); + it("gracefully skips resource flags when OpenShell does not support them", () => { const openshell = makeExecutable("#!/usr/bin/env sh\necho 'usage: openshell sandbox create'\n"); const args = ["sandbox", "create"]; @@ -136,7 +135,7 @@ describe("resources-cmd", () => { expect( appendResourceFlags( args, - { cpu_request: "25%", cpu_limit: "50%", memory_request: "25%", memory_limit: "50%" }, + { cpu: "25%", memory: "25%" }, openshell, ), ).toBe(false); @@ -144,13 +143,13 @@ describe("resources-cmd", () => { }); it("gracefully skips resource flags when profile resolution fails", () => { - const openshell = makeExecutable("#!/usr/bin/env sh\necho '--cpu-request --cpu-limit'\n"); + const openshell = makeExecutable("#!/usr/bin/env sh\necho '--cpu --memory'\n"); const args = ["sandbox", "create"]; expect( appendResourceFlags( args, - { cpu_request: "bogus%", cpu_limit: "50%", memory_request: "25%", memory_limit: "50%" }, + { cpu: "bogus%", memory: "25%" }, openshell, ), ).toBe(false); diff --git a/src/lib/resources-cmd.ts b/src/lib/resources-cmd.ts index 00d160e3c3..1d87d896a9 100644 --- a/src/lib/resources-cmd.ts +++ b/src/lib/resources-cmd.ts @@ -20,7 +20,7 @@ import { dockerSpawnSync } from "./adapters/docker"; const GATEWAY_NAME = "nemoclaw"; function getGatewayContainer(): string { - return process.env.NEMOCLAW_GATEWAY_CONTAINER || `openshell-cluster-${GATEWAY_NAME}`; + return `openshell-cluster-${GATEWAY_NAME}`; } function getBlueprintPath(): string { @@ -38,14 +38,8 @@ function parseResourceProfilesFromBlueprint( const profiles: Record = {}; for (const [name, p] of Object.entries(raw)) { const prof = p as Record; - if (prof.cpu_request && prof.cpu_limit && prof.memory_request && prof.memory_limit) { - profiles[name] = { - cpu_request: String(prof.cpu_request), - cpu_limit: String(prof.cpu_limit), - memory_request: String(prof.memory_request), - memory_limit: String(prof.memory_limit), - }; - } + const profile = normalizeResourceProfile(prof); + if (profile) profiles[name] = profile; } return profiles; } @@ -53,10 +47,8 @@ function parseResourceProfilesFromBlueprint( // ── Types ──────────────────────────────────────────────────────── export interface ResourceProfile { - cpu_request: string; - cpu_limit: string; - memory_request: string; - memory_limit: string; + cpu: string; + memory: string; } export interface HardwareResources { @@ -187,12 +179,12 @@ export function printHardwareResources(json: boolean): HardwareResources { console.log(" Resource Profiles:"); for (const [name, p] of Object.entries(hw.profiles)) { const resolved = resolveProfile(p, hw); - const cpuStr = p.cpu_limit.endsWith("%") - ? `${p.cpu_limit} \u2192 ${resolved.cpu_limit} cores` - : `${p.cpu_limit} cores`; - const ramStr = p.memory_limit.endsWith("%") - ? `${p.memory_limit} \u2192 ${resolved.memory_limit}` - : p.memory_limit; + const cpuStr = p.cpu.endsWith("%") + ? `${p.cpu} \u2192 ${resolved.cpu} cores` + : `${p.cpu} cores`; + const ramStr = p.memory.endsWith("%") + ? `${p.memory} \u2192 ${resolved.memory}` + : p.memory; console.log(` ${name}: cpu=${cpuStr}, ram=${ramStr}`); } } @@ -219,7 +211,8 @@ export function resolveResourceValue( } const pct = parseInt(trimmed.slice(0, -1), 10); if (unit === "cpu") { - return String(Math.max(1, Math.floor(total * pct / 100))); + const milliCores = Math.max(1, Math.floor(total * 1000 * pct / 100)); + return milliCores % 1000 === 0 ? String(milliCores / 1000) : `${milliCores}m`; } // Memory: use Mi for precision on smaller machines, Gi for larger const resultMB = Math.floor(total * pct / 100); @@ -234,17 +227,17 @@ export function resolveResourceValue( } /** - * Parse a Kubernetes CPU quantity to whole cores. - * Handles plain integers ("16") and millicores ("7500m" → 7.5 → 7). + * Parse a Kubernetes CPU quantity. + * Handles plain quantities ("16", "3.5") and millicores ("7500m" -> 7.5). */ function parseCpuQuantity(value: string): number | null { const trimmed = value.trim(); if (trimmed.endsWith("m")) { const millis = parseInt(trimmed.slice(0, -1), 10); if (isNaN(millis)) return null; - return Math.floor(millis / 1000); + return millis / 1000; } - const cores = parseInt(trimmed, 10); + const cores = parseFloat(trimmed); return isNaN(cores) ? null : cores; } @@ -257,10 +250,8 @@ export function resolveProfile(profile: ResourceProfile, hw: HardwareResources): const cpuTotal = hw.cpu.allocatable ? (parseCpuQuantity(hw.cpu.allocatable) ?? hw.cpu.cores) : hw.cpu.cores; const memTotalMB = hw.memory.allocatableMB ?? hw.memory.totalMB; return { - cpu_request: resolveResourceValue(profile.cpu_request, cpuTotal, "cpu"), - cpu_limit: resolveResourceValue(profile.cpu_limit, cpuTotal, "cpu"), - memory_request: resolveResourceValue(profile.memory_request, memTotalMB, "memory"), - memory_limit: resolveResourceValue(profile.memory_limit, memTotalMB, "memory"), + cpu: resolveResourceValue(profile.cpu, cpuTotal, "cpu"), + memory: resolveResourceValue(profile.memory, memTotalMB, "memory"), }; } @@ -281,7 +272,9 @@ export function appendResourceFlags( timeout: 5000, stdio: ["ignore", "pipe", "ignore"], }); - if (result.status !== 0 || !result.stdout?.includes("--cpu-request")) { + const help = result.stdout || ""; + const hasFlag = (name: string) => new RegExp(`(^|\\s)--${name}(\\s|,|$)`).test(help); + if (result.status !== 0 || !hasFlag("cpu") || !hasFlag("memory")) { return false; } } catch { @@ -290,16 +283,24 @@ export function appendResourceFlags( try { const hw = getHardwareResources(); const resolved = resolveProfile(profile, hw); - if (resolved.cpu_request) args.push("--cpu-request", resolved.cpu_request); - if (resolved.cpu_limit) args.push("--cpu-limit", resolved.cpu_limit); - if (resolved.memory_request) args.push("--memory-request", resolved.memory_request); - if (resolved.memory_limit) args.push("--memory-limit", resolved.memory_limit); + if (resolved.cpu) args.push("--cpu", resolved.cpu); + if (resolved.memory) args.push("--memory", resolved.memory); return true; } catch { return false; } } +function normalizeResourceProfile(prof: Record): ResourceProfile | null { + const cpu = prof.cpu; + const memory = prof.memory; + if (!cpu || !memory) return null; + return { + cpu: String(cpu), + memory: String(memory), + }; +} + /** * Load resource profiles from blueprint.yaml. Returns empty object if * the file doesn't exist or has no profiles section. From 9a5798d979b99ebcbd98d7ef998a55bbde3f713c Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 17 May 2026 22:17:32 -0500 Subject: [PATCH 6/7] fix(onboard): keep resource flags within entrypoint budget Signed-off-by: Aaron Erickson --- src/lib/onboard.ts | 27 +++---------------- src/lib/onboard/resource-profile-selection.ts | 12 +++++++++ 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 04f1fd51bc..e22103e6bf 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -42,7 +42,7 @@ const dockerGpuSandboxCreate: typeof import("./onboard/docker-gpu-sandbox-create const dockerDriverGatewayLaunch: typeof import("./onboard/docker-driver-gateway-launch") = require("./onboard/docker-driver-gateway-launch"); const { findReadableNvidiaCdiSpecFiles, getDockerCdiSpecDirs, parseDockerCdiSpecDirs }: typeof import("./onboard/docker-cdi") = require("./onboard/docker-cdi"); const { buildSandboxGpuCreateArgs, getSandboxReadyTimeoutSecs }: typeof import("./onboard/sandbox-gpu-create") = require("./onboard/sandbox-gpu-create"); -const { selectResourceProfileForSandbox }: typeof import("./onboard/resource-profile-selection") = require("./onboard/resource-profile-selection"); +const { appendResourceFlagsForProfile, selectResourceProfileForSandbox }: typeof import("./onboard/resource-profile-selection") = require("./onboard/resource-profile-selection"); const { isValidProxyHost, isValidProxyPort, @@ -325,7 +325,6 @@ const providerModels: typeof import("./inference/provider-models") = require("./ const sandboxCreateStream: typeof import("./sandbox/create-stream") = require("./sandbox/create-stream"); const validationRecovery: typeof import("./validation-recovery") = require("./validation-recovery"); const webSearch: typeof import("./inference/web-search") = require("./inference/web-search"); -const { appendResourceFlags }: typeof import("./resources-cmd") = require("./resources-cmd"); const openshellInstallFlow: typeof import("./onboard/openshell-install") = require("./onboard/openshell-install"); const openshellPinFlow: typeof import("./onboard/openshell-pin") = @@ -390,8 +389,6 @@ const OPENCLAW_LAUNCH_AGENT_PLIST = "~/Library/LaunchAgents/ai.openclaw.gateway. const BRAVE_SEARCH_HELP_URL = "https://brave.com/search/api/"; -// Re-export shared JSON types under the names used throughout this module. -// See src/lib/core/json-types.ts for the canonical definitions. import type { JsonObject as LooseObject, } from "./core/json-types"; @@ -493,9 +490,6 @@ async function promptYesNoOrDefault( return chosen; } -// ── Helpers ────────────────────────────────────────────────────── - -// Gateway state functions — delegated to src/lib/state/gateway.ts const { isSandboxReady, parseSandboxStatus, @@ -5283,8 +5277,6 @@ async function createSandbox( }; process.on("exit", cleanupBuildCtx); - // Create sandbox (use -- echo to avoid dropping into interactive shell) - // Pass the base policy so sandbox starts in proxy mode (required for policy updates later) const defaultPolicyPath = path.join( ROOT, "nemoclaw-blueprint", @@ -5348,13 +5340,7 @@ async function createSandbox( }), ]; - // Append CPU/memory resource flags from selected profile (graceful degradation). - if (resourceProfile) { - const applied = appendResourceFlags(createArgs, resourceProfile, getOpenshellBinary()); - if (!applied) { - note(" OpenShell does not support resource flags — sandbox will use default limits."); - } - } + appendResourceFlagsForProfile(createArgs, resourceProfile, getOpenshellBinary(), { isNonInteractive, note, prompt, promptOrDefault }); // Create OpenShell providers for messaging credentials so they flow through // the provider/placeholder system instead of raw env vars. The L7 proxy @@ -10057,12 +10043,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { console.error(" Inference selection is incomplete; cannot create sandbox."); process.exit(1); } - const selectedProfile = await selectResourceProfileForSandbox({ - isNonInteractive, - note, - prompt, - promptOrDefault, - }); + const resourceProfile = await selectResourceProfileForSandbox({ isNonInteractive, note, prompt, promptOrDefault }); if (fresh) { stopStaleDashboardListenersForSandbox(registry.listSandboxes().sandboxes, sandboxName); } @@ -10078,7 +10059,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { agent, opts.controlUiPort || null, sandboxGpuConfig, - selectedProfile, + resourceProfile, ); webSearchConfig = nextWebSearchConfig; registry.updateSandbox(sandboxName, { diff --git a/src/lib/onboard/resource-profile-selection.ts b/src/lib/onboard/resource-profile-selection.ts index 8527365736..c60d0e6c26 100644 --- a/src/lib/onboard/resource-profile-selection.ts +++ b/src/lib/onboard/resource-profile-selection.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { + appendResourceFlags, getHardwareResources, loadResourceProfiles, resolveResourceValue, @@ -121,3 +122,14 @@ export async function selectResourceProfileForSandbox( return applyResourceEnvOverrides(selectedProfile, deps); } + +export function appendResourceFlagsForProfile( + args: string[], + profile: ResourceProfile | null, + openshellBinary: string, + deps: ResourceProfileSelectionDeps, +): void { + if (profile && !appendResourceFlags(args, profile, openshellBinary)) { + deps.note(" OpenShell does not support resource flags — sandbox will use default limits."); + } +} From 81201dc0285fbc1ebce2b8ed7bb85cb836ea18b3 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sat, 23 May 2026 01:25:33 -0700 Subject: [PATCH 7/7] fix(onboard): keep resource profile wiring budget-neutral Signed-off-by: Aaron Erickson --- src/lib/onboard.ts | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 3a52c0ca5d..17c180a611 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -536,8 +536,6 @@ const OPENCLAW_LAUNCH_AGENT_PLIST = "~/Library/LaunchAgents/ai.openclaw.gateway. const BRAVE_SEARCH_HELP_URL = "https://brave.com/search/api/"; -// Re-export shared JSON types under the names used throughout this module. -// See src/lib/core/json-types.ts for the canonical definitions. import type { JsonObject as LooseObject, } from "./core/json-types"; @@ -3533,8 +3531,6 @@ async function createSandbox( }; process.on("exit", cleanupBuildCtx); - // Create sandbox (use -- echo to avoid dropping into interactive shell) - // Pass the base policy so sandbox starts in proxy mode (required for policy updates later) const defaultPolicyPath = path.join( ROOT, "nemoclaw-blueprint", @@ -3608,13 +3604,7 @@ async function createSandbox( }), ]; - appendResourceFlagsForProfile(createArgs, resourceProfile, getOpenshellBinary(), { - isNonInteractive, - note, - prompt, - promptOrDefault, - }); - + appendResourceFlagsForProfile(createArgs, resourceProfile, getOpenshellBinary(), { isNonInteractive, note, prompt, promptOrDefault }); // Create OpenShell providers for messaging credentials so they flow through // the provider/placeholder system instead of raw env vars. The L7 proxy // rewrites Authorization headers (Bearer/Bot) and URL-path segments @@ -7349,13 +7339,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { setupMessagingChannels, readMessagingChannelConfigFromEnv, promptValidatedSandboxName, - selectResourceProfileForSandbox: () => - selectResourceProfileForSandbox({ - isNonInteractive, - note, - prompt, - promptOrDefault, - }), + selectResourceProfileForSandbox: () => selectResourceProfileForSandbox({ isNonInteractive, note, prompt, promptOrDefault }), stopStaleDashboardListenersForSandbox, listRegistrySandboxes: registry.listSandboxes, createSandbox,