windhover 0.12.0__tar.gz → 0.14.0__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.12.0 → windhover-0.14.0}/PKG-INFO +7 -4
  2. {windhover-0.12.0 → windhover-0.14.0}/README.md +6 -3
  3. {windhover-0.12.0 → windhover-0.14.0}/docs/GUIDE.md +42 -2
  4. {windhover-0.12.0 → windhover-0.14.0}/pyproject.toml +1 -1
  5. {windhover-0.12.0 → windhover-0.14.0}/tests/test_smoke.py +41 -5
  6. windhover-0.14.0/windhover/config.py +89 -0
  7. windhover-0.14.0/windhover/demo_rag.py +55 -0
  8. {windhover-0.12.0 → windhover-0.14.0}/windhover/server.py +136 -59
  9. {windhover-0.12.0 → windhover-0.14.0}/windhover/static/index.html +47 -22
  10. {windhover-0.12.0 → windhover-0.14.0}/windhover/store.py +7 -0
  11. {windhover-0.12.0 → windhover-0.14.0}/windhover/tracer.py +37 -8
  12. windhover-0.12.0/windhover/config.py +0 -61
  13. {windhover-0.12.0 → windhover-0.14.0}/.github/workflows/ci.yml +0 -0
  14. {windhover-0.12.0 → windhover-0.14.0}/.gitignore +0 -0
  15. {windhover-0.12.0 → windhover-0.14.0}/GUIDE.md +0 -0
  16. {windhover-0.12.0 → windhover-0.14.0}/LICENSE +0 -0
  17. {windhover-0.12.0 → windhover-0.14.0}/SPEC.md +0 -0
  18. {windhover-0.12.0 → windhover-0.14.0}/docs/graph.png +0 -0
  19. {windhover-0.12.0 → windhover-0.14.0}/docs/logo.svg +0 -0
  20. {windhover-0.12.0 → windhover-0.14.0}/docs/runs.png +0 -0
  21. {windhover-0.12.0 → windhover-0.14.0}/docs/social-preview.png +0 -0
  22. {windhover-0.12.0 → windhover-0.14.0}/docs/stats.png +0 -0
  23. {windhover-0.12.0 → windhover-0.14.0}/docs/trace.png +0 -0
  24. {windhover-0.12.0 → windhover-0.14.0}/tests/__init__.py +0 -0
  25. {windhover-0.12.0 → windhover-0.14.0}/windhover/__init__.py +0 -0
  26. {windhover-0.12.0 → windhover-0.14.0}/windhover/demo_graph.py +0 -0
  27. {windhover-0.12.0 → windhover-0.14.0}/windhover/extract.py +0 -0
  28. {windhover-0.12.0 → windhover-0.14.0}/windhover/pricing.json +0 -0
  29. {windhover-0.12.0 → windhover-0.14.0}/windhover/static/icon.svg +0 -0
  30. {windhover-0.12.0 → windhover-0.14.0}/windhover/static/manifest.json +0 -0
  31. {windhover-0.12.0 → windhover-0.14.0}/windhover/static/vendor/cytoscape-dagre.js +0 -0
  32. {windhover-0.12.0 → windhover-0.14.0}/windhover/static/vendor/cytoscape.min.js +0 -0
  33. {windhover-0.12.0 → windhover-0.14.0}/windhover/static/vendor/dagre.min.js +0 -0
  34. {windhover-0.12.0 → windhover-0.14.0}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
  35. {windhover-0.12.0 → windhover-0.14.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
  36. {windhover-0.12.0 → windhover-0.14.0}/windhover/static/vendor/fonts/fraunces-600.woff2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: windhover
3
- Version: 0.12.0
3
+ Version: 0.14.0
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
@@ -17,7 +17,7 @@ Requires-Dist: langgraph>=0.2; extra == 'dev'
17
17
  Requires-Dist: pytest; extra == 'dev'
18
18
  Description-Content-Type: text/markdown
19
19
 
20
- <p align="center"><img src="docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
20
+ <p align="center"><img src="https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
21
21
  <h1 align="center">Windhover</h1>
22
22
  <p align="center">
23
23
  <a href="https://pypi.org/project/windhover/"><img src="https://img.shields.io/pypi/v/windhover" alt="PyPI"></a>
@@ -40,11 +40,11 @@ your own app. No LangSmith account, no cloud tunnel, no fragile websocket. HTTP
40
40
 
41
41
  | Living graph (parallel fan-out) | Trace drawer — retrievers, LLM calls, cost, state |
42
42
  |---|---|
43
- | ![Graph view](docs/graph.png) | ![Trace drawer](docs/trace.png) |
43
+ | ![Graph view](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/graph.png) | ![Trace drawer](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/trace.png) |
44
44
 
45
45
  | Runs — search, tags, sessions, interrupts | Dashboards — per-day, per-model |
46
46
  |---|---|
47
- | ![Runs table](docs/runs.png) | ![Stats](docs/stats.png) |
47
+ | ![Runs table](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/runs.png) | ![Stats](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/stats.png) |
48
48
 
49
49
  ## Quick start
50
50
  ```bash
@@ -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
@@ -1,4 +1,4 @@
1
- <p align="center"><img src="docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
1
+ <p align="center"><img src="https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
2
2
  <h1 align="center">Windhover</h1>
3
3
  <p align="center">
4
4
  <a href="https://pypi.org/project/windhover/"><img src="https://img.shields.io/pypi/v/windhover" alt="PyPI"></a>
@@ -21,11 +21,11 @@ your own app. No LangSmith account, no cloud tunnel, no fragile websocket. HTTP
21
21
 
22
22
  | Living graph (parallel fan-out) | Trace drawer — retrievers, LLM calls, cost, state |
23
23
  |---|---|
24
- | ![Graph view](docs/graph.png) | ![Trace drawer](docs/trace.png) |
24
+ | ![Graph view](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/graph.png) | ![Trace drawer](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/trace.png) |
25
25
 
26
26
  | Runs — search, tags, sessions, interrupts | Dashboards — per-day, per-model |
27
27
  |---|---|
28
- | ![Runs table](docs/runs.png) | ![Stats](docs/stats.png) |
28
+ | ![Runs table](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/runs.png) | ![Stats](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/stats.png) |
29
29
 
30
30
  ## Quick start
31
31
  ```bash
@@ -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,12 @@ 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 the canvas, New run, node source,
64
+ and Memory; the Runs page gets an extra graph filter; human-in-the-loop actions always follow
65
+ the graph the run belongs to.
61
66
 
62
67
  - The canvas always reflects **current-on-disk** topology (a subprocess re-extracts on
63
68
  file change). Runs, however, execute the graph **imported at startup** — restart the
@@ -187,11 +192,46 @@ for semantic grading, run your own judge and `POST /api/runs/{id}/scores`.
187
192
  shows the tool-call JSON as the LLM output instead of an empty box.
188
193
  - **Auth**: set `WINDHOVER_TOKEN` to require `Authorization: Bearer …` (or `?token=`)
189
194
  on `/api`. The UI prompts once and remembers. The HTML shell itself stays public —
190
- it contains no data.
195
+ it contains no data. External tracers pass it as `WindhoverTracer(url, token="…")`.
196
+ **Set the token before exposing Windhover beyond localhost** — the HITL endpoints can
197
+ resume and edit graph state.
198
+ - **Alerts**: `WINDHOVER_WEBHOOK=https://…` POSTs a compact JSON summary whenever a run
199
+ errors or pauses on an interrupt (`{source, graph, run_id, status, error, text, …}`) —
200
+ point it at a Slack/Discord webhook or your own receiver. Fire-and-forget.
191
201
  - **Retention**: `WINDHOVER_RETENTION_DAYS=30` prunes old runs on startup and every 6h.
192
202
  Default keeps everything.
193
203
  - **Deep links**: `#runs`, `#sessions`, `#stats`, `#run=<id>`.
194
204
 
205
+ ## Running it for real
206
+
207
+ - **The tracer never slows your app.** Events go onto a bounded in-memory queue drained
208
+ by a background thread; if the Windhover host is slow or down, events are dropped —
209
+ your pipeline's latency is unchanged. (Sheds oldest-first at 2,000 queued events.)
210
+ - **Systemd** (adjust paths):
211
+ ```ini
212
+ [Unit]
213
+ Description=Windhover
214
+ After=network.target
215
+
216
+ [Service]
217
+ WorkingDirectory=/opt/myapp
218
+ Environment=WINDHOVER_GRAPH=myapp.graphs:graph
219
+ Environment=WINDHOVER_DB=/var/lib/windhover/windhover.db
220
+ Environment=WINDHOVER_TOKEN=change-me
221
+ Environment=WINDHOVER_RETENTION_DAYS=30
222
+ ExecStart=/opt/myapp/.venv/bin/windhover
223
+ Restart=always
224
+ TimeoutStopSec=10
225
+
226
+ [Install]
227
+ WantedBy=multi-user.target
228
+ ```
229
+ `TimeoutStopSec` matters: open SSE connections otherwise stretch restarts.
230
+ - **Reverse proxy**: plain HTTP + SSE — any proxy works; disable response buffering for
231
+ `/api/events` and `/api/run` (nginx: `proxy_buffering off;`).
232
+ - **Backups**: runs live in one SQLite file (`WINDHOVER_DB`); copy it (plus `-wal`) or
233
+ rely on `/api/export` for tabular run data.
234
+
195
235
  ## Try it all on the demo graph
196
236
 
197
237
  `WINDHOVER_GRAPH=windhover.demo_graph:graph windhover`, then:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "windhover"
7
- version = "0.12.0"
7
+ version = "0.14.0"
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,56 @@ 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_nonblocking_sink_and_webhook_hook():
591
+ # sink to an unreachable host must return instantly and never raise
592
+ from windhover.tracer import http_sink
593
+ sink = http_sink("http://10.255.255.1:9", max_queue=10)
594
+ t0 = time.time()
595
+ for i in range(50):
596
+ sink({"kind": "span", "i": i})
597
+ assert time.time() - t0 < 0.5, "sink blocked the caller"
598
+
599
+ # store hook fires with the closing status (webhook wiring point)
600
+ p = tempfile.mktemp(suffix=".db")
601
+ s = Store(p)
602
+ fired = []
603
+ s.on_run_closed = lambda summary: fired.append(summary)
604
+ s.open_run({"id": "w1", "graph": "g", "started_ms": 1000})
605
+ s.close_run("w1", "error", 1500, error="Boom: it broke")
606
+ assert fired and fired[0]["status"] == "error" and "Boom" in fired[0]["error"]
607
+ print("non-blocking sink + webhook hook OK")
574
608
 
575
609
 
576
610
  if __name__ == "__main__":
@@ -598,4 +632,6 @@ if __name__ == "__main__":
598
632
  test_functional_api_tracing()
599
633
  test_message_serialization()
600
634
  test_langgraph_json_discovery()
635
+ test_env_multi_graph_parsing()
636
+ test_nonblocking_sink_and_webhook_hook()
601
637
  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
 
@@ -140,6 +160,36 @@ if cfg.retention_days > 0:
140
160
  threading.Thread(target=_retention_loop, daemon=True).start()
141
161
 
142
162
 
163
+ def _fire_webhook(summary: dict) -> None:
164
+ """POST a compact alert when a run needs attention. Fire-and-forget."""
165
+ if summary.get("status") not in ("error", "interrupted"):
166
+ return
167
+ def _post():
168
+ try:
169
+ import urllib.request
170
+ run = store.run_detail(summary["id"]) or summary
171
+ body = {
172
+ "source": "windhover", "graph": run.get("graph") or _default_name(),
173
+ "run_id": summary["id"], "status": summary["status"],
174
+ "duration_ms": summary.get("duration_ms"),
175
+ "session": run.get("session"), "thread_id": run.get("thread_id"),
176
+ "error": (str(summary.get("error") or "").strip().splitlines() or [None])[-1],
177
+ "text": f"[windhover] run {summary['id']} {summary['status']}"
178
+ f" ({run.get('graph') or _default_name()})",
179
+ }
180
+ req = urllib.request.Request(
181
+ cfg.webhook, data=json.dumps(body).encode(),
182
+ headers={"Content-Type": "application/json"})
183
+ urllib.request.urlopen(req, timeout=5)
184
+ except Exception:
185
+ pass
186
+ threading.Thread(target=_post, daemon=True).start()
187
+
188
+
189
+ if cfg.webhook:
190
+ store.on_run_closed = _fire_webhook
191
+
192
+
143
193
  def _template(schema: dict) -> dict:
144
194
  out = {}
145
195
  for k, p in (schema.get("properties") or {}).items():
@@ -149,16 +199,29 @@ def _template(schema: dict) -> dict:
149
199
 
150
200
 
151
201
  # ---- endpoints ------------------------------------------------------------
202
+ @app.get("/api/graphs")
203
+ def api_graphs():
204
+ return JSONResponse({"graphs": list(GRAPHS.keys()), "default": _default_name()})
205
+
206
+
152
207
  @app.get("/api/graph")
153
- def api_graph():
154
- 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())
155
214
 
