///|
/// Read-only web server for the session-file visualizer. Serves the static
/// frontend bundle plus a tiny JSON/raw-file API over discovered session
/// `.jsonl` files:
///
/// - `GET /` → the viewer shell (web/index.html)
/// - `GET /viz_app.js` → the compiled frontend bundle
/// - `GET /api/sessions` → JSON listing across known roots
/// - `GET /api/sessions/` → `{found, events, events_bytes}` envelope
/// (`?known_bytes=N` answers `{found, unchanged, events_bytes}` while the log
/// is still N bytes, so a `--watch` viewer can poll cheaply)
/// - `GET /api/sessions//openseek_session-.jsonl` → raw session file (header line + events)
/// - `GET /api/info` → `{server, version, identity}`, so a
/// second `inspect` can tell whether a busy port already serves its sessions
///
/// The shell and bundle come from disk when running from a checkout (or when
/// `--web-dir`/`--bundle` name them), and otherwise from the copies built into a
/// published binary (`generated_assets.mbt`), so `moonx moonbitlang/inspect`
/// needs no files beside it.
///
/// It never writes to the stores, so pointing it at live session roots is safe.
/// Session discovery is intentionally file-oriented: only
/// `openseek_session-*.jsonl` files are listed, so `.DS_Store`, lock files, and
/// malformed/husk directories do not show up as sessions.
priv struct VizConfig {
port : Int
host : String
session_root : String
session_root_name : String
search_dirs : Array[String]
web_dir : String
// Whether `--web-dir` (or its env) was given, rather than the `web` default.
// Only an explicit directory is searched when running outside a checkout.
web_dir_explicit : Bool
bundle : String
export_path : String
export_session : String
// `--watch`: the served viewer re-checks the open session and the list.
watch : Bool
// `--ensure`: find or start the one loopback, token-protected server for
// these sessions (see ensure.mbt).
ensure : Bool
// Whether `--port` (or its env) was given; `--ensure` otherwise derives one.
port_explicit : Bool
// Exit after this many minutes without a request; 0 never exits.
idle_minutes : Int
}
///|
fn viz_config_from_matches(matches : @argparse.Matches) -> VizConfig raise {
let (
port,
host,
session_root,
session_root_name,
web_dir,
bundle,
export_path,
export_session,
idle_exit,
) = match matches {
{
values: {
"port": [port, ..],
"host": [host, ..],
"session-root": [session_root, ..],
"session-root-name": [session_root_name, ..],
"web-dir": [web_dir, ..],
"bundle": [bundle, ..],
"export": [export_path, ..],
"export-session": [export_session, ..],
"idle-exit": [idle_exit, ..],
..
},
..
} =>
(
port, host, session_root, session_root_name, web_dir, bundle, export_path,
export_session, idle_exit,
)
_ => fail("missing parsed visualizer defaults")
}
guard export_session.is_blank() || !export_path.is_blank() else {
fail("--export-session requires --export")
}
let ensure = matches.flags.get("ensure") is Some(true)
let host = host.trim()
guard !host.is_empty() else {
fail("--host or OPENSEEK_VIZ_HOST must not be empty")
}
let session_root = session_root.trim()
guard !session_root.is_empty() else {
fail("--session-root must not be empty")
}
let session_root_name = session_root_name.trim()
guard !session_root_name.is_empty() else {
fail(
"--session-root-name or OPENSEEK_VIZ_SESSION_ROOT_NAME must not be empty",
)
}
{
port: parse_port(port),
host: "\{host}",
session_root: "\{session_root}",
session_root_name: "\{session_root_name}",
search_dirs: effective_search_dirs(matches),
web_dir,
web_dir_explicit: !(matches.sources.get("web-dir") is (Some(Default) | None)),
bundle,
export_path,
export_session: export_session.trim().to_owned(),
watch: matches.flags.get("watch") is Some(true),
ensure,
port_explicit: !(matches.sources.get("port") is (Some(Default) | None)),
idle_minutes: match idle_exit.trim() {
// Unset: an `--ensure` server is a shared background helper, so it goes
// away on its own; a server someone started by hand runs until stopped.
"" => if ensure { 60 } else { 0 }
text =>
@string.parse_int(text) catch {
_ =>
fail("--idle-exit must be a whole number of minutes, got: \{text}")
}
},
}
}
///|
async fn main {
let matches = cli_command().parse(env=@env.get_env_vars())
let config = viz_config_from_matches(matches)
// Keep main's fix (f26def11): an explicit --session-root drops the default
// cwd scan. effective_search_dirs encodes that; do not revert to a bare
// non_empty_values(matches, "search-dir").
let module_root = match @env.args() {
[exe, ..] => module_root_from_exe(exe)
[] => None
}
let shell = shell_candidates(
config.web_dir,
module_root,
web_dir_explicit=config.web_dir_explicit,
)
let bundle_paths = bundle_candidates(
config.bundle,
config.web_dir,
module_root,
web_dir_explicit=config.web_dir_explicit,
)
// `--ensure` keys everything on the session root's real path and keeps its
// record there, so the root must exist before either is computed; a missing
// relative `.openseek` would otherwise hash the same in every project.
if config.ensure {
@fs.mkdir(config.session_root, recursive=true, allow_exist=true)
}
let catalog = session_catalog(
config.session_root,
config.search_dirs,
config.session_root_name,
)
// Export mode: freeze the sessions the server would serve into one standalone
// HTML and exit, rather than run_forever. The exported file is decoupled from
// this server and from the current parser — it carries its own bundle + data.
if config.export_path != "" {
let rows = catalog.rows()
// --export-session scopes the freeze to one parent and its subagent
// children — one run, one tidy file — instead of every session the
// scan found.
let rows = if config.export_session == "" {
rows
} else {
let scoped = rows.filter(row => {
in_export_scope(row.id, config.export_session)
})
guard scoped.any(row => row.id == config.export_session) else {
fail(
"openseek viz: no session '\{config.export_session}' in the scanned roots",
)
}
scoped
}
let bundle = resolve_bundle(bundle_paths)
guard bundle.text() is Some(bundle_js) else {
fail(
"could not locate the viz_app.js bundle (build with `moon build cmd/viz_app --target js` or pass --bundle)",
)
}
guard resolve_shell(shell).text() is Some(shell_html) else {
fail("could not locate index.html shell (pass --web-dir)")
}
let html = build_export_html(rows, shell_html, bundle_js)
@fs.write_file(config.export_path, html, create_mode=CreateOrTruncate)
// Name the embedded bundle: a stale artifact silently shadowing a
// fresh build is invisible in the output file, but not in this line.
println(
"openseek viz: wrote standalone export '\{config.export_path}' (\{rows.length()} session(s), \{html.length()} chars; bundle: \{bundle.describe()})",
)
return
}
let identity = server_identity(config.session_root, config.search_dirs)
let assets : ViewerAssets = {
shell,
bundle: bundle_paths,
watch: config.watch,
identity,
}
if config.ensure {
ensure_main(config, catalog, assets)
return
}
let addr = @socket.Addr::parse("\{config.host}:\{config.port}") catch {
_ => fail("invalid --host/--port: \{config.host}:\{config.port}")
}
let server = @http.Server(addr, reuse_addr=true) catch {
@os_error.OSError(_) as error => {
// The port is taken. If an `inspect` for these same sessions holds it,
// point at that one rather than fail: starting a second is never wanted.
let probe_host = if config.host == "0.0.0.0" {
"127.0.0.1"
} else {
config.host
}
if probe_inspect(probe_host, config.port, identity) {
println(
"openseek viz: already serving these sessions at http://\{probe_host}:\{config.port}",
)
return
}
raise error
}
error => raise error
}
print_serving(catalog, assets)
print_open_urls(config.host, config.port)
serve(server, catalog, assets, idle_ms=idle_ms(config.idle_minutes))
}
///|
/// `--ensure`: reuse the server already serving these sessions, or become it.
/// Either way the last startup line is `openseek viz: open `, which is
/// what a caller such as the TUI reads.
async fn ensure_main(
config : VizConfig,
catalog : SessionCatalog,
assets : ViewerAssets,
) -> Unit {
let ports = if config.port_explicit {
[config.port]
} else {
ensure_ports(assets.identity)
}
match ensure_server(config.session_root, assets.identity, ports) {
Reused(port, token) =>
println("openseek viz: open \{ensure_url(port, token)} (already running)")
Started(server, port, token) => {
print_serving(catalog, assets)
if config.idle_minutes > 0 {
println(
"openseek viz: exits after \{config.idle_minutes} idle minute(s)",
)
}
println("openseek viz: open \{ensure_url(port, token)}")
serve(
server,
catalog,
assets,
access={ token, port, },
idle_ms=idle_ms(config.idle_minutes),
watch_root=config.session_root,
on_exit=() => remove_own_record(config.session_root, port, token),
)
}
}
}
///|
async fn print_serving(catalog : SessionCatalog, assets : ViewerAssets) -> Unit {
println("openseek viz: serving \{catalog.roots.length()} session root(s)")
for root in catalog.roots {
println("openseek viz: sessions under '\{root.path}'")
}
println("openseek viz: shell from \{resolve_shell(assets.shell).describe()}")
println(
"openseek viz: bundle from \{resolve_bundle(assets.bundle).describe()}",
)
if assets.watch {
println(
"openseek viz: --watch: open sessions refresh every \{watch_interval_ms}ms",
)
}
}
///|
/// `--idle-exit` minutes in milliseconds; 0 stays 0 ("never").
fn idle_ms(minutes : Int) -> Int64 {
minutes.to_int64() * 60_000L
}
///|
/// Serve until stopped; or, with a positive `idle_ms`, until that long passes
/// with no request (a watching browser tab counts, since it asks every
/// second); or, with `watch_root`, until that directory is deleted, so a
/// server started for a temporary session root does not outlive it. Then
/// `on_exit` runs and the server shuts down.
async fn serve(
server : @http.Server,
catalog : SessionCatalog,
assets : ViewerAssets,
access? : Guard,
idle_ms~ : Int64,
watch_root? : String,
root_check_ms? : Int = 10_000,
on_exit? : async () -> Unit = () => (),
) -> Unit {
let last_request = Ref(@async.now())
@async.with_task_group(group => {
if idle_ms > 0L || watch_root is Some(_) {
group.spawn_bg(no_wait=true) <| () => {
let mut check_every = 30_000
if idle_ms > 0L && idle_ms < check_every.to_int64() {
check_every = idle_ms.to_int()
}
if watch_root is Some(_) && root_check_ms < check_every {
check_every = root_check_ms
}
while true {
@async.sleep(check_every)
let reason = match watch_root {
Some(root) if !(@fs.exists(root) catch { _ => true }) =>
Some("session root '\{root}' is gone")
_ =>
if idle_ms > 0L && @async.now() - last_request.val >= idle_ms {
Some("idle for \{idle_ms / 60_000L} minute(s)")
} else {
None
}
}
if reason is Some(why) {
println("openseek viz: \{why}; exiting")
on_exit()
group.return_immediately(())
}
}
}
}
server.run_forever((request, _body, conn) => {
last_request.val = @async.now()
handle(request, conn, catalog, assets, access?)
})
})
}
///|
/// Print the URL(s) for reaching the server. When bound to the wildcard
/// `0.0.0.0` (all interfaces), show both the loopback URL for this machine and
/// the discovered LAN URL a remote host can open; a specific bound host prints
/// just that one URL. The LAN line is best-effort — if no external address can
/// be found, say so rather than print a misleading URL.
async fn print_open_urls(host : String, port : Int) -> Unit {
if host != "0.0.0.0" {
println("openseek viz: open http://\{host}:\{port}")
return
}
println("openseek viz: open http://127.0.0.1:\{port} (this machine)")
match local_lan_ip() {
Some(ip) =>
println(
"openseek viz: open http://\{ip}:\{port} (LAN, reachable from other machines)",
)
None =>
println(
"openseek viz: bound to all interfaces; open this machine's LAN IP from another host",
)
}
}
///|
/// Best-effort discovery of this machine's primary LAN IPv4 address, for
/// printing a URL a remote host can open. Opens a UDP socket "connected" to a
/// public address and reads back the local address the OS chose for that route.
/// No packet is sent — a UDP `connect` only fixes the default peer and triggers
/// source-address selection in the kernel routing table — so this works offline
/// as long as a route (e.g. a LAN default gateway) exists. Returns `None` when
/// there is no usable route, or the chosen address is loopback/unspecified and
/// thus not reachable from another host.
async fn local_lan_ip() -> String? {
let probe = @socket.UdpClient(@socket.Addr::parse("8.8.8.8:53")) catch {
_ => return None
}
let addr = probe.addr
probe.close()
// The probe forces an IPv4 route, so the local address is IPv4; bail on the
// unexpected IPv6 case rather than mis-format it.
if addr.is_ipv6() {
return None
}
let ip = addr.ip()
let a = (ip >> 24) & 255
let b = (ip >> 16) & 255
let c = (ip >> 8) & 255
let d = ip & 255
if a == 127 || ip == 0 {
return None
}
Some("\{a}.\{b}.\{c}.\{d}")
}
///|
/// Where the viewer's own files come from, plus whether to serve it in watch
/// mode. Candidates are resolved per request, so a frontend rebuilt while the
/// server runs is picked up on reload.
priv struct ViewerAssets {
shell : Array[String]
bundle : Array[String]
watch : Bool
// What this server serves (`server_identity`), for `/api/info`.
identity : String
}
///|
/// A fully-computed HTTP reply. Building it does no socket I/O, so the single
/// send in `handle` is the only place a response is written — there is no way to
/// start a second response (which would panic `send_response`).
struct Reply {
code : Int
reason : String
content_type : String
body : String
}
///|
let sessions_dir_name : String = "sessions"
///|
let session_file_prefix : String = "openseek_session-"
///|
let session_file_suffix : String = ".jsonl"
///|
fn session_file_name(id : String) -> String {
"\{session_file_prefix}\{id}\{session_file_suffix}"
}
///|
/// Handle one request: compute the reply (catching any failure as a 500), then
/// send it exactly once. A failure during the send — e.g. the client closed the
/// connection mid-body — is swallowed: the response was already started, so
/// retrying would double-send. `run_forever` keeps serving other connections.
async fn handle(
request : @http.Request,
conn : @http.ServerConnection,
catalog : SessionCatalog,
assets : ViewerAssets,
access? : Guard,
) -> Unit {
// An `--ensure` server answers only its own loopback name and its token;
// `/api/info` stays open so another `inspect` can recognise it.
let path = strip_query(request.path)
let (reply, set_cookie) = match access {
Some(access) if path != "/api/info" =>
if !access.host_allowed(request.headers) {
(text_reply(403, "Forbidden", "unexpected Host header"), None)
} else if !access.authorized(request.path, request.headers) {
(
text_reply(
403, "Forbidden", "missing or wrong token: open the URL `inspect --ensure` printed",
),
None,
)
} else {
// Opening the printed `?t=` URL leaves the token in a cookie, so the
// page's own requests for the bundle and the session API get through.
let cookie = if query_param(request.path, "t") is Some(_) {
Some(access.cookie())
} else {
None
}
(build_reply_or_500(request, catalog, assets), cookie)
}
_ => (build_reply_or_500(request, catalog, assets), None)
}
send_reply(conn, reply, set_cookie?) catch {
// The send already started, so a mid-body failure (client hung up) must not
// trigger a second send_response. Swallow it; run_forever keeps serving.
_ => ()
}
}
///|
async fn build_reply_or_500(
request : @http.Request,
catalog : SessionCatalog,
assets : ViewerAssets,
) -> Reply {
build_reply(request, catalog, assets) catch {
error => text_reply(500, "Internal Server Error", "error: \{error}")
}
}
///|
async fn send_reply(
conn : @http.ServerConnection,
reply : Reply,
set_cookie? : String,
) -> Unit {
let headers : Map[@http.CaseInsensitiveString, String] = {
"Content-Type": reply.content_type,
}
if set_cookie is Some(cookie) {
headers.set(@http.CaseInsensitiveString("Set-Cookie"), cookie)
}
conn.send_response(reply.code, reply.reason, extra_headers=headers)
conn.write_string(reply.body)
conn.end_response()
}
///|
async fn build_reply(
request : @http.Request,
catalog : SessionCatalog,
assets : ViewerAssets,
) -> Reply {
guard request.meth is Get else {
return text_reply(405, "Method Not Allowed", "only GET is supported")
}
let path = strip_query(request.path)
match path {
"/" | "/index.html" =>
match resolve_shell(assets.shell).text() {
Some(html) =>
{
code: 200,
reason: "OK",
content_type: "text/html; charset=utf-8",
body: if assets.watch {
shell_with_watch(html, watch_interval_ms)
} else {
html
},
}
None =>
text_reply(
404, "Not Found", "index.html not found; pass --web-dir or run from the openseek checkout",
)
}
"/viz_app.js" =>
match resolve_bundle(assets.bundle).text() {
Some(js) =>
{
code: 200,
reason: "OK",
content_type: "text/javascript; charset=utf-8",
body: js,
}
None =>
text_reply(
404, "Not Found", "viz_app.js not found; build it with: moon build",
)
}
"/api/sessions" => session_list_reply(catalog)
"/api/info" => info_reply(assets.identity)
_ =>
match path.strip_prefix("/api/sessions/") {
Some(rest) =>
session_path_reply(
catalog,
"\{rest}",
known_bytes?=known_bytes_param(request.path),
)
None => text_reply(404, "Not Found", "no route for \{path}")
}
}
}
///|
/// Resolve `` (envelope) or `/` (raw) after the
/// `/api/sessions/` route prefix. A key is either legacy `` for the first
/// matching file, or an opaque key returned by `/api/sessions`. Both segments
/// are percent-decoded — ids containing URL-reserved characters (spaces, `?`,
/// `#`) must decode in both positions to resolve the right session. `+` stays
/// literal, as it is in a path segment, and malformed escapes stay verbatim. The
/// resolved path always comes from the catalog scan, never from the URL file
/// segment.
async fn session_path_reply(
catalog : SessionCatalog,
rest : String,
known_bytes? : Int64,
) -> Reply {
let (key, file) = match rest.split_once("/") {
None => (@percent.decode_lossy(rest), None)
Some((key, file)) =>
(@percent.decode_lossy(key), Some(@percent.decode_lossy(file)))
}
let row = match catalog.lookup(key) {
Some(found) => found
None => return text_reply(404, "Not Found", "session file not found")
}
guard valid_session_id(row.id) else {
return text_reply(400, "Bad Request", "invalid session id")
}
match file {
None => session_envelope_reply(row.path, known_bytes?)
Some(name) if name == session_file_name(row.id) =>
asset_reply(row.path, "application/x-ndjson; charset=utf-8")
Some(other) =>
text_reply(404, "Not Found", "no such session file: \{other}")
}
}
///|
/// Mirror the session-store id validation enough for URL handling. File scan
/// paths are catalog-derived, but a legacy bare-id URL still needs an id check.
fn valid_session_id(value : String) -> Bool {
value != "" &&
!value.contains("/") &&
!value.contains("\\") &&
!value.contains("\u{0}") &&
value != "." &&
value != ".."
}
///|
/// Serve one session as a single JSON envelope:
/// `{ "found": Bool, "events": String, "events_bytes": Number }`.
///
/// The frontend's HTTP client (`rabbita/http`) resolves any completed response
/// as success regardless of status code, so absence and content must be encoded
/// in the body rather than signalled by 404. `events` is the raw session-file
/// text — the header line followed by event lines — which the frontend parser
/// splits apart itself. `events_bytes` is the byte length of exactly that text,
/// read once, so it can never claim bytes the reply does not carry.
///
/// With `known_bytes` (a watching viewer's current size), a log that has not
/// changed size answers `{ "found": true, "unchanged": true, "events_bytes" }`
/// instead of resending the whole file. Session logs are append-only between
/// compactions, and a compaction rewrites the file to a different size.
async fn session_envelope_reply(path : String, known_bytes? : Int64) -> Reply {
if !@fs.exists(path) {
return json_reply({ "found": false })
}
if known_bytes is Some(known) && file_size(path) == known {
return json_reply({
"found": true,
"unchanged": true,
"events_bytes": Json(known.to_double()),
})
}
let bytes = @fs.read_file(path).binary()
json_reply({
"found": true,
"events": @utf8.decode_lossy(bytes),
"events_bytes": Json(bytes.length().to_double()),
})
}
///|
async fn session_list_reply(catalog : SessionCatalog) -> Reply {
json_reply(catalog.rows().map(row => row.to_json()).to_json())
}
///|
/// Assemble a single self-contained HTML file: the viewer shell with the
/// compiled frontend bundle inlined and every API response the server would
/// serve — the `/api/sessions` listing plus one envelope per session — baked
/// into a `window.__OPENSEEK_DATA__` map, keyed by request path. The frontend's
/// transport checks that map before hitting the network, so the exported file
/// renders every session offline, with no server and no dependence on the
/// current parser: the bundle that understands these logs ships alongside them.
async fn build_export_html(
rows : Array[SessionListRow],
shell_html : String,
bundle_js : String,
) -> String {
let sessions : Array[@viz_export.Session] = []
for row in rows {
sessions.push(
Session(
key=row.key,
id=row.id,
root=row.root,
root_label=row.root_label,
is_marker=row.is_marker,
last_active=row.last_active.map(seconds => seconds.to_double()),
first_prompt=row.first_prompt,
events=read_text_lossy(row.path),
),
)
}
@viz_export.StandaloneExport(shell_html, bundle_js).render(sessions)
}
///|
/// The bundle to serve/embed: an explicit `--bundle` (the first candidate,
/// blank when unset) wins outright when it exists; otherwise the FRESHEST
/// existing candidate by mtime — not the first. List order once let an
/// 11-day-old `release` artifact silently shadow a fresh `debug` build,
/// shipping a stale frontend into an export; for build outputs, recency is
/// the signal that matters. Ties keep candidate order.
async fn locate_bundle(candidates : Array[String]) -> String? {
guard candidates is [explicit, .. rest] else { return None }
if explicit != "" {
if @fs.exists(explicit) {
return Some(explicit)
}
// An explicitly requested bundle that is missing must not silently
// become a different one: say so, then fall back to auto-location.
println(
"openseek viz: warning: --bundle '\{explicit}' does not exist; auto-locating instead",
)
}
let mut best : (String, Int64, Int)? = None
for candidate in rest {
if candidate == "" || !@fs.exists(candidate) {
continue
}
guard file_mtime(candidate) is Some((seconds, nanoseconds)) else {
continue
}
let newer = match best {
None => true
Some((_, best_seconds, best_nanoseconds)) =>
seconds > best_seconds ||
(seconds == best_seconds && nanoseconds > best_nanoseconds)
}
if newer {
best = Some((candidate, seconds, nanoseconds))
}
}
best.map(entry => entry.0)
}
///|
async fn file_mtime(path : String) -> (Int64, Int)? {
let file = @fs.open(path, mode=ReadOnly) catch { _ => return None }
defer file.close()
let stamp = file.mtime() catch { _ => return None }
Some(stamp)
}
///|
/// The first non-empty candidate path that names an existing file, or `None`.
/// Used for the SHELL (a curated file, where list order is the intent);
/// the bundle resolves by recency via `locate_bundle` instead.
async fn first_existing(candidates : Array[String]) -> String? {
for candidate in candidates {
if candidate != "" && @fs.exists(candidate) {
return Some(candidate)
}
}
None
}
///|
priv struct SessionRoot {
path : String
scan_path : String
label : String
recursive : Bool
// Whether this root is a discovered marker store (its directory is named like
// the root marker, e.g. `.openseek`) rather than a direct directory/file of
// copied JSONL logs. The sidebar uses this to decide whether the path's last
// segment is a marker to strip when grouping by directory.
is_marker : Bool
}
///|
priv struct SessionCatalog {
roots : Array[SessionRoot]
}
///|
priv struct SessionListRow {
key : String
id : String
root : String
root_label : String
is_marker : Bool
path : String
last_active : Int64?
last_active_nanoseconds : Int
first_prompt : String
}
///|
fn SessionListRow::to_json(self : SessionListRow) -> Json {
let last_active : Json = match self.last_active {
Some(seconds) => Json(seconds.to_double())
None => Json::null()
}
{
"key": self.key,
"id": self.id,
"root": self.root,
"root_label": self.root_label,
"is_marker": self.is_marker,
"last_active": last_active,
"first_prompt": self.first_prompt,
}
}
///|
fn compare_session_list_rows(a : SessionListRow, b : SessionListRow) -> Int {
match (a.last_active, b.last_active) {
(Some(a_time), Some(b_time)) => {
// Most recently active first: compare b to a for a descending order.
let by_time = b_time.compare(a_time)
if by_time != 0 {
return by_time
}
let by_nanoseconds = b.last_active_nanoseconds.compare(
a.last_active_nanoseconds,
)
if by_nanoseconds != 0 {
return by_nanoseconds
}
compare_session_list_row_labels(a, b)
}
(Some(_), None) => -1
(None, Some(_)) => 1
(None, None) => compare_session_list_row_labels(a, b)
}
}
///|
fn compare_session_list_row_labels(
a : SessionListRow,
b : SessionListRow,
) -> Int {
let root_order = a.root_label.compare(b.root_label)
if root_order != 0 {
root_order
} else {
a.id.compare(b.id)
}
}
///|
async fn session_catalog(
session_root : String,
search_dirs : Array[String],
session_root_name : String,
) -> SessionCatalog {
// Scan the --session-root only when it is a container, so nested stores under
// a container root and standalone copied-log directories are surfaced the same
// way search-dir trees are. A direct store root is already served via
// `session_root_sources`; do not recurse into its `sessions/` directories
// as copied-log roots.
let scan_dirs = if is_session_store(session_root, session_root_name) {
search_dirs
} else {
[session_root, ..search_dirs]
}
let roots = unique_roots(
[
// Serve the explicit --session-root directly when it is a store, a directory
// of copied `openseek_session-*.jsonl` files, or a single matching file.
..session_root_sources(session_root, session_root_name),
..discover_session_roots(scan_dirs, session_root_name),
],
)
{ roots, }
}
///|
async fn discover_session_roots(
search_dirs : Array[String],
session_root_name : String,
) -> Array[SessionRoot] {
let roots : Array[SessionRoot] = []
for dir in search_dirs {
scan_session_roots(dir, session_root_name, roots)
}
roots
}
///|
async fn scan_session_roots(
dir : String,
session_root_name : String,
roots : Array[SessionRoot],
) -> Unit {
guard is_directory(dir) else {
if is_session_jsonl_file(dir) {
roots.push(jsonl_collection_root(dir))
}
return
}
if path_tail(dir) == session_root_name {
if contains_direct_session_file(dir) {
roots.push(jsonl_collection_root(dir))
}
roots.push(store_root(dir, session_root_name))
return
}
let direct = join_path(dir, session_root_name)
if is_directory(direct) {
roots.push(store_root(direct, session_root_name))
}
if contains_direct_session_file(dir) {
roots.push(jsonl_collection_root(dir))
}
let names = @fs.readdir(dir, sort=true) catch { _ => return }
for name in names {
if should_skip_scan_dir(name, session_root_name) {
continue
}
let child = join_path(dir, name)
if is_directory(child) {
scan_session_roots(child, session_root_name, roots)
}
}
}
///|
async fn is_directory(path : String) -> Bool {
(@fs.kind(path, follow_symlink=false) catch { _ => return false }) ==
Directory
}
///|
async fn is_regular_file(path : String) -> Bool {
(@fs.kind(path, follow_symlink=false) catch { _ => return false }) == Regular
}
///|
/// Whether `path` is itself a session store (servable directly) rather than a
/// container to scan: it is named like the root marker (e.g. `.openseek`), or it
/// already holds a `sessions/` directory.
async fn is_session_store(path : String, session_root_name : String) -> Bool {
path_tail(path) == session_root_name ||
is_directory(join_path(path, sessions_dir_name))
}
///|
async fn session_root_sources(
path : String,
session_root_name : String,
) -> Array[SessionRoot] {
let roots : Array[SessionRoot] = []
if is_session_jsonl_source(path) {
roots.push(jsonl_collection_root(path))
}
if is_session_store(path, session_root_name) {
roots.push(store_root(path, session_root_name))
}
roots
}
///|
fn store_root(path : String, session_root_name : String) -> SessionRoot {
{
path,
scan_path: join_path(path, sessions_dir_name),
label: root_label(path),
recursive: true,
is_marker: path_tail(path) == session_root_name,
}
}
///|
fn jsonl_collection_root(path : String) -> SessionRoot {
{
path,
scan_path: path,
label: root_label(path),
recursive: false,
is_marker: false,
}
}
///|
async fn is_session_jsonl_source(path : String) -> Bool {
is_session_jsonl_file(path) || contains_direct_session_file(path)
}
///|
async fn is_session_jsonl_file(path : String) -> Bool {
is_regular_file(path) && session_id_from_file_name(path_tail(path)) is Some(_)
}
///|
async fn contains_direct_session_file(dir : String) -> Bool {
guard is_directory(dir) else { return false }
let names = @fs.readdir(dir, sort=true) catch { _ => return false }
for name in names {
let child = join_path(dir, name)
if session_id_from_file_name(name) is Some(_) && is_regular_file(child) {
return true
}
}
false
}
///|
fn should_skip_scan_dir(name : String, session_root_name : String) -> Bool {
name == session_root_name || should_skip_session_file_scan_dir(name)
}
///|
fn should_skip_session_file_scan_dir(name : String) -> Bool {
name is (".git" | "node_modules" | ".mooncakes" | "_build")
}
///|
let first_prompt_scan_limit : Int = 50
///|
async fn unique_roots(roots : Array[SessionRoot]) -> Array[SessionRoot] {
let seen : Array[String] = []
let unique : Array[SessionRoot] = []
for root in roots {
let key = @fs.realpath(root.scan_path) catch { _ => root.scan_path }
if !seen.contains(key) {
seen.push(key)
unique.push(root)
}
}
unique
}
///|
async fn first_prompt_in_log(path : String) -> String {
let file = @fs.open(path, mode=ReadOnly) catch { _ => return "" }
defer file.close()
let _ = file.read_until("\n") catch { _ => return "" }
for _ in 0.. return "" }
guard next is Some(line) else { break }
if line.is_blank() {
continue
}
let event : @agent_session.SessionEvent = @json.from_json(@json.parse(line)) catch {
_ => return ""
}
if event.item() is User(message) {
let content = message.content()
let text = content.text()
return if text.is_blank() && !content.is_text_only() {
"[Image]"
} else {
text
}
}
}
""
}
///|
async fn SessionCatalog::rows(self : SessionCatalog) -> Array[SessionListRow] {
let rows : Array[SessionListRow] = []
for root in self.roots {
for path in session_files_under(root.scan_path, root.recursive) {
guard session_id_from_file_name(path_tail(path)) is Some(id) else {
continue
}
let (last_active, last_active_nanoseconds) = session_file_stamp(path)
rows.push({
key: stable_session_key(path),
id,
root: root.path,
root_label: root.label,
is_marker: root.is_marker,
path,
last_active,
last_active_nanoseconds,
first_prompt: first_prompt_in_log(path),
})
}
}
rows.sort_by(compare_session_list_rows)
rows
}
///|
async fn session_files_under(path : String, recursive : Bool) -> Array[String] {
let files : Array[String] = []
collect_session_files(path, files, recursive)
files.sort()
files
}
///|
async fn collect_session_files(
path : String,
files : Array[String],
recursive : Bool,
) -> Unit {
let kind = @fs.kind(path, follow_symlink=false) catch { _ => return }
match kind {
Regular =>
if session_id_from_file_name(path_tail(path)) is Some(_) {
files.push(path)
}
Directory => {
let names = @fs.readdir(path, sort=true) catch { _ => return }
for name in names {
if should_skip_session_file_scan_dir(name) {
continue
}
let child = join_path(path, name)
let child_kind = @fs.kind(child, follow_symlink=false) catch {
_ => continue
}
match child_kind {
Regular =>
if session_id_from_file_name(name) is Some(_) {
files.push(child)
}
Directory =>
if recursive {
collect_session_files(child, files, recursive)
}
_ => ()
}
}
}
_ => ()
}
}
///|
fn stable_session_key(path : String) -> String {
let builder = StringBuilder()
builder <+ "file-"
for byte in @utf8.encode(path) {
let value = byte.to_int()
if value < 16 {
builder <+ "0"
}
builder <+ "\{value.to_string(radix=16)}"
}
builder.to_string()
}
///|
fn session_id_from_file_name(name : String) -> String? {
guard name.strip_prefix(session_file_prefix) is Some(rest) &&
rest.strip_suffix(session_file_suffix) is Some(id) else {
return None
}
let id = id.to_owned()
if valid_session_id(id) {
Some(id)
} else {
None
}
}
///|
async fn session_file_stamp(path : String) -> (Int64?, Int) {
let stamp = @fs.mtime(path) catch { _ => return (None, 0) }
let (seconds, nanoseconds) = stamp
(Some(seconds), nanoseconds)
}
///|
async fn SessionCatalog::lookup(
self : SessionCatalog,
key : String,
) -> SessionListRow? {
let rows = self.rows()
match rows.iter().find_first(row => row.key == key) {
Some(row) => Some(row)
None => rows.iter().find_first(row => row.id == key)
}
}
///|
fn root_label(path : String) -> String {
let trimmed = trim_trailing_path_separators(path)
if trimmed.is_empty() {
path
} else {
trimmed
}
}
///|
fn path_tail(path : String) -> String {
let trimmed = trim_trailing_path_separators(path)
if trimmed =~ (re"^(.|\n)*[/\\]", after~) {
after.to_owned()
} else {
trimmed
}
}
///|
fn trim_trailing_path_separators(path : String) -> String {
let trimmed = for view = path[:] {
match view {
// Always keep at least one character, so "/" stays "/".
[.. rest, '/' | '\\'] if !rest.is_empty() => continue rest
_ => break view
}
}
if trimmed.length() == path.length() {
path
} else {
trimmed.to_owned()
}
}
///|
fn join_path(parent : String, child : String) -> String {
if parent.is_empty() || parent.has_suffix("/") || parent.has_suffix("\\") {
"\{parent}\{child}"
} else {
"\{parent}/\{child}"
}
}
///|
/// The OpenSeek checkout root derived from this executable's own path, or
/// `None` for a binary living outside a moon build tree.
///
/// `moon run` keeps the invoking shell's working directory — which is how a
/// user serving another project's sessions (`moon run ../openseek/cmd/...`)
/// ends up with CWD-relative asset defaults pointing at the wrong tree. But
/// the binary itself always sits at
/// `/_build///build/moonbitlang/inspect/...`, so everything
/// before the `_build` component locates the checkout and its assets,
/// wherever the server was started from.
fn module_root_from_exe(exe : String) -> String? {
match exe.split_once("/_build/") {
Some((root, _)) => Some("\{root}")
None =>
if exe.strip_prefix("_build/") is Some(_) {
Some(".")
} else {
None
}
}
}
///|
/// Where to look for the viewer shell, in order: the configured `--web-dir`
/// (CWD-relative by default, preserving in-checkout behavior), then the
/// checkout's own `web/` derived from the executable path, so running from
/// another project's directory needs no flags.
///
/// Outside a checkout (`module_root` is `None`, e.g. under `moonx`) only an
/// explicit `--web-dir` is searched: the default `web/` would otherwise pick up
/// whatever unrelated `web/index.html` the current project has, instead of the
/// shell built into the binary.
fn shell_candidates(
web_dir : String,
module_root : String?,
web_dir_explicit~ : Bool,
) -> Array[String] {
let candidates = []
if web_dir_explicit || module_root is Some(_) {
candidates.push("\{web_dir}/index.html")
}
if module_root is Some(root) {
candidates.push("\{root}/web/index.html")
}
candidates
}
///|
/// Where to look for the compiled frontend bundle. Element 0 is the
/// explicit `--bundle` path (blank when unset) — the one position that
/// matters, since `locate_bundle` resolves the rest by mtime, not list
/// order: `/viz_app.js`, moon's release and debug outputs for the
/// standalone frontend module, and the same paths under the
/// executable-derived checkout root. This lets `moon build cmd/viz_app
/// --target js` followed by `moon run inspect` work with no copy step —
/// from any directory.
///
/// Outside a checkout only an explicit `--bundle` or `--web-dir` is searched,
/// as for the shell: the CWD-relative build outputs belong to a checkout, and
/// the binary carries its own bundle.
fn bundle_candidates(
bundle : String,
web_dir : String,
module_root : String?,
web_dir_explicit~ : Bool,
) -> Array[String] {
let build_outputs = [
"_build/js/release/build/moonbitlang/openseek-viz-app/openseek-viz-app.js", "_build/js/debug/build/moonbitlang/openseek-viz-app/openseek-viz-app.js",
]
let candidates = [bundle]
if web_dir_explicit || module_root is Some(_) {
candidates.push("\{web_dir}/viz_app.js")
}
if module_root is Some(_) {
candidates.append(build_outputs)
}
if module_root is Some(root) {
candidates.push("\{root}/web/viz_app.js")
for path in build_outputs {
candidates.push("\{root}/\{path}")
}
}
candidates
}
///|
/// Read a file into a reply, or a 404 reply when it does not exist.
async fn asset_reply(path : String, content_type : String) -> Reply {
if !@fs.exists(path) {
return text_reply(404, "Not Found", "missing file: \{path}")
}
{ code: 200, reason: "OK", content_type, body: read_text_lossy(path), }
}
///|
/// Read a file as text, decoding invalid UTF-8 lossily rather than raising. A
/// live session file can be torn mid-codepoint by a concurrent append; strict
/// decoding would 500 the whole fetch, hiding the valid prefix. Lossy decoding
/// keeps that prefix, and the torn final line then fails to parse client-side
/// and is reported as a benign truncated tail.
async fn read_text_lossy(path : String) -> String {
@utf8.decode_lossy(@fs.read_file(path).binary())
}
///|
async fn file_size(path : String) -> Int64 {
let file = @fs.open(path, mode=ReadOnly) catch { _ => return 0L }
defer file.close()
file.size() catch {
_ => 0L
}
}
///|
fn json_reply(body : Json) -> Reply {
{
code: 200,
reason: "OK",
content_type: "application/json; charset=utf-8",
body: body.stringify(),
}
}
///|
fn text_reply(code : Int, reason : String, message : String) -> Reply {
{ code, reason, content_type: "text/plain; charset=utf-8", body: message, }
}
///|
/// The path without any `?query` suffix.
fn strip_query(path : String) -> String {
match path.split_once("?") {
Some((path, _)) => "\{path}"
None => path
}
}
///|
fn cli_command() -> @argparse.Command {
Command(
"openseek-viz",
about="Read-only web server for the OpenSeek session visualizer.",
flags=[
FlagArg(
"watch",
long="watch",
env="OPENSEEK_VIZ_WATCH",
about="Keep the browser live: the open session and the session list refresh as their logs grow.",
),
FlagArg(
"ensure",
long="ensure",
env="OPENSEEK_VIZ_ENSURE",
about="Find or start the one server for these sessions: reuse a running one, otherwise serve on loopback at a port derived from --session-root, behind a token, exiting after --idle-exit minutes. Prints `openseek viz: open ` either way.",
),
],
options=[
OptionArg(
"port",
long="port",
env="OPENSEEK_VIZ_PORT",
default_values=["8080"],
about="TCP port to listen on.",
),
OptionArg(
"host",
long="host",
env="OPENSEEK_VIZ_HOST",
default_values=["0.0.0.0"],
about="Interface address to bind. 0.0.0.0 (default) listens on all interfaces and is reachable from other machines; 127.0.0.1 restricts to this machine.",
),
OptionArg(
"session-root",
long="session-root",
default_values=[".openseek"],
about="Compatibility session root to serve in addition to discovered roots.",
),
OptionArg(
"search-dir",
long="search-dir",
env="OPENSEEK_VIZ_SEARCH_DIR",
action=Append,
default_values=["."],
about="Directory to scan recursively for session roots; repeat to scan several trees. The default '.' scan is skipped when --session-root is given explicitly.",
),
OptionArg(
"session-root-name",
long="session-root-name",
env="OPENSEEK_VIZ_SESSION_ROOT_NAME",
default_values=[".openseek"],
about="Directory name treated as an OpenSeek session root during recursive scan.",
),
OptionArg(
"web-dir",
long="web-dir",
env="OPENSEEK_VIZ_WEB_DIR",
default_values=["web"],
about="Directory holding the index.html shell.",
),
OptionArg(
"bundle",
long="bundle",
env="OPENSEEK_VIZ_BUNDLE",
default_values=[""],
about="Path to the compiled viz_app.js; defaults to auto-locating the moon build output.",
),
OptionArg(
"idle-exit",
long="idle-exit",
env="OPENSEEK_VIZ_IDLE_EXIT",
default_values=[""],
about="Exit after this many minutes without a request; 0 never exits. Defaults to 60 with --ensure, otherwise never.",
),
OptionArg(
"export",
long="export",
env="OPENSEEK_VIZ_EXPORT",
default_values=[""],
about="Write a self-contained standalone HTML (all discovered sessions embedded, no server needed) to this path and exit, instead of serving.",
),
OptionArg(
"export-session",
long="export-session",
env="OPENSEEK_VIZ_EXPORT_SESSION",
default_values=[""],
about="Scope --export to this session id plus its subagent children (-sr-N) instead of every discovered session.",
),
],
)
}
///|
/// Whether a session belongs to a scoped export: the target itself or one
/// of its `-sr-N` subagent children — the naming contract the
/// subrun runner derives child session ids by, matched one level deep,
/// like the substrate spawns them.
fn in_export_scope(id : String, target : String) -> Bool {
if id == target {
return true
}
guard id.strip_prefix("\{target}-sr-") is Some(digits) else { return false }
!digits.is_empty() && digits.all(c => c.is_ascii_digit())
}
///|
test "in_export_scope keeps the parent and exactly its own children" {
assert_true(in_export_scope("run", "run"))
assert_true(in_export_scope("run-sr-1", "run"))
assert_true(in_export_scope("run-sr-12", "run"))
// Another parent's child, a lookalike prefix, a non-numeric suffix, and
// a grandchild-shaped id all stay out of scope.
assert_false(in_export_scope("run2-sr-1", "run"))
assert_false(in_export_scope("run-sr-", "run"))
assert_false(in_export_scope("run-sr-x", "run"))
assert_false(in_export_scope("run-sr-1-sr-2", "run"))
assert_true(in_export_scope("run-sr-1-sr-2", "run-sr-1"))
}
///|
/// The directories to scan recursively for session roots. An explicit
/// `--session-root` narrows serving to that root, so the
/// default `.` scan is dropped — pointing the server at another project's
/// sessions must not also pull in whatever tree it was started from. An
/// explicit `--search-dir` always wins: giving both flags means "that root,
/// plus these scans".
fn effective_search_dirs(matches : @argparse.Matches) -> Array[String] {
match matches {
{
values: { "search-dir": _, .. },
sources: { "session-root": Argv, "search-dir": Default, .. },
..
} => []
{ values: { "search-dir": values, .. }, .. } =>
[
for value in values if !value.is_blank() => value.trim().to_owned()
]
_ => []
}
}
///|
fn parse_port(text : String) -> Int raise {
@string.parse_int(text) catch {
_ => fail("--port or OPENSEEK_VIZ_PORT must be an integer, got: \{text}")
}
}
///|
async test "locate_bundle prefers the freshest artifact; explicit wins" {
let root = @fs.tmpdir(prefix="openseek-viz-bundle-")
let old_path = "\{root}/old.js"
let new_path = "\{root}/new.js"
@fs.write_file(old_path, "old", create_mode=CreateOrTruncate)
// Written after a settling delay, so strictly newer even on filesystems with
// coarse timestamp granularity (CI containers are not always APFS).
@async.sleep(20)
@fs.write_file(new_path, "new", create_mode=CreateOrTruncate)
// No explicit bundle: recency beats candidate order.
assert_eq(locate_bundle(["", old_path, new_path]), Some(new_path))
assert_eq(locate_bundle(["", new_path, old_path]), Some(new_path))
// An explicit --bundle wins outright, even when older.
assert_eq(locate_bundle([old_path, new_path]), Some(old_path))
// Missing candidates are skipped; nothing existing is None.
assert_eq(locate_bundle(["", "\{root}/absent.js", old_path]), Some(old_path))
assert_eq(locate_bundle(["", "\{root}/absent.js"]), None)
@fs.rmdir(root, recursive=true)
}