feat(server,cli): event stream inspector — /sessions/{id}/events + correx events
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user