Local run viewer: email reports, run history, and the platform suite (#813)

This commit is contained in:
yoni-at-strix
2026-07-21 08:13:03 -07:00
committed by GitHub
parent 6a3e0597ce
commit f600f99103
93 changed files with 17164 additions and 1482 deletions
+75 -1
View File
@@ -6,6 +6,7 @@ import logging
import signal
import sys
import threading
import webbrowser
from collections.abc import Callable
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as pkg_version
@@ -768,6 +769,7 @@ class StrixTUIApp(App): # type: ignore[misc]
Binding("ctrl+q", "request_quit", "Quit", priority=True),
Binding("ctrl+c", "request_quit", "Quit", priority=True),
Binding("escape", "stop_selected_agent", "Stop Agent", priority=True),
Binding("ctrl+o", "open_viewer", "Open Viewer", priority=True),
]
def __init__(self, args: argparse.Namespace):
@@ -794,6 +796,8 @@ class StrixTUIApp(App): # type: ignore[misc]
self._displayed_events: list[str] = []
self._scan_thread: threading.Thread | None = None
self._viewer_httpd: Any = None
self._viewer_url: str | None = None
self._scan_loop: asyncio.AbstractEventLoop | None = None
self._scan_stop_event = threading.Event()
self._scan_completed = threading.Event()
@@ -903,7 +907,12 @@ class StrixTUIApp(App): # type: ignore[misc]
vulnerabilities_panel = VulnerabilitiesPanel(id="vulnerabilities_panel")
sidebar = Vertical(agents_tree, vulnerabilities_panel, stats_scroll, id="sidebar")
viewer_cta = Static(self._viewer_cta_markup(), id="viewer_cta")
viewer_cta.ALLOW_SELECT = False
sidebar = Vertical(
viewer_cta, agents_tree, vulnerabilities_panel, stats_scroll, id="sidebar"
)
content_container.mount(chat_area_container)
content_container.mount(sidebar)
@@ -1805,6 +1814,7 @@ class StrixTUIApp(App): # type: ignore[misc]
async def action_custom_quit(self) -> None:
self._fire_sandbox_cleanup()
self._shutdown_viewer()
if self._scan_thread and self._scan_thread.is_alive():
self._scan_stop_event.set()
@@ -1813,6 +1823,70 @@ class StrixTUIApp(App): # type: ignore[misc]
self.exit()
def _viewer_cta_markup(self, url: str | None = None) -> str:
if url:
return f"[@click=app.open_viewer][#22c55e]● Viewer running[/][/]\n[dim]{url}[/]"
return "[@click=app.open_viewer]▶ Watch live in browser[/]"
def _set_viewer_cta(self, markup: str) -> None:
with contextlib.suppress(Exception):
self.query_one("#viewer_cta", Static).update(markup)
def action_open_viewer(self) -> None:
if self._viewer_url:
with contextlib.suppress(Exception):
webbrowser.open(self._viewer_url)
return
try:
from strix.viewer.server import authorized_url, bundle_is_built, serve
if not bundle_is_built():
self._set_viewer_cta("[#eab308]Viewer UI not built[/]")
return
run_dir = self.report_state.get_run_dir()
def _viewer_steer(agent_id: str, message: str) -> bool:
# Reuse the exact TUI delivery path, but target the agent the
# web graph selected (not the TUI's current selection).
return send_user_message_to_agent(
coordinator=self.coordinator,
loop=self._scan_loop,
live_view=self.live_view,
target_agent_id=agent_id,
message=message,
)
httpd, url, token = serve(run_dir, open_browser=True, steer_handler=_viewer_steer)
except Exception:
logger.debug("failed to start local viewer", exc_info=True)
self._set_viewer_cta("[red]Viewer failed to start[/]")
return
self._viewer_httpd = httpd
# Store the tokened URL so reopening the CTA re-authorizes the browser
# (this viewer carries a steer handler, so the session is required).
self._viewer_url = authorized_url(url, token)
self._set_viewer_cta(self._viewer_cta_markup(self._viewer_url))
with contextlib.suppress(Exception):
from strix.telemetry import posthog
live = self.report_state.run_record.get("status") not in {
"completed",
"stopped",
"failed",
"interrupted",
}
posthog.viewer_opened(source="tui", live=live)
def _shutdown_viewer(self) -> None:
httpd = self._viewer_httpd
if httpd is None:
return
self._viewer_httpd = None
with contextlib.suppress(Exception):
httpd.shutdown()
httpd.server_close()
def _fire_sandbox_cleanup(self) -> None:
self.coordinator.mark_shutting_down()
loop = self._scan_loop
+16 -4
View File
@@ -24,14 +24,26 @@ def load_session_history(run_dir: Path, agent_ids: Any) -> list[tuple[str, dict[
if not agents_db.exists() or not session_ids:
return []
session_id_set = set(session_ids)
# Open read-only: the scan process may be actively writing this WAL database
# from another process (the local viewer tails it live), and a reader must
# never lock or mutate it. mode=ro (not immutable=1) still reads the latest
# committed WAL state; WAL permits concurrent readers alongside the writer.
conn: sqlite3.Connection | None = None
try:
with sqlite3.connect(agents_db) as conn:
rows = conn.execute(
"select id, session_id, message_data, created_at from agent_messages order by id"
).fetchall()
conn = sqlite3.connect(
f"file:{agents_db}?mode=ro",
uri=True,
check_same_thread=False,
)
rows = conn.execute(
"select id, session_id, message_data, created_at from agent_messages order by id"
).fetchall()
except sqlite3.Error:
logger.exception("Failed to hydrate TUI history from %s", agents_db)
return []
finally:
if conn is not None:
conn.close()
items: list[tuple[str, dict[str, Any], str]] = []
for row_id, agent_id, message_data, created_at in rows: