"""Lifecycle hook helpers for instrumenting tool instance transitions.""" import logging import uuid from datetime import datetime, timezone from sqlalchemy.ext.asyncio import AsyncSession from src.models.instance_event import InstanceEvent from src.models.tool_instance import ToolInstance from src.services.correlation import get_correlation_id from src.services.event_bus import InstanceEventBus, InstanceEventPayload from src.services.notification_service import notification_service logger = logging.getLogger(__name__) def _derive_title(event_type: str) -> str: """Map lifecycle event type to a human-readable notification title.""" mapping = { "instance.created": "Container created", "instance.started": "Container started", "instance.stopped": "Container stopped", "instance.restarted": "Container restarted", "instance.deleted": "Container deleted", "instance.error": "Container error", "instance.health_changed": "Container ready", } return mapping.get( event_type, event_type.replace("instance.", "").replace("_", " ").title(), ) def _should_notify(event_type: str, status: str | None) -> bool: """Determine whether a lifecycle event should generate a notification. Only warnings, errors, and "container is ready" (health_changed running) are sent to users. """ if event_type == "instance.error": return True if event_type == "instance.health_changed" and status == "running": return True # Filter out: created, started, stopped, restarted, deleted, and any # health_changed that is not "running" (unhealthy is handled by health_monitor) return False def _build_payload( event_type: str, instance: ToolInstance, status: str | None = None, message: str | None = None, metadata: dict | None = None, ) -> InstanceEventPayload: """Construct a standard event payload.""" return { "event": event_type, "instance_id": str(instance.id), "status": status or instance.status, "message": message, "metadata": metadata or {}, "timestamp": datetime.now(timezone.utc).isoformat(), "correlation_id": get_correlation_id(), } async def _write_audit_row( session: AsyncSession, instance: ToolInstance, event_type: str, created_by: uuid.UUID | None = None, status: str | None = None, message: str | None = None, metadata: dict | None = None, ) -> InstanceEvent: """Persist an instance_events audit row.""" row = InstanceEvent( instance_id=instance.id, event_type=event_type.replace("instance.", ""), status=status or instance.status, message=message, created_by=created_by, event_metadata=metadata or {}, ) session.add(row) await session.commit() return row async def publish_lifecycle_event( event_bus: InstanceEventBus, session: AsyncSession, instance: ToolInstance, event_type: str, created_by: uuid.UUID | None = None, status: str | None = None, message: str | None = None, metadata: dict | None = None, ) -> None: """Publish a lifecycle event and write an audit row after DB commit. Args: event_bus: The global event bus. session: Active async DB session. instance: The affected tool instance. event_type: One of instance.created, instance.started, etc. created_by: User ID for user-initiated actions; None for system. status: Optional status override. message: Optional human-readable message. metadata: Optional extra metadata. """ payload = _build_payload( event_type=event_type, instance=instance, status=status, message=message, metadata=metadata, ) # Write audit row await _write_audit_row( session=session, instance=instance, event_type=event_type, created_by=created_by, status=status or instance.status, message=message, metadata=metadata, ) # Publish to bus await event_bus.publish(event_type, payload) # Create notification for instance owner (fire-and-forget) # Only send warnings, errors, and "container is ready" notifications. effective_status = status or instance.status if not _should_notify(event_type, effective_status): return severity = "error" if event_type == "instance.error" else "success" title = _derive_title(event_type) try: await notification_service.create_notification( session=session, user_id=instance.owner_id, category="instance", severity=severity, title=title, message=message, source_type="tool_instances", source_id=instance.id, metadata=metadata, ) except Exception: logger.exception( "Failed to create notification for lifecycle event %s", event_type, extra={"correlation_id": payload.get("correlation_id", "unknown")}, )