- 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
27 KiB
SDD Tasks: Container Monitoring & Notification System
Review Workload Forecast
| Field | Value |
|---|---|
| Estimated changed lines | ~2,100 total (PR-1 ~1,000; PR-2 ~700; PR-3 ~400) |
| 400-line budget risk | High |
| Chained PRs recommended | Yes |
| Suggested split | PR 1 (Backend Core) → PR 2 (Frontend UI) → PR 3 (Integration + Polish) |
| Delivery strategy | auto-chain |
| Chain strategy | stacked-to-main |
Decision needed before apply: No
Chained PRs recommended: Yes
Chain strategy: stacked-to-main
400-line budget risk: High
Note: PR-1 and PR-2 exceed the 400-line review budget. Within each PR, tasks are grouped into autonomous work units that can be reviewed independently. If review fanout is available, consider splitting PR-1 into (a) DB + EventBus + SSE and (b) HealthMonitor + Lifecycle Hooks + Logging. PR-2 can be split into (a) useEvents + ToastProvider and (b) Badge updates + Polling removal.
PR-1: Backend Core
Goal: Establish the backend infrastructure for real-time container monitoring: database schema, in-memory event bus, background health monitor, SSE endpoint, structured logging, and lifecycle instrumentation.
Estimated Lines: ~1,000 Review Risk: High
MON-PR1-001: Create Alembic migration for monitoring tables
Description:
Write a single Alembic revision that creates instance_events and health_checks with all columns, constraints, and indexes defined in the spec.
Files to modify:
apps/api/alembic/versions/2026_05_28_add_monitoring_tables.py(new)
Acceptance criteria:
- Migration creates
instance_eventstable with columns:id,instance_id,event_type,status,message,created_by,metadata,created_at. - Migration creates
health_checkstable with columns:id,instance_id,container_status,container_healthy,tunnel_healthy,exit_code,probe_status,probe_output,checked_at. - All 5 indexes from the spec are created.
upgrade()anddowngrade()are both implemented and passalembic upgrade head/alembic downgrade -1.- Migration depends on current
headrevision.
Estimated effort: Small (2–3 hours) Dependencies: None
MON-PR1-002: Create SQLAlchemy models for InstanceEvent and HealthCheck
Description:
Add SQLAlchemy models matching the migration schema, following the existing UUIDPrimaryKeyMixin + Base pattern (no TimestampMixin on InstanceEvent; created_at uses server_default).
Files to modify:
apps/api/src/models/instance_event.py(new)apps/api/src/models/health_check.py(new)apps/api/src/models/__init__.py
Acceptance criteria:
InstanceEventmodel matches spec schema with correct FKs (ON DELETE CASCADE/SET NULL).HealthCheckmodel matches spec schema with correct FK (ON DELETE CASCADE).- Both models exported in
models/__init__.py. alembic revision --autogenerateproduces no drift against the hand-written migration.
Estimated effort: Small (2–3 hours) Dependencies: MON-PR1-001
MON-PR1-003: Add correlation ID context variable and middleware
Description:
Implement an async context variable CORRELATION_ID and a FastAPI middleware that reads X-Request-ID or generates a new UUID on every request. This must be available before structured logging and event publishing.
Files to modify:
apps/api/src/services/correlation.py(new)apps/api/src/main.py
Acceptance criteria:
CORRELATION_ID: contextvars.ContextVar[str]exists withget_correlation_id()helper.CorrelationIdMiddlewaresets the context var fromX-Request-IDheader oruuid.uuid4().- Middleware is registered in
main.pybefore all routes. - Calling
get_correlation_id()inside a request handler returns the same ID for the full request lifecycle.
Estimated effort: Small (2–3 hours) Dependencies: None
MON-PR1-004: Refactor API logging to structured JSON format
Description:
Replace the plain-text formatter in logging_config.py with a JSON formatter that includes timestamp, level, logger, message, correlation_id, instance_id, and event_type. Add a logging.Filter that reads from CORRELATION_ID.
Files to modify:
apps/api/src/logging_config.py
Acceptance criteria:
- Log output is valid JSON lines with required fields.
correlation_idis populated automatically from the context var.instance_idandevent_typeare included when passed asextra=to the logger.- Request/response middleware logs remain functional but now emit JSON.
- Unhandled exception middleware logs tracebacks as JSON.
uvicorn.accessstays atWARNINGto reduce noise.
Estimated effort: Small (2–3 hours) Dependencies: MON-PR1-003
MON-PR1-005: Implement InstanceEventBus singleton with typed pub/sub
Description:
Create the in-memory event bus as a module-level singleton following the TerminalManager pattern. Support typed subscription, unsubscribe, and exception-isolated delivery.
Files to modify:
apps/api/src/services/event_bus.py(new)
Acceptance criteria:
InstanceEventBusis a singleton (__new__+ lock).subscribe(event_type, callback)returns an unsubscribe callable.publish(event_type, payload)delivers to all subscribers in the same event loop iteration.- If a subscriber raises, the exception is logged with
correlation_idand delivery continues to remaining subscribers. InstanceEventPayloadTypedDict matches the spec schema exactly.unsubscribe_all(event_type)exists for test teardown.
Estimated effort: Small (3–4 hours) Dependencies: MON-PR1-003
MON-PR1-006: Implement HealthMonitor background polling task
Description: Build the background monitor that polls Docker + tunnel health every 15 seconds, compares against in-memory state, and only writes to DB / publishes events on actual state changes.
Files to modify:
apps/api/src/services/health_monitor.py(new)apps/api/src/services/docker.py(read-only; reuseget_container_status)
Acceptance criteria:
HealthMonitoracceptsevent_bus: InstanceEventBusand is a singleton-style service.start()is idempotent; creates an asyncio task for_poll_loop().stop()cancels the task and clears_last_known_state.- Poll interval is
15.0seconds (configurable for tests). - Queries all instances where
status NOT IN ("pending", "stopped", "error"). - Per instance: calls
get_container_status(), then HTTP HEAD/GET topublic_urlif present. - On state change: updates
tool_instances.status, insertshealth_checksrow, publishesinstance.health_changedorinstance.error. - On no change: skips all DB writes and event publication.
- Per-instance exceptions are caught, logged as structured JSON, and the loop continues.
_last_known_stateis adict[UUID, HealthSnapshot]dataclass.
Estimated effort: Medium (5–7 hours) Dependencies: MON-PR1-002, MON-PR1-005
MON-PR1-007: Implement SSE streaming endpoint with auth and connection limits
Description:
Create /events/stream using FastAPI StreamingResponse with text/event-stream. Enforce authentication and a max of 5 concurrent connections per user.
Files to modify:
apps/api/src/api/events.py(new)apps/api/src/api/__init__.py
Acceptance criteria:
GET /events/streamreturns401before stream start if auth is missing/invalid.- Returns
429if user already has 5 open SSE connections. - Sends SSE
event:anddata:lines formatted per spec. - Sends
:pingcomment every 30 seconds. - Per-connection
asyncio.Queue(maxsize=100)drops oldest events if client is slow. - On disconnect (
asyncio.CancelledErroror client close), unsubscribes fromInstanceEventBusand releases the connection slot. - Router is exported from
api/__init__.py.
Estimated effort: Medium (4–6 hours) Dependencies: MON-PR1-005
MON-PR1-008: Instrument lifecycle hooks in tool_instances.py
Description:
Add event publishing and audit-row writes at all lifecycle transition points in tool_instances.py. Create a thin lifecycle_hooks.py service to keep tool_instances.py readable.
Files to modify:
apps/api/src/services/lifecycle_hooks.py(new)apps/api/src/api/tool_instances.py
Acceptance criteria:
- After DB commit on
POST /instances→instance.createdevent +instance_eventsrow. - After DB commit on start begins →
instance.startedevent + row. - After probe success →
instance.health_changed(running) event + row. - After container exits during start →
instance.errorevent + row. - After DB commit on stop →
instance.stoppedevent + row. - After DB commit on restart →
instance.restartedevent + row. - After DB commit on delete →
instance.deletedevent + row. created_byis set tocurrent_user.idfor user actions;NULLfor system-detected transitions.correlation_idfrom the request context is propagated into the event payload.
Estimated effort: Medium (4–6 hours) Dependencies: MON-PR1-002, MON-PR1-005, MON-PR1-003
MON-PR1-009: Wire up HealthMonitor, EventBus, and events router in application startup
Description:
Register the new events router and start/stop the HealthMonitor within FastAPI lifespan events.
Files to modify:
apps/api/src/main.py
Acceptance criteria:
events_routeris included in the main FastAPI app with appropriate prefix.HealthMonitoris instantiated with the globalInstanceEventBusand started during app startup.HealthMonitor.stop()is called during app shutdown.- No import cycles introduced.
- App boots and passes a smoke test (
GET /healthstill works).
Estimated effort: Small (1–2 hours) Dependencies: MON-PR1-006, MON-PR1-007
MON-PR1-010: Backend unit tests — EventBus
Description:
Write pytest unit tests for InstanceEventBus covering pub/sub, exception isolation, and unsubscribe.
Files to modify:
tests/unit/test_event_bus.py(new)
Acceptance criteria:
test_publish_delivers_to_all_subscribers: 3 callbacks registered, all receive payload.test_subscriber_exception_isolation: callback A raises, B still receives event.test_unsubscribe_removes_callback: after unsubscribe, callback is not called.test_publish_to_empty_subscriber_list: no error raised.- Tests use a fresh
InstanceEventBusinstance (reset singleton state in fixture).
Estimated effort: Small (2–3 hours) Dependencies: MON-PR1-005
MON-PR1-011: Backend unit tests — HealthMonitor
Description:
Write pytest unit tests for HealthMonitor state-transition logic using mocked Docker and HTTP responses.
Files to modify:
tests/unit/test_health_monitor.py(new)
Acceptance criteria:
test_detects_container_crash: mockget_container_status→exited,exit_code=137; asserts DB status becomeserror, event published,health_checksrow inserted.test_detects_tunnel_failure: mock tunnel HEAD → 502; asserts status →unhealthy,tunnel_healthy=falsein DB.test_detects_recovery: mock running + tunnel 200 after unhealthy; asserts status →running,health_checksrow inserted.test_skips_writes_when_no_state_change: two identical polls; asserts only onehealth_checksrow.test_docker_exception_resilience: mock raisesCalledProcessError; asserts no exception propagates, loop continues.- Uses
db_sessionandevent_busfixtures; mocks poll interval to0.1s.
Estimated effort: Medium (4–5 hours) Dependencies: MON-PR1-006, MON-PR1-010
MON-PR1-012: Backend integration tests — SSE endpoint
Description: Write integration tests for the SSE endpoint covering auth, streaming, connection limits, and disconnect cleanup.
Files to modify:
tests/integration/test_sse_endpoint.py(new)
Acceptance criteria:
test_sse_requires_auth:GET /events/streamwithout cookie →401.test_sse_streams_event: authenticated client connects; backend publishes event; client receives valid SSE line within 1s.test_sse_enforces_connection_limit: open 6 connections; 6th returns429.test_sse_disconnect_unsubscribes: connect, close client, publish event; assert subscriber count is 0 and no error logged.- Uses
authenticated_clientfixture.
Estimated effort: Medium (4–5 hours) Dependencies: MON-PR1-007
PR-2: Frontend UI
Goal: Build the frontend event consumption layer: SSE client hook, toast notification system, and real-time status badge updates.
Estimated Lines: ~700 Review Risk: High
MON-PR2-001: Install sonner and create event TypeScript types
Description:
Add sonner to the frontend dependencies and create the InstanceEventPayload TypeScript interface that mirrors the backend spec.
Files to modify:
apps/web/package.jsonapps/web/src/types/events.ts(new)
Acceptance criteria:
sonneris added todependencies(notdevDependencies).InstanceEventPayloadinterface includes all required fields:event,instance_id,status,message,metadata,timestamp,correlation_id.metadatasub-type includes optional fields:exit_code,tunnel_url,probe_output,error_type,previous_status.pnpm install(or equivalent) succeeds and lockfile updated.
Estimated effort: Small (1–2 hours) Dependencies: PR-1 merged (backend SSE endpoint must exist)
MON-PR2-002: Implement useEvents() SSE hook with reconnect backoff
Description:
Create a React hook that opens an EventSource to /events/stream, handles reconnections with exponential backoff + jitter, and exposes parsed events.
Files to modify:
apps/web/src/hooks/use-events.ts(new)
Acceptance criteria:
- Hook connects to
${API_BASE_URL}/events/streamwith credentials included. - Parsed events are returned in a reactive list/array.
connectedboolean reflectsEventSourceready state.- On error/disconnect: waits
delay = min(30000, 1000 * 2^attempts) * (0.8 + Math.random() * 0.4)before reconnect. - On
401response: stops reconnecting and redirects to login. - On
429response: adds extra 5s penalty before next retry. - Hook cleans up
EventSourceon unmount. reconnectCountis exposed for debugging.
Estimated effort: Medium (4–5 hours) Dependencies: MON-PR2-001
MON-PR2-003: Implement toast rules and deduplication logic
Description: Create a pure module that maps SSE event types to toast configurations and deduplicates rapid duplicate events.
Files to modify:
apps/web/src/components/toast-rules.ts(new)
Acceptance criteria:
instance.started→infotoast, message"Container starting...", duration 3s.instance.health_changedtorunning→successtoast, message"Container running", duration 3s.instance.health_changedtounhealthy→warningtoast, message"Container unhealthy", duration 5s.instance.error→errortoast, uses eventmessage+metadata.exit_codeif present, duration 10s (or persistent if sonner supports it).- Deduplication: same
(instance_id, event_type)within 1s produces only one toast. - Function is pure and testable without React rendering.
Estimated effort: Small (2–3 hours) Dependencies: MON-PR2-001
MON-PR2-004: Implement ToastProvider component
Description:
Build a global toast provider that wraps sonner's <Toaster />, consumes useEvents(), and renders toasts via the rules module.
Files to modify:
apps/web/src/components/toast-provider.tsx(new)apps/web/src/components/app-shell.tsx
Acceptance criteria:
<ToastProvider />mounts<Toaster />and callsuseEvents().- Incoming events are passed through
toast-rules.tsmapping. - Mounted inside
AppShellso it is active on every authenticated page. - Deduplication state is managed internally (e.g.,
Map<string, number>of last toast timestamp). - Does not cause re-renders of the entire app on every SSE event (uses narrow subscription or memoization).
Estimated effort: Small (3–4 hours) Dependencies: MON-PR2-002, MON-PR2-003
MON-PR2-005: Replace health polling with real-time SSE updates in instance list
Description:
Remove the 30-second health polling loop from instance-list.tsx and session-card.tsx. Consume useEvents() to update status badges in real time. Retain a 60-second lightweight list refresh.
Files to modify:
apps/web/src/components/instance-list.tsxapps/web/src/components/session-card.tsxapps/web/src/api/sessions.ts
Acceptance criteria:
setIntervalhealth polling (every 30s) is removed frominstance-list.tsx.session-card.tsxbadge colors map to statuses:running→ green,starting/probing→ blue,unhealthy→ amber,error→ red,stopped→ gray.- Badge text and color update within 1s of receiving the matching SSE event.
api/sessions.tsstill exportscheckInstanceHealthfor on-demand use (do not delete the function).- A 60s list refresh poll remains for resilience (full list re-fetch, not per-instance health).
- Multiple instances update independently (no global refresh on single-instance event).
Estimated effort: Medium (4–5 hours) Dependencies: MON-PR2-002
MON-PR2-006: Frontend unit tests — useEvents hook
Description:
Write tests for the useEvents hook using mocked EventSource to verify reconnect logic and event parsing.
Files to modify:
apps/web/src/hooks/use-events.test.ts(new)
Acceptance criteria:
test_reconnects_with_backoff: simulateEventSourceerror; assert reconnect delay follows exponential pattern up to 30s cap.test_parses_sse_event: simulate incomingmessageevent with JSON payload; assert hook state contains parsed event.test_stops_on_401: simulate 401; assertEventSourceis closed and reconnect stops.test_cleans_up_on_unmount: unmount component; assertEventSource.close()called.
Estimated effort: Small (3–4 hours) Dependencies: MON-PR2-002
MON-PR2-007: Frontend unit tests — toast rules
Description:
Write tests for toast-rules.ts covering mapping correctness and deduplication.
Files to modify:
apps/web/src/components/toast-rules.test.ts(new)
Acceptance criteria:
test_maps_error_event_to_error_toast: asserts type, message includes exit code, duration.test_maps_running_health_change_to_success_toast: asserts type, message, duration.test_deduplicates_within_one_second: two identical events at t=0 and t=0.5 → one toast call.test_allows_duplicate_after_one_second: two identical events at t=0 and t=1.1 → two toast calls.
Estimated effort: Small (2–3 hours) Dependencies: MON-PR2-003
PR-3: Integration + Polish
Goal: Validate the end-to-end event flow, add cross-stack integration tests, tune performance, update documentation, and ensure zero regression.
Estimated Lines: ~400 Review Risk: Medium
MON-PR3-001: Integration tests — lifecycle event flow
Description: Write backend integration tests that exercise real lifecycle endpoints and assert both DB audit rows and event bus publications.
Files to modify:
tests/integration/test_lifecycle_hooks.py(new)
Acceptance criteria:
test_start_publishes_started_event: call start endpoint; assertinstance_eventsrow withevent_type="started"and event bus subscriber receivesinstance.started.test_stop_publishes_stopped_event: call stop endpoint; assertevent_type="stopped"row and subscriber receivesinstance.stopped.test_restart_publishes_restarted_event: call restart endpoint; assertevent_type="restarted".test_delete_publishes_deleted_event: call delete endpoint; assertevent_type="deleted".test_created_by_set_to_user_id: user-initiated actions havecreated_bypopulated.- Uses
authenticated_client,db_session, and a test subscriber onInstanceEventBus.
Estimated effort: Medium (4–5 hours) Dependencies: PR-1 merged, PR-2 merged
MON-PR3-002: End-to-end tests — container start to toast
Description: Write an E2E test (Playwright or Cypress) that starts a container and verifies the toast sequence in the browser.
Files to modify:
tests/e2e/container_monitoring.spec.ts(new)
Acceptance criteria:
- User clicks Start on an instance.
- Toast "Container starting..." appears within 3s.
- After readiness probe passes, toast "Container running" appears within 10s.
- No manual page refresh is performed between steps.
- Test is tagged
@monitoringfor selective CI runs.
Estimated effort: Medium (4–6 hours) Dependencies: PR-1 merged, PR-2 merged
MON-PR3-003: End-to-end tests — container crash detection
Description: Write an E2E test that kills a running container externally and verifies the error toast + badge update.
Files to modify:
tests/e2e/container_monitoring.spec.ts
Acceptance criteria:
- Start a container and wait for "running" state.
- Kill the container via Docker CLI (or API call) from the test setup.
- Error toast appears within 5s.
- Status badge changes from green "running" to red "error" without page refresh.
instance_eventstable containsevent_type="error"withexit_code.
Estimated effort: Medium (4–6 hours) Dependencies: MON-PR3-002
MON-PR3-004: Performance tuning — connection limits and queue bounds
Description: Verify and harden performance constraints: SSE queue cap, heartbeat ping, and connection-per-user limit.
Files to modify:
apps/api/src/api/events.pyapps/web/src/hooks/use-events.ts
Acceptance criteria:
- Per-connection
asyncio.Queueis capped at 100 events; oldest dropped on overflow. - SSE ping (
:ping) is sent every 30s and confirmed with a test. - Max 5 connections per user is enforced and load-tested (even 10 rapid tab opens).
- Frontend reconnect jitter prevents thundering herd (simulate 50 clients disconnect/reconnect).
- Document any latency findings; no regressions in existing terminal WS.
Estimated effort: Small (2–3 hours) Dependencies: PR-1 merged, PR-2 merged
MON-PR3-005: Documentation updates
Description: Add user-facing and developer-facing documentation for the monitoring system.
Files to modify:
docs/features/container-monitoring.md(new)docs/api/events.md(new)docs/architecture/event-bus.md(new)
Acceptance criteria:
docs/features/container-monitoring.mdexplains real-time status, toasts, and health history to users.docs/api/events.mddocumentsGET /events/streamauth, headers, reconnection strategy, and event payload schema.docs/architecture/event-bus.mddocuments the in-memory bus design, health monitor loop, and state machine.- README or nav index updated with links to new docs.
Estimated effort: Small (2–3 hours) Dependencies: PR-1 merged, PR-2 merged
MON-PR3-006: Final cleanup and regression validation
Description: Run the full test suite, fix any flakes, remove debug logging, and verify no existing functionality is broken.
Files to modify:
- Any files with temporary debug code or TODOs introduced in PR-1/PR-2.
Acceptance criteria:
pytestpasses (unit + integration) with no failures.- Frontend build passes with no TypeScript errors.
- Existing terminal WebSocket functionality verified manually or via existing E2E tests.
- Existing instance CRUD (create, start, stop, restart, delete) works end-to-end.
- Tunnel creation and recreation still function.
- No
console.logor debuglogger.debugleft from development. - All TODO comments resolved or converted to tracked issues.
- CHANGELOG or release notes entry added if project maintains one.
Estimated effort: Small (2–3 hours) Dependencies: MON-PR3-001, MON-PR3-002, MON-PR3-003, MON-PR3-004
Dependency Graph (PR Level)
PR-1: Backend Core
│
├─► MON-PR1-001 ──► MON-PR1-002
│
├─► MON-PR1-003 ──► MON-PR1-004
│ └─► MON-PR1-008
│
├─► MON-PR1-005 ──► MON-PR1-006 ──► MON-PR1-009
│ │
│ └─► MON-PR1-007 ──► MON-PR1-012
│
├─► MON-PR1-010
│
└─► MON-PR1-011
PR-2: Frontend UI (depends on PR-1 merged)
│
├─► MON-PR2-001 ──► MON-PR2-002 ──► MON-PR2-004
│ │
│ └─► MON-PR2-005
│
├─► MON-PR2-003 ──► MON-PR2-004
│
├─► MON-PR2-006
│
└─► MON-PR2-007
PR-3: Integration + Polish (depends on PR-1 + PR-2 merged)
│
├─► MON-PR3-001
│
├─► MON-PR3-002 ──► MON-PR3-003
│
├─► MON-PR3-004
│
├─► MON-PR3-005
│
└─► MON-PR3-006
Task Summary
| PR | Task ID | Description | Effort |
|---|---|---|---|
| 1 | MON-PR1-001 | Alembic migration for monitoring tables | S |
| 1 | MON-PR1-002 | SQLAlchemy models for InstanceEvent and HealthCheck | S |
| 1 | MON-PR1-003 | Correlation ID context variable and middleware | S |
| 1 | MON-PR1-004 | Structured JSON logging refactor | S |
| 1 | MON-PR1-005 | InstanceEventBus singleton | S |
| 1 | MON-PR1-006 | HealthMonitor background polling task | M |
| 1 | MON-PR1-007 | SSE streaming endpoint | M |
| 1 | MON-PR1-008 | Lifecycle hook instrumentation | M |
| 1 | MON-PR1-009 | Wire up startup/shutdown and router registration | S |
| 1 | MON-PR1-010 | Unit tests — EventBus | S |
| 1 | MON-PR1-011 | Unit tests — HealthMonitor | M |
| 1 | MON-PR1-012 | Integration tests — SSE endpoint | M |
| 2 | MON-PR2-001 | Install sonner + TypeScript event types | S |
| 2 | MON-PR2-002 | useEvents() SSE hook | M |
| 2 | MON-PR2-003 | Toast rules and deduplication | S |
| 2 | MON-PR2-004 | ToastProvider component | S |
| 2 | MON-PR2-005 | Real-time badge updates + polling removal | M |
| 2 | MON-PR2-006 | Unit tests — useEvents hook | S |
| 2 | MON-PR2-007 | Unit tests — toast rules | S |
| 3 | MON-PR3-001 | Integration tests — lifecycle event flow | M |
| 3 | MON-PR3-002 | E2E tests — container start to toast | M |
| 3 | MON-PR3-003 | E2E tests — container crash detection | M |
| 3 | MON-PR3-004 | Performance tuning (limits, queue, jitter) | S |
| 3 | MON-PR3-005 | Documentation updates | S |
| 3 | MON-PR3-006 | Final cleanup and regression validation | S |
Total tasks: 25
Total estimated effort: ~95 hours (backend ~55h, frontend ~25h, integration ~15h)