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:
2026-05-25 19:02:51 +04:00
parent 09f47bf8e3
commit 5abf7d1253
12 changed files with 316 additions and 143 deletions
@@ -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,
)
@@ -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,15 +72,9 @@ 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,
)
}
} }
is ClientMessage.ChatInput -> { 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,
)
}
} }
@@ -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)
} }
@@ -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)
} }
} }