Files
NexusOS/bin/sync.py
T
Jon WingenderandClaude Sonnet 5 3b0354735c fix(deps): make torch/ML stack opt-in instead of a mandatory install
requirements-amd.txt/requirements-nvidia.txt were pulling a multi-GB
torch wheel by default even though nothing in synapse/ imports torch,
transformers, accelerate, bitsandbytes, or PySide6 - dead weight that
made the pip batch fragile (one failed download could take unrelated
base deps down with it on a slow connection). Split the unused ML/GUI
stack out of requirements-base.txt into a new opt-in requirements-ml.txt,
and dropped the torch lines from the AMD/NVIDIA overlays and generator.

Also: recreate the venv if it exists but pip is missing, instead of
silently reusing a half-built one (ensurepip can fail during venv
creation and leave an interpreter with no pip).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 11:43:17 -05:00

368 lines
15 KiB
Python

#!/usr/bin/env python3
"""NexusOS backup and restore - one entry point for Linux and native Windows.
Same command on both boxes. Everything portable lives here: the git sync with
Gitea, the memory-DB dump / compare / rebuild, and the venv + web-UI rebuild.
The parts that only mean something on Linux - apt packages, the bundled Ollama
binary, the XFCE desktop wiring, the desktop snapshot - stay in bash and get
called from here, skipped outright on Windows.
python bin/sync.py restore [--check]
python bin/sync.py backup [--check] [--full] [--force-db]
python bin/sync.py compare # print the direction verdict only
Standard library only, so it runs before the Promethean venv exists and needs no
sqlite3 binary on PATH - Windows has none, which is why the old
bin/db-compare.sh could never work there.
A fresh machine clones first (git clone <repo> nexus-core), then runs this.
"""
import argparse
import os
import shutil
import sqlite3
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DB = ROOT / "synapse" / "memory" / "memory.db"
DB_SQL = ROOT / "synapse" / "memory" / "memory.db.sql"
DB_SQL_REL = "synapse/memory/memory.db.sql" # git paths are always posix-style
def run(*args, capture=False, check=False):
"""Run a command from the repo root. Returns CompletedProcess."""
exe = shutil.which(args[0]) # resolves npm -> npm.cmd on Windows
if exe is None:
raise SystemExit(f"'{args[0]}' not found on PATH")
return subprocess.run(
[exe, *args[1:]], cwd=ROOT, check=check,
capture_output=capture, text=True, encoding="utf-8",
)
def linux_stage(script: str, *args) -> None:
"""Run one of the Linux-only bash stages. A no-op on Windows, where apt,
xfconf, plank and the rest have nothing to act on."""
if os.name == "nt":
return
path = ROOT / "bin" / script
bash = shutil.which("bash")
if path.exists() and bash:
# Pin the stage to THIS repo. It defaults to ~/nexus-core otherwise, so a
# clone in a scratch dir would restore over the real machine instead.
subprocess.run([bash, str(path), *args], cwd=ROOT, check=False,
env={**os.environ, "NEXUS_ROOT": str(ROOT)})
def venv_python() -> Path:
"""Path to the Promethean interpreter, creating the venv if it's missing."""
venv = ROOT / "Promethean"
py = venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
if py.exists() and subprocess.run(
[str(py), "-m", "pip", "--version"], capture_output=True).returncode:
# venv creation can partially succeed: the interpreter gets built but
# ensurepip's bootstrap fails (e.g. the matching pythonX.Y-venv package
# wasn't installed yet), leaving pip missing. Existence of `py` alone
# can't tell a venv like that apart from a good one, so a prior failed
# run would otherwise be reused forever instead of getting rebuilt now
# that whatever broke ensurepip is fixed.
print("Existing venv has no pip - recreating it...")
shutil.rmtree(venv)
if not py.exists():
result = subprocess.run([sys.executable, "-m", "venv", str(venv)])
if result.returncode:
raise SystemExit(
"\nvenv creation failed - the Python 'venv' module isn't usable here.\n"
"On Debian/Ubuntu this is usually a missing pythonX.Y-venv package; "
"check the 'Installing system packages' warning further up, install "
"it by hand, then re-run."
)
return py
def requirements() -> str:
"""Pick the PyTorch overlay for this host."""
if os.name == "nt":
return "requirements-windows.txt" # CPU / pure-Python, right for native Windows
if shutil.which("nvidia-smi"):
return "requirements-nvidia.txt"
lspci = shutil.which("lspci")
if lspci and "nvidia" in subprocess.run(
[lspci], capture_output=True, text=True).stdout.lower():
return "requirements-nvidia.txt"
return "requirements-amd.txt"
# -- memory DB -----------------------------------------------------------------
def dump_db() -> bool:
"""Dump the (gitignored, binary, WAL) memory DB to a diff-friendly SQL file
so git backs up the assistant's memory + conversation history. sqlite3 reads
through the WAL, so the dump has the latest committed data uncheckpointed."""
if not DB.exists():
return False
try:
with sqlite3.connect(f"file:{DB}?mode=ro", uri=True) as conn:
DB_SQL.write_text("\n".join(conn.iterdump()) + "\n", encoding="utf-8")
except sqlite3.Error as exc:
print(f"Warning: could not dump {DB} ({exc}) - DB not captured")
return False
print(f"Dumped memory DB -> {DB_SQL}")
return True
def _state(conn):
"""What a machine holds: conversations as {id: updated_at} and memory facts
as a set of ids. NOT messages.id - it's INTEGER AUTOINCREMENT, so both
machines hand out the same ids independently and comparing them is
meaningless. A new message bumps its conversation's updated_at, which is
what actually gets caught here."""
def query(sql):
try:
return conn.execute(sql).fetchall()
except sqlite3.Error:
return [] # table absent in an old dump - treat as empty
return (
dict(query("select id, updated_at from conversations")),
{row[0] for row in query("select id from memory")},
)
def _extra(a, b) -> int:
"""How much `a` holds that `b` lacks: conversations `b` is missing or has an
older copy of, plus memory facts `b` doesn't have at all. An out-of-date
copy is NOT extra content on b's side - that asymmetry is what separates
'one box is simply ahead' from a real divergence."""
a_conv, a_mem = a
b_conv, b_mem = b
newer = sum(1 for cid, ts in a_conv.items() if b_conv.get(cid, "") < ts)
return newer + len(a_mem - b_mem)
def compare(db: Path = DB, dump: Path = DB_SQL) -> str:
"""Which way the sync should go. One of: same, local-ahead, local-behind,
diverged, no-live, no-dump.
ponytail: detects direction, does not merge. Diverged is reported, not resolved."""
if not dump.exists() or not dump.stat().st_size:
return "no-dump"
if not db.exists():
return "no-live"
try:
with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as live_conn:
live = _state(live_conn)
with sqlite3.connect(":memory:") as dump_conn:
dump_conn.executescript(dump.read_text(encoding="utf-8"))
backup = _state(dump_conn)
except (sqlite3.Error, OSError):
return "diverged" # can't tell: fail safe, refuse both directions
ahead, behind = _extra(live, backup), _extra(backup, live)
if ahead and behind:
return "diverged"
if ahead:
return "local-ahead"
if behind:
return "local-behind"
return "same"
def restore_db() -> None:
"""Rebuild the memory DB from the pulled dump, keeping a rollback copy."""
print()
state = compare()
if state == "same":
print("Memory DB already matches the backup.")
return
if state == "local-ahead":
print("WARNING: this machine has conversations the backup doesn't.")
print(" Not applying the dump - run `backup` HERE first.")
print(f" To discard local history instead: delete {DB} and restore")
return
if state == "diverged":
print("WARNING: this machine and the backup each have conversations the other lacks.")
print(" Not applying the dump - nothing is merging these automatically.")
print(f" Keep local: run `backup` here. Keep remote: delete {DB} and restore")
return
if state == "no-dump":
print("No memory dump in the backup - skipping DB restore.")
return
print("Restoring memory DB from backup...")
rollback = DB.with_suffix(".db.pre-restore")
if DB.exists():
shutil.copyfile(DB, rollback)
for suffix in ("", "-wal", "-shm"):
Path(str(DB) + suffix).unlink(missing_ok=True)
try:
with sqlite3.connect(DB) as conn:
conn.executescript(DB_SQL.read_text(encoding="utf-8"))
print("Memory DB restored (conversations + history + facts).")
except sqlite3.Error as exc:
print(f"Warning: memory DB rebuild failed ({exc}).")
if rollback.exists():
shutil.move(rollback, DB)
print("Rolled back to previous DB.")
# -- rebuild -------------------------------------------------------------------
def rebuild_env() -> None:
print("\nRebuilding Python environment...")
py, req = venv_python(), requirements()
if (ROOT / req).exists():
subprocess.run([str(py), "-m", "pip", "install", "--upgrade", "pip", "-q"], check=False)
subprocess.run([str(py), "-m", "pip", "install", "-r", req], cwd=ROOT, check=False)
else:
print(f"Warning: {req} not found - skipping pip install.")
print("\nRebuilding frontend dependencies...")
web = ROOT / "interface" / "web"
npm = shutil.which("npm")
if npm is None:
print("Warning: npm not found - the web UI will not be built.")
return
subprocess.run([npm, "install"], cwd=web, check=False)
# Build the UI so the backend can serve it single-process (it mounts
# interface/web/dist at :8000). Without this the app has no UI to show.
print("Building frontend...")
subprocess.run([npm, "run", "build"], cwd=web, check=False)
if not (web / "dist" / "index.html").exists():
print("Warning: interface/web/dist/index.html missing - the app will serve no UI.")
# -- commands ------------------------------------------------------------------
def cmd_restore(args) -> int:
if args.check:
print("Fetching to preview restore (no changes)...")
run("git", "fetch", "origin")
print("\nCommits a restore would apply:")
print(run("git", "log", "--oneline", "..origin/main", capture=True).stdout or "(up to date)")
print(run("git", "diff", "--stat", "..origin/main", capture=True).stdout)
print(f"Memory DB vs backup: {compare()}")
print("\n(dry-run only - nothing changed. Apply with: restore)")
return 0
# System packages first - the venv and npm build below need them present.
linux_stage("restore-linux.sh", "prep")
print("\nPulling latest from Gitea...")
if run("git", "pull", "--ff-only", "origin", "main").returncode:
print("Pull failed (diverged? stash/commit local changes).")
return 1
restore_db()
rebuild_env()
linux_stage("restore-linux.sh", "runtime")
if not args.no_desktop:
linux_stage("restore-linux.sh", "desktop")
print("\nRestore complete. Nexus is ready to start.")
print("Note: Ollama models are not in the backup. Open the Models tab -> Required")
print("and pull the two models NexusOS depends on (memory curator + embeddings)")
print("before anything else, then pick a hardware-fit chat model.")
return 0
def _remote_dump(tmp: Path) -> bool:
"""Write origin/main's dump to tmp. False if it can't be fetched."""
if run("git", "fetch", "-q", "origin").returncode:
print("Note: could not reach Gitea - skipping backup safety check.")
return False
shown = run("git", "show", f"origin/main:{DB_SQL_REL}", capture=True)
if shown.returncode:
return False
tmp.write_text(shown.stdout, encoding="utf-8")
return True
def check_db_direction(tmp_dir: Path) -> bool:
"""Mirror of restore's guard: refuse to dump a stale live DB over a backup
that already holds newer conversations from the other machine. Compares
against the REMOTE dump, not the working-tree copy - that copy is from this
box's last backup and is exactly what goes stale when the other box pushes."""
if not DB.exists():
return True
tmp = tmp_dir / "remote.sql"
if not _remote_dump(tmp):
return True
state = compare(DB, tmp)
if state == "local-behind":
print("REFUSING TO BACK UP: the backup has conversations this machine doesn't.")
print(" Backing up now would overwrite them with this box's older history.")
print(" Run `restore` here first, then back up.")
return False
if state == "diverged":
print("REFUSING TO BACK UP: this machine and the backup each have conversations")
print(" the other lacks. Nothing merges these automatically.")
print(" Force this box's history to win: python bin/sync.py backup --force-db")
return False
return True
def cmd_backup(args) -> int:
import tempfile
if args.full:
linux_stage("backup-linux.sh")
dump_db()
with tempfile.TemporaryDirectory() as tmp_dir:
safe = check_db_direction(Path(tmp_dir))
if args.check:
print(f"\nMemory DB vs backup: {'safe to back up' if safe else 'STALE - restore first'}")
print("\nFiles a backup would commit:")
print(run("git", "status", "--short", capture=True).stdout)
print("Local commits not yet pushed:")
print(run("git", "log", "--oneline", "@{u}..", capture=True).stdout or "(none / no upstream)")
print("(dry-run only - nothing committed or pushed. Apply with: backup)")
return 0
if not safe and not args.force_db:
return 1
run("git", "add", "-A")
if run("git", "diff", "--cached", "--quiet").returncode:
stamp = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
if run("git", "commit", "-q", "-m", f"backup: {stamp}").returncode:
print("Commit failed - see error above (e.g. git identity not set).")
return 1
print("Committed backup snapshot.")
else:
print("No changes to commit.")
print("Pushing to Gitea...")
if run("git", "push", "origin", "main").returncode:
print("Push failed. Set up credentials once with:")
print(" git config credential.helper store # then push once and enter your Gitea token")
return 1
print("Backup complete.")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="cmd", required=True)
restore = sub.add_parser("restore", help="pull from Gitea, rebuild DB + venv + web UI")
restore.add_argument("-c", "--check", action="store_true", help="dry run, change nothing")
restore.add_argument("--no-desktop", action="store_true",
help="skip the XFCE desktop wiring (panel, theme, os-release). "
"Use for a test clone - that stage writes to $HOME, not the repo.")
restore.set_defaults(func=cmd_restore)
backup = sub.add_parser("backup", help="dump DB, commit and push to Gitea")
backup.add_argument("-c", "--check", action="store_true", help="dry run, change nothing")
backup.add_argument("-f", "--full", action="store_true",
help="also snapshot the live desktop wiring + Claude notes (Linux)")
backup.add_argument("--force-db", action="store_true", help="skip the staleness guard")
backup.set_defaults(func=cmd_backup)
compare_cmd = sub.add_parser("compare", help="print the sync direction verdict")
compare_cmd.set_defaults(func=lambda a: (print(compare()), 0)[1])
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())