@misterhuydo/sentinel 1.0.12 → 1.0.13
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/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-03-21T17:
|
|
3
|
-
"checkpoint_at": "2026-03-21T17:
|
|
2
|
+
"message": "Auto-checkpoint at 2026-03-21T17:16:10.950Z",
|
|
3
|
+
"checkpoint_at": "2026-03-21T17:16:10.951Z",
|
|
4
4
|
"active_files": [],
|
|
5
5
|
"notes": [],
|
|
6
6
|
"mtime_snapshot": {}
|
package/package.json
CHANGED
|
@@ -51,7 +51,11 @@ class SentinelConfig:
|
|
|
51
51
|
fix_confidence_threshold: float = 0.7
|
|
52
52
|
log_retention_hours: int = 48
|
|
53
53
|
anthropic_api_key: str = ""
|
|
54
|
-
marker_confirm_hours: int = 24
|
|
54
|
+
marker_confirm_hours: int = 24 # quiet period before confirming a fix
|
|
55
|
+
config_poll_interval: int = 60 # seconds between config repo git pulls
|
|
56
|
+
auto_upgrade: bool = True # auto-upgrade on new patch versions
|
|
57
|
+
version_pin: str = "" # if set, never upgrade beyond this version
|
|
58
|
+
upgrade_check_hours: int = 6 # hours between npm upgrade checks
|
|
55
59
|
|
|
56
60
|
|
|
57
61
|
@dataclass
|
|
@@ -137,6 +141,10 @@ class ConfigLoader:
|
|
|
137
141
|
c.log_retention_hours = int(d.get("LOG_RETENTION_HOURS", 48))
|
|
138
142
|
c.anthropic_api_key = d.get("ANTHROPIC_API_KEY", "")
|
|
139
143
|
c.marker_confirm_hours = int(d.get("MARKER_CONFIRM_HOURS", 24))
|
|
144
|
+
c.config_poll_interval = int(d.get("CONFIG_POLL_INTERVAL", 60))
|
|
145
|
+
c.auto_upgrade = d.get("AUTO_UPGRADE", "true").lower() != "false"
|
|
146
|
+
c.version_pin = d.get("VERSION_PIN", "")
|
|
147
|
+
c.upgrade_check_hours = int(d.get("UPGRADE_CHECK_HOURS", 6))
|
|
140
148
|
self.sentinel = c
|
|
141
149
|
|
|
142
150
|
def _load_log_sources(self):
|
package/python/sentinel/main.py
CHANGED
|
@@ -8,7 +8,10 @@ Usage:
|
|
|
8
8
|
|
|
9
9
|
import argparse
|
|
10
10
|
import asyncio
|
|
11
|
+
import json
|
|
11
12
|
import logging
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
12
15
|
import signal
|
|
13
16
|
import subprocess
|
|
14
17
|
import sys
|
|
@@ -24,7 +27,7 @@ from .log_fetcher import fetch_all
|
|
|
24
27
|
from .log_parser import parse_all, scan_all_for_markers, ErrorEvent
|
|
25
28
|
from .issue_watcher import scan_issues, mark_done, IssueEvent
|
|
26
29
|
from .repo_router import route
|
|
27
|
-
from .reporter import build_and_send, send_fix_notification, send_failure_notification, send_confirmed_notification, send_regression_notification, send_startup_notification
|
|
30
|
+
from .reporter import build_and_send, send_fix_notification, send_failure_notification, send_confirmed_notification, send_regression_notification, send_startup_notification, send_upgrade_notification
|
|
28
31
|
from .state_store import StateStore
|
|
29
32
|
|
|
30
33
|
logging.basicConfig(
|
|
@@ -383,6 +386,160 @@ async def _send_startup_email_delayed(cfg, results: dict, delay: int = 300):
|
|
|
383
386
|
logger.error("Failed to send startup notification: %s", e)
|
|
384
387
|
|
|
385
388
|
|
|
389
|
+
# ── Config repo polling ──────────────────────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
def _poll_config_repo(cfg_loader: ConfigLoader) -> bool:
|
|
392
|
+
"""git pull the project directory if it is a git repo. Returns True if changes were pulled."""
|
|
393
|
+
project_dir = Path(".")
|
|
394
|
+
git_dir = project_dir / ".git"
|
|
395
|
+
if not git_dir.exists():
|
|
396
|
+
return False
|
|
397
|
+
try:
|
|
398
|
+
result = subprocess.run(
|
|
399
|
+
["git", "pull", "--rebase", "--autostash"],
|
|
400
|
+
cwd=str(project_dir),
|
|
401
|
+
capture_output=True, text=True, timeout=30,
|
|
402
|
+
)
|
|
403
|
+
if result.returncode != 0:
|
|
404
|
+
logger.warning("Config repo git pull failed: %s", result.stderr.strip())
|
|
405
|
+
return False
|
|
406
|
+
changed = "Already up to date." not in result.stdout
|
|
407
|
+
if changed:
|
|
408
|
+
logger.info("Config repo updated — reloading config\n%s", result.stdout.strip())
|
|
409
|
+
cfg_loader.load()
|
|
410
|
+
return changed
|
|
411
|
+
except Exception as e:
|
|
412
|
+
logger.warning("Config repo poll error: %s", e)
|
|
413
|
+
return False
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
async def _config_poll_loop(cfg_loader: ConfigLoader):
|
|
417
|
+
"""Background task: poll config repo for changes every config_poll_interval seconds."""
|
|
418
|
+
while True:
|
|
419
|
+
interval = cfg_loader.sentinel.config_poll_interval
|
|
420
|
+
await asyncio.sleep(interval)
|
|
421
|
+
try:
|
|
422
|
+
_poll_config_repo(cfg_loader)
|
|
423
|
+
except Exception as e:
|
|
424
|
+
logger.warning("Config poll loop error: %s", e)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# ── Auto-upgrade ─────────────────────────────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
def _find_sentinel_code_dir() -> Path | None:
|
|
430
|
+
"""Locate the sentinel/code dir relative to the project working directory."""
|
|
431
|
+
# Project dir is cwd; code dir is at ../code or ../../code depending on layout
|
|
432
|
+
for candidate in [
|
|
433
|
+
Path("../code"),
|
|
434
|
+
Path("../../code"),
|
|
435
|
+
Path.home() / "sentinel" / "code",
|
|
436
|
+
]:
|
|
437
|
+
if candidate.exists() and (candidate / "sentinel").exists():
|
|
438
|
+
return candidate.resolve()
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _parse_version(v: str) -> tuple:
|
|
443
|
+
try:
|
|
444
|
+
return tuple(int(x) for x in v.strip().lstrip("v").split(".")[:3])
|
|
445
|
+
except Exception:
|
|
446
|
+
return (0, 0, 0)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _check_and_upgrade(cfg: SentinelConfig) -> bool:
|
|
450
|
+
"""
|
|
451
|
+
Check npm registry for a newer patch/minor version of @misterhuydo/sentinel.
|
|
452
|
+
If found (and not blocked by VERSION_PIN), install it, copy Python source to
|
|
453
|
+
the code dir, then restart via os.execv.
|
|
454
|
+
Returns True if an upgrade was initiated (process will restart).
|
|
455
|
+
"""
|
|
456
|
+
try:
|
|
457
|
+
result = subprocess.run(
|
|
458
|
+
["npm", "show", "@misterhuydo/sentinel", "version"],
|
|
459
|
+
capture_output=True, text=True, timeout=30,
|
|
460
|
+
)
|
|
461
|
+
if result.returncode != 0:
|
|
462
|
+
logger.warning("npm show failed: %s", result.stderr.strip())
|
|
463
|
+
return False
|
|
464
|
+
latest = result.stdout.strip()
|
|
465
|
+
except Exception as e:
|
|
466
|
+
logger.warning("Upgrade check failed: %s", e)
|
|
467
|
+
return False
|
|
468
|
+
|
|
469
|
+
# Determine current version from package installed globally
|
|
470
|
+
try:
|
|
471
|
+
cur_result = subprocess.run(
|
|
472
|
+
["npm", "list", "-g", "--depth=0", "--json"],
|
|
473
|
+
capture_output=True, text=True, timeout=30,
|
|
474
|
+
)
|
|
475
|
+
pkg_data = json.loads(cur_result.stdout or "{}")
|
|
476
|
+
deps = pkg_data.get("dependencies", {})
|
|
477
|
+
current = deps.get("@misterhuydo/sentinel", {}).get("version", "0.0.0")
|
|
478
|
+
except Exception:
|
|
479
|
+
current = "0.0.0"
|
|
480
|
+
|
|
481
|
+
current_t = _parse_version(current)
|
|
482
|
+
latest_t = _parse_version(latest)
|
|
483
|
+
|
|
484
|
+
if cfg.version_pin:
|
|
485
|
+
pin_t = _parse_version(cfg.version_pin)
|
|
486
|
+
if latest_t > pin_t:
|
|
487
|
+
logger.info("Upgrade available (%s → %s) but pinned to %s — skipping",
|
|
488
|
+
current, latest, cfg.version_pin)
|
|
489
|
+
return False
|
|
490
|
+
|
|
491
|
+
if latest_t <= current_t:
|
|
492
|
+
logger.debug("No upgrade available (current=%s, latest=%s)", current, latest)
|
|
493
|
+
return False
|
|
494
|
+
|
|
495
|
+
logger.info("Upgrading @misterhuydo/sentinel %s → %s", current, latest)
|
|
496
|
+
install = subprocess.run(
|
|
497
|
+
["npm", "install", "-g", f"@misterhuydo/sentinel@{latest}"],
|
|
498
|
+
capture_output=True, text=True, timeout=120,
|
|
499
|
+
)
|
|
500
|
+
if install.returncode != 0:
|
|
501
|
+
logger.error("npm install failed: %s", install.stderr.strip())
|
|
502
|
+
return False
|
|
503
|
+
|
|
504
|
+
# Copy updated Python source from newly installed npm package to code dir
|
|
505
|
+
code_dir = _find_sentinel_code_dir()
|
|
506
|
+
if code_dir:
|
|
507
|
+
try:
|
|
508
|
+
npm_prefix_result = subprocess.run(
|
|
509
|
+
["npm", "root", "-g"], capture_output=True, text=True, timeout=10,
|
|
510
|
+
)
|
|
511
|
+
npm_root = npm_prefix_result.stdout.strip()
|
|
512
|
+
new_src = Path(npm_root) / "@misterhuydo" / "sentinel" / "sentinel"
|
|
513
|
+
if new_src.exists():
|
|
514
|
+
dst = code_dir / "sentinel"
|
|
515
|
+
shutil.copytree(str(new_src), str(dst), dirs_exist_ok=True)
|
|
516
|
+
logger.info("Python source updated at %s", dst)
|
|
517
|
+
except Exception as e:
|
|
518
|
+
logger.warning("Failed to copy updated Python source: %s", e)
|
|
519
|
+
|
|
520
|
+
logger.info("Upgrade complete — restarting Sentinel")
|
|
521
|
+
try:
|
|
522
|
+
from .reporter import send_upgrade_notification
|
|
523
|
+
send_upgrade_notification(cfg, current, latest)
|
|
524
|
+
except Exception:
|
|
525
|
+
pass
|
|
526
|
+
|
|
527
|
+
os.execv(sys.executable, [sys.executable] + sys.argv)
|
|
528
|
+
return True # unreachable after execv
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
async def _upgrade_check_loop(cfg_loader: ConfigLoader):
|
|
532
|
+
"""Background task: check for upgrades every upgrade_check_hours hours."""
|
|
533
|
+
# Wait one full interval before first check so startup isn't slowed
|
|
534
|
+
await asyncio.sleep(cfg_loader.sentinel.upgrade_check_hours * 3600)
|
|
535
|
+
while True:
|
|
536
|
+
try:
|
|
537
|
+
_check_and_upgrade(cfg_loader.sentinel)
|
|
538
|
+
except Exception as e:
|
|
539
|
+
logger.warning("Upgrade check loop error: %s", e)
|
|
540
|
+
await asyncio.sleep(cfg_loader.sentinel.upgrade_check_hours * 3600)
|
|
541
|
+
|
|
542
|
+
|
|
386
543
|
# ── Entry point ──────────────────────────────────────────────────────────────────────────────────
|
|
387
544
|
|
|
388
545
|
async def run_loop(cfg_loader: ConfigLoader, store: StateStore):
|
|
@@ -403,6 +560,9 @@ async def run_loop(cfg_loader: ConfigLoader, store: StateStore):
|
|
|
403
560
|
logger.info("Startup checks passed — startup email in 5 minutes")
|
|
404
561
|
|
|
405
562
|
asyncio.ensure_future(_send_startup_email_delayed(cfg_loader.sentinel, results))
|
|
563
|
+
asyncio.ensure_future(_config_poll_loop(cfg_loader))
|
|
564
|
+
if cfg_loader.sentinel.auto_upgrade:
|
|
565
|
+
asyncio.ensure_future(_upgrade_check_loop(cfg_loader))
|
|
406
566
|
|
|
407
567
|
while True:
|
|
408
568
|
try:
|
|
@@ -345,3 +345,32 @@ def send_startup_notification(cfg: SentinelConfig, results: dict):
|
|
|
345
345
|
subject = f'[Sentinel] {status_label} — {ts}'
|
|
346
346
|
_send_email(cfg, subject, html)
|
|
347
347
|
logger.info('Startup notification sent to %d recipient(s)', len(cfg.mails))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def send_upgrade_notification(cfg: SentinelConfig, old_version: str, new_version: str):
|
|
351
|
+
if not cfg.mails:
|
|
352
|
+
return
|
|
353
|
+
ts = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
|
|
354
|
+
subject = f'[Sentinel] Upgraded {old_version} → {new_version}'
|
|
355
|
+
html = (
|
|
356
|
+
'<!DOCTYPE html><html><head><meta charset="utf-8">'
|
|
357
|
+
'<style>'
|
|
358
|
+
'body{font-family:Arial,sans-serif;font-size:14px;color:#222}'
|
|
359
|
+
'h2{color:#1a73e8}'
|
|
360
|
+
'table{border-collapse:collapse;width:100%;margin-bottom:16px}'
|
|
361
|
+
'td{padding:5px 10px;border-bottom:1px solid #eee}'
|
|
362
|
+
'.label{font-weight:bold;width:160px}'
|
|
363
|
+
'.mono{font-family:monospace;font-size:12px}'
|
|
364
|
+
'</style></head><body>'
|
|
365
|
+
'<h2>Sentinel auto-upgraded and restarted</h2>'
|
|
366
|
+
f'<p>{ts}</p>'
|
|
367
|
+
'<table>'
|
|
368
|
+
f'<tr><td class="label">Previous version</td><td class="mono">{old_version}</td></tr>'
|
|
369
|
+
f'<tr><td class="label">New version</td><td class="mono">{new_version}</td></tr>'
|
|
370
|
+
'</table>'
|
|
371
|
+
'<p>Sentinel has restarted automatically with the new version. No action required.</p>'
|
|
372
|
+
'<hr><small>Sentinel — Autonomous DevOps Agent</small>'
|
|
373
|
+
'</body></html>'
|
|
374
|
+
)
|
|
375
|
+
_send_email(cfg, subject, html)
|
|
376
|
+
logger.info('Upgrade notification sent: %s → %s', old_version, new_version)
|
|
@@ -18,3 +18,11 @@ LOG_RETENTION_HOURS=48
|
|
|
18
18
|
|
|
19
19
|
# Claude Code binary path
|
|
20
20
|
CLAUDE_CODE_BIN=claude
|
|
21
|
+
|
|
22
|
+
# Auto-upgrade: check npm for a newer @misterhuydo/sentinel every N hours and restart
|
|
23
|
+
AUTO_UPGRADE=true
|
|
24
|
+
UPGRADE_CHECK_HOURS=6
|
|
25
|
+
# VERSION_PIN=1.0.12 # uncomment to prevent upgrades past this version
|
|
26
|
+
|
|
27
|
+
# Config repo polling: if the project dir is a git repo, pull for config changes every N seconds
|
|
28
|
+
CONFIG_POLL_INTERVAL=60
|