epic-13: add cli and tui entry point, finish epic.

This commit is contained in:
2026-05-16 13:37:58 +04:00
parent 72d20726ce
commit 2207a37549
60 changed files with 2896 additions and 41 deletions
+7
View File
@@ -1,5 +1,6 @@
plugins {
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
id 'application'
}
@@ -9,4 +10,10 @@ application {
dependencies {
implementation "com.github.ajalt.clikt:clikt:5.0.1"
implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-cio:$ktor_version"
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
implementation "io.ktor:ktor-client-websockets:$ktor_version"
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
}
@@ -0,0 +1,3 @@
package com.correx.apps.cli
internal const val DEFAULT_PORT = 8080
@@ -0,0 +1,26 @@
package com.correx.apps.cli
import com.correx.apps.cli.commands.ApproveCommand
import com.correx.apps.cli.commands.ProviderCommand
import com.correx.apps.cli.commands.RunCommand
import com.correx.apps.cli.commands.SessionCommand
import com.correx.apps.cli.commands.StatusCommand
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.subcommands
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
class CorrexCli : CliktCommand(name = "correx") {
val json by option("--json", help = "Output machine-readable JSON").flag()
val quiet by option("--quiet", help = "Output errors only").flag()
override fun run() = Unit
}
fun buildCli(): CorrexCli = CorrexCli().subcommands(
RunCommand(),
SessionCommand(),
ApproveCommand(),
StatusCommand(),
ProviderCommand(),
)
@@ -1,5 +1,7 @@
package com.correx.apps.cli
fun main() {
println("correx :: apps/cli")
import com.github.ajalt.clikt.core.main
fun main(args: Array<String>) {
buildCli().main(args)
}
@@ -0,0 +1,67 @@
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 com.github.ajalt.clikt.parameters.options.required
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
private data class ApprovalRequest(
val decision: String,
val steeringNote: String?,
)
class ApproveCommand : CliktCommand(name = "approve") {
private val sessionId by argument("SESSION_ID")
private val decision by option("--decision", help = "approve | reject | steer").required()
private val note by option("--note", help = "Steering note")
private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT")
private val approveJson = Json { ignoreUnknownKeys = true }
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = (currentContext.findRoot().command as? CorrexCli)?.json ?: false
val decisionUpper = decision.uppercase()
if (decisionUpper !in setOf("APPROVE", "REJECT", "STEER")) {
System.err.println("Invalid decision: $decision. Must be approve, reject, or steer.")
return@runBlocking
}
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(approveJson) }
}
runCatching {
client.post("http://$host:$portInt/sessions/$sessionId/approve") {
contentType(ContentType.Application.Json)
setBody(ApprovalRequest(decision = decisionUpper, steeringNote = note))
}
if (outputJson) {
println("{\"sessionId\": \"$sessionId\", \"decision\": \"$decisionUpper\"}")
} else {
println("Approval decision '$decisionUpper' sent for session $sessionId.")
}
}.getOrElse { e ->
System.err.println("Error sending approval: ${e.message}")
}
client.close()
}
}
@@ -0,0 +1,63 @@
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.subcommands
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.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
class ProviderCommand : CliktCommand(name = "provider") {
override fun run() = Unit
init {
subcommands(ProviderListCommand())
}
}
class ProviderListCommand : CliktCommand(name = "list") {
private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT")
private val providerJson = Json { ignoreUnknownKeys = true }
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = (currentContext.findRoot().command as? CorrexCli)?.json ?: false
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(providerJson) }
}
runCatching {
val resp = client.get("http://$host:$portInt/providers")
val body = resp.body<JsonArray>()
if (outputJson) {
println(body.toString())
} else {
body.forEach { el ->
val obj = el as? JsonObject ?: return@forEach
val providerId = obj["providerId"]?.jsonPrimitive?.content ?: ""
val status = obj["status"]?.jsonPrimitive?.content ?: ""
val load = obj["loadPercent"]?.jsonPrimitive?.content ?: "?"
println("$providerId status=$status load=${load}%")
}
}
}.getOrElse { e ->
System.err.println("Error fetching providers: ${e.message}")
}
client.close()
}
}
@@ -0,0 +1,256 @@
package com.correx.apps.cli.commands
import com.correx.apps.cli.CorrexCli
import com.correx.apps.cli.DEFAULT_PORT
import com.correx.apps.cli.ws.CliWsClient
import com.github.ajalt.clikt.core.CliktCommand
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 com.github.ajalt.clikt.parameters.options.required
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.plugins.websocket.WebSockets
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.send
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@Serializable
private data class StartSessionRequest(val workflowId: String, val sessionId: String?)
@Serializable
private data class StartSessionResponse(val sessionId: String)
@Serializable
private data class ApprovalResponsePayload(
val type: String,
val requestId: String,
val decision: String,
val steeringNote: String?,
)
private data class RunContext(
val sid: String,
val outputJson: Boolean,
val autoApprove: Boolean,
val isTty: Boolean,
)
private data class ApprovalContext(
val session: WebSocketSession,
val requestId: String,
val toolName: String,
val preview: String?,
)
class RunCommand : CliktCommand(name = "run") {
private val workflow by option("--workflow", help = "Path to workflow definition").required()
private val sessionId by option("--session", help = "Existing session ID to resume")
private val autoApprove by option("--auto-approve", help = "Auto-approve all approval requests").flag()
private val host by option("--host", help = "Server host").default("localhost")
private val port by option("--port", help = "Server port").default("$DEFAULT_PORT")
private val serializer = Json { ignoreUnknownKeys = true; classDiscriminator = "type" }
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = (currentContext.findRoot().command as? CorrexCli)?.json ?: false
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(serializer) }
install(WebSockets)
}
val sid = startSession(client, host, portInt, outputJson) ?: run {
client.close()
return@runBlocking
}
printLine("session_started", mapOf("sessionId" to sid, "workflow" to workflow), outputJson)
val ctx = RunContext(sid, outputJson, autoApprove, System.console() != null)
var exitCode = 0
val wsClient = CliWsClient(client)
wsClient.sessionStream(host, portInt, sid) { session, raw ->
val msgJson = runCatching { serializer.parseToJsonElement(raw).jsonObject }.getOrNull()
val keepGoing = handleMessage(session, msgJson, ctx) { exitCode = it }
keepGoing
}
client.close()
if (exitCode != 0) {
throw SystemExitException(exitCode)
}
}
private suspend fun startSession(
client: HttpClient,
host: String,
portInt: Int,
outputJson: Boolean,
): String? = runCatching {
val resp = client.post("http://$host:$portInt/sessions") {
contentType(ContentType.Application.Json)
setBody(StartSessionRequest(workflowId = workflow, sessionId = sessionId))
}
resp.body<StartSessionResponse>().sessionId
}.getOrElse { e ->
printLine("error", mapOf("message" to "Failed to start session: ${e.message}"), outputJson)
null
}
@Suppress("CyclomaticComplexMethod", "LongMethod")
private suspend fun handleMessage(
session: WebSocketSession,
msgJson: JsonObject?,
ctx: RunContext,
setExitCode: (Int) -> Unit,
): Boolean {
val type = msgJson?.get("type")?.jsonPrimitive?.content ?: "unknown"
fun field(key: String): String? = msgJson?.get(key)?.jsonPrimitive?.content
return when (type) {
"SessionCompleted" -> {
printLine("session_completed", mapOf("sessionId" to ctx.sid), ctx.outputJson)
setExitCode(0)
false
}
"SessionFailed" -> {
val reason = field("reason") ?: "unknown"
printLine("session_failed", mapOf("sessionId" to ctx.sid, "reason" to reason), ctx.outputJson)
setExitCode(1)
false
}
"StageStarted" -> {
printLine("stage_started", mapOf("stageId" to (field("stageId") ?: "")), ctx.outputJson)
true
}
"StageCompleted" -> {
printLine("stage_completed", mapOf("stageId" to (field("stageId") ?: "")), ctx.outputJson)
true
}
"StageFailed" -> {
printLine(
"stage_failed",
mapOf("stageId" to (field("stageId") ?: ""), "reason" to (field("reason") ?: "")),
ctx.outputJson,
)
true
}
"ToolStarted" -> {
printLine("tool_started", mapOf("tool" to (field("toolName") ?: "")), ctx.outputJson)
true
}
"ToolCompleted" -> {
printLine("tool_completed", mapOf("tool" to (field("toolName") ?: "")), ctx.outputJson)
true
}
"ToolFailed" -> {
printLine(
"tool_failed",
mapOf("tool" to (field("toolName") ?: ""), "reason" to (field("reason") ?: "")),
ctx.outputJson,
)
true
}
"ToolRejected" -> {
printLine(
"tool_rejected",
mapOf("tool" to (field("toolName") ?: ""), "reason" to (field("reason") ?: "")),
ctx.outputJson,
)
true
}
"ApprovalRequired" -> {
val approvalCtx = ApprovalContext(
session = session,
requestId = field("requestId") ?: "",
toolName = field("toolName") ?: "",
preview = field("preview"),
)
handleApproval(approvalCtx, ctx, setExitCode)
}
else -> true
}
}
private suspend fun handleApproval(
approvalCtx: ApprovalContext,
ctx: RunContext,
setExitCode: (Int) -> Unit,
): Boolean {
val decision = resolveDecision(ctx, approvalCtx.toolName, approvalCtx.preview)
val steeringNote = if (decision == "STEER" && ctx.isTty) {
print("Steering note: ")
readLine()
} else {
null
}
val response = ApprovalResponsePayload(
type = "ApprovalResponse",
requestId = approvalCtx.requestId,
decision = decision,
steeringNote = steeringNote,
)
approvalCtx.session.send(serializer.encodeToString(response))
return if (decision == "REJECT" && !ctx.autoApprove && !ctx.isTty) {
setExitCode(2)
false
} else {
true
}
}
private fun resolveDecision(ctx: RunContext, toolName: String, preview: String?): String = when {
ctx.autoApprove -> "APPROVE"
!ctx.isTty -> {
System.err.println(
"WARNING: approval required but no TTY and --auto-approve not set — denying"
)
"REJECT"
}
else -> promptApproval(toolName, preview)
}
private fun promptApproval(toolName: String, preview: String?): String {
println("\nApproval required for tool: $toolName")
preview?.let { println("Preview: $it") }
print("[a]pprove / [r]eject / [s]teer: ")
return when (readLine()?.trim()?.lowercase()) {
"a", "approve" -> "APPROVE"
"s", "steer" -> "STEER"
else -> "REJECT"
}
}
private fun printLine(event: String, data: Map<String, String?>, outputJson: Boolean) {
if (outputJson) {
val fields = data.entries.joinToString(", ") { (k, v) ->
"\"$k\": ${if (v == null) "null" else "\"$v\""}"
}
println("{\"event\": \"$event\", $fields}")
} else {
val details = data.entries.joinToString(" ") { (k, v) -> "$k=${v ?: "null"}" }
println("[$event] $details")
}
}
}
class SystemExitException(val code: Int) : Exception("exit $code")
@@ -0,0 +1,164 @@
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.subcommands
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.request.post
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
class SessionCommand : CliktCommand(name = "session") {
override fun run() = Unit
init {
subcommands(
SessionListCommand(),
SessionResumeCommand(),
SessionCancelCommand(),
SessionEventsCommand(),
)
}
}
private fun parentJsonFlag(command: CliktCommand): Boolean =
(command.currentContext.findRoot().command as? CorrexCli)?.json ?: false
private val sessionJson = Json { ignoreUnknownKeys = true }
class SessionListCommand : CliktCommand(name = "list") {
private val limit by option("--limit", help = "Max sessions to return").default("20")
private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT")
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = parentJsonFlag(this@SessionListCommand)
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(sessionJson) }
}
runCatching {
val resp = client.get("http://$host:$portInt/sessions?limit=$limit")
val body = resp.body<JsonArray>()
if (outputJson) {
println(body.toString())
} else {
body.forEach { el ->
val obj = el as? JsonObject ?: return@forEach
val sid = obj["sessionId"]?.jsonPrimitive?.content ?: ""
val wf = obj["workflowId"]?.jsonPrimitive?.content ?: ""
val status = obj["status"]?.jsonPrimitive?.content ?: ""
println("$sid workflow=$wf status=$status")
}
}
}.getOrElse { e ->
System.err.println("Error listing sessions: ${e.message}")
}
client.close()
}
}
class SessionResumeCommand : CliktCommand(name = "resume") {
private val id by argument("SESSION_ID")
private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT")
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = parentJsonFlag(this@SessionResumeCommand)
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(sessionJson) }
}
runCatching {
client.post("http://$host:$portInt/sessions/$id/resume")
if (outputJson) {
println("{\"sessionId\": \"$id\", \"action\": \"resumed\"}")
} else {
println("Session $id resumed.")
}
}.getOrElse { e ->
System.err.println("Error resuming session: ${e.message}")
}
client.close()
}
}
class SessionCancelCommand : CliktCommand(name = "cancel") {
private val id by argument("SESSION_ID")
private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT")
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = parentJsonFlag(this@SessionCancelCommand)
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(sessionJson) }
}
runCatching {
client.post("http://$host:$portInt/sessions/$id/cancel")
if (outputJson) {
println("{\"sessionId\": \"$id\", \"action\": \"cancelled\"}")
} else {
println("Session $id cancelled.")
}
}.getOrElse { e ->
System.err.println("Error cancelling session: ${e.message}")
}
client.close()
}
}
class SessionEventsCommand : CliktCommand(name = "events") {
private val id by argument("SESSION_ID")
private val from by option("--from", help = "Start from event ID")
private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT")
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = parentJsonFlag(this@SessionEventsCommand)
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(sessionJson) }
}
val query = from?.let { "?from=$it" } ?: ""
runCatching {
val resp = client.get("http://$host:$portInt/sessions/$id/events$query")
val body = resp.body<JsonArray>()
if (outputJson) {
println(body.toString())
} else {
body.forEachIndexed { idx, el ->
println("[$idx] $el")
}
}
}.getOrElse { e ->
System.err.println("Error fetching events: ${e.message}")
}
client.close()
}
}
@@ -0,0 +1,49 @@
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.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.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
class StatusCommand : CliktCommand(name = "status") {
private val host by option("--host").default("localhost")
private val port by option("--port").default("$DEFAULT_PORT")
private val statusJson = Json { ignoreUnknownKeys = true }
override fun run(): Unit = runBlocking {
val portInt = port.toIntOrNull() ?: DEFAULT_PORT
val outputJson = (currentContext.findRoot().command as? CorrexCli)?.json ?: false
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(statusJson) }
}
runCatching {
val resp = client.get("http://$host:$portInt/status")
val body = resp.body<JsonObject>()
if (outputJson) {
println(body.toString())
} else {
body.entries.forEach { (k, v) ->
println("$k: ${v.jsonPrimitive.content}")
}
}
}.getOrElse { e ->
System.err.println("Error fetching status: ${e.message}")
}
client.close()
}
}
@@ -0,0 +1,25 @@
package com.correx.apps.cli.ws
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.readText
class CliWsClient(private val httpClient: HttpClient) {
suspend fun sessionStream(
host: String,
port: Int,
sessionId: String,
onMessage: suspend (WebSocketSession, String) -> Boolean,
) {
httpClient.webSocket(host = host, port = port, path = "/sessions/$sessionId/stream") {
for (frame in incoming) {
if (frame is Frame.Text) {
val keepGoing = onMessage(this, frame.readText())
if (!keepGoing) break
}
}
}
}
}
+15
View File
@@ -1,5 +1,6 @@
plugins {
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
id 'application'
}
@@ -8,5 +9,19 @@ application {
}
dependencies {
implementation project(':core:events')
implementation project(':core:approvals')
implementation project(':core:sessions')
implementation project(':core:kernel')
implementation project(':core:inference')
implementation project(':core:transitions')
implementation "com.github.ajalt.clikt:clikt:5.0.1"
implementation "io.ktor:ktor-server-core:$ktor_version"
implementation "io.ktor:ktor-server-netty:$ktor_version"
implementation "io.ktor:ktor-server-websockets:$ktor_version"
implementation "io.ktor:ktor-server-content-negotiation:$ktor_version"
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
implementation "io.ktor:ktor-server-status-pages:$ktor_version"
}
@@ -0,0 +1,49 @@
package com.correx.apps.server
import com.correx.apps.server.routes.providerRoutes
import com.correx.apps.server.routes.sessionRoutes
import com.correx.apps.server.routes.workflowRoutes
import com.correx.apps.server.ws.GlobalStreamHandler
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket
import kotlinx.serialization.json.Json
fun Application.configureServer(module: ServerModule) {
install(WebSockets)
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(StatusPages) {
exception<Throwable> { call, cause ->
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to (cause.message ?: "Internal error")))
}
}
val globalStreamHandler = GlobalStreamHandler(module)
routing {
get("/health") {
val providerHealth = runCatching { module.providerRegistry.healthCheckAll() }.getOrDefault(emptyMap())
call.respond(mapOf("status" to "ok", "providers" to providerHealth.size.toString()))
}
webSocket("/stream") {
globalStreamHandler.handle(this)
}
sessionRoutes(module)
workflowRoutes(module)
providerRoutes(module)
}
}
@@ -0,0 +1,15 @@
package com.correx.apps.server
import com.correx.apps.server.registry.ProviderRegistry
import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.core.events.stores.EventStore
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.sessions.DefaultSessionRepository
data class ServerModule(
val orchestrator: DefaultSessionOrchestrator,
val eventStore: EventStore,
val sessionRepository: DefaultSessionRepository,
val workflowRegistry: WorkflowRegistry,
val providerRegistry: ProviderRegistry,
)
@@ -0,0 +1,5 @@
package com.correx.apps.server.approval
data class ApprovalConfig(
val timeoutMs: Long = 300_000L,
)
@@ -0,0 +1,137 @@
package com.correx.apps.server.approval
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.ProtocolSerializer
import com.correx.apps.server.protocol.RiskSummaryDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.core.approvals.ApprovalOutcome
import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.Tier
import com.correx.core.approvals.UserSteering
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.sessions.ApprovalMode
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.websocket.Frame
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import java.util.concurrent.ConcurrentHashMap
class ApprovalCoordinator(
private val orchestrator: DefaultSessionOrchestrator,
private val config: ApprovalConfig,
private val scope: CoroutineScope,
) {
private val sessionClients: ConcurrentHashMap<SessionId, MutableSet<DefaultWebSocketServerSession>> =
ConcurrentHashMap()
private val resolved: ConcurrentHashMap<ApprovalRequestId, Boolean> = ConcurrentHashMap()
private val timeoutJobs: ConcurrentHashMap<ApprovalRequestId, Job> = ConcurrentHashMap()
fun registerClient(sessionId: SessionId, session: DefaultWebSocketServerSession) {
sessionClients.getOrPut(sessionId) { ConcurrentHashMap.newKeySet() }.add(session)
}
fun unregisterClient(sessionId: SessionId, session: DefaultWebSocketServerSession) {
sessionClients[sessionId]?.remove(session)
}
suspend fun onApprovalRequested(event: ApprovalRequestedEvent) {
val msg = ServerMessage.ApprovalRequired(
sessionId = event.sessionId,
requestId = event.requestId,
tier = event.tier,
riskSummary = RiskSummaryDto(
level = event.tier.name,
factors = emptyList(),
recommendedAction = "Review and approve or reject",
),
toolName = null,
preview = null,
)
broadcast(event.sessionId, msg)
scheduleTimeout(event.requestId, event.sessionId, event.stageId, event.tier)
}
suspend fun handleResponse(msg: ClientMessage.ApprovalResponse, sessionId: SessionId): ServerMessage? {
if (resolved.putIfAbsent(msg.requestId, true) != null) {
return ServerMessage.ProtocolError("Approval request ${msg.requestId.value} already resolved")
}
timeoutJobs.remove(msg.requestId)?.cancel()
val domain = msg.toDomainDecision(sessionId, null, Tier.T2)
return runCatching { orchestrator.submitApprovalDecision(msg.requestId, domain) }
.fold(onSuccess = { null }, onFailure = { ServerMessage.ProtocolError(it.message ?: "Unknown error") })
}
private suspend fun broadcast(sessionId: SessionId, msg: ServerMessage) {
val encoded = ProtocolSerializer.encodeServerMessage(msg)
sessionClients[sessionId]?.forEach { client ->
runCatching { client.send(Frame.Text(encoded)) }
}
}
private fun scheduleTimeout(
requestId: ApprovalRequestId,
sessionId: SessionId,
stageId: StageId?,
tier: Tier,
) {
val job = scope.launch {
delay(config.timeoutMs)
if (resolved.putIfAbsent(requestId, true) != null) return@launch
val identity = ApprovalScopeIdentity(sessionId = sessionId, stageId = stageId, projectId = null)
val context = ApprovalContext(identity = identity, mode = ApprovalMode.PROMPT)
val decision = DomainApprovalDecision(
id = null,
requestId = requestId,
outcome = ApprovalOutcome.REJECTED,
state = ApprovalStatus.COMPLETED,
tier = tier,
contextSnapshot = context,
resolutionTimestamp = Clock.System.now(),
reason = "Approval timed out after ${config.timeoutMs}ms",
)
runCatching { orchestrator.submitApprovalDecision(requestId, decision) }
}
timeoutJobs[requestId] = job
}
private fun ClientMessage.ApprovalResponse.toDomainDecision(
sessionId: SessionId,
stageId: StageId?,
tier: Tier,
): DomainApprovalDecision {
val outcome = when (decision) {
ApprovalDecision.APPROVE -> ApprovalOutcome.APPROVED
ApprovalDecision.REJECT -> ApprovalOutcome.REJECTED
ApprovalDecision.STEER -> ApprovalOutcome.APPROVED
}
val identity = ApprovalScopeIdentity(sessionId = sessionId, stageId = stageId, projectId = null)
val context = ApprovalContext(identity = identity, mode = ApprovalMode.PROMPT)
val steering = if (decision == ApprovalDecision.STEER) {
steeringNote?.let { UserSteering(text = it, sessionId = sessionId, timestamp = Clock.System.now()) }
} else {
null
}
return DomainApprovalDecision(
id = null,
requestId = requestId,
outcome = outcome,
state = ApprovalStatus.COMPLETED,
tier = tier,
contextSnapshot = context,
resolutionTimestamp = Clock.System.now(),
reason = steeringNote,
userSteering = steering,
)
}
}
@@ -0,0 +1,34 @@
package com.correx.apps.server.protocol
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
import kotlinx.serialization.Serializable
@Serializable
enum class ApprovalDecision {
APPROVE,
REJECT,
STEER,
}
@Serializable
sealed class ClientMessage {
@Serializable
data class StartSession(val workflowId: String, val config: SessionConfigDto?) : ClientMessage()
@Serializable
data class ResumeSession(val sessionId: SessionId) : ClientMessage()
@Serializable
data class CancelSession(val sessionId: SessionId) : ClientMessage()
@Serializable
data class ApprovalResponse(
val requestId: ApprovalRequestId,
val decision: ApprovalDecision,
val steeringNote: String?,
) : ClientMessage()
@Serializable
data class Ping(val timestamp: Long) : ClientMessage()
}
@@ -0,0 +1,29 @@
package com.correx.apps.server.protocol
import kotlinx.serialization.Serializable
@Serializable
data class RiskSummaryDto(
val level: String,
val factors: List<String>,
val recommendedAction: String,
)
@Serializable
data class ProviderHealthDto(
val providerId: String,
val status: String,
val loadPercent: Int?,
)
@Serializable
data class SessionConfigDto(
val timeoutMs: Long?,
val retryPolicy: String?,
)
@Serializable
enum class PauseReason {
APPROVAL_PENDING,
USER_REQUESTED,
}
@@ -0,0 +1,24 @@
package com.correx.apps.server.protocol
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
class ProtocolException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
object ProtocolSerializer {
private val json = Json {
classDiscriminator = "type"
ignoreUnknownKeys = true
}
fun encodeServerMessage(msg: ServerMessage): String = json.encodeToString(msg)
fun decodeClientMessage(json: String): ClientMessage {
return runCatching {
this.json.decodeFromString<ClientMessage>(json)
}.getOrElse { cause ->
throw ProtocolException("Failed to decode client message", cause)
}
}
}
@@ -0,0 +1,76 @@
package com.correx.apps.server.protocol
import com.correx.core.approvals.Tier
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.Serializable
@Serializable
sealed class ServerMessage {
@Serializable
data class SessionStarted(val sessionId: SessionId, val workflowId: String) : ServerMessage()
@Serializable
data class SessionPaused(val sessionId: SessionId, val reason: PauseReason) : ServerMessage()
@Serializable
data class SessionCompleted(val sessionId: SessionId) : ServerMessage()
@Serializable
data class SessionFailed(val sessionId: SessionId, val reason: String) : ServerMessage()
@Serializable
data class StageStarted(val sessionId: SessionId, val stageId: StageId) : ServerMessage()
@Serializable
data class StageCompleted(val sessionId: SessionId, val stageId: StageId) : ServerMessage()
@Serializable
data class StageFailed(val sessionId: SessionId, val stageId: StageId, val reason: String) :
ServerMessage()
@Serializable
data class InferenceStarted(val sessionId: SessionId, val stageId: StageId) : ServerMessage()
@Serializable
data class InferenceCompleted(val sessionId: SessionId, val stageId: StageId, val outputSummary: String) :
ServerMessage()
@Serializable
data class InferenceTimedOut(val sessionId: SessionId, val stageId: StageId, val elapsedMs: Long) :
ServerMessage()
@Serializable
data class ToolStarted(val sessionId: SessionId, val toolName: String, val tier: Tier) :
ServerMessage()
@Serializable
data class ToolCompleted(val sessionId: SessionId, val toolName: String, val outputSummary: String) :
ServerMessage()
@Serializable
data class ToolFailed(val sessionId: SessionId, val toolName: String, val reason: String) :
ServerMessage()
@Serializable
data class ToolRejected(val sessionId: SessionId, val toolName: String, val reason: String) :
ServerMessage()
@Serializable
data class ApprovalRequired(
val sessionId: SessionId,
val requestId: ApprovalRequestId,
val tier: Tier,
val riskSummary: RiskSummaryDto,
val toolName: String?,
val preview: String?,
) : ServerMessage()
@Serializable
data class ProviderStatusChanged(val providerId: String, val status: ProviderHealthDto) :
ServerMessage()
@Serializable
data class ProtocolError(val message: String) : ServerMessage()
}
@@ -0,0 +1,10 @@
package com.correx.apps.server.registry
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.ProviderHealth
import com.correx.core.events.types.ProviderId
interface ProviderRegistry {
fun listAll(): List<InferenceProvider>
suspend fun healthCheckAll(): Map<ProviderId, ProviderHealth>
}
@@ -0,0 +1,13 @@
package com.correx.apps.server.registry
import com.correx.core.transitions.graph.WorkflowGraph
interface WorkflowRegistry {
fun listAll(): List<WorkflowSummary>
fun find(workflowId: String): WorkflowGraph?
}
data class WorkflowSummary(
val workflowId: String,
val description: String,
)
@@ -0,0 +1,52 @@
package com.correx.apps.server.routes
import com.correx.apps.server.approval.ApprovalCoordinator
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ClientMessage
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
import com.correx.core.utils.TypeId
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
@Serializable
data class ApprovalRequest(
val requestId: String,
val decision: String,
val note: String? = null,
)
fun Route.approvalRoutes(coordinator: ApprovalCoordinator) {
route("/sessions/{id}/approve") {
post {
val id = call.parameters["id"]
?: return@post call.respond(HttpStatusCode.BadRequest, "Missing session id")
val sessionId: SessionId = TypeId(id)
val body = call.receive<ApprovalRequest>()
val requestId: ApprovalRequestId = TypeId(body.requestId)
val decision = when (body.decision.lowercase()) {
"approve" -> ApprovalDecision.APPROVE
"reject" -> ApprovalDecision.REJECT
"steer" -> ApprovalDecision.STEER
else -> return@post call.respond(HttpStatusCode.BadRequest, "Unknown decision: ${body.decision}")
}
val msg = ClientMessage.ApprovalResponse(
requestId = requestId,
decision = decision,
steeringNote = body.note,
)
val error = coordinator.handleResponse(msg, sessionId)
if (error != null) {
call.respond(HttpStatusCode.Conflict, mapOf("error" to (error as? com.correx.apps.server.protocol.ServerMessage.ProtocolError)?.message))
} else {
call.respond(HttpStatusCode.OK)
}
}
}
}
@@ -0,0 +1,31 @@
package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule
import com.correx.apps.server.protocol.ProviderHealthDto
import com.correx.core.inference.ProviderHealth
import io.ktor.server.application.call
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import kotlinx.serialization.Serializable
fun Route.providerRoutes(module: ServerModule) {
get("/providers") {
val providers = module.providerRegistry.listAll()
val healthMap = runCatching { module.providerRegistry.healthCheckAll() }.getOrDefault(emptyMap())
val response = providers.map { provider ->
val health = healthMap[provider.id]
val dto = when (health) {
is ProviderHealth.Healthy -> ProviderHealthDto(provider.id.value, "healthy", null)
is ProviderHealth.Degraded -> ProviderHealthDto(provider.id.value, "degraded", null)
is ProviderHealth.Unavailable -> ProviderHealthDto(provider.id.value, "unavailable", null)
null -> ProviderHealthDto(provider.id.value, "unknown", null)
}
ProviderStatusEntry(providerId = provider.id.value, name = provider.name, health = dto)
}
call.respond(response)
}
}
@Serializable
data class ProviderStatusEntry(val providerId: String, val name: String, val health: ProviderHealthDto)
@@ -0,0 +1,100 @@
package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule
import com.correx.apps.server.protocol.SessionConfigDto
import com.correx.apps.server.ws.SessionStreamHandler
import com.correx.core.events.types.SessionId
import com.correx.core.utils.TypeId
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import io.ktor.server.websocket.webSocket
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class StartSessionRequest(val workflowId: String, val config: SessionConfigDto? = null)
@Serializable
data class SessionSummaryResponse(val sessionId: String, val status: String)
@Serializable
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
data class StartSessionResponse(val sessionId: String)
fun Route.sessionRoutes(module: ServerModule) {
val streamHandler = SessionStreamHandler(module)
route("/sessions") {
get {
val sessions = emptyList<SessionSummaryResponse>()
call.respond(sessions)
}
post {
val body = call.receive<StartSessionRequest>()
module.workflowRegistry.find(body.workflowId)
?: return@post call.respond(HttpStatusCode.BadRequest, "Unknown workflowId: ${body.workflowId}")
val sessionId: SessionId = TypeId(UUID.randomUUID().toString())
call.respond(HttpStatusCode.Created, StartSessionResponse(sessionId.value))
}
route("/{id}") {
get {
val id = call.parameters["id"]
?: return@get call.respond(HttpStatusCode.BadRequest, "Missing session id")
val sessionId: SessionId = TypeId(id)
val session = runCatching { module.sessionRepository.getSession(sessionId) }.getOrNull()
?: return@get call.respond(HttpStatusCode.NotFound, "Session not found")
val response = SessionStateResponse(
sessionId = session.sessionId.value,
status = session.state.status.name,
createdAt = session.state.createdAt?.toString(),
)
call.respond(response)
}
post("/cancel") {
val id = call.parameters["id"]
?: return@post call.respond(HttpStatusCode.BadRequest, "Missing session id")
val sessionId: SessionId = TypeId(id)
module.orchestrator.cancel(sessionId)
call.respond(HttpStatusCode.OK)
}
get("/events") {
val id = call.parameters["id"]
?: return@get call.respond(HttpStatusCode.BadRequest, "Missing session id")
val from = call.request.queryParameters["from"]?.toLongOrNull() ?: 0L
val sessionId: SessionId = TypeId(id)
val events = module.eventStore.readFrom(sessionId, from)
val response = events.map { e ->
EventResponse(
eventId = e.metadata.eventId.value,
sequence = e.sequence,
sessionId = e.metadata.sessionId.value,
)
}
call.respond(response)
}
webSocket("/stream") {
val id = call.parameters["id"] ?: return@webSocket
val lastEventId = call.request.queryParameters["lastEventId"]?.toLongOrNull()
val sessionId: SessionId = TypeId(id)
streamHandler.handle(this, sessionId, lastEventId)
}
}
}
}
@@ -0,0 +1,15 @@
package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule
import com.correx.apps.server.registry.WorkflowSummary
import io.ktor.server.application.call
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
fun Route.workflowRoutes(module: ServerModule) {
get("/workflows") {
val workflows: List<WorkflowSummary> = module.workflowRegistry.listAll()
call.respond(workflows)
}
}
@@ -0,0 +1,69 @@
package com.correx.apps.server.ws
import com.correx.apps.server.ServerModule
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.ProtocolSerializer
import com.correx.apps.server.protocol.ServerMessage
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
private const val HEARTBEAT_INTERVAL_MS = 30_000L
class GlobalStreamHandler(private val module: ServerModule) {
suspend fun handle(session: DefaultWebSocketServerSession) {
sendInitialSnapshot(session)
val heartbeatJob = session.launch {
while (isActive) {
delay(HEARTBEAT_INTERVAL_MS)
val ping = ServerMessage.ProtocolError("ping")
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(ping)))
}
}
try {
for (frame in session.incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
runCatching { ProtocolSerializer.decodeClientMessage(text) }
.onSuccess { msg -> handleClientMessage(session, msg) }
.onFailure {
val error = ServerMessage.ProtocolError("Unknown message: ${it.message}")
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
}
}
}
} catch (_: ClosedReceiveChannelException) {
// client disconnected
} finally {
heartbeatJob.cancel()
}
}
private suspend fun sendInitialSnapshot(session: DefaultWebSocketServerSession) {
val providerHealth = runCatching { module.providerRegistry.healthCheckAll() }.getOrDefault(emptyMap())
providerHealth.forEach { (providerId, _) ->
val msg = ServerMessage.ProviderStatusChanged(
providerId = providerId.value,
status = com.correx.apps.server.protocol.ProviderHealthDto(providerId.value, "unknown", null),
)
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(msg)))
}
}
private suspend fun handleClientMessage(session: DefaultWebSocketServerSession, msg: ClientMessage) {
when (msg) {
is ClientMessage.Ping -> Unit
else -> {
val error = ServerMessage.ProtocolError("Unexpected message type in global stream")
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
}
}
}
}
@@ -0,0 +1,116 @@
package com.correx.apps.server.ws
import com.correx.apps.server.ServerModule
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.ProtocolSerializer
import com.correx.apps.server.protocol.ServerMessage
import com.correx.core.approvals.ApprovalOutcome
import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.Tier
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.approvals.UserSteering
import com.correx.core.events.types.SessionId
import com.correx.core.sessions.ApprovalMode
import com.correx.core.utils.TypeId
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
private const val HEARTBEAT_INTERVAL_MS = 30_000L
class SessionStreamHandler(private val module: ServerModule) {
suspend fun handle(session: DefaultWebSocketServerSession, sessionId: SessionId, lastEventId: Long?) {
val replayEvents = if (lastEventId != null) {
module.eventStore.readFrom(sessionId, lastEventId)
} else {
emptyList()
}
runCatching { module.sessionRepository.getSession(sessionId) }.onSuccess {
val snapshot = ServerMessage.SessionStarted(sessionId, sessionId.value)
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(snapshot)))
}
for (event in replayEvents) {
val msg = ServerMessage.SessionStarted(sessionId, event.metadata.eventId.value)
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(msg)))
}
val heartbeatJob = session.launch {
while (isActive) {
delay(HEARTBEAT_INTERVAL_MS)
val ping = ServerMessage.ProtocolError("ping")
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(ping)))
}
}
try {
for (frame in session.incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
runCatching { ProtocolSerializer.decodeClientMessage(text) }
.onSuccess { msg -> handleClientMessage(session, msg) }
.onFailure {
val error = ServerMessage.ProtocolError("Unknown message: ${it.message}")
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
}
}
}
} catch (_: ClosedReceiveChannelException) {
// client disconnected
} finally {
heartbeatJob.cancel()
}
}
private suspend fun handleClientMessage(session: DefaultWebSocketServerSession, msg: ClientMessage) {
when (msg) {
is ClientMessage.Ping -> Unit
is ClientMessage.CancelSession -> {
module.orchestrator.cancel(msg.sessionId)
}
is ClientMessage.ApprovalResponse -> {
val domainDecision = msg.toDomainDecision(msg.requestId.value)
runCatching { module.orchestrator.submitApprovalDecision(msg.requestId, domainDecision) }
}
else -> {
val error = ServerMessage.ProtocolError("Unexpected message type in session stream")
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
}
}
}
private fun ClientMessage.ApprovalResponse.toDomainDecision(sessionIdValue: String): DomainApprovalDecision {
val outcome = when (decision) {
ApprovalDecision.APPROVE -> ApprovalOutcome.APPROVED
ApprovalDecision.REJECT -> ApprovalOutcome.REJECTED
ApprovalDecision.STEER -> ApprovalOutcome.REJECTED
}
val scopeSessionId: SessionId = TypeId(sessionIdValue)
val identity = ApprovalScopeIdentity(sessionId = scopeSessionId, stageId = null, projectId = null)
val context = ApprovalContext(identity = identity, mode = ApprovalMode.PROMPT)
val steering = steeringNote?.let {
UserSteering(text = it, sessionId = scopeSessionId, timestamp = Clock.System.now())
}
return DomainApprovalDecision(
id = null,
requestId = requestId,
outcome = outcome,
state = ApprovalStatus.COMPLETED,
tier = Tier.T2,
contextSnapshot = context,
resolutionTimestamp = Clock.System.now(),
reason = steeringNote,
userSteering = steering,
)
}
}
+24
View File
@@ -0,0 +1,24 @@
plugins {
id 'org.jetbrains.kotlin.jvm'
id 'org.jetbrains.kotlin.plugin.serialization'
id 'org.jetbrains.kotlin.plugin.compose'
id 'application'
}
application {
mainClass = 'com.correx.apps.tui.MainKt'
}
dependencies {
implementation project(':apps:server')
implementation project(':core:events')
implementation project(':core:approvals')
implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-cio:$ktor_version"
implementation "io.ktor:ktor-client-websockets:$ktor_version"
implementation "com.jakewharton.mosaic:mosaic-runtime:0.13.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinx_serialization_version"
}
@@ -0,0 +1,12 @@
package com.correx.apps.tui
sealed class KeyEvent {
object Quit : KeyEvent()
object NewSession : KeyEvent()
object Cancel : KeyEvent()
object Approve : KeyEvent()
object Reject : KeyEvent()
object Steer : KeyEvent()
object NavUp : KeyEvent()
object NavDown : KeyEvent()
}
@@ -0,0 +1,11 @@
package com.correx.apps.tui
import kotlinx.coroutines.runBlocking
private const val DEFAULT_PORT = 8080
fun main(args: Array<String>) {
val host = args.getOrElse(0) { "localhost" }
val port = args.getOrElse(1) { DEFAULT_PORT.toString() }.toIntOrNull() ?: DEFAULT_PORT
runBlocking { runTuiApp(host, port) }
}
@@ -0,0 +1,89 @@
package com.correx.apps.tui
import com.correx.apps.server.protocol.PauseReason
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.state.ApprovalInfo
import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.TuiState
@Suppress("CyclomaticComplexMethod")
internal fun applyServerMessage(msg: ServerMessage, state: TuiState): TuiState = when (msg) {
is ServerMessage.SessionStarted -> applySessionStarted(msg, state)
is ServerMessage.SessionPaused -> applySessionPaused(msg, state)
is ServerMessage.SessionCompleted -> touchSession(state, msg.sessionId.value, status = "COMPLETED")
is ServerMessage.SessionFailed -> touchSession(state, msg.sessionId.value, status = "FAILED")
is ServerMessage.StageStarted -> state.copy(sessions = state.sessions.map { s ->
if (s.id == msg.sessionId.value) {
s.copy(currentStage = msg.stageId.value, lastEventAt = now())
} else {
s
}
})
is ServerMessage.StageCompleted -> touchSession(state, msg.sessionId.value)
is ServerMessage.StageFailed -> touchSession(state, msg.sessionId.value)
is ServerMessage.InferenceCompleted -> state.copy(sessions = state.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(lastOutput = msg.outputSummary, lastEventAt = now()) else s
})
is ServerMessage.ToolCompleted -> state.copy(sessions = state.sessions.map { s ->
if (s.id == msg.sessionId.value) {
s.copy(lastOutput = "${msg.toolName}: ${msg.outputSummary}", lastEventAt = now())
} else {
s
}
})
is ServerMessage.ApprovalRequired -> {
val info = ApprovalInfo(
requestId = msg.requestId.value,
sessionId = msg.sessionId.value,
tier = msg.tier.name,
riskSummary = msg.riskSummary.level,
toolName = msg.toolName,
preview = msg.preview,
)
state.copy(activeApproval = info)
}
is ServerMessage.ProviderStatusChanged -> state.copy(
providerId = msg.status.providerId,
providerStatus = msg.status.status,
)
else -> state
}
private fun applySessionStarted(msg: ServerMessage.SessionStarted, state: TuiState): TuiState {
val summary = SessionSummary(
id = msg.sessionId.value,
status = "ACTIVE",
workflowId = msg.workflowId,
lastEventAt = now(),
currentStage = null,
lastOutput = null,
)
val selected = state.selectedSessionId ?: msg.sessionId.value
return state.copy(sessions = state.sessions + summary, selectedSessionId = selected)
}
private fun applySessionPaused(msg: ServerMessage.SessionPaused, state: TuiState): TuiState {
val statusLabel = if (msg.reason == PauseReason.APPROVAL_PENDING) {
"PAUSED awaiting approval"
} else {
"PAUSED"
}
return state.copy(sessions = state.sessions.map { s ->
if (s.id == msg.sessionId.value) {
s.copy(status = statusLabel, lastEventAt = now())
} else {
s
}
})
}
private fun touchSession(state: TuiState, sessionId: String, status: String? = null): TuiState =
state.copy(sessions = state.sessions.map { s ->
if (s.id == sessionId) {
if (status != null) s.copy(status = status, lastEventAt = now()) else s.copy(lastEventAt = now())
} else {
s
}
})
internal fun now(): Long = System.currentTimeMillis()
@@ -0,0 +1,191 @@
package com.correx.apps.tui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.tui.components.ActiveSession
import com.correx.apps.tui.components.ApprovalPanel
import com.correx.apps.tui.components.InputBar
import com.correx.apps.tui.components.SessionList
import com.correx.apps.tui.components.StatusBar
import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.ws.TuiWsClient
import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.SessionId
import com.jakewharton.mosaic.runMosaic
import com.jakewharton.mosaic.ui.Column
import com.jakewharton.mosaic.ui.Text
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
suspend fun runTuiApp(host: String, port: Int) {
var state by mutableStateOf(TuiState())
val keyChannel = Channel<KeyEvent>(capacity = Channel.BUFFERED)
val wsClient = TuiWsClient(
host = host,
port = port,
onConnected = { state = state.copy(connected = true, reconnecting = false) },
onDisconnected = { state = state.copy(connected = false, reconnecting = true) },
onMessage = { msg -> state = applyServerMessage(msg, state) },
)
runMosaic {
setContent {
Column {
Text("─── status ───────────────────────────────────────────────")
StatusBar(state)
Text("─── sessions ─────────────────────────────────────────────")
SessionList(state.sessions, state.selectedSessionId)
Text("─── active session ───────────────────────────────────────")
ActiveSession(state.sessions.find { it.id == state.selectedSessionId })
if (state.activeApproval != null) {
Text("─── approval ─────────────────────────────────────────────")
ApprovalPanel(state.activeApproval)
}
Text("─────────────────────────────────────────────────────────")
InputBar(state.inputText)
Text("q:quit n:new c:cancel ↑↓:navigate a/r/s:approval")
}
}
launch { wsClient.connect() }
launch { readKeys(keyChannel) }
launch { handleKeys(keyChannel, wsClient, getState = { state }, setState = { state = it }) }
}
wsClient.close()
}
private suspend fun readKeys(keyChannel: Channel<KeyEvent>) {
withContext(Dispatchers.IO) {
val stdin = System.`in`
var running = true
while (running) {
val b = stdin.read()
if (b == -1) break
val event = charToKeyEvent(b.toChar(), stdin)
if (event != null) {
keyChannel.send(event)
if (event is KeyEvent.Quit) running = false
}
}
}
}
@Suppress("MagicNumber")
private fun charToKeyEvent(ch: Char, stdin: java.io.InputStream): KeyEvent? = when (ch) {
'q' -> KeyEvent.Quit
'n' -> KeyEvent.NewSession
'c' -> KeyEvent.Cancel
'a' -> KeyEvent.Approve
'r' -> KeyEvent.Reject
's' -> KeyEvent.Steer
'k', '' -> {
val next = stdin.read()
if (next == '['.code) {
when (stdin.read().toChar()) {
'A' -> KeyEvent.NavUp
'B' -> KeyEvent.NavDown
else -> null
}
} else {
null
}
}
else -> null
}
@Suppress("CyclomaticComplexMethod")
private suspend fun handleKeys(
keyChannel: Channel<KeyEvent>,
wsClient: TuiWsClient,
getState: () -> TuiState,
setState: (TuiState) -> Unit,
) {
for (event in keyChannel) {
val state = getState()
when (event) {
is KeyEvent.Quit -> { wsClient.close(); return }
is KeyEvent.NewSession -> handleNewSession(wsClient)
is KeyEvent.Cancel -> handleCancel(state, wsClient)
is KeyEvent.Approve -> handleApproval(state, wsClient, ApprovalDecision.APPROVE, null, setState)
is KeyEvent.Reject -> handleApproval(state, wsClient, ApprovalDecision.REJECT, null, setState)
is KeyEvent.Steer -> handleSteer(state, wsClient, setState)
is KeyEvent.NavUp -> setState(navigateUp(state))
is KeyEvent.NavDown -> setState(navigateDown(state))
}
}
}
private suspend fun handleNewSession(wsClient: TuiWsClient) {
val workflow = withContext(Dispatchers.IO) {
System.console()?.readLine("workflow id: ")
} ?: return
wsClient.send(ClientMessage.StartSession(workflowId = workflow, config = null))
}
private suspend fun handleCancel(state: TuiState, wsClient: TuiWsClient) {
state.selectedSessionId?.let { id ->
wsClient.send(ClientMessage.CancelSession(sessionId = SessionId(id)))
}
}
private suspend fun handleApproval(
state: TuiState,
wsClient: TuiWsClient,
decision: ApprovalDecision,
note: String?,
setState: (TuiState) -> Unit,
) {
state.activeApproval?.let { info ->
wsClient.send(
ClientMessage.ApprovalResponse(
requestId = ApprovalRequestId(info.requestId),
decision = decision,
steeringNote = note,
)
)
setState(state.copy(activeApproval = null))
}
}
private suspend fun handleSteer(
state: TuiState,
wsClient: TuiWsClient,
setState: (TuiState) -> Unit,
) {
state.activeApproval?.let { info ->
val note = withContext(Dispatchers.IO) {
System.console()?.readLine("steering note: ")
}
wsClient.send(
ClientMessage.ApprovalResponse(
requestId = ApprovalRequestId(info.requestId),
decision = ApprovalDecision.STEER,
steeringNote = note,
)
)
setState(state.copy(activeApproval = null))
}
}
private fun navigateUp(state: TuiState): TuiState {
val sessions = state.sessions
if (sessions.isEmpty()) return state
val idx = sessions.indexOfFirst { it.id == state.selectedSessionId }
val newIdx = if (idx <= 0) sessions.lastIndex else idx - 1
return state.copy(selectedSessionId = sessions[newIdx].id)
}
private fun navigateDown(state: TuiState): TuiState {
val sessions = state.sessions
if (sessions.isEmpty()) return state
val idx = sessions.indexOfFirst { it.id == state.selectedSessionId }
val newIdx = if (idx >= sessions.lastIndex) 0 else idx + 1
return state.copy(selectedSessionId = sessions[newIdx].id)
}
@@ -0,0 +1,19 @@
package com.correx.apps.tui.components
import androidx.compose.runtime.Composable
import com.correx.apps.tui.state.SessionSummary
import com.jakewharton.mosaic.ui.Column
import com.jakewharton.mosaic.ui.Text
@Suppress("FunctionNaming")
@Composable
fun ActiveSession(session: SessionSummary?) {
Column {
if (session == null) {
Text(" (no active session selected)")
} else {
Text("stage: ${session.currentStage ?: "—"}")
Text("last output: ${session.lastOutput ?: "—"}")
}
}
}
@@ -0,0 +1,19 @@
package com.correx.apps.tui.components
import androidx.compose.runtime.Composable
import com.correx.apps.tui.state.ApprovalInfo
import com.jakewharton.mosaic.ui.Column
import com.jakewharton.mosaic.ui.Text
@Suppress("FunctionNaming")
@Composable
fun ApprovalPanel(approval: ApprovalInfo?) {
if (approval == null) return
Column {
Text("⚠ APPROVAL REQUIRED — Tier ${approval.tier}")
Text("risk: ${approval.riskSummary}")
approval.toolName?.let { Text("tool: $it") }
approval.preview?.let { Text("preview: $it") }
Text("[A] approve [R] reject [S] steer")
}
}
@@ -0,0 +1,10 @@
package com.correx.apps.tui.components
import androidx.compose.runtime.Composable
import com.jakewharton.mosaic.ui.Text
@Suppress("FunctionNaming")
@Composable
fun InputBar(text: String) {
Text("> ${text}_")
}
@@ -0,0 +1,39 @@
package com.correx.apps.tui.components
import androidx.compose.runtime.Composable
import com.correx.apps.tui.state.SessionSummary
import com.jakewharton.mosaic.ui.Column
import com.jakewharton.mosaic.ui.Text
private const val SESSION_ID_DISPLAY_LENGTH = 6
private const val MS_PER_SECOND = 1000L
private const val SECS_PER_MINUTE = 60L
private const val SECS_PER_HOUR = 3600L
@Suppress("FunctionNaming")
@Composable
fun SessionList(sessions: List<SessionSummary>, selectedId: String?) {
Column {
if (sessions.isEmpty()) {
Text(" (no sessions)")
} else {
sessions.forEach { session ->
val prefix = if (session.id == selectedId) "" else " "
val stage = session.currentStage?.let { " stage $it" } ?: ""
val ago = formatAgo(session.lastEventAt)
val shortId = session.id.take(SESSION_ID_DISPLAY_LENGTH)
Text("$prefix [$shortId] \"${session.workflowId}\" ${session.status}$stage $ago")
}
}
}
}
private fun formatAgo(epochMs: Long): String {
val diffMs = System.currentTimeMillis() - epochMs
val secs = diffMs / MS_PER_SECOND
return when {
secs < SECS_PER_MINUTE -> "${secs}s ago"
secs < SECS_PER_HOUR -> "${secs / SECS_PER_MINUTE}m ago"
else -> "${secs / SECS_PER_HOUR}h ago"
}
}
@@ -0,0 +1,21 @@
package com.correx.apps.tui.components
import androidx.compose.runtime.Composable
import com.correx.apps.tui.state.TuiState
import com.jakewharton.mosaic.ui.Text
@Suppress("FunctionNaming")
@Composable
fun StatusBar(state: TuiState) {
val connectionLabel = when {
state.reconnecting -> "reconnecting..."
state.connected -> "● connected"
else -> "○ disconnected"
}
val providerLabel = if (state.providerId.isNotEmpty()) {
"${state.providerId} (${state.providerStatus})"
} else {
state.providerStatus
}
Text("server: $connectionLabel │ provider: $providerLabel")
}
@@ -0,0 +1,30 @@
package com.correx.apps.tui.state
data class TuiState(
val connected: Boolean = false,
val reconnecting: Boolean = false,
val sessions: List<SessionSummary> = emptyList(),
val selectedSessionId: String? = null,
val activeApproval: ApprovalInfo? = null,
val inputText: String = "",
val providerStatus: String = "unknown",
val providerId: String = "",
)
data class SessionSummary(
val id: String,
val status: String,
val workflowId: String,
val lastEventAt: Long,
val currentStage: String?,
val lastOutput: String?,
)
data class ApprovalInfo(
val requestId: String,
val sessionId: String,
val tier: String,
val riskSummary: String,
val toolName: String?,
val preview: String?,
)
@@ -0,0 +1,73 @@
package com.correx.apps.tui.ws
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.ServerMessage
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.delay
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private const val INITIAL_RETRY_DELAY_MS = 1_000L
private const val MAX_RETRY_DELAY_MS = 30_000L
class TuiWsClient(
private val host: String,
private val port: Int,
private val onConnected: suspend () -> Unit,
private val onDisconnected: suspend () -> Unit,
private val onMessage: suspend (ServerMessage) -> Unit,
) {
private val json = Json {
classDiscriminator = "type"
ignoreUnknownKeys = true
}
private val client = HttpClient(CIO) {
install(WebSockets)
}
private var session: WebSocketSession? = null
suspend fun send(message: ClientMessage) {
runCatching {
session?.send(json.encodeToString(message))
}
}
suspend fun connect() {
var delayMs = INITIAL_RETRY_DELAY_MS
while (true) {
runCatching {
client.webSocket(host = host, port = port, path = "/stream") {
session = this
onConnected()
delayMs = INITIAL_RETRY_DELAY_MS
for (frame in incoming) {
if (frame is Frame.Text) {
runCatching {
json.decodeFromString<ServerMessage>(frame.readText())
}.onSuccess { msg ->
onMessage(msg)
}
}
}
}
}
session = null
onDisconnected()
delay(delayMs)
delayMs = minOf(delayMs * 2, MAX_RETRY_DELAY_MS)
}
}
fun close() {
client.close()
}
}