@misterhuydo/sentinel 1.0.6 → 1.0.10
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/bin/sentinel.js +42 -39
- package/lib/add.js +415 -57
- package/lib/generate.js +14 -23
- package/lib/init.js +21 -7
- package/package.json +1 -1
- package/python/sentinel/__pycache__/issue_watcher.cpython-313.pyc +0 -0
- package/python/sentinel/config_loader.py +15 -3
- package/python/sentinel/fix_engine.py +49 -14
- package/python/sentinel/issue_watcher.py +146 -131
- package/python/sentinel/log_parser.py +175 -149
- package/python/sentinel/main.py +110 -32
- package/python/sentinel/reporter.py +159 -0
- package/python/sentinel/state_store.py +275 -164
- package/templates/sentinel.properties +20 -32
- package/templates/workspace-sentinel.properties +20 -0
|
@@ -186,3 +186,162 @@ def send_failure_notification(cfg: SentinelConfig, details: dict):
|
|
|
186
186
|
_send_email(cfg, subject, html)
|
|
187
187
|
logger.info('Failure notification sent for %s', source)
|
|
188
188
|
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---- Confirmed fix notification ----------------------------------------------
|
|
192
|
+
|
|
193
|
+
def send_confirmed_notification(cfg: SentinelConfig, fix: dict):
|
|
194
|
+
"""Notify admins that a fix has been confirmed running in production."""
|
|
195
|
+
if not cfg.mails:
|
|
196
|
+
return
|
|
197
|
+
repo_name = fix.get('repo_name', 'unknown')
|
|
198
|
+
fingerprint = fix.get('fingerprint', '')
|
|
199
|
+
marker = fix.get('sentinel_marker', '')
|
|
200
|
+
ts = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
|
201
|
+
subject = f'[Sentinel] ✅ Fix confirmed in production: {repo_name} ({fingerprint[:8]})'
|
|
202
|
+
html = (
|
|
203
|
+
'<!DOCTYPE html><html><head><meta charset="utf-8">'
|
|
204
|
+
'<style>'
|
|
205
|
+
'body{font-family:Arial,sans-serif;font-size:14px;color:#222}'
|
|
206
|
+
'h2{color:#2e7d32}'
|
|
207
|
+
'table{border-collapse:collapse;width:100%;margin-bottom:16px}'
|
|
208
|
+
'th{background:#f1f3f4;text-align:left;padding:6px 10px}'
|
|
209
|
+
'td{padding:5px 10px;border-bottom:1px solid #eee;vertical-align:top}'
|
|
210
|
+
'.label{font-weight:bold;width:160px}'
|
|
211
|
+
'.mono{font-family:monospace;font-size:12px}'
|
|
212
|
+
'</style></head><body>'
|
|
213
|
+
'<h2>✅ Fix confirmed running in production</h2>'
|
|
214
|
+
f'<p><strong>{repo_name}</strong> · {ts}</p>'
|
|
215
|
+
'<table>'
|
|
216
|
+
f'<tr><td class="label">Fingerprint</td><td class="mono">{fingerprint}</td></tr>'
|
|
217
|
+
f'<tr><td class="label">Sentinel marker</td><td class="mono">{marker}</td></tr>'
|
|
218
|
+
f'<tr><td class="label">Commit</td><td class="mono">{fix.get("commit_hash", "")}</td></tr>'
|
|
219
|
+
f'<tr><td class="label">Branch</td><td class="mono">{fix.get("branch", "")}</td></tr>'
|
|
220
|
+
f'<tr><td class="label">Confirmed at</td><td>{fix.get("confirmed_at", ts)}</td></tr>'
|
|
221
|
+
'</table>'
|
|
222
|
+
'<p>The marker log line was detected in production logs, confirming the fix is live and the fixed code path executed.</p>'
|
|
223
|
+
'<hr><small>Sentinel — Autonomous DevOps Agent</small>'
|
|
224
|
+
'</body></html>'
|
|
225
|
+
)
|
|
226
|
+
_send_email(cfg, subject, html)
|
|
227
|
+
logger.info('Confirmed notification sent for %s', fingerprint)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---- Regression notification ------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def send_regression_notification(cfg: SentinelConfig, fix: dict, event: dict):
|
|
233
|
+
"""Notify admins that a confirmed fix did not resolve the issue."""
|
|
234
|
+
if not cfg.mails:
|
|
235
|
+
return
|
|
236
|
+
repo_name = fix.get('repo_name', 'unknown')
|
|
237
|
+
fingerprint = fix.get('fingerprint', '')
|
|
238
|
+
ts = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
|
239
|
+
subject = f'[Sentinel] ⚠ Regression: fix did not resolve issue in {repo_name}'
|
|
240
|
+
html = (
|
|
241
|
+
'<!DOCTYPE html><html><head><meta charset="utf-8">'
|
|
242
|
+
'<style>'
|
|
243
|
+
'body{font-family:Arial,sans-serif;font-size:14px;color:#222}'
|
|
244
|
+
'h2{color:#c62828}'
|
|
245
|
+
'h3{color:#444;border-bottom:1px solid #ddd;padding-bottom:4px}'
|
|
246
|
+
'table{border-collapse:collapse;width:100%;margin-bottom:16px}'
|
|
247
|
+
'th{background:#f1f3f4;text-align:left;padding:6px 10px}'
|
|
248
|
+
'td{padding:5px 10px;border-bottom:1px solid #eee;vertical-align:top}'
|
|
249
|
+
'.label{font-weight:bold;width:160px}'
|
|
250
|
+
'.mono{font-family:monospace;font-size:12px}'
|
|
251
|
+
'pre{background:#f8f8f8;border:1px solid #ddd;padding:10px;font-size:12px;white-space:pre-wrap}'
|
|
252
|
+
'</style></head><body>'
|
|
253
|
+
'<h2>⚠ Regression detected — fix did not resolve the issue</h2>'
|
|
254
|
+
f'<p><strong>{repo_name}</strong> · {ts}</p>'
|
|
255
|
+
'<p>The original error recurred in production logs after the Sentinel fix was confirmed deployed.</p>'
|
|
256
|
+
'<h3>Fix Details</h3>'
|
|
257
|
+
'<table>'
|
|
258
|
+
f'<tr><td class="label">Fingerprint</td><td class="mono">{fingerprint}</td></tr>'
|
|
259
|
+
f'<tr><td class="label">Commit</td><td class="mono">{fix.get("commit_hash", "")}</td></tr>'
|
|
260
|
+
f'<tr><td class="label">Branch</td><td class="mono">{fix.get("branch", "")}</td></tr>'
|
|
261
|
+
f'<tr><td class="label">Confirmed at</td><td>{fix.get("confirmed_at", "")}</td></tr>'
|
|
262
|
+
'</table>'
|
|
263
|
+
'<h3>Recurring Error</h3>'
|
|
264
|
+
'<table>'
|
|
265
|
+
f'<tr><td class="label">Source</td><td class="mono">{event.get("source", "")}</td></tr>'
|
|
266
|
+
f'<tr><td class="label">Message</td><td class="mono">{event.get("message", "")}</td></tr>'
|
|
267
|
+
'</table>'
|
|
268
|
+
f'<pre>{event.get("body", "")}</pre>'
|
|
269
|
+
'<p>Sentinel will not attempt another automatic fix. Please investigate manually.</p>'
|
|
270
|
+
'<hr><small>Sentinel — Autonomous DevOps Agent</small>'
|
|
271
|
+
'</body></html>'
|
|
272
|
+
)
|
|
273
|
+
_send_email(cfg, subject, html)
|
|
274
|
+
logger.info('Regression notification sent for %s', fingerprint)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---- Startup notification ---------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def send_startup_notification(cfg: SentinelConfig, results: dict):
|
|
280
|
+
"""Send startup summary email 5 minutes after Sentinel starts."""
|
|
281
|
+
if not cfg.mails:
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
ts = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
|
285
|
+
|
|
286
|
+
repos = results.get('repos', [])
|
|
287
|
+
cairn = results.get('cairn', [])
|
|
288
|
+
ssh = results.get('ssh', [])
|
|
289
|
+
warns = results.get('warnings', [])
|
|
290
|
+
|
|
291
|
+
has_errors = any(r['status'] == 'error' for r in repos + cairn + ssh)
|
|
292
|
+
status_label = '⚠ Started with warnings' if has_errors else '✅ Started successfully'
|
|
293
|
+
|
|
294
|
+
def row(label, value, ok=True):
|
|
295
|
+
color = '' if ok else ' style="color:#c62828"'
|
|
296
|
+
return f'<tr><td class="label"{color}>{label}</td><td class="mono"{color}>{value}</td></tr>'
|
|
297
|
+
|
|
298
|
+
repo_rows = ''.join(
|
|
299
|
+
row(r['name'],
|
|
300
|
+
f"{r['status'].upper()}: {r['message']}",
|
|
301
|
+
r['status'] != 'error')
|
|
302
|
+
for r in repos
|
|
303
|
+
)
|
|
304
|
+
cairn_rows = ''.join(
|
|
305
|
+
row(r['name'], r['message'], r['status'] == 'ok')
|
|
306
|
+
for r in cairn
|
|
307
|
+
)
|
|
308
|
+
ssh_rows = ''.join(
|
|
309
|
+
row(f"{r['name']} ({r['host']})",
|
|
310
|
+
r['message'] or 'OK',
|
|
311
|
+
r['status'] == 'ok')
|
|
312
|
+
for r in ssh
|
|
313
|
+
)
|
|
314
|
+
warn_html = (
|
|
315
|
+
'<h3>⚠ Warnings</h3><ul>' +
|
|
316
|
+
''.join(f'<li>{w}</li>' for w in warns) +
|
|
317
|
+
'</ul>'
|
|
318
|
+
) if warns else ''
|
|
319
|
+
|
|
320
|
+
html = (
|
|
321
|
+
'<!DOCTYPE html><html><head><meta charset="utf-8">'
|
|
322
|
+
'<style>'
|
|
323
|
+
'body{font-family:Arial,sans-serif;font-size:14px;color:#222}'
|
|
324
|
+
'h2{color:#1a73e8}'
|
|
325
|
+
'h3{color:#444;border-bottom:1px solid #ddd;padding-bottom:4px}'
|
|
326
|
+
'table{border-collapse:collapse;width:100%;margin-bottom:16px}'
|
|
327
|
+
'th{background:#f1f3f4;text-align:left;padding:6px 10px}'
|
|
328
|
+
'td{padding:5px 10px;border-bottom:1px solid #eee;vertical-align:top}'
|
|
329
|
+
'.label{font-weight:bold;width:200px}'
|
|
330
|
+
'.mono{font-family:monospace;font-size:12px}'
|
|
331
|
+
'</style></head><body>'
|
|
332
|
+
f'<h2>Sentinel {status_label}</h2>'
|
|
333
|
+
f'<p>{ts}</p>'
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
if repo_rows:
|
|
337
|
+
html += f'<h3>Repositories</h3><table>{repo_rows}</table>'
|
|
338
|
+
if cairn_rows:
|
|
339
|
+
html += f'<h3>Cairn Index</h3><table>{cairn_rows}</table>'
|
|
340
|
+
if ssh_rows:
|
|
341
|
+
html += f'<h3>SSH Connectivity</h3><table>{ssh_rows}</table>'
|
|
342
|
+
html += warn_html
|
|
343
|
+
html += '<hr><small>Sentinel — Autonomous DevOps Agent</small></body></html>'
|
|
344
|
+
|
|
345
|
+
subject = f'[Sentinel] {status_label} — {ts}'
|
|
346
|
+
_send_email(cfg, subject, html)
|
|
347
|
+
logger.info('Startup notification sent to %d recipient(s)', len(cfg.mails))
|
|
@@ -1,164 +1,275 @@
|
|
|
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
|
|
47
|
-
fingerprint
|
|
48
|
-
status
|
|
49
|
-
patch_path
|
|
50
|
-
commit_hash
|
|
51
|
-
branch
|
|
52
|
-
pr_url
|
|
53
|
-
repo_name
|
|
54
|
-
timestamp
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
).
|
|
100
|
-
return
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
"
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
(fingerprint,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
+
]
|
|
82
|
+
with self._conn() as conn:
|
|
83
|
+
done = {r[0] for r in conn.execute("SELECT name FROM _sentinel_migrations").fetchall()}
|
|
84
|
+
for name, sql in migrations:
|
|
85
|
+
if name not in done:
|
|
86
|
+
try:
|
|
87
|
+
conn.execute(sql)
|
|
88
|
+
conn.execute("INSERT INTO _sentinel_migrations VALUES (?)", (name,))
|
|
89
|
+
logger.debug("Migration applied: %s", name)
|
|
90
|
+
except Exception:
|
|
91
|
+
pass # column may already exist
|
|
92
|
+
|
|
93
|
+
# ── Errors ────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def seen(self, fingerprint: str) -> bool:
|
|
96
|
+
with self._conn() as conn:
|
|
97
|
+
row = conn.execute(
|
|
98
|
+
"SELECT fingerprint FROM errors WHERE fingerprint = ?", (fingerprint,)
|
|
99
|
+
).fetchone()
|
|
100
|
+
return row is not None
|
|
101
|
+
|
|
102
|
+
def record_error(self, fingerprint: str, source: str, message: str):
|
|
103
|
+
now = _now()
|
|
104
|
+
with self._conn() as conn:
|
|
105
|
+
existing = conn.execute(
|
|
106
|
+
"SELECT count FROM errors WHERE fingerprint = ?", (fingerprint,)
|
|
107
|
+
).fetchone()
|
|
108
|
+
if existing:
|
|
109
|
+
conn.execute(
|
|
110
|
+
"UPDATE errors SET last_seen=?, count=count+1 WHERE fingerprint=?",
|
|
111
|
+
(now, fingerprint),
|
|
112
|
+
)
|
|
113
|
+
else:
|
|
114
|
+
conn.execute(
|
|
115
|
+
"INSERT INTO errors (fingerprint, first_seen, last_seen, count, source, message) "
|
|
116
|
+
"VALUES (?, ?, ?, 1, ?, ?)",
|
|
117
|
+
(fingerprint, now, now, source, message),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def get_recent_errors(self, hours: int = 6) -> list[dict]:
|
|
121
|
+
with self._conn() as conn:
|
|
122
|
+
rows = conn.execute(
|
|
123
|
+
"SELECT * FROM errors WHERE last_seen >= datetime('now', ? || ' hours') "
|
|
124
|
+
"ORDER BY last_seen DESC",
|
|
125
|
+
(f"-{hours}",),
|
|
126
|
+
).fetchall()
|
|
127
|
+
return [dict(r) for r in rows]
|
|
128
|
+
|
|
129
|
+
# ── Fixes ─────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
def record_fix(
|
|
132
|
+
self,
|
|
133
|
+
fingerprint: str,
|
|
134
|
+
status: str,
|
|
135
|
+
patch_path: str = "",
|
|
136
|
+
commit_hash: str = "",
|
|
137
|
+
branch: str = "",
|
|
138
|
+
pr_url: str = "",
|
|
139
|
+
repo_name: str = "",
|
|
140
|
+
sentinel_marker: str = "",
|
|
141
|
+
):
|
|
142
|
+
with self._conn() as conn:
|
|
143
|
+
conn.execute(
|
|
144
|
+
"INSERT INTO fixes (fingerprint, status, patch_path, commit_hash, branch, "
|
|
145
|
+
"pr_url, repo_name, timestamp, sentinel_marker) "
|
|
146
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
147
|
+
(fingerprint, status, patch_path, commit_hash, branch,
|
|
148
|
+
pr_url, repo_name, _now(), sentinel_marker or None),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def get_open_prs(self) -> list[dict]:
|
|
152
|
+
"""Returns fixes pushed as PRs that have not yet been merged (no commit_hash on main)."""
|
|
153
|
+
with self._conn() as conn:
|
|
154
|
+
rows = conn.execute(
|
|
155
|
+
"SELECT * FROM fixes WHERE status='pending' AND pr_url != '' "
|
|
156
|
+
"ORDER BY timestamp DESC"
|
|
157
|
+
).fetchall()
|
|
158
|
+
return [dict(r) for r in rows]
|
|
159
|
+
|
|
160
|
+
def get_recent_fixes(self, hours: int = 6) -> list[dict]:
|
|
161
|
+
with self._conn() as conn:
|
|
162
|
+
rows = conn.execute(
|
|
163
|
+
"SELECT * FROM fixes WHERE timestamp >= datetime('now', ? || ' hours') "
|
|
164
|
+
"ORDER BY timestamp DESC",
|
|
165
|
+
(f"-{hours}",),
|
|
166
|
+
).fetchall()
|
|
167
|
+
return [dict(r) for r in rows]
|
|
168
|
+
|
|
169
|
+
def fix_attempted_recently(self, fingerprint: str, hours: int = 24) -> bool:
|
|
170
|
+
with self._conn() as conn:
|
|
171
|
+
row = conn.execute(
|
|
172
|
+
"SELECT id FROM fixes WHERE fingerprint=? "
|
|
173
|
+
"AND timestamp >= datetime('now', ? || ' hours')",
|
|
174
|
+
(fingerprint, f"-{hours}"),
|
|
175
|
+
).fetchone()
|
|
176
|
+
return row is not None
|
|
177
|
+
|
|
178
|
+
def mark_marker_seen(self, marker: str) -> dict | None:
|
|
179
|
+
"""Record that a SENTINEL marker appeared in production logs."""
|
|
180
|
+
with self._conn() as conn:
|
|
181
|
+
row = conn.execute(
|
|
182
|
+
"SELECT * FROM fixes WHERE sentinel_marker=? AND marker_seen_at IS NULL",
|
|
183
|
+
(marker,),
|
|
184
|
+
).fetchone()
|
|
185
|
+
if not row:
|
|
186
|
+
return None
|
|
187
|
+
conn.execute(
|
|
188
|
+
"UPDATE fixes SET marker_seen_at=? WHERE sentinel_marker=?",
|
|
189
|
+
(_now(), marker),
|
|
190
|
+
)
|
|
191
|
+
return dict(row)
|
|
192
|
+
|
|
193
|
+
def get_fixes_pending_confirmation(self, quiet_hours: int = 24) -> list[dict]:
|
|
194
|
+
"""Return fixes whose marker was seen >= quiet_hours ago and are not yet confirmed/regressed."""
|
|
195
|
+
with self._conn() as conn:
|
|
196
|
+
rows = conn.execute(
|
|
197
|
+
"SELECT * FROM fixes WHERE marker_seen_at IS NOT NULL "
|
|
198
|
+
"AND confirmed_at IS NULL AND fix_outcome IS NULL "
|
|
199
|
+
"AND marker_seen_at <= datetime('now', ? || ' hours')",
|
|
200
|
+
(f"-{quiet_hours}",),
|
|
201
|
+
).fetchall()
|
|
202
|
+
return [dict(r) for r in rows]
|
|
203
|
+
|
|
204
|
+
def confirm_fix(self, fingerprint: str) -> dict | None:
|
|
205
|
+
"""Confirm a fix after quiet period — no recurrence observed."""
|
|
206
|
+
with self._conn() as conn:
|
|
207
|
+
row = conn.execute(
|
|
208
|
+
"SELECT * FROM fixes WHERE fingerprint=? AND marker_seen_at IS NOT NULL "
|
|
209
|
+
"AND confirmed_at IS NULL AND fix_outcome IS NULL",
|
|
210
|
+
(fingerprint,),
|
|
211
|
+
).fetchone()
|
|
212
|
+
if not row:
|
|
213
|
+
return None
|
|
214
|
+
conn.execute(
|
|
215
|
+
"UPDATE fixes SET confirmed_at=?, fix_outcome='confirmed' WHERE fingerprint=?",
|
|
216
|
+
(_now(), fingerprint),
|
|
217
|
+
)
|
|
218
|
+
return dict(row)
|
|
219
|
+
|
|
220
|
+
def mark_regressed(self, fingerprint: str):
|
|
221
|
+
"""Mark fix as regressed — error recurred after marker was seen."""
|
|
222
|
+
with self._conn() as conn:
|
|
223
|
+
conn.execute(
|
|
224
|
+
"UPDATE fixes SET fix_outcome='regressed' "
|
|
225
|
+
"WHERE fingerprint=? AND fix_outcome IS NULL AND marker_seen_at IS NOT NULL",
|
|
226
|
+
(fingerprint,),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def get_confirmed_fix(self, fingerprint: str) -> dict | None:
|
|
230
|
+
"""Return a confirmed (not yet regressed) fix for this fingerprint."""
|
|
231
|
+
with self._conn() as conn:
|
|
232
|
+
row = conn.execute(
|
|
233
|
+
"SELECT * FROM fixes WHERE fingerprint=? AND fix_outcome='confirmed'",
|
|
234
|
+
(fingerprint,),
|
|
235
|
+
).fetchone()
|
|
236
|
+
return dict(row) if row else None
|
|
237
|
+
|
|
238
|
+
def get_marker_seen_fix(self, fingerprint: str) -> dict | None:
|
|
239
|
+
"""Return a fix for this fingerprint where marker was seen but not yet confirmed."""
|
|
240
|
+
with self._conn() as conn:
|
|
241
|
+
row = conn.execute(
|
|
242
|
+
"SELECT * FROM fixes WHERE fingerprint=? "
|
|
243
|
+
"AND marker_seen_at IS NOT NULL AND fix_outcome IS NULL",
|
|
244
|
+
(fingerprint,),
|
|
245
|
+
).fetchone()
|
|
246
|
+
return dict(row) if row else None
|
|
247
|
+
|
|
248
|
+
def get_stale_markers(self, repo_name: str) -> list[str]:
|
|
249
|
+
"""Return markers for confirmed/regressed fixes in a repo — safe to remove from code."""
|
|
250
|
+
with self._conn() as conn:
|
|
251
|
+
rows = conn.execute(
|
|
252
|
+
"SELECT sentinel_marker FROM fixes "
|
|
253
|
+
"WHERE repo_name=? AND fix_outcome IN ('confirmed', 'regressed') "
|
|
254
|
+
"AND sentinel_marker IS NOT NULL",
|
|
255
|
+
(repo_name,),
|
|
256
|
+
).fetchall()
|
|
257
|
+
return [r[0] for r in rows]
|
|
258
|
+
|
|
259
|
+
# ── Reports ───────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
def record_report(self, recipient_count: int, summary: dict):
|
|
262
|
+
with self._conn() as conn:
|
|
263
|
+
conn.execute(
|
|
264
|
+
"INSERT INTO reports (sent_at, recipient_count, summary_json) VALUES (?, ?, ?)",
|
|
265
|
+
(_now(), recipient_count, json.dumps(summary)),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def last_report_time(self) -> datetime | None:
|
|
269
|
+
with self._conn() as conn:
|
|
270
|
+
row = conn.execute(
|
|
271
|
+
"SELECT sent_at FROM reports ORDER BY sent_at DESC LIMIT 1"
|
|
272
|
+
).fetchone()
|
|
273
|
+
if row:
|
|
274
|
+
return datetime.fromisoformat(row["sent_at"])
|
|
275
|
+
return None
|