feat(server,cli): event stream inspector — /sessions/{id}/events + correx events

This commit is contained in:
2026-06-12 14:25:31 +04:00
parent a7d5211f3f
commit a04cd1fa24
5 changed files with 487 additions and 12 deletions
@@ -1,6 +1,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.ProviderCommand
import com.correx.apps.cli.commands.RunCommand
import com.correx.apps.cli.commands.SessionCommand
@@ -25,4 +26,5 @@ fun buildCli(): CorrexCli = CorrexCli().subcommands(
StatusCommand(),
ProviderCommand(),
UndoCommand(),
EventsCommand(),
)
@@ -0,0 +1,85 @@
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.arguments.argument
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.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
@Serializable
data class EventRow(
val sequence: Long,
val eventId: String,
val type: String,
val timestamp: String,
val causationId: String?,
val correlationId: String?,
val payload: JsonElement,
)
fun renderEventRows(rows: List<EventRow>): String {
if (rows.isEmpty()) return "(no events)"
val header = "%-6s %-40s %-30s %s".format("seq", "type", "timestamp", "causation→")
val lines = rows.map { r ->
"%-6d %-40s %-30s %s".format(
r.sequence,
r.type,
r.timestamp,
r.causationId ?: "-",
)
}
return (listOf(header) + lines).joinToString("\n")
}
class EventsCommand : CliktCommand(name = "events") {
private val sessionId by argument("sessionId")
private val type by option("--type")
private val fromSeq by option("--from-seq")
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 eventsJson = Json { ignoreUnknownKeys = true }
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = (currentContext.findRoot().command as? CorrexCli)?.json ?: false
val params = buildList {
type?.let { add("type=$it") }
fromSeq?.let { add("fromSeq=$it") }
limit?.let { add("limit=$it") }
}.joinToString("&")
val query = if (params.isNotEmpty()) "?$params" else ""
val url = "http://$host:$portInt/sessions/$sessionId/events$query"
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(eventsJson) }
}
runCatching {
val rows = client.get(url).body<List<EventRow>>()
if (outputJson) {
println(eventsJson.encodeToString(ListSerializer(EventRow.serializer()), rows))
} else {
println(renderEventRows(rows))
}
}.getOrElse { e ->
System.err.println("Error fetching events: ${e.message}")
}
client.close()
}
}
@@ -0,0 +1,64 @@
package com.correx.apps.cli.commands
import kotlinx.serialization.json.JsonObject
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class EventRenderTest {
private val rows = 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 = "c1",
correlationId = null,
payload = JsonObject(emptyMap()),
),
)
@Test
fun `renderEventRows includes sequence numbers in order`() {
val output = renderEventRows(rows)
val lines = output.lines().filter { it.isNotBlank() }
// header + 2 data rows
assertTrue(lines.size >= 3)
assertTrue(lines[1].trimStart().startsWith("1"))
assertTrue(lines[2].trimStart().startsWith("2"))
}
@Test
fun `renderEventRows includes type column content`() {
val output = renderEventRows(rows)
assertTrue(output.contains("WorkspaceStateObserved"))
assertTrue(output.contains("InitialIntent"))
}
@Test
fun `renderEventRows shows causation id when present`() {
val output = renderEventRows(rows)
assertTrue(output.contains("c1"))
}
@Test
fun `renderEventRows shows dash when causation id absent`() {
val output = renderEventRows(rows)
assertTrue(output.contains("-"))
}
@Test
fun `renderEventRows returns no-events message for empty list`() {
val output = renderEventRows(emptyList())
assertTrue(output.contains("no events"))
}
}