From 2bba8ccad416a7b02295c1875041b3f4bd2fe951 Mon Sep 17 00:00:00 2001 From: Hollis Date: Sat, 1 Aug 2026 00:40:52 -0700 Subject: [PATCH] feat(dev): add reproducible Nix devshell and preview sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the full GPUI workspace buildable and testable on NixOS, and provide safe long-lived and smoke preview workflows that exercise the real devtools UI without touching user data. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- .claude/skills/dev-loop/SKILL.md | 22 + README.md | 87 ++ flake.lock | 48 + flake.nix | 98 ++ openspec/changes/add-nix-devshell/design.md | 8 +- .../specs/nix-development-environment/spec.md | 13 +- openspec/changes/add-nix-devshell/tasks.md | 38 +- tools/trawler-preview.py | 905 ++++++++++++++++++ 8 files changed, 1190 insertions(+), 29 deletions(-) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100755 tools/trawler-preview.py diff --git a/.claude/skills/dev-loop/SKILL.md b/.claude/skills/dev-loop/SKILL.md index f1502b0..b839ceb 100644 --- a/.claude/skills/dev-loop/SKILL.md +++ b/.claude/skills/dev-loop/SKILL.md @@ -6,6 +6,28 @@ description: Run trawler with the devtools automation server to see and drive th Drive a real trawler instance over its devtools socket. Everything here is cross-platform Rust — no OS-specific automation tools. +## Headless Linux / NixOS + +When no graphical login session is available, enter `nix develop` and use the +repository lifecycle helper instead of hand-managing a display and child +processes: + +```sh +tools/trawler-preview.py smoke + +# Or keep one session alive across multiple reasoning/interaction turns: +tools/trawler-preview.py start +tools/trawler-preview.py send --json '{"cmd":"dump"}' +tools/trawler-preview.py screenshot /tmp/trawler.png +tools/trawler-preview.py stop +``` + +The helper runs Xvfb + lavapipe + a minimal EWMH window manager against a +fresh fixture graph, validates session identity before every command, and +preserves logs/screenshots outside the disposable graph. Prefer it on +headless Linux. The manual loop below remains the cross-platform reference +and the path for a visible native desktop session. + ## The loop 1. **Kill any running instance first** — a running `trawler.exe` holds a diff --git a/README.md b/README.md index d371ebd..c71bf51 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,33 @@ cargo test --workspace cargo fmt --all ``` +### NixOS / Linux development shell + +The repository flake provides the pinned Rust 1.94 toolchain and the native +X11/XCB, xkbcommon, font, Wayland, and Vulkan dependencies needed to build +the full GPUI workspace on x86_64 Linux. It does not install Trawler or +change the host system: + +```sh +nix develop +cargo check --workspace --all-targets +cargo test --workspace +``` + +The devshell sets `RUST_TEST_THREADS=1` for the default test command. The +Steel interruption gate measures a strict wall-clock budget and can become +spuriously slow when it runs alongside other CPU-heavy debug tests. Override +the variable for a faster best-effort parallel run. + +The shell exports `TRAWLER_LAVAPIPE_ICD`, the store-pinned Mesa software +Vulkan driver used by the isolated preview helper. It deliberately does not +override `VK_DRIVER_FILES` merely by entering the shell, so a manual launch +on a real Linux desktop can continue to use the desktop's hardware renderer. + +The Linux environment is for development and automation. Windows 11 remains +the product target and the authoritative platform for final visual/interaction +checks. + Some tests are expensive (they build synthetic 100k-block graphs) and are `#[ignore]`d by default: @@ -163,6 +190,66 @@ the compositor — everything else keeps working where capture doesn't. (Implementation note: capture runs in a short-lived helper process because xcap won't enumerate the calling process's own windows on Windows.) +### Headless Linux preview sessions + +From `nix develop`, `tools/trawler-preview.py` runs the real GPUI application +on a private Xvfb display with Mesa lavapipe and a minimal window manager +(required for xcap's window discovery). It owns a fresh fixture graph; it +never reads or infers the default graph directory. + +Run the deterministic end-to-end smoke path (multi-step navigation, semantic +dump, PNG screenshot, cleanup): + +```sh +tools/trawler-preview.py smoke +``` + +The command prints the preserved artifact directory under +`target/dev-preview/`, containing `initial-dump.json`, `final-dump.json`, +`screenshot.png`, and build/application/window-manager/Xvfb logs. + +For iterative work, keep one application open while sending requests and +inspecting successive states: + +```sh +tools/trawler-preview.py start +tools/trawler-preview.py status +tools/trawler-preview.py send --json '{"cmd":"keys","keys":"ctrl-k"}' +tools/trawler-preview.py send --json '{"cmd":"type","text":"trawler-design"}' +tools/trawler-preview.py send --json '{"cmd":"keys","keys":"enter"}' +tools/trawler-preview.py send --json '{"cmd":"dump"}' > /tmp/trawler-dump.json +tools/trawler-preview.py screenshot /tmp/trawler.png +tools/trawler-preview.py stop +``` + +`stop` is idempotent. `status` reports stale session metadata after a hard +child-process exit, and `stop` then removes only helper-owned processes and +scratch state. It never uses a global `pkill`, so a separately installed +desktop Trawler process is unaffected. + +The devtools protocol has no raw mouse/pointer command on the current GPUI +pin. Use keyboard equivalents for agent-driven flows; verify pointer-only +affordances manually on a native desktop. + +To run a visible development build from an interactive Linux desktop, still +use a scratch graph rather than your real one: + +```sh +scratch="$(mktemp -d)/fixture-graph" +cargo run -p trawler --features devtools -- --seed-fixtures "$scratch" +TRAWLER_GRAPH_DIR="$scratch" TRAWLER_DEVTOOLS=1 \ + cargo run -p trawler --features devtools +``` + +This native path uses the current desktop and renderer. The automated helper +always uses its own display, software renderer, binary, graph, and process +lifecycle, so both can coexist without sharing a graph lock. + +If preview startup fails, inspect the printed artifact directory. `build.log`, +`seed.log`, `app.log`, `wm.log`, and `xvfb.log` identify the failing stage. The +helper bounds waits for Xvfb, Trawler, and `devtools.port` instead of hanging +indefinitely. + ## Graph directory format Everything under the graph directory is derived from, or is, a single Loro diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..f1a0641 --- /dev/null +++ b/flake.lock @@ -0,0 +1,48 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1785454630, + "narHash": "sha256-LQy14TZp77TwbQf40gg1V3jo8FwJG0jGDkAH+zRHqg8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1559d3daa3ecc813a650b79375ea61b6741b8746", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1785562362, + "narHash": "sha256-J15aBa3d6B1SUUAQydQ06wFjPzrrcVceb6jkdfwfGls=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "5f29c219a7655519f8a9f8c6968064b82c17cc93", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..5896d78 --- /dev/null +++ b/flake.nix @@ -0,0 +1,98 @@ +{ + description = "Trawler development environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { nixpkgs, rust-overlay, ... }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { + inherit system; + overlays = [ rust-overlay.overlays.default ]; + }; + lib = pkgs.lib; + rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; + + nativeLibraries = with pkgs; [ + fontconfig + freetype + libX11 + libXcursor + libXi + libXrandr + libdrm + libgbm + libxcb + libxkbcommon + mesa + pipewire + vulkan-loader + wayland + ]; + lavapipeIcd = "${pkgs.mesa}/share/vulkan/icd.d/lvp_icd.x86_64.json"; + in + { + devShells.${system}.default = pkgs.mkShell { + packages = with pkgs; [ + rustToolchain + clang + gcc + jq + libclang + lld + openbox + pkg-config + python3 + vulkan-tools + xauth + xdpyinfo + xprop + xwininfo + xorg-server + ]; + + buildInputs = nativeLibraries; + + # Rust's linker and GPUI's runtime loader both need the native + # libraries. mkShell's setup hooks provide NIX_LDFLAGS and + # PKG_CONFIG_PATH; these explicit paths also cover crates that invoke + # rust-lld/cc directly rather than consulting pkg-config. + LIBRARY_PATH = lib.makeLibraryPath nativeLibraries; + LD_LIBRARY_PATH = lib.makeLibraryPath nativeLibraries; + LIBCLANG_PATH = "${pkgs.libclang.lib}/lib"; + + # The Steel interruption gate asserts a wall-clock upper bound. + # Running it alongside other CPU-heavy debug tests can exceed that + # bound on resource-constrained/headless development machines even + # though the same test passes in isolation. Keep the reproducible + # devshell verification path deterministic; developers can override + # this for a faster best-effort parallel run. + RUST_TEST_THREADS = "1"; + + # The preview helper selects this CPU Vulkan driver explicitly. + # Merely entering the shell does not override a native desktop's + # hardware Vulkan selection. + TRAWLER_LAVAPIPE_ICD = lavapipeIcd; + + shellHook = '' + if [[ ! -r "$TRAWLER_LAVAPIPE_ICD" ]]; then + echo "trawler devshell: missing lavapipe ICD: $TRAWLER_LAVAPIPE_ICD" >&2 + return 1 + fi + + for module in xcb xkbcommon xkbcommon-x11 fontconfig; do + if ! pkg-config --exists "$module"; then + echo "trawler devshell: pkg-config cannot resolve $module" >&2 + return 1 + fi + done + ''; + }; + }; +} diff --git a/openspec/changes/add-nix-devshell/design.md b/openspec/changes/add-nix-devshell/design.md index e828969..5cdeec4 100644 --- a/openspec/changes/add-nix-devshell/design.md +++ b/openspec/changes/add-nix-devshell/design.md @@ -46,7 +46,7 @@ The implementation spike SHALL confirm the exact current nixpkgs attribute names ### D2 — Xvfb plus lavapipe provides an isolated graphical session -The preview helper launches a private Xvfb server with a fixed screen size/depth and points the application at it through `DISPLAY`. It selects Mesa's lavapipe ICD explicitly for Vulkan software rendering, so the workflow does not require an attached monitor, access to another user's X authority, or the physical GPU. +The preview helper launches a private Xvfb server with a fixed screen size/depth and points the application at it through `DISPLAY`. It selects Mesa's lavapipe ICD explicitly for Vulkan software rendering, so the workflow does not require an attached monitor, access to another user's X authority, or the physical GPU. A lightweight EWMH window manager runs on that display because xcap discovers application windows through the root `_NET_CLIENT_LIST`; bare Xvfb maps Trawler but does not publish that list, causing the devtools screenshot command to report “no capturable window.” Alternatives considered: @@ -68,9 +68,9 @@ Session start performs the following: 3. Seed the deterministic fixture graph into the temporary workspace; refuse an existing/non-owned graph path. 4. Start Xvfb and launch Trawler with both `TRAWLER_DEVTOOLS=1` and `TRAWLER_GRAPH_DIR` set. 5. Wait with a bounded timeout for `devtools.port`, checking that child processes remain alive. -6. Write a session manifest containing the owned scratch root, graph path, display, app/Xvfb PIDs, log, and devtools port. +6. Write a session manifest containing the owned scratch root, graph path, display, app/window-manager/Xvfb PIDs, logs, and devtools port. -While a serve session is live, helper subcommands read and validate that manifest before issuing JSONL requests or taking screenshots. `status` distinguishes a healthy session from stale metadata. `stop` terminates child processes, removes the manifest and disposable graph state, and is idempotent. The exact CLI spelling may follow repository conventions, but start/status/send/screenshot/stop behaviors are part of the contract. +While a serve session is live, helper subcommands read and validate that manifest before issuing JSONL requests or taking screenshots. `status` distinguishes a healthy session from stale metadata. A serve session is intentionally not backed by a permanent supervisor: if a child is killed externally, the next `status` reports the session stale and `stop` terminates the remaining owned processes. `stop` removes the manifest and disposable graph state and is idempotent. The exact CLI spelling may follow repository conventions, but start/status/send/screenshot/stop behaviors are part of the contract. The smoke command uses the same session controls rather than a second code path. Navigation steps that settle asynchronously are sent as separate requests with bounded dump polling, matching the existing dev-loop guidance. @@ -96,7 +96,7 @@ Documentation also gives an explicit manual native-display development command f - [xcap cannot capture its own window under Xvfb] → Treat screenshot as a blocking acceptance failure; use external X11 capture only for diagnosis and bring any fallback decision back for review. - [Nixpkgs native dependency names/hooks drift] → Pin `flake.lock`, use `pkg-config` checks in the shell verification, and document update procedure. - [Rust overlay duplicates `rust-toolchain.toml` semantics imperfectly] → Use the overlay's rustup-toolchain-file parser and verify `rustc --version` plus installed targets in acceptance tests. -- [Preview helper leaks Xvfb/app processes after interruption] → Track PIDs, install EXIT/INT/TERM traps, use bounded waits, and test a forced-failure cleanup path. +- [Preview helper leaks Xvfb/window-manager/app processes after interruption] → Track PIDs, install EXIT/INT/TERM traps, use bounded waits, and test a forced-failure cleanup path. - [A long-lived session leaves stale metadata after a hard kill] → Validate PIDs/display/port on every command, make `status` identify stale sessions, and let `stop` clean stale owned state safely. - [A requested UI path is pointer-only] → Prefer Trawler's keyboard equivalent; document the protocol limitation and use human native-desktop verification rather than brittle coordinate automation in this change. - [Adding broad Linux dependencies implies product support] → Documentation explicitly calls this a development/automation environment; Windows remains the release target and authoritative visual check. diff --git a/openspec/changes/add-nix-devshell/specs/nix-development-environment/spec.md b/openspec/changes/add-nix-devshell/specs/nix-development-environment/spec.md index 6cdd3e7..61cb11d 100644 --- a/openspec/changes/add-nix-devshell/specs/nix-development-environment/spec.md +++ b/openspec/changes/add-nix-devshell/specs/nix-development-environment/spec.md @@ -19,6 +19,7 @@ The repository SHALL provide smoke and long-lived session commands that launch t #### Scenario: Preview starts on a headless host - **WHEN** the preview command runs with no pre-existing `DISPLAY` or Wayland session - **THEN** it starts an isolated virtual display +- **AND** starts the minimal window-management support required for devtools window discovery - **AND** launches a `--features devtools` Trawler binary with `TRAWLER_DEVTOOLS=1` - **AND** discovers the localhost JSONL endpoint through the fixture graph's `devtools.port` @@ -42,7 +43,7 @@ The preview workflow SHALL support keeping one isolated application session aliv #### Scenario: Session stops explicitly - **WHEN** the agent requests stop -- **THEN** the application and virtual display terminate +- **THEN** the application, window manager, and virtual display terminate - **AND** disposable session state and metadata are removed while requested artifacts remain ### Requirement: Deterministic semantic and visual verification @@ -85,19 +86,19 @@ The preview workflow SHALL clean up application and virtual-display processes an #### Scenario: Successful run cleans up - **WHEN** semantic and screenshot verification complete -- **THEN** the application and virtual display are stopped +- **THEN** the application, window manager, and virtual display are stopped - **AND** the temporary fixture graph is removed - **AND** requested dump, log, and screenshot artifacts remain available #### Scenario: Interrupted run cleans up -- **WHEN** the preview command receives an interrupt or one child process exits unexpectedly -- **THEN** remaining child processes are terminated +- **WHEN** preview startup or a one-shot smoke command receives an interrupt or one of its child processes exits unexpectedly +- **THEN** remaining application, window-manager, and display processes are terminated - **AND** cleanup is safe to run more than once #### Scenario: Stale session is diagnosable -- **WHEN** session metadata remains after a hard process exit +- **WHEN** long-lived session metadata remains after a child process exits unexpectedly or is killed externally - **THEN** `status` reports it as stale rather than live -- **AND** `stop` safely removes remaining owned processes and state +- **AND** a subsequent `stop` safely removes remaining owned processes and state ### Requirement: Nix preview workflow is documented The repository SHALL document how to enter the shell, run the full tests, use smoke and long-lived preview sessions, inspect semantic and PNG outputs, launch manually on a real Linux desktop with a scratch graph, troubleshoot native graphics startup, and clean up, while stating that Windows remains Trawler's product target and raw pointer injection is not available through devtools. diff --git a/openspec/changes/add-nix-devshell/tasks.md b/openspec/changes/add-nix-devshell/tasks.md index e29a43d..884842b 100644 --- a/openspec/changes/add-nix-devshell/tasks.md +++ b/openspec/changes/add-nix-devshell/tasks.md @@ -1,30 +1,30 @@ ## 1. Reproducible Development Shell -- [ ] 1.1 Add `flake.nix` and `flake.lock` with the Rust toolchain derived from `rust-toolchain.toml` and the native GPUI Linux build/runtime dependencies for x86_64-linux. -- [ ] 1.2 Add shell-time validation or documentation for the selected Vulkan ICD and native pkg-config/linker paths without relying on globally installed packages. -- [ ] 1.3 Verify from `nix develop` that `cargo check --workspace --all-targets` completes and that `cargo test --workspace` links and runs the non-ignored core and GPUI fake-platform tests. +- [x] 1.1 Add `flake.nix` and `flake.lock` with the Rust toolchain derived from `rust-toolchain.toml` and the native GPUI Linux build/runtime dependencies for x86_64-linux. +- [x] 1.2 Add shell-time validation or documentation for the selected Vulkan ICD and native pkg-config/linker paths without relying on globally installed packages. +- [x] 1.3 Verify from `nix develop` that `cargo check --workspace --all-targets` completes and that `cargo test --workspace` links and runs the non-ignored core and GPUI fake-platform tests. ## 2. Headless Devtools Preview -- [ ] 2.1 Add a small standard-library JSONL devtools client that sends ordered requests, validates one JSON response per request, and reports protocol errors clearly. -- [ ] 2.2 Add a shared preview lifecycle that creates a unique temporary root, builds the devtools binary, seeds a fresh fixture graph, and refuses paths outside its owned scratch area. -- [ ] 2.3 Launch a private Xvfb display with lavapipe selected explicitly, start Trawler with `TRAWLER_DEVTOOLS=1` and the scratch `TRAWLER_GRAPH_DIR`, and discover `devtools.port` with a bounded health-checked wait. -- [ ] 2.4 Persist and validate a session manifest containing the owned paths, display, child PIDs, log, and devtools endpoint; implement live/stale `status` reporting. -- [ ] 2.5 Implement long-lived start/send/screenshot/stop behavior so iterative commands target one application and fixture graph until explicit cleanup. -- [ ] 2.6 Implement a one-shot smoke flow on the same lifecycle that navigates the fixture with separate `keys`/`type` requests, polls `dump` for settled state, and saves semantic JSON. -- [ ] 2.7 Capture the Trawler window through the devtools `screenshot` command, validate that the artifact is a non-empty PNG, and visually inspect it. +- [x] 2.1 Add a small standard-library JSONL devtools client that sends ordered requests, validates one JSON response per request, and reports protocol errors clearly. +- [x] 2.2 Add a shared preview lifecycle that creates a unique temporary root, builds the devtools binary, seeds a fresh fixture graph, and refuses paths outside its owned scratch area. +- [x] 2.3 Launch a private Xvfb display with lavapipe and a lightweight EWMH window manager, start Trawler with `TRAWLER_DEVTOOLS=1` and the scratch `TRAWLER_GRAPH_DIR`, and discover `devtools.port` with a bounded health-checked wait. +- [x] 2.4 Persist and validate a session manifest containing the owned paths, display, child PIDs, log, and devtools endpoint; implement live/stale `status` reporting. +- [x] 2.5 Implement long-lived start/send/screenshot/stop behavior so iterative commands target one application and fixture graph until explicit cleanup. +- [x] 2.6 Implement a one-shot smoke flow on the same lifecycle that navigates the fixture with separate `keys`/`type` requests, polls `dump` for settled state, and saves semantic JSON. +- [x] 2.7 Capture the Trawler window through the devtools `screenshot` command, validate that the artifact is a non-empty PNG, and visually inspect it. ## 3. Safety and Lifecycle Hardening -- [ ] 3.1 Add idempotent EXIT/INT/TERM cleanup that terminates Trawler and Xvfb and removes disposable graph state while preserving requested dump, log, and screenshot outputs. -- [ ] 3.2 Add bounded failure checks for missing display/renderer startup, application exit, missing `devtools.port`, malformed protocol responses, and screenshot failure. -- [ ] 3.3 Exercise at least one forced-failure/interruption path and verify no preview processes remain. -- [ ] 3.4 Exercise stale-session detection and cleanup after a hard child-process exit. -- [ ] 3.5 Verify structurally that the helper always sets `TRAWLER_GRAPH_DIR` to its owned fixture path and cannot open the default/user graph. +- [x] 3.1 Add idempotent EXIT/INT/TERM cleanup that terminates Trawler, the window manager, and Xvfb and removes disposable graph state while preserving requested dump, log, and screenshot outputs. +- [x] 3.2 Add bounded failure checks for missing display/renderer startup, application exit, missing `devtools.port`, malformed protocol responses, and screenshot failure. +- [x] 3.3 Exercise at least one forced-failure/interruption path and verify no preview processes remain. +- [x] 3.4 Exercise stale-session detection and cleanup after a hard child-process exit. +- [x] 3.5 Verify structurally that the helper always sets `TRAWLER_GRAPH_DIR` to its owned fixture path and cannot open the default/user graph. ## 4. Documentation and Final Verification -- [ ] 4.1 Document `nix develop`, full-workspace checks/tests, smoke and serve session commands, generated artifact paths, native-desktop scratch launch, the no-pointer-injection limitation, troubleshooting, cleanup behavior, and the Windows-product/Linux-development boundary. -- [ ] 4.2 Run formatting and lint checks plus the full non-ignored workspace test suite from the devshell. -- [ ] 4.3 Run both the documented long-lived iterative flow and one-shot smoke flow from clean temporary state and retain semantic dumps and screenshots as verification evidence. -- [ ] 4.4 Verify implementation against the `nix-development-environment` requirements and resolve or document every deviation before archive. +- [x] 4.1 Document `nix develop`, full-workspace checks/tests, smoke and serve session commands, generated artifact paths, native-desktop scratch launch, the no-pointer-injection limitation, troubleshooting, cleanup behavior, and the Windows-product/Linux-development boundary. +- [x] 4.2 Run formatting and lint checks plus the full non-ignored workspace test suite from the devshell. +- [x] 4.3 Run both the documented long-lived iterative flow and one-shot smoke flow from clean temporary state and retain semantic dumps and screenshots as verification evidence. +- [x] 4.4 Verify implementation against the `nix-development-environment` requirements and resolve or document every deviation before archive. diff --git a/tools/trawler-preview.py b/tools/trawler-preview.py new file mode 100755 index 0000000..499b1e3 --- /dev/null +++ b/tools/trawler-preview.py @@ -0,0 +1,905 @@ +#!/usr/bin/env python3 +"""Launch and drive an isolated Trawler devtools preview on Linux. + +Only Python's standard library is used. Run this from ``nix develop`` so +Xvfb, the pinned Rust toolchain, native GPUI libraries, and the lavapipe ICD +are all available. +""" + +from __future__ import annotations + +import argparse +import contextlib +import datetime as dt +import fcntl +import hashlib +import json +import os +import select +import shutil +import signal +import socket +import struct +import subprocess +import sys +import tempfile +import time +import zlib +from pathlib import Path +from typing import Any, Iterator + + +SCHEMA_VERSION = 1 +START_TIMEOUT = 30.0 +REQUEST_TIMEOUT = 5.0 +SCREENSHOT_TIMEOUT = 20.0 +MAX_RESPONSE_BYTES = 8 * 1024 * 1024 +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + +REPO_ROOT = Path(__file__).resolve().parents[1] +REPO_HASH = hashlib.sha256(str(REPO_ROOT).encode()).hexdigest()[:12] +# `nix develop -c` gives each invocation a short-lived TMPDIR/XDG_RUNTIME_DIR. +# Session control state must survive across separate start/send/stop commands, +# so use the host's stable Linux temporary root instead of either variable. +SYSTEM_TMP = Path("/tmp").resolve() +STATE_DIR = SYSTEM_TMP / f"trawler-preview-{os.getuid()}-{REPO_HASH}" +MANIFEST_PATH = STATE_DIR / "session.json" +LOCK_PATH = STATE_DIR / "session.lock" +DEFAULT_ARTIFACT_ROOT = REPO_ROOT / "target" / "dev-preview" + + +class PreviewError(RuntimeError): + """A user-facing preview lifecycle or protocol failure.""" + + +class PreviewInterrupted(BaseException): + """A termination signal converted into structured cleanup control flow.""" + + def __init__(self, signum: int): + self.signum = signum + super().__init__(signal.Signals(signum).name) + + +def install_signal_handlers() -> None: + def interrupt(signum: int, _frame: Any) -> None: + raise PreviewInterrupted(signum) + + signal.signal(signal.SIGTERM, interrupt) + signal.signal(signal.SIGHUP, interrupt) + + +def json_print(value: Any) -> None: + print(json.dumps(value, indent=2, sort_keys=True)) + + +def utc_stamp() -> str: + return dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%SZ") + + +def ensure_state_dir() -> None: + STATE_DIR.mkdir(mode=0o700, parents=True, exist_ok=True) + STATE_DIR.chmod(0o700) + + +@contextlib.contextmanager +def session_lock() -> Iterator[None]: + ensure_state_dir() + fd = os.open(LOCK_PATH, os.O_CREAT | os.O_RDWR, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + yield + finally: + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + + +def atomic_write_json(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + fd, raw_tmp = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + tmp = Path(raw_tmp) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(value, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + dir_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) + finally: + tmp.unlink(missing_ok=True) + + +def read_manifest() -> dict[str, Any] | None: + if not MANIFEST_PATH.exists(): + return None + try: + value = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise PreviewError(f"cannot read session manifest {MANIFEST_PATH}: {exc}") from exc + if not isinstance(value, dict) or value.get("schema") != SCHEMA_VERSION: + raise PreviewError(f"unsupported or malformed session manifest: {MANIFEST_PATH}") + repo = value.get("repo") + if not isinstance(repo, str) or not repo: + raise PreviewError("session manifest has an invalid repository path") + if Path(repo).resolve() != REPO_ROOT: + raise PreviewError("session manifest belongs to another repository") + validate_manifest(value) + validate_owned_paths(value) + return value + + +def validate_process_record(value: Any, label: str) -> None: + if not isinstance(value, dict): + raise PreviewError(f"session manifest has an invalid {label} process record") + for key in ("pid", "pgid", "start_ticks"): + field = value.get(key) + if not isinstance(field, int) or field <= 0: + raise PreviewError(f"session manifest has invalid {label}.{key}") + executable = value.get("exe") + if not isinstance(executable, str) or not Path(executable).is_absolute(): + raise PreviewError(f"session manifest has invalid {label}.exe") + + +def validate_manifest(value: dict[str, Any]) -> None: + state = value.get("state") + if state not in {"starting", "live"}: + raise PreviewError(f"session manifest has invalid state: {state!r}") + for key in ("scratch_root", "graph_dir", "artifact_dir", "binary", "lavapipe_icd"): + field = value.get(key) + if not isinstance(field, str) or not Path(field).is_absolute(): + raise PreviewError(f"session manifest has invalid {key}") + for key in ("app", "wm", "xvfb"): + if key in value: + validate_process_record(value[key], key) + if "display" in value: + display = value["display"] + if not isinstance(display, str) or not display.startswith(":") or not display[1:].isdigit(): + raise PreviewError("session manifest has invalid display") + if "port" in value: + port = value["port"] + if not isinstance(port, int) or not 1 <= port <= 65535: + raise PreviewError("session manifest has invalid port") + if state == "live": + for key in ("app", "wm", "xvfb", "display", "port"): + if key not in value: + raise PreviewError(f"live session manifest is missing {key}") + + +def validate_owned_paths(manifest: dict[str, Any]) -> None: + try: + scratch = Path(manifest["scratch_root"]).resolve() + graph = Path(manifest["graph_dir"]).resolve() + except (KeyError, TypeError) as exc: + raise PreviewError("session manifest is missing owned scratch paths") from exc + if graph != scratch / "fixture-graph": + raise PreviewError("refusing unsafe session manifest: graph is not owned fixture path") + if scratch.parent != SYSTEM_TMP or not scratch.name.startswith("trawler-preview-"): + raise PreviewError("refusing unsafe session manifest: scratch root is not helper-owned") + + +def proc_state(pid: int) -> tuple[str, int] | None: + try: + raw = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8") + tail = raw[raw.rfind(")") + 2 :].split() + # tail[0] is field 3 (state); tail[19] is field 22 (starttime). + return tail[0], int(tail[19]) + except (OSError, ValueError, IndexError): + return None + + +def process_matches(info: dict[str, Any] | None) -> bool: + if not isinstance(info, dict): + return False + try: + pid = int(info["pid"]) + pgid = int(info["pgid"]) + start_ticks = int(info["start_ticks"]) + except (KeyError, TypeError, ValueError): + return False + state = proc_state(pid) + if state is None or state[0] == "Z" or state[1] != start_ticks: + return False + try: + if os.getpgid(pid) != pgid: + return False + executable = str(Path(f"/proc/{pid}/exe").resolve()) + return executable == info.get("exe") + except (OSError, ProcessLookupError): + return False + + +def process_info(process: subprocess.Popen[Any]) -> dict[str, Any]: + state = proc_state(process.pid) + if state is None: + raise PreviewError(f"process {process.pid} exited before it could be recorded") + try: + executable = str(Path(f"/proc/{process.pid}/exe").resolve()) + except OSError: + executable = "" + return { + "pid": process.pid, + "pgid": os.getpgid(process.pid), + "start_ticks": state[1], + "exe": executable, + } + + +def terminate_recorded_process(info: dict[str, Any] | None, label: str) -> None: + if not process_matches(info): + return + assert info is not None + pgid = int(info["pgid"]) + pid = int(info["pid"]) + with contextlib.suppress(ProcessLookupError): + os.killpg(pgid, signal.SIGTERM) + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + if not process_matches(info): + return + time.sleep(0.05) + with contextlib.suppress(ProcessLookupError): + os.killpg(pgid, signal.SIGKILL) + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + if not process_matches(info): + return + time.sleep(0.05) + raise PreviewError(f"could not terminate {label} process group {pgid} (leader {pid})") + + +def terminate_popen(process: subprocess.Popen[Any] | None) -> None: + if process is None or process.poll() is not None: + return + with contextlib.suppress(ProcessLookupError): + os.killpg(os.getpgid(process.pid), signal.SIGTERM) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + with contextlib.suppress(ProcessLookupError): + os.killpg(os.getpgid(process.pid), signal.SIGKILL) + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired as exc: + raise PreviewError(f"could not terminate process group for pid {process.pid}") from exc + if process.poll() is None: + raise PreviewError(f"process {process.pid} remained live after cleanup") + + +def remove_owned_scratch(manifest: dict[str, Any]) -> None: + validate_owned_paths(manifest) + scratch = Path(manifest["scratch_root"]) + if scratch.exists(): + shutil.rmtree(scratch) + if scratch.exists(): + raise PreviewError(f"could not remove owned scratch root: {scratch}") + + +def send_request(port: int, request: dict[str, Any], timeout: float = REQUEST_TIMEOUT) -> dict[str, Any]: + if not isinstance(request, dict): + raise PreviewError("devtools request must be a JSON object") + encoded = json.dumps(request, separators=(",", ":")).encode() + b"\n" + try: + with socket.create_connection(("127.0.0.1", port), timeout=timeout) as sock: + sock.settimeout(timeout) + sock.sendall(encoded) + with sock.makefile("rb") as reader: + line = reader.readline(MAX_RESPONSE_BYTES + 1) + except (OSError, TimeoutError) as exc: + raise PreviewError(f"devtools request failed on 127.0.0.1:{port}: {exc}") from exc + if not line: + raise PreviewError("devtools endpoint closed without a response") + if len(line) > MAX_RESPONSE_BYTES: + raise PreviewError("devtools response exceeded size limit") + if not line.endswith(b"\n"): + raise PreviewError("devtools response was not newline terminated") + try: + response = json.loads(line) + except json.JSONDecodeError as exc: + raise PreviewError(f"devtools returned invalid JSON: {exc}") from exc + if not isinstance(response, dict) or not isinstance(response.get("ok"), bool): + raise PreviewError("devtools response must be an object with boolean 'ok'") + return response + + +def healthy_session(manifest: dict[str, Any], *, probe: bool = True) -> tuple[bool, list[str]]: + reasons: list[str] = [] + if manifest.get("state") != "live": + reasons.append(f"manifest state is {manifest.get('state')!r}") + if not process_matches(manifest.get("app")): + reasons.append("Trawler process is not live") + if not process_matches(manifest.get("xvfb")): + reasons.append("Xvfb process is not live") + if not process_matches(manifest.get("wm")): + reasons.append("window manager process is not live") + port_file = Path(manifest["graph_dir"]) / "devtools.port" + try: + file_port = int(port_file.read_text(encoding="utf-8").strip()) + if file_port != int(manifest.get("port", 0)): + reasons.append("devtools.port differs from the manifest") + except (OSError, ValueError): + reasons.append("devtools.port is missing or malformed") + if probe and not reasons: + try: + response = send_request(int(manifest["port"]), {"cmd": "dump"}) + if not response.get("ok"): + reasons.append(f"devtools dump failed: {response.get('error', 'unknown error')}") + except PreviewError as exc: + reasons.append(str(exc)) + return not reasons, reasons + + +def command_path(name: str) -> str: + path = shutil.which(name) + if not path: + raise PreviewError(f"required command {name!r} is missing; run inside 'nix develop'") + return path + + +def lavapipe_icd() -> Path: + raw = os.environ.get("TRAWLER_LAVAPIPE_ICD") + if not raw: + raise PreviewError("TRAWLER_LAVAPIPE_ICD is unset; run inside 'nix develop'") + path = Path(raw).resolve() + if not path.is_file(): + raise PreviewError(f"lavapipe ICD does not exist: {path}") + return path + + +def run_checked(command: list[str], *, log: Path | None = None) -> subprocess.CompletedProcess[str]: + if log is None: + return subprocess.run(command, cwd=REPO_ROOT, text=True, check=True) + with log.open("a", encoding="utf-8") as handle: + return subprocess.run( + command, + cwd=REPO_ROOT, + text=True, + stdout=handle, + stderr=subprocess.STDOUT, + check=True, + ) + + +def read_display_number(fd: int, process: subprocess.Popen[Any], timeout: float) -> str: + deadline = time.monotonic() + timeout + data = b"" + while time.monotonic() < deadline: + if process.poll() is not None: + raise PreviewError(f"Xvfb exited during startup with status {process.returncode}") + ready, _, _ = select.select([fd], [], [], 0.1) + if ready: + chunk = os.read(fd, 64) + if not chunk: + break + data += chunk + if b"\n" in data: + break + text = data.decode(errors="replace").strip() + if not text.isdigit(): + raise PreviewError(f"Xvfb did not publish a display number within {timeout:.0f}s") + return f":{int(text)}" + + +def wait_for_devtools( + graph_dir: Path, + app: subprocess.Popen[Any], + wm: subprocess.Popen[Any], + xvfb: subprocess.Popen[Any], + timeout: float, +) -> tuple[int, dict[str, Any]]: + port_file = graph_dir / "devtools.port" + deadline = time.monotonic() + timeout + last_error = "port file not written" + while time.monotonic() < deadline: + if app.poll() is not None: + raise PreviewError(f"Trawler exited during startup with status {app.returncode}") + if xvfb.poll() is not None: + raise PreviewError(f"Xvfb exited during startup with status {xvfb.returncode}") + if wm.poll() is not None: + raise PreviewError(f"window manager exited during startup with status {wm.returncode}") + try: + port = int(port_file.read_text(encoding="utf-8").strip()) + if not 1 <= port <= 65535: + raise ValueError("outside TCP port range") + response = send_request(port, {"cmd": "dump"}) + if response.get("ok"): + return port, response + last_error = str(response.get("error", "dump returned ok:false")) + except (OSError, ValueError, PreviewError) as exc: + last_error = str(exc) + time.sleep(0.1) + raise PreviewError(f"devtools did not become healthy within {timeout:.0f}s: {last_error}") + + +def default_artifact_dir(kind: str) -> Path: + return (DEFAULT_ARTIFACT_ROOT / f"{kind}-{utc_stamp()}").resolve() + + +def start_session(artifact_dir: Path | None = None) -> dict[str, Any]: + existing = read_manifest() + if existing is not None: + live, reasons = healthy_session(existing) + if live: + raise PreviewError("a preview session is already live; use status/send/stop") + raise PreviewError(f"a stale preview session exists ({'; '.join(reasons)}); run stop first") + + cargo = command_path("cargo") + xvfb_bin = command_path("Xvfb") + openbox = command_path("openbox") + icd = lavapipe_icd() + artifact_dir = (artifact_dir or default_artifact_dir("session")).resolve() + artifact_dir.mkdir(parents=True, exist_ok=False) + app_log = artifact_dir / "app.log" + xvfb_log = artifact_dir / "xvfb.log" + wm_log = artifact_dir / "wm.log" + build_log = artifact_dir / "build.log" + seed_log = artifact_dir / "seed.log" + + scratch = Path(tempfile.mkdtemp(prefix="trawler-preview-", dir=SYSTEM_TMP)).resolve() + graph = scratch / "fixture-graph" + runtime = scratch / "runtime" + runtime.mkdir(mode=0o700) + binary = (REPO_ROOT / "target" / "debug" / "trawler").resolve() + + app: subprocess.Popen[Any] | None = None + wm: subprocess.Popen[Any] | None = None + xvfb: subprocess.Popen[Any] | None = None + manifest: dict[str, Any] = { + "schema": SCHEMA_VERSION, + "state": "starting", + "repo": str(REPO_ROOT), + "scratch_root": str(scratch), + "graph_dir": str(graph), + "artifact_dir": str(artifact_dir), + "binary": str(binary), + "lavapipe_icd": str(icd), + "app_log": str(app_log), + "xvfb_log": str(xvfb_log), + "wm_log": str(wm_log), + "build_log": str(build_log), + "seed_log": str(seed_log), + } + atomic_write_json(MANIFEST_PATH, manifest) + + try: + try: + run_checked([cargo, "build", "-p", "trawler", "--features", "devtools"], log=build_log) + except subprocess.CalledProcessError as exc: + raise PreviewError(f"devtools build failed; see {build_log}") from exc + if not binary.is_file(): + raise PreviewError(f"build succeeded but binary is missing: {binary}") + + if graph.exists(): + raise PreviewError(f"refusing to seed existing graph path: {graph}") + try: + run_checked([str(binary), "--seed-fixtures", str(graph)], log=seed_log) + except subprocess.CalledProcessError as exc: + raise PreviewError(f"fixture seeding failed; see {seed_log}") from exc + if not (graph / "meta.json").is_file() or not (graph / "snapshot.loro").is_file(): + raise PreviewError(f"fixture seeding did not create a valid graph; see {seed_log}") + + read_fd, write_fd = os.pipe() + try: + with xvfb_log.open("wb") as handle: + xvfb = subprocess.Popen( + [ + xvfb_bin, + "-displayfd", + str(write_fd), + "-screen", + "0", + "1280x800x24", + "-nolisten", + "tcp", + "-noreset", + ], + cwd=REPO_ROOT, + stdout=handle, + stderr=subprocess.STDOUT, + pass_fds=(write_fd,), + start_new_session=True, + ) + os.close(write_fd) + write_fd = -1 + manifest["xvfb"] = process_info(xvfb) + atomic_write_json(MANIFEST_PATH, manifest) + display = read_display_number(read_fd, xvfb, 10.0) + finally: + os.close(read_fd) + if write_fd >= 0: + os.close(write_fd) + + manifest["display"] = display + atomic_write_json(MANIFEST_PATH, manifest) + + env = os.environ.copy() + env.pop("WAYLAND_DISPLAY", None) + env.update( + { + "DISPLAY": display, + "XDG_RUNTIME_DIR": str(runtime), + "XDG_SESSION_TYPE": "x11", + "TRAWLER_GRAPH_DIR": str(graph), + "TRAWLER_DEVTOOLS": "1", + "VK_DRIVER_FILES": str(icd), + "VK_ICD_FILENAMES": str(icd), + "LIBGL_ALWAYS_SOFTWARE": "1", + } + ) + # xcap discovers X11 application windows through the EWMH + # _NET_CLIENT_LIST maintained by a window manager. Bare Xvfb maps + # the GPUI window but leaves that property absent, so devtools + # screenshot capture cannot find its own process window. + with wm_log.open("wb") as handle: + wm = subprocess.Popen( + [openbox, "--sm-disable"], + cwd=REPO_ROOT, + env=env, + stdout=handle, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + manifest["wm"] = process_info(wm) + atomic_write_json(MANIFEST_PATH, manifest) + time.sleep(0.2) + if wm.poll() is not None: + raise PreviewError(f"window manager exited during startup; see {wm_log}") + # Nix's openbox launcher execs the real binary. Refresh the process + # identity after that handoff so later executable validation compares + # against the stable leader, while the provisional record above still + # makes interruption during startup recoverable. + manifest["wm"] = process_info(wm) + atomic_write_json(MANIFEST_PATH, manifest) + + with app_log.open("wb") as handle: + app = subprocess.Popen( + [str(binary)], + cwd=REPO_ROOT, + env=env, + stdout=handle, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + manifest["app"] = process_info(app) + atomic_write_json(MANIFEST_PATH, manifest) + + port, initial_dump = wait_for_devtools(graph, app, wm, xvfb, START_TIMEOUT) + initial_path = artifact_dir / "initial-dump.json" + initial_path.write_text(json.dumps(initial_dump, indent=2, sort_keys=True) + "\n") + manifest.update( + { + "state": "live", + "port": port, + "initial_dump": str(initial_path), + } + ) + atomic_write_json(MANIFEST_PATH, manifest) + return manifest + except BaseException as exc: + cleanup_errors: list[str] = [] + for process, label in [(app, "Trawler"), (wm, "window manager"), (xvfb, "Xvfb")]: + try: + terminate_popen(process) + except PreviewError as cleanup_exc: + cleanup_errors.append(f"{label}: {cleanup_exc}") + if not cleanup_errors: + try: + remove_owned_scratch(manifest) + except (OSError, PreviewError) as cleanup_exc: + cleanup_errors.append(str(cleanup_exc)) + diagnostics = ( + f"artifacts: {artifact_dir}; logs: build={build_log}, seed={seed_log}, " + f"xvfb={xvfb_log}, wm={wm_log}, app={app_log}" + ) + if cleanup_errors: + # Keep the manifest so a later `stop` can retry safely instead + # of making leaked processes or state undiscoverable. + raise PreviewError( + f"startup failed ({exc}); cleanup incomplete: {'; '.join(cleanup_errors)}; " + f"{diagnostics}" + ) from exc + MANIFEST_PATH.unlink(missing_ok=True) + if isinstance(exc, (PreviewInterrupted, KeyboardInterrupt)): + raise + raise PreviewError(f"startup failed: {exc}; {diagnostics}") from exc + + +def stop_session() -> dict[str, Any]: + manifest = read_manifest() + if manifest is None: + return {"ok": True, "state": "none", "message": "no preview session"} + errors: list[str] = [] + for info, label in [ + (manifest.get("app"), "Trawler"), + (manifest.get("wm"), "window manager"), + (manifest.get("xvfb"), "Xvfb"), + ]: + try: + terminate_recorded_process(info, label) + except PreviewError as exc: + errors.append(str(exc)) + if errors: + raise PreviewError("; ".join(errors)) + remove_owned_scratch(manifest) + MANIFEST_PATH.unlink(missing_ok=True) + return { + "ok": True, + "state": "stopped", + "artifact_dir": manifest.get("artifact_dir"), + } + + +def require_live_manifest() -> dict[str, Any]: + manifest = read_manifest() + if manifest is None: + raise PreviewError("no preview session; run start first") + live, reasons = healthy_session(manifest) + if not live: + raise PreviewError(f"preview session is stale ({'; '.join(reasons)}); run stop") + return manifest + + +def validate_png(path: Path) -> dict[str, int]: + data = path.read_bytes() + if not data.startswith(PNG_SIGNATURE): + raise PreviewError(f"screenshot is not a PNG: {path}") + offset = len(PNG_SIGNATURE) + width = height = 0 + saw_iend = False + idat = bytearray() + first = True + while offset + 12 <= len(data): + length = struct.unpack(">I", data[offset : offset + 4])[0] + chunk_type = data[offset + 4 : offset + 8] + chunk_start = offset + 8 + chunk_end = chunk_start + length + crc_end = chunk_end + 4 + if crc_end > len(data): + raise PreviewError(f"PNG chunk extends past end of file: {path}") + expected_crc = struct.unpack(">I", data[chunk_end:crc_end])[0] + actual_crc = zlib.crc32(chunk_type) + actual_crc = zlib.crc32(data[chunk_start:chunk_end], actual_crc) & 0xFFFFFFFF + if actual_crc != expected_crc: + raise PreviewError(f"PNG CRC mismatch in {chunk_type!r}: {path}") + if first: + if chunk_type != b"IHDR" or length != 13: + raise PreviewError(f"PNG does not begin with a valid IHDR: {path}") + width, height = struct.unpack(">II", data[chunk_start : chunk_start + 8]) + if width == 0 or height == 0: + raise PreviewError(f"PNG has zero dimensions: {path}") + first = False + elif chunk_type == b"IHDR": + raise PreviewError(f"PNG contains multiple IHDR chunks: {path}") + if chunk_type == b"IDAT": + if saw_iend: + raise PreviewError(f"PNG contains IDAT after IEND: {path}") + idat.extend(data[chunk_start:chunk_end]) + if chunk_type == b"IEND": + if length != 0: + raise PreviewError(f"PNG IEND chunk is not empty: {path}") + saw_iend = True + if crc_end != len(data): + raise PreviewError(f"PNG contains trailing data after IEND: {path}") + break + offset = crc_end + if not saw_iend: + raise PreviewError(f"PNG has no IEND chunk: {path}") + if not idat: + raise PreviewError(f"PNG has no image data: {path}") + try: + decompressed = zlib.decompress(bytes(idat)) + except zlib.error as exc: + raise PreviewError(f"PNG image data is not valid zlib: {path}") from exc + if not decompressed: + raise PreviewError(f"PNG image data is empty: {path}") + return {"bytes": len(data), "width": width, "height": height} + + +def capture_screenshot(manifest: dict[str, Any], output: Path) -> dict[str, Any]: + output = output.expanduser().resolve() + scratch = Path(manifest["scratch_root"]).resolve() + if output == scratch or scratch in output.parents: + raise PreviewError("screenshot output must be outside disposable session state") + output.parent.mkdir(parents=True, exist_ok=True) + output.unlink(missing_ok=True) + response = send_request( + int(manifest["port"]), + {"cmd": "screenshot", "path": str(output)}, + timeout=SCREENSHOT_TIMEOUT, + ) + if not response.get("ok"): + raise PreviewError(f"devtools screenshot failed: {response.get('error', 'unknown error')}") + returned = Path(response.get("path", "")).resolve() + if returned != output: + raise PreviewError(f"devtools returned unexpected screenshot path: {returned}") + if not output.is_file(): + raise PreviewError(f"devtools reported success but screenshot is missing: {output}") + details = validate_png(output) + return {"ok": True, "path": str(output), **details} + + +def poll_dump(port: int, predicate: Any, description: str, timeout: float = 5.0) -> dict[str, Any]: + deadline = time.monotonic() + timeout + last: dict[str, Any] | None = None + while time.monotonic() < deadline: + last = send_request(port, {"cmd": "dump"}) + if last.get("ok") and predicate(last): + return last + time.sleep(0.1) + raise PreviewError(f"timed out waiting for {description}; last dump: {last}") + + +EXPECTED_FIXTURE_ROWS = [ + ("trawler-design", 0), + ("Goals", 1), + ("Keyboard-first outlining #project", 2), + ("Live Steel queries #project", 2), + ("Tasks", 1), + ("Ship fixture graphs #project", 2), + ("Wire dev automation #project", 2), + ("(table (tag 'project) '(\"due\" \"priority\"))", 1), +] + + +def run_smoke(output_dir: Path | None) -> dict[str, Any]: + artifact_dir = (output_dir or default_artifact_dir("smoke")).expanduser().resolve() + manifest: dict[str, Any] | None = None + result: dict[str, Any] = {} + try: + manifest = start_session(artifact_dir) + port = int(manifest["port"]) + + response = send_request(port, {"cmd": "keys", "keys": "ctrl-k"}) + if not response.get("ok"): + raise PreviewError(f"opening quick-open failed: {response.get('error')}") + poll_dump(port, lambda d: d.get("quick_open") is not None, "quick-open to appear") + + response = send_request(port, {"cmd": "type", "text": "trawler-design"}) + if not response.get("ok"): + raise PreviewError(f"typing quick-open query failed: {response.get('error')}") + poll_dump( + port, + lambda d: (d.get("quick_open") or {}).get("query") == "trawler-design", + "quick-open query", + ) + + response = send_request(port, {"cmd": "keys", "keys": "enter"}) + if not response.get("ok"): + raise PreviewError(f"confirming quick-open failed: {response.get('error')}") + + def fixture_loaded(dump: dict[str, Any]) -> bool: + projection = [(row.get("content"), row.get("depth")) for row in dump.get("rows", [])] + return ( + dump.get("quick_open") is None + and (dump.get("view") or {}).get("kind") == "node" + and projection == EXPECTED_FIXTURE_ROWS + and [row.get("is_query") for row in dump.get("rows", [])] == [False] * 7 + [True] + ) + + final_dump = poll_dump(port, fixture_loaded, "deterministic fixture page", timeout=8.0) + # Query evaluation is debounced; require stable semantic state across + # three polls rather than racing the first post-navigation frame. + projection = [(row.get("content"), row.get("depth")) for row in final_dump["rows"]] + for _ in range(3): + time.sleep(0.2) + settled = send_request(port, {"cmd": "dump"}) + if [(row.get("content"), row.get("depth")) for row in settled.get("rows", [])] != projection: + raise PreviewError("fixture semantic state changed while settling") + final_dump = settled + + final_path = artifact_dir / "final-dump.json" + final_path.write_text(json.dumps(final_dump, indent=2, sort_keys=True) + "\n") + screenshot = capture_screenshot(manifest, artifact_dir / "screenshot.png") + result = { + "ok": True, + "artifact_dir": str(artifact_dir), + "dump": str(final_path), + "screenshot": screenshot, + } + return result + finally: + if manifest is not None or MANIFEST_PATH.exists(): + # Never return an `ok: true` smoke result while cleanup failed. + # A cleanup error intentionally replaces the primary failure so + # leaked state remains visible and actionable via the manifest. + stop_session() + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description=__doc__) + commands = result.add_subparsers(dest="command", required=True) + + start = commands.add_parser("start", help="start a long-lived isolated preview") + start.add_argument("--artifacts", type=Path, help="new directory for preserved logs/artifacts") + + commands.add_parser("status", help="validate and report the current preview") + + send = commands.add_parser("send", help="send one raw JSON object to devtools") + send.add_argument("--json", required=True, dest="request_json") + + screenshot = commands.add_parser("screenshot", help="capture and validate a devtools PNG") + screenshot.add_argument("output", type=Path) + + commands.add_parser("stop", help="stop and clean the current preview (idempotent)") + + smoke = commands.add_parser("smoke", help="run deterministic dump + screenshot verification") + smoke.add_argument("--output-dir", type=Path) + return result + + +def main() -> int: + install_signal_handlers() + args = parser().parse_args() + try: + with session_lock(): + if args.command == "start": + manifest = start_session(args.artifacts) + json_print( + { + "ok": True, + "state": "live", + "display": manifest["display"], + "port": manifest["port"], + "graph_dir": manifest["graph_dir"], + "artifact_dir": manifest["artifact_dir"], + "app_log": manifest["app_log"], + } + ) + return 0 + if args.command == "status": + manifest = read_manifest() + if manifest is None: + json_print({"ok": False, "state": "none"}) + return 2 + live, reasons = healthy_session(manifest) + json_print( + { + "ok": live, + "state": "live" if live else "stale", + "reasons": reasons, + "display": manifest.get("display"), + "port": manifest.get("port"), + "graph_dir": manifest.get("graph_dir"), + "artifact_dir": manifest.get("artifact_dir"), + } + ) + return 0 if live else 1 + if args.command == "send": + manifest = require_live_manifest() + try: + request = json.loads(args.request_json) + except json.JSONDecodeError as exc: + raise PreviewError(f"--json is invalid: {exc}") from exc + if not isinstance(request, dict): + raise PreviewError("--json must contain one JSON object") + response = send_request(int(manifest["port"]), request) + json_print(response) + return 0 if response.get("ok") else 1 + if args.command == "screenshot": + manifest = require_live_manifest() + json_print(capture_screenshot(manifest, args.output)) + return 0 + if args.command == "stop": + json_print(stop_session()) + return 0 + if args.command == "smoke": + json_print(run_smoke(args.output_dir)) + return 0 + except (PreviewError, subprocess.CalledProcessError) as exc: + print(f"trawler-preview: {exc}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + print("trawler-preview: interrupted", file=sys.stderr) + return 130 + except PreviewInterrupted as exc: + print(f"trawler-preview: interrupted by {signal.Signals(exc.signum).name}", file=sys.stderr) + return 128 + exc.signum + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) -- 2.51.2