windhover 0.7.0__tar.gz → 0.8.0__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 (32) hide show
  1. windhover-0.8.0/.github/workflows/ci.yml +19 -0
  2. {windhover-0.7.0 → windhover-0.8.0}/PKG-INFO +27 -4
  3. {windhover-0.7.0 → windhover-0.8.0}/README.md +26 -3
  4. {windhover-0.7.0 → windhover-0.8.0}/pyproject.toml +1 -1
  5. {windhover-0.7.0 → windhover-0.8.0}/tests/test_smoke.py +58 -0
  6. {windhover-0.7.0 → windhover-0.8.0}/windhover/config.py +2 -0
  7. {windhover-0.7.0 → windhover-0.8.0}/windhover/demo_graph.py +5 -1
  8. {windhover-0.7.0 → windhover-0.8.0}/windhover/server.py +122 -2
  9. {windhover-0.7.0 → windhover-0.8.0}/windhover/static/index.html +179 -4
  10. {windhover-0.7.0 → windhover-0.8.0}/windhover/store.py +39 -4
  11. {windhover-0.7.0 → windhover-0.8.0}/windhover/tracer.py +4 -2
  12. {windhover-0.7.0 → windhover-0.8.0}/.gitignore +0 -0
  13. {windhover-0.7.0 → windhover-0.8.0}/LICENSE +0 -0
  14. {windhover-0.7.0 → windhover-0.8.0}/SPEC.md +0 -0
  15. {windhover-0.7.0 → windhover-0.8.0}/docs/graph.png +0 -0
  16. {windhover-0.7.0 → windhover-0.8.0}/docs/logo.svg +0 -0
  17. {windhover-0.7.0 → windhover-0.8.0}/docs/runs.png +0 -0
  18. {windhover-0.7.0 → windhover-0.8.0}/docs/social-preview.png +0 -0
  19. {windhover-0.7.0 → windhover-0.8.0}/docs/stats.png +0 -0
  20. {windhover-0.7.0 → windhover-0.8.0}/docs/trace.png +0 -0
  21. {windhover-0.7.0 → windhover-0.8.0}/tests/__init__.py +0 -0
  22. {windhover-0.7.0 → windhover-0.8.0}/windhover/__init__.py +0 -0
  23. {windhover-0.7.0 → windhover-0.8.0}/windhover/extract.py +0 -0
  24. {windhover-0.7.0 → windhover-0.8.0}/windhover/pricing.json +0 -0
  25. {windhover-0.7.0 → windhover-0.8.0}/windhover/static/icon.svg +0 -0
  26. {windhover-0.7.0 → windhover-0.8.0}/windhover/static/manifest.json +0 -0
  27. {windhover-0.7.0 → windhover-0.8.0}/windhover/static/vendor/cytoscape-dagre.js +0 -0
  28. {windhover-0.7.0 → windhover-0.8.0}/windhover/static/vendor/cytoscape.min.js +0 -0
  29. {windhover-0.7.0 → windhover-0.8.0}/windhover/static/vendor/dagre.min.js +0 -0
  30. {windhover-0.7.0 → windhover-0.8.0}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
  31. {windhover-0.7.0 → windhover-0.8.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
  32. {windhover-0.7.0 → windhover-0.8.0}/windhover/static/vendor/fonts/fraunces-600.woff2 +0 -0
@@ -0,0 +1,19 @@
1
+ name: ci
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ strategy:
11
+ matrix:
12
+ python-version: ["3.10", "3.11", "3.12"]
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: ${{ matrix.python-version }}
18
+ - run: pip install -e .[dev]
19
+ - run: pytest tests/ -q
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: windhover
3
- Version: 0.7.0
3
+ Version: 0.8.0
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
@@ -19,6 +19,11 @@ Description-Content-Type: text/markdown
19
19
 
20
20
  <p align="center"><img src="docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
21
21
  <h1 align="center">Windhover</h1>
22
+ <p align="center">
23
+ <a href="https://pypi.org/project/windhover/"><img src="https://img.shields.io/pypi/v/windhover" alt="PyPI"></a>
24
+ <a href="https://github.com/justfeltlikerunning/windhover/actions/workflows/ci.yml"><img src="https://github.com/justfeltlikerunning/windhover/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
25
+ <img src="https://img.shields.io/badge/license-MIT-8E2434" alt="MIT">
26
+ </p>
22
27
 
23
28
  > *Windhover* — the old poetic name for the kestrel, the falcon that hangs motionless
24
29
  > in the wind, watching everything below. This tool does the same for your agent graphs.
@@ -43,8 +48,8 @@ your own app. No LangSmith account, no cloud tunnel, no fragile websocket. HTTP
43
48
 
44
49
  ## Quick start
45
50
  ```bash
46
- pip install fastapi uvicorn langgraph
47
- WINDHOVER_GRAPH=windhover.demo_graph:graph python -m windhover.server # -> :8090
51
+ pip install windhover langgraph
52
+ WINDHOVER_GRAPH=windhover.demo_graph:graph windhover # -> :8090
48
53
  ```
49
54
  Open `http://<host>:8090`. **New run** (input pre-filled from the graph's schema) →
50
55
  watch it execute → **Runs** for history, span trees, and replay → **Stats** for cost/latency.
@@ -85,11 +90,27 @@ graph.invoke(input, config={
85
90
  status/tag/session filters, bookmarks, pagination, CSV/JSON export.
86
91
  - **Sessions** — group runs into threads/batches; roll-up tokens, cost, errors.
87
92
  - **Scores** — attach numeric evals to runs (API or UI): eval harnesses, LLM-as-judge, human review.
93
+ - **Live tail** — open a running run and watch its spans arrive in real time.
94
+ - **Time-travel** — checkpointed graphs get a per-thread checkpoint browser: state, writes,
95
+ and next-nodes at every superstep (`get_state_history`).
96
+ - **Run diff** — compare any two runs node-by-node: identical vs differing outputs,
97
+ duration and token deltas.
98
+ - **Datasets / batch eval** — store golden input sets, run the graph over them, and get an
99
+ `expected_match` score per item (see Datasets on the Stats page).
88
100
  - **Run history + replay** — SQLite; runs persist even if the browser closes (worker thread).
89
101
  - **Living graph** — file watcher re-extracts topology in a subprocess and pushes it to the UI.
90
102
  - **Dashboards** — runs/tokens per day, per-model usage and latency, per-node latency, error rate.
91
103
  - **Mobile-first PWA**, light/dark. Fully local (FastAPI + Cytoscape.js).
92
104
 
105
+ ## Datasets API
106
+ ```bash
107
+ curl -X POST :8090/api/datasets -H 'Content-Type: application/json' -d '{
108
+ "name": "golden", "items": [
109
+ {"input": {"n": 2}, "expected": 6},
110
+ {"input": {"n": 40}, "expected": "big"}]}'
111
+ curl -X POST :8090/api/datasets/golden/run # -> runs land in an eval:golden:<ts> session
112
+ ```
113
+
93
114
  ## Scores API
94
115
  ```bash
95
116
  curl -X POST :8090/api/runs/RUN_ID/scores -H 'Content-Type: application/json' \
@@ -99,7 +120,9 @@ curl -X POST :8090/api/runs/RUN_ID/scores -H 'Content-Type: application/json' \
99
120
  ## Config (env)
100
121
  `WINDHOVER_GRAPH` (module:attr; unset = ingest-only) · `WINDHOVER_GRAPH_DIR` · `WINDHOVER_DB`
101
122
  · `WINDHOVER_HOST`/`WINDHOVER_PORT` (0.0.0.0/8090) · `WINDHOVER_WATCH` (1) · `WINDHOVER_PRICING`
102
- · `WINDHOVER_RETENTION_DAYS` (0 = keep forever; else prune older runs on startup + every 6h).
123
+ · `WINDHOVER_RETENTION_DAYS` (0 = keep forever; else prune older runs on startup + every 6h)
124
+ · `WINDHOVER_TOKEN` (set to require `Authorization: Bearer <token>` — or `?token=` — on all
125
+ `/api` routes; the UI prompts once and remembers it).
103
126
  Edit `windhover/pricing.json` for your models' $/1M rates (unknown model → cost null).
104
127
 
105
128
  ## Notes
@@ -1,5 +1,10 @@
1
1
  <p align="center"><img src="docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
2
2
  <h1 align="center">Windhover</h1>
3
+ <p align="center">
4
+ <a href="https://pypi.org/project/windhover/"><img src="https://img.shields.io/pypi/v/windhover" alt="PyPI"></a>
5
+ <a href="https://github.com/justfeltlikerunning/windhover/actions/workflows/ci.yml"><img src="https://github.com/justfeltlikerunning/windhover/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
6
+ <img src="https://img.shields.io/badge/license-MIT-8E2434" alt="MIT">
7
+ </p>
3
8
 
4
9
  > *Windhover* — the old poetic name for the kestrel, the falcon that hangs motionless
5
10
  > in the wind, watching everything below. This tool does the same for your agent graphs.
@@ -24,8 +29,8 @@ your own app. No LangSmith account, no cloud tunnel, no fragile websocket. HTTP
24
29
 
25
30
  ## Quick start
26
31
  ```bash
27
- pip install fastapi uvicorn langgraph
28
- WINDHOVER_GRAPH=windhover.demo_graph:graph python -m windhover.server # -> :8090
32
+ pip install windhover langgraph
33
+ WINDHOVER_GRAPH=windhover.demo_graph:graph windhover # -> :8090
29
34
  ```
30
35
  Open `http://<host>:8090`. **New run** (input pre-filled from the graph's schema) →
31
36
  watch it execute → **Runs** for history, span trees, and replay → **Stats** for cost/latency.
@@ -66,11 +71,27 @@ graph.invoke(input, config={
66
71
  status/tag/session filters, bookmarks, pagination, CSV/JSON export.
67
72
  - **Sessions** — group runs into threads/batches; roll-up tokens, cost, errors.
68
73
  - **Scores** — attach numeric evals to runs (API or UI): eval harnesses, LLM-as-judge, human review.
74
+ - **Live tail** — open a running run and watch its spans arrive in real time.
75
+ - **Time-travel** — checkpointed graphs get a per-thread checkpoint browser: state, writes,
76
+ and next-nodes at every superstep (`get_state_history`).
77
+ - **Run diff** — compare any two runs node-by-node: identical vs differing outputs,
78
+ duration and token deltas.
79
+ - **Datasets / batch eval** — store golden input sets, run the graph over them, and get an
80
+ `expected_match` score per item (see Datasets on the Stats page).
69
81
  - **Run history + replay** — SQLite; runs persist even if the browser closes (worker thread).
70
82
  - **Living graph** — file watcher re-extracts topology in a subprocess and pushes it to the UI.
71
83
  - **Dashboards** — runs/tokens per day, per-model usage and latency, per-node latency, error rate.
72
84
  - **Mobile-first PWA**, light/dark. Fully local (FastAPI + Cytoscape.js).
73
85
 
86
+ ## Datasets API
87
+ ```bash
88
+ curl -X POST :8090/api/datasets -H 'Content-Type: application/json' -d '{
89
+ "name": "golden", "items": [
90
+ {"input": {"n": 2}, "expected": 6},
91
+ {"input": {"n": 40}, "expected": "big"}]}'
92
+ curl -X POST :8090/api/datasets/golden/run # -> runs land in an eval:golden:<ts> session
93
+ ```
94
+
74
95
  ## Scores API
75
96
  ```bash
76
97
  curl -X POST :8090/api/runs/RUN_ID/scores -H 'Content-Type: application/json' \
@@ -80,7 +101,9 @@ curl -X POST :8090/api/runs/RUN_ID/scores -H 'Content-Type: application/json' \
80
101
  ## Config (env)
81
102
  `WINDHOVER_GRAPH` (module:attr; unset = ingest-only) · `WINDHOVER_GRAPH_DIR` · `WINDHOVER_DB`
82
103
  · `WINDHOVER_HOST`/`WINDHOVER_PORT` (0.0.0.0/8090) · `WINDHOVER_WATCH` (1) · `WINDHOVER_PRICING`
83
- · `WINDHOVER_RETENTION_DAYS` (0 = keep forever; else prune older runs on startup + every 6h).
104
+ · `WINDHOVER_RETENTION_DAYS` (0 = keep forever; else prune older runs on startup + every 6h)
105
+ · `WINDHOVER_TOKEN` (set to require `Authorization: Bearer <token>` — or `?token=` — on all
106
+ `/api` routes; the UI prompts once and remembers it).
84
107
  Edit `windhover/pricing.json` for your models' $/1M rates (unknown model → cost null).
85
108
 
86
109
  ## Notes
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "windhover"
7
- version = "0.7.0"
7
+ version = "0.8.0"
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"
@@ -244,6 +244,61 @@ def test_xray_topology():
244
244
  print("xray topology OK —", len(plain["nodes"]), "->", len(xray["nodes"]), "nodes")
245
245
 
246
246
 
247
+ def test_auth_check():
248
+ os.environ.setdefault("WINDHOVER_DB", tempfile.mktemp(suffix=".db"))
249
+ from windhover.server import _auth_ok
250
+ assert _auth_ok("", "/api/runs", "", "") # no token configured -> open
251
+ assert _auth_ok("s3cret", "/", "", "") # UI shell never gated
252
+ assert _auth_ok("s3cret", "/static/x.js", "", "")
253
+ assert not _auth_ok("s3cret", "/api/runs", "", "") # gated without creds
254
+ assert _auth_ok("s3cret", "/api/runs", "Bearer s3cret", "")
255
+ assert _auth_ok("s3cret", "/api/runs", "bearer s3cret", "") # case/space tolerant
256
+ assert _auth_ok("s3cret", "/api/events", "", "s3cret") # query token (SSE)
257
+ assert not _auth_ok("s3cret", "/api/runs", "Bearer wrong", "")
258
+ print("auth check OK")
259
+
260
+
261
+ def test_thread_capture_and_history():
262
+ from typing import TypedDict
263
+ from langgraph.graph import StateGraph, START, END
264
+ from langgraph.checkpoint.memory import MemorySaver
265
+ p = tempfile.mktemp(suffix=".db")
266
+ s = Store(p)
267
+
268
+ class St(TypedDict):
269
+ x: int
270
+ g = StateGraph(St)
271
+ g.add_node("inc", lambda st: {"x": st["x"] + 1})
272
+ g.add_edge(START, "inc"); g.add_edge("inc", END)
273
+ app = g.compile(checkpointer=MemorySaver())
274
+ tr = SpanBuilder(db_sink(s), run_name="tt")
275
+ app.invoke({"x": 1}, config={"callbacks": [tr],
276
+ "configurable": {"thread_id": "thread-9"}})
277
+ time.sleep(.1)
278
+ d = s.run_detail(tr.run_id)
279
+ assert d["thread_id"] == "thread-9", d["thread_id"]
280
+ steps = list(app.get_state_history({"configurable": {"thread_id": "thread-9"}}))
281
+ assert len(steps) >= 2 # input checkpoint + node step
282
+ print("thread capture + history OK —", len(steps), "checkpoints")
283
+
284
+
285
+ def test_datasets_and_scoring():
286
+ os.environ.setdefault("WINDHOVER_DB", tempfile.mktemp(suffix=".db"))
287
+ from windhover.server import _expected_score
288
+ p = tempfile.mktemp(suffix=".db")
289
+ s = Store(p)
290
+ ds = s.add_dataset("golden", [{"input": {"n": 2}, "expected": 6},
291
+ {"input": {"n": 5}}])
292
+ assert s.datasets()[0]["n_items"] == 2
293
+ assert s.dataset("golden")["id"] == ds["id"]
294
+ assert _expected_score(6, {"n": 6}) == 1.0
295
+ assert _expected_score(7, {"n": 6}) == 0.0
296
+ assert _expected_score("flattened", {"answer": "tiers were flattened in Q3"}) == 1.0
297
+ assert _expected_score("missing", {"answer": "nope"}) == 0.0
298
+ assert s.delete_dataset("golden") and not s.datasets()
299
+ print("datasets + scoring OK")
300
+
301
+
247
302
  if __name__ == "__main__":
248
303
  test_cost(); print("cost OK")
249
304
  test_store_roundtrip()
@@ -256,4 +311,7 @@ if __name__ == "__main__":
256
311
  test_retriever_spans()
257
312
  test_interrupt_status()
258
313
  test_xray_topology()
314
+ test_auth_check()
315
+ test_thread_capture_and_history()
316
+ test_datasets_and_scoring()
259
317
  print("ALL SMOKE TESTS PASSED")
@@ -17,6 +17,7 @@ class Config:
17
17
  watch: bool # live-topology file watcher
18
18
  pricing_path: str
19
19
  retention_days: int # 0 = keep runs forever
20
+ token: str # WINDHOVER_TOKEN: require Bearer/query token on /api ("" = open)
20
21
 
21
22
  @classmethod
22
23
  def from_env(cls) -> "Config":
@@ -29,4 +30,5 @@ class Config:
29
30
  watch=os.environ.get("WINDHOVER_WATCH", "1") not in ("0", "false", "no"),
30
31
  pricing_path=os.environ.get("WINDHOVER_PRICING", str(PKG_DIR / "pricing.json")),
31
32
  retention_days=int(os.environ.get("WINDHOVER_RETENTION_DAYS", "0") or 0),
33
+ token=os.environ.get("WINDHOVER_TOKEN", ""),
32
34
  )
@@ -45,4 +45,8 @@ _g.add_edge("sign", "summarize")
45
45
  _g.add_edge("magnitude", "summarize")
46
46
  _g.add_edge("summarize", END)
47
47
 
48
- graph = _g.compile()
48
+ try: # checkpointer makes the Time-travel view demoable; optional dependency surface
49
+ from langgraph.checkpoint.memory import MemorySaver
50
+ graph = _g.compile(checkpointer=MemorySaver())
51
+ except Exception:
52
+ graph = _g.compile()
@@ -15,7 +15,7 @@ from fastapi.staticfiles import StaticFiles
15
15
 
16
16
  from .config import Config
17
17
  from .store import Store
18
- from .tracer import SpanBuilder, db_sink, apply_to_store
18
+ from .tracer import SpanBuilder, db_sink, apply_to_store, _trunc
19
19
 
20
20
  cfg = Config.from_env()
21
21
  store = Store(cfg.db_path)
@@ -30,6 +30,27 @@ if cfg.graph_ref:
30
30
  app = FastAPI(title="Windhover")
31
31
 
32
32
 
33
+ def _auth_ok(token: str, path: str, auth_header: str, query_token: str) -> bool:
34
+ """True when the request may proceed. Only /api is gated; static/UI stay
35
+ open (they contain no data — every payload comes through /api)."""
36
+ if not token or not path.startswith("/api"):
37
+ return True
38
+ supplied = (auth_header or "").strip()
39
+ if supplied.lower().startswith("bearer "):
40
+ supplied = supplied[7:].strip()
41
+ return supplied == token or (query_token or "") == token
42
+
43
+
44
+ @app.middleware("http")
45
+ async def _auth_middleware(request: Request, call_next):
46
+ if not _auth_ok(cfg.token, request.url.path,
47
+ request.headers.get("authorization", ""),
48
+ request.query_params.get("token", "")):
49
+ return JSONResponse({"error": "unauthorized — supply WINDHOVER_TOKEN as "
50
+ "Bearer header or ?token="}, 401)
51
+ return await call_next(request)
52
+
53
+
33
54
  # ---- topology manager (subprocess extraction, mtime-cached) ---------------
34
55
  class Topo:
35
56
  def __init__(self):
@@ -152,14 +173,20 @@ async def api_run(request: Request):
152
173
  payload = {}
153
174
  session = payload.pop("_session", None)
154
175
  tags = payload.pop("_tags", None)
176
+ thread = payload.pop("_thread", None)
155
177
  tracer = SpanBuilder(db_sink(store), run_name=cfg.graph_ref, session=session, tags=tags)
156
178
  run_id = tracer.run_id
179
+ config = {"callbacks": [tracer]}
180
+ if thread or getattr(graph, "checkpointer", None) is not None:
181
+ # a checkpointed graph needs a thread; default to the run id so
182
+ # time-travel works out of the box
183
+ config["configurable"] = {"thread_id": thread or run_id}
157
184
  q: "queue.Queue" = queue.Queue()
158
185
 
159
186
  def worker():
160
187
  try:
161
188
  interrupted = False
162
- for update in graph.stream(payload, config={"callbacks": [tracer]},
189
+ for update in graph.stream(payload, config=config,
163
190
  stream_mode="updates"):
164
191
  for node in update:
165
192
  if node == "__interrupt__":
@@ -287,6 +314,99 @@ def api_node_source(name: str):
287
314
  "(external-only run, or a runnable inspect can't trace)"}, 404)
288
315
 
289
316
 
317
+ @app.get("/api/threads/{thread_id}/history")
318
+ def api_thread_history(thread_id: str, limit: int = 80):
319
+ """Time-travel: LangGraph checkpoint history for a thread (local graph
320
+ with a checkpointer only)."""
321
+ if graph is None or getattr(graph, "checkpointer", None) is None:
322
+ return JSONResponse({"error": "no local graph with a checkpointer"}, 404)
323
+ steps = []
324
+ try:
325
+ for st in graph.get_state_history({"configurable": {"thread_id": thread_id}}):
326
+ md = st.metadata or {}
327
+ steps.append({
328
+ "checkpoint_id": ((st.config or {}).get("configurable") or {}).get("checkpoint_id"),
329
+ "step": md.get("step"),
330
+ "source": md.get("source"),
331
+ "writes": _trunc(md.get("writes"), 2000) if md.get("writes") is not None else None,
332
+ "next": list(st.next or []),
333
+ "values": _trunc(st.values, 3000),
334
+ "created_at": str(getattr(st, "created_at", "") or "") or None,
335
+ })
336
+ if len(steps) >= limit:
337
+ break
338
+ except Exception as e:
339
+ return JSONResponse({"error": str(e)}, 500)
340
+ return JSONResponse({"thread_id": thread_id, "steps": steps})
341
+
342
+
343
+ @app.get("/api/datasets")
344
+ def api_datasets():
345
+ return JSONResponse([{k: d[k] for k in ("id", "name", "n_items", "created_ms")}
346
+ for d in store.datasets()])
347
+
348
+
349
+ @app.post("/api/datasets")
350
+ async def api_dataset_add(request: Request):
351
+ body = await request.json()
352
+ name, items = body.get("name"), body.get("items")
353
+ if not name or not isinstance(items, list) or not items:
354
+ return JSONResponse({"error": "requires name and a non-empty items list "
355
+ "[{input: {...}, expected?: ...}]"}, 400)
356
+ for it in items:
357
+ if not isinstance(it, dict) or "input" not in it:
358
+ return JSONResponse({"error": "every item needs an input object"}, 400)
359
+ ds = store.add_dataset(str(name), items)
360
+ return JSONResponse({"id": ds["id"], "name": ds["name"], "n_items": len(items)})
361
+
362
+
363
+ @app.delete("/api/datasets/{ds_id}")
364
+ def api_dataset_delete(ds_id: str):
365
+ return JSONResponse({"ok": store.delete_dataset(ds_id)})
366
+
367
+
368
+ def _expected_score(expected, output) -> float:
369
+ """Naive match: exact for scalars, substring against the JSON otherwise."""
370
+ try:
371
+ if isinstance(expected, (int, float, bool)):
372
+ return 1.0 if any(v == expected for v in
373
+ (output.values() if isinstance(output, dict) else [output])) else 0.0
374
+ return 1.0 if str(expected) in json.dumps(output, default=str) else 0.0
375
+ except Exception:
376
+ return 0.0
377
+
378
+
379
+ @app.post("/api/datasets/{ds_id}/run")
380
+ def api_dataset_run(ds_id: str):
381
+ """Batch-eval: run the local graph over every dataset item; expected values
382
+ become an expected_match score on each run. Fire-and-forget worker."""
383
+ if graph is None:
384
+ return JSONResponse({"error": "no local graph (WINDHOVER_GRAPH unset)"}, 400)
385
+ ds = store.dataset(ds_id)
386
+ if not ds:
387
+ return JSONResponse({"error": "dataset not found"}, 404)
388
+ session = f"eval:{ds['name']}:{int(time.time())}"
389
+
390
+ def worker():
391
+ for i, item in enumerate(ds["items"]):
392
+ tracer = SpanBuilder(db_sink(store), run_name=cfg.graph_ref,
393
+ session=session, tags=[f"dataset:{ds['name']}"])
394
+ config = {"callbacks": [tracer]}
395
+ if getattr(graph, "checkpointer", None) is not None:
396
+ config["configurable"] = {"thread_id": tracer.run_id}
397
+ try:
398
+ out = graph.invoke(dict(item["input"]), config=config)
399
+ except Exception:
400
+ continue # tracer already recorded the error run
401
+ if "expected" in item:
402
+ store.add_score(tracer.run_id, "expected_match",
403
+ _expected_score(item["expected"], out),
404
+ comment=f"item {i}", source="dataset")
405
+
406
+ threading.Thread(target=worker, daemon=True).start()
407
+ return JSONResponse({"ok": True, "session": session, "items": len(ds["items"])})
408
+
409
+
290
410
  @app.get("/api/stats")
291
411
  def api_stats(days: int = 30):
292
412
  return JSONResponse(store.stats(days=max(1, min(days, 365))))
@@ -130,6 +130,73 @@ td.r,th.r{text-align:right}
130
130
  .empty{padding:56px 20px;text-align:center;color:var(--muted)}
131
131
  .empty .t{font-weight:600;color:var(--text2);margin-bottom:4px}
132
132
 
133
+ /* ---------- time-travel (checkpoint history) ---------- */
134
+ window.showHistory=async(tid)=>{
135
+ let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history');
136
+ d=await r.json(); if(!r.ok) return toast(d.error||'history unavailable'); }catch(e){ return toast('history unavailable'); }
137
+ const steps=d.steps||[];
138
+ $('#d-title').textContent='thread '+tid.slice(0,10)+' · time-travel';
139
+ const st=$('#d-status'); st.className='status done'; $('#d-status-t').textContent=steps.length+' checkpoints';
140
+ $('#d-meta').innerHTML='<span>newest first — every LangGraph checkpoint for this thread</span>';
141
+ $('#d-body').innerHTML=steps.map((s,i)=>{
142
+ const did='tt'+i;
143
+ const writes=s.writes&&typeof s.writes==='object'?Object.keys(s.writes):[];
144
+ return `<div>
145
+ <div class="tr-row flat" onclick="tog('${did}',this)">
146
+ <div class="tr-main"><span class="caret">▶</span>
147
+ <span class="chip node">step ${s.step??'—'}</span>
148
+ <span class="tr-name">${esc(s.source||'')}</span>
149
+ ${writes.map(w=>`<span class="tagchip" style="cursor:default">${esc(w)}</span>`).join('')}
150
+ ${(s.next||[]).length?`<span class="mono" style="color:var(--muted);font-size:11px">→ ${esc((s.next||[]).join(', '))}</span>`:''}</div>
151
+ <div class="tr-meta">${s.created_at?esc(String(s.created_at).slice(11,19)):''}</div>
152
+ </div>
153
+ <div class="tr-detail" id="${did}">
154
+ ${s.writes!=null?kv('writes',s.writes):''}
155
+ ${kv('state at this checkpoint',s.values)}
156
+ </div></div>`;
157
+ }).join('')||'<div class="empty">No checkpoints recorded for this thread</div>';
158
+ $('#scrim').classList.add('on'); $('#drawer').classList.add('on');
159
+ };
160
+
161
+ /* ---------- run diff ---------- */
162
+ window.pickDiff=async(id)=>{
163
+ if(!window.__diffA||window.__diffA===id){ window.__diffA=id; toast('Compare: pick a second run (open it, hit compare)'); return; }
164
+ const a=window.__diffA; window.__diffA=null;
165
+ const [ra,rb]=await Promise.all([fetch('/api/runs/'+a).then(r=>r.json()),
166
+ fetch('/api/runs/'+id).then(r=>r.json())]);
167
+ const tops=r=>r.spans.filter(s=>!s.parent_id&&s.type==='node');
168
+ const A=tops(ra),B=tops(rb);
169
+ const names=[...new Set([...A.map(s=>s.name),...B.map(s=>s.name)])];
170
+ $('#d-title').textContent='diff · '+a.slice(0,8)+' ↔ '+id.slice(0,8);
171
+ const st=$('#d-status'); st.className='status done'; $('#d-status-t').textContent='compare';
172
+ const delta=(x,y,f)=>{const va=x?.[f],vb=y?.[f]; if(va==null&&vb==null)return '';
173
+ return `<span class="mono" style="font-size:11px;color:var(--muted)">${va??'—'} → ${vb??'—'}</span>`;};
174
+ $('#d-meta').innerHTML=[
175
+ `<span>A <b class="mono">${esc(ra.graph||'')} ${a.slice(0,8)}</b></span>`,
176
+ `<span>B <b class="mono">${esc(rb.graph||'')} ${id.slice(0,8)}</b></span>`,
177
+ `<span>duration ${delta(ra,rb,'duration_ms')}</span>`,
178
+ `<span>tokens ${delta(ra,rb,'total_tokens')}</span>`].join('');
179
+ $('#d-body').innerHTML=names.map((n,i)=>{
180
+ const sa=A.find(s=>s.name===n),sb=B.find(s=>s.name===n);
181
+ const ja=JSON.stringify(sa?.output??null),jb=JSON.stringify(sb?.output??null);
182
+ const same=ja===jb;
183
+ const did='df'+i;
184
+ return `<div>
185
+ <div class="tr-row flat" onclick="tog('${did}',this)">
186
+ <div class="tr-main"><span class="caret">▶</span><span class="chip node">${esc(n)}</span>
187
+ <span class="tr-name" style="color:${same?'var(--muted)':'var(--err)'}">${same?'≡ identical output':'≠ outputs differ'}</span></div>
188
+ <div class="tr-meta">${sa?.dur_ms??'—'} / ${sb?.dur_ms??'—'} ms</div>
189
+ </div>
190
+ <div class="tr-detail" id="${did}">
191
+ <div class="diffgrid">
192
+ <div class="kv"><div class="k">A · ${esc(n)}</div><pre>${esc(JSON.stringify(sa?.output??'(absent)',null,2))}</pre></div>
193
+ <div class="kv"><div class="k">B · ${esc(n)}</div><pre>${esc(JSON.stringify(sb?.output??'(absent)',null,2))}</pre></div>
194
+ </div>
195
+ </div></div>`;
196
+ }).join('');
197
+ $('#scrim').classList.add('on'); $('#drawer').classList.add('on');
198
+ };
199
+
133
200
  /* ---------- stats ---------- */
134
201
  .metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:16px}
135
202
  .metric{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-lg);box-shadow:var(--shadow);padding:14px 16px}
@@ -185,6 +252,8 @@ td.r,th.r{text-align:right}
185
252
  .code .errline{background:var(--err-weak)}
186
253
  .code .cl.errline{box-shadow:inset 3px 0 0 var(--err);padding-left:6px;margin-left:-6px}
187
254
  .errhint{font:11px var(--mono);color:var(--err);margin-top:3px;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
255
+ .diffgrid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
256
+ @media(max-width:840px){.diffgrid{grid-template-columns:1fr}}
188
257
  .kv{margin:8px 0}
189
258
  .kv .k{display:flex;align-items:center;gap:8px;font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin-bottom:4px}
190
259
  .copy{font-size:10.5px;color:var(--accent-text);background:var(--accent-weak);border-radius:5px;padding:1px 7px}
@@ -317,7 +386,7 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
317
386
  <button data-v="sessions">${ICON.layers} Sessions</button>
318
387
  <button data-v="stats">${ICON.chart} Stats</button>
319
388
  </nav>
320
- <div class="side-foot"><span class="live-dot" id="live-dot" title="live topology"></span><span id="foot-note">watching</span><span class="spacer"></span><span class="mono" id="ver">v0.7</span></div>
389
+ <div class="side-foot"><span class="live-dot" id="live-dot" title="live topology"></span><span id="foot-note">watching</span><span class="spacer"></span><span class="mono" id="ver">v0.8</span></div>
321
390
  </aside>
322
391
 
323
392
  <main>
@@ -429,6 +498,16 @@ const ICON = {
429
498
  document.body.innerHTML = document.body.innerHTML.replaceAll(/\$\{ICON\.(\w+)\}/g,(m,k)=>ICON[k]||'');
430
499
 
431
500
  const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
501
+ /* optional WINDHOVER_TOKEN auth: attach Bearer to /api calls; prompt once on 401 */
502
+ const _fetch=window.fetch.bind(window);
503
+ window.whToken=()=>localStorage.getItem('wh-token')||'';
504
+ window.fetch=(u,o={})=>{ const t=whToken();
505
+ if(t&&String(u).startsWith('/api')) o={...o,headers:{...(o.headers||{}),'Authorization':'Bearer '+t}};
506
+ return _fetch(u,o).then(r=>{ if(r.status===401&&String(u).startsWith('/api')&&!window.__askedTok){
507
+ window.__askedTok=true;
508
+ const v=window.prompt('This Windhover requires an access token (WINDHOVER_TOKEN):');
509
+ if(v){ localStorage.setItem('wh-token',v.trim()); location.reload(); } }
510
+ return r; }); };
432
511
  let cy=null, VIEW='graph';
433
512
 
434
513
  /* ---------- theme ---------- */
@@ -563,7 +642,7 @@ async function openNode(name){
563
642
  $('#d-body').innerHTML=html;
564
643
  $('#scrim').classList.add('on'); $('#drawer').classList.add('on');
565
644
  }
566
- function liveTopology(){ try{ const es=new EventSource('/api/events');
645
+ function liveTopology(){ try{ const es=new EventSource('/api/events'+(whToken()?'?token='+encodeURIComponent(whToken()):''));
567
646
  es.addEventListener('topology',async()=>{ await loadGraph(true); toast('Graph definition changed — view updated'); });
568
647
  es.onerror=()=>{ es.close(); $('#foot-note').textContent='reconnecting…'; setTimeout(liveTopology,5000); };
569
648
  es.onopen=()=>{ $('#foot-note').textContent='watching'; };
@@ -576,7 +655,7 @@ function switchView(v){ VIEW=v;
576
655
  $('#crumb').textContent=v[0].toUpperCase()+v.slice(1);
577
656
  for(const k of ['graph','runs','sessions','stats'])
578
657
  $('#view-'+k).style.display=v===k?'':'none';
579
- if(v==='runs')loadRuns(); if(v==='sessions')loadSessions(); if(v==='stats')loadStats();
658
+ if(v==='runs')loadRuns(); if(v==='sessions')loadSessions(); if(v==='stats'){loadStats();loadDatasets();}
580
659
  if(cy&&v==='graph')setTimeout(()=>cy.resize(),40);
581
660
  }
582
661
  $$('.nav button,.bottom-nav button').forEach(b=>b.onclick=()=>switchView(b.dataset.v));
@@ -654,7 +733,7 @@ window.toggleBook=async(id,b)=>{
654
733
  await fetch('/api/runs/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},
655
734
  body:JSON.stringify({bookmarked:!!b})});
656
735
  loadRuns();};
657
- window.exportRuns=fmt=>{const p=runParams();p.set('format',fmt);location='/api/export?'+p;};
736
+ window.exportRuns=fmt=>{const p=runParams();p.set('format',fmt);if(whToken())p.set('token',whToken());location='/api/export?'+p;};
658
737
  let rqT; $('#rq').oninput=e=>{clearTimeout(rqT);rqT=setTimeout(()=>{RF.q=e.target.value.trim();RF.offset=0;loadRuns();},300);};
659
738
  $('#rstatus').onchange=e=>{RF.status=e.target.value;RF.offset=0;loadRuns();};
660
739
  $('#rbook').onclick=()=>{RF.bookmarked=!RF.bookmarked;$('#rbook').classList.toggle('on',RF.bookmarked);RF.offset=0;loadRuns();};
@@ -689,6 +768,8 @@ async function openRun(id){
689
768
  r.total_tokens?`<span><b class="num">${fmtk(r.total_tokens)}</b> tokens</span>`:'',
690
769
  r.cost_usd!=null?`<span><b class="num">$${r.cost_usd.toFixed(5)}</b> cost</span>`:'',
691
770
  r.session?`<span>session <b class="mono">${esc(r.session)}</b></span>`:'',
771
+ r.thread_id?`<span>thread <b class="mono">${esc(r.thread_id)}</b> <button class="copy" onclick="showHistory('${esc(r.thread_id)}')">time-travel</button></span>`:'',
772
+ `<span><button class="copy" onclick="pickDiff('${id}')">compare</button></span>`,
692
773
  (r.tags||[]).length?`<span>${r.tags.map(t=>`<span class="tagchip">${esc(t)}</span>`).join('')}</span>`:'',
693
774
  `<span>${new Date(r.started_ms).toLocaleString()}</span>`,
694
775
  ].filter(Boolean).join('');
@@ -714,8 +795,15 @@ async function openRun(id){
714
795
  <button class="btn ghost" onclick="addScore('${id}')">Add score</button>
715
796
  </div>`;
716
797
  html += `<div style="margin-top:6px"><button class="btn ghost" onclick='replay(${JSON.stringify(r.spans.filter(s=>s.type==="node").map(s=>({n:s.name,e:s.status==="error"})))})'>⟲ Replay on graph</button></div>`;
798
+ const openIds=[...document.querySelectorAll('#d-body .tr-detail')]
799
+ .filter(e=>e.style.display==='block').map(e=>e.id);
717
800
  $('#d-body').innerHTML=html;
801
+ openIds.forEach(i=>{const e=document.getElementById(i); if(e){e.style.display='block';
802
+ const row=e.previousElementSibling; row&&row.querySelector('.caret')?.classList.add('open');}});
718
803
  $('#scrim').classList.add('on'); $('#drawer').classList.add('on');
804
+ clearTimeout(window.__liveT);
805
+ if(r.status==='running')
806
+ window.__liveT=setTimeout(()=>{ if($('#drawer').classList.contains('on')&&window.__run&&window.__run.id===id) openRun(id); },1500);
719
807
  }
720
808
  window.addScore=async(id)=>{
721
809
  const name=$('#sc-name').value.trim(), value=parseFloat($('#sc-val').value);
@@ -779,6 +867,73 @@ window.showSpanSource=async(sid)=>{
779
867
  :'<pre>no source available for this node (external run, or inspect could not trace it)</pre>';
780
868
  };
781
869
 
870
+ /* ---------- time-travel (checkpoint history) ---------- */
871
+ window.showHistory=async(tid)=>{
872
+ let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history');
873
+ d=await r.json(); if(!r.ok) return toast(d.error||'history unavailable'); }catch(e){ return toast('history unavailable'); }
874
+ const steps=d.steps||[];
875
+ $('#d-title').textContent='thread '+tid.slice(0,10)+' · time-travel';
876
+ const st=$('#d-status'); st.className='status done'; $('#d-status-t').textContent=steps.length+' checkpoints';
877
+ $('#d-meta').innerHTML='<span>newest first — every LangGraph checkpoint for this thread</span>';
878
+ $('#d-body').innerHTML=steps.map((s,i)=>{
879
+ const did='tt'+i;
880
+ const writes=s.writes&&typeof s.writes==='object'?Object.keys(s.writes):[];
881
+ return `<div>
882
+ <div class="tr-row flat" onclick="tog('${did}',this)">
883
+ <div class="tr-main"><span class="caret">▶</span>
884
+ <span class="chip node">step ${s.step??'—'}</span>
885
+ <span class="tr-name">${esc(s.source||'')}</span>
886
+ ${writes.map(w=>`<span class="tagchip" style="cursor:default">${esc(w)}</span>`).join('')}
887
+ ${(s.next||[]).length?`<span class="mono" style="color:var(--muted);font-size:11px">→ ${esc((s.next||[]).join(', '))}</span>`:''}</div>
888
+ <div class="tr-meta">${s.created_at?esc(String(s.created_at).slice(11,19)):''}</div>
889
+ </div>
890
+ <div class="tr-detail" id="${did}">
891
+ ${s.writes!=null?kv('writes',s.writes):''}
892
+ ${kv('state at this checkpoint',s.values)}
893
+ </div></div>`;
894
+ }).join('')||'<div class="empty">No checkpoints recorded for this thread</div>';
895
+ $('#scrim').classList.add('on'); $('#drawer').classList.add('on');
896
+ };
897
+
898
+ /* ---------- run diff ---------- */
899
+ window.pickDiff=async(id)=>{
900
+ if(!window.__diffA||window.__diffA===id){ window.__diffA=id; toast('Compare: pick a second run (open it, hit compare)'); return; }
901
+ const a=window.__diffA; window.__diffA=null;
902
+ const [ra,rb]=await Promise.all([fetch('/api/runs/'+a).then(r=>r.json()),
903
+ fetch('/api/runs/'+id).then(r=>r.json())]);
904
+ const tops=r=>r.spans.filter(s=>!s.parent_id&&s.type==='node');
905
+ const A=tops(ra),B=tops(rb);
906
+ const names=[...new Set([...A.map(s=>s.name),...B.map(s=>s.name)])];
907
+ $('#d-title').textContent='diff · '+a.slice(0,8)+' ↔ '+id.slice(0,8);
908
+ const st=$('#d-status'); st.className='status done'; $('#d-status-t').textContent='compare';
909
+ const delta=(x,y,f)=>{const va=x?.[f],vb=y?.[f]; if(va==null&&vb==null)return '';
910
+ return `<span class="mono" style="font-size:11px;color:var(--muted)">${va??'—'} → ${vb??'—'}</span>`;};
911
+ $('#d-meta').innerHTML=[
912
+ `<span>A <b class="mono">${esc(ra.graph||'')} ${a.slice(0,8)}</b></span>`,
913
+ `<span>B <b class="mono">${esc(rb.graph||'')} ${id.slice(0,8)}</b></span>`,
914
+ `<span>duration ${delta(ra,rb,'duration_ms')}</span>`,
915
+ `<span>tokens ${delta(ra,rb,'total_tokens')}</span>`].join('');
916
+ $('#d-body').innerHTML=names.map((n,i)=>{
917
+ const sa=A.find(s=>s.name===n),sb=B.find(s=>s.name===n);
918
+ const ja=JSON.stringify(sa?.output??null),jb=JSON.stringify(sb?.output??null);
919
+ const same=ja===jb;
920
+ const did='df'+i;
921
+ return `<div>
922
+ <div class="tr-row flat" onclick="tog('${did}',this)">
923
+ <div class="tr-main"><span class="caret">▶</span><span class="chip node">${esc(n)}</span>
924
+ <span class="tr-name" style="color:${same?'var(--muted)':'var(--err)'}">${same?'≡ identical output':'≠ outputs differ'}</span></div>
925
+ <div class="tr-meta">${sa?.dur_ms??'—'} / ${sb?.dur_ms??'—'} ms</div>
926
+ </div>
927
+ <div class="tr-detail" id="${did}">
928
+ <div class="diffgrid">
929
+ <div class="kv"><div class="k">A · ${esc(n)}</div><pre>${esc(JSON.stringify(sa?.output??'(absent)',null,2))}</pre></div>
930
+ <div class="kv"><div class="k">B · ${esc(n)}</div><pre>${esc(JSON.stringify(sb?.output??'(absent)',null,2))}</pre></div>
931
+ </div>
932
+ </div></div>`;
933
+ }).join('');
934
+ $('#scrim').classList.add('on'); $('#drawer').classList.add('on');
935
+ };
936
+
782
937
  /* ---------- stats ---------- */
783
938
  function chartCard(title,daily,val,errKey){
784
939
  const max=Math.max(1,...daily.map(d=>val(d)||0));
@@ -818,6 +973,8 @@ async function loadStats(){
818
973
  <td class="r num">${m.completion_tokens!=null?fmtk(m.completion_tokens):'—'}</td>
819
974
  <td class="r num">${m.cost!=null?'$'+m.cost.toFixed(4):'—'}</td>
820
975
  <td class="r num mono">${fmtms(Math.round(m.avg_ms||0))}</td></tr>`).join('')}</tbody></table></div></div>`:''}
976
+ <div class="page-head"><h1 style="font-size:14px">Datasets — batch eval</h1></div>
977
+ <div class="card" style="margin-bottom:14px"><div id="ds-wrap" style="padding:6px 8px"><div class="empty" style="padding:14px">Loading…</div></div></div>
821
978
  <div class="page-head"><h1 style="font-size:14px">Average latency per node</h1></div>
822
979
  <div class="card">${(s.per_node||[]).map(n=>`<div class="latrow"><div class="latname">${esc(n.name)}</div>
823
980
  <div class="lattrack"><div class="latfill" style="width:${(n.avg_ms||0)/max*100}%"></div></div>
@@ -832,6 +989,24 @@ function fmtk(n){ return n<1000?String(n):(n/1000).toFixed(1)+'k'; }
832
989
  function ago(ms){ const s=(Date.now()-ms)/1000; if(s<60)return Math.round(s)+'s ago'; if(s<3600)return Math.round(s/60)+'m ago'; if(s<86400)return Math.round(s/3600)+'h ago'; return Math.round(s/86400)+'d ago'; }
833
990
  let tT; function toast(m){ const t=$('#toast'); t.textContent=m; t.classList.add('show'); clearTimeout(tT); tT=setTimeout(()=>t.classList.remove('show'),2600); }
834
991
 
992
+ async function loadDatasets(){
993
+ const w=document.getElementById('ds-wrap'); if(!w) return;
994
+ let ds=[]; try{ ds=await(await fetch('/api/datasets')).json(); }catch(e){}
995
+ w.innerHTML=ds.length?ds.map(d=>`<div class="scorerow">
996
+ <span class="chip node">${esc(d.name)}</span>
997
+ <span style="color:var(--muted);font-size:12px">${d.n_items} items</span>
998
+ <span class="del" title="delete" onclick="delDataset('${d.id}')">×</span>
999
+ <button class="btn ghost" style="margin-left:auto;padding:4px 12px" onclick="runDataset('${d.id}','${esc(d.name)}')">Run eval</button>
1000
+ </div>`).join('')
1001
+ :`<div class="empty" style="padding:14px"><div class="t">No datasets yet</div>POST <span class="mono">/api/datasets</span> with <span class="mono">{name, items:[{input:{…}, expected:…}]}</span> — runs land in a session with an <span class="mono">expected_match</span> score per item.</div>`;
1002
+ }
1003
+ window.runDataset=async(id,name)=>{
1004
+ const r=await fetch('/api/datasets/'+id+'/run',{method:'POST'});
1005
+ const d=await r.json();
1006
+ if(!r.ok) return toast(d.error||'eval failed to start');
1007
+ toast('Eval started: '+d.items+' items → session '+d.session);
1008
+ };
1009
+ window.delDataset=async(id)=>{ await fetch('/api/datasets/'+id,{method:'DELETE'}); loadDatasets(); };
835
1010
  loadGraph(); liveTopology();
836
1011
  /* deep links: #runs #sessions #stats #run=<id> */
837
1012
  (function(){ const h=location.hash.slice(1);
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
  import json, os, sqlite3, threading, time, uuid
14
14
  from typing import Any, Optional
15
15
 
16
- SCHEMA_VERSION = 4
16
+ SCHEMA_VERSION = 5
17
17
  _lock = threading.Lock()
18
18
 
19
19
 
@@ -56,6 +56,8 @@ class Store:
56
56
  CREATE TABLE IF NOT EXISTS scores(
57
57
  id TEXT PRIMARY KEY, run_id TEXT, name TEXT, value REAL,
58
58
  comment TEXT, source TEXT, created_ms INTEGER);
59
+ CREATE TABLE IF NOT EXISTS datasets(
60
+ id TEXT PRIMARY KEY, name TEXT UNIQUE, items TEXT, created_ms INTEGER);
59
61
  CREATE INDEX IF NOT EXISTS ix_spans_run ON spans(run_id, seq);
60
62
  CREATE INDEX IF NOT EXISTS ix_runs_started ON runs(started_ms DESC);
61
63
  CREATE INDEX IF NOT EXISTS ix_runs_session ON runs(session);
@@ -64,6 +66,8 @@ class Store:
64
66
  cols = [r[1] for r in c.execute("PRAGMA table_info(runs)").fetchall()]
65
67
  if "bookmarked" not in cols:
66
68
  c.execute("ALTER TABLE runs ADD COLUMN bookmarked INTEGER DEFAULT 0")
69
+ if "thread_id" not in cols:
70
+ c.execute("ALTER TABLE runs ADD COLUMN thread_id TEXT")
67
71
  try:
68
72
  c.execute("SELECT count(*) FROM json_each('[1]')")
69
73
  self.has_json1 = True
@@ -118,11 +122,12 @@ class Store:
118
122
  def open_run(self, run: dict) -> None:
119
123
  with _lock, self._conn() as c:
120
124
  c.execute("""INSERT OR REPLACE INTO runs
121
- (id,graph,source,status,session,tags,input,started_ms)
122
- VALUES(?,?,?,?,?,?,?,?)""",
125
+ (id,graph,source,status,session,tags,input,started_ms,thread_id)
126
+ VALUES(?,?,?,?,?,?,?,?,?)""",
123
127
  (run["id"], run.get("graph"), run.get("source", "ui"), "running",
124
128
  run.get("session"), json.dumps(run.get("tags")),
125
- json.dumps(run.get("input"), default=str), run["started_ms"]))
129
+ json.dumps(run.get("input"), default=str), run["started_ms"],
130
+ run.get("thread_id")))
126
131
 
127
132
  def add_span(self, s: dict) -> None:
128
133
  with _lock, self._conn() as c:
@@ -190,6 +195,36 @@ class Store:
190
195
  with _lock, self._conn() as c:
191
196
  return c.execute("DELETE FROM scores WHERE id=?", (score_id,)).rowcount > 0
192
197
 
198
+ # ---- datasets -----------------------------------------------------------
199
+ def add_dataset(self, name: str, items: list) -> dict:
200
+ ds = {"id": uuid.uuid4().hex[:12], "name": str(name),
201
+ "items": items, "created_ms": int(time.time() * 1000)}
202
+ with _lock, self._conn() as c:
203
+ c.execute("""INSERT OR REPLACE INTO datasets(id,name,items,created_ms)
204
+ VALUES(?,?,?,?)""",
205
+ (ds["id"], ds["name"], json.dumps(items, default=str), ds["created_ms"]))
206
+ return ds
207
+
208
+ def datasets(self) -> list[dict]:
209
+ with _lock, self._conn() as c:
210
+ rows = [dict(r) for r in c.execute(
211
+ "SELECT id,name,items,created_ms FROM datasets ORDER BY created_ms DESC").fetchall()]
212
+ for r in rows:
213
+ r["items"] = json.loads(r["items"]) if r.get("items") else []
214
+ r["n_items"] = len(r["items"])
215
+ return rows
216
+
217
+ def dataset(self, ds_id: str):
218
+ for d in self.datasets():
219
+ if d["id"] == ds_id or d["name"] == ds_id:
220
+ return d
221
+ return None
222
+
223
+ def delete_dataset(self, ds_id: str) -> bool:
224
+ with _lock, self._conn() as c:
225
+ return c.execute("DELETE FROM datasets WHERE id=? OR name=?",
226
+ (ds_id, ds_id)).rowcount > 0
227
+
193
228
  def prune(self, days: int) -> dict:
194
229
  """Delete runs (and their spans/scores/index rows) older than `days`."""
195
230
  cutoff = int((time.time() - days * 86400) * 1000)
@@ -179,7 +179,8 @@ class SpanBuilder(BaseCallbackHandler):
179
179
  self.tags = merged or None
180
180
  self._emit({"kind": "run_open", "run_id": self.run_id, "graph": self.run_name,
181
181
  "input": _trunc(inputs), "started_ms": int(self._t0 * 1000),
182
- "session": self.session, "tags": self.tags})
182
+ "session": self.session, "tags": self.tags,
183
+ "thread_id": md.get("thread_id")})
183
184
  if node:
184
185
  sid = uuid.uuid4().hex[:12]
185
186
  self._span_of[run_id] = sid
@@ -295,7 +296,8 @@ def apply_to_store(store, ev: dict, source: str = "ui") -> None:
295
296
  if kind == "run_open":
296
297
  store.open_run({"id": ev["run_id"], "graph": ev.get("graph"), "source": source,
297
298
  "session": ev.get("session"), "tags": ev.get("tags"),
298
- "input": ev.get("input"), "started_ms": ev["started_ms"]})
299
+ "input": ev.get("input"), "started_ms": ev["started_ms"],
300
+ "thread_id": ev.get("thread_id")})
299
301
  elif kind == "span":
300
302
  store.add_span(ev)
301
303
  elif kind == "run_close":
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