#!/usr/bin/env python3 """ Luzia Status Event Publisher Real-time status updates from Luzia orchestrator to Claude interface Usage: publisher = LuziaStatusPublisher() await publisher.publish_task_started(...) await publisher.publish_progress(...) await publisher.publish_task_completed(...) """ import json import asyncio from datetime import datetime from enum import Enum from dataclasses import dataclass, asdict, field from typing import Optional, List, Dict, Any, Callable from pathlib import Path import logging # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) class StatusMessageType(Enum): """Enumeration of all status message types""" TASK_STARTED = "TASK_STARTED" PROGRESS_UPDATE = "PROGRESS_UPDATE" TASK_COMPLETED = "TASK_COMPLETED" TASK_QUEUED = "TASK_QUEUED" TASK_WARNING = "TASK_WARNING" TASK_FAILED = "TASK_FAILED" SYSTEM_ALERT = "SYSTEM_ALERT" class Severity(Enum): """Severity levels for status messages""" INFO = "info" SUCCESS = "success" WARNING = "warning" ERROR = "error" CRITICAL = "critical" @dataclass class StatusMessage: """Compact status message for real-time updates""" type: StatusMessageType task_id: str project: str timestamp: int # Progress fields (optional) progress_percent: Optional[int] = None current_step: Optional[int] = None total_steps: Optional[int] = None current_step_name: Optional[str] = None # Time fields elapsed_seconds: Optional[int] = None estimated_remaining_seconds: Optional[int] = None # Status fields description: Optional[str] = None status: Optional[str] = None # Warning/Error fields alert_type: Optional[str] = None error: Optional[str] = None warning_type: Optional[str] = None message: Optional[str] = None recommendation: Optional[str] = None # Context severity: Severity = Severity.INFO queue_position: Optional[int] = None queue_ahead: Optional[List[str]] = field(default_factory=list) findings_count: Optional[int] = None retriable: Optional[bool] = None retry_count: Optional[int] = None def to_dict(self) -> Dict[str, Any]: """Convert to dict, removing None values""" result = asdict(self) result['type'] = self.type.value result['severity'] = self.severity.value return {k: v for k, v in result.items() if v is not None} def to_json(self) -> str: """Convert to JSON""" return json.dumps(self.to_dict()) def to_compact_display(self) -> str: """Format for CLI display (one line + details)""" emoji_map = { StatusMessageType.TASK_STARTED: "🟡", StatusMessageType.PROGRESS_UPDATE: "🟢", StatusMessageType.TASK_COMPLETED: "✅", StatusMessageType.TASK_QUEUED: "🔵", StatusMessageType.TASK_WARNING: "⚠️", StatusMessageType.TASK_FAILED: "❌", StatusMessageType.SYSTEM_ALERT: "⚡", } emoji = emoji_map.get(self.type, "•") task_short = self.task_id.split('-')[-1] if '-' in self.task_id else self.task_id[:8] # Build display based on message type if self.type == StatusMessageType.TASK_STARTED: time_str = ( f"est. {self.estimated_remaining_seconds//60}m" if self.estimated_remaining_seconds else "..." ) return ( f"{emoji} [{self.project}-{task_short}] Starting... ⏱ {time_str}\n" f" └─ {self.description}" ) elif self.type == StatusMessageType.PROGRESS_UPDATE: elapsed_m = self.elapsed_seconds // 60 elapsed_s = self.elapsed_seconds % 60 remaining_m = ( self.estimated_remaining_seconds // 60 if self.estimated_remaining_seconds else 0 ) remaining_s = ( self.estimated_remaining_seconds % 60 if self.estimated_remaining_seconds else 0 ) lines = [ f"{emoji} [{self.project}-{task_short}] In Progress - {self.progress_percent}% " f"({self.current_step}/{self.total_steps}) ⏱ {elapsed_m}m {elapsed_s}s", f" └─ {self.current_step_name}", f" └─ Est. remaining: {remaining_m}m {remaining_s}s" ] return "\n".join(lines) elif self.type == StatusMessageType.TASK_COMPLETED: elapsed_m = self.elapsed_seconds // 60 elapsed_s = self.elapsed_seconds % 60 details = [] if self.findings_count: word = "finding" if self.findings_count == 1 else "findings" details.append(f"{self.findings_count} {word}") if self.recommendation: details.append("recommendation") detail_str = ", ".join(details) if details else "Done" return ( f"{emoji} [{self.project}-{task_short}] Completed ✓ ({elapsed_m}m {elapsed_s}s)\n" f" └─ {detail_str}" ) elif self.type == StatusMessageType.TASK_QUEUED: queue_str = f"{len(self.queue_ahead or [])} ahead" wait_m = ( self.estimated_remaining_seconds // 60 if self.estimated_remaining_seconds else 0 ) lines = [ f"{emoji} [{self.project}-{task_short}] Queued ({queue_str}) ⏱ waiting", f" └─ Reason: {self.message}", ] if wait_m > 0: lines.append(f" └─ Est. wait: {wait_m}m") return "\n".join(lines) elif self.type == StatusMessageType.TASK_WARNING: elapsed_m = self.elapsed_seconds // 60 elapsed_s = self.elapsed_seconds % 60 lines = [ f"{emoji} [{self.project}-{task_short}] In Progress - {self.progress_percent}% " f"({self.current_step}/{self.total_steps}) ⏱ {elapsed_m}m {elapsed_s}s", f" └─ {self.current_step_name}", f" └─ Alert: {self.message}", ] if self.recommendation: lines.append(f" └─ {self.recommendation}") return "\n".join(lines) elif self.type == StatusMessageType.TASK_FAILED: elapsed_m = self.elapsed_seconds // 60 elapsed_s = self.elapsed_seconds % 60 lines = [ f"{emoji} [{self.project}-{task_short}] Failed ({elapsed_m}m {elapsed_s}s)", f" └─ Error: {self.error}", ] if self.retriable and self.retry_count: lines.append(f" └─ Auto-retry: Queued (attempt {self.retry_count}/5)") return "\n".join(lines) elif self.type == StatusMessageType.SYSTEM_ALERT: return ( f"{emoji} SYSTEM ALERT - {self.message}\n" f" └─ Action: {self.recommendation}" ) return f"{emoji} {self.description or 'Status update'}" class LuziaStatusPublisher: """Publishes real-time status updates to Claude interface""" def __init__(self, event_handler: Optional[Callable] = None, max_queue_size: int = 100): """ Initialize status publisher Args: event_handler: Optional async function to call on each event max_queue_size: Maximum events in queue before dropping """ self.event_handler = event_handler self.event_queue: asyncio.Queue = asyncio.Queue(maxsize=max_queue_size) self.active_tasks: Dict[str, Dict[str, Any]] = {} self.verbosity_level = "normal" # quiet, normal, verbose self.message_history: List[StatusMessage] = [] self.max_history = 100 def set_verbosity(self, level: str): """Set how chatty Luzia is: quiet, normal, verbose""" if level not in ("quiet", "normal", "verbose"): logger.warning(f"Invalid verbosity level: {level}. Using 'normal'") level = "normal" self.verbosity_level = level async def publish_task_started( self, task_id: str, project: str, description: str, estimated_duration_seconds: int = 300 ): """Task has started""" msg = StatusMessage( type=StatusMessageType.TASK_STARTED, task_id=task_id, project=project, description=description, timestamp=int(datetime.now().timestamp()), estimated_remaining_seconds=estimated_duration_seconds, severity=Severity.INFO ) self.active_tasks[task_id] = { "start_time": datetime.now(), "project": project, "description": description } await self._publish(msg) async def publish_progress( self, task_id: str, progress_percent: int, current_step: int, total_steps: int, current_step_name: str, elapsed_seconds: int, estimated_remaining_seconds: int ): """Update task progress""" if task_id not in self.active_tasks: logger.warning(f"Progress update for unknown task: {task_id}") return msg = StatusMessage( type=StatusMessageType.PROGRESS_UPDATE, task_id=task_id, project=self.active_tasks[task_id]["project"], progress_percent=progress_percent, current_step=current_step, total_steps=total_steps, current_step_name=current_step_name, elapsed_seconds=elapsed_seconds, estimated_remaining_seconds=estimated_remaining_seconds, timestamp=int(datetime.now().timestamp()), severity=Severity.INFO ) # Only publish progress updates based on verbosity level should_publish = ( self.verbosity_level == "verbose" or progress_percent % 25 == 0 or progress_percent == 100 ) if should_publish: await self._publish(msg) async def publish_task_completed( self, task_id: str, elapsed_seconds: int, findings_count: int = 0, recommendations_count: int = 0, status: str = "APPROVED" ): """Task completed successfully""" if task_id not in self.active_tasks: logger.warning(f"Completion for unknown task: {task_id}") return task_info = self.active_tasks[task_id] msg = StatusMessage( type=StatusMessageType.TASK_COMPLETED, task_id=task_id, project=task_info["project"], description=task_info["description"], elapsed_seconds=elapsed_seconds, findings_count=findings_count if findings_count > 0 else None, timestamp=int(datetime.now().timestamp()), status=status, severity=Severity.SUCCESS ) await self._publish(msg) del self.active_tasks[task_id] async def publish_task_queued( self, task_id: str, project: str, description: str, reason: str, queue_position: int, queue_ahead: List[str], estimated_wait_seconds: int ): """Task queued waiting for resources""" msg = StatusMessage( type=StatusMessageType.TASK_QUEUED, task_id=task_id, project=project, description=description, message=reason, queue_position=queue_position, queue_ahead=queue_ahead, estimated_remaining_seconds=estimated_wait_seconds, timestamp=int(datetime.now().timestamp()), severity=Severity.INFO ) await self._publish(msg) async def publish_warning( self, task_id: str, warning_type: str, message: str, current_step: int, total_steps: int, current_step_name: str, elapsed_seconds: int, progress_percent: int, recommendation: str = None ): """Task warning (duration exceeded, resource warning, etc)""" if task_id not in self.active_tasks: logger.warning(f"Warning for unknown task: {task_id}") return task_info = self.active_tasks[task_id] msg = StatusMessage( type=StatusMessageType.TASK_WARNING, task_id=task_id, project=task_info["project"], warning_type=warning_type, message=message, current_step=current_step, total_steps=total_steps, current_step_name=current_step_name, elapsed_seconds=elapsed_seconds, progress_percent=progress_percent, recommendation=recommendation, timestamp=int(datetime.now().timestamp()), severity=Severity.WARNING ) await self._publish(msg) async def publish_task_failed( self, task_id: str, error: str, elapsed_seconds: int, retry_count: int = 0, retriable: bool = False ): """Task failed""" if task_id not in self.active_tasks: logger.warning(f"Failure for unknown task: {task_id}") return task_info = self.active_tasks[task_id] msg = StatusMessage( type=StatusMessageType.TASK_FAILED, task_id=task_id, project=task_info["project"], error=error, elapsed_seconds=elapsed_seconds, retry_count=retry_count if retriable else None, retriable=retriable, timestamp=int(datetime.now().timestamp()), severity=Severity.ERROR ) await self._publish(msg) if not retriable: del self.active_tasks[task_id] async def publish_system_alert( self, alert_type: str, message: str, recommendation: str, severity: Severity = Severity.WARNING ): """System-level alert (resource, health, etc)""" msg = StatusMessage( type=StatusMessageType.SYSTEM_ALERT, task_id="system", project="luzia", alert_type=alert_type, message=message, recommendation=recommendation, timestamp=int(datetime.now().timestamp()), severity=severity ) await self._publish(msg) async def _publish(self, msg: StatusMessage): """Publish message to event queue""" try: await self.event_queue.put_nowait(msg) # Keep history self.message_history.append(msg) if len(self.message_history) > self.max_history: self.message_history = self.message_history[-self.max_history:] # Call event handler if provided if self.event_handler: try: result = self.event_handler(msg) if asyncio.iscoroutine(result): await result except Exception as e: logger.error(f"Error in event handler: {e}") except asyncio.QueueFull: logger.warning("Status queue full, dropping oldest message") try: self.event_queue.get_nowait() await self.event_queue.put_nowait(msg) except asyncio.QueueEmpty: pass async def get_events_stream(self): """Async generator for consuming events""" while True: try: msg = await self.event_queue.get() yield msg except asyncio.CancelledError: break except Exception as e: logger.error(f"Error in event stream: {e}") await asyncio.sleep(1) def get_active_tasks_summary(self) -> Dict[str, Any]: """Get summary of all active tasks""" return { "active_count": len(self.active_tasks), "tasks": self.active_tasks, "timestamp": int(datetime.now().timestamp()) } def get_message_history(self, limit: int = 10) -> List[StatusMessage]: """Get last N messages from history""" return self.message_history[-limit:] if self.message_history else [] async def example_usage(): """Example usage of the status publisher""" def on_event(msg: StatusMessage): """Handle event - print to console""" print(msg.to_compact_display()) print() # Create publisher publisher = LuziaStatusPublisher(event_handler=on_event) publisher.set_verbosity("normal") # Simulate a task task_id = "musica-fix-001" await publisher.publish_task_started( task_id=task_id, project="musica", description="Fix audio synthesis engine", estimated_duration_seconds=600 ) for progress in [25, 50, 75, 100]: await asyncio.sleep(1) await publisher.publish_progress( task_id=task_id, progress_percent=progress, current_step=progress // 25, total_steps=4, current_step_name=f"Step {progress // 25}: Testing phase", elapsed_seconds=int(600 * progress / 100), estimated_remaining_seconds=int(600 * (100 - progress) / 100) ) await publisher.publish_task_completed( task_id=task_id, elapsed_seconds=615, findings_count=2, status="APPROVED" ) if __name__ == "__main__": # Run example asyncio.run(example_usage())