windhover 0.13.0__tar.gz → 0.14.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {windhover-0.13.0 → windhover-0.14.1}/PKG-INFO +4 -1
- {windhover-0.13.0 → windhover-0.14.1}/README.md +3 -0
- {windhover-0.13.0 → windhover-0.14.1}/docs/GUIDE.md +7 -1
- {windhover-0.13.0 → windhover-0.14.1}/pyproject.toml +1 -1
- {windhover-0.13.0 → windhover-0.14.1}/tests/test_smoke.py +46 -5
- windhover-0.14.1/windhover/config.py +89 -0
- windhover-0.14.1/windhover/demo_rag.py +55 -0
- {windhover-0.13.0 → windhover-0.14.1}/windhover/server.py +112 -65
- {windhover-0.13.0 → windhover-0.14.1}/windhover/static/index.html +60 -25
- {windhover-0.13.0 → windhover-0.14.1}/windhover/store.py +32 -19
- windhover-0.13.0/windhover/config.py +0 -63
- {windhover-0.13.0 → windhover-0.14.1}/.github/workflows/ci.yml +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/.gitignore +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/GUIDE.md +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/LICENSE +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/SPEC.md +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/docs/graph.png +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/docs/logo.svg +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/docs/runs.png +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/docs/social-preview.png +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/docs/stats.png +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/docs/trace.png +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/tests/__init__.py +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/windhover/__init__.py +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/windhover/demo_graph.py +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/windhover/extract.py +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/windhover/pricing.json +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/windhover/static/icon.svg +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/windhover/static/manifest.json +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/windhover/static/vendor/cytoscape-dagre.js +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/windhover/static/vendor/cytoscape.min.js +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/windhover/static/vendor/dagre.min.js +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/windhover/static/vendor/fonts/fraunces-600.woff2 +0 -0
- {windhover-0.13.0 → windhover-0.14.1}/windhover/tracer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: windhover
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.1
|
|
4
4
|
Summary: Self-hosted, mobile-friendly observability for LangGraph — traces, run history, cost, and a living graph view.
|
|
5
5
|
Project-URL: Repository, https://github.com/justfeltlikerunning/windhover
|
|
6
6
|
Author: justfeltlikerunning
|
|
@@ -57,6 +57,9 @@ Edit the graph file while it runs and the canvas updates itself.
|
|
|
57
57
|
|
|
58
58
|
Your own graph: `WINDHOVER_GRAPH="myapp.graphs:g" WINDHOVER_GRAPH_DIR=/path python -m windhover.server`
|
|
59
59
|
|
|
60
|
+
Several graphs behind one URL: `WINDHOVER_GRAPH="checkout=app.flows:checkout,support=app.flows:support"`
|
|
61
|
+
(a selector appears in the top bar; `langgraph.json` projects serve all their graphs automatically).
|
|
62
|
+
|
|
60
63
|
## Trace runs from any app
|
|
61
64
|
```python
|
|
62
65
|
from windhover import WindhoverTracer
|
|
@@ -38,6 +38,9 @@ Edit the graph file while it runs and the canvas updates itself.
|
|
|
38
38
|
|
|
39
39
|
Your own graph: `WINDHOVER_GRAPH="myapp.graphs:g" WINDHOVER_GRAPH_DIR=/path python -m windhover.server`
|
|
40
40
|
|
|
41
|
+
Several graphs behind one URL: `WINDHOVER_GRAPH="checkout=app.flows:checkout,support=app.flows:support"`
|
|
42
|
+
(a selector appears in the top bar; `langgraph.json` projects serve all their graphs automatically).
|
|
43
|
+
|
|
41
44
|
## Trace runs from any app
|
|
42
45
|
```python
|
|
43
46
|
from windhover import WindhoverTracer
|
|
@@ -57,7 +57,13 @@ WINDHOVER_GRAPH="myapp.graphs:graph" WINDHOVER_GRAPH_DIR=/path/to/app windhover
|
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
Projects with a **`langgraph.json`** (the LangGraph Studio/CLI convention) need no flags —
|
|
60
|
-
run `windhover` in the project directory and
|
|
60
|
+
run `windhover` in the project directory and every graph the file defines is served.
|
|
61
|
+
|
|
62
|
+
**Multiple graphs**: `WINDHOVER_GRAPH="checkout=app.flows:checkout,support=app.flows:support"`
|
|
63
|
+
puts a graph selector in the top bar. The selector scopes **everything** — canvas, New run,
|
|
64
|
+
node source, Memory, Runs, Sessions, Stats (so per-node latency and model usage never mix
|
|
65
|
+
graphs). The Runs page keeps an "All graphs" override in its own filter; human-in-the-loop
|
|
66
|
+
actions always follow the graph the run belongs to.
|
|
61
67
|
|
|
62
68
|
- The canvas always reflects **current-on-disk** topology (a subprocess re-extracts on
|
|
63
69
|
file change). Runs, however, execute the graph **imported at startup** — restart the
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "windhover"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.14.1"
|
|
8
8
|
description = "Self-hosted, mobile-friendly observability for LangGraph — traces, run history, cost, and a living graph view."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -555,22 +555,61 @@ def test_message_serialization():
|
|
|
555
555
|
|
|
556
556
|
|
|
557
557
|
def test_langgraph_json_discovery():
|
|
558
|
-
import json as _json
|
|
558
|
+
import json as _json
|
|
559
559
|
d = tempfile.mkdtemp()
|
|
560
560
|
gdir = os.path.join(d, "src"); os.makedirs(gdir)
|
|
561
|
-
open(os.path.join(gdir, "app.py"), "w").write("graph = None\n")
|
|
561
|
+
open(os.path.join(gdir, "app.py"), "w").write("graph = None\nother = None\n")
|
|
562
562
|
open(os.path.join(d, "langgraph.json"), "w").write(
|
|
563
|
-
_json.dumps({"graphs": {"main": "./src/app.py:graph"
|
|
563
|
+
_json.dumps({"graphs": {"main": "./src/app.py:graph",
|
|
564
|
+
"side": "./src/app.py:other"}}))
|
|
564
565
|
os.environ.pop("WINDHOVER_GRAPH", None)
|
|
565
566
|
os.environ["WINDHOVER_GRAPH_DIR"] = d
|
|
566
567
|
try:
|
|
567
568
|
from windhover.config import Config
|
|
568
569
|
cfg = Config.from_env()
|
|
569
|
-
assert cfg.
|
|
570
|
+
assert cfg.graphs == (("main", "app:graph"), ("side", "app:other")), cfg.graphs
|
|
571
|
+
assert cfg.graph_ref == "app:graph" # back-compat: first graph
|
|
570
572
|
assert cfg.graph_dir == gdir, cfg.graph_dir
|
|
571
573
|
finally:
|
|
572
574
|
os.environ.pop("WINDHOVER_GRAPH_DIR", None)
|
|
573
|
-
print("langgraph.json discovery OK")
|
|
575
|
+
print("langgraph.json multi-discovery OK")
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def test_env_multi_graph_parsing():
|
|
579
|
+
from windhover.config import Config
|
|
580
|
+
os.environ["WINDHOVER_GRAPH"] = "alpha=m1:g1, m2:g2 ,beta=m3:g3"
|
|
581
|
+
try:
|
|
582
|
+
cfg = Config.from_env()
|
|
583
|
+
assert cfg.graphs == (("alpha", "m1:g1"), ("m2:g2", "m2:g2"),
|
|
584
|
+
("beta", "m3:g3")), cfg.graphs
|
|
585
|
+
finally:
|
|
586
|
+
os.environ.pop("WINDHOVER_GRAPH", None)
|
|
587
|
+
print("env multi-graph parsing OK")
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def test_graph_scoped_stats_and_sessions():
|
|
591
|
+
p = tempfile.mktemp(suffix=".db")
|
|
592
|
+
s = Store(p)
|
|
593
|
+
now = int(time.time() * 1000)
|
|
594
|
+
for i, g in enumerate(("alpha", "alpha", "beta")):
|
|
595
|
+
rid = f"g{i}"
|
|
596
|
+
s.open_run({"id": rid, "graph": g, "session": f"sess-{g}", "started_ms": now - i})
|
|
597
|
+
s.add_span({"id": f"s{i}", "run_id": rid, "type": "node", "name": "shared_name",
|
|
598
|
+
"seq": 0, "dur_ms": 100 * (i + 1)})
|
|
599
|
+
s.add_span({"id": f"l{i}", "run_id": rid, "type": "llm", "name": "m", "seq": 1,
|
|
600
|
+
"model": f"model-{g}", "dur_ms": 5, "prompt_tokens": 10,
|
|
601
|
+
"completion_tokens": 1})
|
|
602
|
+
s.close_run(rid, "done", now + 10)
|
|
603
|
+
st_a = s.stats(graph="alpha")
|
|
604
|
+
assert st_a["totals"]["runs"] == 2
|
|
605
|
+
assert st_a["per_node"][0]["n"] == 2 # only alpha's spans, not beta's
|
|
606
|
+
assert [m["model"] for m in st_a["models"]] == ["model-alpha"]
|
|
607
|
+
st_all = s.stats()
|
|
608
|
+
assert st_all["totals"]["runs"] == 3 and st_all["per_node"][0]["n"] == 3
|
|
609
|
+
ses_b = s.sessions(graph="beta")
|
|
610
|
+
assert [x["session"] for x in ses_b] == ["sess-beta"]
|
|
611
|
+
assert len(s.sessions()) == 2
|
|
612
|
+
print("graph-scoped stats/sessions OK")
|
|
574
613
|
|
|
575
614
|
|
|
576
615
|
def test_nonblocking_sink_and_webhook_hook():
|
|
@@ -618,5 +657,7 @@ if __name__ == "__main__":
|
|
|
618
657
|
test_functional_api_tracing()
|
|
619
658
|
test_message_serialization()
|
|
620
659
|
test_langgraph_json_discovery()
|
|
660
|
+
test_env_multi_graph_parsing()
|
|
661
|
+
test_graph_scoped_stats_and_sessions()
|
|
621
662
|
test_nonblocking_sink_and_webhook_hook()
|
|
622
663
|
print("ALL SMOKE TESTS PASSED")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Windhover configuration — one place, read from the environment.
|
|
2
|
+
|
|
3
|
+
Multi-graph: WINDHOVER_GRAPH accepts a comma-separated list of graphs, each
|
|
4
|
+
"module:attr" or "name=module:attr". With no env set, ALL graphs from a
|
|
5
|
+
langgraph.json (the LangGraph Studio/CLI project file) are served.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
PKG_DIR = Path(__file__).resolve().parent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Config:
|
|
17
|
+
graphs: tuple # ((name, "module:attr"), …); () = ingest-only
|
|
18
|
+
graph_dir: str # import path for the graph modules
|
|
19
|
+
db_path: str
|
|
20
|
+
host: str
|
|
21
|
+
port: int
|
|
22
|
+
watch: bool # live-topology file watcher
|
|
23
|
+
pricing_path: str
|
|
24
|
+
retention_days: int # 0 = keep runs forever
|
|
25
|
+
token: str # WINDHOVER_TOKEN: require Bearer/query token on /api ("" = open)
|
|
26
|
+
webhook: str # WINDHOVER_WEBHOOK: POST run summaries on error/interrupted ("" = off)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def graph_ref(self) -> str:
|
|
30
|
+
"""First graph's ref — kept for single-graph callers/back-compat."""
|
|
31
|
+
return self.graphs[0][1] if self.graphs else ""
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def _parse_env_graphs(raw: str) -> tuple:
|
|
35
|
+
out = []
|
|
36
|
+
for part in raw.split(","):
|
|
37
|
+
part = part.strip()
|
|
38
|
+
if not part:
|
|
39
|
+
continue
|
|
40
|
+
if "=" in part.split(":")[0]: # "name=module:attr"
|
|
41
|
+
name, _, ref = part.partition("=")
|
|
42
|
+
out.append((name.strip(), ref.strip()))
|
|
43
|
+
else:
|
|
44
|
+
out.append((part, part)) # name defaults to the ref
|
|
45
|
+
return tuple(out)
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _discover() -> tuple:
|
|
49
|
+
"""No WINDHOVER_GRAPH? Serve every graph a langgraph.json defines.
|
|
50
|
+
Returns ((name, ref), …), graph_dir."""
|
|
51
|
+
import json as _json
|
|
52
|
+
base = os.environ.get("WINDHOVER_GRAPH_DIR", os.getcwd())
|
|
53
|
+
try:
|
|
54
|
+
graphs = _json.loads(open(os.path.join(base, "langgraph.json")).read()).get("graphs") or {}
|
|
55
|
+
except Exception:
|
|
56
|
+
return (), base
|
|
57
|
+
out, gdir = [], base
|
|
58
|
+
for name, ref in graphs.items():
|
|
59
|
+
path, _, attr = str(ref).partition(":")
|
|
60
|
+
if not attr:
|
|
61
|
+
continue
|
|
62
|
+
if path.endswith(".py"):
|
|
63
|
+
full = os.path.normpath(os.path.join(base, path))
|
|
64
|
+
gdir = os.path.dirname(full) # langgraph convention: shared src dir
|
|
65
|
+
out.append((str(name), f"{os.path.splitext(os.path.basename(full))[0]}:{attr}"))
|
|
66
|
+
else:
|
|
67
|
+
out.append((str(name), f"{path}:{attr}"))
|
|
68
|
+
return tuple(out), gdir
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_env(cls) -> "Config":
|
|
72
|
+
raw = os.environ.get("WINDHOVER_GRAPH", "")
|
|
73
|
+
graph_dir = os.environ.get("WINDHOVER_GRAPH_DIR", os.getcwd())
|
|
74
|
+
if raw:
|
|
75
|
+
graphs = cls._parse_env_graphs(raw)
|
|
76
|
+
else:
|
|
77
|
+
graphs, graph_dir = cls._discover()
|
|
78
|
+
return cls(
|
|
79
|
+
graphs=graphs,
|
|
80
|
+
graph_dir=graph_dir,
|
|
81
|
+
db_path=os.environ.get("WINDHOVER_DB", str(PKG_DIR.parent / "windhover.db")),
|
|
82
|
+
host=os.environ.get("WINDHOVER_HOST", "0.0.0.0"),
|
|
83
|
+
port=int(os.environ.get("WINDHOVER_PORT", "8090")),
|
|
84
|
+
watch=os.environ.get("WINDHOVER_WATCH", "1") not in ("0", "false", "no"),
|
|
85
|
+
pricing_path=os.environ.get("WINDHOVER_PRICING", str(PKG_DIR / "pricing.json")),
|
|
86
|
+
retention_days=int(os.environ.get("WINDHOVER_RETENTION_DAYS", "0") or 0),
|
|
87
|
+
token=os.environ.get("WINDHOVER_TOKEN", ""),
|
|
88
|
+
webhook=os.environ.get("WINDHOVER_WEBHOOK", ""),
|
|
89
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Second demo graph — a tiny retrieval pipeline, so multi-graph serving is
|
|
2
|
+
demoable out of the box:
|
|
3
|
+
|
|
4
|
+
WINDHOVER_GRAPH="numbers=windhover.demo_graph:graph,rag=windhover.demo_rag:graph" windhover
|
|
5
|
+
"""
|
|
6
|
+
import operator
|
|
7
|
+
import time
|
|
8
|
+
from typing import Annotated, TypedDict
|
|
9
|
+
from langgraph.graph import StateGraph, START, END
|
|
10
|
+
|
|
11
|
+
_DOCS = [
|
|
12
|
+
{"id": "handbook#12", "text": "Refunds are processed within five business days."},
|
|
13
|
+
{"id": "handbook#31", "text": "Enterprise plans include priority support."},
|
|
14
|
+
{"id": "faq#4", "text": "You can export your data as CSV at any time."},
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class State(TypedDict):
|
|
19
|
+
question: str
|
|
20
|
+
hits: Annotated[list, operator.add]
|
|
21
|
+
answer: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def retrieve(s):
|
|
25
|
+
time.sleep(.15)
|
|
26
|
+
q = (s.get("question") or "").lower()
|
|
27
|
+
hits = [d for d in _DOCS if any(w in d["text"].lower() for w in q.split())] or _DOCS[:1]
|
|
28
|
+
return {"hits": hits}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def grade(s):
|
|
32
|
+
time.sleep(.1)
|
|
33
|
+
return {"hits": []} # additive reducer: nothing new, grading is a pass-through demo
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def answer(s):
|
|
37
|
+
time.sleep(.2)
|
|
38
|
+
src = s["hits"][0] if s["hits"] else {"id": "none", "text": ""}
|
|
39
|
+
return {"answer": f"Per {src['id']}: {src['text']}"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_g = StateGraph(State)
|
|
43
|
+
_g.add_node("retrieve", retrieve)
|
|
44
|
+
_g.add_node("grade", grade)
|
|
45
|
+
_g.add_node("answer", answer)
|
|
46
|
+
_g.add_edge(START, "retrieve")
|
|
47
|
+
_g.add_edge("retrieve", "grade")
|
|
48
|
+
_g.add_edge("grade", "answer")
|
|
49
|
+
_g.add_edge("answer", END)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
from langgraph.checkpoint.memory import MemorySaver
|
|
53
|
+
graph = _g.compile(checkpointer=MemorySaver())
|
|
54
|
+
except Exception:
|
|
55
|
+
graph = _g.compile()
|
|
@@ -22,11 +22,26 @@ cfg = Config.from_env()
|
|
|
22
22
|
store = Store(cfg.db_path)
|
|
23
23
|
STATIC = Path(__file__).parent / "static"
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
if cfg.
|
|
25
|
+
GRAPHS: dict = {} # name -> compiled graph
|
|
26
|
+
if cfg.graphs:
|
|
27
27
|
sys.path.insert(0, cfg.graph_dir)
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
for _name, _ref in cfg.graphs:
|
|
29
|
+
try:
|
|
30
|
+
_m, _a = _ref.split(":")
|
|
31
|
+
GRAPHS[_name] = getattr(importlib.import_module(_m), _a)
|
|
32
|
+
except Exception as _e:
|
|
33
|
+
print(f"[windhover] failed to import graph {_name} ({_ref}): {_e}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _default_name() -> str:
|
|
37
|
+
return next(iter(GRAPHS), "")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _graph_for(name=None):
|
|
41
|
+
"""Resolve a graph by registry name; no name -> the first graph."""
|
|
42
|
+
if name:
|
|
43
|
+
return GRAPHS.get(name)
|
|
44
|
+
return next(iter(GRAPHS.values()), None)
|
|
30
45
|
|
|
31
46
|
app = FastAPI(title="Windhover")
|
|
32
47
|
|
|
@@ -54,7 +69,8 @@ async def _auth_middleware(request: Request, call_next):
|
|
|
54
69
|
|
|
55
70
|
# ---- topology manager (subprocess extraction, mtime-cached) ---------------
|
|
56
71
|
class Topo:
|
|
57
|
-
def __init__(self):
|
|
72
|
+
def __init__(self, name: str, ref: str):
|
|
73
|
+
self.name, self.ref = name, ref
|
|
58
74
|
self.lock = threading.Lock()
|
|
59
75
|
self.sig = None
|
|
60
76
|
self.data = {"topology": {"nodes": [], "edges": []}, "schema": {}, "sources": {}}
|
|
@@ -62,8 +78,6 @@ class Topo:
|
|
|
62
78
|
self.version = 0
|
|
63
79
|
|
|
64
80
|
def _signature(self) -> float:
|
|
65
|
-
if not cfg.graph_ref:
|
|
66
|
-
return 0.0
|
|
67
81
|
try:
|
|
68
82
|
return max((p.stat().st_mtime for p in Path(cfg.graph_dir).glob("*.py")),
|
|
69
83
|
default=0.0)
|
|
@@ -71,8 +85,6 @@ class Topo:
|
|
|
71
85
|
return 0.0
|
|
72
86
|
|
|
73
87
|
def refresh(self, force=False) -> None:
|
|
74
|
-
if not cfg.graph_ref:
|
|
75
|
-
return
|
|
76
88
|
sig = self._signature()
|
|
77
89
|
with self.lock:
|
|
78
90
|
if not force and sig == self.sig:
|
|
@@ -80,7 +92,7 @@ class Topo:
|
|
|
80
92
|
self.sig = sig
|
|
81
93
|
try:
|
|
82
94
|
out = subprocess.run(
|
|
83
|
-
[sys.executable, "-m", "windhover.extract",
|
|
95
|
+
[sys.executable, "-m", "windhover.extract", self.ref, cfg.graph_dir],
|
|
84
96
|
capture_output=True, text=True, timeout=20,
|
|
85
97
|
cwd=str(Path(__file__).parent.parent))
|
|
86
98
|
data = json.loads(out.stdout)
|
|
@@ -95,7 +107,7 @@ class Topo:
|
|
|
95
107
|
|
|
96
108
|
def get(self) -> dict:
|
|
97
109
|
with self.lock:
|
|
98
|
-
return {**self.data["topology"], "graph":
|
|
110
|
+
return {**self.data["topology"], "graph": self.name, "hash": self.hash,
|
|
99
111
|
"version": self.version, "xray": self.data.get("topology_xray")}
|
|
100
112
|
|
|
101
113
|
def schema(self) -> dict:
|
|
@@ -107,20 +119,28 @@ class Topo:
|
|
|
107
119
|
return self.data.get("sources", {})
|
|
108
120
|
|
|
109
121
|
|
|
110
|
-
|
|
111
|
-
|
|
122
|
+
TOPOS: dict = {name: Topo(name, ref) for name, ref in cfg.graphs if name in GRAPHS}
|
|
123
|
+
for _t in TOPOS.values():
|
|
124
|
+
_t.refresh(force=True)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _topo_for(name=None):
|
|
128
|
+
if name:
|
|
129
|
+
return TOPOS.get(name)
|
|
130
|
+
return next(iter(TOPOS.values()), None)
|
|
112
131
|
|
|
113
132
|
|
|
114
133
|
def _watch_loop():
|
|
115
|
-
while cfg.watch and
|
|
134
|
+
while cfg.watch and TOPOS:
|
|
116
135
|
time.sleep(2)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
136
|
+
for tp in TOPOS.values():
|
|
137
|
+
try:
|
|
138
|
+
tp.refresh()
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
121
141
|
|
|
122
142
|
|
|
123
|
-
if cfg.watch and
|
|
143
|
+
if cfg.watch and TOPOS:
|
|
124
144
|
threading.Thread(target=_watch_loop, daemon=True).start()
|
|
125
145
|
|
|
126
146
|
|
|
@@ -149,13 +169,13 @@ def _fire_webhook(summary: dict) -> None:
|
|
|
149
169
|
import urllib.request
|
|
150
170
|
run = store.run_detail(summary["id"]) or summary
|
|
151
171
|
body = {
|
|
152
|
-
"source": "windhover", "graph": run.get("graph") or
|
|
172
|
+
"source": "windhover", "graph": run.get("graph") or _default_name(),
|
|
153
173
|
"run_id": summary["id"], "status": summary["status"],
|
|
154
174
|
"duration_ms": summary.get("duration_ms"),
|
|
155
175
|
"session": run.get("session"), "thread_id": run.get("thread_id"),
|
|
156
176
|
"error": (str(summary.get("error") or "").strip().splitlines() or [None])[-1],
|
|
157
177
|
"text": f"[windhover] run {summary['id']} {summary['status']}"
|
|
158
|
-
f" ({run.get('graph') or
|
|
178
|
+
f" ({run.get('graph') or _default_name()})",
|
|
159
179
|
}
|
|
160
180
|
req = urllib.request.Request(
|
|
161
181
|
cfg.webhook, data=json.dumps(body).encode(),
|
|
@@ -179,16 +199,29 @@ def _template(schema: dict) -> dict:
|
|
|
179
199
|
|
|
180
200
|
|
|
181
201
|
# ---- endpoints ------------------------------------------------------------
|
|
202
|
+
@app.get("/api/graphs")
|
|
203
|
+
def api_graphs():
|
|
204
|
+
return JSONResponse({"graphs": list(GRAPHS.keys()), "default": _default_name()})
|
|
205
|
+
|
|
206
|
+
|
|
182
207
|
@app.get("/api/graph")
|
|
183
|
-
def api_graph():
|
|
184
|
-
|
|
208
|
+
def api_graph(graph: str = None):
|
|
209
|
+
tp = _topo_for(graph)
|
|
210
|
+
if tp is None:
|
|
211
|
+
return JSONResponse({"nodes": [], "edges": [], "graph": "", "hash": "",
|
|
212
|
+
"version": 0, "xray": None})
|
|
213
|
+
return JSONResponse(tp.get())
|
|
185
214
|
|
|
186
215
|
|
|
187
216
|
@app.get("/api/schema")
|
|
188
|
-
def api_schema():
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
217
|
+
def api_schema(graph: str = None):
|
|
218
|
+
tp = _topo_for(graph)
|
|
219
|
+
if tp is None:
|
|
220
|
+
return JSONResponse({"schema": {}, "template": {},
|
|
221
|
+
"context_schema": {}, "context_template": {}})
|
|
222
|
+
s = tp.schema()
|
|
223
|
+
with tp.lock:
|
|
224
|
+
ctx = tp.data.get("context_schema") or {}
|
|
192
225
|
return JSONResponse({"schema": s, "template": _template(s),
|
|
193
226
|
"context_schema": ctx, "context_template": _template(ctx)})
|
|
194
227
|
|
|
@@ -197,7 +230,7 @@ def _sse(ev: str, data: dict) -> str:
|
|
|
197
230
|
return f"event: {ev}\ndata: {json.dumps(data)}\n\n"
|
|
198
231
|
|
|
199
232
|
|
|
200
|
-
def _stream_execution(graph_input, config, tracer, stream_kwargs=None):
|
|
233
|
+
def _stream_execution(gr, graph_input, config, tracer, stream_kwargs=None):
|
|
201
234
|
"""Shared SSE executor for /api/run and /api/threads/…/resume. Detects both
|
|
202
235
|
dynamic interrupts (__interrupt__ updates) and static breakpoints (stream
|
|
203
236
|
ends with pending next-nodes) and corrects the run status accordingly."""
|
|
@@ -208,7 +241,7 @@ def _stream_execution(graph_input, config, tracer, stream_kwargs=None):
|
|
|
208
241
|
def worker():
|
|
209
242
|
try:
|
|
210
243
|
interrupted = False
|
|
211
|
-
for mode, chunk in
|
|
244
|
+
for mode, chunk in gr.stream(graph_input, config=config,
|
|
212
245
|
stream_mode=["updates", "custom"],
|
|
213
246
|
**(stream_kwargs or {})):
|
|
214
247
|
if mode == "custom":
|
|
@@ -238,9 +271,9 @@ def _stream_execution(graph_input, config, tracer, stream_kwargs=None):
|
|
|
238
271
|
"params": {"cached": True}})
|
|
239
272
|
seq_extra[0] += 1
|
|
240
273
|
q.put(("node", {"node": node, "cached": cached or None}))
|
|
241
|
-
if not interrupted and getattr(
|
|
274
|
+
if not interrupted and getattr(gr, "checkpointer", None) is not None:
|
|
242
275
|
try: # static breakpoint: stream ended but nodes are pending
|
|
243
|
-
st =
|
|
276
|
+
st = gr.get_state(config)
|
|
244
277
|
if st.next:
|
|
245
278
|
interrupted = True
|
|
246
279
|
q.put(("interrupt", {"run_id": run_id,
|
|
@@ -270,21 +303,24 @@ def _stream_execution(graph_input, config, tracer, stream_kwargs=None):
|
|
|
270
303
|
|
|
271
304
|
@app.post("/api/run")
|
|
272
305
|
async def api_run(request: Request):
|
|
273
|
-
if graph is None:
|
|
274
|
-
return JSONResponse({"error": "no local graph (WINDHOVER_GRAPH unset)"}, 400)
|
|
275
306
|
try:
|
|
276
307
|
payload = await request.json()
|
|
277
308
|
except Exception:
|
|
278
309
|
payload = {}
|
|
310
|
+
gname = payload.pop("_graph", None) or _default_name()
|
|
311
|
+
gr = _graph_for(gname)
|
|
312
|
+
if gr is None:
|
|
313
|
+
return JSONResponse({"error": "no local graph (WINDHOVER_GRAPH unset "
|
|
314
|
+
"or unknown graph name)"}, 400)
|
|
279
315
|
session = payload.pop("_session", None)
|
|
280
316
|
tags = payload.pop("_tags", None)
|
|
281
317
|
thread = payload.pop("_thread", None)
|
|
282
318
|
pause_before = payload.pop("_interrupt_before", None)
|
|
283
319
|
pause_after = payload.pop("_interrupt_after", None)
|
|
284
320
|
extra_conf = payload.pop("_configurable", None)
|
|
285
|
-
tracer = SpanBuilder(db_sink(store), run_name=
|
|
321
|
+
tracer = SpanBuilder(db_sink(store), run_name=gname, session=session, tags=tags)
|
|
286
322
|
config = {"callbacks": [tracer]}
|
|
287
|
-
if thread or getattr(
|
|
323
|
+
if thread or getattr(gr, "checkpointer", None) is not None:
|
|
288
324
|
# a checkpointed graph needs a thread; default to the run id so
|
|
289
325
|
# time-travel works out of the box
|
|
290
326
|
config["configurable"] = {"thread_id": thread or tracer.run_id}
|
|
@@ -295,15 +331,16 @@ async def api_run(request: Request):
|
|
|
295
331
|
sk["interrupt_before"] = [str(n) for n in pause_before]
|
|
296
332
|
if pause_after:
|
|
297
333
|
sk["interrupt_after"] = [str(n) for n in pause_after]
|
|
298
|
-
return _stream_execution(payload, config, tracer, sk)
|
|
334
|
+
return _stream_execution(gr, payload, config, tracer, sk)
|
|
299
335
|
|
|
300
336
|
|
|
301
337
|
@app.post("/api/threads/{thread_id}/resume")
|
|
302
|
-
async def api_thread_resume(request: Request, thread_id: str):
|
|
338
|
+
async def api_thread_resume(request: Request, thread_id: str, graph: str = None):
|
|
303
339
|
"""Human-in-the-loop: answer an interrupt (Command(resume=…)), redirect
|
|
304
340
|
(Command(goto=…)), continue past a static breakpoint (no body), or fork
|
|
305
341
|
from an earlier checkpoint (checkpoint_id)."""
|
|
306
|
-
|
|
342
|
+
gr = _graph_for(graph)
|
|
343
|
+
if gr is None or getattr(gr, "checkpointer", None) is None:
|
|
307
344
|
return JSONResponse({"error": "no local graph with a checkpointer"}, 400)
|
|
308
345
|
try:
|
|
309
346
|
body = await request.json()
|
|
@@ -319,21 +356,22 @@ async def api_thread_resume(request: Request, thread_id: str):
|
|
|
319
356
|
graph_input = Command(goto=str(body["goto"]), update=body.get("update"))
|
|
320
357
|
else:
|
|
321
358
|
graph_input = None # plain continue (static breakpoint / after state edit)
|
|
322
|
-
tracer = SpanBuilder(db_sink(store), run_name=
|
|
359
|
+
tracer = SpanBuilder(db_sink(store), run_name=graph or _default_name(),
|
|
323
360
|
session=body.get("_session"),
|
|
324
361
|
tags=(body.get("_tags") or []) + ["resume"])
|
|
325
362
|
configurable = {"thread_id": thread_id}
|
|
326
363
|
if body.get("checkpoint_id"):
|
|
327
364
|
configurable["checkpoint_id"] = str(body["checkpoint_id"])
|
|
328
365
|
config = {"callbacks": [tracer], "configurable": configurable}
|
|
329
|
-
return _stream_execution(graph_input, config, tracer)
|
|
366
|
+
return _stream_execution(gr, graph_input, config, tracer)
|
|
330
367
|
|
|
331
368
|
|
|
332
369
|
@app.post("/api/threads/{thread_id}/state")
|
|
333
|
-
async def api_thread_update_state(request: Request, thread_id: str):
|
|
370
|
+
async def api_thread_update_state(request: Request, thread_id: str, graph: str = None):
|
|
334
371
|
"""Human-in-the-loop: edit state at the current (or a given) checkpoint —
|
|
335
372
|
LangGraph's update_state. Follow with …/resume to continue on the edit."""
|
|
336
|
-
|
|
373
|
+
gr = _graph_for(graph)
|
|
374
|
+
if gr is None or getattr(gr, "checkpointer", None) is None:
|
|
337
375
|
return JSONResponse({"error": "no local graph with a checkpointer"}, 400)
|
|
338
376
|
body = await request.json()
|
|
339
377
|
values = body.get("values")
|
|
@@ -343,8 +381,8 @@ async def api_thread_update_state(request: Request, thread_id: str):
|
|
|
343
381
|
if body.get("checkpoint_id"):
|
|
344
382
|
configurable["checkpoint_id"] = str(body["checkpoint_id"])
|
|
345
383
|
try:
|
|
346
|
-
new_cfg =
|
|
347
|
-
|
|
384
|
+
new_cfg = gr.update_state({"configurable": configurable}, values,
|
|
385
|
+
as_node=body.get("as_node"))
|
|
348
386
|
return JSONResponse({"ok": True, "checkpoint_id":
|
|
349
387
|
(new_cfg.get("configurable") or {}).get("checkpoint_id")})
|
|
350
388
|
except Exception as e:
|
|
@@ -376,8 +414,8 @@ def api_runs(limit: int = 50, offset: int = 0, q: str = None, status: str = None
|
|
|
376
414
|
|
|
377
415
|
|
|
378
416
|
@app.get("/api/sessions")
|
|
379
|
-
def api_sessions(limit: int = 100):
|
|
380
|
-
return JSONResponse(store.sessions(limit=limit))
|
|
417
|
+
def api_sessions(limit: int = 100, graph: str = None):
|
|
418
|
+
return JSONResponse(store.sessions(limit=limit, graph=graph or None))
|
|
381
419
|
|
|
382
420
|
|
|
383
421
|
@app.get("/api/runs/{run_id}")
|
|
@@ -445,8 +483,9 @@ def api_node(name: str, limit: int = 25):
|
|
|
445
483
|
|
|
446
484
|
|
|
447
485
|
@app.get("/api/nodes/{name}/source")
|
|
448
|
-
def api_node_source(name: str):
|
|
449
|
-
|
|
486
|
+
def api_node_source(name: str, graph: str = None):
|
|
487
|
+
tp = _topo_for(graph)
|
|
488
|
+
src = (tp.sources() if tp else {}).get(name)
|
|
450
489
|
if src:
|
|
451
490
|
return JSONResponse(src)
|
|
452
491
|
return JSONResponse({"error": "no source available for this node "
|
|
@@ -454,14 +493,15 @@ def api_node_source(name: str):
|
|
|
454
493
|
|
|
455
494
|
|
|
456
495
|
@app.get("/api/threads/{thread_id}/history")
|
|
457
|
-
def api_thread_history(thread_id: str, limit: int = 80):
|
|
496
|
+
def api_thread_history(thread_id: str, limit: int = 80, graph: str = None):
|
|
458
497
|
"""Time-travel: LangGraph checkpoint history for a thread (local graph
|
|
459
498
|
with a checkpointer only)."""
|
|
460
|
-
|
|
499
|
+
gr = _graph_for(graph)
|
|
500
|
+
if gr is None or getattr(gr, "checkpointer", None) is None:
|
|
461
501
|
return JSONResponse({"error": "no local graph with a checkpointer"}, 404)
|
|
462
502
|
steps = []
|
|
463
503
|
try:
|
|
464
|
-
for st in
|
|
504
|
+
for st in gr.get_state_history({"configurable": {"thread_id": thread_id}}):
|
|
465
505
|
md = st.metadata or {}
|
|
466
506
|
steps.append({
|
|
467
507
|
"checkpoint_id": ((st.config or {}).get("configurable") or {}).get("checkpoint_id"),
|
|
@@ -480,9 +520,10 @@ def api_thread_history(thread_id: str, limit: int = 80):
|
|
|
480
520
|
|
|
481
521
|
|
|
482
522
|
@app.get("/api/memory/namespaces")
|
|
483
|
-
def api_memory_namespaces():
|
|
523
|
+
def api_memory_namespaces(graph: str = None):
|
|
484
524
|
"""LangGraph long-term memory (Store) browser — namespaces."""
|
|
485
|
-
|
|
525
|
+
gr = _graph_for(graph)
|
|
526
|
+
st = getattr(gr, "store", None) if gr is not None else None
|
|
486
527
|
if st is None:
|
|
487
528
|
return JSONResponse({"error": "no local graph with a store"}, 404)
|
|
488
529
|
try:
|
|
@@ -492,9 +533,11 @@ def api_memory_namespaces():
|
|
|
492
533
|
|
|
493
534
|
|
|
494
535
|
@app.get("/api/memory/items")
|
|
495
|
-
def api_memory_items(namespace: str, query: str = None, limit: int = 50
|
|
536
|
+
def api_memory_items(namespace: str, query: str = None, limit: int = 50,
|
|
537
|
+
graph: str = None):
|
|
496
538
|
"""Items in one namespace (dot-separated); optional semantic/text query."""
|
|
497
|
-
|
|
539
|
+
gr = _graph_for(graph)
|
|
540
|
+
st = getattr(gr, "store", None) if gr is not None else None
|
|
498
541
|
if st is None:
|
|
499
542
|
return JSONResponse({"error": "no local graph with a store"}, 404)
|
|
500
543
|
try:
|
|
@@ -546,10 +589,12 @@ def _expected_score(expected, output) -> float:
|
|
|
546
589
|
|
|
547
590
|
|
|
548
591
|
@app.post("/api/datasets/{ds_id}/run")
|
|
549
|
-
def api_dataset_run(ds_id: str):
|
|
592
|
+
def api_dataset_run(ds_id: str, graph: str = None):
|
|
550
593
|
"""Batch-eval: run the local graph over every dataset item; expected values
|
|
551
594
|
become an expected_match score on each run. Fire-and-forget worker."""
|
|
552
|
-
|
|
595
|
+
gname = graph or _default_name()
|
|
596
|
+
gr = _graph_for(gname)
|
|
597
|
+
if gr is None:
|
|
553
598
|
return JSONResponse({"error": "no local graph (WINDHOVER_GRAPH unset)"}, 400)
|
|
554
599
|
ds = store.dataset(ds_id)
|
|
555
600
|
if not ds:
|
|
@@ -558,13 +603,13 @@ def api_dataset_run(ds_id: str):
|
|
|
558
603
|
|
|
559
604
|
def worker():
|
|
560
605
|
for i, item in enumerate(ds["items"]):
|
|
561
|
-
tracer = SpanBuilder(db_sink(store), run_name=
|
|
606
|
+
tracer = SpanBuilder(db_sink(store), run_name=gname,
|
|
562
607
|
session=session, tags=[f"dataset:{ds['name']}"])
|
|
563
608
|
config = {"callbacks": [tracer]}
|
|
564
|
-
if getattr(
|
|
609
|
+
if getattr(gr, "checkpointer", None) is not None:
|
|
565
610
|
config["configurable"] = {"thread_id": tracer.run_id}
|
|
566
611
|
try:
|
|
567
|
-
out =
|
|
612
|
+
out = gr.invoke(dict(item["input"]), config=config)
|
|
568
613
|
except Exception:
|
|
569
614
|
continue # tracer already recorded the error run
|
|
570
615
|
if "expected" in item:
|
|
@@ -577,8 +622,8 @@ def api_dataset_run(ds_id: str):
|
|
|
577
622
|
|
|
578
623
|
|
|
579
624
|
@app.get("/api/stats")
|
|
580
|
-
def api_stats(days: int = 30):
|
|
581
|
-
return JSONResponse(store.stats(days=max(1, min(days, 365))))
|
|
625
|
+
def api_stats(days: int = 30, graph: str = None):
|
|
626
|
+
return JSONResponse(store.stats(days=max(1, min(days, 365)), graph=graph or None))
|
|
582
627
|
|
|
583
628
|
|
|
584
629
|
@app.get("/api/events")
|
|
@@ -586,12 +631,14 @@ async def api_events(request: Request):
|
|
|
586
631
|
async def gen():
|
|
587
632
|
import asyncio
|
|
588
633
|
seen = -1
|
|
589
|
-
|
|
634
|
+
def _ver():
|
|
635
|
+
return sum(tp.version for tp in TOPOS.values())
|
|
636
|
+
yield _sse("hello", {"version": _ver()})
|
|
590
637
|
while True:
|
|
591
638
|
if await request.is_disconnected():
|
|
592
639
|
break
|
|
593
|
-
if
|
|
594
|
-
seen =
|
|
640
|
+
if _ver() != seen:
|
|
641
|
+
seen = _ver()
|
|
595
642
|
yield _sse("topology", {"version": seen})
|
|
596
643
|
else:
|
|
597
644
|
yield ": ping\n\n"
|
|
@@ -149,7 +149,7 @@ window.resumeThread=async(tid)=>{
|
|
|
149
149
|
const raw=document.getElementById('hitl-val').value.trim();
|
|
150
150
|
const body={}; const v=parseMaybeJSON(raw); if(v!==undefined) body.value=v;
|
|
151
151
|
closeAll(); switchView('graph'); toast('Resuming thread '+tid.slice(0,8)+'…');
|
|
152
|
-
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
152
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume?'+rgparam(),
|
|
153
153
|
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
154
154
|
if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
|
|
155
155
|
await consumeRunStream(res);
|
|
@@ -157,14 +157,14 @@ window.resumeThread=async(tid)=>{
|
|
|
157
157
|
window.resumeGoto=async(tid)=>{
|
|
158
158
|
const g=document.getElementById('hitl-goto').value; if(!g) return toast('Pick a node first');
|
|
159
159
|
closeAll(); switchView('graph'); toast('Redirecting thread to '+g+'…');
|
|
160
|
-
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
160
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume?'+rgparam(),
|
|
161
161
|
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({goto:g})});
|
|
162
162
|
if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
|
|
163
163
|
await consumeRunStream(res);
|
|
164
164
|
};
|
|
165
165
|
window.rerunFrom=async(tid,cid)=>{
|
|
166
166
|
closeAll(); switchView('graph'); toast('Forking from checkpoint '+String(cid).slice(-8)+'…');
|
|
167
|
-
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
167
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume?'+rgparam(),
|
|
168
168
|
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({checkpoint_id:cid})});
|
|
169
169
|
if(!res.ok){ toast((await res.json()).error||'fork failed'); return; }
|
|
170
170
|
await consumeRunStream(res);
|
|
@@ -174,7 +174,7 @@ window.editState=async(tid,cid,valuesJson)=>{
|
|
|
174
174
|
if(cur==null) return;
|
|
175
175
|
let values; try{ values=JSON.parse(cur); }catch(e){ return toast('Invalid JSON'); }
|
|
176
176
|
const body={values}; if(cid) body.checkpoint_id=cid;
|
|
177
|
-
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/state',
|
|
177
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/state?'+rgparam(),
|
|
178
178
|
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
179
179
|
const d=await res.json();
|
|
180
180
|
if(!res.ok) return toast(d.error||'update_state failed');
|
|
@@ -184,7 +184,7 @@ window.editState=async(tid,cid,valuesJson)=>{
|
|
|
184
184
|
|
|
185
185
|
/* ---------- time-travel (checkpoint history) ---------- */
|
|
186
186
|
window.showHistory=async(tid)=>{
|
|
187
|
-
let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history');
|
|
187
|
+
let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history?'+rgparam());
|
|
188
188
|
d=await r.json(); if(!r.ok) return toast(d.error||'history unavailable'); }catch(e){ return toast('history unavailable'); }
|
|
189
189
|
const steps=d.steps||[];
|
|
190
190
|
$('#d-title').textContent='thread '+tid.slice(0,10)+' · time-travel';
|
|
@@ -398,6 +398,8 @@ aside{background:var(--rail);border-right:1px solid var(--rail-line)}
|
|
|
398
398
|
.crumb{font-family:var(--display);font-weight:600;font-size:17px;letter-spacing:.005em}
|
|
399
399
|
.pill{font-family:var(--mono);letter-spacing:.02em}
|
|
400
400
|
.plabel{font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted)}
|
|
401
|
+
#gsel{background:transparent;border:none;color:var(--text2);font:inherit;outline:none;cursor:pointer;max-width:260px}
|
|
402
|
+
#gsel option{background:var(--surface);color:var(--text)}
|
|
401
403
|
.page-head h1{font-family:var(--display);font-weight:600;letter-spacing:.005em}
|
|
402
404
|
.page-head h1::after{content:"";display:block;width:52px;height:4px;margin-top:7px;
|
|
403
405
|
background:repeating-linear-gradient(90deg,var(--rufous) 0 7px,transparent 7px 13px)}
|
|
@@ -459,13 +461,13 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
|
|
|
459
461
|
<button data-v="memory" id="nav-mem" style="display:none">${ICON.mem} Memory</button>
|
|
460
462
|
<button data-v="stats">${ICON.chart} Stats</button>
|
|
461
463
|
</nav>
|
|
462
|
-
<div class="side-foot"><span class="live-dot" id="live-dot" title="live topology"></span><span id="foot-note">watching</span><span class="spacer"></span><span class="mono" id="ver">v0.
|
|
464
|
+
<div class="side-foot"><span class="live-dot" id="live-dot" title="live topology"></span><span id="foot-note">watching</span><span class="spacer"></span><span class="mono" id="ver">v0.14</span></div>
|
|
463
465
|
</aside>
|
|
464
466
|
|
|
465
467
|
<main>
|
|
466
468
|
<div class="topbar">
|
|
467
469
|
<span class="crumb" id="crumb">Graph</span>
|
|
468
|
-
<span class="pill" id="gpill"><span class="live-dot"></span><span class="plabel">graph</span><span id="gname">—</span></span>
|
|
470
|
+
<span class="pill" id="gpill"><span class="live-dot"></span><span class="plabel">graph</span><span id="gname">—</span><select id="gsel" style="display:none"></select></span>
|
|
469
471
|
<div class="spacer"></div>
|
|
470
472
|
<button class="icon-btn" id="theme" title="theme">${ICON.moon}</button>
|
|
471
473
|
<button class="btn primary" id="newrun">${ICON.play} New run</button>
|
|
@@ -491,6 +493,7 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
|
|
|
491
493
|
<div class="page-head"><h1>Runs</h1><span class="sub" id="runs-sub"></span></div>
|
|
492
494
|
<div class="rtools">
|
|
493
495
|
<input type="search" id="rq" placeholder="Search payloads, prompts, errors…">
|
|
496
|
+
<select id="rgraph" style="display:none"></select>
|
|
494
497
|
<select id="rstatus"><option value="">All statuses</option>
|
|
495
498
|
<option value="done">done</option><option value="error">error</option>
|
|
496
499
|
<option value="interrupted">interrupted</option>
|
|
@@ -523,7 +526,7 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
|
|
|
523
526
|
</div>
|
|
524
527
|
<div id="view-stats" style="display:none">
|
|
525
528
|
<div class="page">
|
|
526
|
-
<div class="page-head"><h1>Stats</h1><span class="sub">all recorded runs</span></div>
|
|
529
|
+
<div class="page-head"><h1>Stats</h1><span class="sub" id="stats-sub">all recorded runs</span></div>
|
|
527
530
|
<div id="stats-body"></div>
|
|
528
531
|
</div>
|
|
529
532
|
</div>
|
|
@@ -628,10 +631,39 @@ function cyStyle(){ return [
|
|
|
628
631
|
function applyCyStyle(){ cy.style(cyStyle()); }
|
|
629
632
|
|
|
630
633
|
/* ---------- graph ---------- */
|
|
634
|
+
let GSEL=localStorage.getItem('wh-graph')||'';
|
|
635
|
+
async function loadGraphList(){
|
|
636
|
+
try{
|
|
637
|
+
const d=await(await fetch('/api/graphs')).json();
|
|
638
|
+
window.__graphList=d.graphs||[];
|
|
639
|
+
if(!window.__graphList.includes(GSEL)) GSEL=d.default||'';
|
|
640
|
+
const sel=$('#gsel');
|
|
641
|
+
if(window.__graphList.length>1){
|
|
642
|
+
sel.style.display=''; $('#gname').style.display='none';
|
|
643
|
+
sel.innerHTML=window.__graphList.map(g=>`<option ${g===GSEL?'selected':''}>${esc(g)}</option>`).join('');
|
|
644
|
+
sel.onchange=()=>{ GSEL=sel.value; localStorage.setItem('wh-graph',GSEL);
|
|
645
|
+
RF.graph=GSEL; const rg=$('#rgraph'); if(rg) rg.value=GSEL;
|
|
646
|
+
loadGraph(true); probeMemory();
|
|
647
|
+
if(VIEW==='memory') loadMemory();
|
|
648
|
+
if(VIEW==='runs') loadRuns();
|
|
649
|
+
if(VIEW==='sessions') loadSessions();
|
|
650
|
+
if(VIEW==='stats'){ loadStats(); loadDatasets(); } };
|
|
651
|
+
const rg=$('#rgraph');
|
|
652
|
+
if(rg){ rg.style.display='';
|
|
653
|
+
rg.innerHTML='<option value="">All graphs</option>'+window.__graphList.map(g=>`<option>${esc(g)}</option>`).join('');
|
|
654
|
+
RF.graph=GSEL; rg.value=GSEL;
|
|
655
|
+
rg.onchange=e=>{RF.graph=e.target.value;RF.offset=0;loadRuns();}; }
|
|
656
|
+
}
|
|
657
|
+
}catch(e){}
|
|
658
|
+
}
|
|
659
|
+
function multiGraph(){ return (window.__graphList||[]).length>1; }
|
|
660
|
+
function gscope(){ return multiGraph()&&GSEL?'graph='+encodeURIComponent(GSEL):''; }
|
|
661
|
+
function gparam(pfx){ return GSEL?pfx+'graph='+encodeURIComponent(GSEL):''; }
|
|
662
|
+
function rgparam(){ const g=(window.__run||{}).graph||GSEL; return g?'graph='+encodeURIComponent(g):''; }
|
|
631
663
|
let XRAY=false;
|
|
632
664
|
window.toggleXray=()=>{ XRAY=!XRAY; const b=$('#xray'); if(b)b.classList.toggle('on',XRAY); loadGraph(true); };
|
|
633
665
|
async function loadGraph(force){
|
|
634
|
-
const t=await(await fetch('/api/graph')).json();
|
|
666
|
+
const t=await(await fetch('/api/graph?'+gparam(''))).json();
|
|
635
667
|
$('#gname').textContent=t.graph||'ingest-only';
|
|
636
668
|
const xb=$('#xray'); if(xb) xb.style.display=t.xray?'':'none';
|
|
637
669
|
if(!t.nodes.length){ $('#view-graph').innerHTML='<div class="empty" style="padding-top:120px"><div class="t">No local graph configured</div>Set <span class="mono">WINDHOVER_GRAPH</span>, or trace runs in from your app and open Runs.</div>'; return; }
|
|
@@ -696,7 +728,7 @@ function codeBlock(src,hi){ const lines=src.code.replace(/\n$/,'').split('\n');
|
|
|
696
728
|
/* ---------- node pane (tap a node on the graph) ---------- */
|
|
697
729
|
async function openNode(name){
|
|
698
730
|
let d={}; try{ d=await(await fetch('/api/nodes/'+encodeURIComponent(name))).json(); }catch(e){}
|
|
699
|
-
let src=null; try{ const rs=await fetch('/api/nodes/'+encodeURIComponent(name)+'/source');
|
|
731
|
+
let src=null; try{ const rs=await fetch('/api/nodes/'+encodeURIComponent(name)+'/source?'+gparam(''));
|
|
700
732
|
if(rs.ok) src=await rs.json(); }catch(e){}
|
|
701
733
|
const s=d.summary||{}, errs=s.errors||0;
|
|
702
734
|
$('#d-title').textContent=name;
|
|
@@ -771,7 +803,7 @@ function switchView(v){ VIEW=v;
|
|
|
771
803
|
$$('.nav button,.bottom-nav button').forEach(b=>b.onclick=()=>switchView(b.dataset.v));
|
|
772
804
|
|
|
773
805
|
/* ---------- new run ---------- */
|
|
774
|
-
$('#newrun').onclick=async()=>{ let tpl={},ctx={},cs={}; try{const sd=await(await fetch('/api/schema')).json(); tpl=sd.template||{}; ctx=sd.context_template||{}; cs=sd.context_schema||{};}catch(e){}
|
|
806
|
+
$('#newrun').onclick=async()=>{ let tpl={},ctx={},cs={}; try{const sd=await(await fetch('/api/schema?'+gparam(''))).json(); tpl=sd.template||{}; ctx=sd.context_template||{}; cs=sd.context_schema||{};}catch(e){}
|
|
775
807
|
$('#input-json').value=JSON.stringify(tpl,null,2);
|
|
776
808
|
const cw=document.getElementById('ctx-wrap');
|
|
777
809
|
if(cw){ const has=cs&&cs.properties&&Object.keys(cs.properties).length;
|
|
@@ -785,6 +817,7 @@ $('#do-run').onclick=async()=>{ let input; try{input=JSON.parse($('#input-json')
|
|
|
785
817
|
const sess=$('#run-session').value.trim();
|
|
786
818
|
const tags=$('#run-tags').value.split(',').map(s=>s.trim()).filter(Boolean);
|
|
787
819
|
if(sess)input._session=sess; if(tags.length)input._tags=tags;
|
|
820
|
+
if(GSEL) input._graph=GSEL;
|
|
788
821
|
const paused=[...document.querySelectorAll('.pause-chip.on')].map(e=>e.dataset.n);
|
|
789
822
|
if(paused.length) input._interrupt_before=paused;
|
|
790
823
|
const cw=document.getElementById('ctx-wrap');
|
|
@@ -813,11 +846,11 @@ async function runNow(input){
|
|
|
813
846
|
}
|
|
814
847
|
|
|
815
848
|
/* ---------- runs table (filters + pagination) ---------- */
|
|
816
|
-
const RF={q:'',status:'',tag:'',bookmarked:false,session:'',offset:0,limit:50};
|
|
849
|
+
const RF={q:'',status:'',tag:'',bookmarked:false,session:'',graph:'',offset:0,limit:50};
|
|
817
850
|
function runParams(){ const p=new URLSearchParams();
|
|
818
851
|
if(RF.q)p.set('q',RF.q); if(RF.status)p.set('status',RF.status);
|
|
819
852
|
if(RF.tag)p.set('tag',RF.tag); if(RF.bookmarked)p.set('bookmarked','1');
|
|
820
|
-
if(RF.session)p.set('session',RF.session); return p; }
|
|
853
|
+
if(RF.session)p.set('session',RF.session); if(RF.graph)p.set('graph',RF.graph); return p; }
|
|
821
854
|
async function loadRuns(){
|
|
822
855
|
const p=runParams(); p.set('limit',RF.limit); p.set('offset',RF.offset);
|
|
823
856
|
const d=await(await fetch('/api/runs?'+p)).json();
|
|
@@ -864,7 +897,7 @@ $('#rbook').onclick=()=>{RF.bookmarked=!RF.bookmarked;$('#rbook').classList.togg
|
|
|
864
897
|
|
|
865
898
|
/* ---------- sessions ---------- */
|
|
866
899
|
async function loadSessions(){
|
|
867
|
-
const ss=await(await fetch('/api/sessions')).json();
|
|
900
|
+
const ss=await(await fetch('/api/sessions?'+gscope())).json();
|
|
868
901
|
$('#sess-sub').textContent=ss.length?`${ss.length} session${ss.length===1?'':'s'}`:'';
|
|
869
902
|
if(!ss.length){ $('#sess-wrap').innerHTML='<div class="empty"><div class="t">No sessions yet</div>Pass <span class="mono">session=</span> to <span class="mono">WindhoverTracer</span>, or <span class="mono">config={"metadata": {"windhover_session": …}}</span> in any LangChain/LangGraph app.</div>'; return; }
|
|
870
903
|
$('#sess-wrap').innerHTML=`<div style="overflow:auto"><table>
|
|
@@ -1038,7 +1071,7 @@ window.resumeThread=async(tid)=>{
|
|
|
1038
1071
|
const raw=document.getElementById('hitl-val').value.trim();
|
|
1039
1072
|
const body={}; const v=parseMaybeJSON(raw); if(v!==undefined) body.value=v;
|
|
1040
1073
|
closeAll(); switchView('graph'); toast('Resuming thread '+tid.slice(0,8)+'…');
|
|
1041
|
-
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
1074
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume?'+rgparam(),
|
|
1042
1075
|
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
1043
1076
|
if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
|
|
1044
1077
|
await consumeRunStream(res);
|
|
@@ -1046,14 +1079,14 @@ window.resumeThread=async(tid)=>{
|
|
|
1046
1079
|
window.resumeGoto=async(tid)=>{
|
|
1047
1080
|
const g=document.getElementById('hitl-goto').value; if(!g) return toast('Pick a node first');
|
|
1048
1081
|
closeAll(); switchView('graph'); toast('Redirecting thread to '+g+'…');
|
|
1049
|
-
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
1082
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume?'+rgparam(),
|
|
1050
1083
|
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({goto:g})});
|
|
1051
1084
|
if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
|
|
1052
1085
|
await consumeRunStream(res);
|
|
1053
1086
|
};
|
|
1054
1087
|
window.rerunFrom=async(tid,cid)=>{
|
|
1055
1088
|
closeAll(); switchView('graph'); toast('Forking from checkpoint '+String(cid).slice(-8)+'…');
|
|
1056
|
-
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
1089
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume?'+rgparam(),
|
|
1057
1090
|
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({checkpoint_id:cid})});
|
|
1058
1091
|
if(!res.ok){ toast((await res.json()).error||'fork failed'); return; }
|
|
1059
1092
|
await consumeRunStream(res);
|
|
@@ -1063,7 +1096,7 @@ window.editState=async(tid,cid,valuesJson)=>{
|
|
|
1063
1096
|
if(cur==null) return;
|
|
1064
1097
|
let values; try{ values=JSON.parse(cur); }catch(e){ return toast('Invalid JSON'); }
|
|
1065
1098
|
const body={values}; if(cid) body.checkpoint_id=cid;
|
|
1066
|
-
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/state',
|
|
1099
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/state?'+rgparam(),
|
|
1067
1100
|
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
1068
1101
|
const d=await res.json();
|
|
1069
1102
|
if(!res.ok) return toast(d.error||'update_state failed');
|
|
@@ -1073,7 +1106,7 @@ window.editState=async(tid,cid,valuesJson)=>{
|
|
|
1073
1106
|
|
|
1074
1107
|
/* ---------- time-travel (checkpoint history) ---------- */
|
|
1075
1108
|
window.showHistory=async(tid)=>{
|
|
1076
|
-
let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history');
|
|
1109
|
+
let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history?'+rgparam());
|
|
1077
1110
|
d=await r.json(); if(!r.ok) return toast(d.error||'history unavailable'); }catch(e){ return toast('history unavailable'); }
|
|
1078
1111
|
const steps=d.steps||[];
|
|
1079
1112
|
$('#d-title').textContent='thread '+tid.slice(0,10)+' · time-travel';
|
|
@@ -1153,11 +1186,13 @@ function chartCard(title,daily,val,errKey){
|
|
|
1153
1186
|
<div class="clabels"><span>${daily[0]?.day||''}</span><span>${daily[daily.length-1]?.day||''}</span></div></div></div>`;
|
|
1154
1187
|
}
|
|
1155
1188
|
async function loadStats(){
|
|
1156
|
-
const s=await(await fetch('/api/stats?days=30')).json(); const t=s.totals||{};
|
|
1189
|
+
const s=await(await fetch('/api/stats?days=30&'+gscope())).json(); const t=s.totals||{};
|
|
1157
1190
|
const errRate=t.runs?Math.round((t.errors||0)/t.runs*100):0;
|
|
1158
1191
|
const max=Math.max(1,...(s.per_node||[]).map(n=>n.avg_ms||0));
|
|
1159
1192
|
const daily=s.daily||[], models=s.models||[];
|
|
1160
1193
|
const db=t.db_bytes!=null?(t.db_bytes/1048576).toFixed(1)+' MB db':'';
|
|
1194
|
+
const ssub=document.getElementById('stats-sub');
|
|
1195
|
+
if(ssub) ssub.textContent=(multiGraph()&&GSEL)?('graph: '+GSEL):'all recorded runs';
|
|
1161
1196
|
$('#stats-body').innerHTML=`
|
|
1162
1197
|
<div class="metrics">
|
|
1163
1198
|
<div class="metric"><div class="v num">${t.runs||0}</div><div class="l">Total runs</div></div>
|
|
@@ -1199,7 +1234,7 @@ let tT; function toast(m){ const t=$('#toast'); t.textContent=m; t.classList.add
|
|
|
1199
1234
|
|
|
1200
1235
|
let MEM_NS=[];
|
|
1201
1236
|
async function probeMemory(){
|
|
1202
|
-
try{ const r=await fetch('/api/memory/namespaces'); if(!r.ok) return;
|
|
1237
|
+
try{ const r=await fetch('/api/memory/namespaces?'+gparam('')); if(!r.ok){ document.getElementById('nav-mem').style.display='none'; return; }
|
|
1203
1238
|
MEM_NS=(await r.json()).namespaces||[];
|
|
1204
1239
|
document.getElementById('nav-mem').style.display='';
|
|
1205
1240
|
const m=document.querySelector('.bottom-nav .nav-mem'); if(m)m.style.display='';
|
|
@@ -1207,7 +1242,7 @@ async function probeMemory(){
|
|
|
1207
1242
|
}
|
|
1208
1243
|
async function loadMemory(){
|
|
1209
1244
|
const sel=document.getElementById('mem-ns');
|
|
1210
|
-
try{ const r=await fetch('/api/memory/namespaces'); MEM_NS=(await r.json()).namespaces||[]; }catch(e){}
|
|
1245
|
+
try{ const r=await fetch('/api/memory/namespaces?'+gparam('')); MEM_NS=(await r.json()).namespaces||[]; }catch(e){}
|
|
1211
1246
|
const cur=sel.value;
|
|
1212
1247
|
sel.innerHTML=MEM_NS.map(ns=>`<option value="${esc(ns.join('.'))}">${esc(ns.join(' / '))}</option>`).join('');
|
|
1213
1248
|
if(cur) sel.value=cur;
|
|
@@ -1215,7 +1250,7 @@ async function loadMemory(){
|
|
|
1215
1250
|
const w=document.getElementById('mem-wrap');
|
|
1216
1251
|
if(!ns){ w.innerHTML='<div class="empty"><div class="t">No memories yet</div>Nodes write with <span class="mono">get_store().put(namespace, key, value)</span> — items appear here.</div>'; return; }
|
|
1217
1252
|
const q=document.getElementById('mem-q').value.trim();
|
|
1218
|
-
const p=new URLSearchParams({namespace:ns}); if(q)p.set('query',q);
|
|
1253
|
+
const p=new URLSearchParams({namespace:ns}); if(q)p.set('query',q); if(GSEL)p.set('graph',GSEL);
|
|
1219
1254
|
let d={items:[]}; try{ d=await(await fetch('/api/memory/items?'+p)).json(); }catch(e){}
|
|
1220
1255
|
w.innerHTML=(d.items||[]).length?`<div style="overflow:auto"><table>
|
|
1221
1256
|
<thead><tr><th>Key</th><th>Value</th><th class="r">Updated</th></tr></thead>
|
|
@@ -1240,13 +1275,13 @@ async function loadDatasets(){
|
|
|
1240
1275
|
:`<div class="empty" style="padding:14px"><div class="t">No datasets yet</div>POST <span class="mono">/api/datasets</span> with <span class="mono">{name, items:[{input:{…}, expected:…}]}</span> — runs land in a session with an <span class="mono">expected_match</span> score per item.</div>`;
|
|
1241
1276
|
}
|
|
1242
1277
|
window.runDataset=async(id,name)=>{
|
|
1243
|
-
const r=await fetch('/api/datasets/'+id+'/run',{method:'POST'});
|
|
1278
|
+
const r=await fetch('/api/datasets/'+id+'/run?'+gparam(''),{method:'POST'});
|
|
1244
1279
|
const d=await r.json();
|
|
1245
1280
|
if(!r.ok) return toast(d.error||'eval failed to start');
|
|
1246
1281
|
toast('Eval started: '+d.items+' items → session '+d.session);
|
|
1247
1282
|
};
|
|
1248
1283
|
window.delDataset=async(id)=>{ await fetch('/api/datasets/'+id,{method:'DELETE'}); loadDatasets(); };
|
|
1249
|
-
loadGraph();
|
|
1284
|
+
loadGraphList().then(()=>{ loadGraph(); probeMemory(); }); liveTopology();
|
|
1250
1285
|
/* deep links: #runs #sessions #stats #run=<id> */
|
|
1251
1286
|
(function(){ const h=location.hash.slice(1);
|
|
1252
1287
|
if(h==='runs'||h==='sessions'||h==='stats') switchView(h);
|
|
@@ -310,14 +310,18 @@ class Store:
|
|
|
310
310
|
r["scores"] = sc.get(r["id"]) or None
|
|
311
311
|
return {"runs": rows, "total": total, "limit": limit, "offset": offset}
|
|
312
312
|
|
|
313
|
-
def sessions(self, limit: int = 100) -> list[dict]:
|
|
313
|
+
def sessions(self, limit: int = 100, graph: Optional[str] = None) -> list[dict]:
|
|
314
|
+
cond, args = "", []
|
|
315
|
+
if graph:
|
|
316
|
+
cond = " AND graph=?"; args.append(graph)
|
|
314
317
|
with _lock, self._conn() as c:
|
|
315
|
-
return [dict(r) for r in c.execute("""SELECT session,
|
|
318
|
+
return [dict(r) for r in c.execute(f"""SELECT session,
|
|
316
319
|
COUNT(*) runs, COUNT(*) FILTER (WHERE status='error') errors,
|
|
317
320
|
MIN(started_ms) first_ms, MAX(started_ms) last_ms,
|
|
318
321
|
SUM(total_tokens) tokens, SUM(cost_usd) cost, SUM(duration_ms) duration_ms
|
|
319
|
-
FROM runs WHERE session IS NOT NULL AND session!=''
|
|
320
|
-
GROUP BY session ORDER BY last_ms DESC LIMIT ?""",
|
|
322
|
+
FROM runs WHERE session IS NOT NULL AND session!=''{cond}
|
|
323
|
+
GROUP BY session ORDER BY last_ms DESC LIMIT ?""",
|
|
324
|
+
(*args, limit)).fetchall()]
|
|
321
325
|
|
|
322
326
|
def run_detail(self, run_id: str) -> Optional[dict]:
|
|
323
327
|
with _lock, self._conn() as c:
|
|
@@ -366,27 +370,36 @@ class Store:
|
|
|
366
370
|
s[f] = json.loads(s[f]) if s.get(f) else None
|
|
367
371
|
return {"name": name, "summary": dict(agg), "recent": rows}
|
|
368
372
|
|
|
369
|
-
def stats(self, days: int = 30) -> dict:
|
|
373
|
+
def stats(self, days: int = 30, graph: Optional[str] = None) -> dict:
|
|
370
374
|
cutoff = int((time.time() - days * 86400) * 1000)
|
|
375
|
+
rcond, rargs = ("", [])
|
|
376
|
+
scond, sargs = ("", [])
|
|
377
|
+
if graph:
|
|
378
|
+
rcond = " WHERE graph=?"; rargs = [graph]
|
|
379
|
+
scond = " AND runs.graph=?"; sargs = [graph]
|
|
371
380
|
with _lock, self._conn() as c:
|
|
372
|
-
tot = c.execute("""SELECT COUNT(*) runs,
|
|
381
|
+
tot = c.execute(f"""SELECT COUNT(*) runs,
|
|
373
382
|
COUNT(*) FILTER (WHERE status='error') errors,
|
|
374
383
|
SUM(total_tokens) tokens, SUM(cost_usd) cost,
|
|
375
|
-
SUM(llm_calls) llm_calls FROM runs""").fetchone()
|
|
376
|
-
per_node = c.execute("""SELECT name, COUNT(*) n,
|
|
377
|
-
SUM(cost_usd) cost
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
384
|
+
SUM(llm_calls) llm_calls FROM runs{rcond}""", rargs).fetchone()
|
|
385
|
+
per_node = c.execute(f"""SELECT spans.name, COUNT(*) n,
|
|
386
|
+
AVG(spans.dur_ms) avg_ms, SUM(spans.cost_usd) cost
|
|
387
|
+
FROM spans JOIN runs ON runs.id = spans.run_id
|
|
388
|
+
WHERE spans.type='node'{scond}
|
|
389
|
+
GROUP BY spans.name ORDER BY avg_ms DESC LIMIT 20""", sargs).fetchall()
|
|
390
|
+
models = c.execute(f"""SELECT spans.model, COUNT(*) calls,
|
|
391
|
+
SUM(spans.prompt_tokens) prompt_tokens,
|
|
392
|
+
SUM(spans.completion_tokens) completion_tokens,
|
|
393
|
+
SUM(spans.cost_usd) cost, AVG(spans.dur_ms) avg_ms
|
|
394
|
+
FROM spans JOIN runs ON runs.id = spans.run_id
|
|
395
|
+
WHERE spans.type='llm' AND spans.model IS NOT NULL{scond}
|
|
396
|
+
GROUP BY spans.model ORDER BY calls DESC LIMIT 20""", sargs).fetchall()
|
|
397
|
+
daily = c.execute(f"""SELECT
|
|
385
398
|
strftime('%Y-%m-%d', started_ms/1000, 'unixepoch') day,
|
|
386
399
|
COUNT(*) runs, COUNT(*) FILTER (WHERE status='error') errors,
|
|
387
400
|
SUM(total_tokens) tokens, SUM(cost_usd) cost
|
|
388
|
-
FROM runs WHERE started_ms >= ?
|
|
389
|
-
GROUP BY day ORDER BY day""", (cutoff,)).fetchall()
|
|
401
|
+
FROM runs WHERE started_ms >= ?{' AND graph=?' if graph else ''}
|
|
402
|
+
GROUP BY day ORDER BY day""", (cutoff, *rargs)).fetchall()
|
|
390
403
|
try:
|
|
391
404
|
db_bytes = os.path.getsize(self.path)
|
|
392
405
|
except OSError:
|
|
@@ -395,4 +408,4 @@ class Store:
|
|
|
395
408
|
"per_node": [dict(r) for r in per_node],
|
|
396
409
|
"models": [dict(r) for r in models],
|
|
397
410
|
"daily": [dict(r) for r in daily],
|
|
398
|
-
"days": days}
|
|
411
|
+
"days": days, "graph": graph}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
"""Windhover configuration — one place, read from the environment."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
import os
|
|
4
|
-
from dataclasses import dataclass
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
PKG_DIR = Path(__file__).resolve().parent
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@dataclass(frozen=True)
|
|
11
|
-
class Config:
|
|
12
|
-
graph_ref: str # "module:attr" of a compiled graph ("" = ingest-only)
|
|
13
|
-
graph_dir: str # import path for the graph module
|
|
14
|
-
db_path: str
|
|
15
|
-
host: str
|
|
16
|
-
port: int
|
|
17
|
-
watch: bool # live-topology file watcher
|
|
18
|
-
pricing_path: str
|
|
19
|
-
retention_days: int # 0 = keep runs forever
|
|
20
|
-
token: str # WINDHOVER_TOKEN: require Bearer/query token on /api ("" = open)
|
|
21
|
-
webhook: str # WINDHOVER_WEBHOOK: POST run summaries on error/interrupted ("" = off)
|
|
22
|
-
|
|
23
|
-
@staticmethod
|
|
24
|
-
def _discover() -> tuple[str, str]:
|
|
25
|
-
"""No WINDHOVER_GRAPH? Look for a langgraph.json (LangGraph's standard
|
|
26
|
-
project file) in WINDHOVER_GRAPH_DIR / cwd and use its first graph.
|
|
27
|
-
Returns (graph_ref, graph_dir) or ("", dir)."""
|
|
28
|
-
import json as _json
|
|
29
|
-
base = os.environ.get("WINDHOVER_GRAPH_DIR", os.getcwd())
|
|
30
|
-
cfg_path = os.path.join(base, "langgraph.json")
|
|
31
|
-
try:
|
|
32
|
-
graphs = _json.loads(open(cfg_path).read()).get("graphs") or {}
|
|
33
|
-
for _name, ref in graphs.items():
|
|
34
|
-
path, _, attr = str(ref).partition(":")
|
|
35
|
-
if not attr:
|
|
36
|
-
continue
|
|
37
|
-
if path.endswith(".py"):
|
|
38
|
-
full = os.path.normpath(os.path.join(base, path))
|
|
39
|
-
return (f"{os.path.splitext(os.path.basename(full))[0]}:{attr}",
|
|
40
|
-
os.path.dirname(full))
|
|
41
|
-
return f"{path}:{attr}", base # already module:attr
|
|
42
|
-
except Exception:
|
|
43
|
-
pass
|
|
44
|
-
return "", base
|
|
45
|
-
|
|
46
|
-
@classmethod
|
|
47
|
-
def from_env(cls) -> "Config":
|
|
48
|
-
graph_ref = os.environ.get("WINDHOVER_GRAPH", "")
|
|
49
|
-
graph_dir = os.environ.get("WINDHOVER_GRAPH_DIR", os.getcwd())
|
|
50
|
-
if not graph_ref:
|
|
51
|
-
graph_ref, graph_dir = cls._discover()
|
|
52
|
-
return cls(
|
|
53
|
-
graph_ref=graph_ref,
|
|
54
|
-
graph_dir=graph_dir,
|
|
55
|
-
db_path=os.environ.get("WINDHOVER_DB", str(PKG_DIR.parent / "windhover.db")),
|
|
56
|
-
host=os.environ.get("WINDHOVER_HOST", "0.0.0.0"),
|
|
57
|
-
port=int(os.environ.get("WINDHOVER_PORT", "8090")),
|
|
58
|
-
watch=os.environ.get("WINDHOVER_WATCH", "1") not in ("0", "false", "no"),
|
|
59
|
-
pricing_path=os.environ.get("WINDHOVER_PRICING", str(PKG_DIR / "pricing.json")),
|
|
60
|
-
retention_days=int(os.environ.get("WINDHOVER_RETENTION_DAYS", "0") or 0),
|
|
61
|
-
token=os.environ.get("WINDHOVER_TOKEN", ""),
|
|
62
|
-
webhook=os.environ.get("WINDHOVER_WEBHOOK", ""),
|
|
63
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{windhover-0.13.0 → windhover-0.14.1}/windhover/static/vendor/fonts/fraunces-600-italic.woff2
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|