[READ-ONLY] Mirror of https://github.com/just-cameron/central. Autonomous AI building collective intelligence on ATProtocol. The central node of the comind network. central.comind.network/
0

Configure Feed

Select the types of activity you want to include in your feed.

central / tools / healthcheck.py
9.0 kB 277 lines
1#!/usr/bin/env python3 2""" 3Central Health Check 4 5Monitors automation health and reports issues. 6Run via cron or manually to check system status. 7 8Usage: 9 uv run python -m tools.healthcheck 10 uv run python -m tools.healthcheck --alert # Post alert if issues found 11""" 12 13import argparse 14import json 15import os 16import subprocess 17from datetime import datetime, timezone, timedelta 18from pathlib import Path 19 20LOG_DIR = Path(__file__).parent.parent / "logs" 21DRAFTS_DIR = Path(__file__).parent.parent / "drafts" 22ALERT_STATE_FILE = LOG_DIR / "healthcheck_state.json" 23ALERT_COOLDOWN_HOURS = 6 # Don't spam alerts 24 25 26def check_log_errors(log_file: Path, hours: int = 24) -> list[str]: 27 """Check log file for errors in the last N hours.""" 28 if not log_file.exists(): 29 return [f"{log_file.name}: File not found"] 30 31 errors = [] 32 cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) 33 34 content = log_file.read_text() 35 lines = content.strip().split("\n") if content.strip() else [] 36 37 # Check for common error patterns 38 error_patterns = ["error", "Error", "ERROR", "failed", "Failed", "FAILED", "not found"] 39 40 recent_errors = 0 41 for line in lines[-100:]: # Check last 100 lines 42 if any(p in line for p in error_patterns): 43 recent_errors += 1 44 45 if recent_errors > 5: 46 errors.append(f"{log_file.name}: {recent_errors} errors in recent logs") 47 48 return errors 49 50 51def check_queue_depth() -> dict: 52 """Check pending items in draft queues.""" 53 queues = { 54 "bluesky": len(list(DRAFTS_DIR.glob("bluesky/*.txt"))), 55 "x": len(list(DRAFTS_DIR.glob("x/*.txt"))), 56 "review": len(list(DRAFTS_DIR.glob("review/*.txt"))), 57 } 58 return queues 59 60 61def check_last_publish() -> tuple[datetime | None, int]: 62 """Check when we last published and how many in last 24h.""" 63 published_dir = DRAFTS_DIR / "published" 64 if not published_dir.exists(): 65 return None, 0 66 67 files = list(published_dir.glob("*.txt")) 68 if not files: 69 return None, 0 70 71 # Get most recent by filename (timestamp prefix) 72 files.sort(key=lambda f: f.name, reverse=True) 73 74 # Count last 24h 75 cutoff = datetime.now(timezone.utc) - timedelta(hours=24) 76 recent_count = 0 77 latest_time = None 78 79 for f in files: 80 try: 81 # Filename format: 1770098232238-platform-... 82 ts = int(f.name.split("-")[0]) 83 file_time = datetime.fromtimestamp(ts / 1000, tz=timezone.utc) 84 if latest_time is None: 85 latest_time = file_time 86 if file_time > cutoff: 87 recent_count += 1 88 except (ValueError, IndexError): 89 continue 90 91 return latest_time, recent_count 92 93 94def check_responder_quality(hours: int = 24) -> dict: 95 """Quick responder quality check using the audit tool.""" 96 try: 97 from tools.responder_audit import parse_logs, check_quality 98 events = parse_logs(hours) 99 responded = [e for e in events if e.response != "[SKIPPED]"] 100 flagged = [e for e in responded if e.problems] 101 return { 102 "total": len(responded), 103 "flagged": len(flagged), 104 "skipped": len(events) - len(responded), 105 } 106 except Exception: 107 return {"total": 0, "flagged": 0, "skipped": 0} 108 109 110def check_xrpc_indexer() -> bool: 111 """Check if XRPC indexer API is healthy.""" 112 import urllib.request 113 import urllib.error 114 115 try: 116 req = urllib.request.Request( 117 "https://comind-indexer.fly.dev/health", 118 headers={"User-Agent": "central-healthcheck"} 119 ) 120 with urllib.request.urlopen(req, timeout=10) as resp: 121 return resp.status == 200 122 except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError): 123 return False 124 125 126def check_cron_running() -> bool: 127 """Check if cron jobs are configured.""" 128 try: 129 result = subprocess.run(["crontab", "-l"], capture_output=True, text=True) 130 return "central" in result.stdout.lower() or "responder" in result.stdout 131 except: 132 return False 133 134 135def run_healthcheck() -> dict: 136 """Run all health checks and return status.""" 137 status = { 138 "timestamp": datetime.now(timezone.utc).isoformat(), 139 "healthy": True, 140 "issues": [], 141 "metrics": {}, 142 } 143 144 # Check logs for errors 145 for log_name in ["responder.log", "handler.log", "publisher.log", "x-responder.log", "x-handler.log"]: 146 log_path = LOG_DIR / log_name 147 errors = check_log_errors(log_path) 148 status["issues"].extend(errors) 149 150 # Check queue depths 151 queues = check_queue_depth() 152 status["metrics"]["queues"] = queues 153 154 # Alert if review queue is large 155 if queues["review"] > 10: 156 status["issues"].append(f"Review queue backlog: {queues['review']} items") 157 158 # Check last publish 159 last_publish, recent_count = check_last_publish() 160 status["metrics"]["last_publish"] = last_publish.isoformat() if last_publish else None 161 status["metrics"]["published_24h"] = recent_count 162 163 # Alert if no publishes in 24h (and cron is running) 164 if check_cron_running() and recent_count == 0 and last_publish: 165 hours_ago = (datetime.now(timezone.utc) - last_publish).total_seconds() / 3600 166 if hours_ago > 24: 167 status["issues"].append(f"No posts in {int(hours_ago)} hours") 168 169 # Check cron 170 status["metrics"]["cron_configured"] = check_cron_running() 171 if not check_cron_running(): 172 status["issues"].append("Cron jobs not configured") 173 174 # Check XRPC indexer 175 xrpc_healthy = check_xrpc_indexer() 176 status["metrics"]["xrpc_indexer"] = "healthy" if xrpc_healthy else "down" 177 if not xrpc_healthy: 178 status["issues"].append("XRPC indexer API is down (502/unreachable)") 179 180 # Check responder output quality 181 responder_quality = check_responder_quality() 182 status["metrics"]["responder"] = responder_quality 183 if responder_quality.get("flagged", 0) > 0: 184 total = responder_quality.get("total", 0) 185 flagged = responder_quality["flagged"] 186 if total > 0 and flagged / total > 0.5: 187 status["issues"].append( 188 f"Responder quality: {flagged}/{total} responses flagged" 189 ) 190 191 # Set overall health 192 status["healthy"] = len(status["issues"]) == 0 193 194 return status 195 196 197def should_alert() -> bool: 198 """Check if enough time has passed since last alert.""" 199 if not ALERT_STATE_FILE.exists(): 200 return True 201 202 try: 203 with open(ALERT_STATE_FILE) as f: 204 state = json.load(f) 205 last_alert = datetime.fromisoformat(state.get("last_alert", "1970-01-01T00:00:00+00:00")) 206 hours_since = (datetime.now(timezone.utc) - last_alert).total_seconds() / 3600 207 return hours_since >= ALERT_COOLDOWN_HOURS 208 except: 209 return True 210 211 212def record_alert(): 213 """Record that an alert was sent.""" 214 ALERT_STATE_FILE.parent.mkdir(exist_ok=True) 215 with open(ALERT_STATE_FILE, "w") as f: 216 json.dump({"last_alert": datetime.now(timezone.utc).isoformat()}, f) 217 218 219def post_alert(issues: list[str]): 220 """Write alert draft for publishing.""" 221 draft_path = DRAFTS_DIR / "bluesky" / f"alert-{int(datetime.now().timestamp())}.txt" 222 draft_path.parent.mkdir(exist_ok=True) 223 224 issues_text = " | ".join(issues[:3]) # Truncate for post length 225 content = f"⚠️ Health check alert: {issues_text}" 226 227 draft = f"""--- 228platform: bluesky 229type: post 230priority: HIGH 231drafted_at: {datetime.now(timezone.utc).isoformat()} 232--- 233{content} 234""" 235 236 with open(draft_path, "w") as f: 237 f.write(draft) 238 239 print(f"Alert draft written: {draft_path}") 240 record_alert() 241 242 243def main(): 244 parser = argparse.ArgumentParser(description="Central Health Check") 245 parser.add_argument("--alert", action="store_true", help="Post alert if issues found") 246 parser.add_argument("--json", action="store_true", help="Output as JSON") 247 args = parser.parse_args() 248 249 status = run_healthcheck() 250 251 if args.json: 252 print(json.dumps(status, indent=2)) 253 return 254 255 # Pretty print 256 if status["healthy"]: 257 print("✓ All systems healthy") 258 else: 259 print("⚠ Issues detected:") 260 for issue in status["issues"]: 261 print(f" - {issue}") 262 263 print(f"\nMetrics:") 264 print(f" Queues: {status['metrics']['queues']}") 265 print(f" Published (24h): {status['metrics']['published_24h']}") 266 print(f" Last publish: {status['metrics']['last_publish']}") 267 print(f" Cron: {'configured' if status['metrics']['cron_configured'] else 'NOT configured'}") 268 269 if args.alert and not status["healthy"]: 270 if should_alert(): 271 post_alert(status["issues"]) 272 else: 273 print(f"\n[Alert skipped: cooldown period ({ALERT_COOLDOWN_HOURS}h)]") 274 275 276if __name__ == "__main__": 277 main()