windhover 0.7.0__py3-none-any.whl
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/__init__.py +5 -0
- windhover/config.py +32 -0
- windhover/demo_graph.py +48 -0
- windhover/extract.py +103 -0
- windhover/pricing.json +16 -0
- windhover/server.py +333 -0
- windhover/static/icon.svg +8 -0
- windhover/static/index.html +843 -0
- windhover/static/manifest.json +18 -0
- windhover/static/vendor/cytoscape-dagre.js +397 -0
- windhover/static/vendor/cytoscape.min.js +32 -0
- windhover/static/vendor/dagre.min.js +3809 -0
- windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
- windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
- windhover/static/vendor/fonts/fraunces-600.woff2 +0 -0
- windhover/store.py +347 -0
- windhover/tracer.py +325 -0
- windhover-0.7.0.dist-info/METADATA +111 -0
- windhover-0.7.0.dist-info/RECORD +22 -0
- windhover-0.7.0.dist-info/WHEEL +4 -0
- windhover-0.7.0.dist-info/entry_points.txt +2 -0
- windhover-0.7.0.dist-info/licenses/LICENSE +21 -0
windhover/__init__.py
ADDED
windhover/config.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Windhover configuration — one place, read from the environment."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
PKG_DIR = Path(__file__).resolve().parent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Config:
|
|
12
|
+
graph_ref: str # "module:attr" of a compiled graph ("" = ingest-only)
|
|
13
|
+
graph_dir: str # import path for the graph module
|
|
14
|
+
db_path: str
|
|
15
|
+
host: str
|
|
16
|
+
port: int
|
|
17
|
+
watch: bool # live-topology file watcher
|
|
18
|
+
pricing_path: str
|
|
19
|
+
retention_days: int # 0 = keep runs forever
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_env(cls) -> "Config":
|
|
23
|
+
return cls(
|
|
24
|
+
graph_ref=os.environ.get("WINDHOVER_GRAPH", ""),
|
|
25
|
+
graph_dir=os.environ.get("WINDHOVER_GRAPH_DIR", os.getcwd()),
|
|
26
|
+
db_path=os.environ.get("WINDHOVER_DB", str(PKG_DIR.parent / "windhover.db")),
|
|
27
|
+
host=os.environ.get("WINDHOVER_HOST", "0.0.0.0"),
|
|
28
|
+
port=int(os.environ.get("WINDHOVER_PORT", "8090")),
|
|
29
|
+
watch=os.environ.get("WINDHOVER_WATCH", "1") not in ("0", "false", "no"),
|
|
30
|
+
pricing_path=os.environ.get("WINDHOVER_PRICING", str(PKG_DIR / "pricing.json")),
|
|
31
|
+
retention_days=int(os.environ.get("WINDHOVER_RETENTION_DAYS", "0") or 0),
|
|
32
|
+
)
|
windhover/demo_graph.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Self-contained demo graph so Windhover runs out of the box:
|
|
2
|
+
|
|
3
|
+
WINDHOVER_GRAPH=windhover.demo_graph:graph python -m windhover.server
|
|
4
|
+
|
|
5
|
+
No external services. Edit this file while Windhover runs to watch the topology
|
|
6
|
+
update itself in the UI. Includes a parallel fan-out (grow -> parity/sign/
|
|
7
|
+
magnitude -> summarize) so the graph view and trace show concurrent branches;
|
|
8
|
+
`notes` uses an additive reducer so the branches can write it concurrently.
|
|
9
|
+
"""
|
|
10
|
+
import operator
|
|
11
|
+
import time
|
|
12
|
+
from typing import Annotated, TypedDict
|
|
13
|
+
from langgraph.graph import StateGraph, START, END
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class State(TypedDict):
|
|
17
|
+
n: int
|
|
18
|
+
notes: Annotated[list, operator.add]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def seed(s):
|
|
22
|
+
time.sleep(.2)
|
|
23
|
+
n = s.get("n", 1)
|
|
24
|
+
if n < 0:
|
|
25
|
+
raise ValueError(f"n must be non-negative, got {n} — the demo guard tripped")
|
|
26
|
+
return {"n": n}
|
|
27
|
+
def grow(s): time.sleep(.3); return {"n": s["n"] * 3}
|
|
28
|
+
def parity(s): time.sleep(.4); return {"notes": ["even" if s["n"] % 2 == 0 else "odd"]}
|
|
29
|
+
def sign(s): time.sleep(.25); return {"notes": ["positive" if s["n"] > 0 else "non-positive"]}
|
|
30
|
+
def magnitude(s): time.sleep(.35); return {"notes": ["big" if abs(s["n"]) > 100 else "small"]}
|
|
31
|
+
def summarize(s): time.sleep(.2); return {"notes": [f"n={s['n']}: " + ", ".join(s["notes"])]}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_g = StateGraph(State)
|
|
35
|
+
for name, fn in [("seed", seed), ("grow", grow), ("parity", parity),
|
|
36
|
+
("sign", sign), ("magnitude", magnitude), ("summarize", summarize)]:
|
|
37
|
+
_g.add_node(name, fn)
|
|
38
|
+
_g.add_edge(START, "seed")
|
|
39
|
+
_g.add_edge("seed", "grow")
|
|
40
|
+
_g.add_edge("grow", "parity")
|
|
41
|
+
_g.add_edge("grow", "sign")
|
|
42
|
+
_g.add_edge("grow", "magnitude")
|
|
43
|
+
_g.add_edge("parity", "summarize")
|
|
44
|
+
_g.add_edge("sign", "summarize")
|
|
45
|
+
_g.add_edge("magnitude", "summarize")
|
|
46
|
+
_g.add_edge("summarize", END)
|
|
47
|
+
|
|
48
|
+
graph = _g.compile()
|
windhover/extract.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Extract a compiled graph's topology, input schema, and per-node source as
|
|
2
|
+
JSON. Run as a subprocess so the live watcher always sees current-on-disk code
|
|
3
|
+
without importlib.reload fragility.
|
|
4
|
+
|
|
5
|
+
python -m windhover.extract "module:attr" "/graph/dir"
|
|
6
|
+
"""
|
|
7
|
+
import sys, json, importlib, inspect, functools
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def topology(graph, xray: bool = False) -> dict:
|
|
11
|
+
g = graph.get_graph(xray=True) if xray else graph.get_graph()
|
|
12
|
+
nodes = [{"id": nid, "label": str(getattr(n, "name", nid)).replace("__", ""),
|
|
13
|
+
"terminal": nid.endswith(("__start__", "__end__"))}
|
|
14
|
+
for nid, n in g.nodes.items()]
|
|
15
|
+
edges = [{"id": f"e{i}", "source": e.source, "target": e.target,
|
|
16
|
+
"conditional": bool(getattr(e, "conditional", False))}
|
|
17
|
+
for i, e in enumerate(g.edges)]
|
|
18
|
+
return {"nodes": nodes, "edges": edges}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def input_schema(graph) -> dict:
|
|
22
|
+
for meth in ("get_input_jsonschema", "get_input_schema"):
|
|
23
|
+
try:
|
|
24
|
+
s = getattr(graph, meth)()
|
|
25
|
+
return s if isinstance(s, dict) else s.model_json_schema()
|
|
26
|
+
except Exception:
|
|
27
|
+
continue
|
|
28
|
+
return {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _unwrap(fn):
|
|
32
|
+
"""Peel partials, decorators, and bound methods down to the user function."""
|
|
33
|
+
seen = set()
|
|
34
|
+
while id(fn) not in seen:
|
|
35
|
+
seen.add(id(fn))
|
|
36
|
+
if isinstance(fn, functools.partial):
|
|
37
|
+
fn = fn.func
|
|
38
|
+
elif hasattr(fn, "__wrapped__"):
|
|
39
|
+
fn = fn.__wrapped__
|
|
40
|
+
elif inspect.ismethod(fn):
|
|
41
|
+
fn = fn.__func__
|
|
42
|
+
else:
|
|
43
|
+
break
|
|
44
|
+
return fn
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _node_callable(graph, name):
|
|
48
|
+
"""Best-effort: the user callable backing a LangGraph node. The compiled
|
|
49
|
+
graph keeps the builder's node specs; RunnableCallable exposes .func/.afunc."""
|
|
50
|
+
candidates = (
|
|
51
|
+
lambda: graph.builder.nodes[name].runnable.func,
|
|
52
|
+
lambda: graph.builder.nodes[name].runnable.afunc,
|
|
53
|
+
lambda: graph.builder.nodes[name].runnable,
|
|
54
|
+
)
|
|
55
|
+
for get in candidates:
|
|
56
|
+
try:
|
|
57
|
+
fn = get()
|
|
58
|
+
except Exception:
|
|
59
|
+
continue
|
|
60
|
+
if fn is not None:
|
|
61
|
+
return _unwrap(fn)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def sources(graph) -> dict:
|
|
66
|
+
"""{node: {file, line_start, line_end, code}} for every node we can trace
|
|
67
|
+
to real source. Missing nodes simply aren't in the map — never an error."""
|
|
68
|
+
out = {}
|
|
69
|
+
builder = getattr(graph, "builder", None)
|
|
70
|
+
for name in (getattr(builder, "nodes", None) or {}):
|
|
71
|
+
fn = _node_callable(graph, name)
|
|
72
|
+
if fn is None:
|
|
73
|
+
continue
|
|
74
|
+
for target in (fn, type(fn)): # class-based runnables: show the class
|
|
75
|
+
try:
|
|
76
|
+
lines, start = inspect.getsourcelines(target)
|
|
77
|
+
out[name] = {"file": inspect.getsourcefile(target) or "",
|
|
78
|
+
"line_start": start,
|
|
79
|
+
"line_end": start + len(lines) - 1,
|
|
80
|
+
"code": "".join(lines)[:20000]}
|
|
81
|
+
break
|
|
82
|
+
except Exception:
|
|
83
|
+
continue
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load(ref: str, dir_: str):
|
|
88
|
+
sys.path.insert(0, dir_)
|
|
89
|
+
mod, attr = ref.split(":")
|
|
90
|
+
return getattr(importlib.import_module(mod), attr)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
ref, dir_ = sys.argv[1], sys.argv[2]
|
|
95
|
+
g = load(ref, dir_)
|
|
96
|
+
out = {"topology": topology(g), "schema": input_schema(g), "sources": sources(g)}
|
|
97
|
+
try: # subgraph x-ray view, only when it actually differs
|
|
98
|
+
tx = topology(g, xray=True)
|
|
99
|
+
if tx != out["topology"]:
|
|
100
|
+
out["topology_xray"] = tx
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
print(json.dumps(out))
|
windhover/pricing.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "USD per 1M tokens {input,output}. Longest-prefix match on model name. Miss -> cost null. Add your own.",
|
|
3
|
+
"gpt-5": {"input": 1.25, "output": 10},
|
|
4
|
+
"gpt-5-mini": {"input": 0.25, "output": 2},
|
|
5
|
+
"gpt-4o": {"input": 2.5, "output": 10},
|
|
6
|
+
"gpt-4o-mini": {"input": 0.15, "output": 0.6},
|
|
7
|
+
"o1": {"input": 15, "output": 60},
|
|
8
|
+
"o3-mini": {"input": 1.1, "output": 4.4},
|
|
9
|
+
"claude-opus-4": {"input": 15, "output": 75},
|
|
10
|
+
"claude-sonnet-4": {"input": 3, "output": 15},
|
|
11
|
+
"claude-3-5-haiku": {"input": 0.8, "output": 4},
|
|
12
|
+
"claude-3-5-sonnet": {"input": 3, "output": 15},
|
|
13
|
+
"gemini-2.5-pro": {"input": 1.25, "output": 10},
|
|
14
|
+
"gemini-2.5-flash": {"input": 0.3, "output": 2.5},
|
|
15
|
+
"deepseek-chat": {"input": 0.27, "output": 1.1}
|
|
16
|
+
}
|
windhover/server.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Windhover HTTP server — observes a LangGraph graph and any app that traces to it.
|
|
2
|
+
|
|
3
|
+
Runs execute on a worker thread with a DB-sink tracer, so a full span tree
|
|
4
|
+
(nodes -> LLM/tool children, tokens, cost) persists even if the browser leaves.
|
|
5
|
+
A background watcher re-extracts topology in a subprocess when the graph's source
|
|
6
|
+
changes and pushes it to the UI (the "living graph"). Runs use the imported graph
|
|
7
|
+
(restart to run new code); the *view* always reflects current-on-disk topology.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import sys, json, time, hashlib, threading, queue, subprocess, importlib, traceback
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from fastapi import FastAPI, Request
|
|
13
|
+
from fastapi.responses import StreamingResponse, JSONResponse, FileResponse
|
|
14
|
+
from fastapi.staticfiles import StaticFiles
|
|
15
|
+
|
|
16
|
+
from .config import Config
|
|
17
|
+
from .store import Store
|
|
18
|
+
from .tracer import SpanBuilder, db_sink, apply_to_store
|
|
19
|
+
|
|
20
|
+
cfg = Config.from_env()
|
|
21
|
+
store = Store(cfg.db_path)
|
|
22
|
+
STATIC = Path(__file__).parent / "static"
|
|
23
|
+
|
|
24
|
+
graph = None
|
|
25
|
+
if cfg.graph_ref:
|
|
26
|
+
sys.path.insert(0, cfg.graph_dir)
|
|
27
|
+
_m, _a = cfg.graph_ref.split(":")
|
|
28
|
+
graph = getattr(importlib.import_module(_m), _a)
|
|
29
|
+
|
|
30
|
+
app = FastAPI(title="Windhover")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---- topology manager (subprocess extraction, mtime-cached) ---------------
|
|
34
|
+
class Topo:
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self.lock = threading.Lock()
|
|
37
|
+
self.sig = None
|
|
38
|
+
self.data = {"topology": {"nodes": [], "edges": []}, "schema": {}, "sources": {}}
|
|
39
|
+
self.hash = ""
|
|
40
|
+
self.version = 0
|
|
41
|
+
|
|
42
|
+
def _signature(self) -> float:
|
|
43
|
+
if not cfg.graph_ref:
|
|
44
|
+
return 0.0
|
|
45
|
+
try:
|
|
46
|
+
return max((p.stat().st_mtime for p in Path(cfg.graph_dir).glob("*.py")),
|
|
47
|
+
default=0.0)
|
|
48
|
+
except Exception:
|
|
49
|
+
return 0.0
|
|
50
|
+
|
|
51
|
+
def refresh(self, force=False) -> None:
|
|
52
|
+
if not cfg.graph_ref:
|
|
53
|
+
return
|
|
54
|
+
sig = self._signature()
|
|
55
|
+
with self.lock:
|
|
56
|
+
if not force and sig == self.sig:
|
|
57
|
+
return
|
|
58
|
+
self.sig = sig
|
|
59
|
+
try:
|
|
60
|
+
out = subprocess.run(
|
|
61
|
+
[sys.executable, "-m", "windhover.extract", cfg.graph_ref, cfg.graph_dir],
|
|
62
|
+
capture_output=True, text=True, timeout=20,
|
|
63
|
+
cwd=str(Path(__file__).parent.parent))
|
|
64
|
+
data = json.loads(out.stdout)
|
|
65
|
+
except Exception:
|
|
66
|
+
return
|
|
67
|
+
h = hashlib.sha1(json.dumps(data["topology"], sort_keys=True).encode()).hexdigest()[:12]
|
|
68
|
+
with self.lock:
|
|
69
|
+
changed = h != self.hash
|
|
70
|
+
self.data, self.hash = data, h
|
|
71
|
+
if changed:
|
|
72
|
+
self.version += 1
|
|
73
|
+
|
|
74
|
+
def get(self) -> dict:
|
|
75
|
+
with self.lock:
|
|
76
|
+
return {**self.data["topology"], "graph": cfg.graph_ref, "hash": self.hash,
|
|
77
|
+
"version": self.version, "xray": self.data.get("topology_xray")}
|
|
78
|
+
|
|
79
|
+
def schema(self) -> dict:
|
|
80
|
+
with self.lock:
|
|
81
|
+
return self.data.get("schema", {})
|
|
82
|
+
|
|
83
|
+
def sources(self) -> dict:
|
|
84
|
+
with self.lock:
|
|
85
|
+
return self.data.get("sources", {})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
TOPO = Topo()
|
|
89
|
+
TOPO.refresh(force=True)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _watch_loop():
|
|
93
|
+
while cfg.watch and cfg.graph_ref:
|
|
94
|
+
time.sleep(2)
|
|
95
|
+
try:
|
|
96
|
+
TOPO.refresh()
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if cfg.watch and cfg.graph_ref:
|
|
102
|
+
threading.Thread(target=_watch_loop, daemon=True).start()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _retention_loop():
|
|
106
|
+
while True:
|
|
107
|
+
try:
|
|
108
|
+
res = store.prune(cfg.retention_days)
|
|
109
|
+
if res["pruned_runs"]:
|
|
110
|
+
print(f"[windhover] retention: pruned {res['pruned_runs']} runs "
|
|
111
|
+
f"older than {cfg.retention_days}d")
|
|
112
|
+
except Exception as e:
|
|
113
|
+
print(f"[windhover] retention error: {e}")
|
|
114
|
+
time.sleep(6 * 3600)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if cfg.retention_days > 0:
|
|
118
|
+
threading.Thread(target=_retention_loop, daemon=True).start()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _template(schema: dict) -> dict:
|
|
122
|
+
out = {}
|
|
123
|
+
for k, p in (schema.get("properties") or {}).items():
|
|
124
|
+
out[k] = {"array": [], "object": {}, "string": "", "integer": 0,
|
|
125
|
+
"number": 0, "boolean": False}.get(p.get("type"))
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---- endpoints ------------------------------------------------------------
|
|
130
|
+
@app.get("/api/graph")
|
|
131
|
+
def api_graph():
|
|
132
|
+
return JSONResponse(TOPO.get())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.get("/api/schema")
|
|
136
|
+
def api_schema():
|
|
137
|
+
s = TOPO.schema()
|
|
138
|
+
return JSONResponse({"schema": s, "template": _template(s)})
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _sse(ev: str, data: dict) -> str:
|
|
142
|
+
return f"event: {ev}\ndata: {json.dumps(data)}\n\n"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.post("/api/run")
|
|
146
|
+
async def api_run(request: Request):
|
|
147
|
+
if graph is None:
|
|
148
|
+
return JSONResponse({"error": "no local graph (WINDHOVER_GRAPH unset)"}, 400)
|
|
149
|
+
try:
|
|
150
|
+
payload = await request.json()
|
|
151
|
+
except Exception:
|
|
152
|
+
payload = {}
|
|
153
|
+
session = payload.pop("_session", None)
|
|
154
|
+
tags = payload.pop("_tags", None)
|
|
155
|
+
tracer = SpanBuilder(db_sink(store), run_name=cfg.graph_ref, session=session, tags=tags)
|
|
156
|
+
run_id = tracer.run_id
|
|
157
|
+
q: "queue.Queue" = queue.Queue()
|
|
158
|
+
|
|
159
|
+
def worker():
|
|
160
|
+
try:
|
|
161
|
+
interrupted = False
|
|
162
|
+
for update in graph.stream(payload, config={"callbacks": [tracer]},
|
|
163
|
+
stream_mode="updates"):
|
|
164
|
+
for node in update:
|
|
165
|
+
if node == "__interrupt__":
|
|
166
|
+
interrupted = True
|
|
167
|
+
q.put(("interrupt", {"run_id": run_id}))
|
|
168
|
+
else:
|
|
169
|
+
q.put(("node", {"node": node}))
|
|
170
|
+
q.put(("interrupted" if interrupted else "done", {"run_id": run_id}))
|
|
171
|
+
except Exception as e:
|
|
172
|
+
# tracer already recorded the error span/close; surface to the client too
|
|
173
|
+
q.put(("error", {"message": str(e), "trace": traceback.format_exc()[-600:]}))
|
|
174
|
+
q.put((None, None))
|
|
175
|
+
|
|
176
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
177
|
+
|
|
178
|
+
def stream():
|
|
179
|
+
yield _sse("start", {"run_id": run_id})
|
|
180
|
+
while True:
|
|
181
|
+
ev, data = q.get()
|
|
182
|
+
if ev is None:
|
|
183
|
+
break
|
|
184
|
+
yield _sse(ev, data)
|
|
185
|
+
return StreamingResponse(stream(), media_type="text/event-stream")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@app.post("/api/ingest")
|
|
189
|
+
async def api_ingest(request: Request):
|
|
190
|
+
ev = await request.json()
|
|
191
|
+
apply_to_store(store, ev, source="ingest")
|
|
192
|
+
return {"ok": True, "run_id": ev.get("run_id")}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _run_filters(limit: int, offset: int, q, status, graph, session, tag,
|
|
196
|
+
bookmarked, since_ms, until_ms) -> dict:
|
|
197
|
+
return dict(limit=max(1, min(limit, 500)), offset=max(0, offset),
|
|
198
|
+
q=q or None, status=status or None, graph=graph or None,
|
|
199
|
+
session=session or None, tag=tag or None,
|
|
200
|
+
bookmarked=bool(bookmarked) or None,
|
|
201
|
+
since_ms=since_ms, until_ms=until_ms)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@app.get("/api/runs")
|
|
205
|
+
def api_runs(limit: int = 50, offset: int = 0, q: str = None, status: str = None,
|
|
206
|
+
graph: str = None, session: str = None, tag: str = None,
|
|
207
|
+
bookmarked: int = 0, since_ms: int = None, until_ms: int = None):
|
|
208
|
+
return JSONResponse(store.runs(**_run_filters(
|
|
209
|
+
limit, offset, q, status, graph, session, tag, bookmarked, since_ms, until_ms)))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@app.get("/api/sessions")
|
|
213
|
+
def api_sessions(limit: int = 100):
|
|
214
|
+
return JSONResponse(store.sessions(limit=limit))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.get("/api/runs/{run_id}")
|
|
218
|
+
def api_run_detail(run_id: str):
|
|
219
|
+
d = store.run_detail(run_id)
|
|
220
|
+
return JSONResponse(d) if d else JSONResponse({"error": "not found"}, 404)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.patch("/api/runs/{run_id}")
|
|
224
|
+
async def api_run_patch(request: Request, run_id: str):
|
|
225
|
+
body = await request.json()
|
|
226
|
+
tags = body.get("tags")
|
|
227
|
+
if tags is not None and not isinstance(tags, list):
|
|
228
|
+
return JSONResponse({"error": "tags must be a list"}, 400)
|
|
229
|
+
ok = store.update_run_meta(run_id, tags=tags, bookmarked=body.get("bookmarked"))
|
|
230
|
+
return JSONResponse({"ok": ok}) if ok else JSONResponse({"error": "not found"}, 404)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@app.post("/api/runs/{run_id}/scores")
|
|
234
|
+
async def api_score_add(request: Request, run_id: str):
|
|
235
|
+
body = await request.json()
|
|
236
|
+
try:
|
|
237
|
+
name, value = str(body["name"]).strip(), float(body["value"])
|
|
238
|
+
except (KeyError, TypeError, ValueError):
|
|
239
|
+
return JSONResponse({"error": "requires name (str) and value (number)"}, 400)
|
|
240
|
+
if not name:
|
|
241
|
+
return JSONResponse({"error": "name must be non-empty"}, 400)
|
|
242
|
+
sc = store.add_score(run_id, name, value, comment=body.get("comment"),
|
|
243
|
+
source=body.get("source", "api"))
|
|
244
|
+
return JSONResponse(sc) if sc else JSONResponse({"error": "run not found"}, 404)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@app.delete("/api/scores/{score_id}")
|
|
248
|
+
def api_score_delete(score_id: str):
|
|
249
|
+
return JSONResponse({"ok": store.delete_score(score_id)})
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@app.get("/api/export")
|
|
253
|
+
def api_export(format: str = "json", limit: int = 10000, q: str = None,
|
|
254
|
+
status: str = None, graph: str = None, session: str = None,
|
|
255
|
+
tag: str = None, bookmarked: int = 0,
|
|
256
|
+
since_ms: int = None, until_ms: int = None):
|
|
257
|
+
data = store.runs(**{**_run_filters(limit, 0, q, status, graph, session, tag,
|
|
258
|
+
bookmarked, since_ms, until_ms),
|
|
259
|
+
"limit": max(1, min(limit, 100_000))})["runs"]
|
|
260
|
+
if format == "csv":
|
|
261
|
+
import csv, io
|
|
262
|
+
cols = ["id", "graph", "source", "status", "session", "tags", "started_ms",
|
|
263
|
+
"duration_ms", "node_count", "llm_calls", "prompt_tokens",
|
|
264
|
+
"completion_tokens", "total_tokens", "cost_usd", "error"]
|
|
265
|
+
buf = io.StringIO()
|
|
266
|
+
w = csv.writer(buf)
|
|
267
|
+
w.writerow(cols)
|
|
268
|
+
for r in data:
|
|
269
|
+
w.writerow([json.dumps(r[k]) if isinstance(r.get(k), (list, dict))
|
|
270
|
+
else r.get(k) for k in cols])
|
|
271
|
+
return StreamingResponse(iter([buf.getvalue()]), media_type="text/csv",
|
|
272
|
+
headers={"Content-Disposition": "attachment; filename=windhover-runs.csv"})
|
|
273
|
+
return JSONResponse(data)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@app.get("/api/nodes/{name}")
|
|
277
|
+
def api_node(name: str, limit: int = 25):
|
|
278
|
+
return JSONResponse(store.node_history(name, limit))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@app.get("/api/nodes/{name}/source")
|
|
282
|
+
def api_node_source(name: str):
|
|
283
|
+
src = TOPO.sources().get(name)
|
|
284
|
+
if src:
|
|
285
|
+
return JSONResponse(src)
|
|
286
|
+
return JSONResponse({"error": "no source available for this node "
|
|
287
|
+
"(external-only run, or a runnable inspect can't trace)"}, 404)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@app.get("/api/stats")
|
|
291
|
+
def api_stats(days: int = 30):
|
|
292
|
+
return JSONResponse(store.stats(days=max(1, min(days, 365))))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@app.get("/api/events")
|
|
296
|
+
async def api_events(request: Request):
|
|
297
|
+
async def gen():
|
|
298
|
+
import asyncio
|
|
299
|
+
seen = -1
|
|
300
|
+
yield _sse("hello", {"version": TOPO.version})
|
|
301
|
+
while True:
|
|
302
|
+
if await request.is_disconnected():
|
|
303
|
+
break
|
|
304
|
+
if TOPO.version != seen:
|
|
305
|
+
seen = TOPO.version
|
|
306
|
+
yield _sse("topology", {"version": seen})
|
|
307
|
+
else:
|
|
308
|
+
yield ": ping\n\n"
|
|
309
|
+
await asyncio.sleep(2)
|
|
310
|
+
return StreamingResponse(gen(), media_type="text/event-stream")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@app.get("/manifest.json")
|
|
314
|
+
def manifest():
|
|
315
|
+
return FileResponse(STATIC / "manifest.json")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@app.get("/")
|
|
319
|
+
def index():
|
|
320
|
+
# no-cache so UI upgrades take effect on next load (vendor assets still cache)
|
|
321
|
+
return FileResponse(STATIC / "index.html", headers={"Cache-Control": "no-cache"})
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def main():
|
|
328
|
+
import uvicorn
|
|
329
|
+
uvicorn.run(app, host=cfg.host, port=cfg.port)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
if __name__ == "__main__":
|
|
333
|
+
main()
|
|
@@ -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>
|