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