From 7eda1ba5a84fa64743a4531efd531537a5d65648 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 3 Apr 2026 13:25:54 +0000 Subject: [PATCH] Add UNSLOTH_UPDATE_DETAILS.json manifest system for Studio updates Adds a centralized manifest file that controls Studio update behavior, allowing the team to temporarily switch update sources (PyPI vs git main), control llama.cpp source/version, and notify users of critical updates -- all without requiring a PyPI release. Changes: - New UNSLOTH_UPDATE_DETAILS.json manifest at repo root - setup.sh fetches manifest on startup, applies directives for unsloth source, llama.cpp repo/tag overrides, writes install timestamp - install_python_stack.py supports STUDIO_UNSLOTH_GIT_REF env var for installing unsloth from git instead of PyPI - New backend utils/update_check.py for manifest fetch and cache - New /api/update-check endpoint in main.py (unauthenticated) - Background thread checks manifest at startup, prints terminal banner - New use-update-check.ts React hook (module-level cache pattern) - navbar.tsx Update button shows dynamic badge and announcement - __root.tsx shows non-dismissable critical update banner --- UNSLOTH_UPDATE_DETAILS.json | 9 ++ studio/backend/main.py | 34 ++++++ studio/backend/utils/update_check.py | 103 ++++++++++++++++++ studio/frontend/src/app/routes/__root.tsx | 16 +++ studio/frontend/src/components/navbar.tsx | 46 +++++++- studio/frontend/src/hooks/index.ts | 1 + studio/frontend/src/hooks/use-update-check.ts | 76 +++++++++++++ studio/install_python_stack.py | 45 +++++--- studio/setup.sh | 79 ++++++++++++++ 9 files changed, 391 insertions(+), 18 deletions(-) create mode 100644 UNSLOTH_UPDATE_DETAILS.json create mode 100644 studio/backend/utils/update_check.py create mode 100644 studio/frontend/src/hooks/use-update-check.ts diff --git a/UNSLOTH_UPDATE_DETAILS.json b/UNSLOTH_UPDATE_DETAILS.json new file mode 100644 index 0000000000..5e66bfca7a --- /dev/null +++ b/UNSLOTH_UPDATE_DETAILS.json @@ -0,0 +1,9 @@ +{ + "schema_version": 1, + "unsloth_source": "pypi", + "unsloth_github_ref": "main", + "llama_cpp_source": "unslothai", + "llama_cpp_tag": "latest", + "CRITICAL_TIME": null, + "announcement": null +} diff --git a/studio/backend/main.py b/studio/backend/main.py index ad19ee9679..5a573f26a5 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -118,6 +118,25 @@ async def lifespan(app: FastAPI): threading.Thread(target = _precache, daemon = True).start() + # Fetch remote update manifest in the background and print a terminal + # banner when a critical update or announcement is present. + def _check_updates(): + try: + from utils.update_check import fetch_and_cache_update_status + + status = fetch_and_cache_update_status() + if status.critical: + print("\n" + "=" * 60) + print("CRITICAL UPDATE AVAILABLE") + print(" Run `unsloth studio update` then restart Studio.") + print("=" * 60 + "\n") + elif status.announcement_message: + print(f"\n [Update] {status.announcement_message}\n") + except Exception: + pass # non-critical + + threading.Thread(target = _check_updates, daemon = True).start() + if storage.ensure_default_admin(): bootstrap_pw = storage.get_bootstrap_password() app.state.bootstrap_password = bootstrap_pw @@ -203,6 +222,21 @@ async def health_check(): } +@app.get("/api/update-check") +async def update_check(): + """Return cached update status from the remote manifest (unauthenticated).""" + from utils.update_check import get_update_status + + s = get_update_status() + return { + "critical": s.critical, + "announcement_badge": s.announcement_badge, + "announcement_message": s.announcement_message, + "announcement_url": s.announcement_url, + "manifest_fetched": s.manifest_fetched, + } + + @app.post("/api/shutdown") async def shutdown_server( request: Request, diff --git a/studio/backend/utils/update_check.py b/studio/backend/utils/update_check.py new file mode 100644 index 0000000000..c943f59699 --- /dev/null +++ b/studio/backend/utils/update_check.py @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Remote manifest fetch and update-status cache for Unsloth Studio. + +Uses only stdlib (urllib.request, json, time, calendar) so it can run +without any third-party dependencies. The module exposes two public +functions: + + fetch_and_cache_update_status() -- fetches the manifest, reads the + local UNSLOTH_STUDIO_INFO.json, compares CRITICAL_TIME, and caches + the result at module level. + + get_update_status() -- returns the cached UpdateStatus (or defaults + when the fetch has not completed yet). +""" + +from __future__ import annotations + +import calendar +import json +import time +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +_MANIFEST_URL = ( + "https://raw.githubusercontent.com/unslothai/unsloth/main/" + "UNSLOTH_UPDATE_DETAILS.json" +) +_STUDIO_INFO_PATH = Path.home() / ".unsloth" / "studio" / "UNSLOTH_STUDIO_INFO.json" +_FETCH_TIMEOUT = 8 # seconds + + +@dataclass +class UpdateStatus: + critical: bool = False + announcement_badge: Optional[str] = None + announcement_message: Optional[str] = None + announcement_url: Optional[str] = None + manifest_fetched: bool = False + + +_cached_status: UpdateStatus = UpdateStatus() + + +def _parse_iso_utc(s: str) -> float: + """Parse an ISO-8601 UTC string (ending in Z) to a Unix timestamp.""" + s = s.strip().rstrip("Z") + try: + t = time.strptime(s, "%Y-%m-%dT%H:%M:%S") + except ValueError: + t = time.strptime(s[:19], "%Y-%m-%dT%H:%M:%S") + return float(calendar.timegm(t)) + + +def fetch_and_cache_update_status() -> UpdateStatus: + """Fetch the remote manifest, compare with local info, and cache.""" + global _cached_status + + try: + req = urllib.request.Request(_MANIFEST_URL, method="GET") + with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp: + manifest = json.loads(resp.read().decode("utf-8")) + except Exception: + return _cached_status + + status = UpdateStatus(manifest_fetched=True) + + # -- Critical time check -- + critical_time_str = manifest.get("CRITICAL_TIME") + if critical_time_str: + try: + critical_ts = _parse_iso_utc(critical_time_str) + installed_ts = 0.0 + if _STUDIO_INFO_PATH.is_file(): + try: + info = json.loads( + _STUDIO_INFO_PATH.read_text(encoding="utf-8") + ) + installed_ts = _parse_iso_utc(info.get("installed_at_utc", "")) + except Exception: + pass + if installed_ts < critical_ts: + status.critical = True + except Exception: + pass + + # -- Announcement -- + announcement = manifest.get("announcement") + if isinstance(announcement, dict): + status.announcement_badge = announcement.get("badge") or None + status.announcement_message = announcement.get("message") or None + status.announcement_url = announcement.get("url") or None + + _cached_status = status + return status + + +def get_update_status() -> UpdateStatus: + """Return the cached update status (safe to call before fetch completes).""" + return _cached_status diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index d7780c6743..eba4df6af4 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { Navbar } from "@/components/navbar"; +import { useUpdateCheck } from "@/hooks/use-update-check"; import { usePlatformStore } from "@/config/env"; import { Outlet, @@ -33,12 +34,27 @@ export const Route = createRootRoute({ const HIDDEN_NAVBAR_ROUTES = ["/onboarding", "/login", "/change-password"]; +function CriticalUpdateBanner() { + const { critical } = useUpdateCheck(); + if (!critical) return null; + return ( +
+ A critical update is available. Run{" "} + + unsloth studio update + {" "} + then restart Studio. +
+ ); +} + function RootLayout() { const pathname = useRouterState({ select: (s) => s.location.pathname }); const hideNavbar = HIDDEN_NAVBAR_ROUTES.includes(pathname); return ( + {!hideNavbar && } {!hideNavbar && } (defaultShell); const prefersReducedMotion = useReducedMotion(); @@ -148,6 +153,17 @@ function UpdateStudioInstructions({ return (
+ {announcementMessage ? ( +
+ {announcementUrl ? ( + + {announcementMessage} + + ) : ( + announcementMessage + )} +
+ ) : null}
s.deviceType); const chatOnly = usePlatformStore((s) => s.isChatOnly()); const defaultUpdateShell = getDefaultUpdateShell(deviceType); + const updateStatus = useUpdateCheck(); + + const updateBadgeText = updateStatus.critical + ? "Critical Update" + : updateStatus.announcementBadge ?? null; // Warn before closing the tab only when training is running (data loss risk). // We store the handler in a ref so removeUnloadHandler() can clean it up @@ -434,17 +455,24 @@ export function Navbar() { @@ -555,17 +583,23 @@ export function Navbar() {