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,
|
||||
orchestrationRepository = repositories.orchestrationRepository,
|
||||
approvalRepository = repositories.approvalRepository,
|
||||
approvalConfig = correxConfig.approval,
|
||||
)
|
||||
module.start()
|
||||
log.info("==============================")
|
||||
|
||||
embeddedServer(Netty, port = 8080) { configureServer(module) }.start(wait = true)
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
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.WorkflowRegistry
|
||||
import com.correx.core.approvals.DefaultApprovalRepository
|
||||
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.kernel.orchestration.DefaultSessionOrchestrator
|
||||
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||
import com.correx.core.router.RouterFacade
|
||||
import com.correx.core.sessions.DefaultSessionRepository
|
||||
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 eventStore: EventStore,
|
||||
val artifactStore: ArtifactStore,
|
||||
@@ -22,4 +32,30 @@ data class ServerModule(
|
||||
val routerFacade: RouterFacade,
|
||||
val orchestrationRepository: OrchestrationRepository,
|
||||
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
|
||||
|
||||
import com.correx.apps.server.protocol.ApprovalDecision
|
||||
import com.correx.apps.server.protocol.ClientMessage
|
||||
import com.correx.apps.server.protocol.ProtocolSerializer
|
||||
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.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.config.ApprovalConfig
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
@@ -24,16 +23,17 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.Clock
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
|
||||
|
||||
class ApprovalCoordinator(
|
||||
open class ApprovalCoordinator(
|
||||
private val orchestrator: ApprovalGateway,
|
||||
private val config: ApprovalConfig,
|
||||
private val scope: CoroutineScope,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
private val sessionClients: ConcurrentHashMap<SessionId, MutableSet<DefaultWebSocketServerSession>> =
|
||||
ConcurrentHashMap()
|
||||
private val globalClients: MutableSet<DefaultWebSocketServerSession> = ConcurrentHashMap.newKeySet()
|
||||
private val resolved: ConcurrentHashMap<ApprovalRequestId, Boolean> = ConcurrentHashMap()
|
||||
private val timeoutJobs: ConcurrentHashMap<ApprovalRequestId, Job> = ConcurrentHashMap()
|
||||
|
||||
@@ -45,6 +45,14 @@ class ApprovalCoordinator(
|
||||
sessionClients[sessionId]?.remove(session)
|
||||
}
|
||||
|
||||
fun registerGlobalClient(session: DefaultWebSocketServerSession) {
|
||||
globalClients.add(session)
|
||||
}
|
||||
|
||||
fun unregisterGlobalClient(session: DefaultWebSocketServerSession) {
|
||||
globalClients.remove(session)
|
||||
}
|
||||
|
||||
suspend fun onApprovalRequested(event: ApprovalRequestedEvent) {
|
||||
val msg = ServerMessage.ApprovalRequired(
|
||||
sessionId = event.sessionId,
|
||||
@@ -64,7 +72,7 @@ class ApprovalCoordinator(
|
||||
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) {
|
||||
return ServerMessage.ProtocolError(
|
||||
message = "Approval request ${msg.requestId.value} already resolved",
|
||||
@@ -73,7 +81,7 @@ class ApprovalCoordinator(
|
||||
)
|
||||
}
|
||||
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) }
|
||||
.fold(
|
||||
onSuccess = { null },
|
||||
@@ -89,7 +97,9 @@ class ApprovalCoordinator(
|
||||
|
||||
private suspend fun broadcast(sessionId: SessionId, msg: ServerMessage) {
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
@@ -119,34 +129,4 @@ class ApprovalCoordinator(
|
||||
}
|
||||
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 {
|
||||
APPROVE,
|
||||
REJECT,
|
||||
STEER,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -3,17 +3,10 @@ package com.correx.apps.server.ws
|
||||
import com.correx.apps.server.ServerModule
|
||||
import com.correx.apps.server.bridge.DomainEventMapper
|
||||
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.ProtocolSerializer
|
||||
import com.correx.apps.server.protocol.ProviderHealthDto
|
||||
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.NewEvent
|
||||
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.SessionId
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.core.utils.TypeId
|
||||
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
|
||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
@@ -62,6 +53,8 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
)
|
||||
val mapper = DomainEventMapper(module.artifactStore)
|
||||
|
||||
module.approvalCoordinator.registerGlobalClient(session)
|
||||
|
||||
sendInitialSnapshot(session)
|
||||
|
||||
// Launch global stream (snapshot + live-forward) concurrently with incoming frame loop
|
||||
@@ -95,6 +88,8 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
}
|
||||
} catch (_: ClosedReceiveChannelException) {
|
||||
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.CancelSession -> module.orchestrator.cancel(msg.sessionId)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleApprovalResponse(msg: ClientMessage.ApprovalResponse) {
|
||||
val domainDecision = msg.toDomainDecision(msg.requestId.value)
|
||||
runCatching { module.orchestrator.submitApprovalDecision(msg.requestId, domainDecision) }
|
||||
.onFailure {
|
||||
log.warn(
|
||||
"submitApprovalDecision failed for request={}: {}",
|
||||
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 suspend fun handleApprovalResponse(
|
||||
msg: ClientMessage.ApprovalResponse,
|
||||
sendFrame: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
val scopeSessionId: SessionId = TypeId(msg.requestId.value)
|
||||
module.approvalCoordinator.handleResponse(msg, scopeSessionId)?.let { sendFrame(it) }
|
||||
}
|
||||
|
||||
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.bridge.DomainEventMapper
|
||||
import com.correx.apps.server.protocol.ApprovalDecision
|
||||
import com.correx.apps.server.protocol.ClientMessage
|
||||
import com.correx.apps.server.protocol.ProtocolSerializer
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
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.sessions.ApprovalMode
|
||||
import com.correx.core.utils.TypeId
|
||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||
import kotlinx.datetime.Clock
|
||||
import org.slf4j.LoggerFactory
|
||||
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
|
||||
|
||||
private val log = LoggerFactory.getLogger(SessionStreamHandler::class.java)
|
||||
|
||||
@@ -46,6 +35,7 @@ class SessionStreamHandler(private val module: ServerModule) {
|
||||
}
|
||||
}
|
||||
|
||||
module.approvalCoordinator.registerClient(sessionId, session)
|
||||
try {
|
||||
for (frame in session.incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
@@ -53,7 +43,7 @@ class SessionStreamHandler(private val module: ServerModule) {
|
||||
runCatching { ProtocolSerializer.decodeClientMessage(text) }
|
||||
.onSuccess { msg ->
|
||||
log.debug("recv: {}", msg::class.simpleName)
|
||||
handleClientMessage(session, msg)
|
||||
handleClientMessage(session, sessionId, msg)
|
||||
}
|
||||
.onFailure {
|
||||
log.warn("decode error: {}", it.message)
|
||||
@@ -64,10 +54,16 @@ class SessionStreamHandler(private val module: ServerModule) {
|
||||
}
|
||||
} catch (_: ClosedReceiveChannelException) {
|
||||
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) {
|
||||
is ClientMessage.Ping -> Unit
|
||||
is ClientMessage.CancelSession -> {
|
||||
@@ -76,15 +72,9 @@ class SessionStreamHandler(private val module: ServerModule) {
|
||||
}
|
||||
|
||||
is ClientMessage.ApprovalResponse -> {
|
||||
val domainDecision = msg.toDomainDecision(msg.requestId.value)
|
||||
runCatching { module.orchestrator.submitApprovalDecision(msg.requestId, domainDecision) }
|
||||
.onFailure {
|
||||
log.warn(
|
||||
"submitApprovalDecision failed for request={}: {}",
|
||||
msg.requestId.value,
|
||||
it.message,
|
||||
)
|
||||
}
|
||||
module.approvalCoordinator.handleResponse(msg, sessionId)?.let { reply ->
|
||||
session.send(Frame.Text(ProtocolSerializer.encodeServerMessage(reply)))
|
||||
}
|
||||
}
|
||||
|
||||
is ClientMessage.ChatInput -> {
|
||||
@@ -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(
|
||||
ClientMessage.ApprovalResponse(
|
||||
requestId = ApprovalRequestId(active.requestId),
|
||||
decision = ApprovalDecision.STEER,
|
||||
decision = ApprovalDecision.APPROVE,
|
||||
steeringNote = text,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -132,7 +132,7 @@ class InputReducerTest {
|
||||
}
|
||||
|
||||
@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(
|
||||
state = stateWithActiveApproval(inputMode = InputMode.STEER, inputBuffer = "steer this way"),
|
||||
action = Action.SubmitInput,
|
||||
@@ -141,7 +141,7 @@ class InputReducerTest {
|
||||
assertEquals(1, effects.size)
|
||||
val effect = effects[0] as Effect.SendWs
|
||||
val msg = effect.message as ClientMessage.ApprovalResponse
|
||||
assertEquals(ApprovalDecision.STEER, msg.decision)
|
||||
assertEquals(ApprovalDecision.APPROVE, msg.decision)
|
||||
assertEquals("steer this way", msg.steeringNote)
|
||||
assertEquals("req-1", msg.requestId.value)
|
||||
}
|
||||
@@ -155,7 +155,7 @@ class InputReducerTest {
|
||||
assertEquals(null, state.sessions.sessions[0].pendingApproval)
|
||||
assertEquals(1, effects.size)
|
||||
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("req-1", msg.requestId.value)
|
||||
}
|
||||
|
||||
+2
-2
@@ -46,14 +46,14 @@ class ApprovalResponseGlobalSocketRoundTripTest {
|
||||
fun `ApprovalResponse with steering note round-trips`() {
|
||||
val original: ClientMessage = ClientMessage.ApprovalResponse(
|
||||
requestId = ApprovalRequestId("req-steer"),
|
||||
decision = ApprovalDecision.STEER,
|
||||
decision = ApprovalDecision.APPROVE,
|
||||
steeringNote = "please retry with smaller batch",
|
||||
)
|
||||
|
||||
val wire = clientJson.encodeToString(original)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user