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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user