Upgraded installs replaced backend/ + omnivoice/ sources from the bundle but never refreshed pyproject.toml/uv.lock or re-ran uv sync, so any dependency added after the user's venv was created was missing at import time — e.g. a venv predating scalar-fastapi (added May 4) died on startup with ModuleNotFoundError once v0.3.5 code landed on it. - bootstrap.rs: refresh pyproject.toml + uv.lock from the bundle whenever a healthy venv is reused; when the lockfile content changed, run `uv sync --frozen --no-dev` so newly added deps land. On sync failure (e.g. offline upgrade) keep the existing venv instead of bricking a previously-working install. - bootstrap.rs: the repair path now refreshes manifests first (it used to sync against the stale lock from when the venv was created) and applies the restricted-network HTTP env tuning it was missing. - backend/main.py: scalar_fastapi import is now guarded — it only powers /docs, so a venv without it must still boot; /docs returns 503 with an actionable message instead. Closes #307 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9312e434ef
commit
bfc90e90f5
+14
-1
@@ -284,7 +284,12 @@ from fastapi.responses import JSONResponse, RedirectResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from scalar_fastapi import get_scalar_api_reference
|
||||
# Docs-only dependency: a venv created before scalar-fastapi entered the
|
||||
# dependency set must still boot the backend (#307) — /docs degrades instead.
|
||||
try:
|
||||
from scalar_fastapi import get_scalar_api_reference
|
||||
except ImportError:
|
||||
get_scalar_api_reference = None
|
||||
import traceback
|
||||
|
||||
_crash_log_lock = threading.Lock()
|
||||
@@ -471,6 +476,14 @@ app = FastAPI(
|
||||
@app.get("/docs", include_in_schema=False)
|
||||
async def scalar_docs():
|
||||
"""Interactive API documentation powered by Scalar."""
|
||||
if get_scalar_api_reference is None:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"detail": "API docs unavailable: scalar-fastapi is not installed "
|
||||
"in the backend environment (#307)."
|
||||
},
|
||||
)
|
||||
return get_scalar_api_reference(
|
||||
openapi_url=app.openapi_url,
|
||||
title=app.title,
|
||||
|
||||
@@ -250,6 +250,45 @@ pub fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh `pyproject.toml` + `uv.lock` in the project dir from the bundled
|
||||
/// resources, so an upgraded app never runs freshly-synced backend code against
|
||||
/// the stale dependency manifests from when the venv was first created (#307 —
|
||||
/// a venv predating scalar-fastapi's addition crashed main.py on import).
|
||||
/// Returns true when the lockfile content changed (or the project had none):
|
||||
/// the signal that the venv may be missing newly added dependencies and needs
|
||||
/// a `uv sync`.
|
||||
fn refresh_project_manifests(resource_dir: &Path, project_dir: &Path) -> bool {
|
||||
let flat = resource_dir.to_path_buf();
|
||||
let up2 = resource_dir.join("_up_").join("_up_");
|
||||
let res_root = if flat.join("pyproject.toml").is_file() { flat } else { up2 };
|
||||
let res_pyproject = res_root.join("pyproject.toml");
|
||||
let res_uvlock = res_root.join("uv.lock");
|
||||
if res_pyproject.is_file() {
|
||||
if let Err(e) = fs::copy(&res_pyproject, project_dir.join("pyproject.toml")) {
|
||||
log::warn!("Could not refresh pyproject.toml from bundle: {}", e);
|
||||
}
|
||||
}
|
||||
if !res_uvlock.is_file() {
|
||||
return false;
|
||||
}
|
||||
let project_lock = project_dir.join("uv.lock");
|
||||
let lock_changed = match (fs::read(&res_uvlock), fs::read(&project_lock)) {
|
||||
(Ok(bundled), Ok(existing)) => bundled != existing,
|
||||
(Ok(_), Err(_)) => true, // project has no lock yet — treat as drift
|
||||
(Err(e), _) => {
|
||||
log::warn!("Could not read bundled uv.lock: {}", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if lock_changed {
|
||||
if let Err(e) = fs::copy(&res_uvlock, &project_lock) {
|
||||
log::warn!("Could not refresh uv.lock from bundle: {}", e);
|
||||
return false; // don't sync against a lock we failed to refresh
|
||||
}
|
||||
}
|
||||
lock_changed
|
||||
}
|
||||
|
||||
/// Dev-mode fallback: running from the source tree (`bun run dev`).
|
||||
pub fn find_dev_project_root() -> Option<PathBuf> {
|
||||
let candidates = [
|
||||
@@ -428,6 +467,49 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
}
|
||||
log::info!("Synced backend/ from bundle");
|
||||
}
|
||||
// #307: the source dirs above track the bundle, so the
|
||||
// dependency manifests must too — otherwise an upgrade runs
|
||||
// new code against a venv that predates newly added deps.
|
||||
if refresh_project_manifests(res, &project_dir) {
|
||||
log::info!("uv.lock changed since the venv was synced — running uv sync (#307)");
|
||||
if let Some(p) = progress {
|
||||
set_stage(p, BootstrapStage::InstallingDeps);
|
||||
}
|
||||
match resolve_uv(app, &app_data, progress) {
|
||||
Ok(uv_path) => {
|
||||
let mut drift_cmd = Command::new(&uv_path);
|
||||
scrub_python_env(&mut drift_cmd); // #144
|
||||
apply_uv_http_env(&mut drift_cmd);
|
||||
let user_cfg = crate::config::load_config(app);
|
||||
if let Some(pypi) = user_cfg.mirrors.pypi_index.as_deref() {
|
||||
drift_cmd.env("UV_INDEX_URL", pypi);
|
||||
} else if get_effective_region(app) == "china" {
|
||||
drift_cmd.env("UV_INDEX_URL", "https://mirrors.aliyun.com/pypi/simple/");
|
||||
}
|
||||
drift_cmd
|
||||
.args(["sync", "--frozen", "--no-dev", "--verbose"])
|
||||
.current_dir(&project_dir);
|
||||
match run_streaming(app, "installing_deps", &mut drift_cmd) {
|
||||
Ok(ref s) if s.success() => {
|
||||
log::info!("Dependency drift sync complete (#307)");
|
||||
}
|
||||
other => {
|
||||
// Don't brick a previously-working install
|
||||
// (e.g. an offline upgrade): keep the old
|
||||
// venv and let the backend try.
|
||||
log::error!(
|
||||
"Dependency drift sync failed ({:?}) — continuing with \
|
||||
the existing venv; newly added dependencies may be missing (#307)",
|
||||
other
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Could not resolve uv for drift sync: {} (#307)", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Some((venv_py, backend_dir));
|
||||
}
|
||||
@@ -453,8 +535,14 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
Ok(p) => p,
|
||||
Err(e) => { fail(progress, &e); return None; }
|
||||
};
|
||||
// #307: repair against the *current* bundled manifests, not the stale
|
||||
// copies from when the venv was first created.
|
||||
if let Ok(res) = app.path().resource_dir() {
|
||||
let _ = refresh_project_manifests(&res, &project_dir);
|
||||
}
|
||||
let mut repair_cmd = Command::new(&uv_path);
|
||||
scrub_python_env(&mut repair_cmd); // #144: don't inherit AppImage's bundled Python
|
||||
apply_uv_http_env(&mut repair_cmd);
|
||||
let has_lockfile = project_dir.join("uv.lock").is_file();
|
||||
if has_lockfile {
|
||||
repair_cmd.args(["sync", "--frozen", "--no-dev", "--verbose"]);
|
||||
|
||||
Reference in New Issue
Block a user