156
215
 
157
216
  @app.get("/api/schema")
158
- def api_schema():
159
- s = TOPO.schema()
160
- with TOPO.lock:
161
- 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 {}
162
225
  return JSONResponse({"schema": s, "template": _template(s),
163
226
  "context_schema": ctx, "context_template": _template(ctx)})
164
227
 
@@ -167,7 +230,7 @@ def _sse(ev: str, data: dict) -> str:
167
230
  return f"event: {ev}\ndata: {json.dumps(data)}\n\n"
168
231
 
169
232
 
170
- def _stream_execution(graph_input, config, tracer, stream_kwargs=None):
233
+ def _stream_execution(gr, graph_input, config, tracer, stream_kwargs=None):
171
234
  """Shared SSE executor for /api/run and /api/threads/…/resume. Detects both
172
235
  dynamic interrupts (__interrupt__ updates) and static breakpoints (stream
173
236
  ends with pending next-nodes) and corrects the run status accordingly."""
@@ -178,7 +241,7 @@ def _stream_execution(graph_input, config, tracer, stream_kwargs=None):
178
241
  def worker():
179
242
  try:
180
243
  interrupted = False
181
- for mode, chunk in graph.stream(graph_input, config=config,
244
+ for mode, chunk in gr.stream(graph_input, config=config,
182
245
  stream_mode=["updates", "custom"],
183
246
  **(stream_kwargs or {})):
184
247
  if mode == "custom":
@@ -208,9 +271,9 @@ def _stream_execution(graph_input, config, tracer, stream_kwargs=None):
208
271
  "params": {"cached": True}})
