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.
Files changed (36) hide show
  1. {windhover-0.13.0 → windhover-0.14.1}/PKG-INFO +4 -1
  2. {windhover-0.13.0 → windhover-0.14.1}/README.md +3 -0
  3. {windhover-0.13.0 → windhover-0.14.1}/docs/GUIDE.md +7 -1
  4. {windhover-0.13.0 → windhover-0.14.1}/pyproject.toml +1 -1
  5. {windhover-0.13.0 → windhover-0.14.1}/tests/test_smoke.py +46 -5
  6. windhover-0.14.1/windhover/config.py +89 -0
  7. windhover-0.14.1/windhover/demo_rag.py +55 -0
  8. {windhover-0.13.0 → windhover-0.14.1}/windhover/server.py +112 -65
  9. {windhover-0.13.0 → windhover-0.14.1}/windhover/static/index.html +60 -25
  10. {windhover-0.13.0 → windhover-0.14.1}/windhover/store.py +32 -19
  11. windhover-0.13.0/windhover/config.py +0 -63
  12. {windhover-0.13.0 → windhover-0.14.1}/.github/workflows/ci.yml +0 -0
  13. {windhover-0.13.0 → windhover-0.14.1}/.gitignore +0 -0
  14. {windhover-0.13.0 → windhover-0.14.1}/GUIDE.md +0 -0
  15. {windhover-0.13.0 → windhover-0.14.1}/LICENSE +0 -0
  16. {windhover-0.13.0 → windhover-0.14.1}/SPEC.md +0 -0
  17. {windhover-0.13.0 → windhover-0.14.1}/docs/graph.png +0 -0
  18. {windhover-0.13.0 → windhover-0.14.1}/docs/logo.svg +0 -0
  19. {windhover-0.13.0 → windhover-0.14.1}/docs/runs.png +0 -0
  20. {windhover-0.13.0 → windhover-0.14.1}/docs/social-preview.png +0 -0
  21. {windhover-0.13.0 → windhover-0.14.1}/docs/stats.png +0 -0
  22. {windhover-0.13.0 → windhover-0.14.1}/docs/trace.png +0 -0
  23. {windhover-0.13.0 → windhover-0.14.1}/tests/__init__.py +0 -0
  24. {windhover-0.13.0 → windhover-0.14.1}/windhover/__init__.py +0 -0
  25. {windhover-0.13.0 → windhover-0.14.1}/windhover/demo_graph.py +0 -0
  26. {windhover-0.13.0 → windhover-0.14.1}/windhover/extract.py +0 -0
  27. {windhover-0.13.0 → windhover-0.14.1}/windhover/pricing.json +0 -0
  28. {windhover-0.13.0 → windhover-0.14.1}/windhover/static/icon.svg +0 -0
  29. {windhover-0.13.0 → windhover-0.14.1}/windhover/static/manifest.json +0 -0
  30. {windhover-0.13.0 → windhover-0.14.1}/windhover/static/vendor/cytoscape-dagre.js +0 -0
  31. {windhover-0.13.0 → windhover-0.14.1}/windhover/static/vendor/cytoscape.min.js +0 -0
  32. {windhover-0.13.0 → windhover-0.14.1}/windhover/static/vendor/dagre.min.js +0 -0
  33. {windhover-0.13.0 → windhover-0.14.1}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
  34. {windhover-0.13.0 → windhover-0.14.1}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
  35. {windhover-0.13.0 → windhover-0.14.1}/windhover/static/vendor/fonts/fraunces-600.woff2 +0 -0
  36. {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.13.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 it uses the file's first graph.
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.13.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, importlib
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.graph_ref == "app:graph", cfg.graph_ref
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
- graph = None
26
- if cfg.graph_ref:
25
+ GRAPHS: dict = {} # name -> compiled graph
26
+ if cfg.graphs:
27
27
  sys.path.insert(0, cfg.graph_dir)
28
- _m, _a = cfg.graph_ref.split(":")
29
- graph = getattr(importlib.import_module(_m), _a)
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", cfg.graph_ref, cfg.graph_dir],
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": cfg.graph_ref, "hash": self.hash,
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
- TOPO = Topo()
111
- TOPO.refresh(force=True)
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 cfg.graph_ref:
134
+ while cfg.watch and TOPOS:
116
135
  time.sleep(2)
117
- try:
118
- TOPO.refresh()
119
- except Exception:
120
- pass
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 cfg.graph_ref:
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 cfg.graph_ref,
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 cfg.graph_ref})",
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
- return JSONResponse(TOPO.get())
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
- s = TOPO.schema()
190
- with TOPO.lock:
191
- ctx = TOPO.data.get("context_schema") or {}
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 graph.stream(graph_input, config=config,
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(graph, "checkpointer", None) is not None:
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 = graph.get_state(config)
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=cfg.graph_ref, session=session, tags=tags)
321
+ tracer = SpanBuilder(db_sink(store), run_name=gname, session=session, tags=tags)
286
322
  config = {"callbacks": [tracer]}
287
- if thread or getattr(graph, "checkpointer", None) is not None:
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
- if graph is None or getattr(graph, "checkpointer", None) is None:
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=cfg.graph_ref,
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
- if graph is None or getattr(graph, "checkpointer", None) is None:
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 = graph.update_state({"configurable": configurable}, values,
347
- as_node=body.get("as_node"))
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
- src = TOPO.sources().get(name)
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
- if graph is None or getattr(graph, "checkpointer", None) is None:
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 graph.get_state_history({"configurable": {"thread_id": thread_id}}):
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
- st = getattr(graph, "store", None) if graph is not None else None
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
- st = getattr(graph, "store", None) if graph is not None else None
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
- if graph is None:
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=cfg.graph_ref,
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(graph, "checkpointer", None) is not None:
609
+ if getattr(gr, "checkpointer", None) is not None:
565
610
  config["configurable"] = {"thread_id": tracer.run_id}
566
611
  try:
567
- out = graph.invoke(dict(item["input"]), config=config)
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
- yield _sse("hello", {"version": TOPO.version})
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 TOPO.version != seen:
594
- seen = TOPO.version
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.13</span></div>
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(); liveTopology(); probeMemory();
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 ?""", (limit,)).fetchall()]
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, AVG(dur_ms) avg_ms,
377
- SUM(cost_usd) cost FROM spans WHERE type='node'
378
- GROUP BY name ORDER BY avg_ms DESC LIMIT 20""").fetchall()
379
- models = c.execute("""SELECT model, COUNT(*) calls,
380
- SUM(prompt_tokens) prompt_tokens, SUM(completion_tokens) completion_tokens,
381
- SUM(cost_usd) cost, AVG(dur_ms) avg_ms
382
- FROM spans WHERE type='llm' AND model IS NOT NULL
383
- GROUP BY model ORDER BY calls DESC LIMIT 20""").fetchall()
384
- daily = c.execute("""SELECT
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