feature(event-sourcing): baseline for the architecture review fixes.
- fixed the tool overlay in tui. - fixed tool calling - added schemas. - getting all sessionIds via event store. - instead of sending all events on the replay - there's a new way for replaying. - session snapshot is now a thing getting sent for previously created sessions. - added logging to important parts. - revert workflowId from WorkflowCompletedEvent.
This commit is contained in:
@@ -129,6 +129,8 @@ fun main() {
|
||||
providerRegistry = infraRegistry.asServerRegistry(),
|
||||
defaultOrchestrationConfig = defaultOrchestrationConfig,
|
||||
routerFacade = routerFacade,
|
||||
orchestrationRepository = repositories.orchestrationRepository,
|
||||
approvalRepository = repositories.approvalRepository,
|
||||
)
|
||||
log.info("==============================")
|
||||
|
||||
@@ -190,6 +192,7 @@ private fun buildRepositories(
|
||||
DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
|
||||
),
|
||||
artifactRepository = InfrastructureModule.createArtifactRepository(eventStore),
|
||||
approvalRepository = InfrastructureModule.createApprovalRepository(eventStore),
|
||||
)
|
||||
|
||||
private fun buildToolConfig(
|
||||
|
||||
@@ -2,10 +2,12 @@ package com.correx.apps.server
|
||||
|
||||
import com.correx.apps.server.registry.ProviderRegistry
|
||||
import com.correx.apps.server.registry.WorkflowRegistry
|
||||
import com.correx.core.approvals.DefaultApprovalRepository
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
||||
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
import com.correx.core.router.RouterFacade
|
||||
import com.correx.core.sessions.DefaultSessionRepository
|
||||
|
||||
@@ -18,4 +20,6 @@ data class ServerModule(
|
||||
val providerRegistry: ProviderRegistry,
|
||||
val defaultOrchestrationConfig: OrchestrationConfig,
|
||||
val routerFacade: RouterFacade,
|
||||
val orchestrationRepository: OrchestrationRepository,
|
||||
val approvalRepository: DefaultApprovalRepository,
|
||||
)
|
||||
|
||||
@@ -19,7 +19,6 @@ import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
|
||||
class DomainEventMapper(private val artifactStore: ArtifactStore = NoopArtifactStore) {
|
||||
@@ -36,7 +35,6 @@ private object NoopArtifactStore : ArtifactStore {
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
suspend fun domainEventToServerMessage(event: StoredEvent, artifactStore: ArtifactStore): ServerMessage? =
|
||||
when (val p = event.payload) {
|
||||
is WorkflowStartedEvent -> mapWorkflowStarted(p)
|
||||
is WorkflowCompletedEvent -> ServerMessage.SessionCompleted(sessionId = p.sessionId)
|
||||
is WorkflowFailedEvent -> ServerMessage.SessionFailed(sessionId = p.sessionId, reason = p.reason)
|
||||
is TransitionExecutedEvent -> ServerMessage.StageStarted(
|
||||
@@ -91,9 +89,6 @@ suspend fun domainEventToServerMessage(event: StoredEvent, artifactStore: Artifa
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun mapWorkflowStarted(p: WorkflowStartedEvent): ServerMessage =
|
||||
ServerMessage.SessionStarted(sessionId = p.sessionId, workflowId = p.workflowId)
|
||||
|
||||
private fun mapOrchestrationPaused(p: OrchestrationPausedEvent): ServerMessage {
|
||||
val reason = if (p.reason == "APPROVAL_PENDING") PauseReason.APPROVAL_PENDING else PauseReason.USER_REQUESTED
|
||||
return ServerMessage.SessionPaused(sessionId = p.sessionId, reason = reason)
|
||||
@@ -103,15 +98,14 @@ private suspend fun mapInferenceCompleted(
|
||||
event: StoredEvent,
|
||||
p: InferenceCompletedEvent,
|
||||
artifactStore: ArtifactStore,
|
||||
): ServerMessage {
|
||||
val text = runCatching {
|
||||
artifactStore.get(p.responseArtifactId)?.toString(Charsets.UTF_8) ?: ""
|
||||
}.getOrElse { "" }
|
||||
return ServerMessage.InferenceCompleted(
|
||||
): ServerMessage = runCatching {
|
||||
artifactStore.get(p.responseArtifactId)?.toString(Charsets.UTF_8) ?: ""
|
||||
}.getOrElse { "" }.run {
|
||||
ServerMessage.InferenceCompleted(
|
||||
sessionId = p.sessionId,
|
||||
stageId = p.stageId,
|
||||
outputSummary = text,
|
||||
responseText = text,
|
||||
outputSummary = this,
|
||||
responseText = this,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
)
|
||||
}
|
||||
@@ -122,6 +116,6 @@ private fun mapApprovalRequested(p: ApprovalRequestedEvent): ServerMessage =
|
||||
requestId = p.requestId,
|
||||
tier = p.tier,
|
||||
riskSummary = RiskSummaryDto("unknown", emptyList(), ""),
|
||||
toolName = null,
|
||||
preview = null,
|
||||
toolName = p.toolName,
|
||||
preview = p.preview,
|
||||
)
|
||||
|
||||
@@ -1,19 +1,47 @@
|
||||
package com.correx.apps.server.bridge
|
||||
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.core.approvals.DefaultApprovalRepository
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.orchestration.OrchestrationStatus
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
|
||||
class SessionEventBridge(
|
||||
private val eventStore: EventStore,
|
||||
private val artifactStore: ArtifactStore,
|
||||
private val orchestrationRepository: OrchestrationRepository,
|
||||
private val approvalRepository: DefaultApprovalRepository,
|
||||
private val send: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
suspend fun replaySnapshot() {
|
||||
eventStore.allEvents().toList()
|
||||
.mapNotNull { domainEventToServerMessage(it, artifactStore) }
|
||||
.forEach { send(it) }
|
||||
eventStore.allSessionIds().forEach { sessionId ->
|
||||
val orchState = orchestrationRepository.getState(sessionId)
|
||||
if (orchState.status == OrchestrationStatus.IDLE) return@forEach
|
||||
|
||||
val approvalState = if (orchState.pendingApproval) {
|
||||
approvalRepository.getApprovalState(sessionId)
|
||||
} else null
|
||||
|
||||
val pendingRequest = approvalState?.requests?.values
|
||||
?.firstOrNull { req ->
|
||||
approvalState.decisions.values.none { it.requestId == req.id }
|
||||
}
|
||||
|
||||
send(ServerMessage.SessionSnapshot(
|
||||
sessionId = sessionId,
|
||||
workflowId = orchState.workflowId,
|
||||
status = orchState.status.name,
|
||||
currentStageId = orchState.currentStageId?.value,
|
||||
pauseReason = orchState.pauseReason,
|
||||
pendingApproval = orchState.pendingApproval,
|
||||
approvalRequestId = pendingRequest?.id?.value,
|
||||
approvalTier = pendingRequest?.tier?.name,
|
||||
approvalToolName = pendingRequest?.toolName,
|
||||
approvalPreview = pendingRequest?.preview,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun streamLive(sessionId: SessionId) {
|
||||
|
||||
@@ -60,4 +60,5 @@ class LoggingEventStore(private val delegate: EventStore) : EventStore {
|
||||
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = delegate.subscribe(sessionId)
|
||||
|
||||
override fun allEvents(): Sequence<StoredEvent> = delegate.allEvents()
|
||||
override fun allSessionIds(): Set<SessionId> = delegate.allSessionIds().also { log.debug("got session ids from store: {}", it) }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@@ -20,6 +21,21 @@ sealed class ServerMessage {
|
||||
@Serializable
|
||||
data class SessionFailed(val sessionId: SessionId, val reason: String) : ServerMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("session_snapshot")
|
||||
data class SessionSnapshot(
|
||||
val sessionId: SessionId,
|
||||
val workflowId: String,
|
||||
val status: String,
|
||||
val currentStageId: String?,
|
||||
val pauseReason: String?,
|
||||
val pendingApproval: Boolean,
|
||||
val approvalRequestId: String?,
|
||||
val approvalTier: String?,
|
||||
val approvalToolName: String?,
|
||||
val approvalPreview: String?,
|
||||
) : ServerMessage()
|
||||
|
||||
@Serializable
|
||||
data class StageStarted(val sessionId: SessionId, val stageId: StageId, val occurredAt: Long) : ServerMessage()
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
eventStore = module.eventStore,
|
||||
artifactStore = module.artifactStore,
|
||||
send = sendFrame,
|
||||
orchestrationRepository = module.orchestrationRepository,
|
||||
approvalRepository = module.approvalRepository,
|
||||
)
|
||||
sendInitialSnapshot(session)
|
||||
bridge.replaySnapshot()
|
||||
@@ -104,6 +106,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
|
||||
is ClientMessage.ApprovalResponse ->
|
||||
session.send(encodeError("ApprovalResponse must be sent to /sessions/{id}/stream"))
|
||||
|
||||
is ClientMessage.ChatInput -> Unit // handler deferred to subsequent task
|
||||
}
|
||||
}
|
||||
@@ -150,14 +153,19 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
)
|
||||
}
|
||||
}
|
||||
liveStreamJobs.put(sessionId, session.launch {
|
||||
val bridge = SessionEventBridge(
|
||||
eventStore = module.eventStore,
|
||||
artifactStore = module.artifactStore,
|
||||
send = sendFrame,
|
||||
)
|
||||
bridge.streamLive(sessionId)
|
||||
})?.cancel()
|
||||
liveStreamJobs.put(
|
||||
sessionId,
|
||||
session.launch {
|
||||
val bridge = SessionEventBridge(
|
||||
eventStore = module.eventStore,
|
||||
artifactStore = module.artifactStore,
|
||||
send = sendFrame,
|
||||
orchestrationRepository = module.orchestrationRepository,
|
||||
approvalRepository = module.approvalRepository,
|
||||
)
|
||||
bridge.streamLive(sessionId)
|
||||
},
|
||||
)?.cancel()
|
||||
val started = ServerMessage.SessionStarted(sessionId = sessionId, workflowId = msg.workflowId)
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(started)))
|
||||
}
|
||||
|
||||
@@ -79,11 +79,10 @@ class SessionStreamHandler(private val module: ServerModule) {
|
||||
val domainDecision = msg.toDomainDecision(msg.requestId.value)
|
||||
runCatching { module.orchestrator.submitApprovalDecision(msg.requestId, domainDecision) }
|
||||
.onFailure {
|
||||
log.error(
|
||||
log.warn(
|
||||
"submitApprovalDecision failed for request={}: {}",
|
||||
msg.requestId.value,
|
||||
it.message,
|
||||
it,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,6 @@ class DomainEventMapperTest {
|
||||
sessionId = sessionId,
|
||||
terminalStageId = stageId,
|
||||
totalStages = 1,
|
||||
workflowId = workflowId,
|
||||
),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore)
|
||||
|
||||
+50
-8
@@ -1,6 +1,9 @@
|
||||
package com.correx.apps.server.bridge
|
||||
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.core.approvals.ApprovalProjector
|
||||
import com.correx.core.approvals.DefaultApprovalReducer
|
||||
import com.correx.core.approvals.DefaultApprovalRepository
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.EventPayload
|
||||
@@ -14,6 +17,10 @@ import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
|
||||
import com.correx.core.kernel.orchestration.OrchestrationProjector
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
@@ -61,17 +68,37 @@ class SessionEventBridgeTest {
|
||||
override fun lastSequence(sessionId: SessionId): Long? = null
|
||||
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = liveFlow
|
||||
override fun allEvents(): Sequence<StoredEvent> = allEventsList.asSequence()
|
||||
override fun allSessionIds(): Set<SessionId> = allEventsList.map { it.metadata.sessionId }.toSet()
|
||||
}
|
||||
|
||||
private val orchestrationRepository = OrchestrationRepository(
|
||||
DefaultEventReplayer(
|
||||
fakeEventStore(),
|
||||
OrchestrationProjector(DefaultOrchestrationReducer()),
|
||||
),
|
||||
)
|
||||
|
||||
private val approvalRepository = DefaultApprovalRepository(
|
||||
DefaultEventReplayer(
|
||||
fakeEventStore(),
|
||||
ApprovalProjector(DefaultApprovalReducer()),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `replaySnapshot sends mapped messages for all events`() = runTest {
|
||||
val events = listOf(
|
||||
storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L),
|
||||
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1, workflowId), seq = 2L),
|
||||
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 2L),
|
||||
)
|
||||
val store = fakeEventStore(allEventsList = events)
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore) { sent.add(it) }
|
||||
val bridge = SessionEventBridge(
|
||||
store,
|
||||
noopArtifactStore,
|
||||
orchestrationRepository,
|
||||
approvalRepository,
|
||||
) { sent.add(it) }
|
||||
|
||||
bridge.replaySnapshot()
|
||||
|
||||
@@ -84,11 +111,16 @@ class SessionEventBridgeTest {
|
||||
fun `replaySnapshot skips unmapped events`() = runTest {
|
||||
val events = listOf(
|
||||
storedEvent(OrchestrationResumedEvent(sessionId, stageId), seq = 1L),
|
||||
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1, workflowId), seq = 2L),
|
||||
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 2L),
|
||||
)
|
||||
val store = fakeEventStore(allEventsList = events)
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore) { sent.add(it) }
|
||||
val bridge = SessionEventBridge(
|
||||
store,
|
||||
noopArtifactStore,
|
||||
orchestrationRepository,
|
||||
approvalRepository,
|
||||
) { sent.add(it) }
|
||||
|
||||
bridge.replaySnapshot()
|
||||
|
||||
@@ -100,12 +132,17 @@ class SessionEventBridgeTest {
|
||||
fun `streamLive sends mapped messages from live flow`() = runTest {
|
||||
val events = listOf(
|
||||
storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L),
|
||||
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1, workflowId), seq = 2L),
|
||||
storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 2L),
|
||||
)
|
||||
val liveFlow: Flow<StoredEvent> = flow { events.forEach { emit(it) } }
|
||||
val store = fakeEventStore(liveFlow = liveFlow)
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore) { sent.add(it) }
|
||||
val bridge = SessionEventBridge(
|
||||
store,
|
||||
noopArtifactStore,
|
||||
orchestrationRepository,
|
||||
approvalRepository,
|
||||
) { sent.add(it) }
|
||||
|
||||
bridge.streamLive(sessionId)
|
||||
|
||||
@@ -122,12 +159,17 @@ class SessionEventBridgeTest {
|
||||
}
|
||||
val store = fakeEventStore(liveFlow = liveFlow)
|
||||
val sent = mutableListOf<ServerMessage>()
|
||||
val bridge = SessionEventBridge(store, noopArtifactStore) { sent.add(it) }
|
||||
val bridge = SessionEventBridge(
|
||||
store,
|
||||
noopArtifactStore,
|
||||
orchestrationRepository,
|
||||
approvalRepository,
|
||||
) { sent.add(it) }
|
||||
|
||||
channel.send(storedEvent(WorkflowStartedEvent(sessionId, workflowId, stageId), seq = 1L))
|
||||
|
||||
val job = launch { bridge.streamLive(sessionId) }
|
||||
channel.send(storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1, workflowId), seq = 2L))
|
||||
channel.send(storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 2L))
|
||||
channel.close()
|
||||
job.join()
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.slf4j.LoggerFactory
|
||||
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
|
||||
|
||||
private const val DEFAULT_PORT = 8080
|
||||
@@ -32,6 +33,8 @@ private const val STATUS_HEIGHT = 1
|
||||
private const val TOP_ROW_HEIGHT = 9
|
||||
private const val INPUT_HEIGHT = 4
|
||||
|
||||
private val log = LoggerFactory.getLogger("com.correx.apps.tui.main")
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
val host = args.getOrElse(0) { "localhost" }
|
||||
val port = args.getOrElse(1) { DEFAULT_PORT.toString() }.toIntOrNull() ?: DEFAULT_PORT
|
||||
@@ -43,6 +46,13 @@ fun main(args: Array<String>) {
|
||||
TuiRunner.create().use { runner ->
|
||||
fun dispatch(action: Action) {
|
||||
val (next, effects) = RootReducer.reduce(state, action)
|
||||
log.debug("got connection state after reducers: {}", next.connection)
|
||||
log.debug("got approval state after reducers: {}", next.approval)
|
||||
log.debug("got input state after reducers: {}", next.input)
|
||||
log.debug(
|
||||
"got session state after reducers: {}",
|
||||
next.sessions.sessions.firstOrNull() { it.status != "COMPLETED" },
|
||||
)
|
||||
state = next
|
||||
effects.forEach { effect ->
|
||||
effectScope.launch {
|
||||
|
||||
@@ -27,10 +27,10 @@ fun routerPanelWidget(state: TuiState): Paragraph {
|
||||
state.approvalOverlayVisible && state.approval.active != null -> {
|
||||
val a = state.approval.active
|
||||
buildList {
|
||||
add(Line.from(Span.styled("╭─ approval [ctrl+h to hide] ─────────────╮", Style.create().yellow())))
|
||||
val tierLine = "│ ⚠ T${a.tier} │ ${a.toolName ?: ""} │ ${a.riskSummary.take(12)}... │"
|
||||
add(Line.from(Span.styled("╭─ approval [alt+h to hide] ─────────────╮", Style.create().yellow())))
|
||||
val tierLine = "│ ⚠ ${a.tier} │ ${a.toolName ?: "no tool name"} │ ${a.riskSummary.take(12)}... │"
|
||||
add(Line.from(Span.styled(tierLine, Style.create().yellow())))
|
||||
val previewLine = "│ ${(a.preview ?: "").take(40)}… │"
|
||||
val previewLine = "│ ${(a.preview ?: "no preview").take(40)}… │"
|
||||
add(Line.from(Span.styled(previewLine, dimStyle)))
|
||||
add(Line.from(Span.styled("╰──────────────────────────────────────────╯", Style.create().yellow())))
|
||||
}
|
||||
|
||||
@@ -24,4 +24,5 @@ sealed interface Action {
|
||||
data object ToggleEventOverlay : Action
|
||||
data object EnterSteer : Action
|
||||
data object CycleMode : Action
|
||||
data object NoOp : Action
|
||||
}
|
||||
|
||||
@@ -2,12 +2,24 @@ package com.correx.apps.tui.input
|
||||
|
||||
import com.correx.apps.tui.KeyEvent
|
||||
import dev.tamboui.tui.event.KeyCode
|
||||
import org.slf4j.LoggerFactory
|
||||
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
|
||||
|
||||
private val log = LoggerFactory.getLogger("com.correx.apps.tui.input")
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "ReturnCount")
|
||||
fun mapKey(event: TambouiKeyEvent): KeyEvent? {
|
||||
log.debug("got event: {}", event)
|
||||
if (event.isCtrlC) return KeyEvent.Quit
|
||||
if (event.hasAlt()) return null
|
||||
if (event.hasAlt()) {
|
||||
return when {
|
||||
event.isChar('h') -> KeyEvent.ToggleApprovalOverlay.also {
|
||||
log.debug("got event for toggle approval")
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
if (event.hasCtrl()) {
|
||||
return when {
|
||||
event.isChar('q') -> KeyEvent.Quit
|
||||
@@ -16,7 +28,8 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? {
|
||||
event.isChar('a') -> KeyEvent.Approve
|
||||
event.isChar('r') -> KeyEvent.Reject
|
||||
event.isChar('s') -> KeyEvent.Steer
|
||||
event.isChar('h') -> KeyEvent.ToggleApprovalOverlay
|
||||
|
||||
|
||||
event.isChar('e') -> KeyEvent.ToggleEventOverlay
|
||||
else -> null
|
||||
}
|
||||
@@ -32,6 +45,7 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? {
|
||||
val ch = event.character()
|
||||
if (ch.isISOControl()) null else KeyEvent.CharInput(ch)
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import com.correx.apps.tui.state.ApprovalInfo
|
||||
import com.correx.apps.tui.state.ApprovalState
|
||||
import com.correx.apps.tui.state.InputMode
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
private val log = LoggerFactory.getLogger(ApprovalReducer::class.simpleName)
|
||||
|
||||
object ApprovalReducer {
|
||||
fun reduce(
|
||||
@@ -31,6 +34,7 @@ object ApprovalReducer {
|
||||
approval to emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
is Action.RejectActive -> {
|
||||
val active = approval.active
|
||||
if (active != null) {
|
||||
@@ -47,13 +51,19 @@ object ApprovalReducer {
|
||||
approval to emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
is Action.SubmitInput -> if (inputMode == InputMode.STEER) {
|
||||
ApprovalState(active = null) to emptyList()
|
||||
} else {
|
||||
approval to emptyList()
|
||||
}
|
||||
|
||||
is Action.ServerEventReceived -> when (val msg = action.message) {
|
||||
is ServerMessage.ApprovalRequired -> {
|
||||
log.debug(
|
||||
"ApprovalRequired received requestId={} sessionId={} toolName={}",
|
||||
msg.requestId.value, msg.sessionId.value, msg.toolName,
|
||||
)
|
||||
val info = ApprovalInfo(
|
||||
requestId = msg.requestId.value,
|
||||
sessionId = msg.sessionId.value,
|
||||
@@ -64,8 +74,27 @@ object ApprovalReducer {
|
||||
)
|
||||
ApprovalState(active = info) to emptyList()
|
||||
}
|
||||
|
||||
is ServerMessage.SessionSnapshot -> {
|
||||
val requestId = msg.approvalRequestId
|
||||
if (msg.pendingApproval && requestId != null) {
|
||||
val info = ApprovalInfo(
|
||||
requestId = requestId,
|
||||
sessionId = msg.sessionId.value,
|
||||
tier = msg.approvalTier ?: "T2",
|
||||
riskSummary = "unknown",
|
||||
toolName = msg.approvalToolName,
|
||||
preview = msg.approvalPreview,
|
||||
)
|
||||
ApprovalState(active = info) to emptyList()
|
||||
} else {
|
||||
approval to emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
else -> approval to emptyList()
|
||||
}
|
||||
|
||||
else -> approval to emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,14 @@ object InputReducer {
|
||||
},
|
||||
inputBuffer = "",
|
||||
) to emptyList()
|
||||
|
||||
is Action.OpenNewSessionPrompt -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
|
||||
is Action.OpenSteeringPrompt -> if (state.approval.active != null) {
|
||||
state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList()
|
||||
} else {
|
||||
state to emptyList()
|
||||
}
|
||||
|
||||
is Action.EnterSteer -> state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList()
|
||||
is Action.OpenFilter -> state.copy(inputMode = InputMode.FILTER, inputBuffer = "") to emptyList()
|
||||
is Action.AppendChar -> state.copy(inputBuffer = state.inputBuffer + action.ch) to emptyList()
|
||||
@@ -44,6 +46,7 @@ object InputReducer {
|
||||
state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
InputMode.STEER -> {
|
||||
val text = state.inputBuffer.trim()
|
||||
val active = state.approval.active
|
||||
@@ -61,9 +64,11 @@ object InputReducer {
|
||||
state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
InputMode.FILTER -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
|
||||
InputMode.NAVIGATE -> state to emptyList()
|
||||
}
|
||||
|
||||
else -> state to emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.correx.apps.tui.reducer
|
||||
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.tui.input.Action
|
||||
import com.correx.apps.tui.state.TuiState
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
private val log = LoggerFactory.getLogger(RootReducer::class.java.name)
|
||||
|
||||
object RootReducer {
|
||||
fun reduce(
|
||||
@@ -9,13 +13,25 @@ object RootReducer {
|
||||
action: Action,
|
||||
clock: () -> Long = System::currentTimeMillis,
|
||||
): Pair<TuiState, List<Effect>> {
|
||||
log.debug("action={}", action::class.simpleName)
|
||||
val quitEffects: List<Effect> = if (action is Action.Quit) listOf(Effect.Quit) else emptyList()
|
||||
val prevInputMode = state.inputMode
|
||||
val prevInputBuffer = state.inputBuffer
|
||||
val approvalAction = if (
|
||||
action is Action.ServerEventReceived &&
|
||||
action.message is ServerMessage.SessionSnapshot &&
|
||||
action.message.sessionId.value != state.sessions.selectedId
|
||||
) Action.NoOp else action
|
||||
|
||||
log.debug("input state before reducers={}", state.input)
|
||||
val (afterInput, ie) = InputReducer.reduce(state, action)
|
||||
log.debug("sessions state before reducers={}", state.sessions)
|
||||
val (sessions, se) = SessionsReducer.reduce(afterInput.sessions, prevInputMode, prevInputBuffer, action, clock)
|
||||
val (approval, ae) = ApprovalReducer.reduce(afterInput.approval, prevInputMode, action)
|
||||
log.debug("approval state before reducers={}", state.approval)
|
||||
val (approval, ae) = ApprovalReducer.reduce(afterInput.approval, prevInputMode, approvalAction)
|
||||
log.debug("connection state before reducers={}", state.connection)
|
||||
val (connection, ce) = ConnectionReducer.reduce(afterInput.connection, action)
|
||||
log.debug("provider state before reducers={}", state.provider)
|
||||
val (provider, pe) = ProviderReducer.reduce(afterInput.provider, action)
|
||||
val approvalJustArrived = approval.active != null && afterInput.approval.active == null
|
||||
val withSubReducers = afterInput.copy(
|
||||
@@ -30,10 +46,12 @@ object RootReducer {
|
||||
approvalOverlayVisible = !withSubReducers.approvalOverlayVisible,
|
||||
eventOverlayVisible = false,
|
||||
)
|
||||
|
||||
is Action.ToggleEventOverlay -> withSubReducers.copy(
|
||||
eventOverlayVisible = !withSubReducers.eventOverlayVisible,
|
||||
approvalOverlayVisible = false,
|
||||
)
|
||||
|
||||
else -> withSubReducers
|
||||
}
|
||||
return final to (quitEffects + ie + se + ae + ce + pe)
|
||||
|
||||
@@ -11,10 +11,13 @@ import com.correx.apps.tui.state.ToolDisplayStatus
|
||||
import com.correx.apps.tui.state.TuiEventEntry
|
||||
import com.correx.apps.tui.state.TuiToolRecord
|
||||
import com.correx.core.events.types.SessionId
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
private val log = LoggerFactory.getLogger(SessionsReducer::class.simpleName)
|
||||
|
||||
object SessionsReducer {
|
||||
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneOffset.UTC)
|
||||
|
||||
@@ -103,7 +106,32 @@ object SessionsReducer {
|
||||
is ServerMessage.ToolCompleted -> processToolCompletedMessage(msg, sessions)
|
||||
is ServerMessage.ToolFailed -> processToolFailedMessage(msg, sessions)
|
||||
is ServerMessage.ToolRejected -> processToolRejectedMessage(clock, sessions, msg)
|
||||
is ServerMessage.SessionSnapshot -> processSessionSnapshotMessage(clock, sessions, msg)
|
||||
else -> sessions to emptyList()
|
||||
}.also { log.debug("processed server message: {}", msg) }
|
||||
|
||||
private fun processSessionSnapshotMessage(
|
||||
clock: () -> Long,
|
||||
sessionState: SessionsState,
|
||||
msg: ServerMessage.SessionSnapshot,
|
||||
): Pair<SessionsState, List<Effect>> {
|
||||
val summary = SessionSummary(
|
||||
id = msg.sessionId.value,
|
||||
status = when {
|
||||
msg.pendingApproval -> "PAUSED awaiting approval"
|
||||
else -> msg.status
|
||||
},
|
||||
workflowId = msg.workflowId,
|
||||
name = msg.workflowId,
|
||||
lastEventAt = clock(),
|
||||
currentStage = msg.currentStageId,
|
||||
currentStageId = msg.currentStageId,
|
||||
)
|
||||
val selected = sessionState.selectedId ?: msg.sessionId.value
|
||||
return sessionState.copy(
|
||||
sessions = sessionState.sessions + summary,
|
||||
selectedId = selected,
|
||||
) to listOf(Effect.ConnectSession(msg.sessionId))
|
||||
}
|
||||
|
||||
private fun processToolRejectedMessage(
|
||||
|
||||
Reference in New Issue
Block a user