epic-13: add cli and tui entry point, finish epic.
This commit is contained in:
@@ -3,3 +3,7 @@ plugins {
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.correx.core.config
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
|
||||
object ConfigLoader {
|
||||
fun load(): CorrexConfig {
|
||||
val path = configPath()
|
||||
if (!Files.exists(path)) {
|
||||
return CorrexConfig()
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val content = Files.readString(path)
|
||||
parseToml(content)
|
||||
}.getOrElse { e ->
|
||||
System.err.println("Warning: Failed to parse config at $path: ${e.message}")
|
||||
CorrexConfig()
|
||||
}
|
||||
}
|
||||
|
||||
fun configPath(): Path {
|
||||
val envPath = System.getenv("CORREX_CONFIG")
|
||||
return if (envPath != null) {
|
||||
Paths.get(envPath)
|
||||
} else {
|
||||
val homeDir = System.getProperty("user.home")
|
||||
Paths.get(homeDir, ".config", "correx", "config.toml")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseToml(content: String): CorrexConfig {
|
||||
val lines = content.trim().split("\n")
|
||||
var currentSection = ""
|
||||
val sections = mutableMapOf<String, MutableMap<String, String>>()
|
||||
|
||||
for (line in lines) {
|
||||
val trimmed = line.trim()
|
||||
|
||||
when {
|
||||
trimmed.isEmpty() || trimmed.startsWith("#") -> {
|
||||
// Skip empty lines and comments
|
||||
}
|
||||
trimmed.startsWith("[") && trimmed.endsWith("]") -> {
|
||||
// Parse section headers like [server]
|
||||
currentSection = trimmed.substring(1, trimmed.length - 1).trim()
|
||||
sections.putIfAbsent(currentSection, mutableMapOf())
|
||||
}
|
||||
else -> {
|
||||
// Parse key=value pairs
|
||||
val eqIndex = trimmed.indexOf("=")
|
||||
if (eqIndex > 0 && currentSection.isNotEmpty()) {
|
||||
val key = trimmed.substring(0, eqIndex).trim()
|
||||
val value = trimmed.substring(eqIndex + 1).trim()
|
||||
val cleanedValue = stripQuotes(value)
|
||||
sections[currentSection]?.put(key, cleanedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buildConfig(sections)
|
||||
}
|
||||
|
||||
private fun stripQuotes(value: String): String {
|
||||
val isDoubleQuoted = value.startsWith("\"") && value.endsWith("\"")
|
||||
val isSingleQuoted = value.startsWith("'") && value.endsWith("'")
|
||||
return if (isDoubleQuoted || isSingleQuoted) {
|
||||
value.substring(1, value.length - 1)
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildConfig(sections: Map<String, Map<String, String>>): CorrexConfig {
|
||||
val serverSection = sections["server"] ?: emptyMap()
|
||||
val tuiSection = sections["tui"] ?: emptyMap()
|
||||
val cliSection = sections["cli"] ?: emptyMap()
|
||||
val approvalSection = sections["approval"] ?: emptyMap()
|
||||
|
||||
val server = ServerConfig(
|
||||
host = serverSection["host"] ?: "localhost",
|
||||
port = serverSection["port"]?.toIntOrNull() ?: 8080,
|
||||
)
|
||||
|
||||
val tui = TuiConfig(
|
||||
theme = tuiSection["theme"] ?: "dark",
|
||||
sessionListLimit = tuiSection["session_list_limit"]?.toIntOrNull() ?: 5,
|
||||
)
|
||||
|
||||
val cli = CliConfig(
|
||||
defaultOutput = cliSection["default_output"] ?: "human",
|
||||
)
|
||||
|
||||
val approval = ApprovalConfig(
|
||||
timeoutMs = approvalSection["timeout_ms"]?.toLongOrNull() ?: 300_000L,
|
||||
)
|
||||
|
||||
return CorrexConfig(
|
||||
server = server,
|
||||
tui = tui,
|
||||
cli = cli,
|
||||
approval = approval,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.correx.core.config
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CorrexConfig(
|
||||
val server: ServerConfig = ServerConfig(),
|
||||
val tui: TuiConfig = TuiConfig(),
|
||||
val cli: CliConfig = CliConfig(),
|
||||
val approval: ApprovalConfig = ApprovalConfig(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ServerConfig(
|
||||
val host: String = "localhost",
|
||||
val port: Int = 8080,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TuiConfig(
|
||||
val theme: String = "dark",
|
||||
val sessionListLimit: Int = 5,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CliConfig(
|
||||
val defaultOutput: String = "human",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApprovalConfig(
|
||||
val timeoutMs: Long = 300_000L,
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.correx.core.config
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Paths
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ConfigLoaderTest {
|
||||
@Test
|
||||
fun `load returns defaults when config file missing`() {
|
||||
val config = CorrexConfig()
|
||||
assertEquals("localhost", config.server.host)
|
||||
assertEquals(8080, config.server.port)
|
||||
assertEquals("dark", config.tui.theme)
|
||||
assertEquals(5, config.tui.sessionListLimit)
|
||||
assertEquals("human", config.cli.defaultOutput)
|
||||
assertEquals(300_000L, config.approval.timeoutMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseToml parses valid toml content`() {
|
||||
val toml = """
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 9000
|
||||
|
||||
[tui]
|
||||
theme = "light"
|
||||
session_list_limit = 10
|
||||
|
||||
[cli]
|
||||
default_output = "json"
|
||||
|
||||
[approval]
|
||||
timeout_ms = 600000
|
||||
""".trimIndent()
|
||||
|
||||
val loader = ConfigLoader::class.java
|
||||
val parseTomlMethod = loader.getDeclaredMethod("parseToml", String::class.java)
|
||||
parseTomlMethod.isAccessible = true
|
||||
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
|
||||
|
||||
assertEquals("0.0.0.0", result.server.host)
|
||||
assertEquals(9000, result.server.port)
|
||||
assertEquals("light", result.tui.theme)
|
||||
assertEquals(10, result.tui.sessionListLimit)
|
||||
assertEquals("json", result.cli.defaultOutput)
|
||||
assertEquals(600_000L, result.approval.timeoutMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseToml skips comments and empty lines`() {
|
||||
val toml = """
|
||||
# This is a comment
|
||||
[server]
|
||||
# Another comment
|
||||
host = "localhost"
|
||||
|
||||
# Empty line above
|
||||
port = 8080
|
||||
""".trimIndent()
|
||||
|
||||
val loader = ConfigLoader::class.java
|
||||
val parseTomlMethod = loader.getDeclaredMethod("parseToml", String::class.java)
|
||||
parseTomlMethod.isAccessible = true
|
||||
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
|
||||
|
||||
assertEquals("localhost", result.server.host)
|
||||
assertEquals(8080, result.server.port)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `configPath returns a valid path`() {
|
||||
val configPath = ConfigLoader.configPath()
|
||||
// Verify it returns a non-null Path object
|
||||
assertEquals("config.toml", configPath.fileName.toString())
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import kotlinx.datetime.Clock
|
||||
import java.util.*
|
||||
|
||||
class EventDispatcher(private val eventStore: EventStore) {
|
||||
suspend fun emit(
|
||||
fun emit(
|
||||
payload: EventPayload,
|
||||
sessionId: SessionId,
|
||||
causationId: CausationId? = null,
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ object AnyMapSerializer : KSerializer<Map<String, Any>> {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun deserialize(decoder: Decoder): Map<String, Any> =
|
||||
delegate.deserialize(decoder) as Map<String, Any>
|
||||
delegate.deserialize(decoder)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Map<String, Any>) =
|
||||
delegate.serialize(encoder, value)
|
||||
|
||||
+2
-2
@@ -21,8 +21,8 @@ package com.correx.core.inference
|
||||
* timeout. Only a fired [InferenceTimeout] deadline triggers [CancellationReason.StageTimeout].
|
||||
*
|
||||
* ## Event contract
|
||||
* On cancellation, the provider MUST emit [InferenceTimeoutEvent] (for deadline
|
||||
* exceeded) or allow the harness to emit [InferenceFailedEvent] with
|
||||
* On cancellation, the provider MUST emit [com.correx.core.events.events.InferenceTimeoutEvent] (for deadline
|
||||
* exceeded) or allow the harness to emit [com.correx.core.events.events.InferenceFailedEvent] with
|
||||
* [CancellationReason] attached. The provider MUST NOT swallow the cancellation.
|
||||
*/
|
||||
interface InferenceCancellationToken {
|
||||
|
||||
+8
@@ -1,6 +1,8 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.approvals.model.ApprovalDecision
|
||||
import com.correx.core.events.orchestration.OrchestrationState
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.kernel.execution.WorkflowResult
|
||||
@@ -80,6 +82,12 @@ class DefaultSessionOrchestrator(
|
||||
cancellations.getOrPut(sessionId) { AtomicBoolean(false) }.set(true)
|
||||
}
|
||||
|
||||
fun submitApprovalDecision(requestId: ApprovalRequestId, decision: ApprovalDecision) {
|
||||
val deferred = pendingApprovals[requestId]
|
||||
?: error("No pending approval for requestId ${requestId.value}")
|
||||
deferred.complete(decision)
|
||||
}
|
||||
|
||||
private suspend fun executeMove(
|
||||
ctx: EnrichedExecutionContext,
|
||||
decision: TransitionDecision.Move,
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ class ReplayOrchestrator(
|
||||
else -> super.runInference(sessionId, stageId, contextPack, stageConfig, timeoutMs)
|
||||
}
|
||||
|
||||
override fun mapValidationOutcome(
|
||||
override suspend fun mapValidationOutcome(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
context: ValidationContext,
|
||||
|
||||
+66
-22
@@ -1,13 +1,14 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.approvals.ApprovalOutcome
|
||||
import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.domain.ApprovalEngine
|
||||
import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.approvals.model.ApprovalDecision
|
||||
import com.correx.core.approvals.model.DomainApprovalRequest
|
||||
import com.correx.core.context.builder.ContextPackBuilder
|
||||
import com.correx.core.context.model.ContextPack
|
||||
import com.correx.core.context.model.TokenBudget
|
||||
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
|
||||
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.InferenceCompletedEvent
|
||||
@@ -23,6 +24,7 @@ import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.ApprovalDecisionId
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.EventId
|
||||
@@ -39,7 +41,6 @@ import com.correx.core.kernel.execution.WorkflowResult
|
||||
import com.correx.core.risk.RiskAssessor
|
||||
import com.correx.core.risk.RiskContext
|
||||
import com.correx.core.risk.toApprovalTier
|
||||
import com.correx.core.sessions.ApprovalMode
|
||||
import com.correx.core.sessions.Session
|
||||
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||
import com.correx.core.transitions.execution.StageExecutionResult
|
||||
@@ -50,6 +51,7 @@ import com.correx.core.transitions.resolution.TransitionResolver
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import com.correx.core.validation.pipeline.ValidationOutcome
|
||||
import com.correx.core.validation.pipeline.ValidationPipeline
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.datetime.Clock
|
||||
@@ -75,11 +77,12 @@ abstract class SessionOrchestrator(
|
||||
private val contextPackBuilder: ContextPackBuilder = engines.contextPackBuilder
|
||||
private val inferenceRouter: InferenceRouter = engines.inferenceRouter
|
||||
private val validationPipeline: ValidationPipeline = engines.validationPipeline
|
||||
private val approvalEngine: ApprovalEngine = engines.approvalEngine
|
||||
private val riskAssessor: RiskAssessor = engines.riskAssessor
|
||||
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
|
||||
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
|
||||
internal abstract val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean>
|
||||
internal val pendingApprovals: ConcurrentHashMap<ApprovalRequestId, CompletableDeferred<ApprovalDecision>> =
|
||||
ConcurrentHashMap()
|
||||
|
||||
abstract suspend fun run(
|
||||
sessionId: SessionId,
|
||||
@@ -121,7 +124,7 @@ abstract class SessionOrchestrator(
|
||||
}
|
||||
}
|
||||
|
||||
internal open fun mapValidationOutcome(
|
||||
internal open suspend fun mapValidationOutcome(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
context: ValidationContext,
|
||||
@@ -291,13 +294,49 @@ abstract class SessionOrchestrator(
|
||||
|
||||
// --- private functions ---
|
||||
|
||||
private fun handleApproval(
|
||||
private suspend fun handleApproval(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
outcome: ValidationOutcome.NeedsApproval,
|
||||
): StageExecutionResult {
|
||||
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
|
||||
val domainRequest = buildApprovalRequest(sessionId, stageId, outcome)
|
||||
|
||||
emit(
|
||||
sessionId,
|
||||
ApprovalRequestedEvent(
|
||||
requestId = domainRequest.id,
|
||||
tier = domainRequest.tier,
|
||||
validationReportId = domainRequest.validationReportId,
|
||||
riskSummaryId = domainRequest.riskSummaryId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
projectId = null,
|
||||
),
|
||||
)
|
||||
|
||||
val deferred = CompletableDeferred<ApprovalDecision>()
|
||||
pendingApprovals[domainRequest.id] = deferred
|
||||
|
||||
return try {
|
||||
val decision = deferred.await()
|
||||
emitDecisionResolved(sessionId, domainRequest, decision)
|
||||
if (decision.isApproved) {
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
StageExecutionResult.Success(emptyList())
|
||||
} else {
|
||||
StageExecutionResult.Failure(decision.reason ?: "approval rejected", retryable = false)
|
||||
}
|
||||
} finally {
|
||||
pendingApprovals.remove(domainRequest.id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildApprovalRequest(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
outcome: ValidationOutcome.NeedsApproval,
|
||||
): DomainApprovalRequest {
|
||||
val state = orchestrationRepository.getState(sessionId)
|
||||
val inferenceState = inferenceRepository.getInferenceState(sessionId)
|
||||
val riskSummary = riskAssessor.assess(
|
||||
@@ -307,33 +346,38 @@ abstract class SessionOrchestrator(
|
||||
inferenceState = inferenceState,
|
||||
),
|
||||
)
|
||||
|
||||
val riskSummaryId = RiskSummaryId(UUID.randomUUID().toString())
|
||||
emit(
|
||||
sessionId,
|
||||
RiskAssessedEvent(sessionId, stageId, riskSummaryId, riskSummary.level, riskSummary.recommendedAction),
|
||||
)
|
||||
|
||||
val domainRequest = DomainApprovalRequest(
|
||||
return DomainApprovalRequest(
|
||||
id = ApprovalRequestId(UUID.randomUUID().toString()),
|
||||
tier = riskSummary.level.toApprovalTier(),
|
||||
validationReportId = ValidationReportId(UUID.randomUUID().toString()),
|
||||
riskSummaryId = riskSummaryId,
|
||||
timestamp = Clock.System.now(),
|
||||
)
|
||||
val approvalCtx = ApprovalContext(
|
||||
identity = ApprovalScopeIdentity(sessionId, stageId, null),
|
||||
mode = ApprovalMode.PROMPT,
|
||||
}
|
||||
|
||||
private fun emitDecisionResolved(
|
||||
sessionId: SessionId,
|
||||
domainRequest: DomainApprovalRequest,
|
||||
decision: ApprovalDecision,
|
||||
) {
|
||||
emit(
|
||||
sessionId,
|
||||
ApprovalDecisionResolvedEvent(
|
||||
decisionId = ApprovalDecisionId(UUID.randomUUID().toString()),
|
||||
requestId = domainRequest.id,
|
||||
outcome = decision.outcome ?: ApprovalOutcome.REJECTED,
|
||||
status = ApprovalStatus.COMPLETED,
|
||||
tier = domainRequest.tier,
|
||||
resolutionTimestamp = Clock.System.now(),
|
||||
reason = decision.reason,
|
||||
userSteering = decision.userSteering,
|
||||
),
|
||||
)
|
||||
|
||||
val decision = approvalEngine.evaluate(domainRequest, approvalCtx, emptyList(), Clock.System.now())
|
||||
|
||||
return if (decision.state == ApprovalStatus.COMPLETED) {
|
||||
emit(sessionId, OrchestrationResumedEvent(sessionId, stageId))
|
||||
StageExecutionResult.Success(emptyList())
|
||||
} else {
|
||||
StageExecutionResult.Failure("approval pending or rejected", retryable = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ class DefaultToolReducerTest {
|
||||
val record = state.invocations[0]
|
||||
assertEquals(ToolInvocationStatus.COMPLETED, record.status)
|
||||
assertNotNull(record.receipt)
|
||||
assertEquals(0, record.receipt!!.exitCode)
|
||||
assertEquals(0, record.receipt.exitCode)
|
||||
assertNotNull(record.completedAt)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user