epic-13: add cli and tui entry point, finish epic.
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user