windhover 0.14.1__tar.gz → 0.14.3__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.3}/PKG-INFO +10 -3
  2. {windhover-0.14.1 → windhover-0.14.3}/README.md +9 -2
  3. {windhover-0.14.1 → windhover-0.14.3}/SPEC.md +21 -7
  4. {windhover-0.14.1 → windhover-0.14.3}/docs/GUIDE.md +17 -0
  5. {windhover-0.14.1 → windhover-0.14.3}/pyproject.toml +1 -1
  6. {windhover-0.14.1 → windhover-0.14.3}/tests/test_smoke.py +60 -0
  7. {windhover-0.14.1 → windhover-0.14.3}/windhover/server.py +39 -12
  8. {windhover-0.14.1 → windhover-0.14.3}/windhover/static/index.html +20 -3
  9. {windhover-0.14.1 → windhover-0.14.3}/windhover/store.py +3 -0
  10. {windhover-0.14.1 → windhover-0.14.3}/windhover/tracer.py +14 -4
  11. {windhover-0.14.1 → windhover-0.14.3}/.github/workflows/ci.yml +0 -0
  12. {windhover-0.14.1 → windhover-0.14.3}/.gitignore +0 -0
  13. {windhover-0.14.1 → windhover-0.14.3}/GUIDE.md +0 -0
  14. {windhover-0.14.1 → windhover-0.14.3}/LICENSE +0 -0
  15. {windhover-0.14.1 → windhover-0.14.3}/docs/graph.png +0 -0
  16. {windhover-0.14.1 → windhover-0.14.3}/docs/logo.svg +0 -0
  17. {windhover-0.14.1 → windhover-0.14.3}/docs/runs.png +0 -0
  18. {windhover-0.14.1 → windhover-0.14.3}/docs/social-preview.png +0 -0
  19. {windhover-0.14.1 → windhover-0.14.3}/docs/stats.png +0 -0
  20. {windhover-0.14.1 → windhover-0.14.3}/docs/trace.png +0 -0
  21. {windhover-0.14.1 → windhover-0.14.3}/tests/__init__.py +0 -0
  22. {windhover-0.14.1 → windhover-0.14.3}/windhover/__init__.py +0 -0
  23. {windhover-0.14.1 → windhover-0.14.3}/windhover/config.py +0 -0
  24. {windhover-0.14.1 → windhover-0.14.3}/windhover/demo_graph.py +0 -0
  25. {windhover-0.14.1 → windhover-0.14.3}/windhover/demo_rag.py +0 -0
  26. {windhover-0.14.1 → windhover-0.14.3}/windhover/extract.py +0 -0
  27. {windhover-0.14.1 → windhover-0.14.3}/windhover/pricing.json +0 -0
  28. {windhover-0.14.1 → windhover-0.14.3}/windhover/static/icon.svg +0 -0
  29. {windhover-0.14.1 → windhover-0.14.3}/windhover/static/manifest.json +0 -0
  30. {windhover-0.14.1 → windhover-0.14.3}/windhover/static/vendor/cytoscape-dagre.js +0 -0
  31. {windhover-0.14.1 → windhover-0.14.3}/windhover/static/vendor/cytoscape.min.js +0 -0
  32. {windhover-0.14.1 → windhover-0.14.3}/windhover/static/vendor/dagre.min.js +0 -0
  33. {windhover-0.14.1 → windhover-0.14.3}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
  34. {windhover-0.14.1 → windhover-0.14.3}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
  35. {windhover-0.14.1 → windhover-0.14.3}/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.3
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
@@ -117,6 +117,11 @@ graph.invoke(input, config={
117
117
  - **Run history + replay** — SQLite; runs persist even if the browser closes (worker thread).
118
118
  - **Living graph** — file watcher re-extracts topology in a subprocess and pushes it to the UI.
119
119
  - **Dashboards** — runs/tokens per day, per-model usage and latency, per-node latency, error rate.
120
+ - **Multi-graph** — serve every graph in your project behind one URL; a top-bar selector
121
+ scopes all views (runs, sessions, stats included — metrics never mix graphs).
122
+ - **Alerts** — `WINDHOVER_WEBHOOK` pushes a JSON summary when a run errors or pauses.
123
+ - **Never slows your app** — the remote tracer is non-blocking (bounded queue; drops
124
+ rather than delays when the collector is down).
120
125
  - **Mobile-first PWA**, light/dark. Fully local (FastAPI + Cytoscape.js).
121
126
 
122
127
  ## Datasets API
@@ -135,11 +140,13 @@ curl -X POST :8090/api/runs/RUN_ID/scores -H 'Content-Type: application/json' \
135
140
  ```
136
141
 
137
142
  ## Config (env)
138
- `WINDHOVER_GRAPH` (module:attr; unset = ingest-only) · `WINDHOVER_GRAPH_DIR` · `WINDHOVER_DB`
143
+ `WINDHOVER_GRAPH` (`module:attr`, or a comma list `name=module:attr,…`; unset = langgraph.json
144
+ discovery, else ingest-only) · `WINDHOVER_GRAPH_DIR` · `WINDHOVER_DB`
139
145
  · `WINDHOVER_HOST`/`WINDHOVER_PORT` (0.0.0.0/8090) · `WINDHOVER_WATCH` (1) · `WINDHOVER_PRICING`
140
146
  · `WINDHOVER_RETENTION_DAYS` (0 = keep forever; else prune older runs on startup + every 6h)
141
147
  · `WINDHOVER_TOKEN` (set to require `Authorization: Bearer <token>` — or `?token=` — on all
142
- `/api` routes; the UI prompts once and remembers it).
148
+ `/api` routes; the UI prompts once and remembers it)
149
+ · `WINDHOVER_WEBHOOK` (POST a JSON alert whenever a run errors or pauses on an interrupt).
143
150
  Edit `windhover/pricing.json` for your models' $/1M rates (unknown model → cost null).
144
151
 
145
152
  ## Docs
@@ -98,6 +98,11 @@ graph.invoke(input, config={
98
98
  - **Run history + replay** — SQLite; runs persist even if the browser closes (worker thread).
99
99
  - **Living graph** — file watcher re-extracts topology in a subprocess and pushes it to the UI.
100
100
  - **Dashboards** — runs/tokens per day, per-model usage and latency, per-node latency, error rate.
101
+ - **Multi-graph** — serve every graph in your project behind one URL; a top-bar selector
102
+ scopes all views (runs, sessions, stats included — metrics never mix graphs).
103
+ - **Alerts** — `WINDHOVER_WEBHOOK` pushes a JSON summary when a run errors or pauses.
104
+ - **Never slows your app** — the remote tracer is non-blocking (bounded queue; drops
105
+ rather than delays when the collector is down).
101
106
  - **Mobile-first PWA**, light/dark. Fully local (FastAPI + Cytoscape.js).
102
107
 
103
108
  ## Datasets API
@@ -116,11 +121,13 @@ curl -X POST :8090/api/runs/RUN_ID/scores -H 'Content-Type: application/json' \
116
121
  ```
117
122
 
118
123
  ## Config (env)
119
- `WINDHOVER_GRAPH` (module:attr; unset = ingest-only) · `WINDHOVER_GRAPH_DIR` · `WINDHOVER_DB`
124
+ `WINDHOVER_GRAPH` (`module:attr`, or a comma list `name=module:attr,…`; unset = langgraph.json
125
+ discovery, else ingest-only) · `WINDHOVER_GRAPH_DIR` · `WINDHOVER_DB`
120
126
  · `WINDHOVER_HOST`/`WINDHOVER_PORT` (0.0.0.0/8090) · `WINDHOVER_WATCH` (1) · `WINDHOVER_PRICING`
121
127
  · `WINDHOVER_RETENTION_DAYS` (0 = keep forever; else prune older runs on startup + every 6h)
122
128
  · `WINDHOVER_TOKEN` (set to require `Authorization: Bearer <token>` — or `?token=` — on all
123
- `/api` routes; the UI prompts once and remembers it).
129
+ `/api` routes; the UI prompts once and remembers it)
130
+ · `WINDHOVER_WEBHOOK` (POST a JSON alert whenever a run errors or pauses on an interrupt).
124
131
  Edit `windhover/pricing.json` for your models' $/1M rates (unknown model → cost null).
125
132
 
126
133
  ## Docs
@@ -1,4 +1,4 @@
1
- # Windhover — engineering spec (current as of v0.11)
1
+ # Windhover — engineering spec (current as of v0.14)
2
2
 
3
3
  Self-hosted LangGraph/LangChain observability + human-in-the-loop console.
4
4
  Code is the only source of truth; Windhover observes and, for HITL, drives the
@@ -22,22 +22,36 @@ breakpoints, update_state, checkpoint forking). No visual editing, ever.
22
22
  ## Architecture
