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 23:55:55 -04:00
|
|
|
import signal
|
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 sys
|
2026-07-24 23:55:55 -04:00
|
|
|
import tempfile
|
2026-07-24 16:01:03 -04:00
|
|
|
import threading
|
2026-07-24 23:55:55 -04:00
|
|
|
import time
|
|
|
|
|
import urllib.error
|
2026-07-24 16:01:03 -04:00
|
|
|
import urllib.parse
|
2026-07-24 23:55:55 -04:00
|
|
|
import urllib.request
|
2026-07-24 16:01:03 -04:00
|
|
|
from collections.abc import Generator
|
|
|
|
|
from contextlib import contextmanager
|
|
|
|
|
from http import HTTPStatus
|
|
|
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
2026-07-25 16:46:00 -04:00
|
|
|
from importlib import resources
|
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
|
|
|
|
2026-07-25 00:17:21 -04:00
|
|
|
try:
|
|
|
|
|
import fcntl
|
|
|
|
|
except ImportError: # pragma: no cover - retained only for the deprecated direct runner.
|
|
|
|
|
fcntl = None
|
|
|
|
|
|
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 23:55:55 -04:00
|
|
|
from .project import project_root_fingerprint
|
2026-07-24 16:01:03 -04:00
|
|
|
|
2026-07-25 17:34:58 -04:00
|
|
|
VISUALIZATION_TEMPLATE = "graph-browser@14"
|
2026-07-24 16:01:03 -04:00
|
|
|
DEFAULT_EDGE_LIMIT = 100
|
|
|
|
|
MAX_EDGE_LIMIT = 400
|
2026-07-25 01:05:45 -04:00
|
|
|
MAX_LINEAGE_EDGE_LIMIT = 1_000
|
2026-07-25 17:34:58 -04:00
|
|
|
FLOW_REVERSED_RELATIONS = frozenset(
|
|
|
|
|
{
|
|
|
|
|
"defined_in",
|
|
|
|
|
"inherits",
|
|
|
|
|
"imports",
|
|
|
|
|
"depends_on",
|
|
|
|
|
"reads",
|
|
|
|
|
"tested_by",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
FLOW_CONTEXT_RELATIONS = frozenset(
|
|
|
|
|
{
|
|
|
|
|
"documents",
|
|
|
|
|
"governs",
|
|
|
|
|
"relates_to",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
WEB_ROOT_ADJACENT_RELATIONS = frozenset(
|
|
|
|
|
{
|
|
|
|
|
"activates",
|
|
|
|
|
"calls",
|
|
|
|
|
"contains",
|
|
|
|
|
"defines",
|
|
|
|
|
"dispatches_to",
|
|
|
|
|
"implemented_by",
|
|
|
|
|
"launches",
|
|
|
|
|
"writes",
|
|
|
|
|
}
|
|
|
|
|
)
|
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 23:55:55 -04:00
|
|
|
VISUALIZATION_REGISTRY_NAME = ".visualization.json"
|
|
|
|
|
VISUALIZATION_LOCK_NAME = ".visualization.lock"
|
|
|
|
|
VISUALIZATION_RUNTIME = "persistent-worker@1"
|
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
|
2026-07-25 16:00:19 -04:00
|
|
|
self.project_root = index.project.descriptor.root
|
2026-07-24 16:01:03 -04:00
|
|
|
self.title = index.project.descriptor.title
|
2026-07-25 16:00:19 -04:00
|
|
|
self.max_source_bytes = index.project.descriptor.limits.max_source_bytes
|
2026-07-24 16:01:03 -04:00
|
|
|
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"]
|
2026-07-25 16:00:19 -04:00
|
|
|
project_root = spec["project_root"]
|
|
|
|
|
max_source_bytes = spec["max_source_bytes"]
|
2026-07-24 22:26:01 -04:00
|
|
|
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)
|
2026-07-25 16:00:19 -04:00
|
|
|
or not isinstance(project_root, str)
|
|
|
|
|
or type(max_source_bytes) is not int
|
2026-07-24 22:26:01 -04:00
|
|
|
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)
|
2026-07-25 16:00:19 -04:00
|
|
|
try:
|
|
|
|
|
snapshot.project_root = Path(project_root).resolve(strict=True)
|
|
|
|
|
except OSError as error:
|
|
|
|
|
raise DocForgeError("invalid_index", "Visualization project root is invalid") from error
|
|
|
|
|
if not snapshot.project_root.is_dir():
|
|
|
|
|
raise DocForgeError("invalid_index", "Visualization project root is invalid")
|
2026-07-24 22:26:01 -04:00
|
|
|
snapshot.title = title
|
2026-07-25 16:00:19 -04:00
|
|
|
snapshot.max_source_bytes = max_source_bytes
|
2026-07-24 22:26:01 -04:00
|
|
|
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),
|
2026-07-25 16:00:19 -04:00
|
|
|
"project_root": str(self.project_root),
|
2026-07-24 22:07:33 -04:00
|
|
|
"title": self.title,
|
2026-07-25 16:00:19 -04:00
|
|
|
"max_source_bytes": self.max_source_bytes,
|
2026-07-24 22:07:33 -04:00
|
|
|
"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,
|
2026-07-25 17:34:58 -04:00
|
|
|
max_depth=self.max_depth,
|
2026-07-24 16:01:03 -04:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-24 23:15:57 -04:00
|
|
|
def filter_nodes(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
category: str,
|
|
|
|
|
value: str,
|
|
|
|
|
limit: int,
|
|
|
|
|
) -> dict[str, object]:
|
|
|
|
|
bounded = self._bounded_limit(limit)
|
|
|
|
|
if category not in {"family", "authority", "status", "tag"}:
|
|
|
|
|
raise DocForgeError("invalid_filter", "Descriptor filter category is unsupported")
|
|
|
|
|
if not value or len(value) > self.max_query_chars:
|
|
|
|
|
raise DocForgeError("invalid_filter", "Descriptor filter value is invalid")
|
|
|
|
|
if category == "tag":
|
|
|
|
|
clause = "EXISTS (SELECT 1 FROM json_each(tags_json) WHERE value = ?)"
|
|
|
|
|
else:
|
|
|
|
|
clause = f"{category} = ?"
|
|
|
|
|
with self._connection() as connection:
|
|
|
|
|
total = connection.execute(
|
|
|
|
|
f"SELECT COUNT(*) FROM nodes WHERE {clause}",
|
|
|
|
|
(value,),
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
rows = connection.execute(
|
|
|
|
|
f"SELECT * FROM nodes WHERE {clause} ORDER BY node_id LIMIT ?",
|
|
|
|
|
(value, bounded),
|
|
|
|
|
).fetchall()
|
|
|
|
|
results = [_node_dict(row, include_content=False) for row in rows]
|
|
|
|
|
return self._result(
|
|
|
|
|
category=category,
|
|
|
|
|
value=value,
|
|
|
|
|
count=len(results),
|
|
|
|
|
total=total,
|
|
|
|
|
truncated=total > len(results),
|
|
|
|
|
results=results,
|
|
|
|
|
snapshot=True,
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-24 16:01:03 -04:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-25 16:00:19 -04:00
|
|
|
def source(self, node_id: str) -> dict[str, object]:
|
|
|
|
|
"""Return one node's bounded, project-confined UTF-8 source file."""
|
|
|
|
|
|
|
|
|
|
with self._connection() as connection:
|
|
|
|
|
row = connection.execute(
|
|
|
|
|
"SELECT node_id, source_path, source_anchor 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,
|
|
|
|
|
)
|
|
|
|
|
relative = Path(row["source_path"])
|
|
|
|
|
if relative.is_absolute() or ".." in relative.parts or not relative.parts:
|
|
|
|
|
raise DocForgeError("path_escape", "Node source path is unsafe", node_id=node_id)
|
|
|
|
|
source = self.project_root / relative
|
|
|
|
|
try:
|
|
|
|
|
resolved = source.resolve(strict=True)
|
|
|
|
|
except OSError as error:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"missing_source",
|
|
|
|
|
"Node source file is unavailable",
|
|
|
|
|
node_id=node_id,
|
|
|
|
|
) from error
|
|
|
|
|
if (
|
|
|
|
|
source.is_symlink()
|
|
|
|
|
or resolved != source
|
|
|
|
|
or not source.is_relative_to(self.project_root)
|
|
|
|
|
or not source.is_file()
|
|
|
|
|
):
|
|
|
|
|
raise DocForgeError("path_escape", "Node source file is unsafe", node_id=node_id)
|
|
|
|
|
if source.stat().st_size > self.max_source_bytes:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"source_too_large",
|
|
|
|
|
"Node source exceeds the configured source limit",
|
|
|
|
|
node_id=node_id,
|
|
|
|
|
)
|
|
|
|
|
raw = source.read_bytes()
|
|
|
|
|
if len(raw) > self.max_source_bytes:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"source_too_large",
|
|
|
|
|
"Node source exceeds the configured source limit",
|
|
|
|
|
node_id=node_id,
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
content = raw.decode("utf-8")
|
|
|
|
|
except UnicodeDecodeError as error:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_source",
|
|
|
|
|
"Node source is not UTF-8",
|
|
|
|
|
node_id=node_id,
|
|
|
|
|
) from error
|
|
|
|
|
return self._result(
|
|
|
|
|
node_id=node_id,
|
|
|
|
|
source_path=row["source_path"],
|
|
|
|
|
source_anchor=row["source_anchor"],
|
|
|
|
|
content=content,
|
|
|
|
|
snapshot=True,
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-25 01:05:45 -04:00
|
|
|
def lineage(self, node_id: str, *, limit: int) -> dict[str, object]:
|
2026-07-25 17:34:58 -04:00
|
|
|
"""Return bounded semantic flow paths terminating at ``node_id``.
|
2026-07-25 01:05:45 -04:00
|
|
|
|
2026-07-25 17:34:58 -04:00
|
|
|
Structural and execution edges retain their stored direction. Dependency,
|
|
|
|
|
import, data-read, inheritance, and ``tested_by`` edges are reversed so their
|
|
|
|
|
prerequisites flow into the consumer. Context-only documentation edges remain
|
|
|
|
|
available in Web but do not clutter Flow.
|
2026-07-25 01:05:45 -04:00
|
|
|
"""
|
|
|
|
|
if type(limit) is not int or limit < 1 or limit > MAX_LINEAGE_EDGE_LIMIT:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_limit",
|
|
|
|
|
"Visualization lineage limit exceeds the fixed safety boundary",
|
|
|
|
|
maximum=MAX_LINEAGE_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}
|
2026-07-25 17:34:58 -04:00
|
|
|
hops = {node_id: 0}
|
|
|
|
|
selected: list[dict[str, object]] = []
|
|
|
|
|
selected_keys: set[tuple[str, str, str]] = set()
|
2026-07-25 01:05:45 -04:00
|
|
|
truncated = False
|
|
|
|
|
while frontier and len(selected) < limit:
|
|
|
|
|
placeholders = ",".join("?" for _ in frontier)
|
|
|
|
|
rows = connection.execute(
|
|
|
|
|
"SELECT source_id, relation, target_id FROM edges "
|
2026-07-25 17:34:58 -04:00
|
|
|
f"WHERE source_id IN ({placeholders}) OR target_id IN ({placeholders}) "
|
|
|
|
|
"ORDER BY source_id, relation, target_id",
|
|
|
|
|
(*sorted(frontier), *sorted(frontier)),
|
2026-07-25 01:05:45 -04:00
|
|
|
).fetchall()
|
|
|
|
|
next_frontier: set[str] = set()
|
|
|
|
|
for row in rows:
|
2026-07-25 17:34:58 -04:00
|
|
|
key = (row["source_id"], row["relation"], row["target_id"])
|
|
|
|
|
if key in selected_keys or row["relation"] in FLOW_CONTEXT_RELATIONS:
|
|
|
|
|
continue
|
|
|
|
|
reversed_edge = row["relation"] in FLOW_REVERSED_RELATIONS
|
|
|
|
|
source_id = row["target_id"] if reversed_edge else row["source_id"]
|
|
|
|
|
target_id = row["source_id"] if reversed_edge else row["target_id"]
|
|
|
|
|
if target_id not in frontier:
|
|
|
|
|
continue
|
|
|
|
|
if len(selected) >= limit:
|
|
|
|
|
truncated = True
|
|
|
|
|
break
|
|
|
|
|
selected_keys.add(key)
|
|
|
|
|
edge = _visualization_edge(row, reversed_edge=reversed_edge)
|
2026-07-25 01:05:45 -04:00
|
|
|
selected.append(edge)
|
|
|
|
|
if source_id not in visited:
|
|
|
|
|
visited.add(source_id)
|
2026-07-25 17:34:58 -04:00
|
|
|
hops[source_id] = hops[target_id] + 1
|
2026-07-25 01:05:45 -04:00
|
|
|
next_frontier.add(source_id)
|
|
|
|
|
frontier = next_frontier
|
|
|
|
|
if frontier and len(selected) >= limit:
|
|
|
|
|
truncated = True
|
|
|
|
|
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,
|
|
|
|
|
lineage=True,
|
|
|
|
|
edge_limit=limit,
|
|
|
|
|
truncated=truncated,
|
2026-07-25 17:34:58 -04:00
|
|
|
hops=hops,
|
|
|
|
|
node=_node_dict(root_row),
|
|
|
|
|
nodes=[_node_dict(row, include_content=False) for row in node_rows],
|
|
|
|
|
edges=selected,
|
|
|
|
|
snapshot=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def web(self, node_id: str, *, depth: int, limit: int) -> dict[str, object]:
|
|
|
|
|
"""Return a bounded convergence web centered on ``node_id``.
|
|
|
|
|
|
|
|
|
|
Web follows every semantic contributor path toward the focus, including the
|
|
|
|
|
context relationships omitted from Flow. It also reverses direct focus-owned
|
|
|
|
|
members and execution dependencies into adjacent contributor branches. Later
|
|
|
|
|
traversal continues only toward those branches, so entering a package or class
|
|
|
|
|
cannot fan out through unrelated siblings.
|
|
|
|
|
"""
|
|
|
|
|
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_LINEAGE_EDGE_LIMIT:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_limit",
|
|
|
|
|
"Visualization web limit exceeds the fixed safety boundary",
|
|
|
|
|
maximum=MAX_LINEAGE_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}
|
|
|
|
|
hops = {node_id: 0}
|
|
|
|
|
selected: list[dict[str, object]] = []
|
|
|
|
|
selected_keys: set[tuple[str, str, str, bool]] = set()
|
|
|
|
|
truncated = False
|
|
|
|
|
for hop in range(1, depth + 1):
|
|
|
|
|
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",
|
|
|
|
|
(*values, *values),
|
|
|
|
|
).fetchall()
|
|
|
|
|
next_frontier: set[str] = set()
|
|
|
|
|
for row in rows:
|
|
|
|
|
semantic_reversed = row["relation"] in FLOW_REVERSED_RELATIONS
|
|
|
|
|
semantic_source = row["target_id"] if semantic_reversed else row["source_id"]
|
|
|
|
|
semantic_target = row["source_id"] if semantic_reversed else row["target_id"]
|
|
|
|
|
candidates: list[tuple[str, bool]] = []
|
|
|
|
|
if semantic_target in frontier:
|
|
|
|
|
candidates.append((semantic_source, semantic_reversed))
|
|
|
|
|
if (
|
|
|
|
|
semantic_source == node_id
|
|
|
|
|
and row["relation"] in WEB_ROOT_ADJACENT_RELATIONS
|
|
|
|
|
):
|
|
|
|
|
candidates.append((semantic_target, not semantic_reversed))
|
|
|
|
|
for source_id, reversed_edge in candidates:
|
|
|
|
|
key = (
|
|
|
|
|
row["source_id"],
|
|
|
|
|
row["relation"],
|
|
|
|
|
row["target_id"],
|
|
|
|
|
reversed_edge,
|
|
|
|
|
)
|
|
|
|
|
if key in selected_keys:
|
|
|
|
|
continue
|
|
|
|
|
existing_hop = hops.get(source_id)
|
|
|
|
|
if existing_hop is not None and existing_hop < hop:
|
|
|
|
|
continue
|
|
|
|
|
if len(selected) >= limit:
|
|
|
|
|
truncated = True
|
|
|
|
|
break
|
|
|
|
|
selected_keys.add(key)
|
|
|
|
|
selected.append(_visualization_edge(row, reversed_edge=reversed_edge))
|
|
|
|
|
if source_id not in visited:
|
|
|
|
|
visited.add(source_id)
|
|
|
|
|
hops[source_id] = hop
|
|
|
|
|
next_frontier.add(source_id)
|
|
|
|
|
if truncated:
|
|
|
|
|
break
|
|
|
|
|
frontier = next_frontier
|
|
|
|
|
if frontier and len(selected) >= limit:
|
|
|
|
|
truncated = True
|
|
|
|
|
|
|
|
|
|
convergent_ids = {node_id}
|
|
|
|
|
for edge in selected:
|
|
|
|
|
convergent_ids.add(cast(str, edge["source_id"]))
|
|
|
|
|
convergent_ids.add(cast(str, edge["target_id"]))
|
|
|
|
|
placeholders = ",".join("?" for _ in convergent_ids)
|
|
|
|
|
node_rows = connection.execute(
|
|
|
|
|
f"SELECT * FROM nodes WHERE node_id IN ({placeholders}) ORDER BY node_id",
|
|
|
|
|
tuple(sorted(convergent_ids)),
|
|
|
|
|
).fetchall()
|
|
|
|
|
return self._result(
|
|
|
|
|
root=node_id,
|
|
|
|
|
web=True,
|
|
|
|
|
depth=depth,
|
|
|
|
|
edge_limit=limit,
|
|
|
|
|
truncated=truncated,
|
|
|
|
|
hops={node: hops[node] for node in sorted(convergent_ids)},
|
2026-07-25 01:05:45 -04:00
|
|
|
node=_node_dict(root_row),
|
|
|
|
|
nodes=[_node_dict(row, include_content=False) for row in node_rows],
|
|
|
|
|
edges=selected,
|
|
|
|
|
snapshot=True,
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-24 16:01:03 -04:00
|
|
|
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 23:55:55 -04:00
|
|
|
persistent: bool = False,
|
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 23:55:55 -04:00
|
|
|
if (
|
|
|
|
|
(not persistent and initial_grace_seconds <= 0)
|
|
|
|
|
or lease_seconds <= 0
|
|
|
|
|
or monitor_interval_seconds <= 0
|
|
|
|
|
):
|
2026-07-24 21:43:11 -04:00
|
|
|
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 23:55:55 -04:00
|
|
|
self.persistent = persistent
|
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()
|
2026-07-25 00:17:21 -04:00
|
|
|
self._activity_last_seen = time.time()
|
2026-07-24 21:43:11 -04:00
|
|
|
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()
|
2026-07-25 00:17:21 -04:00
|
|
|
self._activity_last_seen = time.time()
|
2026-07-24 21:43:11 -04:00
|
|
|
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 23:55:55 -04:00
|
|
|
if not self.persistent:
|
|
|
|
|
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 23:55:55 -04:00
|
|
|
"lifetime": (
|
|
|
|
|
{"policy": "explicit_stop"}
|
|
|
|
|
if self.persistent
|
|
|
|
|
else {
|
|
|
|
|
"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()
|
2026-07-25 00:17:21 -04:00
|
|
|
self._activity_last_seen = time.time()
|
2026-07-24 21:43:11 -04:00
|
|
|
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}"
|
2026-07-25 16:46:00 -04:00
|
|
|
page_paths = {prefix, f"{prefix}/"}
|
|
|
|
|
asset_prefix = f"{prefix}/assets/"
|
|
|
|
|
is_asset = parsed.path.startswith(asset_prefix)
|
|
|
|
|
if (
|
|
|
|
|
parsed.path not in page_paths
|
|
|
|
|
and not parsed.path.startswith(f"{prefix}/api/")
|
|
|
|
|
and not is_asset
|
2026-07-24 16:01:03 -04:00
|
|
|
):
|
|
|
|
|
self._respond_error(
|
|
|
|
|
handler,
|
|
|
|
|
HTTPStatus.NOT_FOUND,
|
|
|
|
|
"not_found",
|
|
|
|
|
"Not found",
|
|
|
|
|
include_body=include_body,
|
|
|
|
|
)
|
|
|
|
|
return
|
2026-07-25 16:46:00 -04:00
|
|
|
if parsed.path in page_paths:
|
2026-07-25 00:17:21 -04:00
|
|
|
self._touch_lease()
|
2026-07-24 16:01:03 -04:00
|
|
|
self._respond(
|
|
|
|
|
handler,
|
|
|
|
|
_GRAPH_BROWSER_HTML.encode("utf-8"),
|
|
|
|
|
"text/html; charset=utf-8",
|
|
|
|
|
include_body=include_body,
|
|
|
|
|
)
|
|
|
|
|
return
|
2026-07-25 16:46:00 -04:00
|
|
|
if is_asset:
|
|
|
|
|
asset = _GRAPH_BROWSER_ASSETS.get(parsed.path.removeprefix(asset_prefix))
|
|
|
|
|
if asset is None:
|
|
|
|
|
self._respond_error(
|
|
|
|
|
handler,
|
|
|
|
|
HTTPStatus.NOT_FOUND,
|
|
|
|
|
"not_found",
|
|
|
|
|
"Not found",
|
|
|
|
|
include_body=include_body,
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
payload, content_type = asset
|
|
|
|
|
self._respond(
|
|
|
|
|
handler,
|
|
|
|
|
payload,
|
|
|
|
|
content_type,
|
|
|
|
|
include_body=include_body,
|
|
|
|
|
)
|
|
|
|
|
return
|
2026-07-24 16:01:03 -04:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
params = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
|
|
|
|
|
reader = self._current_reader()
|
2026-07-25 00:17:21 -04:00
|
|
|
if parsed.path == f"{prefix}/api/health":
|
|
|
|
|
with self._lock:
|
|
|
|
|
last_activity = self._activity_last_seen
|
|
|
|
|
payload = reader.result(viewer="alive", last_activity_at=last_activity)
|
|
|
|
|
elif parsed.path == f"{prefix}/api/overview":
|
|
|
|
|
self._touch_lease()
|
2026-07-24 16:01:03 -04:00
|
|
|
payload = reader.overview()
|
2026-07-24 21:43:11 -04:00
|
|
|
elif parsed.path == f"{prefix}/api/heartbeat":
|
2026-07-25 00:17:21 -04:00
|
|
|
self._touch_lease()
|
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":
|
2026-07-25 00:17:21 -04:00
|
|
|
self._touch_lease()
|
2026-07-24 16:01:03 -04:00
|
|
|
payload = self._search(reader, params)
|
2026-07-24 23:15:57 -04:00
|
|
|
elif parsed.path == f"{prefix}/api/filter":
|
2026-07-25 00:17:21 -04:00
|
|
|
self._touch_lease()
|
2026-07-24 23:15:57 -04:00
|
|
|
payload = self._filter(reader, params)
|
2026-07-24 16:01:03 -04:00
|
|
|
elif parsed.path == f"{prefix}/api/node":
|
2026-07-25 00:17:21 -04:00
|
|
|
self._touch_lease()
|
2026-07-24 16:01:03 -04:00
|
|
|
payload = self._node(reader, params)
|
2026-07-25 16:00:19 -04:00
|
|
|
elif parsed.path == f"{prefix}/api/source":
|
|
|
|
|
self._touch_lease()
|
|
|
|
|
payload = self._source(reader, params)
|
2026-07-25 01:05:45 -04:00
|
|
|
elif parsed.path == f"{prefix}/api/lineage":
|
|
|
|
|
self._touch_lease()
|
|
|
|
|
payload = self._lineage(reader, params)
|
2026-07-25 17:34:58 -04:00
|
|
|
elif parsed.path == f"{prefix}/api/web":
|
|
|
|
|
self._touch_lease()
|
|
|
|
|
payload = self._web(reader, params)
|
2026-07-24 16:01:03 -04:00
|
|
|
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,
|
2026-07-24 23:15:57 -04:00
|
|
|
"invalid_filter": HTTPStatus.BAD_REQUEST,
|
2026-07-24 16:01:03 -04:00
|
|
|
"invalid_depth": HTTPStatus.BAD_REQUEST,
|
|
|
|
|
"invalid_limit": HTTPStatus.BAD_REQUEST,
|
2026-07-25 16:00:19 -04:00
|
|
|
"path_escape": HTTPStatus.FORBIDDEN,
|
|
|
|
|
"missing_source": HTTPStatus.NOT_FOUND,
|
|
|
|
|
"source_too_large": HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
2026-07-24 16:01:03 -04:00
|
|
|
}.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)
|
|
|
|
|
|
2026-07-25 16:00:19 -04:00
|
|
|
@staticmethod
|
|
|
|
|
def _source(
|
|
|
|
|
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")
|
|
|
|
|
return reader.source(node_id)
|
|
|
|
|
|
2026-07-25 01:05:45 -04:00
|
|
|
def _lineage(
|
|
|
|
|
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")
|
|
|
|
|
limit = _integer(_one(params, "limit") or str(MAX_LINEAGE_EDGE_LIMIT))
|
|
|
|
|
if limit > MAX_LINEAGE_EDGE_LIMIT:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_limit",
|
|
|
|
|
"Visualization lineage limit exceeds the fixed safety boundary",
|
|
|
|
|
maximum=MAX_LINEAGE_EDGE_LIMIT,
|
|
|
|
|
)
|
|
|
|
|
return reader.lineage(node_id, limit=limit)
|
|
|
|
|
|
2026-07-25 17:34:58 -04:00
|
|
|
def _web(
|
|
|
|
|
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 str(reader.max_depth))
|
|
|
|
|
limit = _integer(_one(params, "limit") or str(MAX_LINEAGE_EDGE_LIMIT))
|
|
|
|
|
if limit > MAX_LINEAGE_EDGE_LIMIT:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"invalid_limit",
|
|
|
|
|
"Visualization web limit exceeds the fixed safety boundary",
|
|
|
|
|
maximum=MAX_LINEAGE_EDGE_LIMIT,
|
|
|
|
|
)
|
|
|
|
|
return reader.web(node_id, depth=depth, limit=limit)
|
|
|
|
|
|
2026-07-24 23:15:57 -04:00
|
|
|
def _filter(
|
|
|
|
|
self,
|
|
|
|
|
reader: VisualizationIndexSnapshot,
|
|
|
|
|
params: dict[str, list[str]],
|
|
|
|
|
) -> dict[str, object]:
|
|
|
|
|
category = _one(params, "category").strip()
|
|
|
|
|
value = _one(params, "value").strip()
|
|
|
|
|
limit = _integer(_one(params, "limit") or "50")
|
|
|
|
|
return reader.filter_nodes(category=category, value=value, limit=limit)
|
|
|
|
|
|
2026-07-24 16:01:03 -04:00
|
|
|
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",
|
2026-07-25 16:46:00 -04:00
|
|
|
"default-src 'none'; script-src 'self'; style-src 'self'; "
|
2026-07-24 16:01:03 -04:00
|
|
|
"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 23:55:55 -04:00
|
|
|
class PersistentVisualizationRunner:
|
|
|
|
|
"""Run one project-bound browser until an explicit DocForge stop request."""
|
2026-07-24 22:07:33 -04:00
|
|
|
|
2026-07-24 23:55:55 -04:00
|
|
|
def __init__(self, index: ProjectIndex) -> None:
|
|
|
|
|
self.index = index
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def _cache_root(self) -> Path:
|
|
|
|
|
return self.index.project.descriptor.cache_root
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def _registry_path(self) -> Path:
|
|
|
|
|
return self._cache_root / VISUALIZATION_REGISTRY_NAME
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def _lock_path(self) -> Path:
|
|
|
|
|
return self._cache_root / VISUALIZATION_LOCK_NAME
|
|
|
|
|
|
|
|
|
|
@contextmanager
|
|
|
|
|
def _locked_registry(self) -> Generator[None, None, None]:
|
2026-07-25 00:17:21 -04:00
|
|
|
if fcntl is None:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"visualization_unavailable",
|
|
|
|
|
"The deprecated direct visualization runner is unavailable on this platform",
|
|
|
|
|
)
|
2026-07-24 23:55:55 -04:00
|
|
|
self._cache_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
with self._lock_path.open("a+", encoding="utf-8") as handle:
|
|
|
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
|
|
|
try:
|
|
|
|
|
yield
|
|
|
|
|
finally:
|
|
|
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
|
|
|
|
|
|
|
|
def _read_registry(self) -> dict[str, object] | None:
|
|
|
|
|
path = self._registry_path
|
|
|
|
|
if not path.exists():
|
|
|
|
|
return None
|
|
|
|
|
if path.is_symlink() or not path.is_file():
|
|
|
|
|
raise DocForgeError("visualization_unavailable", "Visualization registry is unsafe")
|
|
|
|
|
try:
|
|
|
|
|
raw_document: object = json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
except (OSError, json.JSONDecodeError):
|
|
|
|
|
self._remove_registry()
|
|
|
|
|
return None
|
|
|
|
|
if not isinstance(raw_document, dict):
|
|
|
|
|
self._remove_registry()
|
|
|
|
|
return None
|
|
|
|
|
document = cast(dict[str, object], raw_document)
|
|
|
|
|
pid = document.get("pid")
|
|
|
|
|
port = document.get("port")
|
|
|
|
|
token = document.get("token")
|
|
|
|
|
snapshot = document.get("snapshot")
|
|
|
|
|
if (
|
|
|
|
|
document.get("runtime") != VISUALIZATION_RUNTIME
|
|
|
|
|
or document.get("template") != VISUALIZATION_TEMPLATE
|
|
|
|
|
or type(pid) is not int
|
|
|
|
|
or pid <= 1
|
|
|
|
|
or type(port) is not int
|
|
|
|
|
or not 1 <= port <= 65535
|
|
|
|
|
or not isinstance(token, str)
|
|
|
|
|
or len(token) < 20
|
|
|
|
|
or not isinstance(snapshot, dict)
|
|
|
|
|
):
|
|
|
|
|
self._remove_registry()
|
|
|
|
|
return None
|
|
|
|
|
return document
|
|
|
|
|
|
|
|
|
|
def _remove_registry(self) -> None:
|
|
|
|
|
path = self._registry_path
|
|
|
|
|
if not path.exists():
|
|
|
|
|
return
|
|
|
|
|
if path.is_symlink() or not path.is_file():
|
|
|
|
|
raise DocForgeError("visualization_unavailable", "Visualization registry is unsafe")
|
|
|
|
|
path.unlink()
|
|
|
|
|
|
|
|
|
|
def _write_registry(self, document: dict[str, object]) -> None:
|
|
|
|
|
descriptor, temporary_name = tempfile.mkstemp(
|
|
|
|
|
prefix=".visualization-", dir=self._cache_root
|
|
|
|
|
)
|
|
|
|
|
temporary = Path(temporary_name)
|
|
|
|
|
try:
|
|
|
|
|
os.fchmod(descriptor, 0o600)
|
|
|
|
|
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
|
|
|
json.dump(document, handle, sort_keys=True, separators=(",", ":"))
|
|
|
|
|
handle.write("\n")
|
|
|
|
|
handle.flush()
|
|
|
|
|
os.fsync(handle.fileno())
|
|
|
|
|
os.replace(temporary, self._registry_path)
|
|
|
|
|
except OSError:
|
|
|
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _worker_process(pid: int) -> bool:
|
|
|
|
|
if pid <= 1:
|
|
|
|
|
return False
|
|
|
|
|
try:
|
|
|
|
|
command = Path(f"/proc/{pid}/cmdline").read_bytes()
|
|
|
|
|
except OSError:
|
|
|
|
|
return False
|
|
|
|
|
return b"docforge.visualization_worker" in command
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _target_url(
|
|
|
|
|
*,
|
|
|
|
|
port: int,
|
|
|
|
|
token: str,
|
|
|
|
|
node_id: str | None,
|
|
|
|
|
query: str | None,
|
|
|
|
|
depth: int,
|
|
|
|
|
) -> str:
|
|
|
|
|
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
|
|
|
|
|
return f"http://127.0.0.1:{port}/{token}/?{urllib.parse.urlencode(parameters)}"
|
|
|
|
|
|
|
|
|
|
def _matches_snapshot(
|
|
|
|
|
self,
|
|
|
|
|
document: dict[str, object],
|
|
|
|
|
snapshot: VisualizationIndexSnapshot,
|
|
|
|
|
) -> bool:
|
|
|
|
|
record = document.get("snapshot")
|
|
|
|
|
return isinstance(record, dict) and record == snapshot.identity
|
|
|
|
|
|
|
|
|
|
def _is_live(
|
2026-07-24 22:07:33 -04:00
|
|
|
self,
|
2026-07-24 23:55:55 -04:00
|
|
|
document: dict[str, object],
|
|
|
|
|
snapshot: VisualizationIndexSnapshot,
|
|
|
|
|
) -> bool:
|
|
|
|
|
if not self._matches_snapshot(document, snapshot):
|
|
|
|
|
return False
|
|
|
|
|
pid = cast(int, document["pid"])
|
|
|
|
|
port = cast(int, document["port"])
|
|
|
|
|
token = cast(str, document["token"])
|
|
|
|
|
if not self._worker_process(pid):
|
|
|
|
|
return False
|
|
|
|
|
request = urllib.request.Request(
|
|
|
|
|
f"http://127.0.0.1:{port}/{token}/api/overview",
|
|
|
|
|
headers={"Accept": "application/json"},
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
with urllib.request.urlopen(request, timeout=1) as response:
|
|
|
|
|
raw_payload: object = json.load(response)
|
|
|
|
|
except (OSError, ValueError, urllib.error.URLError):
|
|
|
|
|
return False
|
|
|
|
|
if not isinstance(raw_payload, dict):
|
|
|
|
|
return False
|
|
|
|
|
payload = cast(dict[str, object], raw_payload)
|
|
|
|
|
return all(payload.get(key) == value for key, value in snapshot.identity.items())
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _terminate_worker(pid: int) -> None:
|
|
|
|
|
if not PersistentVisualizationRunner._worker_process(pid):
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
os.kill(pid, signal.SIGTERM)
|
|
|
|
|
except ProcessLookupError:
|
|
|
|
|
return
|
|
|
|
|
except PermissionError as error:
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
"visualization_unavailable", "Visualization worker cannot be stopped"
|
|
|
|
|
) from error
|
|
|
|
|
deadline = monotonic() + 2
|
|
|
|
|
while PersistentVisualizationRunner._worker_process(pid) and monotonic() < deadline:
|
|
|
|
|
time.sleep(0.05)
|
|
|
|
|
if PersistentVisualizationRunner._worker_process(pid):
|
|
|
|
|
try:
|
|
|
|
|
os.kill(pid, signal.SIGKILL)
|
|
|
|
|
except ProcessLookupError:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
def _result(
|
|
|
|
|
self,
|
|
|
|
|
snapshot: VisualizationIndexSnapshot,
|
2026-07-24 22:07:33 -04:00
|
|
|
*,
|
2026-07-24 23:55:55 -04:00
|
|
|
port: int,
|
|
|
|
|
token: str,
|
|
|
|
|
node_id: str | None,
|
|
|
|
|
query: str | None,
|
|
|
|
|
depth: int,
|
|
|
|
|
reused: bool,
|
|
|
|
|
) -> dict[str, object]:
|
|
|
|
|
return {
|
|
|
|
|
"state": "running",
|
|
|
|
|
"reused": reused,
|
|
|
|
|
"url": self._target_url(
|
|
|
|
|
port=port,
|
|
|
|
|
token=token,
|
|
|
|
|
node_id=node_id,
|
|
|
|
|
query=query,
|
|
|
|
|
depth=depth,
|
|
|
|
|
),
|
|
|
|
|
"bind": "127.0.0.1",
|
|
|
|
|
"port": port,
|
|
|
|
|
"template": VISUALIZATION_TEMPLATE,
|
|
|
|
|
"read_only": True,
|
|
|
|
|
"project_bound": True,
|
|
|
|
|
"lifetime": {
|
|
|
|
|
"policy": "explicit_stop",
|
|
|
|
|
"stop_tool": "docforge_stop_visualization",
|
|
|
|
|
},
|
|
|
|
|
"target": {"node_id": node_id, "query": query, "depth": depth},
|
|
|
|
|
"snapshot": dict(snapshot.identity),
|
|
|
|
|
}
|
2026-07-24 22:07:33 -04:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-07-24 23:55:55 -04:00
|
|
|
with self._locked_registry():
|
|
|
|
|
existing = self._read_registry()
|
|
|
|
|
if existing is not None and self._is_live(existing, snapshot):
|
|
|
|
|
return self._result(
|
|
|
|
|
snapshot,
|
|
|
|
|
port=cast(int, existing["port"]),
|
|
|
|
|
token=cast(str, existing["token"]),
|
|
|
|
|
node_id=node_id,
|
|
|
|
|
query=query,
|
|
|
|
|
depth=depth,
|
|
|
|
|
reused=True,
|
|
|
|
|
)
|
|
|
|
|
if existing is not None:
|
|
|
|
|
self._terminate_worker(cast(int, existing["pid"]))
|
|
|
|
|
self._remove_registry()
|
|
|
|
|
return self._launch(snapshot, node_id=node_id, query=query, depth=depth)
|
|
|
|
|
|
|
|
|
|
def _launch(
|
|
|
|
|
self,
|
|
|
|
|
snapshot: VisualizationIndexSnapshot,
|
|
|
|
|
*,
|
|
|
|
|
node_id: str | None,
|
|
|
|
|
query: str | None,
|
|
|
|
|
depth: int,
|
|
|
|
|
) -> dict[str, object]:
|
2026-07-24 22:07:33 -04:00
|
|
|
parent_socket, child_socket = socket.socketpair()
|
2026-07-24 23:55:55 -04:00
|
|
|
process_id: int | None = None
|
|
|
|
|
token = secrets.token_urlsafe(24)
|
2026-07-24 22:07:33 -04:00
|
|
|
try:
|
|
|
|
|
command = (
|
|
|
|
|
sys.executable,
|
|
|
|
|
"-m",
|
|
|
|
|
"docforge.visualization_worker",
|
|
|
|
|
"--control-fd",
|
|
|
|
|
str(child_socket.fileno()),
|
|
|
|
|
)
|
2026-07-24 23:55:55 -04:00
|
|
|
child_socket.set_inheritable(True)
|
|
|
|
|
process_id = os.posix_spawn(
|
|
|
|
|
sys.executable,
|
2026-07-24 22:07:33 -04:00
|
|
|
command,
|
2026-07-24 23:55:55 -04:00
|
|
|
os.environ,
|
|
|
|
|
setsid=True,
|
2026-07-24 22:07:33 -04:00
|
|
|
)
|
|
|
|
|
child_socket.close()
|
|
|
|
|
request = {
|
|
|
|
|
"snapshot": snapshot.spec(),
|
2026-07-24 23:55:55 -04:00
|
|
|
"token": token,
|
|
|
|
|
"target": {"node_id": node_id, "query": query, "depth": depth},
|
2026-07-24 22:07:33 -04:00
|
|
|
}
|
|
|
|
|
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)
|
2026-07-24 23:55:55 -04:00
|
|
|
if response.get("status") != "ok" or not isinstance(
|
|
|
|
|
response.get("visualization"), dict
|
|
|
|
|
):
|
|
|
|
|
raise ValueError("Visualization worker rejected the snapshot")
|
|
|
|
|
visualization = cast(dict[str, object], response["visualization"])
|
|
|
|
|
port = visualization.get("port")
|
|
|
|
|
if type(port) is not int or not 1 <= port <= 65535:
|
|
|
|
|
raise ValueError("Visualization worker returned an invalid port")
|
|
|
|
|
self._write_registry(
|
|
|
|
|
{
|
|
|
|
|
"runtime": VISUALIZATION_RUNTIME,
|
|
|
|
|
"template": VISUALIZATION_TEMPLATE,
|
|
|
|
|
"pid": process_id,
|
|
|
|
|
"port": port,
|
|
|
|
|
"token": token,
|
|
|
|
|
"snapshot": dict(snapshot.identity),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return self._result(
|
|
|
|
|
snapshot,
|
|
|
|
|
port=port,
|
|
|
|
|
token=token,
|
|
|
|
|
node_id=node_id,
|
|
|
|
|
query=query,
|
|
|
|
|
depth=depth,
|
|
|
|
|
reused=False,
|
|
|
|
|
)
|
|
|
|
|
except (OSError, ValueError) as error:
|
|
|
|
|
if process_id is not None:
|
|
|
|
|
self._terminate_worker(process_id)
|
2026-07-24 22:07:33 -04:00
|
|
|
raise DocForgeError(
|
|
|
|
|
"visualization_unavailable",
|
2026-07-24 23:55:55 -04:00
|
|
|
"The persistent visualization worker failed to start",
|
2026-07-24 22:07:33 -04:00
|
|
|
) from error
|
|
|
|
|
finally:
|
|
|
|
|
child_socket.close()
|
|
|
|
|
parent_socket.close()
|
|
|
|
|
|
2026-07-24 23:55:55 -04:00
|
|
|
def stop(self) -> dict[str, object]:
|
|
|
|
|
with self._locked_registry():
|
|
|
|
|
existing = self._read_registry()
|
|
|
|
|
if existing is None:
|
|
|
|
|
return self._stop_result("not_running")
|
|
|
|
|
self._terminate_worker(cast(int, existing["pid"]))
|
|
|
|
|
self._remove_registry()
|
|
|
|
|
return self._stop_result("stopped")
|
2026-07-24 22:07:33 -04:00
|
|
|
|
2026-07-24 23:55:55 -04:00
|
|
|
def _stop_result(self, state: str) -> dict[str, object]:
|
|
|
|
|
descriptor = self.index.project.descriptor
|
|
|
|
|
return {
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"state": state,
|
|
|
|
|
"project_id": descriptor.project_id,
|
|
|
|
|
"project_root_fingerprint": project_root_fingerprint(descriptor.root),
|
|
|
|
|
"adapter": descriptor.adapter,
|
|
|
|
|
}
|
2026-07-24 22:07:33 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 17:34:58 -04:00
|
|
|
def _visualization_edge(
|
|
|
|
|
row: sqlite3.Row,
|
|
|
|
|
*,
|
|
|
|
|
reversed_edge: bool,
|
|
|
|
|
) -> dict[str, object]:
|
|
|
|
|
stored_source_id = cast(str, row["source_id"])
|
|
|
|
|
stored_target_id = cast(str, row["target_id"])
|
|
|
|
|
return {
|
|
|
|
|
"source_id": stored_target_id if reversed_edge else stored_source_id,
|
|
|
|
|
"relation": row["relation"],
|
|
|
|
|
"target_id": stored_source_id if reversed_edge else stored_target_id,
|
|
|
|
|
"stored_source_id": stored_source_id,
|
|
|
|
|
"stored_target_id": stored_target_id,
|
|
|
|
|
"reversed": reversed_edge,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 16:01:03 -04:00
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 16:46:00 -04:00
|
|
|
def _read_browser_asset(name: str) -> str:
|
|
|
|
|
return resources.files("docforge.assets").joinpath(name).read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_GRAPH_BROWSER_HTML = _read_browser_asset("graph.html")
|
|
|
|
|
_GRAPH_BROWSER_CSS = _read_browser_asset("graph.css")
|
|
|
|
|
_GRAPH_BROWSER_JAVASCRIPT = _read_browser_asset("graph.js")
|
|
|
|
|
_GRAPH_BROWSER_ASSETS = {
|
|
|
|
|
"graph.css": (_GRAPH_BROWSER_CSS.encode("utf-8"), "text/css; charset=utf-8"),
|
|
|
|
|
"graph.js": (
|
|
|
|
|
_GRAPH_BROWSER_JAVASCRIPT.encode("utf-8"),
|
|
|
|
|
"text/javascript; charset=utf-8",
|
|
|
|
|
),
|
|
|
|
|
}
|