4a7f24348c
- Add instance_events and health_checks tables with Alembic migration - InstanceEventBus: typed pub/sub singleton with wildcard support - HealthMonitor: async background loop polling containers every 15s - SSE endpoint GET /events/stream with auth and connection limits - Lifecycle hooks in tool_instances.py (create/start/stop/restart/delete) - Structured JSON logging with correlation IDs - 15 new unit tests (EventBus, HealthMonitor, MonitoringModels) Quality gates: pytest 15 new passed, ruff clean
791 lines
31 KiB
Markdown
791 lines
31 KiB
Markdown
# SDD Design: Container Monitoring & Notification System
|
||
|
||
## Status
|
||
**Phase:** design
|
||
**Date:** 2026-05-28
|
||
**Owner:** Gentle AI
|
||
**Scope:** Cross-cutting (backend + frontend)
|
||
**Est. Lines:** ~2,100 (recommend 3 chained PRs)
|
||
|
||
---
|
||
|
||
## 1. Component Architecture
|
||
|
||
### 1.1 InstanceEventBus — In-Memory Singleton Pub/Sub
|
||
|
||
**Pattern:** Module-level singleton, modeled after `TerminalManager` (`apps/api/src/services/terminal_manager.py`).
|
||
|
||
**Responsibilities:**
|
||
- Maintain a registry of typed subscribers (`instance.created`, `instance.started`, `instance.stopped`, `instance.restarted`, `instance.deleted`, `instance.health_changed`, `instance.error`).
|
||
- Deliver events to all subscribers in the same asyncio event loop iteration.
|
||
- Catch subscriber exceptions, log them with `correlation_id`, and continue delivery.
|
||
- Provide no persistence or queuing; offline subscribers miss events.
|
||
|
||
**Class:**
|
||
```python
|
||
class InstanceEventBus:
|
||
_instance: "InstanceEventBus | None" = None
|
||
_lock: asyncio.Lock = asyncio.Lock()
|
||
|
||
def __new__(cls) -> "InstanceEventBus": ...
|
||
|
||
def subscribe(
|
||
self,
|
||
event_type: str,
|
||
callback: Callable[[InstanceEventPayload], Awaitable[None] | None],
|
||
) -> Callable[[], None]: ...
|
||
|
||
def unsubscribe(self, event_type: str, callback_id: str) -> None: ...
|
||
|
||
async def publish(self, event_type: str, payload: InstanceEventPayload) -> None: ...
|
||
```
|
||
|
||
**Payload type:**
|
||
```python
|
||
class InstanceEventPayload(TypedDict):
|
||
event: str
|
||
instance_id: str
|
||
status: str | None
|
||
message: str | None
|
||
metadata: dict[str, Any]
|
||
timestamp: str # ISO 8601 UTC
|
||
correlation_id: str # UUID
|
||
```
|
||
|
||
**Location:** `apps/api/src/services/event_bus.py`
|
||
|
||
---
|
||
|
||
### 1.2 HealthMonitor — Asyncio Background Task
|
||
|
||
**Pattern:** Singleton background task, modeled after `TerminalManager._idle_check_loop()`.
|
||
|
||
**Responsibilities:**
|
||
- Poll every 15 seconds for all instances whose `status` is NOT IN `("pending", "stopped", "error")`.
|
||
- For each candidate:
|
||
1. Call `docker inspect` via `get_container_status()` in `docker.py`.
|
||
2. For web tools with `public_url`, perform HTTP HEAD/GET to check tunnel health.
|
||
3. Compare against last known in-memory state (`_last_known_state: dict[UUID, HealthSnapshot]`).
|
||
- On state change:
|
||
1. Update `tool_instances.status` in DB.
|
||
2. Insert row into `health_checks`.
|
||
3. Publish appropriate event to `InstanceEventBus`.
|
||
- Catch all exceptions per-instance, log structured error, and continue to next instance.
|
||
|
||
**Class:**
|
||
```python
|
||
class HealthMonitor:
|
||
def __init__(self, event_bus: InstanceEventBus) -> None: ...
|
||
|
||
def start(self) -> None:
|
||
"""Idempotent start of the background polling task."""
|
||
|
||
def stop(self) -> None:
|
||
"""Cancel the background task and clear state."""
|
||
|
||
async def _poll_loop(self) -> None: ...
|
||
async def _check_instance(self, session: AsyncSession, instance: ToolInstance) -> None: ...
|
||
async def _publish_state_change(
|
||
self,
|
||
instance: ToolInstance,
|
||
previous: HealthSnapshot,
|
||
current: HealthSnapshot,
|
||
) -> None: ...
|
||
```
|
||
|
||
**Location:** `apps/api/src/services/health_monitor.py`
|
||
|
||
---
|
||
|
||
### 1.3 SSEManager — FastAPI StreamingResponse
|
||
|
||
**Pattern:** Stateless generator endpoint that bridges `InstanceEventBus` to HTTP `text/event-stream`.
|
||
|
||
**Responsibilities:**
|
||
- Authenticate via existing cookie/JWT (`get_current_user_id`).
|
||
- Return `401` before starting stream if auth fails.
|
||
- Subscribe a per-connection async callback to `InstanceEventBus`.
|
||
- Yield SSE `data:` lines formatted as JSON.
|
||
- Send SSE comment `:ping` every 30 seconds to keep proxies alive.
|
||
- On disconnect (`asyncio.CancelledError` / client close), unsubscribe and release.
|
||
- Enforce max 5 concurrent SSE connections per user.
|
||
|
||
**Endpoint:**
|
||
```python
|
||
@router.get("/events/stream")
|
||
async def events_stream(
|
||
request: Request,
|
||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||
) -> StreamingResponse:
|
||
...
|
||
```
|
||
|
||
**Location:** `apps/api/src/api/events.py`
|
||
|
||
---
|
||
|
||
### 1.4 LifecycleHookService — Instrumentation Points
|
||
|
||
**Responsibilities:**
|
||
- Thin wrapper around existing lifecycle endpoints in `tool_instances.py`.
|
||
- At each lifecycle action (create, start, stop, restart, delete), publish the corresponding typed event **after** the DB transaction commits.
|
||
- Record an `instance_events` audit row for every transition.
|
||
- Pass `created_by` (current user ID) for user-initiated actions; `NULL` for system-detected transitions.
|
||
|
||
**Integration points (all in `apps/api/src/api/tool_instances.py`):**
|
||
|
||
| Endpoint | Event Published | Status | Audit Row |
|
||
|----------|----------------|--------|-----------|
|
||
| `POST /instances` | `instance.created` | `"pending"` | Yes |
|
||
| `POST /instances/{id}/start` | `instance.started` | `"starting"` | Yes |
|
||
| Probe success | `instance.health_changed` | `"running"` | Yes |
|
||
| Container exits during start | `instance.error` | `"error"` | Yes |
|
||
| `POST /instances/{id}/stop` | `instance.stopped` | `"stopped"` | Yes |
|
||
| `POST /instances/{id}/restart` | `instance.restarted` | `"starting"` | Yes |
|
||
| `DELETE /instances/{id}` | `instance.deleted` | `"deleted"` | Yes |
|
||
|
||
**Helper:** `LifecycleHookService` class or module-level async functions in `apps/api/src/services/lifecycle_hooks.py`.
|
||
|
||
---
|
||
|
||
### 1.5 ToastComponent — Frontend Event Consumer
|
||
|
||
**Responsibilities:**
|
||
- Single global `<Toaster />` component mounted in `AppShell`.
|
||
- Subscribes to SSE via `useEvents()` hook.
|
||
- Filters incoming events and maps to toast rules:
|
||
- `instance.error` → error toast, persistent (min 10s).
|
||
- `instance.started` → info toast, 3s.
|
||
- `instance.health_changed` → `running` = success 3s; `unhealthy` = warning 5s.
|
||
- Deduplicates toasts for same `(instance_id, event_type)` within 1s.
|
||
- Exposes a `toast.dismiss(id)` API.
|
||
|
||
**Technology choice:** `sonner` (lightweight, headless-compatible) or a custom 150-line toast stack. **Decision:** Use `sonner` to minimize custom UI code.
|
||
|
||
**Locations:**
|
||
- `apps/web/src/components/toast-provider.tsx` — wraps `Toaster` + `useEvents`.
|
||
- `apps/web/src/components/toast-rules.ts` — event-to-toast mapping logic.
|
||
|
||
---
|
||
|
||
## 2. File Structure
|
||
|
||
### New Files
|
||
|
||
| File | Purpose |
|
||
|------|---------|
|
||
| `apps/api/src/services/event_bus.py` | `InstanceEventBus` singleton + `InstanceEventPayload` type |
|
||
| `apps/api/src/services/health_monitor.py` | `HealthMonitor` background task + `HealthSnapshot` dataclass |
|
||
| `apps/api/src/services/lifecycle_hooks.py` | Helper functions to publish lifecycle events and write audit rows |
|
||
| `apps/api/src/services/correlation.py` | Async context var `CORRELATION_ID` + middleware injection |
|
||
| `apps/api/src/api/events.py` | SSE endpoint `/events/stream` + connection limiter |
|
||
| `apps/api/src/models/instance_event.py` | SQLAlchemy `InstanceEvent` model |
|
||
| `apps/api/src/models/health_check.py` | SQLAlchemy `HealthCheck` model |
|
||
| `apps/api/alembic/versions/2026_05_28_add_monitoring_tables.py` | Alembic revision creating `instance_events` + `health_checks` + indexes |
|
||
| `apps/web/src/hooks/use-events.ts` | `useEvents()` hook: SSE connect, reconnect backoff, event parsing |
|
||
| `apps/web/src/components/toast-provider.tsx` | Global toast provider consuming SSE events |
|
||
| `apps/web/src/components/toast-rules.ts` | Event-to-toast mapping and deduplication logic |
|
||
| `apps/web/src/types/events.ts` | TypeScript `InstanceEventPayload` interface |
|
||
| `tests/unit/test_event_bus.py` | EventBus pub/sub, exception isolation, unsubscribe |
|
||
| `tests/unit/test_health_monitor.py` | State transition logic, DB write gating |
|
||
| `tests/integration/test_sse_endpoint.py` | SSE auth, streaming, disconnect cleanup |
|
||
|
||
### Modified Files
|
||
|
||
| File | Purpose |
|
||
|------|---------|
|
||
| `apps/api/src/api/tool_instances.py` | Inject lifecycle hook calls at create/start/stop/restart/delete; pass `correlation_id` through async context |
|
||
| `apps/api/src/main.py` | Import `events_router`; register at startup; start `HealthMonitor`; add `CorrelationIdMiddleware` |
|
||
| `apps/api/src/logging_config.py` | Replace plain-text formatter with JSON formatter; include `correlation_id`, `instance_id`, `event_type` fields |
|
||
| `apps/api/src/models/__init__.py` | Export `InstanceEvent`, `HealthCheck` for Alembic autogenerate |
|
||
| `apps/web/src/components/instance-list.tsx` | Remove 30s health polling; consume `useEvents` for real-time badge updates; retain 60s list refresh |
|
||
| `apps/web/src/components/session-card.tsx` | Update badge colors based on SSE `status` events |
|
||
| `apps/web/src/components/app-shell.tsx` | Mount `<ToastProvider />` |
|
||
| `apps/web/src/api/sessions.ts` | Remove `checkInstanceHealth` polling call (keep function for on-demand use) |
|
||
| `apps/web/package.json` | Add `sonner` dependency |
|
||
| `tests/conftest.py` (or api equivalent) | Add `event_bus` fixture and `health_monitor` fixture for tests |
|
||
|
||
---
|
||
|
||
## 3. Interface Design
|
||
|
||
### 3.1 EventBus
|
||
|
||
```python
|
||
# apps/api/src/services/event_bus.py
|
||
|
||
class InstanceEventBus:
|
||
"""In-memory typed event bus. Singleton per process."""
|
||
|
||
def subscribe(
|
||
self,
|
||
event_type: str,
|
||
callback: Callable[[InstanceEventPayload], Awaitable[None] | None],
|
||
) -> Callable[[], None]:
|
||
"""Register a callback for an event type. Returns an unsubscribe function."""
|
||
|
||
async def publish(self, event_type: str, payload: InstanceEventPayload) -> None:
|
||
"""Deliver payload to all subscribers of event_type."""
|
||
|
||
def unsubscribe_all(self, event_type: str) -> None:
|
||
"""Remove all subscribers for an event type (used in tests)."""
|
||
```
|
||
|
||
**Usage in SSE endpoint:**
|
||
```python
|
||
async def event_generator(user_id: uuid.UUID):
|
||
queue: asyncio.Queue[InstanceEventPayload] = asyncio.Queue()
|
||
|
||
async def on_event(payload: InstanceEventPayload) -> None:
|
||
await queue.put(payload)
|
||
|
||
unsubscribe = event_bus.subscribe("*", on_event) # or per-type
|
||
try:
|
||
while True:
|
||
payload = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||
yield f"event: {payload['event']}\ndata: {json.dumps(payload)}\n\n"
|
||
finally:
|
||
unsubscribe()
|
||
```
|
||
|
||
### 3.2 HealthMonitor
|
||
|
||
```python
|
||
# apps/api/src/services/health_monitor.py
|
||
|
||
class HealthMonitor:
|
||
POLL_INTERVAL_SECONDS: float = 15.0
|
||
MAX_STARTUP_WAIT_SECONDS: float = 30.0
|
||
|
||
def __init__(self, event_bus: InstanceEventBus) -> None: ...
|
||
|
||
def start(self) -> None:
|
||
"""Idempotent. Creates `asyncio.Task` for `_poll_loop`."""
|
||
|
||
def stop(self) -> None:
|
||
"""Cancel task and clear `_last_known_state`."""
|
||
|
||
async def force_check(self, instance_id: uuid.UUID) -> None:
|
||
"""Immediate check for a single instance (used in tests)."""
|
||
```
|
||
|
||
### 3.3 SSEManager
|
||
|
||
```python
|
||
# apps/api/src/api/events.py
|
||
|
||
@router.get("/events/stream")
|
||
async def events_stream(
|
||
request: Request,
|
||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||
) -> StreamingResponse:
|
||
...
|
||
```
|
||
|
||
**Headers returned:**
|
||
- `Content-Type: text/event-stream`
|
||
- `Cache-Control: no-cache`
|
||
- `Connection: keep-alive`
|
||
- `X-Accel-Buffering: no` (disable nginx buffering)
|
||
|
||
**Rate limit:** Max 5 concurrent connections per `user_id`. Return `429` if exceeded.
|
||
|
||
### 3.4 Frontend: useEvents() Hook
|
||
|
||
```typescript
|
||
// apps/web/src/hooks/use-events.ts
|
||
|
||
export interface UseEventsReturn {
|
||
events: InstanceEventPayload[];
|
||
connected: boolean;
|
||
reconnectCount: number;
|
||
error: Error | null;
|
||
}
|
||
|
||
export function useEvents(): UseEventsReturn {
|
||
// Establishes SSE connection to `${BASE_URL}/events/stream`
|
||
// with exponential backoff reconnect.
|
||
}
|
||
```
|
||
|
||
**Reconnect strategy (client-side):**
|
||
- Initial delay: `1000ms`
|
||
- Multiplier: `2×`
|
||
- Cap: `30000ms`
|
||
- Jitter: `±20%` (`delay * (0.8 + Math.random() * 0.4)`)
|
||
- Max reconnect attempts: unlimited (persistent connection)
|
||
|
||
### 3.5 Correlation ID Propagation
|
||
|
||
```python
|
||
# apps/api/src/services/correlation.py
|
||
|
||
import contextvars
|
||
|
||
CORRELATION_ID: contextvars.ContextVar[str] = contextvars.ContextVar("correlation_id")
|
||
|
||
def get_correlation_id() -> str:
|
||
try:
|
||
return CORRELATION_ID.get()
|
||
except LookupError:
|
||
return str(uuid.uuid4())
|
||
```
|
||
|
||
**Middleware:** `CorrelationIdMiddleware` reads `X-Request-ID` header or generates new UUID, sets `CORRELATION_ID`, and includes it in all logs via a custom `logging.Filter`.
|
||
|
||
---
|
||
|
||
## 4. Data Flow Diagrams
|
||
|
||
### 4.1 Container Start Flow
|
||
|
||
```
|
||
User clicks Start
|
||
│
|
||
▼
|
||
POST /instances/{id}/start
|
||
│
|
||
├──► DB: tool_instances.status = "starting"
|
||
│
|
||
├──► LifecycleHookService.publish("instance.started", {status: "starting", ...})
|
||
│ │
|
||
│ ▼
|
||
│ InstanceEventBus
|
||
│ │
|
||
│ ├──► SSEManager ──► Frontend toast: "Container starting..."
|
||
│ │
|
||
│ └──► InstanceEvent DB write (audit)
|
||
│
|
||
├──► docker compose up -d
|
||
│
|
||
├──► wait_for_container_running()
|
||
│ │
|
||
│ ├──► Success ──► DB.status = "running"
|
||
│ │ LifecycleHookService.publish("instance.health_changed",
|
||
│ │ {status: "running", previous_status: "starting"})
|
||
│ │ │
|
||
│ │ ▼
|
||
│ │ Frontend toast: "Container running"
|
||
│ │
|
||
│ └──► Failure ──► DB.status = "error"
|
||
│ LifecycleHookService.publish("instance.error",
|
||
│ {status: "error", metadata: {exit_code, ...}})
|
||
│ │
|
||
│ ▼
|
||
│ Frontend toast: Error (persistent)
|
||
```
|
||
|
||
### 4.2 Health Monitor Flow
|
||
|
||
```
|
||
HealthMonitor._poll_loop() (every 15s)
|
||
│
|
||
├──► SELECT * FROM tool_instances WHERE status NOT IN ("pending","stopped","error")
|
||
│
|
||
├──► For each instance:
|
||
│ │
|
||
│ ├──► get_container_status(container_id) ──► {State.Status, ExitCode, Health.Status}
|
||
│ │
|
||
│ ├──► if public_url: HTTP HEAD public_url ──► tunnel_healthy?
|
||
│ │
|
||
│ ├──► Compare with _last_known_state[instance_id]
|
||
│ │
|
||
│ ├──► If changed:
|
||
│ │ │
|
||
│ │ ├──► DB: UPDATE tool_instances SET status = ?
|
||
│ │ │
|
||
│ │ ├──► DB: INSERT INTO health_checks (...)
|
||
│ │ │
|
||
│ │ └──► EventBus.publish("instance.health_changed" OR "instance.error")
|
||
│ │ │
|
||
│ │ ▼
|
||
│ │ Frontend badge + toast update
|
||
│ │
|
||
│ └──► If unchanged: skip DB writes
|
||
│
|
||
└──► Catch exception per-instance ──► structured JSON log ──► continue next instance
|
||
```
|
||
|
||
### 4.3 SSE Flow
|
||
|
||
```
|
||
Frontend mount
|
||
│
|
||
▼
|
||
EventSource.open("GET /events/stream")
|
||
│
|
||
├──► Server: auth cookie validation
|
||
│ │
|
||
│ ├──► Invalid ──► 401 (no stream)
|
||
│ │
|
||
│ └──► Valid ──► check connection count ≤ 5
|
||
│ │
|
||
│ ├──► Exceeded ──► 429
|
||
│ │
|
||
│ └──► OK ──► StreamingResponse
|
||
│ │
|
||
│ ├──► Subscribe callback to EventBus
|
||
│ │
|
||
│ ├──► yield "event: ...\ndata: {...}\n\n"
|
||
│ │
|
||
│ ├──► yield ":ping\n" (every 30s)
|
||
│ │
|
||
│ └──► Client disconnect
|
||
│ │
|
||
│ ├──► asyncio.CancelledError
|
||
│ └──► Unsubscribe callback
|
||
│
|
||
└──► Network interruption ──► Frontend closes EventSource
|
||
│
|
||
├──► wait exponential backoff + jitter
|
||
│
|
||
└──► reopen EventSource (repeat from top)
|
||
```
|
||
|
||
---
|
||
|
||
## 5. State Machine
|
||
|
||
### 5.1 Instance Status Transitions
|
||
|
||
```
|
||
+-----------+
|
||
| pending |
|
||
+-----+-----+
|
||
│ create()
|
||
v
|
||
+-----------+ build/compose failure +-------+
|
||
| starting +-------------------------------->│ error │
|
||
+-----+-----+ +---+---+
|
||
│ probe passes / monitor finds running │ restart()
|
||
v v
|
||
+-----------+ crash / OOM / exit ≠ 0 +-----------+
|
||
+--->| running +-------------------------------->│ error |
|
||
| +-----+-----+ +-----------+
|
||
| │ tunnel/probe fail
|
||
| v
|
||
| +-----------+ recover (tunnel OK) +-----------+
|
||
+----+ unhealthy +-------------------------------->│ running |
|
||
+-----+-----+ +-----------+
|
||
│ stop()
|
||
v
|
||
+-----------+
|
||
| stopped |
|
||
+-----------+
|
||
│ delete()
|
||
v
|
||
[gone]
|
||
```
|
||
|
||
### 5.2 Transition Triggers
|
||
|
||
| From | To | Trigger | DB Update | Event Published | Audit Row |
|
||
|------|----|---------|-----------|-----------------|-----------|
|
||
| `pending` | `starting` | User clicks Start | Yes | `instance.started` | Yes |
|
||
| `starting` | `running` | Readiness probe passes | Yes | `instance.health_changed` | Yes |
|
||
| `starting` | `error` | Container exits during start | Yes | `instance.error` | Yes |
|
||
| `running` | `unhealthy` | Monitor: tunnel down or probe fail | Yes | `instance.health_changed` | Yes |
|
||
| `running` | `error` | Monitor: container crashed / OOM | Yes | `instance.error` | Yes |
|
||
| `unhealthy` | `running` | Monitor: recovery detected | Yes | `instance.health_changed` | Yes |
|
||
| `running` | `stopped` | User clicks Stop | Yes | `instance.stopped` | Yes |
|
||
| `unhealthy` | `stopped` | User clicks Stop | Yes | `instance.stopped` | Yes |
|
||
| `error` | `starting` | User clicks Restart | Yes | `instance.restarted` | Yes |
|
||
| any | `deleted` | User clicks Delete | Yes (then row removed) | `instance.deleted` | Yes |
|
||
|
||
**Rule:** The monitor only evaluates instances with `status` in `{"starting", "running", "unhealthy"}`. It does NOT evaluate `pending`, `stopped`, or `error`.
|
||
|
||
---
|
||
|
||
## 6. Database Schema
|
||
|
||
### 6.1 Table: `instance_events`
|
||
|
||
```sql
|
||
CREATE TABLE instance_events (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
instance_id UUID NOT NULL REFERENCES tool_instances(id) ON DELETE CASCADE,
|
||
event_type VARCHAR(50) NOT NULL,
|
||
status VARCHAR(50),
|
||
message TEXT,
|
||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||
metadata JSONB NOT NULL DEFAULT '{}',
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE INDEX idx_instance_events_instance_id ON instance_events(instance_id);
|
||
CREATE INDEX idx_instance_events_created_at ON instance_events(created_at DESC);
|
||
CREATE INDEX idx_instance_events_event_type ON instance_events(event_type);
|
||
```
|
||
|
||
**SQLAlchemy model:**
|
||
```python
|
||
# apps/api/src/models/instance_event.py
|
||
|
||
class InstanceEvent(UUIDPrimaryKeyMixin, Base):
|
||
__tablename__ = "instance_events"
|
||
|
||
instance_id: Mapped[uuid.UUID] = mapped_column(
|
||
Uuid(as_uuid=True), ForeignKey("tool_instances.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
event_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||
status: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||
Uuid(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||
)
|
||
metadata: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||
created_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||
)
|
||
```
|
||
|
||
### 6.2 Table: `health_checks`
|
||
|
||
```sql
|
||
CREATE TABLE health_checks (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
instance_id UUID NOT NULL REFERENCES tool_instances(id) ON DELETE CASCADE,
|
||
container_status VARCHAR(50),
|
||
container_healthy BOOLEAN,
|
||
tunnel_healthy BOOLEAN,
|
||
exit_code INT,
|
||
probe_status VARCHAR(50),
|
||
probe_output TEXT,
|
||
checked_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE INDEX idx_health_checks_instance_id ON health_checks(instance_id);
|
||
CREATE INDEX idx_health_checks_checked_at ON health_checks(checked_at DESC);
|
||
```
|
||
|
||
**SQLAlchemy model:**
|
||
```python
|
||
# apps/api/src/models/health_check.py
|
||
|
||
class HealthCheck(UUIDPrimaryKeyMixin, Base):
|
||
__tablename__ = "health_checks"
|
||
|
||
instance_id: Mapped[uuid.UUID] = mapped_column(
|
||
Uuid(as_uuid=True), ForeignKey("tool_instances.id", ondelete="CASCADE"), nullable=False
|
||
)
|
||
container_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||
container_healthy: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||
tunnel_healthy: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||
exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||
probe_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||
probe_output: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
checked_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||
)
|
||
```
|
||
|
||
### 6.3 Migration
|
||
|
||
**File:** `apps/api/alembic/versions/2026_05_28_add_monitoring_tables.py`
|
||
|
||
**Dependency:** Depends on the latest existing revision (e.g., `2026_05_28_add_terminal_sessions_table.py` or whichever is `head` at apply time).
|
||
|
||
**Operations:**
|
||
1. `CREATE TABLE instance_events`
|
||
2. `CREATE TABLE health_checks`
|
||
3. Create all 5 indexes.
|
||
4. No data backfill.
|
||
|
||
**Rollback:** `op.drop_index(...)`, `op.drop_table("health_checks")`, `op.drop_table("instance_events")`.
|
||
|
||
---
|
||
|
||
## 7. Error Handling Strategy
|
||
|
||
### 7.1 Docker CLI Timeout / Failure
|
||
|
||
**Where:** `HealthMonitor._check_instance()` calling `get_container_status()` or HTTP tunnel probe.
|
||
|
||
**Behavior:**
|
||
- Wrap call in `try/except Exception`.
|
||
- Log structured JSON error with `instance_id`, `correlation_id`, `error_type`, `message`.
|
||
- **Do NOT** update `tool_instances.status`.
|
||
- **Do NOT** insert `health_checks` row.
|
||
- **Do NOT** publish event.
|
||
- Continue to next instance in the poll loop.
|
||
|
||
```python
|
||
try:
|
||
status = await get_container_status(instance.container_id)
|
||
except Exception as exc:
|
||
logger.error(
|
||
"Health check failed",
|
||
extra={
|
||
"instance_id": str(instance.id),
|
||
"correlation_id": get_correlation_id(),
|
||
"error": str(exc),
|
||
},
|
||
)
|
||
return
|
||
```
|
||
|
||
### 7.2 SSE Disconnect
|
||
|
||
**Where:** `events_stream()` generator, proxy/network failure, client close.
|
||
|
||
**Behavior:**
|
||
- Detect disconnect via `asyncio.CancelledError` or `Starlette` disconnect sentinel.
|
||
- Unsubscribe from `InstanceEventBus` in `finally` block.
|
||
- **Do NOT** log error for normal disconnects (log at `INFO` level only).
|
||
- Release connection slot in per-user counter.
|
||
|
||
### 7.3 SSE Reconnect Storm
|
||
|
||
**Where:** Frontend `useEvents()` hook.
|
||
|
||
**Behavior:**
|
||
- Exponential backoff with jitter (see §3.4).
|
||
- If server returns `429`, add extra 5s penalty before retry.
|
||
- If server returns `401`, stop reconnecting and redirect to login.
|
||
|
||
### 7.4 Event Bus Subscriber Crash
|
||
|
||
**Where:** `InstanceEventBus.publish()` iterating callbacks.
|
||
|
||
**Behavior:**
|
||
- Each callback wrapped in `try/except Exception`.
|
||
- Log error with full payload and `correlation_id`.
|
||
- Continue to next subscriber.
|
||
- Publisher (`publish()` call) is never blocked by a slow/failing subscriber.
|
||
|
||
```python
|
||
for callback in self._subscribers[event_type]:
|
||
try:
|
||
if asyncio.iscoroutinefunction(callback):
|
||
await callback(payload)
|
||
else:
|
||
callback(payload)
|
||
except Exception:
|
||
logger.exception("Event subscriber failed", extra={"correlation_id": payload["correlation_id"]})
|
||
```
|
||
|
||
### 7.5 Auth Failure on SSE
|
||
|
||
**Where:** `events_stream()` before `StreamingResponse`.
|
||
|
||
**Behavior:**
|
||
- `get_current_user_id` raises `HTTPException(401)`.
|
||
- FastAPI returns `401 Unauthorized` **before** creating the stream.
|
||
- No `InstanceEventBus` subscription is created.
|
||
- No connection slot is consumed.
|
||
|
||
---
|
||
|
||
## 8. Testing Strategy
|
||
|
||
### 8.1 Unit Tests
|
||
|
||
| Test | File | What |
|
||
|------|------|------|
|
||
| EventBus publish delivers to all subscribers | `tests/unit/test_event_bus.py` | Register 3 callbacks; publish; assert all called with correct payload |
|
||
| EventBus subscriber exception isolation | `tests/unit/test_event_bus.py` | Register callback that raises; publish; assert other callbacks still called |
|
||
| EventBus unsubscribe removes callback | `tests/unit/test_event_bus.py` | Unsubscribe; publish; assert callback not called |
|
||
| HealthMonitor detects crash | `tests/unit/test_health_monitor.py` | Mock `get_container_status` to return `"exited"`, `exit_code=137`; assert DB updated to `error`, event published |
|
||
| HealthMonitor detects tunnel failure | `tests/unit/test_health_monitor.py` | Mock tunnel HEAD to 502; assert status → `unhealthy`, `health_checks` row inserted |
|
||
| HealthMonitor skip on no change | `tests/unit/test_health_monitor.py` | Two identical polls; assert only one `health_checks` row |
|
||
| HealthMonitor Docker exception resilience | `tests/unit/test_health_monitor.py` | Mock `get_container_status` to raise; assert no exception propagates, loop continues |
|
||
|
||
**Fixtures needed:**
|
||
- `event_bus`: fresh `InstanceEventBus()` instance (reset singleton state).
|
||
- `health_monitor`: `HealthMonitor(event_bus)` with mocked `POLL_INTERVAL_SECONDS = 0.1`.
|
||
- `db_session`: async SQLAlchemy session with rollback after each test.
|
||
|
||
### 8.2 Integration Tests
|
||
|
||
| Test | File | What |
|
||
|------|------|------|
|
||
| SSE endpoint requires auth | `tests/integration/test_sse_endpoint.py` | `GET /events/stream` without cookie → `401` |
|
||
| SSE endpoint streams events | `tests/integration/test_sse_endpoint.py` | Authenticated client connects; backend publishes event; client receives SSE line within 1s |
|
||
| SSE endpoint enforces connection limit | `tests/integration/test_sse_endpoint.py` | Open 6 connections; 6th returns `429` |
|
||
| SSE disconnect unsubscribes | `tests/integration/test_sse_endpoint.py` | Connect; close client; publish event; assert no error, subscriber count = 0 |
|
||
| Lifecycle hook publishes on start | `tests/integration/test_lifecycle_hooks.py` | Call start endpoint; assert `instance_events` row exists and event bus receives `instance.started` |
|
||
|
||
### 8.3 E2E Tests
|
||
|
||
| Test | File | What |
|
||
|------|------|------|
|
||
| Start container → toast appears | `tests/e2e/container_monitoring.spec.ts` (or Playwright) | Click Start; assert "Container starting..." toast; wait for probe; assert "Container running" toast |
|
||
| Container crash → error toast | `tests/e2e/container_monitoring.spec.ts` | Start container; kill container externally; assert error toast within 5s |
|
||
| Real-time badge update | `tests/e2e/container_monitoring.spec.ts` | Start container; badge green; kill container; badge turns red without refresh |
|
||
|
||
### 8.4 Frontend Unit Tests
|
||
|
||
| Test | File | What |
|
||
|------|------|------|
|
||
| useEvents reconnect backoff | `apps/web/src/hooks/use-events.test.ts` | Simulate `EventSource` error; assert reconnect delay doubles up to cap |
|
||
| Toast deduplication | `apps/web/src/components/toast-rules.test.ts` | Two identical events within 1s; assert only one toast shown |
|
||
| Event-to-toast mapping | `apps/web/src/components/toast-rules.test.ts` | Map each event type to correct toast type, message, duration |
|
||
|
||
---
|
||
|
||
## 9. Performance Considerations
|
||
|
||
### 9.1 SSE Connection Pool
|
||
|
||
- **Limit:** 5 concurrent SSE connections per user ID.
|
||
- **Reasoning:** Prevents tab-spam from exhausting server memory. A typical user has 1–3 tabs open.
|
||
- **Implementation:** In-memory `dict[uuid.UUID, int]` in `events.py`. In-memory is acceptable because single-process API is assumed.
|
||
|
||
### 9.2 Health Monitor Batching
|
||
|
||
- **Current approach:** `docker inspect` is called once per instance per poll cycle.
|
||
- **Optimization (future):** Batch `docker ps --format json` to get all container statuses in a single CLI invocation, then match by `container_name`. **Not implemented in MVP** to keep changes minimal; document as follow-up.
|
||
- **DB writes:** Only on state change. The monitor compares against `_last_known_state` in memory before touching the DB.
|
||
|
||
### 9.3 Event Bus Memory Profile
|
||
|
||
- **No event history:** The bus holds only subscriber callable references (lightweight).
|
||
- **No queues:** SSE connections use per-connection `asyncio.Queue` capped at 100 items; if a client is slow, drop oldest events to prevent unbounded growth.
|
||
|
||
```python
|
||
queue: asyncio.Queue[InstanceEventPayload] = asyncio.Queue(maxsize=100)
|
||
```
|
||
|
||
### 9.4 Database Write Amplification
|
||
|
||
- **Health checks:** Written only on state change, not every 15-second poll.
|
||
- **Growth estimate:** 100 instances × 10 state changes/day × 365 days ≈ 365k rows/year. Acceptable for PostgreSQL.
|
||
- **Retention (follow-up):** Add a scheduled cleanup job or pg_partman for `health_checks` older than 30 days.
|
||
|
||
### 9.5 Frontend Polling Reduction
|
||
|
||
- **Before:** Health poll every 30s per running instance = 2 req/min/instance.
|
||
- **After:** One SSE connection per browser tab, zero polling for status. Fallback list refresh every 60s retained for resilience.
|
||
- **Server load reduction:** For 50 running instances across all users, eliminates ~100 health-check HTTP requests per minute.
|
||
|
||
### 9.6 JSON Logging Overhead
|
||
|
||
- JSON formatter adds ~20% CPU overhead vs plain text for high-volume logs. Mitigate by:
|
||
- Keeping `uvicorn.access` at `WARNING`.
|
||
- Not logging every SSE ping.
|
||
- Using `orjson` for JSON serialization if available (fallback to stdlib `json`).
|
||
|
||
---
|
||
|
||
## 10. Rollout Plan
|
||
|
||
| PR | Contents | Estimated Lines | Review Risk |
|
||
|----|----------|-----------------|-------------|
|
||
| **PR 1: Backend core** | DB migrations, models, `InstanceEventBus`, `HealthMonitor`, SSE endpoint, correlation ID middleware, JSON logging | ~1,000 | Medium |
|
||
| **PR 2: Frontend** | `useEvents` hook, `ToastProvider`, `sonner` integration, badge real-time updates, remove 30s health polling | ~700 | Medium |
|
||
| **PR 3: Integration + tests** | Lifecycle hook instrumentation in `tool_instances.py`, unit + integration tests, E2E tests | ~400 | Low |
|
||
|
||
**Dependency order:** PR 1 → PR 2 → PR 3. PR 2 can be developed in parallel but must merge after PR 1.
|
||
|
||
---
|
||
|
||
## 11. Open Questions / Decisions
|
||
|
||
| ID | Decision | Status |
|
||
|----|----------|--------|
|
||
| D1 | Use `sonner` for toasts (vs custom implementation) | **Decided:** `sonner` — reduces custom UI code by ~300 lines |
|
||
| D2 | In-memory event bus (vs Redis/NATS) | **Decided:** In-memory — matches `TerminalManager` pattern; defer distributed bus |
|
||
| D3 | SSE instead of WebSocket | **Decided:** SSE — one-way push, simpler auth, HTTP-compatible |
|
||
| D4 | Batch `docker ps` for health monitor | **Deferred:** Keep per-instance `docker inspect` for MVP; document optimization |
|
||
| D5 | `health_checks` retention policy | **Deferred:** 30-day retention to be added in follow-up |
|