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
|
@Serializable
|
||||||
data class GetSessionStats(val sessionId: SessionId) : ClientMessage()
|
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). */
|
/** Operator request for the cross-session idea board (replied to with an IdeaList). */
|
||||||
@Serializable
|
@Serializable
|
||||||
data object ListIdeas : ClientMessage()
|
data object ListIdeas : ClientMessage()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.correx.apps.server.protocol
|
package com.correx.apps.server.protocol
|
||||||
|
|
||||||
|
import com.correx.apps.server.health.HealthReport
|
||||||
import com.correx.apps.server.metrics.MetricsReport
|
import com.correx.apps.server.metrics.MetricsReport
|
||||||
import com.correx.core.approvals.Tier
|
import com.correx.core.approvals.Tier
|
||||||
import com.correx.core.events.events.ClarificationQuestion
|
import com.correx.core.events.events.ClarificationQuestion
|
||||||
@@ -490,6 +491,21 @@ sealed interface ServerMessage {
|
|||||||
override val sessionSequence: Long? = null,
|
override val sessionSequence: Long? = null,
|
||||||
) : ServerMessage, NonEventMessage
|
) : 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
|
* 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
|
* 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.ListIdeas -> sendFrame(queries.listIdeas())
|
||||||
is ClientMessage.DiscardIdea -> handleDiscardIdea(msg, sendFrame)
|
is ClientMessage.DiscardIdea -> handleDiscardIdea(msg, sendFrame)
|
||||||
is ClientMessage.GetSessionStats -> sendFrame(queries.sessionStats(msg.sessionId))
|
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.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList()))
|
||||||
is ClientMessage.UpdateConfig -> sendFrame(queries.updateConfig(msg.patch))
|
is ClientMessage.UpdateConfig -> sendFrame(queries.updateConfig(msg.patch))
|
||||||
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
|
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.ServerModule
|
||||||
import com.correx.apps.server.config.ConfigUpdateResult
|
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.metrics.MetricsInspectionService
|
||||||
import com.correx.apps.server.protocol.ArtifactSummaryDto
|
import com.correx.apps.server.protocol.ArtifactSummaryDto
|
||||||
import com.correx.apps.server.protocol.ConfigFieldDto
|
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) {
|
class StreamQueries(private val module: ServerModule) {
|
||||||
|
|
||||||
private val metricsService = MetricsInspectionService(module.eventStore)
|
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
|
* 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)
|
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),
|
* 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 —
|
* 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
|
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.FailureMetrics
|
||||||
import com.correx.apps.server.metrics.MetricsReport
|
import com.correx.apps.server.metrics.MetricsReport
|
||||||
import com.correx.apps.server.metrics.ProviderStatsDto
|
import com.correx.apps.server.metrics.ProviderStatsDto
|
||||||
@@ -193,6 +195,53 @@ class ServerMessageSerializationTest {
|
|||||||
assertEquals(50.0, decoded.stats.approvalWaitPct)
|
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
|
@Test
|
||||||
fun `ClarificationRequired encodes type, stageId and structured questions`() {
|
fun `ClarificationRequired encodes type, stageId and structured questions`() {
|
||||||
val msg = ServerMessage.ClarificationRequired(
|
val msg = ServerMessage.ClarificationRequired(
|
||||||
|
|||||||
@@ -152,6 +152,14 @@ func PreviewFrame(kind string, w, h int) string {
|
|||||||
m.overlay = OverlayStats
|
m.overlay = OverlayStats
|
||||||
m.statsFor = "04a546aa"
|
m.statsFor = "04a546aa"
|
||||||
m.stats = sampleStats()
|
m.stats = sampleStats()
|
||||||
|
|
||||||
|
case "health":
|
||||||
|
m.connected = true
|
||||||
|
m.currentModel = "llama-cpp:default"
|
||||||
|
m.sessions = sampleSessions()
|
||||||
|
m.selectedID = "04a546aa"
|
||||||
|
m.overlay = OverlayHealth
|
||||||
|
m.health = sampleHealth()
|
||||||
}
|
}
|
||||||
return m.View()
|
return m.View()
|
||||||
}
|
}
|
||||||
@@ -193,6 +201,39 @@ func sampleStats() *protocol.StatsDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sampleHealth() *protocol.HealthDto {
|
||||||
|
return &protocol.HealthDto{
|
||||||
|
Overall: "DEGRADED",
|
||||||
|
Subjects: []protocol.HealthSubjectDto{
|
||||||
|
{
|
||||||
|
Subject: "DISK",
|
||||||
|
Status: "DEGRADED",
|
||||||
|
Metric: "free_bytes",
|
||||||
|
ObservedValue: 512_000_000,
|
||||||
|
Detail: "disk free below 1 GB threshold",
|
||||||
|
Since: "2026-06-14T10:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Subject: "EVENT_STORE",
|
||||||
|
Status: "HEALTHY",
|
||||||
|
Metric: "write_latency_ms",
|
||||||
|
ObservedValue: 4,
|
||||||
|
Detail: "ok",
|
||||||
|
Since: "2026-06-14T09:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Subject: "LLAMA_SERVER",
|
||||||
|
Status: "HEALTHY",
|
||||||
|
Metric: "http_status",
|
||||||
|
ObservedValue: 200,
|
||||||
|
Detail: "ok",
|
||||||
|
Since: "2026-06-14T08:30:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CheckedAt: "2026-06-14T10:05:00Z",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sampleWorkflows() []Workflow {
|
func sampleWorkflows() []Workflow {
|
||||||
return []Workflow{
|
return []Workflow{
|
||||||
{ID: "healthcheck", Description: "ping endpoints and report status"},
|
{ID: "healthcheck", Description: "ping endpoints and report status"},
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const (
|
|||||||
OverlayConfig
|
OverlayConfig
|
||||||
OverlayStats
|
OverlayStats
|
||||||
OverlayIdeas
|
OverlayIdeas
|
||||||
|
OverlayHealth
|
||||||
)
|
)
|
||||||
|
|
||||||
// RouterEntry is one line in a session's conversation transcript.
|
// RouterEntry is one line in a session's conversation transcript.
|
||||||
@@ -259,6 +260,10 @@ type Model struct {
|
|||||||
ideasIndex int
|
ideasIndex int
|
||||||
ideasLoading bool
|
ideasLoading bool
|
||||||
|
|
||||||
|
// health checks (OverlayHealth) — system-scoped, populated by the health.checks reply
|
||||||
|
health *protocol.HealthDto
|
||||||
|
healthLoading bool
|
||||||
|
|
||||||
// command palette
|
// command palette
|
||||||
paletteFilter string
|
paletteFilter string
|
||||||
paletteIndex int
|
paletteIndex int
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ func (m Model) renderOverlay(base string) string {
|
|||||||
return m.center(m.statsModal())
|
return m.center(m.statsModal())
|
||||||
case OverlayIdeas:
|
case OverlayIdeas:
|
||||||
return m.center(m.ideasModal())
|
return m.center(m.ideasModal())
|
||||||
|
case OverlayHealth:
|
||||||
|
return m.center(m.healthModal())
|
||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
@@ -750,6 +752,66 @@ func (m Model) statsModal() string {
|
|||||||
return t.Overlay.Width(w).Render(b.String())
|
return t.Overlay.Width(w).Render(b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) healthModal() string {
|
||||||
|
t := m.theme
|
||||||
|
w := m.modalWidth()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(m.titleLine("health checks"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
if m.healthLoading || m.health == nil {
|
||||||
|
msg := "loading…"
|
||||||
|
if !m.healthLoading && m.health == nil {
|
||||||
|
msg = "no health report available"
|
||||||
|
}
|
||||||
|
b.WriteString(mbg(t, " "+msg, t.P.Faint) + "\n")
|
||||||
|
b.WriteString("\n" + modalHints(t, [][2]string{{"H/esc", "close"}}))
|
||||||
|
return t.Overlay.Width(w).Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
h := m.health
|
||||||
|
cw := w - 4
|
||||||
|
|
||||||
|
// overall status header
|
||||||
|
overallFg := t.P.OK
|
||||||
|
if h.Overall == "DEGRADED" {
|
||||||
|
overallFg = t.P.Bad
|
||||||
|
}
|
||||||
|
overall := lipgloss.NewStyle().Foreground(overallFg).Background(t.P.BgPanel).Bold(true).Render(h.Overall)
|
||||||
|
b.WriteString(clip(mbg(t, " overall ", t.P.Faint)+overall+mbg(t, " checked "+h.CheckedAt, t.P.Faint), cw) + "\n\n")
|
||||||
|
|
||||||
|
if len(h.Subjects) == 0 {
|
||||||
|
b.WriteString(mbg(t, " no health probes recorded — health monitoring may be disabled", t.P.Faint) + "\n")
|
||||||
|
b.WriteString("\n" + modalHints(t, [][2]string{{"H/esc", "close"}}))
|
||||||
|
return t.Overlay.Width(w).Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
section := func(label string) string {
|
||||||
|
return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label)
|
||||||
|
}
|
||||||
|
put := func(styled string) { b.WriteString(clip(styled, cw) + "\n") }
|
||||||
|
faint := func(s string) string { return mbg(t, s, t.P.Faint) }
|
||||||
|
|
||||||
|
for _, sub := range h.Subjects {
|
||||||
|
statusFg := t.P.OK
|
||||||
|
if sub.Status == "DEGRADED" {
|
||||||
|
statusFg = t.P.Bad
|
||||||
|
}
|
||||||
|
statusLabel := lipgloss.NewStyle().Foreground(statusFg).Background(t.P.BgPanel).Bold(sub.Status == "DEGRADED").Render(sub.Status)
|
||||||
|
b.WriteString(section(sub.Subject) + mbg(t, " ", t.P.BgPanel) + statusLabel + "\n")
|
||||||
|
put(faint(fmt.Sprintf(" metric %-18s value %d", sub.Metric, sub.ObservedValue)))
|
||||||
|
if sub.Detail != "" {
|
||||||
|
put(faint(" " + truncate(sub.Detail, cw-4)))
|
||||||
|
}
|
||||||
|
put(faint(" since " + sub.Since))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(modalHints(t, [][2]string{{"H/esc", "close"}}))
|
||||||
|
return t.Overlay.Width(w).Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
// shortID trims a long artifact id for the list column while staying identifiable.
|
// shortID trims a long artifact id for the list column while staying identifiable.
|
||||||
func shortID(id string) string {
|
func shortID(id string) string {
|
||||||
if len(id) <= 14 {
|
if len(id) <= 14 {
|
||||||
|
|||||||
@@ -322,6 +322,9 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
|||||||
if m.ideasIndex >= len(m.ideas) {
|
if m.ideasIndex >= len(m.ideas) {
|
||||||
m.ideasIndex = 0
|
m.ideasIndex = 0
|
||||||
}
|
}
|
||||||
|
case protocol.TypeHealthChecks:
|
||||||
|
m.health = msg.Health
|
||||||
|
m.healthLoading = false
|
||||||
case protocol.TypeConfigSnapshot:
|
case protocol.TypeConfigSnapshot:
|
||||||
m.configFields = msg.ConfigFields
|
m.configFields = msg.ConfigFields
|
||||||
m.configRestart = msg.ConfigRestartRequired
|
m.configRestart = msg.ConfigRestartRequired
|
||||||
@@ -367,7 +370,7 @@ func sessionIDOf(msg protocol.ServerMessage) string {
|
|||||||
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
|
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
|
||||||
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
|
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
|
||||||
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
|
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
|
||||||
protocol.TypeIdeaList:
|
protocol.TypeIdeaList, protocol.TypeHealthChecks:
|
||||||
return ""
|
return ""
|
||||||
default:
|
default:
|
||||||
return msg.SessionID
|
return msg.SessionID
|
||||||
|
|||||||
@@ -178,6 +178,8 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.openArtifacts()
|
m.openArtifacts()
|
||||||
case "I":
|
case "I":
|
||||||
m.openIdeas()
|
m.openIdeas()
|
||||||
|
case "H":
|
||||||
|
m.openHealth()
|
||||||
case "S":
|
case "S":
|
||||||
m.openStats()
|
m.openStats()
|
||||||
case "g":
|
case "g":
|
||||||
@@ -444,6 +446,10 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
if runeIs(k, "S") {
|
if runeIs(k, "S") {
|
||||||
m.overlay = OverlayNone
|
m.overlay = OverlayNone
|
||||||
}
|
}
|
||||||
|
case OverlayHealth:
|
||||||
|
if runeIs(k, "H") {
|
||||||
|
m.overlay = OverlayNone
|
||||||
|
}
|
||||||
case OverlayIdeas:
|
case OverlayIdeas:
|
||||||
switch {
|
switch {
|
||||||
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
case k.Type == tea.KeyUp || runeIs(k, "k"):
|
||||||
@@ -513,6 +519,17 @@ func (m *Model) openStats() {
|
|||||||
m.client.Send(protocol.GetSessionStats(m.selectedID))
|
m.client.Send(protocol.GetSessionStats(m.selectedID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openHealth opens the system health-checks pane and fetches the current report.
|
||||||
|
// Health is system-scoped (not session-scoped), so it opens from anywhere.
|
||||||
|
func (m *Model) openHealth() {
|
||||||
|
m.overlay = OverlayHealth
|
||||||
|
// Always re-fetch and show a loading state: health changes out from under us, so a
|
||||||
|
// cached report would be stale (TUI spec §2 — render from the fetched report).
|
||||||
|
m.health = nil
|
||||||
|
m.healthLoading = true
|
||||||
|
m.client.Send(protocol.GetHealthChecks())
|
||||||
|
}
|
||||||
|
|
||||||
// openModelsOverlay opens the model picker, pre-selecting the resident model.
|
// openModelsOverlay opens the model picker, pre-selecting the resident model.
|
||||||
func (m *Model) openModelsOverlay() {
|
func (m *Model) openModelsOverlay() {
|
||||||
m.overlay = OverlayModels
|
m.overlay = OverlayModels
|
||||||
@@ -566,6 +583,7 @@ func paletteCommands() []paletteCmd {
|
|||||||
{"events", "event inspector", "browse the event stream"},
|
{"events", "event inspector", "browse the event stream"},
|
||||||
{"artifacts", "view artifacts", "browse this session's artifacts"},
|
{"artifacts", "view artifacts", "browse this session's artifacts"},
|
||||||
{"stats", "session stats", "metrics for the selected session"},
|
{"stats", "session stats", "metrics for the selected session"},
|
||||||
|
{"health", "health checks", "system health probe status"},
|
||||||
{"config", "edit config", "view / change correx settings"},
|
{"config", "edit config", "view / change correx settings"},
|
||||||
{"mode", "toggle mode", "switch chat / steering"},
|
{"mode", "toggle mode", "switch chat / steering"},
|
||||||
{"cancel", "cancel session", "stop the selected session"},
|
{"cancel", "cancel session", "stop the selected session"},
|
||||||
@@ -605,6 +623,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) {
|
|||||||
m.openArtifacts()
|
m.openArtifacts()
|
||||||
case "stats":
|
case "stats":
|
||||||
m.openStats()
|
m.openStats()
|
||||||
|
case "health":
|
||||||
|
m.openHealth()
|
||||||
case "config":
|
case "config":
|
||||||
m.openConfig()
|
m.openConfig()
|
||||||
case "mode":
|
case "mode":
|
||||||
|
|||||||
@@ -238,9 +238,9 @@ func (m Model) renderFooter() string {
|
|||||||
}
|
}
|
||||||
default: // StateInSession
|
default: // StateInSession
|
||||||
if nrw {
|
if nrw {
|
||||||
hints = []string{hint("i", "msg"), hint("e", "events"), hint("S", "stats"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
|
hints = []string{hint("i", "msg"), hint("e", "events"), hint("S", "stats"), hint("H", "health"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
|
||||||
} else {
|
} else {
|
||||||
hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
|
hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("H", "health"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")}
|
||||||
}
|
}
|
||||||
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
|
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
|
||||||
hints = append(hints, hint("a", "approval (pending)"))
|
hints = append(hints, hint("a", "approval (pending)"))
|
||||||
|
|||||||
@@ -164,6 +164,39 @@ func TestDecodeGoldenServerFrames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "health.checks carries nested health report",
|
||||||
|
json: `{"type":"health.checks","health":{"overall":"DEGRADED","subjects":[` +
|
||||||
|
`{"subject":"DISK","status":"DEGRADED","metric":"free_bytes","observedValue":512000000,` +
|
||||||
|
`"detail":"disk free below 1 GB threshold","since":"2026-06-14T10:00:00Z"},` +
|
||||||
|
`{"subject":"EVENT_STORE","status":"HEALTHY","metric":"write_latency_ms","observedValue":4,` +
|
||||||
|
`"detail":"ok","since":"2026-06-14T09:00:00Z"}],` +
|
||||||
|
`"checkedAt":"2026-06-14T10:05:00Z"}}`,
|
||||||
|
wantType: TypeHealthChecks,
|
||||||
|
eventBear: false,
|
||||||
|
check: func(t *testing.T, m ServerMessage) {
|
||||||
|
if m.Health == nil {
|
||||||
|
t.Fatalf("health.checks: Health is nil")
|
||||||
|
}
|
||||||
|
if m.Health.Overall != "DEGRADED" {
|
||||||
|
t.Fatalf("health.checks overall = %q, want DEGRADED", m.Health.Overall)
|
||||||
|
}
|
||||||
|
if len(m.Health.Subjects) != 2 {
|
||||||
|
t.Fatalf("health.checks subjects len = %d, want 2", len(m.Health.Subjects))
|
||||||
|
}
|
||||||
|
disk := m.Health.Subjects[0]
|
||||||
|
if disk.Subject != "DISK" || disk.Status != "DEGRADED" || disk.ObservedValue != 512000000 {
|
||||||
|
t.Fatalf("health.checks DISK subject: %+v", disk)
|
||||||
|
}
|
||||||
|
es := m.Health.Subjects[1]
|
||||||
|
if es.Subject != "EVENT_STORE" || es.Status != "HEALTHY" {
|
||||||
|
t.Fatalf("health.checks EVENT_STORE subject: %+v", es)
|
||||||
|
}
|
||||||
|
if m.Health.CheckedAt != "2026-06-14T10:05:00Z" {
|
||||||
|
t.Fatalf("health.checks checkedAt = %q", m.Health.CheckedAt)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "workflow.proposed carries candidate workflows",
|
name: "workflow.proposed carries candidate workflows",
|
||||||
json: `{"type":"workflow.proposed","sessionId":"s1","proposalId":"prop-1","prompt":"Run one?",` +
|
json: `{"type":"workflow.proposed","sessionId":"s1","proposalId":"prop-1","prompt":"Run one?",` +
|
||||||
@@ -248,6 +281,11 @@ func TestEncodeGoldenClientFrames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "GetHealthChecks encodes correctly",
|
||||||
|
frame: GetHealthChecks(),
|
||||||
|
wantType: clientPrefix + "GetHealthChecks",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "DiscardIdea carries the ideaId",
|
name: "DiscardIdea carries the ideaId",
|
||||||
frame: DiscardIdea("i1"),
|
frame: DiscardIdea("i1"),
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ const (
|
|||||||
TypeConfigSnapshot = "config.snapshot"
|
TypeConfigSnapshot = "config.snapshot"
|
||||||
TypeSessionStats = "session.stats"
|
TypeSessionStats = "session.stats"
|
||||||
TypeIdeaList = "idea.list"
|
TypeIdeaList = "idea.list"
|
||||||
|
TypeHealthChecks = "health.checks"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServerMessage is a flat decode of every server->client variant. Field names
|
// ServerMessage is a flat decode of every server->client variant. Field names
|
||||||
@@ -139,6 +140,9 @@ type ServerMessage struct {
|
|||||||
// session.stats — derived metrics for a session (reply to GetSessionStats)
|
// session.stats — derived metrics for a session (reply to GetSessionStats)
|
||||||
Stats *StatsDto `json:"stats"`
|
Stats *StatsDto `json:"stats"`
|
||||||
|
|
||||||
|
// health.checks — system health report (reply to GetHealthChecks)
|
||||||
|
Health *HealthDto `json:"health"`
|
||||||
|
|
||||||
// clarification.required — open questions a stage raised for the operator
|
// clarification.required — open questions a stage raised for the operator
|
||||||
Questions []ClarificationQuestionDto `json:"questions"`
|
Questions []ClarificationQuestionDto `json:"questions"`
|
||||||
|
|
||||||
@@ -242,6 +246,26 @@ type FailureMetricsDto struct {
|
|||||||
WorkflowFailures int64 `json:"workflowFailures"`
|
WorkflowFailures int64 `json:"workflowFailures"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HealthSubjectDto is one monitored subject's health status. Mirrors Kotlin HealthSubjectReport.
|
||||||
|
// status is "HEALTHY" or "DEGRADED"; since is an ISO-8601 string for the last edge event.
|
||||||
|
type HealthSubjectDto struct {
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Metric string `json:"metric"`
|
||||||
|
ObservedValue int64 `json:"observedValue"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Since string `json:"since"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthDto is the system health snapshot. Mirrors Kotlin HealthReport.
|
||||||
|
// overall is "HEALTHY" or "DEGRADED". subjects is empty when health monitoring is disabled
|
||||||
|
// or no probes have fired yet — the TUI renders a visible fallback in that case.
|
||||||
|
type HealthDto struct {
|
||||||
|
Overall string `json:"overall"`
|
||||||
|
Subjects []HealthSubjectDto `json:"subjects"`
|
||||||
|
CheckedAt string `json:"checkedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
type ConfigFieldDto struct {
|
type ConfigFieldDto struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -465,3 +489,8 @@ func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolN
|
|||||||
func Hello(workingDir string) []byte {
|
func Hello(workingDir string) []byte {
|
||||||
return encode("Hello", map[string]any{"workingDir": workingDir})
|
return encode("Hello", map[string]any{"workingDir": workingDir})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetHealthChecks requests the system health-check report (replied to with health.checks).
|
||||||
|
func GetHealthChecks() []byte {
|
||||||
|
return encode("GetHealthChecks", map[string]any{})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user