209
272
  seq_extra[0] += 1
210
273
  q.put(("node", {"node": node, "cached": cached or None}))
211
- if not interrupted and getattr(graph, "checkpointer", None) is not None:
274
+ if not interrupted and getattr(gr, "checkpointer", None) is not None:
212
275
  try: # static breakpoint: stream ended but nodes are pending
213
- st = graph.get_state(config)
276
+ st = gr.get_state(config)
214
277
  if st.next:
215
278
  interrupted = True
216
279
  q.put(("interrupt", {"run_id": run_id,
@@ -240,21 +303,24 @@ def _stream_execution(graph_input, config, tracer, stream_kwargs=None):
240
303
 
241
304
  @app.post("/api/run")
242
305
  async def api_run(request: Request):
243
- if graph is None:
244
- return JSONResponse({"error": "no local graph (WINDHOVER_GRAPH unset)"}, 400)
245
306
  try:
246
307
  payload = await request.json()
247
308
  except Exception:
248
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)
249
315
  session = payload.pop("_session", None)
250
316
  tags = payload.pop("_tags", None)
251
317
  thread = payload.pop("_thread", None)
252
318
  pause_before = payload.pop("_interrupt_before", None)
253
319
  pause_after = payload.pop("_interrupt_after", None)
254
320
  extra_conf = payload.pop("_configurable", None)
255
- 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)
256
322
  config = {"callbacks": [tracer]}
257
- if thread or getattr(graph, "checkpointer", None) is not None:
323
+ if thread or getattr(gr, "checkpointer", None) is not None:
258
324
  # a checkpointed graph needs a thread; default to the run id so
259
325
  # time-travel works out of the box
260
326
  config["configurable"] = {"thread_id": thread or tracer.run_id}
@@ -265,15 +331,16 @@ async def api_run(request: Request):
265
331
  sk["interrupt_before"] = [str(n) for n in pause_before]
266
332
  if pause_after:
267
333
  sk["interrupt_after"] = [str(n) for n in pause_after]
268
- return _stream_execution(payload, config, tracer, sk)
334
+ return _stream_execution(gr, payload, config, tracer, sk)
269
335
 
270
336
 
271
337
  @app.post("/api/threads/{thread_id}/resume")
272
- async def api_thread_resume(request: Request, thread_id: str):
338
+ async def api_thread_resume(request: Request, thread_id: str, graph: str = None):
273
339
  """Human-in-the-loop: answer an interrupt (Command(resume=…)), redirect
274
340
  (Command(goto=…)), continue past a static breakpoint (no body), or fork
275
341
  from an earlier checkpoint (checkpoint_id)."""
276
- 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:
277
344
  return JSONResponse({"error": "no local graph with a checkpointer"}, 400)
278
345
  try:
279
346
  body = await request.json()
@@ -289,21 +356,22 @@ async def api_thread_resume(request: Request, thread_id: str):
289
356
  graph_input = Command(goto=str(body["goto"]), update=body.get("update"))
290
357
  else:
291
358
  graph_input = None # plain continue (static breakpoint / after state edit)
292
- tracer = SpanBuilder(db_sink(store), run_name=cfg.graph_ref,
359
+ tracer = SpanBuilder(db_sink(store), run_name=graph or _default_name(),
293
360
  session=body.get("_session"),
294
361
  tags=(body.get("_tags") or []) + ["resume"])
295
362
  configurable = {"thread_id": thread_id}
296
363
  if body.get("checkpoint_id"):
297
364
  configurable["checkpoint_id"] = str(body["checkpoint_id"])
298
365
  config = {"callbacks": [tracer], "configurable": configurable}
299
- return _stream_execution(graph_input, config, tracer)
366
+ return _stream_execution(gr, graph_input, config, tracer)
300
367
 
301
368
 
302
369
  @app.post("/api/threads/{thread_id}/state")
303
- 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):
304
371
  """Human-in-the-loop: edit state at the current (or a given) checkpoint —
305
372
  LangGraph's update_state. Follow with …/resume to continue on the edit."""
306
- 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:
307
375
  return JSONResponse({"error": "no local graph with a checkpointer"}, 400)
308
376
  body = await request.json()
309
377
  values = body.get("values")
@@ -313,8 +381,8 @@ async def api_thread_update_state(request: Request, thread_id: str):
313
381
  if body.get("checkpoint_id"):
314
382
  configurable["checkpoint_id"] = str(body["checkpoint_id"])
315
383
  try:
316
- new_cfg = graph.update_state({"configurable": configurable}, values,
317
- as_node=body.get("as_node"))
384
+ new_cfg = gr.update_state({"configurable": configurable}, values,
385
+ as_node=body.get("as_node"))
318
386
  return JSONResponse({"ok": True, "checkpoint_id":
319
387
  (new_cfg.get("configurable") or {}).get("checkpoint_id")})
320
388
  except Exception as e:
@@ -415,8 +483,9 @@ def api_node(name: str, limit: int = 25):
415
483
 
416
484
 
417
485
  @app.get("/api/nodes/{name}/source")
418
- def api_node_source(name: str):
419
- 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)
420
489
  if src:
