#!/usr/bin/env python3 """Build, launch, and automatically restart the lightweight Blacksite UI gallery.""" from __future__ import annotations import argparse import hashlib import json import os from pathlib import Path import signal import subprocess import sys import tempfile import time from typing import Any, Iterable, Sequence ROOT = Path(__file__).resolve().parents[2] CARGO_LANE = ROOT / "scripts/codex/cargo_lane.py" BUILD_STORAGE = ROOT / "scripts/codex/build_storage.py" LOG_DIR = ROOT / ".codex/logs" BUILD_GRAPH_CACHE = LOG_DIR / "ui-gallery-build-units.json" METADATA_LIMIT_BYTES = 32 * 1024 * 1024 POLL_SECONDS = 0.25 DEBOUNCE_SECONDS = 0.35 WATCH_ROOTS = ( ROOT / "crates/editor_ui/src", ROOT / "crates/material_schema/src", ROOT / "crates/ui_gallery/src", ) WATCH_FILES = ( ROOT / "Cargo.toml", ROOT / "Cargo.lock", ROOT / "crates/editor_ui/Cargo.toml", ROOT / "crates/material_schema/Cargo.toml", ROOT / "crates/ui_gallery/Cargo.toml", ) def managed_command(*command: str) -> list[str]: return [sys.executable, str(CARGO_LANE), "exec", "dev", *command] def storage_enforce(phase: str) -> int: return subprocess.run( [sys.executable, str(BUILD_STORAGE), "enforce", "--phase", phase], cwd=ROOT, check=False, ).returncode def host_target() -> str: completed = subprocess.run( ["rustc", "-vV"], cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False, ) for line in completed.stdout.splitlines(): if line.startswith("host:"): return line.partition(":")[2].strip() return "" def bounded_command_output(command: Sequence[str], limit: int) -> bytes: """Capture a known-small machine document without ever buffering unbounded stdout.""" with tempfile.TemporaryFile() as output, tempfile.TemporaryFile() as errors: with subprocess.Popen( command, cwd=ROOT, stdout=subprocess.PIPE, stderr=errors, ) as process: assert process.stdout is not None size = 0 while chunk := process.stdout.read(64 * 1024): size += len(chunk) if size > limit: process.kill() process.wait() raise RuntimeError( f"machine output exceeded the {limit // (1024 * 1024)} MiB safety cap" ) output.write(chunk) return_code = process.wait() if return_code != 0: errors.seek(0) detail = errors.read()[-4000:].decode("utf-8", errors="replace") raise RuntimeError(f"command failed ({return_code}): {detail}") output.seek(0) return output.read() def build_graph_key() -> str: digest = hashlib.sha256() for path in WATCH_FILES: if path.is_file(): digest.update(path.relative_to(ROOT).as_posix().encode("utf-8")) digest.update(path.read_bytes()) digest.update(host_target().encode("utf-8")) return digest.hexdigest() def cached_build_graph(key: str) -> set[str] | None: try: document = json.loads(BUILD_GRAPH_CACHE.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return None package_ids = document.get("package_ids") if document.get("key") != key or not isinstance(package_ids, list): return None if not all(isinstance(package_id, str) for package_id in package_ids): return None return set(package_ids) def save_build_graph(key: str, package_ids: set[str]) -> None: BUILD_GRAPH_CACHE.parent.mkdir(parents=True, exist_ok=True) BUILD_GRAPH_CACHE.write_text( json.dumps( {"key": key, "package_ids": sorted(package_ids)}, indent=2, ) + "\n", encoding="utf-8", ) def compiler_artifact(message: object) -> tuple[str, str, bool] | None: if not isinstance(message, dict) or message.get("reason") != "compiler-artifact": return None package_id = message.get("package_id") if not isinstance(package_id, str): return None target = message.get("target") if isinstance(target, dict) and isinstance(target.get("name"), str): target_name = target["name"] else: target_name = package_id.rsplit("#", 1)[-1] return package_id, target_name, bool(message.get("fresh", False)) def diagnostic_lines(message: object, raw_line: str) -> Iterable[str]: if not isinstance(message, dict): yield raw_line return if message.get("reason") == "compiler-message": diagnostic = message.get("message") if isinstance(diagnostic, dict): rendered = diagnostic.get("rendered") if isinstance(rendered, str) and rendered: yield rendered.rstrip("\n") def build_gallery() -> int: graph_key = build_graph_key() graph = cached_build_graph(graph_key) total = len(graph) if graph else None LOG_DIR.mkdir(parents=True, exist_ok=True) log_path = LOG_DIR / "ui-gallery-build.log" command = managed_command( "cargo", "build", "-p", "ui_gallery", "--message-format=json-render-diagnostics", "--color", "never", ) process = subprocess.Popen( command, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, ) assert process.stdout is not None completed: set[str] = set() rebuilt: set[str] = set() last_progress_bucket = -1 with log_path.open("w", encoding="utf-8") as log: for raw_line in process.stdout: stripped = raw_line.rstrip("\n") try: message: object = json.loads(stripped) except json.JSONDecodeError: message = stripped for diagnostic in diagnostic_lines(message, stripped): log.write(diagnostic + "\n") log.flush() artifact = compiler_artifact(message) if artifact is None: continue package_id, target_name, fresh = artifact if not fresh: rebuilt.add(package_id) if package_id in completed: continue completed.add(package_id) if graph is None: in_graph = len(completed) if in_graph != 1 and in_graph % 5 != 0: continue counter = f"{in_graph}/? discovering · {len(rebuilt)} rebuilt" else: in_graph = len(completed.intersection(graph)) percent = min(100, (in_graph * 100) // max(1, total)) progress_bucket = percent // 5 if progress_bucket == last_progress_bucket and in_graph != total: continue last_progress_bucket = progress_bucket counter = f"{min(in_graph, total)}/{total} resolved · {len(rebuilt)} rebuilt" print( f"\rResolving ui_gallery ({counter}) {target_name:<36}", end="", flush=True, ) return_code = process.wait() print() if return_code == 0: save_build_graph(graph_key, completed) expected = len(graph) if graph else len(completed) resolved = len(completed.intersection(graph)) if graph else len(completed) print( f"ui_gallery ready ({resolved}/{expected} resolved · " f"{len(rebuilt)} rebuilt)" ) else: print(f"ui_gallery build failed; see {log_path}", file=sys.stderr) return return_code def lane_runtime_environment() -> dict[str, str]: completed = subprocess.run( [sys.executable, str(CARGO_LANE), "env", "--json", "dev"], cwd=ROOT, text=True, stdout=subprocess.PIPE, check=True, ) document = json.loads(completed.stdout) environment = os.environ.copy() environment.update(document.get("environment", {})) runtime_deps = document.get("runtime_deps") if isinstance(runtime_deps, str) and runtime_deps: previous = environment.get("LD_LIBRARY_PATH") environment["LD_LIBRARY_PATH"] = ( runtime_deps if not previous else runtime_deps + os.pathsep + previous ) return environment def gallery_binary() -> Path: completed = subprocess.run( [sys.executable, str(CARGO_LANE), "env", "--json", "dev"], cwd=ROOT, text=True, stdout=subprocess.PIPE, check=True, ) document = json.loads(completed.stdout) return Path(document["target_dir"]) / "debug/ui_gallery" def launch_gallery() -> subprocess.Popen[bytes]: binary = gallery_binary() if not binary.is_file(): raise RuntimeError(f"gallery binary is missing after build: {binary}") return subprocess.Popen([str(binary)], cwd=ROOT, env=lane_runtime_environment()) def stop_gallery(process: subprocess.Popen[bytes] | None) -> None: if process is None or process.poll() is not None: return process.send_signal(signal.SIGTERM) try: process.wait(timeout=3.0) except subprocess.TimeoutExpired: process.kill() process.wait() def rebuild_and_replace_gallery( current: subprocess.Popen[bytes] | None, ) -> subprocess.Popen[bytes] | None: """Keep the last-good gallery alive until its replacement has built and launched.""" if build_gallery() != 0: return current replacement = launch_gallery() stop_gallery(current) return replacement def source_snapshot() -> dict[Path, int]: files = list(WATCH_FILES) for root in WATCH_ROOTS: if root.is_dir(): files.extend(root.rglob("*.rs")) return { path: path.stat().st_mtime_ns for path in files if path.is_file() } def watch() -> int: if storage_enforce("pre") != 0: return 1 child: subprocess.Popen[bytes] | None = None try: if build_gallery() != 0: return 1 child = launch_gallery() print("Watching material_schema, editor_ui, and ui_gallery Rust sources (Ctrl+C to stop)") snapshot = source_snapshot() changed_at: float | None = None while True: time.sleep(POLL_SECONDS) current = source_snapshot() if current != snapshot: snapshot = current changed_at = time.monotonic() if changed_at is None or time.monotonic() - changed_at < DEBOUNCE_SECONDS: continue changed_at = None child = rebuild_and_replace_gallery(child) except KeyboardInterrupt: print("\nStopping ui_gallery") return 0 finally: stop_gallery(child) storage_enforce("post") def run_once(*, build_only: bool) -> int: if storage_enforce("pre") != 0: return 1 try: result = build_gallery() if result != 0 or build_only: return result return launch_gallery().wait() finally: storage_enforce("post") def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "command", choices=("watch", "run", "build"), nargs="?", default="watch", ) arguments = parser.parse_args() if arguments.command == "watch": return watch() return run_once(build_only=arguments.command == "build") if __name__ == "__main__": raise SystemExit(main())