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
+2 -1
View File
@@ -68,4 +68,5 @@ bin/
/code-analysis/
/.infra/sonarqube/docker-compose.yml
/logs
log4j2.properties
log4j2.properties
/docs/specs/
@@ -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)
@@ -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(
@@ -26,7 +26,9 @@ class DefaultApprovalReducer : ApprovalReducer {
timestamp = event.metadata.timestamp,
causationId = event.metadata.causationId,
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))
}
@@ -39,11 +41,11 @@ class DefaultApprovalReducer : ApprovalReducer {
identity = ApprovalScopeIdentity(
sessionId = event.metadata.sessionId,
stageId = null,
projectId = null
projectId = null,
),
mode = ApprovalMode.PROMPT,
causationId = event.metadata.causationId,
correlationId = event.metadata.correlationId
correlationId = event.metadata.correlationId,
)
val decision = ApprovalDecision(
id = decisionId,
@@ -54,7 +56,7 @@ class DefaultApprovalReducer : ApprovalReducer {
contextSnapshot = contextSnapshot,
resolutionTimestamp = payload.resolutionTimestamp,
reason = payload.reason,
userSteering = payload.userSteering
userSteering = payload.userSteering,
)
state.copy(decisions = state.decisions + (decisionId to decision))
}
@@ -66,7 +68,7 @@ class DefaultApprovalReducer : ApprovalReducer {
permittedTiers = payload.permittedTiers,
reason = payload.reason,
timestamp = event.metadata.timestamp,
expiresAt = payload.expiresAt
expiresAt = payload.expiresAt,
)
state.copy(grants = state.grants + (grant.id to grant))
}
@@ -19,5 +19,7 @@ data class DomainApprovalRequest(
val timestamp: Instant,
val causationId: CausationId? = 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 stageId: StageId?,
val projectId: ProjectId?,
val userSteering: UserSteering? = null
val userSteering: UserSteering? = null,
val toolName: String? = null,
val preview: String? = null,
) : EventPayload
@Serializable
@@ -37,7 +39,7 @@ data class ApprovalDecisionResolvedEvent(
val tier: Tier,
val resolutionTimestamp: Instant,
val reason: String?,
val userSteering: UserSteering? = null
val userSteering: UserSteering? = null,
) : EventPayload
@Serializable
@@ -49,10 +51,10 @@ data class ApprovalGrantCreatedEvent(
val expiresAt: Instant?,
val sessionId: SessionId,
val stageId: StageId?,
val projectId: ProjectId?
val projectId: ProjectId?,
) : EventPayload
@Serializable
data class ApprovalGrantExpiredEvent(
val grantId: GrantId
val grantId: GrantId,
) : EventPayload
@@ -18,7 +18,6 @@ data class WorkflowCompletedEvent(
val sessionId: SessionId,
val terminalStageId: StageId,
val totalStages: Int,
val workflowId: String,
) : EventPayload
@Serializable
@@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable
@Serializable
data class OrchestrationState(
val workflowId: String = "",
val currentStageId: StageId? = null,
val status: OrchestrationStatus = OrchestrationStatus.IDLE,
val retryCount: Int = 0,
@@ -47,4 +47,10 @@ interface EventStore {
* Intended for batch jobs (e.g. CAS liveness scans). Implementations should stream lazily.
*/
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,
): OrchestrationState = when (val p = event.payload) {
is WorkflowStartedEvent -> state.copy(
workflowId = p.workflowId,
status = OrchestrationStatus.RUNNING,
currentStageId = p.startStageId,
retryPolicy = p.retryPolicy,
)
is WorkflowCompletedEvent -> state.copy(
status = OrchestrationStatus.COMPLETED,
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(
status = OrchestrationStatus.PAUSED,
pendingApproval = true,
@@ -94,7 +94,7 @@ class DefaultSessionOrchestrator(
}
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 {
failWorkflow(
sessionId = enriched.sessionId,
@@ -140,7 +140,7 @@ class DefaultSessionOrchestrator(
if (isTerminal(ctx.graph, nextStageId)) {
// Terminal stage is a sentinel — not executed, so don't count it.
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
import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.artifacts.repository.ArtifactRepository
import com.correx.core.events.stores.EventStore
import com.correx.core.inference.InferenceRepository
@@ -11,4 +12,5 @@ data class OrchestratorRepositories(
val orchestrationRepository: OrchestrationRepository,
val sessionRepository: DefaultSessionRepository,
val artifactRepository: ArtifactRepository,
val approvalRepository: DefaultApprovalRepository,
)
@@ -87,7 +87,7 @@ class ReplayOrchestrator(
val nextStageId = decision.to
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)) {
@@ -101,7 +101,7 @@ class ReplayOrchestrator(
}
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 {
step(ctx)
}
@@ -112,7 +112,6 @@ abstract class SessionOrchestrator(
private val toolExecutor: ToolExecutor? = engines.toolExecutor
private val toolRegistry: ToolRegistry? = engines.toolRegistry
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
private val artifactRepository = repositories.artifactRepository
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
internal abstract val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean>
internal val pendingApprovals: ConcurrentHashMap<ApprovalRequestId, CompletableDeferred<ApprovalDecision>> =
@@ -145,7 +144,7 @@ abstract class SessionOrchestrator(
.onFailure {
log.error(
"[SessionOrchestrator] stage=${stageId.value}: " +
"failed to load prompt '$path': ${it.message}",
"failed to load prompt '$path': ${it.message}",
)
}
.getOrNull()
@@ -168,7 +167,7 @@ abstract class SessionOrchestrator(
.onFailure {
log.error(
"[SessionOrchestrator] stage=${stageId.value}: " +
"failed to load default system prompt '$path': ${it.message}",
"failed to load default system prompt '$path': ${it.message}",
)
}
.getOrNull()
@@ -192,7 +191,7 @@ abstract class SessionOrchestrator(
.onFailure {
log.error(
"[SessionOrchestrator] stage=${stageId.value}: " +
"failed to load prompt '$path': ${it.message}",
"failed to load prompt '$path': ${it.message}",
)
}
.getOrNull()
@@ -214,7 +213,7 @@ abstract class SessionOrchestrator(
val llmEmittedSlots = stageConfig.produces.filter { it.kind.llmEmitted }
require(llmEmittedSlots.size <= 1) {
"Stage '${stageId.value}' declares ${llmEmittedSlots.size} LLM-emitted artifact kinds; " +
"only 0 or 1 is supported"
"only 0 or 1 is supported"
}
val responseFormat = llmEmittedSlots.firstOrNull()
?.let { ResponseFormat.Json(it.kind.deriveJsonSchema()) }
@@ -269,6 +268,7 @@ abstract class SessionOrchestrator(
emitToolArtifacts(sessionId, stageId, stageConfig)
verifyProduces(sessionId, stageId, stageConfig)
}
is StageExecutionResult.Failure -> outcome
}
}
@@ -321,6 +321,8 @@ abstract class SessionOrchestrator(
validationReportId = ValidationReportId(UUID.randomUUID().toString()),
riskSummaryId = null,
timestamp = Clock.System.now(),
toolName = toolCall.function.name,
preview = toolCall.function.arguments.take(200),
)
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
emit(
@@ -333,6 +335,8 @@ abstract class SessionOrchestrator(
sessionId = sessionId,
stageId = stageId,
projectId = null,
toolName = toolCall.function.name,
preview = toolCall.function.arguments.take(200),
),
)
val deferred = CompletableDeferred<ApprovalDecision>()
@@ -399,8 +403,8 @@ abstract class SessionOrchestrator(
if (responseFormat !is ResponseFormat.Json) return emptyList()
val compactSchema = Json.encodeToString(JsonSchema.serializer(), responseFormat.schema)
val instruction = "Respond with a single JSON object matching this schema. " +
"Do not include markdown, code fences, or commentary outside the JSON. " +
"Schema: $compactSchema"
"Do not include markdown, code fences, or commentary outside the JSON. " +
"Schema: $compactSchema"
return listOf(
ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
@@ -442,7 +446,7 @@ abstract class SessionOrchestrator(
val missingIds = missing.joinToString(", ") { it.value }
log.error(
"[Orchestrator] stage missing produced artifacts " +
"session=${sessionId.value} stage=${stageId.value} missing=$missingIds",
"session=${sessionId.value} stage=${stageId.value} missing=$missingIds",
)
return StageExecutionResult.Failure(
"stage ${stageId.value} did not produce declared artifacts: $missingIds",
@@ -598,13 +602,12 @@ abstract class SessionOrchestrator(
sessionId: SessionId,
terminalStageId: StageId,
stageCount: Int,
workflowId: String,
): WorkflowResult.Completed {
log.debug(
"[Orchestrator] COMPLETED session={} terminalStage={} stages={}",
sessionId.value, terminalStageId.value, stageCount,
)
emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount, workflowId))
emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount))
cancellations.remove(sessionId)
return WorkflowResult.Completed(sessionId, terminalStageId)
}
@@ -642,6 +645,7 @@ abstract class SessionOrchestrator(
// --- event emission ---
internal suspend fun emit(sessionId: SessionId, payload: EventPayload) {
log.debug("[session {}] emitting event, payload: {}", sessionId.value.substring(0, 7), payload)
eventStore.append(
NewEvent(
metadata = EventMetadata(
@@ -63,6 +63,8 @@ class InMemoryEventStore : EventStore {
.flatMap { stream -> synchronized(stream) { stream.toList() } }
.asSequence()
override fun allSessionIds(): Set<SessionId> = sequences.keys
private fun doAppend(event: NewEvent, stream: MutableList<StoredEvent>): StoredEvent {
val seq = sequences.computeIfAbsent(event.metadata.sessionId) { AtomicLong(0) }
.incrementAndGet()
@@ -42,7 +42,7 @@ class SqliteEventStore(
correlation_id TEXT,
payload TEXT NOT NULL
);
""".trimIndent()
""".trimIndent(),
)
}
}
@@ -60,7 +60,7 @@ class SqliteEventStore(
val s = StoredEvent(
metadata = event.metadata,
sequence = seq,
payload = event.payload
payload = event.payload,
)
insert(s)
@@ -91,7 +91,7 @@ class SqliteEventStore(
val s = StoredEvent(
metadata = event.metadata,
sequence = seq,
payload = event.payload
payload = event.payload,
)
insert(s)
@@ -111,7 +111,7 @@ class SqliteEventStore(
SELECT * FROM events
WHERE session_id = ?
ORDER BY sequence ASC
""".trimIndent()
""".trimIndent(),
).use { ps ->
ps.setString(1, sessionId.value)
@@ -131,7 +131,7 @@ class SqliteEventStore(
WHERE session_id = ?
AND sequence > ?
ORDER BY sequence ASC
""".trimIndent()
""".trimIndent(),
).use { ps ->
ps.setString(1, sessionId.value)
ps.setLong(2, fromSequence)
@@ -151,7 +151,7 @@ class SqliteEventStore(
SELECT MAX(sequence)
FROM events
WHERE session_id = ?
""".trimIndent()
""".trimIndent(),
).use { ps ->
ps.setString(1, sessionId.value)
@@ -169,7 +169,7 @@ class SqliteEventStore(
"""
SELECT * FROM events
ORDER BY session_id ASC, sequence ASC
""".trimIndent()
""".trimIndent(),
).use { ps ->
ps.executeQuery().use { rs ->
buildList {
@@ -180,6 +180,15 @@ class SqliteEventStore(
}
}.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 ----------
private fun nextSequence(sessionId: SessionId): Long =
@@ -188,7 +197,7 @@ class SqliteEventStore(
SELECT COALESCE(MAX(sequence), 0)
FROM events
WHERE session_id = ?
""".trimIndent()
""".trimIndent(),
).use { ps ->
ps.setString(1, sessionId.value)
ps.executeQuery().use { rs ->
@@ -211,7 +220,7 @@ class SqliteEventStore(
correlation_id,
payload
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
""".trimIndent(),
).use { ps ->
ps.setString(1, event.metadata.eventId.value)
ps.setString(2, event.metadata.sessionId.value)
@@ -231,7 +240,7 @@ class SqliteEventStore(
"""
SELECT * FROM events
WHERE event_id = ?
""".trimIndent()
""".trimIndent(),
).use { ps ->
ps.setString(1, eventId.value)
@@ -250,8 +259,8 @@ private fun ResultSet.toStoredEvent(jsonSerializer: JsonEventSerializer): Stored
timestamp = Instant.parse(getString("timestamp")),
schemaVersion = getInt("schema_version"),
causationId = getString("causation_id")?.let { CausationId(it) },
correlationId = getString("correlation_id")?.let { CorrelationId(it) }
correlationId = getString("correlation_id")?.let { CorrelationId(it) },
),
sequence = getLong("sequence"),
payload = jsonSerializer.deserialize(getString("payload"))
payload = jsonSerializer.deserialize(getString("payload")),
)
@@ -1,15 +1,15 @@
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.repository.ArtifactRepository
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.EventDispatcher
import com.correx.core.events.stores.EventStore
import com.correx.core.inference.DefaultInferenceRouter
import com.correx.core.inference.InferenceProvider
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.DefaultRouterFacade
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.index.SqliteArtifactIndex
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.ModelManager
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.persistence.SqliteEventStore
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.TomlWorkflowLoader
import com.correx.infrastructure.workflow.WorkflowLoader
import io.ktor.client.HttpClient
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
@@ -71,25 +67,9 @@ object InfrastructureModule {
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 =
DefaultToolRegistry.build(config.buildTools())
fun createInferenceRouter(
providers: List<InferenceProvider> = emptyList(),
registry: ProviderRegistry = DefaultProviderRegistry(providers),
strategy: RoutingStrategy = FirstAvailableRoutingStrategy(),
): InferenceRouter = DefaultInferenceRouter(registry, strategy)
fun createLlamaCppProvider(
modelId: String,
modelPath: String,
@@ -111,6 +91,16 @@ object InfrastructureModule {
fun createArtifactRepository(eventStore: EventStore): ArtifactRepository =
LiveArtifactRepository(eventStore, DefaultArtifactReducer())
fun createApprovalRepository(eventStore: EventStore): DefaultApprovalRepository =
DefaultApprovalRepository(
DefaultEventReplayer(
eventStore,
ApprovalProjector(
DefaultApprovalReducer(),
),
),
)
fun createWorkflowLoader(): WorkflowLoader = TomlWorkflowLoader()
fun createPromptLoader(): PromptLoader = FileSystemPromptLoader()
@@ -13,7 +13,11 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.io.IOException
import java.nio.file.Files
import java.nio.file.InvalidPathException
@@ -39,7 +43,35 @@ class FileEditTool(
override val name: String = "file_edit"
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 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.ToolResult
import com.correx.core.tools.contract.ValidationResult
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.io.IOException
import java.nio.file.Files
import java.nio.file.InvalidPathException
@@ -24,76 +27,92 @@ class FileReadTool(
override val name: String = "file_read"
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 requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)
override fun validateRequest(request: ToolRequest): ValidationResult =
(request.parameters["path"] as? String)?.let { pathString ->
runCatching {
val path = Paths.get(pathString).normalize().toAbsolutePath()
if (allowedPaths.none { path.startsWith(it.normalize().toAbsolutePath()) }) {
return@runCatching ValidationResult.Invalid("Path '$pathString' is not in the allowed list")
}
override fun validateRequest(request: ToolRequest): ValidationResult {
val pathString = request.parameters["path"] as? String
?: 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()
if (allowedPaths.isNotEmpty() && allowedPaths.none { path.startsWith(it.normalize().toAbsolutePath()) })
ValidationResult.Invalid("Path '$pathString' is not in the allowed list")
else
ValidationResult.Valid
}.getOrElse {
when (it) {
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${it.message}")
is IOException -> ValidationResult.Invalid("IO error: ${it.message}")
is SecurityException -> ValidationResult.Invalid("Security error: ${it.message}")
else -> ValidationResult.Invalid(it.message ?: "Unknown error occured")
}
}.getOrElse {
when (it) {
is InvalidPathException -> ValidationResult.Invalid("Invalid path format: ${it.message}")
is IOException -> ValidationResult.Invalid("IO error: ${it.message}")
is SecurityException -> ValidationResult.Invalid("Security error: ${it.message}")
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) {
validateRequest(request).run {
takeIf { it is ValidationResult.Valid }?.let {
val pathString = request.parameters["path"] as String
val path = Paths.get(pathString).normalize().toAbsolutePath()
if (Files.exists(path)) {
readStringCatching(request, path, pathString)
} else {
ToolResult.Failure(
invocationId = request.invocationId,
reason = "File not found: $pathString",
recoverable = false,
)
}
} ?: ToolResult.Failure(
val validation = validateRequest(request)
if (validation is ValidationResult.Invalid)
return@withContext ToolResult.Failure(
invocationId = request.invocationId,
reason = (this as ValidationResult.Invalid).reason,
reason = validation.reason,
recoverable = false,
)
val pathString = request.parameters["path"] as String
val path = Paths.get(pathString).normalize().toAbsolutePath()
val startLine = request.parameters["start_line"]?.toString()?.toIntOrNull()
val endLine = request.parameters["end_line"]?.toString()?.toIntOrNull()
if (!Files.exists(path))
return@withContext ToolResult.Failure(
invocationId = request.invocationId,
reason = "File not found: $pathString",
recoverable = false,
)
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(
invocationId = request.invocationId,
output = content,
)
}.getOrElse {
ToolResult.Failure(
invocationId = request.invocationId,
reason = "Failed to read file: ${it.message}",
recoverable = false,
)
}
}
private fun readStringCatching(request: ToolRequest, path: Path, pathString: String): ToolResult =
runCatching {
ToolResult.Success(
invocationId = request.invocationId,
output = Files.readString(path),
)
}.getOrElse {
when (it) {
is CancellationException -> throw it
is SecurityException -> ToolResult.Failure(
invocationId = request.invocationId,
reason = "Access denied: $pathString, ${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,
)
}
}
}
@@ -12,8 +12,13 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.nio.file.Path
class ShellTool(
@@ -23,18 +28,36 @@ class ShellTool(
) : Tool, ToolExecutor {
override val name: String = "shell"
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 requiredCapabilities: Set<ToolCapability> =
setOf(ToolCapability.SHELL_EXEC, ToolCapability.PROCESS_SPAWN)
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 {
argv.isNullOrEmpty() ->
argv.isEmpty() ->
ValidationResult.Invalid("Missing or empty 'argv' parameter. Expected List<String>.")
allowedExecutables.isNotEmpty() && argv[0] !in allowedExecutables ->
ValidationResult.Invalid("Executable '${argv[0]}' is not in the allowed list.")
else -> ValidationResult.Valid
}
}
@@ -42,7 +65,13 @@ class ShellTool(
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
validateRequest(request).run {
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 {
workingDir?.let { directory(it.toFile()) }
}.start()
@@ -37,7 +37,6 @@ class EventsTest {
sessionId = SessionId("s1"),
terminalStageId = StageId("stage-a"),
totalStages = 1,
workflowId = workflowId,
)
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.stores.EventStore
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.ProviderId
import com.correx.core.events.types.SessionId
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.InferenceRequest
import com.correx.core.inference.InferenceResponse
import com.correx.core.inference.InferenceRouter
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.router.ChatMode
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.TurnRole
import com.correx.core.router.model.WorkflowStatus
import com.correx.testing.fixtures.inference.MockTokenizer
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@@ -405,25 +413,25 @@ class RouterFacadeTest {
requiredCapabilities: Set<ModelCapability>,
): 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 tokenizer = com.correx.testing.fixtures.inference.MockTokenizer()
override val tokenizer = MockTokenizer()
override suspend fun infer(request: InferenceRequest): InferenceResponse {
assertEquals(1, capturedContextPacks.size)
assertEquals(capturedContextPacks[0].id, request.contextPack.id)
return InferenceResponse(
requestId = request.requestId,
text = "response",
finishReason = com.correx.core.inference.FinishReason.Stop,
finishReason = FinishReason.Stop,
tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5),
latencyMs = 0,
)
}
override suspend fun healthCheck(): com.correx.core.inference.ProviderHealth =
com.correx.core.inference.ProviderHealth.Healthy
override suspend fun healthCheck(): ProviderHealth =
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!")
val req = capturedRequests[0]
assertTrue(req.responseFormat is com.correx.core.inference.ResponseFormat.Text)
assertTrue(req.responseFormat is ResponseFormat.Text)
}
@Test
@@ -512,22 +520,22 @@ class RouterFacadeTest {
private fun mockProvider(responseText: String): InferenceProvider =
object : InferenceProvider {
override val id = com.correx.core.events.types.ProviderId("mock")
override val id = ProviderId("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(
requestId = request.requestId,
text = responseText,
finishReason = com.correx.core.inference.FinishReason.Stop,
finishReason = FinishReason.Stop,
tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5),
latencyMs = 0,
)
override suspend fun healthCheck(): com.correx.core.inference.ProviderHealth =
com.correx.core.inference.ProviderHealth.Healthy
override suspend fun healthCheck(): ProviderHealth =
ProviderHealth.Healthy
override fun capabilities(): Set<com.correx.core.inference.CapabilityScore> =
setOf(com.correx.core.inference.CapabilityScore(ModelCapability.General, 1.0))
override fun capabilities(): Set<CapabilityScore> =
setOf(CapabilityScore(ModelCapability.General, 1.0))
}
private fun mockProviderWithCapture(
@@ -535,24 +543,24 @@ class RouterFacadeTest {
requestIds: MutableList<InferenceRequestId>,
): InferenceProvider =
object : InferenceProvider {
override val id = com.correx.core.events.types.ProviderId("mock")
override val id = ProviderId("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 {
requestIds.add(request.requestId)
return InferenceResponse(
requestId = request.requestId,
text = responseText,
finishReason = com.correx.core.inference.FinishReason.Stop,
finishReason = FinishReason.Stop,
tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5),
latencyMs = 0,
)
}
override suspend fun healthCheck(): com.correx.core.inference.ProviderHealth =
com.correx.core.inference.ProviderHealth.Healthy
override suspend fun healthCheck(): ProviderHealth =
ProviderHealth.Healthy
override fun capabilities(): Set<com.correx.core.inference.CapabilityScore> = emptySet()
override fun capabilities(): Set<CapabilityScore> = emptySet()
}
private fun mockProviderWithRequestCapture(
@@ -560,24 +568,24 @@ class RouterFacadeTest {
requests: MutableList<InferenceRequest>,
): InferenceProvider =
object : InferenceProvider {
override val id = com.correx.core.events.types.ProviderId("mock")
override val id = ProviderId("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 {
requests.add(request)
return InferenceResponse(
requestId = request.requestId,
text = responseText,
finishReason = com.correx.core.inference.FinishReason.Stop,
finishReason = FinishReason.Stop,
tokensUsed = TokenUsage(promptTokens = 10, completionTokens = 5),
latencyMs = 0,
)
}
override suspend fun healthCheck(): com.correx.core.inference.ProviderHealth =
com.correx.core.inference.ProviderHealth.Healthy
override suspend fun healthCheck(): ProviderHealth =
ProviderHealth.Healthy
override fun capabilities(): Set<com.correx.core.inference.CapabilityScore> = emptySet()
override fun capabilities(): Set<CapabilityScore> = emptySet()
}
private fun emptyContextPack(): ContextPack = ContextPack(
@@ -591,7 +599,7 @@ class RouterFacadeTest {
private class MapBackedEventStore : EventStore {
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
override suspend fun append(event: NewEvent): StoredEvent {
@@ -620,10 +628,12 @@ class RouterFacadeTest {
override fun lastSequence(sessionId: SessionId): Long? =
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")
override fun allEvents(): Sequence<StoredEvent> =
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(
started,
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 3, workflowId)),
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 3)),
)
assertEquals(WorkflowStatus.COMPLETED, updated.workflowStatus)
assertNull(updated.currentStageId)
@@ -176,7 +176,7 @@ class RouterProjectorTest {
stored(payload = OrchestrationResumedEvent(sessionId, StageId("a"))),
stored(payload = StageCompletedEvent(sessionId, StageId("b"), StageId("t2"))),
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
@@ -206,7 +206,7 @@ class RouterProjectorTest {
state = projector.apply(
state,
stored(payload = WorkflowCompletedEvent(sessionId, StageId("stage-A"), 1, workflowId)),
stored(payload = WorkflowCompletedEvent(sessionId, StageId("stage-A"), 1)),
)
assertEquals(WorkflowStatus.COMPLETED, state.workflowStatus)
assertNull(state.currentStageId)
@@ -58,7 +58,7 @@ class RouterReducerTest {
)
val completed = reducer.reduce(
started,
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 3, workflowId)),
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 3)),
)
assertEquals(WorkflowStatus.COMPLETED, completed.workflowStatus)
assertNull(completed.currentStageId)
@@ -233,7 +233,7 @@ class RouterReducerTest {
assertEquals(StageOutcomeKind.SUCCESS, s2.l2Memory[0].outcome)
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)
assertNull(s3.currentStageId)
@@ -81,7 +81,7 @@ class OrchestrationProjectorTest {
failureReason = "Something went wrong",
),
),
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1, workflowId)),
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1)),
)
val result1 = events.fold(initialState) { state, event ->
@@ -101,7 +101,7 @@ class OrchestrationProjectorTest {
val s2 =
projector.apply(s1, stored(payload = OrchestrationPausedEvent(sessionId, stageId, "approval required")))
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.PAUSED, s2.status)
@@ -21,8 +21,8 @@ class OrchestrationReducerTest {
private val reducer = DefaultOrchestrationReducer()
private val sessionId = SessionId("s1")
private val stageId = StageId("stage-1")
private val state = OrchestrationState(stageId)
private val workflowId = "workflow-test"
private val state = OrchestrationState(workflowId, stageId)
@Test
fun `WorkflowStartedEvent sets status to RUNNING`() {
@@ -58,7 +58,7 @@ class OrchestrationReducerTest {
val completed = reducer.reduce(
started,
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1, workflowId)),
stored(payload = WorkflowCompletedEvent(sessionId, stageId, 1)),
)
assertTrue(completed.failureReason.isNullOrBlank())
assertEquals(OrchestrationStatus.COMPLETED, completed.status)
@@ -49,7 +49,7 @@ class RouterProjectorTest {
val started =
projector.apply(initialState, stored(payload = WorkflowStartedEvent(sessionId, workflowId, stageId)))
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)
assertNull(completed.currentStageId)
}
@@ -134,7 +134,7 @@ class RouterProjectorTest {
stored(payload = StageCompletedEvent(sessionId, StageId("stage-2"), StageId("t1"))),
stored(payload = SteeringNoteAddedEvent(sessionId, "note")),
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 ->
@@ -155,7 +155,7 @@ class RouterProjectorTest {
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 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(StageId("stage-2"), s2.l2Memory[0].stageId)