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.
- {windhover-0.14.1 → windhover-0.14.2}/PKG-INFO +1 -1
- {windhover-0.14.1 → windhover-0.14.2}/pyproject.toml +1 -1
- {windhover-0.14.1 → windhover-0.14.2}/tests/test_smoke.py +60 -0
- {windhover-0.14.1 → windhover-0.14.2}/windhover/server.py +39 -12
- {windhover-0.14.1 → windhover-0.14.2}/windhover/static/index.html +6 -1
- {windhover-0.14.1 → windhover-0.14.2}/windhover/store.py +3 -0
- {windhover-0.14.1 → windhover-0.14.2}/windhover/tracer.py +14 -4
- {windhover-0.14.1 → windhover-0.14.2}/.github/workflows/ci.yml +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/.gitignore +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/GUIDE.md +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/LICENSE +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/README.md +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/SPEC.md +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/docs/GUIDE.md +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/docs/graph.png +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/docs/logo.svg +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/docs/runs.png +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/docs/social-preview.png +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/docs/stats.png +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/docs/trace.png +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/tests/__init__.py +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/windhover/__init__.py +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/windhover/config.py +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/windhover/demo_graph.py +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/windhover/demo_rag.py +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/windhover/extract.py +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/windhover/pricing.json +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/windhover/static/icon.svg +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/windhover/static/manifest.json +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/windhover/static/vendor/cytoscape-dagre.js +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/windhover/static/vendor/cytoscape.min.js +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/windhover/static/vendor/dagre.min.js +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
- {windhover-0.14.1 → windhover-0.14.2}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
@@ -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)
|
|
@@ -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=>({'&':'&','<':'<','>':'>'}[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
|
-
|
|
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
|
|
File without changes
|
{windhover-0.14.1 → windhover-0.14.2}/windhover/static/vendor/fonts/fraunces-600-italic.woff2
RENAMED
|
File without changes
|
|
File without changes
|