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:
@@ -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)
|
||||||
|
|||||||
+50
-8
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -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,
|
||||||
|
|||||||
+2
-2
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-2
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -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(
|
||||||
|
|||||||
+2
@@ -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()
|
||||||
|
|||||||
+21
-12
@@ -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()
|
||||||
|
|||||||
+33
-1
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+61
-42
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-4
@@ -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()
|
||||||
|
|||||||
-1
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user