Files
UmbraAudio/scripts/umbra_node_report.py
B.D. Murphy 507ed95424 Declare this machine an Umbra node + cross-machine system contract (spec 21)
A parallel effort is moving UmbraTrans into the main Umbra system on oldbox
(192.168.123.14). This commit makes this machine describable to that system and
records what the system still needs to decide. Everything is a proposal for the
controller to ratify; no architecture is decided here.

specs/21-umbra-system-nodes-tasks-and-changes.md
  Four workstreams, each with what exists, what is missing, and a proposal:
  - WS1 data model alignment / copy / verify / transition. Design is far along
    already (manifest.md + spec 20); the missing piece is ratification.
  - WS2 every machine is a node that gets assigned any task. Job types, leases
    and pools exist (spec 19 §12); the node half does not. Proposes node
    identity, capability advertisement, task/result envelopes, and refusal as a
    valid audited outcome.
  - WS3 cross-product change management. Nothing exists today. Proposes a
    requirement catalog owned by the controller, four change classes, and a
    distribute-then-confirm-with-evidence loop. Includes the worked example of a
    controller-owned entity (designation, pronunciation, voice attribute) that
    must apply across every product.
  - WS4 UI topology. Recommends hybrid: controller gets the system portal,
    the audio review UI stays on the worker because it is artifact-native and
    has no authentication at all. Adds a git-tag deploy + verify + confirm flow.

  Also: 25 questions grouped by who can answer, each naming what it blocks, and
  a task list for this machine (NOW / NEXT / LATER) where every task has a
  machine-checkable acceptance criterion.

infra/umbra-node.json
  This machine's declared profile: node_id umbraaudio-m4, role worker, services,
  capabilities, resource pools, constraints, project list with lifecycle, schema
  fingerprints, and an honest facts_for_the_controller block (empty tables, no
  stable utterance id, no auth, no shared-postgres client). No secrets - ids,
  names, labels and counts only.

scripts/umbra_node_report.py
  Read-only node report. Git state, listening ports, .env KEY NAMES only, per
  project row counts, and a schema fingerprint hashed over sorted
  table.column:type so ALTER TABLE reordering does not read as drift. --json for
  machine consumption; --check compares declared profile to measured reality and
  exits 2 on drift. Drift is deliberately limited to stable facts (contract
  version, project set, schema fingerprints) - git HEAD and live ports are
  reported but never drift.

manifest.md
  Now explicitly a dated measurement with an update log, because other machines
  read it as current. 2026-10-01 refresh: Last Dwarf speakers.json 70 -> 93
  after the designation merge, DealHunter ref WAVs 94 -> 97. Every other §1.1
  count re-verified unchanged.

Measured this machine: 4 projects, 2 distinct schema fingerprints (real
per-project drift), only 4 of 11 tables populated, 461 tests passing / 8 known
pre-existing failures. No UmbraAudio behaviour changed.
2026-10-01 07:14:54 -07:00

406 lines
15 KiB
Python

