windhover 0.10.0__tar.gz → 0.12.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.12.0/GUIDE.md +204 -0
- {windhover-0.10.0 → windhover-0.12.0}/PKG-INFO +14 -3
- {windhover-0.10.0 → windhover-0.12.0}/README.md +13 -2
- windhover-0.12.0/SPEC.md +61 -0
- windhover-0.12.0/docs/GUIDE.md +204 -0
- {windhover-0.10.0 → windhover-0.12.0}/pyproject.toml +1 -1
- {windhover-0.10.0 → windhover-0.12.0}/tests/test_smoke.py +156 -0
- windhover-0.12.0/windhover/config.py +61 -0
- {windhover-0.10.0 → windhover-0.12.0}/windhover/extract.py +18 -3
- {windhover-0.10.0 → windhover-0.12.0}/windhover/server.py +28 -3
- {windhover-0.10.0 → windhover-0.12.0}/windhover/static/index.html +39 -8
- {windhover-0.10.0 → windhover-0.12.0}/windhover/store.py +7 -6
- {windhover-0.10.0 → windhover-0.12.0}/windhover/tracer.py +216 -135
- windhover-0.10.0/SPEC.md +0 -83
- windhover-0.10.0/windhover/config.py +0 -34
- {windhover-0.10.0 → windhover-0.12.0}/.github/workflows/ci.yml +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/.gitignore +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/LICENSE +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/docs/graph.png +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/docs/logo.svg +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/docs/runs.png +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/docs/social-preview.png +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/docs/stats.png +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/docs/trace.png +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/tests/__init__.py +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/windhover/__init__.py +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/windhover/demo_graph.py +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/windhover/pricing.json +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/windhover/static/icon.svg +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/windhover/static/manifest.json +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/windhover/static/vendor/cytoscape-dagre.js +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/windhover/static/vendor/cytoscape.min.js +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/windhover/static/vendor/dagre.min.js +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
- {windhover-0.10.0 → windhover-0.12.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
- {windhover-0.10.0 → windhover-0.12.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.
|
|
3
|
+
Version: 0.12.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
|
|
@@ -93,8 +93,12 @@ graph.invoke(input, config={
|
|
|
93
93
|
status/tag/session filters, bookmarks, pagination, CSV/JSON export.
|
|
94
94
|
- **Sessions** — group runs into threads/batches; roll-up tokens, cost, errors.
|
|
95
95
|
- **Scores** — attach numeric evals to runs (API or UI): eval harnesses, LLM-as-judge, human review.
|
|
96
|
-
- **Live tail** — open a running run and watch
|
|
97
|
-
|
|
96
|
+
- **Live tail** — open a running run and watch spans arrive — including **the model typing**
|
|
97
|
+
(streamed tokens flush into the span twice a second); nodes push progress via
|
|
98
|
+
`get_stream_writer()`.
|
|
99
|
+
- **Call configs** — every LLM span records temperature/max-tokens/stream **and the tools the
|
|
100
|
+
model was offered**; conditional-edge branch labels and `add_node(metadata=…)` render on the
|
|
101
|
+
graph and node pane; graphs with a context schema get a runtime-context box on New run.
|
|
98
102
|
- **Custom events** — `dispatch_custom_event("name", {...})` anywhere in your app lands as an
|
|
99
103
|
event marker in the trace, parented to the node that fired it.
|
|
100
104
|
- **Retries + TTFT** — tenacity retries badge the span (`↻2`); streaming LLM calls record
|
|
@@ -135,6 +139,13 @@ curl -X POST :8090/api/runs/RUN_ID/scores -H 'Content-Type: application/json' \
|
|
|
135
139
|
`/api` routes; the UI prompts once and remembers it).
|
|
136
140
|
Edit `windhover/pricing.json` for your models' $/1M rates (unknown model → cost null).
|
|
137
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
|
+
|
|
138
149
|
## Notes
|
|
139
150
|
Runs use the imported graph (restart to run new code); the *view* always reflects
|
|
140
151
|
current-on-disk topology. All frontend assets are vendored — no CDN, works fully offline.
|
|
@@ -74,8 +74,12 @@ graph.invoke(input, config={
|
|
|
74
74
|
status/tag/session filters, bookmarks, pagination, CSV/JSON export.
|
|
75
75
|
- **Sessions** — group runs into threads/batches; roll-up tokens, cost, errors.
|
|
76
76
|
- **Scores** — attach numeric evals to runs (API or UI): eval harnesses, LLM-as-judge, human review.
|
|
77
|
-
- **Live tail** — open a running run and watch
|
|
78
|
-
|
|
77
|
+
- **Live tail** — open a running run and watch spans arrive — including **the model typing**
|
|
78
|
+
(streamed tokens flush into the span twice a second); nodes push progress via
|
|
79
|
+
`get_stream_writer()`.
|
|
80
|
+
- **Call configs** — every LLM span records temperature/max-tokens/stream **and the tools the
|
|
81
|
+
model was offered**; conditional-edge branch labels and `add_node(metadata=…)` render on the
|
|
82
|
+
graph and node pane; graphs with a context schema get a runtime-context box on New run.
|
|
79
83
|
- **Custom events** — `dispatch_custom_event("name", {...})` anywhere in your app lands as an
|
|
80
84
|
event marker in the trace, parented to the node that fired it.
|
|
81
85
|
- **Retries + TTFT** — tenacity retries badge the span (`↻2`); streaming LLM calls record
|
|
@@ -116,6 +120,13 @@ curl -X POST :8090/api/runs/RUN_ID/scores -H 'Content-Type: application/json' \
|
|
|
116
120
|
`/api` routes; the UI prompts once and remembers it).
|
|
117
121
|
Edit `windhover/pricing.json` for your models' $/1M rates (unknown model → cost null).
|
|
118
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
|
+
|
|
119
130
|
## Notes
|
|
120
131
|
Runs use the imported graph (restart to run new code); the *view* always reflects
|
|
121
132
|
current-on-disk topology. All frontend assets are vendored — no CDN, works fully offline.
|
windhover-0.12.0/SPEC.md
ADDED
|
@@ -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,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`.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "windhover"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.12.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"
|