# 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 |
