feat(cli): causation-graph DOT export for event inspector

This commit is contained in:
2026-06-12 19:17:26 +04:00
parent 3dd9c52df0
commit 4422aa8b7a
2 changed files with 102 additions and 4 deletions
@@ -5,6 +5,7 @@ import com.correx.apps.cli.DEFAULT_PORT
import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.options.default import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.options.option
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
@@ -43,6 +44,19 @@ fun renderEventRows(rows: List<EventRow>): String {
return (listOf(header) + lines).joinToString("\n") return (listOf(header) + lines).joinToString("\n")
} }
fun renderDot(rows: List<EventRow>): String = buildString {
appendLine("digraph session {")
rows.forEach { r ->
appendLine(""""${r.eventId}" [label="${r.type} #${r.sequence}"]""")
}
rows.forEach { r ->
r.causationId?.let { causation ->
appendLine(""""${r.eventId}" -> "$causation"""")
}
}
append("}")
}
class EventsCommand : CliktCommand(name = "events") { class EventsCommand : CliktCommand(name = "events") {
private val sessionId by argument("sessionId") private val sessionId by argument("sessionId")
private val type by option("--type") private val type by option("--type")
@@ -50,6 +64,7 @@ class EventsCommand : CliktCommand(name = "events") {
private val limit by option("--limit") private val limit by option("--limit")
private val host by option("--host").default("localhost") private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT") private val port by option("--port").default("$DEFAULT_PORT")
private val dot by option("--dot").flag()
private val eventsJson = Json { ignoreUnknownKeys = true } private val eventsJson = Json { ignoreUnknownKeys = true }
@@ -71,10 +86,10 @@ class EventsCommand : CliktCommand(name = "events") {
runCatching { runCatching {
val rows = client.get(url).body<List<EventRow>>() val rows = client.get(url).body<List<EventRow>>()
if (outputJson) { when {
println(eventsJson.encodeToString(ListSerializer(EventRow.serializer()), rows)) dot -> println(renderDot(rows))
} else { outputJson -> println(eventsJson.encodeToString(ListSerializer(EventRow.serializer()), rows))
println(renderEventRows(rows)) else -> println(renderEventRows(rows))
} }
}.getOrElse { e -> }.getOrElse { e ->
System.err.println("Error fetching events: ${e.message}") System.err.println("Error fetching events: ${e.message}")
@@ -61,4 +61,87 @@ class EventRenderTest {
val output = renderEventRows(emptyList()) val output = renderEventRows(emptyList())
assertTrue(output.contains("no events")) assertTrue(output.contains("no events"))
} }
@Test
fun `renderDot generates correct node lines for all events`() {
val testRows = listOf(
EventRow(
sequence = 1L,
eventId = "e1",
type = "WorkspaceStateObserved",
timestamp = "2026-06-12T10:00:00Z",
causationId = null,
correlationId = null,
payload = JsonObject(emptyMap()),
),
EventRow(
sequence = 2L,
eventId = "e2",
type = "InitialIntent",
timestamp = "2026-06-12T10:01:00Z",
causationId = "e1",
correlationId = null,
payload = JsonObject(emptyMap()),
),
EventRow(
sequence = 3L,
eventId = "e3",
type = "SomeEvent",
timestamp = "2026-06-12T10:02:00Z",
causationId = null,
correlationId = null,
payload = JsonObject(emptyMap()),
),
)
val output = renderDot(testRows)
// Check structure
assertTrue(output.startsWith("digraph session {"), "Output should start with digraph session {")
assertTrue(output.endsWith("}"), "Output should end with }")
// Check node lines with labels (eventId and type #sequence)
assertTrue(output.contains(""""e1" [label="WorkspaceStateObserved #1"]"""), "Should contain e1 node with label")
assertTrue(output.contains(""""e2" [label="InitialIntent #2"]"""), "Should contain e2 node with label")
assertTrue(output.contains(""""e3" [label="SomeEvent #3"]"""), "Should contain e3 node with label")
// Check edge for e2 -> e1 (causation)
assertTrue(output.contains(""""e2" -> "e1""""), "Should contain edge from e2 to its causation e1")
// Check that e1 and e3 (with null causation) don't have outgoing edges
assertTrue(!output.contains(""""e1" -> """"), "e1 should not have outgoing edge (no causation)")
assertTrue(!output.contains(""""e3" -> """"), "e3 should not have outgoing edge (no causation)")
}
@Test
fun `renderDot returns empty digraph for no events`() {
val output = renderDot(emptyList())
assertTrue(output.startsWith("digraph session {"), "Should start with digraph session {")
assertTrue(output.endsWith("}"), "Should end with }")
}
@Test
fun `renderDot handles events with causationId matching eventId`() {
val testRows = listOf(
EventRow(
sequence = 1L,
eventId = "e1",
type = "Event1",
timestamp = "2026-06-12T10:00:00Z",
causationId = null,
correlationId = null,
payload = JsonObject(emptyMap()),
),
EventRow(
sequence = 2L,
eventId = "e2",
type = "Event2",
timestamp = "2026-06-12T10:01:00Z",
causationId = "e1",
correlationId = null,
payload = JsonObject(emptyMap()),
),
)
val output = renderDot(testRows)
assertTrue(output.contains(""""e2" -> "e1""""), "Should correctly reference causation by ID")
}
} }