windhover 0.7.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.7.0/.gitignore +10 -0
- windhover-0.7.0/LICENSE +21 -0
- windhover-0.7.0/PKG-INFO +111 -0
- windhover-0.7.0/README.md +92 -0
- windhover-0.7.0/SPEC.md +83 -0
- windhover-0.7.0/docs/graph.png +0 -0
- windhover-0.7.0/docs/logo.svg +8 -0
- windhover-0.7.0/docs/runs.png +0 -0
- windhover-0.7.0/docs/social-preview.png +0 -0
- windhover-0.7.0/docs/stats.png +0 -0
- windhover-0.7.0/docs/trace.png +0 -0
- windhover-0.7.0/pyproject.toml +27 -0
- windhover-0.7.0/tests/__init__.py +0 -0
- windhover-0.7.0/tests/test_smoke.py +259 -0
- windhover-0.7.0/windhover/__init__.py +5 -0
- windhover-0.7.0/windhover/config.py +32 -0
- windhover-0.7.0/windhover/demo_graph.py +48 -0
- windhover-0.7.0/windhover/extract.py +103 -0
- windhover-0.7.0/windhover/pricing.json +16 -0
- windhover-0.7.0/windhover/server.py +333 -0
- windhover-0.7.0/windhover/static/icon.svg +8 -0
- windhover-0.7.0/windhover/static/index.html +843 -0
- windhover-0.7.0/windhover/static/manifest.json +18 -0
- windhover-0.7.0/windhover/static/vendor/cytoscape-dagre.js +397 -0
- windhover-0.7.0/windhover/static/vendor/cytoscape.min.js +32 -0
- windhover-0.7.0/windhover/static/vendor/dagre.min.js +3809 -0
- windhover-0.7.0/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
- windhover-0.7.0/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
- windhover-0.7.0/windhover/static/vendor/fonts/fraunces-600.woff2 +0 -0
- windhover-0.7.0/windhover/store.py +347 -0
- windhover-0.7.0/windhover/tracer.py +325 -0
windhover-0.7.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 justfeltlikerunning
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
windhover-0.7.0/PKG-INFO
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: windhover
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: Self-hosted, mobile-friendly observability for LangGraph — traces, run history, cost, and a living graph view.
|
|
5
|
+
Project-URL: Repository, https://github.com/justfeltlikerunning/windhover
|
|
6
|
+
Author: justfeltlikerunning
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: agents,langchain,langgraph,llm,observability,tracing
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: fastapi>=0.110
|
|
12
|
+
Requires-Dist: uvicorn>=0.29
|
|
13
|
+
Provides-Extra: demo
|
|
14
|
+
Requires-Dist: langgraph>=0.2; extra == 'demo'
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: langgraph>=0.2; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
<p align="center"><img src="docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
|
|
21
|
+
<h1 align="center">Windhover</h1>
|
|
22
|
+
|
|
23
|
+
> *Windhover* — the old poetic name for the kestrel, the falcon that hangs motionless
|
|
24
|
+
> in the wind, watching everything below. This tool does the same for your agent graphs.
|
|
25
|
+
|
|
26
|
+
**Self-hosted, mobile-friendly observability for [LangGraph](https://github.com/langchain-ai/langgraph).**
|
|
27
|
+
Trace depth like LangSmith (LLM prompts, tokens, cost, latency — plus retrievers and
|
|
28
|
+
human-in-the-loop interrupts), run history, a timing waterfall, per-node stats, error
|
|
29
|
+
forensics down to the throwing source line — and a **living graph view** that auto-updates
|
|
30
|
+
when your code's topology changes. Point it at any compiled graph, or trace runs in from
|
|
31
|
+
your own app. No LangSmith account, no cloud tunnel, no fragile websocket. HTTP + SSE, MIT.
|
|
32
|
+
|
|
33
|
+
> Nothing about your graph's domain is baked in. Topology, the input form, and run
|
|
34
|
+
> outputs all come from the graph itself. Windhover observes — it never edits your graph.
|
|
35
|
+
|
|
36
|
+
| Living graph (parallel fan-out) | Trace drawer — retrievers, LLM calls, cost, state |
|
|
37
|
+
|---|---|
|
|
38
|
+
|  |  |
|
|
39
|
+
|
|
40
|
+
| Runs — search, tags, sessions, interrupts | Dashboards — per-day, per-model |
|
|
41
|
+
|---|---|
|
|
42
|
+
|  |  |
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
```bash
|
|
46
|
+
pip install fastapi uvicorn langgraph
|
|
47
|
+
WINDHOVER_GRAPH=windhover.demo_graph:graph python -m windhover.server # -> :8090
|
|
48
|
+
```
|
|
49
|
+
Open `http://<host>:8090`. **New run** (input pre-filled from the graph's schema) →
|
|
50
|
+
watch it execute → **Runs** for history, span trees, and replay → **Stats** for cost/latency.
|
|
51
|
+
Edit the graph file while it runs and the canvas updates itself.
|
|
52
|
+
|
|
53
|
+
Your own graph: `WINDHOVER_GRAPH="myapp.graphs:g" WINDHOVER_GRAPH_DIR=/path python -m windhover.server`
|
|
54
|
+
|
|
55
|
+
## Trace runs from any app
|
|
56
|
+
```python
|
|
57
|
+
from windhover import WindhoverTracer
|
|
58
|
+
graph.invoke(input, config={"callbacks": [WindhoverTracer("http://HOST:8090")]})
|
|
59
|
+
```
|
|
60
|
+
Node spans, LLM calls (model/prompt/response/tokens/cost), and tools show up in **Runs** —
|
|
61
|
+
wherever your app runs. Non-blocking, best-effort; never raises into your graph.
|
|
62
|
+
|
|
63
|
+
Sessions and tags use standard LangChain config — no Windhover imports needed beyond the tracer:
|
|
64
|
+
```python
|
|
65
|
+
graph.invoke(input, config={
|
|
66
|
+
"callbacks": [WindhoverTracer("http://HOST:8090")],
|
|
67
|
+
"metadata": {"windhover_session": "chat-42", "windhover_tags": ["prod"]},
|
|
68
|
+
"tags": ["also-captured"], # langgraph-internal tags are filtered out
|
|
69
|
+
})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Features
|
|
73
|
+
- **Any graph** — topology from `graph.get_graph()`; input form from its state schema.
|
|
74
|
+
- **Full trace tree** — nodes → nested LLM / tool / **retriever** spans: prompts, responses,
|
|
75
|
+
tokens, cost, latency, retrieved documents with their metadata.
|
|
76
|
+
- **Clickable graph** — tap a node for health, latency, wiring, its **source code**, and recent executions with payloads.
|
|
77
|
+
- **Error forensics** — failed runs show the full traceback; the failing node turns red on the
|
|
78
|
+
graph, and the node's source renders with the **throwing line highlighted**.
|
|
79
|
+
- **Human-in-the-loop aware** — a graph paused on `interrupt()` shows an amber **interrupted**
|
|
80
|
+
status plus the payload it's asking a human about.
|
|
81
|
+
- **State evolution** — every trace shows which state keys each node wrote, in order.
|
|
82
|
+
- **X-ray** — graphs with subgraphs get a canvas toggle that expands composite nodes
|
|
83
|
+
(`get_graph(xray=True)`).
|
|
84
|
+
- **Search & filters** — full-text over prompts/payloads/errors (FTS5, LIKE fallback),
|
|
85
|
+
status/tag/session filters, bookmarks, pagination, CSV/JSON export.
|
|
86
|
+
- **Sessions** — group runs into threads/batches; roll-up tokens, cost, errors.
|
|
87
|
+
- **Scores** — attach numeric evals to runs (API or UI): eval harnesses, LLM-as-judge, human review.
|
|
88
|
+
- **Run history + replay** — SQLite; runs persist even if the browser closes (worker thread).
|
|
89
|
+
- **Living graph** — file watcher re-extracts topology in a subprocess and pushes it to the UI.
|
|
90
|
+
- **Dashboards** — runs/tokens per day, per-model usage and latency, per-node latency, error rate.
|
|
91
|
+
- **Mobile-first PWA**, light/dark. Fully local (FastAPI + Cytoscape.js).
|
|
92
|
+
|
|
93
|
+
## Scores API
|
|
94
|
+
```bash
|
|
95
|
+
curl -X POST :8090/api/runs/RUN_ID/scores -H 'Content-Type: application/json' \
|
|
96
|
+
-d '{"name": "accuracy", "value": 0.92, "comment": "vs golden set"}'
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Config (env)
|
|
100
|
+
`WINDHOVER_GRAPH` (module:attr; unset = ingest-only) · `WINDHOVER_GRAPH_DIR` · `WINDHOVER_DB`
|
|
101
|
+
· `WINDHOVER_HOST`/`WINDHOVER_PORT` (0.0.0.0/8090) · `WINDHOVER_WATCH` (1) · `WINDHOVER_PRICING`
|
|
102
|
+
· `WINDHOVER_RETENTION_DAYS` (0 = keep forever; else prune older runs on startup + every 6h).
|
|
103
|
+
Edit `windhover/pricing.json` for your models' $/1M rates (unknown model → cost null).
|
|
104
|
+
|
|
105
|
+
## Notes
|
|
106
|
+
Runs use the imported graph (restart to run new code); the *view* always reflects
|
|
107
|
+
current-on-disk topology. All frontend assets are vendored — no CDN, works fully offline.
|
|
108
|
+
Deep links: `#runs`, `#sessions`, `#stats`, `#run=<id>`.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
MIT.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<p align="center"><img src="docs/logo.svg" width="112" alt="Windhover — a hovering kestrel"></p>
|
|
2
|
+
<h1 align="center">Windhover</h1>
|
|
3
|
+
|
|
4
|
+
> *Windhover* — the old poetic name for the kestrel, the falcon that hangs motionless
|
|
5
|
+
> in the wind, watching everything below. This tool does the same for your agent graphs.
|
|
6
|
+
|
|
7
|
+
**Self-hosted, mobile-friendly observability for [LangGraph](https://github.com/langchain-ai/langgraph).**
|
|
8
|
+
Trace depth like LangSmith (LLM prompts, tokens, cost, latency — plus retrievers and
|
|
9
|
+
human-in-the-loop interrupts), run history, a timing waterfall, per-node stats, error
|
|
10
|
+
forensics down to the throwing source line — and a **living graph view** that auto-updates
|
|
11
|
+
when your code's topology changes. Point it at any compiled graph, or trace runs in from
|
|
12
|
+
your own app. No LangSmith account, no cloud tunnel, no fragile websocket. HTTP + SSE, MIT.
|
|
13
|
+
|
|
14
|
+
> Nothing about your graph's domain is baked in. Topology, the input form, and run
|
|
15
|
+
> outputs all come from the graph itself. Windhover observes — it never edits your graph.
|
|
16
|
+
|
|
17
|
+
| Living graph (parallel fan-out) | Trace drawer — retrievers, LLM calls, cost, state |
|
|
18
|
+
|---|---|
|
|
19
|
+
|  |  |
|
|
20
|
+
|
|
21
|
+
| Runs — search, tags, sessions, interrupts | Dashboards — per-day, per-model |
|
|
22
|
+
|---|---|
|
|
23
|
+
|  |  |
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
```bash
|
|
27
|
+
pip install fastapi uvicorn langgraph
|
|
28
|
+
WINDHOVER_GRAPH=windhover.demo_graph:graph python -m windhover.server # -> :8090
|
|
29
|
+
```
|
|
30
|
+
Open `http://<host>:8090`. **New run** (input pre-filled from the graph's schema) →
|
|
31
|
+
watch it execute → **Runs** for history, span trees, and replay → **Stats** for cost/latency.
|
|
32
|
+
Edit the graph file while it runs and the canvas updates itself.
|
|
33
|
+
|
|
34
|
+
Your own graph: `WINDHOVER_GRAPH="myapp.graphs:g" WINDHOVER_GRAPH_DIR=/path python -m windhover.server`
|
|
35
|
+
|
|
36
|
+
## Trace runs from any app
|
|
37
|
+
```python
|
|
38
|
+
from windhover import WindhoverTracer
|
|
39
|
+
graph.invoke(input, config={"callbacks": [WindhoverTracer("http://HOST:8090")]})
|
|
40
|
+
```
|
|
41
|
+
Node spans, LLM calls (model/prompt/response/tokens/cost), and tools show up in **Runs** —
|
|
42
|
+
wherever your app runs. Non-blocking, best-effort; never raises into your graph.
|
|
43
|
+
|
|
44
|
+
Sessions and tags use standard LangChain config — no Windhover imports needed beyond the tracer:
|
|
45
|
+
```python
|
|
46
|
+
graph.invoke(input, config={
|
|
47
|
+
"callbacks": [WindhoverTracer("http://HOST:8090")],
|
|
48
|
+
"metadata": {"windhover_session": "chat-42", "windhover_tags": ["prod"]},
|
|
49
|
+
"tags": ["also-captured"], # langgraph-internal tags are filtered out
|
|
50
|
+
})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Features
|
|
54
|
+
- **Any graph** — topology from `graph.get_graph()`; input form from its state schema.
|
|
55
|
+
- **Full trace tree** — nodes → nested LLM / tool / **retriever** spans: prompts, responses,
|
|
56
|
+
tokens, cost, latency, retrieved documents with their metadata.
|
|
57
|
+
- **Clickable graph** — tap a node for health, latency, wiring, its **source code**, and recent executions with payloads.
|
|
58
|
+
- **Error forensics** — failed runs show the full traceback; the failing node turns red on the
|
|
59
|
+
graph, and the node's source renders with the **throwing line highlighted**.
|
|
60
|
+
- **Human-in-the-loop aware** — a graph paused on `interrupt()` shows an amber **interrupted**
|
|
61
|
+
status plus the payload it's asking a human about.
|
|
62
|
+
- **State evolution** — every trace shows which state keys each node wrote, in order.
|
|
63
|
+
- **X-ray** — graphs with subgraphs get a canvas toggle that expands composite nodes
|
|
64
|
+
(`get_graph(xray=True)`).
|
|
65
|
+
- **Search & filters** — full-text over prompts/payloads/errors (FTS5, LIKE fallback),
|
|
66
|
+
status/tag/session filters, bookmarks, pagination, CSV/JSON export.
|
|
67
|
+
- **Sessions** — group runs into threads/batches; roll-up tokens, cost, errors.
|
|
68
|
+
- **Scores** — attach numeric evals to runs (API or UI): eval harnesses, LLM-as-judge, human review.
|
|
69
|
+
- **Run history + replay** — SQLite; runs persist even if the browser closes (worker thread).
|
|
70
|
+
- **Living graph** — file watcher re-extracts topology in a subprocess and pushes it to the UI.
|
|
71
|
+
- **Dashboards** — runs/tokens per day, per-model usage and latency, per-node latency, error rate.
|
|
72
|
+
- **Mobile-first PWA**, light/dark. Fully local (FastAPI + Cytoscape.js).
|
|
73
|
+
|
|
74
|
+
## Scores API
|
|
75
|
+
```bash
|
|
76
|
+
curl -X POST :8090/api/runs/RUN_ID/scores -H 'Content-Type: application/json' \
|
|
77
|
+
-d '{"name": "accuracy", "value": 0.92, "comment": "vs golden set"}'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Config (env)
|
|
81
|
+
`WINDHOVER_GRAPH` (module:attr; unset = ingest-only) · `WINDHOVER_GRAPH_DIR` · `WINDHOVER_DB`
|
|
82
|
+
· `WINDHOVER_HOST`/`WINDHOVER_PORT` (0.0.0.0/8090) · `WINDHOVER_WATCH` (1) · `WINDHOVER_PRICING`
|
|
83
|
+
· `WINDHOVER_RETENTION_DAYS` (0 = keep forever; else prune older runs on startup + every 6h).
|
|
84
|
+
Edit `windhover/pricing.json` for your models' $/1M rates (unknown model → cost null).
|
|
85
|
+
|
|
86
|
+
## Notes
|
|
87
|
+
Runs use the imported graph (restart to run new code); the *view* always reflects
|
|
88
|
+
current-on-disk topology. All frontend assets are vendored — no CDN, works fully offline.
|
|
89
|
+
Deep links: `#runs`, `#sessions`, `#stats`, `#run=<id>`.
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
MIT.
|
windhover-0.7.0/SPEC.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Windhover v0.3 — engineering spec
|
|
2
|
+
|
|
3
|
+
Self-hosted LangGraph observability. Code is the only source of truth; Windhover
|
|
4
|
+
observes. Goal: Langfuse-class trace depth (LLM-level: prompts, tokens, cost,
|
|
5
|
+
latency) + a living graph view that auto-updates when the code's topology changes.
|
|
6
|
+
No visual editing, ever.
|
|
7
|
+
|
|
8
|
+
## Design principles
|
|
9
|
+
1. **Observe, never define.** Topology always derives from the compiled graph.
|
|
10
|
+
2. **The tracer is the single source of span data** — used identically for local
|
|
11
|
+
runs (in-process → DB sink) and external apps (HTTP → ingest). One code path.
|
|
12
|
+
3. **Runs are durable records.** Execution happens off the request thread; a run
|
|
13
|
+
persists even if the browser disconnects.
|
|
14
|
+
4. **Degrade gracefully.** No graph configured → ingest-only collector. Unknown
|
|
15
|
+
model → cost null (not a crash). Missing token usage → tokens null.
|
|
16
|
+
5. **Real package.** pip-installable, typed, smoke-tested. No CDN at runtime once
|
|
17
|
+
vendored (roadmap). MIT.
|
|
18
|
+
|
|
19
|
+
## Data model (SQLite, WAL)
|
|
20
|
+
`schema_meta(version)`. Migrate v2 `events` → v3 node spans best-effort.
|
|
21
|
+
|
|
22
|
+
`runs`
|
|
23
|
+
id TEXT PK · graph · source(ui|ingest) · status(running|done|error) · session ·
|
|
24
|
+
tags(json) · input(json) · error · started_ms · ended_ms · duration_ms ·
|
|
25
|
+
node_count · llm_calls · prompt_tokens · completion_tokens · total_tokens · cost_usd
|
|
26
|
+
|
|
27
|
+
`spans` (a tree: node spans are top-level; llm/tool spans nest under a node)
|
|
28
|
+
id TEXT PK · run_id · parent_id(nullable) · seq · type(node|llm|tool) · name ·
|
|
29
|
+
status(ok|error) · started_ms · ended_ms · offset_ms(rel to run) · dur_ms ·
|
|
30
|
+
input(json) · output(json) · model · prompt_tokens · completion_tokens ·
|
|
31
|
+
cost_usd · error
|
|
32
|
+
INDEX(run_id, seq)
|
|
33
|
+
|
|
34
|
+
## Tracer contract (LangChain callback → sink)
|
|
35
|
+
`SpanBuilder(sink, run_name, session=None, tags=None)` emits to `sink(event)`:
|
|
36
|
+
- `{"kind":"run_open", run_id, graph, input, started_ms, session, tags}`
|
|
37
|
+
- `{"kind":"span", ...span row...}` (node, llm, tool)
|
|
38
|
+
- `{"kind":"run_close", run_id, status, ended_ms, error}`
|
|
39
|
+
Sinks: `db_sink(store)` (in-process), `http_sink(base_url)` (WindhoverTracer).
|
|
40
|
+
|
|
41
|
+
Callback facts (LangGraph, verified):
|
|
42
|
+
- node name only on `on_chain_start` via `metadata.langgraph_node` → map run_id→span.
|
|
43
|
+
- llm span: `on_chat_model_start`/`on_llm_start` (model, prompt) →
|
|
44
|
+
`on_llm_end` (LLMResult: `llm_output.token_usage` OR generation
|
|
45
|
+
`usage_metadata`; response text). parent_run_id = enclosing node's run_id.
|
|
46
|
+
- tool span: `on_tool_start`/`on_tool_end`.
|
|
47
|
+
- root = first `on_chain_start` with `parent_run_id is None`.
|
|
48
|
+
|
|
49
|
+
## Cost
|
|
50
|
+
`pricing.json`: `{ "<model-prefix>": {"input": $/1M, "output": $/1M} }`.
|
|
51
|
+
Longest-prefix match on model name; miss → null. Local bench/subscription models
|
|
52
|
+
have no public price → null by design (matches canary's "est_cost null").
|
|
53
|
+
|
|
54
|
+
## HTTP API
|
|
55
|
+
GET `/api/graph` topology {nodes,edges,graph,hash}
|
|
56
|
+
GET `/api/schema` input json-schema + blank template
|
|
57
|
+
POST `/api/run` run local graph; SSE (start/node/done/error) + persist spans
|
|
58
|
+
POST `/api/ingest` external tracer events (run_open|span|run_close)
|
|
59
|
+
GET `/api/runs?limit=&session=` history
|
|
60
|
+
GET `/api/runs/{id}` run + span tree
|
|
61
|
+
GET `/api/stats` aggregates (counts, cost/tokens over time, per-node latency, error rate)
|
|
62
|
+
GET `/api/events` SSE: topology-changed pushes (living graph)
|
|
63
|
+
|
|
64
|
+
## Living graph
|
|
65
|
+
Background watcher polls mtime of graph module + `WINDHOVER_GRAPH_DIR/**.py` (~2s).
|
|
66
|
+
On change → re-extract topology in a **subprocess** (avoids importlib.reload
|
|
67
|
+
fragility) → hash → if changed, broadcast `topology` on `/api/events`. UI (EventSource)
|
|
68
|
+
re-fetches `/api/graph`, re-layouts, toasts "graph changed".
|
|
69
|
+
|
|
70
|
+
## Frontend (single file, mobile-first PWA)
|
|
71
|
+
Views: **Graph** (topology + live run + live auto-refresh), **Runs** (history w/
|
|
72
|
+
cost+tokens → detail = span tree: node → nested LLM calls w/ model/tokens/cost/
|
|
73
|
+
prompt/response, timing waterfall, replay), **Stats** (strip: runs, cost, tokens,
|
|
74
|
+
error-rate, avg node latency).
|
|
75
|
+
|
|
76
|
+
## Out of scope (roadmap)
|
|
77
|
+
Vendored deps offline · subgraph drill-in · conditional-edge labels · auth ·
|
|
78
|
+
retention/pruning · export.
|
|
79
|
+
|
|
80
|
+
## Layout
|
|
81
|
+
windhover/{__init__,config,store,tracer,server,demo_graph}.py · pricing.json ·
|
|
82
|
+
static/{index.html,manifest.json,icon.svg} · tests/test_smoke.py · pyproject.toml ·
|
|
83
|
+
LICENSE · README.md · SPEC.md
|
|
Binary file
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
2
|
+
<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
3
|
+
<stop offset="0" stop-color="#A32C3E"/><stop offset="1" stop-color="#571017"/></linearGradient></defs>
|
|
4
|
+
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
|
5
|
+
<g transform="translate(56.0 116.7) scale(0.62893)">
|
|
6
|
+
<path d="M312.90,425.29 C310.64,423.25 306.72,418.86 304.18,415.54 C301.65,412.22 298.88,408.93 298.03,408.23 C295.97,406.53 289.90,407.25 282.33,410.11 C274.09,413.23 271.34,412.33 268.20,405.50 C262.04,392.12 261.63,391.51 258.52,391.16 C256.88,390.98 253.06,391.35 250.02,391.99 C242.01,393.68 239.51,392.45 236.41,385.25 C230.11,370.65 228.49,369.21 220.40,371.02 C217.79,371.61 214.35,371.80 212.76,371.45 C209.08,370.64 206.01,365.75 206.00,360.69 C206.00,357.52 206.81,356.01 210.82,351.69 C213.47,348.84 217.71,343.80 220.23,340.50 C222.75,337.20 226.92,332.48 229.51,330.00 C232.09,327.52 236.81,322.12 240.01,318.00 C243.20,313.88 249.27,307.12 253.49,303.00 C257.72,298.88 263.24,292.80 265.78,289.50 C268.31,286.20 272.76,280.90 275.66,277.73 C282.46,270.28 283.78,266.15 281.64,258.90 C278.68,248.89 277.41,247.67 267.38,245.23 C260.71,243.60 251.53,239.67 230.50,229.45 C215.10,221.96 200.47,214.74 198.00,213.41 C195.53,212.08 184.95,206.76 174.50,201.60 C164.05,196.44 153.25,191.06 150.50,189.65 C147.75,188.24 137.08,182.79 126.78,177.55 C114.19,171.14 107.49,167.16 106.33,165.38 C104.65,162.82 104.67,162.68 106.91,160.87 C108.24,159.80 110.80,159.00 112.91,159.00 C116.69,159.00 119.00,157.60 119.00,155.31 C119.00,154.60 114.84,151.96 109.75,149.44 C99.17,144.20 67.99,123.81 66.21,120.97 C65.56,119.93 65.31,118.35 65.66,117.45 C66.19,116.07 67.32,115.93 73.06,116.51 C81.49,117.35 84.00,116.65 84.00,113.45 C84.00,111.44 81.40,109.29 68.75,100.84 C52.97,90.30 37.70,78.18 35.86,74.74 C35.05,73.23 35.18,72.40 36.40,71.18 C37.85,69.72 38.96,69.82 49.38,72.36 C62.67,75.61 67.35,75.59 67.81,72.29 C68.05,70.60 65.53,67.46 57.31,59.22 C46.57,48.44 41.78,42.47 40.57,38.36 C39.71,35.46 42.49,32.71 45.36,33.60 C46.53,33.97 60.99,41.01 77.47,49.25 C93.96,57.49 112.41,66.03 118.47,68.23 C146.15,78.27 183.73,90.19 202.92,95.00 C219.11,99.06 221.00,99.92 227.79,106.30 C230.93,109.26 235.53,113.06 238.00,114.75 C240.47,116.45 245.43,120.83 249.00,124.50 C252.57,128.17 257.52,132.55 260.00,134.25 C262.48,135.94 267.05,139.73 270.18,142.66 C277.53,149.58 279.88,149.68 287.17,143.41 C290.10,140.89 295.20,137.43 298.50,135.71 C303.95,132.88 305.53,132.56 315.72,132.19 C331.48,131.62 338.30,133.73 347.42,142.00 C356.10,149.87 358.09,149.94 365.82,142.66 C368.95,139.73 373.52,135.94 376.00,134.25 C378.48,132.55 383.43,128.17 387.00,124.49 C390.57,120.82 395.98,116.11 399.00,114.01 C402.02,111.92 406.07,108.54 408.00,106.51 C412.56,101.70 416.56,99.78 427.50,97.09 C442.99,93.30 489.94,78.25 517.53,68.23 C523.59,66.03 542.04,57.49 558.53,49.25 C575.01,41.01 589.47,33.97 590.64,33.60 C592.03,33.17 593.37,33.51 594.43,34.57 C597.63,37.77 594.44,42.92 579.49,58.73 C573.72,64.83 569.00,70.52 569.00,71.38 C569.00,74.33 572.54,75.17 578.74,73.67 C591.83,70.50 597.95,69.72 599.51,71.01 C603.83,74.59 599.88,78.43 573.00,96.80 C566.67,101.12 558.72,106.13 555.32,107.93 C548.71,111.43 547.22,113.35 549.22,115.76 C550.25,117.00 552.19,117.16 560.13,116.64 C568.29,116.10 569.88,116.24 570.38,117.54 C570.70,118.39 570.44,119.93 569.79,120.97 C568.01,123.81 536.83,144.20 526.25,149.44 C521.16,151.96 517.00,154.60 517.00,155.31 C517.00,157.60 519.31,159.00 523.09,159.00 C525.20,159.00 527.76,159.80 529.09,160.87 C533.98,164.83 532.25,165.87 461.87,201.06 C425.53,219.23 394.15,235.20 392.12,236.54 C388.01,239.26 374.07,244.22 365.58,245.98 C361.45,246.84 359.57,247.82 358.19,249.82 C355.43,253.80 353.00,260.60 353.00,264.32 C353.00,267.82 357.09,275.09 360.98,278.50 C362.23,279.60 365.86,283.88 369.04,288.00 C372.21,292.12 378.27,298.88 382.50,303.00 C386.73,307.12 392.80,313.88 396.00,318.00 C399.19,322.12 404.37,327.98 407.50,331.00 C415.03,338.28 427.28,352.69 429.35,356.70 C431.73,361.32 431.39,364.53 428.13,368.18 C425.01,371.67 422.07,372.14 412.93,370.56 C406.63,369.48 403.40,372.27 401.99,380.00 C400.84,386.26 398.56,389.89 394.54,391.86 C392.28,392.97 390.71,393.00 386.11,392.02 C383.00,391.36 379.12,390.98 377.48,391.16 C374.37,391.51 373.96,392.12 367.80,405.50 C364.66,412.33 361.91,413.23 353.67,410.11 C346.10,407.25 340.03,406.53 337.97,408.23 C337.12,408.93 334.35,412.22 331.82,415.54 C326.95,421.91 319.68,429.00 318.00,429.00 C317.45,429.00 315.16,427.33 312.90,425.29 z M323.60,201.25 C329.52,190.76 330.86,189.21 336.20,186.68 C344.02,182.98 348.00,178.02 348.00,172.00 C348.00,168.25 346.43,165.00 344.62,165.00 C343.90,165.00 340.29,167.93 336.61,171.50 C332.93,175.07 329.50,178.00 328.99,178.00 C328.48,178.00 326.47,180.49 324.52,183.54 C322.57,186.58 320.28,189.34 319.42,189.67 C317.02,190.59 313.64,188.28 310.95,183.86 C308.20,179.35 294.66,167.00 292.47,167.00 C289.94,167.00 287.69,171.70 288.33,175.64 C289.11,180.46 291.29,182.65 298.80,186.21 C305.24,189.26 306.28,190.41 312.40,201.25 C314.64,205.22 315.56,206.00 318.00,206.00 C320.44,206.00 321.36,205.22 323.60,201.25 z" fill="#fff"/>
|
|
7
|
+
</g>
|
|
8
|
+
</svg>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "windhover"
|
|
7
|
+
version = "0.7.0"
|
|
8
|
+
description = "Self-hosted, mobile-friendly observability for LangGraph — traces, run history, cost, and a living graph view."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "justfeltlikerunning" }]
|
|
13
|
+
keywords = ["langgraph", "langchain", "observability", "tracing", "llm", "agents"]
|
|
14
|
+
dependencies = ["fastapi>=0.110", "uvicorn>=0.29"]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
demo = ["langgraph>=0.2"]
|
|
18
|
+
dev = ["pytest", "langgraph>=0.2"]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
windhover = "windhover.server:main"
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Repository = "https://github.com/justfeltlikerunning/windhover"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["windhover"]
|
|
File without changes
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Windhover smoke tests — store roundtrip, cost math, tracer against a real
|
|
2
|
+
LangGraph run (incl. a fake LLM node to exercise LLM-span capture). Run:
|
|
3
|
+
|
|
4
|
+
python -m pytest tests/ -q (or: python tests/test_smoke.py)
|
|
5
|
+
"""
|
|
6
|
+
import json, os, sys, tempfile, time
|
|
7
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|
8
|
+
|
|
9
|
+
from windhover.store import Store
|
|
10
|
+
from windhover.tracer import SpanBuilder, db_sink, cost_of
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_cost():
|
|
14
|
+
assert cost_of("gpt-4o", 1_000_000, 1_000_000) == 12.5 # 2.5 + 10
|
|
15
|
+
assert cost_of("gpt-4o-2024-11-20", 1_000_000, 0) == 2.5 # prefix match
|
|
16
|
+
assert cost_of("bench/fable", 1000, 1000) is None # unknown -> None
|
|
17
|
+
assert cost_of(None, 1, 1) is None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_store_roundtrip():
|
|
21
|
+
p = tempfile.mktemp(suffix=".db")
|
|
22
|
+
s = Store(p)
|
|
23
|
+
s.open_run({"id": "r1", "graph": "g", "source": "ui", "started_ms": 1000})
|
|
24
|
+
s.add_span({"id": "n1", "run_id": "r1", "type": "node", "name": "a",
|
|
25
|
+
"seq": 0, "offset_ms": 0, "dur_ms": 10, "output": {"x": 1}})
|
|
26
|
+
s.add_span({"id": "l1", "run_id": "r1", "parent_id": "n1", "type": "llm",
|
|
27
|
+
"name": "gpt-4o", "seq": 1, "dur_ms": 5,
|
|
28
|
+
"prompt_tokens": 1_000_000, "completion_tokens": 0, "cost_usd": 2.5})
|
|
29
|
+
s.close_run("r1", "done", 1050)
|
|
30
|
+
d = s.run_detail("r1")
|
|
31
|
+
assert d["status"] == "done" and d["duration_ms"] == 50
|
|
32
|
+
assert d["node_count"] == 1 and d["llm_calls"] == 1
|
|
33
|
+
assert abs(d["cost_usd"] - 2.5) < 1e-9
|
|
34
|
+
assert len(d["spans"]) == 2
|
|
35
|
+
llm = [x for x in d["spans"] if x["type"] == "llm"][0]
|
|
36
|
+
assert llm["parent_id"] == "n1"
|
|
37
|
+
print("store roundtrip OK")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_tracer_local_graph():
|
|
41
|
+
from typing import TypedDict
|
|
42
|
+
from langgraph.graph import StateGraph, START, END
|
|
43
|
+
p = tempfile.mktemp(suffix=".db")
|
|
44
|
+
s = Store(p)
|
|
45
|
+
|
|
46
|
+
class St(TypedDict):
|
|
47
|
+
x: int
|
|
48
|
+
def a(st): return {"x": st["x"] + 1}
|
|
49
|
+
def b(st): return {"x": st["x"] * 10}
|
|
50
|
+
g = StateGraph(St)
|
|
51
|
+
g.add_node("inc", a); g.add_node("mul", b)
|
|
52
|
+
g.add_edge(START, "inc"); g.add_edge("inc", "mul"); g.add_edge("mul", END)
|
|
53
|
+
app = g.compile()
|
|
54
|
+
|
|
55
|
+
tracer = SpanBuilder(db_sink(s), run_name="test")
|
|
56
|
+
out = app.invoke({"x": 4}, config={"callbacks": [tracer]})
|
|
57
|
+
assert out["x"] == 50
|
|
58
|
+
time.sleep(.1)
|
|
59
|
+
d = s.run_detail(tracer.run_id)
|
|
60
|
+
assert d is not None and d["status"] == "done"
|
|
61
|
+
names = [sp["name"] for sp in d["spans"] if sp["type"] == "node"]
|
|
62
|
+
assert "inc" in names and "mul" in names
|
|
63
|
+
print("tracer local-graph OK — nodes:", names)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_search_filters_scores_sessions():
|
|
67
|
+
p = tempfile.mktemp(suffix=".db")
|
|
68
|
+
s = Store(p)
|
|
69
|
+
now = int(time.time() * 1000)
|
|
70
|
+
for i, (sess, tag) in enumerate([("chat-1", "alpha"), ("chat-1", "beta"), (None, "alpha")]):
|
|
71
|
+
rid = f"r{i}"
|
|
72
|
+
s.open_run({"id": rid, "graph": "g", "source": "ui", "session": sess,
|
|
73
|
+
"tags": [tag], "started_ms": now - i * 1000})
|
|
74
|
+
s.add_span({"id": f"n{i}", "run_id": rid, "type": "node", "name": "work",
|
|
75
|
+
"seq": 0, "dur_ms": 5, "output": {"text": f"needle-{i} haystack"}})
|
|
76
|
+
s.close_run(rid, "done" if i else "error", now - i * 1000 + 50)
|
|
77
|
+
# payload search (FTS5 or LIKE fallback — same contract either way)
|
|
78
|
+
hit = s.runs(q=f"needle-1")
|
|
79
|
+
assert hit["total"] == 1 and hit["runs"][0]["id"] == "r1"
|
|
80
|
+
assert s.runs(status="error")["total"] == 1
|
|
81
|
+
assert s.runs(tag="alpha")["total"] == 2
|
|
82
|
+
assert s.runs(session="chat-1")["total"] == 2
|
|
83
|
+
ss = s.sessions()
|
|
84
|
+
assert len(ss) == 1 and ss[0]["session"] == "chat-1" and ss[0]["runs"] == 2
|
|
85
|
+
assert ss[0]["errors"] == 1
|
|
86
|
+
# bookmarks
|
|
87
|
+
assert s.update_run_meta("r0", bookmarked=True)
|
|
88
|
+
assert s.runs(bookmarked=True)["total"] == 1
|
|
89
|
+
assert not s.update_run_meta("nope", bookmarked=True)
|
|
90
|
+
# scores: add, aggregate onto runs page, embed in detail, delete
|
|
91
|
+
sc = s.add_score("r0", "accuracy", 0.9, comment="ok", source="test")
|
|
92
|
+
assert sc is not None
|
|
93
|
+
assert s.add_score("missing-run", "x", 1.0) is None
|
|
94
|
+
assert s.runs(bookmarked=True)["runs"][0]["scores"] == {"accuracy": 0.9}
|
|
95
|
+
assert s.run_detail("r0")["scores"][0]["name"] == "accuracy"
|
|
96
|
+
assert s.delete_score(sc["id"])
|
|
97
|
+
# pagination
|
|
98
|
+
pg = s.runs(limit=2, offset=2)
|
|
99
|
+
assert pg["total"] == 3 and len(pg["runs"]) == 1
|
|
100
|
+
print("search/filters/scores/sessions OK (fts=%s json1=%s)" % (s.has_fts, s.has_json1))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_prune_and_stats():
|
|
104
|
+
p = tempfile.mktemp(suffix=".db")
|
|
105
|
+
s = Store(p)
|
|
106
|
+
old = int((time.time() - 40 * 86400) * 1000)
|
|
107
|
+
now = int(time.time() * 1000)
|
|
108
|
+
s.open_run({"id": "old1", "graph": "g", "started_ms": old})
|
|
109
|
+
s.close_run("old1", "done", old + 10)
|
|
110
|
+
s.open_run({"id": "new1", "graph": "g", "started_ms": now})
|
|
111
|
+
s.add_span({"id": "L1", "run_id": "new1", "type": "llm", "name": "m", "seq": 0,
|
|
112
|
+
"dur_ms": 3, "model": "gpt-4o", "prompt_tokens": 10, "completion_tokens": 5})
|
|
113
|
+
s.close_run("new1", "done", now + 10)
|
|
114
|
+
st = s.stats(days=30)
|
|
115
|
+
assert st["models"] and st["models"][0]["model"] == "gpt-4o"
|
|
116
|
+
assert any(d["runs"] for d in st["daily"])
|
|
117
|
+
res = s.prune(30)
|
|
118
|
+
assert res["pruned_runs"] == 1
|
|
119
|
+
assert s.run_detail("old1") is None and s.run_detail("new1") is not None
|
|
120
|
+
print("prune/stats OK")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_tracer_metadata_session_tags():
|
|
124
|
+
"""The generic wire-level idiom: config metadata/tags from ANY LangChain app."""
|
|
125
|
+
p = tempfile.mktemp(suffix=".db")
|
|
126
|
+
s = Store(p)
|
|
127
|
+
tr = SpanBuilder(db_sink(s), run_name="t")
|
|
128
|
+
tr.on_chain_start({}, {"x": 1}, run_id="root1", parent_run_id=None,
|
|
129
|
+
metadata={"windhover_session": "sess-42", "windhover_tags": ["custom"]},
|
|
130
|
+
tags=["graph:step:1", "langsmith:hidden", "mytag"])
|
|
131
|
+
tr.on_chain_end({}, run_id="root1")
|
|
132
|
+
d = s.run_detail(tr.run_id)
|
|
133
|
+
assert d is not None and d["session"] == "sess-42"
|
|
134
|
+
assert set(d["tags"]) == {"custom", "mytag"} # internal langgraph tags filtered
|
|
135
|
+
print("tracer metadata session/tags OK")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _tiny_graph(fail=False):
|
|
139
|
+
from typing import TypedDict
|
|
140
|
+
from langgraph.graph import StateGraph, START, END
|
|
141
|
+
|
|
142
|
+
class St(TypedDict):
|
|
143
|
+
x: int
|
|
144
|
+
|
|
145
|
+
def boom(st):
|
|
146
|
+
raise RuntimeError("kaboom from boom()")
|
|
147
|
+
|
|
148
|
+
def inc(st):
|
|
149
|
+
return {"x": st["x"] + 1}
|
|
150
|
+
|
|
151
|
+
g = StateGraph(St)
|
|
152
|
+
g.add_node("inc", boom if fail else inc)
|
|
153
|
+
g.add_edge(START, "inc"); g.add_edge("inc", END)
|
|
154
|
+
return g.compile()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_source_extraction():
|
|
158
|
+
from windhover.extract import sources
|
|
159
|
+
app = _tiny_graph()
|
|
160
|
+
src = sources(app)
|
|
161
|
+
assert "inc" in src, f"no source for inc: {list(src)}"
|
|
162
|
+
s = src["inc"]
|
|
163
|
+
assert "def inc(st):" in s["code"]
|
|
164
|
+
assert s["file"].endswith(".py") and s["line_start"] > 0
|
|
165
|
+
assert s["line_end"] >= s["line_start"]
|
|
166
|
+
print("source extraction OK —", s["file"].split("/")[-1], f"L{s['line_start']}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_error_traceback_capture():
|
|
170
|
+
p = tempfile.mktemp(suffix=".db")
|
|
171
|
+
s = Store(p)
|
|
172
|
+
app = _tiny_graph(fail=True)
|
|
173
|
+
tracer = SpanBuilder(db_sink(s), run_name="failing")
|
|
174
|
+
try:
|
|
175
|
+
app.invoke({"x": 1}, config={"callbacks": [tracer]})
|
|
176
|
+
assert False, "graph should have raised"
|
|
177
|
+
except RuntimeError:
|
|
178
|
+
pass
|
|
179
|
+
time.sleep(.1)
|
|
180
|
+
d = s.run_detail(tracer.run_id)
|
|
181
|
+
assert d is not None and d["status"] == "error"
|
|
182
|
+
err_spans = [sp for sp in d["spans"] if sp["status"] == "error" and sp["type"] == "node"]
|
|
183
|
+
assert err_spans, "no error node span recorded"
|
|
184
|
+
err = err_spans[0]["error"]
|
|
185
|
+
# full traceback with file+line so the UI can highlight the source line
|
|
186
|
+
assert "Traceback" in err and 'File "' in err and "kaboom from boom()" in err
|
|
187
|
+
assert d["error"] and "kaboom" in d["error"]
|
|
188
|
+
print("error traceback capture OK")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_retriever_spans():
|
|
192
|
+
p = tempfile.mktemp(suffix=".db")
|
|
193
|
+
s = Store(p)
|
|
194
|
+
tr = SpanBuilder(db_sink(s), run_name="rag")
|
|
195
|
+
tr.on_chain_start({}, {"q": "hi"}, run_id="root", parent_run_id=None)
|
|
196
|
+
|
|
197
|
+
class Doc:
|
|
198
|
+
def __init__(self, c): self.page_content, self.metadata = c, {"src": "kb.md"}
|
|
199
|
+
tr.on_retriever_start({"name": "VectorStoreRetriever"}, "pricing policy",
|
|
200
|
+
run_id="ret1", parent_run_id="root")
|
|
201
|
+
tr.on_retriever_end([Doc("alpha"), Doc("beta")], run_id="ret1")
|
|
202
|
+
tr.on_chain_end({}, run_id="root")
|
|
203
|
+
d = s.run_detail(tr.run_id)
|
|
204
|
+
ret = [sp for sp in d["spans"] if sp["type"] == "retriever"]
|
|
205
|
+
assert ret and ret[0]["name"] == "VectorStoreRetriever"
|
|
206
|
+
assert ret[0]["input"] == "pricing policy"
|
|
207
|
+
assert ret[0]["output"]["count"] == 2
|
|
208
|
+
assert ret[0]["output"]["documents"][0]["content"] == "alpha"
|
|
209
|
+
print("retriever spans OK")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_interrupt_status():
|
|
213
|
+
p = tempfile.mktemp(suffix=".db")
|
|
214
|
+
s = Store(p)
|
|
215
|
+
tr = SpanBuilder(db_sink(s), run_name="hitl")
|
|
216
|
+
tr.on_chain_start({}, {"x": 1}, run_id="root", parent_run_id=None)
|
|
217
|
+
tr.on_chain_end({"__interrupt__": [{"value": "approve the wire transfer?"}]}, run_id="root")
|
|
218
|
+
d = s.run_detail(tr.run_id)
|
|
219
|
+
assert d["status"] == "interrupted", d["status"]
|
|
220
|
+
ints = [sp for sp in d["spans"] if sp["type"] == "interrupt"]
|
|
221
|
+
assert ints and "approve the wire transfer?" in json.dumps(ints[0]["output"])
|
|
222
|
+
print("interrupt status OK")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_xray_topology():
|
|
226
|
+
from typing import TypedDict
|
|
227
|
+
from langgraph.graph import StateGraph, START, END
|
|
228
|
+
from windhover.extract import topology
|
|
229
|
+
|
|
230
|
+
class St(TypedDict):
|
|
231
|
+
x: int
|
|
232
|
+
child = StateGraph(St)
|
|
233
|
+
child.add_node("inner_a", lambda st: {"x": st["x"] + 1})
|
|
234
|
+
child.add_node("inner_b", lambda st: {"x": st["x"] * 2})
|
|
235
|
+
child.add_edge(START, "inner_a"); child.add_edge("inner_a", "inner_b")
|
|
236
|
+
child.add_edge("inner_b", END)
|
|
237
|
+
parent = StateGraph(St)
|
|
238
|
+
parent.add_node("child", child.compile())
|
|
239
|
+
parent.add_edge(START, "child"); parent.add_edge("child", END)
|
|
240
|
+
app = parent.compile()
|
|
241
|
+
plain, xray = topology(app), topology(app, xray=True)
|
|
242
|
+
assert len(xray["nodes"]) > len(plain["nodes"]), (len(plain["nodes"]), len(xray["nodes"]))
|
|
243
|
+
assert any("inner_a" in n["id"] for n in xray["nodes"])
|
|
244
|
+
print("xray topology OK —", len(plain["nodes"]), "->", len(xray["nodes"]), "nodes")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
test_cost(); print("cost OK")
|
|
249
|
+
test_store_roundtrip()
|
|
250
|
+
test_tracer_local_graph()
|
|
251
|
+
test_search_filters_scores_sessions()
|
|
252
|
+
test_prune_and_stats()
|
|
253
|
+
test_tracer_metadata_session_tags()
|
|
254
|
+
test_source_extraction()
|
|
255
|
+
test_error_traceback_capture()
|
|
256
|
+
test_retriever_spans()
|
|
257
|
+
test_interrupt_status()
|
|
258
|
+
test_xray_topology()
|
|
259
|
+
print("ALL SMOKE TESTS PASSED")
|