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:
2026-05-24 18:50:00 +04:00
parent f827685ed0
commit fc7b879891
43 changed files with 525 additions and 211 deletions
+1
View File
@@ -69,3 +69,4 @@ bin/
/.infra/sonarqube/docker-compose.yml /.infra/sonarqube/docker-compose.yml
/logs /logs
log4j2.properties log4j2.properties
/docs/specs/
@@ -129,6 +129,8 @@ fun main() {
providerRegistry = infraRegistry.asServerRegistry(), providerRegistry = infraRegistry.asServerRegistry(),
defaultOrchestrationConfig = defaultOrchestrationConfig, defaultOrchestrationConfig = defaultOrchestrationConfig,
routerFacade = routerFacade, routerFacade = routerFacade,
orchestrationRepository = repositories.orchestrationRepository,
approvalRepository = repositories.approvalRepository,
) )
log.info("==============================") log.info("==============================")
@@ -190,6 +192,7 @@ private fun buildRepositories(
DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())), DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
), ),
artifactRepository = InfrastructureModule.createArtifactRepository(eventStore), artifactRepository = InfrastructureModule.createArtifactRepository(eventStore),
approvalRepository = InfrastructureModule.createApprovalRepository(eventStore),
) )
private fun buildToolConfig( 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.ProviderRegistry
import com.correx.apps.server.registry.WorkflowRegistry import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.artifactstore.ArtifactStore 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.kernel.orchestration.OrchestrationRepository
import com.correx.core.router.RouterFacade import com.correx.core.router.RouterFacade
import com.correx.core.sessions.DefaultSessionRepository import com.correx.core.sessions.DefaultSessionRepository
@@ -18,4 +20,6 @@ data class ServerModule(
val providerRegistry: ProviderRegistry, val providerRegistry: ProviderRegistry,
val defaultOrchestrationConfig: OrchestrationConfig, val defaultOrchestrationConfig: OrchestrationConfig,
val routerFacade: RouterFacade, 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.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.types.ArtifactId import com.correx.core.events.types.ArtifactId
class DomainEventMapper(private val artifactStore: ArtifactStore = NoopArtifactStore) { class DomainEventMapper(private val artifactStore: ArtifactStore = NoopArtifactStore) {
@@ -36,7 +35,6 @@ private object NoopArtifactStore : ArtifactStore {
@Suppress("CyclomaticComplexMethod") @Suppress("CyclomaticComplexMethod")
suspend fun domainEventToServerMessage(event: StoredEvent, artifactStore: ArtifactStore): ServerMessage? = suspend fun domainEventToServerMessage(event: StoredEvent, artifactStore: ArtifactStore): ServerMessage? =
when (val p = event.payload) { when (val p = event.payload) {
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( is TransitionExecutedEvent -> ServerMessage.StageStarted(
@@ -91,9 +89,6 @@ suspend fun domainEventToServerMessage(event: StoredEvent, artifactStore: Artifa
else -> null else -> null
} }
private fun mapWorkflowStarted(p: WorkflowStartedEvent): ServerMessage =
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)
@@ -103,15 +98,14 @@ private suspend fun mapInferenceCompleted(
event: StoredEvent, event: StoredEvent,
p: InferenceCompletedEvent, p: InferenceCompletedEvent,
artifactStore: ArtifactStore, artifactStore: ArtifactStore,
): ServerMessage { ): ServerMessage = runCatching {
val text = runCatching {
artifactStore.get(p.responseArtifactId)?.toString(Charsets.UTF_8) ?: "" artifactStore.get(p.responseArtifactId)?.toString(Charsets.UTF_8) ?: ""
}.getOrElse { "" } }.getOrElse { "" }.run {
return ServerMessage.InferenceCompleted( ServerMessage.InferenceCompleted(
sessionId = p.sessionId, sessionId = p.sessionId,
stageId = p.stageId, stageId = p.stageId,
outputSummary = text, outputSummary = this,
responseText = text, responseText = this,
occurredAt = event.metadata.timestamp.toEpochMilliseconds(), occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
) )
} }
@@ -122,6 +116,6 @@ private fun mapApprovalRequested(p: ApprovalRequestedEvent): ServerMessage =
requestId = p.requestId, requestId = p.requestId,
tier = p.tier, tier = p.tier,
riskSummary = RiskSummaryDto("unknown", emptyList(), ""), riskSummary = RiskSummaryDto("unknown", emptyList(), ""),
toolName = null, toolName = p.toolName,
preview = null, preview = p.preview,
) )
@@ -1,19 +1,47 @@
package com.correx.apps.server.bridge package com.correx.apps.server.bridge
import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.protocol.ServerMessage
import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.artifactstore.ArtifactStore 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.stores.EventStore
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.kernel.orchestration.OrchestrationRepository
class SessionEventBridge( class SessionEventBridge(
private val eventStore: EventStore, private val eventStore: EventStore,
private val artifactStore: ArtifactStore, private val artifactStore: ArtifactStore,
private val orchestrationRepository: OrchestrationRepository,
private val approvalRepository: DefaultApprovalRepository,
private val send: suspend (ServerMessage) -> Unit, private val send: suspend (ServerMessage) -> Unit,
) { ) {
suspend fun replaySnapshot() { suspend fun replaySnapshot() {
eventStore.allEvents().toList() eventStore.allSessionIds().forEach { sessionId ->
.mapNotNull { domainEventToServerMessage(it, artifactStore) } val orchState = orchestrationRepository.getState(sessionId)
.forEach { send(it) } 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) { 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 subscribe(sessionId: SessionId): Flow<StoredEvent> = delegate.subscribe(sessionId)
override fun allEvents(): Sequence<StoredEvent> = delegate.allEvents() 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.ApprovalRequestId
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 kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -20,6 +21,21 @@ sealed class ServerMessage {
@Serializable @Serializable
data class SessionFailed(val sessionId: SessionId, val reason: String) : ServerMessage() 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 @Serializable
data class StageStarted(val sessionId: SessionId, val stageId: StageId, val occurredAt: Long) : ServerMessage() 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, eventStore = module.eventStore,
artifactStore = module.artifactStore, artifactStore = module.artifactStore,
send = sendFrame, send = sendFrame,
orchestrationRepository = module.orchestrationRepository,
approvalRepository = module.approvalRepository,
) )
sendInitialSnapshot(session) sendInitialSnapshot(session)
bridge.replaySnapshot() bridge.replaySnapshot()
@@ -104,6 +106,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported")) is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
is ClientMessage.ApprovalResponse -> is ClientMessage.ApprovalResponse ->
session.send(encodeError("ApprovalResponse must be sent to /sessions/{id}/stream")) session.send(encodeError("ApprovalResponse must be sent to /sessions/{id}/stream"))
is ClientMessage.ChatInput -> Unit // handler deferred to subsequent task is ClientMessage.ChatInput -> Unit // handler deferred to subsequent task
} }
} }
@@ -150,14 +153,19 @@ class GlobalStreamHandler(private val module: ServerModule) {
) )
} }
} }
liveStreamJobs.put(sessionId, session.launch { liveStreamJobs.put(
sessionId,
session.launch {
val bridge = SessionEventBridge( val bridge = SessionEventBridge(
eventStore = module.eventStore, eventStore = module.eventStore,
artifactStore = module.artifactStore, artifactStore = module.artifactStore,
send = sendFrame, send = sendFrame,
orchestrationRepository = module.orchestrationRepository,
approvalRepository = module.approvalRepository,
) )
bridge.streamLive(sessionId) bridge.streamLive(sessionId)
})?.cancel() },
)?.cancel()
val started = ServerMessage.SessionStarted(sessionId = sessionId, workflowId = msg.workflowId) val started = ServerMessage.SessionStarted(sessionId = sessionId, workflowId = msg.workflowId)
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(started))) session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(started)))
} }
@@ -79,11 +79,10 @@ class SessionStreamHandler(private val module: ServerModule) {
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 { .onFailure {
log.error( log.warn(
"submitApprovalDecision failed for request={}: {}", "submitApprovalDecision failed for request={}: {}",
msg.requestId.value, msg.requestId.value,
it.message, it.message,
it,
) )
} }
} }
@@ -83,7 +83,6 @@ class DomainEventMapperTest {
sessionId = sessionId, sessionId = sessionId,
terminalStageId = stageId, terminalStageId = stageId,
totalStages = 1, totalStages = 1,
workflowId = workflowId,
), ),
) )
val result = domainEventToServerMessage(event, noopStore) val result = domainEventToServerMessage(event, noopStore)
@@ -1,6 +1,9 @@
package com.correx.apps.server.bridge package com.correx.apps.server.bridge
import com.correx.apps.server.protocol.ServerMessage 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.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
@@ -14,6 +17,10 @@ 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.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.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.channels.Channel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
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 lastSequence(sessionId: SessionId): Long? = null
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = liveFlow override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = liveFlow
override fun allEvents(): Sequence<StoredEvent> = allEventsList.asSequence() 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 @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, workflowId, stageId), seq = 1L), 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 store = fakeEventStore(allEventsList = events)
val sent = mutableListOf<ServerMessage>() val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore) { sent.add(it) } val bridge = SessionEventBridge(
store,
noopArtifactStore,
orchestrationRepository,
approvalRepository,
) { sent.add(it) }
bridge.replaySnapshot() bridge.replaySnapshot()
@@ -84,11 +111,16 @@ 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, workflowId), seq = 2L), storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 2L),
) )
val store = fakeEventStore(allEventsList = events) val store = fakeEventStore(allEventsList = events)
val sent = mutableListOf<ServerMessage>() val sent = mutableListOf<ServerMessage>()
val bridge = SessionEventBridge(store, noopArtifactStore) { sent.add(it) } val bridge = SessionEventBridge(
store,
noopArtifactStore,
orchestrationRepository,
approvalRepository,
) { sent.add(it) }
bridge.replaySnapshot() bridge.replaySnapshot()
@@ -100,12 +132,17 @@ class SessionEventBridgeTest {
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, workflowId, stageId), seq = 1L), 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 liveFlow: Flow<StoredEvent> = flow { events.forEach { emit(it) } }
val store = fakeEventStore(liveFlow = liveFlow) val store = fakeEventStore(liveFlow = liveFlow)
val sent = mutableListOf<ServerMessage>() 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) bridge.streamLive(sessionId)
@@ -122,12 +159,17 @@ class SessionEventBridgeTest {
} }
val store = fakeEventStore(liveFlow = liveFlow) val store = fakeEventStore(liveFlow = liveFlow)
val sent = mutableListOf<ServerMessage>() 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)) 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, workflowId), seq = 2L)) channel.send(storedEvent(WorkflowCompletedEvent(sessionId, stageId, 1), seq = 2L))
channel.close() channel.close()
job.join() job.join()
@@ -25,6 +25,7 @@ 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 org.slf4j.LoggerFactory
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
private const val DEFAULT_PORT = 8080 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 TOP_ROW_HEIGHT = 9
private const val INPUT_HEIGHT = 4 private const val INPUT_HEIGHT = 4
private val log = LoggerFactory.getLogger("com.correx.apps.tui.main")
fun main(args: Array<String>) { fun main(args: Array<String>) {
val host = args.getOrElse(0) { "localhost" } val host = args.getOrElse(0) { "localhost" }
val port = args.getOrElse(1) { DEFAULT_PORT.toString() }.toIntOrNull() ?: DEFAULT_PORT val port = args.getOrElse(1) { DEFAULT_PORT.toString() }.toIntOrNull() ?: DEFAULT_PORT
@@ -43,6 +46,13 @@ fun main(args: Array<String>) {
TuiRunner.create().use { runner -> TuiRunner.create().use { runner ->
fun dispatch(action: Action) { fun dispatch(action: Action) {
val (next, effects) = RootReducer.reduce(state, 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 state = next
effects.forEach { effect -> effects.forEach { effect ->
effectScope.launch { effectScope.launch {
@@ -27,10 +27,10 @@ fun routerPanelWidget(state: TuiState): Paragraph {
state.approvalOverlayVisible && state.approval.active != null -> { state.approvalOverlayVisible && state.approval.active != null -> {
val a = state.approval.active val a = state.approval.active
buildList { buildList {
add(Line.from(Span.styled("╭─ approval [ctrl+h to hide] ─────────────╮", Style.create().yellow()))) add(Line.from(Span.styled("╭─ approval [alt+h to hide] ─────────────╮", Style.create().yellow())))
val tierLine = "│ ⚠ T${a.tier}${a.toolName ?: ""}${a.riskSummary.take(12)}... │" val tierLine = "│ ⚠ ${a.tier}${a.toolName ?: "no tool name"}${a.riskSummary.take(12)}... │"
add(Line.from(Span.styled(tierLine, Style.create().yellow()))) 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(previewLine, dimStyle)))
add(Line.from(Span.styled("╰──────────────────────────────────────────╯", Style.create().yellow()))) add(Line.from(Span.styled("╰──────────────────────────────────────────╯", Style.create().yellow())))
} }
@@ -24,4 +24,5 @@ sealed interface Action {
data object ToggleEventOverlay : Action data object ToggleEventOverlay : Action
data object EnterSteer : Action data object EnterSteer : Action
data object CycleMode : 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 com.correx.apps.tui.KeyEvent
import dev.tamboui.tui.event.KeyCode import dev.tamboui.tui.event.KeyCode
import org.slf4j.LoggerFactory
import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent import dev.tamboui.tui.event.KeyEvent as TambouiKeyEvent
private val log = LoggerFactory.getLogger("com.correx.apps.tui.input")
@Suppress("CyclomaticComplexMethod", "ReturnCount") @Suppress("CyclomaticComplexMethod", "ReturnCount")
fun mapKey(event: TambouiKeyEvent): KeyEvent? { fun mapKey(event: TambouiKeyEvent): KeyEvent? {
log.debug("got event: {}", event)
if (event.isCtrlC) return KeyEvent.Quit 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()) { if (event.hasCtrl()) {
return when { return when {
event.isChar('q') -> KeyEvent.Quit event.isChar('q') -> KeyEvent.Quit
@@ -16,7 +28,8 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? {
event.isChar('a') -> KeyEvent.Approve event.isChar('a') -> KeyEvent.Approve
event.isChar('r') -> KeyEvent.Reject event.isChar('r') -> KeyEvent.Reject
event.isChar('s') -> KeyEvent.Steer event.isChar('s') -> KeyEvent.Steer
event.isChar('h') -> KeyEvent.ToggleApprovalOverlay
event.isChar('e') -> KeyEvent.ToggleEventOverlay event.isChar('e') -> KeyEvent.ToggleEventOverlay
else -> null else -> null
} }
@@ -32,6 +45,7 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? {
val ch = event.character() val ch = event.character()
if (ch.isISOControl()) null else KeyEvent.CharInput(ch) if (ch.isISOControl()) null else KeyEvent.CharInput(ch)
} }
else -> null 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.ApprovalState
import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.InputMode
import com.correx.core.events.types.ApprovalRequestId import com.correx.core.events.types.ApprovalRequestId
import org.slf4j.LoggerFactory
private val log = LoggerFactory.getLogger(ApprovalReducer::class.simpleName)
object ApprovalReducer { object ApprovalReducer {
fun reduce( fun reduce(
@@ -31,6 +34,7 @@ object ApprovalReducer {
approval to emptyList() approval to emptyList()
} }
} }
is Action.RejectActive -> { is Action.RejectActive -> {
val active = approval.active val active = approval.active
if (active != null) { if (active != null) {
@@ -47,13 +51,19 @@ object ApprovalReducer {
approval to emptyList() approval to emptyList()
} }
} }
is Action.SubmitInput -> if (inputMode == InputMode.STEER) { is Action.SubmitInput -> if (inputMode == InputMode.STEER) {
ApprovalState(active = null) to emptyList() ApprovalState(active = null) to emptyList()
} else { } else {
approval to emptyList() approval to emptyList()
} }
is Action.ServerEventReceived -> when (val msg = action.message) { is Action.ServerEventReceived -> when (val msg = action.message) {
is ServerMessage.ApprovalRequired -> { is ServerMessage.ApprovalRequired -> {
log.debug(
"ApprovalRequired received requestId={} sessionId={} toolName={}",
msg.requestId.value, msg.sessionId.value, msg.toolName,
)
val info = ApprovalInfo( val info = ApprovalInfo(
requestId = msg.requestId.value, requestId = msg.requestId.value,
sessionId = msg.sessionId.value, sessionId = msg.sessionId.value,
@@ -64,8 +74,27 @@ object ApprovalReducer {
) )
ApprovalState(active = info) to emptyList() 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()
} }
else -> approval to emptyList() else -> approval to emptyList()
} }
} }
@@ -22,12 +22,14 @@ object InputReducer {
}, },
inputBuffer = "", inputBuffer = "",
) to emptyList() ) to emptyList()
is Action.OpenNewSessionPrompt -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList() is Action.OpenNewSessionPrompt -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
is Action.OpenSteeringPrompt -> if (state.approval.active != null) { is Action.OpenSteeringPrompt -> if (state.approval.active != null) {
state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList() state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList()
} else { } else {
state to emptyList() state to emptyList()
} }
is Action.EnterSteer -> state.copy(inputMode = InputMode.STEER, inputBuffer = "") 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.OpenFilter -> state.copy(inputMode = InputMode.FILTER, inputBuffer = "") to emptyList()
is Action.AppendChar -> state.copy(inputBuffer = state.inputBuffer + action.ch) 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() state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
} }
} }
InputMode.STEER -> { InputMode.STEER -> {
val text = state.inputBuffer.trim() val text = state.inputBuffer.trim()
val active = state.approval.active val active = state.approval.active
@@ -61,9 +64,11 @@ object InputReducer {
state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList() state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
} }
} }
InputMode.FILTER -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList() InputMode.FILTER -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
InputMode.NAVIGATE -> state to emptyList() InputMode.NAVIGATE -> state to emptyList()
} }
else -> state to emptyList() else -> state to emptyList()
} }
} }
@@ -1,7 +1,11 @@
package com.correx.apps.tui.reducer 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.input.Action
import com.correx.apps.tui.state.TuiState import com.correx.apps.tui.state.TuiState
import org.slf4j.LoggerFactory
private val log = LoggerFactory.getLogger(RootReducer::class.java.name)
object RootReducer { object RootReducer {
fun reduce( fun reduce(
@@ -9,13 +13,25 @@ object RootReducer {
action: Action, action: Action,
clock: () -> Long = System::currentTimeMillis, clock: () -> Long = System::currentTimeMillis,
): Pair<TuiState, List<Effect>> { ): 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 quitEffects: List<Effect> = if (action is Action.Quit) listOf(Effect.Quit) else emptyList()
val prevInputMode = state.inputMode val prevInputMode = state.inputMode
val prevInputBuffer = state.inputBuffer 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) 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 (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) 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 (provider, pe) = ProviderReducer.reduce(afterInput.provider, action)
val approvalJustArrived = approval.active != null && afterInput.approval.active == null val approvalJustArrived = approval.active != null && afterInput.approval.active == null
val withSubReducers = afterInput.copy( val withSubReducers = afterInput.copy(
@@ -30,10 +46,12 @@ object RootReducer {
approvalOverlayVisible = !withSubReducers.approvalOverlayVisible, approvalOverlayVisible = !withSubReducers.approvalOverlayVisible,
eventOverlayVisible = false, eventOverlayVisible = false,
) )
is Action.ToggleEventOverlay -> withSubReducers.copy( is Action.ToggleEventOverlay -> withSubReducers.copy(
eventOverlayVisible = !withSubReducers.eventOverlayVisible, eventOverlayVisible = !withSubReducers.eventOverlayVisible,
approvalOverlayVisible = false, approvalOverlayVisible = false,
) )
else -> withSubReducers else -> withSubReducers
} }
return final to (quitEffects + ie + se + ae + ce + pe) 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.TuiEventEntry
import com.correx.apps.tui.state.TuiToolRecord import com.correx.apps.tui.state.TuiToolRecord
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import org.slf4j.LoggerFactory
import java.time.Instant import java.time.Instant
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
private val log = LoggerFactory.getLogger(SessionsReducer::class.simpleName)
object SessionsReducer { object SessionsReducer {
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneOffset.UTC) 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.ToolCompleted -> processToolCompletedMessage(msg, sessions)
is ServerMessage.ToolFailed -> processToolFailedMessage(msg, sessions) is ServerMessage.ToolFailed -> processToolFailedMessage(msg, sessions)
is ServerMessage.ToolRejected -> processToolRejectedMessage(clock, sessions, msg) is ServerMessage.ToolRejected -> processToolRejectedMessage(clock, sessions, msg)
is ServerMessage.SessionSnapshot -> processSessionSnapshotMessage(clock, sessions, msg)
else -> sessions to emptyList() 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( private fun processToolRejectedMessage(
@@ -26,7 +26,9 @@ class DefaultApprovalReducer : ApprovalReducer {
timestamp = event.metadata.timestamp, timestamp = event.metadata.timestamp,
causationId = event.metadata.causationId, causationId = event.metadata.causationId,
correlationId = event.metadata.correlationId, correlationId = event.metadata.correlationId,
userSteering = payload.userSteering userSteering = payload.userSteering,
toolName = payload.toolName,
preview = payload.preview,
) )
state.copy(requests = state.requests + (request.id to request)) state.copy(requests = state.requests + (request.id to request))
} }
@@ -39,11 +41,11 @@ class DefaultApprovalReducer : ApprovalReducer {
identity = ApprovalScopeIdentity( identity = ApprovalScopeIdentity(
sessionId = event.metadata.sessionId, sessionId = event.metadata.sessionId,
stageId = null, stageId = null,
projectId = null projectId = null,
), ),
mode = ApprovalMode.PROMPT, mode = ApprovalMode.PROMPT,
causationId = event.metadata.causationId, causationId = event.metadata.causationId,
correlationId = event.metadata.correlationId correlationId = event.metadata.correlationId,
) )
val decision = ApprovalDecision( val decision = ApprovalDecision(
id = decisionId, id = decisionId,
@@ -54,7 +56,7 @@ class DefaultApprovalReducer : ApprovalReducer {
contextSnapshot = contextSnapshot, contextSnapshot = contextSnapshot,
resolutionTimestamp = payload.resolutionTimestamp, resolutionTimestamp = payload.resolutionTimestamp,
reason = payload.reason, reason = payload.reason,
userSteering = payload.userSteering userSteering = payload.userSteering,
) )
state.copy(decisions = state.decisions + (decisionId to decision)) state.copy(decisions = state.decisions + (decisionId to decision))
} }
@@ -66,7 +68,7 @@ class DefaultApprovalReducer : ApprovalReducer {
permittedTiers = payload.permittedTiers, permittedTiers = payload.permittedTiers,
reason = payload.reason, reason = payload.reason,
timestamp = event.metadata.timestamp, timestamp = event.metadata.timestamp,
expiresAt = payload.expiresAt expiresAt = payload.expiresAt,
) )
state.copy(grants = state.grants + (grant.id to grant)) state.copy(grants = state.grants + (grant.id to grant))
} }
@@ -19,5 +19,7 @@ data class DomainApprovalRequest(
val timestamp: Instant, val timestamp: Instant,
val causationId: CausationId? = null, val causationId: CausationId? = null,
val correlationId: CorrelationId? = null, val correlationId: CorrelationId? = null,
val userSteering: UserSteering? = null val userSteering: UserSteering? = null,
val toolName: String? = null,
val preview: String? = null,
) )
@@ -25,7 +25,9 @@ data class ApprovalRequestedEvent(
val sessionId: SessionId, val sessionId: SessionId,
val stageId: StageId?, val stageId: StageId?,
val projectId: ProjectId?, val projectId: ProjectId?,
val userSteering: UserSteering? = null val userSteering: UserSteering? = null,
val toolName: String? = null,
val preview: String? = null,
) : EventPayload ) : EventPayload
@Serializable @Serializable
@@ -37,7 +39,7 @@ data class ApprovalDecisionResolvedEvent(
val tier: Tier, val tier: Tier,
val resolutionTimestamp: Instant, val resolutionTimestamp: Instant,
val reason: String?, val reason: String?,
val userSteering: UserSteering? = null val userSteering: UserSteering? = null,
) : EventPayload ) : EventPayload
@Serializable @Serializable
@@ -49,10 +51,10 @@ data class ApprovalGrantCreatedEvent(
val expiresAt: Instant?, val expiresAt: Instant?,
val sessionId: SessionId, val sessionId: SessionId,
val stageId: StageId?, val stageId: StageId?,
val projectId: ProjectId? val projectId: ProjectId?,
) : EventPayload ) : EventPayload
@Serializable @Serializable
data class ApprovalGrantExpiredEvent( data class ApprovalGrantExpiredEvent(
val grantId: GrantId val grantId: GrantId,
) : EventPayload ) : EventPayload
@@ -18,7 +18,6 @@ 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
@@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class OrchestrationState( data class OrchestrationState(
val workflowId: String = "",
val currentStageId: StageId? = null, val currentStageId: StageId? = null,
val status: OrchestrationStatus = OrchestrationStatus.IDLE, val status: OrchestrationStatus = OrchestrationStatus.IDLE,
val retryCount: Int = 0, val retryCount: Int = 0,
@@ -47,4 +47,10 @@ 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>
/**
* Return all known session ids in the store.
* Implementations should derive this from the session index, not by scanning allEvents().
*/
fun allSessionIds(): Set<SessionId>
} }
@@ -16,16 +16,22 @@ class DefaultOrchestrationReducer : OrchestrationReducer {
event: StoredEvent, event: StoredEvent,
): OrchestrationState = when (val p = event.payload) { ): OrchestrationState = when (val p = event.payload) {
is WorkflowStartedEvent -> state.copy( is WorkflowStartedEvent -> state.copy(
workflowId = p.workflowId,
status = OrchestrationStatus.RUNNING, status = OrchestrationStatus.RUNNING,
currentStageId = p.startStageId, currentStageId = p.startStageId,
retryPolicy = p.retryPolicy, retryPolicy = p.retryPolicy,
) )
is WorkflowCompletedEvent -> state.copy( is WorkflowCompletedEvent -> state.copy(
status = OrchestrationStatus.COMPLETED, status = OrchestrationStatus.COMPLETED,
currentStageId = p.terminalStageId, currentStageId = p.terminalStageId,
) )
is WorkflowFailedEvent -> state.copy(status = OrchestrationStatus.FAILED, failureReason = p.reason) is WorkflowFailedEvent -> state.copy(
status = OrchestrationStatus.FAILED,
failureReason = p.reason,
)
is OrchestrationPausedEvent -> state.copy( is OrchestrationPausedEvent -> state.copy(
status = OrchestrationStatus.PAUSED, status = OrchestrationStatus.PAUSED,
pendingApproval = true, pendingApproval = true,
@@ -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, enriched.graph.id) completeWorkflow(enriched.sessionId, enriched.currentStageId, enriched.stageCount)
} 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, ctx.graph.id), completeWorkflow(ctx.sessionId, nextStageId, ctx.stageCount),
) )
} }
@@ -1,5 +1,6 @@
package com.correx.core.kernel.orchestration package com.correx.core.kernel.orchestration
import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.artifacts.repository.ArtifactRepository import com.correx.core.artifacts.repository.ArtifactRepository
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.inference.InferenceRepository import com.correx.core.inference.InferenceRepository
@@ -11,4 +12,5 @@ data class OrchestratorRepositories(
val orchestrationRepository: OrchestrationRepository, val orchestrationRepository: OrchestrationRepository,
val sessionRepository: DefaultSessionRepository, val sessionRepository: DefaultSessionRepository,
val artifactRepository: ArtifactRepository, val artifactRepository: ArtifactRepository,
val approvalRepository: DefaultApprovalRepository,
) )
@@ -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, ctx.graph.id) return completeWorkflow(ctx.sessionId, nextStageId, ctx.stageCount)
} }
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, ctx.graph.id) completeWorkflow(ctx.sessionId, ctx.currentStageId, ctx.stageCount)
} else { } else {
step(ctx) step(ctx)
} }
@@ -112,7 +112,6 @@ abstract class SessionOrchestrator(
private val toolExecutor: ToolExecutor? = engines.toolExecutor private val toolExecutor: ToolExecutor? = engines.toolExecutor
private val toolRegistry: ToolRegistry? = engines.toolRegistry private val toolRegistry: ToolRegistry? = engines.toolRegistry
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
private val artifactRepository = repositories.artifactRepository
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
internal abstract val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> internal abstract val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean>
internal val pendingApprovals: ConcurrentHashMap<ApprovalRequestId, CompletableDeferred<ApprovalDecision>> = internal val pendingApprovals: ConcurrentHashMap<ApprovalRequestId, CompletableDeferred<ApprovalDecision>> =
@@ -269,6 +268,7 @@ abstract class SessionOrchestrator(
emitToolArtifacts(sessionId, stageId, stageConfig) emitToolArtifacts(sessionId, stageId, stageConfig)
verifyProduces(sessionId, stageId, stageConfig) verifyProduces(sessionId, stageId, stageConfig)
} }
is StageExecutionResult.Failure -> outcome is StageExecutionResult.Failure -> outcome
} }
} }
@@ -321,6 +321,8 @@ abstract class SessionOrchestrator(
validationReportId = ValidationReportId(UUID.randomUUID().toString()), validationReportId = ValidationReportId(UUID.randomUUID().toString()),
riskSummaryId = null, riskSummaryId = null,
timestamp = Clock.System.now(), timestamp = Clock.System.now(),
toolName = toolCall.function.name,
preview = toolCall.function.arguments.take(200),
) )
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING")) emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
emit( emit(
@@ -333,6 +335,8 @@ abstract class SessionOrchestrator(
sessionId = sessionId, sessionId = sessionId,
stageId = stageId, stageId = stageId,
projectId = null, projectId = null,
toolName = toolCall.function.name,
preview = toolCall.function.arguments.take(200),
), ),
) )
val deferred = CompletableDeferred<ApprovalDecision>() val deferred = CompletableDeferred<ApprovalDecision>()
@@ -598,13 +602,12 @@ 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, workflowId)) emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount))
cancellations.remove(sessionId) cancellations.remove(sessionId)
return WorkflowResult.Completed(sessionId, terminalStageId) return WorkflowResult.Completed(sessionId, terminalStageId)
} }
@@ -642,6 +645,7 @@ abstract class SessionOrchestrator(
// --- event emission --- // --- event emission ---
internal suspend fun emit(sessionId: SessionId, payload: EventPayload) { internal suspend fun emit(sessionId: SessionId, payload: EventPayload) {
log.debug("[session {}] emitting event, payload: {}", sessionId.value.substring(0, 7), payload)
eventStore.append( eventStore.append(
NewEvent( NewEvent(
metadata = EventMetadata( metadata = EventMetadata(
@@ -63,6 +63,8 @@ class InMemoryEventStore : EventStore {
.flatMap { stream -> synchronized(stream) { stream.toList() } } .flatMap { stream -> synchronized(stream) { stream.toList() } }
.asSequence() .asSequence()
override fun allSessionIds(): Set<SessionId> = sequences.keys
private fun doAppend(event: NewEvent, stream: MutableList<StoredEvent>): StoredEvent { private fun doAppend(event: NewEvent, stream: MutableList<StoredEvent>): StoredEvent {
val seq = sequences.computeIfAbsent(event.metadata.sessionId) { AtomicLong(0) } val seq = sequences.computeIfAbsent(event.metadata.sessionId) { AtomicLong(0) }
.incrementAndGet() .incrementAndGet()
@@ -42,7 +42,7 @@ class SqliteEventStore(
correlation_id TEXT, correlation_id TEXT,
payload TEXT NOT NULL payload TEXT NOT NULL
); );
""".trimIndent() """.trimIndent(),
) )
} }
} }
@@ -60,7 +60,7 @@ class SqliteEventStore(
val s = StoredEvent( val s = StoredEvent(
metadata = event.metadata, metadata = event.metadata,
sequence = seq, sequence = seq,
payload = event.payload payload = event.payload,
) )
insert(s) insert(s)
@@ -91,7 +91,7 @@ class SqliteEventStore(
val s = StoredEvent( val s = StoredEvent(
metadata = event.metadata, metadata = event.metadata,
sequence = seq, sequence = seq,
payload = event.payload payload = event.payload,
) )
insert(s) insert(s)
@@ -111,7 +111,7 @@ class SqliteEventStore(
SELECT * FROM events SELECT * FROM events
WHERE session_id = ? WHERE session_id = ?
ORDER BY sequence ASC ORDER BY sequence ASC
""".trimIndent() """.trimIndent(),
).use { ps -> ).use { ps ->
ps.setString(1, sessionId.value) ps.setString(1, sessionId.value)
@@ -131,7 +131,7 @@ class SqliteEventStore(
WHERE session_id = ? WHERE session_id = ?
AND sequence > ? AND sequence > ?
ORDER BY sequence ASC ORDER BY sequence ASC
""".trimIndent() """.trimIndent(),
).use { ps -> ).use { ps ->
ps.setString(1, sessionId.value) ps.setString(1, sessionId.value)
ps.setLong(2, fromSequence) ps.setLong(2, fromSequence)
@@ -151,7 +151,7 @@ class SqliteEventStore(
SELECT MAX(sequence) SELECT MAX(sequence)
FROM events FROM events
WHERE session_id = ? WHERE session_id = ?
""".trimIndent() """.trimIndent(),
).use { ps -> ).use { ps ->
ps.setString(1, sessionId.value) ps.setString(1, sessionId.value)
@@ -169,7 +169,7 @@ class SqliteEventStore(
""" """
SELECT * FROM events SELECT * FROM events
ORDER BY session_id ASC, sequence ASC ORDER BY session_id ASC, sequence ASC
""".trimIndent() """.trimIndent(),
).use { ps -> ).use { ps ->
ps.executeQuery().use { rs -> ps.executeQuery().use { rs ->
buildList { buildList {
@@ -180,6 +180,15 @@ class SqliteEventStore(
} }
}.asSequence() }.asSequence()
override fun allSessionIds(): Set<SessionId> =
mutableSetOf<SessionId>().apply {
connection.prepareStatement("SELECT DISTINCT session_id FROM events").use { ps ->
ps.executeQuery().use { rs ->
while (rs.next()) add(SessionId(rs.getString("session_id")))
}
}
}
// ---------- helpers ---------- // ---------- helpers ----------
private fun nextSequence(sessionId: SessionId): Long = private fun nextSequence(sessionId: SessionId): Long =
@@ -188,7 +197,7 @@ class SqliteEventStore(
SELECT COALESCE(MAX(sequence), 0) SELECT COALESCE(MAX(sequence), 0)
FROM events FROM events
WHERE session_id = ? WHERE session_id = ?
""".trimIndent() """.trimIndent(),
).use { ps -> ).use { ps ->
ps.setString(1, sessionId.value) ps.setString(1, sessionId.value)
ps.executeQuery().use { rs -> ps.executeQuery().use { rs ->
@@ -211,7 +220,7 @@ class SqliteEventStore(
correlation_id, correlation_id,
payload payload
) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent() """.trimIndent(),
).use { ps -> ).use { ps ->
ps.setString(1, event.metadata.eventId.value) ps.setString(1, event.metadata.eventId.value)
ps.setString(2, event.metadata.sessionId.value) ps.setString(2, event.metadata.sessionId.value)
@@ -231,7 +240,7 @@ class SqliteEventStore(
""" """
SELECT * FROM events SELECT * FROM events
WHERE event_id = ? WHERE event_id = ?
""".trimIndent() """.trimIndent(),
).use { ps -> ).use { ps ->
ps.setString(1, eventId.value) ps.setString(1, eventId.value)
@@ -250,8 +259,8 @@ private fun ResultSet.toStoredEvent(jsonSerializer: JsonEventSerializer): Stored
timestamp = Instant.parse(getString("timestamp")), timestamp = Instant.parse(getString("timestamp")),
schemaVersion = getInt("schema_version"), schemaVersion = getInt("schema_version"),
causationId = getString("causation_id")?.let { CausationId(it) }, causationId = getString("causation_id")?.let { CausationId(it) },
correlationId = getString("correlation_id")?.let { CorrelationId(it) } correlationId = getString("correlation_id")?.let { CorrelationId(it) },
), ),
sequence = getLong("sequence"), sequence = getLong("sequence"),
payload = jsonSerializer.deserialize(getString("payload")) payload = jsonSerializer.deserialize(getString("payload")),
) )
@@ -1,15 +1,15 @@
package com.correx.infrastructure package com.correx.infrastructure
import com.correx.core.approvals.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.artifacts.DefaultArtifactReducer import com.correx.core.artifacts.DefaultArtifactReducer
import com.correx.core.artifacts.repository.ArtifactRepository import com.correx.core.artifacts.repository.ArtifactRepository
import com.correx.core.artifactstore.ArtifactStore import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.EventDispatcher import com.correx.core.events.EventDispatcher
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.inference.DefaultInferenceRouter
import com.correx.core.inference.InferenceProvider import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRouter import com.correx.core.inference.InferenceRouter
import com.correx.core.inference.ProviderRegistry
import com.correx.core.inference.RoutingStrategy
import com.correx.core.router.DefaultRouterContextBuilder import com.correx.core.router.DefaultRouterContextBuilder
import com.correx.core.router.DefaultRouterFacade import com.correx.core.router.DefaultRouterFacade
import com.correx.core.router.DefaultRouterReducer import com.correx.core.router.DefaultRouterReducer
@@ -24,11 +24,8 @@ import com.correx.infrastructure.artifactscas.CasArtifactStore
import com.correx.infrastructure.artifactscas.config.CasConfig import com.correx.infrastructure.artifactscas.config.CasConfig
import com.correx.infrastructure.artifactscas.index.SqliteArtifactIndex import com.correx.infrastructure.artifactscas.index.SqliteArtifactIndex
import com.correx.infrastructure.inference.DefaultProviderRegistry import com.correx.infrastructure.inference.DefaultProviderRegistry
import com.correx.infrastructure.inference.FirstAvailableRoutingStrategy
import com.correx.infrastructure.inference.commons.ModelDescriptor import com.correx.infrastructure.inference.commons.ModelDescriptor
import com.correx.infrastructure.inference.commons.ModelManager
import com.correx.infrastructure.inference.commons.ResidencyMode import com.correx.infrastructure.inference.commons.ResidencyMode
import com.correx.infrastructure.inference.llama.cpp.DefaultModelManager
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
import com.correx.infrastructure.persistence.SqliteEventStore import com.correx.infrastructure.persistence.SqliteEventStore
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
@@ -41,7 +38,6 @@ import com.correx.infrastructure.workflow.FileSystemPromptLoader
import com.correx.infrastructure.workflow.PromptLoader import com.correx.infrastructure.workflow.PromptLoader
import com.correx.infrastructure.workflow.TomlWorkflowLoader import com.correx.infrastructure.workflow.TomlWorkflowLoader
import com.correx.infrastructure.workflow.WorkflowLoader import com.correx.infrastructure.workflow.WorkflowLoader
import io.ktor.client.HttpClient
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
@@ -71,25 +67,9 @@ object InfrastructureModule {
return runBlocking { CasArtifactStore.open(CasConfig(rootDir), index) } return runBlocking { CasArtifactStore.open(CasConfig(rootDir), index) }
} }
fun createModelManager(
eventStore: EventStore,
httpClient: HttpClient,
eventDispatcher: EventDispatcher,
): ModelManager = DefaultModelManager(
eventStore = eventStore,
httpClient = httpClient,
eventDispatcher = eventDispatcher,
)
fun createToolRegistry(config: ToolConfig): ToolRegistry = fun createToolRegistry(config: ToolConfig): ToolRegistry =
DefaultToolRegistry.build(config.buildTools()) DefaultToolRegistry.build(config.buildTools())
fun createInferenceRouter(
providers: List<InferenceProvider> = emptyList(),
registry: ProviderRegistry = DefaultProviderRegistry(providers),
strategy: RoutingStrategy = FirstAvailableRoutingStrategy(),
): InferenceRouter = DefaultInferenceRouter(registry, strategy)
fun createLlamaCppProvider( fun createLlamaCppProvider(
modelId: String, modelId: String,
modelPath: String, modelPath: String,
@@ -111,6 +91,16 @@ object InfrastructureModule {
fun createArtifactRepository(eventStore: EventStore): ArtifactRepository = fun createArtifactRepository(eventStore: EventStore): ArtifactRepository =
LiveArtifactRepository(eventStore, DefaultArtifactReducer()) LiveArtifactRepository(eventStore, DefaultArtifactReducer())
fun createApprovalRepository(eventStore: EventStore): DefaultApprovalRepository =
DefaultApprovalRepository(
DefaultEventReplayer(
eventStore,
ApprovalProjector(
DefaultApprovalReducer(),
),
),
)
fun createWorkflowLoader(): WorkflowLoader = TomlWorkflowLoader() fun createWorkflowLoader(): WorkflowLoader = TomlWorkflowLoader()
fun createPromptLoader(): PromptLoader = FileSystemPromptLoader() fun createPromptLoader(): PromptLoader = FileSystemPromptLoader()
@@ -13,7 +13,11 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.io.IOException import java.io.IOException
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.InvalidPathException import java.nio.file.InvalidPathException
@@ -39,7 +43,35 @@ class FileEditTool(
override val name: String = "file_edit" override val name: String = "file_edit"
override val description: String = "Edit content in a file at the specified path" override val description: String = "Edit content in a file at the specified path"
override val parametersSchema: JsonObject = buildJsonObject {} override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("path") {
put("type", "string")
put("description", "Relative path to the file to edit")
}
putJsonObject("operation") {
put("type", "string")
put("description", "Either 'append', 'replace', or 'patch'")
}
putJsonObject("content") {
put("type", "string")
put("description", "Content to write. Used for 'append' and 'replace'.")
}
putJsonObject("old_str") {
put("type", "string")
put("description", "Exact string to find. Required for 'patch'.")
}
putJsonObject("new_str") {
put("type", "string")
put("description", "Replacement string. Required for 'patch'.")
}
}
put("required", buildJsonArray {
add(JsonPrimitive("path"))
add(JsonPrimitive("operation"))
})
}
override val tier: Tier = Tier.T3 override val tier: Tier = Tier.T3
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE) override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
@@ -7,11 +7,14 @@ import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.io.IOException import java.io.IOException
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.InvalidPathException import java.nio.file.InvalidPathException
@@ -24,74 +27,90 @@ class FileReadTool(
override val name: String = "file_read" override val name: String = "file_read"
override val description: String = "Read content from a file at the specified path" override val description: String = "Read content from a file at the specified path"
override val parametersSchema: JsonObject = buildJsonObject {} override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("path") {
put("type", "string")
put("description", "Relative path to the file to read")
}
putJsonObject("start_line") {
put("type", "integer")
put("description", "First line to read, 1-indexed inclusive. Omit to read from beginning.")
}
putJsonObject("end_line") {
put("type", "integer")
put("description", "Last line to read, 1-indexed inclusive. Omit to read to end of file.")
}
}
put("required", buildJsonArray { add(JsonPrimitive("path")) })
}
override val tier: Tier = Tier.T1 override val tier: Tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ) override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)
override fun validateRequest(request: ToolRequest): ValidationResult = override fun validateRequest(request: ToolRequest): ValidationResult {
(request.parameters["path"] as? String)?.let { pathString -> val pathString = request.parameters["path"] as? String
runCatching { ?: return ValidationResult.Invalid("Missing or invalid 'path' parameter. Expected String.")
val startLine = request.parameters["start_line"]?.toString()?.toIntOrNull()
val endLine = request.parameters["end_line"]?.toString()?.toIntOrNull()
if (startLine != null && startLine < 1)
return ValidationResult.Invalid("'start_line' must be >= 1.")
if (endLine != null && endLine < 1)
return ValidationResult.Invalid("'end_line' must be >= 1.")
if (startLine != null && endLine != null && endLine < startLine)
return ValidationResult.Invalid("'end_line' must be >= 'start_line'.")
return runCatching {
val path = Paths.get(pathString).normalize().toAbsolutePath() val path = Paths.get(pathString).normalize().toAbsolutePath()
if (allowedPaths.none { path.startsWith(it.normalize().toAbsolutePath()) }) { if (allowedPaths.isNotEmpty() && allowedPaths.none { path.startsWith(it.normalize().toAbsolutePath()) })
return@runCatching ValidationResult.Invalid("Path '$pathString' is not in the allowed list") ValidationResult.Invalid("Path '$pathString' is not in the allowed list")
} else
ValidationResult.Valid ValidationResult.Valid
}.getOrElse { }.getOrElse {
when (it) { when (it) {
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${it.message}") is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${it.message}")
is IOException -> ValidationResult.Invalid("IO error: ${it.message}") is IOException -> ValidationResult.Invalid("IO error: ${it.message}")
is SecurityException -> ValidationResult.Invalid("Security error: ${it.message}") is SecurityException -> ValidationResult.Invalid("Security error: ${it.message}")
else -> ValidationResult.Invalid(it.message ?: "Unknown error occured") else -> ValidationResult.Invalid(it.message ?: "Unknown error occurred")
}
} }
} }
} ?: ValidationResult.Invalid("Missing or invalid 'path' parameter. Expected String.")
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) { override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
validateRequest(request).run { val validation = validateRequest(request)
takeIf { it is ValidationResult.Valid }?.let { if (validation is ValidationResult.Invalid)
return@withContext ToolResult.Failure(
invocationId = request.invocationId,
reason = validation.reason,
recoverable = false,
)
val pathString = request.parameters["path"] as String val pathString = request.parameters["path"] as String
val path = Paths.get(pathString).normalize().toAbsolutePath() val path = Paths.get(pathString).normalize().toAbsolutePath()
if (Files.exists(path)) { val startLine = request.parameters["start_line"]?.toString()?.toIntOrNull()
readStringCatching(request, path, pathString) val endLine = request.parameters["end_line"]?.toString()?.toIntOrNull()
} else {
ToolResult.Failure( if (!Files.exists(path))
return@withContext ToolResult.Failure(
invocationId = request.invocationId, invocationId = request.invocationId,
reason = "File not found: $pathString", reason = "File not found: $pathString",
recoverable = false, recoverable = false,
) )
}
} ?: ToolResult.Failure(
invocationId = request.invocationId,
reason = (this as ValidationResult.Invalid).reason,
recoverable = false,
)
}
}
private fun readStringCatching(request: ToolRequest, path: Path, pathString: String): ToolResult =
runCatching { runCatching {
val lines = Files.readAllLines(path)
val start = ((startLine ?: 1) - 1).coerceAtLeast(0)
val end = (endLine ?: lines.size).coerceAtMost(lines.size)
val content = lines.subList(start, end).joinToString("\n")
ToolResult.Success( ToolResult.Success(
invocationId = request.invocationId, invocationId = request.invocationId,
output = Files.readString(path), output = content,
) )
}.getOrElse { }.getOrElse {
when (it) { ToolResult.Failure(
is CancellationException -> throw it
is SecurityException -> ToolResult.Failure(
invocationId = request.invocationId, invocationId = request.invocationId,
reason = "Access denied: $pathString, ${it.message}", reason = "Failed to read file: ${it.message}",
recoverable = false,
)
is IOException -> ToolResult.Failure(
invocationId = request.invocationId,
reason = "IO error: ${it.message}",
recoverable = false,
)
else -> ToolResult.Failure(
invocationId = request.invocationId,
reason = it.message ?: "Unknown error occurred while reading file",
recoverable = false, recoverable = false,
) )
} }
@@ -12,8 +12,13 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.nio.file.Path import java.nio.file.Path
class ShellTool( class ShellTool(
@@ -23,18 +28,36 @@ class ShellTool(
) : Tool, ToolExecutor { ) : Tool, ToolExecutor {
override val name: String = "shell" override val name: String = "shell"
override val description: String = "Execute a shell command" override val description: String = "Execute a shell command"
override val parametersSchema: JsonObject = buildJsonObject {} override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("argv") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Command and arguments as a list. Example: [\"bash\", \"script.sh\"]")
}
}
put("required", buildJsonArray { add(JsonPrimitive("argv")) })
}
override val tier: Tier = Tier.T2 override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = override val requiredCapabilities: Set<ToolCapability> =
setOf(ToolCapability.SHELL_EXEC, ToolCapability.PROCESS_SPAWN) setOf(ToolCapability.SHELL_EXEC, ToolCapability.PROCESS_SPAWN)
override fun validateRequest(request: ToolRequest): ValidationResult { override fun validateRequest(request: ToolRequest): ValidationResult {
val argv = (request.parameters["argv"] as? List<*>)?.filterIsInstance<String>() val argv = when (val raw = request.parameters["argv"]) {
is List<*> -> raw.filterIsInstance<String>()
is String -> runCatching {
Json.decodeFromString<List<String>>(raw)
}.getOrElse { emptyList() }
else -> emptyList()
}
return when { return when {
argv.isNullOrEmpty() -> argv.isEmpty() ->
ValidationResult.Invalid("Missing or empty 'argv' parameter. Expected List<String>.") ValidationResult.Invalid("Missing or empty 'argv' parameter. Expected List<String>.")
allowedExecutables.isNotEmpty() && argv[0] !in allowedExecutables -> allowedExecutables.isNotEmpty() && argv[0] !in allowedExecutables ->
ValidationResult.Invalid("Executable '${argv[0]}' is not in the allowed list.") ValidationResult.Invalid("Executable '${argv[0]}' is not in the allowed list.")
else -> ValidationResult.Valid else -> ValidationResult.Valid
} }
} }
@@ -42,7 +65,13 @@ class ShellTool(
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) { override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
validateRequest(request).run { validateRequest(request).run {
takeIf { this is ValidationResult.Valid }?.let { takeIf { this is ValidationResult.Valid }?.let {
val argv = (request.parameters["argv"] as? List<*>)?.filterIsInstance<String>() val argv = when (val raw = request.parameters["argv"]) {
is List<*> -> raw.filterIsInstance<String>()
is String -> runCatching {
Json.decodeFromString<List<String>>(raw)
}.getOrElse { emptyList() }
else -> emptyList()
}
val process = ProcessBuilder(argv).apply { val process = ProcessBuilder(argv).apply {
workingDir?.let { directory(it.toFile()) } workingDir?.let { directory(it.toFile()) }
}.start() }.start()
@@ -37,7 +37,6 @@ class EventsTest {
sessionId = SessionId("s1"), sessionId = SessionId("s1"),
terminalStageId = StageId("stage-a"), terminalStageId = StageId("stage-a"),
totalStages = 1, totalStages = 1,
workflowId = workflowId,
) )
val json = eventJson.encodeToString(EventPayload.serializer(), event) val json = eventJson.encodeToString(EventPayload.serializer(), event)
@@ -5,14 +5,20 @@ import com.correx.core.events.events.SteeringNoteAddedEvent
import com.correx.core.events.events.StoredEvent import com.correx.core.events.events.StoredEvent
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
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.InferenceRequestId import com.correx.core.events.types.InferenceRequestId
import com.correx.core.events.types.ProviderId
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.CapabilityScore
import com.correx.core.inference.FinishReason
import com.correx.core.inference.InferenceProvider import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRequest import com.correx.core.inference.InferenceRequest
import com.correx.core.inference.InferenceResponse import com.correx.core.inference.InferenceResponse
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.ProviderHealth
import com.correx.core.inference.ResponseFormat
import com.correx.core.inference.TokenUsage import com.correx.core.inference.TokenUsage
import com.correx.core.router.ChatMode import com.correx.core.router.ChatMode
import com.correx.core.router.DefaultRouterFacade import com.correx.core.router.DefaultRouterFacade
@@ -24,6 +30,8 @@ import com.correx.core.router.model.RouterResponse
import com.correx.core.router.model.RouterState import com.correx.core.router.model.RouterState
import com.correx.core.router.model.TurnRole import com.correx.core.router.model.TurnRole
import com.correx.core.router.model.WorkflowStatus import com.correx.core.router.model.WorkflowStatus
import com.correx.testing.fixtures.inference.MockTokenizer
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
@@ -405,25 +413,25 @@ class RouterFacadeTest {
requiredCapabilities: Set<ModelCapability>, requiredCapabilities: Set<ModelCapability>,
): InferenceProvider { ): InferenceProvider {
return object : InferenceProvider { return object : InferenceProvider {
override val id = com.correx.core.events.types.ProviderId("mock") override val id = ProviderId("mock")
override val name = "Mock" override val name = "Mock"
override val tokenizer = com.correx.testing.fixtures.inference.MockTokenizer() override val tokenizer = MockTokenizer()
override suspend fun infer(request: InferenceRequest): InferenceResponse { override suspend fun infer(request: InferenceRequest): InferenceResponse {
assertEquals(1, capturedContextPacks.size) assertEquals(1, capturedContextPacks.size)
assertEquals(capturedContextPacks[0].id, request.contextPack.id) assertEquals(capturedContextPacks[0].id, request.contextPack.id)
return InferenceResponse( return InferenceResponse(
requestId = request.requestId, requestId = request.requestId,
text = "response", text = "response",
finishReason = com.correx.core.inference.FinishReason.Stop, finishReason = FinishReason.Stop,
tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5), tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5),
latencyMs = 0, latencyMs = 0,
) )
} }
override suspend fun healthCheck(): com.correx.core.inference.ProviderHealth = override suspend fun healthCheck(): ProviderHealth =
com.correx.core.inference.ProviderHealth.Healthy ProviderHealth.Healthy
override fun capabilities(): Set<com.correx.core.inference.CapabilityScore> = emptySet() override fun capabilities(): Set<CapabilityScore> = emptySet()
} }
} }
}, },
@@ -456,7 +464,7 @@ class RouterFacadeTest {
) )
facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!") facade.onUserInput(sessionId = SessionId("test-session"), input = "Hello!")
val req = capturedRequests[0] val req = capturedRequests[0]
assertTrue(req.responseFormat is com.correx.core.inference.ResponseFormat.Text) assertTrue(req.responseFormat is ResponseFormat.Text)
} }
@Test @Test
@@ -512,22 +520,22 @@ class RouterFacadeTest {
private fun mockProvider(responseText: String): InferenceProvider = private fun mockProvider(responseText: String): InferenceProvider =
object : InferenceProvider { object : InferenceProvider {
override val id = com.correx.core.events.types.ProviderId("mock") override val id = ProviderId("mock")
override val name = "Mock" override val name = "Mock"
override val tokenizer = com.correx.testing.fixtures.inference.MockTokenizer() override val tokenizer = MockTokenizer()
override suspend fun infer(request: InferenceRequest): InferenceResponse = InferenceResponse( override suspend fun infer(request: InferenceRequest): InferenceResponse = InferenceResponse(
requestId = request.requestId, requestId = request.requestId,
text = responseText, text = responseText,
finishReason = com.correx.core.inference.FinishReason.Stop, finishReason = FinishReason.Stop,
tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5), tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5),
latencyMs = 0, latencyMs = 0,
) )
override suspend fun healthCheck(): com.correx.core.inference.ProviderHealth = override suspend fun healthCheck(): ProviderHealth =
com.correx.core.inference.ProviderHealth.Healthy ProviderHealth.Healthy
override fun capabilities(): Set<com.correx.core.inference.CapabilityScore> = override fun capabilities(): Set<CapabilityScore> =
setOf(com.correx.core.inference.CapabilityScore(ModelCapability.General, 1.0)) setOf(CapabilityScore(ModelCapability.General, 1.0))
} }
private fun mockProviderWithCapture( private fun mockProviderWithCapture(
@@ -535,24 +543,24 @@ class RouterFacadeTest {
requestIds: MutableList<InferenceRequestId>, requestIds: MutableList<InferenceRequestId>,
): InferenceProvider = ): InferenceProvider =
object : InferenceProvider { object : InferenceProvider {
override val id = com.correx.core.events.types.ProviderId("mock") override val id = ProviderId("mock")
override val name = "Mock" override val name = "Mock"
override val tokenizer = com.correx.testing.fixtures.inference.MockTokenizer() override val tokenizer = MockTokenizer()
override suspend fun infer(request: InferenceRequest): InferenceResponse { override suspend fun infer(request: InferenceRequest): InferenceResponse {
requestIds.add(request.requestId) requestIds.add(request.requestId)
return InferenceResponse( return InferenceResponse(
requestId = request.requestId, requestId = request.requestId,
text = responseText, text = responseText,
finishReason = com.correx.core.inference.FinishReason.Stop, finishReason = FinishReason.Stop,
tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5), tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5),
latencyMs = 0, latencyMs = 0,
) )
} }
override suspend fun healthCheck(): com.correx.core.inference.ProviderHealth = override suspend fun healthCheck(): ProviderHealth =
com.correx.core.inference.ProviderHealth.Healthy ProviderHealth.Healthy
override fun capabilities(): Set<com.correx.core.inference.CapabilityScore> = emptySet() override fun capabilities(): Set<CapabilityScore> = emptySet()
} }
private fun mockProviderWithRequestCapture( private fun mockProviderWithRequestCapture(
@@ -560,24 +568,24 @@ class RouterFacadeTest {
requests: MutableList<InferenceRequest>, requests: MutableList<InferenceRequest>,
): InferenceProvider = ): InferenceProvider =
object : InferenceProvider { object : InferenceProvider {
override val id = com.correx.core.events.types.ProviderId("mock") override val id = ProviderId("mock")
override val name = "Mock" override val name = "Mock"
override val tokenizer = com.correx.testing.fixtures.inference.MockTokenizer() override val tokenizer = MockTokenizer()
override suspend fun infer(request: InferenceRequest): InferenceResponse { override suspend fun infer(request: InferenceRequest): InferenceResponse {
requests.add(request) requests.add(request)
return InferenceResponse( return InferenceResponse(
requestId = request.requestId, requestId = request.requestId,
text = responseText, text = responseText,
finishReason = com.correx.core.inference.FinishReason.Stop, finishReason = FinishReason.Stop,
tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5), tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5),
latencyMs = 0, latencyMs = 0,
) )
} }
override suspend fun healthCheck(): com.correx.core.inference.ProviderHealth = override suspend fun healthCheck(): ProviderHealth =
com.correx.core.inference.ProviderHealth.Healthy ProviderHealth.Healthy
override fun capabilities(): Set<com.correx.core.inference.CapabilityScore> = emptySet() override fun capabilities(): Set<CapabilityScore> = emptySet()
} }
private fun emptyContextPack(): ContextPack = ContextPack( private fun emptyContextPack(): ContextPack = ContextPack(
@@ -591,7 +599,7 @@ class RouterFacadeTest {
private class MapBackedEventStore : EventStore { private class MapBackedEventStore : EventStore {
val appendedEvents = mutableListOf<NewEvent>() val appendedEvents = mutableListOf<NewEvent>()
private val storedEvents = mutableMapOf<com.correx.core.events.types.EventId, StoredEvent>() private val storedEvents: MutableMap<EventId, StoredEvent> = mutableMapOf()
private var nextSequence = 1L private var nextSequence = 1L
override suspend fun append(event: NewEvent): StoredEvent { override suspend fun append(event: NewEvent): StoredEvent {
@@ -620,10 +628,12 @@ class RouterFacadeTest {
override fun lastSequence(sessionId: SessionId): Long? = override fun lastSequence(sessionId: SessionId): Long? =
read(sessionId).maxOfOrNull { it.sequence } read(sessionId).maxOfOrNull { it.sequence }
override fun subscribe(sessionId: SessionId): kotlinx.coroutines.flow.Flow<StoredEvent> = override fun subscribe(sessionId: SessionId): Flow<StoredEvent> =
throw UnsupportedOperationException("subscribe not implemented for mock") throw UnsupportedOperationException("subscribe not implemented for mock")
override fun allEvents(): Sequence<StoredEvent> = override fun allEvents(): Sequence<StoredEvent> =
storedEvents.values.asSequence() storedEvents.values.asSequence()
override fun allSessionIds(): Set<SessionId> = storedEvents.values.map { it.metadata.sessionId }.toSet()
} }
} }
@@ -65,7 +65,7 @@ class RouterProjectorTest {
) )
val updated = projector.apply( val updated = projector.apply(
started, started,
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 3, workflowId)), stored(payload = WorkflowCompletedEvent(sessionId, stageId, 3)),
) )
assertEquals(WorkflowStatus.COMPLETED, updated.workflowStatus) assertEquals(WorkflowStatus.COMPLETED, updated.workflowStatus)
assertNull(updated.currentStageId) assertNull(updated.currentStageId)
@@ -176,7 +176,7 @@ class RouterProjectorTest {
stored(payload = OrchestrationResumedEvent(sessionId, StageId("a"))), stored(payload = OrchestrationResumedEvent(sessionId, StageId("a"))),
stored(payload = StageCompletedEvent(sessionId, StageId("b"), StageId("t2"))), stored(payload = StageCompletedEvent(sessionId, StageId("b"), StageId("t2"))),
stored(payload = StageFailedEvent(sessionId, StageId("b"), StageId("t3"), "error")), stored(payload = StageFailedEvent(sessionId, StageId("b"), StageId("t3"), "error")),
stored(payload = WorkflowCompletedEvent(sessionId, StageId("c"), 3, workflowId)), stored(payload = WorkflowCompletedEvent(sessionId, StageId("c"), 3)),
) )
var projected = state var projected = state
@@ -206,7 +206,7 @@ class RouterProjectorTest {
state = projector.apply( state = projector.apply(
state, state,
stored(payload = WorkflowCompletedEvent(sessionId, StageId("stage-A"), 1, workflowId)), stored(payload = WorkflowCompletedEvent(sessionId, StageId("stage-A"), 1)),
) )
assertEquals(WorkflowStatus.COMPLETED, state.workflowStatus) assertEquals(WorkflowStatus.COMPLETED, state.workflowStatus)
assertNull(state.currentStageId) assertNull(state.currentStageId)
@@ -58,7 +58,7 @@ class RouterReducerTest {
) )
val completed = reducer.reduce( val completed = reducer.reduce(
started, started,
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 3, workflowId)), stored(payload = WorkflowCompletedEvent(sessionId, stageId, 3)),
) )
assertEquals(WorkflowStatus.COMPLETED, completed.workflowStatus) assertEquals(WorkflowStatus.COMPLETED, completed.workflowStatus)
assertNull(completed.currentStageId) assertNull(completed.currentStageId)
@@ -233,7 +233,7 @@ class RouterReducerTest {
assertEquals(StageOutcomeKind.SUCCESS, s2.l2Memory[0].outcome) assertEquals(StageOutcomeKind.SUCCESS, s2.l2Memory[0].outcome)
val s3 = val s3 =
reducer.reduce(s2, stored(payload = WorkflowCompletedEvent(sessionId, StageId("stage-A"), 1, workflowId))) reducer.reduce(s2, stored(payload = WorkflowCompletedEvent(sessionId, StageId("stage-A"), 1)))
assertEquals(WorkflowStatus.COMPLETED, s3.workflowStatus) assertEquals(WorkflowStatus.COMPLETED, s3.workflowStatus)
assertNull(s3.currentStageId) assertNull(s3.currentStageId)
@@ -81,7 +81,7 @@ class OrchestrationProjectorTest {
failureReason = "Something went wrong", failureReason = "Something went wrong",
), ),
), ),
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1, workflowId)), stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1)),
) )
val result1 = events.fold(initialState) { state, event -> val result1 = events.fold(initialState) { state, event ->
@@ -101,7 +101,7 @@ class OrchestrationProjectorTest {
val s2 = val s2 =
projector.apply(s1, stored(payload = OrchestrationPausedEvent(sessionId, stageId, "approval required"))) projector.apply(s1, stored(payload = OrchestrationPausedEvent(sessionId, stageId, "approval required")))
val s3 = projector.apply(s2, stored(payload = OrchestrationResumedEvent(sessionId, stageId))) val s3 = projector.apply(s2, stored(payload = OrchestrationResumedEvent(sessionId, stageId)))
val s4 = projector.apply(s3, stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1, workflowId))) val s4 = projector.apply(s3, stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1)))
assertEquals(OrchestrationStatus.RUNNING, s1.status) assertEquals(OrchestrationStatus.RUNNING, s1.status)
assertEquals(OrchestrationStatus.PAUSED, s2.status) assertEquals(OrchestrationStatus.PAUSED, s2.status)
@@ -21,8 +21,8 @@ class OrchestrationReducerTest {
private val reducer = DefaultOrchestrationReducer() private val reducer = DefaultOrchestrationReducer()
private val sessionId = SessionId("s1") private val sessionId = SessionId("s1")
private val stageId = StageId("stage-1") private val stageId = StageId("stage-1")
private val state = OrchestrationState(stageId)
private val workflowId = "workflow-test" private val workflowId = "workflow-test"
private val state = OrchestrationState(workflowId, stageId)
@Test @Test
fun `WorkflowStartedEvent sets status to RUNNING`() { fun `WorkflowStartedEvent sets status to RUNNING`() {
@@ -58,7 +58,7 @@ class OrchestrationReducerTest {
val completed = reducer.reduce( val completed = reducer.reduce(
started, started,
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1, workflowId)), stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1)),
) )
assertTrue(completed.failureReason.isNullOrBlank()) assertTrue(completed.failureReason.isNullOrBlank())
assertEquals(OrchestrationStatus.COMPLETED, completed.status) assertEquals(OrchestrationStatus.COMPLETED, completed.status)
@@ -49,7 +49,7 @@ class RouterProjectorTest {
val started = val started =
projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId))) projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId)))
val completed = val completed =
projector.apply(started, stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1, workflowId))) projector.apply(started, stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1)))
assertEquals(WorkflowStatus.COMPLETED, completed.workflowStatus) assertEquals(WorkflowStatus.COMPLETED, completed.workflowStatus)
assertNull(completed.currentStageId) assertNull(completed.currentStageId)
} }
@@ -134,7 +134,7 @@ class RouterProjectorTest {
stored(payload = StageCompletedEvent(sessionId, StageId("stage-2"), StageId("t1"))), stored(payload = StageCompletedEvent(sessionId, StageId("stage-2"), StageId("t1"))),
stored(payload = SteeringNoteAddedEvent(sessionId, "note")), stored(payload = SteeringNoteAddedEvent(sessionId, "note")),
stored(payload = StageFailedEvent(sessionId, stageId, StageId("t2"), "timeout")), stored(payload = StageFailedEvent(sessionId, stageId, StageId("t2"), "timeout")),
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 2, workflowId)), stored(payload = WorkflowCompletedEvent(sessionId, stageId, 2)),
) )
val result1 = events.fold(initialState) { state, event -> val result1 = events.fold(initialState) { state, event ->
@@ -155,7 +155,7 @@ class RouterProjectorTest {
projector.apply(s1, stored(payload = StageCompletedEvent(sessionId, StageId("stage-2"), StageId("t1")))) projector.apply(s1, stored(payload = StageCompletedEvent(sessionId, StageId("stage-2"), StageId("t1"))))
val s3 = projector.apply(s2, stored(payload = StageFailedEvent(sessionId, stageId, StageId("t2"), "timeout"))) val s3 = projector.apply(s2, stored(payload = StageFailedEvent(sessionId, stageId, StageId("t2"), "timeout")))
val s4 = projector.apply(s3, stored(payload = WorkflowStartedEvent(sessionId, workflowId, StageId("stage-3")))) val s4 = projector.apply(s3, stored(payload = WorkflowStartedEvent(sessionId, workflowId, StageId("stage-3"))))
val s5 = projector.apply(s4, stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1, workflowId))) val s5 = projector.apply(s4, stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1)))
assertEquals(WorkflowStatus.RUNNING, s1.workflowStatus) assertEquals(WorkflowStatus.RUNNING, s1.workflowStatus)
assertEquals(StageId("stage-2"), s2.l2Memory[0].stageId) assertEquals(StageId("stage-2"), s2.l2Memory[0].stageId)