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.
Files changed (35) hide show
  1. {windhover-0.14.0 → windhover-0.14.2}/PKG-INFO +1 -1
  2. {windhover-0.14.0 → windhover-0.14.2}/docs/GUIDE.md +4 -3
  3. {windhover-0.14.0 → windhover-0.14.2}/pyproject.toml +1 -1
  4. {windhover-0.14.0 → windhover-0.14.2}/tests/test_smoke.py +86 -0
  5. {windhover-0.14.0 → windhover-0.14.2}/windhover/server.py +43 -16
  6. {windhover-0.14.0 → windhover-0.14.2}/windhover/static/index.html +20 -5
  7. {windhover-0.14.0 → windhover-0.14.2}/windhover/store.py +35 -19
  8. {windhover-0.14.0 → windhover-0.14.2}/windhover/tracer.py +14 -4
  9. {windhover-0.14.0 → windhover-0.14.2}/.github/workflows/ci.yml +0 -0
  10. {windhover-0.14.0 → windhover-0.14.2}/.gitignore +0 -0
  11. {windhover-0.14.0 → windhover-0.14.2}/GUIDE.md +0 -0
  12. {windhover-0.14.0 → windhover-0.14.2}/LICENSE +0 -0
  13. {windhover-0.14.0 → windhover-0.14.2}/README.md +0 -0
  14. {windhover-0.14.0 → windhover-0.14.2}/SPEC.md +0 -0
  15. {windhover-0.14.0 → windhover-0.14.2}/docs/graph.png +0 -0
  16. {windhover-0.14.0 → windhover-0.14.2}/docs/logo.svg +0 -0
  17. {windhover-0.14.0 → windhover-0.14.2}/docs/runs.png +0 -0
  18. {windhover-0.14.0 → windhover-0.14.2}/docs/social-preview.png +0 -0
  19. {windhover-0.14.0 → windhover-0.14.2}/docs/stats.png +0 -0
  20. {windhover-0.14.0 → windhover-0.14.2}/docs/trace.png +0 -0
  21. {windhover-0.14.0 → windhover-0.14.2}/tests/__init__.py +0 -0
  22. {windhover-0.14.0 → windhover-0.14.2}/windhover/__init__.py +0 -0
  23. {windhover-0.14.0 → windhover-0.14.2}/windhover/config.py +0 -0
  24. {windhover-0.14.0 → windhover-0.14.2}/windhover/demo_graph.py +0 -0
  25. {windhover-0.14.0 → windhover-0.14.2}/windhover/demo_rag.py +0 -0
  26. {windhover-0.14.0 → windhover-0.14.2}/windhover/extract.py +0 -0
  27. {windhover-0.14.0 → windhover-0.14.2}/windhover/pricing.json +0 -0
  28. {windhover-0.14.0 → windhover-0.14.2}/windhover/static/icon.svg +0 -0
  29. {windhover-0.14.0 → windhover-0.14.2}/windhover/static/manifest.json +0 -0
  30. {windhover-0.14.0 → windhover-0.14.2}/windhover/static/vendor/cytoscape-dagre.js +0 -0
  31. {windhover-0.14.0 → windhover-0.14.2}/windhover/static/vendor/cytoscape.min.js +0 -0
  32. {windhover-0.14.0 → windhover-0.14.2}/windhover/static/vendor/dagre.min.js +0 -0
  33. {windhover-0.14.0 → windhover-0.14.2}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
  34. {windhover-0.14.0 → windhover-0.14.2}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
  35. {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.0
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 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.
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.0"
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
- try: # static breakpoint: stream ended but nodes are pending
276
- st = gr.get_state(config)
277
- if st.next:
278
- interrupted = True
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
- # tracer closed the run as done on root end; correct it
285
- store.close_run(run_id, "interrupted", int(time.time() * 1000))
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
- if not name:
445
- return JSONResponse({"error": "name must be non-empty"}, 400)
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
- loadGraph(true); probeMemory(); if(VIEW==='memory') loadMemory(); };
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=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[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 ?""", (limit,)).fetchall()]
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, AVG(dur_ms) avg_ms,
377
- SUM(cost_usd) cost FROM spans WHERE type='node'
378
- GROUP BY name ORDER BY avg_ms DESC LIMIT 20""").fetchall()
379
- models = c.execute("""SELECT model, COUNT(*) calls,
380
- SUM(prompt_tokens) prompt_tokens, SUM(completion_tokens) completion_tokens,
381
- SUM(cost_usd) cost, AVG(dur_ms) avg_ms
382
- FROM spans WHERE type='llm' AND model IS NOT NULL
383
- GROUP BY model ORDER BY calls DESC LIMIT 20""").fetchall()
384
- daily = c.execute("""SELECT
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
- return root, self._runs.get(root)
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.get(root)
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