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/store.py ADDED
@@ -0,0 +1,347 @@
1
+ """Windhover data store — SQLite (WAL), a run + span-tree model.
2
+
3
+ Runs hold aggregate totals; spans form a tree (node spans top-level, LLM/tool
4
+ spans nest under a node). Thread-safe via a module lock; each op uses a short-
5
+ lived connection so worker threads and the request thread never share one.
6
+
7
+ Search uses FTS5 over span payloads when the host SQLite has it, and degrades
8
+ to LIKE scans when it doesn't; tag filtering likewise prefers json1 and falls
9
+ back to a substring match. Feature detection happens once at startup so the
10
+ same code runs on any Python/SQLite build.
11
+ """
12
+ from __future__ import annotations
13
+ import json, os, sqlite3, threading, time, uuid
14
+ from typing import Any, Optional
15
+
16
+ SCHEMA_VERSION = 4
17
+ _lock = threading.Lock()
18
+
19
+
20
+ def _fts_match(q: str) -> str:
21
+ """Turn free text into a safe FTS5 MATCH string (quoted AND terms)."""
22
+ return " ".join('"%s"' % t.replace('"', '""') for t in q.split())
23
+
24
+
25
+ class Store:
26
+ def __init__(self, path: str):
27
+ self.path = path
28
+ self.has_fts = False
29
+ self.has_json1 = False
30
+ self._init()
31
+
32
+ def _conn(self) -> sqlite3.Connection:
33
+ c = sqlite3.connect(self.path, check_same_thread=False, timeout=10)
34
+ c.row_factory = sqlite3.Row
35
+ c.execute("PRAGMA journal_mode=WAL")
36
+ c.execute("PRAGMA busy_timeout=5000")
37
+ return c
38
+
39
+ def _init(self) -> None:
40
+ with _lock, self._conn() as c:
41
+ c.executescript("""
42
+ CREATE TABLE IF NOT EXISTS schema_meta(version INTEGER);
43
+ CREATE TABLE IF NOT EXISTS runs(
44
+ id TEXT PRIMARY KEY, graph TEXT, source TEXT, status TEXT,
45
+ session TEXT, tags TEXT, input TEXT, error TEXT,
46
+ started_ms INTEGER, ended_ms INTEGER, duration_ms INTEGER,
47
+ node_count INTEGER DEFAULT 0, llm_calls INTEGER DEFAULT 0,
48
+ prompt_tokens INTEGER, completion_tokens INTEGER,
49
+ total_tokens INTEGER, cost_usd REAL);
50
+ CREATE TABLE IF NOT EXISTS spans(
51
+ id TEXT PRIMARY KEY, run_id TEXT, parent_id TEXT, seq INTEGER,
52
+ type TEXT, name TEXT, status TEXT,
53
+ started_ms INTEGER, ended_ms INTEGER, offset_ms INTEGER, dur_ms INTEGER,
54
+ input TEXT, output TEXT, model TEXT,
55
+ prompt_tokens INTEGER, completion_tokens INTEGER, cost_usd REAL, error TEXT);
56
+ CREATE TABLE IF NOT EXISTS scores(
57
+ id TEXT PRIMARY KEY, run_id TEXT, name TEXT, value REAL,
58
+ comment TEXT, source TEXT, created_ms INTEGER);
59
+ CREATE INDEX IF NOT EXISTS ix_spans_run ON spans(run_id, seq);
60
+ CREATE INDEX IF NOT EXISTS ix_runs_started ON runs(started_ms DESC);
61
+ CREATE INDEX IF NOT EXISTS ix_runs_session ON runs(session);
62
+ CREATE INDEX IF NOT EXISTS ix_scores_run ON scores(run_id);
63
+ """)
64
+ cols = [r[1] for r in c.execute("PRAGMA table_info(runs)").fetchall()]
65
+ if "bookmarked" not in cols:
66
+ c.execute("ALTER TABLE runs ADD COLUMN bookmarked INTEGER DEFAULT 0")
67
+ try:
68
+ c.execute("SELECT count(*) FROM json_each('[1]')")
69
+ self.has_json1 = True
70
+ except sqlite3.OperationalError:
71
+ self.has_json1 = False
72
+ try:
73
+ c.execute("""CREATE VIRTUAL TABLE IF NOT EXISTS span_fts
74
+ USING fts5(text, span_id UNINDEXED, run_id UNINDEXED)""")
75
+ self.has_fts = True
76
+ # one-time backfill (first boot after upgrade)
77
+ n_fts = c.execute("SELECT count(*) FROM span_fts").fetchone()[0]
78
+ n_spans = c.execute("SELECT count(*) FROM spans").fetchone()[0]
79
+ if n_fts == 0 and n_spans > 0:
80
+ for s in c.execute("SELECT id,run_id,name,model,error,input,output FROM spans"):
81
+ c.execute("INSERT INTO span_fts(text,span_id,run_id) VALUES(?,?,?)",
82
+ (self._span_text(dict(s)), s["id"], s["run_id"]))
83
+ except sqlite3.OperationalError:
84
+ self.has_fts = False
85
+ row = c.execute("SELECT version FROM schema_meta LIMIT 1").fetchone()
86
+ if row is None:
87
+ self._migrate_v2(c)
88
+ c.execute("INSERT INTO schema_meta(version) VALUES(?)", (SCHEMA_VERSION,))
89
+ elif row["version"] < SCHEMA_VERSION:
90
+ c.execute("UPDATE schema_meta SET version=?", (SCHEMA_VERSION,))
91
+
92
+ @staticmethod
93
+ def _span_text(s: dict) -> str:
94
+ parts = [s.get("name"), s.get("model"), s.get("error")]
95
+ for f in ("input", "output"):
96
+ v = s.get(f)
97
+ if v is not None:
98
+ parts.append(v if isinstance(v, str) else json.dumps(v, default=str))
99
+ return " ".join(p for p in parts if p)
100
+
101
+ def _migrate_v2(self, c: sqlite3.Connection) -> None:
102
+ """Best-effort: fold legacy v2 flat `events` into node spans (dev data)."""
103
+ try:
104
+ has = c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='events'").fetchone()
105
+ if not has:
106
+ return
107
+ for e in c.execute("SELECT run_id,seq,node,summary,offset_ms,dur_ms FROM events").fetchall():
108
+ c.execute("""INSERT OR IGNORE INTO spans
109
+ (id,run_id,parent_id,seq,type,name,status,offset_ms,dur_ms,output)
110
+ VALUES(?,?,?,?,?,?,?,?,?,?)""",
111
+ (f"{e['run_id']}-{e['seq']}", e["run_id"], None, e["seq"], "node",
112
+ e["node"], "ok", e["offset_ms"], e["dur_ms"], e["summary"]))
113
+ c.execute("ALTER TABLE events RENAME TO events_legacy_v2")
114
+ except Exception:
115
+ pass # migration is a courtesy; never block startup
116
+
117
+ # ---- writes -----------------------------------------------------------
118
+ def open_run(self, run: dict) -> None:
119
+ with _lock, self._conn() as c:
120
+ c.execute("""INSERT OR REPLACE INTO runs
121
+ (id,graph,source,status,session,tags,input,started_ms)
122
+ VALUES(?,?,?,?,?,?,?,?)""",
123
+ (run["id"], run.get("graph"), run.get("source", "ui"), "running",
124
+ run.get("session"), json.dumps(run.get("tags")),
125
+ json.dumps(run.get("input"), default=str), run["started_ms"]))
126
+
127
+ def add_span(self, s: dict) -> None:
128
+ with _lock, self._conn() as c:
129
+ c.execute("""INSERT OR REPLACE INTO spans
130
+ (id,run_id,parent_id,seq,type,name,status,started_ms,ended_ms,offset_ms,
131
+ dur_ms,input,output,model,prompt_tokens,completion_tokens,cost_usd,error)
132
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
133
+ (s["id"], s["run_id"], s.get("parent_id"), s.get("seq", 0), s["type"],
134
+ s.get("name"), s.get("status", "ok"), s.get("started_ms"), s.get("ended_ms"),
135
+ s.get("offset_ms"), s.get("dur_ms"),
136
+ json.dumps(s.get("input"), default=str) if s.get("input") is not None else None,
137
+ json.dumps(s.get("output"), default=str) if s.get("output") is not None else None,
138
+ s.get("model"), s.get("prompt_tokens"), s.get("completion_tokens"),
139
+ s.get("cost_usd"), s.get("error")))
140
+ if self.has_fts:
141
+ c.execute("DELETE FROM span_fts WHERE span_id=?", (s["id"],))
142
+ c.execute("INSERT INTO span_fts(text,span_id,run_id) VALUES(?,?,?)",
143
+ (self._span_text(s), s["id"], s["run_id"]))
144
+
145
+ def close_run(self, run_id: str, status: str, ended_ms: int, error: Optional[str] = None) -> None:
146
+ with _lock, self._conn() as c:
147
+ agg = c.execute("""SELECT
148
+ COUNT(*) FILTER (WHERE type='node') nc,
149
+ COUNT(*) FILTER (WHERE type='llm') lc,
150
+ SUM(prompt_tokens) pt, SUM(completion_tokens) ct, SUM(cost_usd) cost
151
+ FROM spans WHERE run_id=?""", (run_id,)).fetchone()
152
+ st = c.execute("SELECT started_ms FROM runs WHERE id=?", (run_id,)).fetchone()
153
+ started = st["started_ms"] if st else ended_ms
154
+ pt, ct = agg["pt"], agg["ct"]
155
+ c.execute("""UPDATE runs SET status=?,ended_ms=?,duration_ms=?,error=?,
156
+ node_count=?,llm_calls=?,prompt_tokens=?,completion_tokens=?,total_tokens=?,cost_usd=?
157
+ WHERE id=?""",
158
+ (status, ended_ms, ended_ms - started, error, agg["nc"], agg["lc"],
159
+ pt, ct, (pt or 0) + (ct or 0) if (pt or ct) else None, agg["cost"], run_id))
160
+
161
+ def update_run_meta(self, run_id: str, tags: Optional[list] = None,
162
+ bookmarked: Optional[bool] = None) -> bool:
163
+ sets, args = [], []
164
+ if tags is not None:
165
+ sets.append("tags=?"); args.append(json.dumps([str(t) for t in tags]))
166
+ if bookmarked is not None:
167
+ sets.append("bookmarked=?"); args.append(1 if bookmarked else 0)
168
+ if not sets:
169
+ return False
170
+ args.append(run_id)
171
+ with _lock, self._conn() as c:
172
+ cur = c.execute(f"UPDATE runs SET {','.join(sets)} WHERE id=?", args)
173
+ return cur.rowcount > 0
174
+
175
+ def add_score(self, run_id: str, name: str, value: float,
176
+ comment: Optional[str] = None, source: str = "api") -> Optional[dict]:
177
+ with _lock, self._conn() as c:
178
+ if not c.execute("SELECT 1 FROM runs WHERE id=?", (run_id,)).fetchone():
179
+ return None
180
+ sc = {"id": uuid.uuid4().hex[:12], "run_id": run_id, "name": str(name),
181
+ "value": float(value), "comment": comment, "source": source,
182
+ "created_ms": int(time.time() * 1000)}
183
+ c.execute("""INSERT INTO scores(id,run_id,name,value,comment,source,created_ms)
184
+ VALUES(?,?,?,?,?,?,?)""",
185
+ (sc["id"], sc["run_id"], sc["name"], sc["value"],
186
+ sc["comment"], sc["source"], sc["created_ms"]))
187
+ return sc
188
+
189
+ def delete_score(self, score_id: str) -> bool:
190
+ with _lock, self._conn() as c:
191
+ return c.execute("DELETE FROM scores WHERE id=?", (score_id,)).rowcount > 0
192
+
193
+ def prune(self, days: int) -> dict:
194
+ """Delete runs (and their spans/scores/index rows) older than `days`."""
195
+ cutoff = int((time.time() - days * 86400) * 1000)
196
+ with _lock, self._conn() as c:
197
+ ids = [r["id"] for r in
198
+ c.execute("SELECT id FROM runs WHERE started_ms < ?", (cutoff,)).fetchall()]
199
+ for chunk in (ids[i:i + 500] for i in range(0, len(ids), 500)):
200
+ q = ",".join("?" * len(chunk))
201
+ if self.has_fts:
202
+ c.execute(f"DELETE FROM span_fts WHERE run_id IN ({q})", chunk)
203
+ c.execute(f"DELETE FROM spans WHERE run_id IN ({q})", chunk)
204
+ c.execute(f"DELETE FROM scores WHERE run_id IN ({q})", chunk)
205
+ c.execute(f"DELETE FROM runs WHERE id IN ({q})", chunk)
206
+ return {"pruned_runs": len(ids), "cutoff_ms": cutoff}
207
+
208
+ # ---- reads ------------------------------------------------------------
209
+ def runs(self, limit: int = 50, offset: int = 0, q: Optional[str] = None,
210
+ status: Optional[str] = None, graph: Optional[str] = None,
211
+ session: Optional[str] = None, tag: Optional[str] = None,
212
+ bookmarked: Optional[bool] = None,
213
+ since_ms: Optional[int] = None, until_ms: Optional[int] = None) -> dict:
214
+ where, args = [], []
215
+ if q:
216
+ if self.has_fts:
217
+ where.append("id IN (SELECT DISTINCT run_id FROM span_fts WHERE span_fts MATCH ?)")
218
+ args.append(_fts_match(q))
219
+ else:
220
+ like = f"%{q}%"
221
+ where.append("""id IN (SELECT DISTINCT run_id FROM spans
222
+ WHERE input LIKE ? OR output LIKE ? OR name LIKE ? OR error LIKE ?)""")
223
+ args += [like, like, like, like]
224
+ if status:
225
+ where.append("status=?"); args.append(status)
226
+ if graph:
227
+ where.append("graph=?"); args.append(graph)
228
+ if session:
229
+ where.append("session=?"); args.append(session)
230
+ if tag:
231
+ if self.has_json1:
232
+ where.append("""EXISTS (SELECT 1 FROM json_each(COALESCE(runs.tags,'[]')) je
233
+ WHERE je.value=?)""")
234
+ args.append(tag)
235
+ else:
236
+ where.append("COALESCE(runs.tags,'') LIKE ?")
237
+ args.append(f'%"{tag}"%')
238
+ if bookmarked:
239
+ where.append("bookmarked=1")
240
+ if since_ms is not None:
241
+ where.append("started_ms>=?"); args.append(since_ms)
242
+ if until_ms is not None:
243
+ where.append("started_ms<=?"); args.append(until_ms)
244
+ cond = (" WHERE " + " AND ".join(where)) if where else ""
245
+ with _lock, self._conn() as c:
246
+ total = c.execute(f"SELECT COUNT(*) FROM runs{cond}", args).fetchone()[0]
247
+ rows = [dict(r) for r in c.execute(
248
+ f"SELECT * FROM runs{cond} ORDER BY started_ms DESC LIMIT ? OFFSET ?",
249
+ [*args, limit, offset]).fetchall()]
250
+ if rows:
251
+ ph = ",".join("?" * len(rows))
252
+ sc: dict[str, dict] = {}
253
+ for s in c.execute(
254
+ f"SELECT run_id,name,AVG(value) v FROM scores WHERE run_id IN ({ph}) "
255
+ "GROUP BY run_id,name", [r["id"] for r in rows]).fetchall():
256
+ sc.setdefault(s["run_id"], {})[s["name"]] = round(s["v"], 4)
257
+ for r in rows:
258
+ r["tags"] = json.loads(r["tags"]) if r.get("tags") else None
259
+ r["scores"] = sc.get(r["id"]) or None
260
+ return {"runs": rows, "total": total, "limit": limit, "offset": offset}
261
+
262
+ def sessions(self, limit: int = 100) -> list[dict]:
263
+ with _lock, self._conn() as c:
264
+ return [dict(r) for r in c.execute("""SELECT session,
265
+ COUNT(*) runs, COUNT(*) FILTER (WHERE status='error') errors,
266
+ MIN(started_ms) first_ms, MAX(started_ms) last_ms,
267
+ SUM(total_tokens) tokens, SUM(cost_usd) cost, SUM(duration_ms) duration_ms
268
+ FROM runs WHERE session IS NOT NULL AND session!=''
269
+ GROUP BY session ORDER BY last_ms DESC LIMIT ?""", (limit,)).fetchall()]
270
+
271
+ def run_detail(self, run_id: str) -> Optional[dict]:
272
+ with _lock, self._conn() as c:
273
+ r = c.execute("SELECT * FROM runs WHERE id=?", (run_id,)).fetchone()
274
+ if not r:
275
+ return None
276
+ spans = [dict(s) for s in c.execute(
277
+ "SELECT * FROM spans WHERE run_id=? ORDER BY seq", (run_id,)).fetchall()]
278
+ scores = [dict(s) for s in c.execute(
279
+ "SELECT * FROM scores WHERE run_id=? ORDER BY created_ms", (run_id,)).fetchall()]
280
+ d = dict(r)
281
+ for k in ("input", "tags"):
282
+ d[k] = json.loads(d[k]) if d.get(k) else None
283
+ for s in spans:
284
+ for k in ("input", "output"):
285
+ s[k] = json.loads(s[k]) if s.get(k) else None
286
+ d["spans"] = spans
287
+ d["scores"] = scores
288
+ return d
289
+
290
+ def node_history(self, name: str, limit: int = 25) -> dict:
291
+ """Aggregate + recent executions of one named node/span across all runs,
292
+ each with its payloads and direct child spans (LLM/tool calls inside it)."""
293
+ with _lock, self._conn() as c:
294
+ agg = c.execute("""SELECT COUNT(*) n, AVG(dur_ms) avg_ms,
295
+ MIN(dur_ms) min_ms, MAX(dur_ms) max_ms,
296
+ COUNT(*) FILTER (WHERE status='error') errors,
297
+ SUM(COALESCE(prompt_tokens,0)+COALESCE(completion_tokens,0)) tokens,
298
+ SUM(cost_usd) cost
299
+ FROM spans WHERE name=?""", (name,)).fetchone()
300
+ rows = [dict(r) for r in c.execute("""SELECT s.*, r.graph, r.source
301
+ FROM spans s LEFT JOIN runs r ON r.id=s.run_id
302
+ WHERE s.name=? ORDER BY s.started_ms DESC LIMIT ?""",
303
+ (name, limit)).fetchall()]
304
+ kids: dict[str, list] = {}
305
+ if rows:
306
+ q = ",".join("?" * len(rows))
307
+ for k in c.execute(
308
+ f"SELECT * FROM spans WHERE parent_id IN ({q}) ORDER BY seq",
309
+ [r["id"] for r in rows]).fetchall():
310
+ kids.setdefault(k["parent_id"], []).append(dict(k))
311
+ for r in rows:
312
+ r["children"] = kids.get(r["id"], [])
313
+ for s in (r, *r["children"]):
314
+ for f in ("input", "output"):
315
+ s[f] = json.loads(s[f]) if s.get(f) else None
316
+ return {"name": name, "summary": dict(agg), "recent": rows}
317
+
318
+ def stats(self, days: int = 30) -> dict:
319
+ cutoff = int((time.time() - days * 86400) * 1000)
320
+ with _lock, self._conn() as c:
321
+ tot = c.execute("""SELECT COUNT(*) runs,
322
+ COUNT(*) FILTER (WHERE status='error') errors,
323
+ SUM(total_tokens) tokens, SUM(cost_usd) cost,
324
+ SUM(llm_calls) llm_calls FROM runs""").fetchone()
325
+ per_node = c.execute("""SELECT name, COUNT(*) n, AVG(dur_ms) avg_ms,
326
+ SUM(cost_usd) cost FROM spans WHERE type='node'
327
+ GROUP BY name ORDER BY avg_ms DESC LIMIT 20""").fetchall()
328
+ models = c.execute("""SELECT model, COUNT(*) calls,
329
+ SUM(prompt_tokens) prompt_tokens, SUM(completion_tokens) completion_tokens,
330
+ SUM(cost_usd) cost, AVG(dur_ms) avg_ms
331
+ FROM spans WHERE type='llm' AND model IS NOT NULL
332
+ GROUP BY model ORDER BY calls DESC LIMIT 20""").fetchall()
333
+ daily = c.execute("""SELECT
334
+ strftime('%Y-%m-%d', started_ms/1000, 'unixepoch') day,
335
+ COUNT(*) runs, COUNT(*) FILTER (WHERE status='error') errors,
336
+ SUM(total_tokens) tokens, SUM(cost_usd) cost
337
+ FROM runs WHERE started_ms >= ?
338
+ GROUP BY day ORDER BY day""", (cutoff,)).fetchall()
339
+ try:
340
+ db_bytes = os.path.getsize(self.path)
341
+ except OSError:
342
+ db_bytes = None
343
+ return {"totals": {**dict(tot), "db_bytes": db_bytes},
344
+ "per_node": [dict(r) for r in per_node],
345
+ "models": [dict(r) for r in models],
346
+ "daily": [dict(r) for r in daily],
347
+ "days": days}