feat: approval gates block indefinitely instead of auto-rejecting on timeout
Remove the wall-clock approval timeout that auto-rejected pending approvals. The timeout modeled machine latency, but approvals are human latency (unbounded); it also created an intent/outcome divergence where a human approving at the same instant the timeout fired was silently overridden by the auto-reject. Approvals now block until the operator decides; the resolved-request dedup still guards against double-submit. Add ServerMessage.ApprovalResolved and map ApprovalDecisionResolvedEvent to it in DomainEventMapper so clients are notified when any approval is resolved (by anyone) — letting a second connected client clear its prompt. This is the event-sourced replacement for the removed timeout notification path. Drop the now-dead ApprovalConfig (timeout_ms) and its loader/test/sample-config references. ApprovalStatus.TIMED_OUT is retained for replay of historical events.
This commit is contained in:
@@ -160,7 +160,6 @@ fun main() {
|
|||||||
orchestrationRepository = repositories.orchestrationRepository,
|
orchestrationRepository = repositories.orchestrationRepository,
|
||||||
approvalRepository = repositories.approvalRepository,
|
approvalRepository = repositories.approvalRepository,
|
||||||
toolRegistry = toolRegistry,
|
toolRegistry = toolRegistry,
|
||||||
approvalConfig = correxConfig.approval,
|
|
||||||
)
|
)
|
||||||
module.start()
|
module.start()
|
||||||
log.info("==============================")
|
log.info("==============================")
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import com.correx.core.approvals.ApprovalProjector
|
|||||||
import com.correx.core.approvals.DefaultApprovalReducer
|
import com.correx.core.approvals.DefaultApprovalReducer
|
||||||
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.events.ApprovalRequestedEvent
|
||||||
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
|
||||||
@@ -49,17 +48,14 @@ class ServerModule(
|
|||||||
val orchestrationRepository: OrchestrationRepository,
|
val orchestrationRepository: OrchestrationRepository,
|
||||||
val approvalRepository: DefaultApprovalRepository,
|
val approvalRepository: DefaultApprovalRepository,
|
||||||
val toolRegistry: ToolRegistry,
|
val toolRegistry: ToolRegistry,
|
||||||
approvalConfig: ApprovalConfig = ApprovalConfig(),
|
// Long-lived scope owned by the module — backs the event-store subscription.
|
||||||
// Long-lived scope owned by the module — backs the ApprovalCoordinator timers
|
// SupervisorJob so one failure doesn't kill the whole module;
|
||||||
// and event-store subscription. SupervisorJob so one failure doesn't kill the
|
// Dispatchers.Default since the work is non-blocking and CPU-light.
|
||||||
// whole module; Dispatchers.Default since the work is non-blocking and CPU-light.
|
|
||||||
val moduleScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
val moduleScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||||
approvalCoordinator: ApprovalCoordinator? = null,
|
approvalCoordinator: ApprovalCoordinator? = null,
|
||||||
) {
|
) {
|
||||||
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
|
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
|
||||||
orchestrator = orchestrator,
|
orchestrator = orchestrator,
|
||||||
config = approvalConfig,
|
|
||||||
scope = moduleScope,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private var subscriptionJob: Job? = null
|
private var subscriptionJob: Job? = null
|
||||||
|
|||||||
@@ -5,39 +5,23 @@ import com.correx.apps.server.protocol.ProtocolSerializer
|
|||||||
import com.correx.apps.server.protocol.RiskSummaryDto
|
import com.correx.apps.server.protocol.RiskSummaryDto
|
||||||
import com.correx.apps.server.protocol.ServerMessage
|
import com.correx.apps.server.protocol.ServerMessage
|
||||||
import com.correx.apps.server.protocol.toDto
|
import com.correx.apps.server.protocol.toDto
|
||||||
import com.correx.core.approvals.ApprovalOutcome
|
|
||||||
import com.correx.core.approvals.ApprovalStatus
|
|
||||||
import com.correx.core.approvals.Tier
|
import com.correx.core.approvals.Tier
|
||||||
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.events.ApprovalRequestedEvent
|
||||||
import com.correx.core.events.risk.RiskAction
|
import com.correx.core.events.risk.RiskAction
|
||||||
import com.correx.core.events.types.ApprovalRequestId
|
import com.correx.core.events.types.ApprovalRequestId
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
|
||||||
import com.correx.core.kernel.orchestration.ApprovalGateway
|
import com.correx.core.kernel.orchestration.ApprovalGateway
|
||||||
import com.correx.core.sessions.ApprovalMode
|
|
||||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||||
import io.ktor.websocket.Frame
|
import io.ktor.websocket.Frame
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.datetime.Clock
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import com.correx.core.approvals.model.ApprovalDecision as DomainApprovalDecision
|
|
||||||
|
|
||||||
open class ApprovalCoordinator(
|
open class ApprovalCoordinator(
|
||||||
private val orchestrator: ApprovalGateway,
|
private val orchestrator: ApprovalGateway,
|
||||||
private val config: ApprovalConfig,
|
|
||||||
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 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 requestSessions: ConcurrentHashMap<ApprovalRequestId, SessionId> = ConcurrentHashMap()
|
private val requestSessions: ConcurrentHashMap<ApprovalRequestId, SessionId> = ConcurrentHashMap()
|
||||||
|
|
||||||
fun registerClient(sessionId: SessionId, session: DefaultWebSocketServerSession) {
|
fun registerClient(sessionId: SessionId, session: DefaultWebSocketServerSession) {
|
||||||
@@ -74,7 +58,6 @@ open class ApprovalCoordinator(
|
|||||||
sessionSequence = 0L,
|
sessionSequence = 0L,
|
||||||
)
|
)
|
||||||
broadcast(event.sessionId, msg)
|
broadcast(event.sessionId, msg)
|
||||||
scheduleTimeout(event.requestId, event.sessionId, event.stageId, event.tier)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun lookupSession(requestId: ApprovalRequestId): SessionId? = requestSessions[requestId]
|
fun lookupSession(requestId: ApprovalRequestId): SessionId? = requestSessions[requestId]
|
||||||
@@ -91,12 +74,11 @@ open class ApprovalCoordinator(
|
|||||||
open suspend fun handleResponse(msg: ClientMessage.ApprovalResponse, sessionId: SessionId): ServerMessage? {
|
open suspend 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} was already resolved",
|
||||||
sequence = null,
|
sequence = null,
|
||||||
sessionSequence = null,
|
sessionSequence = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
timeoutJobs.remove(msg.requestId)?.cancel()
|
|
||||||
requestSessions.remove(msg.requestId)
|
requestSessions.remove(msg.requestId)
|
||||||
val domain = msg.toDomain(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) }
|
||||||
@@ -120,30 +102,4 @@ open class ApprovalCoordinator(
|
|||||||
runCatching { client.send(Frame.Text(encoded)) }
|
runCatching { client.send(Frame.Text(encoded)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scheduleTimeout(
|
|
||||||
requestId: ApprovalRequestId,
|
|
||||||
sessionId: SessionId,
|
|
||||||
stageId: StageId?,
|
|
||||||
tier: Tier,
|
|
||||||
) {
|
|
||||||
val job = scope.launch {
|
|
||||||
delay(config.timeoutMs)
|
|
||||||
if (resolved.putIfAbsent(requestId, true) != null) return@launch
|
|
||||||
val identity = ApprovalScopeIdentity(sessionId = sessionId, stageId = stageId, projectId = null)
|
|
||||||
val context = ApprovalContext(identity = identity, mode = ApprovalMode.PROMPT)
|
|
||||||
val decision = DomainApprovalDecision(
|
|
||||||
id = null,
|
|
||||||
requestId = requestId,
|
|
||||||
outcome = ApprovalOutcome.REJECTED,
|
|
||||||
state = ApprovalStatus.COMPLETED,
|
|
||||||
tier = tier,
|
|
||||||
contextSnapshot = context,
|
|
||||||
resolutionTimestamp = Clock.System.now(),
|
|
||||||
reason = "Approval timed out after ${config.timeoutMs}ms",
|
|
||||||
)
|
|
||||||
runCatching { orchestrator.submitApprovalDecision(requestId, decision) }
|
|
||||||
}
|
|
||||||
timeoutJobs[requestId] = job
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.correx.apps.server.protocol.RiskSummaryDto
|
|||||||
import com.correx.apps.server.protocol.ServerMessage
|
import com.correx.apps.server.protocol.ServerMessage
|
||||||
import com.correx.apps.server.protocol.toDto
|
import com.correx.apps.server.protocol.toDto
|
||||||
import com.correx.core.artifactstore.ArtifactStore
|
import com.correx.core.artifactstore.ArtifactStore
|
||||||
|
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||||
import com.correx.core.events.events.ChatSessionStartedEvent
|
import com.correx.core.events.events.ChatSessionStartedEvent
|
||||||
@@ -160,6 +161,15 @@ suspend fun domainEventToServerMessage(
|
|||||||
)
|
)
|
||||||
|
|
||||||
is ApprovalRequestedEvent -> mapApprovalRequested(p, seq, sessionSequence)
|
is ApprovalRequestedEvent -> mapApprovalRequested(p, seq, sessionSequence)
|
||||||
|
is ApprovalDecisionResolvedEvent -> ServerMessage.ApprovalResolved(
|
||||||
|
// ApprovalDecisionResolvedEvent has no sessionId on its payload — read it from the event envelope
|
||||||
|
sessionId = event.metadata.sessionId,
|
||||||
|
requestId = p.requestId,
|
||||||
|
outcome = p.outcome.name,
|
||||||
|
reason = p.reason,
|
||||||
|
sequence = seq,
|
||||||
|
sessionSequence = sessionSequence,
|
||||||
|
)
|
||||||
is ArtifactCreatedEvent -> ServerMessage.ArtifactCreated(
|
is ArtifactCreatedEvent -> ServerMessage.ArtifactCreated(
|
||||||
sessionId = p.sessionId,
|
sessionId = p.sessionId,
|
||||||
stageId = p.stageId,
|
stageId = p.stageId,
|
||||||
|
|||||||
@@ -223,6 +223,17 @@ sealed interface ServerMessage {
|
|||||||
override val sessionSequence: Long,
|
override val sessionSequence: Long,
|
||||||
) : ServerMessage, SessionMessage
|
) : ServerMessage, SessionMessage
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("approval.resolved")
|
||||||
|
data class ApprovalResolved(
|
||||||
|
val sessionId: SessionId,
|
||||||
|
val requestId: ApprovalRequestId,
|
||||||
|
val outcome: String,
|
||||||
|
val reason: String?,
|
||||||
|
override val sequence: Long,
|
||||||
|
override val sessionSequence: Long,
|
||||||
|
) : ServerMessage, SessionMessage
|
||||||
|
|
||||||
// -- System / infra --
|
// -- System / infra --
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
+33
-15
@@ -4,7 +4,6 @@ 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.ServerMessage
|
import com.correx.apps.server.protocol.ServerMessage
|
||||||
import com.correx.core.approvals.Tier
|
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.ApprovalRequestedEvent
|
||||||
import com.correx.core.events.events.EventMetadata
|
import com.correx.core.events.events.EventMetadata
|
||||||
import com.correx.core.events.events.EventPayload
|
import com.correx.core.events.events.EventPayload
|
||||||
@@ -29,6 +28,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
@@ -43,6 +43,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
|
|||||||
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ class ApprovalCoordinatorWiringTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `handleResponse forwards to gateway and returns null on success`(): Unit = runBlocking {
|
fun `handleResponse forwards to gateway and returns null on success`(): Unit = runBlocking {
|
||||||
val gateway = RecordingGateway()
|
val gateway = RecordingGateway()
|
||||||
val coord = ApprovalCoordinator(gateway, ApprovalConfig(timeoutMs = 300_000L), scope)
|
val coord = ApprovalCoordinator(gateway)
|
||||||
val msg = ClientMessage.ApprovalResponse(
|
val msg = ClientMessage.ApprovalResponse(
|
||||||
requestId = requestId,
|
requestId = requestId,
|
||||||
decision = ApprovalDecision.APPROVE,
|
decision = ApprovalDecision.APPROVE,
|
||||||
@@ -113,7 +114,7 @@ class ApprovalCoordinatorWiringTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `duplicate ApprovalResponse for same requestId returns ProtocolError`(): Unit = runBlocking {
|
fun `duplicate ApprovalResponse for same requestId returns ProtocolError`(): Unit = runBlocking {
|
||||||
val gateway = RecordingGateway()
|
val gateway = RecordingGateway()
|
||||||
val coord = ApprovalCoordinator(gateway, ApprovalConfig(timeoutMs = 300_000L), scope)
|
val coord = ApprovalCoordinator(gateway)
|
||||||
val msg = ClientMessage.ApprovalResponse(
|
val msg = ClientMessage.ApprovalResponse(
|
||||||
requestId = requestId,
|
requestId = requestId,
|
||||||
decision = ApprovalDecision.APPROVE,
|
decision = ApprovalDecision.APPROVE,
|
||||||
@@ -130,16 +131,6 @@ class ApprovalCoordinatorWiringTest {
|
|||||||
assertEquals(1, gateway.submissions.size)
|
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]
|
* Proves the ServerModule.start() subscription wiring shape: an [ApprovalRequestedEvent]
|
||||||
* flowing through the event store reaches the wired-up callback, and non-approval events
|
* flowing through the event store reaches the wired-up callback, and non-approval events
|
||||||
@@ -183,7 +174,7 @@ class ApprovalCoordinatorWiringTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `lookupSession returns mapping registered by onApprovalRequested and cleared by handleResponse`(): Unit = runBlocking {
|
fun `lookupSession returns mapping registered by onApprovalRequested and cleared by handleResponse`(): Unit = runBlocking {
|
||||||
val gateway = RecordingGateway()
|
val gateway = RecordingGateway()
|
||||||
val coord = ApprovalCoordinator(gateway, ApprovalConfig(timeoutMs = 300_000L), scope)
|
val coord = ApprovalCoordinator(gateway)
|
||||||
val otherSessionId = SessionId("other-session")
|
val otherSessionId = SessionId("other-session")
|
||||||
|
|
||||||
// Initially no mapping
|
// Initially no mapping
|
||||||
@@ -215,7 +206,7 @@ class ApprovalCoordinatorWiringTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `onApprovalRequested with non-null riskSummary registers session without error`(): Unit = runBlocking {
|
fun `onApprovalRequested with non-null riskSummary registers session without error`(): Unit = runBlocking {
|
||||||
val gateway = RecordingGateway()
|
val gateway = RecordingGateway()
|
||||||
val coord = ApprovalCoordinator(gateway, ApprovalConfig(timeoutMs = 300_000L), scope)
|
val coord = ApprovalCoordinator(gateway)
|
||||||
val riskSummary = RiskSummary(
|
val riskSummary = RiskSummary(
|
||||||
level = RiskLevel.HIGH,
|
level = RiskLevel.HIGH,
|
||||||
signals = listOf(RiskSignal.ValidationErrors(errorCount = 2)),
|
signals = listOf(RiskSignal.ValidationErrors(errorCount = 2)),
|
||||||
@@ -235,4 +226,31 @@ class ApprovalCoordinatorWiringTest {
|
|||||||
coord.onApprovalRequested(event)
|
coord.onApprovalRequested(event)
|
||||||
assertEquals(sessionId, coord.lookupSession(requestId))
|
assertEquals(sessionId, coord.lookupSession(requestId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval gates block indefinitely — no auto-reject after a delay.
|
||||||
|
* An unanswered request must not cause any decision to be submitted to the gateway.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `unanswered approval request does not auto-reject after a delay`(): Unit = runBlocking {
|
||||||
|
val gateway = RecordingGateway()
|
||||||
|
val coord = ApprovalCoordinator(gateway)
|
||||||
|
val event = ApprovalRequestedEvent(
|
||||||
|
requestId = requestId,
|
||||||
|
tier = Tier.T2,
|
||||||
|
validationReportId = ValidationReportId("vr-1"),
|
||||||
|
riskSummaryId = null,
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = null,
|
||||||
|
projectId = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
coord.onApprovalRequested(event)
|
||||||
|
// Give any hypothetical background job time to fire.
|
||||||
|
delay(200L)
|
||||||
|
|
||||||
|
assertTrue(gateway.submissions.isEmpty(), "No decision should be submitted for an unanswered approval")
|
||||||
|
// The session mapping must still be present — the request is still pending.
|
||||||
|
assertEquals(sessionId, coord.lookupSession(requestId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package com.correx.apps.server.bridge
|
|||||||
|
|
||||||
import com.correx.apps.server.protocol.PauseReason
|
import com.correx.apps.server.protocol.PauseReason
|
||||||
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.Tier
|
||||||
import com.correx.core.artifactstore.ArtifactStore
|
import com.correx.core.artifactstore.ArtifactStore
|
||||||
|
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
import com.correx.core.events.events.EventMetadata
|
import com.correx.core.events.events.EventMetadata
|
||||||
import com.correx.core.events.events.InferenceCompletedEvent
|
import com.correx.core.events.events.InferenceCompletedEvent
|
||||||
@@ -28,6 +31,7 @@ import com.correx.core.events.risk.RiskAction
|
|||||||
import com.correx.core.events.risk.RiskLevel
|
import com.correx.core.events.risk.RiskLevel
|
||||||
import com.correx.core.events.risk.RiskSignal
|
import com.correx.core.events.risk.RiskSignal
|
||||||
import com.correx.core.events.risk.RiskSummary
|
import com.correx.core.events.risk.RiskSummary
|
||||||
|
import com.correx.core.events.types.ApprovalDecisionId
|
||||||
import com.correx.core.events.types.ApprovalRequestId
|
import com.correx.core.events.types.ApprovalRequestId
|
||||||
import com.correx.core.events.types.ArtifactId
|
import com.correx.core.events.types.ArtifactId
|
||||||
import com.correx.core.events.types.EventId
|
import com.correx.core.events.types.EventId
|
||||||
@@ -410,6 +414,50 @@ class DomainEventMapperTest {
|
|||||||
assertTrue(result.riskSummary.factors[1].contains("Repeated failure"))
|
assertTrue(result.riskSummary.factors[1].contains("Repeated failure"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ApprovalDecisionResolvedEvent maps to ApprovalResolved with correct fields`(): Unit = runTest {
|
||||||
|
val requestId = ApprovalRequestId("req-3")
|
||||||
|
val event = storedEvent(
|
||||||
|
ApprovalDecisionResolvedEvent(
|
||||||
|
decisionId = ApprovalDecisionId("dec-1"),
|
||||||
|
requestId = requestId,
|
||||||
|
outcome = ApprovalOutcome.APPROVED,
|
||||||
|
status = ApprovalStatus.COMPLETED,
|
||||||
|
tier = Tier.T2,
|
||||||
|
resolutionTimestamp = timestamp,
|
||||||
|
reason = "user approved",
|
||||||
|
userSteering = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 5L) as ServerMessage.ApprovalResolved
|
||||||
|
assertEquals(sessionId, result.sessionId)
|
||||||
|
assertEquals(requestId, result.requestId)
|
||||||
|
assertEquals("APPROVED", result.outcome)
|
||||||
|
assertEquals("user approved", result.reason)
|
||||||
|
assertEquals(event.sequence, result.sequence)
|
||||||
|
assertEquals(5L, result.sessionSequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ApprovalDecisionResolvedEvent maps to ApprovalResolved with null reason`(): Unit = runTest {
|
||||||
|
val requestId = ApprovalRequestId("req-4")
|
||||||
|
val event = storedEvent(
|
||||||
|
ApprovalDecisionResolvedEvent(
|
||||||
|
decisionId = ApprovalDecisionId("dec-2"),
|
||||||
|
requestId = requestId,
|
||||||
|
outcome = ApprovalOutcome.REJECTED,
|
||||||
|
status = ApprovalStatus.COMPLETED,
|
||||||
|
tier = Tier.T3,
|
||||||
|
resolutionTimestamp = timestamp,
|
||||||
|
reason = null,
|
||||||
|
userSteering = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalResolved
|
||||||
|
assertEquals("REJECTED", result.outcome)
|
||||||
|
assertNull(result.reason)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `unmapped event returns null`(): Unit = runTest {
|
fun `unmapped event returns null`(): Unit = runTest {
|
||||||
val event = storedEvent(SteeringNoteAddedEvent(sessionId = sessionId, content = "test"))
|
val event = storedEvent(SteeringNoteAddedEvent(sessionId = sessionId, content = "test"))
|
||||||
|
|||||||
@@ -204,13 +204,11 @@ object ConfigLoader {
|
|||||||
val serverSection = sections["server"] ?: emptyMap()
|
val serverSection = sections["server"] ?: emptyMap()
|
||||||
val tuiSection = sections["tui"] ?: emptyMap()
|
val tuiSection = sections["tui"] ?: emptyMap()
|
||||||
val cliSection = sections["cli"] ?: emptyMap()
|
val cliSection = sections["cli"] ?: emptyMap()
|
||||||
val approvalSection = sections["approval"] ?: emptyMap()
|
|
||||||
val toolsSection = sections["tools"] ?: emptyMap()
|
val toolsSection = sections["tools"] ?: emptyMap()
|
||||||
val toolsShellSection = sections["tools.shell"] ?: emptyMap()
|
val toolsShellSection = sections["tools.shell"] ?: emptyMap()
|
||||||
val toolsFileReadSection = sections["tools.file_read"] ?: emptyMap()
|
val toolsFileReadSection = sections["tools.file_read"] ?: emptyMap()
|
||||||
val toolsFileWriteSection = sections["tools.file_write"] ?: emptyMap()
|
val toolsFileWriteSection = sections["tools.file_write"] ?: emptyMap()
|
||||||
val toolsFileEditSection = sections["tools.file_edit"] ?: emptyMap()
|
val toolsFileEditSection = sections["tools.file_edit"] ?: emptyMap()
|
||||||
val routerSection = sections["router"] ?: emptyMap()
|
|
||||||
val routerEmbedderSection = sections["router.embedder"] ?: emptyMap()
|
val routerEmbedderSection = sections["router.embedder"] ?: emptyMap()
|
||||||
val routerL3Section = sections["router.l3"] ?: emptyMap()
|
val routerL3Section = sections["router.l3"] ?: emptyMap()
|
||||||
|
|
||||||
@@ -228,10 +226,6 @@ object ConfigLoader {
|
|||||||
defaultOutput = asString(cliSection["default_output"], "human"),
|
defaultOutput = asString(cliSection["default_output"], "human"),
|
||||||
)
|
)
|
||||||
|
|
||||||
val approval = ApprovalConfig(
|
|
||||||
timeoutMs = asLong(approvalSection["timeout_ms"], 300_000L),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Resolve tool enable flags: prefer nested [tools.shell], [tools.file_read], etc.
|
// Resolve tool enable flags: prefer nested [tools.shell], [tools.file_read], etc.
|
||||||
// Fall back to flat [tools] section for backward compat
|
// Fall back to flat [tools] section for backward compat
|
||||||
val shellEnabled = when {
|
val shellEnabled = when {
|
||||||
@@ -350,7 +344,6 @@ object ConfigLoader {
|
|||||||
server = server,
|
server = server,
|
||||||
tui = tui,
|
tui = tui,
|
||||||
cli = cli,
|
cli = cli,
|
||||||
approval = approval,
|
|
||||||
tools = tools,
|
tools = tools,
|
||||||
providers = providers,
|
providers = providers,
|
||||||
router = router,
|
router = router,
|
||||||
@@ -392,15 +385,6 @@ object ConfigLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun asLong(value: Any?, default: Long = 0L): Long {
|
|
||||||
return when (value) {
|
|
||||||
is Long -> value
|
|
||||||
is Int -> value.toLong()
|
|
||||||
is String -> value.toLongOrNull() ?: default
|
|
||||||
else -> default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun asBoolean(value: Any?, default: Boolean = false): Boolean {
|
private fun asBoolean(value: Any?, default: Boolean = false): Boolean {
|
||||||
return when (value) {
|
return when (value) {
|
||||||
is Boolean -> value
|
is Boolean -> value
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ data class CorrexConfig(
|
|||||||
val server: ServerConfig = ServerConfig(),
|
val server: ServerConfig = ServerConfig(),
|
||||||
val tui: TuiConfig = TuiConfig(),
|
val tui: TuiConfig = TuiConfig(),
|
||||||
val cli: CliConfig = CliConfig(),
|
val cli: CliConfig = CliConfig(),
|
||||||
val approval: ApprovalConfig = ApprovalConfig(),
|
|
||||||
val tools: ToolsConfig = ToolsConfig(),
|
val tools: ToolsConfig = ToolsConfig(),
|
||||||
val providers: List<ProviderConfig> = emptyList(),
|
val providers: List<ProviderConfig> = emptyList(),
|
||||||
val router: RouterConfig = RouterConfig(),
|
val router: RouterConfig = RouterConfig(),
|
||||||
@@ -30,11 +29,6 @@ data class CliConfig(
|
|||||||
val defaultOutput: String = "human",
|
val defaultOutput: String = "human",
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ApprovalConfig(
|
|
||||||
val timeoutMs: Long = 300_000L,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ToolsConfig(
|
data class ToolsConfig(
|
||||||
val sandboxRoot: String = "",
|
val sandboxRoot: String = "",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ class ConfigLoaderTest {
|
|||||||
assertEquals("dark", config.tui.theme)
|
assertEquals("dark", config.tui.theme)
|
||||||
assertEquals(5, config.tui.sessionListLimit)
|
assertEquals(5, config.tui.sessionListLimit)
|
||||||
assertEquals("human", config.cli.defaultOutput)
|
assertEquals("human", config.cli.defaultOutput)
|
||||||
assertEquals(300_000L, config.approval.timeoutMs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -28,9 +27,6 @@ class ConfigLoaderTest {
|
|||||||
|
|
||||||
[cli]
|
[cli]
|
||||||
default_output = "json"
|
default_output = "json"
|
||||||
|
|
||||||
[approval]
|
|
||||||
timeout_ms = 600000
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
val loader = ConfigLoader::class.java
|
val loader = ConfigLoader::class.java
|
||||||
@@ -43,7 +39,6 @@ class ConfigLoaderTest {
|
|||||||
assertEquals("light", result.tui.theme)
|
assertEquals("light", result.tui.theme)
|
||||||
assertEquals(10, result.tui.sessionListLimit)
|
assertEquals(10, result.tui.sessionListLimit)
|
||||||
assertEquals("json", result.cli.defaultOutput)
|
assertEquals("json", result.cli.defaultOutput)
|
||||||
assertEquals(600_000L, result.approval.timeoutMs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ session_list_limit = 5
|
|||||||
[cli]
|
[cli]
|
||||||
default_output = "human"
|
default_output = "human"
|
||||||
|
|
||||||
[approval]
|
|
||||||
timeout_ms = 300000
|
|
||||||
|
|
||||||
[tools]
|
[tools]
|
||||||
sandbox_root = "~/.config/correx/sandbox"
|
sandbox_root = "~/.config/correx/sandbox"
|
||||||
working_dir = "/tmp"
|
working_dir = "/tmp"
|
||||||
|
|||||||
Reference in New Issue
Block a user