@misterhuydo/sentinel 1.0.11 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-03-21T16:59:06.845Z",
3
- "checkpoint_at": "2026-03-21T16:59:06.846Z",
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/lib/generate.js CHANGED
@@ -112,7 +112,7 @@ for project_dir in "$WORKSPACE"/*/; do
112
112
 
113
113
  # Must have at least one repo-config with a valid GitHub REPO_URL
114
114
  valid_repo=false
115
- for props in "$project_dir/config/repo-configs/"*.properties 2>/dev/null; do
115
+ for props in "$project_dir/config/repo-configs/"*.properties; do
116
116
  [[ -f "$props" ]] || continue
117
117
  if grep -qE "^REPO_URL[[:space:]]*=[[:space:]]*(git@github\.com:|https://github\.com/)" "$props"; then
118
118
  valid_repo=true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -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 # quiet period before confirming a fix
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):
@@ -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 &mdash; 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