23
23
  - **FastAPI + SSE** (no websockets). Single-file UI (`static/index.html`) +
24
24
  vendored cytoscape/dagre/fonts.
25
+ - **Multi-graph registry**: `WINDHOVER_GRAPH` comma list (`name=module:attr`)
26
+ or every graph in `langgraph.json`; per-graph `Topo` watchers; `graph=`
27
+ scopes graph/schema/source/memory/threads/stats/sessions/dataset-run;
28
+ `/api/run` takes `_graph`; run rows store the registry name.
25
29
  - **Topology**: subprocess (`windhover.extract`) re-imports the graph on file
26
30
  mtime change → nodes/edges (+labels/metadata), input & context schemas,
27
31
  per-node source (inspect, unwrapped), x-ray variant; sha1-hash change bumps a
28
32
  version pushed over `/api/events` SSE.
29
- - **Tracer** (`windhover.tracer.SpanBuilder`): chains→node spans (langgraph_node
30
- metadata), LLM (params, tools offered, tokens+details, TTFT, streaming
31
- partials every ~0.5 s), tools, retrievers, retries, custom events,
32
- GraphInterruptinterrupted, session/tags/run_name/thread_id via config
33
- metadata. Best-effort: never raises into the user's graph.
33
+ - **Tracer** (`windhover.tracer.SpanBuilder`): concurrency-safe per-root run
34
+ contexts (parallel invokes = separate runs; bare llm/tool/retriever calls
35
+ open implicit runs; bookkeeping purged per run no growth in long-lived
36
+ apps). Captures chainsnode spans, LLM (params, tools offered,
37
+ tokens+cache/reasoning details, TTFT, streaming partials ~0.5 s), tools,
38
+ retrievers, retries, custom events, GraphInterrupt→interrupted (a pause,
39
+ never an error), messages as {role, content, tool_calls},
40
+ session/tags/run_name/thread_id via config metadata. The HTTP sink is
41
+ non-blocking (bounded queue + drain thread, sheds oldest). Best-effort:
42
+ never raises into — or slows — the user's graph.
34
43
  - **Store** (`windhover.store`, SQLite WAL, schema v7): runs / spans (tree) /
