@misterhuydo/sentinel 1.2.4 → 1.2.6
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.
- package/.cairn/.hint-lock +1 -1
- package/.cairn/session.json +2 -2
- package/package.json +21 -21
- package/python/sentinel/config_loader.py +14 -0
- package/python/sentinel/fix_engine.py +259 -242
- package/python/sentinel/health_checker.py +219 -0
- package/python/sentinel/log_syncer.py +164 -0
- package/python/sentinel/main.py +62 -0
- package/python/sentinel/sentinel_boss.py +2406 -2143
- package/python/sentinel/state_store.py +542 -499
|
@@ -1,499 +1,542 @@
|
|
|
1
|
-
"""
|
|
2
|
-
state_store.py — SQLite-backed persistence for errors, fixes, and reports.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import sqlite3
|
|
7
|
-
import logging
|
|
8
|
-
from contextlib import contextmanager
|
|
9
|
-
from datetime import datetime, timezone
|
|
10
|
-
|
|
11
|
-
logger = logging.getLogger(__name__)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def _now() -> str:
|
|
15
|
-
return datetime.now(timezone.utc).isoformat()
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class StateStore:
|
|
19
|
-
def __init__(self, db_path: str = "./sentinel.db"):
|
|
20
|
-
self.db_path = db_path
|
|
21
|
-
self._init_db()
|
|
22
|
-
|
|
23
|
-
@contextmanager
|
|
24
|
-
def _conn(self):
|
|
25
|
-
conn = sqlite3.connect(self.db_path)
|
|
26
|
-
conn.row_factory = sqlite3.Row
|
|
27
|
-
try:
|
|
28
|
-
yield conn
|
|
29
|
-
conn.commit()
|
|
30
|
-
finally:
|
|
31
|
-
conn.close()
|
|
32
|
-
|
|
33
|
-
def _init_db(self):
|
|
34
|
-
with self._conn() as conn:
|
|
35
|
-
conn.executescript("""
|
|
36
|
-
CREATE TABLE IF NOT EXISTS errors (
|
|
37
|
-
fingerprint TEXT PRIMARY KEY,
|
|
38
|
-
first_seen TEXT NOT NULL,
|
|
39
|
-
last_seen TEXT NOT NULL,
|
|
40
|
-
count INTEGER NOT NULL DEFAULT 1,
|
|
41
|
-
source TEXT,
|
|
42
|
-
message TEXT
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
CREATE TABLE IF NOT EXISTS fixes (
|
|
46
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
47
|
-
fingerprint TEXT NOT NULL,
|
|
48
|
-
status TEXT NOT NULL, -- pending|applied|failed|skipped
|
|
49
|
-
patch_path TEXT,
|
|
50
|
-
commit_hash TEXT,
|
|
51
|
-
branch TEXT,
|
|
52
|
-
pr_url TEXT,
|
|
53
|
-
repo_name TEXT,
|
|
54
|
-
timestamp TEXT NOT NULL,
|
|
55
|
-
sentinel_marker TEXT, -- SENTINEL:#<fingerprint> injected into fix
|
|
56
|
-
marker_seen_at TEXT, -- when marker was first seen in production logs
|
|
57
|
-
confirmed_at TEXT, -- set after quiet period with no recurrence
|
|
58
|
-
fix_outcome TEXT -- confirmed | regressed
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
-- Migration: add columns if upgrading from older schema
|
|
62
|
-
CREATE TABLE IF NOT EXISTS _sentinel_migrations (name TEXT PRIMARY KEY);
|
|
63
|
-
|
|
64
|
-
CREATE TABLE IF NOT EXISTS reports (
|
|
65
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
66
|
-
sent_at TEXT NOT NULL,
|
|
67
|
-
recipient_count INTEGER NOT NULL DEFAULT 0,
|
|
68
|
-
summary_json TEXT
|
|
69
|
-
);
|
|
70
|
-
""")
|
|
71
|
-
self._migrate()
|
|
72
|
-
logger.debug("StateStore initialised at %s", self.db_path)
|
|
73
|
-
|
|
74
|
-
def _migrate(self):
|
|
75
|
-
"""Add new columns to existing DBs without breaking old installs."""
|
|
76
|
-
migrations = [
|
|
77
|
-
("add_sentinel_marker", "ALTER TABLE fixes ADD COLUMN sentinel_marker TEXT"),
|
|
78
|
-
("add_confirmed_at", "ALTER TABLE fixes ADD COLUMN confirmed_at TEXT"),
|
|
79
|
-
("add_fix_outcome", "ALTER TABLE fixes ADD COLUMN fix_outcome TEXT"),
|
|
80
|
-
("add_marker_seen_at", "ALTER TABLE fixes ADD COLUMN marker_seen_at TEXT"),
|
|
81
|
-
("add_watched_bots_project", "ALTER TABLE watched_bots ADD COLUMN project_name TEXT"),
|
|
82
|
-
]
|
|
83
|
-
with self._conn() as conn:
|
|
84
|
-
done = {r[0] for r in conn.execute("SELECT name FROM _sentinel_migrations").fetchall()}
|
|
85
|
-
for name, sql in migrations:
|
|
86
|
-
if name not in done:
|
|
87
|
-
try:
|
|
88
|
-
conn.execute(sql)
|
|
89
|
-
conn.execute("INSERT INTO _sentinel_migrations VALUES (?)", (name,))
|
|
90
|
-
logger.debug("Migration applied: %s", name)
|
|
91
|
-
except Exception:
|
|
92
|
-
pass # column may already exist
|
|
93
|
-
|
|
94
|
-
# ── Errors ────────────────────────────────────────────────────────────────
|
|
95
|
-
|
|
96
|
-
def seen(self, fingerprint: str) -> bool:
|
|
97
|
-
with self._conn() as conn:
|
|
98
|
-
row = conn.execute(
|
|
99
|
-
"SELECT fingerprint FROM errors WHERE fingerprint = ?", (fingerprint,)
|
|
100
|
-
).fetchone()
|
|
101
|
-
return row is not None
|
|
102
|
-
|
|
103
|
-
def record_error(self, fingerprint: str, source: str, message: str):
|
|
104
|
-
now = _now()
|
|
105
|
-
with self._conn() as conn:
|
|
106
|
-
existing = conn.execute(
|
|
107
|
-
"SELECT count FROM errors WHERE fingerprint = ?", (fingerprint,)
|
|
108
|
-
).fetchone()
|
|
109
|
-
if existing:
|
|
110
|
-
conn.execute(
|
|
111
|
-
"UPDATE errors SET last_seen=?, count=count+1 WHERE fingerprint=?",
|
|
112
|
-
(now, fingerprint),
|
|
113
|
-
)
|
|
114
|
-
else:
|
|
115
|
-
conn.execute(
|
|
116
|
-
"INSERT INTO errors (fingerprint, first_seen, last_seen, count, source, message) "
|
|
117
|
-
"VALUES (?, ?, ?, 1, ?, ?)",
|
|
118
|
-
(fingerprint, now, now, source, message),
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
def get_recent_errors(self, hours: int = 6) -> list[dict]:
|
|
122
|
-
with self._conn() as conn:
|
|
123
|
-
rows = conn.execute(
|
|
124
|
-
"SELECT * FROM errors WHERE last_seen >= datetime('now', ? || ' hours') "
|
|
125
|
-
"ORDER BY last_seen DESC",
|
|
126
|
-
(f"-{hours}",),
|
|
127
|
-
).fetchall()
|
|
128
|
-
return [dict(r) for r in rows]
|
|
129
|
-
|
|
130
|
-
# ── Fixes ─────────────────────────────────────────────────────────────────
|
|
131
|
-
|
|
132
|
-
def record_fix(
|
|
133
|
-
self,
|
|
134
|
-
fingerprint: str,
|
|
135
|
-
status: str,
|
|
136
|
-
patch_path: str = "",
|
|
137
|
-
commit_hash: str = "",
|
|
138
|
-
branch: str = "",
|
|
139
|
-
pr_url: str = "",
|
|
140
|
-
repo_name: str = "",
|
|
141
|
-
sentinel_marker: str = "",
|
|
142
|
-
):
|
|
143
|
-
with self._conn() as conn:
|
|
144
|
-
conn.execute(
|
|
145
|
-
"INSERT INTO fixes (fingerprint, status, patch_path, commit_hash, branch, "
|
|
146
|
-
"pr_url, repo_name, timestamp, sentinel_marker) "
|
|
147
|
-
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
148
|
-
(fingerprint, status, patch_path, commit_hash, branch,
|
|
149
|
-
pr_url, repo_name, _now(), sentinel_marker or None),
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
def get_open_prs(self) -> list[dict]:
|
|
153
|
-
"""Returns fixes pushed as PRs that have not yet been merged (no commit_hash on main)."""
|
|
154
|
-
with self._conn() as conn:
|
|
155
|
-
rows = conn.execute(
|
|
156
|
-
"SELECT * FROM fixes WHERE status='pending' AND pr_url != '' "
|
|
157
|
-
"ORDER BY timestamp DESC"
|
|
158
|
-
).fetchall()
|
|
159
|
-
return [dict(r) for r in rows]
|
|
160
|
-
|
|
161
|
-
def get_recent_fixes(self, hours: int = 6) -> list[dict]:
|
|
162
|
-
with self._conn() as conn:
|
|
163
|
-
rows = conn.execute(
|
|
164
|
-
"SELECT * FROM fixes WHERE timestamp >= datetime('now', ? || ' hours') "
|
|
165
|
-
"ORDER BY timestamp DESC",
|
|
166
|
-
(f"-{hours}",),
|
|
167
|
-
).fetchall()
|
|
168
|
-
return [dict(r) for r in rows]
|
|
169
|
-
|
|
170
|
-
def fix_attempted_recently(self, fingerprint: str, hours: int = 24) -> bool:
|
|
171
|
-
with self._conn() as conn:
|
|
172
|
-
row = conn.execute(
|
|
173
|
-
"SELECT id FROM fixes WHERE fingerprint=? "
|
|
174
|
-
"AND timestamp >= datetime('now', ? || ' hours')",
|
|
175
|
-
(fingerprint, f"-{hours}"),
|
|
176
|
-
).fetchone()
|
|
177
|
-
return row is not None
|
|
178
|
-
|
|
179
|
-
def mark_marker_seen(self, marker: str) -> dict | None:
|
|
180
|
-
"""Record that a SENTINEL marker appeared in production logs."""
|
|
181
|
-
with self._conn() as conn:
|
|
182
|
-
row = conn.execute(
|
|
183
|
-
"SELECT * FROM fixes WHERE sentinel_marker=? AND marker_seen_at IS NULL",
|
|
184
|
-
(marker,),
|
|
185
|
-
).fetchone()
|
|
186
|
-
if not row:
|
|
187
|
-
return None
|
|
188
|
-
conn.execute(
|
|
189
|
-
"UPDATE fixes SET marker_seen_at=? WHERE sentinel_marker=?",
|
|
190
|
-
(_now(), marker),
|
|
191
|
-
)
|
|
192
|
-
return dict(row)
|
|
193
|
-
|
|
194
|
-
def get_fixes_pending_confirmation(self, quiet_hours: int = 24) -> list[dict]:
|
|
195
|
-
"""Return fixes whose marker was seen >= quiet_hours ago and are not yet confirmed/regressed."""
|
|
196
|
-
with self._conn() as conn:
|
|
197
|
-
rows = conn.execute(
|
|
198
|
-
"SELECT * FROM fixes WHERE marker_seen_at IS NOT NULL "
|
|
199
|
-
"AND confirmed_at IS NULL AND fix_outcome IS NULL "
|
|
200
|
-
"AND marker_seen_at <= datetime('now', ? || ' hours')",
|
|
201
|
-
(f"-{quiet_hours}",),
|
|
202
|
-
).fetchall()
|
|
203
|
-
return [dict(r) for r in rows]
|
|
204
|
-
|
|
205
|
-
def confirm_fix(self, fingerprint: str) -> dict | None:
|
|
206
|
-
"""Confirm a fix after quiet period — no recurrence observed."""
|
|
207
|
-
with self._conn() as conn:
|
|
208
|
-
row = conn.execute(
|
|
209
|
-
"SELECT * FROM fixes WHERE fingerprint=? AND marker_seen_at IS NOT NULL "
|
|
210
|
-
"AND confirmed_at IS NULL AND fix_outcome IS NULL",
|
|
211
|
-
(fingerprint,),
|
|
212
|
-
).fetchone()
|
|
213
|
-
if not row:
|
|
214
|
-
return None
|
|
215
|
-
conn.execute(
|
|
216
|
-
"UPDATE fixes SET confirmed_at=?, fix_outcome='confirmed' WHERE fingerprint=?",
|
|
217
|
-
(_now(), fingerprint),
|
|
218
|
-
)
|
|
219
|
-
return dict(row)
|
|
220
|
-
|
|
221
|
-
def mark_regressed(self, fingerprint: str):
|
|
222
|
-
"""Mark fix as regressed — error recurred after marker was seen."""
|
|
223
|
-
with self._conn() as conn:
|
|
224
|
-
conn.execute(
|
|
225
|
-
"UPDATE fixes SET fix_outcome='regressed' "
|
|
226
|
-
"WHERE fingerprint=? AND fix_outcome IS NULL AND marker_seen_at IS NOT NULL",
|
|
227
|
-
(fingerprint,),
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
def get_confirmed_fix(self, fingerprint: str) -> dict | None:
|
|
231
|
-
"""Return a confirmed (not yet regressed) fix for this fingerprint."""
|
|
232
|
-
with self._conn() as conn:
|
|
233
|
-
row = conn.execute(
|
|
234
|
-
"SELECT * FROM fixes WHERE fingerprint=? AND fix_outcome='confirmed'",
|
|
235
|
-
(fingerprint,),
|
|
236
|
-
).fetchone()
|
|
237
|
-
return dict(row) if row else None
|
|
238
|
-
|
|
239
|
-
def get_marker_seen_fix(self, fingerprint: str) -> dict | None:
|
|
240
|
-
"""Return a fix for this fingerprint where marker was seen but not yet confirmed."""
|
|
241
|
-
with self._conn() as conn:
|
|
242
|
-
row = conn.execute(
|
|
243
|
-
"SELECT * FROM fixes WHERE fingerprint=? "
|
|
244
|
-
"AND marker_seen_at IS NOT NULL AND fix_outcome IS NULL",
|
|
245
|
-
(fingerprint,),
|
|
246
|
-
).fetchone()
|
|
247
|
-
return dict(row) if row else None
|
|
248
|
-
|
|
249
|
-
def get_stale_markers(self, repo_name: str) -> list[str]:
|
|
250
|
-
"""Return markers for confirmed/regressed fixes in a repo — safe to remove from code."""
|
|
251
|
-
with self._conn() as conn:
|
|
252
|
-
rows = conn.execute(
|
|
253
|
-
"SELECT sentinel_marker FROM fixes "
|
|
254
|
-
"WHERE repo_name=? AND fix_outcome IN ('confirmed', 'regressed') "
|
|
255
|
-
"AND sentinel_marker IS NOT NULL",
|
|
256
|
-
(repo_name,),
|
|
257
|
-
).fetchall()
|
|
258
|
-
return [r[0] for r in rows]
|
|
259
|
-
|
|
260
|
-
# ── Reports ───────────────────────────────────────────────────────────────
|
|
261
|
-
|
|
262
|
-
def record_report(self, recipient_count: int, summary: dict):
|
|
263
|
-
with self._conn() as conn:
|
|
264
|
-
conn.execute(
|
|
265
|
-
"INSERT INTO reports (sent_at, recipient_count, summary_json) VALUES (?, ?, ?)",
|
|
266
|
-
(_now(), recipient_count, json.dumps(summary)),
|
|
267
|
-
)
|
|
268
|
-
|
|
269
|
-
def last_report_time(self) -> datetime | None:
|
|
270
|
-
with self._conn() as conn:
|
|
271
|
-
row = conn.execute(
|
|
272
|
-
"SELECT sent_at FROM reports ORDER BY sent_at DESC LIMIT 1"
|
|
273
|
-
).fetchone()
|
|
274
|
-
if row:
|
|
275
|
-
return datetime.fromisoformat(row["sent_at"])
|
|
276
|
-
return None
|
|
277
|
-
|
|
278
|
-
# ── Dedup store (used by Slack bot watcher) ───────────────────────────────
|
|
279
|
-
|
|
280
|
-
def is_seen_dedup(self, key: str) -> bool:
|
|
281
|
-
"""Return True if this dedup key has already been processed."""
|
|
282
|
-
with self._conn() as conn:
|
|
283
|
-
conn.execute(
|
|
284
|
-
"CREATE TABLE IF NOT EXISTS dedup "
|
|
285
|
-
"(key TEXT PRIMARY KEY, seen_at TEXT)"
|
|
286
|
-
)
|
|
287
|
-
row = conn.execute(
|
|
288
|
-
"SELECT key FROM dedup WHERE key = ?", (key,)
|
|
289
|
-
).fetchone()
|
|
290
|
-
return row is not None
|
|
291
|
-
|
|
292
|
-
def mark_seen_dedup(self, key: str):
|
|
293
|
-
"""Record a dedup key so it won't be processed again."""
|
|
294
|
-
with self._conn() as conn:
|
|
295
|
-
conn.execute(
|
|
296
|
-
"CREATE TABLE IF NOT EXISTS dedup "
|
|
297
|
-
"(key TEXT PRIMARY KEY, seen_at TEXT)"
|
|
298
|
-
)
|
|
299
|
-
conn.execute(
|
|
300
|
-
"INSERT OR IGNORE INTO dedup (key, seen_at) VALUES (?, ?)",
|
|
301
|
-
(key, _now()),
|
|
302
|
-
)
|
|
303
|
-
|
|
304
|
-
# ── Watched bots (Slack passive monitor) ─────────────────────────────────
|
|
305
|
-
|
|
306
|
-
def _ensure_watched_bots_table(self, conn):
|
|
307
|
-
conn.execute(
|
|
308
|
-
"CREATE TABLE IF NOT EXISTS watched_bots "
|
|
309
|
-
"(bot_id TEXT PRIMARY KEY, bot_name TEXT, added_by TEXT, added_at TEXT)"
|
|
310
|
-
)
|
|
311
|
-
|
|
312
|
-
def add_watched_bot(self, bot_id: str, bot_name: str, added_by: str = "config", project_name: str = ""):
|
|
313
|
-
with self._conn() as conn:
|
|
314
|
-
self._ensure_watched_bots_table(conn)
|
|
315
|
-
conn.execute(
|
|
316
|
-
"INSERT OR REPLACE INTO watched_bots (bot_id, bot_name, added_by, added_at, project_name) "
|
|
317
|
-
"VALUES (?, ?, ?, ?, ?)",
|
|
318
|
-
(bot_id, bot_name, added_by, _now(), project_name or None),
|
|
319
|
-
)
|
|
320
|
-
|
|
321
|
-
def remove_watched_bot(self, bot_id: str) -> bool:
|
|
322
|
-
"""Returns True if a row was deleted."""
|
|
323
|
-
with self._conn() as conn:
|
|
324
|
-
self._ensure_watched_bots_table(conn)
|
|
325
|
-
cur = conn.execute("DELETE FROM watched_bots WHERE bot_id = ?", (bot_id,))
|
|
326
|
-
return cur.rowcount > 0
|
|
327
|
-
|
|
328
|
-
def is_watched_bot(self, bot_id: str) -> bool:
|
|
329
|
-
with self._conn() as conn:
|
|
330
|
-
self._ensure_watched_bots_table(conn)
|
|
331
|
-
return conn.execute(
|
|
332
|
-
"SELECT 1 FROM watched_bots WHERE bot_id = ?", (bot_id,)
|
|
333
|
-
).fetchone() is not None
|
|
334
|
-
|
|
335
|
-
def get_watched_bots(self) -> list[dict]:
|
|
336
|
-
with self._conn() as conn:
|
|
337
|
-
self._ensure_watched_bots_table(conn)
|
|
338
|
-
rows = conn.execute(
|
|
339
|
-
"SELECT * FROM watched_bots ORDER BY added_at"
|
|
340
|
-
).fetchall()
|
|
341
|
-
return [dict(r) for r in rows]
|
|
342
|
-
|
|
343
|
-
# ── Conversation history persistence (per Slack user) ────────────────────
|
|
344
|
-
|
|
345
|
-
_MAX_HISTORY_MESSAGES = 40 # keep last 20 turns (user+assistant pairs)
|
|
346
|
-
|
|
347
|
-
# ── User-submitted issues (create_issue via Sentinel Boss) ─────────────────
|
|
348
|
-
|
|
349
|
-
def record_submitted_issue(
|
|
350
|
-
self,
|
|
351
|
-
user_id: str,
|
|
352
|
-
user_name: str,
|
|
353
|
-
project: str,
|
|
354
|
-
fname: str,
|
|
355
|
-
description: str,
|
|
356
|
-
):
|
|
357
|
-
"""Record that a user submitted an issue via Sentinel Boss."""
|
|
358
|
-
with self._conn() as conn:
|
|
359
|
-
conn.execute(
|
|
360
|
-
"CREATE TABLE IF NOT EXISTS submitted_issues "
|
|
361
|
-
"(id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, user_name TEXT, "
|
|
362
|
-
" project TEXT, fname TEXT, description TEXT, submitted_at TEXT)"
|
|
363
|
-
)
|
|
364
|
-
conn.execute(
|
|
365
|
-
"INSERT INTO submitted_issues "
|
|
366
|
-
"(user_id, user_name, project, fname, description, submitted_at) "
|
|
367
|
-
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
368
|
-
(user_id, user_name, project, fname, description[:300], _now()),
|
|
369
|
-
)
|
|
370
|
-
|
|
371
|
-
def get_submitted_issues(self, user_id: str, hours: int = 0) -> list[dict]:
|
|
372
|
-
"""Return issues submitted by a specific user. hours=0 means all time."""
|
|
373
|
-
with self._conn() as conn:
|
|
374
|
-
conn.execute(
|
|
375
|
-
"CREATE TABLE IF NOT EXISTS submitted_issues "
|
|
376
|
-
"(id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, user_name TEXT, "
|
|
377
|
-
" project TEXT, fname TEXT, description TEXT, submitted_at TEXT)"
|
|
378
|
-
)
|
|
379
|
-
if hours:
|
|
380
|
-
rows = conn.execute(
|
|
381
|
-
"SELECT * FROM submitted_issues WHERE user_id=? "
|
|
382
|
-
"AND submitted_at >= datetime('now', ? || ' hours') "
|
|
383
|
-
"ORDER BY submitted_at DESC",
|
|
384
|
-
(user_id, f"-{hours}"),
|
|
385
|
-
).fetchall()
|
|
386
|
-
else:
|
|
387
|
-
rows = conn.execute(
|
|
388
|
-
"SELECT * FROM submitted_issues WHERE user_id=? "
|
|
389
|
-
"ORDER BY submitted_at DESC",
|
|
390
|
-
(user_id,),
|
|
391
|
-
).fetchall()
|
|
392
|
-
return [dict(r) for r in rows]
|
|
393
|
-
|
|
394
|
-
def upsert_user(self, user_id: str, display_name: str) -> None:
|
|
395
|
-
"""Store or update the display name for a Slack user ID."""
|
|
396
|
-
with self._conn() as conn:
|
|
397
|
-
conn.execute(
|
|
398
|
-
"CREATE TABLE IF NOT EXISTS slack_users "
|
|
399
|
-
"(user_id TEXT PRIMARY KEY, display_name TEXT, updated_at TEXT)"
|
|
400
|
-
)
|
|
401
|
-
conn.execute(
|
|
402
|
-
"INSERT OR REPLACE INTO slack_users (user_id, display_name, updated_at) "
|
|
403
|
-
"VALUES (?, ?, ?)",
|
|
404
|
-
(user_id, display_name, _now()),
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
def get_user_name(self, user_id: str) -> str:
|
|
408
|
-
"""Return the stored display name for a user ID, or the ID itself if unknown."""
|
|
409
|
-
with self._conn() as conn:
|
|
410
|
-
conn.execute(
|
|
411
|
-
"CREATE TABLE IF NOT EXISTS slack_users "
|
|
412
|
-
"(user_id TEXT PRIMARY KEY, display_name TEXT, updated_at TEXT)"
|
|
413
|
-
)
|
|
414
|
-
row = conn.execute(
|
|
415
|
-
"SELECT display_name FROM slack_users WHERE user_id = ?", (user_id,)
|
|
416
|
-
).fetchone()
|
|
417
|
-
return row[0] if row else user_id
|
|
418
|
-
|
|
419
|
-
def get_all_users(self) -> dict[str, str]:
|
|
420
|
-
"""Return all known {user_id: display_name} mappings."""
|
|
421
|
-
with self._conn() as conn:
|
|
422
|
-
conn.execute(
|
|
423
|
-
"CREATE TABLE IF NOT EXISTS slack_users "
|
|
424
|
-
"(user_id TEXT PRIMARY KEY, display_name TEXT, updated_at TEXT)"
|
|
425
|
-
)
|
|
426
|
-
rows = conn.execute(
|
|
427
|
-
"SELECT user_id, display_name FROM slack_users ORDER BY display_name"
|
|
428
|
-
).fetchall()
|
|
429
|
-
return {r[0]: r[1] for r in rows}
|
|
430
|
-
|
|
431
|
-
def save_conversation(self, user_id: str, history: list):
|
|
432
|
-
"""Persist the last N messages of a user conversation to SQLite."""
|
|
433
|
-
trimmed = history[-self._MAX_HISTORY_MESSAGES:]
|
|
434
|
-
with self._conn() as conn:
|
|
435
|
-
conn.execute(
|
|
436
|
-
"CREATE TABLE IF NOT EXISTS conversations "
|
|
437
|
-
"(user_id TEXT PRIMARY KEY, history_json TEXT, updated_at TEXT)"
|
|
438
|
-
)
|
|
439
|
-
conn.execute(
|
|
440
|
-
"INSERT OR REPLACE INTO conversations (user_id, history_json, updated_at) "
|
|
441
|
-
"VALUES (?, ?, ?)",
|
|
442
|
-
(user_id, json.dumps(trimmed, default=str), _now()),
|
|
443
|
-
)
|
|
444
|
-
|
|
445
|
-
def load_conversation(self, user_id: str) -> list:
|
|
446
|
-
"""Load persisted conversation history for a user (empty list if none)."""
|
|
447
|
-
with self._conn() as conn:
|
|
448
|
-
conn.execute(
|
|
449
|
-
"CREATE TABLE IF NOT EXISTS conversations "
|
|
450
|
-
"(user_id TEXT PRIMARY KEY, history_json TEXT, updated_at TEXT)"
|
|
451
|
-
)
|
|
452
|
-
row = conn.execute(
|
|
453
|
-
"SELECT history_json FROM conversations WHERE user_id = ?", (user_id,)
|
|
454
|
-
).fetchone()
|
|
455
|
-
if row:
|
|
456
|
-
try:
|
|
457
|
-
return json.loads(row[0])
|
|
458
|
-
except Exception:
|
|
459
|
-
return []
|
|
460
|
-
return []
|
|
461
|
-
|
|
462
|
-
# ── Admin operations ──────────────────────────────────────────────────────
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
1
|
+
"""
|
|
2
|
+
state_store.py — SQLite-backed persistence for errors, fixes, and reports.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
import logging
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _now() -> str:
|
|
15
|
+
return datetime.now(timezone.utc).isoformat()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StateStore:
|
|
19
|
+
def __init__(self, db_path: str = "./sentinel.db"):
|
|
20
|
+
self.db_path = db_path
|
|
21
|
+
self._init_db()
|
|
22
|
+
|
|
23
|
+
@contextmanager
|
|
24
|
+
def _conn(self):
|
|
25
|
+
conn = sqlite3.connect(self.db_path)
|
|
26
|
+
conn.row_factory = sqlite3.Row
|
|
27
|
+
try:
|
|
28
|
+
yield conn
|
|
29
|
+
conn.commit()
|
|
30
|
+
finally:
|
|
31
|
+
conn.close()
|
|
32
|
+
|
|
33
|
+
def _init_db(self):
|
|
34
|
+
with self._conn() as conn:
|
|
35
|
+
conn.executescript("""
|
|
36
|
+
CREATE TABLE IF NOT EXISTS errors (
|
|
37
|
+
fingerprint TEXT PRIMARY KEY,
|
|
38
|
+
first_seen TEXT NOT NULL,
|
|
39
|
+
last_seen TEXT NOT NULL,
|
|
40
|
+
count INTEGER NOT NULL DEFAULT 1,
|
|
41
|
+
source TEXT,
|
|
42
|
+
message TEXT
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
CREATE TABLE IF NOT EXISTS fixes (
|
|
46
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
47
|
+
fingerprint TEXT NOT NULL,
|
|
48
|
+
status TEXT NOT NULL, -- pending|applied|failed|skipped
|
|
49
|
+
patch_path TEXT,
|
|
50
|
+
commit_hash TEXT,
|
|
51
|
+
branch TEXT,
|
|
52
|
+
pr_url TEXT,
|
|
53
|
+
repo_name TEXT,
|
|
54
|
+
timestamp TEXT NOT NULL,
|
|
55
|
+
sentinel_marker TEXT, -- SENTINEL:#<fingerprint> injected into fix
|
|
56
|
+
marker_seen_at TEXT, -- when marker was first seen in production logs
|
|
57
|
+
confirmed_at TEXT, -- set after quiet period with no recurrence
|
|
58
|
+
fix_outcome TEXT -- confirmed | regressed
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
-- Migration: add columns if upgrading from older schema
|
|
62
|
+
CREATE TABLE IF NOT EXISTS _sentinel_migrations (name TEXT PRIMARY KEY);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS reports (
|
|
65
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
66
|
+
sent_at TEXT NOT NULL,
|
|
67
|
+
recipient_count INTEGER NOT NULL DEFAULT 0,
|
|
68
|
+
summary_json TEXT
|
|
69
|
+
);
|
|
70
|
+
""")
|
|
71
|
+
self._migrate()
|
|
72
|
+
logger.debug("StateStore initialised at %s", self.db_path)
|
|
73
|
+
|
|
74
|
+
def _migrate(self):
|
|
75
|
+
"""Add new columns to existing DBs without breaking old installs."""
|
|
76
|
+
migrations = [
|
|
77
|
+
("add_sentinel_marker", "ALTER TABLE fixes ADD COLUMN sentinel_marker TEXT"),
|
|
78
|
+
("add_confirmed_at", "ALTER TABLE fixes ADD COLUMN confirmed_at TEXT"),
|
|
79
|
+
("add_fix_outcome", "ALTER TABLE fixes ADD COLUMN fix_outcome TEXT"),
|
|
80
|
+
("add_marker_seen_at", "ALTER TABLE fixes ADD COLUMN marker_seen_at TEXT"),
|
|
81
|
+
("add_watched_bots_project", "ALTER TABLE watched_bots ADD COLUMN project_name TEXT"),
|
|
82
|
+
]
|
|
83
|
+
with self._conn() as conn:
|
|
84
|
+
done = {r[0] for r in conn.execute("SELECT name FROM _sentinel_migrations").fetchall()}
|
|
85
|
+
for name, sql in migrations:
|
|
86
|
+
if name not in done:
|
|
87
|
+
try:
|
|
88
|
+
conn.execute(sql)
|
|
89
|
+
conn.execute("INSERT INTO _sentinel_migrations VALUES (?)", (name,))
|
|
90
|
+
logger.debug("Migration applied: %s", name)
|
|
91
|
+
except Exception:
|
|
92
|
+
pass # column may already exist
|
|
93
|
+
|
|
94
|
+
# ── Errors ────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def seen(self, fingerprint: str) -> bool:
|
|
97
|
+
with self._conn() as conn:
|
|
98
|
+
row = conn.execute(
|
|
99
|
+
"SELECT fingerprint FROM errors WHERE fingerprint = ?", (fingerprint,)
|
|
100
|
+
).fetchone()
|
|
101
|
+
return row is not None
|
|
102
|
+
|
|
103
|
+
def record_error(self, fingerprint: str, source: str, message: str):
|
|
104
|
+
now = _now()
|
|
105
|
+
with self._conn() as conn:
|
|
106
|
+
existing = conn.execute(
|
|
107
|
+
"SELECT count FROM errors WHERE fingerprint = ?", (fingerprint,)
|
|
108
|
+
).fetchone()
|
|
109
|
+
if existing:
|
|
110
|
+
conn.execute(
|
|
111
|
+
"UPDATE errors SET last_seen=?, count=count+1 WHERE fingerprint=?",
|
|
112
|
+
(now, fingerprint),
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
conn.execute(
|
|
116
|
+
"INSERT INTO errors (fingerprint, first_seen, last_seen, count, source, message) "
|
|
117
|
+
"VALUES (?, ?, ?, 1, ?, ?)",
|
|
118
|
+
(fingerprint, now, now, source, message),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def get_recent_errors(self, hours: int = 6) -> list[dict]:
|
|
122
|
+
with self._conn() as conn:
|
|
123
|
+
rows = conn.execute(
|
|
124
|
+
"SELECT * FROM errors WHERE last_seen >= datetime('now', ? || ' hours') "
|
|
125
|
+
"ORDER BY last_seen DESC",
|
|
126
|
+
(f"-{hours}",),
|
|
127
|
+
).fetchall()
|
|
128
|
+
return [dict(r) for r in rows]
|
|
129
|
+
|
|
130
|
+
# ── Fixes ─────────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
def record_fix(
|
|
133
|
+
self,
|
|
134
|
+
fingerprint: str,
|
|
135
|
+
status: str,
|
|
136
|
+
patch_path: str = "",
|
|
137
|
+
commit_hash: str = "",
|
|
138
|
+
branch: str = "",
|
|
139
|
+
pr_url: str = "",
|
|
140
|
+
repo_name: str = "",
|
|
141
|
+
sentinel_marker: str = "",
|
|
142
|
+
):
|
|
143
|
+
with self._conn() as conn:
|
|
144
|
+
conn.execute(
|
|
145
|
+
"INSERT INTO fixes (fingerprint, status, patch_path, commit_hash, branch, "
|
|
146
|
+
"pr_url, repo_name, timestamp, sentinel_marker) "
|
|
147
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
148
|
+
(fingerprint, status, patch_path, commit_hash, branch,
|
|
149
|
+
pr_url, repo_name, _now(), sentinel_marker or None),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def get_open_prs(self) -> list[dict]:
|
|
153
|
+
"""Returns fixes pushed as PRs that have not yet been merged (no commit_hash on main)."""
|
|
154
|
+
with self._conn() as conn:
|
|
155
|
+
rows = conn.execute(
|
|
156
|
+
"SELECT * FROM fixes WHERE status='pending' AND pr_url != '' "
|
|
157
|
+
"ORDER BY timestamp DESC"
|
|
158
|
+
).fetchall()
|
|
159
|
+
return [dict(r) for r in rows]
|
|
160
|
+
|
|
161
|
+
def get_recent_fixes(self, hours: int = 6) -> list[dict]:
|
|
162
|
+
with self._conn() as conn:
|
|
163
|
+
rows = conn.execute(
|
|
164
|
+
"SELECT * FROM fixes WHERE timestamp >= datetime('now', ? || ' hours') "
|
|
165
|
+
"ORDER BY timestamp DESC",
|
|
166
|
+
(f"-{hours}",),
|
|
167
|
+
).fetchall()
|
|
168
|
+
return [dict(r) for r in rows]
|
|
169
|
+
|
|
170
|
+
def fix_attempted_recently(self, fingerprint: str, hours: int = 24) -> bool:
|
|
171
|
+
with self._conn() as conn:
|
|
172
|
+
row = conn.execute(
|
|
173
|
+
"SELECT id FROM fixes WHERE fingerprint=? "
|
|
174
|
+
"AND timestamp >= datetime('now', ? || ' hours')",
|
|
175
|
+
(fingerprint, f"-{hours}"),
|
|
176
|
+
).fetchone()
|
|
177
|
+
return row is not None
|
|
178
|
+
|
|
179
|
+
def mark_marker_seen(self, marker: str) -> dict | None:
|
|
180
|
+
"""Record that a SENTINEL marker appeared in production logs."""
|
|
181
|
+
with self._conn() as conn:
|
|
182
|
+
row = conn.execute(
|
|
183
|
+
"SELECT * FROM fixes WHERE sentinel_marker=? AND marker_seen_at IS NULL",
|
|
184
|
+
(marker,),
|
|
185
|
+
).fetchone()
|
|
186
|
+
if not row:
|
|
187
|
+
return None
|
|
188
|
+
conn.execute(
|
|
189
|
+
"UPDATE fixes SET marker_seen_at=? WHERE sentinel_marker=?",
|
|
190
|
+
(_now(), marker),
|
|
191
|
+
)
|
|
192
|
+
return dict(row)
|
|
193
|
+
|
|
194
|
+
def get_fixes_pending_confirmation(self, quiet_hours: int = 24) -> list[dict]:
|
|
195
|
+
"""Return fixes whose marker was seen >= quiet_hours ago and are not yet confirmed/regressed."""
|
|
196
|
+
with self._conn() as conn:
|
|
197
|
+
rows = conn.execute(
|
|
198
|
+
"SELECT * FROM fixes WHERE marker_seen_at IS NOT NULL "
|
|
199
|
+
"AND confirmed_at IS NULL AND fix_outcome IS NULL "
|
|
200
|
+
"AND marker_seen_at <= datetime('now', ? || ' hours')",
|
|
201
|
+
(f"-{quiet_hours}",),
|
|
202
|
+
).fetchall()
|
|
203
|
+
return [dict(r) for r in rows]
|
|
204
|
+
|
|
205
|
+
def confirm_fix(self, fingerprint: str) -> dict | None:
|
|
206
|
+
"""Confirm a fix after quiet period — no recurrence observed."""
|
|
207
|
+
with self._conn() as conn:
|
|
208
|
+
row = conn.execute(
|
|
209
|
+
"SELECT * FROM fixes WHERE fingerprint=? AND marker_seen_at IS NOT NULL "
|
|
210
|
+
"AND confirmed_at IS NULL AND fix_outcome IS NULL",
|
|
211
|
+
(fingerprint,),
|
|
212
|
+
).fetchone()
|
|
213
|
+
if not row:
|
|
214
|
+
return None
|
|
215
|
+
conn.execute(
|
|
216
|
+
"UPDATE fixes SET confirmed_at=?, fix_outcome='confirmed' WHERE fingerprint=?",
|
|
217
|
+
(_now(), fingerprint),
|
|
218
|
+
)
|
|
219
|
+
return dict(row)
|
|
220
|
+
|
|
221
|
+
def mark_regressed(self, fingerprint: str):
|
|
222
|
+
"""Mark fix as regressed — error recurred after marker was seen."""
|
|
223
|
+
with self._conn() as conn:
|
|
224
|
+
conn.execute(
|
|
225
|
+
"UPDATE fixes SET fix_outcome='regressed' "
|
|
226
|
+
"WHERE fingerprint=? AND fix_outcome IS NULL AND marker_seen_at IS NOT NULL",
|
|
227
|
+
(fingerprint,),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def get_confirmed_fix(self, fingerprint: str) -> dict | None:
|
|
231
|
+
"""Return a confirmed (not yet regressed) fix for this fingerprint."""
|
|
232
|
+
with self._conn() as conn:
|
|
233
|
+
row = conn.execute(
|
|
234
|
+
"SELECT * FROM fixes WHERE fingerprint=? AND fix_outcome='confirmed'",
|
|
235
|
+
(fingerprint,),
|
|
236
|
+
).fetchone()
|
|
237
|
+
return dict(row) if row else None
|
|
238
|
+
|
|
239
|
+
def get_marker_seen_fix(self, fingerprint: str) -> dict | None:
|
|
240
|
+
"""Return a fix for this fingerprint where marker was seen but not yet confirmed."""
|
|
241
|
+
with self._conn() as conn:
|
|
242
|
+
row = conn.execute(
|
|
243
|
+
"SELECT * FROM fixes WHERE fingerprint=? "
|
|
244
|
+
"AND marker_seen_at IS NOT NULL AND fix_outcome IS NULL",
|
|
245
|
+
(fingerprint,),
|
|
246
|
+
).fetchone()
|
|
247
|
+
return dict(row) if row else None
|
|
248
|
+
|
|
249
|
+
def get_stale_markers(self, repo_name: str) -> list[str]:
|
|
250
|
+
"""Return markers for confirmed/regressed fixes in a repo — safe to remove from code."""
|
|
251
|
+
with self._conn() as conn:
|
|
252
|
+
rows = conn.execute(
|
|
253
|
+
"SELECT sentinel_marker FROM fixes "
|
|
254
|
+
"WHERE repo_name=? AND fix_outcome IN ('confirmed', 'regressed') "
|
|
255
|
+
"AND sentinel_marker IS NOT NULL",
|
|
256
|
+
(repo_name,),
|
|
257
|
+
).fetchall()
|
|
258
|
+
return [r[0] for r in rows]
|
|
259
|
+
|
|
260
|
+
# ── Reports ───────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
def record_report(self, recipient_count: int, summary: dict):
|
|
263
|
+
with self._conn() as conn:
|
|
264
|
+
conn.execute(
|
|
265
|
+
"INSERT INTO reports (sent_at, recipient_count, summary_json) VALUES (?, ?, ?)",
|
|
266
|
+
(_now(), recipient_count, json.dumps(summary)),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def last_report_time(self) -> datetime | None:
|
|
270
|
+
with self._conn() as conn:
|
|
271
|
+
row = conn.execute(
|
|
272
|
+
"SELECT sent_at FROM reports ORDER BY sent_at DESC LIMIT 1"
|
|
273
|
+
).fetchone()
|
|
274
|
+
if row:
|
|
275
|
+
return datetime.fromisoformat(row["sent_at"])
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
# ── Dedup store (used by Slack bot watcher) ───────────────────────────────
|
|
279
|
+
|
|
280
|
+
def is_seen_dedup(self, key: str) -> bool:
|
|
281
|
+
"""Return True if this dedup key has already been processed."""
|
|
282
|
+
with self._conn() as conn:
|
|
283
|
+
conn.execute(
|
|
284
|
+
"CREATE TABLE IF NOT EXISTS dedup "
|
|
285
|
+
"(key TEXT PRIMARY KEY, seen_at TEXT)"
|
|
286
|
+
)
|
|
287
|
+
row = conn.execute(
|
|
288
|
+
"SELECT key FROM dedup WHERE key = ?", (key,)
|
|
289
|
+
).fetchone()
|
|
290
|
+
return row is not None
|
|
291
|
+
|
|
292
|
+
def mark_seen_dedup(self, key: str):
|
|
293
|
+
"""Record a dedup key so it won't be processed again."""
|
|
294
|
+
with self._conn() as conn:
|
|
295
|
+
conn.execute(
|
|
296
|
+
"CREATE TABLE IF NOT EXISTS dedup "
|
|
297
|
+
"(key TEXT PRIMARY KEY, seen_at TEXT)"
|
|
298
|
+
)
|
|
299
|
+
conn.execute(
|
|
300
|
+
"INSERT OR IGNORE INTO dedup (key, seen_at) VALUES (?, ?)",
|
|
301
|
+
(key, _now()),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# ── Watched bots (Slack passive monitor) ─────────────────────────────────
|
|
305
|
+
|
|
306
|
+
def _ensure_watched_bots_table(self, conn):
|
|
307
|
+
conn.execute(
|
|
308
|
+
"CREATE TABLE IF NOT EXISTS watched_bots "
|
|
309
|
+
"(bot_id TEXT PRIMARY KEY, bot_name TEXT, added_by TEXT, added_at TEXT)"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def add_watched_bot(self, bot_id: str, bot_name: str, added_by: str = "config", project_name: str = ""):
|
|
313
|
+
with self._conn() as conn:
|
|
314
|
+
self._ensure_watched_bots_table(conn)
|
|
315
|
+
conn.execute(
|
|
316
|
+
"INSERT OR REPLACE INTO watched_bots (bot_id, bot_name, added_by, added_at, project_name) "
|
|
317
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
318
|
+
(bot_id, bot_name, added_by, _now(), project_name or None),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def remove_watched_bot(self, bot_id: str) -> bool:
|
|
322
|
+
"""Returns True if a row was deleted."""
|
|
323
|
+
with self._conn() as conn:
|
|
324
|
+
self._ensure_watched_bots_table(conn)
|
|
325
|
+
cur = conn.execute("DELETE FROM watched_bots WHERE bot_id = ?", (bot_id,))
|
|
326
|
+
return cur.rowcount > 0
|
|
327
|
+
|
|
328
|
+
def is_watched_bot(self, bot_id: str) -> bool:
|
|
329
|
+
with self._conn() as conn:
|
|
330
|
+
self._ensure_watched_bots_table(conn)
|
|
331
|
+
return conn.execute(
|
|
332
|
+
"SELECT 1 FROM watched_bots WHERE bot_id = ?", (bot_id,)
|
|
333
|
+
).fetchone() is not None
|
|
334
|
+
|
|
335
|
+
def get_watched_bots(self) -> list[dict]:
|
|
336
|
+
with self._conn() as conn:
|
|
337
|
+
self._ensure_watched_bots_table(conn)
|
|
338
|
+
rows = conn.execute(
|
|
339
|
+
"SELECT * FROM watched_bots ORDER BY added_at"
|
|
340
|
+
).fetchall()
|
|
341
|
+
return [dict(r) for r in rows]
|
|
342
|
+
|
|
343
|
+
# ── Conversation history persistence (per Slack user) ────────────────────
|
|
344
|
+
|
|
345
|
+
_MAX_HISTORY_MESSAGES = 40 # keep last 20 turns (user+assistant pairs)
|
|
346
|
+
|
|
347
|
+
# ── User-submitted issues (create_issue via Sentinel Boss) ─────────────────
|
|
348
|
+
|
|
349
|
+
def record_submitted_issue(
|
|
350
|
+
self,
|
|
351
|
+
user_id: str,
|
|
352
|
+
user_name: str,
|
|
353
|
+
project: str,
|
|
354
|
+
fname: str,
|
|
355
|
+
description: str,
|
|
356
|
+
):
|
|
357
|
+
"""Record that a user submitted an issue via Sentinel Boss."""
|
|
358
|
+
with self._conn() as conn:
|
|
359
|
+
conn.execute(
|
|
360
|
+
"CREATE TABLE IF NOT EXISTS submitted_issues "
|
|
361
|
+
"(id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, user_name TEXT, "
|
|
362
|
+
" project TEXT, fname TEXT, description TEXT, submitted_at TEXT)"
|
|
363
|
+
)
|
|
364
|
+
conn.execute(
|
|
365
|
+
"INSERT INTO submitted_issues "
|
|
366
|
+
"(user_id, user_name, project, fname, description, submitted_at) "
|
|
367
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
368
|
+
(user_id, user_name, project, fname, description[:300], _now()),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
def get_submitted_issues(self, user_id: str, hours: int = 0) -> list[dict]:
|
|
372
|
+
"""Return issues submitted by a specific user. hours=0 means all time."""
|
|
373
|
+
with self._conn() as conn:
|
|
374
|
+
conn.execute(
|
|
375
|
+
"CREATE TABLE IF NOT EXISTS submitted_issues "
|
|
376
|
+
"(id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, user_name TEXT, "
|
|
377
|
+
" project TEXT, fname TEXT, description TEXT, submitted_at TEXT)"
|
|
378
|
+
)
|
|
379
|
+
if hours:
|
|
380
|
+
rows = conn.execute(
|
|
381
|
+
"SELECT * FROM submitted_issues WHERE user_id=? "
|
|
382
|
+
"AND submitted_at >= datetime('now', ? || ' hours') "
|
|
383
|
+
"ORDER BY submitted_at DESC",
|
|
384
|
+
(user_id, f"-{hours}"),
|
|
385
|
+
).fetchall()
|
|
386
|
+
else:
|
|
387
|
+
rows = conn.execute(
|
|
388
|
+
"SELECT * FROM submitted_issues WHERE user_id=? "
|
|
389
|
+
"ORDER BY submitted_at DESC",
|
|
390
|
+
(user_id,),
|
|
391
|
+
).fetchall()
|
|
392
|
+
return [dict(r) for r in rows]
|
|
393
|
+
|
|
394
|
+
def upsert_user(self, user_id: str, display_name: str) -> None:
|
|
395
|
+
"""Store or update the display name for a Slack user ID."""
|
|
396
|
+
with self._conn() as conn:
|
|
397
|
+
conn.execute(
|
|
398
|
+
"CREATE TABLE IF NOT EXISTS slack_users "
|
|
399
|
+
"(user_id TEXT PRIMARY KEY, display_name TEXT, updated_at TEXT)"
|
|
400
|
+
)
|
|
401
|
+
conn.execute(
|
|
402
|
+
"INSERT OR REPLACE INTO slack_users (user_id, display_name, updated_at) "
|
|
403
|
+
"VALUES (?, ?, ?)",
|
|
404
|
+
(user_id, display_name, _now()),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
def get_user_name(self, user_id: str) -> str:
|
|
408
|
+
"""Return the stored display name for a user ID, or the ID itself if unknown."""
|
|
409
|
+
with self._conn() as conn:
|
|
410
|
+
conn.execute(
|
|
411
|
+
"CREATE TABLE IF NOT EXISTS slack_users "
|
|
412
|
+
"(user_id TEXT PRIMARY KEY, display_name TEXT, updated_at TEXT)"
|
|
413
|
+
)
|
|
414
|
+
row = conn.execute(
|
|
415
|
+
"SELECT display_name FROM slack_users WHERE user_id = ?", (user_id,)
|
|
416
|
+
).fetchone()
|
|
417
|
+
return row[0] if row else user_id
|
|
418
|
+
|
|
419
|
+
def get_all_users(self) -> dict[str, str]:
|
|
420
|
+
"""Return all known {user_id: display_name} mappings."""
|
|
421
|
+
with self._conn() as conn:
|
|
422
|
+
conn.execute(
|
|
423
|
+
"CREATE TABLE IF NOT EXISTS slack_users "
|
|
424
|
+
"(user_id TEXT PRIMARY KEY, display_name TEXT, updated_at TEXT)"
|
|
425
|
+
)
|
|
426
|
+
rows = conn.execute(
|
|
427
|
+
"SELECT user_id, display_name FROM slack_users ORDER BY display_name"
|
|
428
|
+
).fetchall()
|
|
429
|
+
return {r[0]: r[1] for r in rows}
|
|
430
|
+
|
|
431
|
+
def save_conversation(self, user_id: str, history: list):
|
|
432
|
+
"""Persist the last N messages of a user conversation to SQLite."""
|
|
433
|
+
trimmed = history[-self._MAX_HISTORY_MESSAGES:]
|
|
434
|
+
with self._conn() as conn:
|
|
435
|
+
conn.execute(
|
|
436
|
+
"CREATE TABLE IF NOT EXISTS conversations "
|
|
437
|
+
"(user_id TEXT PRIMARY KEY, history_json TEXT, updated_at TEXT)"
|
|
438
|
+
)
|
|
439
|
+
conn.execute(
|
|
440
|
+
"INSERT OR REPLACE INTO conversations (user_id, history_json, updated_at) "
|
|
441
|
+
"VALUES (?, ?, ?)",
|
|
442
|
+
(user_id, json.dumps(trimmed, default=str), _now()),
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
def load_conversation(self, user_id: str) -> list:
|
|
446
|
+
"""Load persisted conversation history for a user (empty list if none)."""
|
|
447
|
+
with self._conn() as conn:
|
|
448
|
+
conn.execute(
|
|
449
|
+
"CREATE TABLE IF NOT EXISTS conversations "
|
|
450
|
+
"(user_id TEXT PRIMARY KEY, history_json TEXT, updated_at TEXT)"
|
|
451
|
+
)
|
|
452
|
+
row = conn.execute(
|
|
453
|
+
"SELECT history_json FROM conversations WHERE user_id = ?", (user_id,)
|
|
454
|
+
).fetchone()
|
|
455
|
+
if row:
|
|
456
|
+
try:
|
|
457
|
+
return json.loads(row[0])
|
|
458
|
+
except Exception:
|
|
459
|
+
return []
|
|
460
|
+
return []
|
|
461
|
+
|
|
462
|
+
# ── Admin operations ──────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
# -- Health state (deliberate stop tracking) -----------------------------------
|
|
465
|
+
|
|
466
|
+
def _ensure_health_states_table(self, conn):
|
|
467
|
+
conn.execute(
|
|
468
|
+
"CREATE TABLE IF NOT EXISTS health_states "
|
|
469
|
+
"(repo_name TEXT PRIMARY KEY, status TEXT NOT NULL, "
|
|
470
|
+
" note TEXT, since TEXT NOT NULL)"
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
def get_health_state(self, repo_name: str) -> dict | None:
|
|
474
|
+
"""Return the current health state for a repo, or None if normal."""
|
|
475
|
+
with self._conn() as conn:
|
|
476
|
+
self._ensure_health_states_table(conn)
|
|
477
|
+
row = conn.execute(
|
|
478
|
+
"SELECT * FROM health_states WHERE repo_name = ?", (repo_name,)
|
|
479
|
+
).fetchone()
|
|
480
|
+
return dict(row) if row else None
|
|
481
|
+
|
|
482
|
+
def set_health_state(self, repo_name: str, status: str, note: str = "") -> None:
|
|
483
|
+
"""Set health state for a repo. status: 'pending' | 'confirmed'."""
|
|
484
|
+
with self._conn() as conn:
|
|
485
|
+
self._ensure_health_states_table(conn)
|
|
486
|
+
conn.execute(
|
|
487
|
+
"INSERT OR REPLACE INTO health_states (repo_name, status, note, since) "
|
|
488
|
+
"VALUES (?, ?, ?, ?)",
|
|
489
|
+
(repo_name, status, note, _now()),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
def clear_health_state(self, repo_name: str) -> None:
|
|
493
|
+
"""Remove health state record — app is healthy again."""
|
|
494
|
+
with self._conn() as conn:
|
|
495
|
+
self._ensure_health_states_table(conn)
|
|
496
|
+
conn.execute("DELETE FROM health_states WHERE repo_name = ?", (repo_name,))
|
|
497
|
+
|
|
498
|
+
def get_all_maintenance(self) -> list[dict]:
|
|
499
|
+
"""Return all repos currently in a maintenance/pending state."""
|
|
500
|
+
with self._conn() as conn:
|
|
501
|
+
self._ensure_health_states_table(conn)
|
|
502
|
+
rows = conn.execute(
|
|
503
|
+
"SELECT * FROM health_states ORDER BY since DESC"
|
|
504
|
+
).fetchall()
|
|
505
|
+
return [dict(r) for r in rows]
|
|
506
|
+
|
|
507
|
+
def get_all_user_stats(self) -> list[dict]:
|
|
508
|
+
"""Return activity summary for every known Slack user."""
|
|
509
|
+
users = self.get_all_users() # {user_id: display_name}
|
|
510
|
+
result = []
|
|
511
|
+
for uid, name in users.items():
|
|
512
|
+
issues = self.get_submitted_issues(uid)
|
|
513
|
+
conv = self.load_conversation(uid)
|
|
514
|
+
result.append({
|
|
515
|
+
"user_id": uid,
|
|
516
|
+
"display_name": name,
|
|
517
|
+
"issues_submitted": len(issues),
|
|
518
|
+
"conversation_messages": len(conv),
|
|
519
|
+
})
|
|
520
|
+
return result
|
|
521
|
+
|
|
522
|
+
def reset_fingerprint(self, fingerprint: str) -> bool:
|
|
523
|
+
"""Remove a fix record so Sentinel will retry the error on the next poll."""
|
|
524
|
+
with self._conn() as conn:
|
|
525
|
+
cur = conn.execute("DELETE FROM fixes WHERE fingerprint = ?", (fingerprint,))
|
|
526
|
+
conn.execute("UPDATE errors SET last_seen = NULL WHERE fingerprint = ?", (fingerprint,))
|
|
527
|
+
return cur.rowcount > 0
|
|
528
|
+
|
|
529
|
+
def get_all_errors(self, hours: int = 0) -> list[dict]:
|
|
530
|
+
"""Return full unfiltered error list, optionally within a time window."""
|
|
531
|
+
with self._conn() as conn:
|
|
532
|
+
if hours:
|
|
533
|
+
rows = conn.execute(
|
|
534
|
+
"SELECT * FROM errors WHERE last_seen >= datetime('now', ? || ' hours') "
|
|
535
|
+
"ORDER BY last_seen DESC",
|
|
536
|
+
(f"-{hours}",),
|
|
537
|
+
).fetchall()
|
|
538
|
+
else:
|
|
539
|
+
rows = conn.execute(
|
|
540
|
+
"SELECT * FROM errors ORDER BY last_seen DESC"
|
|
541
|
+
).fetchall()
|
|
542
|
+
return [dict(r) for r in rows]
|