windhover 0.14.0__tar.gz → 0.14.2__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.14.0 → windhover-0.14.2}/PKG-INFO +1 -1
- {windhover-0.14.0 → windhover-0.14.2}/docs/GUIDE.md +4 -3
- {windhover-0.14.0 → windhover-0.14.2}/pyproject.toml +1 -1
- {windhover-0.14.0 → windhover-0.14.2}/tests/test_smoke.py +86 -0
- {windhover-0.14.0 → windhover-0.14.2}/windhover/server.py +43 -16
- {windhover-0.14.0 → windhover-0.14.2}/windhover/static/index.html +20 -5
- {windhover-0.14.0 → windhover-0.14.2}/windhover/store.py +35 -19
- {windhover-0.14.0 → windhover-0.14.2}/windhover/tracer.py +14 -4
- {windhover-0.14.0 → windhover-0.14.2}/.github/workflows/ci.yml +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/.gitignore +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/GUIDE.md +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/LICENSE +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/README.md +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/SPEC.md +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/docs/graph.png +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/docs/logo.svg +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/docs/runs.png +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/docs/social-preview.png +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/docs/stats.png +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/docs/trace.png +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/tests/__init__.py +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/windhover/__init__.py +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/windhover/config.py +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/windhover/demo_graph.py +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/windhover/demo_rag.py +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/windhover/extract.py +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/windhover/pricing.json +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/windhover/static/icon.svg +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/windhover/static/manifest.json +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/windhover/static/vendor/cytoscape-dagre.js +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/windhover/static/vendor/cytoscape.min.js +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/windhover/static/vendor/dagre.min.js +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
- {windhover-0.14.0 → windhover-0.14.2}/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.14.
|
|
3
|
+
Version: 0.14.2
|
|
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
|
|
@@ -60,9 +60,10 @@ Projects with a **`langgraph.json`** (the LangGraph Studio/CLI convention) need
|
|
|
60
60
|
run `windhover` in the project directory and every graph the file defines is served.
|
|
61
61
|
|
|
62
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
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
puts a graph selector in the top bar. The selector scopes **everything** — canvas, New run,
|
|
64
|
+
node source, Memory, Runs, Sessions, Stats (so per-node latency and model usage never mix
|
|
65
|
+
graphs). The Runs page keeps an "All graphs" override in its own filter; human-in-the-loop
|
|
66
|
+
actions always follow the graph the run belongs to.
|
|
66
67
|
|
|
67
68
|
- The canvas always reflects **current-on-disk** topology (a subprocess re-extracts on
|
|
68
69
|
file change). Runs, however, execute the graph **imported at startup** — restart the
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "windhover"
|
|
7
|
-
version = "0.14.
|
|
7
|
+
version = "0.14.2"
|
|
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"
|
|
@@ -587,6 +587,31 @@ def test_env_multi_graph_parsing():
|
|
|
587
587
|
print("env multi-graph parsing OK")
|
|
588
588
|
|
|
589
589
|
|
|
590
|
+
def test_graph_scoped_stats_and_sessions():
|
|
591
|
+
p = tempfile.mktemp(suffix=".db")
|
|
592
|
+
s = Store(p)
|
|
593
|
+
now = int(time.time() * 1000)
|
|
594
|
+
for i, g in enumerate(("alpha", "alpha", "beta")):
|
|
595
|
+
rid = f"g{i}"
|
|
596
|
+
s.open_run({"id": rid, "graph": g, "session": f"sess-{g}", "started_ms": now - i})
|
|
597
|
+
s.add_span({"id": f"s{i}", "run_id": rid, "type": "node", "name": "shared_name",
|
|
598
|
+
"seq": 0, "dur_ms": 100 * (i + 1)})
|
|
599
|
+
s.add_span({"id": f"l{i}", "run_id": rid, "type": "llm", "name": "m", "seq": 1,
|
|
600
|
+
"model": f"model-{g}", "dur_ms": 5, "prompt_tokens": 10,
|
|
601
|
+
"completion_tokens": 1})
|
|
602
|
+
s.close_run(rid, "done", now + 10)
|
|
603
|
+
st_a = s.stats(graph="alpha")
|
|
604
|
+
assert st_a["totals"]["runs"] == 2
|
|
605
|
+
assert st_a["per_node"][0]["n"] == 2 # only alpha's spans, not beta's
|
|
606
|
+
assert [m["model"] for m in st_a["models"]] == ["model-alpha"]
|
|
607
|
+
st_all = s.stats()
|
|
608
|
+
assert st_all["totals"]["runs"] == 3 and st_all["per_node"][0]["n"] == 3
|
|
609
|
+
ses_b = s.sessions(graph="beta")
|
|
610
|
+
assert [x["session"] for x in ses_b] == ["sess-beta"]
|
|
611
|
+
assert len(s.sessions()) == 2
|
|
612
|
+
print("graph-scoped stats/sessions OK")
|
|
613
|
+
|
|
614
|
+
|
|
590
615
|
def test_nonblocking_sink_and_webhook_hook():
|
|
591
616
|
# sink to an unreachable host must return instantly and never raise
|
|
592
617
|
from windhover.tracer import http_sink
|
|
@@ -607,6 +632,63 @@ def test_nonblocking_sink_and_webhook_hook():
|
|
|
607
632
|
print("non-blocking sink + webhook hook OK")
|
|
608
633
|
|
|
609
634
|
|
|
635
|
+
def test_fork_not_marked_interrupted():
|
|
636
|
+
"""Pending-next check must query by thread only — a config carrying
|
|
637
|
+
checkpoint_id reads the historical checkpoint and falsely flags forks."""
|
|
638
|
+
os.environ.setdefault("WINDHOVER_DB", tempfile.mktemp(suffix=".db"))
|
|
639
|
+
from windhover.server import _pending_next
|
|
640
|
+
from typing import TypedDict
|
|
641
|
+
from langgraph.graph import StateGraph, START, END
|
|
642
|
+
from langgraph.checkpoint.memory import MemorySaver
|
|
643
|
+
|
|
644
|
+
class St(TypedDict):
|
|
645
|
+
x: int
|
|
646
|
+
g = StateGraph(St)
|
|
647
|
+
g.add_node("inc", lambda st: {"x": st["x"] + 1})
|
|
648
|
+
g.add_node("mul", lambda st: {"x": st["x"] * 10})
|
|
649
|
+
g.add_edge(START, "inc"); g.add_edge("inc", "mul"); g.add_edge("mul", END)
|
|
650
|
+
app = g.compile(checkpointer=MemorySaver())
|
|
651
|
+
cfgc = {"configurable": {"thread_id": "fork-t"}}
|
|
652
|
+
app.invoke({"x": 1}, config=cfgc) # completes
|
|
653
|
+
hist = list(app.get_state_history(cfgc))
|
|
654
|
+
early = next(s for s in hist if s.next) # historical, pending
|
|
655
|
+
cid = early.config["configurable"]["checkpoint_id"]
|
|
656
|
+
fork_cfg = {"configurable": {"thread_id": "fork-t", "checkpoint_id": cid}}
|
|
657
|
+
assert app.get_state(fork_cfg).next, "sanity: historical checkpoint has next"
|
|
658
|
+
assert _pending_next(app, fork_cfg) == [], "must ignore checkpoint_id"
|
|
659
|
+
print("fork-not-interrupted OK")
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def test_score_rejects_nonfinite():
|
|
663
|
+
p = tempfile.mktemp(suffix=".db")
|
|
664
|
+
s = Store(p)
|
|
665
|
+
s.open_run({"id": "sf1", "graph": "g", "started_ms": 1})
|
|
666
|
+
s.close_run("sf1", "done", 2)
|
|
667
|
+
assert s.add_score("sf1", "ok", 0.5) is not None
|
|
668
|
+
assert s.add_score("sf1", "bad", float("inf")) is None
|
|
669
|
+
assert s.add_score("sf1", "bad", float("nan")) is None
|
|
670
|
+
# the runs API payload must stay JSON-parseable
|
|
671
|
+
import json as _json
|
|
672
|
+
_json.loads(_json.dumps(s.runs()["runs"][0], allow_nan=False))
|
|
673
|
+
print("non-finite score rejection OK")
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def test_tracer_cleanup_no_leak():
|
|
677
|
+
p = tempfile.mktemp(suffix=".db")
|
|
678
|
+
s = Store(p)
|
|
679
|
+
tr = SpanBuilder(db_sink(s), run_name="leak")
|
|
680
|
+
for i in range(20):
|
|
681
|
+
tr.on_chain_start({}, {"i": i}, run_id=f"r{i}", parent_run_id=None)
|
|
682
|
+
tr.on_chain_start({}, {}, run_id=f"n{i}", parent_run_id=f"r{i}",
|
|
683
|
+
metadata={"langgraph_node": "w"})
|
|
684
|
+
tr.on_chain_end({}, run_id=f"n{i}")
|
|
685
|
+
tr.on_chain_end({}, run_id=f"r{i}")
|
|
686
|
+
assert s.runs(limit=100)["total"] == 20
|
|
687
|
+
assert not tr._runs and not tr._root_of and not tr._span_of and not tr._open, (
|
|
688
|
+
len(tr._runs), len(tr._root_of), len(tr._span_of), len(tr._open))
|
|
689
|
+
print("tracer cleanup (no leak) OK")
|
|
690
|
+
|
|
691
|
+
|
|
610
692
|
if __name__ == "__main__":
|
|
611
693
|
test_cost(); print("cost OK")
|
|
612
694
|
test_store_roundtrip()
|
|
@@ -633,5 +715,9 @@ if __name__ == "__main__":
|
|
|
633
715
|
test_message_serialization()
|
|
634
716
|
test_langgraph_json_discovery()
|
|
635
717
|
test_env_multi_graph_parsing()
|
|
718
|
+
test_graph_scoped_stats_and_sessions()
|
|
636
719
|
test_nonblocking_sink_and_webhook_hook()
|
|
720
|
+
test_fork_not_marked_interrupted()
|
|
721
|
+
test_score_rejects_nonfinite()
|
|
722
|
+
test_tracer_cleanup_no_leak()
|
|
637
723
|
print("ALL SMOKE TESTS PASSED")
|
|
@@ -230,6 +230,20 @@ def _sse(ev: str, data: dict) -> str:
|
|
|
230
230
|
return f"event: {ev}\ndata: {json.dumps(data)}\n\n"
|
|
231
231
|
|
|
232
232
|
|
|
233
|
+
def _pending_next(gr, config) -> list:
|
|
234
|
+
"""Nodes still pending on the LATEST checkpoint of this thread. Must query
|
|
235
|
+
by thread only — carrying a checkpoint_id (fork/resume-from) would read the
|
|
236
|
+
HISTORICAL checkpoint, whose next-nodes are always non-empty."""
|
|
237
|
+
try:
|
|
238
|
+
thread_id = (config.get("configurable") or {}).get("thread_id")
|
|
239
|
+
if not thread_id:
|
|
240
|
+
return []
|
|
241
|
+
st = gr.get_state({"configurable": {"thread_id": thread_id}})
|
|
242
|
+
return list(st.next or [])
|
|
243
|
+
except Exception:
|
|
244
|
+
return []
|
|
245
|
+
|
|
246
|
+
|
|
233
247
|
def _stream_execution(gr, graph_input, config, tracer, stream_kwargs=None):
|
|
234
248
|
"""Shared SSE executor for /api/run and /api/threads/…/resume. Detects both
|
|
235
249
|
dynamic interrupts (__interrupt__ updates) and static breakpoints (stream
|
|
@@ -272,17 +286,17 @@ def _stream_execution(gr, graph_input, config, tracer, stream_kwargs=None):
|
|
|
272
286
|
seq_extra[0] += 1
|
|
273
287
|
q.put(("node", {"node": node, "cached": cached or None}))
|
|
274
288
|
if not interrupted and getattr(gr, "checkpointer", None) is not None:
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
q.put(("interrupt", {"run_id": run_id,
|
|
280
|
-
"next": list(st.next)}))
|
|
281
|
-
except Exception:
|
|
282
|
-
pass
|
|
289
|
+
nxt = _pending_next(gr, config)
|
|
290
|
+
if nxt:
|
|
291
|
+
interrupted = True
|
|
292
|
+
q.put(("interrupt", {"run_id": run_id, "next": nxt}))
|
|
283
293
|
if interrupted:
|
|
284
|
-
#
|
|
285
|
-
|
|
294
|
+
# correct the status ONLY if the tracer didn't already mark it
|
|
295
|
+
# (dynamic interrupts close as interrupted themselves — a second
|
|
296
|
+
# close here would double-fire the webhook)
|
|
297
|
+
row = store.run_detail(run_id)
|
|
298
|
+
if row and row.get("status") != "interrupted":
|
|
299
|
+
store.close_run(run_id, "interrupted", int(time.time() * 1000))
|
|
286
300
|
q.put(("interrupted" if interrupted else "done", {"run_id": run_id}))
|
|
287
301
|
except Exception as e:
|
|
288
302
|
# tracer already recorded the error span/close; surface to the client too
|
|
@@ -307,6 +321,17 @@ async def api_run(request: Request):
|
|
|
307
321
|
payload = await request.json()
|
|
308
322
|
except Exception:
|
|
309
323
|
payload = {}
|
|
324
|
+
if not isinstance(payload, dict):
|
|
325
|
+
# non-dict graph inputs (e.g. functional-API entrypoints taking a
|
|
326
|
+
# scalar) carry no _directives — run them as-is on the default graph
|
|
327
|
+
gr = _graph_for(_default_name())
|
|
328
|
+
if gr is None:
|
|
329
|
+
return JSONResponse({"error": "no local graph"}, 400)
|
|
330
|
+
tracer = SpanBuilder(db_sink(store), run_name=_default_name())
|
|
331
|
+
config = {"callbacks": [tracer]}
|
|
332
|
+
if getattr(gr, "checkpointer", None) is not None:
|
|
333
|
+
config["configurable"] = {"thread_id": tracer.run_id}
|
|
334
|
+
return _stream_execution(gr, payload, config, tracer)
|
|
310
335
|
gname = payload.pop("_graph", None) or _default_name()
|
|
311
336
|
gr = _graph_for(gname)
|
|
312
337
|
if gr is None:
|
|
@@ -414,8 +439,8 @@ def api_runs(limit: int = 50, offset: int = 0, q: str = None, status: str = None
|
|
|
414
439
|
|
|
415
440
|
|
|
416
441
|
@app.get("/api/sessions")
|
|
417
|
-
def api_sessions(limit: int = 100):
|
|
418
|
-
return JSONResponse(store.sessions(limit=limit))
|
|
442
|
+
def api_sessions(limit: int = 100, graph: str = None):
|
|
443
|
+
return JSONResponse(store.sessions(limit=limit, graph=graph or None))
|
|
419
444
|
|
|
420
445
|
|
|
421
446
|
@app.get("/api/runs/{run_id}")
|
|
@@ -441,8 +466,10 @@ async def api_score_add(request: Request, run_id: str):
|
|
|
441
466
|
name, value = str(body["name"]).strip(), float(body["value"])
|
|
442
467
|
except (KeyError, TypeError, ValueError):
|
|
443
468
|
return JSONResponse({"error": "requires name (str) and value (number)"}, 400)
|
|
444
|
-
|
|
445
|
-
|
|
469
|
+
import math
|
|
470
|
+
if not name or not math.isfinite(value):
|
|
471
|
+
return JSONResponse({"error": "name must be non-empty and value finite "
|
|
472
|
+
"(NaN/Infinity would corrupt API JSON)"}, 400)
|
|
446
473
|
sc = store.add_score(run_id, name, value, comment=body.get("comment"),
|
|
447
474
|
source=body.get("source", "api"))
|
|
448
475
|
return JSONResponse(sc) if sc else JSONResponse({"error": "run not found"}, 404)
|
|
@@ -622,8 +649,8 @@ def api_dataset_run(ds_id: str, graph: str = None):
|
|
|
622
649
|
|
|
623
650
|
|
|
624
651
|
@app.get("/api/stats")
|
|
625
|
-
def api_stats(days: int = 30):
|
|
626
|
-
return JSONResponse(store.stats(days=max(1, min(days, 365))))
|
|
652
|
+
def api_stats(days: int = 30, graph: str = None):
|
|
653
|
+
return JSONResponse(store.stats(days=max(1, min(days, 365)), graph=graph or None))
|
|
627
654
|
|
|
628
655
|
|
|
629
656
|
@app.get("/api/events")
|
|
@@ -184,6 +184,7 @@ window.editState=async(tid,cid,valuesJson)=>{
|
|
|
184
184
|
|
|
185
185
|
/* ---------- time-travel (checkpoint history) ---------- */
|
|
186
186
|
window.showHistory=async(tid)=>{
|
|
187
|
+
clearTimeout(window.__liveT);
|
|
187
188
|
let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history?'+rgparam());
|
|
188
189
|
d=await r.json(); if(!r.ok) return toast(d.error||'history unavailable'); }catch(e){ return toast('history unavailable'); }
|
|
189
190
|
const steps=d.steps||[];
|
|
@@ -218,6 +219,7 @@ window.showHistory=async(tid)=>{
|
|
|
218
219
|
window.pickDiff=async(id)=>{
|
|
219
220
|
if(!window.__diffA||window.__diffA===id){ window.__diffA=id; toast('Compare: pick a second run (open it, hit compare)'); return; }
|
|
220
221
|
const a=window.__diffA; window.__diffA=null;
|
|
222
|
+
clearTimeout(window.__liveT);
|
|
221
223
|
const [ra,rb]=await Promise.all([fetch('/api/runs/'+a).then(r=>r.json()),
|
|
222
224
|
fetch('/api/runs/'+id).then(r=>r.json())]);
|
|
223
225
|
const tops=r=>r.spans.filter(s=>!s.parent_id&&s.type==='node');
|
|
@@ -526,7 +528,7 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
|
|
|
526
528
|
</div>
|
|
527
529
|
<div id="view-stats" style="display:none">
|
|
528
530
|
<div class="page">
|
|
529
|
-
<div class="page-head"><h1>Stats</h1><span class="sub">all recorded runs</span></div>
|
|
531
|
+
<div class="page-head"><h1>Stats</h1><span class="sub" id="stats-sub">all recorded runs</span></div>
|
|
530
532
|
<div id="stats-body"></div>
|
|
531
533
|
</div>
|
|
532
534
|
</div>
|
|
@@ -642,14 +644,22 @@ async function loadGraphList(){
|
|
|
642
644
|
sel.style.display=''; $('#gname').style.display='none';
|
|
643
645
|
sel.innerHTML=window.__graphList.map(g=>`<option ${g===GSEL?'selected':''}>${esc(g)}</option>`).join('');
|
|
644
646
|
sel.onchange=()=>{ GSEL=sel.value; localStorage.setItem('wh-graph',GSEL);
|
|
645
|
-
|
|
647
|
+
RF.graph=GSEL; const rg=$('#rgraph'); if(rg) rg.value=GSEL;
|
|
648
|
+
loadGraph(true); probeMemory();
|
|
649
|
+
if(VIEW==='memory') loadMemory();
|
|
650
|
+
if(VIEW==='runs') loadRuns();
|
|
651
|
+
if(VIEW==='sessions') loadSessions();
|
|
652
|
+
if(VIEW==='stats'){ loadStats(); loadDatasets(); } };
|
|
646
653
|
const rg=$('#rgraph');
|
|
647
654
|
if(rg){ rg.style.display='';
|
|
648
655
|
rg.innerHTML='<option value="">All graphs</option>'+window.__graphList.map(g=>`<option>${esc(g)}</option>`).join('');
|
|
656
|
+
RF.graph=GSEL; rg.value=GSEL;
|
|
649
657
|
rg.onchange=e=>{RF.graph=e.target.value;RF.offset=0;loadRuns();}; }
|
|
650
658
|
}
|
|
651
659
|
}catch(e){}
|
|
652
660
|
}
|
|
661
|
+
function multiGraph(){ return (window.__graphList||[]).length>1; }
|
|
662
|
+
function gscope(){ return multiGraph()&&GSEL?'graph='+encodeURIComponent(GSEL):''; }
|
|
653
663
|
function gparam(pfx){ return GSEL?pfx+'graph='+encodeURIComponent(GSEL):''; }
|
|
654
664
|
function rgparam(){ const g=(window.__run||{}).graph||GSEL; return g?'graph='+encodeURIComponent(g):''; }
|
|
655
665
|
let XRAY=false;
|
|
@@ -719,6 +729,7 @@ function codeBlock(src,hi){ const lines=src.code.replace(/\n$/,'').split('\n');
|
|
|
719
729
|
|
|
720
730
|
/* ---------- node pane (tap a node on the graph) ---------- */
|
|
721
731
|
async function openNode(name){
|
|
732
|
+
clearTimeout(window.__liveT); window.__run=null;
|
|
722
733
|
let d={}; try{ d=await(await fetch('/api/nodes/'+encodeURIComponent(name))).json(); }catch(e){}
|
|
723
734
|
let src=null; try{ const rs=await fetch('/api/nodes/'+encodeURIComponent(name)+'/source?'+gparam(''));
|
|
724
735
|
if(rs.ok) src=await rs.json(); }catch(e){}
|
|
@@ -889,7 +900,7 @@ $('#rbook').onclick=()=>{RF.bookmarked=!RF.bookmarked;$('#rbook').classList.togg
|
|
|
889
900
|
|
|
890
901
|
/* ---------- sessions ---------- */
|
|
891
902
|
async function loadSessions(){
|
|
892
|
-
const ss=await(await fetch('/api/sessions')).json();
|
|
903
|
+
const ss=await(await fetch('/api/sessions?'+gscope())).json();
|
|
893
904
|
$('#sess-sub').textContent=ss.length?`${ss.length} session${ss.length===1?'':'s'}`:'';
|
|
894
905
|
if(!ss.length){ $('#sess-wrap').innerHTML='<div class="empty"><div class="t">No sessions yet</div>Pass <span class="mono">session=</span> to <span class="mono">WindhoverTracer</span>, or <span class="mono">config={"metadata": {"windhover_session": …}}</span> in any LangChain/LangGraph app.</div>'; return; }
|
|
895
906
|
$('#sess-wrap').innerHTML=`<div style="overflow:auto"><table>
|
|
@@ -1098,6 +1109,7 @@ window.editState=async(tid,cid,valuesJson)=>{
|
|
|
1098
1109
|
|
|
1099
1110
|
/* ---------- time-travel (checkpoint history) ---------- */
|
|
1100
1111
|
window.showHistory=async(tid)=>{
|
|
1112
|
+
clearTimeout(window.__liveT);
|
|
1101
1113
|
let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history?'+rgparam());
|
|
1102
1114
|
d=await r.json(); if(!r.ok) return toast(d.error||'history unavailable'); }catch(e){ return toast('history unavailable'); }
|
|
1103
1115
|
const steps=d.steps||[];
|
|
@@ -1132,6 +1144,7 @@ window.showHistory=async(tid)=>{
|
|
|
1132
1144
|
window.pickDiff=async(id)=>{
|
|
1133
1145
|
if(!window.__diffA||window.__diffA===id){ window.__diffA=id; toast('Compare: pick a second run (open it, hit compare)'); return; }
|
|
1134
1146
|
const a=window.__diffA; window.__diffA=null;
|
|
1147
|
+
clearTimeout(window.__liveT);
|
|
1135
1148
|
const [ra,rb]=await Promise.all([fetch('/api/runs/'+a).then(r=>r.json()),
|
|
1136
1149
|
fetch('/api/runs/'+id).then(r=>r.json())]);
|
|
1137
1150
|
const tops=r=>r.spans.filter(s=>!s.parent_id&&s.type==='node');
|
|
@@ -1178,11 +1191,13 @@ function chartCard(title,daily,val,errKey){
|
|
|
1178
1191
|
<div class="clabels"><span>${daily[0]?.day||''}</span><span>${daily[daily.length-1]?.day||''}</span></div></div></div>`;
|
|
1179
1192
|
}
|
|
1180
1193
|
async function loadStats(){
|
|
1181
|
-
const s=await(await fetch('/api/stats?days=30')).json(); const t=s.totals||{};
|
|
1194
|
+
const s=await(await fetch('/api/stats?days=30&'+gscope())).json(); const t=s.totals||{};
|
|
1182
1195
|
const errRate=t.runs?Math.round((t.errors||0)/t.runs*100):0;
|
|
1183
1196
|
const max=Math.max(1,...(s.per_node||[]).map(n=>n.avg_ms||0));
|
|
1184
1197
|
const daily=s.daily||[], models=s.models||[];
|
|
1185
1198
|
const db=t.db_bytes!=null?(t.db_bytes/1048576).toFixed(1)+' MB db':'';
|
|
1199
|
+
const ssub=document.getElementById('stats-sub');
|
|
1200
|
+
if(ssub) ssub.textContent=(multiGraph()&&GSEL)?('graph: '+GSEL):'all recorded runs';
|
|
1186
1201
|
$('#stats-body').innerHTML=`
|
|
1187
1202
|
<div class="metrics">
|
|
1188
1203
|
<div class="metric"><div class="v num">${t.runs||0}</div><div class="l">Total runs</div></div>
|
|
@@ -1215,7 +1230,7 @@ async function loadStats(){
|
|
|
1215
1230
|
}
|
|
1216
1231
|
|
|
1217
1232
|
/* ---------- misc ---------- */
|
|
1218
|
-
function closeAll(){ $('#scrim').classList.remove('on'); $('#drawer').classList.remove('on'); $('#modal').classList.remove('on'); }
|
|
1233
|
+
function closeAll(){ clearTimeout(window.__liveT); $('#scrim').classList.remove('on'); $('#drawer').classList.remove('on'); $('#modal').classList.remove('on'); }
|
|
1219
1234
|
function esc(s){ return String(s).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); }
|
|
1220
1235
|
function fmtms(ms){ return ms<1000?ms+' ms':(ms/1000).toFixed(1)+' s'; }
|
|
1221
1236
|
function fmtk(n){ return n<1000?String(n):(n/1000).toFixed(1)+'k'; }
|
|
@@ -195,6 +195,9 @@ class Store:
|
|
|
195
195
|
|
|
196
196
|
def add_score(self, run_id: str, name: str, value: float,
|
|
197
197
|
comment: Optional[str] = None, source: str = "api") -> Optional[dict]:
|
|
198
|
+
import math
|
|
199
|
+
if not math.isfinite(float(value)):
|
|
200
|
+
return None # NaN/Infinity would render the runs API unparseable
|
|
198
201
|
with _lock, self._conn() as c:
|
|
199
202
|
if not c.execute("SELECT 1 FROM runs WHERE id=?", (run_id,)).fetchone():
|
|
200
203
|
return None
|
|
@@ -310,14 +313,18 @@ class Store:
|
|
|
310
313
|
r["scores"] = sc.get(r["id"]) or None
|
|
311
314
|
return {"runs": rows, "total": total, "limit": limit, "offset": offset}
|
|
312
315
|
|
|
313
|
-
def sessions(self, limit: int = 100) -> list[dict]:
|
|
316
|
+
def sessions(self, limit: int = 100, graph: Optional[str] = None) -> list[dict]:
|
|
317
|
+
cond, args = "", []
|
|
318
|
+
if graph:
|
|
319
|
+
cond = " AND graph=?"; args.append(graph)
|
|
314
320
|
with _lock, self._conn() as c:
|
|
315
|
-
return [dict(r) for r in c.execute("""SELECT session,
|
|
321
|
+
return [dict(r) for r in c.execute(f"""SELECT session,
|
|
316
322
|
COUNT(*) runs, COUNT(*) FILTER (WHERE status='error') errors,
|
|
317
323
|
MIN(started_ms) first_ms, MAX(started_ms) last_ms,
|
|
318
324
|
SUM(total_tokens) tokens, SUM(cost_usd) cost, SUM(duration_ms) duration_ms
|
|
319
|
-
FROM runs WHERE session IS NOT NULL AND session!=''
|
|
320
|
-
GROUP BY session ORDER BY last_ms DESC LIMIT ?""",
|
|
325
|
+
FROM runs WHERE session IS NOT NULL AND session!=''{cond}
|
|
326
|
+
GROUP BY session ORDER BY last_ms DESC LIMIT ?""",
|
|
327
|
+
(*args, limit)).fetchall()]
|
|
321
328
|
|
|
322
329
|
def run_detail(self, run_id: str) -> Optional[dict]:
|
|
323
330
|
with _lock, self._conn() as c:
|
|
@@ -366,27 +373,36 @@ class Store:
|
|
|
366
373
|
s[f] = json.loads(s[f]) if s.get(f) else None
|
|
367
374
|
return {"name": name, "summary": dict(agg), "recent": rows}
|
|
368
375
|
|
|
369
|
-
def stats(self, days: int = 30) -> dict:
|
|
376
|
+
def stats(self, days: int = 30, graph: Optional[str] = None) -> dict:
|
|
370
377
|
cutoff = int((time.time() - days * 86400) * 1000)
|
|
378
|
+
rcond, rargs = ("", [])
|
|
379
|
+
scond, sargs = ("", [])
|
|
380
|
+
if graph:
|
|
381
|
+
rcond = " WHERE graph=?"; rargs = [graph]
|
|
382
|
+
scond = " AND runs.graph=?"; sargs = [graph]
|
|
371
383
|
with _lock, self._conn() as c:
|
|
372
|
-
tot = c.execute("""SELECT COUNT(*) runs,
|
|
384
|
+
tot = c.execute(f"""SELECT COUNT(*) runs,
|
|
373
385
|
COUNT(*) FILTER (WHERE status='error') errors,
|
|
374
386
|
SUM(total_tokens) tokens, SUM(cost_usd) cost,
|
|
375
|
-
SUM(llm_calls) llm_calls FROM runs""").fetchone()
|
|
376
|
-
per_node = c.execute("""SELECT name, COUNT(*) n,
|
|
377
|
-
SUM(cost_usd) cost
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
387
|
+
SUM(llm_calls) llm_calls FROM runs{rcond}""", rargs).fetchone()
|
|
388
|
+
per_node = c.execute(f"""SELECT spans.name, COUNT(*) n,
|
|
389
|
+
AVG(spans.dur_ms) avg_ms, SUM(spans.cost_usd) cost
|
|
390
|
+
FROM spans JOIN runs ON runs.id = spans.run_id
|
|
391
|
+
WHERE spans.type='node'{scond}
|
|
392
|
+
GROUP BY spans.name ORDER BY avg_ms DESC LIMIT 20""", sargs).fetchall()
|
|
393
|
+
models = c.execute(f"""SELECT spans.model, COUNT(*) calls,
|
|
394
|
+
SUM(spans.prompt_tokens) prompt_tokens,
|
|
395
|
+
SUM(spans.completion_tokens) completion_tokens,
|
|
396
|
+
SUM(spans.cost_usd) cost, AVG(spans.dur_ms) avg_ms
|
|
397
|
+
FROM spans JOIN runs ON runs.id = spans.run_id
|
|
398
|
+
WHERE spans.type='llm' AND spans.model IS NOT NULL{scond}
|
|
399
|
+
GROUP BY spans.model ORDER BY calls DESC LIMIT 20""", sargs).fetchall()
|
|
400
|
+
daily = c.execute(f"""SELECT
|
|
385
401
|
strftime('%Y-%m-%d', started_ms/1000, 'unixepoch') day,
|
|
386
402
|
COUNT(*) runs, COUNT(*) FILTER (WHERE status='error') errors,
|
|
387
403
|
SUM(total_tokens) tokens, SUM(cost_usd) cost
|
|
388
|
-
FROM runs WHERE started_ms >= ?
|
|
389
|
-
GROUP BY day ORDER BY day""", (cutoff,)).fetchall()
|
|
404
|
+
FROM runs WHERE started_ms >= ?{' AND graph=?' if graph else ''}
|
|
405
|
+
GROUP BY day ORDER BY day""", (cutoff, *rargs)).fetchall()
|
|
390
406
|
try:
|
|
391
407
|
db_bytes = os.path.getsize(self.path)
|
|
392
408
|
except OSError:
|
|
@@ -395,4 +411,4 @@ class Store:
|
|
|
395
411
|
"per_node": [dict(r) for r in per_node],
|
|
396
412
|
"models": [dict(r) for r in models],
|
|
397
413
|
"daily": [dict(r) for r in daily],
|
|
398
|
-
"days": days}
|
|
414
|
+
"days": days, "graph": graph}
|
|
@@ -151,6 +151,7 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
151
151
|
self.session = session
|
|
152
152
|
self.tags = tags
|
|
153
153
|
self.run_id = uuid.uuid4().hex[:12] # id of the FIRST run this tracer opens
|
|
154
|
+
self._closed = 0 # runs completed (cleanup bookkeeping)
|
|
154
155
|
self._runs: dict = {} # lc root id -> run ctx
|
|
155
156
|
self._root_of: dict = {} # lc run id -> lc root id
|
|
156
157
|
self._open: dict = {} # lc run id -> pending span info
|
|
@@ -171,7 +172,10 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
171
172
|
if root is None:
|
|
172
173
|
root = self._root_of.get(run_id) or (run_id if parent_run_id is None else parent_run_id)
|
|
173
174
|
self._root_of[run_id] = root
|
|
174
|
-
|
|
175
|
+
ctx = self._runs.get(root)
|
|
176
|
+
if ctx is not None:
|
|
177
|
+
ctx["members"].add(run_id)
|
|
178
|
+
return root, ctx
|
|
175
179
|
|
|
176
180
|
def _open_ctx(self, root, inputs, metadata=None, lc_tags=None):
|
|
177
181
|
md = metadata or {}
|
|
@@ -181,8 +185,8 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
181
185
|
if not str(t).startswith(self._INTERNAL_TAG_PREFIXES)]
|
|
182
186
|
extra = md.get("windhover_tags") or []
|
|
183
187
|
tags = list(dict.fromkeys([*(self.tags or []), *map(str, extra), *map(str, user_tags)])) or None
|
|
184
|
-
ctx = {"id": self.run_id if not self._runs else uuid.uuid4().hex[:12],
|
|
185
|
-
"t0": time.time(), "seq": 0, "interrupted": False}
|
|
188
|
+
ctx = {"id": self.run_id if not self._closed and not self._runs else uuid.uuid4().hex[:12],
|
|
189
|
+
"t0": time.time(), "seq": 0, "interrupted": False, "members": {root}}
|
|
186
190
|
self._runs[root] = ctx
|
|
187
191
|
self._emit({"kind": "run_open", "run_id": ctx["id"], "graph": name,
|
|
188
192
|
"input": _trunc(inputs), "started_ms": int(ctx["t0"] * 1000),
|
|
@@ -229,11 +233,17 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
229
233
|
ctx["seq"] += 1
|
|
230
234
|
|
|
231
235
|
def _close_ctx(self, root, status, error=None):
|
|
232
|
-
ctx = self._runs.
|
|
236
|
+
ctx = self._runs.pop(root, None)
|
|
233
237
|
if ctx is None:
|
|
234
238
|
return
|
|
235
239
|
self._emit({"kind": "run_close", "run_id": ctx["id"], "status": status,
|
|
236
240
|
"ended_ms": int(time.time() * 1000), "error": error})
|
|
241
|
+
# long-lived tracers (production apps) must not grow without bound
|
|
242
|
+
self._closed += 1
|
|
243
|
+
for m in ctx.get("members", ()):
|
|
244
|
+
self._root_of.pop(m, None)
|
|
245
|
+
self._span_of.pop(m, None)
|
|
246
|
+
self._open.pop(m, None)
|
|
237
247
|
|
|
238
248
|
# -- chains / nodes --------------------------------------------------------
|
|
239
249
|
def on_chain_start(self, serialized, inputs, *, run_id=None, parent_run_id=None,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{windhover-0.14.0 → windhover-0.14.2}/windhover/static/vendor/fonts/fraunces-600-italic.woff2
RENAMED
|
File without changes
|
|
File without changes
|