@misterhuydo/sentinel 1.0.4 → 1.0.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/lib/generate.js +22 -3
- package/package.json +1 -1
- package/python/sentinel/__pycache__/config_loader.cpython-313.pyc +0 -0
- package/python/sentinel/__pycache__/fix_engine.cpython-313.pyc +0 -0
- package/python/sentinel/__pycache__/main.cpython-313.pyc +0 -0
- package/python/sentinel/__pycache__/reporter.cpython-313.pyc +0 -0
- package/python/sentinel/config_loader.py +4 -2
- package/python/sentinel/fix_engine.py +36 -30
- package/python/sentinel/issue_watcher.py +131 -0
- package/python/sentinel/main.py +355 -223
- package/python/sentinel/reporter.py +188 -173
- package/templates/sentinel.properties +32 -31
|
@@ -1,173 +1,188 @@
|
|
|
1
|
-
"""
|
|
2
|
-
reporter.py
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
from
|
|
12
|
-
|
|
13
|
-
from
|
|
14
|
-
|
|
15
|
-
from
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
body
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
1
|
+
"""
|
|
2
|
+
reporter.py -- Email notifications for Sentinel.
|
|
3
|
+
|
|
4
|
+
Two modes:
|
|
5
|
+
1. Per-fix notification -- sent immediately after every fix (always on).
|
|
6
|
+
2. Health digest -- periodic summary, only if SEND_HEALTH=enabled.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import smtplib
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from email.mime.multipart import MIMEMultipart
|
|
13
|
+
from email.mime.text import MIMEText
|
|
14
|
+
|
|
15
|
+
from jinja2 import Template
|
|
16
|
+
|
|
17
|
+
from .config_loader import SentinelConfig
|
|
18
|
+
from .state_store import StateStore
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# ---- Templates ---------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
_FIX_TEMPLATE = Template('<!DOCTYPE html><html><head><meta charset="utf-8">\n<style>\n body{font-family:Arial,sans-serif;font-size:14px;color:#222}\n h2{color:#1a73e8;margin-bottom:4px}\n h3{color:#444;border-bottom:1px solid #ddd;padding-bottom:4px}\n table{border-collapse:collapse;width:100%;margin-bottom:16px}\n th{background:#f1f3f4;text-align:left;padding:6px 10px}\n td{padding:5px 10px;border-bottom:1px solid #eee;vertical-align:top}\n .label{font-weight:bold;width:160px}\n .ok{color:#2e7d32;font-weight:bold}\n .mono{font-family:monospace;font-size:12px}\n pre{background:#f8f8f8;border:1px solid #ddd;padding:10px;font-size:12px;white-space:pre-wrap}\n .badge-a{background:#2e7d32;color:#fff;padding:2px 8px;border-radius:4px}\n .badge-p{background:#e65100;color:#fff;padding:2px 8px;border-radius:4px}\n</style></head><body>\n<h2>Sentinel Fix Report</h2>\n<p>\n <span class="{{ \'badge-a\' if auto_publish else \'badge-p\' }}">\n {{ \'PUSHED TO \' + branch|upper if auto_publish else \'PENDING REVIEW\' }}\n </span>\n <strong>{{ repo_name }}</strong> · {{ generated_at }}\n</p>\n<h3>Error Detected</h3>\n<table>\n <tr><td class="label">Service</td><td class="mono">{{ source }}</td></tr>\n <tr><td class="label">Severity</td><td class="mono">{{ severity }}</td></tr>\n <tr><td class="label">Fingerprint</td><td class="mono">{{ fingerprint }}</td></tr>\n <tr><td class="label">First seen</td><td>{{ first_seen }}</td></tr>\n <tr><td class="label">Message</td><td class="mono">{{ message }}</td></tr>\n</table>\n{% if stack_trace %}<h3>Stack Trace</h3><pre>{{ stack_trace }}</pre>{% endif %}\n<h3>Fix Applied</h3>\n<table>\n <tr><td class="label">Repository</td><td class="mono">{{ repo_name }}</td></tr>\n <tr><td class="label">Commit</td><td class="mono">{{ commit_hash }}</td></tr>\n <tr><td class="label">Branch</td><td class="mono">{{ branch }}</td></tr>\n {% if pr_url %}\n <tr><td class="label">Pull Request</td>\n <td><a href="{{ pr_url }}">{{ pr_url }}</a> — review and merge to apply</td></tr>\n {% else %}\n <tr><td class="label">Status</td>\n <td class="ok">Pushed directly to {{ branch }}</td></tr>\n {% endif %}\n {% if files_changed %}\n <tr><td class="label">Files changed</td>\n <td class="mono">{{ files_changed | join(\'<br>\') }}</td></tr>\n {% endif %}\n</table>\n<hr><small>Sentinel — Autonomous DevOps Agent</small>\n</body></html>\n')
|
|
25
|
+
|
|
26
|
+
_HEALTH_TEMPLATE = Template('<!DOCTYPE html><html><head><meta charset="utf-8">\n<style>\n body{font-family:Arial,sans-serif;font-size:14px;color:#222}\n h2{color:#1a73e8}\n h3{color:#444;border-bottom:1px solid #ddd;padding-bottom:4px}\n table{border-collapse:collapse;width:100%;margin-bottom:16px}\n th{background:#f1f3f4;text-align:left;padding:6px 10px}\n td{padding:5px 10px;border-bottom:1px solid #eee}\n .ok{color:#2e7d32}.fail{color:#c62828}.warn{color:#e65100}\n .mono{font-family:monospace;font-size:12px}\n</style></head><body>\n<h2>Sentinel Health Digest</h2>\n<p>Generated: <strong>{{ generated_at }}</strong></p>\n<h3>Summary (last {{ hours }}h)</h3>\n<table>\n <tr><th>Metric</th><th>Count</th></tr>\n <tr><td>Errors detected</td><td>{{ stats.errors }}</td></tr>\n <tr><td>Fixes applied</td><td class="ok">{{ stats.applied }}</td></tr>\n <tr><td>Fixes failed</td><td class="fail">{{ stats.failed }}</td></tr>\n <tr><td>Skipped</td><td class="warn">{{ stats.skipped }}</td></tr>\n</table>\n{% if open_prs %}\n<h3>Pending Review (AUTO_PUBLISH=false)</h3>\n<table>\n <tr><th>Repo</th><th>Branch</th><th>PR</th><th>Age</th></tr>\n {% for pr in open_prs %}\n <tr>\n <td>{{ pr.repo_name }}</td>\n <td class="mono">{{ pr.branch }}</td>\n <td><a href="{{ pr.pr_url }}">{{ pr.pr_url }}</a></td>\n <td>{{ pr.age }}</td>\n </tr>\n {% endfor %}\n</table>\n{% endif %}\n<hr><small>Sentinel — Autonomous DevOps Agent</small>\n</body></html>\n')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---- Per-fix notification ----------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def send_fix_notification(cfg: SentinelConfig, fix: dict):
|
|
32
|
+
"""
|
|
33
|
+
Send an immediate email after a fix is applied or a PR is opened.
|
|
34
|
+
|
|
35
|
+
fix dict keys:
|
|
36
|
+
source, severity, fingerprint, first_seen, message, stack_trace,
|
|
37
|
+
repo_name, commit_hash, branch, pr_url, auto_publish, files_changed
|
|
38
|
+
"""
|
|
39
|
+
if not cfg.mails:
|
|
40
|
+
logger.warning("No MAILS configured -- skipping fix notification")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
auto_publish = fix.get("auto_publish", False)
|
|
44
|
+
source = fix.get("source", "unknown")
|
|
45
|
+
verb = "fix" if auto_publish else "PR"
|
|
46
|
+
subject = f"[Sentinel] {verb}({source}): {fix.get('message', '')[:80]}"
|
|
47
|
+
|
|
48
|
+
html = _FIX_TEMPLATE.render(
|
|
49
|
+
generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
|
|
50
|
+
auto_publish=auto_publish,
|
|
51
|
+
repo_name=fix.get("repo_name", "unknown"),
|
|
52
|
+
source=source,
|
|
53
|
+
severity=fix.get("severity", "ERROR"),
|
|
54
|
+
fingerprint=fix.get("fingerprint", ""),
|
|
55
|
+
first_seen=fix.get("first_seen", ""),
|
|
56
|
+
message=fix.get("message", ""),
|
|
57
|
+
stack_trace=fix.get("stack_trace", ""),
|
|
58
|
+
commit_hash=fix.get("commit_hash", ""),
|
|
59
|
+
branch=fix.get("branch", "unknown"),
|
|
60
|
+
pr_url=fix.get("pr_url") or "",
|
|
61
|
+
files_changed=fix.get("files_changed") or [],
|
|
62
|
+
)
|
|
63
|
+
_send_email(cfg, subject, html)
|
|
64
|
+
logger.info("Fix notification sent to %d recipient(s)", len(cfg.mails))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---- Health digest -----------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def build_and_send(cfg: SentinelConfig, store: StateStore):
|
|
70
|
+
"""Send periodic health digest. Only called if SEND_HEALTH=enabled."""
|
|
71
|
+
if not cfg.mails:
|
|
72
|
+
logger.warning("No MAILS configured -- skipping health digest")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
hours = cfg.report_interval_hours
|
|
76
|
+
errors = store.get_recent_errors(hours)
|
|
77
|
+
fixes = store.get_recent_fixes(hours)
|
|
78
|
+
open_prs = store.get_open_prs()
|
|
79
|
+
|
|
80
|
+
stats = {
|
|
81
|
+
"errors": len(errors),
|
|
82
|
+
"applied": sum(1 for f in fixes if f["status"] == "applied"),
|
|
83
|
+
"failed": sum(1 for f in fixes if f["status"] == "failed"),
|
|
84
|
+
"skipped": sum(1 for f in fixes if f["status"] == "skipped"),
|
|
85
|
+
}
|
|
86
|
+
for pr in open_prs:
|
|
87
|
+
pr["age"] = _age(pr.get("timestamp", ""))
|
|
88
|
+
|
|
89
|
+
html = _HEALTH_TEMPLATE.render(
|
|
90
|
+
generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
|
|
91
|
+
hours=hours, stats=stats, open_prs=open_prs,
|
|
92
|
+
)
|
|
93
|
+
subject = (
|
|
94
|
+
f"[Sentinel] Health Digest -- "
|
|
95
|
+
f"{stats['applied']} fixed, {stats['failed']} failed, "
|
|
96
|
+
f"{stats['errors']} detected"
|
|
97
|
+
)
|
|
98
|
+
_send_email(cfg, subject, html)
|
|
99
|
+
store.record_report(recipient_count=len(cfg.mails), summary=stats)
|
|
100
|
+
logger.info("Health digest sent to %d recipient(s)", len(cfg.mails))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---- Shared helpers ----------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def _send_email(cfg: SentinelConfig, subject: str, html: str):
|
|
106
|
+
msg = MIMEMultipart("alternative")
|
|
107
|
+
msg["Subject"] = subject
|
|
108
|
+
msg["From"] = cfg.smtp_user
|
|
109
|
+
msg["To"] = ", ".join(cfg.mails)
|
|
110
|
+
msg.attach(MIMEText(html, "html"))
|
|
111
|
+
if cfg.smtp_host.lower() == "ses":
|
|
112
|
+
_send_ses(cfg, msg)
|
|
113
|
+
else:
|
|
114
|
+
_send_smtp(cfg, msg)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _send_smtp(cfg: SentinelConfig, msg: MIMEMultipart):
|
|
118
|
+
with smtplib.SMTP(cfg.smtp_host, cfg.smtp_port) as smtp:
|
|
119
|
+
smtp.ehlo()
|
|
120
|
+
smtp.starttls()
|
|
121
|
+
smtp.login(cfg.smtp_user, cfg.smtp_password)
|
|
122
|
+
smtp.sendmail(cfg.smtp_user, cfg.mails, msg.as_string())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _send_ses(cfg: SentinelConfig, msg: MIMEMultipart):
|
|
126
|
+
_send_smtp(cfg, msg)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _age(ts_str: str) -> str:
|
|
130
|
+
try:
|
|
131
|
+
ts = datetime.fromisoformat(ts_str)
|
|
132
|
+
if ts.tzinfo is None:
|
|
133
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
134
|
+
delta = datetime.now(timezone.utc) - ts
|
|
135
|
+
hours = int(delta.total_seconds() // 3600)
|
|
136
|
+
return f"{int(delta.total_seconds() // 60)}m" if hours < 1 else f"{hours}h"
|
|
137
|
+
except Exception:
|
|
138
|
+
return "?"
|
|
139
|
+
|
|
140
|
+
def send_failure_notification(cfg: SentinelConfig, details: dict):
|
|
141
|
+
"""
|
|
142
|
+
Notify admins when Claude Code cannot fix a problem (from logs or issues/).
|
|
143
|
+
|
|
144
|
+
details dict keys: source, message, repo_name, reason, body
|
|
145
|
+
"""
|
|
146
|
+
if not cfg.mails:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
source = details.get('source', 'unknown')
|
|
150
|
+
repo_name = details.get('repo_name', 'unknown')
|
|
151
|
+
reason = details.get('reason', 'unknown')
|
|
152
|
+
message = details.get('message', '')
|
|
153
|
+
body = details.get('body', '')[:1000]
|
|
154
|
+
ts = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
|
155
|
+
|
|
156
|
+
subject = f'[Sentinel] UNRESOLVED ({source}): {message[:80]}'
|
|
157
|
+
|
|
158
|
+
ctx_html = f'<h3>Context</h3><pre>{body}</pre>' if body else ''
|
|
159
|
+
html = (
|
|
160
|
+
'<!DOCTYPE html><html><head><meta charset="utf-8">'
|
|
161
|
+
'<style>'
|
|
162
|
+
'body{font-family:Arial,sans-serif;font-size:14px;color:#222}'
|
|
163
|
+
'h2{color:#c62828}'
|
|
164
|
+
'h3{color:#444;border-bottom:1px solid #ddd;padding-bottom:4px}'
|
|
165
|
+
'table{border-collapse:collapse;width:100%;margin-bottom:16px}'
|
|
166
|
+
'th{background:#f1f3f4;text-align:left;padding:6px 10px}'
|
|
167
|
+
'td{padding:5px 10px;border-bottom:1px solid #eee;vertical-align:top}'
|
|
168
|
+
'.label{font-weight:bold;width:160px}'
|
|
169
|
+
'.mono{font-family:monospace;font-size:12px}'
|
|
170
|
+
'pre{background:#f8f8f8;border:1px solid #ddd;padding:10px;font-size:12px;white-space:pre-wrap}'
|
|
171
|
+
'</style></head><body>'
|
|
172
|
+
'<h2>⚠ Sentinel could not fix this issue</h2>'
|
|
173
|
+
f'<p><strong>{repo_name}</strong> · {ts}</p>'
|
|
174
|
+
'<h3>Details</h3>'
|
|
175
|
+
'<table>'
|
|
176
|
+
f'<tr><td class="label">Source</td><td class="mono">{source}</td></tr>'
|
|
177
|
+
f'<tr><td class="label">Repository</td><td class="mono">{repo_name}</td></tr>'
|
|
178
|
+
f'<tr><td class="label">Message</td><td class="mono">{message}</td></tr>'
|
|
179
|
+
f'<tr><td class="label">Reason</td><td>{reason}</td></tr>'
|
|
180
|
+
'</table>'
|
|
181
|
+
+ ctx_html +
|
|
182
|
+
'<hr><small>Sentinel — Autonomous DevOps Agent</small>'
|
|
183
|
+
'</body></html>'
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
_send_email(cfg, subject, html)
|
|
187
|
+
logger.info('Failure notification sent for %s', source)
|
|
188
|
+
|
|
@@ -1,31 +1,32 @@
|
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
#
|
|
31
|
-
|
|
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
|