souleyez 2.26.0__py3-none-any.whl → 2.28.0__py3-none-any.whl

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.
@@ -10,6 +10,95 @@ from .job_status import STATUS_DONE, STATUS_NO_RESULTS, STATUS_WARNING, STATUS_E
10
10
  logger = logging.getLogger(__name__)
11
11
 
12
12
 
13
+ # Common error patterns that indicate tool failure (not "no results")
14
+ TOOL_ERROR_PATTERNS = {
15
+ 'common': [
16
+ 'connection refused',
17
+ 'connection timed out',
18
+ 'no route to host',
19
+ 'network is unreachable',
20
+ 'name or service not known',
21
+ 'temporary failure in name resolution',
22
+ 'host is down',
23
+ 'connection reset by peer',
24
+ ],
25
+ 'nmap': [
26
+ 'host seems down',
27
+ 'note: host seems down',
28
+ 'failed to resolve',
29
+ ],
30
+ 'gobuster': [
31
+ 'timeout occurred during the request',
32
+ 'error on running gobuster',
33
+ 'unable to connect',
34
+ 'context deadline exceeded',
35
+ ],
36
+ 'hydra': [
37
+ 'can not connect',
38
+ 'could not connect',
39
+ 'error connecting',
40
+ 'target does not support',
41
+ ],
42
+ 'nikto': [
43
+ 'error connecting to host',
44
+ 'unable to connect',
45
+ 'no web server found',
46
+ ],
47
+ 'nuclei': [
48
+ 'could not connect',
49
+ 'context deadline exceeded',
50
+ 'no address found',
51
+ ],
52
+ 'ffuf': [
53
+ 'error making request',
54
+ 'context deadline exceeded',
55
+ ],
56
+ 'sqlmap': [
57
+ 'connection timed out',
58
+ 'unable to connect',
59
+ 'target url content is not stable',
60
+ ],
61
+ 'enum4linux': [
62
+ 'could not initialise',
63
+ 'nt_status_connection_refused',
64
+ 'nt_status_host_unreachable',
65
+ 'nt_status_io_timeout',
66
+ ],
67
+ 'smbmap': [
68
+ 'could not connect',
69
+ 'connection error',
70
+ 'nt_status_connection_refused',
71
+ ],
72
+ }
73
+
74
+
75
+ def detect_tool_error(log_content: str, tool: str) -> Optional[str]:
76
+ """
77
+ Check log content for tool errors that indicate failure (not just "no results").
78
+
79
+ Args:
80
+ log_content: The log file content
81
+ tool: Tool name (lowercase)
82
+
83
+ Returns:
84
+ Error pattern found, or None if no error detected
85
+ """
86
+ log_lower = log_content.lower()
87
+
88
+ # Check common patterns
89
+ for pattern in TOOL_ERROR_PATTERNS['common']:
90
+ if pattern in log_lower:
91
+ return pattern
92
+
93
+ # Check tool-specific patterns
94
+ tool_patterns = TOOL_ERROR_PATTERNS.get(tool, [])
95
+ for pattern in tool_patterns:
96
+ if pattern in log_lower:
97
+ return pattern
98
+
99
+ return None
100
+
101
+
13
102
  def handle_job_result(job: Dict[str, Any]) -> Optional[Dict[str, Any]]:
14
103
  """
15
104
  Process completed job and parse results into database.
@@ -44,6 +133,7 @@ def handle_job_result(job: Dict[str, Any]) -> Optional[Dict[str, Any]]:
44
133
  return None
45
134
 
46
135
  if not log_path or not os.path.exists(log_path):
136
+ logger.error(f"Job {job.get('id')} parse failed: log file missing or does not exist (path={log_path})")
47
137
  return None
48
138
 
49
139
  # Get engagement ID from job or fall back to current engagement
@@ -56,10 +146,12 @@ def handle_job_result(job: Dict[str, Any]) -> Optional[Dict[str, Any]]:
56
146
  engagement = em.get_current()
57
147
 
58
148
  if not engagement:
149
+ logger.error(f"Job {job.get('id')} parse failed: no engagement_id and no current engagement")
59
150
  return None
60
151
 
61
152
  engagement_id = engagement['id']
62
- except Exception:
153
+ except Exception as e:
154
+ logger.error(f"Job {job.get('id')} parse failed: engagement lookup error: {e}")
63
155
  return None
64
156
 
65
157
  # Route to appropriate parser
@@ -110,6 +202,13 @@ def handle_job_result(job: Dict[str, Any]) -> Optional[Dict[str, Any]]:
110
202
  parse_result = parse_dalfox_job(engagement_id, log_path, job)
111
203
  elif tool == 'http_fingerprint':
112
204
  parse_result = parse_http_fingerprint_job(engagement_id, log_path, job)
205
+ elif tool == 'hashcat':
206
+ parse_result = parse_hashcat_job(engagement_id, log_path, job)
207
+ elif tool == 'john':
208
+ parse_result = parse_john_job(engagement_id, log_path, job)
209
+ else:
210
+ # No parser for this tool - log it so we know
211
+ logger.warning(f"Job {job.get('id')} has no parser for tool '{tool}' - results not extracted")
113
212
 
114
213
  # NOTE: Auto-chaining is now handled in background.py after parsing completes
115
214
  # This avoids duplicate job creation and gives better control over timing
@@ -517,9 +616,16 @@ def parse_nmap_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> Di
517
616
  'version': svc.get('version', '')
518
617
  })
519
618
 
619
+ # Check for nmap errors before determining status
620
+ with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
621
+ log_content = f.read()
622
+ nmap_error = detect_tool_error(log_content, 'nmap')
623
+
520
624
  # Determine status based on results
521
625
  hosts_up = len([h for h in parsed.get('hosts', []) if h.get('status') == 'up'])
522
- if hosts_up > 0:
626
+ if nmap_error:
627
+ status = STATUS_ERROR # Tool failed to run properly
628
+ elif hosts_up > 0:
523
629
  status = STATUS_DONE # Found hosts
524
630
  else:
525
631
  status = STATUS_NO_RESULTS # No hosts up
@@ -1112,8 +1218,13 @@ def parse_gobuster_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -
1112
1218
  exclude_length = length_match.group(1)
1113
1219
  logger.info(f"Gobuster wildcard detected: Length {exclude_length}b")
1114
1220
 
1221
+ # Check for gobuster errors
1222
+ gobuster_error = detect_tool_error(log_content, 'gobuster')
1223
+
1115
1224
  # Determine status based on results
1116
- if wildcard_detected:
1225
+ if gobuster_error:
1226
+ status = STATUS_ERROR # Tool failed to connect
1227
+ elif wildcard_detected:
1117
1228
  # Wildcard detected - warning status (triggers auto-retry)
1118
1229
  status = STATUS_WARNING
1119
1230
  elif stats['total'] > 0:
@@ -1494,8 +1605,13 @@ def parse_sqlmap_job(engagement_id: int, log_path: str, job: Dict[str, Any]) ->
1494
1605
 
1495
1606
  stats = get_sqli_stats(parsed)
1496
1607
 
1608
+ # Check for sqlmap errors
1609
+ sqlmap_error = detect_tool_error(log_content, 'sqlmap')
1610
+
1497
1611
  # Determine status based on results
1498
- if stats['sqli_confirmed'] or stats['xss_possible'] or stats['fi_possible']:
1612
+ if sqlmap_error:
1613
+ status = STATUS_ERROR # Tool failed to connect
1614
+ elif stats['sqli_confirmed'] or stats['xss_possible'] or stats['fi_possible']:
1499
1615
  status = STATUS_DONE # Found injection vulnerabilities
1500
1616
  else:
1501
1617
  status = STATUS_NO_RESULTS # No injections found
@@ -2001,11 +2117,22 @@ def parse_smbmap_job(engagement_id: int, log_path: str, job: Dict[str, Any]) ->
2001
2117
  )
2002
2118
  findings_added += 1
2003
2119
 
2120
+ # Check for smbmap errors
2121
+ smbmap_error = detect_tool_error(log_content, 'smbmap')
2122
+
2123
+ # Determine status
2124
+ if smbmap_error:
2125
+ status = STATUS_ERROR # Tool failed to connect
2126
+ elif shares_added > 0 or findings_added > 0:
2127
+ status = STATUS_DONE
2128
+ else:
2129
+ status = STATUS_NO_RESULTS
2130
+
2004
2131
  return {
2005
2132
  'tool': 'smbmap',
2006
2133
  'host': parsed['target'],
2007
2134
  'connection_status': parsed.get('status', 'Unknown'), # SMB connection status
2008
- 'status': STATUS_DONE if (shares_added > 0 or findings_added > 0) else STATUS_NO_RESULTS, # Job status
2135
+ 'status': status, # Job status
2009
2136
  'shares_added': shares_added,
2010
2137
  'files_added': files_added,
2011
2138
  'findings_added': findings_added
@@ -2372,8 +2499,13 @@ def parse_hydra_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> D
2372
2499
  )
2373
2500
  findings_added += 1
2374
2501
 
2502
+ # Check for hydra errors
2503
+ hydra_error = detect_tool_error(log_content, 'hydra')
2504
+
2375
2505
  # Determine status based on results
2376
- if len(parsed.get('credentials', [])) > 0:
2506
+ if hydra_error:
2507
+ status = STATUS_ERROR # Tool failed to connect
2508
+ elif len(parsed.get('credentials', [])) > 0:
2377
2509
  status = STATUS_DONE # Found valid credentials
2378
2510
  elif len(parsed.get('usernames', [])) > 0:
2379
2511
  status = STATUS_DONE # Found valid usernames (partial success is still a result)
@@ -2485,8 +2617,15 @@ def parse_nuclei_job(engagement_id: int, log_path: str, job: Dict[str, Any]) ->
2485
2617
  )
2486
2618
  findings_added += 1
2487
2619
 
2620
+ # Check for nuclei errors
2621
+ with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
2622
+ log_content = f.read()
2623
+ nuclei_error = detect_tool_error(log_content, 'nuclei')
2624
+
2488
2625
  # Determine status based on results
2489
- if parsed.get('findings_count', 0) > 0:
2626
+ if nuclei_error:
2627
+ status = STATUS_ERROR # Tool failed to connect
2628
+ elif parsed.get('findings_count', 0) > 0:
2490
2629
  status = STATUS_DONE # Found vulnerabilities
2491
2630
  else:
2492
2631
  status = STATUS_NO_RESULTS # No vulnerabilities found
@@ -2608,6 +2747,9 @@ def parse_enum4linux_job(engagement_id: int, log_path: str, job: Dict[str, Any])
2608
2747
  'ip': parsed['target']
2609
2748
  })
2610
2749
 
2750
+ # Check for enum4linux errors
2751
+ enum4linux_error = detect_tool_error(log_content, 'enum4linux')
2752
+
2611
2753
  # Determine status: done if we found any results (shares, users, or findings)
2612
2754
  has_results = (
2613
2755
  findings_added > 0 or
@@ -2616,9 +2758,16 @@ def parse_enum4linux_job(engagement_id: int, log_path: str, job: Dict[str, Any])
2616
2758
  stats['total_shares'] > 0
2617
2759
  )
2618
2760
 
2761
+ if enum4linux_error:
2762
+ status = STATUS_ERROR # Tool failed to connect
2763
+ elif has_results:
2764
+ status = STATUS_DONE
2765
+ else:
2766
+ status = STATUS_NO_RESULTS
2767
+
2619
2768
  return {
2620
2769
  'tool': 'enum4linux',
2621
- 'status': STATUS_DONE if has_results else STATUS_NO_RESULTS,
2770
+ 'status': status,
2622
2771
  'findings_added': findings_added,
2623
2772
  'credentials_added': credentials_added,
2624
2773
  'users_found': len(parsed['users']),
@@ -2725,13 +2874,26 @@ def parse_ffuf_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> Di
2725
2874
 
2726
2875
  if host_id and parsed.get('paths'):
2727
2876
  paths_added = wpm.bulk_add_web_paths(host_id, parsed['paths'])
2728
-
2877
+
2729
2878
  # Check for sensitive paths and create findings (same as gobuster)
2730
2879
  created_findings = _create_findings_for_sensitive_paths(engagement_id, host_id, parsed['paths'], job)
2731
2880
 
2881
+ # Check for ffuf errors
2882
+ with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
2883
+ log_content = f.read()
2884
+ ffuf_error = detect_tool_error(log_content, 'ffuf')
2885
+
2886
+ # Determine status
2887
+ if ffuf_error:
2888
+ status = STATUS_ERROR # Tool failed to connect
2889
+ elif parsed.get('results_found', 0) > 0:
2890
+ status = STATUS_DONE
2891
+ else:
2892
+ status = STATUS_NO_RESULTS
2893
+
2732
2894
  return {
2733
2895
  'tool': 'ffuf',
2734
- 'status': STATUS_DONE if parsed.get('results_found', 0) > 0 else STATUS_NO_RESULTS,
2896
+ 'status': status,
2735
2897
  'target': target,
2736
2898
  'results_found': parsed.get('results_found', 0),
2737
2899
  'paths_added': paths_added,
@@ -3050,8 +3212,13 @@ def parse_nikto_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> D
3050
3212
  )
3051
3213
  findings_added += 1
3052
3214
 
3215
+ # Check for nikto errors
3216
+ nikto_error = detect_tool_error(output, 'nikto')
3217
+
3053
3218
  # Determine status based on results
3054
- if findings_added > 0:
3219
+ if nikto_error:
3220
+ status = STATUS_ERROR # Tool failed to connect
3221
+ elif findings_added > 0:
3055
3222
  status = STATUS_DONE
3056
3223
  else:
3057
3224
  status = STATUS_NO_RESULTS
@@ -3245,3 +3412,165 @@ def parse_dalfox_job(engagement_id: int, log_path: str, job: Dict[str, Any]) ->
3245
3412
  except Exception as e:
3246
3413
  logger.error(f"Error parsing dalfox job: {e}")
3247
3414
  return {'error': str(e)}
3415
+
3416
+
3417
+ def parse_hashcat_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> Dict[str, Any]:
3418
+ """Parse hashcat job results and extract cracked passwords."""
3419
+ try:
3420
+ from souleyez.parsers.hashcat_parser import parse_hashcat_output, map_to_credentials
3421
+ from souleyez.storage.credentials import CredentialsManager
3422
+ from souleyez.storage.findings import FindingsManager
3423
+
3424
+ # Read the log file
3425
+ with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
3426
+ log_content = f.read()
3427
+
3428
+ # Parse hashcat output
3429
+ hash_file = job.get('metadata', {}).get('hash_file', '')
3430
+ parsed = parse_hashcat_output(log_content, hash_file)
3431
+
3432
+ # Store credentials
3433
+ cm = CredentialsManager()
3434
+ creds_added = 0
3435
+
3436
+ for cracked in parsed.get('cracked', []):
3437
+ try:
3438
+ cm.add_credential(
3439
+ engagement_id=engagement_id,
3440
+ host_id=None, # Hash cracking typically not tied to a specific host
3441
+ username='', # Hashcat doesn't always know the username
3442
+ password=cracked['password'],
3443
+ service='cracked_hash',
3444
+ credential_type='password',
3445
+ tool='hashcat',
3446
+ status='cracked',
3447
+ notes=f"Cracked from hash: {cracked['hash'][:32]}..."
3448
+ )
3449
+ creds_added += 1
3450
+ except Exception:
3451
+ pass # Skip duplicates
3452
+
3453
+ # Create finding if we cracked passwords
3454
+ fm = FindingsManager()
3455
+ findings_added = 0
3456
+
3457
+ if parsed.get('cracked'):
3458
+ fm.add_finding(
3459
+ engagement_id=engagement_id,
3460
+ title=f"Password Hashes Cracked - {len(parsed['cracked'])} passwords recovered",
3461
+ finding_type='credential',
3462
+ severity='high',
3463
+ description=f"Hashcat successfully cracked {len(parsed['cracked'])} password hash(es).\n\n"
3464
+ f"Status: {parsed['stats'].get('status', 'unknown')}\n"
3465
+ f"Cracked: {parsed['stats'].get('cracked_count', len(parsed['cracked']))}",
3466
+ tool='hashcat'
3467
+ )
3468
+ findings_added += 1
3469
+
3470
+ # Determine status
3471
+ if creds_added > 0:
3472
+ status = STATUS_DONE
3473
+ elif parsed['stats'].get('status') == 'exhausted':
3474
+ status = STATUS_NO_RESULTS # Ran to completion but found nothing
3475
+ else:
3476
+ status = STATUS_NO_RESULTS
3477
+
3478
+ return {
3479
+ 'tool': 'hashcat',
3480
+ 'status': status,
3481
+ 'cracked_count': len(parsed.get('cracked', [])),
3482
+ 'credentials_added': creds_added,
3483
+ 'findings_added': findings_added,
3484
+ 'hashcat_status': parsed['stats'].get('status', 'unknown')
3485
+ }
3486
+
3487
+ except Exception as e:
3488
+ logger.error(f"Error parsing hashcat job: {e}")
3489
+ return {'error': str(e)}
3490
+
3491
+
3492
+ def parse_john_job(engagement_id: int, log_path: str, job: Dict[str, Any]) -> Dict[str, Any]:
3493
+ """Parse John the Ripper job results and extract cracked passwords."""
3494
+ try:
3495
+ from souleyez.parsers.john_parser import parse_john_output
3496
+ from souleyez.storage.credentials import CredentialsManager
3497
+ from souleyez.storage.findings import FindingsManager
3498
+
3499
+ # Read the log file
3500
+ with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
3501
+ log_content = f.read()
3502
+
3503
+ # Get hash file from job metadata if available
3504
+ hash_file = job.get('metadata', {}).get('hash_file', None)
3505
+
3506
+ # Parse john output
3507
+ parsed = parse_john_output(log_content, hash_file)
3508
+
3509
+ # Store credentials
3510
+ cm = CredentialsManager()
3511
+ creds_added = 0
3512
+
3513
+ for cred in parsed.get('cracked', []):
3514
+ username = cred.get('username', '')
3515
+ password = cred.get('password', '')
3516
+
3517
+ if password: # At minimum we need a password
3518
+ try:
3519
+ cm.add_credential(
3520
+ engagement_id=engagement_id,
3521
+ host_id=None, # Hash cracking typically not tied to a specific host
3522
+ username=username if username != 'unknown' else '',
3523
+ password=password,
3524
+ service='cracked_hash',
3525
+ credential_type='password',
3526
+ tool='john',
3527
+ status='cracked',
3528
+ notes=f"Cracked by John the Ripper"
3529
+ )
3530
+ creds_added += 1
3531
+ except Exception:
3532
+ pass # Skip duplicates
3533
+
3534
+ # Create finding if we cracked passwords
3535
+ fm = FindingsManager()
3536
+ findings_added = 0
3537
+
3538
+ if parsed.get('cracked'):
3539
+ usernames = [c.get('username', 'unknown') for c in parsed['cracked'] if c.get('username')]
3540
+ usernames_str = ', '.join(usernames[:10]) # First 10
3541
+ if len(usernames) > 10:
3542
+ usernames_str += f" (+{len(usernames) - 10} more)"
3543
+
3544
+ fm.add_finding(
3545
+ engagement_id=engagement_id,
3546
+ title=f"Password Hashes Cracked - {len(parsed['cracked'])} passwords recovered",
3547
+ finding_type='credential',
3548
+ severity='high',
3549
+ description=f"John the Ripper successfully cracked {len(parsed['cracked'])} password hash(es).\n\n"
3550
+ f"Usernames: {usernames_str}\n"
3551
+ f"Session status: {parsed.get('session_status', 'unknown')}",
3552
+ tool='john'
3553
+ )
3554
+ findings_added += 1
3555
+
3556
+ # Determine status
3557
+ if creds_added > 0:
3558
+ status = STATUS_DONE
3559
+ elif parsed.get('session_status') == 'completed':
3560
+ status = STATUS_NO_RESULTS # Ran to completion but found nothing
3561
+ else:
3562
+ status = STATUS_NO_RESULTS
3563
+
3564
+ return {
3565
+ 'tool': 'john',
3566
+ 'status': status,
3567
+ 'cracked_count': len(parsed.get('cracked', [])),
3568
+ 'credentials_added': creds_added,
3569
+ 'findings_added': findings_added,
3570
+ 'session_status': parsed.get('session_status', 'unknown'),
3571
+ 'total_loaded': parsed.get('total_loaded', 0)
3572
+ }
3573
+
3574
+ except Exception as e:
3575
+ logger.error(f"Error parsing john job: {e}")
3576
+ return {'error': str(e)}
@@ -4,12 +4,13 @@ Worker health check and management utilities
4
4
  """
