AI-LSC: AI - Local Stack Control
This commit is contained in:
parent
34d429345d
commit
d244d69ca6
201
CHANGES.md
201
CHANGES.md
|
|
@ -1,5 +1,204 @@
|
|||
# AI-LSC Changelog
|
||||
|
||||
## v3.4 — File-system map alignment + QCOW2 exports + real updates
|
||||
|
||||
### 1. The base dir now follows the canonical file-system map
|
||||
|
||||
`constants.py` / `utils/paths.py` are the single source of truth for
|
||||
the /mnt/AI layout, and the runtime code now actually honours it:
|
||||
|
||||
- **Install-root router** (`utils/paths.py::install_dir_for`): a
|
||||
tool's declared `filesystem.install` is honoured verbatim; when
|
||||
absent, web UIs (`flags.has_web`, launcher not pinned to
|
||||
`{tools_root}`) route to `dashboards/<tool_id>/`, everything else
|
||||
stays under `tools/<tool_id>/`. `runtime/installer.py` resolves
|
||||
every per-tool destination through the router.
|
||||
- **Multi-root detection**: `candidate_install_dirs()` probed by
|
||||
installer preflight, verification, version detection, and the
|
||||
main-window drift audit — so tools installed before the routing
|
||||
change are still detected, verified, and **updated in place**
|
||||
instead of being orphaned or duplicated.
|
||||
- **Registry state moved** from `<base>/registry/` (off-map) to
|
||||
`<base>/configs/registry/` (app state belongs under configs/ per
|
||||
the map). `main_window._migrate_registry_dir()` migrates existing
|
||||
installs once on startup; legacy dir removed when emptied.
|
||||
- **`exports/qcow2/`** added to REQUIRED_DIRS (25 dirs now) alongside
|
||||
`exports/oci-images/`.
|
||||
- Ollama server probe order is now map-correct:
|
||||
`runtime/ollama` → `tools/ollama` → legacy `ollama` / `bin/ollama`.
|
||||
- Every launcher-template placeholder from the path tree resolves
|
||||
everywhere (`{dashboards_root}`, `{runtime_root}`, `{configs_root}`,
|
||||
`{datasets_root}`, …) — in `resolve_launcher_cmd`, the runtime
|
||||
executor's `format_context`, env overrides, post_install commands,
|
||||
and the stack-export placeholder chain.
|
||||
|
||||
### 2. QCOW2 / QCOW VM-disk exports (the missing export backend)
|
||||
|
||||
`stack/export.py::ContainerBackend.write_vm_image()` — a fully rootless
|
||||
pipeline (no loop mounts, no root):
|
||||
|
||||
1. Stage a rootfs tree (`/opt/ai-lsc/stack.json` + compose file,
|
||||
`/root/start.sh` boot script, `/etc/ai-lsc-release`) under
|
||||
`exports/qcow2/.staging-*`.
|
||||
2. Populate an ext4 filesystem straight from the staging dir with
|
||||
`mkfs.ext4 -d` into a sparse raw image (auto-sized: staging usage
|
||||
× 4 + headroom, min 2 GiB).
|
||||
3. `qemu-img convert -c -O qcow2` (also `raw`; legacy `qcow` on
|
||||
older qemu hosts — modern qemu ≥ ~10 refuses v1 writes and the
|
||||
exporter surfaces a clean, actionable error suggesting qcow2).
|
||||
4. Sidecar `<image>.manifest.json` with sha256, virtual size,
|
||||
`qemu-img info`, tool list, and a boot hint (rootfs disk — pair
|
||||
with an external kernel such as the Firecracker export's vmlinux,
|
||||
or attach to any VM).
|
||||
|
||||
Tool detection is built in: missing `qemu-img` / `mkfs.ext4` raise
|
||||
`FileNotFoundError` with the pacman/apt install hint. UI: two new
|
||||
buttons on the Container Stacks tab ("Export -> QCOW2 Image",
|
||||
"Export -> QCOW (legacy)"); the file list shows images with sizes.
|
||||
|
||||
Formats are registry-driven (`VM_IMAGE_FORMATS`, one output dir per
|
||||
format under `exports/`).
|
||||
|
||||
### 2b. QCrows (.qcrows) VM container image export — the real format
|
||||
|
||||
QCrows (cue-crows) is the in-house **self-describing VM container
|
||||
image format for Kata Containers**, master-implemented in the
|
||||
**cockpit-kata** project (`qcrows-spec.md` v0.2.0 + the `qcrows-pack`
|
||||
/ `qcrows-verify` / `qcrows-inspect` / `qcrows-export` /
|
||||
`qcrows-initrd-regen` tools). (An earlier iteration of this changelog
|
||||
misidentified qcrows as sorcery-go's Sovereign Bundle — that format is
|
||||
now supported under its own `svb` key, below.)
|
||||
|
||||
`ContainerBackend.write_qcrows()` produces a spec-conformant archive:
|
||||
|
||||
```
|
||||
<tar> (PAX per spec §2.1; .qcrows or .qcrows.gz)
|
||||
├── metadata.toml generated manifest (qcrows-pack's template
|
||||
│ incl. section order qcrows-inspect parses)
|
||||
├── menu.toml Cockpit UI menu entry
|
||||
├── hashes.sha256 sha256sum -c byte-compatible, ./-prefixed
|
||||
├── rootfs.tar.gz the staged ai-lsc stack rootfs
|
||||
├── boot-params.conf console=ttyS0 root=/dev/vda rw
|
||||
├── build.toml ai-lsc provenance (generator, kernel origin)
|
||||
├── spec.md per-image build guide
|
||||
└── kernel/ REQUIRED (v0.2+): vmlinuz|vmlinux + config
|
||||
```
|
||||
|
||||
- **Kernel sourcing** (kernel is REQUIRED by v0.2 — same refusal
|
||||
behaviour as qcrows-pack): `<base>/runtime/qcrows/` (canonical guest
|
||||
kernel home: `vmlinuz|vmlinux` + `config`) → the firecracker dir's
|
||||
`vmlinux` → host kernel fallback (`/boot/vmlinuz-<release>`, any
|
||||
`/boot/vmlinuz-*` for Arch-style names; config from
|
||||
`/boot/config-*`, `/usr/lib/modules/<ver>/config`, or
|
||||
`/proc/config.gz`). Host fallback logs a warning — a Kata-tuned
|
||||
guest kernel is recommended for real deployments.
|
||||
- **Pre-flight Kata checks**: kernel-version probe mirrors
|
||||
qcrows-pack (bzImage strings → `CONFIG_VERSION_SIGNATURE` →
|
||||
"unknown"); the five required Kata config options
|
||||
(`CONFIG_VSOCKETS`, `CONFIG_VIRTIO`, `CONFIG_VIRTIO_PCI`,
|
||||
`CONFIG_DEVTMPFS`, `CONFIG_DEVTMPFS_MOUNT`) are checked and
|
||||
shortfalls logged + recorded in the sidecar (qcrows-verify warns,
|
||||
never fails, on these).
|
||||
- **Master round-trip verified**: images built by ai-lsc pass
|
||||
cockpit-kata's own `qcrows-verify` (11 PASS / 0 FAIL / exit 0, both
|
||||
compressed and uncompressed) and render fully in `qcrows-inspect`.
|
||||
Tar members carry the `./` prefix exactly like qcrows-pack's
|
||||
`tar czf - ./*` output; hashes match `sha256sum -c` byte-for-byte.
|
||||
- Output: `exports/qcrows/ai-lsc-stack-<stamp>.qcrows` (+ optional
|
||||
`.gz`) with an ai-lsc sidecar manifest (sha256, kernel origin,
|
||||
config warnings, verify hint). UI button
|
||||
"Export -> QCrows Image (.qcrows)".
|
||||
|
||||
**Tool quirks found in cockpit-kata** (their code, reported not
|
||||
modified — the ai-lsc writer avoids all of them):
|
||||
|
||||
1. `qcrows-pack` renames `initrd.cpio.gz` to `initrd.gz` — its
|
||||
extension grep (`\.[^.]*$`) only captures the last suffix, and the
|
||||
packer then derives both the on-disk name and `metadata.toml`'s
|
||||
`initrd.path` from it; `qcrows-verify`'s initrd probe
|
||||
(`initrd.img|initrd.cpio.gz|…`) then can't find it.
|
||||
2. `qcrows-pack` writes GNU-format tar although spec §2.1 mandates
|
||||
PAX (ai-lsc writes PAX).
|
||||
3. `metadata.toml`'s generated `rootfs.path` assumes the source
|
||||
tarball is named `rootfs.*` — other names produce a path that
|
||||
doesn't match the staged file.
|
||||
4. `qcrows-inspect`'s first-match key parsing reports kernel's
|
||||
`included`/`size_mb` for the initrd/rootfs rows (visible in its
|
||||
own output on its own packs).
|
||||
|
||||
### 2c. Sovereign Bundle (.svb) export (sorcery-go format)
|
||||
|
||||
Kept as its own format key (`svb`) after the qcrows identification
|
||||
was corrected: `ContainerBackend.write_svb()` implements
|
||||
`sorcery-sovereign-bundle-v1` (METADATA.json first entry, optional
|
||||
64-byte ed25519 SIGNATURE.sig over the payload sha256, per-file
|
||||
MANIFEST.txt, then payload) with deterministic `payload_sha`
|
||||
(identical content → identical hash) and optional signing via
|
||||
`AI_LSC_SVB_SIGNING_KEY` + the `cryptography` package. Output:
|
||||
`exports/svb/*.svb`. Cross-verified with a Go verifier mirroring
|
||||
sorcery-go's parse path — including the discovery that sorcery-go's
|
||||
own `VerifySVBSignature` diverges from its builder (it hashes
|
||||
concatenated entry contents instead of the inner tar.gz), so their
|
||||
verifier can't validate their own bundles; the ai-lsc writer follows
|
||||
the builder.
|
||||
|
||||
### 3. Updates actually update now
|
||||
|
||||
The registry's `installer.update_cmd` existed in the schema since
|
||||
v3.0 but nothing executed it. New
|
||||
`InstallerManager.update_tool()`:
|
||||
|
||||
- Registry `update_cmd` runs first, rendered with all path-tree
|
||||
placeholders plus `{install_dir}`, cwd = the *detected* install
|
||||
location (so pre-routing installs update where they live).
|
||||
- Per-type defaults otherwise: `uv tool upgrade` (reinstall
|
||||
step-down preserves `--with` extras), `pipx upgrade`, per-tool
|
||||
venv `pip install -U`, `git pull --ff-only` (+ yarn rebuild for
|
||||
git_node), `npm update`.
|
||||
- `install_with_preflight()` now flows found-tools into
|
||||
`update_tool()` by default — the UI's single Deploy/Update button
|
||||
is a real in-place updater instead of returning "already
|
||||
installed; use force=True".
|
||||
- New `RuntimeExecutor.update_tool()` facade; service rows pass the
|
||||
full registry entry through (`registry_entry=`) so routing,
|
||||
update_cmd, and post_install all flow from one source.
|
||||
|
||||
### 4. Open WebUI install/start fixes
|
||||
|
||||
- **Step-down data loss fixed**: `install_uv` used to fall back to
|
||||
`pipx install open-webui` — silently dropping `--with
|
||||
audioop-lts`, producing an install that later crashed importing
|
||||
`audioop` on Python 3.13. The chain is now: uv (default Python)
|
||||
→ uv with a managed CPython 3.12 (`uv` fetches it automatically —
|
||||
sidesteps wheels not yet rebuilt for newer Pythons) → per-tool pip
|
||||
venv installing the package **plus every `--with` extra**.
|
||||
- Launcher pre-creates `workspaces/openwebui` (DATA_DIR) alongside
|
||||
the existing `configs/auth` bootstrap so first start can't fail on
|
||||
a missing data dir.
|
||||
|
||||
### 5. LibreChat install/start fixes
|
||||
|
||||
- **Fresh-clone start bug**: `yarn backend` aborts without `.env`,
|
||||
and production mode needs `yarn build` assets that plain
|
||||
`yarn install` never produces. The installer entry now carries
|
||||
`post_install: "cp -n .env.example .env 2>/dev/null || true;
|
||||
yarn build"` — runs after clone/update in the project dir.
|
||||
- **Map-aligned paths**: installs to `dashboards/librechat/`
|
||||
(was `tools/librechat`), data → `workspaces/librechat` (the
|
||||
off-map root-level `data/` dir is gone), config stays
|
||||
`configs/librechat`, launcher uses `{dashboards_root}`. Existing
|
||||
`tools/librechat` clones are detected and updated in place.
|
||||
- `agents/librechat_config.py` docstring path corrected to the
|
||||
dashboards location.
|
||||
|
||||
### 6. Housekeeping
|
||||
|
||||
- Version 3.3.0 → 3.4.0 (`pyproject.toml`, `constants.APP_VERSION`).
|
||||
- Container Stacks tab lists qcow2 images (+ manifests, sizes) and
|
||||
excludes the qcow2 dir from the generic directory listing.
|
||||
- Verification tab passes the full registry entry so multi-root
|
||||
detection applies to compliance checks too.
|
||||
|
||||
## v3.3 — Registry git-URL sync + bandwidth-aware git-pull + ComfyUI
|
||||
|
||||
Patch release over v3.2. No schema changes, no UI reorganisation, no
|
||||
|
|
@ -123,7 +322,7 @@ bandwidth-saving behaviour the user asked for.
|
|||
|
||||
- `pyproject.toml`: 3.2.0 → 3.3.0
|
||||
- `src/ai_lsc/constants.py`: `APP_VERSION` 3.2.0 → 3.3.0
|
||||
(`APP_CODENAME` unchanged: `"Decalogue"`)
|
||||
(`APP_CODENAME` unchanged: `"Ankh of Jah"`)
|
||||
- New file: `docs/REGISTRY-URLS-v3.3.0.md` (per-file old → new URL
|
||||
table, also serves as a release audit trail).
|
||||
- `gitcommit` rewritten for v3.3.0.
|
||||
|
|
|
|||
|
|
@ -187,3 +187,66 @@ WEBUI_DATA_DIR={workspaces_root}/hermes-webui OLLAMA_BASE_URL=http://localhost:1
|
|||
|
||||
The ai-lsc runtime resolves env-prefix shells via `shlex.split()` on the
|
||||
launcher cmd, so the env-assignment form is parsed correctly.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open WebUI Python-3.13 audioop fix (follow-up #2)
|
||||
|
||||
After the launcher fix landed, open-webui started successfully but crashed
|
||||
at import time inside the uv-managed tool env:
|
||||
|
||||
```
|
||||
ModuleNotFoundError: No module named 'audioop'
|
||||
…
|
||||
ModuleNotFoundError: No module named 'pyaudioop'
|
||||
```
|
||||
|
||||
Root cause: Python 3.13 removed the `audioop` stdlib module (PEP 594).
|
||||
`pydub` (an open-webui dep) imports `audioop` and falls back to
|
||||
`pyaudioop` on ImportError; neither was present in the uv-managed
|
||||
open-webui env at `~/.local/share/uv/tools/open-webui/`.
|
||||
|
||||
### Fix
|
||||
|
||||
`uv tool install` supports `--with <extra>` for additional dependencies.
|
||||
The `pkg` field in the registry now carries that flag, and `install_uv`
|
||||
was hardened to shlex-split `pkg` so the flag passes through verbatim:
|
||||
|
||||
- `src/ai_lsc/runtime/installer.py` `install_uv` — shlex-splits `pkg`;
|
||||
validates only the first token as the package name; passes remaining
|
||||
tokens as additional argv to `uv tool install`. Falls back to pipx
|
||||
using just the first token.
|
||||
- `src/ai_lsc/runtime/installer.py` `_detect_installation` /
|
||||
`_binary_name` / `_detect_version` — all now take only the first
|
||||
shlex token of `pkg` when looking up the binary on PATH or building
|
||||
version-detection commands. (Without this, preflight would look for
|
||||
a binary literally named `open-webui --with audioop-lts`.)
|
||||
- `src/ai_lsc/registry/layers/user_interfaces.py` — both `openwebui`
|
||||
and `hermes_webui` installer.pkg changed from `"open-webui"` to
|
||||
`"open-webui --with audioop-lts"`. Inline comment explains the
|
||||
Python-3.13 / PEP-594 rationale.
|
||||
- `src/ai_lsc/registry/defaults.py` — openwebui entry updated to match.
|
||||
|
||||
### Verification
|
||||
|
||||
- 95 Python files AST-parse cleanly.
|
||||
- Registry loads 187 tools; `validate_registry()` reports 0 errors.
|
||||
- `shlex.split("open-webui --with audioop-lts")` yields the expected
|
||||
argv: `["open-webui", "--with", "audioop-lts"]`.
|
||||
- `_binary_name("open-webui --with audioop-lts", "uv")` resolves to
|
||||
`"open_webui"` (the actual on-PATH binary name).
|
||||
|
||||
### Operator workaround for existing installs
|
||||
|
||||
If open-webui was installed before this fix landed, the audioop-lts
|
||||
backport can be added in place:
|
||||
|
||||
```bash
|
||||
~/.local/share/uv/tools/open-webui/bin/python -m pip install audioop-lts
|
||||
```
|
||||
|
||||
Or reinstall via the new pkg spec:
|
||||
|
||||
```bash
|
||||
uv tool install --force "open-webui --with audioop-lts"
|
||||
```
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@
|
|||
<h1 align="center">AI - Local Stack Control</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>v3.1.1 — Codename: Ankh of Jah (local-coder-mesh build)</strong><br>
|
||||
<strong>v3.4.0 — Codename: Ankh of Jah</strong><br>
|
||||
Author: <a href="https://dcos.net">Jeremy Anderson</a> <<a href="mailto:info@dcos.net">info@dcos.net</a>><br>
|
||||
Web: <a href="https://dcos.net">https://dcos.net</a>
|
||||
</p>
|
||||
|
||||
<p align="center"><em>This build includes the local-coder-mesh integration — see <a href="CHANGES.md">CHANGES.md</a> for the full list of changes vs upstream v3.1.</em></p>
|
||||
<p align="center"><em>Includes the local-coder-mesh integration, 10-layer taxonomy migration, MoE QA pass, and openhuman/openwebui hardening — see <a href="CHANGES.md">CHANGES.md</a> for the full version history.</em></p>
|
||||
|
||||
<p align="center">
|
||||
A PySide6 desktop application for orchestrating local AI/ML tool stacks across a 10-layer architecture.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
#!/usr/bin/env python3
|
||||
"""AI Local Stack Control v3.1 — Ankh of Jah
|
||||
"""AI Local Stack Control v3.3.0 — Ankh of Jah
|
||||
|
||||
Direct launcher. Run from the project root:
|
||||
|
||||
|
|
|
|||
12
bootstrap.sh
12
bootstrap.sh
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# AI Local Stack Control v3.1.1 — Ankh of Jah
|
||||
# AI Local Stack Control v3.3.0 — Ankh of Jah
|
||||
# Bootstrap Script
|
||||
#
|
||||
# Fully portable: works wherever the tarball lands.
|
||||
|
|
@ -46,7 +46,7 @@ export AI_LSC_BASE_DIR="$AI_BASE"
|
|||
|
||||
echo ""
|
||||
echo -e "${BOLD}╔══════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BOLD}║ AI Local Stack Control v3.1.1 — Ankh of Jah ║${NC}"
|
||||
echo -e "${BOLD}║ AI Local Stack Control v3.3.0 — Ankh of Jah ║${NC}"
|
||||
echo -e "${BOLD}╚══════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "${CYAN} Project root : ${SCRIPT_DIR}${NC}"
|
||||
|
|
@ -316,11 +316,15 @@ else
|
|||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
for cmd in ollama podman docker tmux ripgrep fd tree-sitter; do
|
||||
for cmd in ollama podman docker tmux ripgrep fd tree-sitter qemu-img; do
|
||||
if command -v "$cmd" &>/dev/null; then
|
||||
info "${cmd} — found"
|
||||
else
|
||||
warn "${cmd} — not found (optional)"
|
||||
if [ "$cmd" = "qemu-img" ]; then
|
||||
warn "qemu-img — not found (needed for QCOW2 VM-image exports: pacman -S qemu-img / apt install qemu-utils)"
|
||||
else
|
||||
warn "${cmd} — not found (optional)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ Intentionally NOT touched:
|
|||
- 3 registry-only git tools with real-looking URLs already in
|
||||
place (goose -> block/goose, nvidia_agent_skills ->
|
||||
NVIDIA/agent-skills, picode -> jasonjmcghee/picode.git).
|
||||
- APP_CODENAME ("Decalogue") and all historical codename
|
||||
- APP_CODENAME ("Ankh of Jah") and all historical codename
|
||||
references in docstrings / UI labels / README banner / bootstrap
|
||||
banner — left as-is per user's clarification.
|
||||
|
||||
|
|
@ -102,7 +102,7 @@ removed from the registry.
|
|||
Versioning:
|
||||
- pyproject.toml: 3.2.0 -> 3.3.0
|
||||
- src/ai_lsc/constants.py: APP_VERSION 3.2.0 -> 3.3.0
|
||||
(APP_CODENAME unchanged: "Decalogue")
|
||||
(APP_CODENAME unchanged: "Ankh of Jah")
|
||||
- docs/REGISTRY-URLS-v3.3.0.md: per-file old -> new URL table.
|
||||
|
||||
Verification:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "ai-lsc"
|
||||
version = "3.3.0"
|
||||
version = "3.4.0"
|
||||
description = "AI Local Stack Control — PySide6 desktop app for orchestrating local AI/ML tool stacks"
|
||||
readme = "README.md"
|
||||
license = {text = "AGPL-3.0-or-later"}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ print(f'Registry loaded: {len(DEFAULT_REGISTRY)} tools')
|
|||
print(f'Validation errors: {len(errors)}')
|
||||
"
|
||||
|
||||
# Expected output (v3.1):
|
||||
# Expected output (v3.4.0):
|
||||
# Registry loaded: 125 tools
|
||||
# Validation errors: 0
|
||||
```
|
||||
|
|
@ -253,6 +253,6 @@ for f in os.listdir('ai_lsc/registry/layers'):
|
|||
- Try different **Stack Templates** to find the right combination for your workflow
|
||||
- Set up the **Skills Console** to extend your tool capabilities
|
||||
- Use **Code Analysis** to inspect and understand your project dependencies
|
||||
- Read [CHANGES.md](CHANGES.md) for the full v3.1 changelog
|
||||
- Read [CHANGES.md](CHANGES.md) for the full version history (v3.0 → v3.4.0)
|
||||
- Read [whatremains.txt](whatremains.txt) for known deferred items (curl|sh installers, etc.)
|
||||
- Read [docs/ADR-002-pipeline-ticker.md](docs/ADR-002-pipeline-ticker.md) and [docs/ADR-003-workspace-tab.md](docs/ADR-003-workspace-tab.md) for the design rationale behind the two new widgets
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
AI Local Stack Control v3.1 — Release codename: Ankh of Jah.
|
||||
AI Local Stack Control v3.3.0 — Release codename: Ankh of Jah.
|
||||
|
||||
Extracted from the monolithic ``ai_lsc_v11.py`` in incremental phases.
|
||||
Currently contains:
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ Usage
|
|||
config.set_ollama_endpoint(ollama_port=11434)
|
||||
config.set_litellm_endpoint(litellm_port=4000)
|
||||
config.set_tool_schemas(tool_schemas)
|
||||
config.save("/mnt/AI/runtime/librechat/config.yaml")
|
||||
config.save("/mnt/AI/dashboards/librechat")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"""
|
||||
AI-LSC v3.1 — Application-wide constants.
|
||||
AI-LSC v3.3.0 — Application-wide constants.
|
||||
Release codename: Ankh of Jah
|
||||
Pure data: file names, schema version, required directories, default ports,
|
||||
status styles, log colours, service licences, tree-skip patterns,
|
||||
and the reorganized 10-layer navigation layer order.
|
||||
and the 10-layer navigation layer order.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -15,8 +15,8 @@ BASE_DIR: str = os.environ.get("AI_LSC_BASE_DIR", "/mnt/AI")
|
|||
CANONICAL_BASE_DIR: str = BASE_DIR
|
||||
|
||||
# ── Filenames ────────────────────────────────────────────────────────────
|
||||
APP_VERSION: str = "3.3.0"
|
||||
APP_CODENAME: str = "Decalogue"
|
||||
APP_VERSION: str = "3.4.0"
|
||||
APP_CODENAME: str = "Ankh of Jah"
|
||||
APP_DISPLAY_NAME: str = f"AI - Local Stack Control v{APP_VERSION} - http://dcos.net"
|
||||
CONFIG_FILE: str = "controller_config.json"
|
||||
APP_ICON_FILE: str = "ai-lsc-logo.png"
|
||||
|
|
@ -51,6 +51,8 @@ REQUIRED_DIRS: list[str] = [
|
|||
"dashboards", # web UIs and landing pages (Dashy, Open-WebUI, …)
|
||||
"tools", # standalone compiles (built from distfiles)
|
||||
"exports/oci-images", # finalized Podman .tar snapshots (-> MinIO registry)
|
||||
"exports/qcow2", # VM disk-image exports (qemu qcow2 / legacy qcow)
|
||||
"exports/qcrows", # QCrows VM container images (.qcrows, spec v0.2)
|
||||
"scripts", # system admin / maintenance automation for the stack
|
||||
"logs", # system, runtime, and pipeline service logs
|
||||
]
|
||||
|
|
@ -158,11 +160,14 @@ NAV_LAYER_ORDER: list[str] = [
|
|||
]
|
||||
|
||||
# ── Ollama server candidate paths (probed in order) ────────────────
|
||||
# Map-aligned order: runtime/ (native binaries) is probed first;
|
||||
# the legacy root-level and bin/ locations remain as fallbacks for
|
||||
# pre-v3.4 installs.
|
||||
OLLAMA_SERVER_CANDIDATES: list[str] = [
|
||||
"ollama", # /mnt/AI/ollama
|
||||
"runtime/ollama", # /mnt/AI/runtime/ollama (canonical)
|
||||
"tools/ollama", # /mnt/AI/tools/ollama
|
||||
"runtime/ollama", # /mnt/AI/runtime/ollama
|
||||
"bin/ollama" # /mnt/AI/bin/ollama
|
||||
"ollama", # /mnt/AI/ollama (legacy)
|
||||
"bin/ollama" # /mnt/AI/bin/ollama (legacy)
|
||||
]
|
||||
|
||||
# ── Model tier routing (reserved for v4.0 agentic layer) ──────────
|
||||
|
|
|
|||
|
|
@ -796,7 +796,10 @@ DEFAULT_REGISTRY: dict = {
|
|||
},
|
||||
"launcher": {
|
||||
"type": 'tmux',
|
||||
"cmd": 'OLLAMA_HOST=0.0.0.0:{port} OLLAMA_MODELS={models_root}/ollama ollama serve',
|
||||
# Force ollama to use /mnt/AI/models/ollama (not ~/.ollama/models)
|
||||
# and put its identity-key + runtime state under /mnt/AI/.ollama.
|
||||
# See inference.py ollama entry for full rationale.
|
||||
"cmd": 'mkdir -p {base_dir}/.ollama && HOME={base_dir} OLLAMA_HOST=0.0.0.0:{port} OLLAMA_MODELS={models_root}/ollama ollama serve',
|
||||
"default_port": 11434,
|
||||
},
|
||||
"deps": [
|
||||
|
|
@ -2905,19 +2908,25 @@ DEFAULT_REGISTRY: dict = {
|
|||
"type": 'git_node',
|
||||
"pkg": 'https://github.com/danny-avila/LibreChat.git',
|
||||
"update_cmd": 'git pull --ff-only && yarn install && yarn build',
|
||||
# Two known fresh-clone start bugs, fixed at install time:
|
||||
# 1. `yarn backend` aborts without an .env file — seed one
|
||||
# from the shipped example (never clobber an existing one).
|
||||
# 2. production mode requires the client assets from
|
||||
# `yarn build` — plain `yarn install` doesn't produce them.
|
||||
"post_install": 'cp -n .env.example .env 2>/dev/null || true; yarn build',
|
||||
},
|
||||
"launcher": {
|
||||
"type": 'tmux',
|
||||
"cmd": 'cd {tools_root}/librechat && API_PLUGINS=false PORT={port} NODE_ENV=production yarn backend',
|
||||
"cmd": 'cd {dashboards_root}/librechat && API_PLUGINS=false PORT={port} NODE_ENV=production yarn backend',
|
||||
"default_port": 3080,
|
||||
},
|
||||
"deps": [
|
||||
'ollama',
|
||||
],
|
||||
"filesystem": {
|
||||
"install": 'tools/librechat',
|
||||
"install": 'dashboards/librechat',
|
||||
"config": 'configs/librechat',
|
||||
"data": 'data/librechat',
|
||||
"data": 'workspaces/librechat',
|
||||
"logs": 'logs/librechat',
|
||||
},
|
||||
"description": 'Default agent interface with native tool execution, system files parsing, and multi-user configurations.',
|
||||
|
|
@ -3125,21 +3134,30 @@ DEFAULT_REGISTRY: dict = {
|
|||
"category": 'Extensible Interface',
|
||||
"installer": {
|
||||
"type": 'uv',
|
||||
"pkg": 'open-webui',
|
||||
# open-webui's pydub dep imports `audioop`, which Python 3.13
|
||||
# removed from the stdlib (PEP 594). The `audioop-lts` backport
|
||||
# restores it; --with pulls it into the tool's isolated env.
|
||||
"pkg": 'open-webui --with audioop-lts',
|
||||
"env_overrides": {
|
||||
'OPEN_WEBUI_CONFIG_DIR': '{base_dir}/configs/openwebui',
|
||||
},
|
||||
},
|
||||
"launcher": {
|
||||
"type": 'tmux',
|
||||
"cmd": 'WEBUI_DATA_DIR={workspaces_root}/openwebui open-webui serve --port {port}',
|
||||
# Minimal launcher — see user_interfaces.py openwebui entry for
|
||||
# the full rationale. Summary: open-webui auto-discovers ollama
|
||||
# at http://localhost:11434 (do NOT set OLLAMA_BASE_URL here).
|
||||
# DATA_DIR replaces the removed --data-dir CLI flag. cd to
|
||||
# configs/auth/ so the auto-generated .webui_secret_key lands
|
||||
# in the canonical private-data dir, not the ai-lsc run dir.
|
||||
"cmd": 'mkdir -p {base_dir}/configs/auth {workspaces_root}/openwebui && chmod 700 {base_dir}/configs/auth && cd {base_dir}/configs/auth && DATA_DIR={workspaces_root}/openwebui open-webui serve --port {port}',
|
||||
"default_port": 8080,
|
||||
},
|
||||
"deps": [
|
||||
'ollama',
|
||||
],
|
||||
"filesystem": {
|
||||
"install": 'tools/openwebui',
|
||||
"install": 'dashboards/openwebui',
|
||||
"config": 'configs/openwebui',
|
||||
"data": 'workspaces/openwebui',
|
||||
"logs": 'logs/openwebui',
|
||||
|
|
|
|||
|
|
@ -35,7 +35,24 @@ TOOLS: dict[str, dict] = {
|
|||
},
|
||||
"launcher": {
|
||||
"type": "tmux",
|
||||
"cmd": "OLLAMA_HOST=0.0.0.0:{port} OLLAMA_MODELS={models_root}/ollama ollama serve",
|
||||
# Force ollama to use /mnt/AI/models/ollama (not ~/.ollama/models)
|
||||
# and put its identity-key + runtime state under /mnt/AI/.ollama
|
||||
# (not ~/.ollama). We set HOME={base_dir} *only for ollama* —
|
||||
# this is safe because ollama doesn't depend on uv-tool/pipx
|
||||
# (which are $HOME-relative). For uv-tool-installed tools like
|
||||
# open-webui, we CANNOT override HOME globally or the tool's
|
||||
# own venv lookup breaks; each tool that needs a HOME redirect
|
||||
# must opt in via its own launcher cmd.
|
||||
#
|
||||
# OLLAMA_HOST=0.0.0.0:{port} — bind to all interfaces so
|
||||
# open-webui and other tools on the box can reach it.
|
||||
# OLLAMA_MODELS — pins the models dir to the canonical ai-lsc
|
||||
# location; this is what makes `ollama list` and open-webui's
|
||||
# model picker see the same set.
|
||||
# mkdir -p {base_dir}/.ollama — pre-creates the dotdir ollama
|
||||
# expects so it doesn't trip on a broken ~/.ollama state on
|
||||
# the host (e.g. a stale file where a dir should be).
|
||||
"cmd": "mkdir -p {base_dir}/.ollama && HOME={base_dir} OLLAMA_HOST=0.0.0.0:{port} OLLAMA_MODELS={models_root}/ollama ollama serve",
|
||||
"default_port": 11434
|
||||
},
|
||||
"deps": [],
|
||||
|
|
|
|||
|
|
@ -20,16 +20,46 @@ TOOLS: dict[str, dict] = {
|
|||
"category": 'Extensible Interface',
|
||||
"installer": {
|
||||
"type": "uv",
|
||||
"pkg": "open-webui"
|
||||
# open-webui's pydub dep imports `audioop`, which Python 3.13
|
||||
# removed from the stdlib (PEP 594). The `audioop-lts` backport
|
||||
# restores it; --with pulls it into the tool's isolated env.
|
||||
"pkg": "open-webui --with audioop-lts"
|
||||
},
|
||||
"launcher": {
|
||||
# Minimal launcher — open-webui auto-discovers ollama at
|
||||
# http://localhost:11434 (its built-in default). Do NOT add
|
||||
# OLLAMA_BASE_URL / CORS_ORIGINS / ENABLE_DIRECT_CONNECTIONS /
|
||||
# WEBUI_SECRET_KEY env vars — past iterations proved they break
|
||||
# model discovery. open-webui handles all of these itself when
|
||||
# left alone.
|
||||
#
|
||||
# Two things we DO control:
|
||||
# 1. DATA_DIR — points the sqlite db + uploads at the
|
||||
# canonical workspaces dir. (The old --data-dir CLI flag
|
||||
# was removed upstream; the env var is the replacement.)
|
||||
# 2. cd to {base_dir}/configs/auth/ before launching —
|
||||
# open-webui writes its auto-generated .webui_secret_key
|
||||
# to Path.cwd() (see backend/open_webui/__init__.py
|
||||
# KEY_FILE). Running from configs/auth/ keeps the secret
|
||||
# out of the ai-lsc run dir and in the canonical
|
||||
# private-data location, with no env-var games.
|
||||
"type": "tmux",
|
||||
"cmd": "WEBUI_DATA_DIR={workspaces_root}/openwebui open-webui serve --port {port}",
|
||||
"cmd": "mkdir -p {base_dir}/configs/auth {workspaces_root}/openwebui && chmod 700 {base_dir}/configs/auth && cd {base_dir}/configs/auth && DATA_DIR={workspaces_root}/openwebui open-webui serve --port {port}",
|
||||
"default_port": 8080
|
||||
},
|
||||
"deps": [
|
||||
"ollama"
|
||||
],
|
||||
"filesystem": {
|
||||
# Map-aligned per the v3.4 file-system map: the web UI app
|
||||
# marker lives under dashboards/, config under configs/, and
|
||||
# the sqlite/uploads DATA_DIR under workspaces/. The uv-managed
|
||||
# binary itself lives in tools/.uv/bin (on the managed PATH).
|
||||
"install": "dashboards/openwebui",
|
||||
"config": "configs/openwebui",
|
||||
"data": "workspaces/openwebui",
|
||||
"logs": "logs/openwebui"
|
||||
},
|
||||
"description": "Extensible frontend for LLMs.",
|
||||
"license": 'MIT',
|
||||
"flags": {
|
||||
|
|
@ -79,11 +109,17 @@ TOOLS: dict[str, dict] = {
|
|||
"installer": {
|
||||
"type": "git_node",
|
||||
"pkg": "https://github.com/danny-avila/LibreChat.git",
|
||||
"update_cmd": "git pull --ff-only && yarn install && yarn build"
|
||||
"update_cmd": "git pull --ff-only && yarn install && yarn build",
|
||||
# Two known fresh-clone start bugs, fixed at install time:
|
||||
# 1. `yarn backend` aborts without an .env file — seed one
|
||||
# from the shipped example (never clobber an existing one).
|
||||
# 2. production mode requires the client assets from
|
||||
# `yarn build` — plain `yarn install` doesn't produce them.
|
||||
"post_install": "cp -n .env.example .env 2>/dev/null || true; yarn build"
|
||||
},
|
||||
"launcher": {
|
||||
"type": "tmux",
|
||||
"cmd": "cd {tools_root}/librechat && API_PLUGINS=false PORT={port} NODE_ENV=production yarn backend",
|
||||
"cmd": "cd {dashboards_root}/librechat && API_PLUGINS=false PORT={port} NODE_ENV=production yarn backend",
|
||||
"default_port": 3080
|
||||
},
|
||||
"deps": [
|
||||
|
|
@ -101,9 +137,9 @@ TOOLS: dict[str, dict] = {
|
|||
"is_skills_collection": False
|
||||
},
|
||||
"filesystem": {
|
||||
"install": "tools/librechat",
|
||||
"install": "dashboards/librechat",
|
||||
"config": "configs/librechat",
|
||||
"data": "data/librechat",
|
||||
"data": "workspaces/librechat",
|
||||
"logs": "logs/librechat"
|
||||
}
|
||||
},
|
||||
|
|
@ -536,11 +572,21 @@ TOOLS: dict[str, dict] = {
|
|||
"category": 'Chat Frontend',
|
||||
"installer": {
|
||||
"type": "uv",
|
||||
"pkg": "open-webui"
|
||||
# Same pydub/audioop Python-3.13 fix as the openwebui entry.
|
||||
"pkg": "open-webui --with audioop-lts"
|
||||
},
|
||||
"launcher": {
|
||||
"type": "tmux",
|
||||
"cmd": "WEBUI_DATA_DIR={workspaces_root}/hermes-webui OLLAMA_BASE_URL=http://localhost:17051 open-webui serve --port {port}",
|
||||
# Same minimal pattern as the openwebui entry — cd to
|
||||
# configs/auth/ so .webui_secret_key lands there, DATA_DIR
|
||||
# points at the hermes-webui data volume.
|
||||
#
|
||||
# The ONE env var we set here: OLLAMA_BASE_URL. The default is
|
||||
# http://localhost:11434 (ollama direct); hermes_webui needs to
|
||||
# point at hermes_agent (17051) instead so every conversation
|
||||
# flows through the Hermes runtime's tool-use layer. This is
|
||||
# the only difference from the openwebui entry.
|
||||
"cmd": "mkdir -p {base_dir}/configs/auth && chmod 700 {base_dir}/configs/auth && cd {base_dir}/configs/auth && DATA_DIR={workspaces_root}/hermes-webui OLLAMA_BASE_URL=http://127.0.0.1:17051 open-webui serve --port {port}",
|
||||
"default_port": 8081
|
||||
},
|
||||
"deps": [
|
||||
|
|
@ -625,4 +671,54 @@ TOOLS: dict[str, dict] = {
|
|||
"is_skills_collection": False
|
||||
}
|
||||
},
|
||||
'openhuman': {
|
||||
"name": "OpenHuman",
|
||||
"level": 10,
|
||||
"layer": 'Human Interface & System Operations',
|
||||
"role": 'Personal AI Harness',
|
||||
"category": 'Personal AI Harness',
|
||||
"installer": {
|
||||
# Same curl|sh policy category as ollama / meilisearch / fabric
|
||||
# (see whatremains.txt C-05). The openhuman README explicitly
|
||||
# warns this script path is unverified (no detached signature);
|
||||
# the native-package paths (deb / rpm / AppImage / brew cask)
|
||||
# are preferred when the user is ready to move off the script
|
||||
# installer.
|
||||
"type": "script",
|
||||
"cmd": "curl -fsSL https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/install.sh | bash",
|
||||
"pkg": "https://github.com/tinyhumansai/openhuman"
|
||||
},
|
||||
"launcher": {
|
||||
# Desktop GUI app (Rust core + native shell). One-click start
|
||||
# → talks to localhost ollama. OLLAMA_BASE_URL is preset so
|
||||
# openhuman's first-run model-router defaults to the local
|
||||
# ollama instance instead of the managed subscription endpoint.
|
||||
# Wrapped in `bash -c` because launch_desktop uses
|
||||
# subprocess.Popen(argv) directly (no shell), so env-assignment
|
||||
# prefixes must be expanded by an actual shell.
|
||||
"type": "desktop",
|
||||
"cmd": "bash -c 'OLLAMA_BASE_URL=http://127.0.0.1:11434 exec openhuman'",
|
||||
"default_port": None
|
||||
},
|
||||
"deps": [
|
||||
"ollama"
|
||||
],
|
||||
"description": "Personal AI harness: persistent local memory tree, "
|
||||
"agent orchestration on durable graphs, deep-research "
|
||||
"pipeline. Local-first with opt-in Privacy Mode that "
|
||||
"forces all inference to localhost ollama (or another "
|
||||
"managed backend). Native packages (.deb / .rpm / "
|
||||
".AppImage / brew cask) preferred over the script "
|
||||
"installer once the user moves past first boot.",
|
||||
"license": 'GPL-3.0',
|
||||
"flags": {
|
||||
"has_cli": False,
|
||||
"has_gui": True,
|
||||
"has_web": False,
|
||||
"is_ollama": False,
|
||||
"is_passive": False,
|
||||
"is_mcp": False,
|
||||
"is_skills_collection": False
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,7 +175,11 @@ def _check_entry(tool_id: str, entry: dict[str, Any]) -> list[str]:
|
|||
# Warn if script cmd doesn't contain {tools_root}
|
||||
if itype == "script" and inst.get("cmd"):
|
||||
cmd_str = inst["cmd"]
|
||||
if "{tools_root}" not in cmd_str and tool_id != "ollama":
|
||||
# carve-out: ollama and openhuman ship native installers
|
||||
# (apt/dpkg/AppImage/brew cask) that target system paths,
|
||||
# not tools_root. Their script installers are accepted as-is
|
||||
# per the C-05 curl|sh policy (see whatremains.txt).
|
||||
if "{tools_root}" not in cmd_str and tool_id not in {"ollama", "openhuman"}:
|
||||
errors.append(
|
||||
f"{tool_id}: script installer cmd should reference "
|
||||
f"{{{{tools_root}}}} to avoid polluting system dirs"
|
||||
|
|
|
|||
|
|
@ -104,16 +104,28 @@ class RuntimeExecutor:
|
|||
port: str = "",
|
||||
model_arg: str = "",
|
||||
) -> dict[str, str]:
|
||||
"""Build the ``{placeholders}`` dict used by launcher commands."""
|
||||
"""Build the ``{placeholders}`` dict used by launcher commands.
|
||||
|
||||
Every key of the path tree is exposed (``{tools_root}``,
|
||||
``{dashboards_root}``, ``{runtime_root}``, ``{configs_root}``,
|
||||
``{datasets_root}``, …) so registry launcher templates can
|
||||
reference any canonical /mnt/AI location.
|
||||
"""
|
||||
from ai_lsc.constants import BASE_DIR
|
||||
return {
|
||||
from ai_lsc.utils.paths import build_path_tree
|
||||
ctx = {
|
||||
str(k): str(v)
|
||||
for k, v in build_path_tree(BASE_DIR).items()
|
||||
}
|
||||
ctx.update({
|
||||
"base_dir": BASE_DIR,
|
||||
"tools_root": self.tools_root,
|
||||
"models_root": self.models_root,
|
||||
"workspaces_root": self.workspaces_root,
|
||||
"port": port,
|
||||
"model_arg": model_arg,
|
||||
}
|
||||
})
|
||||
return ctx
|
||||
|
||||
# -- service lifecycle -----------------------------------------------
|
||||
|
||||
|
|
@ -224,15 +236,26 @@ class RuntimeExecutor:
|
|||
env_overrides: dict[str, str] | None = None,
|
||||
filesystem: dict[str, str] | None = None,
|
||||
license_spdx: str | None = None,
|
||||
registry_entry: dict | None = None,
|
||||
) -> str:
|
||||
"""Dispatch tool installation to the correct installer.
|
||||
|
||||
If *tool_id* is provided, the installer uses preflight detection
|
||||
and routes artifacts to ``tools_root/<tool_id>/``.
|
||||
If *tool_id* is provided, the installer uses preflight detection.
|
||||
When the tool is already installed, the call becomes an in-place
|
||||
**update** (honouring the registry's ``installer.update_cmd``)
|
||||
unless *force* is set or the tool is missing.
|
||||
|
||||
If *force* is True, skips preflight and installs unconditionally.
|
||||
|
||||
*post_install* runs a shell command inside ``tools_root/<tool_id>``
|
||||
after clone (e.g. ``pip install -r requirements.txt``, ``make``).
|
||||
*registry_entry* (the tool's full registry metadata dict) routes
|
||||
artifacts to the canonical install root declared by
|
||||
``filesystem.install`` / the v3.4 router (web UIs →
|
||||
``dashboards/<id>/``, standalone compiles → ``tools/<id>/``)
|
||||
and supplies ``update_cmd`` / ``post_install`` defaults.
|
||||
|
||||
*post_install* runs a shell command inside the tool's install
|
||||
directory after clone (e.g. ``pip install -r requirements.txt``,
|
||||
``make``).
|
||||
|
||||
*env_overrides* remaps upstream environment variables (HF_HOME,
|
||||
TRANSFORMERS_CACHE, etc.) into ``/mnt/AI/`` paths.
|
||||
|
|
@ -259,6 +282,7 @@ class RuntimeExecutor:
|
|||
post_install=post_install,
|
||||
env_overrides=env_overrides,
|
||||
license_spdx=license_spdx,
|
||||
entry=registry_entry,
|
||||
)
|
||||
return self._installer.run(
|
||||
inst_type=inst_type,
|
||||
|
|
@ -269,6 +293,40 @@ class RuntimeExecutor:
|
|||
post_install=post_install,
|
||||
env_overrides=env_overrides,
|
||||
license_spdx=license_spdx,
|
||||
entry=registry_entry,
|
||||
)
|
||||
|
||||
def update_tool(
|
||||
self,
|
||||
tool_id: str,
|
||||
inst_type: str,
|
||||
pkg: str,
|
||||
cmd: str = "",
|
||||
update_cmd: str = "",
|
||||
registry_entry: dict | None = None,
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
post_install: str | None = None,
|
||||
) -> str:
|
||||
"""Update an already-installed tool in place.
|
||||
|
||||
Honours the registry's ``installer.update_cmd`` when provided;
|
||||
otherwise falls back to per-type defaults (``uv tool upgrade``,
|
||||
``pipx upgrade``, ``git pull --ff-only`` + node rebuild, …).
|
||||
Runs in the *detected* install location so tools installed
|
||||
before the v3.4 layout change update where they live.
|
||||
|
||||
Returns a description of the result.
|
||||
"""
|
||||
_validate_tool_id(tool_id)
|
||||
return self._installer.update_tool(
|
||||
tool_id=tool_id,
|
||||
inst_type=inst_type,
|
||||
pkg=pkg,
|
||||
cmd=cmd,
|
||||
update_cmd=update_cmd,
|
||||
entry=registry_entry,
|
||||
env_overrides=env_overrides,
|
||||
post_install=post_install,
|
||||
)
|
||||
|
||||
# -- verification ---------------------------------------------------
|
||||
|
|
@ -280,6 +338,7 @@ class RuntimeExecutor:
|
|||
pkg: str,
|
||||
cmd: str = "",
|
||||
filesystem: dict[str, str] | None = None,
|
||||
registry_entry: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run the installation compliance checklist for a tool.
|
||||
|
||||
|
|
@ -291,6 +350,7 @@ class RuntimeExecutor:
|
|||
pkg=pkg,
|
||||
cmd=cmd,
|
||||
filesystem=filesystem,
|
||||
entry=registry_entry,
|
||||
)
|
||||
|
||||
# -- model management ------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ from typing import Any
|
|||
from urllib.parse import urlparse
|
||||
|
||||
from ai_lsc.utils.logging import get_logger
|
||||
from ai_lsc.utils.paths import (
|
||||
build_path_tree,
|
||||
candidate_install_dirs,
|
||||
install_dir_for,
|
||||
)
|
||||
from ai_lsc.utils.process import enriched_env
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -92,6 +97,21 @@ def _validate_url(url: str, *, allow_schemes: tuple[str, ...] = ("http", "https"
|
|||
raise ValueError(f"unsafe URL rejected: {url!r}")
|
||||
return url
|
||||
|
||||
|
||||
def _uv_with_extras(tokens: list[str]) -> list[str]:
|
||||
"""Extract the package names pulled in by ``--with`` flags.
|
||||
|
||||
``["open-webui", "--with", "audioop-lts"]`` → ``["audioop-lts"]``.
|
||||
Multiple ``--with`` flags each contribute one package; unknown
|
||||
flags contribute nothing. Used by the uv→pip step-down so the
|
||||
fallback venv keeps every extra the registry declared.
|
||||
"""
|
||||
extras: list[str] = []
|
||||
for i, tok in enumerate(tokens[:-1]):
|
||||
if tok == "--with":
|
||||
extras.append(tokens[i + 1])
|
||||
return extras
|
||||
|
||||
# Step-down containment order (most isolated first)
|
||||
STEP_DOWN_ORDER: list[str] = [
|
||||
"ollama", "uv", "pipx", "pip",
|
||||
|
|
@ -152,6 +172,43 @@ class InstallerManager:
|
|||
# e.g. in the UI layer).
|
||||
self.license_gate = license_gate
|
||||
|
||||
# ── Install-root routing ─────────────────────────────────────────
|
||||
|
||||
def _dest_for(
|
||||
self,
|
||||
tool_id: str,
|
||||
entry: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Resolve the on-disk destination directory for *tool_id*.
|
||||
|
||||
Honours the registry entry's ``filesystem.install`` and the
|
||||
v3.4 install-root router (web UIs → ``dashboards/``, everything
|
||||
else → ``tools/``). Falls back to the historical
|
||||
``tools_root/<tool_id>`` when no entry is supplied, so
|
||||
pre-v3.4 call sites keep working unchanged.
|
||||
"""
|
||||
if entry:
|
||||
return str(install_dir_for(entry, tool_id, self.base_dir))
|
||||
return os.path.join(self.tools_root, tool_id)
|
||||
|
||||
def _existing_dir_for(
|
||||
self,
|
||||
tool_id: str,
|
||||
entry: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Like :meth:`_dest_for`, but prefer an existing install.
|
||||
|
||||
Scans the candidate roots in router order and returns the first
|
||||
directory that already exists; only when none exists is the
|
||||
canonical (fresh-install) destination returned. This keeps
|
||||
updates flowing to wherever a tool actually lives today
|
||||
instead of forking a second copy under the new canonical root.
|
||||
"""
|
||||
for cand in candidate_install_dirs(tool_id, entry, self.base_dir):
|
||||
if cand.is_dir():
|
||||
return str(cand)
|
||||
return self._dest_for(tool_id, entry)
|
||||
|
||||
# ── Environment construction ─────────────────────────────────────
|
||||
|
||||
def _env(
|
||||
|
|
@ -198,14 +255,20 @@ class InstallerManager:
|
|||
env["PIPX_HOME"] = os.path.join(self.tools_root, ".pipx")
|
||||
|
||||
# ── Per-tool env overrides from registry ────────────────────────
|
||||
# Keys may contain {tools_root}, {base_dir} placeholders.
|
||||
# Values may contain any {path_tree_key} placeholder
|
||||
# ({tools_root}, {base_dir}, {dashboards_root}, …).
|
||||
if env_overrides:
|
||||
tree = build_path_tree(self.base_dir)
|
||||
for key, raw_val in env_overrides.items():
|
||||
expanded = raw_val.replace(
|
||||
"{tools_root}", self.tools_root,
|
||||
).replace(
|
||||
"{base_dir}", self.base_dir,
|
||||
)
|
||||
for tree_key, tree_val in tree.items():
|
||||
expanded = expanded.replace(
|
||||
"{" + tree_key + "}", str(tree_val),
|
||||
)
|
||||
env[key] = expanded
|
||||
logger.debug(
|
||||
"env override: %s=%s (tool %s)", key, expanded, tool_id,
|
||||
|
|
@ -230,6 +293,7 @@ class InstallerManager:
|
|||
inst_type: str,
|
||||
pkg: str,
|
||||
cmd: str = "",
|
||||
entry: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Check whether a tool is already installed before installing.
|
||||
|
||||
|
|
@ -246,7 +310,7 @@ class InstallerManager:
|
|||
}
|
||||
|
||||
location, version = self._detect_installation(
|
||||
tool_id, inst_type, pkg, cmd,
|
||||
tool_id, inst_type, pkg, cmd, entry,
|
||||
)
|
||||
if location:
|
||||
result["found"] = True
|
||||
|
|
@ -262,39 +326,54 @@ class InstallerManager:
|
|||
inst_type: str,
|
||||
pkg: str,
|
||||
cmd: str = "",
|
||||
entry: dict[str, Any] | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Detect existing installation. Returns (location, version)."""
|
||||
"""Detect existing installation. Returns (location, version).
|
||||
|
||||
# 1. Check tools_root/<tool_id> directory existence
|
||||
tool_dir = os.path.join(self.tools_root, tool_id)
|
||||
if os.path.isdir(tool_dir):
|
||||
ver = self._detect_version(inst_type, pkg, cmd, tool_dir)
|
||||
return tool_dir, ver
|
||||
Probes every candidate install root (router dir, dashboards/,
|
||||
tools/) so tools installed before the v3.4 routing change are
|
||||
still found and offered an update instead of a reinstall.
|
||||
"""
|
||||
|
||||
# `pkg` may carry extra uv/flags tokens (e.g.
|
||||
# "open-webui --with audioop-lts"); for preflight we only care
|
||||
# about the first token — the actual package name.
|
||||
pkg_token = shlex.split(pkg)[0] if pkg else pkg
|
||||
|
||||
# 1. Check candidate install directories (router order)
|
||||
for tool_dir in candidate_install_dirs(
|
||||
tool_id, entry, self.base_dir,
|
||||
):
|
||||
if os.path.isdir(tool_dir):
|
||||
ver = self._detect_version(
|
||||
inst_type, pkg_token, cmd, str(tool_dir),
|
||||
)
|
||||
return str(tool_dir), ver
|
||||
|
||||
# 2. Check tools_root/.pipx, tools_root/.uv, tools_root/.local
|
||||
for subdir in [".pipx", ".uv", ".local"]:
|
||||
check = os.path.join(self.tools_root, subdir, "bin", pkg)
|
||||
check = os.path.join(self.tools_root, subdir, "bin", pkg_token)
|
||||
if os.path.exists(check):
|
||||
return os.path.dirname(check), ""
|
||||
|
||||
# 3. Check tools_root/bin
|
||||
bin_check = os.path.join(self.tools_root, "bin", pkg)
|
||||
bin_check = os.path.join(self.tools_root, "bin", pkg_token)
|
||||
if os.path.exists(bin_check):
|
||||
return os.path.dirname(bin_check), ""
|
||||
|
||||
# 4. Check system PATH via shutil.which
|
||||
binary_name = self._binary_name(pkg, inst_type)
|
||||
binary_name = self._binary_name(pkg_token, inst_type)
|
||||
system_path = shutil.which(binary_name)
|
||||
if system_path:
|
||||
ver = self._detect_version(inst_type, pkg, cmd)
|
||||
ver = self._detect_version(inst_type, pkg_token, cmd)
|
||||
return system_path, ver
|
||||
|
||||
# 5. OS package manager query (pacman / dnf / apt) — list-form
|
||||
# subprocess calls, no shell, no interpolation.
|
||||
_PKG_MGR_QUERIES: dict[str, list[str]] = {
|
||||
"pacman": ["pacman", "-Qi", pkg],
|
||||
"dnf": ["dnf", "info", pkg],
|
||||
"apt": ["dpkg", "-s", pkg],
|
||||
"pacman": ["pacman", "-Qi", pkg_token],
|
||||
"dnf": ["dnf", "info", pkg_token],
|
||||
"apt": ["dpkg", "-s", pkg_token],
|
||||
}
|
||||
if inst_type in _PKG_MGR_QUERIES:
|
||||
try:
|
||||
|
|
@ -306,19 +385,24 @@ class InstallerManager:
|
|||
for line in proc.stdout.splitlines():
|
||||
if line.strip().startswith("Version"):
|
||||
ver = line.split(":", 1)[-1].strip()
|
||||
return f"{inst_type}:{pkg}", ver
|
||||
return f"{inst_type}:{pkg_token}", ver
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
return "", ""
|
||||
|
||||
def _binary_name(self, pkg: str, inst_type: str) -> str:
|
||||
"""Map a package name to its likely binary name."""
|
||||
"""Map a package name to its likely binary name.
|
||||
|
||||
Takes the first shlex token of *pkg* so a spec like
|
||||
``"open-webui --with audioop-lts"`` resolves to ``open_webui``.
|
||||
"""
|
||||
first = shlex.split(pkg)[0] if pkg else pkg
|
||||
if inst_type == "npm":
|
||||
return pkg if "/" not in pkg else pkg.split("/")[-1]
|
||||
return first if "/" not in first else first.split("/")[-1]
|
||||
if inst_type in ("uv", "pip"):
|
||||
return pkg.replace("-", "_").replace(".", "_")
|
||||
return pkg
|
||||
return first.replace("-", "_").replace(".", "_")
|
||||
return first
|
||||
|
||||
def _detect_version(
|
||||
self,
|
||||
|
|
@ -329,9 +413,19 @@ class InstallerManager:
|
|||
) -> str:
|
||||
"""Try to extract the installed version."""
|
||||
if inst_type == "git":
|
||||
git_dir = os.path.join(self.tools_root, pkg.split("/")[-1]
|
||||
.replace(".git", ""))
|
||||
if os.path.isdir(os.path.join(git_dir, ".git")):
|
||||
# Use only the first token of `pkg` for the dir-name lookup
|
||||
# so a spec like "repo --branch foo" doesn't break the
|
||||
# rsplit("/"). (Git pkg specs are normally bare URLs, but
|
||||
# be defensive.)
|
||||
pkg_first = shlex.split(pkg)[0] if pkg else pkg
|
||||
repo_name = pkg_first.split("/")[-1].replace(".git", "")
|
||||
git_dirs = [
|
||||
str(d) for d in candidate_install_dirs(
|
||||
repo_name, None, self.base_dir,
|
||||
)
|
||||
if os.path.isdir(os.path.join(d, ".git"))
|
||||
]
|
||||
for git_dir in git_dirs:
|
||||
for argv in (
|
||||
["git", "describe", "--tags", "--abbrev=0"],
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
|
|
@ -355,7 +449,11 @@ class InstallerManager:
|
|||
else:
|
||||
tmpl = _VERSION_CMDS.get(inst_type, "")
|
||||
if tmpl:
|
||||
ver_argv = shlex.split(tmpl.format(pkg=pkg, cmd=pkg))
|
||||
# Use only the first token of `pkg` so a spec like
|
||||
# "open-webui --with audioop-lts" renders as just the
|
||||
# binary name in the version-detection command.
|
||||
pkg_first = shlex.split(pkg)[0] if pkg else pkg
|
||||
ver_argv = shlex.split(tmpl.format(pkg=pkg_first, cmd=pkg_first))
|
||||
|
||||
if not ver_argv:
|
||||
return ""
|
||||
|
|
@ -378,14 +476,16 @@ class InstallerManager:
|
|||
self,
|
||||
tool_id: str,
|
||||
post_install_cmd: str,
|
||||
dest: str = "",
|
||||
) -> str:
|
||||
"""Run a post-install hook inside ``tools_root/<tool_id>``."""
|
||||
"""Run a post-install hook inside the tool's install directory."""
|
||||
if not post_install_cmd:
|
||||
return ""
|
||||
dest = os.path.join(self.tools_root, tool_id)
|
||||
cwd = dest or os.path.join(self.tools_root, tool_id)
|
||||
env = self._env(tool_id)
|
||||
# Replace {tools_root} in the command
|
||||
cmd = post_install_cmd.replace("{tools_root}", self.tools_root)
|
||||
# Resolve {tools_root} / {base_dir} / {install_dir} and every
|
||||
# path-tree placeholder in the command.
|
||||
cmd = self._render_cmd(post_install_cmd, cwd)
|
||||
logger.info("Running post-install for %s: %s", tool_id, cmd)
|
||||
try:
|
||||
# Post-install commands are arbitrary shell snippets supplied by
|
||||
|
|
@ -395,7 +495,7 @@ class InstallerManager:
|
|||
# break out of the subprocess call itself.
|
||||
subprocess.run(
|
||||
["bash", "-c", cmd], check=True, env=env,
|
||||
timeout=300, cwd=dest,
|
||||
timeout=600, cwd=cwd,
|
||||
)
|
||||
return f"Post-install completed for {tool_id}."
|
||||
except (subprocess.CalledProcessError, OSError) as exc:
|
||||
|
|
@ -404,6 +504,20 @@ class InstallerManager:
|
|||
)
|
||||
return f"Post-install FAILED for {tool_id}: {exc}"
|
||||
|
||||
def _render_cmd(self, cmd: str, install_dir: str = "") -> str:
|
||||
"""Resolve path placeholders in a registry shell snippet.
|
||||
|
||||
Supports ``{tools_root}``, ``{base_dir}``, ``{install_dir}``
|
||||
and every key of the path tree (``{dashboards_root}``, …).
|
||||
"""
|
||||
rendered = cmd.replace("{tools_root}", self.tools_root)
|
||||
rendered = rendered.replace("{base_dir}", self.base_dir)
|
||||
if install_dir:
|
||||
rendered = rendered.replace("{install_dir}", install_dir)
|
||||
for key, value in build_path_tree(self.base_dir).items():
|
||||
rendered = rendered.replace("{" + key + "}", str(value))
|
||||
return rendered
|
||||
|
||||
# ── Strategy methods ────────────────────────────────────────────
|
||||
|
||||
def install_ollama(self, pkg: str, tool_id: str) -> str:
|
||||
|
|
@ -438,26 +552,84 @@ class InstallerManager:
|
|||
return f"Ollama model '{pkg}' queued for pull."
|
||||
|
||||
def install_uv(self, pkg: str, tool_id: str,
|
||||
env_overrides: dict[str, str] | None = None) -> str:
|
||||
"""Install a Python tool via ``uv tool install`` pinned to tools_root."""
|
||||
dest = os.path.join(self.tools_root, tool_id)
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
entry: dict[str, Any] | None = None) -> str:
|
||||
"""Install a Python tool via ``uv tool install`` pinned to tools_root.
|
||||
|
||||
The ``pkg`` string may carry extra ``uv`` flags after the package
|
||||
name — e.g. ``"open-webui --with audioop-lts"``. The first token
|
||||
is validated as the package name; the remaining tokens are passed
|
||||
through to ``uv tool install`` verbatim. This lets registry
|
||||
entries pin Python-3.13-deps backports (audioop-lts for pydub,
|
||||
etc.) without needing new schema fields or threading new params
|
||||
through every caller.
|
||||
|
||||
Step-down containment (fix for the open-webui install bug):
|
||||
|
||||
1. ``uv tool install <pkg> [flags]`` with the default Python.
|
||||
2. If that fails, retry with ``--python 3.12`` — uv fetches a
|
||||
managed CPython 3.12 on the fly, which sidesteps wheels that
|
||||
have not been rebuilt for the host's newer Python (the
|
||||
pydub/audioop class of failure).
|
||||
3. If uv fails entirely, fall back to a per-tool pip venv that
|
||||
installs the package **plus every ``--with`` extra** — the
|
||||
historical pipx fallback silently dropped the extras, which
|
||||
produced installs that broke at import time.
|
||||
"""
|
||||
dest = self._dest_for(tool_id, entry)
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
env = self._env(tool_id, env_overrides)
|
||||
# Shlex-split so registry authors can write
|
||||
# `pkg: "open-webui --with audioop-lts"` and have it land as
|
||||
# `["uv", "tool", "install", "open-webui", "--with", "audioop-lts"]`.
|
||||
tokens = shlex.split(pkg)
|
||||
if not tokens:
|
||||
raise ValueError(f"invalid pkg spec for {tool_id}: {pkg!r}")
|
||||
_validate_pkg(tokens[0])
|
||||
extras = _uv_with_extras(tokens)
|
||||
try:
|
||||
_validate_pkg(pkg)
|
||||
subprocess.run(
|
||||
["uv", "tool", "install", pkg],
|
||||
["uv", "tool", "install", *tokens],
|
||||
check=True, env=env, timeout=300,
|
||||
)
|
||||
return f"UV tool '{pkg}' installed to {env['UV_TOOL_DIR']}."
|
||||
return f"UV tool '{tokens[0]}' installed to {env['UV_TOOL_DIR']}."
|
||||
except subprocess.CalledProcessError:
|
||||
logger.info("uv install failed for %s, stepping down to pipx", pkg)
|
||||
return self.install_pipx(pkg, tool_id, env_overrides)
|
||||
logger.info(
|
||||
"uv install failed for %s on the default Python; "
|
||||
"retrying with a managed CPython 3.12", tokens[0],
|
||||
)
|
||||
try:
|
||||
subprocess.run(
|
||||
["uv", "tool", "install", "--python", "3.12", *tokens],
|
||||
check=True, env=env, timeout=600,
|
||||
)
|
||||
return (
|
||||
f"UV tool '{tokens[0]}' installed to "
|
||||
f"{env['UV_TOOL_DIR']} (managed CPython 3.12)."
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
if extras:
|
||||
logger.info(
|
||||
"uv failed for %s even on 3.12; falling back to a "
|
||||
"pip venv WITH the %d --with extra(s) intact",
|
||||
tokens[0], len(extras),
|
||||
)
|
||||
pip_spec = " ".join([tokens[0], *extras])
|
||||
return self.install_pip(
|
||||
pip_spec, tool_id, env_overrides, entry,
|
||||
)
|
||||
logger.info(
|
||||
"uv failed for %s; stepping down to pipx", tokens[0],
|
||||
)
|
||||
return self.install_pipx(
|
||||
tokens[0], tool_id, env_overrides, entry,
|
||||
)
|
||||
|
||||
def install_pipx(self, pkg: str, tool_id: str,
|
||||
env_overrides: dict[str, str] | None = None) -> str:
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
entry: dict[str, Any] | None = None) -> str:
|
||||
"""Install a Python CLI tool via ``pipx`` pinned to tools_root."""
|
||||
dest = os.path.join(self.tools_root, tool_id)
|
||||
dest = self._dest_for(tool_id, entry)
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
env = self._env(tool_id, env_overrides)
|
||||
try:
|
||||
|
|
@ -469,12 +641,18 @@ class InstallerManager:
|
|||
return f"pipx '{pkg}' installed to {env['PIPX_HOME']}."
|
||||
except subprocess.CalledProcessError:
|
||||
logger.info("pipx install failed for %s, stepping down to pip", pkg)
|
||||
return self.install_pip(pkg, tool_id, env_overrides)
|
||||
return self.install_pip(pkg, tool_id, env_overrides, entry)
|
||||
|
||||
def install_pip(self, pkg: str, tool_id: str,
|
||||
env_overrides: dict[str, str] | None = None) -> str:
|
||||
"""Install a Python tool via ``pip`` into a per-tool venv."""
|
||||
dest = os.path.join(self.tools_root, tool_id)
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
entry: dict[str, Any] | None = None) -> str:
|
||||
"""Install a Python tool via ``pip`` into a per-tool venv.
|
||||
|
||||
*pkg* may be a space-separated spec naming several distributions
|
||||
(``"open-webui audioop-lts"``) — the uv step-down path uses this
|
||||
to keep ``--with`` extras alive in the fallback venv.
|
||||
"""
|
||||
dest = self._dest_for(tool_id, entry)
|
||||
venv_dir = os.path.join(dest, ".venv")
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
env = self._env(tool_id, env_overrides)
|
||||
|
|
@ -484,16 +662,20 @@ class InstallerManager:
|
|||
check=True, env=env, timeout=60,
|
||||
)
|
||||
pip_bin = os.path.join(venv_dir, "bin", "pip")
|
||||
pip_tokens = shlex.split(pkg) if pkg else []
|
||||
if not pip_tokens:
|
||||
raise ValueError(f"invalid pkg spec for {tool_id}: {pkg!r}")
|
||||
try:
|
||||
_validate_pkg(pkg)
|
||||
for tok in pip_tokens:
|
||||
_validate_pkg(tok)
|
||||
subprocess.run(
|
||||
[pip_bin, "install", pkg],
|
||||
[pip_bin, "install", *pip_tokens],
|
||||
check=True, env=env, timeout=300,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.warning("pip install failed for %s: %s", pkg, exc)
|
||||
raise
|
||||
self._symlink_venv_bin(tool_id, venv_dir, pkg)
|
||||
self._symlink_venv_bin(tool_id, venv_dir, pip_tokens[0])
|
||||
return f"pip '{pkg}' installed to {venv_dir}."
|
||||
|
||||
def _symlink_venv_bin(
|
||||
|
|
@ -544,9 +726,10 @@ class InstallerManager:
|
|||
return f"Dispatched apt for {pkg}."
|
||||
|
||||
def install_npm(self, pkg: str, tool_id: str,
|
||||
env_overrides: dict[str, str] | None = None) -> str:
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
entry: dict[str, Any] | None = None) -> str:
|
||||
"""Install an npm package to an isolated prefix under tools_root."""
|
||||
dest = os.path.join(self.tools_root, tool_id)
|
||||
dest = self._dest_for(tool_id, entry)
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
env = self._env(tool_id, env_overrides)
|
||||
_validate_pkg(pkg)
|
||||
|
|
@ -562,6 +745,7 @@ class InstallerManager:
|
|||
tool_id: str,
|
||||
post_install: str | None = None,
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
entry: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Clone or update a git repository at ``tools_root/<tool_id>``.
|
||||
|
||||
|
|
@ -571,8 +755,14 @@ class InstallerManager:
|
|||
fails for any reason — diverged branches, network errors,
|
||||
corrupted index, etc.), move the old dir aside and re-clone
|
||||
fresh, so the install always ends in a usable state.
|
||||
|
||||
The destination follows the v3.4 install-root router when
|
||||
*entry* is supplied (web UIs → ``dashboards/<tool_id>/``).
|
||||
A pre-existing clone under ``tools/<tool_id>/`` is still
|
||||
detected and updated in place — the install never forks a
|
||||
second copy just because the routing changed.
|
||||
"""
|
||||
dest = os.path.join(self.tools_root, tool_id)
|
||||
dest = self._existing_dir_for(tool_id, entry)
|
||||
git_dir = os.path.join(dest, ".git")
|
||||
|
||||
# Step-down: each branch resolves one situation and assigns msg.
|
||||
|
|
@ -607,7 +797,7 @@ class InstallerManager:
|
|||
msg = f"Git source cloned: {dest}"
|
||||
|
||||
if post_install:
|
||||
self._run_post_install(tool_id, post_install)
|
||||
self._run_post_install(tool_id, post_install, dest)
|
||||
return msg
|
||||
|
||||
def install_git_node(
|
||||
|
|
@ -615,6 +805,7 @@ class InstallerManager:
|
|||
pkg: str,
|
||||
tool_id: str,
|
||||
post_install: str | None = None,
|
||||
entry: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Clone or update a git repo and run ``yarn install``.
|
||||
|
||||
|
|
@ -623,8 +814,11 @@ class InstallerManager:
|
|||
then ``yarn install`` to pick up any changed dependencies. If
|
||||
the destination exists but is not a git repo, or if the pull
|
||||
fails, move the old dir aside and re-clone fresh.
|
||||
|
||||
Destination routing matches :meth:`install_git` (existing
|
||||
clones are updated in place wherever they live).
|
||||
"""
|
||||
dest = os.path.join(self.tools_root, tool_id)
|
||||
dest = self._existing_dir_for(tool_id, entry)
|
||||
git_dir = os.path.join(dest, ".git")
|
||||
|
||||
# Step-down: each branch resolves one situation, runs the
|
||||
|
|
@ -671,7 +865,7 @@ class InstallerManager:
|
|||
msg = f"Git+Node source synchronized: {dest}"
|
||||
|
||||
if post_install:
|
||||
self._run_post_install(tool_id, post_install)
|
||||
self._run_post_install(tool_id, post_install, dest)
|
||||
return msg
|
||||
|
||||
# ── git install helpers ────────────────────────────────────────
|
||||
|
|
@ -767,6 +961,7 @@ class InstallerManager:
|
|||
post_install: str | None = None,
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
license_spdx: str | None = None,
|
||||
entry: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Dispatch to the correct installer strategy.
|
||||
|
||||
|
|
@ -774,6 +969,11 @@ class InstallerManager:
|
|||
|
||||
Parameters
|
||||
----------
|
||||
entry :
|
||||
The tool's full registry entry. When supplied, artifacts
|
||||
are routed to the install-root destination declared by
|
||||
``filesystem.install`` / the v3.4 router instead of the
|
||||
flat ``tools_root/<tool_id>``.
|
||||
license_spdx :
|
||||
SPDX ID for the tool's license. If provided AND a
|
||||
``license_gate`` was passed to the InstallerManager
|
||||
|
|
@ -806,21 +1006,29 @@ class InstallerManager:
|
|||
|
||||
strategies: dict[str, Any] = {
|
||||
"ollama": lambda: self.install_ollama(pkg, tool_id),
|
||||
"uv": lambda: self.install_uv(pkg, tool_id, env_overrides),
|
||||
"pipx": lambda: self.install_pipx(pkg, tool_id, env_overrides),
|
||||
"pip": lambda: self.install_pip(pkg, tool_id, env_overrides),
|
||||
"uv": lambda: self.install_uv(
|
||||
pkg, tool_id, env_overrides, entry,
|
||||
),
|
||||
"pipx": lambda: self.install_pipx(
|
||||
pkg, tool_id, env_overrides, entry,
|
||||
),
|
||||
"pip": lambda: self.install_pip(
|
||||
pkg, tool_id, env_overrides, entry,
|
||||
),
|
||||
"script": lambda: self.install_script(
|
||||
cmd, ctx, tool_id, env_overrides,
|
||||
),
|
||||
"pacman": lambda: self.install_pacman(pkg),
|
||||
"dnf": lambda: self.install_dnf(pkg),
|
||||
"apt": lambda: self.install_apt(pkg),
|
||||
"npm": lambda: self.install_npm(pkg, tool_id, env_overrides),
|
||||
"npm": lambda: self.install_npm(
|
||||
pkg, tool_id, env_overrides, entry,
|
||||
),
|
||||
"git": lambda: self.install_git(
|
||||
pkg, tool_id, post_install, env_overrides,
|
||||
pkg, tool_id, post_install, env_overrides, entry,
|
||||
),
|
||||
"git_node": lambda: self.install_git_node(
|
||||
pkg, tool_id, post_install,
|
||||
pkg, tool_id, post_install, entry,
|
||||
),
|
||||
"custom": lambda: self.install_custom(pkg, tool_id),
|
||||
}
|
||||
|
|
@ -852,6 +1060,7 @@ class InstallerManager:
|
|||
inst_type=meta.get("installer", {}).get("type", "pacman"),
|
||||
pkg=meta.get("installer", {}).get("pkg", ""),
|
||||
cmd=meta.get("installer", {}).get("cmd", ""),
|
||||
entry=meta,
|
||||
)
|
||||
for tid, meta in tools.items()
|
||||
}
|
||||
|
|
@ -867,15 +1076,28 @@ class InstallerManager:
|
|||
post_install: str | None = None,
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
license_spdx: str | None = None,
|
||||
entry: dict[str, Any] | None = None,
|
||||
update_if_found: bool = True,
|
||||
) -> str:
|
||||
"""Install a tool with preflight detection.
|
||||
|
||||
If the tool is already installed and *force* is False, returns
|
||||
a message saying the tool exists and suggesting an update.
|
||||
If the tool is already installed and *force* is False:
|
||||
|
||||
* ``update_if_found=True`` (default) — run the update path
|
||||
(:meth:`update_tool`), honouring the registry's
|
||||
``installer.update_cmd`` when declared. This is what makes
|
||||
the UI's single Deploy/Update button behave as a smooth
|
||||
in-place updater.
|
||||
* ``update_if_found=False`` — return the historical
|
||||
"already installed" notice without touching anything.
|
||||
|
||||
If *force* is True, proceeds with installation regardless.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entry :
|
||||
The tool's full registry entry (routes artifacts + enables
|
||||
update_cmd).
|
||||
license_spdx :
|
||||
SPDX ID for the tool's license. Forwarded to ``run()``
|
||||
for gate checking.
|
||||
|
|
@ -884,16 +1106,152 @@ class InstallerManager:
|
|||
# subprocess call on a blocked tool.
|
||||
self._check_license(tool_id, license_spdx)
|
||||
|
||||
check = self.preflight(tool_id, inst_type, pkg, cmd)
|
||||
check = self.preflight(tool_id, inst_type, pkg, cmd, entry)
|
||||
if check["found"] and not force:
|
||||
return (
|
||||
f"Tool '{tool_id}' already installed at {check['location']}. "
|
||||
f"Version: {check['version'] or 'unknown'}. "
|
||||
f"Use force=True to update."
|
||||
if not update_if_found:
|
||||
return (
|
||||
f"Tool '{tool_id}' already installed at "
|
||||
f"{check['location']}. "
|
||||
f"Version: {check['version'] or 'unknown'}. "
|
||||
f"Use force=True to update."
|
||||
)
|
||||
update_cmd = (entry or {}).get("installer", {}).get(
|
||||
"update_cmd", "",
|
||||
) if entry else ""
|
||||
return self.update_tool(
|
||||
tool_id=tool_id,
|
||||
inst_type=inst_type,
|
||||
pkg=pkg,
|
||||
cmd=cmd,
|
||||
update_cmd=update_cmd or "",
|
||||
entry=entry,
|
||||
env_overrides=env_overrides,
|
||||
post_install=post_install,
|
||||
detected=check,
|
||||
)
|
||||
return self.run(
|
||||
inst_type, pkg, cmd, ctx, tool_id,
|
||||
post_install, env_overrides,
|
||||
post_install, env_overrides, license_spdx, entry,
|
||||
)
|
||||
|
||||
# ── Updates ─────────────────────────────────────────────────────
|
||||
|
||||
def update_tool(
|
||||
self,
|
||||
tool_id: str,
|
||||
inst_type: str,
|
||||
pkg: str,
|
||||
cmd: str = "",
|
||||
update_cmd: str = "",
|
||||
entry: dict[str, Any] | None = None,
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
post_install: str | None = None,
|
||||
detected: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Update an already-installed tool in place.
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. **Registry ``update_cmd``** — rendered with the path-tree
|
||||
placeholders plus ``{install_dir}`` and executed in the
|
||||
tool's install directory (e.g. LibreChat's
|
||||
``git pull --ff-only && yarn install && yarn build``).
|
||||
2. **Per-type defaults** — ``uv tool upgrade``, ``pipx
|
||||
upgrade``, per-tool venv ``pip install -U``, ``git pull
|
||||
--ff-only`` (+ node rebuild for ``git_node``), ``npm
|
||||
update``.
|
||||
3. **System packages** (pacman / dnf / apt) and ``custom``
|
||||
installs return guidance instead of shelling out.
|
||||
|
||||
The working directory is the *detected* install location when
|
||||
known, else the router destination — so updates land where the
|
||||
tool actually lives, never forking a second copy.
|
||||
"""
|
||||
_validate_tool_id(tool_id)
|
||||
dest = (
|
||||
detected.get("location")
|
||||
if detected and detected.get("location")
|
||||
else self._existing_dir_for(tool_id, entry)
|
||||
)
|
||||
env = self._env(tool_id, env_overrides)
|
||||
|
||||
# 1. Registry-declared update command wins.
|
||||
if update_cmd:
|
||||
rendered = self._render_cmd(update_cmd, dest)
|
||||
logger.info("Updating %s via update_cmd: %s", tool_id, rendered)
|
||||
subprocess.run(
|
||||
["bash", "-c", rendered], check=True, env=env,
|
||||
cwd=dest if os.path.isdir(dest) else None,
|
||||
timeout=1800,
|
||||
)
|
||||
return f"Update completed for {tool_id} (update_cmd) in {dest}."
|
||||
|
||||
# 2. Per-type defaults.
|
||||
pkg_first = shlex.split(pkg)[0] if pkg else pkg
|
||||
if inst_type == "uv":
|
||||
try:
|
||||
subprocess.run(
|
||||
["uv", "tool", "upgrade", pkg_first],
|
||||
check=True, env=env, timeout=600,
|
||||
)
|
||||
return f"UV tool '{pkg_first}' upgraded."
|
||||
except subprocess.CalledProcessError:
|
||||
# upgrade failed (e.g. broken env) — reinstall keeps
|
||||
# --with extras alive via install_uv's step-down.
|
||||
logger.info(
|
||||
"uv upgrade failed for %s; reinstalling", pkg_first,
|
||||
)
|
||||
return self.install_uv(
|
||||
pkg, tool_id, env_overrides, entry,
|
||||
)
|
||||
if inst_type == "pipx":
|
||||
try:
|
||||
subprocess.run(
|
||||
["pipx", "upgrade", pkg_first],
|
||||
check=True, env=env, timeout=600,
|
||||
)
|
||||
return f"pipx '{pkg_first}' upgraded."
|
||||
except subprocess.CalledProcessError:
|
||||
logger.info(
|
||||
"pipx upgrade failed for %s; reinstalling", pkg_first,
|
||||
)
|
||||
return self.install_pipx(
|
||||
pkg, tool_id, env_overrides, entry,
|
||||
)
|
||||
if inst_type == "pip":
|
||||
venv_pip = os.path.join(dest, ".venv", "bin", "pip")
|
||||
if os.path.isfile(venv_pip):
|
||||
_validate_pkg(pkg_first)
|
||||
subprocess.run(
|
||||
[venv_pip, "install", "--upgrade", pkg_first],
|
||||
check=True, env=env, timeout=600,
|
||||
)
|
||||
return f"pip '{pkg_first}' upgraded in {dest}/.venv."
|
||||
return (
|
||||
f"No managed venv for {tool_id}; re-run with force to "
|
||||
f"reinstall."
|
||||
)
|
||||
if inst_type in ("git", "git_node"):
|
||||
# install_git / install_git_node already implement
|
||||
# pull-ff-only-with-fallback + node rebuild semantics.
|
||||
handler = (self.install_git_node if inst_type == "git_node"
|
||||
else self.install_git)
|
||||
msg = handler(
|
||||
pkg, tool_id, post_install, env_overrides, entry,
|
||||
)
|
||||
return f"Update: {msg}"
|
||||
if inst_type == "npm":
|
||||
_validate_pkg(pkg_first)
|
||||
subprocess.run(
|
||||
["npm", "update", "--prefix", dest, pkg_first],
|
||||
check=True, env=env, timeout=600,
|
||||
)
|
||||
return f"NPM '{pkg_first}' updated in {dest}."
|
||||
|
||||
# 3. Types we deliberately don't auto-update.
|
||||
return (
|
||||
f"'{tool_id}' is a {inst_type}-managed install — update it "
|
||||
f"with the system package manager or its upstream installer."
|
||||
)
|
||||
|
||||
# ── Installation verification ───────────────────────────────────
|
||||
|
|
@ -905,6 +1263,7 @@ class InstallerManager:
|
|||
pkg: str,
|
||||
cmd: str = "",
|
||||
filesystem: dict[str, str] | None = None,
|
||||
entry: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a compliance checklist against a single tool installation.
|
||||
|
||||
|
|
@ -927,9 +1286,10 @@ class InstallerManager:
|
|||
fs = filesystem or {}
|
||||
tool_dir = os.path.join(self.tools_root, tool_id)
|
||||
|
||||
# 1. Native install detected
|
||||
# 1. Native install detected (multi-root: router + dashboards
|
||||
# + tools, so pre-v3.4 installs still verify)
|
||||
location, version = self._detect_installation(
|
||||
tool_id, inst_type, pkg, cmd,
|
||||
tool_id, inst_type, pkg, cmd, entry,
|
||||
)
|
||||
checks.append(VerifyCheck(
|
||||
name="Native Install",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -219,6 +219,7 @@ if _HAS_QT:
|
|||
self._setup_environment_hierarchy()
|
||||
self._migrate_legacy_state_files()
|
||||
|
||||
self._migrate_registry_dir()
|
||||
self.dtach_bin: str | None = find_binary("dtach-ng", "dtach")
|
||||
# License gate — checks every tool's license before the
|
||||
# installer dispatches to a subprocess. SaaS-blocked
|
||||
|
|
@ -245,7 +246,7 @@ if _HAS_QT:
|
|||
self.txt_base_dir.setReadOnly(True)
|
||||
|
||||
self.registry_mgr = RegistryManager(
|
||||
os.path.join(self.base_dir, "registry")
|
||||
os.path.join(self.base_dir, "configs", "registry")
|
||||
)
|
||||
self.skill_resolver = (
|
||||
SkillRuntimeResolver(self.skills_root)
|
||||
|
|
@ -360,6 +361,40 @@ if _HAS_QT:
|
|||
filter(None, [uv_bin, npm_bin])
|
||||
)
|
||||
|
||||
def _migrate_registry_dir(self) -> None:
|
||||
"""One-time move of the app registry into configs/registry.
|
||||
|
||||
v3.4 aligned the base dir with the canonical file-system
|
||||
map: the app-internal ecosystem registry (ecosystem.json +
|
||||
user tweaks) is app state and belongs under ``configs/``.
|
||||
Pre-v3.4 installs kept it at ``<base_dir>/registry/`` —
|
||||
move it when the legacy dir exists and the new one does
|
||||
not; remove the legacy dir when it is left empty. Never
|
||||
raises.
|
||||
"""
|
||||
import shutil
|
||||
from ai_lsc.utils.paths import legacy_registry_dir
|
||||
|
||||
old = legacy_registry_dir(self.base_dir)
|
||||
new = os.path.join(self.base_dir, "configs", "registry")
|
||||
try:
|
||||
if os.path.isdir(old):
|
||||
os.makedirs(new, exist_ok=True)
|
||||
for fname in os.listdir(old):
|
||||
src = os.path.join(old, fname)
|
||||
dst = os.path.join(new, fname)
|
||||
if not os.path.exists(dst):
|
||||
shutil.move(src, dst)
|
||||
if not os.listdir(old):
|
||||
os.rmdir(old)
|
||||
from ai_lsc.utils.logging import get_logger
|
||||
get_logger(__name__).info(
|
||||
"Migrated tool registry to %s (legacy %s "
|
||||
"removed)", new, old,
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# LEFT NAVIGATION RACK LAYOUT
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
|
@ -964,7 +999,7 @@ if _HAS_QT:
|
|||
layout.setContentsMargins(30, 20, 30, 20)
|
||||
|
||||
header = QHBoxLayout()
|
||||
lbl_title = QLabel("<b>AI-LSC v3.1 — Ankh of Jah</b>")
|
||||
lbl_title = QLabel("<b>AI-LSC v3.3.0 — Ankh of Jah</b>")
|
||||
lbl_title.setFont(QFont("Segoe UI", 16))
|
||||
header.addWidget(lbl_title)
|
||||
header.addStretch()
|
||||
|
|
@ -1368,11 +1403,14 @@ if _HAS_QT:
|
|||
if handler:
|
||||
handler()
|
||||
|
||||
# Drift detection
|
||||
# Drift detection (multi-root: checks the router dir,
|
||||
# dashboards/, and tools/ so pre-v3.4 installs are not
|
||||
# false-flagged)
|
||||
state_path = self._get_active_state_file()
|
||||
drift: list[str] = []
|
||||
if state_path:
|
||||
try:
|
||||
from ai_lsc.utils.paths import candidate_install_dirs
|
||||
with open(state_path) as f:
|
||||
state = json.load(f)
|
||||
git_types = {"git", "git_node"}
|
||||
|
|
@ -1383,8 +1421,12 @@ if _HAS_QT:
|
|||
and self.registry_mgr.get_tool(tid)
|
||||
.get("installer", {})
|
||||
.get("type") in git_types
|
||||
and not os.path.exists(
|
||||
os.path.join(self.tools_root, tid)
|
||||
and not any(
|
||||
d.is_dir() for d in candidate_install_dirs(
|
||||
tid,
|
||||
self.registry_mgr.get_tool(tid),
|
||||
self.base_dir,
|
||||
)
|
||||
)
|
||||
]
|
||||
except Exception:
|
||||
|
|
@ -1449,6 +1491,11 @@ if _HAS_QT:
|
|||
label = {
|
||||
"lxc": "LXC configs + launch script",
|
||||
"firecracker": "Firecracker microVM configs + launch script",
|
||||
"qcow2": "QCOW2 VM disk image",
|
||||
"qcow": "QCOW (legacy) VM disk image",
|
||||
"raw": "Raw VM disk image",
|
||||
"qcrows": "QCrows VM container image (.qcrows)",
|
||||
"svb": "Sovereign Bundle (.svb)",
|
||||
}.get(backend_type, f"{backend_type.capitalize()} Compose")
|
||||
self.log(
|
||||
f"{label} generated: {out_file}",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""ContainerStacksTab widget — lists exported stack files.
|
||||
|
||||
Displays available stack snapshots and provides export buttons for
|
||||
Podman Compose and Docker Compose outputs.
|
||||
Podman Compose, Docker Compose, LXC, Firecracker, and QCOW2/QCOW VM
|
||||
disk-image outputs.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
|
@ -60,6 +61,32 @@ if _HAS_QT:
|
|||
lambda: self.main.finalize_stack_export("firecracker")
|
||||
)
|
||||
header.addWidget(btn_firecracker)
|
||||
|
||||
btn_qcow2 = QPushButton("Export -> QCOW2 Image")
|
||||
btn_qcow2.setStyleSheet("background-color: #d35400;")
|
||||
btn_qcow2.clicked.connect(
|
||||
lambda: self.main.finalize_stack_export("qcow2")
|
||||
)
|
||||
header.addWidget(btn_qcow2)
|
||||
|
||||
btn_qcrows = QPushButton("Export -> QCrows Image (.qcrows)")
|
||||
btn_qcrows.setStyleSheet("background-color: #7f8c8d;")
|
||||
btn_qcrows.clicked.connect(
|
||||
lambda: self.main.finalize_stack_export("qcrows")
|
||||
)
|
||||
header.addWidget(btn_qcrows)
|
||||
|
||||
btn_svb = QPushButton("Export -> Sovereign .svb")
|
||||
btn_svb.clicked.connect(
|
||||
lambda: self.main.finalize_stack_export("svb")
|
||||
)
|
||||
header.addWidget(btn_svb)
|
||||
|
||||
btn_qcow = QPushButton("Export -> QCOW (legacy)")
|
||||
btn_qcow.clicked.connect(
|
||||
lambda: self.main.finalize_stack_export("qcow")
|
||||
)
|
||||
header.addWidget(btn_qcow)
|
||||
layout.addLayout(header)
|
||||
|
||||
self.file_list = QListWidget()
|
||||
|
|
@ -75,12 +102,27 @@ if _HAS_QT:
|
|||
# the LXC and Firecracker backends.
|
||||
if fname.endswith((".yml", ".yaml", ".json", ".sh")):
|
||||
self.file_list.addItem(fname)
|
||||
# Also surface subdirectories that hold per-container /
|
||||
# per-VM configs (lxc/, firecracker/).
|
||||
# Image-format dirs (qcow2/, and any future format like the
|
||||
# in-house qcrows/) list their images + sidecar manifests
|
||||
# with sizes; config dirs (lxc/, firecracker/) get a plain
|
||||
# directory entry.
|
||||
for fname in sorted(os.listdir(self.main.exports_root)):
|
||||
if fname.startswith("."):
|
||||
# transient .staging-* dirs from in-flight exports
|
||||
continue
|
||||
fpath = os.path.join(self.main.exports_root, fname)
|
||||
if os.path.isdir(fpath):
|
||||
if not os.path.isdir(fpath):
|
||||
continue
|
||||
if fname in ("lxc", "firecracker"):
|
||||
self.file_list.addItem(f"{fname}/ (directory)")
|
||||
continue
|
||||
for sub in sorted(os.listdir(fpath)):
|
||||
sub_path = os.path.join(fpath, sub)
|
||||
if os.path.isfile(sub_path) and not sub.startswith("."):
|
||||
size_mb = os.path.getsize(sub_path) // (1024 * 1024)
|
||||
self.file_list.addItem(
|
||||
f"{fname}/{sub} ({size_mb} MiB)"
|
||||
)
|
||||
|
||||
else:
|
||||
ContainerStacksTab = None
|
||||
|
|
|
|||
|
|
@ -612,13 +612,17 @@ if _HAS_QT:
|
|||
mgr = InstallerManager(
|
||||
tools_root=self.main.tools_root,
|
||||
base_bin_dir=self.main.base_bin_dir,
|
||||
base_dir=self.main.base_dir,
|
||||
)
|
||||
try:
|
||||
if inst_type == "git_node":
|
||||
msg = mgr.install_git_node(pkg, tool_id, post_install)
|
||||
msg = mgr.install_git_node(
|
||||
pkg, tool_id, post_install, entry=meta,
|
||||
)
|
||||
else:
|
||||
msg = mgr.install_git(
|
||||
pkg, tool_id, post_install, env_overrides or None,
|
||||
entry=meta,
|
||||
)
|
||||
self.main.log(msg, "GitRepo")
|
||||
self.refresh()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ Renders one tool (or skill:-prefixed behavior binding) inside the
|
|||
Tools/Services page. Each row shows the service name, live status,
|
||||
CPU load, port input, model selector (for engine/LLM-runtime services),
|
||||
Ollama pull controls, launcher buttons (CLI/GUI/Web), and
|
||||
Install/Sync + Start/Stop action buttons.
|
||||
Install/Update + Start/Stop action buttons. The install button runs
|
||||
preflight first: missing tools install to their map-aligned root
|
||||
(dashboards/ for web UIs, tools/ otherwise); already-installed tools
|
||||
update in place (registry ``update_cmd`` when declared, per-type
|
||||
default otherwise).
|
||||
|
||||
All process management is delegated to
|
||||
:class:`~ai_lsc.runtime.executor.RuntimeExecutor` -- this widget
|
||||
|
|
@ -222,7 +226,7 @@ if _HAS_QT:
|
|||
btn_api.clicked.connect(self._open_api_dialog)
|
||||
layout.addWidget(btn_api)
|
||||
|
||||
self.btn_update = QPushButton("Install / Sync")
|
||||
self.btn_update = QPushButton("Install / Update")
|
||||
self.btn_update.setStyleSheet("background-color: #8e44ad;")
|
||||
self.btn_update.clicked.connect(self.smart_install)
|
||||
self._install_btn_original_text = self.btn_update.text()
|
||||
|
|
@ -331,6 +335,7 @@ if _HAS_QT:
|
|||
tool_id=self.tool_id,
|
||||
ctx=ctx,
|
||||
license_spdx=license_spdx,
|
||||
registry_entry=self.meta,
|
||||
)
|
||||
QTimer.singleShot(
|
||||
0, lambda d=desc, t=self.tool_id: self._on_install_done(d, t)
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ class VerificationWorker(QThread if _HAS_QT else object): # type: ignore[misc]
|
|||
pkg=installer.get("pkg", ""),
|
||||
cmd=installer.get("cmd", ""),
|
||||
filesystem=fs,
|
||||
entry=meta,
|
||||
)
|
||||
self.tool_done.emit(tool_id, result)
|
||||
completed += 1
|
||||
|
|
|
|||
|
|
@ -4,14 +4,26 @@ AI-LSC — Centralised path definitions.
|
|||
Every path in the application is derived from ``BASE_DIR``
|
||||
using :mod:`pathlib`. Import these in UI and orchestration code
|
||||
instead of constructing paths with ``os.path.join`` ad-hoc.
|
||||
|
||||
This module also owns the **install-root router** — the mapping that
|
||||
decides where a tool's artifacts live under the canonical /mnt/AI
|
||||
layout (``tools/`` for standalone compiles, ``dashboards/`` for web
|
||||
UIs, ``runtime/`` for native binaries) — plus the multi-root
|
||||
candidate list used for detection of pre-existing installs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ai_lsc.constants import BASE_DIR, REQUIRED_DIRS
|
||||
|
||||
# Only path-tree keys are auto-resolvable as launcher placeholders.
|
||||
# `{port}` / `{model_arg}` are runtime values resolved separately.
|
||||
_TOOL_ID_RE = re.compile(r"^[A-Za-z0-9_.:\-]+$")
|
||||
|
||||
|
||||
def build_path_tree(base_dir: str | Path | None = None) -> dict[str, Path]:
|
||||
"""Return a dict of well-known absolute paths under *base_dir*.
|
||||
|
|
@ -40,20 +52,22 @@ def build_path_tree(base_dir: str | Path | None = None) -> dict[str, Path]:
|
|||
"corpus_root": Path("/mnt/AI/corpus"), # parent of hot/ and cold/
|
||||
"datasets_root": Path("/mnt/AI/datasets"), # parent of wordlists/, huggingface/, github/
|
||||
"pipelines_root": Path("/mnt/AI/pipelines"), # ETL / chunking / routing scripts
|
||||
"configs_root": Path("/mnt/AI/configs"), # app state + templated configs
|
||||
"registry_root": Path("/mnt/AI/registry"), # app-internal: ecosystem.json + manifests/
|
||||
"configs_root": Path("/mnt/AI/configs"), # app state + templated configs
|
||||
"registry_root": Path("/mnt/AI/configs/registry"), # app-internal: ecosystem.json
|
||||
"agents_root": Path("/mnt/AI/agents"), # configs and chains for autonomous actors
|
||||
"skills_root": Path("/mnt/AI/skills"), # 3rd-party integrations and tool wrappers
|
||||
"projects_root": Path("/mnt/AI/projects"), # parent of active/, labs/, vault/
|
||||
"blueprints_root": Path("/mnt/AI/blueprints"), # Dockerfiles / build contexts for Podman exports
|
||||
"workspaces_root": Path("/mnt/AI/workspaces"), # Jupyter, OpenNotebook, etc.
|
||||
"dashboards_root": Path("/mnt/AI/dashboards"), # web UIs (Dashy, Open-WebUI, Hermes WebUI, etc.)
|
||||
"exports_root": Path("/mnt/AI/exports"), # parent of oci-images/
|
||||
"dashboards_root": Path("/mnt/AI/dashboards"), # web UIs (Dashy, Homepage, Open-WebUI, etc.)
|
||||
"exports_root": Path("/mnt/AI/exports"), # parent of oci-images/, qcow2/, qcrows/
|
||||
"exports_qcow2": Path("/mnt/AI/exports/qcow2"), # VM disk images (qcow2 / legacy qcow)
|
||||
"exports_qcrows": Path("/mnt/AI/exports/qcrows"),# QCrows VM container images (.qcrows)
|
||||
"scripts_root": Path("/mnt/AI/scripts"), # system admin / maintenance automation
|
||||
"logs_root": Path("/mnt/AI/logs"),
|
||||
"backends_root": Path("/mnt/AI/backends"), # S3/MinIO/Ceph connection profiles
|
||||
"distfiles_root": Path("/mnt/AI/distfiles"), # permanent local mirror of source tarballs
|
||||
"config_root": Path("/mnt/AI/configs"), # app state + templated configs
|
||||
"config_root": Path("/mnt/AI/configs"), # alias of configs_root
|
||||
}
|
||||
"""
|
||||
root = Path(base_dir) if base_dir is not None else Path(BASE_DIR)
|
||||
|
|
@ -67,7 +81,11 @@ def build_path_tree(base_dir: str | Path | None = None) -> dict[str, Path]:
|
|||
"corpus_root": root / "corpus",
|
||||
"datasets_root": root / "datasets",
|
||||
"pipelines_root": root / "pipelines",
|
||||
"registry_root": root / "registry",
|
||||
# App-internal registry state (ecosystem.json + user tweaks)
|
||||
# lives under configs/ per the v3.4 file-system map. Pre-v3.4
|
||||
# installs used <base>/registry — main_window migrates that
|
||||
# directory on startup.
|
||||
"registry_root": root / "configs" / "registry",
|
||||
"agents_root": root / "agents",
|
||||
"skills_root": root / "skills",
|
||||
"projects_root": root / "projects",
|
||||
|
|
@ -75,6 +93,8 @@ def build_path_tree(base_dir: str | Path | None = None) -> dict[str, Path]:
|
|||
"workspaces_root": root / "workspaces",
|
||||
"dashboards_root": root / "dashboards",
|
||||
"exports_root": root / "exports",
|
||||
"exports_qcow2": root / "exports" / "qcow2",
|
||||
"exports_qcrows": root / "exports" / "qcrows",
|
||||
"scripts_root": root / "scripts",
|
||||
"logs_root": root / "logs",
|
||||
"backends_root": root / "backends",
|
||||
|
|
@ -89,6 +109,121 @@ def build_path_tree(base_dir: str | Path | None = None) -> dict[str, Path]:
|
|||
}
|
||||
|
||||
|
||||
# ── Install-root router ─────────────────────────────────────────────
|
||||
#
|
||||
# The v3.4 file-system map gives every top-level directory a purpose:
|
||||
# tools/ — standalone compiles (built from distfiles)
|
||||
# runtime/ — native compiled binaries (ollama, llama.cpp, MinIO…)
|
||||
# dashboards/ — web UIs and landing pages (Open-WebUI, LibreChat…)
|
||||
#
|
||||
# A tool's declared ``filesystem.install`` (a path relative to the base
|
||||
# dir) is the single source of truth. When absent, the router picks a
|
||||
# root by tool kind — but only when the launcher does not already pin
|
||||
# the tool to ``{tools_root}`` via a ``cd {tools_root}/<id>`` command
|
||||
# (a pinned launcher would break if the artifacts moved).
|
||||
INSTALL_ROOT_DEFAULT = "tools"
|
||||
INSTALL_ROOT_ROUTES: dict[str, str] = {
|
||||
"web": "dashboards", # flags.has_web → web UI apps
|
||||
}
|
||||
|
||||
|
||||
def install_dir_for(
|
||||
entry: dict[str, Any] | None,
|
||||
tool_id: str = "",
|
||||
base_dir: str | Path | None = None,
|
||||
) -> Path:
|
||||
"""Return the canonical install directory for a registry *entry*.
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. ``filesystem.install`` declared on the entry (relative to the
|
||||
base dir) — honoured verbatim.
|
||||
2. ``flags.has_web`` is set AND the launcher does not reference
|
||||
``{tools_root}`` → ``<base>/dashboards/<tool_id>/``.
|
||||
3. Otherwise → ``<base>/tools/<tool_id>/`` (the historical
|
||||
default, still correct for standalone compiles).
|
||||
|
||||
Falls back to ``tools/<tool_id>`` for empty / malformed entries.
|
||||
"""
|
||||
root = Path(base_dir) if base_dir is not None else Path(BASE_DIR)
|
||||
entry = entry or {}
|
||||
|
||||
rel = (entry.get("filesystem") or {}).get("install", "")
|
||||
if rel:
|
||||
# Normalise without allowing traversal outside the base dir.
|
||||
parts = [p for p in Path(rel).parts if p not in (".", "..")]
|
||||
clean = Path(*parts) if parts else Path("")
|
||||
if str(clean) not in ("", "."):
|
||||
return root / clean
|
||||
|
||||
if tool_id and not _TOOL_ID_RE.fullmatch(tool_id):
|
||||
tool_id = ""
|
||||
|
||||
launcher_cmd = (entry.get("launcher") or {}).get("cmd", "") or ""
|
||||
pins_tools_root = "{tools_root}" in launcher_cmd
|
||||
flags = entry.get("flags") or {}
|
||||
if (tool_id and flags.get("has_web")
|
||||
and INSTALL_ROOT_ROUTES.get("web")
|
||||
and not pins_tools_root):
|
||||
return root / INSTALL_ROOT_ROUTES["web"] / tool_id
|
||||
return root / INSTALL_ROOT_DEFAULT / (tool_id or "")
|
||||
|
||||
|
||||
def candidate_install_dirs(
|
||||
tool_id: str,
|
||||
entry: dict[str, Any] | None = None,
|
||||
base_dir: str | Path | None = None,
|
||||
) -> list[Path]:
|
||||
"""Return every directory an install of *tool_id* might live in.
|
||||
|
||||
Ordered best-candidate-first: the router's canonical dir, then the
|
||||
other plausible roots (dashboards/, tools/), de-duplicated. Used
|
||||
by installer preflight / verification / drift detection so tools
|
||||
installed before the v3.4 routing change are still detected.
|
||||
"""
|
||||
if not tool_id or not _TOOL_ID_RE.fullmatch(tool_id):
|
||||
return []
|
||||
root = Path(base_dir) if base_dir is not None else Path(BASE_DIR)
|
||||
entry = entry or {}
|
||||
|
||||
cands: list[Path] = [install_dir_for(entry, tool_id, root)]
|
||||
for dirname in (INSTALL_ROOT_ROUTES.get("web", "dashboards"),
|
||||
INSTALL_ROOT_DEFAULT):
|
||||
cands.append(root / dirname / tool_id)
|
||||
# Pre-v3.4 git tools could land in a dir named after the repo
|
||||
# (not the tool_id) — cover the common LibreChat-style casing too.
|
||||
repo_hint = (entry.get("installer", {}) or {}).get("pkg", "")
|
||||
if isinstance(repo_hint, str) and repo_hint.rstrip("/").endswith(".git"):
|
||||
repo_name = repo_hint.rstrip("/").rsplit("/", 1)[-1][:-4]
|
||||
if repo_name and _TOOL_ID_RE.fullmatch(repo_name):
|
||||
cands.append(root / INSTALL_ROOT_DEFAULT / repo_name)
|
||||
|
||||
seen: set[Path] = set()
|
||||
ordered: list[Path] = []
|
||||
for c in cands:
|
||||
if c not in seen:
|
||||
seen.add(c)
|
||||
ordered.append(c)
|
||||
return ordered
|
||||
|
||||
|
||||
def legacy_registry_dir(base_dir: str | Path | None = None) -> Path:
|
||||
"""Pre-v3.4 location of the app-internal registry dir."""
|
||||
root = Path(base_dir) if base_dir is not None else Path(BASE_DIR)
|
||||
return root / "registry"
|
||||
|
||||
|
||||
def ensure_required_dirs(base_dir: str | Path | None = None) -> list[Path]:
|
||||
"""Create every REQUIRED_DIRS entry under *base_dir*; return the list."""
|
||||
root = Path(base_dir) if base_dir is not None else Path(BASE_DIR)
|
||||
created: list[Path] = []
|
||||
for rel in REQUIRED_DIRS:
|
||||
p = root / rel
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
created.append(p)
|
||||
return created
|
||||
|
||||
|
||||
def resolve_launcher_cmd(
|
||||
cmd_template: str,
|
||||
base_dir: str | Path | None = None,
|
||||
|
|
@ -97,17 +232,30 @@ def resolve_launcher_cmd(
|
|||
) -> str:
|
||||
"""Resolve ``{placeholder}`` tokens in a launcher command template.
|
||||
|
||||
Recognised placeholders (case-sensitive):
|
||||
``{port}``, ``{base_dir}``, ``{tools_root}``,
|
||||
``{models_root}``, ``{workspaces_root}``, ``{model_arg}``
|
||||
Every key of :func:`build_path_tree` is recognised as a
|
||||
placeholder (``{base_dir}``, ``{tools_root}``, ``{dashboards_root}``,
|
||||
``{runtime_root}``, ``{configs_root}``, …), plus the runtime values
|
||||
``{port}`` and ``{model_arg}``. Unknown placeholders are left
|
||||
untouched so callers can chain their own substitution.
|
||||
"""
|
||||
paths = build_path_tree(base_dir)
|
||||
resolved = cmd_template
|
||||
for key, value in paths.items():
|
||||
resolved = resolved.replace("{" + key + "}", str(value))
|
||||
return (
|
||||
cmd_template
|
||||
resolved
|
||||
.replace("{port}", str(port or ""))
|
||||
.replace("{base_dir}", str(paths["base_dir"]))
|
||||
.replace("{tools_root}", str(paths["tools_root"]))
|
||||
.replace("{models_root}", str(paths["models_root"]))
|
||||
.replace("{workspaces_root}", str(paths["workspaces_root"]))
|
||||
.replace("{model_arg}", model_arg)
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_path_tree",
|
||||
"install_dir_for",
|
||||
"candidate_install_dirs",
|
||||
"legacy_registry_dir",
|
||||
"ensure_required_dirs",
|
||||
"resolve_launcher_cmd",
|
||||
"INSTALL_ROOT_DEFAULT",
|
||||
"INSTALL_ROOT_ROUTES",
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in New Issue