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.
Files changed (36) hide show
  1. windhover-0.12.0/GUIDE.md +204 -0
  2. {windhover-0.10.0 → windhover-0.12.0}/PKG-INFO +14 -3
  3. {windhover-0.10.0 → windhover-0.12.0}/README.md +13 -2
  4. windhover-0.12.0/SPEC.md +61 -0
  5. windhover-0.12.0/docs/GUIDE.md +204 -0
  6. {windhover-0.10.0 → windhover-0.12.0}/pyproject.toml +1 -1
  7. {windhover-0.10.0 → windhover-0.12.0}/tests/test_smoke.py +156 -0
  8. windhover-0.12.0/windhover/config.py +61 -0
  9. {windhover-0.10.0 → windhover-0.12.0}/windhover/extract.py +18 -3
  10. {windhover-0.10.0 → windhover-0.12.0}/windhover/server.py +28 -3
  11. {windhover-0.10.0 → windhover-0.12.0}/windhover/static/index.html +39 -8
  12. {windhover-0.10.0 → windhover-0.12.0}/windhover/store.py +7 -6
  13. {windhover-0.10.0 → windhover-0.12.0}/windhover/tracer.py +216 -135
  14. windhover-0.10.0/SPEC.md +0 -83
  15. windhover-0.10.0/windhover/config.py +0 -34
  16. {windhover-0.10.0 → windhover-0.12.0}/.github/workflows/ci.yml +0 -0
  17. {windhover-0.10.0 → windhover-0.12.0}/.gitignore +0 -0
  18. {windhover-0.10.0 → windhover-0.12.0}/LICENSE +0 -0
  19. {windhover-0.10.0 → windhover-0.12.0}/docs/graph.png +0 -0
  20. {windhover-0.10.0 → windhover-0.12.0}/docs/logo.svg +0 -0
  21. {windhover-0.10.0 → windhover-0.12.0}/docs/runs.png +0 -0
  22. {windhover-0.10.0 → windhover-0.12.0}/docs/social-preview.png +0 -0
  23. {windhover-0.10.0 → windhover-0.12.0}/docs/stats.png +0 -0
  24. {windhover-0.10.0 → windhover-0.12.0}/docs/trace.png +0 -0
  25. {windhover-0.10.0 → windhover-0.12.0}/tests/__init__.py +0 -0
  26. {windhover-0.10.0 → windhover-0.12.0}/windhover/__init__.py +0 -0
  27. {windhover-0.10.0 → windhover-0.12.0}/windhover/demo_graph.py +0 -0
  28. {windhover-0.10.0 → windhover-0.12.0}/windhover/pricing.json +0 -0
  29. {windhover-0.10.0 → windhover-0.12.0}/windhover/static/icon.svg +0 -0
  30. {windhover-0.10.0 → windhover-0.12.0}/windhover/static/manifest.json +0 -0
  31. {windhover-0.10.0 → windhover-0.12.0}/windhover/static/vendor/cytoscape-dagre.js +0 -0
  32. {windhover-0.10.0 → windhover-0.12.0}/windhover/static/vendor/cytoscape.min.js +0 -0
  33. {windhover-0.10.0 → windhover-0.12.0}/windhover/static/vendor/dagre.min.js +0 -0
  34. {windhover-0.10.0 → windhover-0.12.0}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
  35. {windhover-0.10.0 → windhover-0.12.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
  36. {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.10.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 its spans arrive in real time; nodes can push
97
- progress via LangGraph's `get_stream_writer()`.
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 its spans arrive in real time; nodes can push
78
- progress via LangGraph's `get_stream_writer()`.
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.
@@ -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.10.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"