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:
2026-05-30 21:48:23 +04:00
parent 780a00229e
commit d3ce310100
11 changed files with 106 additions and 98 deletions
@@ -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
@@ -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
-3
View File
@@ -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"