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.
- {windhover-0.12.0 → windhover-0.14.0}/PKG-INFO +7 -4
- {windhover-0.12.0 → windhover-0.14.0}/README.md +6 -3
- {windhover-0.12.0 → windhover-0.14.0}/docs/GUIDE.md +42 -2
- {windhover-0.12.0 → windhover-0.14.0}/pyproject.toml +1 -1
- {windhover-0.12.0 → windhover-0.14.0}/tests/test_smoke.py +41 -5
- windhover-0.14.0/windhover/config.py +89 -0
- windhover-0.14.0/windhover/demo_rag.py +55 -0
- {windhover-0.12.0 → windhover-0.14.0}/windhover/server.py +136 -59
- {windhover-0.12.0 → windhover-0.14.0}/windhover/static/index.html +47 -22
- {windhover-0.12.0 → windhover-0.14.0}/windhover/store.py +7 -0
- {windhover-0.12.0 → windhover-0.14.0}/windhover/tracer.py +37 -8
- windhover-0.12.0/windhover/config.py +0 -61
- {windhover-0.12.0 → windhover-0.14.0}/.github/workflows/ci.yml +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/.gitignore +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/GUIDE.md +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/LICENSE +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/SPEC.md +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/docs/graph.png +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/docs/logo.svg +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/docs/runs.png +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/docs/social-preview.png +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/docs/stats.png +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/docs/trace.png +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/tests/__init__.py +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/windhover/__init__.py +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/windhover/demo_graph.py +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/windhover/extract.py +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/windhover/pricing.json +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/windhover/static/icon.svg +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/windhover/static/manifest.json +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/windhover/static/vendor/cytoscape-dagre.js +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/windhover/static/vendor/cytoscape.min.js +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/windhover/static/vendor/dagre.min.js +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
- {windhover-0.12.0 → windhover-0.14.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
- {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.
|
|
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
|
-
|  |  |
|
|
43
|
+
|  |  |
|
|
44
44
|
|
|
45
45
|
| Runs — search, tags, sessions, interrupts | Dashboards — per-day, per-model |
|
|
46
46
|
|---|---|
|
|
47
|
-
|  |  |
|
|
47
|
+
|  |  |
|
|
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
|
-
|  |  |
|
|
24
|
+
|  |  |
|
|
25
25
|
|
|
26
26
|
| Runs — search, tags, sessions, interrupts | Dashboards — per-day, per-model |
|
|
27
27
|
|---|---|
|
|
28
|
-
|  |  |
|
|
28
|
+
|  |  |
|
|
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
|
|
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.
|
|
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
|
|
558
|
+
import json as _json
|
|
559
559
|
d = tempfile.mkdtemp()
|
|
560
560
|
gdir = os.path.join(d, "src"); os.makedirs(gdir)
|
|
561
|
-
open(os.path.join(gdir, "app.py"), "w").write("graph = None\n")
|
|
561
|
+
open(os.path.join(gdir, "app.py"), "w").write("graph = None\nother = None\n")
|
|
562
562
|
open(os.path.join(d, "langgraph.json"), "w").write(
|
|
563
|
-
_json.dumps({"graphs": {"main": "./src/app.py:graph"
|
|
563
|
+
_json.dumps({"graphs": {"main": "./src/app.py:graph",
|
|
564
|
+
"side": "./src/app.py:other"}}))
|
|
564
565
|
os.environ.pop("WINDHOVER_GRAPH", None)
|
|
565
566
|
os.environ["WINDHOVER_GRAPH_DIR"] = d
|
|
566
567
|
try:
|
|
567
568
|
from windhover.config import Config
|
|
568
569
|
cfg = Config.from_env()
|
|
569
|
-
assert cfg.
|
|
570
|
+
assert cfg.graphs == (("main", "app:graph"), ("side", "app:other")), cfg.graphs
|
|
571
|
+
assert cfg.graph_ref == "app:graph" # back-compat: first graph
|
|
570
572
|
assert cfg.graph_dir == gdir, cfg.graph_dir
|
|
571
573
|
finally:
|
|
572
574
|
os.environ.pop("WINDHOVER_GRAPH_DIR", None)
|
|
573
|
-
print("langgraph.json discovery OK")
|
|
575
|
+
print("langgraph.json multi-discovery OK")
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def test_env_multi_graph_parsing():
|
|
579
|
+
from windhover.config import Config
|
|
580
|
+
os.environ["WINDHOVER_GRAPH"] = "alpha=m1:g1, m2:g2 ,beta=m3:g3"
|
|
581
|
+
try:
|
|
582
|
+
cfg = Config.from_env()
|
|
583
|
+
assert cfg.graphs == (("alpha", "m1:g1"), ("m2:g2", "m2:g2"),
|
|
584
|
+
("beta", "m3:g3")), cfg.graphs
|
|
585
|
+
finally:
|
|
586
|
+
os.environ.pop("WINDHOVER_GRAPH", None)
|
|
587
|
+
print("env multi-graph parsing OK")
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def test_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
|
-
|
|
26
|
-
if cfg.
|
|
25
|
+
GRAPHS: dict = {} # name -> compiled graph
|
|
26
|
+
if cfg.graphs:
|
|
27
27
|
sys.path.insert(0, cfg.graph_dir)
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
for _name, _ref in cfg.graphs:
|
|
29
|
+
try:
|
|
30
|
+
_m, _a = _ref.split(":")
|
|
31
|
+
GRAPHS[_name] = getattr(importlib.import_module(_m), _a)
|
|
32
|
+
except Exception as _e:
|
|
33
|
+
print(f"[windhover] failed to import graph {_name} ({_ref}): {_e}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _default_name() -> str:
|
|
37
|
+
return next(iter(GRAPHS), "")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _graph_for(name=None):
|
|
41
|
+
"""Resolve a graph by registry name; no name -> the first graph."""
|
|
42
|
+
if name:
|
|
43
|
+
return GRAPHS.get(name)
|
|
44
|
+
return next(iter(GRAPHS.values()), None)
|
|
30
45
|
|
|
31
46
|
app = FastAPI(title="Windhover")
|
|
32
47
|
|
|
@@ -54,7 +69,8 @@ async def _auth_middleware(request: Request, call_next):
|
|
|
54
69
|
|
|
55
70
|
# ---- topology manager (subprocess extraction, mtime-cached) ---------------
|
|
56
71
|
class Topo:
|
|
57
|
-
def __init__(self):
|
|
72
|
+
def __init__(self, name: str, ref: str):
|
|
73
|
+
self.name, self.ref = name, ref
|
|
58
74
|
self.lock = threading.Lock()
|
|
59
75
|
self.sig = None
|
|
60
76
|
self.data = {"topology": {"nodes": [], "edges": []}, "schema": {}, "sources": {}}
|
|
@@ -62,8 +78,6 @@ class Topo:
|
|
|
62
78
|
self.version = 0
|
|
63
79
|
|
|
64
80
|
def _signature(self) -> float:
|
|
65
|
-
if not cfg.graph_ref:
|
|
66
|
-
return 0.0
|
|
67
81
|
try:
|
|
68
82
|
return max((p.stat().st_mtime for p in Path(cfg.graph_dir).glob("*.py")),
|
|
69
83
|
default=0.0)
|
|
@@ -71,8 +85,6 @@ class Topo:
|
|
|
71
85
|
return 0.0
|
|
72
86
|
|
|
73
87
|
def refresh(self, force=False) -> None:
|
|
74
|
-
if not cfg.graph_ref:
|
|
75
|
-
return
|
|
76
88
|
sig = self._signature()
|
|
77
89
|
with self.lock:
|
|
78
90
|
if not force and sig == self.sig:
|
|
@@ -80,7 +92,7 @@ class Topo:
|
|
|
80
92
|
self.sig = sig
|
|
81
93
|
try:
|
|
82
94
|
out = subprocess.run(
|
|
83
|
-
[sys.executable, "-m", "windhover.extract",
|
|
95
|
+
[sys.executable, "-m", "windhover.extract", self.ref, cfg.graph_dir],
|
|
84
96
|
capture_output=True, text=True, timeout=20,
|
|
85
97
|
cwd=str(Path(__file__).parent.parent))
|
|
86
98
|
data = json.loads(out.stdout)
|
|
@@ -95,7 +107,7 @@ class Topo:
|
|
|
95
107
|
|
|
96
108
|
def get(self) -> dict:
|
|
97
109
|
with self.lock:
|
|
98
|
-
return {**self.data["topology"], "graph":
|
|
110
|
+
return {**self.data["topology"], "graph": self.name, "hash": self.hash,
|
|
99
111
|
"version": self.version, "xray": self.data.get("topology_xray")}
|
|
100
112
|
|
|
101
113
|
def schema(self) -> dict:
|
|
@@ -107,20 +119,28 @@ class Topo:
|
|
|
107
119
|
return self.data.get("sources", {})
|
|
108
120
|
|
|
109
121
|
|
|
110
|
-
|
|
111
|
-
|
|
122
|
+
TOPOS: dict = {name: Topo(name, ref) for name, ref in cfg.graphs if name in GRAPHS}
|
|
123
|
+
for _t in TOPOS.values():
|
|
124
|
+
_t.refresh(force=True)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _topo_for(name=None):
|
|
128
|
+
if name:
|
|
129
|
+
return TOPOS.get(name)
|
|
130
|
+
return next(iter(TOPOS.values()), None)
|
|
112
131
|
|
|
113
132
|
|
|
114
133
|
def _watch_loop():
|
|
115
|
-
while cfg.watch and
|
|
134
|
+
while cfg.watch and TOPOS:
|
|
116
135
|
time.sleep(2)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
136
|
+
for tp in TOPOS.values():
|
|
137
|
+
try:
|
|
138
|
+
tp.refresh()
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
121
141
|
|
|
122
142
|
|
|
123
|
-
if cfg.watch and
|
|
143
|
+
if cfg.watch and TOPOS:
|
|
124
144
|
threading.Thread(target=_watch_loop, daemon=True).start()
|
|
125
145
|
|
|
126
146
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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=
|
|
321
|
+
tracer = SpanBuilder(db_sink(store), run_name=gname, session=session, tags=tags)
|
|
256
322
|
config = {"callbacks": [tracer]}
|
|
257
|
-
if thread or getattr(
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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 =
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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(
|
|
609
|
+
if getattr(gr, "checkpointer", None) is not None:
|
|
535
610
|
config["configurable"] = {"thread_id": tracer.run_id}
|
|
536
611
|
try:
|
|
537
|
-
out =
|
|
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
|
-
|
|
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
|
|
564
|
-
seen =
|
|
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.
|
|
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();
|
|
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
|
|
460
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{windhover-0.12.0 → windhover-0.14.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2
RENAMED
|
File without changes
|
|
File without changes
|