5
5
  import psutil
6
6
  import time
7
- from typing import Optional, Tuple
7
+ from typing import Optional, Tuple, Dict, Any
8
8
 
9
9
 
10
10
  def is_worker_running() -> Tuple[bool, Optional[int]]:
11
11
  """
12
- Check if background worker is running
12
+ Check if background worker is running.
13
+
13
14
  Returns: (is_running, pid)
14
15
  """
15
16
  for proc in psutil.process_iter(['pid', 'cmdline']):
@@ -29,6 +30,40 @@ def is_worker_running() -> Tuple[bool, Optional[int]]:
29
30
  return False, None
30
31
 
31
32
 
33
+ def is_worker_healthy() -> Tuple[bool, Optional[int], Optional[str]]:
34
+ """
35
+ Check if background worker is running AND healthy (responding).
36
+
37
+ Uses heartbeat file to verify worker is actively processing.
38
+ A worker process may exist but be frozen/hung - heartbeat detects this.
39
+
40
+ Returns: (is_healthy, pid, issue)
41
+ - is_healthy: True if worker is running and heartbeat is fresh
42
+ - pid: Worker PID if found, None otherwise
43
+ - issue: Description of issue if not healthy, None otherwise
44
+ """
45
+ from souleyez.engine.background import (
46
+ get_heartbeat_age, HEARTBEAT_STALE_THRESHOLD
47
+ )
48
+
49
+ is_running, pid = is_worker_running()
50
+
51
+ if not is_running:
52
+ return False, None, "Worker process not found"
53
+
54
+ # Check heartbeat
55
+ heartbeat_age = get_heartbeat_age()
56
+
57
+ if heartbeat_age is None:
58
+ # No heartbeat file - worker may have just started
59
+ return True, pid, "No heartbeat yet (may be starting)"
60
+
61
+ if heartbeat_age > HEARTBEAT_STALE_THRESHOLD:
62
+ return False, pid, f"Heartbeat stale ({int(heartbeat_age)}s old, threshold: {HEARTBEAT_STALE_THRESHOLD}s)"
63
+
64
+ return True, pid, None
65
+
66
+
32
67
  def start_worker_if_needed() -> bool:
