1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages
DocForge2/src/docforge/visualization.py

1797 lines
72 KiB
Python
Raw Normal View History

2026-07-24 16:01:03 -04:00
"""Project-bound, read-only graph visualization over a validated DocForge index."""
from __future__ import annotations
import atexit
import json
2026-07-24 22:07:33 -04:00
import os
2026-07-24 16:01:03 -04:00
import secrets
2026-07-24 22:07:33 -04:00
import socket
2026-07-24 16:01:03 -04:00
import sqlite3
2026-07-24 22:07:33 -04:00
import subprocess
import sys
2026-07-24 16:01:03 -04:00
import threading
import urllib.parse
from collections.abc import Generator
from contextlib import contextmanager
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
2026-07-24 22:07:33 -04:00
from pathlib import Path
2026-07-24 21:43:11 -04:00
from time import monotonic
2026-07-24 22:26:01 -04:00
from typing import cast
2026-07-24 16:01:03 -04:00
from .errors import DocForgeError
from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize
2026-07-24 21:43:11 -04:00
VISUALIZATION_TEMPLATE = "graph-browser@5"
2026-07-24 16:01:03 -04:00
DEFAULT_EDGE_LIMIT = 100
MAX_EDGE_LIMIT = 400
2026-07-24 21:43:11 -04:00
DEFAULT_INITIAL_GRACE_SECONDS = 120.0
DEFAULT_LEASE_SECONDS = 180.0
LEASE_MONITOR_INTERVAL_SECONDS = 1.0
2026-07-24 16:01:03 -04:00
class _VisualizationHttpServer(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = False
class VisualizationIndexSnapshot:
"""Fast read model pinned to one index file validated by ``ProjectIndex.check``."""
_IDENTITY_KEYS = (
"project_id",
"project_root_fingerprint",
"revision",
"source_hash",
"adapter",
"node_count",
"edge_count",
)
def __init__(self, index: ProjectIndex, checked: dict[str, object]) -> None:
self.path = index.path
self.title = index.project.descriptor.title
self.max_query_chars = index.project.descriptor.limits.max_query_chars
self.max_results = index.project.descriptor.limits.max_results
self.max_depth = index.project.descriptor.limits.max_traversal_depth
2026-07-24 22:26:01 -04:00
self.identity: dict[str, object] = {key: checked[key] for key in self._IDENTITY_KEYS}
2026-07-24 16:01:03 -04:00
self._stat = self._safe_stat()
2026-07-24 22:07:33 -04:00
@classmethod
def from_spec(cls, spec: dict[str, object]) -> VisualizationIndexSnapshot:
snapshot = cls.__new__(cls)
2026-07-24 22:26:01 -04:00
path = spec["path"]
title = spec["title"]
max_query_chars = spec["max_query_chars"]
max_results = spec["max_results"]
max_depth = spec["max_depth"]
if (
not isinstance(path, str)
or not isinstance(title, str)
or type(max_query_chars) is not int
or type(max_results) is not int
or type(max_depth) is not int
):
raise DocForgeError("invalid_index", "Visualization snapshot is invalid")
snapshot.path = Path(path)
snapshot.title = title
snapshot.max_query_chars = max_query_chars
snapshot.max_results = max_results
snapshot.max_depth = max_depth
2026-07-24 22:07:33 -04:00
identity = spec["identity"]
if not isinstance(identity, dict):
raise DocForgeError("invalid_index", "Visualization identity is invalid")
2026-07-24 22:26:01 -04:00
typed_identity = cast(dict[str, object], identity)
snapshot.identity = {key: typed_identity[key] for key in cls._IDENTITY_KEYS}
2026-07-24 22:07:33 -04:00
snapshot._stat = snapshot._safe_stat()
return snapshot
def spec(self) -> dict[str, object]:
return {
"path": str(self.path),
"title": self.title,
"max_query_chars": self.max_query_chars,
"max_results": self.max_results,
"max_depth": self.max_depth,
"identity": dict(self.identity),
}
2026-07-24 16:01:03 -04:00
def overview(self) -> dict[str, object]:
with self._connection() as connection:
return self._result(
title=self.title,
node_count=connection.execute("SELECT COUNT(*) FROM nodes").fetchone()[0],
edge_count=connection.execute("SELECT COUNT(*) FROM edges").fetchone()[0],
families=_facet_rows(connection, "nodes", "family"),
authorities=_facet_rows(connection, "nodes", "authority"),
statuses=_facet_rows(connection, "nodes", "status"),
relations=_facet_rows(connection, "edges", "relation"),
max_results=self.max_results,
snapshot=True,
)
def search(
self,
*,
query: str,
family: str | None,
limit: int,
) -> dict[str, object]:
bounded = self._bounded_limit(limit)
with self._connection() as connection:
if query:
if len(query) > self.max_query_chars:
raise DocForgeError(
"invalid_query", "Search query exceeds the configured limit"
)
terms = re_tokenize(query)
if not terms:
raise DocForgeError("invalid_query", "Search query contains no searchable text")
expression = " AND ".join(
f'"{term.replace(chr(34), chr(34) * 2)}"' for term in terms
)
family_clause = "AND nodes.family = ?" if family else ""
values: tuple[object, ...] = (
(expression, family, bounded) if family else (expression, bounded)
)
rows = connection.execute(
"""
SELECT nodes.*, bm25(node_fts) AS rank,
snippet(node_fts, 3, '[', ']', '', 18) AS snippet
FROM node_fts JOIN nodes USING(node_id)
WHERE node_fts MATCH ?
"""
+ family_clause
+ " ORDER BY rank, nodes.node_id LIMIT ?",
values,
).fetchall()
2026-07-24 22:26:01 -04:00
results: list[dict[str, object]] = []
2026-07-24 16:01:03 -04:00
for row in rows:
item = _node_dict(row, include_content=False)
item.update({"rank": row["rank"], "snippet": row["snippet"]})
results.append(item)
else:
family_clause = "WHERE family = ?" if family else ""
values = (family, bounded) if family else (bounded,)
rows = connection.execute(
f"SELECT * FROM nodes {family_clause} ORDER BY node_id LIMIT ?",
values,
).fetchall()
results = [_node_dict(row, include_content=False) for row in rows]
return self._result(
query=query,
family=family,
count=len(results),
results=results,
snapshot=True,
)
def node(self, node_id: str, *, depth: int, limit: int) -> dict[str, object]:
if type(depth) is not int or depth < 1 or depth > self.max_depth:
raise DocForgeError("invalid_depth", "Traversal depth is outside the configured limit")
if type(limit) is not int or limit < 1 or limit > MAX_EDGE_LIMIT:
raise DocForgeError(
"invalid_limit",
"Visualization edge limit exceeds the fixed safety boundary",
maximum=MAX_EDGE_LIMIT,
)
with self._connection() as connection:
root_row = connection.execute(
"SELECT * FROM nodes WHERE node_id = ?", (node_id,)
).fetchone()
if root_row is None:
raise DocForgeError(
"missing_node",
"No node has the requested stable ID",
node_id=node_id,
)
visited = {node_id}
frontier = {node_id}
selected: list[dict[str, str]] = []
selected_keys: set[tuple[str, str, str]] = set()
truncated = False
for _ in range(depth):
if not frontier or len(selected) >= limit:
break
placeholders = ",".join("?" for _ in frontier)
values = tuple(sorted(frontier))
rows = connection.execute(
"SELECT source_id, relation, target_id FROM edges "
f"WHERE source_id IN ({placeholders}) OR target_id IN ({placeholders}) "
"ORDER BY source_id, relation, target_id LIMIT ?",
(*values, *values, limit + 1),
).fetchall()
next_frontier: set[str] = set()
for row in rows:
key = (row["source_id"], row["relation"], row["target_id"])
if key in selected_keys:
continue
if len(selected) >= limit:
truncated = True
break
selected_keys.add(key)
edge = {
"source_id": row["source_id"],
"relation": row["relation"],
"target_id": row["target_id"],
}
selected.append(edge)
for candidate in (edge["source_id"], edge["target_id"]):
if candidate not in visited:
visited.add(candidate)
next_frontier.add(candidate)
frontier = next_frontier
placeholders = ",".join("?" for _ in visited)
node_rows = connection.execute(
f"SELECT * FROM nodes WHERE node_id IN ({placeholders}) ORDER BY node_id",
tuple(sorted(visited)),
).fetchall()
return self._result(
root=node_id,
depth=depth,
edge_limit=limit,
truncated=truncated,
node=_node_dict(root_row),
nodes=[_node_dict(row, include_content=False) for row in node_rows],
edges=selected,
snapshot=True,
)
def require_node(self, node_id: str) -> None:
with self._connection() as connection:
row = connection.execute("SELECT 1 FROM nodes WHERE node_id = ?", (node_id,)).fetchone()
if row is None:
raise DocForgeError(
"missing_node",
"No node has the requested stable ID",
node_id=node_id,
)
def _bounded_limit(self, value: int) -> int:
if type(value) is not int or value < 1 or value > self.max_results:
raise DocForgeError("invalid_limit", "Result limit is outside the configured range")
return value
def _safe_stat(self) -> tuple[int, int, int, int]:
if (
self.path.is_symlink()
or not self.path.is_file()
or self.path.resolve(strict=True) != self.path
):
raise DocForgeError("missing_index", "Validated visualization index is unavailable")
stat = self.path.stat()
return (stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns)
@contextmanager
def _connection(self) -> Generator[sqlite3.Connection, None, None]:
if self._safe_stat() != self._stat:
raise DocForgeError(
"visualization_stale",
"The validated index changed; invoke docforge_visualize again",
)
connection: sqlite3.Connection | None = None
try:
connection = sqlite3.connect(f"file:{self.path}?mode=ro", uri=True)
connection.row_factory = sqlite3.Row
application_id = connection.execute("PRAGMA application_id").fetchone()[0]
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
if application_id != APPLICATION_ID or schema_version != INDEX_SCHEMA_VERSION:
raise DocForgeError(
"invalid_index", "Visualization index has an unsupported schema"
)
metadata = dict(connection.execute("SELECT key, value FROM metadata"))
if any(metadata.get(key) != str(value) for key, value in self.identity.items()):
raise DocForgeError(
"visualization_stale",
"The validated index identity changed; invoke docforge_visualize again",
)
yield connection
if self._safe_stat() != self._stat:
raise DocForgeError(
"visualization_stale",
"The validated index changed during the request",
)
except DocForgeError:
raise
except (json.JSONDecodeError, KeyError, sqlite3.Error, TypeError) as error:
raise DocForgeError(
"invalid_index", "Visualization index is corrupt or unreadable"
) from error
finally:
if connection is not None:
connection.close()
def _result(self, **payload: object) -> dict[str, object]:
return {
"status": "ok",
**self.identity,
**payload,
}
2026-07-24 22:26:01 -04:00
def result(self, **payload: object) -> dict[str, object]:
return self._result(**payload)
2026-07-24 16:01:03 -04:00
class VisualizationRunner:
"""Start one token-protected loopback reader for one immutable project binding."""
2026-07-24 21:43:11 -04:00
def __init__(
self,
2026-07-24 22:07:33 -04:00
index: ProjectIndex | None,
2026-07-24 21:43:11 -04:00
*,
2026-07-24 22:07:33 -04:00
snapshot_spec: dict[str, object] | None = None,
token: str | None = None,
register_atexit: bool = True,
2026-07-24 21:43:11 -04:00
initial_grace_seconds: float = DEFAULT_INITIAL_GRACE_SECONDS,
lease_seconds: float = DEFAULT_LEASE_SECONDS,
monitor_interval_seconds: float = LEASE_MONITOR_INTERVAL_SECONDS,
) -> None:
2026-07-24 22:07:33 -04:00
if (index is None) == (snapshot_spec is None):
raise ValueError("Provide exactly one visualization index or snapshot")
2026-07-24 21:43:11 -04:00
if initial_grace_seconds <= 0 or lease_seconds <= 0 or monitor_interval_seconds <= 0:
raise ValueError("Visualization lease durations must be positive")
2026-07-24 16:01:03 -04:00
self.index = index
2026-07-24 22:07:33 -04:00
self._snapshot_spec = snapshot_spec
self._register_atexit = register_atexit
2026-07-24 21:43:11 -04:00
self.initial_grace_seconds = initial_grace_seconds
self.lease_seconds = lease_seconds
self.monitor_interval_seconds = monitor_interval_seconds
2026-07-24 16:01:03 -04:00
self._lock = threading.Lock()
2026-07-24 22:07:33 -04:00
self._token = token or secrets.token_urlsafe(24)
2026-07-24 16:01:03 -04:00
self._server: _VisualizationHttpServer | None = None
self._thread: threading.Thread | None = None
2026-07-24 21:43:11 -04:00
self._lease_thread: threading.Thread | None = None
self._lease_stop = threading.Event()
self._lease_last_activity = monotonic()
self._lease_connected = False
2026-07-24 16:01:03 -04:00
self._atexit_registered = False
self._reader: VisualizationIndexSnapshot | None = None
def start(
self,
*,
node_id: str | None = None,
query: str | None = None,
depth: int = 1,
) -> dict[str, object]:
if node_id is not None and query is not None:
raise DocForgeError(
"invalid_visualization_target",
"Choose either one exact node ID or one search query",
)
2026-07-24 22:07:33 -04:00
reader = (
VisualizationIndexSnapshot(self.index, self.index.check())
if self.index is not None
else VisualizationIndexSnapshot.from_spec(self._snapshot_spec or {})
)
maximum_depth = reader.max_depth
2026-07-24 16:01:03 -04:00
if type(depth) is not int or depth < 1 or depth > maximum_depth:
raise DocForgeError(
"invalid_depth",
"Visualization depth is outside the configured traversal limit",
)
if node_id is not None:
reader.require_node(node_id)
elif query is not None:
reader.search(query=query, family=None, limit=1)
with self._lock:
self._reader = reader
if self._server is None:
runner = self
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
runner._handle_get(self)
def do_HEAD(self) -> None: # noqa: N802
runner._handle_get(self, include_body=False)
def do_POST(self) -> None: # noqa: N802
runner._respond_error(
self,
HTTPStatus.METHOD_NOT_ALLOWED,
"method_not_allowed",
"The visualization service is read-only",
)
def do_PUT(self) -> None: # noqa: N802
self.do_POST()
def do_PATCH(self) -> None: # noqa: N802
self.do_POST()
def do_DELETE(self) -> None: # noqa: N802
self.do_POST()
2026-07-24 22:26:01 -04:00
def log_message(self, format: str, *args: object) -> None:
del format, args
2026-07-24 16:01:03 -04:00
return
self._server = _VisualizationHttpServer(("127.0.0.1", 0), Handler)
2026-07-24 21:43:11 -04:00
self._lease_last_activity = monotonic()
self._lease_connected = False
self._lease_stop.clear()
2026-07-24 16:01:03 -04:00
self._thread = threading.Thread(
target=self._server.serve_forever,
name="docforge-visualization",
2026-07-24 21:43:11 -04:00
daemon=False,
2026-07-24 16:01:03 -04:00
)
self._thread.start()
2026-07-24 21:43:11 -04:00
self._lease_thread = threading.Thread(
target=self._monitor_lease,
name="docforge-visualization-lease",
daemon=True,
)
self._lease_thread.start()
2026-07-24 22:07:33 -04:00
if self._register_atexit and not self._atexit_registered:
2026-07-24 16:01:03 -04:00
atexit.register(self.stop)
self._atexit_registered = True
server = self._server
assert server is not None
port = int(server.server_address[1])
parameters: dict[str, str] = {"depth": str(depth)}
if node_id is not None:
parameters["node"] = node_id
if query is not None:
parameters["q"] = query
query_string = urllib.parse.urlencode(parameters)
url = f"http://127.0.0.1:{port}/{self._token}/"
if query_string:
url = f"{url}?{query_string}"
return {
"state": "running",
"url": url,
"bind": "127.0.0.1",
"port": port,
"template": VISUALIZATION_TEMPLATE,
"read_only": True,
"project_bound": True,
2026-07-24 21:43:11 -04:00
"lifetime": {
"policy": "browser_lease",
"initial_grace_seconds": self.initial_grace_seconds,
"lease_seconds": self.lease_seconds,
},
2026-07-24 16:01:03 -04:00
"target": {
"node_id": node_id,
"query": query,
"depth": depth,
},
"snapshot": dict(reader.identity),
}
def stop(self) -> None:
with self._lock:
server = self._server
thread = self._thread
self._server = None
self._thread = None
2026-07-24 21:43:11 -04:00
self._lease_thread = None
2026-07-24 16:01:03 -04:00
self._reader = None
2026-07-24 21:43:11 -04:00
self._lease_stop.set()
2026-07-24 16:01:03 -04:00
if server is None:
return
server.shutdown()
server.server_close()
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=2)
2026-07-24 22:07:33 -04:00
def is_running(self) -> bool:
with self._lock:
return self._server is not None
2026-07-24 21:43:11 -04:00
def _touch_lease(self) -> None:
with self._lock:
if self._server is None:
return
self._lease_last_activity = monotonic()
self._lease_connected = True
def _monitor_lease(self) -> None:
while not self._lease_stop.wait(self.monitor_interval_seconds):
with self._lock:
if self._server is None:
return
timeout = (
self.lease_seconds if self._lease_connected else self.initial_grace_seconds
)
expired = monotonic() - self._lease_last_activity >= timeout
if expired:
self.stop()
return
2026-07-24 16:01:03 -04:00
def _handle_get(self, handler: BaseHTTPRequestHandler, *, include_body: bool = True) -> None:
parsed = urllib.parse.urlparse(handler.path)
prefix = f"/{self._token}"
if parsed.path not in {prefix, f"{prefix}/"} and not parsed.path.startswith(
f"{prefix}/api/"
):
self._respond_error(
handler,
HTTPStatus.NOT_FOUND,
"not_found",
"Not found",
include_body=include_body,
)
return
2026-07-24 21:43:11 -04:00
self._touch_lease()
2026-07-24 16:01:03 -04:00
if parsed.path in {prefix, f"{prefix}/"}:
self._respond(
handler,
_GRAPH_BROWSER_HTML.encode("utf-8"),
"text/html; charset=utf-8",
include_body=include_body,
)
return
try:
params = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
reader = self._current_reader()
if parsed.path == f"{prefix}/api/overview":
payload = reader.overview()
2026-07-24 21:43:11 -04:00
elif parsed.path == f"{prefix}/api/heartbeat":
2026-07-24 22:26:01 -04:00
payload = reader.result(
2026-07-24 21:43:11 -04:00
viewer="alive",
lease_seconds=self.lease_seconds,
)
2026-07-24 16:01:03 -04:00
elif parsed.path == f"{prefix}/api/search":
payload = self._search(reader, params)
elif parsed.path == f"{prefix}/api/node":
payload = self._node(reader, params)
else:
self._respond_error(
handler,
HTTPStatus.NOT_FOUND,
"not_found",
"Not found",
include_body=include_body,
)
return
except DocForgeError as error:
status = {
"missing_node": HTTPStatus.NOT_FOUND,
"stale_index": HTTPStatus.CONFLICT,
"source_changed": HTTPStatus.CONFLICT,
"stale_adapter_source": HTTPStatus.CONFLICT,
"visualization_stale": HTTPStatus.CONFLICT,
"invalid_query": HTTPStatus.BAD_REQUEST,
"invalid_depth": HTTPStatus.BAD_REQUEST,
"invalid_limit": HTTPStatus.BAD_REQUEST,
}.get(error.code, HTTPStatus.SERVICE_UNAVAILABLE)
self._respond_json(
handler,
{"status": "error", "error": error.as_dict()},
status=status,
include_body=include_body,
)
return
except (TypeError, ValueError):
self._respond_error(
handler,
HTTPStatus.BAD_REQUEST,
"invalid_request",
"Request parameters are invalid",
include_body=include_body,
)
return
self._respond_json(handler, payload, include_body=include_body)
def _search(
self,
reader: VisualizationIndexSnapshot,
params: dict[str, list[str]],
) -> dict[str, object]:
query = _one(params, "q").strip()
family = _one(params, "family").strip() or None
limit = _integer(_one(params, "limit") or "50")
return reader.search(query=query, family=family, limit=limit)
def _node(
self,
reader: VisualizationIndexSnapshot,
params: dict[str, list[str]],
) -> dict[str, object]:
node_id = _one(params, "id").strip()
if not node_id:
raise DocForgeError("missing_node", "One exact node ID is required")
depth = _integer(_one(params, "depth") or "1")
limit = _integer(_one(params, "limit") or str(DEFAULT_EDGE_LIMIT))
if limit > MAX_EDGE_LIMIT:
raise DocForgeError(
"invalid_limit",
"Visualization edge limit exceeds the fixed safety boundary",
maximum=MAX_EDGE_LIMIT,
)
return reader.node(node_id, depth=depth, limit=limit)
def _current_reader(self) -> VisualizationIndexSnapshot:
with self._lock:
reader = self._reader
if reader is None:
raise DocForgeError(
"visualization_unavailable",
"The visualization snapshot is unavailable",
)
return reader
@staticmethod
def _respond(
handler: BaseHTTPRequestHandler,
payload: bytes,
content_type: str,
*,
status: HTTPStatus = HTTPStatus.OK,
include_body: bool = True,
) -> None:
handler.send_response(status)
handler.send_header("Content-Type", content_type)
handler.send_header("Content-Length", str(len(payload)))
handler.send_header("Cache-Control", "no-store")
handler.send_header(
"Content-Security-Policy",
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
"connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; "
"frame-ancestors 'none'",
)
handler.send_header("X-Content-Type-Options", "nosniff")
handler.send_header("X-Frame-Options", "DENY")
handler.send_header("Referrer-Policy", "no-referrer")
handler.end_headers()
if include_body:
handler.wfile.write(payload)
@classmethod
def _respond_json(
cls,
handler: BaseHTTPRequestHandler,
body: dict[str, object],
*,
status: HTTPStatus = HTTPStatus.OK,
include_body: bool = True,
) -> None:
cls._respond(
handler,
json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8"),
"application/json; charset=utf-8",
status=status,
include_body=include_body,
)
@classmethod
def _respond_error(
cls,
handler: BaseHTTPRequestHandler,
status: HTTPStatus,
code: str,
message: str,
*,
include_body: bool = True,
) -> None:
cls._respond_json(
handler,
{
"status": "error",
"error": {"code": code, "message": message, "details": {}},
},
status=status,
include_body=include_body,
)
2026-07-24 22:07:33 -04:00
class DetachedVisualizationRunner:
"""Launch a viewer worker that survives a short-lived MCP transport process."""
def __init__(
self,
index: ProjectIndex,
*,
2026-07-24 22:09:02 -04:00
owner_pid: int | None = None,
2026-07-24 22:07:33 -04:00
initial_grace_seconds: float = DEFAULT_INITIAL_GRACE_SECONDS,
lease_seconds: float = DEFAULT_LEASE_SECONDS,
monitor_interval_seconds: float = LEASE_MONITOR_INTERVAL_SECONDS,
) -> None:
2026-07-24 22:09:02 -04:00
if owner_pid is not None and owner_pid <= 1:
raise ValueError("Visualization owner PID must identify a live user process")
2026-07-24 22:07:33 -04:00
self.index = index
2026-07-24 22:09:02 -04:00
self.owner_pid = owner_pid if owner_pid is not None else os.getppid()
2026-07-24 22:07:33 -04:00
self.initial_grace_seconds = initial_grace_seconds
self.lease_seconds = lease_seconds
self.monitor_interval_seconds = monitor_interval_seconds
self._process: subprocess.Popen[bytes] | None = None
def start(
self,
*,
node_id: str | None = None,
query: str | None = None,
depth: int = 1,
) -> dict[str, object]:
if node_id is not None and query is not None:
raise DocForgeError(
"invalid_visualization_target",
"Choose either one exact node ID or one search query",
)
snapshot = VisualizationIndexSnapshot(self.index, self.index.check())
if type(depth) is not int or depth < 1 or depth > snapshot.max_depth:
raise DocForgeError(
"invalid_depth",
"Visualization depth is outside the configured traversal limit",
)
if node_id is not None:
snapshot.require_node(node_id)
elif query is not None:
snapshot.search(query=query, family=None, limit=1)
self.stop()
parent_socket, child_socket = socket.socketpair()
try:
command = (
sys.executable,
"-m",
"docforge.visualization_worker",
"--control-fd",
str(child_socket.fileno()),
)
process = subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
pass_fds=(child_socket.fileno(),),
start_new_session=True,
)
self._process = process
child_socket.close()
request = {
"snapshot": snapshot.spec(),
"token": secrets.token_urlsafe(24),
"initial_grace_seconds": self.initial_grace_seconds,
"lease_seconds": self.lease_seconds,
"monitor_interval_seconds": self.monitor_interval_seconds,
2026-07-24 22:09:02 -04:00
"owner_pid": self.owner_pid,
2026-07-24 22:07:33 -04:00
"target": {
"node_id": node_id,
"query": query,
"depth": depth,
},
}
parent_socket.settimeout(10)
parent_socket.sendall(
json.dumps(request, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
)
response = _receive_worker_response(parent_socket)
except (OSError, subprocess.SubprocessError, ValueError) as error:
self.stop()
raise DocForgeError(
"visualization_unavailable",
"The detached visualization worker failed to start",
) from error
finally:
child_socket.close()
parent_socket.close()
if response.get("status") != "ok" or not isinstance(response.get("visualization"), dict):
self.stop()
raise DocForgeError(
"visualization_unavailable",
"The detached visualization worker rejected the snapshot",
)
2026-07-24 22:26:01 -04:00
return cast(dict[str, object], response["visualization"])
2026-07-24 22:07:33 -04:00
def stop(self) -> None:
process = self._process
self._process = None
if process is None or process.poll() is not None:
return
process.terminate()
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=2)
def _receive_worker_response(control: socket.socket) -> dict[str, object]:
chunks: list[bytes] = []
size = 0
while True:
chunk = control.recv(65536)
if not chunk:
break
chunks.append(chunk)
size += len(chunk)
if size > 1_000_000:
raise ValueError("Visualization worker response exceeded its fixed boundary")
if b"\n" in chunk:
break
payload = b"".join(chunks).split(b"\n", 1)[0]
result = json.loads(payload)
if not isinstance(result, dict):
raise ValueError("Visualization worker returned an invalid response")
2026-07-24 22:26:01 -04:00
return cast(dict[str, object], result)
2026-07-24 22:07:33 -04:00
2026-07-24 16:01:03 -04:00
def _one(params: dict[str, list[str]], name: str) -> str:
values = params.get(name) or [""]
return values[0]
def _integer(value: str) -> int:
return int(value)
def _node_dict(row: sqlite3.Row, *, include_content: bool = True) -> dict[str, object]:
result = {
"node_id": row["node_id"],
"title": row["title"],
"family": row["family"],
"authority": row["authority"],
"status": row["status"],
"tags": json.loads(row["tags_json"]),
"summary": row["summary"],
"source_path": row["source_path"],
"source_anchor": row["source_anchor"],
"content_hash": row["content_hash"],
}
if include_content:
result["content"] = row["content"]
return result
def _facet_rows(connection: sqlite3.Connection, table: str, column: str) -> list[dict[str, object]]:
allowed = {
("nodes", "family"),
("nodes", "authority"),
("nodes", "status"),
("edges", "relation"),
}
if (table, column) not in allowed:
raise DocForgeError("invalid_index", "Unsupported visualization facet")
rows = connection.execute(
f"SELECT {column}, COUNT(*) AS count FROM {table} "
f"GROUP BY {column} ORDER BY count DESC, {column}"
).fetchall()
return [{"value": row[0], "count": row[1]} for row in rows]
_GRAPH_BROWSER_HTML = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DocForge graph</title>
<style>
:root {
color-scheme: dark;
--bg: #07101a;
--panel: #0c1825;
--panel-2: #111f2f;
--line: #263b51;
--text: #ecf5ff;
--muted: #93abc3;
--accent: #51d7ff;
--accent-2: #8fffc5;
--warn: #ffd27a;
2026-07-24 21:43:11 -04:00
--left-width: 310px;
--right-width: 350px;
--primary-fill: #176b7d;
--primary-stroke: #83e8ff;
--child-fill: #216c51;
--child-stroke: #91f2bd;
--edge-fill: #634580;
--edge-stroke: #d0a7ff;
2026-07-24 16:01:03 -04:00
font: 14px/1.45 Inter, ui-sans-serif, system-ui, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--text); }
button, input, select { font: inherit; }
button { cursor: pointer; }
code, pre { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.app { display: grid; grid-template-rows: auto 1fr; min-height: 100vh; }
header {
display: flex; gap: 18px; align-items: center; padding: 14px 18px;
border-bottom: 1px solid var(--line); background: rgba(8, 18, 30, .96);
}
header h1 { margin: 0; font-size: 17px; }
.stats { display: flex; gap: 14px; color: var(--muted); }
.status { margin-left: auto; color: var(--muted); }
2026-07-24 21:43:11 -04:00
.layout {
min-height: 0; display: grid;
grid-template-columns: var(--left-width) 7px minmax(360px, 1fr) 7px var(--right-width);
}
2026-07-24 16:01:03 -04:00
aside { min-height: 0; overflow: auto; padding: 16px; background: var(--panel); }
2026-07-24 21:43:11 -04:00
.panel-resizer {
position: relative; z-index: 4; min-height: 0; background: #0a1521;
cursor: col-resize; touch-action: none;
}
.panel-resizer::after {
content: ""; position: absolute; inset: 0 2px; background: var(--line);
transition: background .15s;
}
.panel-resizer:hover::after, .panel-resizer:focus-visible::after,
.panel-resizer.resizing::after { background: var(--accent); }
.panel-resizer:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
2026-07-24 16:01:03 -04:00
form { display: grid; gap: 8px; }
input, select {
width: 100%; border: 1px solid var(--line); border-radius: 8px;
padding: 9px 10px; background: var(--panel-2); color: var(--text);
}
.search-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; }
.button {
border: 1px solid #277fa0; border-radius: 8px; padding: 8px 12px;
background: #12384a; color: var(--text);
}
.results { display: grid; gap: 7px; margin-top: 14px; }
2026-07-24 21:43:11 -04:00
.section-label {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
margin: 18px 0 8px; color: var(--muted); font-size: 11px;
font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
}
.section-label span {
min-width: 24px; border: 1px solid var(--line); border-radius: 999px;
padding: 1px 6px; text-align: center; letter-spacing: 0;
}
.neighborhood { margin-top: 18px; padding-top: 2px; border-top: 1px solid var(--line); }
.node-list { display: grid; gap: 6px; }
.node-list-item {
width: 100%; display: grid; grid-template-columns: 8px minmax(0, 1fr);
gap: 9px; align-items: center; text-align: left; border: 1px solid var(--line);
border-radius: 9px; padding: 8px; background: rgba(17, 31, 47, .72); color: var(--text);
}
.node-list-item:hover, .node-list-item:focus-visible {
border-color: var(--item-color, var(--accent)); outline: none;
background: rgba(24, 43, 62, .9);
}
.node-swatch {
width: 8px; height: 28px; border-radius: 999px;
background: var(--item-color, var(--accent));
box-shadow: 0 0 12px color-mix(in srgb, var(--item-color, var(--accent)) 35%, transparent);
}
.node-list-copy { min-width: 0; }
.node-list-copy strong, .node-list-copy span {
display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.node-list-copy span { color: var(--muted); font-size: 11px; }
.legend {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 14px;
}
.legend span {
display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px;
}
.legend i { width: 8px; height: 8px; border-radius: 50%; }
2026-07-24 16:01:03 -04:00
.result {
width: 100%; text-align: left; border: 1px solid var(--line); border-radius: 9px;
padding: 9px; background: var(--panel-2); color: var(--text);
}
.result:hover, .result:focus { border-color: var(--accent); }
.result strong, .result span { display: block; overflow: hidden; text-overflow: ellipsis; }
.result span { color: var(--muted); font-size: 12px; white-space: nowrap; }
.canvas { position: relative; min-height: 0; overflow: hidden; }
svg { width: 100%; height: 100%; min-height: 620px; background:
2026-07-24 16:09:56 -04:00
radial-gradient(circle at center, #10243a 0, #07101a 64%);
cursor: grab; touch-action: none; user-select: none;
}
.canvas.dragging svg { cursor: grabbing; }
.viewport-controls {
position: absolute; z-index: 2; top: 12px; right: 12px;
display: grid; grid-template-columns: repeat(3, 36px) auto;
align-items: center; gap: 6px; padding: 6px;
border: 1px solid var(--line); border-radius: 10px;
background: rgba(7, 16, 26, .9); box-shadow: 0 5px 18px rgba(0, 0, 0, .28);
}
.viewport-control {
width: 36px; height: 34px; border: 1px solid #31526d; border-radius: 7px;
background: #102b3d; color: var(--text); font-weight: 700;
}
.viewport-control:hover, .viewport-control:focus-visible {
border-color: var(--accent); outline: 2px solid transparent;
}
.zoom-level {
min-width: 48px; padding: 0 5px; color: var(--muted);
font-variant-numeric: tabular-nums; text-align: right;
}
.viewport-hint {
position: absolute; z-index: 1; left: 12px; bottom: 12px;
padding: 5px 8px; border: 1px solid var(--line); border-radius: 7px;
background: rgba(7, 16, 26, .78); color: var(--muted); font-size: 11px;
pointer-events: none;
}
2026-07-24 21:43:11 -04:00
.edge { stroke-opacity: .68; stroke-width: 1.4; }
.edge.child-edge { stroke: #4cbe8a; }
.edge.context-edge { stroke: #a77bd6; }
.edge.boundary-edge { stroke: #52718b; stroke-dasharray: 5 4; }
2026-07-24 16:01:03 -04:00
.edge-label { fill: #8198ae; font-size: 9px; pointer-events: none; }
2026-07-24 16:09:56 -04:00
.node { cursor: pointer; }
2026-07-24 21:43:11 -04:00
.node circle { stroke-width: 1.8; transition: stroke-width .15s, filter .15s; }
.node.root circle { stroke-width: 3; filter: drop-shadow(0 0 8px rgba(81, 215, 255, .24)); }
2026-07-24 16:01:03 -04:00
.node:hover circle { stroke: #fff; stroke-width: 3; }
.node text { fill: var(--text); font-size: 10px; pointer-events: none; }
.node .family { fill: var(--muted); font-size: 8px; }
.empty {
position: absolute; inset: 0; display: grid; place-items: center;
color: var(--muted); pointer-events: none;
}
2026-07-24 21:14:30 -04:00
.empty[hidden] { display: none; }
2026-07-24 21:43:11 -04:00
.connection-state {
position: absolute; z-index: 6; inset: 50% auto auto 50%; transform: translate(-50%, -50%);
width: min(440px, calc(100% - 40px)); border: 1px solid #9a4b59; border-radius: 12px;
padding: 18px; background: rgba(32, 13, 20, .96); color: #ffd4dc;
box-shadow: 0 18px 60px rgba(0, 0, 0, .45); text-align: center;
}
.connection-state[hidden] { display: none; }
.connection-state strong { display: block; margin-bottom: 5px; }
2026-07-24 16:01:03 -04:00
.detail-head { display: flex; align-items: start; gap: 10px; }
.detail-head h2 { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
.badge {
display: inline-block; margin: 4px 5px 0 0; border: 1px solid var(--line);
border-radius: 999px; padding: 3px 7px; color: var(--muted); font-size: 11px;
}
.meta { display: grid; gap: 8px; margin: 14px 0; }
.meta div { display: grid; grid-template-columns: 82px 1fr; gap: 8px; }
.meta dt { color: var(--muted); }
.meta dd { margin: 0; overflow-wrap: anywhere; }
.summary { color: #c9d9e8; }
pre {
white-space: pre-wrap; overflow-wrap: anywhere; max-height: 44vh; overflow: auto;
padding: 12px; border: 1px solid var(--line); border-radius: 9px;
background: #07111c; color: #d6e6f5;
}
2026-07-24 21:01:53 -04:00
dialog {
2026-07-24 21:43:11 -04:00
width: min(760px, calc(100vw - 32px)); height: min(680px, calc(100vh - 32px));
min-width: min(360px, calc(100vw - 20px)); min-height: 280px;
max-width: calc(100vw - 16px); max-height: calc(100vh - 16px);
2026-07-24 21:01:53 -04:00
padding: 0; overflow: hidden; border: 1px solid #36536e; border-radius: 14px;
background: var(--panel); color: var(--text);
2026-07-24 21:43:11 -04:00
box-shadow: 0 24px 80px rgba(0, 0, 0, .58); resize: both;
2026-07-24 21:01:53 -04:00
}
2026-07-24 21:43:11 -04:00
dialog::backdrop { background: rgba(2, 8, 14, .48); }
2026-07-24 21:01:53 -04:00
.dialog-shell {
2026-07-24 21:43:11 -04:00
display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: 100%; height: 100%;
2026-07-24 21:01:53 -04:00
}
.dialog-head {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel-2);
2026-07-24 21:43:11 -04:00
cursor: move; touch-action: none; user-select: none;
2026-07-24 21:01:53 -04:00
}
.dialog-head strong { font-size: 15px; }
.dialog-close {
width: 34px; height: 34px; border: 1px solid var(--line); border-radius: 8px;
background: #102b3d; color: var(--text); font-size: 21px; line-height: 1;
}
.dialog-close:hover, .dialog-close:focus-visible {
border-color: var(--accent); outline: 2px solid transparent;
}
.dialog-body { min-height: 0; overflow: auto; padding: 18px; }
.dialog-body pre { max-height: none; }
.dialog-actions {
display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px;
border-top: 1px solid var(--line); background: var(--panel-2);
}
2026-07-24 16:01:03 -04:00
.error { color: #ff9aac; }
@media (max-width: 980px) {
.layout { grid-template-columns: 240px 1fr; }
2026-07-24 21:43:11 -04:00
.panel-resizer { display: none; }
2026-07-24 16:01:03 -04:00
.right { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); }
}
@media (max-width: 700px) {
.layout { display: block; }
aside { max-height: none; }
svg { min-height: 520px; }
header { flex-wrap: wrap; }
.status { width: 100%; margin-left: 0; }
}
</style>
</head>
<body>
<div class="app">
<header>
<h1 id="project-title">DocForge graph</h1>
<div class="stats">
<span><strong id="node-count"></strong> nodes</span>
<span><strong id="edge-count"></strong> edges</span>
</div>
<div class="status" id="status">Loading validated index snapshot</div>
</header>
<div class="layout">
<aside class="left">
<form id="search-form">
<label for="search">Find nodes</label>
<div class="search-row">
<input id="search" name="q" autocomplete="off" placeholder="title, symbol, path…">
<button class="button" type="submit">Find</button>
</div>
<label for="family">Family</label>
<select id="family" name="family"><option value="">All families</option></select>
</form>
<div class="results" id="results"></div>
2026-07-24 21:43:11 -04:00
<div class="neighborhood" id="neighborhood" hidden>
<div class="legend" aria-label="Node role colors">
<span><i style="background: var(--primary-stroke)"></i>Primary</span>
<span><i style="background: var(--child-stroke)"></i>Children</span>
<span><i style="background: var(--edge-stroke)"></i>Edge</span>
</div>
<div id="neighborhood-sections"></div>
</div>
2026-07-24 16:01:03 -04:00
</aside>
2026-07-24 21:43:11 -04:00
<div class="panel-resizer" id="left-resizer" role="separator" tabindex="0"
aria-label="Resize navigation panel" aria-orientation="vertical"
aria-valuemin="220" aria-valuemax="900" aria-valuenow="310"></div>
2026-07-24 16:01:03 -04:00
<main class="canvas">
2026-07-24 16:09:56 -04:00
<div class="viewport-controls" aria-label="Graph viewport controls">
<button class="viewport-control" id="zoom-in" type="button"
title="Zoom in" aria-label="Zoom in">+</button>
<button class="viewport-control" id="zoom-out" type="button"
title="Zoom out" aria-label="Zoom out"></button>
<button class="viewport-control" id="reset-view" type="button"
title="Reset view" aria-label="Reset graph view"></button>
<output class="zoom-level" id="zoom-level" aria-live="polite">100%</output>
</div>
2026-07-24 16:01:03 -04:00
<svg id="graph" viewBox="-600 -410 1200 820"
role="img" aria-label="Node neighborhood"></svg>
<div class="empty" id="empty">Search for a node to inspect its neighborhood.</div>
2026-07-24 21:43:11 -04:00
<div class="connection-state" id="connection-state" role="alert" hidden>
<strong>Visualization disconnected</strong>
<span>The project-bound listener is unavailable. Invoke docforge_visualize again.</span>
</div>
2026-07-24 21:01:53 -04:00
<div class="viewport-hint">
Click node to inspect · mouse wheel to zoom · left-drag to pan
</div>
2026-07-24 16:01:03 -04:00
</main>
2026-07-24 21:43:11 -04:00
<div class="panel-resizer" id="right-resizer" role="separator" tabindex="0"
aria-label="Resize details panel" aria-orientation="vertical"
aria-valuemin="240" aria-valuemax="900" aria-valuenow="350"></div>
2026-07-24 16:01:03 -04:00
<aside class="right">
2026-07-24 21:01:53 -04:00
<div id="details">
<p class="summary">Choose a search result to load its neighborhood.</p>
</div>
2026-07-24 16:01:03 -04:00
</aside>
</div>
</div>
2026-07-24 21:01:53 -04:00
<dialog id="node-dialog" aria-labelledby="node-dialog-label">
<div class="dialog-shell">
<div class="dialog-head">
<strong id="node-dialog-label">Inspect node</strong>
<button class="dialog-close" id="close-node-dialog" type="button"
aria-label="Close node inspection">×</button>
</div>
<div class="dialog-body" id="node-dialog-details"></div>
<div class="dialog-actions">
<button class="button" id="explore-node" type="button">Explore neighborhood</button>
<button class="button" id="dismiss-node-dialog" type="button">Close</button>
</div>
</div>
</dialog>
2026-07-24 16:01:03 -04:00
<script>
const base = location.pathname.replace(/\/?$/, "/");
2026-07-24 16:09:56 -04:00
const defaultViewport = Object.freeze({x: -600, y: -410, width: 1200, height: 820});
const state = {
overview: null,
graph: null,
root: null,
depth: 1,
searchLimit: 1,
viewport: {...defaultViewport},
pointer: null,
suppressClick: false,
2026-07-24 21:01:53 -04:00
inspectedNode: null,
2026-07-24 21:43:11 -04:00
dialogDrag: null,
leaseTimer: null,
2026-07-24 16:09:56 -04:00
};
2026-07-24 16:01:03 -04:00
const $ = (id) => document.getElementById(id);
const api = async (path) => {
2026-07-24 21:43:11 -04:00
let response;
try {
response = await fetch(`${base}api/${path}`, {cache: "no-store"});
} catch (_error) {
$("connection-state").hidden = false;
throw new Error("Visualization listener disconnected; invoke docforge_visualize again");
}
2026-07-24 16:01:03 -04:00
const body = await response.json();
if (!response.ok || body.status === "error") {
throw new Error(body.error?.message || "DocForge request failed");
}
2026-07-24 21:43:11 -04:00
$("connection-state").hidden = true;
2026-07-24 16:01:03 -04:00
return body;
};
const escapeText = (value) => String(value ?? "");
const short = (value, length = 34) => {
const text = escapeText(value);
return text.length > length ? `${text.slice(0, length - 1)}` : text;
};
2026-07-24 16:09:56 -04:00
function applyViewport() {
const view = state.viewport;
$("graph").setAttribute("viewBox", `${view.x} ${view.y} ${view.width} ${view.height}`);
const zoom = Math.round((defaultViewport.width / view.width) * 100);
$("zoom-level").textContent = `${zoom}%`;
}
function resetViewport() {
state.viewport = {...defaultViewport};
applyViewport();
}
function zoomAt(factor, clientX = null, clientY = null) {
const svg = $("graph");
const rect = svg.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const current = state.viewport;
const nextWidth = Math.min(
defaultViewport.width * 4,
Math.max(defaultViewport.width * .2, current.width * factor),
);
const nextHeight = nextWidth * (defaultViewport.height / defaultViewport.width);
const ratioX = clientX === null ? .5 : (clientX - rect.left) / rect.width;
const ratioY = clientY === null ? .5 : (clientY - rect.top) / rect.height;
const anchorX = current.x + ratioX * current.width;
const anchorY = current.y + ratioY * current.height;
state.viewport = {
x: anchorX - ratioX * nextWidth,
y: anchorY - ratioY * nextHeight,
width: nextWidth,
height: nextHeight,
};
applyViewport();
}
2026-07-24 16:01:03 -04:00
function setStatus(message, error = false) {
$("status").textContent = message;
$("status").classList.toggle("error", error);
}
2026-07-24 21:43:11 -04:00
async function renewViewerLease() {
try {
await api("heartbeat");
} catch (error) {
setStatus(error.message, true);
if (state.leaseTimer !== null) clearInterval(state.leaseTimer);
state.leaseTimer = null;
}
}
function startViewerLease() {
if (state.leaseTimer !== null) clearInterval(state.leaseTimer);
state.leaseTimer = setInterval(renewViewerLease, 15000);
document.addEventListener("visibilitychange", () => {
if (!document.hidden) renewViewerLease();
});
}
2026-07-24 16:01:03 -04:00
function renderOverview(data) {
state.overview = data;
state.searchLimit = Math.max(1, Number(data.max_results) || 1);
$("project-title").textContent = data.title || data.project_id;
$("node-count").textContent = Number(data.node_count).toLocaleString();
$("edge-count").textContent = Number(data.edge_count).toLocaleString();
const family = $("family");
for (const item of data.families) {
const option = document.createElement("option");
option.value = item.value;
option.textContent = `${item.value} (${item.count})`;
family.append(option);
}
}
function renderResults(items) {
const results = $("results");
results.replaceChildren();
if (!items.length) {
const note = document.createElement("p");
note.className = "summary";
note.textContent = "No matching nodes.";
results.append(note);
return;
}
for (const item of items) {
const button = document.createElement("button");
button.type = "button";
button.className = "result";
const title = document.createElement("strong");
title.textContent = item.title;
const id = document.createElement("span");
id.textContent = item.node_id;
const family = document.createElement("span");
family.textContent = `${item.family} · ${item.source_path}`;
button.append(title, id, family);
button.addEventListener("click", () => loadNode(item.node_id));
results.append(button);
}
}
2026-07-24 21:43:11 -04:00
function analyzeTopology(data) {
const nodeIds = new Set(data.nodes.map((node) => node.node_id));
const adjacency = new Map([...nodeIds].map((nodeId) => [nodeId, new Set()]));
const outgoing = new Map([...nodeIds].map((nodeId) => [nodeId, new Set()]));
for (const edge of data.edges) {
if (!nodeIds.has(edge.source_id) || !nodeIds.has(edge.target_id)) continue;
adjacency.get(edge.source_id).add(edge.target_id);
adjacency.get(edge.target_id).add(edge.source_id);
outgoing.get(edge.source_id).add(edge.target_id);
}
const hops = new Map([[data.root, 0]]);
let frontier = [data.root];
while (frontier.length) {
const next = [];
for (const nodeId of frontier) {
for (const neighbor of adjacency.get(nodeId) || []) {
if (hops.has(neighbor)) continue;
hops.set(neighbor, hops.get(nodeId) + 1);
next.push(neighbor);
}
}
frontier = next;
}
const children = new Set();
frontier = [...(outgoing.get(data.root) || [])];
for (const nodeId of frontier) children.add(nodeId);
while (frontier.length) {
const next = [];
for (const nodeId of frontier) {
for (const candidate of outgoing.get(nodeId) || []) {
if (candidate === data.root || children.has(candidate)) continue;
children.add(candidate);
next.push(candidate);
}
}
frontier = next;
}
return new Map(data.nodes.map((node) => [
node.node_id,
{
hop: hops.get(node.node_id) ?? data.depth + 1,
role: node.node_id === data.root
? "primary"
: children.has(node.node_id) ? "child" : "edge",
},
]));
}
function layoutNodes(nodes, rootId, topology) {
const ordered = [...nodes].sort((a, b) => {
const first = topology.get(a.node_id);
const second = topology.get(b.node_id);
return first.hop - second.hop
|| first.role.localeCompare(second.role)
|| a.node_id.localeCompare(b.node_id);
});
2026-07-24 16:01:03 -04:00
const root = ordered.find((node) => node.node_id === rootId);
const positions = new Map();
if (root) positions.set(root.node_id, {x: 0, y: 0});
2026-07-24 21:43:11 -04:00
const rings = new Map();
for (const node of ordered) {
if (node.node_id === rootId) continue;
const hop = Math.max(1, topology.get(node.node_id).hop);
if (!rings.has(hop)) rings.set(hop, []);
rings.get(hop).push(node);
}
for (const [hop, ringNodes] of rings) {
ringNodes.forEach((node, index) => {
const angle = (index / Math.max(1, ringNodes.length)) * Math.PI * 2 - Math.PI / 2;
const radius = 165 + (hop - 1) * 145;
positions.set(node.node_id, {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
});
});
}
2026-07-24 16:01:03 -04:00
return positions;
}
2026-07-24 21:43:11 -04:00
function darken(hex, amount) {
const value = Number.parseInt(hex.slice(1), 16);
const factor = 1 - Math.min(.5, Math.max(0, amount));
const channels = [
(value >> 16) & 255,
(value >> 8) & 255,
value & 255,
].map((channel) => Math.round(channel * factor).toString(16).padStart(2, "0"));
return `#${channels.join("")}`;
}
function nodePalette(role, hop) {
const colors = {
primary: {fill: "#176b7d", stroke: "#83e8ff"},
child: {fill: "#216c51", stroke: "#91f2bd"},
edge: {fill: "#634580", stroke: "#d0a7ff"},
}[role];
const distanceShade = Math.min(.5, Math.max(0, hop - 1) * .17);
return {
fill: darken(colors.fill, distanceShade),
stroke: darken(colors.stroke, distanceShade),
};
}
function renderNeighborhood(data, topology) {
const sections = [
{role: "primary", label: "Primary focus"},
{role: "child", label: "Children"},
{role: "edge", label: "Edge & context"},
];
const container = $("neighborhood-sections");
container.replaceChildren();
for (const section of sections) {
const nodes = data.nodes
.filter((node) => topology.get(node.node_id).role === section.role)
.sort((a, b) => topology.get(a.node_id).hop - topology.get(b.node_id).hop
|| a.node_id.localeCompare(b.node_id));
if (!nodes.length) continue;
const heading = document.createElement("div");
heading.className = "section-label";
heading.append(document.createTextNode(section.label));
const count = document.createElement("span");
count.textContent = String(nodes.length);
heading.append(count);
const list = document.createElement("div");
list.className = "node-list";
for (const node of nodes) {
const topologyNode = topology.get(node.node_id);
const palette = nodePalette(topologyNode.role, topologyNode.hop);
const button = document.createElement("button");
button.type = "button";
button.className = "node-list-item";
button.style.setProperty("--item-color", palette.stroke);
button.title = `Focus ${node.title}`;
const swatch = document.createElement("i");
swatch.className = "node-swatch";
const copy = document.createElement("span");
copy.className = "node-list-copy";
const title = document.createElement("strong");
title.textContent = node.title;
const meta = document.createElement("span");
const hopLabel = `${topologyNode.hop} hop${topologyNode.hop === 1 ? "" : "s"}`;
meta.textContent = `${node.family} · ${hopLabel}`;
copy.append(title, meta);
button.append(swatch, copy);
button.addEventListener("click", () => loadNode(node.node_id));
list.append(button);
}
container.append(heading, list);
}
$("neighborhood").hidden = false;
}
2026-07-24 16:01:03 -04:00
function svgElement(name, attributes = {}) {
const element = document.createElementNS("http://www.w3.org/2000/svg", name);
for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, value);
return element;
}
function renderGraph(data) {
state.graph = data;
state.root = data.root;
2026-07-24 16:09:56 -04:00
resetViewport();
2026-07-24 16:01:03 -04:00
const svg = $("graph");
svg.replaceChildren();
$("empty").hidden = data.nodes.length > 0;
2026-07-24 21:43:11 -04:00
const topology = analyzeTopology(data);
const positions = layoutNodes(data.nodes, data.root, topology);
renderNeighborhood(data, topology);
2026-07-24 16:01:03 -04:00
const edgeLayer = svgElement("g");
const nodeLayer = svgElement("g");
for (const edge of data.edges) {
const source = positions.get(edge.source_id);
const target = positions.get(edge.target_id);
if (!source || !target) continue;
2026-07-24 21:43:11 -04:00
const targetRole = topology.get(edge.target_id)?.role || "edge";
const edgeRole = edge.source_id === data.root && targetRole === "child"
? "child-edge"
: edge.target_id === data.root || targetRole === "edge"
? "context-edge"
: "boundary-edge";
2026-07-24 16:01:03 -04:00
edgeLayer.append(svgElement("line", {
2026-07-24 21:43:11 -04:00
x1: source.x, y1: source.y, x2: target.x, y2: target.y,
class: `edge ${edgeRole}`
2026-07-24 16:01:03 -04:00
}));
const label = svgElement("text", {
x: (source.x + target.x) / 2,
y: (source.y + target.y) / 2,
class: "edge-label",
"text-anchor": "middle"
});
label.textContent = edge.relation;
edgeLayer.append(label);
}
for (const node of data.nodes) {
const point = positions.get(node.node_id);
if (!point) continue;
2026-07-24 21:43:11 -04:00
const topologyNode = topology.get(node.node_id);
const palette = nodePalette(topologyNode.role, topologyNode.hop);
2026-07-24 16:01:03 -04:00
const group = svgElement("g", {
2026-07-24 21:43:11 -04:00
class: `node ${topologyNode.role}${node.node_id === data.root ? " root" : ""}`,
2026-07-24 16:01:03 -04:00
transform: `translate(${point.x} ${point.y})`,
2026-07-24 21:43:11 -04:00
"data-hop": String(topologyNode.hop),
2026-07-24 16:01:03 -04:00
tabindex: "0",
role: "button",
2026-07-24 21:43:11 -04:00
"aria-label": [
node.title, node.family, topologyNode.role, `${topologyNode.hop} hops`
].join(", ")
2026-07-24 16:01:03 -04:00
});
2026-07-24 21:43:11 -04:00
group.append(svgElement("circle", {
r: node.node_id === data.root ? 25 : 18,
fill: palette.fill,
stroke: palette.stroke,
}));
2026-07-24 16:01:03 -04:00
const title = svgElement("text", {y: 35, "text-anchor": "middle"});
title.textContent = short(node.title, 26);
const family = svgElement("text", {y: 48, "text-anchor": "middle", class: "family"});
family.textContent = short(node.family, 22);
group.append(title, family);
2026-07-24 16:09:56 -04:00
group.addEventListener("click", () => {
2026-07-24 21:01:53 -04:00
if (!state.suppressClick) inspectNode(node.node_id);
2026-07-24 16:09:56 -04:00
});
2026-07-24 16:01:03 -04:00
group.addEventListener("keydown", (event) => {
2026-07-24 21:01:53 -04:00
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
inspectNode(node.node_id);
}
2026-07-24 16:01:03 -04:00
});
nodeLayer.append(group);
}
svg.append(edgeLayer, nodeLayer);
}
2026-07-24 21:01:53 -04:00
function renderDetails(details, node, data) {
2026-07-24 16:01:03 -04:00
details.replaceChildren();
const heading = document.createElement("div");
heading.className = "detail-head";
const title = document.createElement("h2");
title.textContent = node.title;
heading.append(title);
const badges = document.createElement("div");
for (const value of [node.family, node.authority, node.status, ...node.tags]) {
const badge = document.createElement("span");
badge.className = "badge";
badge.textContent = value;
badges.append(badge);
}
const summary = document.createElement("p");
summary.className = "summary";
summary.textContent = node.summary;
const dl = document.createElement("dl");
dl.className = "meta";
for (const [label, value] of [
["ID", node.node_id],
["Source", node.source_path],
["Anchor", node.source_anchor || ""],
["Neighbors", `${data.nodes.length - 1} nodes · ${data.edges.length} edges`],
]) {
const row = document.createElement("div");
const dt = document.createElement("dt");
const dd = document.createElement("dd");
dt.textContent = label;
dd.textContent = escapeText(value);
row.append(dt, dd);
dl.append(row);
}
const content = document.createElement("pre");
content.textContent = node.content;
details.append(heading, badges, summary, dl, content);
}
2026-07-24 21:01:53 -04:00
function closeNodeDialog() {
const dialog = $("node-dialog");
if (dialog.open) dialog.close();
else state.inspectedNode = null;
}
async function inspectNode(nodeId) {
try {
setStatus(`Inspecting ${nodeId}`);
const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"});
const data = await api(`node?${params}`);
state.inspectedNode = nodeId;
renderDetails($("node-dialog-details"), data.node, data);
2026-07-24 21:43:11 -04:00
$("node-dialog-label").textContent = short(data.node.title, 72);
2026-07-24 21:01:53 -04:00
const dialog = $("node-dialog");
if (!dialog.open) dialog.showModal();
$("close-node-dialog").focus();
setStatus(`Inspecting ${nodeId}`);
} catch (error) {
setStatus(error.message, true);
}
}
2026-07-24 16:01:03 -04:00
async function search() {
const params = new URLSearchParams({
q: $("search").value.trim(),
family: $("family").value,
limit: String(state.searchLimit),
});
try {
setStatus("Searching validated index…");
const data = await api(`search?${params}`);
renderResults(data.results || []);
setStatus(`${data.count} matching node${data.count === 1 ? "" : "s"}`);
} catch (error) {
setStatus(error.message, true);
}
}
async function loadNode(nodeId) {
try {
setStatus(`Loading ${nodeId}`);
const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"});
const data = await api(`node?${params}`);
renderGraph(data);
2026-07-24 21:01:53 -04:00
renderDetails($("details"), data.node, data);
2026-07-24 16:01:03 -04:00
setStatus(`${data.nodes.length} nodes · ${data.edges.length} edges in neighborhood`);
history.replaceState(null, "", `?node=${encodeURIComponent(nodeId)}&depth=${state.depth}`);
} catch (error) {
setStatus(error.message, true);
}
}
2026-07-24 21:43:11 -04:00
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
function setPanelWidth(side, width) {
const maximum = Math.max(260, Math.floor(window.innerWidth * .46));
const bounded = clamp(width, side === "left" ? 220 : 240, maximum);
document.documentElement.style.setProperty(`--${side}-width`, `${bounded}px`);
$(`${side}-resizer`).setAttribute("aria-valuenow", String(Math.round(bounded)));
}
function setupPanelResizer(side) {
const handle = $(`${side}-resizer`);
handle.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
const property = getComputedStyle(document.documentElement)
.getPropertyValue(`--${side}-width`);
const startWidth = Number.parseFloat(property) || (side === "left" ? 310 : 350);
const startX = event.clientX;
handle.classList.add("resizing");
handle.setPointerCapture(event.pointerId);
const move = (moveEvent) => {
const delta = moveEvent.clientX - startX;
setPanelWidth(side, startWidth + (side === "left" ? delta : -delta));
};
const end = (endEvent) => {
if (handle.hasPointerCapture(endEvent.pointerId)) {
handle.releasePointerCapture(endEvent.pointerId);
}
handle.classList.remove("resizing");
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", end);
handle.removeEventListener("pointercancel", end);
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", end);
handle.addEventListener("pointercancel", end);
});
handle.addEventListener("keydown", (event) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const property = getComputedStyle(document.documentElement)
.getPropertyValue(`--${side}-width`);
const width = Number.parseFloat(property) || (side === "left" ? 310 : 350);
const direction = event.key === "ArrowRight" ? 16 : -16;
setPanelWidth(side, width + (side === "left" ? direction : -direction));
});
handle.addEventListener("dblclick", () => setPanelWidth(side, side === "left" ? 310 : 350));
}
function beginDialogDrag(event) {
if (event.button !== 0 || event.target.closest("button")) return;
const dialog = $("node-dialog");
const header = event.currentTarget;
const bounds = dialog.getBoundingClientRect();
dialog.style.margin = "0";
dialog.style.right = "auto";
dialog.style.bottom = "auto";
dialog.style.left = `${bounds.left}px`;
dialog.style.top = `${bounds.top}px`;
dialog.style.width = `${bounds.width}px`;
dialog.style.height = `${bounds.height}px`;
state.dialogDrag = {
id: event.pointerId,
startX: event.clientX,
startY: event.clientY,
left: bounds.left,
top: bounds.top,
};
header.setPointerCapture(event.pointerId);
}
function moveDialog(event) {
const drag = state.dialogDrag;
if (!drag || drag.id !== event.pointerId) return;
const dialog = $("node-dialog");
const maximumLeft = Math.max(8, window.innerWidth - dialog.offsetWidth - 8);
const maximumTop = Math.max(8, window.innerHeight - dialog.offsetHeight - 8);
dialog.style.left = `${clamp(drag.left + event.clientX - drag.startX, 8, maximumLeft)}px`;
dialog.style.top = `${clamp(drag.top + event.clientY - drag.startY, 8, maximumTop)}px`;
}
function endDialogDrag(event) {
const drag = state.dialogDrag;
if (!drag || drag.id !== event.pointerId) return;
const header = $("node-dialog").querySelector(".dialog-head");
if (header.hasPointerCapture(event.pointerId)) header.releasePointerCapture(event.pointerId);
state.dialogDrag = null;
}
2026-07-24 16:01:03 -04:00
$("search-form").addEventListener("submit", (event) => { event.preventDefault(); search(); });
$("family").addEventListener("change", search);
2026-07-24 16:09:56 -04:00
$("zoom-in").addEventListener("click", () => zoomAt(.8));
$("zoom-out").addEventListener("click", () => zoomAt(1.25));
$("reset-view").addEventListener("click", resetViewport);
2026-07-24 21:01:53 -04:00
$("close-node-dialog").addEventListener("click", closeNodeDialog);
$("dismiss-node-dialog").addEventListener("click", closeNodeDialog);
2026-07-24 21:43:11 -04:00
setupPanelResizer("left");
setupPanelResizer("right");
$("node-dialog").querySelector(".dialog-head").addEventListener("pointerdown", beginDialogDrag);
$("node-dialog").querySelector(".dialog-head").addEventListener("pointermove", moveDialog);
$("node-dialog").querySelector(".dialog-head").addEventListener("pointerup", endDialogDrag);
$("node-dialog").querySelector(".dialog-head").addEventListener("pointercancel", endDialogDrag);
2026-07-24 21:01:53 -04:00
$("explore-node").addEventListener("click", async () => {
const nodeId = state.inspectedNode;
closeNodeDialog();
if (nodeId) await loadNode(nodeId);
});
$("node-dialog").addEventListener("click", (event) => {
if (event.target !== $("node-dialog")) return;
const bounds = $("node-dialog").getBoundingClientRect();
const inside = event.clientX >= bounds.left && event.clientX <= bounds.right
&& event.clientY >= bounds.top && event.clientY <= bounds.bottom;
if (!inside) closeNodeDialog();
});
$("node-dialog").addEventListener("close", () => {
state.inspectedNode = null;
if (state.graph) {
const nodeCount = state.graph.nodes.length;
const edgeCount = state.graph.edges.length;
setStatus(`${nodeCount} nodes · ${edgeCount} edges in neighborhood`);
}
});
2026-07-24 16:09:56 -04:00
$("graph").addEventListener("wheel", (event) => {
event.preventDefault();
zoomAt(event.deltaY < 0 ? .85 : 1.18, event.clientX, event.clientY);
}, {passive: false});
$("graph").addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
const svg = $("graph");
state.suppressClick = false;
state.pointer = {
id: event.pointerId,
startX: event.clientX,
startY: event.clientY,
viewport: {...state.viewport},
moved: false,
};
});
$("graph").addEventListener("pointermove", (event) => {
const pointer = state.pointer;
if (!pointer || pointer.id !== event.pointerId) return;
const svg = $("graph");
const rect = svg.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const deltaX = event.clientX - pointer.startX;
const deltaY = event.clientY - pointer.startY;
if (!pointer.moved && Math.hypot(deltaX, deltaY) < 4) return;
pointer.moved = true;
state.suppressClick = true;
2026-07-24 21:14:30 -04:00
if (!svg.hasPointerCapture(event.pointerId)) svg.setPointerCapture(event.pointerId);
2026-07-24 16:09:56 -04:00
svg.closest(".canvas").classList.add("dragging");
state.viewport = {
...pointer.viewport,
x: pointer.viewport.x - deltaX * (pointer.viewport.width / rect.width),
y: pointer.viewport.y - deltaY * (pointer.viewport.height / rect.height),
};
applyViewport();
});
function endPan(event) {
const pointer = state.pointer;
if (!pointer || pointer.id !== event.pointerId) return;
const svg = $("graph");
if (svg.hasPointerCapture(event.pointerId)) svg.releasePointerCapture(event.pointerId);
svg.closest(".canvas").classList.remove("dragging");
state.pointer = null;
if (pointer.moved) {
setTimeout(() => { state.suppressClick = false; }, 0);
}
}
$("graph").addEventListener("pointerup", endPan);
$("graph").addEventListener("pointercancel", endPan);
applyViewport();
2026-07-24 16:01:03 -04:00
(async () => {
try {
const params = new URLSearchParams(location.search);
state.depth = Math.max(1, Number(params.get("depth")) || 1);
const overview = await api("overview");
renderOverview(overview);
2026-07-24 21:43:11 -04:00
startViewerLease();
2026-07-24 16:01:03 -04:00
const nodeId = params.get("node");
const query = params.get("q");
if (nodeId) {
await loadNode(nodeId);
} else {
if (query) $("search").value = query;
await search();
}
} catch (error) {
setStatus(error.message, true);
}
})();
</script>
</body>
</html>
"""