Blacksite/scripts/editor/ui_gallery.py
Rbanh 53dc1e44d8
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
feat: add production UI gallery and inspector system
2026-07-18 12:05:26 -04:00

367 lines
11 KiB
Python
Executable File

#!/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())