33
68
  """
34
69
  Start worker if not running
@@ -107,3 +142,64 @@ def get_worker_status() -> dict:
107
142
  pass
108
143
 
109
144
  return status
145
+
146
+
147
+ def get_worker_health() -> Dict[str, Any]:
148
+ """
149
+ Get detailed worker health status including heartbeat info.
150
+
151
+ Returns dict with:
152
+ - running: Whether worker process exists
153
+ - healthy: Whether worker is running AND responsive
154
+ - pid: Worker PID if running
155
+ - uptime: Seconds since worker started
156
+ - heartbeat_age: Seconds since last heartbeat
157
+ - heartbeat_stale: Whether heartbeat is stale
158
+ - issue: Description of any health issue
159
+ - cpu_percent: CPU usage percentage
160
+ - memory_mb: Memory usage in MB
161
+ """
162
+ from souleyez.engine.background import (
163
+ get_heartbeat_age, HEARTBEAT_STALE_THRESHOLD
164
+ )
165
+
166
+ is_running, pid = is_worker_running()
167
+ heartbeat_age = get_heartbeat_age()
168
+
169
+ health = {
170
+ 'running': is_running,
171
+ 'healthy': False,
172
+ 'pid': pid,
173
+ 'uptime': None,
174
+ 'heartbeat_age': heartbeat_age,
175
+ 'heartbeat_stale': heartbeat_age is None or heartbeat_age > HEARTBEAT_STALE_THRESHOLD,
176
+ 'issue': None,
177
+ 'cpu_percent': None,
178
+ 'memory_mb': None
179
+ }
180
+
181
+ if not is_running:
182
+ health['issue'] = "Worker process not found"
183
+ return health
184
+
185
+ # Get process info
186
+ try:
187
+ proc = psutil.Process(pid)
188
+ health['uptime'] = int(time.time() - proc.create_time())
189
+ health['cpu_percent'] = proc.cpu_percent(interval=0.1)
190
+ health['memory_mb'] = round(proc.memory_info().rss / 1024 / 1024, 1)
191
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
192
+ health['issue'] = "Cannot access worker process"
193
+ return health
194
+
195
+ # Check heartbeat
196
+ if heartbeat_age is None:
197
+ health['issue'] = "No heartbeat yet (worker may be starting)"
198
+ health['healthy'] = True # Give benefit of doubt for new workers
199
+ elif heartbeat_age > HEARTBEAT_STALE_THRESHOLD:
200
+ health['issue'] = f"Worker unresponsive (heartbeat {int(heartbeat_age)}s old)"
201
+ health['healthy'] = False
202
+ else:
203
+ health['healthy'] = True
204
+
205
+ return health