feat(server,cli): event-sourced health checks — disk watermark probe (observability §4)

Health states are events. A HealthMonitor polls registered HealthProbes on an
interval and appends HealthDegraded/HealthRestored only on a status transition
(hysteresis), so health is observable, replayable, and renderable like every
other fact in the log. The probe reads nondeterministic environment state and
records it at observe-time (Invariant #9); HealthProjection rebuilds current
health by replay, never re-probing (Invariant #8). Monitor is live-only, started
in ServerModule.start alongside narration; seeded from the projection so a
restart does not re-emit a degraded the log already records.

First concrete probe: DiskWatermarkProbe — event-log + CAS size and growth rate,
warning before the 2am surprise. Read surface mirrors `correx stats`:
GET /health/checks replays the system session and `correx health` renders it.
Health events ride the reserved __system__ session, filtered from operator
session listings.

LLAMA_SERVER (liveness + tokens/sec trend) and EVENT_STORE (append/fsync latency)
probes plug into the same HealthProbe framework as follow-ups.
This commit is contained in:
2026-06-13 22:19:33 +04:00
parent 3467826d4a
commit f8fd2601a8
19 changed files with 949 additions and 3 deletions
@@ -2,6 +2,7 @@ package com.correx.apps.cli
import com.correx.apps.cli.commands.ApproveCommand
import com.correx.apps.cli.commands.EventsCommand
import com.correx.apps.cli.commands.HealthCommand
import com.correx.apps.cli.commands.ProviderCommand
import com.correx.apps.cli.commands.ReplayCommand
import com.correx.apps.cli.commands.RunCommand
@@ -31,4 +32,5 @@ fun buildCli(): CorrexCli = CorrexCli().subcommands(
EventsCommand(),
ReplayCommand(),
StatsCommand(),
HealthCommand(),
)
@@ -0,0 +1,79 @@
package com.correx.apps.cli.commands
import com.correx.apps.cli.CorrexCli
import com.correx.apps.cli.DEFAULT_PORT
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.option
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class HealthSubjectReportDto(
val subject: String,
val status: String,
val metric: String,
val observedValue: Long = 0,
val detail: String = "",
val since: String = "",
)
@Serializable
data class HealthReportDto(
val overall: String = "HEALTHY",
val subjects: List<HealthSubjectReportDto> = emptyList(),
val checkedAt: String = "",
)
fun renderHealth(report: HealthReportDto): String {
val lines = mutableListOf<String>()
lines += "Overall: ${report.overall}"
if (report.checkedAt.isNotEmpty()) lines += "Checked: ${report.checkedAt}"
lines += ""
if (report.subjects.isEmpty()) {
lines += "No health checks recorded yet."
return lines.joinToString("\n")
}
for (s in report.subjects) {
lines += "%-14s %-9s %s".format(s.subject, s.status, s.detail)
if (s.since.isNotEmpty()) lines += " since ${s.since}"
}
return lines.joinToString("\n")
}
class HealthCommand : CliktCommand(name = "health") {
private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT")
private val healthJson = Json { ignoreUnknownKeys = true }
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = (currentContext.findRoot().command as? CorrexCli)?.json ?: false
val url = "http://$host:$portInt/health/checks"
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(healthJson) }
}
runCatching {
if (outputJson) {
println(client.get(url).bodyAsText())
} else {
println(renderHealth(client.get(url).body()))
}
}.onFailure { e ->
System.err.println("Error fetching health: ${e.message}")
}
client.close()
}
}
@@ -0,0 +1,37 @@
package com.correx.apps.cli.commands
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class HealthRenderTest {
@Test
fun `render shows overall and each subject row`() {
val report = HealthReportDto(
overall = "DEGRADED",
subjects = listOf(
HealthSubjectReportDto(
subject = "DISK",
status = "DEGRADED",
metric = "total_bytes",
observedValue = 6_000_000_000,
detail = "event log + CAS at 5.6 GiB (warn ≥ 4.7 GiB)",
since = "2026-06-13T01:00:00Z",
),
),
checkedAt = "2026-06-13T02:00:00Z",
)
val out = renderHealth(report)
assertTrue(out.contains("Overall: DEGRADED"), "overall missing")
assertTrue(out.contains("DISK"), "subject row missing")
assertTrue(out.contains("5.6 GiB"), "detail missing")
assertTrue(out.contains("since 2026-06-13T01:00:00Z"), "since line missing")
}
@Test
fun `render handles the empty no-checks case`() {
val out = renderHealth(HealthReportDto(overall = "HEALTHY"))
assertTrue(out.contains("Overall: HEALTHY"))
assertTrue(out.contains("No health checks recorded yet."), "empty hint missing")
}
}