mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Fixes 15 defects found by a max-effort multi-agent review of the last 6 merged PRs (#388, #390, #391, #392, #393, #394), all adversarially verified before fixing. Install queue + dispatcher (the serious cluster): - features.ts: finalize the installer child exactly once. A failed spawn fires both "error" and "close", and the second event released the file lock and active slot that pump() had just handed to the next queued bundle, letting two pip processes write the same venv concurrently. Outcome recording now happens before pump() so the next bundle's first progress frame cannot race the previous install's bookkeeping. - feature-status.ts: keep failed-install errors in a per-bundle map instead of the single progress slot. With the queue auto-starting the next install, the slot was overwritten within seconds and a failed install vanished without ever surfacing to GET /features. - bridge.ts: scope child lifecycle per process (stopped-children set + request generation tags) instead of an instance-wide shuttingDown flag that the next spawn reset. A stale SIGTERMed child's late close event could record a phantom crash (5 of which permanently disable the dispatcher), null out the freshly spawned child, and reject the new child's pending requests. The request-timeout kill path still counts as a real crash. - install_feature.py: the pre-write disk re-check measured ai_dir's filesystem even when budgeting the cross-filesystem copy that lands on the venv's disk; now each budget is checked against the filesystem the bytes actually land on, so ENOSPC cannot strike mid-write and leave site-packages half overwritten. Behavior regressions: - embed-subtitles: preserve pre-existing subtitle tracks (0:s?) and MKV attachments (0:t?) that the -map 0:v:0/0:a? rewrite silently dropped; data streams stay unmapped on purpose (the actual MPEG remux fix). The new subtitle maps first so the language tag hits the right stream. - usage-survey-overlay: fail closed when the settings fetch fails; the fail-open path rendered the blocking survey against an unhealthy API and soft-locked admins, the lock-out class #392 fixed. - features-store: queued bundles poll instead of each holding an SSE connection (Install All could pin 7 EventSources and exhaust the browser's 6-per-origin HTTP/1.1 limit, hanging the whole app); listenToProgress closes any prior stream and stops any poll before subscribing; installAll skips bundles already installing or queued. Contracts, tests, i18n: - openapi.yaml: add "queued" to the features status enum and document downloadBytes/installedBytes (Schemathesis conformance). - feature-lifecycle e2e: queue transcription (~0.5 GB) instead of ocr (~6 GB) and give the test a budget that covers both install drains (the stacked waits exceeded the old 900s timeout). - docker-compose.qa.yml: parameterize the host port (QA_APP_PORT) so QA_PROJECT_NAME concurrent stacks can actually bind. - compare + watermark-image: restore per-input error attribution ("Invalid first/second image", "Invalid watermark image") lost in the shared-handler migration. - ai-features-section: the "{size} on disk" suffix now goes through i18n; key added to all 21 locales. - watermark-image + content-aware-resize: migrate to the shared inputHandlerFor("image") chain like compare/vectorize/compose, fixing drift in the inline copies (no SVG sanitize, no RAW extension hint, no AVIF probe). Verified: typecheck across 9 workspaces, Biome clean on all changed files, 584 targeted unit tests and 249 integration tests green (including real-ffmpeg embed-subtitles runs). One unit test updated to the new poll-while-queued contract with a single-EventSource assertion. Claude-Session: https://claude.ai/code/session_017mR1HiHaf3a1BmUtrHX4j3
571 lines
20 KiB
Python
571 lines
20 KiB
Python
"""Pre-built AI bundle installer for SnapOtter.
|
|
|
|
Downloads a pre-built tar.gz archive (or uses a local file), verifies its
|
|
SHA256 checksum, extracts site-packages and models, and writes installed.json.
|
|
|
|
Invoked by the Node.js backend as a subprocess.
|
|
|
|
Usage:
|
|
python3 install_feature.py <bundleId> <manifestPath> <modelsDir>
|
|
|
|
Progress is reported via JSON lines on stderr (parsed by the Node bridge).
|
|
Final result is a JSON object on stdout.
|
|
"""
|
|
|
|
import errno
|
|
import glob
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
# -- Helpers --
|
|
|
|
def emit_progress(percent: int, stage: str) -> None:
|
|
"""Emit a progress update via stderr JSON line."""
|
|
sys.stderr.write(json.dumps({"progress": percent, "stage": stage}) + "\n")
|
|
sys.stderr.flush()
|
|
|
|
|
|
def fail(message: str) -> None:
|
|
"""Print error to stderr and exit non-zero."""
|
|
sys.stderr.write(json.dumps({"error": message}) + "\n")
|
|
sys.stderr.flush()
|
|
sys.exit(1)
|
|
|
|
|
|
# -- Architecture detection --
|
|
|
|
def detect_arch() -> str:
|
|
"""Return the bundle archive key for this host.
|
|
|
|
Only two archive variants are currently published to the bundle repo:
|
|
'amd64-gpu' and 'arm64-cpu' (see deepsafe/feature-bundles). There is no
|
|
CPU-only amd64 variant yet, so amd64 hosts always resolve to 'amd64-gpu'
|
|
even when no GPU is present: this downloads working CUDA-capable
|
|
packages, just larger than a CPU-only host strictly needs. Do not change
|
|
this to branch on GPU presence without first publishing an 'amd64-cpu'
|
|
archive for every bundle; requesting a key that doesn't exist in the
|
|
manifest fails the install outright (see the archives.get(arch) lookup
|
|
below), which would be worse than the current oversized-but-working
|
|
download.
|
|
"""
|
|
machine = platform.machine().lower()
|
|
if machine in ("aarch64", "arm64"):
|
|
return "arm64-cpu"
|
|
return "amd64-gpu"
|
|
|
|
|
|
# -- Disk space --
|
|
|
|
def _existing_ancestor(path: str) -> str:
|
|
"""Nearest existing ancestor of path (so disk_usage never raises on a
|
|
not-yet-created dir like the venv)."""
|
|
p = os.path.abspath(path)
|
|
while p and not os.path.exists(p):
|
|
parent = os.path.dirname(p)
|
|
if parent == p:
|
|
break
|
|
p = parent
|
|
return p or "/"
|
|
|
|
|
|
def check_disk_space(path: str, needed_bytes: int) -> None:
|
|
"""Fail if insufficient disk space on the filesystem holding path."""
|
|
usage = shutil.disk_usage(_existing_ancestor(path))
|
|
if usage.free < needed_bytes:
|
|
free_gb = usage.free / (1024 ** 3)
|
|
need_gb = needed_bytes / (1024 ** 3)
|
|
fail(
|
|
f"Insufficient disk space: need {need_gb:.1f} GB, "
|
|
f"have {free_gb:.1f} GB free. "
|
|
f"Free up space and retry."
|
|
)
|
|
|
|
|
|
def estimate_extracted(compressed: int, extracted: int) -> int:
|
|
"""Extracted-size estimate for the disk preflight. When the manifest omits
|
|
extractedSize (0), the budget would otherwise collapse to just the
|
|
compressed size and under-reserve for the extracted payload; fall back to a
|
|
conservative 3x of compressed (measured extracted/compressed ratios reach
|
|
~3x). This is only the early sanity bail; the accurate guard is the
|
|
real-on-disk re-check just before the destructive venv write."""
|
|
return extracted if extracted > 0 else compressed * 3
|
|
|
|
|
|
def dir_size(path: str) -> int:
|
|
"""Total size in bytes of all files under path (best-effort)."""
|
|
total = 0
|
|
for root, _dirs, files in os.walk(path):
|
|
for f in files:
|
|
try:
|
|
total += os.path.getsize(os.path.join(root, f))
|
|
except OSError:
|
|
pass
|
|
return total
|
|
|
|
|
|
def same_filesystem(a: str, b: str) -> bool:
|
|
"""True if paths a and b live on the same filesystem (so a rename between
|
|
them is a cheap metadata op rather than a full copy)."""
|
|
try:
|
|
return os.stat(_existing_ancestor(a)).st_dev == os.stat(_existing_ancestor(b)).st_dev
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
# -- Venv site-packages discovery --
|
|
|
|
def get_site_packages_dir(venv_path: str) -> str:
|
|
"""Find the site-packages directory inside a Python venv."""
|
|
matches = glob.glob(os.path.join(venv_path, "lib", "python*", "site-packages"))
|
|
if matches:
|
|
return matches[0]
|
|
return ""
|
|
|
|
|
|
# -- SHA256 verification --
|
|
|
|
def verify_sha256(filepath: str, expected: str) -> bool:
|
|
"""Stream-hash a file and compare to expected hex digest."""
|
|
h = hashlib.sha256()
|
|
with open(filepath, "rb") as f:
|
|
while True:
|
|
chunk = f.read(8192)
|
|
if not chunk:
|
|
break
|
|
h.update(chunk)
|
|
return h.hexdigest() == expected
|
|
|
|
|
|
# -- Download with resume --
|
|
|
|
def download_with_resume(
|
|
url: str,
|
|
dest: str,
|
|
expected_size: int,
|
|
progress_start: int,
|
|
progress_end: int,
|
|
) -> None:
|
|
"""Download a file with resume support via Range headers.
|
|
|
|
Uses .partial and .meta sidecar files for crash recovery.
|
|
"""
|
|
partial_path = dest + ".partial"
|
|
meta_path = dest + ".meta"
|
|
|
|
# Check for existing partial download
|
|
bytes_downloaded = 0
|
|
if os.path.exists(partial_path) and os.path.exists(meta_path):
|
|
try:
|
|
with open(meta_path, "r") as f:
|
|
meta = json.load(f)
|
|
bytes_downloaded = meta.get("bytesDownloaded", 0)
|
|
if bytes_downloaded > 0:
|
|
actual_size = os.path.getsize(partial_path)
|
|
if actual_size != bytes_downloaded:
|
|
bytes_downloaded = 0 # Mismatch, restart
|
|
except (json.JSONDecodeError, OSError):
|
|
bytes_downloaded = 0
|
|
|
|
if bytes_downloaded == 0 and os.path.exists(partial_path):
|
|
os.unlink(partial_path)
|
|
|
|
max_retries = 3
|
|
for attempt in range(max_retries):
|
|
try:
|
|
headers = {"User-Agent": "snapotter-installer/2.0"}
|
|
if bytes_downloaded > 0:
|
|
headers["Range"] = f"bytes={bytes_downloaded}-"
|
|
emit_progress(
|
|
progress_start,
|
|
f"Resuming download from {bytes_downloaded / (1024**3):.1f} GB...",
|
|
)
|
|
|
|
req = urllib.request.Request(url, headers=headers)
|
|
with urllib.request.urlopen(req, timeout=300) as resp:
|
|
mode = "ab" if bytes_downloaded > 0 else "wb"
|
|
with open(partial_path, mode) as f:
|
|
while True:
|
|
chunk = resp.read(65536)
|
|
if not chunk:
|
|
break
|
|
f.write(chunk)
|
|
bytes_downloaded += len(chunk)
|
|
|
|
# Update progress
|
|
if expected_size > 0:
|
|
pct = bytes_downloaded / expected_size
|
|
progress = int(
|
|
progress_start + pct * (progress_end - progress_start)
|
|
)
|
|
progress = min(progress, progress_end)
|
|
stage = f"Downloading... {bytes_downloaded / (1024**3):.1f} GB"
|
|
emit_progress(progress, stage)
|
|
|
|
# Write meta periodically (every 10 MB)
|
|
if bytes_downloaded % (10 * 1024 * 1024) < 65536:
|
|
with open(meta_path, "w") as mf:
|
|
json.dump({"bytesDownloaded": bytes_downloaded}, mf)
|
|
|
|
# Download complete
|
|
os.rename(partial_path, dest)
|
|
if os.path.exists(meta_path):
|
|
os.unlink(meta_path)
|
|
return
|
|
|
|
except Exception as e:
|
|
# Write meta for resume on next attempt
|
|
with open(meta_path, "w") as mf:
|
|
json.dump({"bytesDownloaded": bytes_downloaded}, mf)
|
|
|
|
if attempt < max_retries - 1:
|
|
delay = 10 * (2 ** attempt)
|
|
emit_progress(
|
|
progress_start,
|
|
f"Download failed (attempt {attempt + 1}/{max_retries}), "
|
|
f"retrying in {delay}s: {e}",
|
|
)
|
|
time.sleep(delay)
|
|
else:
|
|
# Clean up on final failure
|
|
for p in (partial_path, meta_path):
|
|
if os.path.exists(p):
|
|
os.unlink(p)
|
|
raise RuntimeError(
|
|
f"Failed to download after {max_retries} attempts: {e}"
|
|
)
|
|
|
|
|
|
# -- Safe tar extraction --
|
|
|
|
def safe_extract(tar_path: str, staging_dir: str) -> None:
|
|
"""Extract a tar.gz with security guards."""
|
|
os.makedirs(staging_dir, exist_ok=True)
|
|
with tarfile.open(tar_path, "r:gz") as tf:
|
|
for member in tf.getmembers():
|
|
# Block symlinks, hardlinks, devices
|
|
if not member.isfile() and not member.isdir():
|
|
raise RuntimeError(f"Blocked unsafe tar entry type: {member.name}")
|
|
# Block absolute paths and traversal
|
|
if member.name.startswith("/") or ".." in member.name.split("/"):
|
|
raise RuntimeError(f"Blocked unsafe tar path: {member.name}")
|
|
# The filter= kwarg was added in Python 3.12; the manual guards above
|
|
# already block unsafe entries on older interpreters (e.g. 3.11).
|
|
if sys.version_info >= (3, 12):
|
|
tf.extractall(staging_dir, filter="data")
|
|
else:
|
|
tf.extractall(staging_dir)
|
|
|
|
|
|
# -- File move --
|
|
|
|
def move_tree(src: str, dst: str) -> None:
|
|
"""Merge src into dst, overwriting existing files. Renames entries where
|
|
possible so that on the same filesystem no copy (and thus no transient
|
|
doubling of the payload on disk) occurs; falls back to a copy only across
|
|
filesystems. The old copytree+rmtree approach duplicated the whole tree on
|
|
disk during the move, which could exhaust the host on a tight-disk node."""
|
|
if not os.path.isdir(src):
|
|
return
|
|
os.makedirs(dst, exist_ok=True)
|
|
for name in os.listdir(src):
|
|
s = os.path.join(src, name)
|
|
d = os.path.join(dst, name)
|
|
if os.path.isdir(s) and os.path.isdir(d):
|
|
# Both dirs exist: merge recursively rather than replace.
|
|
move_tree(s, d)
|
|
continue
|
|
if os.path.exists(d):
|
|
if os.path.isdir(d):
|
|
shutil.rmtree(d)
|
|
else:
|
|
os.remove(d)
|
|
try:
|
|
os.rename(s, d)
|
|
except OSError as e:
|
|
if getattr(e, "errno", None) == errno.EXDEV:
|
|
# Cross-filesystem: rename isn't allowed, fall back to a copy.
|
|
if os.path.isdir(s):
|
|
shutil.copytree(s, d)
|
|
else:
|
|
shutil.copy2(s, d)
|
|
else:
|
|
raise
|
|
# Remove whatever remains of src (emptied by renames, or copied originals).
|
|
shutil.rmtree(src, ignore_errors=True)
|
|
|
|
|
|
# -- Fixups (NCCL wheel) --
|
|
|
|
def apply_fixups(staging_dir: str, venv_path: str) -> None:
|
|
"""Install any wheels from fixups/ directory (local only, no network)."""
|
|
fixups_dir = os.path.join(staging_dir, "fixups")
|
|
if not os.path.isdir(fixups_dir):
|
|
return
|
|
wheels = [f for f in os.listdir(fixups_dir) if f.endswith(".whl")]
|
|
if not wheels:
|
|
return
|
|
python_path = os.path.join(venv_path, "bin", "python3")
|
|
if not os.path.exists(python_path):
|
|
return
|
|
for wheel in wheels:
|
|
pkg_name = wheel.split("-")[0]
|
|
try:
|
|
subprocess.run(
|
|
[python_path, "-m", "pip", "install", "--no-index",
|
|
f"--find-links={fixups_dir}", pkg_name],
|
|
capture_output=True, text=True, timeout=60,
|
|
)
|
|
except Exception:
|
|
pass # Non-fatal
|
|
|
|
|
|
# -- installed.json management --
|
|
|
|
def read_installed(ai_dir: str) -> dict:
|
|
"""Read the current installed.json, returning empty structure if missing."""
|
|
path = os.path.join(ai_dir, "installed.json")
|
|
if not os.path.exists(path):
|
|
return {"bundles": {}}
|
|
try:
|
|
with open(path, "r") as f:
|
|
return json.load(f)
|
|
except (json.JSONDecodeError, OSError):
|
|
return {"bundles": {}}
|
|
|
|
|
|
def write_installed_atomic(ai_dir: str, data: dict) -> None:
|
|
"""Write installed.json atomically (write .tmp then rename)."""
|
|
path = os.path.join(ai_dir, "installed.json")
|
|
tmp_path = path + ".tmp"
|
|
with open(tmp_path, "w") as f:
|
|
json.dump(data, f, indent=2)
|
|
f.write("\n")
|
|
os.rename(tmp_path, path)
|
|
|
|
|
|
# -- Main --
|
|
|
|
def main() -> None:
|
|
if len(sys.argv) < 4:
|
|
fail(
|
|
f"Usage: {sys.argv[0]} <bundleId> <manifestPath> <modelsDir>\n"
|
|
f"Got {len(sys.argv) - 1} argument(s)"
|
|
)
|
|
|
|
bundle_id = sys.argv[1]
|
|
manifest_path = sys.argv[2]
|
|
models_dir = sys.argv[3]
|
|
ai_dir = os.path.dirname(models_dir)
|
|
staging_base = os.path.join(ai_dir, "staging")
|
|
venv_path = os.environ.get("PYTHON_VENV_PATH", os.path.join(ai_dir, "venv"))
|
|
|
|
# -- Load manifest --
|
|
emit_progress(0, "Reading manifest...")
|
|
|
|
try:
|
|
with open(manifest_path, "r") as f:
|
|
manifest = json.load(f)
|
|
except Exception as e:
|
|
fail(f"Failed to read manifest: {e}")
|
|
|
|
bundles = manifest.get("bundles", {})
|
|
if bundle_id not in bundles:
|
|
fail(f"Unknown bundle: {bundle_id}")
|
|
|
|
bundle = bundles[bundle_id]
|
|
archives = bundle.get("archives")
|
|
if not archives:
|
|
fail(f"Bundle '{bundle_id}' has no archives in manifest (v2 required)")
|
|
|
|
# -- Detect architecture --
|
|
arch = detect_arch()
|
|
archive = archives.get(arch)
|
|
if not archive:
|
|
fail(f"No archive for architecture '{arch}' in bundle '{bundle_id}'")
|
|
|
|
archive_file = archive["file"]
|
|
expected_sha256 = archive["sha256"]
|
|
compressed_size = archive.get("compressedSize", 0)
|
|
extracted_size = archive.get("extractedSize", 0)
|
|
|
|
# -- Check for local file override (testing / offline) --
|
|
local_path = os.environ.get("SNAPOTTER_BUNDLE_LOCAL_PATH")
|
|
|
|
if local_path:
|
|
# Local mode: use the file directly, verify checksum
|
|
emit_progress(5, "Using local bundle archive...")
|
|
tar_path = local_path
|
|
|
|
if not os.path.exists(tar_path):
|
|
fail(f"Local bundle file not found: {tar_path}")
|
|
|
|
# Verify checksum
|
|
emit_progress(10, "Verifying checksum...")
|
|
if not verify_sha256(tar_path, expected_sha256):
|
|
fail(
|
|
f"SHA256 checksum mismatch for local file.\n"
|
|
f"Expected: {expected_sha256}\n"
|
|
f"This usually means the manifest and archive are out of sync."
|
|
)
|
|
else:
|
|
# Remote mode: download from HuggingFace
|
|
bundle_repo = manifest.get("bundleRepo", "deepsafe/feature-bundles")
|
|
url = f"https://huggingface.co/{bundle_repo}/resolve/main/{archive_file}"
|
|
|
|
# Disk space check (early sanity bail before a multi-GB download).
|
|
# estimate_extracted covers the extractedSize:0 case so the budget can't
|
|
# collapse to just the compressed size; the accurate guard is the
|
|
# real-on-disk re-check just before the destructive venv write below.
|
|
needed = compressed_size + estimate_extracted(compressed_size, extracted_size)
|
|
needed += 500 * 1024 * 1024 # 500 MB buffer
|
|
if needed > 0:
|
|
check_disk_space(ai_dir, needed)
|
|
|
|
# Download
|
|
os.makedirs(staging_base, exist_ok=True)
|
|
tar_path = os.path.join(staging_base, f"{bundle_id}-{arch}.tar.gz")
|
|
|
|
emit_progress(2, f"Downloading {bundle.get('name', bundle_id)} bundle...")
|
|
|
|
try:
|
|
download_with_resume(url, tar_path, compressed_size, 2, 85)
|
|
except RuntimeError as e:
|
|
fail(
|
|
f"{e}\n\n"
|
|
f"You can download the bundle manually from:\n"
|
|
f" {url}\n"
|
|
f"Then upload it via Settings > AI Features > Offline Import."
|
|
)
|
|
|
|
# Verify checksum
|
|
emit_progress(86, "Verifying integrity...")
|
|
if not verify_sha256(tar_path, expected_sha256):
|
|
# Delete and retry once from scratch
|
|
os.unlink(tar_path)
|
|
emit_progress(86, "Checksum mismatch, retrying download...")
|
|
try:
|
|
download_with_resume(url, tar_path, compressed_size, 2, 85)
|
|
except RuntimeError as e:
|
|
fail(str(e))
|
|
|
|
if not verify_sha256(tar_path, expected_sha256):
|
|
os.unlink(tar_path)
|
|
fail(
|
|
f"SHA256 checksum mismatch after re-download.\n"
|
|
f"Expected: {expected_sha256}\n"
|
|
f"The archive may be corrupted. Try again later."
|
|
)
|
|
|
|
# -- Extract to staging --
|
|
staging_dir = os.path.join(ai_dir, f"staging-{bundle_id}")
|
|
emit_progress(88, "Extracting packages and models...")
|
|
|
|
try:
|
|
if os.path.exists(staging_dir):
|
|
shutil.rmtree(staging_dir)
|
|
safe_extract(tar_path, staging_dir)
|
|
except Exception as e:
|
|
if os.path.exists(staging_dir):
|
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
fail(f"Failed to extract archive: {e}")
|
|
|
|
# -- Read bundle.json from tar --
|
|
bundle_json_path = os.path.join(staging_dir, "bundle.json")
|
|
if not os.path.exists(bundle_json_path):
|
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
fail("Archive is missing bundle.json")
|
|
|
|
try:
|
|
with open(bundle_json_path, "r") as f:
|
|
bundle_meta = json.load(f)
|
|
except Exception as e:
|
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
fail(f"Invalid bundle.json: {e}")
|
|
|
|
version = bundle_meta.get("version", manifest.get("imageVersion", "unknown"))
|
|
model_ids = bundle_meta.get("models", [])
|
|
|
|
# -- Disk re-check before the first destructive venv write --
|
|
# The upfront check ran before the download and used an estimate; now the
|
|
# payload is really on disk, so measure it and verify there's room to place
|
|
# it before we start writing into the venv. Running here (after the
|
|
# local/remote branches merge) also covers the offline-import path, which
|
|
# skipped the upfront check entirely. Each budget is checked against the
|
|
# filesystem the bytes actually land on: when the venv lives on a
|
|
# different filesystem than staging, the site-packages payload is COPIED
|
|
# onto the venv's disk, so that disk (not ai_dir's) must hold it. Models
|
|
# stay under ai_dir either way, moving by rename.
|
|
disk_floor = 1024 ** 3 # 1 GB for fixups / installed.json / slack
|
|
staging_sp = os.path.join(staging_dir, "site-packages")
|
|
if not same_filesystem(staging_dir, venv_path):
|
|
sp_bytes = dir_size(staging_sp) if os.path.isdir(staging_sp) else 0
|
|
check_disk_space(venv_path, sp_bytes + disk_floor)
|
|
check_disk_space(ai_dir, disk_floor)
|
|
|
|
# -- Move site-packages --
|
|
emit_progress(92, "Installing packages...")
|
|
site_packages_dir = get_site_packages_dir(venv_path)
|
|
|
|
try:
|
|
if os.path.isdir(staging_sp) and site_packages_dir:
|
|
move_tree(staging_sp, site_packages_dir)
|
|
|
|
# -- Move models --
|
|
emit_progress(95, "Installing models...")
|
|
staging_models = os.path.join(staging_dir, "models")
|
|
if os.path.isdir(staging_models):
|
|
os.makedirs(models_dir, exist_ok=True)
|
|
move_tree(staging_models, models_dir)
|
|
except OSError as e:
|
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
if getattr(e, "errno", None) == errno.ENOSPC:
|
|
fail("Ran out of disk space while installing the bundle. Free up space and retry.")
|
|
fail(f"Failed to install bundle files: {e}")
|
|
|
|
# -- Apply fixups --
|
|
emit_progress(97, "Finalizing...")
|
|
apply_fixups(staging_dir, venv_path)
|
|
|
|
# -- Write installed.json --
|
|
emit_progress(98, "Recording installation...")
|
|
installed = read_installed(ai_dir)
|
|
installed["bundles"][bundle_id] = {
|
|
"version": version,
|
|
"installedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
"models": model_ids,
|
|
}
|
|
write_installed_atomic(ai_dir, installed)
|
|
|
|
# -- Cleanup --
|
|
if os.path.exists(staging_dir):
|
|
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
# Clean up downloaded tar (but not if local override)
|
|
if not local_path and os.path.exists(tar_path):
|
|
os.unlink(tar_path)
|
|
|
|
# -- Done --
|
|
emit_progress(100, "Complete")
|
|
|
|
result = {
|
|
"success": True,
|
|
"bundleId": bundle_id,
|
|
"version": version,
|
|
"models": model_ids,
|
|
}
|
|
print(json.dumps(result))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|