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