421
490
  return JSONResponse(src)
422
491
  return JSONResponse({"error": "no source available for this node "
@@ -424,14 +493,15 @@ def api_node_source(name: str):
424
493
 
425
494
 
426
495
  @app.get("/api/threads/{thread_id}/history")
427
- def api_thread_history(thread_id: str, limit: int = 80):
496
+ def api_thread_history(thread_id: str, limit: int = 80, graph: str = None):
428
497
  """Time-travel: LangGraph checkpoint history for a thread (local graph
429
498
  with a checkpointer only)."""
430
- 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:
431
501
  return JSONResponse({"error": "no local graph with a checkpointer"}, 404)
432
502
  steps = []
433
503
  try:
434
- 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}}):
435
505
  md = st.metadata or {}
436
506
  steps.append({
437
507
  "checkpoint_id": ((st.config or {}).get("configurable") or {}).get("checkpoint_id"),
@@ -450,9 +520,10 @@ def api_thread_history(thread_id: str, limit: int = 80):
450
520
 
451
521
 
452
522
  @app.get("/api/memory/namespaces")
453
- def api_memory_namespaces():
523
+ def api_memory_namespaces(graph: str = None):
454
524
  """LangGraph long-term memory (Store) browser — namespaces."""
455
- 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
456
527
  if st is None:
457
528
  return JSONResponse({"error": "no local graph with a store"}, 404)
458
529
  try:
@@ -462,9 +533,11 @@ def api_memory_namespaces():
462
533
 
463
534
 
464
535
  @app.get("/api/memory/items")
465
- 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):
466
538
  """Items in one namespace (dot-separated); optional semantic/text query."""
467
- 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
468
541
  if st is None:
469
542
  return JSONResponse({"error": "no local graph with a store"}, 404)
470
543
  try:
@@ -516,10 +589,12 @@ def _expected_score(expected, output) -> float:
516
589
 
517
590
 
518
591
  @app.post("/api/datasets/{ds_id}/run")
519
- def api_dataset_run(ds_id: str):
592
+ def api_dataset_run(ds_id: str, graph: str = None):
520
593
  """Batch-eval: run the local graph over every dataset item; expected values
521
594
  become an expected_match score on each run. Fire-and-forget worker."""
522
- if graph is None:
595
+ gname = graph or _default_name()
596
+ gr = _graph_for(gname)
597
+ if gr is None:
523
598
  return JSONResponse({"error": "no local graph (WINDHOVER_GRAPH unset)"}, 400)
524
599
  ds = store.dataset(ds_id)
525
600
  if not ds:
@@ -528,13 +603,13 @@ def api_dataset_run(ds_id: str):
528
603
 
529
604
  def worker():
530
605
  for i, item in enumerate(ds["items"]):
531
- tracer = SpanBuilder(db_sink(store), run_name=cfg.graph_ref,
606
+ tracer = SpanBuilder(db_sink(store), run_name=gname,
532
607
  session=session, tags=[f"dataset:{ds['name']}"])
533
608
  config = {"callbacks": [tracer]}
534
- if getattr(graph, "checkpointer", None) is not None:
609
+ if getattr(gr, "checkpointer", None) is not None:
535
610
  config["configurable"] = {"thread_id": tracer.run_id}
536
611
  try:
537
- out = graph.invoke(dict(item["input"]), config=config)
612
+ out = gr.invoke(dict(item["input"]), config=config)
538
613
  except Exception:
539
614
  continue # tracer already recorded the error run
540
615
  if "expected" in item:
@@ -556,12 +631,14 @@ async def api_events(request: Request):
556
631
  async def gen():
557
632
  import asyncio
558
633
  seen = -1
559
- 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()})
560
637
  while True:
561
638
  if await request.is_disconnected():
562
639
  break
563
- if TOPO.version != seen:
564
- seen = TOPO.version
640
+ if _ver() != seen:
641
+ seen = _ver()
565
642
  yield _sse("topology", {"version": seen})
566
643
  else:
567
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.12</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>
@@ -628,10 +631,31 @@ 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
+ loadGraph(true); probeMemory(); if(VIEW==='memory') loadMemory(); };
646
+ const rg=$('#rgraph');
647
+ if(rg){ rg.style.display='';
648
+ rg.innerHTML='<option value="">All graphs</option>'+window.__graphList.map(g=>`<option>${esc(g)}</option>`).join('');
649
+ rg.onchange=e=>{RF.graph=e.target.value;RF.offset=0;loadRuns();}; }
650
+ }
651
+ }catch(e){}
652
+ }
653
+ function gparam(pfx){ return GSEL?pfx+'graph='+encodeURIComponent(GSEL):''; }
654
+ function rgparam(){ const g=(window.__run||{}).graph||GSEL; return g?'graph='+encodeURIComponent(g):''; }
631
655
  let XRAY=false;
632
656
  window.toggleXray=()=>{ XRAY=!XRAY; const b=$('#xray'); if(b)b.classList.toggle('on',XRAY); loadGraph(true); };
