kryten-robot 0.6.9__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.
@@ -0,0 +1,698 @@
1
+ """State Query Handler - Respond to NATS queries for channel state.
2
+
3
+ This module provides a NATS request/reply endpoint that returns the current
4
+ channel state (emotes, playlist, userlist) as JSON.
5
+
6
+ Follows the unified command pattern: kryten.robot.command
7
+ Commands are dispatched based on the 'command' field in the request.
8
+ """
9
+
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ from datetime import UTC, datetime
14
+
15
+ try:
16
+ import psutil
17
+ PSUTIL_AVAILABLE = True
18
+ except ImportError:
19
+ PSUTIL_AVAILABLE = False
20
+
21
+ from .application_state import ApplicationState
22
+ from .nats_client import NatsClient
23
+ from .state_manager import StateManager
24
+
25
+
26
+ class StateQueryHandler:
27
+ """Handle NATS queries for channel state via unified command pattern.
28
+
29
+ Subscribes to kryten.robot.command and responds to state queries.
30
+
31
+ Supported commands:
32
+ - state.emotes: Get emote list
33
+ - state.playlist: Get playlist
34
+ - state.userlist: Get user list
35
+ - state.all: Get all state (emotes, playlist, userlist)
36
+ - state.user: Get specific user info
37
+ - state.profiles: Get all user profiles
38
+ - system.health: Get service health status
39
+ - system.channels: Get list of connected channels
40
+ - system.version: Get Kryten-Robot version
41
+ - system.stats: Get comprehensive runtime statistics
42
+ - system.config: Get current configuration (passwords redacted)
43
+ - system.ping: Simple alive check
44
+
45
+ Attributes:
46
+ state_manager: StateManager instance to query.
47
+ nats_client: NATS client for subscriptions.
48
+ logger: Logger instance.
49
+ app_state: ApplicationState for runtime information.
50
+
51
+ Examples:
52
+ >>> handler = StateQueryHandler(state_manager, nats_client, logger, "cytu.be", "mychannel", app_state)
53
+ >>> await handler.start()
54
+ >>> # Send command to kryten.robot.command with {"service": "robot", "command": "state.emotes"}
55
+ >>> await handler.stop()
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ state_manager: StateManager,
61
+ nats_client: NatsClient,
62
+ logger: logging.Logger,
63
+ domain: str,
64
+ channel: str,
65
+ app_state: ApplicationState | None = None,
66
+ ):
67
+ """Initialize state query handler.
68
+
69
+ Args:
70
+ state_manager: StateManager instance.
71
+ nats_client: NATS client for subscriptions.
72
+ logger: Logger for structured output.
73
+ domain: CyTube domain name.
74
+ channel: CyTube channel name.
75
+ app_state: ApplicationState for system management features.
76
+ """
77
+ self._state_manager = state_manager
78
+ self._nats = nats_client
79
+ self._logger = logger
80
+ self._domain = domain
81
+ self._channel = channel
82
+ self._app_state = app_state
83
+ self._running = False
84
+ self._subscription = None
85
+
86
+ # Metrics
87
+ self._queries_processed = 0
88
+ self._queries_failed = 0
89
+
90
+ @property
91
+ def stats(self) -> dict:
92
+ """Get query processing statistics."""
93
+ return {
94
+ "queries_processed": self._queries_processed,
95
+ "queries_failed": self._queries_failed,
96
+ }
97
+
98
+ @property
99
+ def is_running(self) -> bool:
100
+ """Check if handler is running."""
101
+ return self._running
102
+
103
+ async def start(self) -> None:
104
+ """Start listening for commands on unified subject."""
105
+ if self._running:
106
+ self._logger.warning("State query handler already running")
107
+ return
108
+
109
+ self._running = True
110
+
111
+ # Subscribe to unified command subject using request-reply pattern
112
+ subject = "kryten.robot.command"
113
+
114
+ try:
115
+ self._subscription = await self._nats.subscribe_request_reply(
116
+ subject,
117
+ callback=self._handle_command_msg
118
+ )
119
+ self._logger.info(f"State query handler listening on: {subject}")
120
+
121
+ except Exception as e:
122
+ self._logger.error(f"Failed to subscribe to command subject: {e}", exc_info=True)
123
+ self._running = False
124
+ raise
125
+
126
+ async def stop(self) -> None:
127
+ """Stop listening for queries."""
128
+ if not self._running:
129
+ return
130
+
131
+ self._logger.info("Stopping state query handler")
132
+
133
+ if self._subscription:
134
+ try:
135
+ await self._subscription.unsubscribe()
136
+ except Exception as e:
137
+ self._logger.warning(f"Error unsubscribing: {e}")
138
+
139
+ self._subscription = None
140
+ self._running = False
141
+ self._logger.info("State query handler stopped")
142
+
143
+ async def _handle_command_msg(self, msg) -> None:
144
+ """Handle incoming command message (actual implementation).
145
+
146
+ Args:
147
+ msg: NATS message object with data and reply subject.
148
+ """
149
+ try:
150
+ # Parse request
151
+ request = {}
152
+ if msg.data:
153
+ try:
154
+ request = json.loads(msg.data.decode('utf-8'))
155
+ except json.JSONDecodeError as e:
156
+ raise ValueError(f"Invalid JSON: {e}") from e
157
+
158
+ command = request.get('command')
159
+ if not command:
160
+ raise ValueError("Missing 'command' field")
161
+
162
+ # Check service field for routing (other services can ignore)
163
+ service = request.get('service')
164
+ if service and service != 'robot':
165
+ # Not for us, ignore silently
166
+ return
167
+
168
+ # Dispatch to handler
169
+ handler_map = {
170
+ "state.emotes": self._handle_state_emotes,
171
+ "state.playlist": self._handle_state_playlist,
172
+ "state.userlist": self._handle_state_userlist,
173
+ "state.all": self._handle_state_all,
174
+ "state.user": self._handle_state_user,
175
+ "state.profiles": self._handle_state_profiles,
176
+ "system.health": self._handle_system_health,
177
+ "system.channels": self._handle_system_channels,
178
+ "system.version": self._handle_system_version,
179
+ "system.stats": self._handle_system_stats,
180
+ "system.config": self._handle_system_config,
181
+ "system.ping": self._handle_system_ping,
182
+ "system.shutdown": self._handle_system_shutdown,
183
+ "system.reload": self._handle_system_reload,
184
+ }
185
+
186
+ handler = handler_map.get(command)
187
+ if not handler:
188
+ raise ValueError(f"Unknown command: {command}")
189
+
190
+ # Execute handler
191
+ result = await handler(request)
192
+
193
+ # Build success response
194
+ response = {
195
+ "service": "robot",
196
+ "command": command,
197
+ "success": True,
198
+ "data": result
199
+ }
200
+
201
+ # Send response
202
+ if msg.reply:
203
+ response_bytes = json.dumps(response).encode('utf-8')
204
+ await self._nats.publish(msg.reply, response_bytes)
205
+ self._logger.debug(f"Sent response for command '{command}'")
206
+
207
+ self._queries_processed += 1
208
+
209
+ except Exception as e:
210
+ self._logger.error(f"Error handling command: {e}", exc_info=True)
211
+ self._queries_failed += 1
212
+
213
+ # Send error response if reply subject provided
214
+ if msg.reply:
215
+ try:
216
+ command = request.get('command', 'unknown')
217
+ error_response = {
218
+ "service": "robot",
219
+ "command": command,
220
+ "success": False,
221
+ "error": str(e)
222
+ }
223
+ response_bytes = json.dumps(error_response).encode('utf-8')
224
+ await self._nats.publish(msg.reply, response_bytes)
225
+ except Exception as reply_error:
226
+ self._logger.error(f"Failed to send error response: {reply_error}")
227
+
228
+ async def _handle_state_emotes(self, request: dict) -> dict:
229
+ """Get emote list."""
230
+ return {"emotes": self._state_manager.get_emotes()}
231
+
232
+ async def _handle_state_playlist(self, request: dict) -> dict:
233
+ """Get playlist."""
234
+ return {"playlist": self._state_manager.get_playlist()}
235
+
236
+ async def _handle_state_userlist(self, request: dict) -> dict:
237
+ """Get user list."""
238
+ return {"userlist": self._state_manager.get_userlist()}
239
+
240
+ async def _handle_state_all(self, request: dict) -> dict:
241
+ """Get all state (emotes, playlist, userlist)."""
242
+ return {
243
+ "emotes": self._state_manager.get_emotes(),
244
+ "playlist": self._state_manager.get_playlist(),
245
+ "userlist": self._state_manager.get_userlist(),
246
+ "stats": self._state_manager.stats
247
+ }
248
+
249
+ async def _handle_state_user(self, request: dict) -> dict:
250
+ """Get specific user info."""
251
+ username = request.get('username')
252
+ if not username:
253
+ raise ValueError("username required")
254
+
255
+ return {
256
+ "user": self._state_manager.get_user(username),
257
+ "profile": self._state_manager.get_user_profile(username)
258
+ }
259
+
260
+ async def _handle_state_profiles(self, request: dict) -> dict:
261
+ """Get all user profiles."""
262
+ return {"profiles": self._state_manager.get_all_profiles()}
263
+
264
+ async def _handle_system_health(self, request: dict) -> dict:
265
+ """Get service health status."""
266
+ return {
267
+ "service": "robot",
268
+ "status": "healthy" if self._running else "unhealthy",
269
+ "domain": self._domain,
270
+ "channel": self._channel,
271
+ "nats_connected": self._nats.is_connected,
272
+ "queries_processed": self._queries_processed,
273
+ "queries_failed": self._queries_failed,
274
+ }
275
+
276
+ async def _handle_system_channels(self, request: dict) -> dict:
277
+ """Get list of connected channels.
278
+
279
+ Returns information about all channels this robot instance is connected to.
280
+ Currently supports single-channel mode, but structured for future multi-channel support.
281
+
282
+ Returns:
283
+ Dictionary with 'channels' key containing list of channel info dicts.
284
+ Each channel dict contains: domain, channel, connected status.
285
+ """
286
+ return {
287
+ "channels": [
288
+ {
289
+ "domain": self._domain,
290
+ "channel": self._channel,
291
+ "connected": self._nats.is_connected
292
+ }
293
+ ]
294
+ }
295
+
296
+ async def _handle_system_version(self, request: dict) -> dict:
297
+ """Get Kryten-Robot version information.
298
+
299
+ Returns version string for client applications to check compatibility.
300
+ Clients can use this to enforce minimum server version requirements.
301
+
302
+ Returns:
303
+ Dictionary with 'version' key containing semantic version string.
304
+ """
305
+ from . import __version__
306
+
307
+ return {
308
+ "version": __version__
309
+ }
310
+
311
+ async def _handle_system_stats(self, request: dict) -> dict:
312
+ """Get comprehensive runtime statistics.
313
+
314
+ Returns detailed statistics including event rates, command execution,
315
+ connection status, memory usage, and state information.
316
+
317
+ Returns:
318
+ Dictionary containing nested statistics:
319
+ - uptime_seconds: Total uptime
320
+ - events: Published events and rates
321
+ - commands: Command execution stats
322
+ - queries: Query handler stats
323
+ - connections: CyTube and NATS connection info
324
+ - state: Channel state (users, playlist, emotes)
325
+ - memory: Process memory usage
326
+ """
327
+ if not self._app_state:
328
+ raise ValueError("ApplicationState not available for stats")
329
+
330
+ # Calculate uptime
331
+ uptime = self._app_state.get_uptime()
332
+
333
+ # Get event publisher stats
334
+ events_stats = {}
335
+ if self._app_state.event_publisher:
336
+ pub_stats = self._app_state.event_publisher.stats
337
+ last_time = pub_stats.get('last_event_time')
338
+ last_type = pub_stats.get('last_event_type')
339
+ events_stats = {
340
+ "published": pub_stats.get('events_published', 0),
341
+ "failed": pub_stats.get('publish_errors', 0),
342
+ "rate_1min": pub_stats.get('rate_1min', 0.0),
343
+ "rate_5min": pub_stats.get('rate_5min', 0.0),
344
+ "last_event_time": datetime.fromtimestamp(last_time, tz=UTC).isoformat() if last_time else None,
345
+ "last_event_type": last_type
346
+ }
347
+
348
+ # Get command subscriber stats
349
+ commands_stats = {}
350
+ if self._app_state.command_subscriber:
351
+ cmd_stats = self._app_state.command_subscriber.stats
352
+ last_time = cmd_stats.get('last_command_time')
353
+ last_type = cmd_stats.get('last_command_type')
354
+ commands_stats = {
355
+ "received": cmd_stats.get('commands_processed', 0),
356
+ "executed": cmd_stats.get('commands_succeeded', 0),
357
+ "failed": cmd_stats.get('commands_failed', 0),
358
+ "rate_1min": cmd_stats.get('rate_1min', 0.0),
359
+ "last_command_time": datetime.fromtimestamp(last_time, tz=UTC).isoformat() if last_time else None,
360
+ "last_command_type": last_type
361
+ }
362
+
363
+ # Get query handler stats (self)
364
+ queries_stats = {
365
+ "processed": self._queries_processed,
366
+ "failed": self._queries_failed,
367
+ "rate_1min": 0.0 # Could add StatsTracker here too if needed
368
+ }
369
+
370
+ # Get connection stats
371
+ connections_stats = {}
372
+
373
+ # CyTube connection
374
+ cytube_stats = {"connected": False, "uptime_seconds": 0, "last_event_time": None, "reconnect_count": 0}
375
+ if self._app_state.connector:
376
+ connector = self._app_state.connector
377
+ connected_since = connector.connected_since
378
+ last_event = connector.last_event_time
379
+ cytube_stats = {
380
+ "connected": connector.is_connected,
381
+ "uptime_seconds": (datetime.now(UTC).timestamp() - connected_since) if connected_since else 0,
382
+ "last_event_time": datetime.fromtimestamp(last_event, tz=UTC).isoformat() if last_event else None,
383
+ "reconnect_count": connector.reconnect_count
384
+ }
385
+
386
+ # NATS connection
387
+ nats_stats = {"connected": False, "uptime_seconds": 0, "server": None, "reconnect_count": 0}
388
+ if self._app_state.nats_client:
389
+ nats = self._app_state.nats_client
390
+ connected_since = nats.connected_since
391
+ nats_stats = {
392
+ "connected": nats.is_connected,
393
+ "uptime_seconds": (datetime.now(UTC).timestamp() - connected_since) if connected_since else 0,
394
+ "server": nats.connected_url,
395
+ "reconnect_count": nats.reconnect_count
396
+ }
397
+
398
+ connections_stats = {
399
+ "cytube": cytube_stats,
400
+ "nats": nats_stats
401
+ }
402
+
403
+ # Get state counts
404
+ state_stats = {}
405
+ if self._app_state.state_manager:
406
+ sm = self._app_state.state_manager
407
+ state_stats = {
408
+ "users_online": sm.users_count(),
409
+ "playlist_items": sm.playlist_count(),
410
+ "emotes_count": sm.emotes_count()
411
+ }
412
+
413
+ # Get memory stats (if psutil available)
414
+ memory_stats = {}
415
+ if PSUTIL_AVAILABLE:
416
+ try:
417
+ process = psutil.Process()
418
+ mem_info = process.memory_info()
419
+ memory_stats = {
420
+ "rss_mb": mem_info.rss / (1024 * 1024),
421
+ "vms_mb": mem_info.vms / (1024 * 1024)
422
+ }
423
+ except Exception as e:
424
+ self._logger.warning(f"Failed to get memory stats: {e}")
425
+ memory_stats = {"error": str(e)}
426
+ else:
427
+ memory_stats = {"error": "psutil not available"}
428
+
429
+ return {
430
+ "uptime_seconds": uptime,
431
+ "events": events_stats,
432
+ "commands": commands_stats,
433
+ "queries": queries_stats,
434
+ "connections": connections_stats,
435
+ "state": state_stats,
436
+ "memory": memory_stats
437
+ }
438
+
439
+ async def _handle_system_config(self, request: dict) -> dict:
440
+ """Get current effective configuration.
441
+
442
+ Returns the running configuration with sensitive fields redacted.
443
+ Useful for debugging and verifying configuration changes.
444
+
445
+ Returns:
446
+ Dictionary containing configuration sections with passwords redacted.
447
+ """
448
+ if not self._app_state:
449
+ raise ValueError("ApplicationState not available for config")
450
+
451
+ config = self._app_state.config
452
+
453
+ # Build sanitized config response
454
+ return {
455
+ "cytube": {
456
+ "domain": config.cytube.domain,
457
+ "channel": config.cytube.channel,
458
+ "user": config.cytube.user,
459
+ "password": "***REDACTED***"
460
+ },
461
+ "nats": {
462
+ "servers": config.nats.servers,
463
+ "user": config.nats.user,
464
+ "password": "***REDACTED***" if config.nats.password else None,
465
+ "max_reconnect_attempts": config.nats.max_reconnect_attempts,
466
+ "reconnect_time_wait": config.nats.reconnect_time_wait
467
+ },
468
+ "health": {
469
+ "enabled": config.health.enabled,
470
+ "host": config.health.host,
471
+ "port": config.health.port
472
+ },
473
+ "commands": {
474
+ "enabled": config.commands.enabled
475
+ },
476
+ "logging": {
477
+ "base_path": config.logging.base_path,
478
+ "admin_operations": config.logging.admin_operations,
479
+ "playlist_operations": config.logging.playlist_operations,
480
+ "chat_messages": config.logging.chat_messages,
481
+ "command_audit": config.logging.command_audit
482
+ },
483
+ "log_level": config.log_level
484
+ }
485
+
486
+ async def _handle_system_ping(self, request: dict) -> dict:
487
+ """Simple alive check.
488
+
489
+ Ultra-lightweight health check that proves NATS connectivity and
490
+ responsiveness. Faster than full health check.
491
+
492
+ Returns:
493
+ Dictionary with pong=True, timestamp, uptime, service, and version.
494
+ """
495
+ from . import __version__
496
+
497
+ uptime = self._app_state.get_uptime() if self._app_state else 0
498
+
499
+ return {
500
+ "pong": True,
501
+ "timestamp": datetime.now(UTC).isoformat(),
502
+ "uptime_seconds": uptime,
503
+ "service": "robot",
504
+ "version": __version__
505
+ }
506
+
507
+ async def _handle_system_shutdown(self, request: dict) -> dict:
508
+ """Initiate graceful shutdown.
509
+
510
+ Schedules a graceful shutdown after optional delay. The shutdown is
511
+ performed by setting the application's shutdown_event, which triggers
512
+ the normal cleanup sequence in __main__.py.
513
+
514
+ Args:
515
+ request: Request dict with optional 'delay_seconds' (0-300) and 'reason'
516
+
517
+ Returns:
518
+ Dictionary containing:
519
+ - success: Always True (if validation passes)
520
+ - message: Acknowledgment message
521
+ - delay_seconds: Actual delay applied
522
+ - shutdown_time: ISO8601 timestamp when shutdown will occur
523
+
524
+ Raises:
525
+ ValueError: If delay is invalid or ApplicationState not available
526
+ """
527
+ if not self._app_state:
528
+ raise ValueError("ApplicationState not available for shutdown")
529
+
530
+ # Parse and validate delay
531
+ delay_seconds = request.get('delay_seconds', 0)
532
+
533
+ # Convert to int/float if needed
534
+ try:
535
+ delay_seconds = float(delay_seconds)
536
+ except (TypeError, ValueError) as e:
537
+ raise ValueError("delay_seconds must be a number") from e
538
+
539
+ # Validate range
540
+ if delay_seconds < 0 or delay_seconds > 300:
541
+ raise ValueError("delay_seconds must be between 0 and 300")
542
+
543
+ delay_seconds = int(delay_seconds)
544
+ reason = request.get('reason', 'Remote shutdown via system.shutdown')
545
+
546
+ # Calculate shutdown time
547
+ shutdown_time = datetime.now(UTC).timestamp() + delay_seconds
548
+ shutdown_time_iso = datetime.fromtimestamp(shutdown_time, tz=UTC).isoformat()
549
+
550
+ # Log the shutdown request
551
+ self._logger.warning(
552
+ f"Shutdown requested: delay={delay_seconds}s, reason={reason}, "
553
+ f"scheduled_time={shutdown_time_iso}"
554
+ )
555
+
556
+ # Schedule shutdown
557
+ async def trigger_shutdown():
558
+ """Wait for delay then trigger shutdown event."""
559
+ if delay_seconds > 0:
560
+ self._logger.info(f"Waiting {delay_seconds}s before shutdown...")
561
+ await asyncio.sleep(delay_seconds)
562
+
563
+ self._logger.warning(f"Triggering shutdown: {reason}")
564
+ self._app_state.shutdown_event.set()
565
+
566
+ # Create task to handle shutdown (non-blocking)
567
+ asyncio.create_task(trigger_shutdown())
568
+
569
+ return {
570
+ "success": True,
571
+ "message": "Shutdown initiated",
572
+ "delay_seconds": delay_seconds,
573
+ "shutdown_time": shutdown_time_iso,
574
+ "reason": reason
575
+ }
576
+
577
+ async def _handle_system_reload(self, request: dict) -> dict:
578
+ """Reload configuration.
579
+
580
+ Attempts to reload configuration from file. Only "safe" changes are
581
+ applied - settings that can be updated without breaking connections.
582
+
583
+ Safe changes:
584
+ - log_level: Immediately updates logging level
585
+ - nats.user/password: Will apply on next reconnect
586
+ - health settings: Would require health server restart (not implemented)
587
+
588
+ Unsafe changes (rejected):
589
+ - cytube.domain, cytube.channel, cytube.user: Would break connection
590
+
591
+ Args:
592
+ request: Request dict with optional 'config_path'
593
+
594
+ Returns:
595
+ Dictionary containing:
596
+ - success: Whether reload succeeded
597
+ - message: Human-readable result message
598
+ - changes: Dict of what changed (key: "old -> new")
599
+ - errors: List of validation errors if failed
600
+
601
+ Raises:
602
+ ValueError: If ApplicationState not available
603
+ """
604
+ if not self._app_state:
605
+ raise ValueError("ApplicationState not available for reload")
606
+
607
+ from .config import load_config
608
+
609
+ # Determine config path
610
+ config_path = request.get('config_path', self._app_state.config_path)
611
+
612
+ try:
613
+ # Load new configuration
614
+ self._logger.info(f"Loading configuration from {config_path}")
615
+ new_config = load_config(config_path)
616
+
617
+ # Track changes
618
+ changes = {}
619
+ errors = []
620
+ old_config = self._app_state.config
621
+
622
+ # Check for unsafe changes
623
+ if new_config.cytube.domain != old_config.cytube.domain:
624
+ errors.append("Cannot change cytube.domain without restart")
625
+
626
+ if new_config.cytube.channel != old_config.cytube.channel:
627
+ errors.append("Cannot change cytube.channel without restart")
628
+
629
+ if new_config.cytube.user != old_config.cytube.user:
630
+ errors.append("Cannot change cytube.user without restart")
631
+
632
+ # If there are errors, reject the reload
633
+ if errors:
634
+ self._logger.warning(f"Configuration reload rejected: {errors}")
635
+ return {
636
+ "success": False,
637
+ "message": "Configuration validation failed",
638
+ "changes": {},
639
+ "errors": errors
640
+ }
641
+
642
+ # Apply safe changes
643
+
644
+ # 1. Log level change
645
+ if new_config.log_level != old_config.log_level:
646
+ old_level = old_config.log_level
647
+ new_level = new_config.log_level
648
+ changes["log_level"] = f"{old_level} -> {new_level}"
649
+
650
+ # Update logging level
651
+ numeric_level = getattr(logging, new_level.upper(), None)
652
+ if numeric_level:
653
+ logging.getLogger().setLevel(numeric_level)
654
+ self._logger.info(f"Updated log level to {new_level}")
655
+
656
+ # 2. NATS credentials (will apply on next reconnect)
657
+ if (new_config.nats.user != old_config.nats.user or
658
+ new_config.nats.password != old_config.nats.password):
659
+ changes["nats.credentials"] = "updated (will apply on next reconnect)"
660
+
661
+ # 3. NATS servers
662
+ if new_config.nats.servers != old_config.nats.servers:
663
+ changes["nats.servers"] = "updated (will apply on next reconnect)"
664
+
665
+ # Update stored config
666
+ self._app_state.config = new_config
667
+
668
+ self._logger.info(f"Configuration reloaded successfully: {changes}")
669
+
670
+ return {
671
+ "success": True,
672
+ "message": "Configuration reloaded successfully",
673
+ "changes": changes,
674
+ "errors": []
675
+ }
676
+
677
+ except FileNotFoundError:
678
+ error_msg = f"Configuration file not found: {config_path}"
679
+ self._logger.error(error_msg)
680
+ return {
681
+ "success": False,
682
+ "message": "Configuration reload failed",
683
+ "changes": {},
684
+ "errors": [error_msg]
685
+ }
686
+
687
+ except Exception as e:
688
+ error_msg = f"Failed to load configuration: {str(e)}"
689
+ self._logger.error(f"Configuration reload error: {e}", exc_info=True)
690
+ return {
691
+ "success": False,
692
+ "message": "Configuration reload failed",
693
+ "changes": {},
694
+ "errors": [error_msg]
695
+ }
696
+
697
+
698
+ __all__ = ["StateQueryHandler"]