@misterhuydo/sentinel 1.0.5 → 1.0.9

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.
@@ -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 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
- );
56
-
57
- CREATE TABLE IF NOT EXISTS reports (
58
- id INTEGER PRIMARY KEY AUTOINCREMENT,
59
- sent_at TEXT NOT NULL,
60
- recipient_count INTEGER NOT NULL DEFAULT 0,
61
- summary_json TEXT
62
- );
63
- """)
64
- logger.debug("StateStore initialised at %s", self.db_path)
65
-
66
- # ── Errors ────────────────────────────────────────────────────────────────
67
-
68
- def seen(self, fingerprint: str) -> bool:
69
- with self._conn() as conn:
70
- row = conn.execute(
71
- "SELECT fingerprint FROM errors WHERE fingerprint = ?", (fingerprint,)
72
- ).fetchone()
73
- return row is not None
74
-
75
- def record_error(self, fingerprint: str, source: str, message: str):
76
- now = _now()
77
- with self._conn() as conn:
78
- existing = conn.execute(
79
- "SELECT count FROM errors WHERE fingerprint = ?", (fingerprint,)
80
- ).fetchone()
81
- if existing:
82
- conn.execute(
83
- "UPDATE errors SET last_seen=?, count=count+1 WHERE fingerprint=?",
84
- (now, fingerprint),
85
- )
86
- else:
87
- conn.execute(
88
- "INSERT INTO errors (fingerprint, first_seen, last_seen, count, source, message) "
89
- "VALUES (?, ?, ?, 1, ?, ?)",
90
- (fingerprint, now, now, source, message),
91
- )
92
-
93
- def get_recent_errors(self, hours: int = 6) -> list[dict]:
94
- with self._conn() as conn:
95
- rows = conn.execute(
96
- "SELECT * FROM errors WHERE last_seen >= datetime('now', ? || ' hours') "
97
- "ORDER BY last_seen DESC",
98
- (f"-{hours}",),
99
- ).fetchall()
100
- return [dict(r) for r in rows]
101
-
102
- # ── Fixes ─────────────────────────────────────────────────────────────────
103
-
104
- def record_fix(
105
- self,
106
- fingerprint: str,
107
- status: str,
108
- patch_path: str = "",
109
- commit_hash: str = "",
110
- branch: str = "",
111
- pr_url: str = "",
112
- repo_name: str = "",
113
- ):
114
- with self._conn() as conn:
115
- conn.execute(
116
- "INSERT INTO fixes (fingerprint, status, patch_path, commit_hash, branch, pr_url, repo_name, timestamp) "
117
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
118
- (fingerprint, status, patch_path, commit_hash, branch, pr_url, repo_name, _now()),
119
- )
120
-
121
- def get_open_prs(self) -> list[dict]:
122
- """Returns fixes pushed as PRs that have not yet been merged (no commit_hash on main)."""
123
- with self._conn() as conn:
124
- rows = conn.execute(
125
- "SELECT * FROM fixes WHERE status='pending' AND pr_url != '' "
126
- "ORDER BY timestamp DESC"
127
- ).fetchall()
128
- return [dict(r) for r in rows]
129
-
130
- def get_recent_fixes(self, hours: int = 6) -> list[dict]:
131
- with self._conn() as conn:
132
- rows = conn.execute(
133
- "SELECT * FROM fixes WHERE timestamp >= datetime('now', ? || ' hours') "
134
- "ORDER BY timestamp DESC",
135
- (f"-{hours}",),
136
- ).fetchall()
137
- return [dict(r) for r in rows]
138
-
139
- def fix_attempted_recently(self, fingerprint: str, hours: int = 24) -> bool:
140
- with self._conn() as conn:
141
- row = conn.execute(
142
- "SELECT id FROM fixes WHERE fingerprint=? "
143
- "AND timestamp >= datetime('now', ? || ' hours')",
144
- (fingerprint, f"-{hours}"),
145
- ).fetchone()
146
- return row is not None
147
-
148
- # ── Reports ───────────────────────────────────────────────────────────────
149
-
150
- def record_report(self, recipient_count: int, summary: dict):
151
- with self._conn() as conn:
152
- conn.execute(
153
- "INSERT INTO reports (sent_at, recipient_count, summary_json) VALUES (?, ?, ?)",
154
- (_now(), recipient_count, json.dumps(summary)),
155
- )
156
-
157
- def last_report_time(self) -> datetime | None:
158
- with self._conn() as conn:
159
- row = conn.execute(
160
- "SELECT sent_at FROM reports ORDER BY sent_at DESC LIMIT 1"
161
- ).fetchone()
162
- if row:
163
- return datetime.fromisoformat(row["sent_at"])
164
- return None
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
@@ -1,32 +1,20 @@
1
- # Sentinel master config
2
-
3
- # Schedule
4
- POLL_INTERVAL_SECONDS=120
5
-
6
- # Email reporting
7
- SMTP_HOST=smtp.gmail.com
8
- SMTP_PORT=587
9
- SMTP_USER=sentinel@yourdomain.com
10
- SMTP_PASSWORD=<app-password>
11
- MAILS=huy@yourdomain.com
12
- SEND_HEALTH=disabled
13
- REPORT_INTERVAL_HOURS=1
14
-
15
- # State DB
16
- STATE_DB=./sentinel.db
17
-
18
- # Workspace
19
- WORKSPACE_DIR=./workspace
20
-
21
- # Claude Code binary path
22
- CLAUDE_CODE_BIN=claude
23
-
24
- # GitHub token (required for opening PRs when AUTO_PUBLISH=false)
25
- GITHUB_TOKEN=<github-pat>
26
-
27
- # Fix confidence threshold (0.0 - 1.0); fixes below this are skipped
28
- FIX_CONFIDENCE_THRESHOLD=0.7
29
-
30
- # Rolling log retention window — fetched logs older than this are pruned
31
- # Claude Code reads the full window for context when generating fixes
32
- LOG_RETENTION_HOURS=48
1
+ # Sentinel project config — project-specific settings.
2
+ # Shared settings (SMTP, schedule, etc.) live in the workspace-level sentinel.properties
3
+ # one directory above this project. Values here override workspace defaults.
4
+
5
+ # Who receives fix notifications and health reports for this project
6
+ MAILS=you@yourdomain.com
7
+
8
+ # Health digest — disabled by default; per-fix emails are always sent
9
+ SEND_HEALTH=disabled
10
+ REPORT_INTERVAL_HOURS=1
11
+
12
+ # GitHub token for opening PRs (required when AUTO_PUBLISH=false)
13
+ GITHUB_TOKEN=<github-pat>
14
+
15
+ # State DB and workspace paths (relative to this project dir)
16
+ STATE_DB=./sentinel.db
17
+ WORKSPACE_DIR=./workspace
18
+
19
+ # Claude Code auth — set if using API key, leave blank for OAuth
20
+ # ANTHROPIC_API_KEY=sk-ant-...
@@ -0,0 +1,20 @@
1
+ # Sentinel workspace config — shared across all projects in this workspace.
2
+ # Per-project sentinel.properties can override any of these.
3
+
4
+ # Schedule
5
+ POLL_INTERVAL_SECONDS=120
6
+
7
+ # SMTP — configure once here, all projects share it
8
+ SMTP_HOST=smtp.gmail.com
9
+ SMTP_PORT=587
10
+ SMTP_USER=sentinel@yourdomain.com
11
+ SMTP_PASSWORD=<app-password>
12
+
13
+ # Fix confidence threshold (0.0 - 1.0)
14
+ FIX_CONFIDENCE_THRESHOLD=0.7
15
+
16
+ # Rolling log retention window (hours)
17
+ LOG_RETENTION_HOURS=48
18
+
19
+ # Claude Code binary path
20
+ CLAUDE_CODE_BIN=claude