feat(cli): causation-graph DOT export for event inspector
This commit is contained in:
@@ -5,6 +5,7 @@ import com.correx.apps.cli.DEFAULT_PORT
|
||||
import com.github.ajalt.clikt.core.CliktCommand
|
||||
import com.github.ajalt.clikt.parameters.arguments.argument
|
||||
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 io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
@@ -43,6 +44,19 @@ fun renderEventRows(rows: List<EventRow>): String {
|
||||
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") {
|
||||
private val sessionId by argument("sessionId")
|
||||
private val type by option("--type")
|
||||
@@ -50,6 +64,7 @@ class EventsCommand : CliktCommand(name = "events") {
|
||||
private val limit by option("--limit")
|
||||
private val host by option("--host").default("localhost")
|
||||
private val port by option("--port").default("$DEFAULT_PORT")
|
||||
private val dot by option("--dot").flag()
|
||||
|
||||
private val eventsJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@@ -71,10 +86,10 @@ class EventsCommand : CliktCommand(name = "events") {
|
||||
|
||||
runCatching {
|
||||
val rows = client.get(url).body<List<EventRow>>()
|
||||
if (outputJson) {
|
||||
println(eventsJson.encodeToString(ListSerializer(EventRow.serializer()), rows))
|
||||
} else {
|
||||
println(renderEventRows(rows))
|
||||
when {
|
||||
dot -> println(renderDot(rows))
|
||||
outputJson -> println(eventsJson.encodeToString(ListSerializer(EventRow.serializer()), rows))
|
||||
else -> println(renderEventRows(rows))
|
||||
}
|
||||
}.getOrElse { e ->
|
||||
System.err.println("Error fetching events: ${e.message}")
|
||||
|
||||
@@ -61,4 +61,87 @@ class EventRenderTest {
|
||||
val output = renderEventRows(emptyList())
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user