chore(damn): detekt, build, tests, formatting

fixed detekt issues where possible.
fixed disttar failing build because tools is added twice in the server module.
added workflowId where required.
fixed some tests not being recognized because of runBlocking without explicit return type.
formatting + imports.
This commit is contained in:
2026-05-22 00:10:05 +04:00
parent 2c459da009
commit f827685ed0
185 changed files with 1134 additions and 832 deletions
+7
View File
@@ -8,6 +8,13 @@ application {
mainClass = 'com.correx.apps.server.MainKt' mainClass = 'com.correx.apps.server.MainKt'
} }
tasks.named('distTar') {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
tasks.named('distZip') {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
dependencies { dependencies {
implementation project(':core:config') implementation project(':core:config')
implementation project(':core:events') implementation project(':core:events')
@@ -9,7 +9,6 @@ import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.plugins.calllogging.CallLogging import io.ktor.server.plugins.calllogging.CallLogging
import org.slf4j.event.Level
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.statuspages.StatusPages import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.response.respond import io.ktor.server.response.respond
@@ -18,6 +17,7 @@ import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.webSocket import io.ktor.server.websocket.webSocket
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.slf4j.event.Level
fun Application.configureServer(module: ServerModule) { fun Application.configureServer(module: ServerModule) {
install(CallLogging) { level = Level.INFO } install(CallLogging) { level = Level.INFO }
@@ -49,9 +49,20 @@ import java.nio.file.Paths
private val log = LoggerFactory.getLogger("com.correx.apps.server.Main") private val log = LoggerFactory.getLogger("com.correx.apps.server.Main")
fun main() { fun main() {
log.info("=== correx server starting ===")
log.info(" port : 8080")
val artifactStore = InfrastructureModule.createArtifactStore() val artifactStore = InfrastructureModule.createArtifactStore()
val eventStore = LoggingEventStore(InfrastructureModule.createEventStore(artifactStore)) val eventStore = LoggingEventStore(InfrastructureModule.createEventStore(artifactStore))
logStoresInfo(eventStore, artifactStore)
val infraRegistry = InfrastructureModule.createProviderRegistry(listOf(buildLlamaProvider())) val infraRegistry = InfrastructureModule.createProviderRegistry(listOf(buildLlamaProvider()))
val modelId = System.getenv("CORREX_MODEL_ID") ?: "default"
val modelPath = System.getenv("CORREX_MODEL_PATH") ?: "(not set)"
val llamaUrl = System.getenv("CORREX_LLAMA_URL") ?: "http://127.0.0.1:10000"
logModelInfo(modelId, modelPath, llamaUrl, infraRegistry)
val repositories = buildRepositories(eventStore) val repositories = buildRepositories(eventStore)
val approvalEngine = DefaultApprovalEngine() val approvalEngine = DefaultApprovalEngine()
val correxConfig = ConfigLoader.load() val correxConfig = ConfigLoader.load()
@@ -78,6 +89,9 @@ fun main() {
eventDispatcher = EventDispatcher(eventStore), eventDispatcher = EventDispatcher(eventStore),
workDir = sandboxRoot, workDir = sandboxRoot,
) )
logToolInfo(sandboxRoot, workingDir, shellAllowedExecutables, toolRegistry)
val inferenceRouter = DefaultInferenceRouter(infraRegistry, FirstAvailableRoutingStrategy()) val inferenceRouter = DefaultInferenceRouter(infraRegistry, FirstAvailableRoutingStrategy())
val engines = OrchestratorEngines( val engines = OrchestratorEngines(
transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) }, transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) },
@@ -116,52 +130,46 @@ fun main() {
defaultOrchestrationConfig = defaultOrchestrationConfig, defaultOrchestrationConfig = defaultOrchestrationConfig,
routerFacade = routerFacade, routerFacade = routerFacade,
) )
val modelId = System.getenv("CORREX_MODEL_ID") ?: "default" log.info("==============================")
val modelPath = System.getenv("CORREX_MODEL_PATH") ?: "(not set)"
val llamaUrl = System.getenv("CORREX_LLAMA_URL") ?: "http://127.0.0.1:10000"
logServerStartup(
modelId,
modelPath,
llamaUrl,
sandboxRoot,
workingDir,
shellAllowedExecutables,
eventStore,
artifactStore,
toolRegistry,
infraRegistry,
)
embeddedServer(Netty, port = 8080) { configureServer(module) }.start(wait = true) embeddedServer(Netty, port = 8080) { configureServer(module) }.start(wait = true)
} }
private fun logServerStartup( private fun logModelInfo(
modelId: String, modelId: String,
modelPath: String, modelPath: String,
llamaUrl: String, llamaUrl: String,
sandboxRoot: Path?,
workingDir: Path?,
shellAllowedExecutables: Set<String>,
eventStore: LoggingEventStore,
artifactStore: ArtifactStore,
toolRegistry: ToolRegistry,
infraRegistry: DefaultProviderRegistry, infraRegistry: DefaultProviderRegistry,
) { ) {
log.info("=== correx server starting ===") log.info("==========MODEL INFO==========")
log.info(" port : 8080")
log.info(" model id : {}", modelId) log.info(" model id : {}", modelId)
log.info(" model path : {}", modelPath) log.info(" model path : {}", modelPath)
log.info(" llama url : {}", llamaUrl) log.info(" llama url : {}", llamaUrl)
log.info(" providers : {} registered", infraRegistry.listAll().size)
}
private fun logToolInfo(
sandboxRoot: Path?,
workingDir: Path?,
shellAllowedExecutables: Set<String>,
toolRegistry: ToolRegistry,
) {
log.info("==========TOOLS INFO==========")
log.info(" sandbox root : {}", sandboxRoot) log.info(" sandbox root : {}", sandboxRoot)
log.info(" working dir : {}", workingDir) log.info(" working dir : {}", workingDir)
if (shellAllowedExecutables.isEmpty()) { if (shellAllowedExecutables.isEmpty()) {
log.warn(" shell tool : no executable allowlist configured — all executables allowed") log.warn(" shell tool : no executable allowlist configured — all executables allowed")
} }
log.info(" tools : {}", toolRegistry.all().map { it.name })
}
private fun logStoresInfo(
eventStore: LoggingEventStore,
artifactStore: ArtifactStore,
) {
log.info("==========STORE INFO==========")
log.info(" event store : {}", eventStore::class.simpleName) log.info(" event store : {}", eventStore::class.simpleName)
log.info(" artifact store: {}", artifactStore::class.simpleName) log.info(" artifact store: {}", artifactStore::class.simpleName)
log.info(" tools : {}", toolRegistry.all().map { it.name })
log.info(" providers : {} registered", infraRegistry.listAll().size)
log.info("==============================")
} }
private fun buildLlamaProvider(): LlamaCppInferenceProvider = InfrastructureModule.createLlamaCppProvider( private fun buildLlamaProvider(): LlamaCppInferenceProvider = InfrastructureModule.createLlamaCppProvider(
@@ -6,8 +6,8 @@ import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.router.RouterFacade import com.correx.core.router.RouterFacade
import com.correx.core.sessions.DefaultSessionRepository
data class ServerModule( data class ServerModule(
val orchestrator: DefaultSessionOrchestrator, val orchestrator: DefaultSessionOrchestrator,
@@ -10,7 +10,6 @@ import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.Tier import com.correx.core.approvals.Tier
import com.correx.core.approvals.UserSteering import com.correx.core.approvals.UserSteering
import com.correx.core.approvals.model.ApprovalContext 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.model.ApprovalScopeIdentity
import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.ApprovalRequestId
@@ -25,7 +24,8 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.*
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
class ApprovalCoordinator( class ApprovalCoordinator(
private val orchestrator: ApprovalGateway, private val orchestrator: ApprovalGateway,
@@ -5,13 +5,13 @@ import com.correx.apps.server.protocol.RiskSummaryDto
import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.protocol.ServerMessage
import com.correx.core.artifactstore.ArtifactStore import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.events.InferenceCompletedEvent import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.InferenceStartedEvent import com.correx.core.events.events.InferenceStartedEvent
import com.correx.core.events.events.InferenceTimeoutEvent import com.correx.core.events.events.InferenceTimeoutEvent
import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent import com.correx.core.events.events.ToolExecutionFailedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent import com.correx.core.events.events.ToolExecutionRejectedEvent
@@ -20,7 +20,7 @@ import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.events.StoredEvent import com.correx.core.events.types.ArtifactId
class DomainEventMapper(private val artifactStore: ArtifactStore = NoopArtifactStore) { class DomainEventMapper(private val artifactStore: ArtifactStore = NoopArtifactStore) {
suspend fun map(event: StoredEvent): ServerMessage? = suspend fun map(event: StoredEvent): ServerMessage? =
@@ -39,47 +39,80 @@ suspend fun domainEventToServerMessage(event: StoredEvent, artifactStore: Artifa
is WorkflowStartedEvent -> mapWorkflowStarted(p) is WorkflowStartedEvent -> mapWorkflowStarted(p)
is WorkflowCompletedEvent -> ServerMessage.SessionCompleted(sessionId = p.sessionId) is WorkflowCompletedEvent -> ServerMessage.SessionCompleted(sessionId = p.sessionId)
is WorkflowFailedEvent -> ServerMessage.SessionFailed(sessionId = p.sessionId, reason = p.reason) is WorkflowFailedEvent -> ServerMessage.SessionFailed(sessionId = p.sessionId, reason = p.reason)
is TransitionExecutedEvent -> ServerMessage.StageStarted(sessionId = p.sessionId, stageId = p.to) is TransitionExecutedEvent -> ServerMessage.StageStarted(
is StageCompletedEvent -> ServerMessage.StageCompleted(sessionId = p.sessionId, stageId = p.stageId) sessionId = p.sessionId,
is StageFailedEvent -> ServerMessage.StageFailed( stageId = p.to,
sessionId = p.sessionId, stageId = p.stageId, reason = p.reason, occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
) )
is StageCompletedEvent -> ServerMessage.StageCompleted(
sessionId = p.sessionId,
stageId = p.stageId,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
)
is StageFailedEvent -> ServerMessage.StageFailed(
sessionId = p.sessionId,
stageId = p.stageId,
reason = p.reason,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
)
is OrchestrationPausedEvent -> mapOrchestrationPaused(p) is OrchestrationPausedEvent -> mapOrchestrationPaused(p)
is InferenceStartedEvent -> ServerMessage.InferenceStarted(sessionId = p.sessionId, stageId = p.stageId) is InferenceStartedEvent -> ServerMessage.InferenceStarted(sessionId = p.sessionId, stageId = p.stageId)
is InferenceCompletedEvent -> mapInferenceCompleted(p, artifactStore) is InferenceCompletedEvent -> mapInferenceCompleted(event, p, artifactStore)
is InferenceTimeoutEvent -> ServerMessage.InferenceTimedOut( is InferenceTimeoutEvent -> ServerMessage.InferenceTimedOut(
sessionId = p.sessionId, stageId = p.stageId, elapsedMs = p.timeoutMs, sessionId = p.sessionId, stageId = p.stageId, elapsedMs = p.timeoutMs,
) )
is ToolInvocationRequestedEvent -> ServerMessage.ToolStarted( is ToolInvocationRequestedEvent -> ServerMessage.ToolStarted(
sessionId = p.sessionId, toolName = p.toolName, tier = p.tier, sessionId = p.sessionId, toolName = p.toolName, tier = p.tier,
) )
is ToolExecutionCompletedEvent -> ServerMessage.ToolCompleted( is ToolExecutionCompletedEvent -> ServerMessage.ToolCompleted(
sessionId = p.sessionId, toolName = p.toolName, outputSummary = p.receipt.outputSummary, sessionId = p.sessionId,
toolName = p.toolName,
outputSummary = p.receipt.outputSummary,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
) )
is ToolExecutionFailedEvent -> ServerMessage.ToolFailed( is ToolExecutionFailedEvent -> ServerMessage.ToolFailed(
sessionId = p.sessionId, toolName = p.toolName, reason = p.reason, sessionId = p.sessionId,
toolName = p.toolName,
reason = p.reason,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
) )
is ToolExecutionRejectedEvent -> ServerMessage.ToolRejected( is ToolExecutionRejectedEvent -> ServerMessage.ToolRejected(
sessionId = p.sessionId, toolName = p.toolName, reason = p.reason, sessionId = p.sessionId, toolName = p.toolName, reason = p.reason,
) )
is ApprovalRequestedEvent -> mapApprovalRequested(p) is ApprovalRequestedEvent -> mapApprovalRequested(p)
else -> null else -> null
} }
private fun mapWorkflowStarted(p: WorkflowStartedEvent): ServerMessage = private fun mapWorkflowStarted(p: WorkflowStartedEvent): ServerMessage =
ServerMessage.SessionStarted(sessionId = p.sessionId, workflowId = p.startStageId.value) ServerMessage.SessionStarted(sessionId = p.sessionId, workflowId = p.workflowId)
private fun mapOrchestrationPaused(p: OrchestrationPausedEvent): ServerMessage { private fun mapOrchestrationPaused(p: OrchestrationPausedEvent): ServerMessage {
val reason = if (p.reason == "APPROVAL_PENDING") PauseReason.APPROVAL_PENDING else PauseReason.USER_REQUESTED val reason = if (p.reason == "APPROVAL_PENDING") PauseReason.APPROVAL_PENDING else PauseReason.USER_REQUESTED
return ServerMessage.SessionPaused(sessionId = p.sessionId, reason = reason) return ServerMessage.SessionPaused(sessionId = p.sessionId, reason = reason)
} }
private suspend fun mapInferenceCompleted(p: InferenceCompletedEvent, artifactStore: ArtifactStore): ServerMessage { private suspend fun mapInferenceCompleted(
event: StoredEvent,
p: InferenceCompletedEvent,
artifactStore: ArtifactStore,
): ServerMessage {
val text = runCatching { val text = runCatching {
artifactStore.get(p.responseArtifactId)?.toString(Charsets.UTF_8) ?: "" artifactStore.get(p.responseArtifactId)?.toString(Charsets.UTF_8) ?: ""
}.getOrElse { "" } }.getOrElse { "" }
return ServerMessage.InferenceCompleted( return ServerMessage.InferenceCompleted(
sessionId = p.sessionId, stageId = p.stageId, outputSummary = text, responseText = text, sessionId = p.sessionId,
stageId = p.stageId,
outputSummary = text,
responseText = text,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
) )
} }
@@ -1,7 +1,7 @@
package com.correx.apps.server.protocol package com.correx.apps.server.protocol
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class ProtocolException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) class ProtocolException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
@@ -21,13 +21,13 @@ sealed class ServerMessage {
data class SessionFailed(val sessionId: SessionId, val reason: String) : ServerMessage() data class SessionFailed(val sessionId: SessionId, val reason: String) : ServerMessage()
@Serializable @Serializable
data class StageStarted(val sessionId: SessionId, val stageId: StageId) : ServerMessage() data class StageStarted(val sessionId: SessionId, val stageId: StageId, val occurredAt: Long) : ServerMessage()
@Serializable @Serializable
data class StageCompleted(val sessionId: SessionId, val stageId: StageId) : ServerMessage() data class StageCompleted(val sessionId: SessionId, val stageId: StageId, val occurredAt: Long) : ServerMessage()
@Serializable @Serializable
data class StageFailed(val sessionId: SessionId, val stageId: StageId, val reason: String) : data class StageFailed(val sessionId: SessionId, val stageId: StageId, val reason: String, val occurredAt: Long) :
ServerMessage() ServerMessage()
@Serializable @Serializable
@@ -39,6 +39,7 @@ sealed class ServerMessage {
val stageId: StageId, val stageId: StageId,
val outputSummary: String, val outputSummary: String,
val responseText: String = "", val responseText: String = "",
val occurredAt: Long,
) : ServerMessage() ) : ServerMessage()
@Serializable @Serializable
@@ -50,11 +51,15 @@ sealed class ServerMessage {
ServerMessage() ServerMessage()
@Serializable @Serializable
data class ToolCompleted(val sessionId: SessionId, val toolName: String, val outputSummary: String) : data class ToolCompleted(
ServerMessage() val sessionId: SessionId,
val toolName: String,
val outputSummary: String,
val occurredAt: Long,
) : ServerMessage()
@Serializable @Serializable
data class ToolFailed(val sessionId: SessionId, val toolName: String, val reason: String) : data class ToolFailed(val sessionId: SessionId, val toolName: String, val reason: String, val occurredAt: Long) :
ServerMessage() ServerMessage()
@Serializable @Serializable
@@ -1,8 +1,8 @@
package com.correx.apps.server.registry package com.correx.apps.server.registry
import com.correx.core.events.types.ProviderId
import com.correx.core.inference.InferenceProvider import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.ProviderHealth import com.correx.core.inference.ProviderHealth
import com.correx.core.events.types.ProviderId
interface ProviderRegistry { interface ProviderRegistry {
fun listAll(): List<InferenceProvider> fun listAll(): List<InferenceProvider>
@@ -10,7 +10,6 @@ import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.utils.TypeId import com.correx.core.utils.TypeId
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import org.slf4j.LoggerFactory
import io.ktor.server.request.receive import io.ktor.server.request.receive
import io.ktor.server.response.respond import io.ktor.server.response.respond
import io.ktor.server.routing.Route import io.ktor.server.routing.Route
@@ -21,7 +20,8 @@ import io.ktor.server.websocket.webSocket
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID import org.slf4j.LoggerFactory
import java.util.*
@Serializable @Serializable
data class StartSessionRequest(val workflowId: String, val config: SessionConfigDto? = null) data class StartSessionRequest(val workflowId: String, val config: SessionConfigDto? = null)
@@ -21,7 +21,7 @@ import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.util.UUID import java.util.*
private val log = LoggerFactory.getLogger(GlobalStreamHandler::class.java) private val log = LoggerFactory.getLogger(GlobalStreamHandler::class.java)
@@ -10,12 +10,10 @@ import com.correx.apps.server.protocol.ServerMessage.RouterResponseMessage
import com.correx.core.approvals.ApprovalOutcome import com.correx.core.approvals.ApprovalOutcome
import com.correx.core.approvals.ApprovalStatus import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.Tier 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.approvals.UserSteering
import com.correx.core.approvals.model.ApprovalContext
import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.router.RouterFacade
import com.correx.core.sessions.ApprovalMode import com.correx.core.sessions.ApprovalMode
import com.correx.core.utils.TypeId import com.correx.core.utils.TypeId
import io.ktor.server.websocket.DefaultWebSocketServerSession import io.ktor.server.websocket.DefaultWebSocketServerSession
@@ -24,6 +22,7 @@ import io.ktor.websocket.readText
import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
private val log = LoggerFactory.getLogger(SessionStreamHandler::class.java) private val log = LoggerFactory.getLogger(SessionStreamHandler::class.java)
@@ -75,11 +74,20 @@ class SessionStreamHandler(private val module: ServerModule) {
runCatching { module.orchestrator.cancel(msg.sessionId) } runCatching { module.orchestrator.cancel(msg.sessionId) }
.onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) } .onFailure { log.error("cancel failed for session={}: {}", msg.sessionId.value, it.message, it) }
} }
is ClientMessage.ApprovalResponse -> { is ClientMessage.ApprovalResponse -> {
val domainDecision = msg.toDomainDecision(msg.requestId.value) val domainDecision = msg.toDomainDecision(msg.requestId.value)
runCatching { module.orchestrator.submitApprovalDecision(msg.requestId, domainDecision) } runCatching { module.orchestrator.submitApprovalDecision(msg.requestId, domainDecision) }
.onFailure { log.error("submitApprovalDecision failed for request={}: {}", msg.requestId.value, it.message, it) } .onFailure {
log.error(
"submitApprovalDecision failed for request={}: {}",
msg.requestId.value,
it.message,
it,
)
}
} }
is ClientMessage.ChatInput -> { is ClientMessage.ChatInput -> {
runCatching { runCatching {
module.routerFacade.onUserInput( module.routerFacade.onUserInput(
@@ -88,21 +96,24 @@ class SessionStreamHandler(private val module: ServerModule) {
mode = msg.mode, mode = msg.mode,
) )
}.onSuccess { response -> }.onSuccess { response ->
session.send(Frame.Text( session.send(
ProtocolSerializer.encodeServerMessage( Frame.Text(
RouterResponseMessage( ProtocolSerializer.encodeServerMessage(
sessionId = msg.sessionId, RouterResponseMessage(
content = response.content, sessionId = msg.sessionId,
steeringEmitted = response.steeringEmitted, content = response.content,
) steeringEmitted = response.steeringEmitted,
) ),
)) ),
),
)
}.onFailure { }.onFailure {
log.error("routerFacade.onUserInput failed for session={}: {}", msg.sessionId.value, it.message, it) log.error("routerFacade.onUserInput failed for session={}: {}", msg.sessionId.value, it.message, it)
val error = ServerMessage.ProtocolError("Router error: ${it.message}") val error = ServerMessage.ProtocolError("Router error: ${it.message}")
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error))) session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(error)))
} }
} }
else -> { else -> {
log.warn("unexpected message type={} in session stream", msg::class.simpleName) log.warn("unexpected message type={} in session stream", msg::class.simpleName)
val error = ServerMessage.ProtocolError("Unexpected message type in session stream") val error = ServerMessage.ProtocolError("Unexpected message type in session stream")
@@ -24,8 +24,8 @@ import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.EventId import com.correx.core.events.types.EventId
import com.correx.core.events.types.InferenceRequestId import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.ProviderId import com.correx.core.events.types.ProviderId
@@ -35,8 +35,8 @@ import com.correx.core.events.types.ToolInvocationId
import com.correx.core.events.types.TransitionId import com.correx.core.events.types.TransitionId
import com.correx.core.events.types.ValidationReportId import com.correx.core.events.types.ValidationReportId
import com.correx.core.inference.TokenUsage import com.correx.core.inference.TokenUsage
import kotlinx.datetime.Instant
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -45,7 +45,9 @@ class DomainEventMapperTest {
private val sessionId = SessionId("session-1") private val sessionId = SessionId("session-1")
private val stageId = StageId("stage-1") private val stageId = StageId("stage-1")
private val workflowId = "workflow-test"
private val timestamp = Instant.parse("2026-01-01T00:00:00Z") private val timestamp = Instant.parse("2026-01-01T00:00:00Z")
private val occurredAt = timestamp.toEpochMilliseconds()
private val noopStore: ArtifactStore = object : ArtifactStore { private val noopStore: ArtifactStore = object : ArtifactStore {
override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("art-1") override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("art-1")
override suspend fun get(id: ArtifactId): ByteArray? = null override suspend fun get(id: ArtifactId): ByteArray? = null
@@ -68,15 +70,21 @@ class DomainEventMapperTest {
@Test @Test
fun `WorkflowStartedEvent maps to SessionStarted`(): Unit = runTest { fun `WorkflowStartedEvent maps to SessionStarted`(): Unit = runTest {
val event = storedEvent(WorkflowStartedEvent(sessionId = sessionId, startStageId = stageId)) val event =
storedEvent(WorkflowStartedEvent(sessionId = sessionId, startStageId = stageId, workflowId = workflowId))
val result = domainEventToServerMessage(event, noopStore) val result = domainEventToServerMessage(event, noopStore)
assertEquals(ServerMessage.SessionStarted(sessionId, stageId.value), result) assertEquals(ServerMessage.SessionStarted(sessionId, workflowId), result)
} }
@Test @Test
fun `WorkflowCompletedEvent maps to SessionCompleted`(): Unit = runTest { fun `WorkflowCompletedEvent maps to SessionCompleted`(): Unit = runTest {
val event = storedEvent( val event = storedEvent(
WorkflowCompletedEvent(sessionId = sessionId, terminalStageId = stageId, totalStages = 1), WorkflowCompletedEvent(
sessionId = sessionId,
terminalStageId = stageId,
totalStages = 1,
workflowId = workflowId,
),
) )
val result = domainEventToServerMessage(event, noopStore) val result = domainEventToServerMessage(event, noopStore)
assertEquals(ServerMessage.SessionCompleted(sessionId), result) assertEquals(ServerMessage.SessionCompleted(sessionId), result)
@@ -99,7 +107,7 @@ class DomainEventMapperTest {
TransitionExecutedEvent(sessionId = sessionId, from = from, to = to, transitionId = TransitionId("t1")), TransitionExecutedEvent(sessionId = sessionId, from = from, to = to, transitionId = TransitionId("t1")),
) )
val result = domainEventToServerMessage(event, noopStore) val result = domainEventToServerMessage(event, noopStore)
assertEquals(ServerMessage.StageStarted(sessionId, to), result) assertEquals(ServerMessage.StageStarted(sessionId, to, occurredAt), result)
} }
@Test @Test
@@ -108,7 +116,7 @@ class DomainEventMapperTest {
StageCompletedEvent(sessionId = sessionId, stageId = stageId, transitionId = TransitionId("t1")), StageCompletedEvent(sessionId = sessionId, stageId = stageId, transitionId = TransitionId("t1")),
) )
val result = domainEventToServerMessage(event, noopStore) val result = domainEventToServerMessage(event, noopStore)
assertEquals(ServerMessage.StageCompleted(sessionId, stageId), result) assertEquals(ServerMessage.StageCompleted(sessionId, stageId, occurredAt), result)
} }
@Test @Test
@@ -119,7 +127,7 @@ class DomainEventMapperTest {
), ),
) )
val result = domainEventToServerMessage(event, noopStore) val result = domainEventToServerMessage(event, noopStore)
assertEquals(ServerMessage.StageFailed(sessionId, stageId, "err"), result) assertEquals(ServerMessage.StageFailed(sessionId, stageId, "err", occurredAt), result)
} }
@Test @Test
@@ -142,13 +150,15 @@ class DomainEventMapperTest {
@Test @Test
fun `InferenceStartedEvent maps to InferenceStarted`(): Unit = runTest { fun `InferenceStartedEvent maps to InferenceStarted`(): Unit = runTest {
val event = storedEvent(InferenceStartedEvent( val event = storedEvent(
requestId = InferenceRequestId("req-1"), InferenceStartedEvent(
sessionId = sessionId, requestId = InferenceRequestId("req-1"),
stageId = stageId, sessionId = sessionId,
providerId = ProviderId("p1"), stageId = stageId,
promptArtifactId = ArtifactId("art-prompt"), providerId = ProviderId("p1"),
)) promptArtifactId = ArtifactId("art-prompt"),
),
)
val result = domainEventToServerMessage(event, noopStore) val result = domainEventToServerMessage(event, noopStore)
assertEquals(ServerMessage.InferenceStarted(sessionId, stageId), result) assertEquals(ServerMessage.InferenceStarted(sessionId, stageId), result)
} }
@@ -161,45 +171,55 @@ class DomainEventMapperTest {
override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("x") override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("x")
override suspend fun get(id: ArtifactId): ByteArray? = override suspend fun get(id: ArtifactId): ByteArray? =
if (id == artifactId) responseText.toByteArray(Charsets.UTF_8) else null if (id == artifactId) responseText.toByteArray(Charsets.UTF_8) else null
override suspend fun flushBefore(commit: suspend () -> Unit) = commit() override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
} }
val event = storedEvent(InferenceCompletedEvent( val event = storedEvent(
requestId = InferenceRequestId("req-1"), InferenceCompletedEvent(
sessionId = sessionId, requestId = InferenceRequestId("req-1"),
stageId = stageId, sessionId = sessionId,
providerId = ProviderId("p1"), stageId = stageId,
tokensUsed = TokenUsage(10, 5), providerId = ProviderId("p1"),
latencyMs = 100L, tokensUsed = TokenUsage(10, 5),
responseArtifactId = artifactId, latencyMs = 100L,
)) responseArtifactId = artifactId,
),
)
val result = domainEventToServerMessage(event, store) val result = domainEventToServerMessage(event, store)
assertEquals(ServerMessage.InferenceCompleted(sessionId, stageId, responseText, responseText), result) assertEquals(
ServerMessage.InferenceCompleted(sessionId, stageId, responseText, responseText, occurredAt),
result,
)
} }
@Test @Test
fun `InferenceCompletedEvent falls back to empty string when artifact missing`(): Unit = runTest { fun `InferenceCompletedEvent falls back to empty string when artifact missing`(): Unit = runTest {
val event = storedEvent(InferenceCompletedEvent( val event = storedEvent(
requestId = InferenceRequestId("req-1"), InferenceCompletedEvent(
sessionId = sessionId, requestId = InferenceRequestId("req-1"),
stageId = stageId, sessionId = sessionId,
providerId = ProviderId("p1"), stageId = stageId,
tokensUsed = TokenUsage(10, 5), providerId = ProviderId("p1"),
latencyMs = 100L, tokensUsed = TokenUsage(10, 5),
responseArtifactId = ArtifactId("missing"), latencyMs = 100L,
)) responseArtifactId = ArtifactId("missing"),
),
)
val result = domainEventToServerMessage(event, noopStore) val result = domainEventToServerMessage(event, noopStore)
assertEquals(ServerMessage.InferenceCompleted(sessionId, stageId, "", ""), result) assertEquals(ServerMessage.InferenceCompleted(sessionId, stageId, "", "", occurredAt), result)
} }
@Test @Test
fun `InferenceTimeoutEvent maps to InferenceTimedOut`(): Unit = runTest { fun `InferenceTimeoutEvent maps to InferenceTimedOut`(): Unit = runTest {
val event = storedEvent(InferenceTimeoutEvent( val event = storedEvent(
requestId = InferenceRequestId("req-1"), InferenceTimeoutEvent(
sessionId = sessionId, requestId = InferenceRequestId("req-1"),
stageId = stageId, sessionId = sessionId,
providerId = ProviderId("p1"), stageId = stageId,
timeoutMs = 5000L, providerId = ProviderId("p1"),
)) timeoutMs = 5000L,
),
)
val result = domainEventToServerMessage(event, noopStore) val result = domainEventToServerMessage(event, noopStore)
assertEquals(ServerMessage.InferenceTimedOut(sessionId, stageId, 5000L), result) assertEquals(ServerMessage.InferenceTimedOut(sessionId, stageId, 5000L), result)
} }
@@ -207,16 +227,18 @@ class DomainEventMapperTest {
@Test @Test
fun `ToolInvocationRequestedEvent maps to ToolStarted`(): Unit = runTest { fun `ToolInvocationRequestedEvent maps to ToolStarted`(): Unit = runTest {
val invId = ToolInvocationId("inv-1") val invId = ToolInvocationId("inv-1")
val event = storedEvent(ToolInvocationRequestedEvent( val event = storedEvent(
invocationId = invId, ToolInvocationRequestedEvent(
sessionId = sessionId, invocationId = invId,
stageId = stageId, sessionId = sessionId,
toolName = "file_write", stageId = stageId,
tier = Tier.T2, toolName = "file_write",
request = ToolRequest( tier = Tier.T2,
invocationId = invId, sessionId = sessionId, stageId = stageId, toolName = "file_write", request = ToolRequest(
invocationId = invId, sessionId = sessionId, stageId = stageId, toolName = "file_write",
),
), ),
)) )
val result = domainEventToServerMessage(event, noopStore) val result = domainEventToServerMessage(event, noopStore)
assertEquals(ServerMessage.ToolStarted(sessionId, "file_write", Tier.T2), result) assertEquals(ServerMessage.ToolStarted(sessionId, "file_write", Tier.T2), result)
} }
@@ -239,7 +261,7 @@ class DomainEventMapperTest {
), ),
) )
val result = domainEventToServerMessage(event, noopStore) val result = domainEventToServerMessage(event, noopStore)
assertEquals(ServerMessage.ToolCompleted(sessionId, "file_write", "wrote 3 lines"), result) assertEquals(ServerMessage.ToolCompleted(sessionId, "file_write", "wrote 3 lines", occurredAt), result)
} }
@Test @Test
@@ -253,7 +275,7 @@ class DomainEventMapperTest {
), ),
) )
val result = domainEventToServerMessage(event, noopStore) val result = domainEventToServerMessage(event, noopStore)
assertEquals(ServerMessage.ToolFailed(sessionId, "file_write", "disk full"), result) assertEquals(ServerMessage.ToolFailed(sessionId, "file_write", "disk full", occurredAt), result)
} }
@Test @Test
@@ -274,15 +296,17 @@ class DomainEventMapperTest {
@Test @Test
fun `ApprovalRequestedEvent maps to ApprovalRequired`(): Unit = runTest { fun `ApprovalRequestedEvent maps to ApprovalRequired`(): Unit = runTest {
val requestId = ApprovalRequestId("req-1") val requestId = ApprovalRequestId("req-1")
val event = storedEvent(ApprovalRequestedEvent( val event = storedEvent(
requestId = requestId, ApprovalRequestedEvent(
tier = Tier.T3, requestId = requestId,
validationReportId = ValidationReportId("vr-1"), tier = Tier.T3,
riskSummaryId = null, validationReportId = ValidationReportId("vr-1"),
sessionId = sessionId, riskSummaryId = null,
stageId = stageId, sessionId = sessionId,
projectId = null, stageId = stageId,
)) projectId = null,
),
)
val result = domainEventToServerMessage(event, noopStore) as ServerMessage.ApprovalRequired val result = domainEventToServerMessage(event, noopStore) as ServerMessage.ApprovalRequired
assertEquals(requestId, result.requestId) assertEquals(requestId, result.requestId)
assertEquals(Tier.T3, result.tier) assertEquals(Tier.T3, result.tier)
@@ -5,8 +5,8 @@ import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.NewEvent import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
@@ -27,6 +27,7 @@ class SessionEventBridgeTest {
private val sessionId = SessionId("session-1") private val sessionId = SessionId("session-1")
private val stageId = StageId("stage-1") private val stageId = StageId("stage-1")
private val workflowId = "workflow-test"
private val timestamp = Instant.parse("2026-01-01T00:00:00Z") private val timestamp = Instant.parse("2026-01-01T00:00:00Z")
private val noopArtifactStore: ArtifactStore = object : ArtifactStore { private val noopArtifactStore: ArtifactStore = object : ArtifactStore {
@@ -65,8 +66,8 @@ class SessionEventBridgeTest {
@Test @Test
fun `replaySnapshot sends mapped messages for all events`() = runTest { fun `replaySnapshot sends mapped messages for all events`() = runTest {
val events = listOf( val events = listOf(
storedEvent(WorkflowStartedEvent(sessionId, stageId), seq = 1L), storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L),
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 2L), storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1, workflowId), seq = 2L),
) )
val store = fakeEventStore(allEventsList = events) val store = fakeEventStore(allEventsList = events)
val sent = mutableListOf<ServerMessage>() val sent = mutableListOf<ServerMessage>()
@@ -75,7 +76,7 @@ class SessionEventBridgeTest {
bridge.replaySnapshot() bridge.replaySnapshot()
assertEquals(2, sent.size) assertEquals(2, sent.size)
assertEquals(ServerMessage.SessionStarted(sessionId, stageId.value), sent[0]) assertEquals(ServerMessage.SessionStarted(sessionId, workflowId), sent[0])
assertEquals(ServerMessage.SessionCompleted(sessionId), sent[1]) assertEquals(ServerMessage.SessionCompleted(sessionId), sent[1])
} }
@@ -83,7 +84,7 @@ class SessionEventBridgeTest {
fun `replaySnapshot skips unmapped events`() = runTest { fun `replaySnapshot skips unmapped events`() = runTest {
val events = listOf( val events = listOf(
storedEvent(OrchestrationResumedEvent(sessionId, stageId), seq = 1L), storedEvent(OrchestrationResumedEvent(sessionId, stageId), seq = 1L),
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 2L), storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1, workflowId), seq = 2L),
) )
val store = fakeEventStore(allEventsList = events) val store = fakeEventStore(allEventsList = events)
val sent = mutableListOf<ServerMessage>() val sent = mutableListOf<ServerMessage>()
@@ -98,8 +99,8 @@ class SessionEventBridgeTest {
@Test @Test
fun `streamLive sends mapped messages from live flow`() = runTest { fun `streamLive sends mapped messages from live flow`() = runTest {
val events = listOf( val events = listOf(
storedEvent(WorkflowStartedEvent(sessionId, stageId), seq = 1L), storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L),
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 2L), storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1, workflowId), seq = 2L),
) )
val liveFlow: Flow<StoredEvent> = flow { events.forEach { emit(it) } } val liveFlow: Flow<StoredEvent> = flow { events.forEach { emit(it) } }
val store = fakeEventStore(liveFlow = liveFlow) val store = fakeEventStore(liveFlow = liveFlow)
@@ -109,7 +110,7 @@ class SessionEventBridgeTest {
bridge.streamLive(sessionId) bridge.streamLive(sessionId)
assertEquals(2, sent.size) assertEquals(2, sent.size)
assertEquals(ServerMessage.SessionStarted(sessionId, stageId.value), sent[0]) assertEquals(ServerMessage.SessionStarted(sessionId, workflowId), sent[0])
assertEquals(ServerMessage.SessionCompleted(sessionId), sent[1]) assertEquals(ServerMessage.SessionCompleted(sessionId), sent[1])
} }
@@ -123,10 +124,10 @@ class SessionEventBridgeTest {
val sent = mutableListOf<ServerMessage>() val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore) { sent.add(it) } val bridge = SessionEventBridge(store, noopArtifactStore) { sent.add(it) }
channel.send(storedEvent(WorkflowStartedEvent(sessionId, stageId), seq = 1L)) channel.send(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L))
val job = launch { bridge.streamLive(sessionId) } val job = launch { bridge.streamLive(sessionId) }
channel.send(storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 2L)) channel.send(storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1, workflowId), seq = 2L))
channel.close() channel.close()
job.join() job.join()
@@ -20,12 +20,12 @@ import dev.tamboui.terminal.Frame
import dev.tamboui.tui.EventHandler import dev.tamboui.tui.EventHandler
import dev.tamboui.tui.Renderer import dev.tamboui.tui.Renderer
import dev.tamboui.tui.TuiRunner import dev.tamboui.tui.TuiRunner
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
private const val DEFAULT_PORT = 8080 private const val DEFAULT_PORT = 8080
private const val STATUS_HEIGHT = 1 private const val STATUS_HEIGHT = 1
@@ -70,7 +70,7 @@ fun main(args: Array<String>) {
val handler = EventHandler { event, _ -> val handler = EventHandler { event, _ ->
if (event is TambouiKeyEvent) { if (event is TambouiKeyEvent) {
mapKey(event, state.inputMode) mapKey(event)
?.let { KeyResolver.resolve(it, state.inputMode, state.inputBuffer) } ?.let { KeyResolver.resolve(it, state.inputMode, state.inputBuffer) }
?.let(::dispatch) ?.let(::dispatch)
} }
@@ -23,50 +23,11 @@ fun inputBarWidget(state: TuiState): Paragraph {
?: "(no session)" ?: "(no session)"
val (row1, row2) = when (state.inputMode) { val (row1, row2) = when (state.inputMode) {
InputMode.ROUTER -> { InputMode.ROUTER -> createRowsForRouter(state, dimStyle, sessionName, hasApproval, hasSession)
val cursor = if (state.inputBuffer.isNotEmpty()) { InputMode.NAVIGATE -> createRowsForNavigate(dimStyle, sessionName, hasApproval, hasSession)
Line.from(Span.raw(""), Span.raw(state.inputBuffer)) InputMode.FILTER -> createRowsForFilter(state, dimStyle)
} else { InputMode.STEER -> createRowsForSteer(state, dimStyle, sessionName)
Line.from(Span.raw(""), Span.styled("Ask anything…", dimStyle))
}
val hints = buildList<String> {
add("$sessionName · router")
add("tab navigate")
if (hasApproval) { add("ctrl+a approve"); add("ctrl+r reject") }
if (hasSession) add("ctrl+s steer")
add("ctrl+e events")
add("ctrl+q")
}.joinToString(" ")
cursor to Line.from(Span.styled(hints, dimStyle))
}
InputMode.NAVIGATE -> {
val row1Line = Line.from(Span.styled(" ↑↓ sessions enter select", dimStyle))
val hints = buildList<String> {
add("$sessionName · navigate")
add("tab router")
if (hasApproval) { add("ctrl+a approve"); add("ctrl+r reject") }
if (hasSession) add("ctrl+s steer")
add("ctrl+e events")
add("ctrl+q")
}.joinToString(" ")
row1Line to Line.from(Span.styled(hints, dimStyle))
}
InputMode.FILTER -> {
val cursor = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer))
} else {
Line.from(Span.raw(""), Span.styled("/filter…", dimStyle))
}
cursor to Line.from(Span.styled("filtering sessions esc clear enter apply", dimStyle))
}
InputMode.STEER -> {
val cursor = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer))
} else {
Line.from(Span.raw(""), Span.styled("Steer the session…", dimStyle))
}
cursor to Line.from(Span.styled("$sessionName · steering esc cancel enter send", dimStyle))
}
} }
val block = Block.builder() val block = Block.builder()
@@ -77,3 +38,73 @@ fun inputBarWidget(state: TuiState): Paragraph {
return Paragraph.builder().text(Text.from(listOf(row1, row2))).block(block).build() return Paragraph.builder().text(Text.from(listOf(row1, row2))).block(block).build()
} }
private fun createRowsForSteer(
state: TuiState,
dimStyle: Style?,
sessionName: String,
): Pair<Line?, Line?> {
val cursor = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer))
} else {
Line.from(Span.raw(""), Span.styled("Steer the session…", dimStyle))
}
return cursor to Line.from(Span.styled("$sessionName · steering esc cancel enter send", dimStyle))
}
private fun createRowsForFilter(
state: TuiState,
dimStyle: Style?,
): Pair<Line?, Line?> {
val cursor = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer))
} else {
Line.from(Span.raw(""), Span.styled("/filter…", dimStyle))
}
return cursor to Line.from(Span.styled("filtering sessions esc clear enter apply", dimStyle))
}
private fun createRowsForNavigate(
dimStyle: Style?,
sessionName: String,
hasApproval: Boolean,
hasSession: Boolean,
): Pair<Line?, Line?> {
val row1Line = Line.from(Span.styled(" ↑↓ sessions enter select", dimStyle))
val hints = buildList<String> {
add("$sessionName · navigate")
add("tab router")
if (hasApproval) {
add("ctrl+a approve"); add("ctrl+r reject")
}
if (hasSession) add("ctrl+s steer")
add("ctrl+e events")
add("ctrl+q")
}.joinToString(" ")
return row1Line to Line.from(Span.styled(hints, dimStyle))
}
private fun createRowsForRouter(
state: TuiState,
dimStyle: Style?,
sessionName: String,
hasApproval: Boolean,
hasSession: Boolean,
): Pair<Line?, Line?> {
val cursor = if (state.inputBuffer.isNotEmpty()) {
Line.from(Span.raw(""), Span.raw(state.inputBuffer))
} else {
Line.from(Span.raw(""), Span.styled("Ask anything…", dimStyle))
}
val hints = buildList<String> {
add("$sessionName · router")
add("tab navigate")
if (hasApproval) {
add("ctrl+a approve"); add("ctrl+r reject")
}
if (hasSession) add("ctrl+s steer")
add("ctrl+e events")
add("ctrl+q")
}.joinToString(" ")
return cursor to Line.from(Span.styled(hints, dimStyle))
}
@@ -1,7 +1,6 @@
package com.correx.apps.tui.components package com.correx.apps.tui.components
import com.correx.apps.tui.state.TuiState import com.correx.apps.tui.state.TuiState
import dev.tamboui.style.Color
import dev.tamboui.style.Style import dev.tamboui.style.Style
import dev.tamboui.text.Line import dev.tamboui.text.Line
import dev.tamboui.text.Span import dev.tamboui.text.Span
@@ -28,7 +28,6 @@ fun sessionListWidget(state: TuiState): Paragraph {
val isSelected = s.id == state.sessions.selectedId val isSelected = s.id == state.sessions.selectedId
val prefix = if (isSelected) Span.styled("", Style.create().blue()) else Span.raw(" ") val prefix = if (isSelected) Span.styled("", Style.create().blue()) else Span.raw(" ")
val idSpan = Span.styled("[${s.id.take(ID_LEN)}]", dimStyle) val idSpan = Span.styled("[${s.id.take(ID_LEN)}]", dimStyle)
val statusStr = s.status.uppercase().padEnd(STATUS_WIDTH)
val statusSpan = statusSpan(s) val statusSpan = statusSpan(s)
val rawName = s.name.ifEmpty { s.workflowId } val rawName = s.name.ifEmpty { s.workflowId }
val truncated = if (rawName.length > NAME_MAX) rawName.take(NAME_MAX - 1) + "" else rawName val truncated = if (rawName.length > NAME_MAX) rawName.take(NAME_MAX - 1) + "" else rawName
@@ -61,6 +60,7 @@ private fun statusSpan(s: SessionSummary): Span {
fun filteredSessions(sessions: SessionsState): List<SessionSummary> { fun filteredSessions(sessions: SessionsState): List<SessionSummary> {
val f = sessions.filter val f = sessions.filter
return if (f.isEmpty()) sessions.sessions val sorted = sessions.sessions.sortedByDescending { it.lastEventAt }
else sessions.sessions.filter { it.workflowId.contains(f, ignoreCase = true) } return if (f.isEmpty()) sorted
else sorted.filter { it.workflowId.contains(f, ignoreCase = true) }
} }
@@ -1,24 +1,23 @@
package com.correx.apps.tui.input package com.correx.apps.tui.input
import com.correx.apps.tui.KeyEvent import com.correx.apps.tui.KeyEvent
import com.correx.apps.tui.state.InputMode
import dev.tamboui.tui.event.KeyCode import dev.tamboui.tui.event.KeyCode
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
@Suppress("CyclomaticComplexMethod", "ReturnCount") @Suppress("CyclomaticComplexMethod", "ReturnCount")
fun mapKey(event: TambouiKeyEvent, mode: InputMode): KeyEvent? { fun mapKey(event: TambouiKeyEvent): KeyEvent? {
if (event.isCtrlC()) return KeyEvent.Quit if (event.isCtrlC) return KeyEvent.Quit
if (event.hasAlt()) return null if (event.hasAlt()) return null
if (event.hasCtrl()) { if (event.hasCtrl()) {
return when (event.character()) { return when {
'q' -> KeyEvent.Quit event.isChar('q') -> KeyEvent.Quit
'n' -> KeyEvent.NewSession event.isChar('n') -> KeyEvent.NewSession
'c' -> KeyEvent.Cancel event.isChar('c') -> KeyEvent.Cancel
'a' -> KeyEvent.Approve event.isChar('a') -> KeyEvent.Approve
'r' -> KeyEvent.Reject event.isChar('r') -> KeyEvent.Reject
's' -> KeyEvent.Steer event.isChar('s') -> KeyEvent.Steer
'h' -> KeyEvent.ToggleApprovalOverlay event.isChar('h') -> KeyEvent.ToggleApprovalOverlay
'e' -> KeyEvent.ToggleEventOverlay event.isChar('e') -> KeyEvent.ToggleEventOverlay
else -> null else -> null
} }
} }
@@ -17,11 +17,13 @@ object RootReducer {
val (approval, ae) = ApprovalReducer.reduce(afterInput.approval, prevInputMode, action) val (approval, ae) = ApprovalReducer.reduce(afterInput.approval, prevInputMode, action)
val (connection, ce) = ConnectionReducer.reduce(afterInput.connection, action) val (connection, ce) = ConnectionReducer.reduce(afterInput.connection, action)
val (provider, pe) = ProviderReducer.reduce(afterInput.provider, action) val (provider, pe) = ProviderReducer.reduce(afterInput.provider, action)
val approvalJustArrived = approval.active != null && afterInput.approval.active == null
val withSubReducers = afterInput.copy( val withSubReducers = afterInput.copy(
sessions = sessions, sessions = sessions,
approval = approval, approval = approval,
connection = connection, connection = connection,
provider = provider, provider = provider,
approvalOverlayVisible = if (approvalJustArrived) true else afterInput.approvalOverlayVisible,
) )
val final = when (action) { val final = when (action) {
is Action.ToggleApprovalOverlay -> withSubReducers.copy( is Action.ToggleApprovalOverlay -> withSubReducers.copy(
@@ -25,8 +25,18 @@ object SessionsReducer {
action: Action, action: Action,
clock: () -> Long = System::currentTimeMillis, clock: () -> Long = System::currentTimeMillis,
): Pair<SessionsState, List<Effect>> = when (action) { ): Pair<SessionsState, List<Effect>> = when (action) {
is Action.NavigateUp -> if (inputMode == InputMode.NAVIGATE) navigateUp(sessions) to emptyList() else sessions to emptyList() is Action.NavigateUp -> if (inputMode == InputMode.NAVIGATE) {
is Action.NavigateDown -> if (inputMode == InputMode.NAVIGATE) navigateDown(sessions) to emptyList() else sessions to emptyList() navigateUp(sessions) to emptyList()
} else {
sessions to emptyList()
}
is Action.NavigateDown -> if (inputMode == InputMode.NAVIGATE) {
navigateDown(sessions) to emptyList()
} else {
sessions to emptyList()
}
is Action.SubmitInput -> if (inputMode == InputMode.FILTER) { is Action.SubmitInput -> if (inputMode == InputMode.FILTER) {
sessions.copy(filter = inputText) to emptyList() sessions.copy(filter = inputText) to emptyList()
} else { } else {
@@ -52,8 +62,12 @@ object SessionsReducer {
else -> sessions to emptyList() else -> sessions to emptyList()
} }
private fun filteredSessions(sessions: SessionsState): List<SessionSummary> =
if (sessions.filter.isBlank()) sessions.sessions
else sessions.sessions.filter { it.workflowId.contains(sessions.filter, ignoreCase = true) }
private fun navigateUp(sessions: SessionsState): SessionsState { private fun navigateUp(sessions: SessionsState): SessionsState {
val list = sessions.sessions val list = filteredSessions(sessions)
if (list.isEmpty()) return sessions if (list.isEmpty()) return sessions
val idx = list.indexOfFirst { it.id == sessions.selectedId } val idx = list.indexOfFirst { it.id == sessions.selectedId }
val newIdx = if (idx <= 0) list.lastIndex else idx - 1 val newIdx = if (idx <= 0) list.lastIndex else idx - 1
@@ -61,7 +75,7 @@ object SessionsReducer {
} }
private fun navigateDown(sessions: SessionsState): SessionsState { private fun navigateDown(sessions: SessionsState): SessionsState {
val list = sessions.sessions val list = filteredSessions(sessions)
if (list.isEmpty()) return sessions if (list.isEmpty()) return sessions
val idx = list.indexOfFirst { it.id == sessions.selectedId } val idx = list.indexOfFirst { it.id == sessions.selectedId }
val newIdx = if (idx >= list.lastIndex) 0 else idx + 1 val newIdx = if (idx >= list.lastIndex) 0 else idx + 1
@@ -77,194 +91,244 @@ object SessionsReducer {
msg: ServerMessage, msg: ServerMessage,
clock: () -> Long, clock: () -> Long,
): Pair<SessionsState, List<Effect>> = when (msg) { ): Pair<SessionsState, List<Effect>> = when (msg) {
is ServerMessage.SessionStarted -> { is ServerMessage.SessionStarted -> processSessionStartedMessage(msg, clock, sessions)
val summary = SessionSummary( is ServerMessage.SessionPaused -> processSessionPausedMessage(msg, sessions, clock)
id = msg.sessionId.value, is ServerMessage.SessionCompleted -> processSessionCompletedMessage(sessions, msg, clock)
status = "ACTIVE", is ServerMessage.SessionFailed -> processSessionFailedMessage(sessions, msg, clock)
workflowId = msg.workflowId, is ServerMessage.StageStarted -> processStageStartedMessage(msg, sessions)
name = msg.workflowId, is ServerMessage.StageCompleted -> processStageCompletedMessage(msg, sessions)
lastEventAt = clock(), is ServerMessage.StageFailed -> processStageFailedMessage(msg, sessions)
currentStage = null, is ServerMessage.ToolStarted -> processToolStartedMessage(clock, sessions, msg)
lastOutput = null, is ServerMessage.InferenceCompleted -> processInferenceCompletedMessage(sessions, msg)
) is ServerMessage.ToolCompleted -> processToolCompletedMessage(msg, sessions)
val selected = sessions.selectedId ?: msg.sessionId.value is ServerMessage.ToolFailed -> processToolFailedMessage(msg, sessions)
sessions.copy(sessions = sessions.sessions + summary, selectedId = selected) to is ServerMessage.ToolRejected -> processToolRejectedMessage(clock, sessions, msg)
listOf(Effect.ConnectSession(msg.sessionId))
}
is ServerMessage.SessionPaused -> {
val statusLabel = if (msg.reason == PauseReason.APPROVAL_PENDING) {
"PAUSED awaiting approval"
} else {
"PAUSED"
}
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(status = statusLabel, lastEventAt = clock()) else s
},
) to emptyList()
}
is ServerMessage.SessionCompleted -> touchSession(sessions, msg.sessionId.value, "COMPLETED", clock) to listOf(
Effect.DisconnectSession,
)
is ServerMessage.SessionFailed -> touchSession(
sessions,
msg.sessionId.value,
"FAILED",
clock,
) to listOf(Effect.DisconnectSession)
is ServerMessage.StageStarted -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val entry = TuiEventEntry(formatTime(now), "StageStarted", msg.stageId.value)
s.copy(
currentStage = msg.stageId.value,
currentStageId = msg.stageId.value,
tools = emptyList(),
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.StageCompleted -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val entry = TuiEventEntry(formatTime(now), "StageCompleted", msg.stageId.value)
s.copy(
currentStage = null,
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.StageFailed -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val entry = TuiEventEntry(formatTime(now), "StageFailed", msg.stageId.value)
s.copy(
currentStage = null,
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.ToolStarted -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val record = TuiToolRecord(msg.toolName, msg.tier.level, ToolDisplayStatus.STARTED, null)
s.copy(
tools = (s.tools + record).takeLast(8),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.InferenceCompleted -> applyInferenceCompleted(sessions, msg, clock)
is ServerMessage.ToolCompleted -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val updatedTools = s.tools.map { t ->
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
t.copy(status = ToolDisplayStatus.COMPLETED)
} else {
t
}
}
val entry = TuiEventEntry(formatTime(now), "ToolCompleted", msg.toolName)
s.copy(
tools = updatedTools,
lastOutput = "${msg.toolName}: ${msg.outputSummary}",
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.ToolFailed -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val updatedTools = s.tools.map { t ->
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
t.copy(status = ToolDisplayStatus.FAILED)
} else {
t
}
}
s.copy(tools = updatedTools, lastEventAt = now)
} else {
s
}
},
) to emptyList()
}
is ServerMessage.ToolRejected -> {
val now = clock()
sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val updatedTools = s.tools.map { t ->
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
t.copy(status = ToolDisplayStatus.REJECTED)
} else {
t
}
}
s.copy(tools = updatedTools, lastEventAt = now)
} else {
s
}
},
) to emptyList()
}
else -> sessions to emptyList() else -> sessions to emptyList()
} }
private fun applyInferenceCompleted( private fun processToolRejectedMessage(
sessions: SessionsState,
msg: ServerMessage.InferenceCompleted,
clock: () -> Long, clock: () -> Long,
sessions: SessionsState,
msg: ServerMessage.ToolRejected,
): Pair<SessionsState, List<Effect>> { ): Pair<SessionsState, List<Effect>> {
val now = clock() val now = clock()
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val updatedTools = s.tools.map { t ->
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
t.copy(status = ToolDisplayStatus.REJECTED)
} else {
t
}
}
s.copy(tools = updatedTools, lastEventAt = now)
} else {
s
}
},
) to emptyList()
}
private fun processToolFailedMessage(
msg: ServerMessage.ToolFailed,
sessions: SessionsState,
): Pair<SessionsState, List<Effect>> {
val now = msg.occurredAt
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val updatedTools = s.tools.map { t ->
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
t.copy(status = ToolDisplayStatus.FAILED)
} else {
t
}
}
s.copy(tools = updatedTools, lastEventAt = now)
} else {
s
}
},
) to emptyList()
}
private fun processToolCompletedMessage(
msg: ServerMessage.ToolCompleted,
sessions: SessionsState,
): Pair<SessionsState, List<Effect>> {
val now = msg.occurredAt
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val updatedTools = s.tools.map { t ->
if (t.name == msg.toolName && t.status == ToolDisplayStatus.STARTED) {
t.copy(status = ToolDisplayStatus.COMPLETED)
} else {
t
}
}
val entry = TuiEventEntry(formatTime(now), "ToolCompleted", msg.toolName)
s.copy(
tools = updatedTools,
lastOutput = "${msg.toolName}: ${msg.outputSummary}",
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
private fun processToolStartedMessage(
clock: () -> Long,
sessions: SessionsState,
msg: ServerMessage.ToolStarted,
): Pair<SessionsState, List<Effect>> {
val now = clock()
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val record = TuiToolRecord(msg.toolName, msg.tier.level, ToolDisplayStatus.STARTED, null)
s.copy(
tools = (s.tools + record).takeLast(8),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
private fun processStageFailedMessage(
msg: ServerMessage.StageFailed,
sessions: SessionsState,
): Pair<SessionsState, List<Effect>> {
val now = msg.occurredAt
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val entry = TuiEventEntry(formatTime(now), "StageFailed", msg.stageId.value)
s.copy(
currentStage = null,
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
private fun processStageCompletedMessage(
msg: ServerMessage.StageCompleted,
sessions: SessionsState,
): Pair<SessionsState, List<Effect>> {
val now = msg.occurredAt
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val entry = TuiEventEntry(formatTime(now), "StageCompleted", msg.stageId.value)
s.copy(
currentStage = null,
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
private fun processStageStartedMessage(
msg: ServerMessage.StageStarted,
sessions: SessionsState,
): Pair<SessionsState, List<Effect>> {
val now = msg.occurredAt
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) {
val entry = TuiEventEntry(formatTime(now), "StageStarted", msg.stageId.value)
s.copy(
currentStage = msg.stageId.value,
currentStageId = msg.stageId.value,
tools = emptyList(),
recentEvents = (s.recentEvents + entry).takeLast(7),
lastEventAt = now,
)
} else {
s
}
},
) to emptyList()
}
private fun processSessionFailedMessage(
sessions: SessionsState,
msg: ServerMessage.SessionFailed,
clock: () -> Long,
): Pair<SessionsState, List<Effect.DisconnectSession>> = touchSession(
sessions,
msg.sessionId.value,
"FAILED",
clock,
) to listOf(Effect.DisconnectSession)
private fun processSessionCompletedMessage(
sessions: SessionsState,
msg: ServerMessage.SessionCompleted,
clock: () -> Long,
): Pair<SessionsState, List<Effect.DisconnectSession>> =
touchSession(sessions, msg.sessionId.value, "COMPLETED", clock) to listOf(
Effect.DisconnectSession,
)
private fun processSessionPausedMessage(
msg: ServerMessage.SessionPaused,
sessions: SessionsState,
clock: () -> Long,
): Pair<SessionsState, List<Effect>> {
val statusLabel = if (msg.reason == PauseReason.APPROVAL_PENDING) {
"PAUSED awaiting approval"
} else {
"PAUSED"
}
return sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) s.copy(status = statusLabel, lastEventAt = clock()) else s
},
) to emptyList()
}
private fun processSessionStartedMessage(
msg: ServerMessage.SessionStarted,
clock: () -> Long,
sessions: SessionsState,
): Pair<SessionsState, List<Effect.ConnectSession>> {
val summary = SessionSummary(
id = msg.sessionId.value,
status = "ACTIVE",
workflowId = msg.workflowId,
name = msg.workflowId,
lastEventAt = clock(),
currentStage = null,
lastOutput = null,
)
val selected = sessions.selectedId ?: msg.sessionId.value
return sessions.copy(sessions = sessions.sessions + summary, selectedId = selected) to
listOf(Effect.ConnectSession(msg.sessionId))
}
private fun processInferenceCompletedMessage(
sessions: SessionsState,
msg: ServerMessage.InferenceCompleted,
): Pair<SessionsState, List<Effect>> {
val now = msg.occurredAt
return sessions.copy( return sessions.copy(
sessions = sessions.sessions.map { s -> sessions = sessions.sessions.map { s ->
if (s.id == msg.sessionId.value) { if (s.id == msg.sessionId.value) {
@@ -1,13 +1,12 @@
package com.correx.apps.tui.input package com.correx.apps.tui.input
import com.correx.apps.tui.KeyEvent import com.correx.apps.tui.KeyEvent
import com.correx.apps.tui.state.InputMode
import dev.tamboui.tui.event.KeyCode import dev.tamboui.tui.event.KeyCode
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
import dev.tamboui.tui.event.KeyModifiers import dev.tamboui.tui.event.KeyModifiers
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
class TambouiKeyMapperTest { class TambouiKeyMapperTest {
@@ -15,156 +14,156 @@ class TambouiKeyMapperTest {
@Test @Test
fun `ctrl-c maps to Quit in InputMode ROUTER`() { fun `ctrl-c maps to Quit in InputMode ROUTER`() {
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.ROUTER)) assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)))
} }
@Test @Test
fun `ctrl-c maps to Quit in InputMode FILTER`() { fun `ctrl-c maps to Quit in InputMode FILTER`() {
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.FILTER)) assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)))
} }
@Test @Test
fun `ctrl-c maps to Quit in InputMode NAVIGATE`() { fun `ctrl-c maps to Quit in InputMode NAVIGATE`() {
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.NAVIGATE)) assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)))
} }
@Test @Test
fun `ctrl-c maps to Quit in InputMode STEER`() { fun `ctrl-c maps to Quit in InputMode STEER`() {
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code), InputMode.STEER)) assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'c'.code)))
} }
// --- Ctrl+key → keybind events --- // --- Ctrl+key → keybind events ---
@Test @Test
fun `ctrl-q maps to Quit`() { fun `ctrl-q maps to Quit`() {
assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'q'.code), InputMode.ROUTER)) assertEquals(KeyEvent.Quit, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'q'.code)))
} }
@Test @Test
fun `ctrl-a maps to Approve`() { fun `ctrl-a maps to Approve`() {
assertEquals(KeyEvent.Approve, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'a'.code), InputMode.ROUTER)) assertEquals(KeyEvent.Approve, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'a'.code)))
} }
@Test @Test
fun `ctrl-r maps to Reject`() { fun `ctrl-r maps to Reject`() {
assertEquals(KeyEvent.Reject, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'r'.code), InputMode.ROUTER)) assertEquals(KeyEvent.Reject, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'r'.code)))
} }
@Test @Test
fun `ctrl-n maps to NewSession`() { fun `ctrl-n maps to NewSession`() {
assertEquals(KeyEvent.NewSession, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'n'.code), InputMode.ROUTER)) assertEquals(KeyEvent.NewSession, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'n'.code)))
} }
@Test @Test
fun `ctrl-s maps to Steer`() { fun `ctrl-s maps to Steer`() {
assertEquals(KeyEvent.Steer, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 's'.code), InputMode.ROUTER)) assertEquals(KeyEvent.Steer, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 's'.code)))
} }
@Test @Test
fun `ctrl-h maps to ToggleApprovalOverlay`() { fun `ctrl-h maps to ToggleApprovalOverlay`() {
assertEquals(KeyEvent.ToggleApprovalOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'h'.code), InputMode.ROUTER)) assertEquals(KeyEvent.ToggleApprovalOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'h'.code)))
} }
@Test @Test
fun `ctrl-e maps to ToggleEventOverlay`() { fun `ctrl-e maps to ToggleEventOverlay`() {
assertEquals(KeyEvent.ToggleEventOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'e'.code), InputMode.ROUTER)) assertEquals(KeyEvent.ToggleEventOverlay, mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'e'.code)))
} }
@Test @Test
fun `ctrl+unbound key maps to null`() { fun `ctrl+unbound key maps to null`() {
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'z'.code), InputMode.ROUTER)) assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.CTRL, 'z'.code)))
} }
// --- Alt always → null --- // --- Alt always → null ---
@Test @Test
fun `alt modifier maps to null`() { fun `alt modifier maps to null`() {
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.ALT, 'q'.code), InputMode.ROUTER)) assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.ALT, 'q'.code)))
} }
// --- Bare chars always → CharInput (never trigger keybinds) --- // --- Bare chars always → CharInput (never trigger keybinds) ---
@Test @Test
fun `bare a maps to CharInput in ROUTER`() { fun `bare a maps to CharInput in ROUTER`() {
assertEquals(KeyEvent.CharInput('a'), mapKey(TambouiKeyEvent.ofChar('a'), InputMode.ROUTER)) assertEquals(KeyEvent.CharInput('a'), mapKey(TambouiKeyEvent.ofChar('a')))
} }
@Test @Test
fun `bare q maps to CharInput in ROUTER`() { fun `bare q maps to CharInput in ROUTER`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.ROUTER)) assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q')))
} }
@Test @Test
fun `bare s maps to CharInput in ROUTER`() { fun `bare s maps to CharInput in ROUTER`() {
assertEquals(KeyEvent.CharInput('s'), mapKey(TambouiKeyEvent.ofChar('s'), InputMode.ROUTER)) assertEquals(KeyEvent.CharInput('s'), mapKey(TambouiKeyEvent.ofChar('s')))
} }
@Test @Test
fun `bare x maps to CharInput in ROUTER`() { fun `bare x maps to CharInput in ROUTER`() {
assertEquals(KeyEvent.CharInput('x'), mapKey(TambouiKeyEvent.ofChar('x'), InputMode.ROUTER)) assertEquals(KeyEvent.CharInput('x'), mapKey(TambouiKeyEvent.ofChar('x')))
} }
@Test @Test
fun `bare q maps to CharInput in FILTER`() { fun `bare q maps to CharInput in FILTER`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.FILTER)) assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q')))
} }
@Test @Test
fun `bare q maps to CharInput in STEER`() { fun `bare q maps to CharInput in STEER`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.STEER)) assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q')))
} }
@Test @Test
fun `bare q maps to CharInput in NAVIGATE`() { fun `bare q maps to CharInput in NAVIGATE`() {
assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q'), InputMode.NAVIGATE)) assertEquals(KeyEvent.CharInput('q'), mapKey(TambouiKeyEvent.ofChar('q')))
} }
// --- KeyCode mappings --- // --- KeyCode mappings ---
@Test @Test
fun `UP maps to NavUp`() { fun `UP maps to NavUp`() {
assertEquals(KeyEvent.NavUp, mapKey(TambouiKeyEvent.ofKey(KeyCode.UP), InputMode.ROUTER)) assertEquals(KeyEvent.NavUp, mapKey(TambouiKeyEvent.ofKey(KeyCode.UP)))
} }
@Test @Test
fun `DOWN maps to NavDown`() { fun `DOWN maps to NavDown`() {
assertEquals(KeyEvent.NavDown, mapKey(TambouiKeyEvent.ofKey(KeyCode.DOWN), InputMode.ROUTER)) assertEquals(KeyEvent.NavDown, mapKey(TambouiKeyEvent.ofKey(KeyCode.DOWN)))
} }
@Test @Test
fun `ENTER maps to Enter`() { fun `ENTER maps to Enter`() {
assertEquals(KeyEvent.Enter, mapKey(TambouiKeyEvent.ofKey(KeyCode.ENTER), InputMode.ROUTER)) assertEquals(KeyEvent.Enter, mapKey(TambouiKeyEvent.ofKey(KeyCode.ENTER)))
} }
@Test @Test
fun `ESCAPE maps to Escape`() { fun `ESCAPE maps to Escape`() {
assertEquals(KeyEvent.Escape, mapKey(TambouiKeyEvent.ofKey(KeyCode.ESCAPE), InputMode.ROUTER)) assertEquals(KeyEvent.Escape, mapKey(TambouiKeyEvent.ofKey(KeyCode.ESCAPE)))
} }
@Test @Test
fun `BACKSPACE maps to Backspace`() { fun `BACKSPACE maps to Backspace`() {
assertEquals(KeyEvent.Backspace, mapKey(TambouiKeyEvent.ofKey(KeyCode.BACKSPACE), InputMode.ROUTER)) assertEquals(KeyEvent.Backspace, mapKey(TambouiKeyEvent.ofKey(KeyCode.BACKSPACE)))
} }
@Test @Test
fun `TAB maps to Filter`() { fun `TAB maps to Filter`() {
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB), InputMode.ROUTER)) assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB)))
} }
@Test @Test
fun `TAB maps to Filter in FILTER mode`() { fun `TAB maps to Filter in FILTER mode`() {
assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB), InputMode.FILTER)) assertEquals(KeyEvent.Filter, mapKey(TambouiKeyEvent.ofKey(KeyCode.TAB)))
} }
// --- ISO control chars → null --- // --- ISO control chars → null ---
@Test @Test
fun `ESC codepoint as CHAR event maps to null`() { fun `ESC codepoint as CHAR event maps to null`() {
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x1B), InputMode.ROUTER)) assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x1B)))
} }
@Test @Test
fun `NUL codepoint as CHAR event maps to null`() { fun `NUL codepoint as CHAR event maps to null`() {
assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x00), InputMode.ROUTER)) assertNull(mapKey(TambouiKeyEvent(KeyCode.CHAR, KeyModifiers.NONE, 0x00)))
} }
} }
@@ -51,7 +51,11 @@ class RootReducerTest {
) )
val (state2, _) = RootReducer.reduce(state1, Action.ServerEventReceived(startMsg2), fixedClock) val (state2, _) = RootReducer.reduce(state1, Action.ServerEventReceived(startMsg2), fixedClock)
// selected is s1 (first); navigating down should move to s2 // selected is s1 (first); navigating down should move to s2
val (state3, _) = RootReducer.reduce(state2.copy(inputMode = InputMode.NAVIGATE), Action.NavigateDown, fixedClock) val (state3, _) = RootReducer.reduce(
state2.copy(inputMode = InputMode.NAVIGATE),
Action.NavigateDown,
fixedClock,
)
assertEquals("s2", state3.sessions.selectedId) assertEquals("s2", state3.sessions.selectedId)
} }
@@ -132,7 +132,11 @@ class SessionsReducerTest {
sessions = listOf(session("s1").copy(currentStage = "stage-1")), sessions = listOf(session("s1").copy(currentStage = "stage-1")),
selectedId = "s1", selectedId = "s1",
) )
val msg = ServerMessage.StageCompleted(sessionId = SessionId("s1"), stageId = StageId("stage-1")) val msg = ServerMessage.StageCompleted(
sessionId = SessionId("s1"),
stageId = StageId("stage-1"),
occurredAt = fixedClock(),
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals(null, state.sessions[0].currentStage) assertEquals(null, state.sessions[0].currentStage)
assertEquals(1000L, state.sessions[0].lastEventAt) assertEquals(1000L, state.sessions[0].lastEventAt)
@@ -148,6 +152,7 @@ class SessionsReducerTest {
sessionId = SessionId("s1"), sessionId = SessionId("s1"),
stageId = StageId("stage-1"), stageId = StageId("stage-1"),
reason = "error", reason = "error",
occurredAt = fixedClock(),
) )
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals(null, state.sessions[0].currentStage) assertEquals(null, state.sessions[0].currentStage)
@@ -177,6 +182,7 @@ class SessionsReducerTest {
val msg = ServerMessage.InferenceCompleted( val msg = ServerMessage.InferenceCompleted(
sessionId = SessionId("s1"), stageId = StageId("stage-1"), sessionId = SessionId("s1"), stageId = StageId("stage-1"),
outputSummary = "summary", responseText = "full response", outputSummary = "summary", responseText = "full response",
occurredAt = fixedClock(),
) )
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("summary", state.sessions[0].lastOutput) assertEquals("summary", state.sessions[0].lastOutput)
@@ -190,6 +196,7 @@ class SessionsReducerTest {
val msg = ServerMessage.InferenceCompleted( val msg = ServerMessage.InferenceCompleted(
sessionId = SessionId("s1"), stageId = StageId("stage-1"), sessionId = SessionId("s1"), stageId = StageId("stage-1"),
outputSummary = "", responseText = "", outputSummary = "", responseText = "",
occurredAt = fixedClock(),
) )
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals(null, state.sessions[0].lastResponseText) assertEquals(null, state.sessions[0].lastResponseText)
@@ -198,7 +205,11 @@ class SessionsReducerTest {
@Test @Test
fun `ServerEventReceived StageStarted sets currentStage`() { fun `ServerEventReceived StageStarted sets currentStage`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1") val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.StageStarted(sessionId = SessionId("s1"), stageId = StageId("stage-1")) val msg = ServerMessage.StageStarted(
sessionId = SessionId("s1"),
stageId = StageId("stage-1"),
occurredAt = fixedClock(),
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("stage-1", state.sessions[0].currentStage) assertEquals("stage-1", state.sessions[0].currentStage)
assertEquals(1000L, state.sessions[0].lastEventAt) assertEquals(1000L, state.sessions[0].lastEventAt)
@@ -207,7 +218,12 @@ class SessionsReducerTest {
@Test @Test
fun `ServerEventReceived ToolCompleted updates lastOutput`() { fun `ServerEventReceived ToolCompleted updates lastOutput`() {
val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1") val s = SessionsState(sessions = listOf(session("s1")), selectedId = "s1")
val msg = ServerMessage.ToolCompleted(sessionId = SessionId("s1"), toolName = "file_write", outputSummary = "wrote 3 lines") val msg = ServerMessage.ToolCompleted(
sessionId = SessionId("s1"),
toolName = "file_write",
outputSummary = "wrote 3 lines",
occurredAt = fixedClock(),
)
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg)) val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
assertEquals("file_write: wrote 3 lines", state.sessions[0].lastOutput) assertEquals("file_write: wrote 3 lines", state.sessions[0].lastOutput)
assertEquals(1000L, state.sessions[0].lastEventAt) assertEquals(1000L, state.sessions[0].lastEventAt)
@@ -13,4 +13,4 @@ data class StoredEvent(
val metadata: EventMetadata, val metadata: EventMetadata,
val sequence: Long, val sequence: Long,
val payload: EventPayload val payload: EventPayload
) )
@@ -15,4 +15,4 @@ data class EventMetadata(
val schemaVersion: Int, val schemaVersion: Int,
val causationId: CausationId?, val causationId: CausationId?,
val correlationId: CorrelationId? val correlationId: CorrelationId?
) )
@@ -3,4 +3,4 @@ package com.correx.core.events.events
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
sealed interface EventPayload sealed interface EventPayload
@@ -60,4 +60,4 @@ data class ModelUnloadedEvent(
val providerId: ProviderId, val providerId: ProviderId,
val sessionId: SessionId, val sessionId: SessionId,
val cancellationReason: String? = null, // serialized label, not the sealed class val cancellationReason: String? = null, // serialized label, not the sealed class
) : EventPayload ) : EventPayload
@@ -8,6 +8,7 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class WorkflowStartedEvent( data class WorkflowStartedEvent(
val sessionId: SessionId, val sessionId: SessionId,
val workflowId: String,
val startStageId: StageId, val startStageId: StageId,
val retryPolicy: RetryPolicy? = null, val retryPolicy: RetryPolicy? = null,
) : EventPayload ) : EventPayload
@@ -17,6 +18,7 @@ data class WorkflowCompletedEvent(
val sessionId: SessionId, val sessionId: SessionId,
val terminalStageId: StageId, val terminalStageId: StageId,
val totalStages: Int, val totalStages: Int,
val workflowId: String,
) : EventPayload ) : EventPayload
@Serializable @Serializable
@@ -47,4 +49,4 @@ data class RetryAttemptedEvent(
val attemptNumber: Int, val attemptNumber: Int,
val maxAttempts: Int, val maxAttempts: Int,
val failureReason: String, val failureReason: String,
) : EventPayload ) : EventPayload
@@ -5,4 +5,4 @@ import com.correx.core.events.events.EventPayload
interface EventSerializer { interface EventSerializer {
fun serialize(payload: EventPayload): String fun serialize(payload: EventPayload): String
fun deserialize(raw: String): EventPayload fun deserialize(raw: String): EventPayload
} }
@@ -12,4 +12,4 @@ class JsonEventSerializer(
override fun deserialize(raw: String): EventPayload = override fun deserialize(raw: String): EventPayload =
json.decodeFromString(EventPayload.serializer(), raw) json.decodeFromString(EventPayload.serializer(), raw)
} }
@@ -33,10 +33,10 @@ import com.correx.core.events.events.SessionFailedEvent
import com.correx.core.events.events.SessionPausedEvent import com.correx.core.events.events.SessionPausedEvent
import com.correx.core.events.events.SessionResumedEvent import com.correx.core.events.events.SessionResumedEvent
import com.correx.core.events.events.SessionStartedEvent import com.correx.core.events.events.SessionStartedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.StageStartedEvent import com.correx.core.events.events.StageStartedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent import com.correx.core.events.events.ToolExecutionFailedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent import com.correx.core.events.events.ToolExecutionRejectedEvent
@@ -106,4 +106,4 @@ val eventModule = SerializersModule {
val eventJson = Json { val eventJson = Json {
serializersModule = eventModule serializersModule = eventModule
} }
@@ -47,4 +47,4 @@ interface EventStore {
* Intended for batch jobs (e.g. CAS liveness scans). Implementations should stream lazily. * Intended for batch jobs (e.g. CAS liveness scans). Implementations should stream lazily.
*/ */
fun allEvents(): Sequence<StoredEvent> fun allEvents(): Sequence<StoredEvent>
} }
@@ -39,4 +39,4 @@ typealias ToolInvocationId = TypeId
// Inference // Inference
typealias InferenceRequestId = TypeId typealias InferenceRequestId = TypeId
typealias ProviderId = TypeId typealias ProviderId = TypeId
@@ -8,4 +8,4 @@ data class TokenUsage(
val completionTokens: Int, val completionTokens: Int,
) { ) {
val totalTokens: Int get() = promptTokens + completionTokens val totalTokens: Int get() = promptTokens + completionTokens
} }
@@ -11,4 +11,4 @@ class DefaultStateBuilder<S>(
projection.apply(state, event) projection.apply(state, event)
} }
} }
} }
@@ -7,4 +7,4 @@ interface Projection<S> {
fun initial(): S fun initial(): S
fun apply(state: S, event: StoredEvent): S fun apply(state: S, event: StoredEvent): S
} }
@@ -4,4 +4,4 @@ import com.correx.core.events.events.StoredEvent
interface StateBuilder<S> { interface StateBuilder<S> {
fun build(events: List<StoredEvent>): S fun build(events: List<StoredEvent>): S
} }
@@ -16,4 +16,4 @@ class DefaultEventReplayer<S>(
return DefaultStateBuilder(projection) return DefaultStateBuilder(projection)
.build(events) .build(events)
} }
} }
@@ -4,4 +4,4 @@ import com.correx.core.events.types.SessionId
interface EventReplayer<S> { interface EventReplayer<S> {
fun rebuild(sessionId: SessionId): S fun rebuild(sessionId: SessionId): S
} }
@@ -11,4 +11,4 @@ value class TypeId(val value: String) {
} }
override fun toString(): String = value override fun toString(): String = value
} }
@@ -44,4 +44,4 @@ class DefaultInferenceReducer : InferenceReducer {
return state.copy(records = updated) return state.copy(records = updated)
} }
} }
@@ -16,4 +16,4 @@ sealed class FinishReason {
object Cancelled : FinishReason() object Cancelled : FinishReason()
@Serializable @Serializable
data class Error(val message: String) : FinishReason() data class Error(val message: String) : FinishReason()
} }
@@ -13,4 +13,4 @@ data class GenerationConfig(
val maxTokens: Int, val maxTokens: Int,
val stopSequences: List<String> = emptyList(), val stopSequences: List<String> = emptyList(),
val seed: Long? = null, // null = non-deterministic; set for replay val seed: Long? = null, // null = non-deterministic; set for replay
) )
@@ -28,4 +28,4 @@ package com.correx.core.inference
interface InferenceCancellationToken { interface InferenceCancellationToken {
val isCancelled: Boolean val isCancelled: Boolean
fun cancel(reason: CancellationReason) fun cancel(reason: CancellationReason)
} }
@@ -12,4 +12,4 @@ class InferenceProjector(
state: InferenceState, state: InferenceState,
event: StoredEvent event: StoredEvent
): InferenceState = reducer.reduce(state, event) ): InferenceState = reducer.reduce(state, event)
} }
@@ -17,4 +17,4 @@ interface ProviderRegistry {
fun resolve(capability: ModelCapability): List<InferenceProvider> // ordered by score desc fun resolve(capability: ModelCapability): List<InferenceProvider> // ordered by score desc
fun listAll(): List<InferenceProvider> fun listAll(): List<InferenceProvider>
suspend fun healthCheckAll(): Map<ProviderId, ProviderHealth> suspend fun healthCheckAll(): Map<ProviderId, ProviderHealth>
} }
@@ -16,4 +16,4 @@ data class InferenceRecord(
val tokensUsed: TokenUsage? = null, val tokensUsed: TokenUsage? = null,
val latencyMs: Long? = null, val latencyMs: Long? = null,
val failureReason: String? = null, val failureReason: String? = null,
) )
@@ -4,4 +4,4 @@ import com.correx.core.events.events.StoredEvent
interface InferenceReducer { interface InferenceReducer {
fun reduce(state: InferenceState, event: StoredEvent): InferenceState fun reduce(state: InferenceState, event: StoredEvent): InferenceState
} }
@@ -9,4 +9,4 @@ class InferenceRepository(
fun getInferenceState(sessionId: SessionId): InferenceState { fun getInferenceState(sessionId: SessionId): InferenceState {
return replayer.rebuild(sessionId) return replayer.rebuild(sessionId)
} }
} }
@@ -16,4 +16,4 @@ data class InferenceRequest(
val timeout: InferenceTimeout? = null, val timeout: InferenceTimeout? = null,
val responseFormat: ResponseFormat = ResponseFormat.Text, val responseFormat: ResponseFormat = ResponseFormat.Text,
val tools: List<ToolDefinition> = emptyList(), val tools: List<ToolDefinition> = emptyList(),
) )
@@ -11,4 +11,4 @@ data class InferenceResponse(
val tokensUsed: TokenUsage, val tokensUsed: TokenUsage,
val latencyMs: Long, val latencyMs: Long,
val toolCalls: List<ToolCallRequest> = emptyList(), val toolCalls: List<ToolCallRequest> = emptyList(),
) )
@@ -41,4 +41,4 @@ class ProviderUnavailableException(
reason: String, reason: String,
) : Exception( ) : Exception(
"Provider '${providerId.value}' is unavailable: $reason", "Provider '${providerId.value}' is unavailable: $reason",
) )
@@ -5,4 +5,4 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class InferenceState( data class InferenceState(
val records: List<InferenceRecord> = emptyList() val records: List<InferenceRecord> = emptyList()
) )
@@ -2,4 +2,4 @@ package com.correx.core.inference
enum class InferenceStatus { enum class InferenceStatus {
STARTED, COMPLETED, FAILED, TIMED_OUT STARTED, COMPLETED, FAILED, TIMED_OUT
} }
@@ -4,4 +4,4 @@ sealed class ProviderHealth {
object Healthy : ProviderHealth() object Healthy : ProviderHealth()
data class Degraded(val reason: String) : ProviderHealth() data class Degraded(val reason: String) : ProviderHealth()
data class Unavailable(val reason: String) : ProviderHealth() data class Unavailable(val reason: String) : ProviderHealth()
} }
@@ -10,4 +10,4 @@ value class Token(val id: Int)
interface Tokenizer { interface Tokenizer {
suspend fun tokenize(text: String): List<Token> suspend fun tokenize(text: String): List<Token>
suspend fun countTokens(text: String): Int suspend fun countTokens(text: String): Int
} }
@@ -13,4 +13,4 @@ data class ToolCallRequest(
data class ToolCallFunction( data class ToolCallFunction(
val name: String, val name: String,
val arguments: String, val arguments: String,
) )
@@ -4,4 +4,4 @@ sealed interface ReplayStrategy {
data object Full : ReplayStrategy data object Full : ReplayStrategy
data object SkipInference : ReplayStrategy // use recorded InferenceCompletedEvent artifacts data object SkipInference : ReplayStrategy // use recorded InferenceCompletedEvent artifacts
data object SkipValidation : ReplayStrategy // trust recorded outcomes data object SkipValidation : ReplayStrategy // trust recorded outcomes
} }
@@ -15,4 +15,4 @@ sealed interface StageOutcome {
data class InferenceFailure(val reason: String, val retryable: Boolean) : StageOutcome data class InferenceFailure(val reason: String, val retryable: Boolean) : StageOutcome
data class ApprovalRequired(val request: DomainApprovalRequest) : StageOutcome data class ApprovalRequired(val request: DomainApprovalRequest) : StageOutcome
data object Cancelled : StageOutcome data object Cancelled : StageOutcome
} }
@@ -7,4 +7,4 @@ sealed interface WorkflowResult {
data class Completed(val sessionId: SessionId, val terminalStageId: StageId) : WorkflowResult data class Completed(val sessionId: SessionId, val terminalStageId: StageId) : WorkflowResult
data class Failed(val sessionId: SessionId, val reason: String, val retryExhausted: Boolean) : WorkflowResult data class Failed(val sessionId: SessionId, val reason: String, val retryExhausted: Boolean) : WorkflowResult
data class Cancelled(val sessionId: SessionId) : WorkflowResult data class Cancelled(val sessionId: SessionId) : WorkflowResult
} }
@@ -46,4 +46,4 @@ class DefaultOrchestrationReducer : OrchestrationReducer {
else -> state else -> state
} }
} }
@@ -1,14 +1,13 @@
package com.correx.core.kernel.orchestration package com.correx.core.kernel.orchestration
import com.correx.core.approvals.model.ApprovalDecision import com.correx.core.approvals.model.ApprovalDecision
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.artifacts.ArtifactState import com.correx.core.artifacts.ArtifactState
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ArtifactValidatedEvent import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.ArtifactLifecyclePhase
import org.slf4j.LoggerFactory
import com.correx.core.events.orchestration.OrchestrationState import com.correx.core.events.orchestration.OrchestrationState
import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.ArtifactLifecyclePhase
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import com.correx.core.kernel.execution.WorkflowResult import com.correx.core.kernel.execution.WorkflowResult
@@ -17,6 +16,7 @@ import com.correx.core.sessions.Session
import com.correx.core.transitions.execution.StageExecutionResult import com.correx.core.transitions.execution.StageExecutionResult
import com.correx.core.transitions.graph.WorkflowGraph import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.TransitionDecision import com.correx.core.transitions.resolution.TransitionDecision
import org.slf4j.LoggerFactory
import java.util.concurrent.* import java.util.concurrent.*
import java.util.concurrent.atomic.* import java.util.concurrent.atomic.*
@@ -94,7 +94,7 @@ class DefaultSessionOrchestrator(
} }
is TransitionDecision.Stay -> if (isTerminal(enriched.graph, enriched.currentStageId)) { is TransitionDecision.Stay -> if (isTerminal(enriched.graph, enriched.currentStageId)) {
completeWorkflow(enriched.sessionId, enriched.currentStageId, enriched.stageCount) completeWorkflow(enriched.sessionId, enriched.currentStageId, enriched.stageCount, enriched.graph.id)
} else { } else {
failWorkflow( failWorkflow(
sessionId = enriched.sessionId, sessionId = enriched.sessionId,
@@ -140,7 +140,7 @@ class DefaultSessionOrchestrator(
if (isTerminal(ctx.graph, nextStageId)) { if (isTerminal(ctx.graph, nextStageId)) {
// Terminal stage is a sentinel — not executed, so don't count it. // Terminal stage is a sentinel — not executed, so don't count it.
return StepResult.Terminal( return StepResult.Terminal(
completeWorkflow(ctx.sessionId, nextStageId, ctx.stageCount), completeWorkflow(ctx.sessionId, nextStageId, ctx.stageCount, ctx.graph.id),
) )
} }
@@ -150,6 +150,7 @@ class DefaultSessionOrchestrator(
currentStageId = advanceStage(ctx.sessionId, ctx.currentStageId, decision), currentStageId = advanceStage(ctx.sessionId, ctx.currentStageId, decision),
), ),
) )
is StepResult.Terminal -> result is StepResult.Terminal -> result
} }
} }
@@ -171,7 +172,7 @@ class DefaultSessionOrchestrator(
is StageExecutionResult.Failure -> { is StageExecutionResult.Failure -> {
log.debug( log.debug(
"[Orchestrator] stage failed session=${ctx.sessionId.value} " + "[Orchestrator] stage failed session=${ctx.sessionId.value} " +
"stage=${stageId.value} reason=${result.reason} retryable=${result.retryable}", "stage=${stageId.value} reason=${result.reason} retryable=${result.retryable}",
) )
val refreshedState = orchestrationRepository.getState(ctx.sessionId) val refreshedState = orchestrationRepository.getState(ctx.sessionId)
val shouldRetry = retryCoordinator.shouldRetry( val shouldRetry = retryCoordinator.shouldRetry(
@@ -10,4 +10,4 @@ data class OrchestrationConfig(
val stageTimeoutMs: Long = 60_000L, val stageTimeoutMs: Long = 60_000L,
val sandboxRoot: Path = Path.of(System.getProperty("java.io.tmpdir"), "correx-sandbox"), val sandboxRoot: Path = Path.of(System.getProperty("java.io.tmpdir"), "correx-sandbox"),
val defaultSystemPromptPath: String? = null, val defaultSystemPromptPath: String? = null,
) )
@@ -13,4 +13,4 @@ class OrchestrationProjector(
state: OrchestrationState, state: OrchestrationState,
event: StoredEvent, event: StoredEvent,
): OrchestrationState = orchestrationReducer.reduce(state, event) ): OrchestrationState = orchestrationReducer.reduce(state, event)
} }
@@ -5,4 +5,4 @@ import com.correx.core.events.orchestration.OrchestrationState
interface OrchestrationReducer { interface OrchestrationReducer {
fun reduce(state: OrchestrationState, event: StoredEvent): OrchestrationState fun reduce(state: OrchestrationState, event: StoredEvent): OrchestrationState
} }
@@ -8,4 +8,4 @@ class OrchestrationRepository(
private val replayer: EventReplayer<OrchestrationState>, private val replayer: EventReplayer<OrchestrationState>,
) { ) {
fun getState(sessionId: SessionId): OrchestrationState = replayer.rebuild(sessionId) fun getState(sessionId: SessionId): OrchestrationState = replayer.rebuild(sessionId)
} }
@@ -2,7 +2,6 @@ package com.correx.core.kernel.orchestration
import com.correx.core.approvals.domain.NoOpApprovalEngine import com.correx.core.approvals.domain.NoOpApprovalEngine
import com.correx.core.artifactstore.ArtifactStore import com.correx.core.artifactstore.ArtifactStore
import org.slf4j.LoggerFactory
import com.correx.core.context.model.ContextPack import com.correx.core.context.model.ContextPack
import com.correx.core.events.events.ArtifactValidatedEvent import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent import com.correx.core.events.events.ArtifactValidatingEvent
@@ -12,17 +11,18 @@ import com.correx.core.events.types.StageId
import com.correx.core.inference.GenerationConfig import com.correx.core.inference.GenerationConfig
import com.correx.core.inference.InferenceRequest import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.ResponseFormat import com.correx.core.inference.ResponseFormat
import com.correx.core.sessions.Session
import com.correx.core.kernel.execution.ReplayStrategy import com.correx.core.kernel.execution.ReplayStrategy
import com.correx.core.kernel.execution.WorkflowResult import com.correx.core.kernel.execution.WorkflowResult
import com.correx.core.kernel.replay.ReplayInferenceProvider import com.correx.core.kernel.replay.ReplayInferenceProvider
import com.correx.core.kernel.retry.exception.ReplayArtifactMissingException import com.correx.core.kernel.retry.exception.ReplayArtifactMissingException
import com.correx.core.risk.NoOpRiskAssessor import com.correx.core.risk.NoOpRiskAssessor
import com.correx.core.sessions.Session
import com.correx.core.transitions.execution.StageExecutionResult import com.correx.core.transitions.execution.StageExecutionResult
import com.correx.core.transitions.graph.StageConfig import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.WorkflowGraph import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.TransitionDecision import com.correx.core.transitions.resolution.TransitionDecision
import com.correx.core.validation.model.ValidationContext import com.correx.core.validation.model.ValidationContext
import org.slf4j.LoggerFactory
import java.util.* import java.util.*
import java.util.concurrent.* import java.util.concurrent.*
import java.util.concurrent.atomic.* import java.util.concurrent.atomic.*
@@ -87,7 +87,7 @@ class ReplayOrchestrator(
val nextStageId = decision.to val nextStageId = decision.to
if (isTerminal(ctx.graph, nextStageId)) { if (isTerminal(ctx.graph, nextStageId)) {
return completeWorkflow(ctx.sessionId, nextStageId, ctx.stageCount) return completeWorkflow(ctx.sessionId, nextStageId, ctx.stageCount, ctx.graph.id)
} }
when (val result = enterStage(ctx, nextStageId, session)) { when (val result = enterStage(ctx, nextStageId, session)) {
@@ -101,7 +101,7 @@ class ReplayOrchestrator(
} }
is TransitionDecision.Stay -> if (isTerminal(ctx.graph, ctx.currentStageId)) { is TransitionDecision.Stay -> if (isTerminal(ctx.graph, ctx.currentStageId)) {
completeWorkflow(ctx.sessionId, ctx.currentStageId, ctx.stageCount) completeWorkflow(ctx.sessionId, ctx.currentStageId, ctx.stageCount, ctx.graph.id)
} else { } else {
step(ctx) step(ctx)
} }
@@ -2,22 +2,24 @@ package com.correx.core.kernel.orchestration
import com.correx.core.approvals.ApprovalOutcome import com.correx.core.approvals.ApprovalOutcome
import com.correx.core.approvals.ApprovalStatus import com.correx.core.approvals.ApprovalStatus
import com.correx.core.approvals.Tier
import com.correx.core.approvals.model.ApprovalDecision import com.correx.core.approvals.model.ApprovalDecision
import com.correx.core.approvals.model.DomainApprovalRequest import com.correx.core.approvals.model.DomainApprovalRequest
import com.correx.core.artifacts.ArtifactState
import com.correx.core.artifacts.kind.JsonSchema
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.context.builder.ContextPackBuilder import com.correx.core.context.builder.ContextPackBuilder
import com.correx.core.context.model.ContextEntry import com.correx.core.context.model.ContextEntry
import com.correx.core.context.model.ContextLayer import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.ContextPack import com.correx.core.context.model.ContextPack
import com.correx.core.context.model.TokenBudget import com.correx.core.context.model.TokenBudget
import com.correx.core.events.types.ContextEntryId import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactValidatedEvent import com.correx.core.events.events.ArtifactValidatedEvent
import com.correx.core.events.events.ArtifactValidatingEvent import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload import com.correx.core.events.events.EventPayload
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.InferenceCompletedEvent import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.InferenceFailedEvent import com.correx.core.events.events.InferenceFailedEvent
import com.correx.core.events.events.InferenceStartedEvent import com.correx.core.events.events.InferenceStartedEvent
@@ -26,27 +28,25 @@ import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.RiskAssessedEvent import com.correx.core.events.events.RiskAssessedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.events.TransitionExecutedEvent import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.artifacts.ArtifactState
import com.correx.core.artifacts.kind.JsonSchema
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ApprovalDecisionId import com.correx.core.events.types.ApprovalDecisionId
import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.ApprovalRequestId
import com.correx.core.events.types.ArtifactId import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.ContextPackId import com.correx.core.events.types.ContextPackId
import com.correx.core.events.types.EventId import com.correx.core.events.types.EventId
import com.correx.core.events.types.InferenceRequestId import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.RiskSummaryId import com.correx.core.events.types.RiskSummaryId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import com.correx.core.events.types.ValidationReportId
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ToolInvocationId import com.correx.core.events.types.ToolInvocationId
import com.correx.core.events.types.ValidationReportId
import com.correx.core.inference.FinishReason import com.correx.core.inference.FinishReason
import com.correx.core.inference.InferenceRepository import com.correx.core.inference.InferenceRepository
import com.correx.core.inference.InferenceRequest import com.correx.core.inference.InferenceRequest
@@ -56,14 +56,14 @@ import com.correx.core.inference.ResponseFormat
import com.correx.core.inference.ToolCallRequest import com.correx.core.inference.ToolCallRequest
import com.correx.core.inference.ToolDefinition import com.correx.core.inference.ToolDefinition
import com.correx.core.inference.ToolFunction import com.correx.core.inference.ToolFunction
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.registry.ToolRegistry
import com.correx.core.kernel.execution.WorkflowResult import com.correx.core.kernel.execution.WorkflowResult
import com.correx.core.risk.RiskAssessor import com.correx.core.risk.RiskAssessor
import com.correx.core.risk.RiskContext import com.correx.core.risk.RiskContext
import com.correx.core.risk.toApprovalTier import com.correx.core.risk.toApprovalTier
import com.correx.core.sessions.Session import com.correx.core.sessions.Session
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.registry.ToolRegistry
import com.correx.core.transitions.evaluation.EvaluationContext import com.correx.core.transitions.evaluation.EvaluationContext
import com.correx.core.transitions.evaluation.PromptResolver import com.correx.core.transitions.evaluation.PromptResolver
import com.correx.core.transitions.execution.StageExecutionResult import com.correx.core.transitions.execution.StageExecutionResult
@@ -587,6 +587,7 @@ abstract class SessionOrchestrator(
sessionId, sessionId,
WorkflowStartedEvent( WorkflowStartedEvent(
sessionId = sessionId, sessionId = sessionId,
workflowId = graph.id,
startStageId = graph.start, startStageId = graph.start,
retryPolicy = config.retryPolicy, retryPolicy = config.retryPolicy,
), ),
@@ -597,12 +598,13 @@ abstract class SessionOrchestrator(
sessionId: SessionId, sessionId: SessionId,
terminalStageId: StageId, terminalStageId: StageId,
stageCount: Int, stageCount: Int,
workflowId: String,
): WorkflowResult.Completed { ): WorkflowResult.Completed {
log.debug( log.debug(
"[Orchestrator] COMPLETED session={} terminalStage={} stages={}", "[Orchestrator] COMPLETED session={} terminalStage={} stages={}",
sessionId.value, terminalStageId.value, stageCount, sessionId.value, terminalStageId.value, stageCount,
) )
emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount)) emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount, workflowId))
cancellations.remove(sessionId) cancellations.remove(sessionId)
return WorkflowResult.Completed(sessionId, terminalStageId) return WorkflowResult.Completed(sessionId, terminalStageId)
} }
@@ -13,7 +13,7 @@ import com.correx.core.router.model.RouterConfig
import com.correx.core.router.model.RouterL2Entry import com.correx.core.router.model.RouterL2Entry
import com.correx.core.router.model.RouterState import com.correx.core.router.model.RouterState
import com.correx.core.router.model.RouterTurn import com.correx.core.router.model.RouterTurn
import java.util.UUID import java.util.*
interface RouterContextBuilder { interface RouterContextBuilder {
fun build(state: RouterState, budget: TokenBudget): ContextPack fun build(state: RouterState, budget: TokenBudget): ContextPack
@@ -5,11 +5,11 @@ import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.SteeringNoteAddedEvent import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId import com.correx.core.events.types.EventId
import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import com.correx.core.inference.GenerationConfig import com.correx.core.inference.GenerationConfig
import com.correx.core.inference.InferenceRequest import com.correx.core.inference.InferenceRequest
import com.correx.core.events.types.InferenceRequestId
import com.correx.core.inference.InferenceRouter import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.ModelCapability import com.correx.core.inference.ModelCapability
import com.correx.core.inference.ResponseFormat import com.correx.core.inference.ResponseFormat
@@ -18,8 +18,8 @@ import com.correx.core.router.model.RouterResponse
import com.correx.core.router.model.RouterTurn import com.correx.core.router.model.RouterTurn
import com.correx.core.router.model.TurnRole import com.correx.core.router.model.TurnRole
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import java.util.UUID import java.util.*
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.*
interface RouterFacade { interface RouterFacade {
suspend fun onUserInput( suspend fun onUserInput(
@@ -2,9 +2,9 @@ package com.correx.core.router
import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.StageCompletedEvent import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.WorkflowCompletedEvent import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.events.WorkflowStartedEvent
@@ -8,4 +8,4 @@ enum class ApprovalMode {
PROMPT, PROMPT,
AUTO, AUTO,
YOLO YOLO
} }
@@ -56,4 +56,4 @@ class DefaultSessionReducer : SessionReducer {
updatedAt = event.metadata.timestamp updatedAt = event.metadata.timestamp
) )
} }
} }
@@ -13,4 +13,4 @@ class DefaultSessionRepository(
sessionId, sessionId,
replayer.rebuild(sessionId), replayer.rebuild(sessionId),
) )
} }
@@ -16,4 +16,4 @@ class SessionCounterProjection(
): SessionCounterState { ): SessionCounterState {
return state.copy(count = state.count + 1) return state.copy(count = state.count + 1)
} }
} }
@@ -3,4 +3,4 @@ package com.correx.core.sessions
data class SessionCounterState( data class SessionCounterState(
val sessionId: String, val sessionId: String,
val count: Int val count: Int
) )
@@ -16,4 +16,4 @@ class SessionProjector(
state: SessionState, state: SessionState,
event: StoredEvent event: StoredEvent
): SessionState = reducer.reduce(state, event) ): SessionState = reducer.reduce(state, event)
} }
@@ -7,4 +7,4 @@ interface SessionReducer {
state: SessionState, state: SessionState,
event: StoredEvent event: StoredEvent
): SessionState ): SessionState
} }
@@ -7,4 +7,4 @@ enum class SessionStatus {
COMPLETED, COMPLETED,
FAILED, FAILED,
; ;
} }
@@ -3,4 +3,4 @@ package com.correx.core.sessions
sealed interface TransitionResult { sealed interface TransitionResult {
data class Applied(val newState: SessionStatus) : TransitionResult data class Applied(val newState: SessionStatus) : TransitionResult
data object Rejected : TransitionResult data object Rejected : TransitionResult
} }
@@ -2,4 +2,4 @@ package com.correx.core.sessions.projections
import com.correx.testing.contracts.fixtures.projections.CountingProjectionContractTest import com.correx.testing.contracts.fixtures.projections.CountingProjectionContractTest
class SessionCounterProjectionTest : CountingProjectionContractTest() class SessionCounterProjectionTest : CountingProjectionContractTest()
@@ -31,4 +31,4 @@ internal class CycleExtractor(
return canonicalize(cycles) return canonicalize(cycles)
} }
} }
@@ -6,4 +6,4 @@ import com.correx.core.transitions.graph.TransitionEdge
data class DetectedCycle( data class DetectedCycle(
val nodes: List<StageId>, val nodes: List<StageId>,
val edges: List<TransitionEdge> val edges: List<TransitionEdge>
) )
@@ -18,4 +18,4 @@ internal class DeterministicAdjacencyBuilder {
) )
} }
} }
} }
@@ -33,4 +33,4 @@ internal object CycleCanonicalizer {
} }
.sortedBy { it.nodes.first().value } .sortedBy { it.nodes.first().value }
} }
} }
@@ -10,4 +10,4 @@ data class EvaluationContext(
val currentStage: StageId, val currentStage: StageId,
val artifacts: Map<ArtifactId, ArtifactState> = emptyMap(), val artifacts: Map<ArtifactId, ArtifactState> = emptyMap(),
val variables: Map<String, String> = emptyMap(), val variables: Map<String, String> = emptyMap(),
) )
@@ -7,4 +7,4 @@ fun interface TransitionConditionEvaluator {
condition: TransitionCondition, condition: TransitionCondition,
context: EvaluationContext context: EvaluationContext
): Boolean ): Boolean
} }
@@ -9,4 +9,4 @@ sealed interface StageExecutionResult {
val reason: String, val reason: String,
val retryable: Boolean, val retryable: Boolean,
) : StageExecutionResult ) : StageExecutionResult
} }
@@ -4,4 +4,4 @@ interface StageExecutor {
fun execute( fun execute(
request: StageExecutionRequest request: StageExecutionRequest
): StageExecutionResult ): StageExecutionResult
} }
@@ -12,4 +12,4 @@ internal fun WorkflowGraph.sortedTransitions(): List<TransitionEdge> =
{ it.id.value }, { it.id.value },
{ it.to.value } { it.to.value }
) )
) )
@@ -4,4 +4,4 @@ import com.correx.core.transitions.evaluation.EvaluationContext
fun interface TransitionCondition { fun interface TransitionCondition {
fun evaluate(context: EvaluationContext): Boolean fun evaluate(context: EvaluationContext): Boolean
} }
@@ -8,4 +8,4 @@ data class TransitionEdge(
val from: StageId, val from: StageId,
val to: StageId, val to: StageId,
val condition: TransitionCondition val condition: TransitionCondition
) )
@@ -10,6 +10,7 @@ data class WorkflowGraph(
) { ) {
val stageIds: Set<StageId> get() = stages.keys val stageIds: Set<StageId> get() = stages.keys
init { init {
require(id.isNotBlank()) { "workflow id must not be blank" }
require(start in stages) { "start stage must exist in stages" } require(start in stages) { "start stage must exist in stages" }
} }
} }
@@ -44,4 +44,4 @@ class DefaultStageExecutionEventMapper : StageExecutionEventMapper {
) )
} }
} }
} }
@@ -9,4 +9,4 @@ interface StageExecutionEventMapper {
request: StageExecutionRequest, request: StageExecutionRequest,
result: StageExecutionResult result: StageExecutionResult
): List<EventPayload> ): List<EventPayload>
} }
@@ -12,4 +12,4 @@ sealed interface CyclePolicy {
data class Approval( data class Approval(
val timeoutMs: Long val timeoutMs: Long
) : CyclePolicy ) : CyclePolicy
} }
@@ -3,4 +3,4 @@ package com.correx.core.transitions.policy
data class CyclePolicyBinding( data class CyclePolicyBinding(
val cycle: CycleSignature, val cycle: CycleSignature,
val policy: CyclePolicy val policy: CyclePolicy
) )
@@ -9,4 +9,4 @@ class CyclePolicyResolver(
.firstOrNull { it.cycle == signature } .firstOrNull { it.cycle == signature }
?.policy ?.policy
} }
} }
@@ -20,4 +20,4 @@ class PolicyValidation {
return errors return errors
} }
} }

Some files were not shown because too many files have changed in this diff Show More