windhover 0.11.0__tar.gz → 0.13.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 (36) hide show
  1. windhover-0.13.0/GUIDE.md +204 -0
  2. {windhover-0.11.0 → windhover-0.13.0}/PKG-INFO +11 -4
  3. {windhover-0.11.0 → windhover-0.13.0}/README.md +10 -3
  4. windhover-0.13.0/SPEC.md +61 -0
  5. windhover-0.13.0/docs/GUIDE.md +239 -0
  6. {windhover-0.11.0 → windhover-0.13.0}/pyproject.toml +1 -1
  7. {windhover-0.11.0 → windhover-0.13.0}/tests/test_smoke.py +112 -0
  8. windhover-0.13.0/windhover/config.py +63 -0
  9. {windhover-0.11.0 → windhover-0.13.0}/windhover/server.py +51 -2
  10. {windhover-0.11.0 → windhover-0.13.0}/windhover/static/index.html +21 -5
  11. {windhover-0.11.0 → windhover-0.13.0}/windhover/store.py +7 -0
  12. {windhover-0.11.0 → windhover-0.13.0}/windhover/tracer.py +233 -167
  13. windhover-0.11.0/SPEC.md +0 -83
  14. windhover-0.11.0/windhover/config.py +0 -34
  15. {windhover-0.11.0 → windhover-0.13.0}/.github/workflows/ci.yml +0 -0
  16. {windhover-0.11.0 → windhover-0.13.0}/.gitignore +0 -0
  17. {windhover-0.11.0 → windhover-0.13.0}/LICENSE +0 -0
  18. {windhover-0.11.0 → windhover-0.13.0}/docs/graph.png +0 -0
  19. {windhover-0.11.0 → windhover-0.13.0}/docs/logo.svg +0 -0
  20. {windhover-0.11.0 → windhover-0.13.0}/docs/runs.png +0 -0
  21. {windhover-0.11.0 → windhover-0.13.0}/docs/social-preview.png +0 -0
  22. {windhover-0.11.0 → windhover-0.13.0}/docs/stats.png +0 -0
  23. {windhover-0.11.0 → windhover-0.13.0}/docs/trace.png +0 -0
  24. {windhover-0.11.0 → windhover-0.13.0}/tests/__init__.py +0 -0
  25. {windhover-0.11.0 → windhover-0.13.0}/windhover/__init__.py +0 -0
  26. {windhover-0.11.0 → windhover-0.13.0}/windhover/demo_graph.py +0 -0
  27. {windhover-0.11.0 → windhover-0.13.0}/windhover/extract.py +0 -0
  28. {windhover-0.11.0 → windhover-0.13.0}/windhover/pricing.json +0 -0
  29. {windhover-0.11.0 → windhover-0.13.0}/windhover/static/icon.svg +0 -0
  30. {windhover-0.11.0 → windhover-0.13.0}/windhover/static/manifest.json +0 -0
  31. {windhover-0.11.0 → windhover-0.13.0}/windhover/static/vendor/cytoscape-dagre.js +0 -0
  32. {windhover-0.11.0 → windhover-0.13.0}/windhover/static/vendor/cytoscape.min.js +0 -0
  33. {windhover-0.11.0 → windhover-0.13.0}/windhover/static/vendor/dagre.min.js +0 -0
  34. {windhover-0.11.0 → windhover-0.13.0}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
  35. {windhover-0.11.0 → windhover-0.13.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
  36. {windhover-0.11.0 → windhover-0.13.0}/windhover/static/vendor/fonts/fraunces-600.woff2 +0 -0
@@ -0,0 +1,204 @@
1
+ # Windhover guide — features, how-tos, and the fine print
2
+
3
+ Everything Windhover shows comes from your graph. That has one important consequence,
4
+ worth understanding before anything else:
5
+
6
+ ## Feature availability — panels light up when your graph supports them
7
+
8
+ Windhover detects what your graph provides and shows only the views that apply.
9
+ An "absent" tab is not a bug — it means the graph doesn't carry that capability yet.
10
+
11
+ | You see… | …when your graph has |
12
+ |----------------------------------|-------------------------------------------------------------|
13
+ | Graph / Runs / Stats / Sessions | always |
14
+ | Node source panels | a local graph (`WINDHOVER_GRAPH`) whose nodes are plain Python (inspectable) |
15
+ | **Memory** tab | `compile(store=…)` — any LangGraph `BaseStore` |
16
+ | **time-travel**, thread chips | `compile(checkpointer=…)` |
17
+ | Resume / breakpoints / state edit / fork | a checkpointer (they all operate on threads) |
18
+ | **X-ray** toggle | subgraphs (`get_graph(xray=True)` differs) |
19
+ | Progress toasts during a run | nodes that call `get_stream_writer()` |
20
+ | "Model typing" live output | an LLM constructed with `streaming=True` |
21
+ | TTFT on LLM spans | streaming calls (first-token time only exists when tokens stream) |
22
+ | Edge labels on the canvas | conditional edges whose branch names differ from their targets |
23
+ | Node metadata in the node pane | `add_node("x", fn, metadata={…})` |
24
+ | Runtime-context box on New run | a graph compiled with a `context_schema` |
25
+
26
+ Minimal fully-featured compile:
27
+
28
+ ```python
29
+ from langgraph.checkpoint.memory import MemorySaver
30
+ from langgraph.store.memory import InMemoryStore
31
+
32
+ graph = builder.compile(checkpointer=MemorySaver(), store=InMemoryStore())
33
+ ```
34
+
35
+ ### Persistence caveat — read this one
36
+
37
+ `MemorySaver` and `InMemoryStore` live **inside the server process**. Restart the
38
+ server and threads, checkpoints, and memory items are gone (runs and spans are NOT —
39
+ they live in Windhover's own SQLite). For durable threads/memory use LangGraph's
40
+ persistent backends:
41
+
42
+ ```python
43
+ from langgraph.checkpoint.sqlite import SqliteSaver # pip install langgraph-checkpoint-sqlite
44
+ graph = builder.compile(checkpointer=SqliteSaver.from_conn_string("checkpoints.db"))
45
+ # or PostgresSaver / PostgresStore from langgraph-checkpoint-postgres
46
+ ```
47
+
48
+ Also: runs recorded **before** you added a checkpointer have no thread id, so they
49
+ never grow time-travel buttons retroactively. Same for any field added by an upgrade —
50
+ old rows simply don't have it.
51
+
52
+ ## How-to: observe a local graph
53
+
54
+ ```bash
55
+ pip install windhover langgraph
56
+ WINDHOVER_GRAPH="myapp.graphs:graph" WINDHOVER_GRAPH_DIR=/path/to/app windhover
57
+ ```
58
+
59
+ Projects with a **`langgraph.json`** (the LangGraph Studio/CLI convention) need no flags —
60
+ run `windhover` in the project directory and it uses the file's first graph.
61
+
62
+ - The canvas always reflects **current-on-disk** topology (a subprocess re-extracts on
63
+ file change). Runs, however, execute the graph **imported at startup** — restart the
64
+ server to run new code. The "Graph definition changed" toast is the tell.
65
+ - **New run** builds its input form from your graph's own state schema. Blank template
66
+ fields mean your state type isn't introspectable (e.g. `dict`) — you can still paste
67
+ any JSON.
68
+
69
+ ## How-to: trace from your own app (no local graph needed)
70
+
71
+ ```python
72
+ from windhover import WindhoverTracer
73
+
74
+ graph.invoke(inputs, config={
75
+ "callbacks": [WindhoverTracer("http://observability-host:8090")],
76
+ "metadata": {
77
+ "windhover_session": "checkout-7431", # groups runs into a session
78
+ "windhover_tags": ["prod", "eu"], # filterable tags
79
+ "windhover_run_name": "checkout-flow", # display name in Runs
80
+ },
81
+ })
82
+ ```
83
+
84
+ - Everything is standard LangChain config — no other Windhover imports.
85
+ - The tracer is best-effort and non-blocking: if the Windhover host is down, your
86
+ graph runs normally and the trace is simply lost.
87
+ - External runs show `ingest` as their source. They have full traces, sessions,
88
+ scores, search — but **no source panels, HITL, or time-travel** (those need the
89
+ graph running inside the Windhover server).
90
+
91
+ ## How-to: human-in-the-loop
92
+
93
+ All five controls are plain LangGraph primitives — Windhover adds no magic, only UI.
94
+
95
+ 1. **Ask a human mid-run** — in a node:
96
+ ```python
97
+ from langgraph.types import interrupt
98
+ def payout(state):
99
+ ok = interrupt({"question": f"approve ${state['amount']}?"})
100
+ if not ok:
101
+ return {"status": "rejected"}
102
+ ...
103
+ ```
104
+ The run turns amber **interrupted**; its drawer shows the question with a respond
105
+ box. Answers parse as JSON when possible (`true`, `42`, `{"a":1}`), else as text.
106
+ 2. **Static breakpoints** — pick "pause before" chips on New run (`interrupt_before`).
107
+ Handy for inspecting state before an expensive node. Resume continues.
108
+ 3. **Redirect** — the goto picker sends execution to any node (`Command(goto=…)`).
109
+ 4. **Edit state** — in time-travel, ✎ on any checkpoint patches values via
110
+ `update_state`; then Resume continues *on the edited state*.
111
+ 5. **Fork** — ⑂ re-runs from any historical checkpoint, branching the thread.
112
+
113
+ Fine print:
114
+ - Resuming records a **new run** on the same thread, tagged `resume` — traces stay
115
+ immutable; the thread ties them together.
116
+ - A pause is **not** an error: LangGraph delivers interrupts as a `GraphInterrupt`
117
+ exception internally; Windhover records the node span as `interrupted`, not failed.
118
+ - Answering an interrupt re-executes the interrupted node **from its start** (that's
119
+ LangGraph's contract) — keep side effects before an `interrupt()` idempotent.
120
+
121
+ ## How-to: long-term memory (the Memory tab)
122
+
123
+ ```python
124
+ from langgraph.config import get_store
125
+
126
+ def remember(state):
127
+ store = get_store()
128
+ store.put(("users", state["user_id"]), "preferences", {"tone": "brief"})
129
+ ```
130
+
131
+ The Memory tab lists namespaces and their items with search. **It populates only when
132
+ your nodes actually write to the store** — an empty tab on a fresh graph is expected.
133
+ Windhover is read-only here: it browses, it never writes or deletes memories.
134
+
135
+ ## How-to: datasets & batch eval
136
+
137
+ ```bash
138
+ curl -X POST :8090/api/datasets -H 'Content-Type: application/json' -d '{
139
+ "name": "golden",
140
+ "items": [
141
+ {"input": {"question": "capital of France?"}, "expected": "Paris"},
142
+ {"input": {"question": "2+2?"}, "expected": 4}
143
+ ]}'
144
+ ```
145
+
146
+ "Run eval" (Stats page) executes the local graph per item; each run lands in an
147
+ `eval:golden:<timestamp>` session with an `expected_match` score: exact match for
148
+ numbers/booleans, substring-of-output-JSON for strings. It's a smoke-level matcher —
149
+ for semantic grading, run your own judge and `POST /api/runs/{id}/scores`.
150
+
151
+ ## More things the audit covered
152
+
153
+ - **Concurrency & batch** — one tracer instance safely handles parallel `.invoke()`s:
154
+ every root execution becomes its own run. (`graph.batch()` shares a single LangChain
155
+ root, so a batch records as one run — use separate invokes when you want separate runs.)
156
+ - **Bare LangChain (no graph)** — `llm.invoke(..., config={"callbacks": [tracer]})` with
157
+ no chain around it opens an implicit run: Windhover works for plain pipelines too.
158
+ - **Functional API** — `@entrypoint`/`@task` graphs trace fully (tasks appear as node
159
+ spans). The canvas shows only the entrypoint — tasks are dynamic calls, not static
160
+ topology; the trace is where their structure lives.
161
+ - **Node caching** — LangGraph `CachePolicy` cache hits fire **no callbacks**; for local
162
+ runs Windhover synthesizes the span with a `cached` marker so the trace stays complete.
163
+ External-tracer apps can't see cache hits at all (there's nothing to observe).
164
+ - **Conversations** — payloads shaped like message lists (`role`/`content`) render as a
165
+ chat transcript instead of raw JSON — LangChain messages are captured in that shape
166
+ automatically, tool calls included.
167
+
168
+ ## How-to: everything else, briefly
169
+
170
+ - **Custom events**: `from langchain_core.callbacks import dispatch_custom_event;
171
+ dispatch_custom_event("cache-refreshed", {"rows": 1200})` inside any node/tool →
172
+ an event marker in the trace, parented where it fired.
173
+ - **Progress**: `from langgraph.config import get_stream_writer;
174
+ get_stream_writer()({"step": "embedding", "pct": 40})` → live toasts during a run.
175
+ (Writer output only flows in streaming executions — Windhover's local runs always
176
+ stream, but your own `.invoke()` elsewhere won't emit it.)
177
+ - **Errors**: open a failed run → full traceback; the failing node is red on replay;
178
+ "view source" highlights the throwing line inside the node's own code.
179
+ - **Compare**: open a run → `compare` → open a second run → `compare` again: node-by-node
180
+ output diff with duration/token deltas.
181
+ - **Search**: full-text over prompts/payloads/errors. Uses SQLite FTS5 when available,
182
+ transparently falls back to LIKE scans on minimal SQLite builds (slower, same results).
183
+ - **Cost**: longest-prefix match against `windhover/pricing.json` ($/1M tokens). Unknown
184
+ model → cost shows `—`, never a guess. Cached/reasoning token counts display on the
185
+ model line but aren't priced separately.
186
+ - **Structured output**: function-calling responses have empty text content; Windhover
187
+ shows the tool-call JSON as the LLM output instead of an empty box.
188
+ - **Auth**: set `WINDHOVER_TOKEN` to require `Authorization: Bearer …` (or `?token=`)
189
+ on `/api`. The UI prompts once and remembers. The HTML shell itself stays public —
190
+ it contains no data.
191
+ - **Retention**: `WINDHOVER_RETENTION_DAYS=30` prunes old runs on startup and every 6h.
192
+ Default keeps everything.
193
+ - **Deep links**: `#runs`, `#sessions`, `#stats`, `#run=<id>`.
194
+
195
+ ## Try it all on the demo graph
196
+
197
+ `WINDHOVER_GRAPH=windhover.demo_graph:graph windhover`, then:
198
+
199
+ - `{"n": 4}` — normal run: parallel fan-out, state evolution, memory write.
200
+ - `{"n": -3}` — error forensics: red node, traceback, highlighted source line.
201
+ - `{"n": 200}` — HITL: pauses asking "grow 200 → 600?"; answer `true` or `false`
202
+ in the drawer.
203
+ - After a few runs: Memory tab (`demo/summaries`), time-travel on any run's thread,
204
+ compare two runs with different `n`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: windhover
3
- Version: 0.11.0
3
+ Version: 0.13.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
@@ -17,7 +17,7 @@ Requires-Dist: langgraph>=0.2; extra == 'dev'
17
17
  Requires-Dist: pytest; extra == 'dev'
18
18
  Description-Content-Type: text/markdown
19
19
 
20
- <p align="center"><img src="docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
20
+ <p align="center"><img src="https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
21
21
  <h1 align="center">Windhover</h1>
22
22
  <p align="center">
23
23
  <a href="https://pypi.org/project/windhover/"><img src="https://img.shields.io/pypi/v/windhover" alt="PyPI"></a>
@@ -40,11 +40,11 @@ your own app. No LangSmith account, no cloud tunnel, no fragile websocket. HTTP
40
40
 
41
41
  | Living graph (parallel fan-out) | Trace drawer — retrievers, LLM calls, cost, state |
42
42
  |---|---|
43
- | ![Graph view](docs/graph.png) | ![Trace drawer](docs/trace.png) |
43
+ | ![Graph view](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/graph.png) | ![Trace drawer](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/trace.png) |
44
44
 
45
45
  | Runs — search, tags, sessions, interrupts | Dashboards — per-day, per-model |
46
46
  |---|---|
47
- | ![Runs table](docs/runs.png) | ![Stats](docs/stats.png) |
47
+ | ![Runs table](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/runs.png) | ![Stats](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/stats.png) |
48
48
 
49
49
  ## Quick start
50
50
  ```bash
@@ -139,6 +139,13 @@ curl -X POST :8090/api/runs/RUN_ID/scores -H 'Content-Type: application/json' \
139
139
  `/api` routes; the UI prompts once and remembers it).
140
140
  Edit `windhover/pricing.json` for your models' $/1M rates (unknown model → cost null).
141
141
 
142
+ ## Docs
143
+ **[The guide](docs/GUIDE.md)** covers every feature with how-tos and the fine print —
144
+ including the most important nuance: *panels light up based on what your graph supports*
145
+ (Memory needs a `store=`, time-travel/HITL need a `checkpointer=`, X-ray needs subgraphs,
146
+ live typing needs `streaming=True`). An absent tab isn't a bug — it's a graph without that
147
+ capability. [SPEC.md](SPEC.md) has the architecture.
148
+
142
149
  ## Notes
143
150
  Runs use the imported graph (restart to run new code); the *view* always reflects
144
151
  current-on-disk topology. All frontend assets are vendored — no CDN, works fully offline.
@@ -1,4 +1,4 @@
1
- <p align="center"><img src="docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
1
+ <p align="center"><img src="https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
2
2
  <h1 align="center">Windhover</h1>
3
3
  <p align="center">
4
4
  <a href="https://pypi.org/project/windhover/"><img src="https://img.shields.io/pypi/v/windhover" alt="PyPI"></a>
@@ -21,11 +21,11 @@ your own app. No LangSmith account, no cloud tunnel, no fragile websocket. HTTP
21
21
 
22
22
  | Living graph (parallel fan-out) | Trace drawer — retrievers, LLM calls, cost, state |
23
23
  |---|---|
24
- | ![Graph view](docs/graph.png) | ![Trace drawer](docs/trace.png) |
24
+ | ![Graph view](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/graph.png) | ![Trace drawer](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/trace.png) |
25
25
 
26
26
  | Runs — search, tags, sessions, interrupts | Dashboards — per-day, per-model |
27
27
  |---|---|
28
- | ![Runs table](docs/runs.png) | ![Stats](docs/stats.png) |
28
+ | ![Runs table](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/runs.png) | ![Stats](https://raw.githubusercontent.com/justfeltlikerunning/windhover/main/docs/stats.png) |
29
29
 
30
30
  ## Quick start
31
31
  ```bash
@@ -120,6 +120,13 @@ curl -X POST :8090/api/runs/RUN_ID/scores -H 'Content-Type: application/json' \
120
120
  `/api` routes; the UI prompts once and remembers it).
121
121
  Edit `windhover/pricing.json` for your models' $/1M rates (unknown model → cost null).
122
122
 
123
+ ## Docs
124
+ **[The guide](docs/GUIDE.md)** covers every feature with how-tos and the fine print —
125
+ including the most important nuance: *panels light up based on what your graph supports*
126
+ (Memory needs a `store=`, time-travel/HITL need a `checkpointer=`, X-ray needs subgraphs,
127
+ live typing needs `streaming=True`). An absent tab isn't a bug — it's a graph without that
128
+ capability. [SPEC.md](SPEC.md) has the architecture.
129
+
123
130
  ## Notes
124
131
  Runs use the imported graph (restart to run new code); the *view* always reflects
125
132
  current-on-disk topology. All frontend assets are vendored — no CDN, works fully offline.
@@ -0,0 +1,61 @@
1
+ # Windhover — engineering spec (current as of v0.11)
2
+
3
+ Self-hosted LangGraph/LangChain observability + human-in-the-loop console.
4
+ Code is the only source of truth; Windhover observes and, for HITL, drives the
5
+ graph **only through native LangGraph primitives** (interrupt/resume, goto,
6
+ breakpoints, update_state, checkpoint forking). No visual editing, ever.
7
+
8
+ ## Design principles
9
+ 1. **Observe, never define.** Topology, schemas, source, memory — all derive from
10
+ the user's compiled graph. Features appear only when the graph supports them.
11
+ 2. **One tracer, two sinks.** `SpanBuilder` handles every LangChain callback and
12
+ feeds either the local DB sink or the HTTP ingest sink — identical traces
13
+ wherever the run happened.
14
+ 3. **Runs are durable records.** Execution happens off the request thread; a run
15
+ persists even if the browser disconnects. Resumes are NEW runs on the same
16
+ thread — traces are immutable.
17
+ 4. **Degrade gracefully.** No graph → ingest-only collector. No FTS5/json1 →
18
+ LIKE fallbacks. Unknown model → cost null. Interrupt → pause, not error.
19
+ 5. **Real package.** pip-installable (`windhover`), CI-tested 3.10–3.12, all
20
+ frontend assets vendored (offline-capable), MIT.
21
+
22
+ ## Architecture
23
+ - **FastAPI + SSE** (no websockets). Single-file UI (`static/index.html`) +
24
+ vendored cytoscape/dagre/fonts.
25
+ - **Topology**: subprocess (`windhover.extract`) re-imports the graph on file
26
+ mtime change → nodes/edges (+labels/metadata), input & context schemas,
27
+ per-node source (inspect, unwrapped), x-ray variant; sha1-hash change bumps a
28
+ version pushed over `/api/events` SSE.
29
+ - **Tracer** (`windhover.tracer.SpanBuilder`): chains→node spans (langgraph_node
30
+ metadata), LLM (params, tools offered, tokens+details, TTFT, streaming
31
+ partials every ~0.5 s), tools, retrievers, retries, custom events,
32
+ GraphInterrupt→interrupted, session/tags/run_name/thread_id via config
33
+ metadata. Best-effort: never raises into the user's graph.
34
+ - **Store** (`windhover.store`, SQLite WAL, schema v7): runs / spans (tree) /
35
+ scores / datasets (+span_fts). Feature-detects FTS5 & json1 at startup;
36
+ idempotent column migrations.
37
+ - **HITL** (`/api/threads/*`): thin wrappers over `Command(resume|goto)`,
38
+ `interrupt_before/after`, `update_state`, `get_state_history`, checkpoint-id
39
+ forking. Requires the local graph to have a checkpointer.
40
+ - **Auth**: optional `WINDHOVER_TOKEN` Bearer/?token= gate on `/api` only.
41
+
42
+ ## Data model (schema v7)
43
+ `runs`: id · graph · source(ui|ingest) · status(running|done|error|interrupted) ·
44
+ session · tags(json) · thread_id · input(json) · error · timing · aggregates
45
+ (node_count, llm_calls, tokens, cost_usd) · bookmarked.
46
+ `spans`: id · run_id · parent_id · seq · type(node|llm|tool|retriever|event|
47
+ interrupt) · name · status(ok|error|interrupted|running←streaming partial) ·
48
+ timing/offset/dur · input/output(json) · model · tokens · cost_usd · error
49
+ (full traceback) · retries · ttft_ms · usage_detail(json) · params(json).
50
+ `scores`: id · run_id · name · value · comment · source.
51
+ `datasets`: id · name · items(json).
52
+
53
+ ## API surface
54
+ Observe: `/api/graph` `/api/schema` `/api/runs[…filters]` `/api/runs/{id}`
55
+ `/api/nodes/{name}[/source]` `/api/sessions` `/api/stats` `/api/export`
56
+ `/api/memory/namespaces` `/api/memory/items` `/api/events` (SSE).
57
+ Act: `/api/run` (SSE; `_session/_tags/_thread/_interrupt_before/_interrupt_after/
58
+ _configurable`), `/api/ingest`, `PATCH /api/runs/{id}`, scores CRUD, datasets
59
+ CRUD + `/run`, `/api/threads/{id}/history|resume|state`.
60
+
61
+ User-facing behavior, nuances, and how-tos: see `docs/GUIDE.md`.
@@ -0,0 +1,239 @@
1
+ # Windhover guide — features, how-tos, and the fine print
2
+
3
+ Everything Windhover shows comes from your graph. That has one important consequence,
4
+ worth understanding before anything else:
5
+
6
+ ## Feature availability — panels light up when your graph supports them
7
+
8
+ Windhover detects what your graph provides and shows only the views that apply.
9
+ An "absent" tab is not a bug — it means the graph doesn't carry that capability yet.
10
+
11
+ | You see… | …when your graph has |
12
+ |----------------------------------|-------------------------------------------------------------|
13
+ | Graph / Runs / Stats / Sessions | always |
14
+ | Node source panels | a local graph (`WINDHOVER_GRAPH`) whose nodes are plain Python (inspectable) |
15
+ | **Memory** tab | `compile(store=…)` — any LangGraph `BaseStore` |
16
+ | **time-travel**, thread chips | `compile(checkpointer=…)` |
17
+ | Resume / breakpoints / state edit / fork | a checkpointer (they all operate on threads) |
18
+ | **X-ray** toggle | subgraphs (`get_graph(xray=True)` differs) |
19
+ | Progress toasts during a run | nodes that call `get_stream_writer()` |
20
+ | "Model typing" live output | an LLM constructed with `streaming=True` |
21
+ | TTFT on LLM spans | streaming calls (first-token time only exists when tokens stream) |
22
+ | Edge labels on the canvas | conditional edges whose branch names differ from their targets |
23
+ | Node metadata in the node pane | `add_node("x", fn, metadata={…})` |
24
+ | Runtime-context box on New run | a graph compiled with a `context_schema` |
25
+
26
+ Minimal fully-featured compile:
27
+
28
+ ```python
29
+ from langgraph.checkpoint.memory import MemorySaver
30
+ from langgraph.store.memory import InMemoryStore
31
+
32
+ graph = builder.compile(checkpointer=MemorySaver(), store=InMemoryStore())
33
+ ```
34
+
35
+ ### Persistence caveat — read this one
36
+
37
+ `MemorySaver` and `InMemoryStore` live **inside the server process**. Restart the
38
+ server and threads, checkpoints, and memory items are gone (runs and spans are NOT —
39
+ they live in Windhover's own SQLite). For durable threads/memory use LangGraph's
40
+ persistent backends:
41
+
42
+ ```python
43
+ from langgraph.checkpoint.sqlite import SqliteSaver # pip install langgraph-checkpoint-sqlite
44
+ graph = builder.compile(checkpointer=SqliteSaver.from_conn_string("checkpoints.db"))
45
+ # or PostgresSaver / PostgresStore from langgraph-checkpoint-postgres
46
+ ```
47
+
48
+ Also: runs recorded **before** you added a checkpointer have no thread id, so they
49
+ never grow time-travel buttons retroactively. Same for any field added by an upgrade —
50
+ old rows simply don't have it.
51
+
52
+ ## How-to: observe a local graph
53
+
54
+ ```bash
55
+ pip install windhover langgraph
56
+ WINDHOVER_GRAPH="myapp.graphs:graph" WINDHOVER_GRAPH_DIR=/path/to/app windhover
57
+ ```
58
+
59
+ Projects with a **`langgraph.json`** (the LangGraph Studio/CLI convention) need no flags —
60
+ run `windhover` in the project directory and it uses the file's first graph.
61
+
62
+ - The canvas always reflects **current-on-disk** topology (a subprocess re-extracts on
63
+ file change). Runs, however, execute the graph **imported at startup** — restart the
64
+ server to run new code. The "Graph definition changed" toast is the tell.
65
+ - **New run** builds its input form from your graph's own state schema. Blank template
66
+ fields mean your state type isn't introspectable (e.g. `dict`) — you can still paste
67
+ any JSON.
68
+
69
+ ## How-to: trace from your own app (no local graph needed)
70
+
71
+ ```python
72
+ from windhover import WindhoverTracer
73
+
74
+ graph.invoke(inputs, config={
75
+ "callbacks": [WindhoverTracer("http://observability-host:8090")],
76
+ "metadata": {
77
+ "windhover_session": "checkout-7431", # groups runs into a session
78
+ "windhover_tags": ["prod", "eu"], # filterable tags
79
+ "windhover_run_name": "checkout-flow", # display name in Runs
80
+ },
81
+ })
82
+ ```
83
+
84
+ - Everything is standard LangChain config — no other Windhover imports.
85
+ - The tracer is best-effort and non-blocking: if the Windhover host is down, your
86
+ graph runs normally and the trace is simply lost.
87
+ - External runs show `ingest` as their source. They have full traces, sessions,
88
+ scores, search — but **no source panels, HITL, or time-travel** (those need the
89
+ graph running inside the Windhover server).
90
+
91
+ ## How-to: human-in-the-loop
92
+
93
+ All five controls are plain LangGraph primitives — Windhover adds no magic, only UI.
94
+
95
+ 1. **Ask a human mid-run** — in a node:
96
+ ```python
97
+ from langgraph.types import interrupt
98
+ def payout(state):
99
+ ok = interrupt({"question": f"approve ${state['amount']}?"})
100
+ if not ok:
101
+ return {"status": "rejected"}
102
+ ...
103
+ ```
104
+ The run turns amber **interrupted**; its drawer shows the question with a respond
105
+ box. Answers parse as JSON when possible (`true`, `42`, `{"a":1}`), else as text.
106
+ 2. **Static breakpoints** — pick "pause before" chips on New run (`interrupt_before`).
107
+ Handy for inspecting state before an expensive node. Resume continues.
108
+ 3. **Redirect** — the goto picker sends execution to any node (`Command(goto=…)`).
109
+ 4. **Edit state** — in time-travel, ✎ on any checkpoint patches values via
110
+ `update_state`; then Resume continues *on the edited state*.
111
+ 5. **Fork** — ⑂ re-runs from any historical checkpoint, branching the thread.
112
+
113
+ Fine print:
114
+ - Resuming records a **new run** on the same thread, tagged `resume` — traces stay
115
+ immutable; the thread ties them together.
116
+ - A pause is **not** an error: LangGraph delivers interrupts as a `GraphInterrupt`
117
+ exception internally; Windhover records the node span as `interrupted`, not failed.
118
+ - Answering an interrupt re-executes the interrupted node **from its start** (that's
119
+ LangGraph's contract) — keep side effects before an `interrupt()` idempotent.
120
+
121
+ ## How-to: long-term memory (the Memory tab)
122
+
123
+ ```python
124
+ from langgraph.config import get_store
125
+
126
+ def remember(state):
127
+ store = get_store()
128
+ store.put(("users", state["user_id"]), "preferences", {"tone": "brief"})
129
+ ```
130
+
131
+ The Memory tab lists namespaces and their items with search. **It populates only when
132
+ your nodes actually write to the store** — an empty tab on a fresh graph is expected.
133
+ Windhover is read-only here: it browses, it never writes or deletes memories.
134
+
135
+ ## How-to: datasets & batch eval
136
+
137
+ ```bash
138
+ curl -X POST :8090/api/datasets -H 'Content-Type: application/json' -d '{
139
+ "name": "golden",
140
+ "items": [
141
+ {"input": {"question": "capital of France?"}, "expected": "Paris"},
142
+ {"input": {"question": "2+2?"}, "expected": 4}
143
+ ]}'
144
+ ```
145
+
146
+ "Run eval" (Stats page) executes the local graph per item; each run lands in an
147
+ `eval:golden:<timestamp>` session with an `expected_match` score: exact match for
148
+ numbers/booleans, substring-of-output-JSON for strings. It's a smoke-level matcher —
149
+ for semantic grading, run your own judge and `POST /api/runs/{id}/scores`.
150
+
151
+ ## More things the audit covered
152
+
153
+ - **Concurrency & batch** — one tracer instance safely handles parallel `.invoke()`s:
154
+ every root execution becomes its own run. (`graph.batch()` shares a single LangChain
155
+ root, so a batch records as one run — use separate invokes when you want separate runs.)
156
+ - **Bare LangChain (no graph)** — `llm.invoke(..., config={"callbacks": [tracer]})` with
157
+ no chain around it opens an implicit run: Windhover works for plain pipelines too.
158
+ - **Functional API** — `@entrypoint`/`@task` graphs trace fully (tasks appear as node
159
+ spans). The canvas shows only the entrypoint — tasks are dynamic calls, not static
160
+ topology; the trace is where their structure lives.
161
+ - **Node caching** — LangGraph `CachePolicy` cache hits fire **no callbacks**; for local
162
+ runs Windhover synthesizes the span with a `cached` marker so the trace stays complete.
163
+ External-tracer apps can't see cache hits at all (there's nothing to observe).
164
+ - **Conversations** — payloads shaped like message lists (`role`/`content`) render as a
165
+ chat transcript instead of raw JSON — LangChain messages are captured in that shape
166
+ automatically, tool calls included.
167
+
168
+ ## How-to: everything else, briefly
169
+
170
+ - **Custom events**: `from langchain_core.callbacks import dispatch_custom_event;
171
+ dispatch_custom_event("cache-refreshed", {"rows": 1200})` inside any node/tool →
172
+ an event marker in the trace, parented where it fired.
173
+ - **Progress**: `from langgraph.config import get_stream_writer;
174
+ get_stream_writer()({"step": "embedding", "pct": 40})` → live toasts during a run.
175
+ (Writer output only flows in streaming executions — Windhover's local runs always
176
+ stream, but your own `.invoke()` elsewhere won't emit it.)
177
+ - **Errors**: open a failed run → full traceback; the failing node is red on replay;
178
+ "view source" highlights the throwing line inside the node's own code.
179
+ - **Compare**: open a run → `compare` → open a second run → `compare` again: node-by-node
180
+ output diff with duration/token deltas.
181
+ - **Search**: full-text over prompts/payloads/errors. Uses SQLite FTS5 when available,
182
+ transparently falls back to LIKE scans on minimal SQLite builds (slower, same results).
183
+ - **Cost**: longest-prefix match against `windhover/pricing.json` ($/1M tokens). Unknown
184
+ model → cost shows `—`, never a guess. Cached/reasoning token counts display on the
185
+ model line but aren't priced separately.
186
+ - **Structured output**: function-calling responses have empty text content; Windhover
187
+ shows the tool-call JSON as the LLM output instead of an empty box.
188
+ - **Auth**: set `WINDHOVER_TOKEN` to require `Authorization: Bearer …` (or `?token=`)
189
+ on `/api`. The UI prompts once and remembers. The HTML shell itself stays public —
190
+ it contains no data. External tracers pass it as `WindhoverTracer(url, token="…")`.
191
+ **Set the token before exposing Windhover beyond localhost** — the HITL endpoints can
192
+ resume and edit graph state.
193
+ - **Alerts**: `WINDHOVER_WEBHOOK=https://…` POSTs a compact JSON summary whenever a run
194
+ errors or pauses on an interrupt (`{source, graph, run_id, status, error, text, …}`) —
195
+ point it at a Slack/Discord webhook or your own receiver. Fire-and-forget.
196
+ - **Retention**: `WINDHOVER_RETENTION_DAYS=30` prunes old runs on startup and every 6h.
197
+ Default keeps everything.
198
+ - **Deep links**: `#runs`, `#sessions`, `#stats`, `#run=<id>`.
199
+
200
+ ## Running it for real
201
+
202
+ - **The tracer never slows your app.** Events go onto a bounded in-memory queue drained
203
+ by a background thread; if the Windhover host is slow or down, events are dropped —
204
+ your pipeline's latency is unchanged. (Sheds oldest-first at 2,000 queued events.)
205
+ - **Systemd** (adjust paths):
206
+ ```ini
207
+ [Unit]
208
+ Description=Windhover
209
+ After=network.target
210
+
211
+ [Service]
212
+ WorkingDirectory=/opt/myapp
213
+ Environment=WINDHOVER_GRAPH=myapp.graphs:graph
214
+ Environment=WINDHOVER_DB=/var/lib/windhover/windhover.db
215
+ Environment=WINDHOVER_TOKEN=change-me
216
+ Environment=WINDHOVER_RETENTION_DAYS=30
217
+ ExecStart=/opt/myapp/.venv/bin/windhover
218
+ Restart=always
219
+ TimeoutStopSec=10
220
+
221
+ [Install]
222
+ WantedBy=multi-user.target
223
+ ```
224
+ `TimeoutStopSec` matters: open SSE connections otherwise stretch restarts.
225
+ - **Reverse proxy**: plain HTTP + SSE — any proxy works; disable response buffering for
226
+ `/api/events` and `/api/run` (nginx: `proxy_buffering off;`).
227
+ - **Backups**: runs live in one SQLite file (`WINDHOVER_DB`); copy it (plus `-wal`) or
228
+ rely on `/api/export` for tabular run data.
229
+
230
+ ## Try it all on the demo graph
231
+
232
+ `WINDHOVER_GRAPH=windhover.demo_graph:graph windhover`, then:
233
+
234
+ - `{"n": 4}` — normal run: parallel fan-out, state evolution, memory write.
235
+ - `{"n": -3}` — error forensics: red node, traceback, highlighted source line.
236
+ - `{"n": 200}` — HITL: pauses asking "grow 200 → 600?"; answer `true` or `false`
237
+ in the drawer.
238
+ - After a few runs: Memory tab (`demo/summaries`), time-travel on any run's thread,
239
+ compare two runs with different `n`.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "windhover"
7
- version = "0.11.0"
7
+ version = "0.13.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"