633
657
  async function loadGraph(force){
634
- const t=await(await fetch('/api/graph')).json();
658
+ const t=await(await fetch('/api/graph?'+gparam(''))).json();
635
659
  $('#gname').textContent=t.graph||'ingest-only';
636
660
  const xb=$('#xray'); if(xb) xb.style.display=t.xray?'':'none';
637
661
  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 +720,7 @@ function codeBlock(src,hi){ const lines=src.code.replace(/\n$/,'').split('\n');
696
720
  /* ---------- node pane (tap a node on the graph) ---------- */
697
721
  async function openNode(name){
698
722
  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');
723
+ let src=null; try{ const rs=await fetch('/api/nodes/'+encodeURIComponent(name)+'/source?'+gparam(''));
700
724
  if(rs.ok) src=await rs.json(); }catch(e){}
701
725
  const s=d.summary||{}, errs=s.errors||0;
702
726
  $('#d-title').textContent=name;
@@ -771,7 +795,7 @@ function switchView(v){ VIEW=v;
771
795
  $$('.nav button,.bottom-nav button').forEach(b=>b.onclick=()=>switchView(b.dataset.v));
772
796
 
773
797
  /* ---------- 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){}
798
+ $('#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
799
  $('#input-json').value=JSON.stringify(tpl,null,2);
776
800
  const cw=document.getElementById('ctx-wrap');
777
801
  if(cw){ const has=cs&&cs.properties&&Object.keys(cs.properties).length;
@@ -785,6 +809,7 @@ $('#do-run').onclick=async()=>{ let input; try{input=JSON.parse($('#input-json')
785
809
  const sess=$('#run-session').value.trim();
786
810
  const tags=$('#run-tags').value.split(',').map(s=>s.trim()).filter(Boolean);
787
811
  if(sess)input._session=sess; if(tags.length)input._tags=tags;
812
+ if(GSEL) input._graph=GSEL;
788
813
  const paused=[...document.querySelectorAll('.pause-chip.on')].map(e=>e.dataset.n);
789
814
  if(paused.length) input._interrupt_before=paused;
790
815
  const cw=document.getElementById('ctx-wrap');
@@ -813,11 +838,11 @@ async function runNow(input){
813
838
  }
814
839
 
815
840
  /* ---------- runs table (filters + pagination) ---------- */
816
- const RF={q:'',status:'',tag:'',bookmarked:false,session:'',offset:0,limit:50};
841
+ const RF={q:'',status:'',tag:'',bookmarked:false,session:'',graph:'',offset:0,limit:50};
817
842
  function runParams(){ const p=new URLSearchParams();
818
843
  if(RF.q)p.set('q',RF.q); if(RF.status)p.set('status',RF.status);
819
844
  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; }
845
+ if(RF.session)p.set('session',RF.session); if(RF.graph)p.set('graph',RF.graph); return p; }
821
846
  async function loadRuns(){
822
847
  const p=runParams(); p.set('limit',RF.limit); p.set('offset',RF.offset);
823
848
  const d=await(await fetch('/api/runs?'+p)).json();
@@ -1038,7 +1063,7 @@ window.resumeThread=async(tid)=>{
1038
1063
  const raw=document.getElementById('hitl-val').value.trim();
1039
1064
  const body={}; const v=parseMaybeJSON(raw); if(v!==undefined) body.value=v;
1040
1065
  closeAll(); switchView('graph'); toast('Resuming thread '+tid.slice(0,8)+'…');
1041
- const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
1066
+ const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume?'+rgparam(),
1042
1067
  {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
1043
1068
  if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
1044
1069
  await consumeRunStream(res);
@@ -1046,14 +1071,14 @@ window.resumeThread=async(tid)=>{
1046
1071
  window.resumeGoto=async(tid)=>{
1047
1072
  const g=document.getElementById('hitl-goto').value; if(!g) return toast('Pick a node first');
1048
1073
  closeAll(); switchView('graph'); toast('Redirecting thread to '+g+'…');
1049
- const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
1074
+ const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume?'+rgparam(),
1050
1075
  {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({goto:g})});
1051
1076
  if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
1052
1077
  await consumeRunStream(res);
1053
1078
  };
1054
1079
  window.rerunFrom=async(tid,cid)=>{
1055
1080
  closeAll(); switchView('graph'); toast('Forking from checkpoint '+String(cid).slice(-8)+'…');
1056
- const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
1081
+ const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume?'+rgparam(),
1057
1082
  {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({checkpoint_id:cid})});
1058
1083
  if(!res.ok){ toast((await res.json()).error||'fork failed'); return; }
1059
1084
  await consumeRunStream(res);
@@ -1063,7 +1088,7 @@ window.editState=async(tid,cid,valuesJson)=>{
1063
1088
  if(cur==null) return;
1064
1089
  let values; try{ values=JSON.parse(cur); }catch(e){ return toast('Invalid JSON'); }
1065
1090
  const body={values}; if(cid) body.checkpoint_id=cid;
1066
- const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/state',
1091
+ const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/state?'+rgparam(),
1067
1092
  {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
1068
1093
  const d=await res.json();
1069
1094
  if(!res.ok) return toast(d.error||'update_state failed');
@@ -1073,7 +1098,7 @@ window.editState=async(tid,cid,valuesJson)=>{
1073
1098
 
1074
1099
  /* ---------- time-travel (checkpoint history) ---------- */
1075
1100
  window.showHistory=async(tid)=>{
1076
- let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history');
1101
+ let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history?'+rgparam());
1077
1102
  d=await r.json(); if(!r.ok) return toast(d.error||'history unavailable'); }catch(e){ return toast('history unavailable'); }
1078
1103
  const steps=d.steps||[];
1079
1104
  $('#d-title').textContent='thread '+tid.slice(0,10)+' · time-travel';
@@ -1199,7 +1224,7 @@ let tT; function toast(m){ const t=$('#toast'); t.textContent=m; t.classList.add
1199
1224
 
1200
1225
  let MEM_NS=[];
1201
1226
  async function probeMemory(){
1202
- try{ const r=await fetch('/api/memory/namespaces'); if(!r.ok) return;
1227
+ try{ const r=await fetch('/api/memory/namespaces?'+gparam('')); if(!r.ok){ document.getElementById('nav-mem').style.display='none'; return; }
1203
1228
  MEM_NS=(await r.json()).namespaces||[];
1204
1229
  document.getElementById('nav-mem').style.display='';
1205
1230
  const m=document.querySelector('.bottom-nav .nav-mem'); if(m)m.style.display='';
@@ -1207,7 +1232,7 @@ async function probeMemory(){
1207
1232
  }
1208
1233
  async function loadMemory(){
1209
1234
  const sel=document.getElementById('mem-ns');
1210
- try{ const r=await fetch('/api/memory/namespaces'); MEM_NS=(await r.json()).namespaces||[]; }catch(e){}
1235
+ try{ const r=await fetch('/api/memory/namespaces?'+gparam('')); MEM_NS=(await r.json()).namespaces||[]; }catch(e){}
1211
1236
  const cur=sel.value;
1212
1237
  sel.innerHTML=MEM_NS.map(ns=>`<option value="${esc(ns.join('.'))}">${esc(ns.join(' / '))}</option>`).join('');
1213
1238
  if(cur) sel.value=cur;
@@ -1215,7 +1240,7 @@ async function loadMemory(){
1215
1240
  const w=document.getElementById('mem-wrap');
1216
1241
  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
1242
  const q=document.getElementById('mem-q').value.trim();
1218
- const p=new URLSearchParams({namespace:ns}); if(q)p.set('query',q);
1243
+ const p=new URLSearchParams({namespace:ns}); if(q)p.set('query',q); if(GSEL)p.set('graph',GSEL);
1219
1244
  let d={items:[]}; try{ d=await(await fetch('/api/memory/items?'+p)).json(); }catch(e){}
1220
1245
  w.innerHTML=(d.items||[]).length?`<div style="overflow:auto"><table>
1221
1246
  <thead><tr><th>Key</th><th>Value</th><th class="r">Updated</th></tr></thead>
@@ -1240,13 +1265,13 @@ async function loadDatasets(){
1240
1265
  :`<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
1266
  }
1242
1267
  window.runDataset=async(id,name)=>{
1243
- const r=await fetch('/api/datasets/'+id+'/run',{method:'POST'});
1268
+ const r=await fetch('/api/datasets/'+id+'/run?'+gparam(''),{method:'POST'});
1244
1269
  const d=await r.json();
1245
1270
  if(!r.ok) return toast(d.error||'eval failed to start');
1246
1271
  toast('Eval started: '+d.items+' items → session '+d.session);
1247
1272
  };
1248
1273
  window.delDataset=async(id)=>{ await fetch('/api/datasets/'+id,{method:'DELETE'}); loadDatasets(); };
1249
- loadGraph(); liveTopology(); probeMemory();
1274
+ loadGraphList().then(()=>{ loadGraph(); probeMemory(); }); liveTopology();
1250
1275
  /* deep links: #runs #sessions #stats #run=<id> */
1251
1276
  (function(){ const h=location.hash.slice(1);
1252
1277
  if(h==='runs'||h==='sessions'||h==='stats') switchView(h);
@@ -27,6 +27,7 @@ class Store:
27
27
  self.path = path
28
28
  self.has_fts = False
29
29
  self.has_json1 = False
30
+ self.on_run_closed = None # optional hook(run_dict) — used for webhooks
30
31
  self._init()
31
32
 
32
33
  def _conn(self) -> sqlite3.Connection:
@@ -171,6 +172,12 @@ class Store:
171
172
  WHERE id=?""",
172
173
  (status, ended_ms, ended_ms - started, error, agg["nc"], agg["lc"],
173
174
  pt, ct, (pt or 0) + (ct or 0) if (pt or ct) else None, agg["cost"], run_id))
175
+ if self.on_run_closed is not None:
176
+ try:
177
+ self.on_run_closed({"id": run_id, "status": status, "error": error,
178
+ "duration_ms": ended_ms - started})
179
+ except Exception:
180
+ pass
174
181
 
175
182
  def update_run_meta(self, run_id: str, tags: Optional[list] = None,
176
183
  bookmarked: Optional[bool] = None) -> bool:
@@ -456,19 +456,48 @@ def db_sink(store) -> Callable[[dict], None]:
456
456
  return lambda ev: apply_to_store(store, ev, source="ui")
457
457
 
458
458
 
459
- def http_sink(base_url: str) -> Callable[[dict], None]:
460
- import urllib.request
459
+ def http_sink(base_url: str, token: Optional[str] = None,
460
+ max_queue: int = 2000) -> Callable[[dict], None]:
461
+ """Non-blocking: events go onto a bounded queue drained by a daemon thread,
462
+ so a slow or unreachable Windhover host NEVER adds latency to the traced
463
+ app. On overflow the oldest events are dropped (observability is
464
+ best-effort; the app comes first)."""
465
+ import queue as _q, threading as _t, urllib.request
461
466
  url = base_url.rstrip("/") + "/api/ingest"
467
+ headers = {"Content-Type": "application/json"}
468
+ if token:
469
+ headers["Authorization"] = f"Bearer {token}"
470
+ buf: "_q.Queue" = _q.Queue(maxsize=max_queue)
471
+
472
+ def _drain():
473
+ while True:
474
+ ev = buf.get()
475
+ try:
476
+ req = urllib.request.Request(url, data=json.dumps(ev).encode(),
477
+ headers=headers)
478
+ urllib.request.urlopen(req, timeout=5)
479
+ except Exception:
480
+ pass # drop — never retry-storm a down collector
481
+
482
+ _t.Thread(target=_drain, daemon=True).start()
462
483
 
463
484
  def sink(ev: dict) -> None:
464
- req = urllib.request.Request(url, data=json.dumps(ev).encode(),
465
- headers={"Content-Type": "application/json"})
466
- urllib.request.urlopen(req, timeout=3)
485
+ try:
486
+ buf.put_nowait(ev)
487
+ except _q.Full:
488
+ try:
489
+ buf.get_nowait() # shed oldest, keep newest
490
+ buf.put_nowait(ev)
491
+ except Exception:
492
+ pass
467
493
  return sink
468
494
 
469
495
 
470
496
  class WindhoverTracer(SpanBuilder):
471
- """Drop into any app: config={"callbacks": [WindhoverTracer("http://host:8090")]}."""
497
+ """Drop into any app: config={"callbacks": [WindhoverTracer("http://host:8090")]}.
498
+ Non-blocking; pass token= when the collector sets WINDHOVER_TOKEN."""
472
499
  def __init__(self, base_url: str, name: str = "external",
473
- session: Optional[str] = None, tags: Optional[list] = None):
474
- super().__init__(http_sink(base_url), run_name=name, session=session, tags=tags)
500
+ session: Optional[str] = None, tags: Optional[list] = None,
501
+ token: Optional[str] = None):
502
+ super().__init__(http_sink(base_url, token=token),
503
+ run_name=name, session=session, tags=tags)
@@ -1,61 +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
-
22
- @staticmethod
23
- def _discover() -> tuple[str, str]:
24
- """No WINDHOVER_GRAPH? Look for a langgraph.json (LangGraph's standard
25
- project file) in WINDHOVER_GRAPH_DIR / cwd and use its first graph.
26
- Returns (graph_ref, graph_dir) or ("", dir)."""
27
- import json as _json
28
- base = os.environ.get("WINDHOVER_GRAPH_DIR", os.getcwd())
29
- cfg_path = os.path.join(base, "langgraph.json")
30
- try:
31
- graphs = _json.loads(open(cfg_path).read()).get("graphs") or {}
32
- for _name, ref in graphs.items():
33
- path, _, attr = str(ref).partition(":")
34
- if not attr:
35
- continue
36
- if path.endswith(".py"):
37
- full = os.path.normpath(os.path.join(base, path))
38
- return (f"{os.path.splitext(os.path.basename(full))[0]}:{attr}",
39
- os.path.dirname(full))
40
- return f"{path}:{attr}", base # already module:attr
41
- except Exception:
42
- pass
43
- return "", base
44
-
45
- @classmethod
46
- def from_env(cls) -> "Config":
47
- graph_ref = os.environ.get("WINDHOVER_GRAPH", "")
48
- graph_dir = os.environ.get("WINDHOVER_GRAPH_DIR", os.getcwd())
49
- if not graph_ref:
50
- graph_ref, graph_dir = cls._discover()
51
- return cls(
52
- graph_ref=graph_ref,
53
- graph_dir=graph_dir,
54
- db_path=os.environ.get("WINDHOVER_DB", str(PKG_DIR.parent / "windhover.db")),
55
- host=os.environ.get("WINDHOVER_HOST", "0.0.0.0"),
56
- port=int(os.environ.get("WINDHOVER_PORT", "8090")),
57
- watch=os.environ.get("WINDHOVER_WATCH", "1") not in ("0", "false", "no"),
58
- pricing_path=os.environ.get("WINDHOVER_PRICING", str(PKG_DIR / "pricing.json")),
59
- retention_days=int(os.environ.get("WINDHOVER_RETENTION_DAYS", "0") or 0),
60
- token=os.environ.get("WINDHOVER_TOKEN", ""),
61
- )
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