367 lines
11 KiB
Python
Executable File
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())
|