- 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
25 KiB
Container Monitoring & Notification System Specification
Purpose
Provide real-time visibility into container lifecycle events, health state transitions, and failures through an in-memory event bus, a background health monitor, Server-Sent Events (SSE), and frontend toast notifications. Persist an append-only audit trail of lifecycle events and health state changes. Replace frontend polling with push-based updates and introduce structured JSON logging with correlation IDs.
Assumption: This specification treats "Container Monitoring & Notification System" as a new cross-cutting domain. It introduces new tables, a new event bus, a new SSE endpoint, and new frontend components. Modifications to existing
tool_instanceslifecycle hooks and the frontend polling strategy are captured here as part of this feature domain.
Non-Functional Requirements
| ID | Requirement |
|---|---|
| NFR-1 | Performance: The SSE endpoint MUST support at least 100 concurrent connections per API process without degrading event delivery latency below 1 second. |
| NFR-2 | Latency: Events MUST reach the frontend within 1 second of detection by the health monitor or a lifecycle hook. |
| NFR-3 | Reliability: The background health monitor MUST catch exceptions from Docker CLI commands, log the error, and continue the next polling cycle. It MUST NOT terminate the background task on transient errors. |
| NFR-4 | Durability: instance_events and health_checks rows MUST survive API restarts because they are stored in PostgreSQL. |
Requirements
Requirement: R1 — InstanceEventBus publishes typed lifecycle events
The system MUST provide an in-memory singleton event bus named InstanceEventBus.
- The bus MUST support publishing typed events to multiple subscribers.
- The bus MUST support subscribing and unsubscribing via callable callbacks.
- Events MUST be delivered to all subscribers in the same asyncio event loop iteration.
- If a subscriber raises an exception, the bus MUST catch it, log it, and continue delivering to remaining subscribers.
Event Payload Schema (JSON)
Every published event MUST conform to the following schema:
| Field | Type | Required | Description |
|---|---|---|---|
event |
string |
Yes | One of: instance.created, instance.started, instance.stopped, instance.restarted, instance.deleted, instance.health_changed, instance.error |
instance_id |
string (UUID) |
Yes | The affected instance ID |
status |
string |
No | Snapshot of the instance status at the time of the event |
message |
string |
No | Human-readable description |
metadata |
object |
No | Contextual data; see below |
timestamp |
string (ISO 8601) |
Yes | Event timestamp in UTC |
correlation_id |
string (UUID) |
Yes | Request correlation ID |
metadata object fields:
| Field | Type | Description |
|---|---|---|
exit_code |
integer |
Container exit code, if applicable |
tunnel_url |
string |
Public tunnel URL at time of event |
probe_output |
string |
Last probe stdout/stderr |
error_type |
string |
One of: "container", "tunnel", "probe" |
previous_status |
string |
Previous instance status on health changes |
Scenario: Event bus publish and subscribe
- GIVEN a subscriber callback is registered with
InstanceEventBus.subscribe(callback) - WHEN
InstanceEventBus.publish("instance.started", payload)is called - THEN the callback receives the payload within the same event loop iteration
- AND the payload contains
event: "instance.started",instance_id,timestamp, andcorrelation_id
Scenario: Subscriber exception isolation
- GIVEN two subscribers A and B are registered
- WHEN subscriber A raises an exception during event delivery
- THEN subscriber B still receives the event
- AND the exception from A is logged as an error with
correlation_id
Requirement: R2 — Background health monitor polls containers every 15 seconds
The system MUST run a background asyncio task that polls the health of all instances whose status is not pending, stopped, or error, every 15 seconds.
- For each candidate instance, the monitor MUST:
- Invoke
docker inspectto readState.Status,State.ExitCode, andState.Health.Status. - For web-enabled instances, perform an HTTP
GETorHEADto thepublic_urlto determine tunnel health. - Compare the result with the last known state stored in memory.
- Invoke
- On state change, the monitor MUST:
- Update
tool_instances.statusin the database. - Insert a row into
health_checks. - Publish the appropriate event to
InstanceEventBus.
- Update
- The monitor MUST NOT insert a
health_checksrow when the state has not changed. - The monitor MUST catch all exceptions from Docker CLI or HTTP calls, log a structured error, and continue to the next instance.
Scenario: Monitor detects container crash
- GIVEN an instance with status
"running" - WHEN the monitor polls and
docker inspectreturnsState.Status = "exited"andState.ExitCode = 1 - THEN
tool_instances.statusis updated to"error" - AND a
health_checksrow is inserted withcontainer_status = "exited"andexit_code = 1 - AND an
instance.errorevent is published withmetadata.error_type = "container"
Scenario: Monitor detects tunnel failure
- GIVEN an instance with status
"running"and a previously healthy tunnel - WHEN the monitor polls and the tunnel URL returns HTTP 502/503/504 or is unreachable
- THEN
tool_instances.statusis updated to"unhealthy" - AND a
health_checksrow is inserted withtunnel_healthy = false - AND an
instance.health_changedevent is published withstatus = "unhealthy"andmetadata.previous_status = "running"
Scenario: Monitor detects recovery
- GIVEN an instance with status
"unhealthy" - WHEN the monitor polls and finds the container running and the tunnel returning HTTP 200
- THEN
tool_instances.statusis updated to"running" - AND a
health_checksrow is inserted withcontainer_status = "running"andtunnel_healthy = true - AND an
instance.health_changedevent is published withstatus = "running"andmetadata.previous_status = "unhealthy"
Requirement: R3 — SSE endpoint streams events to authenticated clients
The system MUST expose GET /events/stream.
- The endpoint MUST require authentication using the same cookie/JWT session mechanism as the rest of the API.
- It MUST return
Content-Type: text/event-streamwithCache-Control: no-cacheandConnection: keep-alive. - It MUST stream JSON event payloads formatted as SSE
data:lines. - On connection start, the server MUST subscribe to
InstanceEventBus. - On client disconnect, the server MUST unsubscribe and release resources.
- The endpoint MUST return
401 Unauthorizedif authentication is missing or invalid, and MUST NOT start a stream.
SSE format per event:
event: instance.started
data: {"event":"instance.started","instance_id":"...","status":"starting","message":"Container starting...","metadata":{},"timestamp":"2026-05-28T12:00:00Z","correlation_id":"..."}
Scenario: Authenticated client receives real-time events
- GIVEN an authenticated frontend session
- WHEN the client opens
GET /events/stream - THEN an SSE connection is established
- AND events published to
InstanceEventBusare streamed within 1 second
Scenario: Unauthenticated client is rejected
- GIVEN a client with no valid session cookie or JWT
- WHEN the client opens
GET /events/stream - THEN the server responds with
401 Unauthorized - AND no SSE stream is started
Scenario: Client reconnects after network interruption
- GIVEN a connected SSE client that loses network connectivity
- WHEN the network recovers
- THEN the frontend reconnects with exponential backoff (1s, 2s, 4s, 8s, capped at 30s) with ±20% jitter
- AND a new SSE connection is established
Requirement: R4 — Frontend displays toast notifications for errors
The system MUST display toast notifications in the frontend based on SSE events.
- Error events (
instance.error) MUST display an error toast that persists until manually dismissed or for a minimum of 10 seconds. - The error toast MUST show the event
messageand, if present, themetadata.exit_code. - Start events (
instance.started) SHOULD display an info toast with duration 3 seconds. - Running events (
instance.health_changedto"running") SHOULD display a success toast with duration 3 seconds. - Unhealthy events (
instance.health_changedto"unhealthy") SHOULD display a warning toast with duration 5 seconds.
Scenario: Build failure toast
- GIVEN the frontend is connected to the SSE stream
- WHEN an
instance.errorevent is received withmetadata.exit_code = 137 - THEN an error toast is displayed with the message and exit code
137 - AND the toast remains visible for at least 10 seconds
Scenario: Successful start toast sequence
- GIVEN the frontend is connected to the SSE stream
- WHEN an
instance.startedevent is received - THEN an info toast "Container starting..." appears for 3 seconds
- AND when a subsequent
instance.health_changedevent withstatus = "running"is received - THEN a success toast "Container running" appears for 3 seconds
Requirement: R5 — Instance status badges update in real-time
The system MUST update instance status badges in the frontend within 1 second of receiving the corresponding SSE event.
- The frontend MUST stop polling for instance status every 30 seconds and instead rely on SSE events for status changes.
- The frontend MAY retain a lightweight fallback poll (e.g., every 60 seconds) for list refresh.
- Status badge colors MUST map to statuses as follows:
running→ greenstarting,probing→ blueunhealthy→ yellow/ambererror→ redstopped→ gray
Scenario: Badge updates on crash
- GIVEN an instance card showing a green
"running"badge - WHEN an
instance.errorevent is received for that instance - THEN the badge changes to red
"error"without a page refresh - AND the update occurs within 1 second
Requirement: R6 — instance_events table records lifecycle transitions
The system MUST persist every lifecycle transition in an instance_events table.
Table: instance_events
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
UUID |
PK | Unique event ID |
instance_id |
UUID |
NOT NULL, FK → tool_instances.id ON DELETE CASCADE |
Affected instance |
event_type |
VARCHAR(50) |
NOT NULL | created, started, stopped, restarted, deleted, health_changed, error |
status |
VARCHAR(50) |
Instance status snapshot at time of event | |
message |
TEXT |
Human-readable description | |
created_by |
UUID |
FK → users.id ON DELETE SET NULL |
User who triggered the action (NULL for system events) |
metadata |
JSONB |
DEFAULT '{}' |
Contextual data (exit_code, tunnel_url, probe_output, etc.) |
created_at |
TIMESTAMPTZ |
DEFAULT now() |
Event timestamp |
Indexes:
idx_instance_events_instance_idon (instance_id)idx_instance_events_created_aton (created_at DESC)idx_instance_events_event_typeon (event_type)
Scenario: Start event recorded
- GIVEN an authenticated user starts an instance
- WHEN the start operation begins
- THEN an
instance_eventsrow is inserted withevent_type = "started",status = "starting", andcreated_byset to the user's ID
Scenario: System error event recorded
- GIVEN the background monitor detects a container crash
- WHEN the state change is processed
- THEN an
instance_eventsrow is inserted withevent_type = "error",status = "error", andcreated_by = NULL
Requirement: R7 — health_checks table records state-change snapshots
The system MUST persist health state changes in a health_checks table.
Table: health_checks
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
UUID |
PK | Unique check ID |
instance_id |
UUID |
NOT NULL, FK → tool_instances.id ON DELETE CASCADE |
Affected instance |
container_status |
VARCHAR(50) |
Docker container state (running, exited, dead, not_found) |
|
container_healthy |
BOOLEAN |
Result of Docker healthcheck, if configured | |
tunnel_healthy |
BOOLEAN |
Result of HTTP probe to tunnel URL | |
exit_code |
INT |
Container exit code, if applicable | |
probe_status |
VARCHAR(50) |
passed, failed, pending, not_configured |
|
probe_output |
TEXT |
Last probe stdout/stderr | |
checked_at |
TIMESTAMPTZ |
DEFAULT now() |
Timestamp of the check |
Indexes:
idx_health_checks_instance_idon (instance_id)idx_health_checks_checked_aton (checked_at DESC)
Scenario: Health state change recorded
- GIVEN the monitor detects a transition from
"running"to"unhealthy" - WHEN the state change is processed
- THEN a
health_checksrow is inserted withcontainer_status,tunnel_healthy, andchecked_atset to the current timestamp - AND no row is inserted on the next poll if the state remains
"unhealthy"
Requirement: R8 — Structured JSON logging with correlation IDs
The system MUST emit API logs in structured JSON format.
- Every log entry MUST include the fields:
timestamp,level,logger,message,correlation_id. - Log entries related to an instance MUST include
instance_id. - Log entries related to an event MUST include
event_type. - The system MUST generate a
correlation_idfor each incoming HTTP request and propagate it through the request lifecycle using an async context variable. - The
correlation_idMUST be included in all SSE event payloads published during that request. - The
correlation_idMUST be present in all logs emitted by the background health monitor for a given polling cycle (the monitor MAY generate a newcorrelation_idper cycle).
Scenario: Request logging with correlation ID
- GIVEN an incoming HTTP request with header
X-Request-ID: "abc-123" - WHEN the request triggers an instance start
- THEN all log entries for that request include
correlation_id: "abc-123" - AND the
instance.startedevent published by that request includescorrelation_id: "abc-123"
Scenario: Health monitor structured logging
- GIVEN the background health monitor is running
- WHEN a Docker CLI error occurs during a poll
- THEN the log entry is JSON formatted with
level: "ERROR",instance_id,message, andcorrelation_id
API Contracts
SSE Endpoint
GET /events/stream
Authentication: Session cookie or JWT (same as existing API).
Response Headers:
Content-Type: text/event-streamCache-Control: no-cacheConnection: keep-alive
Success Response (200): Stream of SSE events.
Error Responses:
401 Unauthorized— Missing or invalid authentication.429 Too Many Requests— Client has exceeded the maximum of 5 concurrent SSE connections per user.
Reconnection Strategy (Frontend):
- Initial delay: 1 second.
- Multiplier: 2× per failed attempt.
- Maximum delay: 30 seconds.
- Jitter: ±20% randomization.
Lifecycle Hook Event Mapping
| User Action / System Event | Published Event | Status | Metadata Notes |
|---|---|---|---|
POST /instances (create) |
instance.created |
"pending" |
— |
POST /instances/{id}/start begins |
instance.started |
"starting" |
— |
| Readiness probe passes | instance.health_changed |
"running" |
previous_status: "starting" |
| Container exits during start | instance.error |
"error" |
error_type: "container", exit_code |
POST /instances/{id}/stop |
instance.stopped |
"stopped" |
— |
POST /instances/{id}/restart |
instance.restarted |
"starting" |
— |
DELETE /instances/{id} |
instance.deleted |
"deleted" |
— |
| Monitor detects crash | instance.error |
"error" |
error_type: "container", exit_code |
| Monitor detects tunnel failure | instance.health_changed |
"unhealthy" |
previous_status: "running" |
| Monitor detects recovery | instance.health_changed |
"running" |
previous_status: "unhealthy" |
Data Model
New Tables
instance_events
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);
health_checks
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);
Migration Strategy
- Tool: Alembic.
- Revision: Single revision creating both tables with indexes.
- Data: No backfill required; tables start empty.
- Rollback: Drop both tables and indexes.
Behavior Specs
Health Monitor State Machine
+-----------+
| pending |
+-----+-----+
| start()
v
+-----------+ probe fails / exit +-------+
| starting +---------------------------> | error |
+-----+-----+ +---+---+
| probe passes | restart()
v v
+-----------+ crash / OOM +-----------+
+--->| running +-----------------------> | error |
| +-----+-----+ +-----------+
| | tunnel/probe fail
| v
| +-----------+ recover +-----------+
+----+ unhealthy +-----------------------> | running |
+-----+-----+ +-----------+
| stop
v
+-----------+
| stopped |
+-----------+
- Transitions are triggered by the background monitor or by user-initiated lifecycle actions.
- The monitor evaluates instances with status in
{starting, running, unhealthy}every 15 seconds. - Transitions to
errororstoppedfromrunningorunhealthyare captured inhealth_checksand published to the event bus.
Event Bus Publish/Subscribe Contract
- Singleton:
InstanceEventBusis instantiated once per API process. - Subscribe:
subscribe(callback: Callable[[dict], Awaitable[None] | None]) -> Callable[[], None]- Returns an unsubscribe function.
- Publish:
publish(event_type: str, payload: dict) -> None- Iterates over all subscribers.
- If a callback is async, it is awaited; if sync, it is called directly.
- Any exception is caught, logged with
correlation_id, and delivery continues.
- No persistence: The bus does not queue events for offline subscribers.
SSE Connection Lifecycle
- Connect: Client sends
GET /events/streamwith valid auth. - Validate: Server verifies session; on failure returns
401. - Subscribe: Server registers an
InstanceEventBussubscriber callback. - Stream: Server yields SSE
data:lines for each event received. - Heartbeat: Server sends an SSE comment (
:ping) every 30 seconds to keep proxies alive. - Disconnect: Client closes connection; server catches
asyncio.CancelledError, unsubscribes, and exits. - Reconnect: Client waits per backoff strategy and repeats step 1.
Toast Display Rules
| SSE Event | Toast Type | Message | Duration |
|---|---|---|---|
instance.started |
Info | "Container starting..." |
3s |
instance.health_changed → running |
Success | "Container running" |
3s |
instance.health_changed → unhealthy |
Warning | "Container unhealthy" |
5s |
instance.error |
Error | message + exit_code if present |
10s (or persistent) |
- The frontend MUST deduplicate toasts for the same
instance_idandevent_typereceived within 1 second. - Only the
instance.errortoast MUST remain visible until manually dismissed; all others auto-dismiss after their duration.
Scenarios (Acceptance Criteria)
SC-1: User starts container → sees "starting..." toast → then "running" toast
- GIVEN the user clicks Start on an instance
- WHEN the start operation begins
- THEN an info toast
"Container starting..."appears - AND when the container passes the readiness probe
- THEN a success toast
"Container running"appears
SC-2: Container fails to build → sees error toast with exit code within 5 seconds
- GIVEN the user clicks Start on an instance
- WHEN the container exits during startup with
exit_code = 137 - THEN an error toast appears with the message and exit code
137 - AND the toast appears within 5 seconds of the container exiting
SC-3: Container crashes while running → sees error toast + status changes to "error"
- GIVEN an instance with status
"running" - WHEN the background monitor detects the container has exited with a non-zero code
- THEN an error toast is displayed
- AND the instance status badge updates to
"error"
SC-4: Tunnel dies → sees tunnel error toast
- GIVEN an instance with status
"running"and a healthy tunnel - WHEN the background monitor detects the tunnel URL returns HTTP 502/503/504 or is unreachable
- THEN a warning toast
"Tunnel error"is displayed (or error toast if mapped toinstance.error) - AND the instance status badge updates to
"unhealthy"
SC-5: Multiple instances running → each shows independent status updates
- GIVEN two instances with status
"running" - WHEN the first instance crashes and the second remains healthy
- THEN the first instance's status badge updates to
"error" - AND the second instance's status badge remains
"running" - AND only the first instance shows an error toast
SC-6: Page reload → SSE reconnects, receives current state
- GIVEN the frontend is connected to SSE and an instance is running
- WHEN the user reloads the page
- THEN the frontend reconnects to
GET /events/stream - AND the SSE connection is established within 2 seconds
- AND subsequent state changes are received as events
Error Handling
Docker CLI failure during health check
- The monitor MUST catch
subprocess.CalledProcessError,TimeoutExpired, and any other exception from the Docker CLI wrapper. - It MUST log a structured JSON error with
instance_id,correlation_id, and the exception details. - It MUST skip the instance for the current cycle and retry on the next 15-second poll.
- It MUST NOT update
tool_instances.statusor publish an event for that instance during the failed cycle.
SSE client disconnect
- The server MUST detect disconnect via
asyncio.CancelledErrororStarletterequest disconnect signals. - It MUST unsubscribe from
InstanceEventBusand release the generator. - It MUST NOT log an error for normal client disconnects.
Auth failure on SSE
- If authentication is missing or invalid, the server MUST return
401 Unauthorizedbefore starting theStreamingResponse. - It MUST NOT create an
InstanceEventBussubscription.
Event bus subscriber exception
- If a subscriber callback raises an exception,
InstanceEventBusMUST catch it. - It MUST log the exception with the event payload and
correlation_id. - It MUST continue calling the remaining subscribers.
- The publisher MUST NOT be blocked by a failing subscriber.
Risks
- Legacy spec path: This change uses the flat
openspec/changes/{change}/spec.mdpath. Future archive steps should migrate to the nestedopenspec/changes/{change}/specs/{domain}/spec.mdconvention. - Domain assumption: The proposal did not contain an explicit "Capabilities" section. Domains were inferred from the proposed components (event bus, monitor, SSE, toasts, logging). If the parent orchestrator expects separate delta specs for
tool-instances,instance-runtime-health, orsessions-hub, those should be extracted before the design phase. - No canonical spec exists for a "container-monitoring" or "notifications" domain, so this spec is written as a full new domain spec. Archive will need to create
openspec/specs/container-monitoring-notifications/spec.mdor similar.