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.
- windhover-0.8.0/.github/workflows/ci.yml +19 -0
- {windhover-0.7.0 → windhover-0.8.0}/PKG-INFO +27 -4
- {windhover-0.7.0 → windhover-0.8.0}/README.md +26 -3
- {windhover-0.7.0 → windhover-0.8.0}/pyproject.toml +1 -1
- {windhover-0.7.0 → windhover-0.8.0}/tests/test_smoke.py +58 -0
- {windhover-0.7.0 → windhover-0.8.0}/windhover/config.py +2 -0
- {windhover-0.7.0 → windhover-0.8.0}/windhover/demo_graph.py +5 -1
- {windhover-0.7.0 → windhover-0.8.0}/windhover/server.py +122 -2
- {windhover-0.7.0 → windhover-0.8.0}/windhover/static/index.html +179 -4
- {windhover-0.7.0 → windhover-0.8.0}/windhover/store.py +39 -4
- {windhover-0.7.0 → windhover-0.8.0}/windhover/tracer.py +4 -2
- {windhover-0.7.0 → windhover-0.8.0}/.gitignore +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/LICENSE +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/SPEC.md +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/docs/graph.png +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/docs/logo.svg +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/docs/runs.png +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/docs/social-preview.png +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/docs/stats.png +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/docs/trace.png +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/tests/__init__.py +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/windhover/__init__.py +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/windhover/extract.py +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/windhover/pricing.json +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/windhover/static/icon.svg +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/windhover/static/manifest.json +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/windhover/static/vendor/cytoscape-dagre.js +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/windhover/static/vendor/cytoscape.min.js +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/windhover/static/vendor/dagre.min.js +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
- {windhover-0.7.0 → windhover-0.8.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
- {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.
|
|
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
|
|
47
|
-
WINDHOVER_GRAPH=windhover.demo_graph:graph
|
|
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
|
|
28
|
-
WINDHOVER_GRAPH=windhover.demo_graph:graph
|
|
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
|
+
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
|
-
|
|
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=
|
|
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.
|
|
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 =
|
|
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
|
|
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
|