diff --git a/backend/app.py b/backend/app.py index a8d646b..971cf83 100644 --- a/backend/app.py +++ b/backend/app.py @@ -194,11 +194,17 @@ def get_tool_filename(tool): def find_tool_executable(tool): cfg = load_config() - return llama_manager_service.get_backend_bin_dir(APP_CONTEXT, cfg.get("backend")) / get_tool_filename(tool) + return llama_manager_service.resolve_backend_tool_executable( + APP_CONTEXT, cfg.get("backend"), tool + ) def get_runtime_files(): cfg = load_config() + if llama_manager_service.is_system_backend(cfg.get("backend")): + # System tools live across PATH directories; there is no single + # repository bin directory to enumerate. + return [] search_dir = llama_manager_service.get_backend_bin_dir(APP_CONTEXT, cfg.get("backend")) if not search_dir.exists(): return [] @@ -973,6 +979,7 @@ def do_DELETE(self): .add("POST", "/api/install", install_routes.start_install) .add("POST", "/api/update", install_routes.start_update) .add("POST", "/api/activate-custom", install_routes.activate_custom) + .add("POST", "/api/activate-system", install_routes.activate_system) .add("POST", "/api/launch/preflight", process_routes.preflight_launch) .add("POST", "/api/presets/fingerprint", process_routes.fingerprint_preset) .add("POST", "/api/launch", process_routes.launch) diff --git a/backend/context.py b/backend/context.py index df07372..c973dd2 100644 --- a/backend/context.py +++ b/backend/context.py @@ -56,7 +56,7 @@ class BackendServices: binary_suffix: str = "" current_arch: str = "unknown" current_platform: str = "unknown" - find_tool_executable: Callable[[str], Path] = _missing_service + find_tool_executable: Callable[[str], Optional[Path]] = _missing_service get_local_llama_metrics: Callable[[str, str, str], Tuple[Optional[str], str]] = _missing_service get_local_llama_props: Callable[[str, str, str], Tuple[Optional[str], str]] = _missing_service get_local_llama_slots: Callable[[str, str, str], Tuple[Optional[str], str]] = _missing_service diff --git a/backend/routes/install.py b/backend/routes/install.py index bf47e5e..13538ad 100644 --- a/backend/routes/install.py +++ b/backend/routes/install.py @@ -37,7 +37,7 @@ def get_releases(request, response, ctx): spec = None query = urllib.parse.parse_qs(request.query or "") backend = (query.get("backend") or [""])[0].strip() - if llama_manager.is_custom_backend(backend): + if llama_manager.is_custom_backend(backend) or llama_manager.is_system_backend(backend): response.json([]) return if backend: @@ -89,6 +89,15 @@ def start_install(request, response, ctx): if llama_manager.is_custom_backend(backend): response.error("Use /api/activate-custom to set up the custom backend", 400) return + if llama_manager.is_system_backend(backend): + if activate_existing: + response.error("Use /api/activate-system to activate System (PATH) tools", 400) + else: + response.error( + "System (PATH) tools are installed by the OS package manager; " + "use /api/activate-system to use them", 400 + ) + return if backend not in backend_specs: response.error(f"Unsupported backend: {backend}", 400) return @@ -142,6 +151,11 @@ def start_update(request, response, ctx): if llama_manager.is_custom_backend(backend): response.error("Cannot auto-update a custom backend installation", 400) return + if llama_manager.is_system_backend(backend): + response.error( + "System (PATH) tools are updated by the OS package manager", 400 + ) + return if backend not in backend_specs: response.error(f"Unsupported configured backend: {backend}", 400) return @@ -216,6 +230,34 @@ def _update(latest_tag, backend_name): response.json({"status": "started", "from": tag, "to": latest}) +def activate_system(request, response, ctx): + body = request.body or {} + if not isinstance(body, dict): + response.error("Invalid request body", 400) + return + for key in ("path", "executable", "executable_path", "bin_dir", "directory"): + if key in body: + response.error( + "System activation uses the PATH inherited by Llama GUI; " + "client-supplied paths are not accepted", + 400, + ) + return + try: + claim_error = _claim_install_slot(ctx) + if claim_error is not None: + response.error(*claim_error) + return + try: + result = llama_manager.activate_system_backend(ctx) + response.json(result) + finally: + process_manager.release_install_slot(ctx) + except Exception as e: + print(f"[install] activate system backend failed: {e}", file=sys.stderr) + response.error(sanitize_error(e, 500), 500) + + def activate_custom(request, response, ctx): backend = (request.body or {}).get("backend", "custom") if not llama_manager.is_custom_backend(backend): diff --git a/backend/routes/lifecycle.py b/backend/routes/lifecycle.py index 173ea73..811758e 100644 --- a/backend/routes/lifecycle.py +++ b/backend/routes/lifecycle.py @@ -27,9 +27,24 @@ def post_open_folder(request, response, ctx): try: if folder == "llama": backend = ctx.services.load_config().get("backend") - target = (llama_manager.get_backend_bin_dir(ctx, backend).parent - if llama_manager.is_custom_backend(backend) else ctx.paths.llama) - target.mkdir(parents=True, exist_ok=True) + if llama_manager.is_system_backend(backend): + # Read-only policy: reveal the discovered server's directory + # without creating anything. A missing server reports PATH + # guidance instead of falling back to an application folder. + try: + server_exe = ctx.services.find_tool_executable("llama-server") + except Exception: + server_exe = None + if server_exe is None or not server_exe.is_file(): + response.error(llama_manager.SYSTEM_PATH_GUIDANCE, 409) + return + target = server_exe.parent + elif llama_manager.is_custom_backend(backend): + target = llama_manager.get_backend_bin_dir(ctx, backend).parent + target.mkdir(parents=True, exist_ok=True) + else: + target = ctx.paths.llama + target.mkdir(parents=True, exist_ok=True) else: target = model_dir.get_models_dir(ctx) lifecycle_service.open_folder_in_file_manager(target) diff --git a/backend/routes/status.py b/backend/routes/status.py index 5400032..d8f7f80 100644 --- a/backend/routes/status.py +++ b/backend/routes/status.py @@ -20,17 +20,25 @@ def get_status(request, response, ctx): for tool in services.llama_tools: name = services.get_tool_filename(tool) executable = services.find_tool_executable(tool) - exes[name] = executable.is_file() and ( + exes[name] = executable is not None and executable.is_file() and ( services.current_platform == "win32" or os.access(executable, os.X_OK) ) runtime_files = services.get_runtime_files() runtime_health = dict(services.validate_runtime_dependencies()) has_config = bool(cfg.get("tag")) - core_tools_present = all( - exes.get(services.get_tool_filename(tool), False) - for tool in ("llama-cli", "llama-server") - ) + is_system = llama_manager.is_system_backend(cfg.get("backend")) + if is_system: + # System readiness needs the required server entry point, not the + # optional CLI. A missing estimator or benchmark tool never blocks it. + core_tools_present = bool( + exes.get(services.get_tool_filename("llama-server"), False) + ) + else: + core_tools_present = all( + exes.get(services.get_tool_filename(tool), False) + for tool in ("llama-cli", "llama-server") + ) installed = has_config and core_tools_present and runtime_health.get("ok", True) config_stale = has_config and not installed process_status = process_manager.get_process_status_snapshot(ctx) @@ -43,6 +51,42 @@ def get_status(request, response, ctx): print(f"[status] failed to read llama API target: {exc}", file=sys.stderr) api_target = {"host": LLAMA_HOST, "port": LLAMA_PORT} + # PATH discovery for display: per-tool entry points, availability, and + # probe state. Paths resolve regardless of the active backend so the + # GUI can preview System before activation; build/probe details come + # from runtime health and are only present while System is active. + system_probes = runtime_health.get("system_probes") or {} + system_build_tags = runtime_health.get("build_tags") or {} + system_tools = {} + for tool in services.llama_tools: + try: + discovered = services.find_tool_executable(tool) + except Exception: + discovered = None + probe = system_probes.get(tool) or {} + system_tools[tool] = { + "path": str(discovered) if discovered is not None else None, + "available": bool( + exes.get(services.get_tool_filename(tool), False) + ), + "build_tag": system_build_tags.get(tool), + "probe_ok": probe.get("ok"), + "probe_error": probe.get("error"), + } + + system_server_error = None + if is_system: + server_probe = system_probes.get("llama-server") or {} + if not system_tools.get("llama-server", {}).get("available"): + system_server_error = llama_manager.SYSTEM_PATH_GUIDANCE + elif server_probe.get("ok") is False: + detail = server_probe.get("error") or "could not be started" + server_name = services.get_tool_filename("llama-server") + system_server_error = ( + f"{server_name} {detail}. " + f"Check that it runs with `{server_name} --version`." + ) + response.json( { "installed": installed, @@ -55,6 +99,8 @@ def get_status(request, response, ctx): "runtime_files_label": "Runtime libraries", "runtime_health": runtime_health, "missing_runtime_files": runtime_health.get("missing_runtime_files", []), + "system_tools": system_tools, + "system_server_error": system_server_error, **model_dir_info, "running": process_status["running"], "active_process_tool": process_status["active_process_tool"], @@ -73,6 +119,8 @@ def get_status(request, response, ctx): "id": key, "label": spec["label"], **({"custom": True, "bin_dir": llama_manager.custom_backend_bin_label(key)} if llama_manager.is_custom_backend(key) else {}), + **({"system_path": True} + if llama_manager.is_system_backend(key) else {}), } for key, spec in backend_specs.items() ], diff --git a/backend/services/llama_manager.py b/backend/services/llama_manager.py index 780d8d9..372c34d 100644 --- a/backend/services/llama_manager.py +++ b/backend/services/llama_manager.py @@ -22,11 +22,13 @@ from ..context import AppContext from ..http import sanitize_error from . import official_backends +from .subprocess_utils import get_no_window_creationflags RPATH_LIBRARY_RE = re.compile(r"^\s*@rpath/([^\s(]+)") LDD_NOT_FOUND_RE = re.compile(r"^\s*([^\s]+)\s+=>\s+not found\s*$") SHA256_DIGEST_RE = re.compile(r"^sha256:([0-9a-fA-F]{64})$") +BUILD_TAG_RE = re.compile(r"\bbuild\s+(\d+)\b", re.I) # Runtime dependency validation shells out to otool on macOS and ldd on Linux, # so results are cached briefly. Config-changing operations (install, cleanup, @@ -47,12 +49,60 @@ "custom-02": {"label": "Custom 02", "folder": "custom-02"}, } +# Backend ID for llama.cpp tools resolved from the GUI process's inherited +# PATH. The OS package manager owns installation and updates; Llama GUI only +# discovers, launches, and stops its own child processes. Stage 1 covers +# classification and discovery; activation/probes/launch arrive in later stages. +SYSTEM_BACKEND_ID = "system" +SYSTEM_BACKEND_LABEL = "System (PATH)" + +# Actionable guidance when the required System server entry point cannot be +# found on the inherited PATH. Never dump the environment or offer shell edits. +SYSTEM_PATH_GUIDANCE = ( + "llama-server was not found on the PATH inherited by Llama GUI. " + "Start the GUI from the environment where it is available, or restart " + "the GUI after changing its launch environment." +) + +# Internal PATH discovery target that is never a public launch tool. Memory +# estimation locates it separately; Stage 1 only resolves it. +SYSTEM_INTERNAL_TOOLS = ("llama-fit-params",) + +# Bounded, model-free `--version` execution check for System entry points. +SYSTEM_VERSION_PROBE_TIMEOUT_SECONDS = 5 + + +def parse_llama_build_tag(output: str) -> Optional[str]: + """Return the normalized ``bNNNNN`` tag from `--version` output, if present.""" + match = BUILD_TAG_RE.search(output or "") + return f"b{match.group(1)}" if match else None + def is_custom_backend(backend: Any) -> bool: return isinstance(backend, str) and backend in CUSTOM_BACKEND_SPECS +def is_system_backend(backend: Any) -> bool: + return isinstance(backend, str) and backend == SYSTEM_BACKEND_ID + + +def is_externally_managed_backend(backend: Any) -> bool: + """Installations owned outside the GUI (package manager, not llama/).""" + return is_system_backend(backend) + + +def is_official_backend(backend: Any) -> bool: + """Downloadable llama.cpp builds managed under llama/bin/.""" + return ( + isinstance(backend, str) + and not is_custom_backend(backend) + and not is_system_backend(backend) + ) + + def get_backend_bin_dir(ctx: AppContext, backend: Any) -> pathlib.Path: + if is_system_backend(backend): + raise ValueError("System (PATH) backend has no single bin directory.") if backend == "custom": return ctx.paths.llama_custom_bin if is_custom_backend(backend): @@ -61,6 +111,8 @@ def get_backend_bin_dir(ctx: AppContext, backend: Any) -> pathlib.Path: def get_backend_grammars_dir(ctx: AppContext, backend: Any) -> pathlib.Path: + if is_system_backend(backend): + raise ValueError("System (PATH) backend has no single grammars directory.") if backend == "custom": return ctx.paths.llama_custom_grammars if is_custom_backend(backend): @@ -71,6 +123,251 @@ def get_backend_grammars_dir(ctx: AppContext, backend: Any) -> pathlib.Path: def custom_backend_bin_label(backend: str) -> str: return f"llama/{CUSTOM_BACKEND_SPECS[backend]['folder']}/bin/" + +def is_system_discovery_tool(ctx: AppContext, tool: Any) -> bool: + """Restrict PATH discovery to known llama.cpp tools. + + Public launch tools come from ``ctx.services.llama_tools``; the memory + estimator is an internal target that must never become a launch option. + Anything else returns False so arbitrary executable names cannot pass + through the API into ``shutil.which()``. + """ + if not isinstance(tool, str) or not tool: + return False + try: + allowed = tuple(ctx.services.llama_tools or ()) + except Exception: + allowed = () + return tool in allowed or tool in SYSTEM_INTERNAL_TOOLS + + +def resolve_system_tool_executable( + ctx: AppContext, tool: str +) -> Optional[pathlib.Path]: + """Resolve one known tool from the inherited PATH, or None if unavailable. + + Uses ``shutil.which()`` on the platform-aware filename so normal PATH + precedence (including relative entries) applies. The result is made + absolute with ``os.path.abspath()`` without dereferencing symlinks, so + Nix/store wrappers keep their entry point. No shell is launched and no + repository installation directory is consulted: tools may live in + different directories and a missing tool never falls back elsewhere. + """ + if not is_system_discovery_tool(ctx, tool): + return None + try: + filename = ctx.services.get_tool_filename(tool) + except Exception: + return None + if not isinstance(filename, str) or not filename: + return None + if "/" in filename or "\\" in filename or "\x00" in filename: + return None + if ".." in pathlib.PurePosixPath(filename).parts: + return None + try: + found = shutil.which(filename) + except Exception: + return None + if not found: + return None + path = pathlib.Path(os.path.abspath(found)) + try: + if not path.is_file(): + return None + except OSError: + return None + current_platform = getattr(ctx.services, "current_platform", None) or sys.platform + if current_platform == "unknown": + current_platform = sys.platform + if current_platform != "win32": + try: + if not os.access(path, os.X_OK): + return None + except OSError: + return None + return path + + +def resolve_backend_tool_executable( + ctx: AppContext, backend: Any, tool: str +) -> Optional[pathlib.Path]: + """Shared resolver for the selected backend's tools. + + System backends resolve each known tool independently from PATH and + return None when it is missing or undiscoverable. Official and Custom + backends preserve the existing folder-path result (which callers check + for existence) so their behavior is unchanged. + """ + if is_system_backend(backend): + return resolve_system_tool_executable(ctx, tool) + try: + filename = ctx.services.get_tool_filename(tool) + except Exception: + return None + try: + return get_backend_bin_dir(ctx, backend) / filename + except Exception: + return None + + +def probe_system_tool_executable( + ctx: AppContext, tool: str, exe_path: pathlib.Path +) -> dict[str, Any]: + """Run a bounded, model-free `--version` check of one System entry point. + + Uses the discovered entry point itself (never a resolved symlink target), + the inherited runtime environment, and the shared window-hiding flags. + A successful run with an unrecognized build format reports ``ok=True`` + with ``build_tag=None``; that is distinct from a failure to execute. + """ + try: + completed = subprocess.run( + [str(exe_path), "--version"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=os.environ.copy(), + cwd=str(ctx.paths.root), + timeout=SYSTEM_VERSION_PROBE_TIMEOUT_SECONDS, + check=False, + creationflags=get_no_window_creationflags(), + ) + except subprocess.TimeoutExpired: + print( + f"[llama_manager] system version probe timed out for {tool}: {exe_path}", + file=sys.stderr, + ) + return { + "ok": False, + "build_tag": None, + "error": f"timed out after {SYSTEM_VERSION_PROBE_TIMEOUT_SECONDS} seconds", + "executable": str(exe_path), + } + except OSError as exc: + print( + f"[llama_manager] system version probe failed for {tool}: {exc}", + file=sys.stderr, + ) + return { + "ok": False, + "build_tag": None, + "error": f"could not start ({exc})", + "executable": str(exe_path), + } + if completed.returncode != 0: + print( + f"[llama_manager] system version probe exited with status " + f"{completed.returncode} for {tool}: {exe_path}", + file=sys.stderr, + ) + return { + "ok": False, + "build_tag": None, + "error": f"exited with status {completed.returncode}", + "executable": str(exe_path), + } + combined = "\n".join( + part for part in (completed.stdout, completed.stderr) if part + ) + return { + "ok": True, + "build_tag": parse_llama_build_tag(combined), + "error": None, + "executable": str(exe_path), + } + + +def _system_executable_identity(exe_path: pathlib.Path) -> Optional[tuple[Any, ...]]: + """File/symlink identity for cache invalidation across package updates. + + ``os.stat()`` follows the entry point, so replacing a symlink target + while the GUI is open changes the identity and stale probe results are + never reused. Returns None when the file cannot be stated. + """ + try: + stat_result = os.stat(exe_path) + except OSError: + return None + return ( + stat_result.st_dev, + stat_result.st_ino, + stat_result.st_size, + stat_result.st_mtime_ns, + ) + + +def _system_runtime_cache_key( + ctx: AppContext, backend: Any, tool_names: tuple[str, ...] +) -> tuple[Any, ...]: + """Cache key including each tool's path and file identity. + + Discovery itself is cheap, so resolve on every status poll and let the + identity detect replaced/removed tools; only cache misses spawn probes. + """ + parts = [] + for tool in tool_names: + try: + exe_path = resolve_backend_tool_executable(ctx, backend, tool) + except Exception: + exe_path = None + parts.append( + ( + tool, + str(exe_path) if exe_path is not None else None, + _system_executable_identity(exe_path) if exe_path is not None else None, + ) + ) + return (backend, tool_names, tuple(parts)) + + +def _validate_system_runtime_dependencies( + ctx: AppContext, tool_names: tuple[str, ...] +) -> dict[str, Any]: + """Execution-check health for System tools; never requires packaged libs. + + A successful probe establishes that the entry point starts. It does not + establish model loading or GPU compatibility. Missing tools are reported + explicitly and never fail the check on their own; only a present tool + that fails to execute does. + """ + checked_tools: list[str] = [] + probe_failures: list[str] = [] + missing_executables: list[str] = [] + build_tags: dict[str, str] = {} + probes: dict[str, dict[str, Any]] = {} + for tool in tool_names: + try: + exe_path = resolve_backend_tool_executable( + ctx, SYSTEM_BACKEND_ID, tool + ) + except Exception: + exe_path = None + if exe_path is None: + missing_executables.append(ctx.services.get_tool_filename(tool)) + continue + probe = probe_system_tool_executable(ctx, tool, exe_path) + probes[tool] = probe + if probe["ok"]: + checked_tools.append(tool) + if probe["build_tag"]: + build_tags[tool] = probe["build_tag"] + else: + probe_failures.append(tool) + return { + "ok": not probe_failures, + "checked": bool(checked_tools), + "checked_tools": checked_tools, + "unchecked_tools": sorted(probe_failures), + "checked_runtime_files": [], + "unchecked_runtime_files": [], + "required_runtime_files": [], + "missing_runtime_files": [], + "missing_executables": missing_executables, + "build_tags": build_tags, + "system_probes": probes, + } + # (gpu_target, family label) for every target upstream publishes. LEMONADE_ROCM_TARGETS = [ ("gfx103X", "AMD RDNA2"), @@ -114,7 +411,7 @@ def build_backend_specs(current_platform: str, current_arch: str) -> dict[str, A def with_custom(specs: dict[str, Any]) -> dict[str, Any]: return {**specs, **{ key: {"label": spec["label"]} for key, spec in CUSTOM_BACKEND_SPECS.items() - }} + }, SYSTEM_BACKEND_ID: {"label": SYSTEM_BACKEND_LABEL}} if current_platform == "win32": if current_arch == "arm64": @@ -443,16 +740,6 @@ def validate_runtime_dependencies( current_platform = ctx.services.current_platform or sys.platform if current_platform == "unknown": current_platform = sys.platform - skipped = _linux_runtime_validation_opt_out(current_platform) - if skipped is not None: - return skipped - if current_platform != "darwin" and not current_platform.startswith("linux"): - return { - "ok": True, - "checked": False, - "required_runtime_files": [], - "missing_runtime_files": [], - } tool_names = tuple(tools) if tools is not None else () if not tool_names: @@ -464,6 +751,33 @@ def validate_runtime_dependencies( print(f"[llama_manager] load_config failed during runtime validation: {e}", file=sys.stderr) cfg = {} + if is_system_backend(cfg.get("backend")): + # System health comes from bounded `--version` execution probes, so it + # runs on every platform. Neither the Linux-only ldd opt-out nor the + # Unix-only packaged-library gate applies here. + cache_key = _system_runtime_cache_key(ctx, cfg.get("backend"), tool_names) + now = time.monotonic() + with ctx.state.runtime_health_lock: + cached = ctx.state.runtime_health_cache.get(cache_key) + if cached is not None and now - cached[0] < RUNTIME_HEALTH_CACHE_TTL_SECONDS: + return copy.deepcopy(cached[1]) + + result = _validate_system_runtime_dependencies(ctx, tool_names) + with ctx.state.runtime_health_lock: + ctx.state.runtime_health_cache[cache_key] = (time.monotonic(), result) + return copy.deepcopy(result) + + skipped = _linux_runtime_validation_opt_out(current_platform) + if skipped is not None: + return skipped + if current_platform != "darwin" and not current_platform.startswith("linux"): + return { + "ok": True, + "checked": False, + "required_runtime_files": [], + "missing_runtime_files": [], + } + cache_key = (cfg.get("backend"), tool_names) now = time.monotonic() with ctx.state.runtime_health_lock: @@ -489,11 +803,13 @@ def _validate_runtime_dependencies_uncached( current_platform = ctx.services.current_platform or sys.platform if current_platform == "unknown": current_platform = sys.platform + if is_system_backend(cfg.get("backend")): + return _validate_system_runtime_dependencies(ctx, tool_names) runtime_dir = get_backend_bin_dir(ctx, cfg.get("backend")) for tool in tool_names: exe_path = ctx.services.find_tool_executable(tool) - if not exe_path.exists(): + if exe_path is None or not exe_path.exists(): missing_executables.append(ctx.services.get_tool_filename(tool)) continue try: @@ -594,7 +910,7 @@ def activate_custom_backend(ctx: AppContext, backend: str = "custom") -> dict[st with ctx.state.config_lock: cfg = dict(ctx.services.load_config()) - if cfg.get("backend") and not is_custom_backend(cfg.get("backend")) and cfg.get("tag"): + if cfg.get("backend") and is_official_backend(cfg.get("backend")) and cfg.get("tag"): cfg["official_install"] = { "backend": cfg["backend"], "tag": cfg["tag"], @@ -618,17 +934,124 @@ def activate_custom_backend(ctx: AppContext, backend: str = "custom") -> dict[st return {"ok": False, "error": sanitize_error(e, 500)} +def activate_system_backend(ctx: AppContext) -> dict[str, Any]: + """Activate System (PATH) tools after a successful server execution probe. + + Discovery uses the inherited PATH only; no client-supplied path is + accepted (the route rejects those before calling here). Only + ``llama-server`` is required; every other known tool is reported as an + independently available feature. Configuration is unchanged unless the + server probe succeeds. Discovered paths and build tags are returned for + display but never persisted: restart rediscovers them. + """ + try: + try: + tools = list(ctx.services.llama_tools or []) + except Exception: + tools = [] + try: + server_filename = ctx.services.get_tool_filename("llama-server") + except Exception: + server_filename = "llama-server" + + paths: dict[str, str] = {} + found: list[str] = [] + missing: list[str] = [] + for tool in tools: + try: + exe_path = resolve_system_tool_executable(ctx, tool) + except Exception: + exe_path = None + try: + exe_name = ctx.services.get_tool_filename(tool) + except Exception: + exe_name = str(tool) + if exe_path is None: + missing.append(exe_name) + else: + found.append(exe_name) + paths[tool] = str(exe_path) + + try: + server_exe = resolve_system_tool_executable(ctx, "llama-server") + except Exception: + server_exe = None + if server_exe is None: + return { + "ok": False, + "found": sorted(found), + "missing": sorted(missing), + "missing_required": [server_filename], + "paths": paths, + "build_tags": {}, + "error": SYSTEM_PATH_GUIDANCE, + } + + probe = probe_system_tool_executable(ctx, "llama-server", server_exe) + if not probe.get("ok"): + detail = probe.get("error") or "could not be started" + return { + "ok": False, + "found": sorted(found), + "missing": sorted(missing), + "missing_required": [], + "paths": paths, + "build_tags": {}, + "probe_error": detail, + "error": ( + f"{server_filename} {detail}. " + f"Check that it runs with `{server_filename} --version`." + ), + } + + build_tags: dict[str, str] = {} + if probe.get("build_tag"): + build_tags["llama-server"] = probe["build_tag"] + + with ctx.state.config_lock: + cfg = dict(ctx.services.load_config()) + if cfg.get("backend") and is_official_backend(cfg.get("backend")) and cfg.get("tag"): + cfg["official_install"] = { + "backend": cfg["backend"], + "tag": cfg["tag"], + "version": cfg.get("version") or cfg["tag"], + } + cfg["version"] = SYSTEM_BACKEND_ID + cfg["backend"] = SYSTEM_BACKEND_ID + cfg["tag"] = SYSTEM_BACKEND_ID + ctx.services.save_config(cfg) + ctx.state.clear_runtime_health_cache() + return { + "ok": True, + "backend": SYSTEM_BACKEND_ID, + "tag": SYSTEM_BACKEND_ID, + "version": SYSTEM_BACKEND_ID, + "found": sorted(found), + "missing": sorted(missing), + "missing_required": [], + "paths": paths, + "build_tags": build_tags, + "server_path": str(server_exe), + } + except Exception as e: + print(f"[llama_manager] activate_system_backend failed: {e}", file=sys.stderr) + return {"ok": False, "error": sanitize_error(e, 500)} + + def get_official_install_status( ctx: AppContext, cfg: Optional[Mapping[str, Any]] = None ) -> dict[str, Any]: cfg = cfg or ctx.services.load_config() stored = cfg.get("official_install") stored = stored if isinstance(stored, Mapping) else {} + if is_system_backend(stored.get("backend")): + # System is never an official installation; ignore a stale record. + stored = {} backend = stored.get("backend") tag = stored.get("tag") version = stored.get("version") - if cfg.get("backend") and not is_custom_backend(cfg.get("backend")) and cfg.get("tag"): + if cfg.get("backend") and is_official_backend(cfg.get("backend")) and cfg.get("tag"): backend = cfg.get("backend") tag = cfg.get("tag") version = cfg.get("version") or tag @@ -669,8 +1092,7 @@ def _probe_official_build(ctx: AppContext) -> tuple[bool, Optional[str]]: return False, None if result.returncode != 0: return False, None - match = re.search(r"\bbuild\s+(\d+)\b", result.stdout + "\n" + result.stderr, re.I) - return True, f"b{match.group(1)}" if match else None + return True, parse_llama_build_tag(result.stdout + "\n" + result.stderr) def activate_official_backend(ctx: AppContext, backend: str) -> dict[str, Any]: diff --git a/backend/services/process_manager.py b/backend/services/process_manager.py index 09d650c..41ca394 100644 --- a/backend/services/process_manager.py +++ b/backend/services/process_manager.py @@ -491,8 +491,10 @@ def _load_config_safe(ctx: AppContext) -> dict[str, Any]: def _fit_params_executable(ctx: AppContext) -> Any: - suffix = getattr(ctx.services, "binary_suffix", "") or "" cfg = _load_config_safe(ctx) + if llama_manager.is_system_backend(cfg.get("backend")): + return llama_manager.resolve_system_tool_executable(ctx, "llama-fit-params") + suffix = getattr(ctx.services, "binary_suffix", "") or "" bin_dir = llama_manager.get_backend_bin_dir(ctx, cfg.get("backend")) return bin_dir / f"llama-fit-params{suffix}" @@ -572,10 +574,22 @@ def parse_list_devices_output(output: str) -> list[str]: def get_buffer_types(ctx: AppContext) -> dict[str, Any]: allowed_tools = list(ctx.services.llama_tools or []) - tool = "llama-cli" if "llama-cli" in allowed_tools else (allowed_tools[0] if allowed_tools else "llama-cli") + if llama_manager.is_system_backend(_load_config_safe(ctx).get("backend")): + # PATH tools need not share one directory or build: prefer an + # available CLI probe target and use the server when CLI is absent. + ordered = [tool for tool in ("llama-cli", "llama-server") if tool in allowed_tools] + if not ordered: + ordered = ["llama-cli"] + tool = next( + (candidate for candidate in ordered + if ctx.services.find_tool_executable(candidate) is not None), + ordered[0], + ) + else: + tool = "llama-cli" if "llama-cli" in allowed_tools else (allowed_tools[0] if allowed_tools else "llama-cli") exe_name = ctx.services.get_tool_filename(tool) exe_path = ctx.services.find_tool_executable(tool) - if not exe_path.exists(): + if exe_path is None or not exe_path.exists(): return { "buffers": ["CPU"], "default": "CPU", @@ -709,26 +723,33 @@ def _memory_estimate_args(args: list[str]) -> list[str]: return filtered_args -def estimate_memory(ctx: AppContext, tool: str, args_list: Optional[Iterable[Any]], env: Any = None) -> dict[str, Any]: +def _validate_system_estimator(ctx: AppContext, exe_path: Path) -> Optional[str]: + """Confirm the System estimator starts; estimation never blocks launches.""" try: - environment = normalize_process_env(env) - except ValueError as exc: - return {"error": str(exc)} - allowed_tools = ctx.services.llama_tools or [] - if tool not in allowed_tools: - return {"error": f"Unknown tool: {tool!r}"} - - exe_path = _fit_params_executable(ctx) - if not exe_path.exists(): - return {"error": "llama-fit-params not found. Install or repair llama.cpp first."} + health = dict(ctx.services.validate_runtime_dependencies(["llama-fit-params"])) + except Exception as exc: + print(f"[process] system estimator probe lookup failed: {exc}", file=sys.stderr) + health = {} + probe = (health.get("system_probes") or {}).get("llama-fit-params") + if probe is None or probe.get("executable") != str(exe_path): + probe = llama_manager.probe_system_tool_executable( + ctx, "llama-fit-params", exe_path + ) + if probe.get("ok"): + return None + detail = probe.get("error") or "could not be started" + return ( + f"llama-fit-params {detail}. " + "Memory estimation is unavailable for this installation." + ) - runtime_health = dict(ctx.services.validate_runtime_dependencies([tool])) - missing_runtime_files = runtime_health.get("missing_runtime_files") or [] - if missing_runtime_files: - missing = ", ".join(str(name) for name in missing_runtime_files) - plural = "libraries" if len(missing_runtime_files) != 1 else "library" - return {"error": f"Missing llama.cpp runtime {plural}: {missing}."} +def _run_memory_estimate_command( + ctx: AppContext, + exe_path: Path, + args_list: Optional[Iterable[Any]], + environment: Mapping[str, str], +) -> dict[str, Any]: args = flatten_launch_args(args_list) filtered_args = _memory_estimate_args(args) @@ -774,6 +795,41 @@ def estimate_memory(ctx: AppContext, tool: str, args_list: Optional[Iterable[Any } +def estimate_memory(ctx: AppContext, tool: str, args_list: Optional[Iterable[Any]], env: Any = None) -> dict[str, Any]: + try: + environment = normalize_process_env(env) + except ValueError as exc: + return {"error": str(exc)} + allowed_tools = ctx.services.llama_tools or [] + if tool not in allowed_tools: + return {"error": f"Unknown tool: {tool!r}"} + + exe_path = _fit_params_executable(ctx) + backend = _load_config_safe(ctx).get("backend") + if exe_path is None or not exe_path.exists(): + if llama_manager.is_system_backend(backend): + return {"error": ( + "llama-fit-params was not found on the PATH inherited by Llama GUI. " + "Memory estimation is unavailable for this installation." + )} + return {"error": "llama-fit-params not found. Install or repair llama.cpp first."} + + if llama_manager.is_system_backend(backend): + # Validate the estimator itself rather than the requested tool. + estimator_error = _validate_system_estimator(ctx, exe_path) + if estimator_error is not None: + return {"error": estimator_error} + else: + runtime_health = dict(ctx.services.validate_runtime_dependencies([tool])) + missing_runtime_files = runtime_health.get("missing_runtime_files") or [] + if missing_runtime_files: + missing = ", ".join(str(name) for name in missing_runtime_files) + plural = "libraries" if len(missing_runtime_files) != 1 else "library" + return {"error": f"Missing llama.cpp runtime {plural}: {missing}."} + + return _run_memory_estimate_command(ctx, exe_path, args_list, environment) + + def _launch_api_target_values( ctx: AppContext, args_list: Optional[Iterable[Any]] ) -> tuple[Any, Any]: @@ -1063,6 +1119,11 @@ def normalize_process_env(value: Any) -> dict[str, str]: def _build_process_env(ctx: AppContext) -> dict[str, str]: env = os.environ.copy() cfg = _load_config_safe(ctx) + if llama_manager.is_system_backend(cfg.get("backend")): + # System tools use the inherited environment as-is; no repository + # binary/library directory is prepended. Per-launch LLAMA_/GGML_ + # overrides are merged by the caller. + return env bin_dir = llama_manager.get_backend_bin_dir(ctx, cfg.get("backend")) runtime_paths = [str(bin_dir)] existing_path = env.get("PATH", "") @@ -1140,19 +1201,51 @@ def redact_sensitive_text(text: Any, args: Iterable[Any]) -> str: return result +def _validate_system_launch_probe( + ctx: AppContext, tool: str, exe_path: Path, exe_name: str +) -> tuple[Optional[Path], Optional[str]]: + """Confirm a System entry point starts, using the cached version probe. + + Falls back to a direct probe when discovery changed since the cached + result, so the validated entry point is always the one passed to Popen. + """ + try: + health = dict(ctx.services.validate_runtime_dependencies([tool])) + except Exception as exc: + print(f"[process] system runtime probe lookup failed: {exc}", file=sys.stderr) + health = {} + probe = (health.get("system_probes") or {}).get(tool) + if probe is None or probe.get("executable") != str(exe_path): + probe = llama_manager.probe_system_tool_executable(ctx, tool, exe_path) + if probe.get("ok"): + return exe_path, None + detail = probe.get("error") or "could not be started" + return None, ( + f"{exe_name} {detail}. " + f"Check that it runs with `{exe_name} --version`." + ) + + def _validate_launch_environment( ctx: AppContext, tool: str ) -> tuple[Optional[Path], Optional[str]]: exe_name = ctx.services.get_tool_filename(tool) exe_path = ctx.services.find_tool_executable(tool) - if not exe_path.is_file(): + backend = _load_config_safe(ctx).get("backend") + if exe_path is None or not exe_path.is_file(): + if llama_manager.is_system_backend(backend): + return None, ( + f"{exe_name} was not found on the PATH inherited by Llama GUI. " + "Install it with your system package manager, then restart the GUI." + ) return None, f"{exe_name} not found. Install llama.cpp first." current_platform = ctx.services.current_platform if current_platform == "unknown": current_platform = sys.platform if current_platform != "win32" and not os.access(exe_path, os.X_OK): - backend = _load_config_safe(ctx).get("backend") + if llama_manager.is_system_backend(backend): + return None, f"{exe_name} is not executable. Run chmod +x on {exe_path}." recovery = ( f"Run chmod +x on {llama_manager.custom_backend_bin_label(backend)}{exe_name}." if llama_manager.is_custom_backend(backend) @@ -1160,6 +1253,9 @@ def _validate_launch_environment( ) return None, f"{exe_name} is not executable. {recovery}" + if llama_manager.is_system_backend(backend): + return _validate_system_launch_probe(ctx, tool, exe_path, exe_name) + runtime_health = dict(ctx.services.validate_runtime_dependencies([tool])) missing_runtime_files = runtime_health.get("missing_runtime_files") or [] if not missing_runtime_files: @@ -1619,7 +1715,10 @@ def remove_llama_files(ctx: AppContext) -> int: with ctx.state.config_lock: config_data = _load_config_safe(ctx) - if not llama_manager.is_custom_backend(config_data.get("backend")): + if not ( + llama_manager.is_custom_backend(config_data.get("backend")) + or llama_manager.is_system_backend(config_data.get("backend")) + ): config_data.update({"version": None, "backend": None, "tag": None}) config_data.pop("official_install", None) ctx.services.save_config(config_data) diff --git a/docs/directory.md b/docs/directory.md index 62fa536..d4c488b 100644 --- a/docs/directory.md +++ b/docs/directory.md @@ -100,7 +100,7 @@ ### Route Modules (`backend/routes/`) -`API_ROUTER` at the bottom of `backend/app.py` is the authoritative registry: 49 exact routes plus one prefix route, 50 endpoints total. Keep this table in sync with it — a route that is registered but undocumented here is the drift that is hardest to notice. +`API_ROUTER` at the bottom of `backend/app.py` is the authoritative registry: 50 exact routes plus one prefix route, 51 endpoints total. Keep this table in sync with it — a route that is registered but undocumented here is the drift that is hardest to notice. | Route | Endpoints | |-------|-----------| @@ -108,7 +108,7 @@ | `external_server.py` | `GET /api/chat/target` (read the live and remembered target), `POST /api/chat/target` (register an externally started llama-server as the proxy target; `POST {"restore": true}` re-registers the address saved by an earlier session), `DELETE /api/chat/target` (clear it) | | `benchmarks.py` | `POST /api/benchmark/wikitext2` — ensure WikiText-2 raw test file exists | | `process.py` | `POST /api/launch`, `POST /api/launch/preflight`, `POST /api/presets/fingerprint`, `POST /api/estimate-memory`, generation-bound `POST /api/stop`, `POST /api/send-input`, `POST /api/cleanup-llama`, `GET /api/output`, `GET /api/llama/health`, `GET /api/llama/buffer-types` | -| `install.py` | `GET /api/backends` — refresh the cached official CUDA/ROCm catalog (`?refresh=1` requests a manual refresh; returns a warning on fallback), `GET /api/releases`, `GET /api/download-progress`, `POST /api/install`, `POST /api/update`, `POST /api/activate-custom` (optional `backend`: `custom` or `custom-02`; omitted defaults to `custom`) | +| `install.py` | `GET /api/backends` — refresh the cached official CUDA/ROCm catalog (`?refresh=1` requests a manual refresh; returns a warning on fallback), `GET /api/releases`, `GET /api/download-progress`, `POST /api/install`, `POST /api/update`, `POST /api/activate-custom` (optional `backend`: `custom` or `custom-02`; omitted defaults to `custom`), `POST /api/activate-system` (System PATH activation; accepts no client-supplied path) | | `metrics.py` | `GET /api/llama/metrics`, `GET /api/llama/slots`, `GET /api/llama/props` — Prometheus proxy and template-capability props | | `models.py` | `GET /api/models` — list GGUF files recursively as names relative to the active model root | | `model_dir.py` | `POST /api/models-dir` — set or reset the active model root | @@ -1049,6 +1049,64 @@ Both slots retain `tag` / `version: "custom"` for existing flag-compatibility be **Open llama.cpp** opens the active Custom slot's root (containing `bin` and `grammars`), or the main `llama/` directory for official builds. Official update and repair paths exclude both slots. **Remove llama.cpp Files** only removes official runtime directories and metadata; it preserves both custom directories and an active custom selection. The GitHub release importer remains deferred. +## System (PATH) Backend + +Install & Update also offers **System (PATH)**, which uses the llama.cpp tools +already on the GUI process's inherited `PATH` instead of a downloaded build. +The OS package manager owns installation and updates; Llama GUI only +discovers, launches, and stops its own child processes. This replaces the old +workaround of symlinking system executables into `llama/custom/bin/` and fits +NixOS packages and wrappers that supply their own runtime dependencies. + +Selection and discovery live in `backend/services/llama_manager.py`. +`build_backend_specs()` appends the `system` spec (no download asset) and +`GET /api/status` marks it with `system_path: true` in `available_backends`. +Each known tool resolves independently with `shutil.which()` on the +platform-aware filename: inherited `PATH` only (normal precedence, relative +entries included), made absolute without dereferencing symlinks so +Nix/store wrappers keep their entry point, with no shell and no fallback to +repository installations. Discovery accepts only known llama.cpp tools; +`llama-fit-params` is an internal memory-estimation target, never a launch +option. A missing System tool resolves to `None` explicitly. + +**Activate System** sends `POST /api/activate-system` with an empty body — +client-supplied paths are rejected. The route holds the install/launch +interlock and requires a bounded, model-free `--version` probe of the +discovered `llama-server` before saving config; only `llama-server` is +required, every other tool is an independently available feature. Success +persists just the `backend`/`tag`/`version: "system"` marker (merging with the +latest config so model-root and other settings survive) and preserves the +remembered official installation for the return path. Failed probing leaves +configuration unchanged, and discovered paths/build tags are never persisted — +restart rediscovers them from the new process's environment. + +Runtime validation executes the discovered entry point with a 5-second +`--version` probe using the inherited environment and shared window-hiding +flags. Exit 0 with an unrecognized format is a successful execution with no +build tag (conservative flag gates apply); only failure to execute fails +health. Results are cached under the runtime-health lock keyed by executable +path plus file identity, so a replaced package target invalidates stale +probes. `ldd`/`otool` never run against System entries. Launches `exec` the +re-resolved entry point with the inherited environment plus validated +per-launch `LLAMA_`/`GGML_` overrides; no repository binary/library directory +is prepended. Memory estimation validates `llama-fit-params` itself and a +missing/broken estimator never blocks server launches. + +`GET /api/status` reports `system_tools` (per-tool discovered path, +availability, build tag, probe state), `system_server_error` (`None` when +healthy), and server-only readiness — a missing CLI leaves server/Chat +usable. The selected `version: "system"` marker stays separate from +discovered build tags, and a removed/broken server reports stale with `PATH` +guidance without clearing the selection. Release listing returns empty for +System, install/update/activate-existing reject it before any download or +mutation, cleanup preserves an active System selection while clearing removed +official-install metadata, and Open Folder reveals the discovered server's +directory read-only. The frontend (`ui/js/manager/manager-backends.js`, +`ui/js/flag-core.js`) renders per-tool paths, feeds per-tool build tags into +the shared flag-compatibility gates (unknown builds show `(unknown build)`), +and adjusts Quick Launch and Benchmarking readiness per requested tool, with +backend launch validation remaining authoritative. + ## Auto-Update System ### How It Works @@ -1372,6 +1430,7 @@ Prefer `rg` for local search. On Windows/PowerShell, use patterns like `rg -n "p | `CONTRIBUTING.md` | Developer quickstart: setup, run, dev loop, tests, and PR checklist | | `docs/directory.md` | This file — project structure and feature reference | | `docs/tests.md` | Test suite layout, commands, and what each test covers | +| `docs/system-path-backend-plan.md` | Proposed staged implementation for using system-managed llama.cpp tools from PATH (issue #399) | | `docs/gpu-monitoring.md` | User setup guide for NVIDIA SMI, AMD SMI, and the optional cross-vendor all-smi collector | | `docs/maintenance.md` | Release, dependency, compatibility, and repository maintenance guidance | | `docs/security.md` | Security model, trust boundaries, and reporting guidance | diff --git a/docs/system-path-backend-plan.md b/docs/system-path-backend-plan.md new file mode 100644 index 0000000..9ca06cd --- /dev/null +++ b/docs/system-path-backend-plan.md @@ -0,0 +1,337 @@ +# Implementation plan: System (PATH) llama.cpp backend + +Source: [issue #399](https://github.com/thomas9120/LLama-GUI/issues/399) +Prepared: 2026-10-08 +Repository baseline: `6543038` +Status: proposed implementation; no runtime changes have been made. + +## Goal + +Let users activate the llama.cpp tools already available on the GUI process's +`PATH`. Llama GUI will construct launch arguments, start and stop its own child +processes, and provide the existing Chat, API, Benchmarking, and Monitor features. +The user's system package manager will own llama.cpp installation and updates. + +This replaces the issue author's workaround of symlinking system executables into +`llama/custom/bin/`. It is particularly useful for NixOS packages and wrappers +that supply their own runtime dependencies, and also applies to other system +installations. + +## Proposed behavior and scope + +- Add backend ID `system`, displayed as **System (PATH)**, on supported platforms. + Keep it selectable when tools are missing so activation can explain the problem. +- Require an executable `llama-server` for activation. Treat `llama-cli`, + `llama-bench`, `llama-perplexity`, utility tools, and `llama-fit-params` as + independently available features. Existing official and Custom backend + requirements remain unchanged. +- Use the PATH inherited by the Python GUI process. Resolve each tool separately; + tools need not share one directory. Display the paths used for discovery. +- Launch the discovered absolute path with the normal GUI-generated arguments. + Preserve symlink and wrapper entry points rather than replacing them with their + final targets. Use an argument list and the existing subprocess implementation. +- Resolve again before a launch or probe. Package updates can replace a symlink + target while the GUI is open. A running process continues until the normal stop + action; a later launch uses the then-current tool. +- Preserve the inherited environment for System, then merge the already-supported + per-launch `LLAMA_`/`GGML_` overrides. Do not prepend repository binary or library + directories for this backend. +- Disable release selection, GUI binary updates, and Repair for System. A failed + activation leaves the current backend active. Missing System tools never fall + back to official or Custom binaries. +- Keep the existing model root, presets, bundled chat templates, subprocess + working directory, authentication, and process lifecycle. Grammar files remain + user-selected paths; there is no package-data-directory discovery in this scope. + +This feature does not install system packages, modify PATH, source shell startup +files, configure Nix/CUDA, or connect to an already-running server. The existing +external-server feature continues to handle that last case. No new runtime +dependency or general provider framework is needed. + +## Existing foundations and changes needed + +| Area | Current behavior | Required change | +|------|------------------|-----------------| +| `backend/services/llama_manager.py` | Backend specs, folder selection, Custom activation, packaged runtime checks, official-install metadata | System classification, discovery/probes, activation, and correct preservation of the official installation | +| `backend/app.py` and `backend/context.py` | `find_tool_executable()` returns a path inside one selected installation folder | Delegate to shared discovery; represent an unavailable System tool explicitly; register the activation route | +| `backend/services/process_manager.py` | Preflight and launch share validation; environment prepends a selected bin directory; the estimator locates `llama-fit-params` separately | Use System discovery/environment for every subprocess and validate the executable actually being invoked | +| `backend/routes/status.py` | Installation readiness requires CLI and server; tool availability is reported as booleans | Apply System's server requirement and add discovered paths/build information | +| `backend/routes/install.py` | Custom activation has install/process interlocks; other actions assume a downloadable backend | Add System activation and reject inappropriate install/update/release operations | +| `backend/routes/lifecycle.py` | Open Folder creates an application-owned installation directory | Give System a read-only folder-opening policy | +| `ui/js/manager/manager-backends.js`, `ui/js/manager/manager-install.js`, and `ui/js/manager/manager-status.js` | Backend selection, release fetching, activation, and build-tag propagation | System controls, status, switching, and build information | +| `ui/index.html`, `ui/js/quick-launch-ui.js`, and `ui/js/flag-core.js` | Setup markup, launch readiness, and shared argument generation | System guidance, per-tool readiness, and correct build gates | + +Use the existing service/context injection and focused frontend namespaces. +Keep shared flag state authoritative across Configure, Quick Launch, Chat, and +command preview. Touch other consumers only where a demonstrated assumption about +folders, availability, or build tags requires it. + +## Stage 1: Backend classification and executable discovery + +**Outcome:** one resolver finds the selected backend's tools without changing +existing official/Custom behavior. + +1. Add System to `build_backend_specs()` in `backend/services/llama_manager.py`. + Keep `CUSTOM_BACKEND_SPECS` limited to its two real folder slots; startup must + not create a System bin/grammar directory. +2. Add narrow helpers for System detection and for identifying installations + managed outside the GUI. Use them where existing code assumes that every + non-Custom backend is an official installation. Keep Custom folder behavior + separate from System PATH behavior. +3. Introduce shared tool resolution in the owning backend service. For System, + use `shutil.which()` on a known tool filename, make the returned path absolute + without dereferencing its symlinks, and check file/executable availability. + Search the inherited PATH only, including its normal precedence and relative + entries; use platform-aware executable naming. Do not expand the search into + other repository installations or launch a shell to perform discovery. +4. Restrict discovery inputs to known llama.cpp tools. Include `llama-fit-params` + as an internal discovery target without adding it to the public launch-tool + allowlist. Do not permit arbitrary executable names through the API. +5. Make `find_tool_executable()` delegate to that resolver. Use `None` for an + unavailable System tool, update the `BackendServices` callable annotation, + and explicitly guard all consumers before path operations. Preserve the + existing folder-path results for official and Custom installations. +6. Remove System calls into folder-only helpers. System has no single bin + directory: runtime enumeration and setup should not scan or create PATH + directories. A guard in the folder helper can catch accidental misuse. + +**Verification:** add focused discovery cases under `tests/backend/`, covering +PATH precedence, tools in different directories, missing tools despite local +official binaries, spaces in paths, executable permissions, symlinks/wrappers, +Windows `.exe` lookup, and existing official/Custom resolution. Use fixtures for +platform-specific lookup rather than relying on the host's installed llama.cpp. + +**Exit criterion:** a missing System tool is explicit, and choosing System cannot +select a tool from a different backend by fallback. + +## Stage 2: Runtime probes, environment, and process integration + +**Depends on:** Stage 1. +**Outcome:** the existing launch pipeline can run system tools and wrappers. + +1. Add a System branch to `_build_process_env()` that copies the inherited + environment without injecting local binary/library paths. Apply existing + validated per-launch overrides afterward. Keep official/Custom environment + construction unchanged. +2. Validate System executables with a bounded, model-free `--version` subprocess + using the discovered entry point, inherited runtime environment, existing + window-hiding conventions, and a short timeout such as five seconds. Parse + stdout and stderr for recognized build formats; successful execution with an + unknown build format is distinct from a failure to execute. +3. Use that execution check for System runtime health instead of requiring nearby + DLLs, `.so` files, or `@rpath` libraries. Do not run `ldd`/`otool` against a Nix + wrapper and interpret a script-inspection failure as a broken installation. + A successful version probe establishes that the entry point starts; it does + not establish model loading or GPU compatibility. +4. Cache probe results under the existing runtime-health lock. Include the + executable path and file/symlink identity in the System cache key, retain a + bounded TTL, and invalidate on activation or discovery changes. Ordinary + status polls should reuse results rather than spawn probes on every request. + Avoid holding process/config locks during subprocess execution. +5. Integrate missing-tool errors and System probes into preflight and launch. + Resolve and validate the same entry point that is passed to `Popen`; if it + disappears between validation and execution, return the normal logged, + sanitized launch failure. Preserve install/process interlocks, runtime + generations, output capture, health polling, and Windows process groups. +6. Route `_fit_params_executable()` through shared discovery. In memory + estimation, validate `llama-fit-params` itself for System; the current code + locates the estimator separately but validates the requested CLI/server tool. + Missing estimation support must not block server launches. +7. Review `get_buffer_types()`, which currently chooses CLI. For System, prefer + an available supported CLI/server probe target, using server when CLI is + absent. Preserve the existing unavailable/error behavior if the build does + not support the probe rather than implying that GPU capability was verified. + +**Verification:** cover preserved PATH/library variables, wrapper execution, +known/unknown builds, probe failures/timeouts, cache invalidation, missing +estimator, server-only buffer discovery, unchanged argument/environment payloads, +and existing start/stop behavior. Extend +`tests/backend/test_process_environment.py` and relevant service tests. + +**Exit criterion:** server preflight and launch use the system entry point and +environment, with recoverable errors and no packaged-library requirement. + +## Stage 3: Activation, persistence, status, and switching + +**Depends on:** Stages 1-2. +**Outcome:** System can be activated, restored after restart, and switched safely. + +1. Add proposed route `POST /api/activate-system` in `backend/routes/install.py` + and register it in `backend/app.py`. It accepts no client-supplied executable + path. Reuse the Custom activation install-slot claim/release pattern to reject + activation during an install or while a GUI-managed process is running. +2. Discover tools and require the System server execution probe to succeed before + changing configuration. Return discovered paths, found/missing tool lists, + normalized build tags, and actionable failure information. Validate request + shape and log unexpected errors before returning `sanitize_error()` output. +3. Persist the selection under `config_lock`, merging with the latest config so + model-root/external-target settings survive. A simple marker such as + `backend: "system"`, `tag: "system"`, `version: "system"` is sufficient for + selection; discovered paths and build tags come from current discovery/cache, + not persisted Nix-store paths. Configuration is unchanged on failed probing. +4. Preserve `official_install` when leaving an official build. Update both Custom + activation and `get_official_install_status()` so System is never recorded as + the official installation. Switching System -> Custom -> official must retain + the correct official backend/tag. Reuse Activate Existing for the return path. +5. Extend `/api/status` additively. Retain `executables` booleans and existing + fields. Mark the available-backend entry with `system_path: true`, expose + per-tool discovered paths/build tags and probe state, and calculate System + readiness from its server rather than both CLI and server. Keep selected + backend configuration separate from discovered version information. +6. Report a removed/broken System server as unavailable/stale with PATH guidance. + Do not clear the user's selection or silently select another backend. On GUI + restart, rediscover with that process's inherited environment. +7. Explicitly reject System in install, repair-via-install, update, and + activate-existing official requests before downloads or config mutation. + Return an empty release list for System without a release lookup. Verify + official catalog refresh does not turn System into a download target. +8. Keep cleanup limited to the existing application-owned directories. Preserve + the active System selection while clearing removed official-install metadata. + For System Open Folder, open the discovered server's containing directory + without creating it; report unavailable if the server cannot be found. +9. Update the Route Modules table and endpoint count in `docs/directory.md` in + the same implementation change. This plan adds one exact route; the current + baseline's 50 endpoints would become 51 if no other routes change meanwhile. + +**Verification:** successful/failed activation, running/install rejection, +restart restoration, server-only status, direct misuse of install/update routes, +official/System/Custom round trips, config merge preservation, cleanup, and +read-only folder opening. Extend `tests/backend/test_custom_slots.py` and +`tests/backend/test_extracted_routes.py`; put System-specific cases in a focused +`test_system_backend.py` file under the existing backend tests directory. + +**Exit criterion:** a System selection survives restart and cleanup, and users +can return to their previous installation without downloading it again. + +## Stage 4: Frontend controls, readiness, and build compatibility + +**Depends on:** Stage 3. +**Outcome:** the user can discover, activate, and understand System from the GUI. + +1. Render the backend from backend-provided metadata in the existing Manager + package. Selecting it shows **Activate System**, hides release selection, + and explains that the OS package manager owns installation and updates. + Add minimal setup markup in `ui/index.html`; keep the current script order. +2. Display each discovered tool/path and availability using `textContent`. + Show server as required for System and the other tools as feature-specific. + Retain both CLI/server requirements in existing official/Custom views. +3. Distinguish the pending dropdown choice from the active backend. Route System + activation to its endpoint, retain duplicate-action guards, refresh status + afterward, and show errors near the activation controls. Polling must not + reset the user's pending selection. +4. Disable System update/repair and suppress release fetching, including forced + refresh paths. Selecting an official target while System is active should + offer Activate Existing where appropriate. Derive this from accepted status. +5. Adjust Quick Launch and other affected readiness checks to consider the + requested executable. A missing CLI must leave server/Chat usable. Missing + benchmark/perplexity tools or the estimator should produce feature-specific + guidance, with backend launch validation remaining authoritative. +6. Feed detected build tags into the existing shared flag-core compatibility + gates. PATH can resolve CLI and server from different builds; use the tag + for the tool whose arguments are being generated, including tool changes + and benchmark source configurations. Extend the existing tag mechanism + narrowly rather than creating per-tab flag state. Unknown build formats + retain the existing conservative gate behavior and an explicit unknown + version display; do not invent a release tag from a package version. +7. Keep command preview on the existing shared argument builder. The System + details show the actual executable path; a new command builder is unnecessary. + Update folder/remove action labels and explanations to match Stage 3. +8. Explain discovery failures concretely: **llama-server was not found on the + PATH inherited by Llama GUI. Start the GUI from the environment where it is + available, or restart the GUI after changing its launch environment.** + Avoid displaying the entire environment or offering automatic shell edits. + +**Verification:** extend `tests/frontend/manager_releases_unit.cjs`, relevant +flag-core/launch/benchmark tests, and `tests/frontend/flag_sync_smoke.cjs`. +Check activation, pending selection, disabled actions, server-only readiness, +tool-specific build gates, safe path rendering, and official return activation. +Run `node --check` on every changed JS file and `npm run test:frontend` for DOM +wiring/shared-state changes. + +**Exit criterion:** a server-only System installation is usable through the +normal GUI, and unavailable optional features have clear, localized errors. + +## Stage 5: Regression coverage and release checks + +**Depends on:** Stages 1-4; add focused tests alongside their owning stages. +**Outcome:** the whole feature and existing backends pass the release checks. + +- Exercise three representative System layouts: server only, a complete set of + tools, and wrapper/symlink entry points. Include a broken server, different + CLI/server builds, an unknown version format, and a changed package target. +- Keep host llama.cpp installations out of deterministic unit tests. Use + temporary files, mocked subprocess responses, and environment fixtures. +- Update `tests/frontend/llama_flags_supported_unit.cjs` so selecting System + checks PATH tools rather than local downloaded binaries first. Cover selection + in `tests/frontend/llama_flags_runner_unit.cjs`. Preserve explicit binary + directory precedence and the required pinned-build CI checks. The full flag + comparison still needs both CLI and server; server-only runtime support must + not weaken that test's existing contract. +- Recheck official and both Custom activation, launch environment, missing-tool + errors, cleanup, and switching. Reuse the existing suites rather than adding + another process manager or browser harness. +- Run the full backend suite in the project venv, the frontend suite, and docs + link/route-sync checks once the staged work is integrated. + +```powershell +.venv/Scripts/python.exe -m unittest discover tests -v +npm test +.venv/Scripts/python.exe -m unittest tests.backend.test_docs_links tests.backend.test_docs_sync -v +``` + +On Unix, use `.venv/bin/python` for the Python commands. Follow [the test +reference](tests.md) for focused commands during each stage. Keep CI on its +existing Linux and Windows runners; cover macOS path/probe behavior with fixtures. + +**Exit criterion:** automated checks pass without requiring a system llama.cpp +installation, GPU, or native macOS runner, and CI's pinned binary check remains +strict. + +## Stage 6: User documentation and native validation + +**Depends on:** Stage 5. +**Outcome:** the feature is documented and its real environment behavior is known. + +1. Update `docs/directory.md` with System selection/discovery, runtime validation, + status metadata, and the final route contract. Update `docs/tests.md` for new + coverage, and `docs/troubleshooting.md` for inherited PATH, restart, unknown + build tags, and package-manager-owned updates. +2. Document terminal-versus-desktop/Pinokio environment differences. Keep startup + entry points and runtime dependencies unchanged. If implementation requires + launcher/startup changes, inspect the companion launcher documented in the + [project reference](directory.md#companion-repositories) before making them. +3. Validate on NixOS with an actual system package/wrapper: activate, load a model, + send a Chat message, inspect Monitor, stop, restart, and switch back. Test a + package/profile update to confirm later launches rediscover the entry point. +4. Smoke-test a conventional Linux installation, Windows PATH executables, and + macOS package-manager/desktop startup when those environments are available. + Check optional tools only where installed; a missing estimator is an expected + unavailable feature. Record which native checks were actually run. + +**Exit criterion:** docs describe the implemented behavior, and native NixOS +validation establishes that wrappers and real model/GPU loading work. If that +environment is unavailable, explicitly record the remaining native check rather +than claiming fixtures establish NixOS/CUDA compatibility. + +## Effort estimate and completion criteria + +This is a small-to-medium implementation. Discovery is straightforward; runtime +environment assumptions, optional tools, build gates, and switching account for +most of the integration work. + +- Stages 1-2: approximately one developer day. +- Stage 3: approximately half to one day. +- Stage 4: approximately one day. +- Stages 5-6: approximately half to two days, depending on native environments. + +Allow roughly **3-5 developer days** for a reviewed implementation by someone +familiar with this repo. Native NixOS/macOS availability can extend elapsed time. +Expect around **10-14 production files** plus focused tests and documentation; +keep the final diff driven by the concrete consumers listed above. A minimal +server-launch prototype could take 1-2 days but would not complete this plan. + +The feature is complete when System activates without copied/symlinked repository +binaries, normal server/Chat flows work with its inherited environment, missing +optional tools affect only their features, official/Custom switching remains +correct, system files are never modified by maintenance actions, automated checks +pass, and native validation results or remaining limitations are documented. diff --git a/docs/tests.md b/docs/tests.md index aef205d..a8281df 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -181,6 +181,7 @@ Fast Node tests: - `manager_lifecycle_unit.cjs`: canonical Manager-package evaluation/configuration without DOM access, requests, or timers; dependency replacement after initialization; quit cancellation and sidebar wiring; restart/readiness/cache-busted reload; failure recovery for quit/restart controls; and successful app-update handoff to GUI lifecycle while retaining the app-update status renderer. Uses only injected API fixtures and recorded timers, with production test hooks disabled. - `shared_services_unit.cjs`: inert service loading; API cache defaults/overrides, method/body/header/signal forwarding, literal JSON null, invalid JSON and HTTP/network failures; confirmation/prompt keyboard, backdrop and cancel behavior, safe text assignment, focus placement, empty-versus-cancel results, and listener cleanup across repeated use. - `manager_releases_unit.cjs`: backend selection, backend-aware release fetching, `fetchJson` cache bypass, and installed-backend summary rendering. Custom slot cases cover backend-provided labels/paths, explicit activation payloads, duplicate/polling guards, failed activation retaining the current build, and update/repair restrictions. The suite uses the public Manager facade and gated rendering/action hooks, with injected confirmations rather than private-state mutation. It also checks inert configuration, repeated initialization, one action per click, and polling cleanup on install error/unload. The browser suite switches Custom → Custom 02 → official → Custom, including a missing-tool failure and unchanged shared flags. +- `system_path_frontend_unit.cjs`: System (PATH) Manager controls, shared build gates, and readiness. Covers selecting System (Activate System label, hidden release selection, setup text, no release fetch), activation routing with duplicate guards and PATH-guidance failures, disabled update/repair, official-target Activate Existing while System is active, pending-selection survival across polls, server-required versus feature-tool rendering with text-only paths, unknown-build display, stale-server guidance, per-tool flag-core tags with conservative unknown handling, server-only Quick Launch readiness with CLI-specific guidance, and missing-tool benchmark errors with per-tool load-mode translation. - `theme_ui_unit.cjs`: theme preference storage, `data-theme` root attribute application, unknown-theme normalization, and registry-driven color-scheme hints (asserted for every entry in `THEMES`, so a new theme with the wrong `scheme` fails here). Also covers the sidebar theme menu against a DOM stub: rendering one row per registry entry, `aria-checked`/roving `tabindex`, arrow-key wrapping, Home/End, Escape returning focus to the trigger, and outside-click dismissal. Asserts every `THEMES` entry has a matching palette block in `tokens.css`, so a theme cannot be offered in the menu while rendering as the fallback. Also enforces contrast floors for every theme, which is what makes adding a theme safe rather than merely cheap: - AA (4.5:1) for `--fg`, `--fg-muted`, the six semantic text colors (`--accent-text`, `--green`, `--red`, `--yellow`, `--favorite`, `--cyan`) and `--red-fg`. @@ -191,8 +192,8 @@ Fast Node tests: - `module_namespace_unit.cjs`: frontend script load order and exported namespaces, plus the Tier 2 Session 0 guardrail contracts described under [Common Commands](#common-commands). - `script_order.cjs`: shared test helper, not a suite. Parses active `