3d1f8d9cf7
FastAPI matches routes in declaration order. The DELETE /notifications
endpoint (bulk clear) was registered AFTER DELETE /notifications/{id},
so the path parameter route intercepted all requests to the bulk route,
causing a 422 UUID validation error instead of hitting clear_all.
Moved clear_all_notifications above dismiss_notification in the router.
Added regression test to verify route order.
Quality gates: pytest (22 passed)
163 lines
5.0 KiB
Python
163 lines
5.0 KiB
Python
"""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")},
|
|
)
|