feat(server,cli): session replay inspection — timeline + determinism digest

This commit is contained in:
2026-06-12 14:32:24 +04:00
parent a04cd1fa24
commit 64b58e3b05
5 changed files with 397 additions and 0 deletions
@@ -3,6 +3,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.ReplayCommand
import com.correx.apps.cli.commands.RunCommand
import com.correx.apps.cli.commands.SessionCommand
import com.correx.apps.cli.commands.StatusCommand
@@ -27,4 +28,5 @@ fun buildCli(): CorrexCli = CorrexCli().subcommands(
ProviderCommand(),
UndoCommand(),
EventsCommand(),
ReplayCommand(),
)
@@ -0,0 +1,95 @@
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.core.ProgramResult
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.client.statement.bodyAsText
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class TimelineEntryDto(
val sequence: Long,
val type: String,
val stageId: String?,
val summary: String,
)
@Serializable
data class ReplayReportDto(
val sessionId: String,
val eventCount: Long,
val timeline: List<TimelineEntryDto>,
val digest: String,
val deterministic: Boolean,
)
private const val SEPARATOR_WIDTH = 110
private fun renderReport(report: ReplayReportDto): String {
val lines = mutableListOf<String>()
lines += "Session: ${report.sessionId}"
lines += "Events: ${report.eventCount}"
lines += "Digest: ${report.digest}"
lines += "Deterministic: ${report.deterministic}"
lines += ""
lines += "%-6s %-35s %-20s %s".format("seq", "type", "stageId", "summary")
lines += "-".repeat(SEPARATOR_WIDTH)
for (entry in report.timeline) {
lines += "%-6d %-35s %-20s %s".format(
entry.sequence,
entry.type,
entry.stageId ?: "-",
entry.summary,
)
}
return lines.joinToString("\n")
}
class ReplayCommand : CliktCommand(name = "replay") {
private val sessionId by argument("sessionId")
private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT")
private val replayJson = Json { ignoreUnknownKeys = true }
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = (currentContext.findRoot().command as? CorrexCli)?.json ?: false
val url = "http://$host:$portInt/sessions/$sessionId/replay"
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(replayJson) }
}
val nonDeterministic = runCatching {
if (outputJson) {
val body = client.get(url).bodyAsText()
println(body)
val parsed = replayJson.decodeFromString<ReplayReportDto>(body)
!parsed.deterministic
} else {
val report = client.get(url).body<ReplayReportDto>()
println(renderReport(report))
!report.deterministic
}
}.getOrElse { e ->
System.err.println("Error fetching replay: ${e.message}")
false
}
client.close()
if (nonDeterministic) throw ProgramResult(1)
}
}