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 package com.correx.apps.cli
import com.correx.apps.cli.commands.ApproveCommand 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.ProviderCommand
import com.correx.apps.cli.commands.RunCommand import com.correx.apps.cli.commands.RunCommand
import com.correx.apps.cli.commands.SessionCommand import com.correx.apps.cli.commands.SessionCommand
@@ -25,4 +26,5 @@ fun buildCli(): CorrexCli = CorrexCli().subcommands(
StatusCommand(), StatusCommand(),
ProviderCommand(), ProviderCommand(),
UndoCommand(), 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"))
}
}
@@ -3,6 +3,8 @@ package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule import com.correx.apps.server.ServerModule
import com.correx.apps.server.protocol.SessionConfigDto import com.correx.apps.server.protocol.SessionConfigDto
import com.correx.apps.server.ws.SessionStreamHandler import com.correx.apps.server.ws.SessionStreamHandler
import com.correx.core.events.events.EventPayload
import com.correx.core.events.serialization.eventJson
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.utils.TypeId import com.correx.core.utils.TypeId
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
@@ -14,9 +16,25 @@ import io.ktor.server.routing.post
import io.ktor.server.routing.route import io.ktor.server.routing.route
import io.ktor.server.websocket.webSocket import io.ktor.server.websocket.webSocket
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.util.* import java.util.*
internal const val DEFAULT_EVENT_LIMIT = 200
@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,
)
@Serializable @Serializable
data class StartSessionRequest(val workflowId: String, val config: SessionConfigDto? = null) data class StartSessionRequest(val workflowId: String, val config: SessionConfigDto? = null)
@@ -34,9 +52,6 @@ data class SessionSummaryResponse(
@Serializable @Serializable
data class SessionStateResponse(val sessionId: String, val status: String, val createdAt: String?) data class SessionStateResponse(val sessionId: String, val status: String, val createdAt: String?)
@Serializable
data class EventResponse(val eventId: String, val sequence: Long, val sessionId: String)
@Serializable @Serializable
data class StartSessionResponse(val sessionId: String) data class StartSessionResponse(val sessionId: String)
@@ -140,16 +155,27 @@ private fun Route.getEventsRoute(module: ServerModule) {
get("/events") { get("/events") {
val id = call.parameters["id"] val id = call.parameters["id"]
?: return@get call.respond(HttpStatusCode.BadRequest, "Missing session id") ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing session id")
val from = call.request.queryParameters["from"]?.toLongOrNull() ?: 0L val fromSeq = call.request.queryParameters["fromSeq"]?.toLongOrNull() ?: 0L
val events = module.eventStore.readFrom(TypeId(id), from) val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: DEFAULT_EVENT_LIMIT
call.respond( val typeFilter = call.request.queryParameters["type"]
events.map { e -> val rows = module.eventStore.readFrom(TypeId(id), fromSeq)
EventResponse( .asSequence()
eventId = e.metadata.eventId.value, .map { stored ->
sequence = e.sequence, val payloadJson = eventJson.encodeToJsonElement(EventPayload.serializer(), stored.payload)
sessionId = e.metadata.sessionId.value, val type = (payloadJson as? JsonObject)?.get("type")?.jsonPrimitive?.content ?: "unknown"
EventRow(
sequence = stored.sequence,
eventId = stored.metadata.eventId.value,
type = type,
timestamp = stored.metadata.timestamp.toString(),
causationId = stored.metadata.causationId?.value,
correlationId = stored.metadata.correlationId?.value,
payload = payloadJson,
) )
} }
) .filter { typeFilter == null || it.type == typeFilter }
.take(limit)
.toList()
call.respond(rows)
} }
} }
@@ -0,0 +1,298 @@
package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule
import com.correx.apps.server.configureServer
import com.correx.apps.server.registry.ProviderRegistry
import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.apps.server.registry.WorkflowSummary
import com.correx.apps.server.undo.SessionUndoService
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.context.builder.DefaultContextPackBuilder
import com.correx.core.context.compression.DefaultContextCompressor
import com.correx.core.events.EventDispatcher
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.WorkspaceStateObservedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.inference.InferenceProjector
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRepository
import com.correx.core.inference.ProviderHealth
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationProjector
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.kernel.orchestration.OrchestratorEngines
import com.correx.core.kernel.orchestration.OrchestratorRepositories
import com.correx.core.kernel.retry.DefaultRetryCoordinator
import com.correx.core.events.types.ProviderId
import com.correx.core.risk.DefaultRiskAssessor
import com.correx.core.router.model.RouterConfig
import com.correx.core.sessions.DefaultSessionReducer
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.sessions.SessionProjector
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.DefaultTransitionResolver
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.infrastructure.InfrastructureModule
import com.correx.infrastructure.inference.commons.UnavailableProbe
import com.correx.infrastructure.tools.FileEditConfig
import com.correx.infrastructure.tools.FileReadConfig
import com.correx.infrastructure.tools.FileWriteConfig
import com.correx.infrastructure.tools.ShellConfig
import com.correx.infrastructure.tools.ToolConfig
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.testApplication
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.datetime.Clock
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class EventInspectRoutesTest {
private val testJson = Json { ignoreUnknownKeys = true }
private val s1 = SessionId("s1")
private fun storedEvent(payload: com.correx.core.events.events.EventPayload, seq: Long): StoredEvent =
StoredEvent(
metadata = EventMetadata(
eventId = EventId("e$seq"),
sessionId = s1,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = seq,
sessionSequence = seq,
payload = payload,
)
private val seedEvents = listOf(
storedEvent(WorkspaceStateObservedEvent(sessionId = s1, workspaceRoot = "/repo", stateKey = "git:abc", source = "git"), 1L),
storedEvent(WorkspaceStateObservedEvent(sessionId = s1, workspaceRoot = "/repo", stateKey = "git:def", source = "git"), 2L),
storedEvent(InitialIntentEvent(sessionId = s1, intent = "build the thing"), 3L),
)
private class SeededEventStore(private val events: List<StoredEvent>) : EventStore {
override suspend fun append(event: NewEvent): StoredEvent = error("unused")
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = error("unused")
override fun read(sessionId: SessionId): List<StoredEvent> = events.filter { it.metadata.sessionId == sessionId }
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
events.filter { it.metadata.sessionId == sessionId && it.sequence >= fromSequence }
override fun lastSequence(sessionId: SessionId): Long? = events.filter { it.metadata.sessionId == sessionId }.lastOrNull()?.sequence
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
override suspend fun lastGlobalSequence(): Long = 0L
override fun allEvents(): Sequence<StoredEvent> = emptySequence()
override fun allSessionIds(): Set<SessionId> = events.map { it.metadata.sessionId }.toSet()
}
private fun buildModule(eventStore: EventStore, tempDir: Path): ServerModule {
val artifactStore = object : ArtifactStore {
override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("noop")
override suspend fun get(id: ArtifactId): ByteArray? = null
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
}
val provider = InfrastructureModule.createLlamaCppProvider(
modelId = "test-model",
modelPath = "/dev/null",
baseUrl = "http://127.0.0.1:1",
)
val providerRegistry = InfrastructureModule.createProviderRegistry(listOf(provider))
val inferenceRouter = com.correx.core.inference.DefaultInferenceRouter(
providerRegistry,
com.correx.infrastructure.inference.FirstAvailableRoutingStrategy(),
)
val toolConfig = ToolConfig(
shell = ShellConfig(enabled = false, allowedExecutables = emptySet(), workingDir = tempDir),
fileRead = FileReadConfig(enabled = false, allowedPaths = setOf(tempDir)),
fileWrite = FileWriteConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir),
fileEdit = FileEditConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir),
)
val toolRegistry = InfrastructureModule.createToolRegistry(toolConfig)
val eventDispatcher = EventDispatcher(eventStore)
val toolExecutor = InfrastructureModule.createToolExecutor(
registry = toolRegistry,
eventDispatcher = eventDispatcher,
workDir = tempDir,
artifactStore = null,
)
val engines = OrchestratorEngines(
transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) },
contextPackBuilder = DefaultContextPackBuilder(DefaultContextCompressor()),
inferenceRouter = inferenceRouter,
validationPipeline = ValidationPipeline(validators = emptyList()),
approvalEngine = com.correx.core.approvals.domain.DefaultApprovalEngine(),
riskAssessor = DefaultRiskAssessor(),
toolRegistry = toolRegistry,
toolExecutor = toolExecutor,
)
val repositories = OrchestratorRepositories(
eventStore = eventStore,
inferenceRepository = InferenceRepository(DefaultEventReplayer(eventStore, InferenceProjector())),
orchestrationRepository = OrchestrationRepository(
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
),
sessionRepository = DefaultSessionRepository(
DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
),
artifactRepository = InfrastructureModule.createArtifactRepository(eventStore),
approvalRepository = InfrastructureModule.createApprovalRepository(eventStore),
)
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
retryCoordinator = DefaultRetryCoordinator(eventStore),
artifactStore = artifactStore,
tokenizer = provider.tokenizer,
decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore),
)
val routerFacade = InfrastructureModule.createRouterFacade(
eventStore = eventStore,
inferenceRouter = inferenceRouter,
config = RouterConfig(),
tokenizer = provider.tokenizer,
)
val noopProviderRegistry = object : ProviderRegistry {
override fun listAll() = emptyList<InferenceProvider>()
override suspend fun healthCheckAll() = emptyMap<ProviderId, ProviderHealth>()
}
val noopWorkflowRegistry = object : WorkflowRegistry {
override fun listAll(): List<WorkflowSummary> = emptyList()
override fun find(workflowId: String): WorkflowGraph? = null
}
val sessionUndoService = SessionUndoService(
eventStore = eventStore,
artifactStore = artifactStore,
bootRoots = setOf(tempDir),
)
return ServerModule(
orchestrator = orchestrator,
eventStore = eventStore,
artifactStore = artifactStore,
sessionRepository = repositories.sessionRepository,
workflowRegistry = noopWorkflowRegistry,
providerRegistry = noopProviderRegistry,
defaultOrchestrationConfig = OrchestrationConfig(sandboxRoot = tempDir),
routerFacade = routerFacade,
orchestrationRepository = repositories.orchestrationRepository,
approvalRepository = repositories.approvalRepository,
toolRegistry = toolRegistry,
sessionUndoService = sessionUndoService,
resourceProbe = UnavailableProbe,
)
}
@Test
fun `GET sessions id events returns all events ascending sequence`(@TempDir tempDir: Path) {
val eventStore = SeededEventStore(seedEvents)
val module = buildModule(eventStore, tempDir)
testApplication {
application { configureServer(module) }
val client = createClient {}
val response = client.get("/sessions/s1/events")
assertEquals(HttpStatusCode.OK, response.status)
val array = testJson.parseToJsonElement(response.bodyAsText()) as JsonArray
assertEquals(3, array.size)
assertEquals(1L, (array[0] as JsonObject)["sequence"]!!.jsonPrimitive.content.toLong())
assertEquals(2L, (array[1] as JsonObject)["sequence"]!!.jsonPrimitive.content.toLong())
assertEquals(3L, (array[2] as JsonObject)["sequence"]!!.jsonPrimitive.content.toLong())
}
}
@Test
fun `GET sessions id events with type filter returns matching events only`(@TempDir tempDir: Path) {
val eventStore = SeededEventStore(seedEvents)
val module = buildModule(eventStore, tempDir)
testApplication {
application { configureServer(module) }
val client = createClient {}
val response = client.get("/sessions/s1/events?type=InitialIntent")
assertEquals(HttpStatusCode.OK, response.status)
val array = testJson.parseToJsonElement(response.bodyAsText()) as JsonArray
assertEquals(1, array.size)
assertEquals("InitialIntent", (array[0] as JsonObject)["type"]!!.jsonPrimitive.content)
}
}
@Test
fun `GET sessions id events with fromSeq skips earlier sequences`(@TempDir tempDir: Path) {
val eventStore = SeededEventStore(seedEvents)
val module = buildModule(eventStore, tempDir)
testApplication {
application { configureServer(module) }
val client = createClient {}
val response = client.get("/sessions/s1/events?fromSeq=2")
assertEquals(HttpStatusCode.OK, response.status)
val array = testJson.parseToJsonElement(response.bodyAsText()) as JsonArray
assertEquals(2, array.size)
assertEquals(2L, (array[0] as JsonObject)["sequence"]!!.jsonPrimitive.content.toLong())
assertEquals(3L, (array[1] as JsonObject)["sequence"]!!.jsonPrimitive.content.toLong())
}
}
@Test
fun `GET sessions id events with limit truncates after filtering`(@TempDir tempDir: Path) {
val eventStore = SeededEventStore(seedEvents)
val module = buildModule(eventStore, tempDir)
testApplication {
application { configureServer(module) }
val client = createClient {}
val response = client.get("/sessions/s1/events?limit=1")
assertEquals(HttpStatusCode.OK, response.status)
val array = testJson.parseToJsonElement(response.bodyAsText()) as JsonArray
assertEquals(1, array.size)
assertEquals(1L, (array[0] as JsonObject)["sequence"]!!.jsonPrimitive.content.toLong())
val filtered = client.get("/sessions/s1/events?type=WorkspaceStateObserved&limit=1")
val filteredArray = testJson.parseToJsonElement(filtered.bodyAsText()) as JsonArray
assertEquals(1, filteredArray.size)
assertEquals(
"WorkspaceStateObserved",
(filteredArray[0] as JsonObject)["type"]!!.jsonPrimitive.content,
)
}
}
@Test
fun `GET sessions id events for unknown session returns empty array`(@TempDir tempDir: Path) {
val eventStore = SeededEventStore(seedEvents)
val module = buildModule(eventStore, tempDir)
testApplication {
application { configureServer(module) }
val client = createClient {}
val response = client.get("/sessions/no-such-session/events")
assertEquals(HttpStatusCode.OK, response.status)
val array = testJson.parseToJsonElement(response.bodyAsText()) as JsonArray
assertEquals(0, array.size)
}
}
}