From fd67a6c68d6e26f84fe6c19bcb18bf44876d8439 Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 14 Jun 2026 18:57:51 +0400 Subject: [PATCH] feat(tui-go): health-checks pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../apps/server/protocol/ClientMessage.kt | 4 ++ .../apps/server/protocol/ServerMessage.kt | 16 +++++ .../apps/server/ws/GlobalStreamHandler.kt | 1 + .../correx/apps/server/ws/StreamQueries.kt | 14 +++++ .../ServerMessageSerializationTest.kt | 49 +++++++++++++++ apps/tui-go/internal/app/demo.go | 41 ++++++++++++ apps/tui-go/internal/app/model.go | 5 ++ apps/tui-go/internal/app/overlays.go | 62 +++++++++++++++++++ apps/tui-go/internal/app/server.go | 5 +- apps/tui-go/internal/app/update.go | 20 ++++++ apps/tui-go/internal/app/view.go | 4 +- apps/tui-go/internal/protocol/golden_test.go | 38 ++++++++++++ apps/tui-go/internal/protocol/protocol.go | 29 +++++++++ 13 files changed, 285 insertions(+), 3 deletions(-) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt index 1c796f69..0dce009d 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ClientMessage.kt @@ -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() diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt index 31625a57..138a2bd3 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt @@ -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 diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt index 41d3c9e5..d5017a42 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/GlobalStreamHandler.kt @@ -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")) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt index 0c1331ff..cf58b417 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ws/StreamQueries.kt @@ -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 — diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt index 8be8fa5c..7aeb68fa 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/protocol/ServerMessageSerializationTest.kt @@ -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(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( diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index 2163512c..0b355336 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -152,6 +152,14 @@ func PreviewFrame(kind string, w, h int) string { m.overlay = OverlayStats m.statsFor = "04a546aa" 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() } @@ -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 { return []Workflow{ {ID: "healthcheck", Description: "ping endpoints and report status"}, diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 190274b0..98c8edb5 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -53,6 +53,7 @@ const ( OverlayConfig OverlayStats OverlayIdeas + OverlayHealth ) // RouterEntry is one line in a session's conversation transcript. @@ -259,6 +260,10 @@ type Model struct { ideasIndex int ideasLoading bool + // health checks (OverlayHealth) — system-scoped, populated by the health.checks reply + health *protocol.HealthDto + healthLoading bool + // command palette paletteFilter string paletteIndex int diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index 10cd6b4d..3e2d2362 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -61,6 +61,8 @@ func (m Model) renderOverlay(base string) string { return m.center(m.statsModal()) case OverlayIdeas: return m.center(m.ideasModal()) + case OverlayHealth: + return m.center(m.healthModal()) } return base } @@ -750,6 +752,66 @@ func (m Model) statsModal() 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. func shortID(id string) string { if len(id) <= 14 { diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index dd18b255..a323028d 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -322,6 +322,9 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { if m.ideasIndex >= len(m.ideas) { m.ideasIndex = 0 } + case protocol.TypeHealthChecks: + m.health = msg.Health + m.healthLoading = false case protocol.TypeConfigSnapshot: m.configFields = msg.ConfigFields m.configRestart = msg.ConfigRestartRequired @@ -367,7 +370,7 @@ func sessionIDOf(msg protocol.ServerMessage) string { protocol.TypeWorkflowList, protocol.TypeRouterResponse, protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus, protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats, - protocol.TypeIdeaList: + protocol.TypeIdeaList, protocol.TypeHealthChecks: return "" default: return msg.SessionID diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 8d8c5edb..83e37cbd 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -178,6 +178,8 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.openArtifacts() case "I": m.openIdeas() + case "H": + m.openHealth() case "S": m.openStats() case "g": @@ -444,6 +446,10 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { if runeIs(k, "S") { m.overlay = OverlayNone } + case OverlayHealth: + if runeIs(k, "H") { + m.overlay = OverlayNone + } case OverlayIdeas: switch { case k.Type == tea.KeyUp || runeIs(k, "k"): @@ -513,6 +519,17 @@ func (m *Model) openStats() { 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. func (m *Model) openModelsOverlay() { m.overlay = OverlayModels @@ -566,6 +583,7 @@ func paletteCommands() []paletteCmd { {"events", "event inspector", "browse the event stream"}, {"artifacts", "view artifacts", "browse this session's artifacts"}, {"stats", "session stats", "metrics for the selected session"}, + {"health", "health checks", "system health probe status"}, {"config", "edit config", "view / change correx settings"}, {"mode", "toggle mode", "switch chat / steering"}, {"cancel", "cancel session", "stop the selected session"}, @@ -605,6 +623,8 @@ func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { m.openArtifacts() case "stats": m.openStats() + case "health": + m.openHealth() case "config": m.openConfig() case "mode": diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index de013c8d..a385d8a0 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -238,9 +238,9 @@ func (m Model) renderFooter() string { } default: // StateInSession 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 { - 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 { hints = append(hints, hint("a", "approval (pending)")) diff --git a/apps/tui-go/internal/protocol/golden_test.go b/apps/tui-go/internal/protocol/golden_test.go index 9e77cb70..428b4022 100644 --- a/apps/tui-go/internal/protocol/golden_test.go +++ b/apps/tui-go/internal/protocol/golden_test.go @@ -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", 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", frame: DiscardIdea("i1"), diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index b7e3e41d..73d21080 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -55,6 +55,7 @@ const ( TypeConfigSnapshot = "config.snapshot" TypeSessionStats = "session.stats" TypeIdeaList = "idea.list" + TypeHealthChecks = "health.checks" ) // 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) 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 Questions []ClarificationQuestionDto `json:"questions"` @@ -242,6 +246,26 @@ type FailureMetricsDto struct { 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 { Key string `json:"key"` Type string `json:"type"` @@ -465,3 +489,8 @@ func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolN func Hello(workingDir string) []byte { 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{}) +}