#!/usr/bin/env python3
"""Read-only Umbra node report: the facts another machine needs to align with.
Every Umbra machine is a *node* in the Umbra system (spec 21). Before any data
model can be aligned, or any task assigned, the controller needs a factual
description of what a node actually holds: its git state, its projects, the
per-project SQLite shape (row counts plus a schema fingerprint), the JSON state
files, and the audio artifact counts.
This script produces exactly that, and nothing else. It is strictly read-only:
it never opens a database for writing, never mutates a project, and never reads
secret values (only the *names* of ``.env`` keys).
Usage:
python scripts/umbra_node_report.py # human-readable
python scripts/umbra_node_report.py --json # machine-readable
python scripts/umbra_node_report.py --check # profile vs reality
python scripts/umbra_node_report.py --project-dir "Last Dwarf"
``--check`` compares ``infra/umbra-node.json`` (the node's declared profile)
against measured reality and exits non-zero on drift. That is the "confirmation"
half of spec 21 §5: a node confirms the profile it advertises. The check is
deliberately limited to *stable* facts -- contract version, project set, and the
per-project schema fingerprints. Volatile facts (git HEAD, which ports happen to
be listening right now) are reported but never treated as drift.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import platform
import socket
import sqlite3
import subprocess
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).parent.parent))
from umbraaudio.config import list_projects, slugify_project_name
REPO_ROOT = Path(__file__).resolve().parent.parent
NODE_PROFILE = REPO_ROOT / "infra" / "umbra-node.json"
# Version of the node-report contract itself. Bump when the shape below changes;
# `infra/umbra-node.json` must declare the same value for `--check` to confirm.
NODE_CONTRACT_VERSION = 1
# Ports that carry meaning for this system. Reported only; never contacted.
MEANINGFUL_PORTS = {
8420: "umbraaudio web UI (FastAPI/HTMX)",
8881: "Qwen3-TTS local API",
18881: "qwen-relay listener (loopback -> LAN GPU box)",
1234: "LM Studio OpenAI-compatible server",
5432: "PostgreSQL (control plane, not hosted here)",
}
# Tables whose row counts matter for alignment. Seven of eleven tables are empty
# in every project today (manifest.md §1.2) - that emptiness is itself a fact
# the shared schema must absorb.
TRACKED_TABLES = (
"project",
"chapters",
"utterances",
"name_tokens",
"characters",
"generated_audio",
"pronunciation_guide",
"quality_reports",
"review_decisions",
"exception_reviews",
"voice_review_history",
)
# JSON state files, classified per manifest.md §0.
STATE_FILES = {
"speakers.json": "AUTHORITATIVE",
"voice_profiles.json": "AUTHORITATIVE",
"voice_status.json": "AUTHORITATIVE",
"voice_engines.json": "AUTHORITATIVE",
"voice_assignments.json": "AUTHORITATIVE (legacy)",
"phonetic_names.json": "AUTHORITATIVE",
"m4b_meta.json": "AUTHORITATIVE",
"analysis_status.json": "CACHE",
"pipeline_status.json": "CACHE",
"generation_status.json": "CACHE",
}
def _run(*cmd: str) -> str | None:
"""Return stdout of a command, or None if it is unavailable/fails."""
try:
out = subprocess.run(
cmd, cwd=REPO_ROOT, capture_output=True, text=True, timeout=20, check=False
)
except (OSError, subprocess.SubprocessError):
return None
return out.stdout.strip() or None
def _git_state() -> dict[str, Any]:
head = _run("git", "rev-parse", "--short", "HEAD")
branch = _run("git", "rev-parse", "--abbrev-ref", "HEAD")
dirty = _run("git", "status", "--porcelain")
changed = [line[3:] for line in (dirty or "").splitlines() if len(line) > 3]
return {
"head": head,
"branch": branch,
"clean": dirty == "",
"uncommitted_files": changed,
}
def _listening_ports() -> dict[str, Any]:
"""Report which meaningful ports this host listens on (best effort)."""
listening: list[int] = []
raw = _run("lsof", "-nP", "-iTCP", "-sTCP:LISTEN")
if raw:
for line in raw.splitlines()[1:]:
for token in line.split():
if token.count(":") == 1:
port_text = token.rsplit(":", 1)[1]
if port_text.isdigit():
listening.append(int(port_text))
return {
"listening": sorted(set(listening)),
"meaningful": {
str(port): role
for port, role in MEANINGFUL_PORTS.items()
if port in listening
},
}
def _env_key_names() -> list[str]:
"""Return ``.env`` KEY NAMES only. Values are never read or reported."""
env_path = REPO_ROOT / ".env"
if not env_path.exists():
return []
names = set()
for line in env_path.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
names.add(line.split("=", 1)[0].strip())
return sorted(names)
def _schema_fingerprint(db: sqlite3.Connection) -> tuple[str, int]:
"""Hash the logical schema (tables + columns + declared types).
``ALTER TABLE`` reorders columns, so the hash is computed over *sorted*
tuples: it detects added/dropped/renamed/retyped columns without reporting
spurious drift from ordering alone.
"""
rows = db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).fetchall()
parts: list[str] = []
for (table,) in rows:
for col in db.execute(f'PRAGMA table_info("{table}")'):
parts.append(f"{table}.{col[1]}:{col[2]}")
digest = hashlib.sha256("\n".join(sorted(parts)).encode()).hexdigest()
return digest[:16], len(parts)
def _project_fingerprint(project_dir: Path) -> dict[str, Any]:
data_dir = project_dir / ".umbra"
db_path = data_dir / "project.db"
report: dict[str, Any] = {
"project_dir": str(project_dir.relative_to(REPO_ROOT)),
"db_exists": db_path.exists(),
"db_size_bytes": db_path.stat().st_size if db_path.exists() else 0,
"state_files": {},
"voice_refs": 0,
"vmatrix_pt": 0,
"voice_embeddings": 0,
"utterance_wavs": 0,
"markers": 0,
"full_stitches": 0,
"exports": [],
}
for name, classification in STATE_FILES.items():
path = data_dir / name
if path.exists():
report["state_files"][name] = {
"classification": classification,
"size_bytes": path.stat().st_size,
}
voices = data_dir / "voices"
if voices.is_dir():
report["voice_refs"] = sum(1 for _ in voices.rglob("*_ref.wav"))
prompts = data_dir / "voice_prompts"
if prompts.is_dir():
report["vmatrix_pt"] = sum(1 for _ in prompts.rglob("*.pt"))
embeddings = data_dir / "voice_embeddings"
if embeddings.is_dir():
report["voice_embeddings"] = sum(1 for _ in embeddings.rglob("*.npy"))
audio = data_dir / "chapter_audio"
if audio.is_dir():
report["utterance_wavs"] = sum(1 for _ in audio.rglob("utt_*.wav"))
report["markers"] = sum(1 for _ in audio.rglob("utt_*.wav.txt"))
report["full_stitches"] = sum(1 for _ in audio.rglob("ch*_full.*"))
export_dir = data_dir / "export"
if export_dir.is_dir():
report["exports"] = sorted(
p.name for p in export_dir.iterdir() if p.is_file() and p.suffix in {".m4b", ".mp3"}
)
if not db_path.exists():
report["schema_fingerprint"] = None
report["tables"] = {}
return report
# Read-only URI: never creates, migrates, or writes. The UmbraTrans spec 18
# §4.2.1 forbids helpers whose side effects touch the source project.
con = sqlite3.connect(f"file:{db_path.resolve()}?mode=ro", uri=True)
try:
fingerprint, column_count = _schema_fingerprint(con)
report["schema_fingerprint"] = fingerprint
report["schema_columns"] = column_count
present = {
row[0]
for row in con.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
}
counts: dict[str, int] = {}
for table in TRACKED_TABLES:
if table not in present:
counts[table] = -1 # table absent = a different schema shape
continue
try:
counts[table] = con.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
except sqlite3.Error:
counts[table] = -2
report["tables"] = counts
finally:
con.close()
return report
def build_report(project_dir: str | None = None) -> dict[str, Any]:
"""Collect the full node report. Read-only."""
projects = list_projects()
if project_dir:
projects = [p for p in projects if p["slug"] == project_dir] or [
{"slug": slugify_project_name(project_dir), "name": project_dir}
]
fingerprints = [
_project_fingerprint(REPO_ROOT / p["slug"])
for p in projects
if (REPO_ROOT / p["slug"]).is_dir()
]
report: dict[str, Any] = {
"report_version": NODE_CONTRACT_VERSION,
"node_contract_version": NODE_CONTRACT_VERSION,
"generated_at": datetime.now(UTC).isoformat(timespec="seconds"),
"node_profile_path": str(NODE_PROFILE.relative_to(REPO_ROOT)),
"host": {
"hostname": socket.gethostname(),
"platform": platform.platform(),
"python": platform.python_version(),
},
"git": _git_state(),
"listening": _listening_ports(),
"env_key_names": _env_key_names(),
"project_count": len(projects),
"projects": fingerprints,
"schema_fingerprint_set": sorted(
{p["schema_fingerprint"] for p in fingerprints if p["schema_fingerprint"]}
),
}
# Facts the controller must know that are *not* derivable from the repo.
report["facts"] = {
"project_base_hard_coded": True,
"project_base_value": "/Volumes/1TB_APL_Storage/UmbraAudio",
"authoritative_sql_tables": ["project", "chapters", "utterances", "name_tokens"],
"empty_sql_tables": [
"characters",
"generated_audio",
"pronunciation_guide",
"quality_reports",
"review_decisions",
"voice_review_history",
"exception_reviews",
],
"speaker_identity_lives_in": "speakers.json + voice_profiles.json + ref WAVs",
"audio_record_lives_in": "filesystem + *.wav.txt markers",
"authentication": "none; active_project is an unsigned browser cookie",
"shared_database_present_here": False,
"secret_values_reported": False,
}
return report
def check_profile(report: dict[str, Any]) -> list[str]:
"""Compare the declared node profile with measured reality.
Only stable facts are checked (contract version, project set, schema
fingerprints). Returns a list of drift messages; empty means confirmed.
"""
if not NODE_PROFILE.exists():
return [f"missing node profile: {NODE_PROFILE.relative_to(REPO_ROOT)}"]
declared = json.loads(NODE_PROFILE.read_text(encoding="utf-8"))
drift: list[str] = []
declared_version = declared.get("node_contract_version")
if declared_version != NODE_CONTRACT_VERSION:
drift.append(
f"node_contract_version declared {declared_version} != supported "
f"{NODE_CONTRACT_VERSION}"
)
declared_projects = set(declared.get("projects", []))
measured_projects = {p["project_dir"] for p in report["projects"]}
if declared_projects != measured_projects:
drift.append(
f"projects declared {sorted(declared_projects)} != measured {sorted(measured_projects)}"
)
if declared.get("schema_fingerprints"):
declared_set = sorted(declared["schema_fingerprints"])
if declared_set != report["schema_fingerprint_set"]:
drift.append(
f"schema fingerprints declared {declared_set} != measured "
f"{report['schema_fingerprint_set']}"
)
return drift
def _print_human(report: dict[str, Any], drift: list[str], checked: bool) -> None:
host = report["host"]
git = report["git"]
print("UMBRA NODE REPORT (read-only)")
print(f" generated_at {report['generated_at']}")
print(f" host {host['hostname']} {host['platform']}")
dirty_note = "clean" if git["clean"] else f"{len(git['uncommitted_files'])} uncommitted"
print(f" python {host['python']}")
print(f" git {git['branch']} @ {git['head']} ({dirty_note})")
print(f" listening {report['listening']['meaningful'] or 'no known ports'}")
print(f" env keys {len(report['env_key_names'])} (names only, no values)")
print(f" schema fingerprints {report['schema_fingerprint_set']}")
print("\n projects:")
for proj in report["projects"]:
populated = {t: n for t, n in proj["tables"].items() if n > 0}
empty = [t for t, n in proj["tables"].items() if n == 0]
absent = [t for t, n in proj["tables"].items() if n == -1]
print(f" - {proj['project_dir']} db={proj['db_size_bytes']}B "
f"schema={proj['schema_fingerprint']}")
print(f" populated: {populated}")
if empty:
print(f" empty: {empty}")
if absent:
print(f" absent: {absent}")
print(f" audio: refs={proj['voice_refs']} vmatrix={proj['vmatrix_pt']} "
f"wavs={proj['utterance_wavs']} markers={proj['markers']} "
f"stitches={proj['full_stitches']} exports={proj['exports']}")
print("\n system facts:")
for key, value in report["facts"].items():
print(f" {key}: {value}")
if drift:
print("\n PROFILE DRIFT (declared profile does not match measured reality):")
for d in drift:
print(f" ! {d}")
elif checked:
print("\n profile check: CONFIRMED (declared profile matches measured reality)")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json", action="store_true", help="machine-readable output")
parser.add_argument("--check", action="store_true",
help="compare infra/umbra-node.json to reality; exit 2 on drift")
parser.add_argument("--project-dir", default=None, help="limit to one project directory")
args = parser.parse_args()
report = build_report(project_dir=args.project_dir)
drift = check_profile(report) if args.check else []
if args.json:
report["profile_drift"] = drift
report["profile_checked"] = args.check
print(json.dumps(report, indent=2, sort_keys=True))
else:
_print_human(report, drift, args.check)
return 2 if drift else 0
if __name__ == "__main__":
raise SystemExit(main())