feat(tui-go): health-checks pane
Surface the health subsystem (llama/event-store/disk probes) in the TUI, mirroring the session-stats pane: ClientMessage.GetHealthChecks -> ServerMessage.HealthChecks (health.checks, reuses HealthReport) -> StreamQueries.healthChecks via HealthInspectionService; Go OverlayHealth on key H + palette 'health checks', pull-based fetch, per-subject status with degraded loud. Empty/disabled -> visible fallback (no blank/lie). Go+Kotlin golden tests pin the wire contract field-for-field. Observability spec §4/§3.
This commit is contained in:
@@ -48,6 +48,10 @@ sealed class ClientMessage {
|
||||
@Serializable
|
||||
data class GetSessionStats(val sessionId: SessionId) : ClientMessage()
|
||||
|
||||
/** Operator request for the system health checks (replied to with a HealthChecks). */
|
||||
@Serializable
|
||||
data object GetHealthChecks : ClientMessage()
|
||||
|
||||
/** Operator request for the cross-session idea board (replied to with an IdeaList). */
|
||||
@Serializable
|
||||
data object ListIdeas : ClientMessage()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.correx.apps.server.protocol
|
||||
|
||||
import com.correx.apps.server.health.HealthReport
|
||||
import com.correx.apps.server.metrics.MetricsReport
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ClarificationQuestion
|
||||
@@ -490,6 +491,21 @@ sealed interface ServerMessage {
|
||||
override val sessionSequence: Long? = null,
|
||||
) : ServerMessage, NonEventMessage
|
||||
|
||||
/**
|
||||
* The system health-check report, returned in response to a GetHealthChecks request. Not
|
||||
* event-derived (a projection-replay snapshot read), so cursors are null. [health] is the same
|
||||
* [HealthReport] the REST `GET /health/checks` endpoint serves — one definition, two wires.
|
||||
* When health monitoring is disabled ([health] holds an empty/disabled report) the message is
|
||||
* still sent; the client renders a "health monitoring disabled" fallback instead of going blank.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("health.checks")
|
||||
data class HealthChecks(
|
||||
val health: HealthReport,
|
||||
override val sequence: Long? = null,
|
||||
override val sessionSequence: Long? = null,
|
||||
) : ServerMessage, NonEventMessage
|
||||
|
||||
/**
|
||||
* The current editable config (reply to GetConfig / UpdateConfig). [restartRequired] lists the
|
||||
* keys in a just-applied patch that need a server restart to take effect; [error] is set (and
|
||||
|
||||
@@ -234,6 +234,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
is ClientMessage.ListIdeas -> sendFrame(queries.listIdeas())
|
||||
is ClientMessage.DiscardIdea -> handleDiscardIdea(msg, sendFrame)
|
||||
is ClientMessage.GetSessionStats -> sendFrame(queries.sessionStats(msg.sessionId))
|
||||
is ClientMessage.GetHealthChecks -> sendFrame(queries.healthChecks())
|
||||
is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList()))
|
||||
is ClientMessage.UpdateConfig -> sendFrame(queries.updateConfig(msg.patch))
|
||||
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.correx.apps.server.ws
|
||||
|
||||
import com.correx.apps.server.ServerModule
|
||||
import com.correx.apps.server.config.ConfigUpdateResult
|
||||
import com.correx.apps.server.health.HealthInspectionService
|
||||
import com.correx.apps.server.metrics.MetricsInspectionService
|
||||
import com.correx.apps.server.protocol.ArtifactSummaryDto
|
||||
import com.correx.apps.server.protocol.ConfigFieldDto
|
||||
@@ -37,6 +38,7 @@ private val lenientJson = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
class StreamQueries(private val module: ServerModule) {
|
||||
|
||||
private val metricsService = MetricsInspectionService(module.eventStore)
|
||||
private val healthService = HealthInspectionService(module.eventStore)
|
||||
|
||||
/**
|
||||
* A session's derived metrics as a snapshot frame. Replays the event log through
|
||||
@@ -48,6 +50,18 @@ class StreamQueries(private val module: ServerModule) {
|
||||
return ServerMessage.SessionStats(sessionId = sessionId, stats = report)
|
||||
}
|
||||
|
||||
/**
|
||||
* The system health-check report as a snapshot frame. Replays the system session through
|
||||
* [HealthInspectionService] (the same projection the REST /health/checks endpoint uses) — pure
|
||||
* read, no events appended, so it is replay-neutral (invariant #1/#8). When health monitoring is
|
||||
* disabled or no probes have fired yet, the report has empty subjects and overall=HEALTHY; the
|
||||
* client renders a visible "no health probes recorded" fallback rather than going blank.
|
||||
*/
|
||||
suspend fun healthChecks(): ServerMessage.HealthChecks {
|
||||
val report = withContext(Dispatchers.IO) { healthService.inspect() }
|
||||
return ServerMessage.HealthChecks(health = report)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a session's events to assemble its artifact listing (creation order preserved),
|
||||
* resolving each artifact's stored bytes via the CAS artifact store. Pure snapshot read —
|
||||
|
||||
+49
@@ -1,5 +1,7 @@
|
||||
package com.correx.apps.server.protocol
|
||||
|
||||
import com.correx.apps.server.health.HealthReport
|
||||
import com.correx.apps.server.health.HealthSubjectReport
|
||||
import com.correx.apps.server.metrics.FailureMetrics
|
||||
import com.correx.apps.server.metrics.MetricsReport
|
||||
import com.correx.apps.server.metrics.ProviderStatsDto
|
||||
@@ -193,6 +195,53 @@ class ServerMessageSerializationTest {
|
||||
assertEquals(50.0, decoded.stats.approvalWaitPct)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `HealthChecks encodes type and nested health report`() {
|
||||
val msg = ServerMessage.HealthChecks(
|
||||
health = HealthReport(
|
||||
overall = "DEGRADED",
|
||||
subjects = listOf(
|
||||
HealthSubjectReport(
|
||||
subject = "DISK",
|
||||
status = "DEGRADED",
|
||||
metric = "free_bytes",
|
||||
observedValue = 512_000_000L,
|
||||
detail = "disk free below 1 GB threshold",
|
||||
since = "2026-06-14T10:00:00Z",
|
||||
),
|
||||
HealthSubjectReport(
|
||||
subject = "EVENT_STORE",
|
||||
status = "HEALTHY",
|
||||
metric = "write_latency_ms",
|
||||
observedValue = 4L,
|
||||
detail = "ok",
|
||||
since = "2026-06-14T09:00:00Z",
|
||||
),
|
||||
),
|
||||
checkedAt = "2026-06-14T10:05:00Z",
|
||||
),
|
||||
)
|
||||
val jsonStr = ProtocolSerializer.encodeServerMessage(msg)
|
||||
assert(jsonStr.contains("\"type\":\"health.checks\"")) { "expected type=health.checks" }
|
||||
assert(jsonStr.contains("\"overall\":\"DEGRADED\"")) { "expected overall=DEGRADED" }
|
||||
assert(jsonStr.contains("\"subject\":\"DISK\"")) { "expected DISK subject" }
|
||||
assert(jsonStr.contains("\"status\":\"DEGRADED\"")) { "expected DEGRADED status" }
|
||||
assert(jsonStr.contains("\"checkedAt\":\"2026-06-14T10:05:00Z\"")) { "expected checkedAt" }
|
||||
// Pin every subject field name so a rename breaks the Go↔Kotlin wire contract here too.
|
||||
assert(jsonStr.contains("\"metric\":\"free_bytes\"")) { "expected metric field name" }
|
||||
assert(jsonStr.contains("\"observedValue\":512000000")) { "expected observedValue field name" }
|
||||
assert(jsonStr.contains("\"detail\":\"disk free below 1 GB threshold\"")) { "expected detail field name" }
|
||||
assert(jsonStr.contains("\"since\":\"2026-06-14T10:00:00Z\"")) { "expected since field name" }
|
||||
|
||||
val decoded = json.decodeFromString<ServerMessage.HealthChecks>(jsonStr)
|
||||
assertEquals("DEGRADED", decoded.health.overall)
|
||||
assertEquals(2, decoded.health.subjects.size)
|
||||
assertEquals("DISK", decoded.health.subjects[0].subject)
|
||||
assertEquals(512_000_000L, decoded.health.subjects[0].observedValue)
|
||||
assertEquals("EVENT_STORE", decoded.health.subjects[1].subject)
|
||||
assertEquals("HEALTHY", decoded.health.subjects[1].status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ClarificationRequired encodes type, stageId and structured questions`() {
|
||||
val msg = ServerMessage.ClarificationRequired(
|
||||
|
||||
Reference in New Issue
Block a user