windhover 0.14.1__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.1 → windhover-0.14.2}/PKG-INFO +1 -1
  2. {windhover-0.14.1 → windhover-0.14.2}/pyproject.toml +1 -1
  3. {windhover-0.14.1 → windhover-0.14.2}/tests/test_smoke.py +60 -0
  4. {windhover-0.14.1 → windhover-0.14.2}/windhover/server.py +39 -12
  5. {windhover-0.14.1 → windhover-0.14.2}/windhover/static/index.html +6 -1
  6. {windhover-0.14.1 → windhover-0.14.2}/windhover/store.py +3 -0
  7. {windhover-0.14.1 → windhover-0.14.2}/windhover/tracer.py +14 -4
  8. {windhover-0.14.1 → windhover-0.14.2}/.github/workflows/ci.yml +0 -0
  9. {windhover-0.14.1 → windhover-0.14.2}/.gitignore +0 -0
  10. {windhover-0.14.1 → windhover-0.14.2}/GUIDE.md +0 -0
  11. {windhover-0.14.1 → windhover-0.14.2}/LICENSE +0 -0
  12. {windhover-0.14.1 → windhover-0.14.2}/README.md +0 -0
  13. {windhover-0.14.1 → windhover-0.14.2}/SPEC.md +0 -0
  14. {windhover-0.14.1 → windhover-0.14.2}/docs/GUIDE.md +0 -0
  15. {windhover-0.14.1 → windhover-0.14.2}/docs/graph.png +0 -0
  16. {windhover-0.14.1 → windhover-0.14.2}/docs/logo.svg +0 -0
  17. {windhover-0.14.1 → windhover-0.14.2}/docs/runs.png +0 -0
  18. {windhover-0.14.1 → windhover-0.14.2}/docs/social-preview.png +0 -0
  19. {windhover-0.14.1 → windhover-0.14.2}/docs/stats.png +0 -0
  20. {windhover-0.14.1 → windhover-0.14.2}/docs/trace.png +0 -0
  21. {windhover-0.14.1 → windhover-0.14.2}/tests/__init__.py +0 -0
  22. {windhover-0.14.1 → windhover-0.14.2}/windhover/__init__.py +0 -0
  23. {windhover-0.14.1 → windhover-0.14.2}/windhover/config.py +0 -0
  24. {windhover-0.14.1 → windhover-0.14.2}/windhover/demo_graph.py +0 -0
  25. {windhover-0.14.1 → windhover-0.14.2}/windhover/demo_rag.py +0 -0
  26. {windhover-0.14.1 → windhover-0.14.2}/windhover/extract.py +0 -0
  27. {windhover-0.14.1 → windhover-0.14.2}/windhover/pricing.json +0 -0
  28. {windhover-0.14.1 → windhover-0.14.2}/windhover/static/icon.svg +0 -0
  29. {windhover-0.14.1 → windhover-0.14.2}/windhover/static/manifest.json +0 -0
  30. {windhover-0.14.1 → windhover-0.14.2}/windhover/static/vendor/cytoscape-dagre.js +0 -0
  31. {windhover-0.14.1 → windhover-0.14.2}/windhover/static/vendor/cytoscape.min.js +0 -0
  32. {windhover-0.14.1 → windhover-0.14.2}/windhover/static/vendor/dagre.min.js +0 -0
  33. {windhover-0.14.1 → windhover-0.14.2}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
  34. {windhover-0.14.1 → windhover-0.14.2}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
  35. {windhover-0.14.1 → 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.1
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "windhover"
7
- version = "0.14.1"
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"
@@ -632,6 +632,63 @@ def test_nonblocking_sink_and_webhook_hook():
632
632
  print("non-blocking sink + webhook hook OK")
633
633
 
634
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
+
635
692
  if __name__ == "__main__":
636
693
  test_cost(); print("cost OK")
637
694
  test_store_roundtrip()
@@ -660,4 +717,7 @@ if __name__ == "__main__":
660
717
  test_env_multi_graph_parsing()
661
718
  test_graph_scoped_stats_and_sessions()
662
719
  test_nonblocking_sink_and_webhook_hook()
720
+ test_fork_not_marked_interrupted()
721
+ test_score_rejects_nonfinite()
722
+ test_tracer_cleanup_no_leak()
663
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:
@@ -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)
@@ -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');
@@ -727,6 +729,7 @@ function codeBlock(src,hi){ const lines=src.code.replace(/\n$/,'').split('\n');
727
729
 
728
730
  /* ---------- node pane (tap a node on the graph) ---------- */
729
731
  async function openNode(name){
732
+ clearTimeout(window.__liveT); window.__run=null;
730
733
  let d={}; try{ d=await(await fetch('/api/nodes/'+encodeURIComponent(name))).json(); }catch(e){}
731
734
  let src=null; try{ const rs=await fetch('/api/nodes/'+encodeURIComponent(name)+'/source?'+gparam(''));
732
735
  if(rs.ok) src=await rs.json(); }catch(e){}
@@ -1106,6 +1109,7 @@ window.editState=async(tid,cid,valuesJson)=>{
1106
1109
 
1107
1110
  /* ---------- time-travel (checkpoint history) ---------- */
1108
1111
  window.showHistory=async(tid)=>{
1112
+ clearTimeout(window.__liveT);
1109
1113
  let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history?'+rgparam());
1110
1114
  d=await r.json(); if(!r.ok) return toast(d.error||'history unavailable'); }catch(e){ return toast('history unavailable'); }
1111
1115
  const steps=d.steps||[];
@@ -1140,6 +1144,7 @@ window.showHistory=async(tid)=>{
1140
1144
  window.pickDiff=async(id)=>{
1141
1145
  if(!window.__diffA||window.__diffA===id){ window.__diffA=id; toast('Compare: pick a second run (open it, hit compare)'); return; }
1142
1146
  const a=window.__diffA; window.__diffA=null;
1147
+ clearTimeout(window.__liveT);
1143
1148
  const [ra,rb]=await Promise.all([fetch('/api/runs/'+a).then(r=>r.json()),
1144
1149
  fetch('/api/runs/'+id).then(r=>r.json())]);
1145
1150
  const tops=r=>r.spans.filter(s=>!s.parent_id&&s.type==='node');
@@ -1225,7 +1230,7 @@ async function loadStats(){
1225
1230
  }
1226
1231
 
1227
1232
  /* ---------- misc ---------- */
1228
- 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'); }
1229
1234
  function esc(s){ return String(s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
1230
1235
  function fmtms(ms){ return ms<1000?ms+' ms':(ms/1000).toFixed(1)+' s'; }
1231
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
@@ -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
File without changes