35
44
  scores / datasets (+span_fts). Feature-detects FTS5 & json1 at startup;
36
45
  idempotent column migrations.
37
46
  - **HITL** (`/api/threads/*`): thin wrappers over `Command(resume|goto)`,
38
47
  `interrupt_before/after`, `update_state`, `get_state_history`, checkpoint-id
39
- forking. Requires the local graph to have a checkpointer.
48
+ forking. Requires the local graph to have a checkpointer. Pending-node
49
+ detection queries by thread only (a checkpoint_id would read history).
50
+ - **Cache visibility**: LangGraph `CachePolicy` hits fire no callbacks; local
51
+ runs synthesize the node span with `params.cached`.
40
52
  - **Auth**: optional `WINDHOVER_TOKEN` Bearer/?token= gate on `/api` only.
53
+ - **Alerts**: `WINDHOVER_WEBHOOK` POSTs a summary on error/interrupted runs
54
+ (store.on_run_closed hook, fire-and-forget, deduped).
41
55
 
42
56
  ## Data model (schema v7)
43
57
  `runs`: id · graph · source(ui|ingest) · status(running|done|error|interrupted) ·
@@ -154,6 +154,23 @@ curl -X POST :8090/api/datasets -H 'Content-Type: application/json' -d '{
154
154
  numbers/booleans, substring-of-output-JSON for strings. It's a smoke-level matcher —
155
155
  for semantic grading, run your own judge and `POST /api/runs/{id}/scores`.
156
156
 
