Files
UmbraAudio/backup/restore_support.py
B.D. Murphy 0c9bea72e0 Add enterprise/infra tooling and repoint Qwen3-TTS submodule to internal fork
Two groups of changes, committed together as the follow-up to the initial push:

1. .gitmodules — submodule Qwen3-TTS now points at the internal Gitea fork
   (ssh://git@mini-eq:2222/umbragit/Qwen3-TTS.git) instead of upstream GitHub,
   so a clone actually gets the UmbraAudio branding/integration commit
   012e452 rather than vanilla upstream.

2. New infra/ tooling for the cross-machine work in spec 21: agent_bridge,
   remote_credential_broker (Keychain-backed, secrets read at runtime and
   generated via read_secret/random_secret — no hardcoded credentials), the
   enterprise bootstrap scripts, and the oldbox SSH/auth prompts. Plus
   backup/ restore scripts, two specs (18 UmbraTrans, 19 enterprise control
   plane), umbraaudio/voices/lmstudio_gate.py, and in-flight edits to the
   discovery/attribution/profiling modules, config, chapter_builder, emotion,
   pronunciation, qwen_tts and routes.

Also adds *.db to .gitignore: SQLite project databases are runtime state and no
.db file was tracked before.

Verified before staging: .env's only change is UMBRA_TTS__QWEN_TTS_HELPER_URL and
the committed copy has zero secret-looking values; the broker ships only
config.example.json.
2026-10-06 06:57:41 -07:00

822 lines
33 KiB
Python

#!/usr/bin/env python3
"""Portable backup/restore support helpers for UmbraAudio.
This module uses only the Python standard library. It snapshots global Umbra
state with SQLite's backup API, restores it atomically, rewrites machine-local
paths in a restored copy, and performs basic integrity checks.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shlex
import shutil
import sqlite3
import sys
import tempfile
import time
from pathlib import Path
from typing import Any
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def is_database(path: Path) -> bool:
return path.name.endswith((".db", ".db-wal", ".db-shm"))
def is_sqlite_file(path: Path) -> bool:
try:
with path.open("rb") as handle:
return handle.read(16) == b"SQLite format 3\x00"
except OSError:
return False
def safe_relative_path(value: str) -> Path:
path = Path(value)
if path.is_absolute() or ".." in path.parts:
raise RuntimeError(f"unsafe relative path in backup metadata: {value!r}")
return path
def sql_identifier(value: str) -> str:
return '"' + value.replace('"', '""') + '"'
def sqlite_backup(source: Path, destination: Path) -> str:
destination.parent.mkdir(parents=True, exist_ok=True)
source_uri = f"file:{source}?mode=ro"
source_db = sqlite3.connect(source_uri, uri=True)
destination_db = sqlite3.connect(destination)
try:
source_db.backup(destination_db)
result = destination_db.execute("PRAGMA integrity_check").fetchone()[0]
if result != "ok":
raise RuntimeError(f"integrity_check failed for {source}: {result}")
finally:
destination_db.close()
source_db.close()
return "ok"
def copy_tree_files(
source: Path,
destination: Path,
skip_databases: bool = True,
reject_symlinks: bool = False,
) -> list[str]:
copied: list[str] = []
for path in sorted(source.rglob("*")):
relative = path.relative_to(source)
target = destination / relative
if path.is_symlink():
if reject_symlinks:
raise RuntimeError(f"global state symlink is not allowed: {relative}")
target.parent.mkdir(parents=True, exist_ok=True)
os.symlink(os.readlink(path), target)
elif path.is_dir():
target.mkdir(parents=True, exist_ok=True)
elif path.is_file():
if skip_databases and is_database(path):
continue
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
copied.append(str(relative))
return copied
def snapshot_global(source: Path, output: Path, original_root: Path) -> None:
if output.exists():
raise RuntimeError(f"global-state output already exists: {output}")
if not source.exists():
raise RuntimeError(f"global state root does not exist: {source}")
if source == output or source in output.parents:
raise RuntimeError("global state output must be outside the source root")
temporary = Path(tempfile.mkdtemp(prefix="umbra-global-snapshot-"))
try:
files = copy_tree_files(source, temporary, skip_databases=True)
databases: list[dict[str, Any]] = []
for path in sorted(source.rglob("*.db")):
if not path.is_file():
continue
relative = path.relative_to(source)
target = temporary / relative
status = sqlite_backup(path, target)
databases.append(
{"path": str(relative), "integrity_check": status, "sha256": sha256(target)}
)
files.append(str(relative))
file_hashes = {
relative: sha256(temporary / relative)
for relative in sorted(files)
if (temporary / relative).is_file()
}
manifest = {
"schema": "umbra.global_state/v1",
"original_root": str(original_root),
"source_root": str(source),
"files": sorted(files),
"file_hashes": file_hashes,
"databases": databases,
}
(temporary / "MANIFEST.json").write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
(temporary / "GLOBAL-STATE-SHA256SUMS").write_text(
"".join(f"{sha256(temporary / relative)} {relative}\n" for relative in sorted(files)),
encoding="utf-8",
)
output.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(temporary, output, symlinks=True)
finally:
shutil.rmtree(temporary, ignore_errors=True)
def _replace_prefix(value: Any, old_root: str, new_root: str) -> Any:
if isinstance(value, str) and old_root and value.startswith(old_root):
return new_root + value[len(old_root) :]
return value
def assert_no_old_prefixes(
connection: sqlite3.Connection,
old_roots: list[str],
) -> None:
for table_row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'"):
table = table_row[0]
if table.startswith("sqlite_"):
continue
table_identifier = sql_identifier(table)
for column in connection.execute(f"PRAGMA table_info({table_identifier})").fetchall():
column_name = column[1]
column_identifier = sql_identifier(column_name)
if (column[2] or "").upper() not in {"TEXT", ""}:
continue
try:
values = connection.execute(
f"SELECT {column_identifier} FROM {table_identifier} "
f"WHERE {column_identifier} IS NOT NULL"
).fetchall()
except sqlite3.Error:
continue
for (value,) in values:
if isinstance(value, str) and any(
old_root and value.startswith(old_root) for old_root in old_roots
):
raise RuntimeError(f"old path prefix remains in {table}.{column_name}: {value}")
def migrate_database_paths(database: Path, replacements: list[tuple[str, str]]) -> None:
connection = sqlite3.connect(database)
try:
tables = {
row[0]
for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
for table, columns in {
"voice_registry": ("voice_ref_path", "vmatrix_path", "sample_audio_path"),
"library_speakers": ("voice_ref_path",),
"library_utterances": ("voice_ref_path",),
}.items():
if table not in tables:
continue
for column in columns:
try:
rows = connection.execute(
f'SELECT rowid, "{column}" FROM "{table}" WHERE "{column}" IS NOT NULL'
).fetchall()
except sqlite3.Error:
continue
for rowid, value in rows:
replacement = value
for old_root, new_root in replacements:
replacement = _replace_prefix(replacement, old_root, new_root)
if replacement != value:
connection.execute(
f'UPDATE "{table}" SET "{column}" = ? WHERE rowid = ?',
(replacement, rowid),
)
connection.commit()
assert_no_old_prefixes(connection, [old for old, _ in replacements if old])
result = connection.execute("PRAGMA integrity_check").fetchone()[0]
if result != "ok":
raise RuntimeError(f"integrity_check failed after path migration: {database}: {result}")
finally:
connection.close()
def migrate_project_database(database: Path, old_root: str, new_root: str) -> None:
connection = sqlite3.connect(database)
try:
tables = {
row[0]
for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
if not row[0].startswith("sqlite_")
}
for table in tables:
table_identifier = sql_identifier(table)
columns = connection.execute(f"PRAGMA table_info({table_identifier})").fetchall()
for column in columns:
column_name = column[1]
column_identifier = sql_identifier(column_name)
if (column[2] or "").upper() not in {"TEXT", ""}:
continue
try:
query = (
f"SELECT rowid, {column_identifier} FROM {table_identifier} "
f"WHERE {column_identifier} IS NOT NULL"
)
rows = connection.execute(query).fetchall()
except sqlite3.Error:
continue
for rowid, value in rows:
replacement = _replace_prefix(value, old_root, new_root)
if replacement != value:
connection.execute(
f"UPDATE {table_identifier} SET {column_identifier} = ? "
"WHERE rowid = ?",
(replacement, rowid),
)
connection.commit()
assert_no_old_prefixes(connection, [old_root] if old_root else [])
result = connection.execute("PRAGMA integrity_check").fetchone()[0]
if result != "ok":
raise RuntimeError(
f"integrity_check failed after project path migration: {database}: {result}"
)
finally:
connection.close()
def _validate_target(path: Path, force: bool) -> Path:
if path.is_symlink():
raise RuntimeError(f"refusing symlink target: {path}")
if path.exists() and not path.is_dir():
raise RuntimeError(f"target exists and is not a directory: {path}")
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists() and any(path.iterdir()) and not force:
raise RuntimeError(f"target is not empty: {path}; use --force")
return path
def _commit_directory(temporary: Path, target: Path, force: bool) -> Path | None:
_validate_target(target, force)
previous: Path | None = None
if target.exists():
previous = target.with_name(f"{target.name}.old.{int(time.time())}")
target.rename(previous)
try:
temporary.rename(target)
except Exception:
if previous is not None and previous.exists() and not target.exists():
previous.rename(target)
raise
return previous
def restore_global(
backup_dir: Path,
target: Path,
force: bool,
old_project_root: str = "",
new_project_root: str = "",
) -> None:
source = backup_dir / "global-state"
manifest_path = source / "MANIFEST.json"
if not manifest_path.exists():
raise RuntimeError(f"global-state manifest is missing: {manifest_path}")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
old_root = manifest.get("original_root", "")
_validate_target(target, force)
temporary = Path(tempfile.mkdtemp(prefix=f".{target.name}.global-restore.", dir=target.parent))
try:
copy_tree_files(
source,
temporary,
skip_databases=False,
reject_symlinks=True,
)
for metadata_name in ("MANIFEST.json", "GLOBAL-STATE-SHA256SUMS"):
metadata_path = temporary / metadata_name
if metadata_path.exists():
metadata_path.unlink()
for relative in manifest.get("files", []):
safe_relative_path(relative)
file_hashes = manifest.get("file_hashes", {})
for relative, expected_hash in file_hashes.items():
safe_relative = safe_relative_path(relative)
path = temporary / safe_relative
if not path.is_file() or sha256(path) != expected_hash:
raise RuntimeError(f"global file checksum mismatch: {relative}")
replacements = [(old_root, str(target))]
if old_project_root and new_project_root:
replacements.append((old_project_root, new_project_root))
for database in manifest.get("databases", []):
relative = safe_relative_path(database["path"])
path = temporary / relative
if not path.is_file() or sha256(path) != database.get("sha256"):
raise RuntimeError(f"global database checksum mismatch: {relative}")
migrate_database_paths(path, replacements)
previous = _commit_directory(temporary, target, force)
temporary = Path()
if previous is not None:
print(f"Previous global state retained at: {previous}")
finally:
if temporary and str(temporary) not in {"", "."}:
shutil.rmtree(temporary, ignore_errors=True)
print(f"Restored global state to {target}")
def _replace_text(path: Path, replacements: list[tuple[str, str]]) -> list[str]:
text = path.read_text(encoding="utf-8")
changed: list[str] = []
for old, new in replacements:
if old in text:
text = text.replace(old, new)
changed.append(old)
path.write_text(text, encoding="utf-8")
return changed
def patch_restored_paths(
root: Path,
old_root: str,
new_root: Path,
global_root: Path | None = None,
) -> None:
changes: list[str] = []
env_path = root / ".env"
if env_path.exists() and old_root:
original = root / ".env.archive-original"
if not original.exists():
shutil.copy2(env_path, original)
changes.extend(_replace_text(env_path, [(old_root, str(new_root))]))
if env_path.exists() and global_root is not None:
lines = env_path.read_text(encoding="utf-8").splitlines()
keys = {"UMBRA_GLOBAL_STATE_DIR", "UMBRA_VOICE_REGISTRY_PATH"}
lines = [line for line in lines if line.split("=", 1)[0] not in keys]
lines.extend(
[
f"UMBRA_GLOBAL_STATE_DIR={json.dumps(str(global_root))}",
f"UMBRA_VOICE_REGISTRY_PATH={json.dumps(str(global_root / 'voice_registry.db'))}",
]
)
env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
changes.append(".env global-state settings")
config_path = root / "umbraaudio" / "config.py"
if config_path.exists() and old_root:
text = config_path.read_text(encoding="utf-8")
if "import os\n" not in text:
text = text.replace("import re\n", "import os\nimport re\n", 1)
portable_project_base = (
"PROJECT_BASE = Path(\n"
' os.environ.get("UMBRA_PROJECT_BASE", '
"str(Path(__file__).resolve().parents[1]))\n"
").expanduser()"
)
text = text.replace(
f'PROJECT_BASE = Path("{old_root}")',
portable_project_base,
)
text = text.replace(
f'"{old_root}/models/Qwen3-TTS-12Hz-1.7B-VoiceDesign"',
'str(PROJECT_BASE / "models/Qwen3-TTS-12Hz-1.7B-VoiceDesign")',
)
text = text.replace(
f'"{old_root}/models/Qwen3-TTS-12Hz-1.7B-Base"',
'str(PROJECT_BASE / "models/Qwen3-TTS-12Hz-1.7B-Base")',
)
portable_voice_registry = (
"voice_registry_path: Path = Field(\n"
" default_factory=lambda: Path(\n"
" os.environ.get(\n"
' "UMBRA_VOICE_REGISTRY_PATH",\n'
' str(Path.home() / ".umbra" / "voice_registry.db"),\n'
" )\n"
" ).expanduser()\n"
")"
)
old_voice_field = (
'voice_registry_path: Path = Field(default=Path.home() / ".umbra" / '
'"voice_registry.db")'
)
text = text.replace(old_voice_field, portable_voice_registry)
config_path.write_text(text, encoding="utf-8")
changes.append("umbraaudio/config.py")
library_path = root / "umbraaudio" / "library.py"
if library_path.exists():
text = library_path.read_text(encoding="utf-8")
if "import os\n" not in text:
text = text.replace("import json\n", "import json\nimport os\n", 1)
portable_library_root = (
"def _global_state_root() -> Path:\n"
" return Path(\n"
' os.environ.get("UMBRA_GLOBAL_STATE_DIR", str(Path.home() / ".umbra"))\n'
" ).expanduser()\n"
)
text = text.replace(
'DEFAULT_LIBRARY_DB = Path.home() / ".umbra" / "voice_registry.db"\n'
'DEFAULT_LIBRARY_VOICES = Path.home() / ".umbra" / "library" / "voices"',
portable_library_root
+ 'DEFAULT_LIBRARY_DB = _global_state_root() / "voice_registry.db"\n'
+ 'DEFAULT_LIBRARY_VOICES = _global_state_root() / "library" / "voices"',
)
old_db_getter = "def get_library_db_path() -> Path:\n return DEFAULT_LIBRARY_DB"
new_db_getter = (
"def get_library_db_path() -> Path:\n return _global_state_root() / "
'"voice_registry.db"'
)
text = text.replace(old_db_getter, new_db_getter)
old_voices_getter = (
"def get_library_voices_dir() -> Path:\n return DEFAULT_LIBRARY_VOICES"
)
new_voices_getter = (
"def get_library_voices_dir() -> Path:\n return _global_state_root() / "
'"library" / "voices"'
)
text = text.replace(old_voices_getter, new_voices_getter)
library_path.write_text(text, encoding="utf-8")
changes.append("umbraaudio/library.py")
server_path = root / "scripts" / "qwen_tts_server.py"
if server_path.exists() and old_root:
portable_prompt_dir = (
"str(Path(__file__).resolve().parents[1] / "
'"DealHunterShip_4.0e_whole-to-audio/.umbra/voice_prompts")'
)
portable_voicedesign = (
'str(Path(__file__).resolve().parents[1] / "models/Qwen3-TTS-12Hz-1.7B-VoiceDesign")'
)
portable_base = (
'str(Path(__file__).resolve().parents[1] / "models/Qwen3-TTS-12Hz-1.7B-Base")'
)
replacements = [
(
f'"{old_root}/DealHunterShip_4.0e_whole-to-audio/.umbra/voice_prompts"',
portable_prompt_dir,
),
(
f'"{old_root}/models/Qwen3-TTS-12Hz-1.7B-VoiceDesign"',
portable_voicedesign,
),
(
f'"{old_root}/models/Qwen3-TTS-12Hz-1.7B-Base"',
portable_base,
),
]
changes.extend(_replace_text(server_path, replacements))
storage_path = root / "umbraaudio" / "storage.py"
if storage_path.exists():
text = storage_path.read_text(encoding="utf-8")
if "import os\n" not in text:
text = text.replace("import logging\n", "import logging\nimport os\n", 1)
if old_root:
portable_local_root = (
"DEFAULT_LOCAL_ROOT = Path(os.environ.get("
'"UMBRA_PROJECT_ROOT", str(Path(__file__).resolve().parents[1]))'
").expanduser()"
)
text = text.replace(
f'DEFAULT_LOCAL_ROOT = Path("{old_root}")',
portable_local_root,
)
portable_nas_root = (
"DEFAULT_NAS_ROOT = Path(os.environ.get("
'"UMBRA_NAS_ROOT", "/Volumes/Umbra/UmbraAudio"'
")).expanduser()"
)
text = text.replace(
'DEFAULT_NAS_ROOT = Path("/Volumes/Umbra/UmbraAudio")',
portable_nas_root,
)
storage_path.write_text(text, encoding="utf-8")
changes.append("umbraaudio/storage.py")
if old_root:
escaped_root = json.dumps(str(new_root))[1:-1]
operational_files = [
root / "scripts" / "qwen_tts_server.py",
root / "tts_server" / "qwen_tts_server.py",
root / "Qwen3-TTS" / "qwen_tts" / "cli" / "demo.py",
root / "start_server.sh",
root / "test_voice_clone.py",
]
for operational_path in operational_files:
if operational_path.exists():
_replace_text(operational_path, [(old_root, escaped_root)])
nas_lifecycle = root / "scripts" / "nas_lifecycle.py"
if nas_lifecycle.exists():
text = nas_lifecycle.read_text(encoding="utf-8")
if "import os\n" not in text:
text = text.replace("import logging\n", "import logging\nimport os\n", 1)
text = text.replace(
f'DEFAULT_LOCAL_ROOT = Path("{old_root}")',
"DEFAULT_LOCAL_ROOT = Path(__file__).resolve().parents[1]",
)
portable_nas = (
"DEFAULT_NAS_ROOT = Path(os.environ.get("
'"UMBRA_NAS_ROOT", "/Volumes/Umbra/UmbraAudio"'
")).expanduser()"
)
text = text.replace(
'DEFAULT_NAS_ROOT = Path("/Volumes/Umbra/UmbraAudio")',
portable_nas,
)
text = text.replace(
'MOUNT_SCRIPT = Path.home() / ".umbra/mount_nas.sh"',
'MOUNT_SCRIPT = _global_state_root() / "mount_nas.sh"',
)
text = text.replace(
'CONFIG_PATH = Path.home() / ".umbra/nas_config.json"',
'CONFIG_PATH = _global_state_root() / "nas_config.json"',
)
if "def _global_state_root()" not in text:
global_root_function = (
"def _global_state_root() -> Path:\n"
" return Path(os.environ.get(\n"
' "UMBRA_GLOBAL_STATE_DIR", str(Path.home() / ".umbra")\n'
" )).expanduser()\n\n"
"DEFAULT_LOCAL_ROOT ="
)
text = text.replace(
"DEFAULT_LOCAL_ROOT =",
global_root_function,
1,
)
nas_lifecycle.write_text(text, encoding="utf-8")
changes.append("scripts/nas_lifecycle.py")
sync_voice = root / "tts_server" / "sync_voice_data.sh"
if sync_voice.exists():
text = sync_voice.read_text(encoding="utf-8")
text = text.replace(
'UMBRA_DATA="${UMBRA_DATA_DIR:-/Volumes/1TB_APL_Storage/UmbraAudio}"',
'PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"\n'
'UMBRA_DATA="${UMBRA_DATA_DIR:-$PROJECT_ROOT}"',
)
sync_voice.write_text(text, encoding="utf-8")
changes.append("tts_server/sync_voice_data.sh")
verify_nas = root / "scripts" / "verify_nas.py"
if verify_nas.exists():
text = verify_nas.read_text(encoding="utf-8")
text = text.replace(
f'Path("{old_root}")',
"Path(__file__).resolve().parents[1]",
)
verify_nas.write_text(text, encoding="utf-8")
changes.append("scripts/verify_nas.py")
embeddings = root / "scripts" / "extract_embeddings.py"
if embeddings.exists():
text = embeddings.read_text(encoding="utf-8")
text = text.replace(
f'"{old_root}/models/Qwen3-TTS-12Hz-1.7B-Base"',
'str(project_root / "models/Qwen3-TTS-12Hz-1.7B-Base")',
)
text = text.replace(
f'"{old_root}/models/Qwen3-TTS-12Hz-1.7B-VoiceDesign"',
'str(project_root / "models/Qwen3-TTS-12Hz-1.7B-VoiceDesign")',
)
embeddings.write_text(text, encoding="utf-8")
changes.append("scripts/extract_embeddings.py")
legacy_start = root / "start_server.sh"
if legacy_start.exists():
legacy_start.write_text(
"#!/usr/bin/env bash\n"
"set -Eeuo pipefail\n"
"printf '%s\\n' 'Legacy Gradio demo launcher is disabled in this restore.'\n"
"printf '%s\\n' 'Use scripts/start_web_server.sh or the generated service commands.'\n"
"exit 1\n",
encoding="utf-8",
)
legacy_start.chmod(0o755)
changes.append("start_server.sh")
start_script = root / "scripts" / "start_web_server.sh"
if start_script.exists():
web_command = (
'exec "$PROJ/.venv/bin/python" -c \''
"from pathlib import Path; from umbraaudio.web import create_app; "
"from umbraaudio.config import load_config; import uvicorn; "
'c = load_config(Path(".")); uvicorn.run(create_app(c), '
'host="0.0.0.0", port=8420, log_level="info")\'\n'
)
global_exports = ""
if global_root is not None:
global_exports = (
f"export UMBRA_GLOBAL_STATE_DIR={shlex.quote(str(global_root))}\n"
"export UMBRA_VOICE_REGISTRY_PATH="
f"{shlex.quote(str(global_root / 'voice_registry.db'))}\n"
)
start_script.write_text(
"#!/usr/bin/env bash\n"
"set -Eeuo pipefail\n"
'PROJ="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"\n'
'exec >> "$PROJ/logs/web_server.log" 2>&1\n'
"echo \"[$(date '+%F %T')] web server starting (wrapper pid $$)\"\n"
'cd "$PROJ"\n' + global_exports + web_command,
encoding="utf-8",
)
start_script.chmod(0o755)
changes.append("scripts/start_web_server.sh")
if old_root:
for database in sorted(root.rglob("*.db")):
if not database.is_file() or any(
part in {".venv", ".python"} for part in database.parts
):
continue
if not is_sqlite_file(database):
continue
migrate_project_database(database, old_root, str(new_root))
commands = root / "RESTORED-SERVICE-COMMANDS.md"
web_command = (
"python -c 'from pathlib import Path; from umbraaudio.web import create_app; "
"from umbraaudio.config import load_config; import uvicorn; "
'uvicorn.run(create_app(load_config(Path("."))), host="0.0.0.0", port=8420)\''
)
voicedesign_model = json.dumps(str(new_root / "models/Qwen3-TTS-12Hz-1.7B-VoiceDesign"))
base_model = json.dumps(str(new_root / "models/Qwen3-TTS-12Hz-1.7B-Base"))
global_exports = ""
if global_root is not None:
global_exports = (
f"export UMBRA_GLOBAL_STATE_DIR={shlex.quote(str(global_root))}\n"
"export UMBRA_VOICE_REGISTRY_PATH="
f"{shlex.quote(str(global_root / 'voice_registry.db'))}\n"
)
commands.write_text(
"# Restored UmbraAudio service commands\n\n"
"These commands are generated for the restore location and are not installed "
"automatically.\n\n"
"```bash\n"
f"cd {json.dumps(str(new_root))}\n"
'export PATH="$PWD/.venv/bin:$PATH"\n'
f"export UMBRA_PROJECT_BASE={json.dumps(str(new_root))}\n"
+ global_exports
+ f"{web_command}\n"
f"QWEN_VOICEDESIGN_MODEL={voicedesign_model}\n"
f"QWEN_BASE_MODEL={base_model}\n"
"python scripts/qwen_tts_server.py\n"
"```\n\n"
"The external launchd plist is intentionally not installed automatically; review the "
"context/ directory and adapt paths and permissions first.\n",
encoding="utf-8",
)
(root / "PORTABILITY-PATCH.txt").write_text(
"The following restored-copy portability adjustments were applied:\n"
+ "".join(f"- {item}\n" for item in sorted(set(changes)))
+ "\nThe original .env was preserved as .env.archive-original.\n",
encoding="utf-8",
)
def write_checksum_manifest(root: Path) -> Path:
output = root / "SHA256SUMS"
lines: list[str] = []
for path in sorted(root.rglob("*")):
if not path.is_file() or path == output:
continue
relative = path.relative_to(root).as_posix()
lines.append(f"{sha256(path)} {relative}\n")
output.write_text("".join(lines), encoding="utf-8")
return output
def validate_tree(root: Path, old_root: str | None = None) -> list[str]:
errors: list[str] = []
required = (root / "pyproject.toml", root / "umbraaudio", root / "Qwen3-TTS")
for path in required:
if not path.exists():
errors.append(f"missing required path: {path}")
model = root / "models" / "Qwen3-TTS-12Hz-1.7B-Base" / "model.safetensors"
if not model.exists():
errors.append(f"missing model: {model}")
for database in sorted(root.rglob("*.db")):
if not database.is_file():
continue
if database.stat().st_size == 0:
continue
if not is_sqlite_file(database):
if any(
part in {".mypy_cache", ".pytest_cache", ".ruff_cache"} for part in database.parts
):
continue
errors.append(f"non-SQLite .db file: {database}")
continue
try:
connection = sqlite3.connect(f"file:{database}?mode=ro", uri=True)
result = connection.execute("PRAGMA quick_check").fetchone()[0]
if old_root:
try:
assert_no_old_prefixes(connection, [old_root])
except RuntimeError as exc:
errors.append(str(exc))
connection.close()
if result != "ok":
errors.append(f"SQLite quick_check failed: {database}: {result}")
except Exception as exc: # noqa: BLE001
errors.append(f"SQLite check error: {database}: {exc}")
if old_root:
code_files = [
root / "umbraaudio" / "config.py",
root / "umbraaudio" / "library.py",
root / "umbraaudio" / "storage.py",
root / "scripts" / "nas_lifecycle.py",
root / "scripts" / "verify_nas.py",
root / "scripts" / "extract_embeddings.py",
root / "scripts" / "qwen_tts_server.py",
root / "tts_server" / "qwen_tts_server.py",
root / "tts_server" / "sync_voice_data.sh",
root / "start_server.sh",
]
for code_file in code_files:
if code_file.exists() and old_root in code_file.read_text(encoding="utf-8"):
errors.append(f"old project root remains in {code_file}")
return errors
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
snapshot = sub.add_parser("snapshot-global")
snapshot.add_argument("--source", type=Path, required=True)
snapshot.add_argument("--output", type=Path, required=True)
snapshot.add_argument("--original-root", type=Path, required=True)
restore = sub.add_parser("restore-global")
restore.add_argument("--backup-dir", type=Path, required=True)
restore.add_argument("--target", type=Path, required=True)
restore.add_argument("--force", action="store_true")
restore.add_argument("--old-project-root", default="")
restore.add_argument("--new-project-root", default="")
patch = sub.add_parser("patch-paths")
patch.add_argument("--root", type=Path, required=True)
patch.add_argument("--old-root", required=True)
patch.add_argument("--new-root", type=Path, required=True)
patch.add_argument("--global-state-dir", type=Path)
manifest = sub.add_parser("write-manifest")
manifest.add_argument("--root", type=Path, required=True)
validate = sub.add_parser("validate-tree")
validate.add_argument("--root", type=Path, required=True)
validate.add_argument("--old-root", default="")
args = parser.parse_args()
try:
if args.command == "snapshot-global":
snapshot_global(args.source, args.output, args.original_root)
elif args.command == "restore-global":
restore_global(
args.backup_dir,
args.target,
args.force,
args.old_project_root,
args.new_project_root,
)
elif args.command == "patch-paths":
patch_restored_paths(
args.root,
args.old_root,
args.new_root,
args.global_state_dir,
)
elif args.command == "write-manifest":
write_checksum_manifest(args.root)
elif args.command == "validate-tree":
errors = validate_tree(args.root, args.old_root or None)
if errors:
for error in errors:
print(error, file=sys.stderr)
return 1
print(f"Tree validation passed: {args.root}")
return 0
except Exception as exc: # noqa: BLE001
print(f"ERROR: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())