diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/EventsCommand.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/EventsCommand.kt index b072f927..8b8ead5b 100644 --- a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/EventsCommand.kt +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/EventsCommand.kt @@ -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): String { return (listOf(header) + lines).joinToString("\n") } +fun renderDot(rows: List): 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>() - 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}") diff --git a/apps/cli/src/test/kotlin/com/correx/apps/cli/commands/EventRenderTest.kt b/apps/cli/src/test/kotlin/com/correx/apps/cli/commands/EventRenderTest.kt index 2351a5de..fac81275 100644 --- a/apps/cli/src/test/kotlin/com/correx/apps/cli/commands/EventRenderTest.kt +++ b/apps/cli/src/test/kotlin/com/correx/apps/cli/commands/EventRenderTest.kt @@ -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") + } }