157
+ ## Deep agents & agent frameworks
158
+
159
+ Agent frameworks built on LangGraph — including `deepagents` (planning, virtual file
160
+ system, sub-agents) and `create_react_agent` — trace with **no integration work**:
161
+
162
+ - Every LLM call shows the **tools the model was offered**, so you can see an agent's
163
+ action space shrink inside a sub-agent.
164
+ - **Sub-agents nest**: the `task` tool call that spawns one carries the entire child
165
+ agent's spans beneath it — one tree from top-level plan to nested completion.
166
+ - **Plans render as checklists**: todo-shaped state (`[{content, status}]`) displays
167
+ with ✓/◐/○ markers instead of raw JSON, and state evolution shows each planning
168
+ update in order.
169
+ - Framework-internal middleware spans (e.g. `TodoListMiddleware.after_model`) appear
170
+ italic-muted so the agent's real work stands out.
171
+ - The canvas shows the compiled loop (agent ↔ tools) — an agent's *structure* lives in
172
+ its traces, not its topology; that's the nature of dynamic agents.
173
+
157
174
  ## More things the audit covered
158
175
 
159
176
  - **Concurrency & batch** — one tracer instance safely handles parallel `.invoke()`s:
@@ -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.3"
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');
@@ -328,6 +330,13 @@ window.pickDiff=async(id)=>{
328
330
  .msg .mrole{flex:none;font-family:var(--mono);font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding-top:2px;width:64px;text-align:right}
329
331
  .msg.user .mrole{color:var(--accent-text)} .msg.assistant .mrole{color:var(--chip-llm-t)} .msg.tool .mrole,.msg.system .mrole{color:var(--chip-tool-t)}
330
332
  .msg>div{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:5px 9px;white-space:pre-wrap;word-break:break-word;flex:1}
333
+ .todos{background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:8px 12px;display:flex;flex-direction:column;gap:4px}
334
+ .todo{font-size:12.5px;display:flex;gap:8px;align-items:baseline}
335
+ .todo .ti{font-family:var(--mono);color:var(--muted)}
336
+ .todo.completed{color:var(--muted);text-decoration:line-through}
337
+ .todo.completed .ti{color:var(--ok);text-decoration:none}
338
+ .todo.in_progress .ti{color:var(--warn)}
339
+ .tr-name.fw{color:var(--muted);font-weight:500;font-style:italic}
331
340
 
332
341
  /* ---------- modal (new run) ---------- */
333
342
  .modal{position:fixed;inset:0;display:grid;place-items:center;z-index:60;opacity:0;pointer-events:none;transition:.15s}
@@ -727,6 +736,7 @@ function codeBlock(src,hi){ const lines=src.code.replace(/\n$/,'').split('\n');
727
736
 
728
737
  /* ---------- node pane (tap a node on the graph) ---------- */
729
738
  async function openNode(name){
739
+ clearTimeout(window.__liveT); window.__run=null;
730
740
  let d={}; try{ d=await(await fetch('/api/nodes/'+encodeURIComponent(name))).json(); }catch(e){}
731
741
  let src=null; try{ const rs=await fetch('/api/nodes/'+encodeURIComponent(name)+'/source?'+gparam(''));
732
742
  if(rs.ok) src=await rs.json(); }catch(e){}
@@ -1005,7 +1015,7 @@ function renderSpans(list,byParent,total,depth){
1005
1015
  <div class="tr-row" onclick="tog('${did}',this)">
1006
1016
  <div class="tr-main"><span class="indent" style="width:${depth*18}px"></span>
1007
1017
  <span class="caret">▶</span><span class="chip ${s.type}">${s.type}</span>
1008
- <span class="tr-name ${s.status==='error'?'err':''}">${esc(s.name||s.type)}</span>${s.retries?`<span class="badge-retry">↻${s.retries}</span>`:''}</div>
1018
+ <span class="tr-name ${s.status==='error'?'err':''} ${/Middleware\./.test(s.name||'')?'fw':''}">${esc(s.name||s.type)}</span>${s.retries?`<span class="badge-retry">↻${s.retries}</span>`:''}</div>
1009
1019
  <div class="tr-gantt"><div class="tr-bar ${s.type}" style="left:${left}%;width:${width}%"></div></div>
1010
1020
  <div class="tr-meta">${meta}</div>
1011
1021
  </div>
@@ -1021,6 +1031,9 @@ function renderSpans(list,byParent,total,depth){
1021
1031
  }).join('');
1022
1032
  }
1023
1033
  function isMsg(m){ return m&&typeof m==='object'&&typeof m.content!=='undefined'&&typeof (m.role||m.type)==='string'; }
1034
+ function isTodo(x){ return x&&typeof x==='object'&&typeof x.content==='string'&&typeof x.status==='string'; }
1035
+ function todoHtml(arr){ const ic={completed:'✓','in_progress':'◐',pending:'○'};
1036
+ return `<div class="todos">${arr.map(t=>`<div class="todo ${esc(t.status)}"><span class="ti">${ic[t.status]||'○'}</span>${esc(t.content)}</div>`).join('')}</div>`; }
1024
1037
  function chatHtml(arr){ return `<div class="chat">${arr.map(m=>{
1025
1038
  const role=(m.role||m.type||'').replace('human','user').replace('ai','assistant');
1026
1039
  const body=typeof m.content==='string'?m.content:JSON.stringify(m.content);
@@ -1030,8 +1043,10 @@ function chatHtml(arr){ return `<div class="chat">${arr.map(m=>{
1030
1043
  function kv(k,v){
1031
1044
  const msgs=Array.isArray(v)&&v.length&&v.every(isMsg)?v
1032
1045
  :(v&&typeof v==='object'&&Array.isArray(v.messages)&&v.messages.length&&v.messages.every(isMsg)?v.messages:null);
1046
+ const todos=Array.isArray(v)&&v.length&&v.every(isTodo)?v
1047
+ :(v&&typeof v==='object'&&Array.isArray(v.todos)&&v.todos.length&&v.todos.every(isTodo)?v.todos:null);
1033
1048
  const txt=typeof v==='string'?v:JSON.stringify(v,null,2);
1034
- return `<div class="kv"><div class="k">${k}<button class="copy" onclick="navigator.clipboard.writeText(${JSON.stringify(txt).replace(/"/g,'&quot;')});toast('Copied')">copy</button></div>${msgs?chatHtml(msgs):`<pre>${esc(txt)}</pre>`}</div>`; }
1049
+ return `<div class="kv"><div class="k">${k}<button class="copy" onclick="navigator.clipboard.writeText(${JSON.stringify(txt).replace(/"/g,'&quot;')});toast('Copied')">copy</button></div>${msgs?chatHtml(msgs):(todos?todoHtml(todos):`<pre>${esc(txt)}</pre>`)}</div>`; }
1035
1050
  window.tog=(id,row)=>{ const el=document.getElementById(id); const open=el.style.display==='block';
1036
1051
  el.style.display=open?'none':'block'; row.querySelector('.caret').classList.toggle('open',!open); };
1037
1052
  window.replay=async(steps)=>{ closeAll(); switchView('graph');
@@ -1106,6 +1121,7 @@ window.editState=async(tid,cid,valuesJson)=>{
1106
1121
 
1107
1122
  /* ---------- time-travel (checkpoint history) ---------- */
1108
1123
  window.showHistory=async(tid)=>{
1124
+ clearTimeout(window.__liveT);
1109
1125
  let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history?'+rgparam());
1110
1126
  d=await r.json(); if(!r.ok) return toast(d.error||'history unavailable'); }catch(e){ return toast('history unavailable'); }
1111
1127
  const steps=d.steps||[];
@@ -1140,6 +1156,7 @@ window.showHistory=async(tid)=>{
1140
1156
  window.pickDiff=async(id)=>{
1141
1157
  if(!window.__diffA||window.__diffA===id){ window.__diffA=id; toast('Compare: pick a second run (open it, hit compare)'); return; }
1142
1158
  const a=window.__diffA; window.__diffA=null;
1159
+ clearTimeout(window.__liveT);
1143
1160
  const [ra,rb]=await Promise.all([fetch('/api/runs/'+a).then(r=>r.json()),
1144
1161
  fetch('/api/runs/'+id).then(r=>r.json())]);
1145
1162
  const tops=r=>r.spans.filter(s=>!s.parent_id&&s.type==='node');
@@ -1225,7 +1242,7 @@ async function loadStats(){
1225
1242
  }
1226
1243
 
1227
1244
  /* ---------- misc ---------- */
1228
- function closeAll(){ $('#scrim').classList.remove('on'); $('#drawer').classList.remove('on'); $('#modal').classList.remove('on'); }
1245
+ function closeAll(){ clearTimeout(window.__liveT); $('#scrim').classList.remove('on'); $('#drawer').classList.remove('on'); $('#modal').classList.remove('on'); }
1229
1246
  function esc(s){ return String(s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
1230
1247
  function fmtms(ms){ return ms<1000?ms+' ms':(ms/1000).toFixed(1)+' s'; }
1231
1248
  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