"""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 import InstanceEvent from src.models 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")}, )