1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Bind visualization status to snapshot freshness

This commit is contained in:
Andraxion 2026-07-29 05:23:52 -04:00
parent 24bd13f9d9
commit 176b2d2784
9 changed files with 627 additions and 43 deletions

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import argparse
import json
import math
import os
import plistlib
import secrets
@ -25,15 +26,27 @@ from typing import BinaryIO, cast
from .errors import DocForgeError
from .index import ProjectIndex
from .models import IncrementalStateProject
from .project import project_root_fingerprint
from .telemetry import increment, stage
from .visualization import VISUALIZATION_TEMPLATE, VisualizationIndexSnapshot
MANAGER_PROTOCOL = "docforge-viewer-manager@1"
MANAGER_RUNTIME = "viewer-manager@1"
MANAGER_PROTOCOL = "docforge-viewer-manager@2"
MANAGER_RUNTIME = "viewer-manager@2"
DEFAULT_IDLE_TIMEOUT_SECONDS = 3600.0
DEFAULT_CHECK_INTERVAL_SECONDS = 30.0
MAX_MESSAGE_BYTES = 1_000_000
WORKER_SNAPSHOT_KEYS = frozenset(
{
"project_id",
"project_root_fingerprint",
"revision",
"source_hash",
"adapter",
"node_count",
"edge_count",
}
)
def default_runtime_root() -> Path:
@ -225,6 +238,12 @@ class _ManagedWorker:
last_activity_at: float
@dataclass(frozen=True)
class _WorkerHealth:
last_activity_at: float
index_state: str
class ViewerManager:
"""Own project viewer processes behind an authenticated loopback control API."""
@ -426,12 +445,13 @@ class ViewerManager:
self._workers.pop(key, None)
self._stop_worker(worker)
return {"status": "ok", "state": "not_running"}
worker.last_activity_at = activity
worker.last_activity_at = activity.last_activity_at
return {
"status": "ok",
"state": "running",
"snapshot": dict(worker.snapshot),
"idle_seconds": max(0, int(time.time() - activity)),
"index_state": activity.index_state,
"idle_seconds": max(0, int(time.time() - activity.last_activity_at)),
"idle_timeout_seconds": self.idle_timeout_seconds,
}
@ -501,9 +521,9 @@ class ViewerManager:
if worker.snapshot != snapshot.identity or worker.process.poll() is not None:
return False
activity = self._health(worker)
if activity is None:
if activity is None or activity.index_state != "current":
return False
worker.last_activity_at = activity
worker.last_activity_at = activity.last_activity_at
return True
@staticmethod
@ -518,7 +538,7 @@ class ViewerManager:
worker.process.wait(timeout=2)
@staticmethod
def _health(worker: _ManagedWorker) -> float | None:
def _health(worker: _ManagedWorker) -> _WorkerHealth | None:
request = urllib.request.Request(
f"http://127.0.0.1:{worker.port}/{worker.token}/api/health",
headers={"Accept": "application/json"},
@ -531,10 +551,25 @@ class ViewerManager:
if not isinstance(payload, dict):
return None
payload = cast(dict[str, object], payload)
if payload.get("viewer") != "alive":
if payload.get("status") != "ok" or payload.get("viewer") != "alive":
return None
if frozenset(worker.snapshot) != WORKER_SNAPSHOT_KEYS:
return None
if any(payload.get(key) != value for key, value in worker.snapshot.items()):
return None
activity = payload.get("last_activity_at")
return float(activity) if isinstance(activity, int | float) else None
index_state = payload.get("index_state")
if (
isinstance(activity, bool)
or not isinstance(activity, int | float)
or not math.isfinite(activity)
or index_state not in {"current", "stale"}
):
return None
return _WorkerHealth(
last_activity_at=float(activity),
index_state=cast(str, index_state),
)
def _result(
self,
@ -575,11 +610,11 @@ class ViewerManager:
with self._lock:
expired: list[tuple[str, _ManagedWorker]] = []
for key, worker in self._workers.items():
activity = self._health(worker)
if activity is None or activity < cutoff:
health = self._health(worker)
if health is None or health.last_activity_at < cutoff:
expired.append((key, worker))
else:
worker.last_activity_at = activity
worker.last_activity_at = health.last_activity_at
for key, worker in expired:
self._workers.pop(key, None)
self._stop_worker(worker)
@ -637,7 +672,77 @@ class ViewerManagerClient:
def status(self) -> dict[str, object]:
with stage("visualization.status"):
return self._lifecycle_request("status")
response = self._lifecycle_request("status")
lifecycle = response.get("state")
if lifecycle not in {"running", "not_running"}:
raise DocForgeError(
"visualization_unavailable",
"Viewer manager returned an invalid lifecycle state",
)
if lifecycle != "running":
return {
**response,
"revision": "unknown",
"source_hash": None,
"snapshot_state": "unknown",
"staleness": "unknown",
"freshness": {
"index": "unknown",
"source": "unknown",
},
}
index_value = response.get("index_state")
index_state = (
cast(str, index_value) if index_value in {"current", "stale"} else "unknown"
)
snapshot_value = response.get("snapshot")
snapshot_payload: dict[str, object] = (
cast(dict[str, object], snapshot_value) if isinstance(snapshot_value, dict) else {}
)
source_state = self._source_state(snapshot_payload)
revision = snapshot_payload.get("revision")
source_hash = snapshot_payload.get("source_hash")
snapshot_state = (
"stale"
if "stale" in {index_state, source_state}
else (
"current"
if index_state == "current" and source_state == "current"
else "unknown"
)
)
return {
**response,
"revision": revision if isinstance(revision, str) else "unknown",
"source_hash": source_hash if isinstance(source_hash, str) else None,
"snapshot_state": snapshot_state,
"staleness": snapshot_state,
"freshness": {
"index": index_state,
"source": source_state,
},
}
def _source_state(self, snapshot: object) -> str:
project = self.index.project
if not isinstance(project, IncrementalStateProject) or not isinstance(snapshot, dict):
return "unknown"
snapshot = cast(dict[str, object], snapshot)
revision = snapshot.get("revision")
source_hash = snapshot.get("source_hash")
if not isinstance(revision, str) or not isinstance(source_hash, str):
return "unknown"
try:
state = project.incremental_state()
except (DocForgeError, OSError, RuntimeError, TypeError, ValueError):
return "unknown"
if state is None:
return "unknown"
return (
"current"
if state.revision == revision and state.source_hash == source_hash
else "stale"
)
def _lifecycle_request(self, action: str) -> dict[str, object]:
descriptor = self.index.project.descriptor