From f2ffd3b2140692960f376e3cc3dd6b284754e455 Mon Sep 17 00:00:00 2001 From: thatcooperguy <129788948+thatcooperguy@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:43:44 -0500 Subject: [PATCH 01/21] Add self-healing setup inventory --- nvh/api/server.py | 91 +++++++++++ nvh/catalog/__init__.py | 1 + nvh/catalog/nvhive-catalog.json | 133 +++++++++++++++++ nvh/cli/main.py | 34 +++++ nvh/integrations/catalog.py | 186 +++++++++++++++++++++++ nvh/integrations/comfyui.py | 22 +++ nvh/integrations/receipts.py | 224 +++++++++++++++++++++++++++ nvh/integrations/setup_agent.py | 153 +++++++++++++++++++ nvh/integrations/studio_packs.py | 57 +++++++ pyproject.toml | 2 +- tests/test_install_receipts.py | 53 +++++++ tests/test_setup_agent.py | 28 ++++ tests/test_setup_catalog.py | 24 +++ web/app/setup/page.tsx | 249 +++++++++++++++++++++++++++---- web/lib/api.ts | 30 ++++ web/lib/types.ts | 91 +++++++++++ 16 files changed, 1351 insertions(+), 27 deletions(-) create mode 100644 nvh/catalog/__init__.py create mode 100644 nvh/catalog/nvhive-catalog.json create mode 100644 nvh/integrations/catalog.py create mode 100644 nvh/integrations/receipts.py create mode 100644 tests/test_install_receipts.py create mode 100644 tests/test_setup_catalog.py diff --git a/nvh/api/server.py b/nvh/api/server.py index aef0dd4..b7210b0 100644 --- a/nvh/api/server.py +++ b/nvh/api/server.py @@ -871,6 +871,14 @@ def clean_home_dir(cls, value: str | None) -> str | None: return cleaned or None +class SetupAssistantRequest(BaseModel): + question: str = Field(..., min_length=1, max_length=2000) + home_dir: str | None = Field( + default=None, + description="Optional NVH_HOME candidate to use while answering setup questions", + ) + + @app.get("/v1/system/storage", summary="Inspect rootless persistent storage") async def system_storage( home_dir: str | None = None, @@ -921,6 +929,89 @@ async def setup_helper( return _response_envelope(setup_helper_report(home_dir=home_dir)) +@app.post("/v1/setup/assistant", summary="Ask the local setup helper") +async def setup_assistant( + request: SetupAssistantRequest, + _auth: None = Depends(require_auth), +) -> dict[str, Any]: + """Answer setup questions with local state, receipts, and deterministic rules.""" + from nvh.integrations.setup_agent import setup_assistant_reply + + return _response_envelope( + setup_assistant_reply(request.question, home_dir=request.home_dir) + ) + + +@app.get("/v1/setup/catalog", summary="Load the setup catalog") +async def setup_catalog( + refresh: bool = False, + _auth: None = Depends(require_auth), +) -> dict[str, Any]: + """Return remote/cache/bundled setup catalog data for the wizard.""" + from nvh.integrations.catalog import load_setup_catalog + + return _response_envelope(load_setup_catalog(refresh=refresh)) + + +@app.get("/v1/setup/receipts", summary="List rootless install receipts") +async def setup_receipts( + kind: str | None = None, + status_filter: str | None = None, + limit: int = 100, + _auth: None = Depends(require_auth), +) -> dict[str, Any]: + """Return install receipts written under NVH_HOME.""" + from nvh.integrations.receipts import list_receipts, receipt_summary + + safe_limit = max(1, min(limit, 500)) + receipts = list_receipts(kind=kind, status=status_filter, limit=safe_limit) + summary = receipt_summary() + return _response_envelope({ + "receipts": receipts, + "count": len(receipts), + "summary": {key: value for key, value in summary.items() if key != "receipts"}, + }) + + +def _receipt_or_404(receipt_id: str) -> dict[str, Any]: + from nvh.integrations.receipts import load_receipt + + try: + return load_receipt(receipt_id) + except KeyError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@app.get("/v1/setup/receipts/{receipt_id}", summary="Get one install receipt") +async def setup_receipt( + receipt_id: str, + _auth: None = Depends(require_auth), +) -> dict[str, Any]: + return _response_envelope(_receipt_or_404(receipt_id)) + + +@app.get("/v1/setup/receipts/{receipt_id}/repair-plan", summary="Preview receipt repair") +async def setup_receipt_repair_plan( + receipt_id: str, + _auth: None = Depends(require_auth), +) -> dict[str, Any]: + from nvh.integrations.receipts import repair_plan + + _receipt_or_404(receipt_id) + return _response_envelope(repair_plan(receipt_id)) + + +@app.get("/v1/setup/receipts/{receipt_id}/uninstall-plan", summary="Preview receipt uninstall") +async def setup_receipt_uninstall_plan( + receipt_id: str, + _auth: None = Depends(require_auth), +) -> dict[str, Any]: + from nvh.integrations.receipts import uninstall_plan + + _receipt_or_404(receipt_id) + return _response_envelope(uninstall_plan(receipt_id)) + + # -- /v1/system/info ---------------------------------------------------------- @app.get("/v1/system/info", summary="Combined system status — GPU + providers + budget in one call") diff --git a/nvh/catalog/__init__.py b/nvh/catalog/__init__.py new file mode 100644 index 0000000..8c6a7a3 --- /dev/null +++ b/nvh/catalog/__init__.py @@ -0,0 +1 @@ +"""Bundled nvHive setup catalog.""" diff --git a/nvh/catalog/nvhive-catalog.json b/nvh/catalog/nvhive-catalog.json new file mode 100644 index 0000000..f975d22 --- /dev/null +++ b/nvh/catalog/nvhive-catalog.json @@ -0,0 +1,133 @@ +{ + "schema_version": 1, + "updated_at": "2026-04-28T00:00:00Z", + "channel": "bundled", + "profiles": [ + { + "id": "student", + "title": "Student Starter", + "description": "Small, useful local AI lab for coursework and first experiments.", + "pack_ids": ["starter"], + "model_ids": ["gemma3-4b", "qwen3-8b", "nomic-embed-text"] + }, + { + "id": "creator", + "title": "Creator Studio", + "description": "ComfyUI, Blender, and vision helpers for media projects.", + "pack_ids": ["creative", "comfy"], + "model_ids": ["gemma3-4b", "llava-7b"] + }, + { + "id": "agent", + "title": "Agent Builder", + "description": "Local agent libraries, a coding model, and embeddings.", + "pack_ids": ["agents"], + "model_ids": ["qwen25-coder-7b", "nomic-embed-text"] + }, + { + "id": "game", + "title": "Game Dev Lab", + "description": "Game prototyping, Blender assets, and mod helper workspace.", + "pack_ids": ["game", "creative"], + "model_ids": ["qwen25-coder-7b", "llava-7b"] + }, + { + "id": "full", + "title": "Full Workstation", + "description": "Everything nvHive can install without root access.", + "pack_ids": ["all"], + "model_ids": ["recommended"] + } + ], + "packs": [ + { + "id": "rootless-ollama", + "title": "Rootless Ollama Runtime", + "category": "runtime", + "install_command": "nvh studio --install rootless-ollama -y", + "recommended": true + }, + { + "id": "llm-starter", + "title": "Top Local LLM Starter", + "category": "llm", + "install_command": "nvh studio --install llm-starter -y", + "recommended": true + }, + { + "id": "agent-lab", + "title": "Agent Lab", + "category": "agent", + "install_command": "nvh studio --install agent-lab -y", + "recommended": true + }, + { + "id": "comfyui-power-nodes", + "title": "ComfyUI Power Nodes", + "category": "comfyui", + "install_command": "nvh studio --install comfy -y", + "recommended": true + }, + { + "id": "blender-creative", + "title": "Blender Creative Studio", + "category": "creative", + "install_command": "nvh studio --install creative -y", + "recommended": true + } + ], + "models": [ + { + "id": "gemma3-4b", + "title": "Gemma 3 4B", + "provider": "ollama", + "install_target": "gemma3:4b", + "recommended_vram_gb": 6, + "category": "chat" + }, + { + "id": "qwen3-8b", + "title": "Qwen 3 8B", + "provider": "ollama", + "install_target": "qwen3:8b", + "recommended_vram_gb": 8, + "category": "chat" + }, + { + "id": "qwen25-coder-7b", + "title": "Qwen 2.5 Coder 7B", + "provider": "ollama", + "install_target": "qwen2.5-coder:7b", + "recommended_vram_gb": 8, + "category": "code" + }, + { + "id": "nomic-embed-text", + "title": "Nomic Embed Text", + "provider": "ollama", + "install_target": "nomic-embed-text", + "recommended_vram_gb": 0, + "category": "embedding" + } + ], + "comfyui_examples": [ + { + "id": "z-image-turbo-text-to-image", + "title": "Z-Image-Turbo Text to Image", + "category": "text-to-image", + "recommended_vram_gb": 8 + }, + { + "id": "wan22-5b-video-generation", + "title": "Wan 2.2 5B Video Generation", + "category": "text-to-video", + "recommended_vram_gb": 8 + }, + { + "id": "flux-controlnet-canny-depth", + "title": "FLUX.1 ControlNet Canny and Depth", + "category": "controlnet", + "recommended_vram_gb": 16 + } + ] +} diff --git a/nvh/cli/main.py b/nvh/cli/main.py index 80ec5d4..b04bd35 100644 --- a/nvh/cli/main.py +++ b/nvh/cli/main.py @@ -9085,6 +9085,40 @@ def _fail(check: str, detail: str = "", fix: str = "") -> None: "Run `nvh doctor --storage --home-dir /path/on/mounted/volume/nvhive`", ) + try: + from nvh.integrations.receipts import receipt_summary + + receipts = receipt_summary() + detail = ( + f"{receipts['count']} receipt(s), " + f"{receipts['unhealthy']} need attention, root {receipts['root']}" + ) + if receipts["unhealthy"]: + _warn( + "Install receipts", + detail, + "Open the setup wizard or rerun the matching `nvh studio` / `nvh workstation` command.", + ) + else: + _pass("Install receipts", detail) + except Exception as e: + _warn("Install receipts", str(e)) + + try: + from nvh.integrations.catalog import catalog_status + + catalog = catalog_status(refresh=False) + detail = ( + f"{catalog.get('source')} catalog, {catalog.get('profile_count', 0)} profiles, " + f"{catalog.get('model_count', 0)} models" + ) + if catalog.get("error"): + _warn("Setup catalog", f"{detail}; {catalog['error']}") + else: + _pass("Setup catalog", detail) + except Exception as e: + _warn("Setup catalog", str(e)) + # 2. Config file exists and is valid YAML from nvh.config.settings import DEFAULT_CONFIG_PATH if not DEFAULT_CONFIG_PATH.exists(): diff --git a/nvh/integrations/catalog.py b/nvh/integrations/catalog.py new file mode 100644 index 0000000..73de498 --- /dev/null +++ b/nvh/integrations/catalog.py @@ -0,0 +1,186 @@ +"""Remote setup catalog with bundled fallback. + +The catalog lets nvHive update recommended profiles, model picks, and ComfyUI +starter workflows between package releases. Network access is optional: the +WebUI and CLI always fall back to a bundled catalog. +""" + +from __future__ import annotations + +import json +import os +import time +from importlib import resources +from pathlib import Path +from typing import Any + +from nvh.integrations.storage import storage_layout + +DEFAULT_CATALOG_URL = ( + "https://raw.githubusercontent.com/thatcooperguy/nvHive/main/" + "nvh/catalog/nvhive-catalog.json" +) +CATALOG_ENV = "NVH_CATALOG_URL" +SCHEMA_VERSION = 1 + + +def _now() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def catalog_cache_dir(*, create: bool = True) -> Path: + root = storage_layout().cache_dir / "catalog" + if create: + root.mkdir(parents=True, exist_ok=True) + return root + + +def catalog_cache_path(*, create: bool = True) -> Path: + return catalog_cache_dir(create=create) / "nvhive-catalog.json" + + +def _validate_catalog(catalog: dict[str, Any]) -> dict[str, Any]: + if int(catalog.get("schema_version", 0)) != SCHEMA_VERSION: + raise ValueError("Unsupported catalog schema_version") + for key in ("profiles", "models", "packs", "comfyui_examples"): + if not isinstance(catalog.get(key), list): + raise ValueError(f"Catalog is missing list field: {key}") + return catalog + + +def _generated_fallback_catalog() -> dict[str, Any]: + from nvh.integrations.comfyui import examples_as_dicts + from nvh.integrations.studio_packs import catalog_as_dicts, model_catalog_as_dicts + + return { + "schema_version": SCHEMA_VERSION, + "updated_at": _now(), + "channel": "generated-fallback", + "profiles": [ + { + "id": "student", + "title": "Student Starter", + "pack_ids": ["starter"], + "model_ids": ["gemma3-4b", "qwen3-8b", "nomic-embed-text"], + "description": "Small, useful local AI lab for coursework and experiments.", + }, + { + "id": "creator", + "title": "Creator Studio", + "pack_ids": ["creative", "comfy"], + "model_ids": ["gemma3-4b", "llava-7b"], + "description": "ComfyUI, Blender, and vision helpers for media projects.", + }, + { + "id": "agent", + "title": "Agent Builder", + "pack_ids": ["agents"], + "model_ids": ["qwen25-coder-7b", "nomic-embed-text"], + "description": "Local agent libraries, coding model, and embeddings.", + }, + { + "id": "full", + "title": "Full Workstation", + "pack_ids": ["all"], + "model_ids": ["recommended"], + "description": "Everything nvHive can install without root access.", + }, + ], + "packs": catalog_as_dicts(), + "models": model_catalog_as_dicts(), + "comfyui_examples": examples_as_dicts(), + } + + +def bundled_catalog() -> dict[str, Any]: + try: + payload = ( + resources.files("nvh.catalog") + .joinpath("nvhive-catalog.json") + .read_text(encoding="utf-8") + ) + return _validate_catalog(json.loads(payload)) + except Exception: + return _generated_fallback_catalog() + + +def _read_cached_catalog() -> dict[str, Any] | None: + path = catalog_cache_path(create=False) + if not path.exists(): + return None + try: + return _validate_catalog(json.loads(path.read_text(encoding="utf-8"))) + except Exception: + return None + + +def _write_cached_catalog(catalog: dict[str, Any]) -> None: + path = catalog_cache_path(create=True) + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(catalog, indent=2, sort_keys=True) + "\n", encoding="utf-8") + tmp.replace(path) + + +def _fetch_remote_catalog(url: str, timeout: float) -> dict[str, Any]: + import httpx + + response = httpx.get(url, follow_redirects=True, timeout=timeout) + response.raise_for_status() + return _validate_catalog(response.json()) + + +def load_setup_catalog( + *, + refresh: bool = False, + url: str | None = None, + timeout: float = 5.0, +) -> dict[str, Any]: + """Load setup catalog from remote, cache, or bundled fallback.""" + catalog_url = url or os.environ.get(CATALOG_ENV) or DEFAULT_CATALOG_URL + errors: list[str] = [] + + if refresh: + try: + catalog = _fetch_remote_catalog(catalog_url, timeout) + catalog["_cached_at"] = _now() + _write_cached_catalog(catalog) + return { + "source": "remote", + "url": catalog_url, + "catalog": catalog, + "error": None, + } + except Exception as exc: + errors.append(str(exc)) + + cached = _read_cached_catalog() + if cached is not None: + return { + "source": "cache", + "url": catalog_url, + "catalog": cached, + "error": "; ".join(errors) if errors else None, + } + + return { + "source": "bundled", + "url": catalog_url, + "catalog": bundled_catalog(), + "error": "; ".join(errors) if errors else None, + } + + +def catalog_status(*, refresh: bool = False) -> dict[str, Any]: + loaded = load_setup_catalog(refresh=refresh) + catalog = loaded["catalog"] + return { + "source": loaded["source"], + "url": loaded["url"], + "error": loaded["error"], + "schema_version": catalog.get("schema_version"), + "updated_at": catalog.get("updated_at"), + "profile_count": len(catalog.get("profiles", [])), + "pack_count": len(catalog.get("packs", [])), + "model_count": len(catalog.get("models", [])), + "comfyui_example_count": len(catalog.get("comfyui_examples", [])), + } diff --git a/nvh/integrations/comfyui.py b/nvh/integrations/comfyui.py index 92c0fb9..30a64b2 100644 --- a/nvh/integrations/comfyui.py +++ b/nvh/integrations/comfyui.py @@ -610,6 +610,28 @@ async def install_comfyui( "message": "Installed nvHive ComfyUI example pack", "examples_dir": str(examples_dir), } + try: + from nvh.integrations.receipts import write_receipt + + write_receipt( + kind="comfyui", + item_id="workspace", + title="ComfyUI Workspace", + install_path=app_dir, + source_urls=[COMFYUI_REPO_URL], + files=[ + str(examples_dir / "examples.json"), + str(examples_dir / "README.md"), + ], + metadata={ + "torch_profile": torch_profile, + "venv_python": str(venv_python), + "examples_dir": str(examples_dir), + "status": detect_comfyui(), + }, + ) + except Exception: + pass yield { "event": "complete", diff --git a/nvh/integrations/receipts.py b/nvh/integrations/receipts.py new file mode 100644 index 0000000..0ffc1f0 --- /dev/null +++ b/nvh/integrations/receipts.py @@ -0,0 +1,224 @@ +"""Install receipts for rootless workstation tools. + +Receipts are small JSON manifests written under ``NVH_HOME`` after nvHive +installs a tool, model, or workspace. They make setup resumable and auditable +without requiring root access or a system package database. +""" + +from __future__ import annotations + +import json +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +from nvh.integrations.storage import storage_layout + +SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class InstallReceipt: + """Manifest for one rootless install artifact.""" + + id: str + kind: str + item_id: str + title: str + status: str + installed_at: str + updated_at: str + install_path: str + version: str | None = None + source_urls: list[str] = field(default_factory=list) + launchers: list[str] = field(default_factory=list) + models: list[str] = field(default_factory=list) + files: list[str] = field(default_factory=list) + no_root: bool = True + metadata: dict[str, Any] = field(default_factory=dict) + schema_version: int = SCHEMA_VERSION + + def as_dict(self) -> dict[str, Any]: + return asdict(self) + + +def _now() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def receipts_root(*, create: bool = True) -> Path: + root = storage_layout().home / "receipts" + if create: + root.mkdir(parents=True, exist_ok=True) + return root + + +def receipt_id(kind: str, item_id: str) -> str: + return f"{kind}:{item_id}" + + +def _safe_slug(value: str) -> str: + slug = "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "_" for ch in value) + slug = slug.strip("._") + if not slug: + raise KeyError("Invalid receipt id") + return slug + + +def _receipt_path(identifier: str) -> Path: + return receipts_root() / f"{_safe_slug(identifier)}.json" + + +def write_receipt( + *, + kind: str, + item_id: str, + title: str, + install_path: str | Path, + status: str = "installed", + version: str | None = None, + source_urls: list[str] | tuple[str, ...] | None = None, + launchers: list[str] | tuple[str, ...] | None = None, + models: list[str] | tuple[str, ...] | None = None, + files: list[str] | tuple[str, ...] | None = None, + metadata: dict[str, Any] | None = None, + no_root: bool = True, +) -> dict[str, Any]: + """Create or update one install receipt.""" + identifier = receipt_id(kind, item_id) + path = _receipt_path(identifier) + previous: dict[str, Any] = {} + if path.exists(): + try: + previous = json.loads(path.read_text(encoding="utf-8")) + except Exception: + previous = {} + + now = _now() + receipt = InstallReceipt( + id=identifier, + kind=kind, + item_id=item_id, + title=title, + status=status, + installed_at=str(previous.get("installed_at") or now), + updated_at=now, + install_path=str(install_path), + version=version, + source_urls=list(source_urls or []), + launchers=list(launchers or []), + models=list(models or []), + files=list(files or []), + no_root=no_root, + metadata=metadata or {}, + ) + data = receipt.as_dict() + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + tmp.replace(path) + return enrich_receipt(data) + + +def load_receipt(identifier: str) -> dict[str, Any]: + path = _receipt_path(identifier) + if not path.exists(): + raise KeyError(f"Unknown receipt: {identifier}") + return enrich_receipt(json.loads(path.read_text(encoding="utf-8"))) + + +def list_receipts( + *, + kind: str | None = None, + status: str | None = None, + limit: int = 100, +) -> list[dict[str, Any]]: + """Return recent receipts sorted by updated time descending.""" + receipts: list[dict[str, Any]] = [] + root = receipts_root(create=False) + if not root.exists(): + return [] + for path in root.glob("*.json"): + try: + data = enrich_receipt(json.loads(path.read_text(encoding="utf-8"))) + except Exception: + continue + if kind and data.get("kind") != kind: + continue + if status and data.get("status") != status: + continue + receipts.append(data) + receipts.sort(key=lambda item: str(item.get("updated_at", "")), reverse=True) + return receipts[: max(1, min(limit, 500))] + + +def enrich_receipt(receipt: dict[str, Any]) -> dict[str, Any]: + """Add lightweight health data without mutating the on-disk manifest.""" + install_path = Path(str(receipt.get("install_path", ""))).expanduser() + launchers = [Path(str(path)).expanduser() for path in receipt.get("launchers", [])] + files = [Path(str(path)).expanduser() for path in receipt.get("files", [])] + missing_launchers = [str(path) for path in launchers if not path.exists()] + missing_files = [str(path) for path in files if not path.exists()] + health = { + "install_path_exists": install_path.exists(), + "missing_launchers": missing_launchers, + "missing_files": missing_files, + "healthy": install_path.exists() and not missing_launchers and not missing_files, + } + return {**receipt, "health": health} + + +def receipt_summary() -> dict[str, Any]: + receipts = list_receipts() + by_kind: dict[str, int] = {} + unhealthy = 0 + for receipt in receipts: + by_kind[receipt["kind"]] = by_kind.get(receipt["kind"], 0) + 1 + if not receipt.get("health", {}).get("healthy", False): + unhealthy += 1 + return { + "count": len(receipts), + "by_kind": by_kind, + "unhealthy": unhealthy, + "root": str(receipts_root(create=False)), + "receipts": receipts[:10], + } + + +def repair_plan(identifier: str) -> dict[str, Any]: + receipt = load_receipt(identifier) + kind = receipt["kind"] + item_id = receipt["item_id"] + commands: list[str] + if kind == "studio-pack": + commands = [f"nvh studio --install {item_id} -y"] + elif kind == "studio-model": + target = receipt.get("metadata", {}).get("install_target") or item_id + commands = [f"nvh studio --install-models {target} -y"] + elif kind == "comfyui": + commands = ["nvh workstation --with-comfyui -y"] + else: + commands = [f"nvh setup repair {identifier}"] + return { + "receipt": receipt, + "safe_to_run_without_root": True, + "commands": commands, + "reason": "Re-run the rootless installer for this item and refresh the receipt.", + } + + +def uninstall_plan(identifier: str) -> dict[str, Any]: + receipt = load_receipt(identifier) + paths = [receipt["install_path"], *receipt.get("launchers", []), *receipt.get("files", [])] + unique_paths = [] + for path in paths: + if path and path not in unique_paths: + unique_paths.append(path) + return { + "receipt": receipt, + "safe_to_run_without_root": True, + "destructive": True, + "target_paths": unique_paths, + "commands": [f"rm -rf {path!r}" for path in unique_paths], + "reason": "Preview only. nvHive records the paths so a user can remove rootless files deliberately.", + } diff --git a/nvh/integrations/setup_agent.py b/nvh/integrations/setup_agent.py index c004fa6..31ebee7 100644 --- a/nvh/integrations/setup_agent.py +++ b/nvh/integrations/setup_agent.py @@ -11,7 +11,10 @@ from pathlib import Path from typing import Any +from nvh.integrations.catalog import catalog_status from nvh.integrations.comfyui import detect_comfyui +from nvh.integrations.jobs import list_jobs +from nvh.integrations.receipts import receipt_summary, repair_plan from nvh.integrations.runtime import runtime_status from nvh.integrations.storage import storage_status from nvh.integrations.studio_packs import catalog_with_status, model_catalog_with_status @@ -37,6 +40,31 @@ def _pack_by_id(catalog: dict[str, Any]) -> dict[str, dict[str, Any]]: return {pack["id"]: pack for pack in catalog.get("packs", [])} +def _safe_receipt_summary() -> dict[str, Any]: + try: + return receipt_summary() + except Exception as exc: + return {"count": 0, "by_kind": {}, "unhealthy": 0, "root": None, "receipts": [], "error": str(exc)} + + +def _safe_catalog_status() -> dict[str, Any]: + try: + return catalog_status(refresh=False) + except Exception as exc: + return {"source": "unavailable", "error": str(exc)} + + +def _recent_failed_job() -> dict[str, Any] | None: + try: + jobs = list_jobs(limit=10) + except Exception: + return None + for job in jobs: + if job.get("status") in {"failed", "interrupted", "canceled"}: + return job + return None + + def setup_helper_report(home_dir: str | Path | None = None) -> dict[str, Any]: """Return a local setup diagnosis and ranked action list.""" storage = storage_status(home_dir=home_dir, min_free_gb=20) @@ -132,4 +160,129 @@ def setup_helper_report(home_dir: str | Path | None = None) -> dict[str, Any]: "comfyui": comfy, "model_recommendation_count": len(missing_models), "actions": [action.as_dict() for action in actions], + "receipts": _safe_receipt_summary(), + "catalog": _safe_catalog_status(), + "assistant": { + "mode": "offline-deterministic", + "can_read_jobs": True, + "can_read_receipts": True, + "can_refresh_catalog": True, + "description": ( + "Local setup helper can explain next steps, inspect recent install state, " + "and suggest rootless repair commands without requiring a cloud model." + ), + }, + } + + +def _commands_for_actions(actions: list[dict[str, Any]], *action_ids: str) -> list[str]: + wanted = set(action_ids) + commands = [ + action["command"] for action in actions + if action.get("id") in wanted and action.get("command") + ] + return commands + + +def setup_assistant_reply( + question: str, + home_dir: str | Path | None = None, +) -> dict[str, Any]: + """Answer a setup question using local state and deterministic rules.""" + report = setup_helper_report(home_dir=home_dir) + actions = report["actions"] + q = question.strip().lower() + receipts = report.get("receipts", {}) + failed_job = _recent_failed_job() + commands: list[str] = [] + focus = "next-step" + + if not q: + answer = "Ask about storage, ComfyUI, models, Blender, repair, or the next setup step." + elif any(word in q for word in ["storage", "mount", "persistent", "home", "nvh_home"]): + focus = "storage" + commands = _commands_for_actions(actions, "storage") or [ + 'nvh doctor --storage --home-dir "/path/on/mounted/volume/nvhive"', + ] + answer = ( + "Use the mounted file volume for NVH_HOME before large downloads. " + f"Current storage source is {report['storage']['configured_by']} at " + f"{report['storage']['layout']['home']}." + ) + elif any(word in q for word in ["comfy", "image", "video", "workflow"]): + focus = "comfyui" + commands = _commands_for_actions(actions, "comfyui", "comfyui-examples") or [ + "nvh workstation --with-comfyui -y", + ] + answer = ( + "ComfyUI is managed as a rootless workspace under NVH_HOME. " + "Install it from the wizard or run the command below; model weights stay explicit " + "because many upstream downloads require license acceptance." + ) + elif any(word in q for word in ["model", "llm", "ollama", "local ai"]): + focus = "models" + commands = _commands_for_actions(actions, "rootless-ollama", "starter-models") or [ + "nvh studio --install rootless-ollama -y", + "nvh studio --install-models recommended -y", + ] + answer = ( + "Start with the rootless Ollama runtime, then download the recommended models " + "that fit the detected GPU. The wizard keeps these under NVH_HOME/models." + ) + elif any(word in q for word in ["blender", "creative", "game", "asset"]): + focus = "creative" + commands = _commands_for_actions(actions, "creative-tools") or [ + "nvh studio --install creative -y", + ] + answer = ( + "Creative tools are installed without sudo under NVH_HOME/apps and NVH_HOME/studio. " + "The creative profile adds Blender plus game asset workspaces." + ) + elif any(word in q for word in ["repair", "fix", "failed", "error", "broken"]): + focus = "repair" + if failed_job: + answer = ( + f"The most recent problem I found is {failed_job['title']} with status " + f"{failed_job['status']}: {failed_job.get('message', 'no message')}. " + "Retry the matching wizard step after checking storage and network access." + ) + elif receipts.get("unhealthy"): + first = receipts.get("receipts", [{}])[0] + try: + commands = repair_plan(first["id"])["commands"] + except Exception: + commands = [] + answer = ( + f"I found {receipts['unhealthy']} receipt(s) with missing files or launchers. " + "Use the repair command to rerun the rootless installer for that item." + ) + else: + answer = ( + "I do not see a failed recent install or unhealthy receipt. " + "Run the wizard step again if you want to refresh an installed component." + ) + else: + commands = [action["command"] for action in actions[:3]] + next_title = actions[0]["title"] if actions else "Open the setup wizard" + answer = ( + f"Best next step: {next_title}. " + f"{report['summary']}. Receipts tracked: {receipts.get('count', 0)}." + ) + + if not commands and actions: + commands = [actions[0]["command"]] + + return { + "question": question, + "answer": answer, + "focus": focus, + "commands": commands, + "observations": { + "ready": report["ready"], + "receipt_count": receipts.get("count", 0), + "unhealthy_receipts": receipts.get("unhealthy", 0), + "catalog_source": report.get("catalog", {}).get("source"), + "recent_problem": failed_job, + }, + "actions": actions[:5], } diff --git a/nvh/integrations/studio_packs.py b/nvh/integrations/studio_packs.py index a7590b0..8e91981 100644 --- a/nvh/integrations/studio_packs.py +++ b/nvh/integrations/studio_packs.py @@ -552,6 +552,10 @@ def catalog_as_dicts() -> list[dict[str, Any]]: return [asdict(pack) for pack in STUDIO_PACKS] +def model_catalog_as_dicts() -> list[dict[str, Any]]: + return [asdict(model) for model in STUDIO_MODELS] + + def bundles_as_dict() -> dict[str, list[str]]: return {key: list(value) for key, value in PACK_BUNDLES.items()} @@ -823,6 +827,31 @@ def _write_marker(pack: StudioPack, extra: dict[str, Any] | None = None) -> None if extra: marker.update(extra) _marker_path(pack.id).write_text(json.dumps(marker, indent=2), encoding="utf-8") + try: + from nvh.integrations.receipts import write_receipt + + launcher_paths = [str(_local_bin() / launcher) for launcher in pack.launchers] + version = str(marker.get("version")) if marker.get("version") else None + write_receipt( + kind="studio-pack", + item_id=pack.id, + title=pack.title, + install_path=root, + version=version, + source_urls=pack.source_urls, + launchers=launcher_paths, + models=pack.models, + files=[str(_marker_path(pack.id))], + metadata={ + "category": pack.category, + "install_kind": pack.install_kind, + "recommended_vram_gb": pack.recommended_vram_gb, + "estimated_disk_gb": pack.estimated_disk_gb, + "marker": marker, + }, + ) + except Exception: + pass def _write_script(path: Path, content: str) -> None: @@ -1208,6 +1237,32 @@ def _write_blender_launcher() -> Path: return launcher +def _write_model_receipt(model: StudioModel) -> None: + try: + from nvh.integrations.receipts import write_receipt + + layout = storage_layout() + write_receipt( + kind="studio-model", + item_id=model.id, + title=model.title, + install_path=layout.ollama_models_dir, + source_urls=[model.source_url], + models=[model.install_target], + metadata={ + "provider": model.provider, + "install_target": model.install_target, + "category": model.category, + "recommended_vram_gb": model.recommended_vram_gb, + "estimated_disk_gb": model.estimated_disk_gb, + "capabilities": model.capabilities, + "license_note": model.license_note, + }, + ) + except Exception: + pass + + async def _install_blender_app(pack: StudioPack, force_update: bool) -> AsyncIterator[dict[str, Any]]: if platform.system() != "Linux" or platform.machine().lower() not in {"x86_64", "amd64"}: yield { @@ -1365,6 +1420,7 @@ async def install_studio_models( model.install_target in installed or model.install_target.split(":")[0] in installed ): + _write_model_receipt(model) yield { "event": "model", "status": "complete", @@ -1378,6 +1434,7 @@ async def install_studio_models( env=_ollama_env(), ): yield {**event, "model_id": model.id} + _write_model_receipt(model) yield { "event": "complete", diff --git a/pyproject.toml b/pyproject.toml index cf716da..8102d1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,7 +101,7 @@ nvhive-mcp = "nvh.mcp_server:main" include = ["nvh*"] [tool.setuptools.package-data] -nvh = ["config/*.yaml", "workflows/*.yaml"] +nvh = ["catalog/*.json", "config/*.yaml", "workflows/*.yaml"] [tool.ruff] target-version = "py311" diff --git a/tests/test_install_receipts.py b/tests/test_install_receipts.py new file mode 100644 index 0000000..05e61c6 --- /dev/null +++ b/tests/test_install_receipts.py @@ -0,0 +1,53 @@ +"""Tests for rootless install receipts.""" + +from __future__ import annotations + +from pathlib import Path + +from nvh.integrations import receipts + + +def test_write_list_and_repair_receipt(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("NVH_HOME", str(tmp_path / "nvh")) + install_path = tmp_path / "nvh" / "studio" / "packs" / "agent-lab" + install_path.mkdir(parents=True) + launcher = tmp_path / "nvh" / "bin" / "nvhive-agent-lab" + launcher.parent.mkdir(parents=True) + launcher.write_text("#!/usr/bin/env bash\n", encoding="utf-8") + + receipt = receipts.write_receipt( + kind="studio-pack", + item_id="agent-lab", + title="Agent Lab", + install_path=install_path, + launchers=[str(launcher)], + source_urls=["https://example.test/agent-lab"], + ) + + assert receipt["id"] == "studio-pack:agent-lab" + assert receipt["health"]["healthy"] is True + + listed = receipts.list_receipts() + assert [item["id"] for item in listed] == ["studio-pack:agent-lab"] + + plan = receipts.repair_plan("studio-pack:agent-lab") + assert plan["safe_to_run_without_root"] is True + assert plan["commands"] == ["nvh studio --install agent-lab -y"] + + +def test_uninstall_plan_is_preview_only(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("NVH_HOME", str(tmp_path / "nvh")) + target = Path(tmp_path / "nvh" / "comfyui" / "ComfyUI") + target.mkdir(parents=True) + + receipts.write_receipt( + kind="comfyui", + item_id="workspace", + title="ComfyUI Workspace", + install_path=target, + ) + plan = receipts.uninstall_plan("comfyui:workspace") + + assert plan["destructive"] is True + assert str(target) in plan["target_paths"] + assert target.exists() diff --git a/tests/test_setup_agent.py b/tests/test_setup_agent.py index f9d5941..d099e83 100644 --- a/tests/test_setup_agent.py +++ b/tests/test_setup_agent.py @@ -2,11 +2,23 @@ from __future__ import annotations +from types import SimpleNamespace + from nvh.integrations import setup_agent def test_setup_helper_prioritizes_storage(tmp_path, monkeypatch) -> None: monkeypatch.delenv("NVH_HOME", raising=False) + storage = SimpleNamespace( + ok=True, + configured_by="argument", + as_dict=lambda: { + "ok": True, + "configured_by": "argument", + "layout": {"home": str(tmp_path / "nvh")}, + }, + ) + monkeypatch.setattr(setup_agent, "storage_status", lambda **_: storage) monkeypatch.setattr( setup_agent, "model_catalog_with_status", @@ -33,3 +45,19 @@ def test_setup_helper_flags_default_storage(monkeypatch) -> None: assert report["ready"] is False assert report["actions"][0]["id"] == "storage" + assert report["assistant"]["mode"] == "offline-deterministic" + + +def test_setup_assistant_answers_comfyui_question(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("NVH_HOME", str(tmp_path / "nvh")) + monkeypatch.setattr( + setup_agent, + "detect_comfyui", + lambda: {"installed": False, "examples_installed": False}, + ) + + reply = setup_agent.setup_assistant_reply("How do I install ComfyUI?", tmp_path / "nvh") + + assert reply["focus"] == "comfyui" + assert "ComfyUI" in reply["answer"] + assert reply["commands"] diff --git a/tests/test_setup_catalog.py b/tests/test_setup_catalog.py new file mode 100644 index 0000000..5acd2f8 --- /dev/null +++ b/tests/test_setup_catalog.py @@ -0,0 +1,24 @@ +"""Tests for setup catalog fallback and status.""" + +from __future__ import annotations + +from nvh.integrations import catalog + + +def test_bundled_catalog_has_student_profiles() -> None: + data = catalog.bundled_catalog() + + assert data["schema_version"] == catalog.SCHEMA_VERSION + assert {profile["id"] for profile in data["profiles"]} >= {"student", "creator", "full"} + assert data["models"] + assert data["comfyui_examples"] + + +def test_catalog_status_uses_bundled_without_network(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("NVH_HOME", str(tmp_path / "nvh")) + + status = catalog.catalog_status(refresh=False) + + assert status["source"] == "bundled" + assert status["profile_count"] >= 3 + assert status["model_count"] >= 1 diff --git a/web/app/setup/page.tsx b/web/app/setup/page.tsx index 5b13e69..86e27bc 100644 --- a/web/app/setup/page.tsx +++ b/web/app/setup/page.tsx @@ -9,9 +9,12 @@ import { getRecommendations, getFreeProviders, saveProviderKey, + askSetupAssistant, getStorageStatus, configureStorage, + getSetupCatalog, getSetupHelper, + getSetupReceipts, cancelInstallJob, getComfyUIStatus, getComfyUIExamples, @@ -33,7 +36,10 @@ import type { ComfyUIInstallEvent, ComfyUIStatus, InstallJob, + SetupAssistantReply, + SetupCatalogResult, SetupHelperReport, + SetupReceiptsResult, StorageStatus, StudioPack, StudioPackInstallEvent, @@ -206,6 +212,13 @@ export default function SetupPage() { const [storageError, setStorageError] = useState(null); const [setupHelper, setSetupHelper] = useState(null); const [setupHelperError, setSetupHelperError] = useState(null); + const [setupReceipts, setSetupReceipts] = useState(null); + const [setupCatalog, setSetupCatalog] = useState(null); + const [setupInventoryError, setSetupInventoryError] = useState(null); + const [assistantQuestion, setAssistantQuestion] = useState(''); + const [assistantReply, setAssistantReply] = useState(null); + const [assistantLoading, setAssistantLoading] = useState(false); + const [assistantError, setAssistantError] = useState(null); const [expandedProvider, setExpandedProvider] = useState(null); const [keyInputs, setKeyInputs] = useState>({}); const [savingKey, setSavingKey] = useState(null); @@ -283,13 +296,28 @@ export default function SetupPage() { } }, []); + const refreshSetupInventory = useCallback(async (refreshCatalog = false) => { + try { + const [receipts, catalog] = await Promise.all([ + getSetupReceipts({ limit: 8 }), + getSetupCatalog(refreshCatalog), + ]); + setSetupReceipts(receipts); + setSetupCatalog(catalog); + setSetupInventoryError(null); + } catch (err) { + setSetupInventoryError(err instanceof Error ? err.message : 'Could not load setup inventory'); + } + }, []); + useEffect(() => { void refreshInstallJobs(); + void refreshSetupInventory(false); const timer = window.setInterval(() => { void refreshInstallJobs(); }, 3000); return () => window.clearInterval(timer); - }, [refreshInstallJobs]); + }, [refreshInstallJobs, refreshSetupInventory]); useEffect(() => { setComfyInstalling(installJobs.some(job => job.kind === 'comfyui-install' && isActiveInstallJob(job))); @@ -311,6 +339,21 @@ export default function SetupPage() { } }; + const handleAskAssistant = async () => { + const question = assistantQuestion.trim(); + if (!question) return; + setAssistantLoading(true); + setAssistantError(null); + try { + const reply = await askSetupAssistant(question, storageStatus?.layout.home); + setAssistantReply(reply); + } catch (err) { + setAssistantError(err instanceof Error ? err.message : 'Setup helper could not answer'); + } finally { + setAssistantLoading(false); + } + }; + useEffect(() => { // Check API health @@ -452,6 +495,7 @@ export default function SetupPage() { refreshStudioPacks(), refreshComfyUI(), refreshSetupHelper(status.layout.home), + refreshSetupInventory(false), ]); } catch (err) { setStorageError(err instanceof Error ? err.message : 'Could not configure persistent storage'); @@ -630,11 +674,14 @@ export default function SetupPage() { setModelsInstalling(false); refreshStudioModels(); void refreshInstallJobs(); + void refreshSetupInventory(false); + void refreshSetupHelper(storageStatus?.layout.home); }, onError: error => { setModelError(error); setModelsInstalling(false); void refreshInstallJobs(); + void refreshSetupHelper(storageStatus?.layout.home); }, } ); @@ -679,11 +726,14 @@ export default function SetupPage() { setStudioInstalling(false); refreshStudioPacks(); void refreshInstallJobs(); + void refreshSetupInventory(false); + void refreshSetupHelper(storageStatus?.layout.home); }, onError: error => { setStudioError(error); setStudioInstalling(false); void refreshInstallJobs(); + void refreshSetupHelper(storageStatus?.layout.home); }, } ); @@ -720,11 +770,14 @@ export default function SetupPage() { setComfyInstalling(false); refreshComfyUI(); void refreshInstallJobs(); + void refreshSetupInventory(false); + void refreshSetupHelper(storageStatus?.layout.home); }, onError: error => { setComfyError(error); setComfyInstalling(false); void refreshInstallJobs(); + void refreshSetupHelper(storageStatus?.layout.home); }, } ); @@ -792,6 +845,10 @@ export default function SetupPage() { const activeInstallJobs = installJobs.filter(isActiveInstallJob); const visibleInstallJobs = installJobs.slice(0, 5); const helperActions = setupHelper?.actions.slice(0, 4) ?? []; + const visibleReceipts = setupReceipts?.receipts.slice(0, 5) ?? []; + const unhealthyReceiptCount = setupReceipts?.summary.unhealthy ?? setupHelper?.receipts?.unhealthy ?? 0; + const receiptCount = setupReceipts?.count ?? setupHelper?.receipts?.count ?? 0; + const catalogSource = setupCatalog?.source ?? setupHelper?.catalog?.source ?? 'bundled'; const goToHelperAction = (actionId: string) => { if (actionId === 'storage') setStep('storage'); @@ -922,6 +979,92 @@ export default function SetupPage() { )} + {(setupReceipts || setupCatalog || setupInventoryError) && ( +
+
+
+
Setup Inventory
+
+ {receiptCount} receipt{receiptCount === 1 ? '' : 's'} tracked / catalog source {catalogSource} +
+
+
+ + +
+
+ {setupInventoryError && ( +
+ {setupInventoryError} +
+ )} +
+
+
Receipts
+
{receiptCount}
+
+
+
Needs Repair
+
{unhealthyReceiptCount}
+
+
+
Profiles
+
+ {setupCatalog?.catalog.profiles.length ?? setupHelper?.catalog?.profile_count ?? 0} +
+
+
+
Models
+
+ {setupCatalog?.catalog.models.length ?? setupHelper?.catalog?.model_count ?? 0} +
+
+
+ {visibleReceipts.length > 0 && ( +
+ {visibleReceipts.map(receipt => ( +
+
+
+ +
{receipt.title}
+ + {receipt.kind} + +
+
{receipt.install_path}
+
+ +
+ ))} +
+ )} +
+ )} + {(setupHelper || setupHelperError) && (
@@ -945,34 +1088,88 @@ export default function SetupPage() {
)} {setupHelper && ( -
- {helperActions.map(action => ( - + ))} +
+ +
+
+
+
Ask Setup Helper
+
+ Offline local guidance using jobs, receipts, storage, and catalog state +
-
- {action.reason} + + {setupHelper.assistant?.mode ?? 'offline'} + +
+
+ setAssistantQuestion(event.target.value)} + onKeyDown={event => { if (event.key === 'Enter') void handleAskAssistant(); }} + placeholder="Why did ComfyUI fail? What should I install next?" + className="input-base flex-1 px-3 py-2 text-xs font-mono" + /> + +
+ {assistantError && ( +
+ {assistantError}
-
- {action.command} + )} + {assistantReply && ( +
+
+ {assistantReply.answer} +
+ {assistantReply.commands.length > 0 && ( +
+ {assistantReply.commands.map(command => ( +
+ {command} +
+ ))} +
+ )}
- - ))} + )} +
)}
diff --git a/web/lib/api.ts b/web/lib/api.ts index 27c426d..4fa2f47 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -36,7 +36,10 @@ import type { StorageConfigureRequest, StorageStatus, RuntimeStatus, + SetupAssistantReply, + SetupCatalogResult, SetupHelperReport, + SetupReceiptsResult, ComfyUIExamplesResult, ComfyUIInstallEvent, ComfyUIInstallRequest, @@ -146,6 +149,33 @@ export async function getSetupHelper(homeDir?: string): Promise { + return apiPost('/v1/setup/assistant', { + question, + home_dir: homeDir, + }); +} + +export async function getSetupCatalog(refresh = false): Promise { + return apiGet(`/v1/setup/catalog${refresh ? '?refresh=true' : ''}`); +} + +export async function getSetupReceipts(options: { + kind?: string; + status?: string; + limit?: number; +} = {}): Promise { + const params = new URLSearchParams(); + if (options.kind) params.set('kind', options.kind); + if (options.status) params.set('status_filter', options.status); + if (options.limit) params.set('limit', String(options.limit)); + const qs = params.toString(); + return apiGet(`/v1/setup/receipts${qs ? `?${qs}` : ''}`); +} + const TERMINAL_JOB_STATUSES = new Set(['complete', 'failed', 'canceled', 'interrupted']); export async function getInstallJobs(options: { diff --git a/web/lib/types.ts b/web/lib/types.ts index b0da805..bb33c66 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -443,6 +443,97 @@ export interface SetupHelperReport { comfyui: Record; model_recommendation_count: number; actions: SetupAction[]; + receipts?: SetupReceiptsSummary; + catalog?: SetupCatalogStatus; + assistant?: { + mode: string; + can_read_jobs: boolean; + can_read_receipts: boolean; + can_refresh_catalog: boolean; + description: string; + }; +} + +export interface InstallReceiptHealth { + install_path_exists: boolean; + missing_launchers: string[]; + missing_files: string[]; + healthy: boolean; +} + +export interface InstallReceipt { + id: string; + kind: string; + item_id: string; + title: string; + status: string; + installed_at: string; + updated_at: string; + install_path: string; + version: string | null; + source_urls: string[]; + launchers: string[]; + models: string[]; + files: string[]; + no_root: boolean; + metadata: Record; + schema_version: number; + health: InstallReceiptHealth; +} + +export interface SetupReceiptsSummary { + count: number; + by_kind: Record; + unhealthy: number; + root: string | null; +} + +export interface SetupReceiptsResult { + receipts: InstallReceipt[]; + count: number; + summary: SetupReceiptsSummary; +} + +export interface SetupCatalogStatus { + source: string; + url?: string; + error?: string | null; + schema_version?: number; + updated_at?: string; + profile_count?: number; + pack_count?: number; + model_count?: number; + comfyui_example_count?: number; +} + +export interface SetupCatalogResult { + source: string; + url: string; + error: string | null; + catalog: { + schema_version: number; + updated_at: string; + channel?: string; + profiles: Array>; + packs: Array>; + models: Array>; + comfyui_examples: Array>; + }; +} + +export interface SetupAssistantReply { + question: string; + answer: string; + focus: string; + commands: string[]; + observations: { + ready: boolean; + receipt_count: number; + unhealthy_receipts: number; + catalog_source?: string; + recent_problem?: InstallJob | null; + }; + actions: SetupAction[]; } // ─── UI State helpers ──────────────────────────────────────────────────────── From 829b82bb611aac1edd2fff5fad7d684144a1c526 Mon Sep 17 00:00:00 2001 From: thatcooperguy <129788948+thatcooperguy@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:54:42 -0500 Subject: [PATCH 02/21] Make setup wizard repairs one click --- nvh/catalog/nvhive-catalog.json | 1 + nvh/integrations/catalog.py | 9 +- nvh/integrations/setup_agent.py | 194 +++++++++++++++++++++++++++- tests/test_setup_agent.py | 50 +++++++- web/app/setup/page.tsx | 220 ++++++++++++++++++++++++++++---- web/lib/types.ts | 15 +++ 6 files changed, 452 insertions(+), 37 deletions(-) diff --git a/nvh/catalog/nvhive-catalog.json b/nvh/catalog/nvhive-catalog.json index f975d22..6d99756 100644 --- a/nvh/catalog/nvhive-catalog.json +++ b/nvh/catalog/nvhive-catalog.json @@ -72,6 +72,7 @@ "id": "blender-creative", "title": "Blender Creative Studio", "category": "creative", + "latest_version": "4.5.4", "install_command": "nvh studio --install creative -y", "recommended": true } diff --git a/nvh/integrations/catalog.py b/nvh/integrations/catalog.py index 73de498..5c20004 100644 --- a/nvh/integrations/catalog.py +++ b/nvh/integrations/catalog.py @@ -50,7 +50,12 @@ def _validate_catalog(catalog: dict[str, Any]) -> dict[str, Any]: def _generated_fallback_catalog() -> dict[str, Any]: from nvh.integrations.comfyui import examples_as_dicts - from nvh.integrations.studio_packs import catalog_as_dicts, model_catalog_as_dicts + from nvh.integrations.studio_packs import BLENDER_VERSION, catalog_as_dicts, model_catalog_as_dicts + + packs = catalog_as_dicts() + for pack in packs: + if pack.get("id") == "blender-creative": + pack["latest_version"] = BLENDER_VERSION return { "schema_version": SCHEMA_VERSION, @@ -86,7 +91,7 @@ def _generated_fallback_catalog() -> dict[str, Any]: "description": "Everything nvHive can install without root access.", }, ], - "packs": catalog_as_dicts(), + "packs": packs, "models": model_catalog_as_dicts(), "comfyui_examples": examples_as_dicts(), } diff --git a/nvh/integrations/setup_agent.py b/nvh/integrations/setup_agent.py index 31ebee7..c3d8322 100644 --- a/nvh/integrations/setup_agent.py +++ b/nvh/integrations/setup_agent.py @@ -36,6 +36,23 @@ def as_dict(self) -> dict[str, Any]: return asdict(self) +@dataclass(frozen=True) +class SetupIssue: + """One proactive setup finding the wizard should surface.""" + + id: str + title: str + severity: str + reason: str + fix_action_id: str | None = None + affected_item: str | None = None + current_version: str | None = None + available_version: str | None = None + + def as_dict(self) -> dict[str, Any]: + return asdict(self) + + def _pack_by_id(catalog: dict[str, Any]) -> dict[str, dict[str, Any]]: return {pack["id"]: pack for pack in catalog.get("packs", [])} @@ -54,6 +71,15 @@ def _safe_catalog_status() -> dict[str, Any]: return {"source": "unavailable", "error": str(exc)} +def _safe_catalog_data() -> dict[str, Any]: + try: + from nvh.integrations.catalog import load_setup_catalog + + return load_setup_catalog(refresh=False).get("catalog", {}) + except Exception: + return {} + + def _recent_failed_job() -> dict[str, Any] | None: try: jobs = list_jobs(limit=10) @@ -65,6 +91,45 @@ def _recent_failed_job() -> dict[str, Any] | None: return None +def _action_for_job(job: dict[str, Any]) -> str: + kind = job.get("kind") + if kind == "comfyui-install": + return "comfyui" + if kind == "studio-model-install": + return "starter-models" + if kind == "studio-pack-install": + return "studio-packs" + return "storage" + + +def _catalog_entry_by_id(catalog: dict[str, Any]) -> dict[str, dict[str, Any]]: + entries: dict[str, dict[str, Any]] = {} + for key in ("packs", "models", "comfyui_examples"): + for item in catalog.get(key, []): + item_id = item.get("id") + if item_id: + entries[str(item_id)] = item + return entries + + +def _version_from_catalog(entry: dict[str, Any] | None) -> str | None: + if not entry: + return None + value = entry.get("latest_version") or entry.get("version") + return str(value) if value else None + + +def _looks_older(current: str | None, latest: str | None) -> bool: + if not current or not latest or current == latest: + return False + try: + from packaging.version import Version + + return Version(current) < Version(latest) + except Exception: + return current != latest + + def setup_helper_report(home_dir: str | Path | None = None) -> dict[str, Any]: """Return a local setup diagnosis and ranked action list.""" storage = storage_status(home_dir=home_dir, min_free_gb=20) @@ -74,8 +139,17 @@ def setup_helper_report(home_dir: str | Path | None = None) -> dict[str, Any]: comfy = detect_comfyui() by_pack = _pack_by_id(packs) actions: list[SetupAction] = [] + issues: list[SetupIssue] = [] if not storage.ok or storage.configured_by == "default": + issues.append(SetupIssue( + id="storage", + title="Persistent storage is not ready", + severity="required", + reason="Large installs may be lost if NVH_HOME is still using the default or an unwritable path.", + fix_action_id="storage", + affected_item="NVH_HOME", + )) actions.append(SetupAction( id="storage", title="Choose persistent NVH_HOME", @@ -86,6 +160,14 @@ def setup_helper_report(home_dir: str | Path | None = None) -> dict[str, Any]: )) if runtime.strategy == "needs-runtime": + issues.append(SetupIssue( + id="runtime-fallback", + title="Python runtime needs a fallback", + severity="recommended", + reason="This image does not appear to have a complete Python venv/pip path.", + fix_action_id="runtime-fallback", + affected_item="python-runtime-fallback", + )) actions.append(SetupAction( id="runtime-fallback", title="Install optional runtime fallback", @@ -97,6 +179,14 @@ def setup_helper_report(home_dir: str | Path | None = None) -> dict[str, Any]: ollama_pack = by_pack.get("rootless-ollama", {}) if not ollama_pack.get("status", {}).get("installed"): + issues.append(SetupIssue( + id="rootless-ollama", + title="Local model runtime is missing", + severity="recommended", + reason="Local LLM downloads need a rootless Ollama runtime.", + fix_action_id="rootless-ollama", + affected_item="rootless-ollama", + )) actions.append(SetupAction( id="rootless-ollama", title="Install local model runtime", @@ -111,6 +201,14 @@ def setup_helper_report(home_dir: str | Path | None = None) -> dict[str, Any]: if model.get("recommended") and not model.get("installed") ] if missing_models: + issues.append(SetupIssue( + id="starter-models", + title="Recommended local models are missing", + severity="recommended", + reason=f"{len(missing_models)} recommended model(s) are not installed yet.", + fix_action_id="starter-models", + affected_item="local-models", + )) actions.append(SetupAction( id="starter-models", title="Download recommended local models", @@ -121,6 +219,14 @@ def setup_helper_report(home_dir: str | Path | None = None) -> dict[str, Any]: )) if not comfy.get("installed"): + issues.append(SetupIssue( + id="comfyui", + title="ComfyUI is not installed", + severity="optional", + reason="Visual image/video workflows are unavailable until ComfyUI is installed.", + fix_action_id="comfyui", + affected_item="comfyui", + )) actions.append(SetupAction( id="comfyui", title="Install ComfyUI visual workspace", @@ -130,6 +236,14 @@ def setup_helper_report(home_dir: str | Path | None = None) -> dict[str, Any]: reason="ComfyUI enables local image/video workflows and nvHive starter examples.", )) elif not comfy.get("examples_installed"): + issues.append(SetupIssue( + id="comfyui-examples", + title="ComfyUI starter examples need repair", + severity="recommended", + reason="ComfyUI is present, but the nvHive example manifest is missing.", + fix_action_id="comfyui-examples", + affected_item="comfyui", + )) actions.append(SetupAction( id="comfyui-examples", title="Refresh ComfyUI examples", @@ -150,17 +264,81 @@ def setup_helper_report(home_dir: str | Path | None = None) -> dict[str, Any]: reason="Adds Blender LTS and asset workspaces for creative students.", )) + receipts = _safe_receipt_summary() + for receipt in receipts.get("receipts", []): + health = receipt.get("health", {}) + if not health.get("healthy", True): + action_id = f"repair-receipt:{receipt['id']}" + missing = len(health.get("missing_launchers", [])) + len(health.get("missing_files", [])) + issues.append(SetupIssue( + id=f"receipt:{receipt['id']}", + title=f"{receipt.get('title', receipt['id'])} needs repair", + severity="recommended", + reason=f"{missing or 1} expected file or launcher path is missing.", + fix_action_id=action_id, + affected_item=receipt["id"], + )) + try: + command = repair_plan(receipt["id"])["commands"][0] + except Exception: + command = f"nvh setup repair {receipt['id']}" + actions.append(SetupAction( + id=action_id, + title=f"Repair {receipt.get('title', receipt['id'])}", + priority=25, + status="recommended", + command=command, + reason="A previous rootless install receipt has missing files or launchers.", + )) + + catalog_data = _safe_catalog_data() + catalog_entries = _catalog_entry_by_id(catalog_data) + for receipt in receipts.get("receipts", []): + current_version = receipt.get("version") + latest_version = _version_from_catalog(catalog_entries.get(receipt.get("item_id"))) + if _looks_older(current_version, latest_version): + action_id = f"repair-receipt:{receipt['id']}" + issues.append(SetupIssue( + id=f"outdated:{receipt['id']}", + title=f"{receipt.get('title', receipt['id'])} has an update", + severity="recommended", + reason="A newer version is available in the setup catalog.", + fix_action_id=action_id, + affected_item=receipt["id"], + current_version=str(current_version), + available_version=str(latest_version), + )) + + failed_job = _recent_failed_job() + if failed_job: + action_id = _action_for_job(failed_job) + issues.append(SetupIssue( + id=f"job:{failed_job['id']}", + title=f"{failed_job.get('title', 'Install job')} needs attention", + severity="recommended", + reason=str(failed_job.get("message") or "A recent setup job did not finish."), + fix_action_id=action_id, + affected_item=failed_job.get("kind"), + )) + actions.sort(key=lambda action: action.priority) + issues.sort(key=lambda issue: {"required": 0, "recommended": 1, "optional": 2}.get(issue.severity, 3)) ready = not any(action.status == "required" for action in actions) return { "ready": ready, - "summary": "Ready for downloads" if ready else "Persistent storage needs attention", + "summary": ( + "Ready for downloads" + if ready and not issues + else f"{len(issues)} setup item(s) need attention" + ), "storage": storage.as_dict(), "runtime": runtime.as_dict(), "comfyui": comfy, "model_recommendation_count": len(missing_models), "actions": [action.as_dict() for action in actions], - "receipts": _safe_receipt_summary(), + "issues": [issue.as_dict() for issue in issues], + "issue_count": len(issues), + "receipts": receipts, "catalog": _safe_catalog_status(), "assistant": { "mode": "offline-deterministic", @@ -207,7 +385,8 @@ def setup_assistant_reply( answer = ( "Use the mounted file volume for NVH_HOME before large downloads. " f"Current storage source is {report['storage']['configured_by']} at " - f"{report['storage']['layout']['home']}." + f"{report['storage']['layout']['home']}. The wizard should guide this with a folder picker; " + "the CLI command is only an advanced override." ) elif any(word in q for word in ["comfy", "image", "video", "workflow"]): focus = "comfyui" @@ -216,7 +395,7 @@ def setup_assistant_reply( ] answer = ( "ComfyUI is managed as a rootless workspace under NVH_HOME. " - "Install it from the wizard or run the command below; model weights stay explicit " + "Use the install button from the wizard; model weights stay explicit " "because many upstream downloads require license acceptance." ) elif any(word in q for word in ["model", "llm", "ollama", "local ai"]): @@ -227,7 +406,7 @@ def setup_assistant_reply( ] answer = ( "Start with the rootless Ollama runtime, then download the recommended models " - "that fit the detected GPU. The wizard keeps these under NVH_HOME/models." + "that fit the detected GPU. The wizard can run both steps and keeps files under NVH_HOME/models." ) elif any(word in q for word in ["blender", "creative", "game", "asset"]): focus = "creative" @@ -236,7 +415,7 @@ def setup_assistant_reply( ] answer = ( "Creative tools are installed without sudo under NVH_HOME/apps and NVH_HOME/studio. " - "The creative profile adds Blender plus game asset workspaces." + "Use the creative profile or repair button; manual commands are just overrides." ) elif any(word in q for word in ["repair", "fix", "failed", "error", "broken"]): focus = "repair" @@ -244,7 +423,7 @@ def setup_assistant_reply( answer = ( f"The most recent problem I found is {failed_job['title']} with status " f"{failed_job['status']}: {failed_job.get('message', 'no message')}. " - "Retry the matching wizard step after checking storage and network access." + "Use the matching repair/install button after checking storage and network access." ) elif receipts.get("unhealthy"): first = receipts.get("receipts", [{}])[0] @@ -279,6 +458,7 @@ def setup_assistant_reply( "commands": commands, "observations": { "ready": report["ready"], + "issue_count": report.get("issue_count", 0), "receipt_count": receipts.get("count", 0), "unhealthy_receipts": receipts.get("unhealthy", 0), "catalog_source": report.get("catalog", {}).get("source"), diff --git a/tests/test_setup_agent.py b/tests/test_setup_agent.py index d099e83..7f1db91 100644 --- a/tests/test_setup_agent.py +++ b/tests/test_setup_agent.py @@ -4,7 +4,7 @@ from types import SimpleNamespace -from nvh.integrations import setup_agent +from nvh.integrations import receipts, setup_agent def test_setup_helper_prioritizes_storage(tmp_path, monkeypatch) -> None: @@ -61,3 +61,51 @@ def test_setup_assistant_answers_comfyui_question(tmp_path, monkeypatch) -> None assert reply["focus"] == "comfyui" assert "ComfyUI" in reply["answer"] assert reply["commands"] + + +def test_setup_helper_surfaces_unhealthy_receipt(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("NVH_HOME", str(tmp_path / "nvh")) + storage = SimpleNamespace( + ok=True, + configured_by="argument", + as_dict=lambda: { + "ok": True, + "configured_by": "argument", + "layout": {"home": str(tmp_path / "nvh")}, + }, + ) + runtime = SimpleNamespace( + strategy="python-venv", + as_dict=lambda: {"strategy": "python-venv"}, + ) + monkeypatch.setattr(setup_agent, "storage_status", lambda **_: storage) + monkeypatch.setattr(setup_agent, "runtime_status", lambda: runtime) + monkeypatch.setattr( + setup_agent, + "catalog_with_status", + lambda: { + "packs": [ + {"id": "rootless-ollama", "status": {"installed": True}}, + {"id": "blender-creative", "status": {"installed": True}}, + ], + }, + ) + monkeypatch.setattr(setup_agent, "model_catalog_with_status", lambda: {"models": []}) + monkeypatch.setattr( + setup_agent, + "detect_comfyui", + lambda: {"installed": True, "examples_installed": True}, + ) + receipts.write_receipt( + kind="studio-pack", + item_id="agent-lab", + title="Agent Lab", + install_path=tmp_path / "missing-agent-lab", + launchers=[str(tmp_path / "missing-agent-lab" / "nvhive-agent-lab")], + ) + + report = setup_agent.setup_helper_report(home_dir=tmp_path / "nvh") + + assert report["issue_count"] >= 1 + assert any(issue["id"] == "receipt:studio-pack:agent-lab" for issue in report["issues"]) + assert any(action["id"] == "repair-receipt:studio-pack:agent-lab" for action in report["actions"]) diff --git a/web/app/setup/page.tsx b/web/app/setup/page.tsx index 86e27bc..a07b76b 100644 --- a/web/app/setup/page.tsx +++ b/web/app/setup/page.tsx @@ -36,6 +36,7 @@ import type { ComfyUIInstallEvent, ComfyUIStatus, InstallJob, + InstallReceipt, SetupAssistantReply, SetupCatalogResult, SetupHelperReport, @@ -636,14 +637,14 @@ export default function SetupPage() { )); }; - const handleInstallStudioModels = () => { + const handleInstallStudioModels = (modelIds?: string[]) => { if (modelsInstalling) return; if (!storageStatus?.ok || storageStatus.configured_by === 'default') { setModelError('Set a persistent NVH_HOME on the mounted volume before downloading models.'); setStep('storage'); return; } - const selected = Array.from(selectedStudioModels); + const selected = modelIds?.length ? modelIds : Array.from(selectedStudioModels); if (selected.length === 0) { setModelError('Select at least one local model.'); return; @@ -687,6 +688,12 @@ export default function SetupPage() { ); }; + const recommendedMissingModelIds = () => ( + studioModels + .filter(model => model.recommended && !model.installed) + .map(model => model.id) + ); + const handleInstallStudioPacks = (packIds?: string[]) => { if (studioInstalling) return; if (!storageStatus?.ok || storageStatus.configured_by === 'default') { @@ -845,16 +852,90 @@ export default function SetupPage() { const activeInstallJobs = installJobs.filter(isActiveInstallJob); const visibleInstallJobs = installJobs.slice(0, 5); const helperActions = setupHelper?.actions.slice(0, 4) ?? []; + const helperIssues = setupHelper?.issues?.slice(0, 4) ?? []; const visibleReceipts = setupReceipts?.receipts.slice(0, 5) ?? []; const unhealthyReceiptCount = setupReceipts?.summary.unhealthy ?? setupHelper?.receipts?.unhealthy ?? 0; const receiptCount = setupReceipts?.count ?? setupHelper?.receipts?.count ?? 0; const catalogSource = setupCatalog?.source ?? setupHelper?.catalog?.source ?? 'bundled'; - const goToHelperAction = (actionId: string) => { - if (actionId === 'storage') setStep('storage'); - else if (actionId === 'starter-models') setStep('models'); - else if (actionId === 'comfyui' || actionId === 'comfyui-examples') setStep('comfyui'); - else setStep('studio'); + const runHelperAction = (actionId: string) => { + if (actionId.startsWith('repair-receipt:')) { + const receiptId = actionId.slice('repair-receipt:'.length); + const receipt = [ + ...(setupReceipts?.receipts ?? []), + ...(setupHelper?.receipts?.receipts ?? []), + ].find(item => item.id === receiptId); + if (receipt) handleRepairReceipt(receipt); + else void refreshSetupInventory(false); + return; + } + if (actionId === 'storage') { + setStep('storage'); + return; + } + if (actionId === 'starter-models') { + const missing = recommendedMissingModelIds(); + if (missing.length > 0) handleInstallStudioModels(missing); + else setStep('models'); + return; + } + if (actionId === 'rootless-ollama') { + handleInstallStudioPacks(['rootless-ollama']); + return; + } + if (actionId === 'runtime-fallback') { + handleInstallStudioPacks(['python-runtime-fallback']); + return; + } + if (actionId === 'comfyui' || actionId === 'comfyui-examples') { + handleInstallComfyUI(); + return; + } + if (actionId === 'creative-tools') { + handleInstallStudioPacks(['creative']); + return; + } + setStep('studio'); + }; + + const helperActionLabel = (actionId: string) => { + if (actionId.startsWith('repair-receipt:')) return !storageReady ? 'Set Storage' : 'Repair'; + if (actionId === 'storage') return 'Choose Folder'; + if (!storageReady) return 'Set Storage'; + if (actionId === 'starter-models') return modelsInstalling ? 'Downloading' : 'Download'; + if (actionId === 'comfyui' || actionId === 'comfyui-examples') { + return comfyInstalling ? 'Installing' : 'Install'; + } + return studioInstalling ? 'Installing' : 'Run'; + }; + + const helperActionDisabled = (actionId: string) => { + if (actionId.startsWith('repair-receipt:')) return !storageReady || studioInstalling || modelsInstalling || comfyInstalling; + if (actionId === 'storage') return false; + if (actionId === 'starter-models') return modelsInstalling || !storageReady; + if (actionId === 'comfyui' || actionId === 'comfyui-examples') { + return comfyInstalling || !storageReady; + } + return studioInstalling || !storageReady; + }; + + const handleRepairReceipt = (receipt: InstallReceipt) => { + if (receipt.kind === 'comfyui') { + setStep('comfyui'); + handleInstallComfyUI(); + return; + } + if (receipt.kind === 'studio-model') { + setStep('models'); + handleInstallStudioModels([receipt.item_id]); + return; + } + if (receipt.kind === 'studio-pack') { + setStep('studio'); + handleInstallStudioPacks([receipt.item_id]); + return; + } + setStep('studio'); }; return ( @@ -1049,14 +1130,16 @@ export default function SetupPage() {
))} @@ -1089,12 +1172,47 @@ export default function SetupPage() { )} {setupHelper && (
+ {helperIssues.length > 0 && ( +
+ {helperIssues.map(issue => ( +
+
+
+ +
{issue.title}
+ + {issue.severity} + +
+
+ {issue.reason} +
+ {(issue.current_version || issue.available_version) && ( +
+ {issue.current_version ?? 'unknown'} {'>'} {issue.available_version ?? 'unknown'} +
+ )} +
+ {issue.fix_action_id && ( + + )} +
+ ))} +
+ )}
{helperActions.map(action => ( - +
- +
+ + Manual override + +
+ {action.command} +
+
+
))} @@ -1158,15 +1303,36 @@ export default function SetupPage() {
{assistantReply.answer}
- {assistantReply.commands.length > 0 && ( -
- {assistantReply.commands.map(command => ( -
- {command} -
+ {assistantReply.actions.length > 0 && ( +
+ {assistantReply.actions.slice(0, 3).map(action => ( + ))}
)} + {assistantReply.commands.length > 0 && ( +
+ + Manual overrides + +
+ {assistantReply.commands.map(command => ( +
+ {command} +
+ ))} +
+
+ )}
)} @@ -1669,7 +1835,7 @@ export default function SetupPage() { + )} + + + ))} + + )} + + )} {visibleReceipts.length > 0 && (
{visibleReceipts.map(receipt => ( diff --git a/web/lib/api.ts b/web/lib/api.ts index 4fa2f47..7c3087d 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -40,6 +40,7 @@ import type { SetupCatalogResult, SetupHelperReport, SetupReceiptsResult, + CompatibilityReport, ComfyUIExamplesResult, ComfyUIInstallEvent, ComfyUIInstallRequest, @@ -163,6 +164,11 @@ export async function getSetupCatalog(refresh = false): Promise(`/v1/setup/catalog${refresh ? '?refresh=true' : ''}`); } +export async function getSetupCompatibility(homeDir?: string): Promise { + const qs = homeDir ? `?home_dir=${encodeURIComponent(homeDir)}` : ''; + return apiGet(`/v1/setup/compatibility${qs}`); +} + export async function getSetupReceipts(options: { kind?: string; status?: string; diff --git a/web/lib/types.ts b/web/lib/types.ts index d57d97c..a81f01a 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -458,6 +458,13 @@ export interface SetupHelperReport { issue_count?: number; receipts?: SetupReceiptsSummary; catalog?: SetupCatalogStatus; + compatibility?: { + summary?: string; + issue_count: number; + blocked_count: number; + rootless_fixable_count: number; + recommended_torch_profile?: string; + }; assistant?: { mode: string; can_read_jobs: boolean; @@ -535,6 +542,49 @@ export interface SetupCatalogResult { }; } +export interface CompatibilityRequirement { + id: string; + label: string; + status: 'ok' | 'fixable' | 'warning' | 'blocked' | string; + detail: string; + fix_action_id: string | null; + rootless_fix_available: boolean; +} + +export interface AppCompatibility { + id: string; + title: string; + category: string; + status: 'ready' | 'fixable' | 'degraded' | 'blocked' | string; + severity: 'info' | 'optional' | 'recommended' | 'required' | string; + summary: string; + recommended_action_id: string | null; + rootless_fix_available: boolean; + requirements: CompatibilityRequirement[]; + notes: string[]; +} + +export interface HostFact { + id: string; + label: string; + value: string; + status: string; + severity: string; + detail: string; +} + +export interface CompatibilityReport { + summary: string; + ready: boolean; + issue_count: number; + blocked_count: number; + rootless_fixable_count: number; + recommended_torch_profile: string; + host: Record; + facts: HostFact[]; + apps: AppCompatibility[]; +} + export interface SetupAssistantReply { question: string; answer: string; From 26f45f6c4fa75d2e76bb7d04852f53ed05bda29b Mon Sep 17 00:00:00 2001 From: thatcooperguy <129788948+thatcooperguy@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:19:02 -0500 Subject: [PATCH 04/21] Run nvWizard boot preflight on startup --- nvh/api/server.py | 66 +++++++ nvh/cli/main.py | 26 ++- nvh/integrations/boot_preflight.py | 265 +++++++++++++++++++++++++++++ nvh/integrations/compatibility.py | 9 +- nvh/integrations/setup_agent.py | 41 ++++- tests/test_boot_preflight.py | 81 +++++++++ tests/test_compatibility.py | 15 ++ web/app/setup/page.tsx | 96 +++++++++-- web/lib/api.ts | 9 + web/lib/types.ts | 42 +++++ 10 files changed, 634 insertions(+), 16 deletions(-) create mode 100644 nvh/integrations/boot_preflight.py create mode 100644 tests/test_boot_preflight.py diff --git a/nvh/api/server.py b/nvh/api/server.py index 03e966b..4b88fd4 100644 --- a/nvh/api/server.py +++ b/nvh/api/server.py @@ -273,6 +273,33 @@ def get_engine() -> Engine: return _engine +async def _run_boot_preflight_on_startup(app: FastAPI) -> None: + if os.environ.get("NVH_BOOT_PREFLIGHT", "1").lower() in {"0", "false", "off", "no"}: + logger.info("Hive API: boot preflight disabled by NVH_BOOT_PREFLIGHT.") + return + try: + from nvh.integrations.boot_preflight import run_boot_preflight + + result = await asyncio.to_thread(run_boot_preflight) + app.state.boot_preflight = result + logger.info("Hive API: boot preflight complete. %s", result.get("summary")) + except Exception as exc: + app.state.boot_preflight = { + "summary": "Boot preflight failed", + "error": str(exc), + "changes": [], + "agent_helper": { + "offline_helper_ready": True, + "local_agent_ready": False, + "mode": "offline-deterministic", + "recommended_action_id": "agent-lab", + "summary": "Offline setup helper is still available.", + "requirements": [], + }, + } + logger.warning("Hive API: boot preflight failed: %s", exc) + + # --------------------------------------------------------------------------- # Lifespan # --------------------------------------------------------------------------- @@ -280,6 +307,7 @@ def get_engine() -> Engine: @asynccontextmanager async def lifespan(app: FastAPI): global _engine + boot_task: asyncio.Task[None] | None = None from nvh.utils.logging import setup_logging json_mode = os.environ.get("HIVE_LOG_FORMAT", "text") == "json" setup_logging(level=os.environ.get("HIVE_LOG_LEVEL", "INFO"), json_format=json_mode) @@ -288,12 +316,16 @@ async def lifespan(app: FastAPI): _engine = Engine() enabled = await _engine.initialize() logger.info("Hive API: engine ready. Advisors: %s", ", ".join(enabled) or "none") + boot_task = asyncio.create_task(_run_boot_preflight_on_startup(app)) yield except Exception as exc: logger.error("Hive API: engine initialization error: %s", exc) + boot_task = asyncio.create_task(_run_boot_preflight_on_startup(app)) # Don't crash — partial init is fine; requests will fail gracefully. yield finally: + if boot_task and not boot_task.done(): + boot_task.cancel() logger.info("Hive API: shutting down.") if _engine: if hasattr(_engine, 'webhooks') and _engine.webhooks: @@ -964,6 +996,40 @@ async def setup_compatibility( return _response_envelope(compatibility_report(home_dir=home_dir)) +@app.get("/v1/setup/boot-preflight", summary="Return boot-time VM image preflight") +async def setup_boot_preflight( + home_dir: str | None = None, + recheck: bool = False, + _auth: None = Depends(require_auth), +) -> dict[str, Any]: + """Return the persisted boot preflight, running it if needed.""" + from nvh.integrations.boot_preflight import boot_preflight_status, run_boot_preflight + + if recheck: + result = await asyncio.to_thread(run_boot_preflight, home_dir=home_dir) + app.state.boot_preflight = result + return _response_envelope(result) + + cached = getattr(app.state, "boot_preflight", None) + if cached and not home_dir: + return _response_envelope(cached) + result = await asyncio.to_thread(boot_preflight_status, home_dir=home_dir, run_if_missing=True) + return _response_envelope(result) + + +@app.post("/v1/setup/boot-preflight/recheck", summary="Run boot-time VM image preflight now") +async def setup_boot_preflight_recheck( + home_dir: str | None = None, + _auth: None = Depends(require_auth), +) -> dict[str, Any]: + """Force a boot preflight refresh after the user repairs something.""" + from nvh.integrations.boot_preflight import run_boot_preflight + + result = await asyncio.to_thread(run_boot_preflight, home_dir=home_dir) + app.state.boot_preflight = result + return _response_envelope(result) + + @app.get("/v1/setup/receipts", summary="List rootless install receipts") async def setup_receipts( kind: str | None = None, diff --git a/nvh/cli/main.py b/nvh/cli/main.py index b04bd35..12ff3ef 100644 --- a/nvh/cli/main.py +++ b/nvh/cli/main.py @@ -19,6 +19,7 @@ import webbrowser from decimal import Decimal from pathlib import Path +from typing import Any # ---------------------------------------------------------------------- # Windows asyncio proactor GC crash workaround @@ -7800,6 +7801,18 @@ def workstation( storage = ensure_storage(home_dir, min_free_gb=min_free_gb) profile = detect_workstation_profile(home_dir=storage.layout.home) + boot_report: dict[str, Any] | None = None + recommended_torch_profile = "nvidia-cu121" + try: + from nvh.integrations.boot_preflight import run_boot_preflight + + boot_report = run_boot_preflight(home_dir=storage.layout.home) + recommended_torch_profile = ( + boot_report.get("compatibility", {}).get("recommended_torch_profile") + or recommended_torch_profile + ) + except Exception as exc: + console.print(f" [yellow]![/yellow] Boot preflight skipped: {exc}") console.print("\n[bold green]NVHive Student Workstation[/bold green]") console.print(" [dim]Target: Linux GPU desktop or forwarded cloud session[/dim]\n") console.print(f" NVH_HOME: [bold]{storage.layout.home}[/bold]") @@ -7820,6 +7833,17 @@ def workstation( if profile.recommended_chat_models: console.print(f" Chat models: {', '.join(profile.recommended_chat_models)}") console.print(f" ComfyUI: {', '.join(profile.recommended_comfy_profiles)} profiles\n") + if boot_report: + agent_helper = boot_report.get("agent_helper", {}) + console.print(f" Boot check: [bold]{boot_report.get('summary')}[/bold]") + console.print(f" AI helper: {agent_helper.get('summary', 'Offline setup helper is available.')}") + if boot_report.get("changes"): + for change in boot_report.get("changes", [])[:5]: + console.print( + f" [yellow]![/yellow] {change.get('label')}: " + f"{change.get('before')} -> {change.get('after')}" + ) + console.print() for note in profile.notes: console.print(f" [yellow]![/yellow] {note}") @@ -7900,7 +7924,7 @@ async def _install_comfy() -> None: from nvh.integrations.comfyui import install_comfyui last_log = 0.0 - async for event in install_comfyui(torch_profile="nvidia-cu130"): + async for event in install_comfyui(torch_profile=recommended_torch_profile): kind = event.get("event", "") message = event.get("message", "") now = _time.monotonic() diff --git a/nvh/integrations/boot_preflight.py b/nvh/integrations/boot_preflight.py new file mode 100644 index 0000000..10fce3e --- /dev/null +++ b/nvh/integrations/boot_preflight.py @@ -0,0 +1,265 @@ +"""Boot-time VM image preflight for rootless cloud GPU sessions. + +Cloud desktop images can change underneath a persistent user mount. This +module records a small host fingerprint at nvHive startup, compares it to the +previous boot stored under ``NVH_HOME``, and keeps the wizard focused on what +changed. +""" + +from __future__ import annotations + +import hashlib +import json +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from nvh.integrations.compatibility import compatibility_report +from nvh.integrations.storage import storage_layout + +STATE_FILENAME = "boot-preflight.json" +STATE_SCHEMA_VERSION = 1 + +_FACT_LABELS = { + "distro": "Base OS", + "kernel": "Kernel", + "machine": "Architecture", + "libc": "glibc", + "python_version": "Python", + "python_strategy": "Python runtime", + "gpu_name": "GPU", + "gpu_memory_total_mb": "GPU memory", + "driver_version": "NVIDIA driver", + "cuda_version": "CUDA driver API", + "git_available": "Git", + "curl_available": "curl", + "tar_available": "tar", + "node_available": "Node.js", + "npm_available": "npm", + "display_available": "Desktop display", + "storage_home": "NVH_HOME", +} + +_CRITICAL_FACTS = { + "distro", + "kernel", + "machine", + "libc", + "python_version", + "python_strategy", + "driver_version", + "cuda_version", + "git_available", + "curl_available", + "tar_available", + "storage_home", +} + + +def _state_path(home_dir: str | Path | None = None) -> Path: + return storage_layout(home_dir).config_dir / STATE_FILENAME + + +def _read_state(home_dir: str | Path | None = None) -> dict[str, Any] | None: + path = _state_path(home_dir) + if not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + return data if isinstance(data, dict) else None + + +def _write_state(state: dict[str, Any], home_dir: str | Path | None = None) -> None: + path = _state_path(home_dir) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + try: + path.chmod(0o600) + except Exception: + pass + + +def _available(value: str | None) -> bool: + return bool(value and str(value).strip()) + + +def host_fingerprint(report: dict[str, Any]) -> dict[str, Any]: + """Extract stable boot facts from a compatibility report.""" + host = report.get("host", {}) + commands = host.get("commands", {}) + gpu = host.get("gpu", {}) + python = host.get("python", {}) + libc = host.get("libc", {}) + display = host.get("display", {}) + storage = host.get("storage", {}) + storage_layout_data = storage.get("layout", {}) + return { + "distro": host.get("distro", ""), + "kernel": host.get("kernel", ""), + "machine": host.get("machine", ""), + "libc": f"{libc.get('name', '')} {libc.get('version', '')}".strip(), + "python_version": python.get("version", ""), + "python_strategy": python.get("strategy", ""), + "gpu_name": gpu.get("name", ""), + "gpu_memory_total_mb": str(gpu.get("memory_total_mb", "")), + "driver_version": gpu.get("driver_version", ""), + "cuda_version": gpu.get("cuda_version", ""), + "git_available": _available(commands.get("git")), + "curl_available": _available(commands.get("curl")), + "tar_available": _available(commands.get("tar")), + "node_available": _available(commands.get("node")), + "npm_available": _available(commands.get("npm")), + "display_available": _available(display.get("DISPLAY")) or _available(display.get("WAYLAND_DISPLAY")), + "storage_home": storage_layout_data.get("home", ""), + } + + +def fingerprint_id(fingerprint: dict[str, Any]) -> str: + payload = json.dumps(fingerprint, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] + + +def diff_fingerprints(previous: dict[str, Any] | None, current: dict[str, Any]) -> list[dict[str, Any]]: + """Return user-facing boot changes between two host fingerprints.""" + if not previous: + return [] + changes: list[dict[str, Any]] = [] + for key in sorted(set(previous) | set(current)): + before = previous.get(key) + after = current.get(key) + if before == after: + continue + severity = "required" if key in _CRITICAL_FACTS else "recommended" + changes.append( + { + "id": key, + "label": _FACT_LABELS.get(key, key.replace("_", " ").title()), + "before": str(before) if before not in (None, "") else "missing", + "after": str(after) if after not in (None, "") else "missing", + "severity": severity, + "detail": "Re-run compatibility checks before launching installed apps.", + } + ) + return changes + + +def _result_summary( + *, + first_run: bool, + changes: list[dict[str, Any]], + compatibility: dict[str, Any], +) -> str: + issue_count = int(compatibility.get("issue_count", 0) or 0) + blocked_count = int(compatibility.get("blocked_count", 0) or 0) + if first_run: + return f"Boot baseline captured with {issue_count} compatibility item(s)." + if changes: + return f"VM image changed in {len(changes)} place(s); {blocked_count} blocked compatibility item(s)." + if issue_count: + return f"VM image unchanged; {issue_count} compatibility item(s) still need attention." + return "VM image unchanged and app compatibility is ready." + + +def _agent_helper_status(compatibility: dict[str, Any]) -> dict[str, Any]: + agent_app = next( + (app for app in compatibility.get("apps", []) if app.get("id") == "agent-lab"), + {}, + ) + local_ready = agent_app.get("status") == "ready" + action_id = agent_app.get("recommended_action_id") or "agent-lab" + return { + "offline_helper_ready": True, + "local_agent_ready": local_ready, + "mode": "local-agent-ready" if local_ready else "offline-deterministic", + "recommended_action_id": None if local_ready else action_id, + "summary": ( + "Local agent helper is ready for guided setup." + if local_ready + else "Offline setup helper is active; install Local Agent Lab for the fuller AI assistant." + ), + "requirements": agent_app.get("requirements", []), + } + + +def run_boot_preflight(home_dir: str | Path | None = None) -> dict[str, Any]: + """Run and persist the boot preflight under the selected NVH_HOME.""" + previous_state = _read_state(home_dir) + previous_result = previous_state.get("last_result") if previous_state else None + previous_fingerprint = ( + previous_state.get("last_fingerprint") + if previous_state + else None + ) + + compatibility = compatibility_report(home_dir=home_dir) + current_fingerprint = host_fingerprint(compatibility) + current_id = fingerprint_id(current_fingerprint) + previous_id = fingerprint_id(previous_fingerprint) if previous_fingerprint else None + changes = diff_fingerprints(previous_fingerprint, current_fingerprint) + first_run = previous_fingerprint is None + checked_at = datetime.now(UTC).isoformat() + agent_helper = _agent_helper_status(compatibility) + + result = { + "schema_version": STATE_SCHEMA_VERSION, + "checked_at": checked_at, + "state_file": str(_state_path(home_dir)), + "first_run": first_run, + "changed": bool(changes), + "needs_attention": bool(changes) or not compatibility.get("ready", False), + "fingerprint_id": current_id, + "previous_fingerprint_id": previous_id, + "previous_checked_at": previous_result.get("checked_at") if isinstance(previous_result, dict) else None, + "summary": _result_summary(first_run=first_run, changes=changes, compatibility=compatibility), + "changes": changes, + "agent_helper": agent_helper, + "compatibility": compatibility, + } + _write_state( + { + "schema_version": STATE_SCHEMA_VERSION, + "last_checked_at": checked_at, + "last_fingerprint": current_fingerprint, + "last_result": result, + }, + home_dir=home_dir, + ) + return result + + +def boot_preflight_status( + home_dir: str | Path | None = None, + *, + run_if_missing: bool = True, +) -> dict[str, Any]: + """Return the latest boot preflight, optionally running it once if absent.""" + state = _read_state(home_dir) + result = state.get("last_result") if state else None + if isinstance(result, dict): + return result + if run_if_missing: + return run_boot_preflight(home_dir=home_dir) + return { + "schema_version": STATE_SCHEMA_VERSION, + "checked_at": None, + "state_file": str(_state_path(home_dir)), + "first_run": True, + "changed": False, + "needs_attention": False, + "fingerprint_id": None, + "previous_fingerprint_id": None, + "previous_checked_at": None, + "summary": "Boot preflight has not run yet.", + "changes": [], + "agent_helper": { + "offline_helper_ready": True, + "local_agent_ready": False, + "mode": "offline-deterministic", + "recommended_action_id": "agent-lab", + "summary": "Offline setup helper is active; boot preflight has not checked Local Agent Lab yet.", + "requirements": [], + }, + "compatibility": None, + } diff --git a/nvh/integrations/compatibility.py b/nvh/integrations/compatibility.py index e660a17..2618798 100644 --- a/nvh/integrations/compatibility.py +++ b/nvh/integrations/compatibility.py @@ -18,7 +18,7 @@ from nvh.integrations.runtime import runtime_status from nvh.integrations.storage import storage_status -from nvh.integrations.studio_packs import BLENDER_VERSION, model_catalog_with_status +from nvh.integrations.studio_packs import BLENDER_VERSION, catalog_with_status, model_catalog_with_status @dataclass(frozen=True) @@ -341,12 +341,18 @@ def compatibility_report(home_dir: str | Path | None = None) -> dict[str, Any]: display_ready = bool(host["display"].get("DISPLAY") or host["display"].get("WAYLAND_DISPLAY")) cuda_profile = recommended_torch_profile(gpu.get("cuda_version")) model_status = model_catalog_with_status() + pack_status = catalog_with_status() + pack_by_id = {pack.get("id"): pack for pack in pack_status.get("packs", [])} recommended_models = model_status.get("recommended_ids", []) missing_recommended_models = [ model["id"] for model in model_status.get("models", []) if model.get("recommended") and not model.get("installed") ] + def _pack_installed(pack_id: str) -> bool: + status = pack_by_id.get(pack_id, {}).get("status", {}) + return bool(status.get("installed")) + apps = [ _overall( "persistent-storage", @@ -421,6 +427,7 @@ def compatibility_report(home_dir: str | Path | None = None) -> dict[str, Any]: "Local Agent Lab", "agent", [ + _req("pack", "Agent lab pack", _pack_installed("agent-lab"), "Installs the local agent helper environment under NVH_HOME.", fix_action_id="agent-lab", rootless_fix_available=True), _req("python", "Python 3.11+", _version_at_least(py["version"], "3.11"), f"Detected Python {py['version']}.", blocked=not _version_at_least(py["version"], "3.11")), _req("venv", "Python venv/pip", bool(py["venv_available"] and py["pip_available"]), f"Runtime strategy: {py['strategy']}.", fix_action_id="runtime-fallback", rootless_fix_available=True), _req("storage", "Persistent workspace", bool(storage["ok"]), "Agent packages install under NVH_HOME/studio.", fix_action_id="storage", rootless_fix_available=True), diff --git a/nvh/integrations/setup_agent.py b/nvh/integrations/setup_agent.py index 4fd1f53..6417be5 100644 --- a/nvh/integrations/setup_agent.py +++ b/nvh/integrations/setup_agent.py @@ -89,6 +89,15 @@ def _safe_compatibility_report(home_dir: str | Path | None = None) -> dict[str, return {"summary": "Compatibility unavailable", "issue_count": 0, "apps": [], "error": str(exc)} +def _safe_boot_preflight(home_dir: str | Path | None = None) -> dict[str, Any]: + try: + from nvh.integrations.boot_preflight import boot_preflight_status + + return boot_preflight_status(home_dir=home_dir, run_if_missing=False) + except Exception as exc: + return {"summary": "Boot preflight unavailable", "changes": [], "agent_helper": {}, "error": str(exc)} + + def _recent_failed_job() -> dict[str, Any] | None: try: jobs = list_jobs(limit=10) @@ -331,6 +340,7 @@ def setup_helper_report(home_dir: str | Path | None = None) -> dict[str, Any]: )) compatibility = _safe_compatibility_report(home_dir=home_dir) + boot_preflight = _safe_boot_preflight(home_dir=home_dir) for app in compatibility.get("apps", []): if app.get("status") == "ready": continue @@ -345,9 +355,21 @@ def setup_helper_report(home_dir: str | Path | None = None) -> dict[str, Any]: affected_item=app["id"], )) + boot_changes = boot_preflight.get("changes") or [] + if boot_changes: + issues.append(SetupIssue( + id="boot:vm-image-changed", + title="Base VM image changed since the last nvHive boot", + severity="recommended", + reason=boot_preflight.get("summary", "Re-run the setup preflight before launching installed apps."), + fix_action_id=None, + affected_item="boot-preflight", + )) + actions.sort(key=lambda action: action.priority) issues.sort(key=lambda issue: {"required": 0, "recommended": 1, "optional": 2}.get(issue.severity, 3)) ready = not any(action.status == "required" for action in actions) + agent_helper = boot_preflight.get("agent_helper") or {} return { "ready": ready, "summary": ( @@ -371,14 +393,21 @@ def setup_helper_report(home_dir: str | Path | None = None) -> dict[str, Any]: "rootless_fixable_count": compatibility.get("rootless_fixable_count", 0), "recommended_torch_profile": compatibility.get("recommended_torch_profile"), }, + "boot_preflight": { + "summary": boot_preflight.get("summary"), + "checked_at": boot_preflight.get("checked_at"), + "changed": bool(boot_preflight.get("changed")), + "change_count": len(boot_changes), + "agent_helper": agent_helper, + }, "assistant": { - "mode": "offline-deterministic", + "mode": agent_helper.get("mode", "offline-deterministic"), "can_read_jobs": True, "can_read_receipts": True, "can_refresh_catalog": True, "description": ( - "Local setup helper can explain next steps, inspect recent install state, " - "and suggest rootless repair commands without requiring a cloud model." + "nvWizard is the rootless setup questmaster: it checks the GPU forge, " + "watches VM image drift, and suggests repairs without requiring a cloud model." ), }, } @@ -393,6 +422,10 @@ def _commands_for_actions(actions: list[dict[str, Any]], *action_ids: str) -> li return commands +def _persona_wrap(answer: str) -> str: + return f"nvWizard says: {answer}" + + def setup_assistant_reply( question: str, home_dir: str | Path | None = None, @@ -484,7 +517,7 @@ def setup_assistant_reply( return { "question": question, - "answer": answer, + "answer": _persona_wrap(answer), "focus": focus, "commands": commands, "observations": { diff --git a/tests/test_boot_preflight.py b/tests/test_boot_preflight.py new file mode 100644 index 0000000..7aa2733 --- /dev/null +++ b/tests/test_boot_preflight.py @@ -0,0 +1,81 @@ +"""Tests for boot-time VM image preflight state.""" + +from __future__ import annotations + +from nvh.integrations import boot_preflight + + +def _compatibility_report(*, kernel: str = "6.8.0", cuda: str = "12.4", agent_ready: bool = False) -> dict: + return { + "summary": "ready", + "ready": True, + "issue_count": 0, + "blocked_count": 0, + "rootless_fixable_count": 0, + "recommended_torch_profile": "nvidia-cu121", + "host": { + "distro": "Ubuntu 24.04", + "kernel": kernel, + "machine": "x86_64", + "libc": {"name": "glibc", "version": "2.35"}, + "python": {"version": "3.11.9", "strategy": "python-venv"}, + "gpu": { + "name": "NVIDIA RTX", + "memory_total_mb": "24576", + "driver_version": "570.00", + "cuda_version": cuda, + }, + "commands": { + "git": "/usr/bin/git", + "curl": "/usr/bin/curl", + "tar": "/usr/bin/tar", + "node": "/usr/bin/node", + "npm": "/usr/bin/npm", + }, + "display": {"DISPLAY": ":0", "WAYLAND_DISPLAY": ""}, + "storage": {"layout": {"home": "/mnt/nvh"}}, + }, + "apps": [ + { + "id": "agent-lab", + "status": "ready" if agent_ready else "fixable", + "recommended_action_id": None if agent_ready else "agent-lab", + "requirements": [], + } + ], + } + + +def test_boot_preflight_captures_baseline_and_agent_helper(tmp_path, monkeypatch) -> None: + monkeypatch.setattr( + boot_preflight, + "compatibility_report", + lambda home_dir=None: _compatibility_report(agent_ready=False), + ) + + report = boot_preflight.run_boot_preflight(home_dir=tmp_path / "nvh") + + assert report["first_run"] is True + assert report["changed"] is False + assert report["agent_helper"]["mode"] == "offline-deterministic" + assert report["agent_helper"]["recommended_action_id"] == "agent-lab" + + +def test_boot_preflight_detects_image_drift(tmp_path, monkeypatch) -> None: + reports = [ + _compatibility_report(kernel="6.8.0", cuda="12.4", agent_ready=True), + _compatibility_report(kernel="6.10.0", cuda="13.0", agent_ready=True), + ] + monkeypatch.setattr( + boot_preflight, + "compatibility_report", + lambda home_dir=None: reports.pop(0), + ) + + boot_preflight.run_boot_preflight(home_dir=tmp_path / "nvh") + changed = boot_preflight.run_boot_preflight(home_dir=tmp_path / "nvh") + + change_ids = {change["id"] for change in changed["changes"]} + assert changed["changed"] is True + assert {"kernel", "cuda_version"}.issubset(change_ids) + assert changed["agent_helper"]["mode"] == "local-agent-ready" diff --git a/tests/test_compatibility.py b/tests/test_compatibility.py index 6af2b2c..94aee8a 100644 --- a/tests/test_compatibility.py +++ b/tests/test_compatibility.py @@ -66,6 +66,15 @@ def test_compatibility_report_marks_rootless_fixable(tmp_path, monkeypatch) -> N "ollama_running": False, }, ) + monkeypatch.setattr( + compatibility, + "catalog_with_status", + lambda: { + "packs": [ + {"id": "agent-lab", "status": {"installed": False}}, + ], + }, + ) report = compatibility.compatibility_report(home_dir=tmp_path / "nvh") by_id = {app["id"]: app for app in report["apps"]} @@ -74,6 +83,7 @@ def test_compatibility_report_marks_rootless_fixable(tmp_path, monkeypatch) -> N assert by_id["rootless-ollama"]["status"] == "ready" assert by_id["local-models"]["status"] == "fixable" assert by_id["local-models"]["recommended_action_id"] == "starter-models" + assert by_id["agent-lab"]["recommended_action_id"] == "agent-lab" def test_compatibility_report_blocks_missing_git_for_comfyui(tmp_path, monkeypatch) -> None: @@ -118,6 +128,11 @@ def test_compatibility_report_blocks_missing_git_for_comfyui(tmp_path, monkeypat "ollama_running": True, }, ) + monkeypatch.setattr( + compatibility, + "catalog_with_status", + lambda: {"packs": [{"id": "agent-lab", "status": {"installed": True}}]}, + ) report = compatibility.compatibility_report(home_dir=tmp_path / "nvh") comfy = {app["id"]: app for app in report["apps"]}["comfyui"] diff --git a/web/app/setup/page.tsx b/web/app/setup/page.tsx index 3ce088b..f05e757 100644 --- a/web/app/setup/page.tsx +++ b/web/app/setup/page.tsx @@ -13,7 +13,7 @@ import { getStorageStatus, configureStorage, getSetupCatalog, - getSetupCompatibility, + getSetupBootPreflight, getSetupHelper, getSetupReceipts, cancelInstallJob, @@ -36,6 +36,8 @@ import type { ComfyUIExample, ComfyUIInstallEvent, ComfyUIStatus, + ComfyUITorchProfile, + BootPreflightReport, CompatibilityReport, InstallJob, InstallReceipt, @@ -218,6 +220,7 @@ export default function SetupPage() { const [setupReceipts, setSetupReceipts] = useState(null); const [setupCatalog, setSetupCatalog] = useState(null); const [setupCompatibility, setSetupCompatibility] = useState(null); + const [bootPreflight, setBootPreflight] = useState(null); const [setupInventoryError, setSetupInventoryError] = useState(null); const [assistantQuestion, setAssistantQuestion] = useState(''); const [assistantReply, setAssistantReply] = useState(null); @@ -302,14 +305,15 @@ export default function SetupPage() { const refreshSetupInventory = useCallback(async (refreshCatalog = false, homeDir?: string) => { try { - const [receipts, catalog, compatibility] = await Promise.all([ + const [receipts, catalog, boot] = await Promise.all([ getSetupReceipts({ limit: 8 }), getSetupCatalog(refreshCatalog), - getSetupCompatibility(homeDir ?? storageStatus?.layout.home), + getSetupBootPreflight(homeDir ?? storageStatus?.layout.home), ]); setSetupReceipts(receipts); setSetupCatalog(catalog); - setSetupCompatibility(compatibility); + setBootPreflight(boot); + setSetupCompatibility(boot.compatibility); setSetupInventoryError(null); } catch (err) { setSetupInventoryError(err instanceof Error ? err.message : 'Could not load setup inventory'); @@ -360,6 +364,18 @@ export default function SetupPage() { } }; + const handleBootRecheck = async () => { + try { + const boot = await getSetupBootPreflight(storageStatus?.layout.home, true); + setBootPreflight(boot); + setSetupCompatibility(boot.compatibility); + setSetupInventoryError(null); + void refreshSetupHelper(storageStatus?.layout.home); + } catch (err) { + setSetupInventoryError(err instanceof Error ? err.message : 'Boot preflight could not run'); + } + }; + useEffect(() => { // Check API health @@ -763,7 +779,7 @@ export default function SetupPage() { setComfyEvents([]); installComfyUIStream( - { torch_profile: 'nvidia-cu130', force_update: false }, + { torch_profile: recommendedTorchProfile, force_update: false }, { onJob: job => { mergeInstallJob(job); @@ -868,6 +884,16 @@ export default function SetupPage() { const compatibilityIssueCount = setupCompatibility?.issue_count ?? setupHelper?.compatibility?.issue_count ?? 0; const compatibilityBlockedCount = setupCompatibility?.blocked_count ?? setupHelper?.compatibility?.blocked_count ?? 0; const compatibilityFixableCount = setupCompatibility?.rootless_fixable_count ?? setupHelper?.compatibility?.rootless_fixable_count ?? 0; + const bootChangeCount = bootPreflight?.changes.length ?? setupHelper?.boot_preflight?.change_count ?? 0; + const bootAgentHelper = bootPreflight?.agent_helper ?? setupHelper?.boot_preflight?.agent_helper; + const detectedTorchProfile = setupCompatibility?.recommended_torch_profile + ?? setupHelper?.compatibility?.recommended_torch_profile + ?? 'nvidia-cu121'; + const recommendedTorchProfile: ComfyUITorchProfile = ( + ['nvidia-cu130', 'nvidia-cu121', 'cpu', 'skip'].includes(detectedTorchProfile) + ? detectedTorchProfile + : 'nvidia-cu121' + ) as ComfyUITorchProfile; const runHelperAction = (actionId: string) => { if (actionId.startsWith('repair-receipt:')) { @@ -1087,7 +1113,10 @@ export default function SetupPage() {
+ {(bootPreflight || setupHelper?.boot_preflight) && ( +
+
+
+
nvWizard Boot Watch
+
+ {bootPreflight?.summary ?? setupHelper?.boot_preflight?.summary ?? 'Boot preflight runs when nvHive launches.'} +
+
+
+ + {bootChangeCount ? `${bootChangeCount} shift${bootChangeCount === 1 ? '' : 's'}` : 'image steady'} + + + {bootAgentHelper?.local_agent_ready ? 'agent awake' : 'offline guide'} + +
+
+
+ {bootAgentHelper?.summary ?? 'Offline setup helper is available before any cloud or local model is installed.'} +
+ {bootAgentHelper?.recommended_action_id && ( + + )} + {bootPreflight?.changes && bootPreflight.changes.length > 0 && ( +
+ {bootPreflight.changes.slice(0, 5).map(change => ( +
+ + {change.label}: {change.before} {'->'} {change.after} +
+ ))} +
+ )} +
+ )} {(setupCompatibility || setupHelper?.compatibility) && (
@@ -1241,7 +1317,7 @@ export default function SetupPage() {
-
Local Setup Helper
+
nvWizard Quest Log
{setupHelper?.summary ?? 'Offline setup recommendations'}
@@ -1356,9 +1432,9 @@ export default function SetupPage() {
-
Ask Setup Helper
+
Ask nvWizard
- Offline local guidance using jobs, receipts, storage, and catalog state + GPU questmaster guidance from jobs, receipts, storage, and catalog state
@@ -1370,7 +1446,7 @@ export default function SetupPage() { value={assistantQuestion} onChange={event => setAssistantQuestion(event.target.value)} onKeyDown={event => { if (event.key === 'Enter') void handleAskAssistant(); }} - placeholder="Why did ComfyUI fail? What should I install next?" + placeholder="What quest is blocked? Why did ComfyUI fail?" className="input-base flex-1 px-3 py-2 text-xs font-mono" />
)} + {missionControl && ( +
+
+
+
Mission Timeline
+
{missionControl.summary}
+
+ +
+
+ {missionStages.slice(0, 6).map(stage => ( + + ))} +
+
+
+
Mount Autopilot
+
+ {mountRecommendation?.recommended_home ?? 'No mount picked yet'} +
+
+ score {mountRecommendation?.score ?? 0} +
+
+
+
Auto Repair
+
+ {autoRepairActions.filter(action => action.safe_to_auto_run).length} safe / {autoRepairActions.filter(action => !action.safe_to_auto_run).length} confirm +
+
+ env, catalog, examples only +
+
+
+
Smoke Tests
+
+ {smokeTests?.summary ?? 'Waiting for checks'} +
+
+ models: {modelFit?.recommended_ids?.slice(0, 3).join(', ') || 'no queue'} +
+
+
+
+ )} {(setupCompatibility || setupHelper?.compatibility) && (
diff --git a/web/lib/api.ts b/web/lib/api.ts index ee68e40..dc8d248 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -42,6 +42,9 @@ import type { SetupReceiptsResult, CompatibilityReport, BootPreflightReport, + MissionControlReport, + AutoRepairResult, + MountAutopilotReport, ComfyUIExamplesResult, ComfyUIInstallEvent, ComfyUIInstallRequest, @@ -138,6 +141,21 @@ export async function configureStorage(request: StorageConfigureRequest): Promis return apiPost('/v1/system/storage', request); } +export async function getMountAutopilot(minFreeGb = 20): Promise { + return apiGet(`/v1/system/mount-autopilot?min_free_gb=${encodeURIComponent(String(minFreeGb))}`); +} + +export async function activateMountAutopilot(homeDir?: string, minFreeGb = 20): Promise<{ + summary: string; + storage: StorageStatus; + mount_autopilot: MountAutopilotReport; +}> { + return apiPost('/v1/system/mount-autopilot/activate', { + home_dir: homeDir, + min_free_gb: minFreeGb, + }); +} + export async function getRuntimeStatus(): Promise { return apiGet('/v1/system/runtime'); } @@ -178,6 +196,17 @@ export async function getSetupBootPreflight(homeDir?: string, recheck = false): return apiGet(`/v1/setup/boot-preflight${qs ? `?${qs}` : ''}`); } +export async function getSetupMissionControl(homeDir?: string): Promise { + const qs = homeDir ? `?home_dir=${encodeURIComponent(homeDir)}` : ''; + return apiGet(`/v1/setup/mission-control${qs}`); +} + +export async function repairSetupWorkspace(homeDir?: string): Promise { + return apiPost('/v1/setup/repair-workspace', { + home_dir: homeDir, + }); +} + export async function getSetupReceipts(options: { kind?: string; status?: string; diff --git a/web/lib/types.ts b/web/lib/types.ts index 09cd5da..5920837 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -623,10 +623,113 @@ export interface BootPreflightReport { summary: string; changes: BootPreflightChange[]; agent_helper: BootAgentHelper; + mount_autopilot?: MountAutopilotReport | null; + auto_repair?: AutoRepairPlan | AutoRepairResult | null; + smoke_tests?: SmokeTestReport | null; + model_fit?: { + summary?: string; + detected_vram_gb?: number; + recommended_ids?: string[]; + } | null; compatibility: CompatibilityReport | null; error?: string; } +export interface MountCandidate { + path: string; + recommended_home: string; + label: string; + source: string; + exists: boolean; + writable: boolean; + free_gb: number | null; + total_gb: number | null; + score: number; + warnings: string[]; + evidence: string[]; +} + +export interface MountAutopilotReport { + summary: string; + confidence: string; + current: StorageStatus; + recommended: MountCandidate | null; + candidates: MountCandidate[]; +} + +export interface AutoRepairAction { + id: string; + title: string; + status: string; + summary: string; + safe_to_auto_run: boolean; + action_type: string; + button_action_id: string; +} + +export interface AutoRepairPlan { + summary: string; + auto_count: number; + needs_user_count: number; + actions: AutoRepairAction[]; +} + +export interface AutoRepairResult { + summary: string; + completed: Array; + skipped: Array; + errors: Array; + plan: AutoRepairPlan; +} + +export interface SmokeTestItem { + id: string; + title: string; + status: 'pass' | 'warn' | 'fail' | 'skip' | string; + summary: string; + detail: string; + action_id: string | null; +} + +export interface SmokeTestReport { + summary: string; + ready: boolean; + passed: number; + warnings: number; + failed: number; + tests: SmokeTestItem[]; +} + +export interface ModelFitReport { + summary: string; + detected_vram_gb: number; + free_gb: number | null; + recommended_ids: string[]; + best_by_use_case: Record>; + models: Array>; + ollama_available: boolean; + ollama_running: boolean; +} + +export interface MissionStage { + id: string; + title: string; + status: 'pass' | 'warn' | 'fail' | string; + summary: string; + action_id: string | null; +} + +export interface MissionControlReport { + summary: string; + ready: boolean; + stages: MissionStage[]; + boot_preflight: BootPreflightReport; + mount_autopilot: MountAutopilotReport; + auto_repair: AutoRepairPlan; + smoke_tests: SmokeTestReport; + model_fit: ModelFitReport; +} + export interface SetupAssistantReply { question: string; answer: string; From 9f035d99b238bb5f68c06a6b3cdf185d189fdb9e Mon Sep 17 00:00:00 2001 From: thatcooperguy <129788948+thatcooperguy@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:01:35 -0500 Subject: [PATCH 06/21] Add beginner launcher and resilience advisor --- README.md | 47 +++++++++--- docs/LINUX_DESKTOP.md | 26 +++++++ install.sh | 114 ++++++++++++++++++++++++++-- nvh/cli/main.py | 2 +- nvh/core/agents.py | 24 +++++- start-linux.sh | 168 +++++++++++++++++++++++++++++++++++++++++ tests/test_agents.py | 12 +++ web/app/setup/page.tsx | 110 ++++++++++++++++++++++++--- 8 files changed, 477 insertions(+), 26 deletions(-) create mode 100644 start-linux.sh diff --git a/README.md b/README.md index b418e5c..74a9dec 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,23 @@ nvh "setup comfyui" # → agent installs, configure ## Install -Three ways to get nvHive — pick the one that matches your setup. No Docker, no container runtime, no root required. +Four ways to get nvHive - pick the one that matches your setup. No Docker, no container runtime, no root required. -### Option 1 — One-line installer (recommended for GPU VMs) +### Option 0 - Launch a Linux GPU desktop lab + +This is the easiest path for GeForce NOW-style Linux sessions and cloud desktops where only a mounted file volume survives reconnects: + +```bash +curl -sSL https://raw.githubusercontent.com/thatcooperguy/nvHive/main/start-linux.sh | bash +``` + +The launcher auto-detects a likely persistent mount, sets `NVH_HOME`, installs nvHive rootlessly if needed, creates the desktop launcher, starts the API/WebUI, and opens the setup wizard. If Python is missing, set `NVH_USE_BINARY=1` and the same launcher downloads the single-file Linux binary instead of creating a venv. + +```bash +curl -sSL https://raw.githubusercontent.com/thatcooperguy/nvHive/main/start-linux.sh | NVH_USE_BINARY=1 bash +``` + +### Option 1 - One-line installer (recommended for GPU VMs) ```bash curl -sSL https://raw.githubusercontent.com/thatcooperguy/nvHive/main/install.sh | bash @@ -30,12 +44,14 @@ curl -sSL https://raw.githubusercontent.com/thatcooperguy/nvHive/main/install.sh Works on any Linux box with no root. Installs to `NVH_HOME` when set, otherwise `~/.nvh/` for new installs, uses Python `venv` + `pip` by default, offers a rootless micromamba fallback only when the cloud image needs it, pulls Ollama if you have an NVIDIA GPU, and writes a sensible default config. +If `NVH_HOME` is not set, the installer now checks common persistent mount roots such as `/mnt`, `/media/$USER`, `/workspace`, `/data`, `/persistent`, and `/storage` before falling back to `~/.nvh`. + Windows: `iwr -useb https://raw.githubusercontent.com/thatcooperguy/nvHive/main/install.ps1 | iex` macOS: `curl -sSL https://raw.githubusercontent.com/thatcooperguy/nvHive/main/install-mac.sh | bash` -### Option 2 — Single-file binary (no Python needed) +### Option 2 - Single-file binary (no Python needed) -Fully standalone. No Python install, no pip, no venv. Click your OS: +Fully standalone. No Python install, no pip, no venv. Download the asset, make it executable, and run `nvh workstation --launch`. Click your OS:

@@ -51,9 +67,18 @@ Fully standalone. No Python install, no pip, no venv. Click your OS:

-On Linux/macOS after download: `chmod +x nvh-* && ./nvh-*`. Full asset list (wheel, sdist, checksums) lives on the [Releases page](https://github.com/thatcooperguy/nvHive/releases/latest). +Linux terminal path: + +```bash +mkdir -p "$HOME/.local/bin" +curl -fL https://github.com/thatcooperguy/nvHive/releases/latest/download/nvh-linux-x86_64 -o "$HOME/.local/bin/nvh" +chmod +x "$HOME/.local/bin/nvh" +NVH_HOME=/mnt/persist/nvhive "$HOME/.local/bin/nvh" workstation --launch -y +``` + +On Linux/macOS after a browser download: `chmod +x nvh-* && ./nvh-* workstation --launch -y`. Full asset list (wheel, sdist, checksums) lives on the [Releases page](https://github.com/thatcooperguy/nvHive/releases/latest). -### Option 3 — pip from PyPI (for existing Python environments) +### Option 3 - pip from PyPI (for existing Python environments) ```bash pip install nvhive # core @@ -69,7 +94,8 @@ nvh # guided setup — GPU detect, provider keys, l nvh workstation --all -y # Linux GPU desktop: launcher + WebUI + ComfyUI + studio packs nvh webui # Setup > Models lets you choose exact local downloads nvh studio --install starter -y # rootless LLMs + agents + ComfyUI nodes + game-dev tools -nvh "your question" # just ask — nvHive figures out the rest +nvh convene --cabinet product_resilience "How can this setup fail for a beginner?" +nvh "your question" # just ask - nvHive figures out the rest ``` For a fresh Linux cloud desktop where only a mounted file volume persists, @@ -105,9 +131,10 @@ showing progress after a browser refresh and can be canceled from the wizard. For pip installs, the downloaded WebUI, npm cache, and no-root Node fallback also live under `$NVH_HOME`, so reconnecting to a cloud desktop does not mean starting the frontend toolchain from scratch. -The wizard now includes a local setup helper that ranks the next storage, -runtime, model, ComfyUI, and creative-tool actions before any local LLM is -installed. +The wizard now opens in Beginner Mode: one recommended action, Fix My Setup, +and Advanced Details only when the student wants the full diagnostics. It also +includes a local setup helper that ranks the next storage, runtime, model, +ComfyUI, and creative-tool actions before any local LLM is installed. The ComfyUI step lets students select workflow examples and save a model download plan with source links, folder targets, and a helper checklist script, because many image/video weights are large or require upstream terms. diff --git a/docs/LINUX_DESKTOP.md b/docs/LINUX_DESKTOP.md index a0a6497..36effde 100644 --- a/docs/LINUX_DESKTOP.md +++ b/docs/LINUX_DESKTOP.md @@ -11,6 +11,22 @@ Target user journey: ## Quick Start +Easiest path: + +```bash +curl -sSL https://raw.githubusercontent.com/thatcooperguy/nvHive/main/start-linux.sh | bash +``` + +That script chooses a likely persistent mount for `NVH_HOME`, installs nvHive +without root, creates the desktop launcher, and starts the WebUI setup wizard. +To force the no-Python binary path: + +```bash +curl -sSL https://raw.githubusercontent.com/thatcooperguy/nvHive/main/start-linux.sh | NVH_USE_BINARY=1 bash +``` + +Manual path: + ```bash export NVH_HOME=/mnt/persist/nvhive curl -sSL https://raw.githubusercontent.com/thatcooperguy/nvHive/main/install.sh | bash @@ -33,6 +49,11 @@ nvh workstation nvh webui ``` +The setup wizard starts in Beginner Mode with one recommended action, a Fix My +Setup repair button, and Advanced Details for diagnostics. It is designed so a +student can click through storage, models, ComfyUI, and creative packs without +typing manual commands. + ## What `nvh workstation` Does - Detects NVIDIA GPU availability with `nvidia-smi` @@ -40,6 +61,7 @@ nvh webui - Creates `$NVH_HOME/bin/nvhive-ai-studio` - Creates a Linux desktop launcher named `NVHive AI Studio` - Shows a student-friendly setup checklist +- Runs nvWizard boot checks for storage, Python, CUDA/PyTorch, ComfyUI, models, and install receipts - With `--all`, ensures local AI, installs ComfyUI, installs the rootless starter pack, and launches WebUI - Uses user-space paths only under `NVH_HOME` for durable models, ComfyUI, packs, runtime fallback tools, apps, WebUI assets, cache, logs, and config @@ -88,6 +110,10 @@ The local setup helper endpoint, `/v1/setup/helper`, works offline. It ranks the next storage, runtime, model, ComfyUI, and creative-tool actions before any local LLM is installed. +The council also includes a `product_resilience` preset with an Underdog Student +Advocate. Use it when you want a skeptical review of what could break for a +beginner on a no-root cloud GPU desktop. + CLI equivalents: ```bash diff --git a/install.sh b/install.sh index aa0251a..33e5a2b 100755 --- a/install.sh +++ b/install.sh @@ -27,15 +27,114 @@ set -euo pipefail G='\033[0;32m'; Y='\033[1;33m'; B='\033[0;34m'; R='\033[0;31m'; D='\033[0;90m'; N='\033[0m' -if [ -z "${NVH_HOME:-}" ]; then +free_gb_for_path() { + df -Pk "$1" 2>/dev/null | awk 'NR==2 {printf "%d", $4 / 1048576}' +} + +score_nvh_home_candidate() { + local base="${1%/}" + [ -n "$base" ] || return 1 + [ -d "$base" ] || return 1 + [ -w "$base" ] || return 1 + + local name home free_gb score + name="$(basename "$base")" + home="$base/nvhive" + case "$name" in + nvh|nvhive|.nvh) home="$base" ;; + esac + + score=0 + case "$base" in + "$HOME"|"$HOME/"*) score=$((score - 15)) ;; + /mnt/*|/media/*|/workspace*|/data*|/persistent*|/storage*) score=$((score + 45)) ;; + esac + case "$base" in + *persist*|*Persist*|*workspace*|*Workspace*|*project*|*Project*|*data*|*Data*) + score=$((score + 20)) + ;; + *tmp*|*cache*|*Cache*) + score=$((score - 40)) + ;; + esac + free_gb="$(free_gb_for_path "$base")" + free_gb="${free_gb:-0}" + if [ "$free_gb" -ge 100 ]; then + score=$((score + 35)) + elif [ "$free_gb" -ge 50 ]; then + score=$((score + 30)) + elif [ "$free_gb" -ge 20 ]; then + score=$((score + 20)) + elif [ "$free_gb" -ge 10 ]; then + score=$((score + 8)) + else + score=$((score - 15)) + fi + if [ -f "$home/nvh-env.sh" ] || [ -d "$home/repo" ] || [ -d "$home/models" ]; then + score=$((score + 30)) + fi + printf '%s|%s\n' "$score" "$home" +} + +detect_nvh_home() { + local roots=() + local env_name env_value root child scored score home best_score best_home + if [ -d "$HOME/nvh/repo" ] && [ ! -d "$HOME/.nvh/repo" ]; then - NVH_HOME="$HOME/nvh" + printf '%s\n' "$HOME/nvh" + return 0 + fi + + for env_name in NVH_MOUNT PERSISTENT_HOME PERSISTENT_DIR PERSISTENT_STORAGE WORKSPACE PROJECTS PROJECT_HOME DATA_DIR; do + env_value="${!env_name:-}" + [ -n "$env_value" ] && roots+=("$env_value") + done + roots+=("/mnt" "/media/${USER:-}" "/workspace" "/data" "/persistent" "/storage") + + best_score=-999 + best_home="" + for root in "${roots[@]}"; do + [ -n "$root" ] || continue + if scored="$(score_nvh_home_candidate "$root")"; then + score="${scored%%|*}" + home="${scored#*|}" + if [ "$score" -gt "$best_score" ]; then + best_score="$score" + best_home="$home" + fi + fi + [ -d "$root" ] || continue + for child in "$root"/*; do + [ -d "$child" ] || continue + if scored="$(score_nvh_home_candidate "$child")"; then + score="${scored%%|*}" + home="${scored#*|}" + if [ "$score" -gt "$best_score" ]; then + best_score="$score" + best_home="$home" + fi + fi + done + done + + if [ -n "$best_home" ] && [ "$best_score" -ge 55 ]; then + printf '%s\n' "$best_home" + return 0 + fi + return 1 +} + +if [ -z "${NVH_HOME:-}" ]; then + if NVH_HOME="$(detect_nvh_home)"; then + NVH_HOME_AUTOPILOT=true else NVH_HOME="$HOME/.nvh" + NVH_HOME_AUTOPILOT=false fi NVH_HOME_CONFIGURED=false else NVH_HOME_CONFIGURED=true + NVH_HOME_AUTOPILOT=false fi NVH_VENV="$NVH_HOME/venv" NVH_REPO="$NVH_HOME/repo" @@ -94,9 +193,14 @@ echo "" # Find Python — check common locations since the VM may have it anywhere # --------------------------------------------------------------------------- if [ "$NVH_HOME_CONFIGURED" = "false" ]; then - echo -e "${Y}NVH_HOME was not set; using ${G}$NVH_HOME${N}" - echo -e "${D}For cloud desktops, set NVH_HOME to the mounted persistent file volume before install.${N}" - echo -e "${D}Example: export NVH_HOME=/mnt/persist/nvhive${N}" + if [ "$NVH_HOME_AUTOPILOT" = "true" ]; then + echo -e "${G}Mount autopilot selected ${NVH_HOME}${N}" + echo -e "${D}Override anytime with: export NVH_HOME=/path/on/persistent/mount${N}" + else + echo -e "${Y}NVH_HOME was not set; using ${G}$NVH_HOME${N}" + echo -e "${D}For cloud desktops, set NVH_HOME to the mounted persistent file volume before install.${N}" + echo -e "${D}Example: export NVH_HOME=/mnt/persist/nvhive${N}" + fi echo "" fi echo -e "${D}Persistent home: $NVH_HOME${N}" diff --git a/nvh/cli/main.py b/nvh/cli/main.py index 12ff3ef..3206698 100644 --- a/nvh/cli/main.py +++ b/nvh/cli/main.py @@ -1694,7 +1694,7 @@ def convene_cmd( preset: str | None = typer.Option( None, "--cabinet", help="Agent cabinet: executive, engineering," - " security_review, code_review, product, data, full_board", + " security_review, code_review, product, product_resilience, data, full_board", ), num_agents: int | None = typer.Option( None, "--num-agents", "-n", diff --git a/nvh/core/agents.py b/nvh/core/agents.py index 540561d..6a73632 100644 --- a/nvh/core/agents.py +++ b/nvh/core/agents.py @@ -140,6 +140,23 @@ class PersonaTemplate: triggers=["ux", "user experience", "design", "wireframe", "prototype", "usability", "accessibility", "information architecture", "user flow", "persona"], ), + PersonaTemplate( + role="Underdog Student Advocate", + expertise="beginner onboarding, rootless Linux cloud desktops, self-healing setup, failure-mode discovery", + perspective="skeptical review from a smart student with no sudo, limited time, shifting VM images, and one persistent file mount", + triggers=["student", "beginner", "wizard", "self-healing", "self healing", "rootless", + "install", "setup", "comfyui", "gpu", "driver", "cuda", "mount", + "persistent", "cloud desktop", "geforce now", "easy to use", "broken"], + weight_boost=0.18, + system_prompt=( + "You are the **Underdog Student Advocate**. Your job is to be the useful skeptic " + "in the room: assume the user is smart but busy, has no root access, may be on a " + "fresh Linux GPU VM, and may lose everything outside the mounted file volume. " + "Look for confusing copy, hidden manual steps, fragile installs, old CUDA or Python " + "versions, missing disk checks, slow downloads, and places where nvHive should heal " + "itself or clearly explain the next safe action." + ), + ), PersonaTemplate( role="Engineering Manager", expertise="team leadership, project management, hiring, engineering culture", @@ -278,7 +295,7 @@ def generate_agents( role=template.role, expertise=template.expertise, perspective=template.perspective, - system_prompt=_build_system_prompt(template, query), + system_prompt=template.system_prompt if template.system_prompt else _build_system_prompt(template, query), weight_boost=template.weight_boost, )) @@ -348,6 +365,11 @@ def generate_agents_with_llm( if t.role in ("Product Manager", "UX Designer", "Engineering Manager", "CEO / Business Strategist") ], + "product_resilience": [ + t for t in _PERSONA_POOL + if t.role in ("Underdog Student Advocate", "Product Manager", "UX Designer", + "DevOps/SRE Engineer", "QA/Test Engineer", "ML/AI Engineer") + ], "data": [ t for t in _PERSONA_POOL if t.role in ("Data Engineer", "Database Administrator", "ML/AI Engineer", diff --git a/start-linux.sh b/start-linux.sh new file mode 100644 index 0000000..ad8b368 --- /dev/null +++ b/start-linux.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# NVHive Linux GPU desktop launcher. +# Rootless, mount-aware, and safe to run from a GitHub download link. + +set -euo pipefail + +G='\033[0;32m'; Y='\033[1;33m'; D='\033[0;90m'; R='\033[0;31m'; N='\033[0m' +REPO_RAW="${NVH_REPO_RAW:-https://raw.githubusercontent.com/thatcooperguy/nvHive/main}" +LINUX_BINARY_URL="${NVH_BINARY_URL:-https://github.com/thatcooperguy/nvHive/releases/latest/download/nvh-linux-x86_64}" + +free_gb_for_path() { + df -Pk "$1" 2>/dev/null | awk 'NR==2 {printf "%d", $4 / 1048576}' +} + +score_candidate() { + local base="${1%/}" + [ -d "$base" ] || return 1 + [ -w "$base" ] || return 1 + local name home free_gb score + name="$(basename "$base")" + home="$base/nvhive" + case "$name" in + nvh|nvhive|.nvh) home="$base" ;; + esac + score=0 + case "$base" in + "$HOME"|"$HOME/"*) score=$((score - 15)) ;; + /mnt/*|/media/*|/workspace*|/data*|/persistent*|/storage*) score=$((score + 45)) ;; + esac + case "$base" in + *persist*|*Persist*|*workspace*|*Workspace*|*project*|*Project*|*data*|*Data*) score=$((score + 20)) ;; + *tmp*|*cache*|*Cache*) score=$((score - 40)) ;; + esac + free_gb="$(free_gb_for_path "$base")" + free_gb="${free_gb:-0}" + if [ "$free_gb" -ge 100 ]; then + score=$((score + 35)) + elif [ "$free_gb" -ge 50 ]; then + score=$((score + 30)) + elif [ "$free_gb" -ge 20 ]; then + score=$((score + 20)) + elif [ "$free_gb" -lt 10 ]; then + score=$((score - 15)) + fi + if [ -f "$home/nvh-env.sh" ] || [ -d "$home/repo" ] || [ -d "$home/models" ]; then + score=$((score + 30)) + fi + printf '%s|%s\n' "$score" "$home" +} + +detect_home() { + local roots=() + local env_name env_value root child scored score home best_score best_home + if [ -d "$HOME/nvh/repo" ] && [ ! -d "$HOME/.nvh/repo" ]; then + printf '%s\n' "$HOME/nvh" + return 0 + fi + for env_name in NVH_MOUNT PERSISTENT_HOME PERSISTENT_DIR PERSISTENT_STORAGE WORKSPACE PROJECTS PROJECT_HOME DATA_DIR; do + env_value="${!env_name:-}" + [ -n "$env_value" ] && roots+=("$env_value") + done + roots+=("/mnt" "/media/${USER:-}" "/workspace" "/data" "/persistent" "/storage") + best_score=-999 + best_home="" + for root in "${roots[@]}"; do + [ -n "$root" ] || continue + if scored="$(score_candidate "$root")"; then + score="${scored%%|*}" + home="${scored#*|}" + if [ "$score" -gt "$best_score" ]; then + best_score="$score" + best_home="$home" + fi + fi + [ -d "$root" ] || continue + for child in "$root"/*; do + [ -d "$child" ] || continue + if scored="$(score_candidate "$child")"; then + score="${scored%%|*}" + home="${scored#*|}" + if [ "$score" -gt "$best_score" ]; then + best_score="$score" + best_home="$home" + fi + fi + done + done + if [ -n "$best_home" ] && [ "$best_score" -ge 55 ]; then + printf '%s\n' "$best_home" + return 0 + fi + return 1 +} + +find_python() { + for py in python3.12 python3.11 python3.10 python3; do + if command -v "$py" >/dev/null 2>&1; then + echo "$py" + return 0 + fi + done + return 1 +} + +install_binary() { + mkdir -p "$NVH_HOME/bin" + echo -e "${Y}Python was not found, so nvHive is using the single-file Linux binary.${N}" + echo -e "${D}Downloading: $LINUX_BINARY_URL${N}" + if command -v curl >/dev/null 2>&1; then + curl -fL "$LINUX_BINARY_URL" -o "$NVH_HOME/bin/nvh" + elif command -v wget >/dev/null 2>&1; then + wget -O "$NVH_HOME/bin/nvh" "$LINUX_BINARY_URL" + else + echo -e "${R}Need curl or wget to download the nvHive binary.${N}" + exit 1 + fi + chmod +x "$NVH_HOME/bin/nvh" +} + +if [ -z "${NVH_HOME:-}" ]; then + if NVH_HOME="$(detect_home)"; then + echo -e "${G}Mount autopilot selected ${NVH_HOME}${N}" + else + NVH_HOME="$HOME/.nvh" + echo -e "${Y}No persistent mount was obvious; using ${NVH_HOME}.${N}" + fi +fi +export NVH_HOME +export NVH_BIN="$NVH_HOME/bin" +export PATH="$NVH_BIN:$PATH" + +echo "" +echo -e "${G}NVHive Linux Launch${N}" +echo -e "${D}Persistent home: $NVH_HOME${N}" + +if [ -f "$NVH_HOME/nvh-env.sh" ]; then + # shellcheck disable=SC1091 + source "$NVH_HOME/nvh-env.sh" +fi + +if ! command -v nvh >/dev/null 2>&1; then + if [ -x "$NVH_HOME/venv/bin/nvh" ]; then + export PATH="$NVH_HOME/venv/bin:$PATH" + elif [ "${NVH_USE_BINARY:-0}" = "1" ] || ! find_python >/dev/null; then + install_binary + else + echo -e "${G}Installing nvHive rootlessly into ${NVH_HOME}${N}" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$REPO_RAW/install.sh" | bash + elif command -v wget >/dev/null 2>&1; then + wget -qO- "$REPO_RAW/install.sh" | bash + else + echo -e "${R}Need curl or wget to install nvHive.${N}" + exit 1 + fi + [ -f "$NVH_HOME/nvh-env.sh" ] && source "$NVH_HOME/nvh-env.sh" + [ -x "$NVH_HOME/venv/bin/nvh" ] && export PATH="$NVH_HOME/venv/bin:$PATH" + fi +fi + +if ! command -v nvh >/dev/null 2>&1; then + echo -e "${R}nvh is still not on PATH after install.${N}" + echo -e "${D}Try: source \"$NVH_HOME/nvh-env.sh\"${N}" + exit 1 +fi + +echo -e "${G}Launching nvHive workstation and WebUI.${N}" +nvh workstation --home-dir "$NVH_HOME" --launch -y diff --git a/tests/test_agents.py b/tests/test_agents.py index 04142a4..5092a92 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -95,6 +95,7 @@ def test_list_presets(self): assert "security_review" in presets assert "code_review" in presets assert "product" in presets + assert "product_resilience" in presets assert "data" in presets assert "full_board" in presets @@ -115,6 +116,17 @@ def test_invalid_preset(self): with pytest.raises(ValueError, match="Unknown preset"): get_preset_agents("nonexistent", "test") + def test_product_resilience_preset_includes_underdog_advocate(self): + agents = get_preset_agents( + "product_resilience", + "Make nvHive self-healing for rootless GPU cloud desktop students", + ) + roles = [a.role for a in agents] + assert "Underdog Student Advocate" in roles + advocate = next(a for a in agents if a.role == "Underdog Student Advocate") + assert "no root access" in advocate.system_prompt + assert "mounted file volume" in advocate.system_prompt + def test_preset_agents_have_system_prompts(self): agents = get_preset_agents("engineering", "Build a REST API") for agent in agents: diff --git a/web/app/setup/page.tsx b/web/app/setup/page.tsx index d15a283..f66b0e1 100644 --- a/web/app/setup/page.tsx +++ b/web/app/setup/page.tsx @@ -267,6 +267,7 @@ export default function SetupPage() { const [installJobs, setInstallJobs] = useState([]); const [jobsError, setJobsError] = useState(null); const [cancelingJobId, setCancelingJobId] = useState(null); + const [advancedSetupOpen, setAdvancedSetupOpen] = useState(false); // Live-polled provider health drives Ollama status and the // configured-providers list so the setup screen reflects newly @@ -932,6 +933,14 @@ export default function SetupPage() { ? detectedTorchProfile : 'nvidia-cu121' ) as ComfyUITorchProfile; + const setupConcernCount = + (setupInventoryError ? 1 : 0) + + (setupHelperError ? 1 : 0) + + unhealthyReceiptCount + + compatibilityIssueCount + + bootChangeCount; + const showAdvancedSetup = advancedSetupOpen || setupConcernCount > 0 || activeInstallJobs.length > 0; + const topHelperAction = helperActions[0] ?? null; const runHelperAction = (actionId: string) => { if (actionId.startsWith('repair-receipt:')) { @@ -1041,9 +1050,9 @@ export default function SetupPage() {
-
First-Time Setup
-

Setup Wizard

-

Get Hive configured and running in minutes

+
nvWizard Setup
+

Student AI Lab

+

Pick a durable home once; nvWizard handles the rest

@@ -1079,6 +1088,90 @@ export default function SetupPage() { ))}
+ {step === 'welcome' && ( +
+
+
+
Beginner Mode
+
+ {storageReady ? 'Start with the recommended lab' : 'First, choose the persistent file mount'} +
+
+ nvWizard checks storage, GPU, CUDA, Python, ComfyUI, models, and install receipts, then recommends the next safe action. Manual commands stay available under Advanced Details. +
+ {topHelperAction && ( +
+
Recommended next
+
{topHelperAction.title}
+
{topHelperAction.reason}
+
+ )} +
+
+ + + +
+
+
+
+
Storage
+
+ {storageReady ? 'ready' : 'needed'} +
+
+
+
Checks
+
+ {setupConcernCount ? `${setupConcernCount} to review` : 'clear'} +
+
+
+
Jobs
+
+ {activeInstallJobs.length ? `${activeInstallJobs.length} running` : 'idle'} +
+
+
+
API
+
+ {apiStatus === 'connected' ? 'online' : 'checking'} +
+
+
+
+ )} + {(visibleInstallJobs.length > 0 || jobsError) && (
@@ -1157,7 +1250,7 @@ export default function SetupPage() {
)} - {(setupReceipts || setupCatalog || setupInventoryError) && ( + {showAdvancedSetup && (setupReceipts || setupCatalog || setupInventoryError) && (
@@ -1439,7 +1532,7 @@ export default function SetupPage() {
)} - {(setupHelper || setupHelperError) && ( + {showAdvancedSetup && (setupHelper || setupHelperError) && (
@@ -1645,12 +1738,11 @@ export default function SetupPage() { C
-

Welcome to Hive

-

AI Command Center - NVIDIA Powered

+

Welcome to nvHive

+

NVIDIA-powered AI lab, guided by nvWizard

- Hive lets you run multiple AI advisors in parallel - locally on your NVIDIA GPU with zero cost, - or via cloud APIs. This wizard will get you set up in minutes. + Choose what you want to build. nvWizard keeps the heavy downloads, rootless tools, and ComfyUI setup on your persistent file mount.
From 9e3b00843ba5fb25e335936fb5c9a4aec2398f80 Mon Sep 17 00:00:00 2001 From: thatcooperguy <129788948+thatcooperguy@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:21:54 -0500 Subject: [PATCH 07/21] Add Claw agent wizard packs --- README.md | 7 +- docs/LINUX_DESKTOP.md | 22 +- nvh/cli/main.py | 2 +- nvh/integrations/compatibility.py | 31 +- nvh/integrations/setup_agent.py | 42 +++ nvh/integrations/smoke_tests.py | 19 ++ nvh/integrations/studio_packs.py | 549 +++++++++++++++++++++++++++++- tests/test_studio_packs.py | 40 +++ web/app/setup/page.tsx | 86 ++++- 9 files changed, 770 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 74a9dec..d5f99c3 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ source "$NVH_HOME/nvh-env.sh" nvh workstation --home-dir "$NVH_HOME" --all -y ``` -`nvh workstation --all -y` creates a desktop launcher, starts the WebUI, prepares rootless local model tooling, installs ComfyUI with nvHive starter workflow examples, and adds AI Studio packs for LLMs, agents, ComfyUI nodes, and Linux game projects. +`nvh workstation --all -y` creates a desktop launcher, starts the WebUI, prepares rootless local model tooling, installs ComfyUI with nvHive starter workflow examples, and adds AI Studio packs for LLMs, agents, Claw agent options, ComfyUI nodes, and Linux game projects. Use packs directly when you want a specific no-root lab: @@ -118,6 +118,7 @@ nvh studio --models nvh studio --install-models recommended -y nvh studio --install llms -y nvh studio --install agents -y +nvh studio --install claw -y # OpenClaw + NemoClaw when Docker/OpenShell is usable nvh studio --install comfy -y nvh studio --install game -y nvh studio --install creative -y # Blender LTS + game/asset workspace @@ -134,7 +135,9 @@ starting the frontend toolchain from scratch. The wizard now opens in Beginner Mode: one recommended action, Fix My Setup, and Advanced Details only when the student wants the full diagnostics. It also includes a local setup helper that ranks the next storage, runtime, model, -ComfyUI, and creative-tool actions before any local LLM is installed. +ComfyUI, OpenClaw/NemoClaw, and creative-tool actions before any local LLM is +installed. NemoClaw is shown as a guarded sandbox option only when Docker works +without sudo; OpenClaw remains the simple rootless agent path. The ComfyUI step lets students select workflow examples and save a model download plan with source links, folder targets, and a helper checklist script, because many image/video weights are large or require upstream terms. diff --git a/docs/LINUX_DESKTOP.md b/docs/LINUX_DESKTOP.md index 36effde..1286a5c 100644 --- a/docs/LINUX_DESKTOP.md +++ b/docs/LINUX_DESKTOP.md @@ -7,7 +7,7 @@ Target user journey: 1. Launch the Linux desktop instance. 2. Run one install command or `pip install nvhive`. 3. Click the NVHive AI Studio desktop icon, or run `nvh workstation --launch`. -4. Use local chat models, cloud/free advisors, ComfyUI examples, agent packs, and game-dev helpers from one WebUI. +4. Use local chat models, cloud/free advisors, ComfyUI examples, OpenClaw/NemoClaw agent packs, and game-dev helpers from one WebUI. ## Quick Start @@ -51,8 +51,8 @@ nvh webui The setup wizard starts in Beginner Mode with one recommended action, a Fix My Setup repair button, and Advanced Details for diagnostics. It is designed so a -student can click through storage, models, ComfyUI, and creative packs without -typing manual commands. +student can click through storage, models, ComfyUI, Claw agents, and creative +packs without typing manual commands. ## What `nvh workstation` Does @@ -67,14 +67,15 @@ typing manual commands. ## Rootless AI Studio Packs -`nvh studio` installs optional packs without root access. It never calls `sudo`, `apt`, `dnf`, `pacman`, `systemctl`, or Docker. +`nvh studio` installs optional packs without root access. It never calls `sudo`, `apt`, `dnf`, `pacman`, or `systemctl`. NemoClaw is the exception that checks Docker because it is an OpenShell sandbox stack; the wizard blocks it unless Docker already works without sudo. | Bundle | Command | Installs | | --- | --- | --- | | Starter lab | `nvh studio --install starter -y` | Rootless Ollama, top local LLMs, agent lab, ComfyUI power nodes, game-dev lab | | Runtime fallback | `nvh studio --install python-runtime-fallback -y` | Optional micromamba binary under `$NVH_HOME` for cloud images where Python `venv` is broken | | LLMs | `nvh studio --install llms -y` | Gemma 3, Qwen 3, Llama 3.1, Qwen coder, DeepSeek reasoning, embeddings | -| Agents | `nvh studio --install agents -y` | LangGraph, CrewAI, AutoGen, JupyterLab, search/tool packages | +| Agents | `nvh studio --install agents -y` | LangGraph, CrewAI, AutoGen, JupyterLab, search/tool packages, OpenClaw | +| Claw agents | `nvh studio --install claw -y` | OpenClaw rootless workspace, plus NVIDIA NemoClaw when Docker/OpenShell is usable | | ComfyUI | `nvh studio --install comfy -y` | ComfyUI Manager, Impact Pack, ControlNet Aux, Video Helper Suite, GGUF, rgthree | | Games | `nvh studio --install game -y` | Pygame/Panda3D lab, asset helpers, Linux/Wine mod workspace | | Creative | `nvh studio --install creative -y` | Blender 4.5 LTS portable install, launcher, game/asset workspace | @@ -107,8 +108,14 @@ refresh the browser, reconnect to a cloud desktop, or cancel a long download without losing the setup state. The local setup helper endpoint, `/v1/setup/helper`, works offline. It ranks the -next storage, runtime, model, ComfyUI, and creative-tool actions before any local -LLM is installed. +next storage, runtime, model, ComfyUI, OpenClaw/NemoClaw, and creative-tool +actions before any local LLM is installed. + +OpenClaw is the simple agent option. nvHive installs it into a persistent +user-owned Node workspace and writes `nvhive-openclaw`. NemoClaw is the guarded +NVIDIA/OpenShell path. It remains visible in the wizard, but it is marked +blocked until Docker is installed, running, and reachable by the current user +without sudo. The council also includes a `product_resilience` preset with an Underdog Student Advocate. Use it when you want a skeptical review of what could break for a @@ -177,6 +184,7 @@ nvh studio --list # show rootless LLM/agent/ComfyUI/game packs nvh studio --models # show recommended local model downloads nvh studio --install-models recommended -y nvh studio --install starter -y +nvh studio --install claw -y nvh studio --install creative -y nvh doctor --fix # repair local models/config where possible nvh webui # launch browser dashboard diff --git a/nvh/cli/main.py b/nvh/cli/main.py index 3206698..3f03521 100644 --- a/nvh/cli/main.py +++ b/nvh/cli/main.py @@ -7582,7 +7582,7 @@ def studio( None, "--install", "-i", - help="Install a pack id or bundle: starter, all, llms, agents, comfy, game", + help="Install a pack id or bundle: starter, all, llms, agents, claw, comfy, game, creative", ), install_models: str | None = typer.Option( None, diff --git a/nvh/integrations/compatibility.py b/nvh/integrations/compatibility.py index 2618798..2612c47 100644 --- a/nvh/integrations/compatibility.py +++ b/nvh/integrations/compatibility.py @@ -18,7 +18,13 @@ from nvh.integrations.runtime import runtime_status from nvh.integrations.storage import storage_status -from nvh.integrations.studio_packs import BLENDER_VERSION, catalog_with_status, model_catalog_with_status +from nvh.integrations.studio_packs import ( + BLENDER_VERSION, + _docker_status, + _node_runtime_status, + catalog_with_status, + model_catalog_with_status, +) @dataclass(frozen=True) @@ -212,12 +218,14 @@ def _host_facts() -> dict[str, Any]: "tar": _which("tar"), "node": _which("node"), "npm": _which("npm"), + "docker": _which("docker"), "nvidia-smi": _which("nvidia-smi"), }, "command_versions": { "git": _command_version("git", "--version"), "node": _command_version("node", "--version"), "npm": _command_version("npm", "--version"), + "docker": _command_version("docker", "--version"), }, "gpu": nvidia, "display": { @@ -244,6 +252,8 @@ def _fact_list(host: dict[str, Any]) -> list[HostFact]: HostFact("venv", "Python venv", "available" if host["python"]["venv_available"] else "missing", "ok" if host["python"]["venv_available"] else "fixable", "recommended"), HostFact("git", "Git", commands.get("git") or "missing", "ok" if commands.get("git") else "blocked", "required"), HostFact("curl", "curl", commands.get("curl") or "missing", "ok" if commands.get("curl") else "blocked", "required"), + HostFact("node", "Node.js", host["command_versions"].get("node") or "rootless install available", "ok" if _node_runtime_status().get("ready") else "fixable", "recommended"), + HostFact("docker", "Docker runtime", host["command_versions"].get("docker") or "missing", "ok" if _docker_status().get("ready") else "degraded", "optional", "Required only for NemoClaw/OpenShell sandboxes."), HostFact("nvidia-smi", "NVIDIA driver", gpu.get("driver_version", "not detected"), "ok" if gpu else "degraded", "recommended"), HostFact("cuda", "CUDA driver API", gpu.get("cuda_version", "unknown"), "ok" if gpu.get("cuda_version") else "degraded", "recommended"), HostFact("display", "Linux desktop display", "available" if display_ready else "not detected", "ok" if display_ready else "degraded", "optional"), @@ -343,6 +353,8 @@ def compatibility_report(home_dir: str | Path | None = None) -> dict[str, Any]: model_status = model_catalog_with_status() pack_status = catalog_with_status() pack_by_id = {pack.get("id"): pack for pack in pack_status.get("packs", [])} + node_status = _node_runtime_status() + docker_status = _docker_status() recommended_models = model_status.get("recommended_ids", []) missing_recommended_models = [ model["id"] for model in model_status.get("models", []) @@ -434,6 +446,23 @@ def _pack_installed(pack_id: str) -> bool: ], recommended_action_id="agent-lab", ), + _overall( + "claw-agents", + "OpenClaw and NVIDIA NemoClaw", + "agent", + [ + _req("linux", "Linux desktop session", is_linux, "OpenClaw/NemoClaw packs are optimized for Linux cloud desktops.", blocked=not is_linux), + _req("node", "Node.js 22.16+ and npm 10+", bool(node_status.get("ready")), f"Node={node_status.get('node_version') or 'missing'}, npm={node_status.get('npm_version') or 'missing'}.", fix_action_id="claw-agents", rootless_fix_available=bool(node_status.get("can_auto_install"))), + _req("openclaw", "OpenClaw pack", _pack_installed("openclaw-agent"), "Simple self-hosted agent platform install.", fix_action_id="claw-agents", rootless_fix_available=True), + _req("docker", "Docker for NemoClaw", bool(docker_status.get("ready")), docker_status.get("detail", "Docker must work without sudo.")), + _req("storage", "Persistent workspace", bool(storage["ok"]), "Claw workspaces install under NVH_HOME/studio.", fix_action_id="storage", rootless_fix_available=True), + ], + recommended_action_id="claw-agents", + notes=[ + "OpenClaw is the default rootless agent path.", + "NemoClaw remains optional until Docker/OpenShell is available without sudo.", + ], + ), _overall( "game-dev-lab", "Game Dev Lab", diff --git a/nvh/integrations/setup_agent.py b/nvh/integrations/setup_agent.py index 6417be5..bc7b25a 100644 --- a/nvh/integrations/setup_agent.py +++ b/nvh/integrations/setup_agent.py @@ -271,6 +271,38 @@ def setup_helper_report(home_dir: str | Path | None = None) -> dict[str, Any]: reason="ComfyUI exists, but the nvHive examples manifest is missing.", )) + openclaw_pack = by_pack.get("openclaw-agent", {}) + nemoclaw_pack = by_pack.get("nemoclaw-sandbox", {}) + openclaw_status = openclaw_pack.get("status", {}) + nemoclaw_status = nemoclaw_pack.get("status", {}) + nemoclaw_details = nemoclaw_status.get("details", {}) + if not openclaw_status.get("installed"): + issues.append(SetupIssue( + id="claw-agents", + title="OpenClaw agent option is not installed", + severity="optional", + reason="OpenClaw gives students a self-hosted agent platform that can use local or cloud models.", + fix_action_id="claw-agents", + affected_item="openclaw-agent", + )) + actions.append(SetupAction( + id="claw-agents", + title="Install Claw agent options", + priority=65, + status="optional", + command="nvh studio --install claw -y", + reason="Adds OpenClaw, and adds NemoClaw too when Docker/OpenShell is usable without sudo.", + )) + elif nemoclaw_details.get("installable") and not nemoclaw_status.get("installed"): + actions.append(SetupAction( + id="claw-agents", + title="Add NemoClaw sandbox option", + priority=66, + status="optional", + command="nvh studio --install claw -y", + reason="Docker is reachable, so nvHive can add the NVIDIA NemoClaw/OpenShell path.", + )) + creative_pack = by_pack.get("blender-creative", {}) if not creative_pack.get("status", {}).get("installed"): actions.append(SetupAction( @@ -472,6 +504,16 @@ def setup_assistant_reply( "Start with the rootless Ollama runtime, then download the recommended models " "that fit the detected GPU. The wizard can run both steps and keeps files under NVH_HOME/models." ) + elif any(word in q for word in ["claw", "openclaw", "nemo", "nemoclaw", "desktop agent", "sandbox agent"]): + focus = "claw-agents" + commands = _commands_for_actions(actions, "claw-agents") or [ + "nvh studio --install claw -y", + ] + answer = ( + "OpenClaw is the simple rootless agent install. NemoClaw is the guarded NVIDIA/OpenShell " + "path and only lights up when Docker works without sudo. In the wizard, use the Claw Agents " + "pack; manual commands are just the advanced override." + ) elif any(word in q for word in ["blender", "creative", "game", "asset"]): focus = "creative" commands = _commands_for_actions(actions, "creative-tools") or [ diff --git a/nvh/integrations/smoke_tests.py b/nvh/integrations/smoke_tests.py index 493642f..237595d 100644 --- a/nvh/integrations/smoke_tests.py +++ b/nvh/integrations/smoke_tests.py @@ -40,6 +40,13 @@ def _pack_installed(pack_id: str, packs: list[dict[str, Any]]) -> bool: return False +def _pack_details(pack_id: str, packs: list[dict[str, Any]]) -> dict[str, Any]: + for pack in packs: + if pack.get("id") == pack_id: + return pack.get("status", {}).get("details", {}) + return {} + + def smoke_test_report(home_dir: str | None = None) -> dict[str, Any]: """Return non-destructive app health checks.""" storage = storage_status(home_dir=home_dir) @@ -77,6 +84,18 @@ def smoke_test_report(home_dir: str | None = None) -> dict[str, Any]: summary="Local agent helper pack is installed" if _pack_installed("agent-lab", packs) else "Local Agent Lab is not installed", action_id="agent-lab", ), + SmokeTest( + id="claw-agents", + title="Claw agent options", + status="pass" if _pack_installed("openclaw-agent", packs) else "warn", + summary=( + "OpenClaw is installed" + if _pack_installed("openclaw-agent", packs) + else "OpenClaw can be installed; NemoClaw requires Docker/OpenShell access" + ), + detail=str(_pack_details("nemoclaw-sandbox", packs).get("blocked_reason", "")), + action_id="claw-agents", + ), SmokeTest( id="comfyui", title="ComfyUI workspace", diff --git a/nvh/integrations/studio_packs.py b/nvh/integrations/studio_packs.py index 6e41f09..cbee6a2 100644 --- a/nvh/integrations/studio_packs.py +++ b/nvh/integrations/studio_packs.py @@ -2,7 +2,8 @@ Packs are intentionally user-space only: files, launchers, models, and caches go under ``NVH_HOME``. The installer never calls sudo, apt, -dnf, pacman, systemctl, or Docker. +dnf, pacman, or systemctl. Container-backed packs only run when a provider +already exposes Docker without sudo. """ from __future__ import annotations @@ -11,6 +12,7 @@ import json import os import platform +import re import shutil import socket import stat @@ -33,6 +35,14 @@ "https://download.blender.org/release/Blender4.5/" f"blender-{BLENDER_VERSION}-linux-x64.tar.xz" ) +NODE_MAJOR_VERSION = "22" +NODE_MIN_VERSION = (22, 16, 0) +NPM_MIN_VERSION = (10, 0, 0) +OPENCLAW_PACKAGE = "openclaw@latest" +OPENCLAW_DOC_URL = "https://openclawdoc.com/docs/getting-started/installation/" +NEMOCLAW_INSTALL_URL = "https://www.nvidia.com/nemoclaw.sh" +NEMOCLAW_DOC_URL = "https://docs.nvidia.com/nemoclaw/latest/get-started/quickstart.html" +NEMOCLAW_PACKAGE = "nemoclaw@latest" @dataclass(frozen=True) @@ -314,6 +324,53 @@ class StudioModel: "This pack gives the local AI agent layer a ready Python home.", ], ), + StudioPack( + id="openclaw-agent", + title="OpenClaw Agent Workspace", + category="claw", + tagline="Self-hosted agent platform with local model support", + description=( + "Installs OpenClaw into a persistent user-space Node workspace, adds a " + "launcher, and keeps agent state under NVH_HOME/studio instead of the base OS." + ), + recommended_vram_gb=0, + estimated_disk_gb=2.0, + install_kind="openclaw_agent", + no_root=True, + models=[], + python_packages=[], + comfy_nodes=[], + launchers=["nvhive-openclaw"], + source_urls=[OPENCLAW_DOC_URL, "https://openclaw.ai/install.sh"], + notes=[ + "Requires Node.js 22.16+ and npm 10+; nvHive can install a rootless Node runtime on Linux.", + "Use with local Ollama models or a configured cloud provider.", + ], + ), + StudioPack( + id="nemoclaw-sandbox", + title="NVIDIA NemoClaw Sandbox", + category="claw", + tagline="OpenClaw inside NVIDIA OpenShell guardrails", + description=( + "Adds NVIDIA NemoClaw as the guarded OpenClaw path when the host exposes " + "a Docker runtime that works without sudo. The wizard blocks this pack on " + "locked-down sessions that cannot run containers." + ), + recommended_vram_gb=0, + estimated_disk_gb=40.0, + install_kind="nemoclaw_sandbox", + no_root=True, + models=[], + python_packages=[], + comfy_nodes=[], + launchers=["nvhive-nemoclaw"], + source_urls=[NEMOCLAW_DOC_URL, NEMOCLAW_INSTALL_URL], + notes=[ + "NemoClaw is alpha software and requires a usable Docker/OpenShell path.", + "Recommended only when the cloud image grants rootless Docker or docker group access.", + ], + ), StudioPack( id="comfyui-power-nodes", title="ComfyUI Power Nodes", @@ -441,7 +498,8 @@ class StudioModel: PACK_BUNDLES: dict[str, list[str]] = { "starter": ["rootless-ollama", "llm-starter", "agent-lab", "comfyui-power-nodes", "game-dev-lab"], "llms": ["rootless-ollama", "llm-starter", "llm-coder-reasoner"], - "agents": ["agent-lab"], + "agents": ["agent-lab", "openclaw-agent"], + "claw": ["openclaw-agent", "nemoclaw-sandbox"], "comfy": ["comfyui-power-nodes"], "game": ["game-dev-lab", "game-mod-helper"], "creative": ["blender-creative", "game-dev-lab", "game-mod-helper"], @@ -450,6 +508,8 @@ class StudioModel: "llm-starter", "llm-coder-reasoner", "agent-lab", + "openclaw-agent", + "nemoclaw-sandbox", "comfyui-power-nodes", "game-dev-lab", "game-mod-helper", @@ -516,6 +576,224 @@ def _blender_binary() -> Path: return _blender_app_dir() / "blender" +def _node_runtime_root() -> Path: + return storage_layout().runtime_dir / "node" + + +def _fnm_root() -> Path: + return storage_layout().runtime_dir / "fnm" + + +def _openclaw_workspace() -> Path: + return _pack_root("openclaw-agent") / "workspace" + + +def _openclaw_prefix() -> Path: + return _pack_root("openclaw-agent") / "node" + + +def _openclaw_binary() -> Path: + suffix = ".cmd" if os.name == "nt" else "" + return _openclaw_prefix() / "bin" / f"openclaw{suffix}" + + +def _nemoclaw_workspace() -> Path: + return _pack_root("nemoclaw-sandbox") / "workspace" + + +def _nemoclaw_prefix() -> Path: + return _pack_root("nemoclaw-sandbox") / "node" + + +def _nemoclaw_binary_from_env(env: dict[str, str] | None = None) -> str: + suffix = ".cmd" if os.name == "nt" else "" + candidates = [ + _nemoclaw_prefix() / "bin" / f"nemoclaw{suffix}", + _local_bin() / "nemoclaw", + _pack_root("nemoclaw-sandbox") / "home" / ".local" / "bin" / "nemoclaw", + _pack_root("nemoclaw-sandbox") / "home" / ".npm-global" / "bin" / "nemoclaw", + ] + for candidate in candidates: + if candidate.exists(): + return str(candidate) + found = shutil.which("nemoclaw", path=env.get("PATH") if env else None) + return found or "" + + +def _parse_semver(value: str | None) -> tuple[int, int, int] | None: + if not value: + return None + match = re.search(r"(\d+)(?:\.(\d+))?(?:\.(\d+))?", value) + if not match: + return None + return tuple(int(part or 0) for part in match.groups()) # type: ignore[return-value] + + +def _semver_at_least(value: tuple[int, int, int] | None, minimum: tuple[int, int, int]) -> bool: + return bool(value and value >= minimum) + + +def _run_capture(cmd: list[str], *, env: dict[str, str] | None = None, timeout: float = 8.0) -> str: + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + env=env, + ) + except Exception: + return "" + return ((result.stdout or result.stderr) or "").strip().splitlines()[0] if (result.stdout or result.stderr) else "" + + +def _find_rootless_node_bin() -> Path | None: + root = _fnm_root() / "node-versions" + if not root.exists(): + return None + installs = sorted(root.glob(f"v{NODE_MAJOR_VERSION}.*/installation/bin"), reverse=True) + for install in installs: + if (install / "node").exists() and (install / "npm").exists(): + return install + return None + + +def _node_env(extra: dict[str, str] | None = None) -> dict[str, str]: + env = os.environ.copy() + env.update(storage_layout().env()) + rootless_bin = _find_rootless_node_bin() + path_parts = [ + str(_local_bin()), + str(_openclaw_prefix() / "bin"), + str(_nemoclaw_prefix() / "bin"), + str(_pack_root("nemoclaw-sandbox") / "home" / ".local" / "bin"), + str(_pack_root("nemoclaw-sandbox") / "home" / ".npm-global" / "bin"), + ] + if rootless_bin: + path_parts.insert(0, str(rootless_bin)) + env["PATH"] = os.pathsep.join(path_parts + [env.get("PATH", "")]) + env["NPM_CONFIG_PREFIX"] = str(_openclaw_prefix()) + env["OPENCLAW_HOME"] = str(_openclaw_workspace()) + env["NEMOCLAW_WORKSPACE"] = str(_nemoclaw_workspace()) + if extra: + env.update(extra) + return env + + +def _node_runtime_status(env: dict[str, str] | None = None) -> dict[str, Any]: + env = env or _node_env() + node = shutil.which("node", path=env.get("PATH")) + npm = shutil.which("npm", path=env.get("PATH")) + node_text = _run_capture([node, "--version"], env=env) if node else "" + npm_text = _run_capture([npm, "--version"], env=env) if npm else "" + node_version = _parse_semver(node_text) + npm_version = _parse_semver(npm_text) + node_ok = _semver_at_least(node_version, NODE_MIN_VERSION) + npm_ok = _semver_at_least(npm_version, NPM_MIN_VERSION) + can_auto_install = ( + platform.system() == "Linux" + and bool(shutil.which("bash")) + and bool(shutil.which("curl")) + ) + return { + "node": node or "", + "npm": npm or "", + "node_version": node_text, + "npm_version": npm_text, + "node_ok": node_ok, + "npm_ok": npm_ok, + "ready": node_ok and npm_ok, + "can_auto_install": can_auto_install, + "minimum_node": ".".join(str(part) for part in NODE_MIN_VERSION), + "minimum_npm": ".".join(str(part) for part in NPM_MIN_VERSION), + } + + +def _docker_status() -> dict[str, Any]: + docker = shutil.which("docker") + if not docker: + return { + "binary": "", + "ready": False, + "detail": "Docker was not found on PATH.", + "rootless_hint": "NemoClaw needs Docker or a provider-enabled rootless container runtime.", + } + try: + result = subprocess.run( + [docker, "info"], + capture_output=True, + text=True, + timeout=10, + ) + except Exception as exc: + return { + "binary": docker, + "ready": False, + "detail": f"Docker could not be checked: {exc}", + "rootless_hint": "Ask the provider to enable rootless Docker or docker group access.", + } + if result.returncode == 0: + return { + "binary": docker, + "ready": True, + "detail": "Docker daemon is reachable without sudo.", + "rootless_hint": "", + } + detail = (result.stderr or result.stdout or "Docker daemon is not reachable.").strip().splitlines()[0] + return { + "binary": docker, + "ready": False, + "detail": detail, + "rootless_hint": "NemoClaw is blocked until Docker works without sudo in this session.", + } + + +def _prepare_node_runtime() -> tuple[dict[str, str], dict[str, Any]]: + env = _node_env() + status = _node_runtime_status(env) + if status["ready"]: + return env, status + if not status["can_auto_install"]: + raise RuntimeError( + "OpenClaw needs Node.js 22.16+ and npm 10+. This host cannot auto-install " + "the rootless Node runtime because Linux, bash, and curl are not all available." + ) + + fnm_dir = _fnm_root() + fnm_dir.mkdir(parents=True, exist_ok=True) + install_env = os.environ.copy() + install_env.update(storage_layout().env()) + install_env["FNM_DIR"] = str(fnm_dir) + install_env["NODE_VERSION"] = NODE_MAJOR_VERSION + subprocess.run( + ["bash", "-lc", "curl -fsSL https://fnm.vercel.app/install | bash -s -- --skip-shell"], + check=True, + timeout=180, + env=install_env, + ) + + fnm = fnm_dir / "fnm" + if not fnm.exists(): + fnm = fnm_dir / "fnm.exe" + if not fnm.exists(): + raise RuntimeError("Rootless Node install finished, but fnm was not found.") + subprocess.run( + [str(fnm), "install", NODE_MAJOR_VERSION], + check=True, + timeout=300, + env=install_env, + ) + + env = _node_env() + status = _node_runtime_status(env) + if not status["ready"]: + raise RuntimeError( + f"Node runtime is still not ready. Node={status['node_version'] or 'missing'} " + f"npm={status['npm_version'] or 'missing'}." + ) + return env, status + + def _find_pack(pack_id: str) -> StudioPack: for pack in STUDIO_PACKS: if pack.id == pack_id: @@ -733,6 +1011,38 @@ def pack_status(pack: StudioPack) -> dict[str, Any]: elif pack.install_kind == "python_venv": installed = _venv_python(pack.id).exists() and marker is not None details["venv"] = str(_venv_python(pack.id).parent.parent) + elif pack.install_kind == "openclaw_agent": + node = _node_runtime_status() + binary = _openclaw_binary() + installed = binary.exists() or marker is not None + installable = bool(node["ready"] or node["can_auto_install"]) + details.update(node) + details["binary"] = str(binary) + details["workspace"] = str(_openclaw_workspace()) + details["installable"] = installable + if not installable: + details["blocked_reason"] = "OpenClaw needs Node.js 22.16+ and npm 10+, or a Linux host where nvHive can install Node rootlessly." + elif pack.install_kind == "nemoclaw_sandbox": + node = _node_runtime_status() + docker = _docker_status() + binary = _nemoclaw_binary_from_env(_node_env()) + installed = bool(binary) or marker is not None + installable = bool(docker["ready"] and (node["ready"] or node["can_auto_install"])) + details.update({ + "node": node, + "docker": docker, + "binary": binary, + "workspace": str(_nemoclaw_workspace()), + "installable": installable, + "alpha": True, + "estimated_min_disk_gb": 20, + "estimated_recommended_disk_gb": 40, + "recommended_ram_gb": 16, + }) + if not docker["ready"]: + details["blocked_reason"] = "NemoClaw needs Docker/OpenShell access that works without sudo; use OpenClaw or ask the provider to enable rootless Docker." + elif not installable: + details["blocked_reason"] = "NemoClaw needs Node.js 22.16+ and npm 10+." elif pack.install_kind == "comfy_nodes": custom_nodes = _comfyui_app_dir() / "custom_nodes" missing_nodes = [node.name for node in pack.comfy_nodes if not (custom_nodes / node.name).exists()] @@ -1108,6 +1418,235 @@ async def _install_python_venv(pack: StudioPack, force_update: bool) -> AsyncIte _write_marker(pack, {"packages": pack.python_packages, "venv": str(root / "venv")}) +def _write_openclaw_launcher() -> Path: + root = _pack_root("openclaw-agent") + workspace = _openclaw_workspace() + launcher = _local_bin() / "nvhive-openclaw" + content = f"""#!/usr/bin/env bash +set -euo pipefail + +export NVH_HOME="${{NVH_HOME:-{storage_layout().home}}}" +export OPENCLAW_HOME="${{OPENCLAW_HOME:-{workspace}}}" +export NPM_CONFIG_PREFIX="${{NPM_CONFIG_PREFIX:-{_openclaw_prefix()}}}" +export PATH="{_openclaw_prefix()}/bin:{_local_bin()}:$PATH" +mkdir -p "$OPENCLAW_HOME" "{root}/logs" +cd "$OPENCLAW_HOME" +if [ "$#" -eq 0 ]; then + exec openclaw onboard --install-daemon +fi +exec openclaw "$@" +""" + _write_script(launcher, content) + return launcher + + +def _write_openclaw_readme() -> None: + root = _pack_root("openclaw-agent") + root.mkdir(parents=True, exist_ok=True) + (root / "README.md").write_text( + f"""# OpenClaw Agent Workspace + +OpenClaw is installed in this rootless nvHive pack: + +`{root}` + +Launch the guided OpenClaw onboarding: + +```bash +nvhive-openclaw +``` + +Advanced overrides: + +```bash +nvhive-openclaw --help +nvhive-openclaw tui +``` + +The wizard keeps OpenClaw state in `{_openclaw_workspace()}` and can route to +local Ollama models or configured cloud model providers. +""", + encoding="utf-8", + ) + + +async def _install_openclaw_agent(pack: StudioPack, force_update: bool) -> AsyncIterator[dict[str, Any]]: + if os.name == "nt": + yield {"event": "error", "status": "failed", "message": "OpenClaw rootless pack currently targets Linux/WSL sessions."} + return + + root = _pack_root(pack.id) + root.mkdir(parents=True, exist_ok=True) + _openclaw_workspace().mkdir(parents=True, exist_ok=True) + + if _openclaw_binary().exists() and not force_update: + launcher = _write_openclaw_launcher() + _write_openclaw_readme() + _write_marker(pack, {"binary": str(_openclaw_binary()), "launcher": str(launcher), "workspace": str(_openclaw_workspace())}) + yield {"event": "step", "status": "complete", "message": "OpenClaw already installed"} + return + + yield {"event": "step", "status": "running", "message": "Checking Node.js 22.16+ and npm 10+ for OpenClaw"} + try: + env, node_status = await asyncio.to_thread(_prepare_node_runtime) + except Exception as exc: + yield {"event": "error", "status": "failed", "message": str(exc)} + return + npm = shutil.which("npm", path=env.get("PATH")) + if not npm: + yield {"event": "error", "status": "failed", "message": "npm is unavailable after Node runtime setup."} + return + + _openclaw_prefix().mkdir(parents=True, exist_ok=True) + async for event in _run_command( + [npm, "install", "--prefix", str(_openclaw_prefix()), OPENCLAW_PACKAGE], + label="Install OpenClaw package", + env=env, + ): + yield event + + launcher = _write_openclaw_launcher() + _write_openclaw_readme() + _write_marker(pack, { + "binary": str(_openclaw_binary()), + "launcher": str(launcher), + "workspace": str(_openclaw_workspace()), + "node": node_status, + }) + yield { + "event": "complete", + "status": "complete", + "message": "OpenClaw installed. Launch nvhive-openclaw to onboard the agent.", + "launcher": str(launcher), + } + + +def _write_nemoclaw_launcher() -> Path: + root = _pack_root("nemoclaw-sandbox") + workspace = _nemoclaw_workspace() + launcher = _local_bin() / "nvhive-nemoclaw" + content = f"""#!/usr/bin/env bash +set -euo pipefail + +export NVH_HOME="${{NVH_HOME:-{storage_layout().home}}}" +export NEMOCLAW_WORKSPACE="${{NEMOCLAW_WORKSPACE:-{workspace}}}" +export NPM_CONFIG_PREFIX="${{NPM_CONFIG_PREFIX:-{_nemoclaw_prefix()}}}" +export PATH="{_nemoclaw_prefix()}/bin:{_local_bin()}:$PATH" +mkdir -p "$NEMOCLAW_WORKSPACE" "{root}/logs" +cd "$NEMOCLAW_WORKSPACE" +if [ "$#" -eq 0 ]; then + exec nemoclaw onboard +fi +exec nemoclaw "$@" +""" + _write_script(launcher, content) + return launcher + + +def _write_nemoclaw_readme(docker: dict[str, Any]) -> None: + root = _pack_root("nemoclaw-sandbox") + root.mkdir(parents=True, exist_ok=True) + (root / "README.md").write_text( + f"""# NVIDIA NemoClaw Sandbox + +NemoClaw is the guarded OpenClaw path. It uses NVIDIA OpenShell and requires a +Docker runtime that works without sudo in this Linux session. + +Docker check: + +`{docker.get("detail", "not checked")}` + +Launch onboarding: + +```bash +nvhive-nemoclaw +``` + +Advanced overrides: + +```bash +nvhive-nemoclaw --help +nvhive-nemoclaw status +``` + +Keep the sandbox workspace on the persistent mount: + +`{_nemoclaw_workspace()}` +""", + encoding="utf-8", + ) + + +async def _install_nemoclaw_sandbox(pack: StudioPack, force_update: bool) -> AsyncIterator[dict[str, Any]]: + if os.name == "nt": + yield {"event": "error", "status": "failed", "message": "NemoClaw requires a Linux, macOS, or WSL2 container runtime; nvHive only enables this pack on Linux sessions."} + return + if platform.system() != "Linux": + yield {"event": "error", "status": "failed", "message": "This nvHive pack targets Linux cloud desktops."} + return + + docker = _docker_status() + if not docker["ready"]: + yield { + "event": "error", + "status": "failed", + "message": f"NemoClaw is blocked: {docker['detail']} {docker['rootless_hint']}", + "details": docker, + } + return + + root = _pack_root(pack.id) + root.mkdir(parents=True, exist_ok=True) + _nemoclaw_workspace().mkdir(parents=True, exist_ok=True) + + current_env = _node_env({"NPM_CONFIG_PREFIX": str(_nemoclaw_prefix())}) + existing_binary = _nemoclaw_binary_from_env(current_env) + if existing_binary and not force_update: + launcher = _write_nemoclaw_launcher() + _write_nemoclaw_readme(docker) + _write_marker(pack, {"binary": existing_binary, "launcher": str(launcher), "workspace": str(_nemoclaw_workspace()), "docker": docker}) + yield {"event": "step", "status": "complete", "message": "NemoClaw CLI already installed"} + return + + yield {"event": "step", "status": "running", "message": "Checking Node.js 22.16+ and npm 10+ for NemoClaw"} + try: + env, node_status = await asyncio.to_thread(_prepare_node_runtime) + except Exception as exc: + yield {"event": "error", "status": "failed", "message": str(exc)} + return + env = _node_env({"NPM_CONFIG_PREFIX": str(_nemoclaw_prefix())}) + npm = shutil.which("npm", path=env.get("PATH")) + if not npm: + yield {"event": "error", "status": "failed", "message": "npm is unavailable after Node runtime setup."} + return + + _nemoclaw_prefix().mkdir(parents=True, exist_ok=True) + async for event in _run_command( + [npm, "install", "--prefix", str(_nemoclaw_prefix()), NEMOCLAW_PACKAGE], + label="Install NemoClaw CLI", + env=env, + ): + yield event + + binary = _nemoclaw_binary_from_env(env) + launcher = _write_nemoclaw_launcher() + _write_nemoclaw_readme(docker) + _write_marker(pack, { + "binary": binary, + "launcher": str(launcher), + "workspace": str(_nemoclaw_workspace()), + "node": node_status, + "docker": docker, + "onboard_next": "nvhive-nemoclaw", + }) + yield { + "event": "complete", + "status": "complete", + "message": "NemoClaw CLI installed. Launch nvhive-nemoclaw to create the OpenShell sandbox.", + "launcher": str(launcher), + } + + async def _install_comfy_nodes(pack: StudioPack, force_update: bool) -> AsyncIterator[dict[str, Any]]: app_dir = _comfyui_app_dir() venv_python = _comfyui_venv_python() @@ -1492,6 +2031,12 @@ async def install_studio_packs( elif pack.install_kind == "python_venv": async for event in _install_python_venv(pack, force_update): yield {**event, "pack_id": pack.id} + elif pack.install_kind == "openclaw_agent": + async for event in _install_openclaw_agent(pack, force_update): + yield {**event, "pack_id": pack.id} + elif pack.install_kind == "nemoclaw_sandbox": + async for event in _install_nemoclaw_sandbox(pack, force_update): + yield {**event, "pack_id": pack.id} elif pack.install_kind == "comfy_nodes": async for event in _install_comfy_nodes(pack, force_update): yield {**event, "pack_id": pack.id} diff --git a/tests/test_studio_packs.py b/tests/test_studio_packs.py index 0a608db..be1c534 100644 --- a/tests/test_studio_packs.py +++ b/tests/test_studio_packs.py @@ -14,6 +14,8 @@ def test_catalog_is_rootless_and_grouped() -> None: assert "rootless-ollama" in ids assert "llm-starter" in ids assert "agent-lab" in ids + assert "openclaw-agent" in ids + assert "nemoclaw-sandbox" in ids assert "comfyui-power-nodes" in ids assert "game-dev-lab" in ids assert "blender-creative" in ids @@ -31,6 +33,13 @@ def test_pack_bundles_expand_without_duplicates() -> None: creative = studio_packs.expand_pack_ids(["creative"]) assert creative == ["blender-creative", "game-dev-lab", "game-mod-helper"] + claw = studio_packs.expand_pack_ids(["claw"]) + assert claw == ["openclaw-agent", "nemoclaw-sandbox"] + + agents = studio_packs.expand_pack_ids(["agents"]) + assert "agent-lab" in agents + assert "openclaw-agent" in agents + def test_model_catalog_marks_vram_recommendations(monkeypatch) -> None: monkeypatch.setattr(studio_packs, "_detect_vram_gb", lambda: 8) @@ -68,6 +77,37 @@ def test_blender_pack_status_uses_persistent_apps_home(tmp_path, monkeypatch) -> assert status["details"]["version"] == studio_packs.BLENDER_VERSION +def test_claw_status_marks_nemoclaw_blocked_without_docker(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("NVH_HOME", str(tmp_path / "nvh")) + monkeypatch.setattr(studio_packs, "_node_runtime_status", lambda env=None: { + "node": "/tmp/node", + "npm": "/tmp/npm", + "node_version": "v22.16.0", + "npm_version": "10.9.0", + "node_ok": True, + "npm_ok": True, + "ready": True, + "can_auto_install": True, + "minimum_node": "22.16.0", + "minimum_npm": "10.0.0", + }) + monkeypatch.setattr(studio_packs, "_docker_status", lambda: { + "binary": "", + "ready": False, + "detail": "Docker was not found on PATH.", + "rootless_hint": "Ask the provider to enable rootless Docker.", + }) + monkeypatch.setattr(studio_packs, "_nemoclaw_binary_from_env", lambda env=None: "") + + openclaw = studio_packs.pack_status(studio_packs._find_pack("openclaw-agent")) + nemoclaw = studio_packs.pack_status(studio_packs._find_pack("nemoclaw-sandbox")) + + assert openclaw["details"]["installable"] is True + assert nemoclaw["installed"] is False + assert nemoclaw["details"]["installable"] is False + assert "Docker" in nemoclaw["details"]["blocked_reason"] + + @pytest.mark.asyncio async def test_comfy_nodes_skip_without_comfyui(tmp_path, monkeypatch) -> None: monkeypatch.setenv("NVH_STUDIO_HOME", str(tmp_path / "studio")) diff --git a/web/app/setup/page.tsx b/web/app/setup/page.tsx index f66b0e1..ce8cccd 100644 --- a/web/app/setup/page.tsx +++ b/web/app/setup/page.tsx @@ -202,6 +202,22 @@ function ProviderCard({ p, expandedProvider, setExpandedProvider, keyInputs, set const isActiveInstallJob = (job: InstallJob) => job.status === 'queued' || job.status === 'running'; +const studioPackDetails = (pack: StudioPack): Record => pack.status?.details ?? {}; + +const studioPackInstallable = (pack: StudioPack) => studioPackDetails(pack).installable !== false; + +const studioPackBlockedReason = (pack: StudioPack) => { + const reason = studioPackDetails(pack).blocked_reason; + return typeof reason === 'string' ? reason : ''; +}; + +const selectableStudioPackIds = (packs: StudioPack[], packIds: string[]) => ( + packIds.filter(packId => { + const pack = packs.find(item => item.id === packId); + return !pack || studioPackInstallable(pack); + }) +); + export default function SetupPage() { const [step, setStep] = useState('welcome'); const [apiKeys, setApiKeys] = useState>({}); @@ -471,7 +487,8 @@ export default function SetupPage() { setStudioPacks(data.packs); setStudioBundles(data.bundles); setStudioRoot(data.root); - setSelectedStudioPacks(new Set(data.bundles.starter ?? data.packs.map(pack => pack.id))); + const starterIds = data.bundles.starter ?? data.packs.map(pack => pack.id); + setSelectedStudioPacks(new Set(selectableStudioPackIds(data.packs, starterIds))); }) .catch(() => {}) .finally(() => setStudioLoading(false)); @@ -582,7 +599,8 @@ export default function SetupPage() { setStudioRoot(data.root); setSelectedStudioPacks(prev => { if (prev.size > 0) return prev; - return new Set(data.bundles.starter ?? data.packs.map(pack => pack.id)); + const starterIds = data.bundles.starter ?? data.packs.map(pack => pack.id); + return new Set(selectableStudioPackIds(data.packs, starterIds)); }); } catch { // keep current pack state @@ -592,6 +610,11 @@ export default function SetupPage() { }; const toggleStudioPack = (packId: string) => { + const pack = studioPacks.find(item => item.id === packId); + if (pack && !studioPackInstallable(pack)) { + setStudioError(studioPackBlockedReason(pack) || `${pack.title} is blocked on this host.`); + return; + } setSelectedStudioPacks(prev => { const next = new Set(prev); if (next.has(packId)) next.delete(packId); @@ -602,7 +625,7 @@ export default function SetupPage() { const selectStudioBundle = (bundleId: string) => { const packIds = studioBundles[bundleId] ?? []; - setSelectedStudioPacks(new Set(packIds)); + setSelectedStudioPacks(new Set(selectableStudioPackIds(studioPacks, packIds))); }; const applyWizardProfile = (profile: WizardProfile) => { @@ -615,13 +638,15 @@ export default function SetupPage() { const vramLimit = detectedModelVram || 12; const starterPackIds = studioBundles.starter ?? studioPacks.map(pack => pack.id); const creativePackIds = studioBundles.creative ?? ['blender-creative', 'game-dev-lab', 'game-mod-helper']; + const installableStarterPackIds = selectableStudioPackIds(studioPacks, starterPackIds); + const installableCreativePackIds = selectableStudioPackIds(studioPacks, creativePackIds); const starterExamples = visibleComfyExamples .filter(example => example.recommended_vram_gb <= vramLimit) .map(example => example.id); if (profile === 'student') { setSelectedStudioModels(new Set(recommendedModels)); - setSelectedStudioPacks(new Set(starterPackIds)); + setSelectedStudioPacks(new Set(installableStarterPackIds)); setSelectedComfyExamples(new Set(starterExamples)); setStep('models'); return; @@ -629,7 +654,7 @@ export default function SetupPage() { if (profile === 'creator') { setSelectedStudioModels(new Set(recommendedModels)); - setSelectedStudioPacks(new Set([...(studioBundles.comfy ?? ['comfyui-power-nodes']), ...creativePackIds])); + setSelectedStudioPacks(new Set(selectableStudioPackIds(studioPacks, [...(studioBundles.comfy ?? ['comfyui-power-nodes']), ...creativePackIds]))); setSelectedComfyExamples(new Set(starterExamples)); setStep('comfyui'); return; @@ -637,14 +662,14 @@ export default function SetupPage() { if (profile === 'game') { setSelectedStudioModels(new Set(recommendedModels)); - setSelectedStudioPacks(new Set(creativePackIds)); + setSelectedStudioPacks(new Set(installableCreativePackIds)); setSelectedComfyExamples(new Set(starterExamples)); setStep('studio'); return; } setSelectedStudioModels(new Set(allModelIds.length ? allModelIds : recommendedModels)); - setSelectedStudioPacks(new Set(studioBundles.all ?? studioPacks.map(pack => pack.id))); + setSelectedStudioPacks(new Set(selectableStudioPackIds(studioPacks, studioBundles.all ?? studioPacks.map(pack => pack.id)))); setSelectedComfyExamples(new Set(starterExamples)); setStep('models'); }; @@ -888,8 +913,10 @@ export default function SetupPage() { .filter(example => selectedComfyExamples.has(example.id)) .flatMap(example => example.models) ).size; - const selectedStudioPackIds = Array.from(selectedStudioPacks); + const selectedStudioPackIds = selectableStudioPackIds(studioPacks, Array.from(selectedStudioPacks)); const starterStudioPackIds = studioBundles.starter ?? []; + const clawStudioPackIds = studioBundles.claw ?? []; + const blockedStudioPackCount = studioPacks.filter(pack => !studioPackInstallable(pack)).length; const studioCategories = Array.from(new Set(studioPacks.map(pack => pack.category))); const selectedStudioPackDiskGb = studioPacks .filter(pack => selectedStudioPacks.has(pack.id)) @@ -979,6 +1006,15 @@ export default function SetupPage() { handleInstallStudioPacks(['creative']); return; } + if (actionId === 'claw-agents') { + const installableClawIds = selectableStudioPackIds(studioPacks, studioBundles.claw ?? ['openclaw-agent', 'nemoclaw-sandbox']); + if (installableClawIds.length > 0) handleInstallStudioPacks(installableClawIds); + else { + setStudioError('No Claw agent option is installable on this host yet. Check Node.js and Docker/OpenShell readiness in Advanced Details.'); + setStep('studio'); + } + return; + } if (actionId === 'repair-workspace') { void handleRepairWorkspace(); return; @@ -2437,7 +2473,7 @@ export default function SetupPage() {
Step 6

AI Studio Packs

- One-click rootless packs for LLMs, local agents, ComfyUI sub software, Blender, runtime fallback, and Linux game projects. + One-click rootless packs for LLMs, OpenClaw/NemoClaw agents, ComfyUI sub software, Blender, runtime fallback, and Linux game projects.

@@ -2449,7 +2485,7 @@ export default function SetupPage() { No sudo. Installs under {studioRoot || storageStatus?.layout.studio_dir || 'NVH_HOME/studio'} and {storageStatus?.layout.bin_dir || 'NVH_HOME/bin'}
- {starterStudioPackIds.length} packs - {studioPacks.filter(pack => pack.status.installed).length}/{studioPacks.length} installed - selected ~{selectedStudioPackDiskGb.toFixed(1)} GB - free {storageFreeGb === null ? 'unknown' : `${storageFreeGb} GB`} + {starterStudioPackIds.length} starter packs - {clawStudioPackIds.length} Claw options - {studioPacks.filter(pack => pack.status.installed).length}/{studioPacks.length} installed - {blockedStudioPackCount} blocked by host - selected ~{selectedStudioPackDiskGb.toFixed(1)} GB - free {storageFreeGb === null ? 'unknown' : `${storageFreeGb} GB`}
@@ -2460,6 +2496,13 @@ export default function SetupPage() { > Select Starter +
{studioPacks.filter(pack => pack.category === category).map(pack => { - const selected = selectedStudioPacks.has(pack.id); + const installable = studioPackInstallable(pack); + const selected = selectedStudioPacks.has(pack.id) && installable; + const blockedReason = studioPackBlockedReason(pack); + const badgeText = pack.status.installed ? 'INSTALLED' : installable ? 'READY' : 'BLOCKED'; return (