refactor(approval): wire ApprovalCoordinator end-to-end
ApprovalCoordinator (added in epic-13) was never instantiated. Its four capabilities — multi-client fan-out, single-resolver dedupe, request timeout, and a unified domain mapper — were duplicated piecemeal across GlobalStreamHandler and SessionStreamHandler, with a STEER-outcome divergence between them (coordinator mapped STEER → APPROVED while GlobalStreamHandler mapped STEER → REJECTED). This refactor wires the coordinator and removes the duplication. Protocol: drop ApprovalDecision.STEER. STEER is no longer a discriminator; it's expressed as (APPROVE | REJECT) + non-null steeringNote, so the user can steer alongside either choice. TUI's current single-keybind STEER input flow now sends APPROVE + note (matching the original epic-13 intent); a future overlay UX will let the user pick APPROVE or REJECT explicitly. Mapper: ClientMessage.ApprovalResponse → DomainApprovalDecision lives in a single shared `toDomain` helper (apps/server/approval/ ApprovalResponseMapper.kt). Both stream handlers and the coordinator use it. Config: delete the duplicate apps/server/approval/ApprovalConfig.kt; ApprovalCoordinator now takes core.config.ApprovalConfig (already had timeoutMs=300_000L). ConfigLoader already populates it. Wiring: - ServerModule owns a SupervisorJob-backed moduleScope, constructs ApprovalCoordinator, and on start() subscribes eventStore.subscribeAll() to route ApprovalRequestedEvent payloads to onApprovalRequested. - GlobalStreamHandler delegates ApprovalResponse to approvalCoordinator.handleResponse and registers/unregisters via the new registerGlobalClient/unregisterGlobalClient (global sockets aren't bound to one SessionId, so broadcasts union session and global sets). - SessionStreamHandler delegates ApprovalResponse and registers per-session via registerClient/unregisterClient. - Timeout is on by default at 5 minutes via config.timeoutMs. Tests: ApprovalCoordinatorWiringTest covers delegate path, dedupe producing ProtocolError, and event-stream subscription routing.
This commit is contained in:
@@ -131,7 +131,9 @@ fun main() {
|
|||||||
routerFacade = routerFacade,
|
routerFacade = routerFacade,
|
||||||
orchestrationRepository = repositories.orchestrationRepository,
|
orchestrationRepository = repositories.orchestrationRepository,
|
||||||
approvalRepository = repositories.approvalRepository,
|
approvalRepository = repositories.approvalRepository,
|
||||||
|
approvalConfig = correxConfig.approval,
|
||||||
)
|
)
|
||||||
|
module.start()
|
||||||
log.info("==============================")
|
log.info("==============================")
|
||||||
|
|
||||||
embeddedServer(Netty, port = 8080) { configureServer(module) }.start(wait = true)
|
embeddedServer(Netty, port = 8080) { configureServer(module) }.start(wait = true)
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
package com.correx.apps.server
|
package com.correx.apps.server
|
||||||
|
|
||||||
|
import com.correx.apps.server.approval.ApprovalCoordinator
|
||||||
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.approvals.DefaultApprovalRepository
|
||||||
import com.correx.core.artifactstore.ArtifactStore
|
import com.correx.core.artifactstore.ArtifactStore
|
||||||
|
import com.correx.core.config.ApprovalConfig
|
||||||
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
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.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
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
|
||||||
data class ServerModule(
|
class ServerModule(
|
||||||
val orchestrator: DefaultSessionOrchestrator,
|
val orchestrator: DefaultSessionOrchestrator,
|
||||||
val eventStore: EventStore,
|
val eventStore: EventStore,
|
||||||
val artifactStore: ArtifactStore,
|
val artifactStore: ArtifactStore,
|
||||||
@@ -22,4 +32,30 @@ data class ServerModule(
|
|||||||
val routerFacade: RouterFacade,
|
val routerFacade: RouterFacade,
|
||||||
val orchestrationRepository: OrchestrationRepository,
|
val orchestrationRepository: OrchestrationRepository,
|
||||||
val approvalRepository: DefaultApprovalRepository,
|
val approvalRepository: DefaultApprovalRepository,
|
||||||
|
approvalConfig: ApprovalConfig = ApprovalConfig(),
|
||||||
|
// Long-lived scope owned by the module — backs the ApprovalCoordinator timers
|
||||||
|
// and event-store subscription. SupervisorJob so one failure doesn't kill the
|
||||||
|
// whole module; Dispatchers.Default since the work is non-blocking and CPU-light.
|
||||||
|
val moduleScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||||
|
approvalCoordinator: ApprovalCoordinator? = null,
|
||||||
|
) {
|
||||||
|
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
|
||||||
|
orchestrator = orchestrator,
|
||||||
|
config = approvalConfig,
|
||||||
|
scope = moduleScope,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private var subscriptionJob: Job? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to the event store and forward [ApprovalRequestedEvent] payloads to
|
||||||
|
* the [ApprovalCoordinator]. Idempotent: calling twice is a no-op for the second call.
|
||||||
|
*/
|
||||||
|
fun start() {
|
||||||
|
if (subscriptionJob != null) return
|
||||||
|
subscriptionJob = eventStore.subscribeAll()
|
||||||
|
.filter { it.payload is ApprovalRequestedEvent }
|
||||||
|
.onEach { approvalCoordinator.onApprovalRequested(it.payload as ApprovalRequestedEvent) }
|
||||||
|
.launchIn(moduleScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
package com.correx.apps.server.approval
|
|
||||||
|
|
||||||
data class ApprovalConfig(
|
|
||||||
val timeoutMs: Long = 300_000L,
|
|
||||||
)
|
|
||||||
+18
-38
@@ -1,6 +1,5 @@
|
|||||||
package com.correx.apps.server.approval
|
package com.correx.apps.server.approval
|
||||||
|
|
||||||
import com.correx.apps.server.protocol.ApprovalDecision
|
|
||||||
import com.correx.apps.server.protocol.ClientMessage
|
import com.correx.apps.server.protocol.ClientMessage
|
||||||
import com.correx.apps.server.protocol.ProtocolSerializer
|
import com.correx.apps.server.protocol.ProtocolSerializer
|
||||||
import com.correx.apps.server.protocol.RiskSummaryDto
|
import com.correx.apps.server.protocol.RiskSummaryDto
|
||||||
@@ -8,9 +7,9 @@ import com.correx.apps.server.protocol.ServerMessage
|
|||||||
import com.correx.core.approvals.ApprovalOutcome
|
import com.correx.core.approvals.ApprovalOutcome
|
||||||
import com.correx.core.approvals.ApprovalStatus
|
import com.correx.core.approvals.ApprovalStatus
|
||||||
import com.correx.core.approvals.Tier
|
import com.correx.core.approvals.Tier
|
||||||
import com.correx.core.approvals.UserSteering
|
|
||||||
import com.correx.core.approvals.model.ApprovalContext
|
import com.correx.core.approvals.model.ApprovalContext
|
||||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||||
|
import com.correx.core.config.ApprovalConfig
|
||||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
import com.correx.core.events.types.ApprovalRequestId
|
import com.correx.core.events.types.ApprovalRequestId
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
@@ -24,16 +23,17 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import java.util.concurrent.*
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
|
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
|
||||||
|
|
||||||
class ApprovalCoordinator(
|
open class ApprovalCoordinator(
|
||||||
private val orchestrator: ApprovalGateway,
|
private val orchestrator: ApprovalGateway,
|
||||||
private val config: ApprovalConfig,
|
private val config: ApprovalConfig,
|
||||||
private val scope: CoroutineScope,
|
val scope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
private val sessionClients: ConcurrentHashMap<SessionId, MutableSet<DefaultWebSocketServerSession>> =
|
private val sessionClients: ConcurrentHashMap<SessionId, MutableSet<DefaultWebSocketServerSession>> =
|
||||||
ConcurrentHashMap()
|
ConcurrentHashMap()
|
||||||
|
private val globalClients: MutableSet<DefaultWebSocketServerSession> = ConcurrentHashMap.newKeySet()
|
||||||
private val resolved: ConcurrentHashMap<ApprovalRequestId, Boolean> = ConcurrentHashMap()
|
private val resolved: ConcurrentHashMap<ApprovalRequestId, Boolean> = ConcurrentHashMap()
|
||||||
private val timeoutJobs: ConcurrentHashMap<ApprovalRequestId, Job> = ConcurrentHashMap()
|
private val timeoutJobs: ConcurrentHashMap<ApprovalRequestId, Job> = ConcurrentHashMap()
|
||||||
|
|
||||||
@@ -45,6 +45,14 @@ class ApprovalCoordinator(
|
|||||||
sessionClients[sessionId]?.remove(session)
|
sessionClients[sessionId]?.remove(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun registerGlobalClient(session: DefaultWebSocketServerSession) {
|
||||||
|
globalClients.add(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregisterGlobalClient(session: DefaultWebSocketServerSession) {
|
||||||
|
globalClients.remove(session)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun onApprovalRequested(event: ApprovalRequestedEvent) {
|
suspend fun onApprovalRequested(event: ApprovalRequestedEvent) {
|
||||||
val msg = ServerMessage.ApprovalRequired(
|
val msg = ServerMessage.ApprovalRequired(
|
||||||
sessionId = event.sessionId,
|
sessionId = event.sessionId,
|
||||||
@@ -64,7 +72,7 @@ class ApprovalCoordinator(
|
|||||||
scheduleTimeout(event.requestId, event.sessionId, event.stageId, event.tier)
|
scheduleTimeout(event.requestId, event.sessionId, event.stageId, event.tier)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleResponse(msg: ClientMessage.ApprovalResponse, sessionId: SessionId): ServerMessage? {
|
open fun handleResponse(msg: ClientMessage.ApprovalResponse, sessionId: SessionId): ServerMessage? {
|
||||||
if (resolved.putIfAbsent(msg.requestId, true) != null) {
|
if (resolved.putIfAbsent(msg.requestId, true) != null) {
|
||||||
return ServerMessage.ProtocolError(
|
return ServerMessage.ProtocolError(
|
||||||
message = "Approval request ${msg.requestId.value} already resolved",
|
message = "Approval request ${msg.requestId.value} already resolved",
|
||||||
@@ -73,7 +81,7 @@ class ApprovalCoordinator(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
timeoutJobs.remove(msg.requestId)?.cancel()
|
timeoutJobs.remove(msg.requestId)?.cancel()
|
||||||
val domain = msg.toDomainDecision(sessionId, null, Tier.T2)
|
val domain = msg.toDomain(sessionId, null, Tier.T2)
|
||||||
return runCatching { orchestrator.submitApprovalDecision(msg.requestId, domain) }
|
return runCatching { orchestrator.submitApprovalDecision(msg.requestId, domain) }
|
||||||
.fold(
|
.fold(
|
||||||
onSuccess = { null },
|
onSuccess = { null },
|
||||||
@@ -89,7 +97,9 @@ class ApprovalCoordinator(
|
|||||||
|
|
||||||
private suspend fun broadcast(sessionId: SessionId, msg: ServerMessage) {
|
private suspend fun broadcast(sessionId: SessionId, msg: ServerMessage) {
|
||||||
val encoded = ProtocolSerializer.encodeServerMessage(msg)
|
val encoded = ProtocolSerializer.encodeServerMessage(msg)
|
||||||
sessionClients[sessionId]?.forEach { client ->
|
val sessionSubs = sessionClients[sessionId].orEmpty()
|
||||||
|
val recipients: Set<DefaultWebSocketServerSession> = sessionSubs + globalClients
|
||||||
|
recipients.forEach { client ->
|
||||||
runCatching { client.send(Frame.Text(encoded)) }
|
runCatching { client.send(Frame.Text(encoded)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,34 +129,4 @@ class ApprovalCoordinator(
|
|||||||
}
|
}
|
||||||
timeoutJobs[requestId] = job
|
timeoutJobs[requestId] = job
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ClientMessage.ApprovalResponse.toDomainDecision(
|
|
||||||
sessionId: SessionId,
|
|
||||||
stageId: StageId?,
|
|
||||||
tier: Tier,
|
|
||||||
): DomainApprovalDecision {
|
|
||||||
val outcome = when (decision) {
|
|
||||||
ApprovalDecision.APPROVE -> ApprovalOutcome.APPROVED
|
|
||||||
ApprovalDecision.REJECT -> ApprovalOutcome.REJECTED
|
|
||||||
ApprovalDecision.STEER -> ApprovalOutcome.APPROVED
|
|
||||||
}
|
|
||||||
val identity = ApprovalScopeIdentity(sessionId = sessionId, stageId = stageId, projectId = null)
|
|
||||||
val context = ApprovalContext(identity = identity, mode = ApprovalMode.PROMPT)
|
|
||||||
val steering = if (decision == ApprovalDecision.STEER) {
|
|
||||||
steeringNote?.let { UserSteering(text = it, sessionId = sessionId, timestamp = Clock.System.now()) }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
return DomainApprovalDecision(
|
|
||||||
id = null,
|
|
||||||
requestId = requestId,
|
|
||||||
outcome = outcome,
|
|
||||||
state = ApprovalStatus.COMPLETED,
|
|
||||||
tier = tier,
|
|
||||||
contextSnapshot = context,
|
|
||||||
resolutionTimestamp = Clock.System.now(),
|
|
||||||
reason = steeringNote,
|
|
||||||
userSteering = steering,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.correx.apps.server.approval
|
||||||
|
|
||||||
|
import com.correx.apps.server.protocol.ApprovalDecision
|
||||||
|
import com.correx.apps.server.protocol.ClientMessage
|
||||||
|
import com.correx.core.approvals.ApprovalOutcome
|
||||||
|
import com.correx.core.approvals.ApprovalStatus
|
||||||
|
import com.correx.core.approvals.Tier
|
||||||
|
import com.correx.core.approvals.UserSteering
|
||||||
|
import com.correx.core.approvals.model.ApprovalContext
|
||||||
|
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import com.correx.core.sessions.ApprovalMode
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates a wire-level [ClientMessage.ApprovalResponse] into a domain [DomainApprovalDecision].
|
||||||
|
*
|
||||||
|
* Outcome is driven purely by [ClientMessage.ApprovalResponse.decision]:
|
||||||
|
* APPROVE -> APPROVED, REJECT -> REJECTED.
|
||||||
|
*
|
||||||
|
* A non-null [ClientMessage.ApprovalResponse.steeringNote] attaches a [UserSteering] regardless of
|
||||||
|
* the approve/reject decision — the user may steer alongside either choice.
|
||||||
|
*/
|
||||||
|
internal fun ClientMessage.ApprovalResponse.toDomain(
|
||||||
|
sessionId: SessionId,
|
||||||
|
stageId: StageId?,
|
||||||
|
tier: Tier,
|
||||||
|
): DomainApprovalDecision {
|
||||||
|
val outcome = when (decision) {
|
||||||
|
ApprovalDecision.APPROVE -> ApprovalOutcome.APPROVED
|
||||||
|
ApprovalDecision.REJECT -> ApprovalOutcome.REJECTED
|
||||||
|
}
|
||||||
|
val identity = ApprovalScopeIdentity(sessionId = sessionId, stageId = stageId, projectId = null)
|
||||||
|
val context = ApprovalContext(identity = identity, mode = ApprovalMode.PROMPT)
|
||||||
|
val steering = steeringNote?.let {
|
||||||
|
UserSteering(text = it, sessionId = sessionId, timestamp = Clock.System.now())
|
||||||
|
}
|
||||||
|
return DomainApprovalDecision(
|
||||||
|
id = null,
|
||||||
|
requestId = requestId,
|
||||||
|
outcome = outcome,
|
||||||
|
state = ApprovalStatus.COMPLETED,
|
||||||
|
tier = tier,
|
||||||
|
contextSnapshot = context,
|
||||||
|
resolutionTimestamp = Clock.System.now(),
|
||||||
|
reason = steeringNote,
|
||||||
|
userSteering = steering,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import kotlinx.serialization.Serializable
|
|||||||
enum class ApprovalDecision {
|
enum class ApprovalDecision {
|
||||||
APPROVE,
|
APPROVE,
|
||||||
REJECT,
|
REJECT,
|
||||||
STEER,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -3,17 +3,10 @@ package com.correx.apps.server.ws
|
|||||||
import com.correx.apps.server.ServerModule
|
import com.correx.apps.server.ServerModule
|
||||||
import com.correx.apps.server.bridge.DomainEventMapper
|
import com.correx.apps.server.bridge.DomainEventMapper
|
||||||
import com.correx.apps.server.bridge.SessionEventBridge
|
import com.correx.apps.server.bridge.SessionEventBridge
|
||||||
import com.correx.apps.server.protocol.ApprovalDecision
|
|
||||||
import com.correx.apps.server.protocol.ClientMessage
|
import com.correx.apps.server.protocol.ClientMessage
|
||||||
import com.correx.apps.server.protocol.ProtocolSerializer
|
import com.correx.apps.server.protocol.ProtocolSerializer
|
||||||
import com.correx.apps.server.protocol.ProviderHealthDto
|
import com.correx.apps.server.protocol.ProviderHealthDto
|
||||||
import com.correx.apps.server.protocol.ServerMessage
|
import com.correx.apps.server.protocol.ServerMessage
|
||||||
import com.correx.core.approvals.ApprovalOutcome
|
|
||||||
import com.correx.core.approvals.ApprovalStatus
|
|
||||||
import com.correx.core.approvals.Tier
|
|
||||||
import com.correx.core.approvals.UserSteering
|
|
||||||
import com.correx.core.approvals.model.ApprovalContext
|
|
||||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
|
||||||
import com.correx.core.events.events.EventMetadata
|
import com.correx.core.events.events.EventMetadata
|
||||||
import com.correx.core.events.events.NewEvent
|
import com.correx.core.events.events.NewEvent
|
||||||
import com.correx.core.events.events.StoredEvent
|
import com.correx.core.events.events.StoredEvent
|
||||||
@@ -22,9 +15,7 @@ import com.correx.core.events.stores.EventStore
|
|||||||
import com.correx.core.events.types.EventId
|
import com.correx.core.events.types.EventId
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.inference.ProviderHealth
|
import com.correx.core.inference.ProviderHealth
|
||||||
import com.correx.core.sessions.ApprovalMode
|
|
||||||
import com.correx.core.utils.TypeId
|
import com.correx.core.utils.TypeId
|
||||||
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
|
|
||||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||||
import io.ktor.websocket.Frame
|
import io.ktor.websocket.Frame
|
||||||
import io.ktor.websocket.readText
|
import io.ktor.websocket.readText
|
||||||
@@ -62,6 +53,8 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
)
|
)
|
||||||
val mapper = DomainEventMapper(module.artifactStore)
|
val mapper = DomainEventMapper(module.artifactStore)
|
||||||
|
|
||||||
|
module.approvalCoordinator.registerGlobalClient(session)
|
||||||
|
|
||||||
sendInitialSnapshot(session)
|
sendInitialSnapshot(session)
|
||||||
|
|
||||||
// Launch global stream (snapshot + live-forward) concurrently with incoming frame loop
|
// Launch global stream (snapshot + live-forward) concurrently with incoming frame loop
|
||||||
@@ -95,6 +88,8 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
}
|
}
|
||||||
} catch (_: ClosedReceiveChannelException) {
|
} catch (_: ClosedReceiveChannelException) {
|
||||||
log.info("client disconnected")
|
log.info("client disconnected")
|
||||||
|
} finally {
|
||||||
|
module.approvalCoordinator.unregisterGlobalClient(session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,46 +125,17 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
|||||||
is ClientMessage.StartSession -> handleStartSession(session, msg, sendFrame)
|
is ClientMessage.StartSession -> handleStartSession(session, msg, sendFrame)
|
||||||
is ClientMessage.CancelSession -> module.orchestrator.cancel(msg.sessionId)
|
is ClientMessage.CancelSession -> module.orchestrator.cancel(msg.sessionId)
|
||||||
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
|
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
|
||||||
is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg)
|
is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame)
|
||||||
is ClientMessage.ChatInput -> Unit // handler deferred to subsequent task
|
is ClientMessage.ChatInput -> Unit // handler deferred to subsequent task
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleApprovalResponse(msg: ClientMessage.ApprovalResponse) {
|
private suspend fun handleApprovalResponse(
|
||||||
val domainDecision = msg.toDomainDecision(msg.requestId.value)
|
msg: ClientMessage.ApprovalResponse,
|
||||||
runCatching { module.orchestrator.submitApprovalDecision(msg.requestId, domainDecision) }
|
sendFrame: suspend (ServerMessage) -> Unit,
|
||||||
.onFailure {
|
) {
|
||||||
log.warn(
|
val scopeSessionId: SessionId = TypeId(msg.requestId.value)
|
||||||
"submitApprovalDecision failed for request={}: {}",
|
module.approvalCoordinator.handleResponse(msg, scopeSessionId)?.let { sendFrame(it) }
|
||||||
msg.requestId.value,
|
|
||||||
it.message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ClientMessage.ApprovalResponse.toDomainDecision(sessionIdValue: String): DomainApprovalDecision {
|
|
||||||
val outcome = when (decision) {
|
|
||||||
ApprovalDecision.APPROVE -> ApprovalOutcome.APPROVED
|
|
||||||
ApprovalDecision.REJECT -> ApprovalOutcome.REJECTED
|
|
||||||
ApprovalDecision.STEER -> ApprovalOutcome.REJECTED
|
|
||||||
}
|
|
||||||
val scopeSessionId: SessionId = TypeId(sessionIdValue)
|
|
||||||
val identity = ApprovalScopeIdentity(sessionId = scopeSessionId, stageId = null, projectId = null)
|
|
||||||
val context = ApprovalContext(identity = identity, mode = ApprovalMode.PROMPT)
|
|
||||||
val steering = steeringNote?.let {
|
|
||||||
UserSteering(text = it, sessionId = scopeSessionId, timestamp = Clock.System.now())
|
|
||||||
}
|
|
||||||
return DomainApprovalDecision(
|
|
||||||
id = null,
|
|
||||||
requestId = requestId,
|
|
||||||
outcome = outcome,
|
|
||||||
state = ApprovalStatus.COMPLETED,
|
|
||||||
tier = Tier.T2,
|
|
||||||
contextSnapshot = context,
|
|
||||||
resolutionTimestamp = Clock.System.now(),
|
|
||||||
reason = steeringNote,
|
|
||||||
userSteering = steering,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun encodeError(message: String): Frame.Text =
|
private fun encodeError(message: String): Frame.Text =
|
||||||
|
|||||||
@@ -2,27 +2,16 @@ package com.correx.apps.server.ws
|
|||||||
|
|
||||||
import com.correx.apps.server.ServerModule
|
import com.correx.apps.server.ServerModule
|
||||||
import com.correx.apps.server.bridge.DomainEventMapper
|
import com.correx.apps.server.bridge.DomainEventMapper
|
||||||
import com.correx.apps.server.protocol.ApprovalDecision
|
|
||||||
import com.correx.apps.server.protocol.ClientMessage
|
import com.correx.apps.server.protocol.ClientMessage
|
||||||
import com.correx.apps.server.protocol.ProtocolSerializer
|
import com.correx.apps.server.protocol.ProtocolSerializer
|
||||||
import com.correx.apps.server.protocol.ServerMessage
|
import com.correx.apps.server.protocol.ServerMessage
|
||||||
import com.correx.apps.server.protocol.ServerMessage.RouterResponseMessage
|
import com.correx.apps.server.protocol.ServerMessage.RouterResponseMessage
|
||||||
import com.correx.core.approvals.ApprovalOutcome
|
|
||||||
import com.correx.core.approvals.ApprovalStatus
|
|
||||||
import com.correx.core.approvals.Tier
|
|
||||||
import com.correx.core.approvals.UserSteering
|
|
||||||
import com.correx.core.approvals.model.ApprovalContext
|
|
||||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.sessions.ApprovalMode
|
|
||||||
import com.correx.core.utils.TypeId
|
|
||||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||||
import io.ktor.websocket.Frame
|
import io.ktor.websocket.Frame
|
||||||
import io.ktor.websocket.readText
|
import io.ktor.websocket.readText
|
||||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
|
|
||||||
|
|
||||||
private val log = LoggerFactory.getLogger(SessionStreamHandler::class.java)
|
private val log = LoggerFactory.getLogger(SessionStreamHandler::class.java)
|
||||||
|
|
||||||
@@ -46,6 +35,7 @@ class SessionStreamHandler(private val module: ServerModule) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.approvalCoordinator.registerClient(sessionId, session)
|
||||||
try {
|
try {
|
||||||
for (frame in session.incoming) {
|
for (frame in session.incoming) {
|
||||||
if (frame is Frame.Text) {
|
if (frame is Frame.Text) {
|
||||||
@@ -53,7 +43,7 @@ class SessionStreamHandler(private val module: ServerModule) {
|
|||||||
runCatching { ProtocolSerializer.decodeClientMessage(text) }
|
runCatching { ProtocolSerializer.decodeClientMessage(text) }
|
||||||
.onSuccess { msg ->
|
.onSuccess { msg ->
|
||||||
log.debug("recv: {}", msg::class.simpleName)
|
log.debug("recv: {}", msg::class.simpleName)
|
||||||
handleClientMessage(session, msg)
|
handleClientMessage(session, sessionId, msg)
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure {
|
||||||
log.warn("decode error: {}", it.message)
|
log.warn("decode error: {}", it.message)
|
||||||
@@ -64,10 +54,16 @@ class SessionStreamHandler(private val module: ServerModule) {
|
|||||||
}
|
}
|
||||||
} catch (_: ClosedReceiveChannelException) {
|
} catch (_: ClosedReceiveChannelException) {
|
||||||
log.info("client disconnected session={}", sessionId.value)
|
log.info("client disconnected session={}", sessionId.value)
|
||||||
|
} finally {
|
||||||
|
module.approvalCoordinator.unregisterClient(sessionId, session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleClientMessage(session: DefaultWebSocketServerSession, msg: ClientMessage) {
|
private suspend fun handleClientMessage(
|
||||||
|
session: DefaultWebSocketServerSession,
|
||||||
|
sessionId: SessionId,
|
||||||
|
msg: ClientMessage,
|
||||||
|
) {
|
||||||
when (msg) {
|
when (msg) {
|
||||||
is ClientMessage.Ping -> Unit
|
is ClientMessage.Ping -> Unit
|
||||||
is ClientMessage.CancelSession -> {
|
is ClientMessage.CancelSession -> {
|
||||||
@@ -76,14 +72,8 @@ class SessionStreamHandler(private val module: ServerModule) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
is ClientMessage.ApprovalResponse -> {
|
is ClientMessage.ApprovalResponse -> {
|
||||||
val domainDecision = msg.toDomainDecision(msg.requestId.value)
|
module.approvalCoordinator.handleResponse(msg, sessionId)?.let { reply ->
|
||||||
runCatching { module.orchestrator.submitApprovalDecision(msg.requestId, domainDecision) }
|
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(reply)))
|
||||||
.onFailure {
|
|
||||||
log.warn(
|
|
||||||
"submitApprovalDecision failed for request={}: {}",
|
|
||||||
msg.requestId.value,
|
|
||||||
it.message,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,28 +111,4 @@ class SessionStreamHandler(private val module: ServerModule) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ClientMessage.ApprovalResponse.toDomainDecision(sessionIdValue: String): DomainApprovalDecision {
|
|
||||||
val outcome = when (decision) {
|
|
||||||
ApprovalDecision.APPROVE -> ApprovalOutcome.APPROVED
|
|
||||||
ApprovalDecision.REJECT -> ApprovalOutcome.REJECTED
|
|
||||||
ApprovalDecision.STEER -> ApprovalOutcome.REJECTED
|
|
||||||
}
|
|
||||||
val scopeSessionId: SessionId = TypeId(sessionIdValue)
|
|
||||||
val identity = ApprovalScopeIdentity(sessionId = scopeSessionId, stageId = null, projectId = null)
|
|
||||||
val context = ApprovalContext(identity = identity, mode = ApprovalMode.PROMPT)
|
|
||||||
val steering = steeringNote?.let {
|
|
||||||
UserSteering(text = it, sessionId = scopeSessionId, timestamp = Clock.System.now())
|
|
||||||
}
|
|
||||||
return DomainApprovalDecision(
|
|
||||||
id = null,
|
|
||||||
requestId = requestId,
|
|
||||||
outcome = outcome,
|
|
||||||
state = ApprovalStatus.COMPLETED,
|
|
||||||
tier = Tier.T2,
|
|
||||||
contextSnapshot = context,
|
|
||||||
resolutionTimestamp = Clock.System.now(),
|
|
||||||
reason = steeringNote,
|
|
||||||
userSteering = steering,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
package com.correx.apps.server.approval
|
||||||
|
|
||||||
|
import com.correx.apps.server.protocol.ApprovalDecision
|
||||||
|
import com.correx.apps.server.protocol.ClientMessage
|
||||||
|
import com.correx.apps.server.protocol.ServerMessage
|
||||||
|
import com.correx.core.approvals.Tier
|
||||||
|
import com.correx.core.config.ApprovalConfig
|
||||||
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
|
import com.correx.core.events.events.EventMetadata
|
||||||
|
import com.correx.core.events.events.EventPayload
|
||||||
|
import com.correx.core.events.events.NewEvent
|
||||||
|
import com.correx.core.events.events.StoredEvent
|
||||||
|
import com.correx.core.events.events.WorkflowStartedEvent
|
||||||
|
import com.correx.core.events.stores.EventStore
|
||||||
|
import com.correx.core.events.types.ApprovalRequestId
|
||||||
|
import com.correx.core.events.types.EventId
|
||||||
|
import com.correx.core.events.types.RiskSummaryId
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import com.correx.core.events.types.ValidationReportId
|
||||||
|
import com.correx.core.kernel.orchestration.ApprovalGateway
|
||||||
|
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
|
class ApprovalCoordinatorWiringTest {
|
||||||
|
|
||||||
|
private val sessionId = SessionId("session-1")
|
||||||
|
private val requestId = ApprovalRequestId("req-1")
|
||||||
|
private val timestamp = Instant.parse("2026-01-01T00:00:00Z")
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RecordingGateway : ApprovalGateway {
|
||||||
|
val submissions = CopyOnWriteArrayList<Pair<ApprovalRequestId, DomainApprovalDecision>>()
|
||||||
|
override fun submitApprovalDecision(requestId: ApprovalRequestId, decision: DomainApprovalDecision) {
|
||||||
|
submissions.add(requestId to decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storedEvent(payload: EventPayload, seq: Long): StoredEvent = StoredEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId("evt-$seq"),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = timestamp,
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
sequence = seq,
|
||||||
|
sessionSequence = seq,
|
||||||
|
payload = payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun fakeEventStore(liveFlow: MutableSharedFlow<StoredEvent>): EventStore = object : EventStore {
|
||||||
|
override suspend fun append(event: NewEvent): StoredEvent = error("unused")
|
||||||
|
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = error("unused")
|
||||||
|
override fun read(sessionId: SessionId): List<StoredEvent> = emptyList()
|
||||||
|
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> = emptyList()
|
||||||
|
override fun lastSequence(sessionId: SessionId): Long = 0L
|
||||||
|
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = liveFlow
|
||||||
|
override fun allEvents(): Sequence<StoredEvent> = emptySequence()
|
||||||
|
override fun allSessionIds(): Set<SessionId> = emptySet()
|
||||||
|
override fun subscribeAll(): Flow<StoredEvent> = liveFlow
|
||||||
|
override suspend fun lastGlobalSequence(): Long = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `handleResponse forwards to gateway and returns null on success`() {
|
||||||
|
val gateway = RecordingGateway()
|
||||||
|
val coord = ApprovalCoordinator(gateway, ApprovalConfig(timeoutMs = 300_000L), scope)
|
||||||
|
val msg = ClientMessage.ApprovalResponse(
|
||||||
|
requestId = requestId,
|
||||||
|
decision = ApprovalDecision.APPROVE,
|
||||||
|
steeringNote = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
val reply = coord.handleResponse(msg, sessionId)
|
||||||
|
|
||||||
|
assertNull(reply)
|
||||||
|
assertEquals(1, gateway.submissions.size)
|
||||||
|
assertEquals(requestId, gateway.submissions[0].first)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `duplicate ApprovalResponse for same requestId returns ProtocolError`() {
|
||||||
|
val gateway = RecordingGateway()
|
||||||
|
val coord = ApprovalCoordinator(gateway, ApprovalConfig(timeoutMs = 300_000L), scope)
|
||||||
|
val msg = ClientMessage.ApprovalResponse(
|
||||||
|
requestId = requestId,
|
||||||
|
decision = ApprovalDecision.APPROVE,
|
||||||
|
steeringNote = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
val first = coord.handleResponse(msg, sessionId)
|
||||||
|
val second = coord.handleResponse(msg, sessionId)
|
||||||
|
|
||||||
|
assertNull(first)
|
||||||
|
assertNotNull(second)
|
||||||
|
assertInstanceOf(ServerMessage.ProtocolError::class.java, second)
|
||||||
|
// Gateway only sees the first submission; dedupe blocks the second.
|
||||||
|
assertEquals(1, gateway.submissions.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proves the ServerModule.start() subscription wiring: an [ApprovalRequestedEvent] flowing
|
||||||
|
* through the event store reaches the coordinator's onApprovalRequested. We verify this by
|
||||||
|
* subscribing the same wiring the module uses and checking the coordinator's behavior
|
||||||
|
* (it schedules a timeout job and dedupes the requestId).
|
||||||
|
*
|
||||||
|
* After the event is observed, a subsequent handleResponse with a matching requestId should
|
||||||
|
* succeed once (the coordinator's internal `resolved` map was NOT pre-populated by the event).
|
||||||
|
* The behavioral assertion: the gateway sees the decision exactly once.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Proves the ServerModule.start() subscription wiring shape: an [ApprovalRequestedEvent]
|
||||||
|
* flowing through the event store reaches the wired-up callback, and non-approval events
|
||||||
|
* are filtered out.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `ApprovalRequestedEvent in store reaches coordinator via subscription`() = runBlocking {
|
||||||
|
val liveFlow = MutableSharedFlow<StoredEvent>(extraBufferCapacity = 16)
|
||||||
|
val store = fakeEventStore(liveFlow)
|
||||||
|
|
||||||
|
val triggered = CompletableDeferred<ApprovalRequestedEvent>()
|
||||||
|
// Mirror the exact wiring used in ServerModule.start().
|
||||||
|
val subscription = store.subscribeAll()
|
||||||
|
.filter { it.payload is ApprovalRequestedEvent }
|
||||||
|
.onEach { triggered.complete(it.payload as ApprovalRequestedEvent) }
|
||||||
|
.launchIn(scope)
|
||||||
|
|
||||||
|
// Wait for subscription to attach so emit isn't dropped by the SharedFlow (replay=0).
|
||||||
|
while (liveFlow.subscriptionCount.value == 0) {
|
||||||
|
yield()
|
||||||
|
}
|
||||||
|
|
||||||
|
val event = ApprovalRequestedEvent(
|
||||||
|
requestId = requestId,
|
||||||
|
tier = Tier.T2,
|
||||||
|
validationReportId = ValidationReportId("vr-1"),
|
||||||
|
riskSummaryId = RiskSummaryId("rs-1"),
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = null,
|
||||||
|
projectId = null,
|
||||||
|
)
|
||||||
|
// Non-approval event must be filtered out.
|
||||||
|
liveFlow.emit(storedEvent(WorkflowStartedEvent(sessionId, "wf", StageId("stage-x")), seq = 1L))
|
||||||
|
liveFlow.emit(storedEvent(event, seq = 2L))
|
||||||
|
|
||||||
|
val received = withTimeout(2_000L) { triggered.await() }
|
||||||
|
assertEquals(requestId, received.requestId)
|
||||||
|
subscription.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ object InputReducer {
|
|||||||
Effect.SendWs(
|
Effect.SendWs(
|
||||||
ClientMessage.ApprovalResponse(
|
ClientMessage.ApprovalResponse(
|
||||||
requestId = ApprovalRequestId(active.requestId),
|
requestId = ApprovalRequestId(active.requestId),
|
||||||
decision = ApprovalDecision.STEER,
|
decision = ApprovalDecision.APPROVE,
|
||||||
steeringNote = text,
|
steeringNote = text,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ class InputReducerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `SubmitInput in STEER mode with active approval emits ApprovalResponse STEER`() {
|
fun `SubmitInput in STEER mode with active approval emits ApprovalResponse APPROVE with steering note`() {
|
||||||
val (state, effects) = reduce(
|
val (state, effects) = reduce(
|
||||||
state = stateWithActiveApproval(inputMode = InputMode.STEER, inputBuffer = "steer this way"),
|
state = stateWithActiveApproval(inputMode = InputMode.STEER, inputBuffer = "steer this way"),
|
||||||
action = Action.SubmitInput,
|
action = Action.SubmitInput,
|
||||||
@@ -141,7 +141,7 @@ class InputReducerTest {
|
|||||||
assertEquals(1, effects.size)
|
assertEquals(1, effects.size)
|
||||||
val effect = effects[0] as Effect.SendWs
|
val effect = effects[0] as Effect.SendWs
|
||||||
val msg = effect.message as ClientMessage.ApprovalResponse
|
val msg = effect.message as ClientMessage.ApprovalResponse
|
||||||
assertEquals(ApprovalDecision.STEER, msg.decision)
|
assertEquals(ApprovalDecision.APPROVE, msg.decision)
|
||||||
assertEquals("steer this way", msg.steeringNote)
|
assertEquals("steer this way", msg.steeringNote)
|
||||||
assertEquals("req-1", msg.requestId.value)
|
assertEquals("req-1", msg.requestId.value)
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ class InputReducerTest {
|
|||||||
assertEquals(null, state.sessions.sessions[0].pendingApproval)
|
assertEquals(null, state.sessions.sessions[0].pendingApproval)
|
||||||
assertEquals(1, effects.size)
|
assertEquals(1, effects.size)
|
||||||
val msg = (effects[0] as Effect.SendWs).message as ClientMessage.ApprovalResponse
|
val msg = (effects[0] as Effect.SendWs).message as ClientMessage.ApprovalResponse
|
||||||
assertEquals(ApprovalDecision.STEER, msg.decision)
|
assertEquals(ApprovalDecision.APPROVE, msg.decision)
|
||||||
assertEquals("go left", msg.steeringNote)
|
assertEquals("go left", msg.steeringNote)
|
||||||
assertEquals("req-1", msg.requestId.value)
|
assertEquals("req-1", msg.requestId.value)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -46,14 +46,14 @@ class ApprovalResponseGlobalSocketRoundTripTest {
|
|||||||
fun `ApprovalResponse with steering note round-trips`() {
|
fun `ApprovalResponse with steering note round-trips`() {
|
||||||
val original: ClientMessage = ClientMessage.ApprovalResponse(
|
val original: ClientMessage = ClientMessage.ApprovalResponse(
|
||||||
requestId = ApprovalRequestId("req-steer"),
|
requestId = ApprovalRequestId("req-steer"),
|
||||||
decision = ApprovalDecision.STEER,
|
decision = ApprovalDecision.APPROVE,
|
||||||
steeringNote = "please retry with smaller batch",
|
steeringNote = "please retry with smaller batch",
|
||||||
)
|
)
|
||||||
|
|
||||||
val wire = clientJson.encodeToString(original)
|
val wire = clientJson.encodeToString(original)
|
||||||
val decoded = ProtocolSerializer.decodeClientMessage(wire) as ClientMessage.ApprovalResponse
|
val decoded = ProtocolSerializer.decodeClientMessage(wire) as ClientMessage.ApprovalResponse
|
||||||
|
|
||||||
assertEquals(ApprovalDecision.STEER, decoded.decision)
|
assertEquals(ApprovalDecision.APPROVE, decoded.decision)
|
||||||
assertEquals("please retry with smaller batch", decoded.steeringNote)
|
assertEquals("please retry with smaller batch", decoded.steeringNote)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user