epic-12: after epic audit and init commit
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':core:events')
|
||||
implementation project(':core:sessions')
|
||||
implementation project(':core:transitions')
|
||||
implementation project(':core:validation')
|
||||
implementation project(':core:approvals')
|
||||
implementation project(':core:context')
|
||||
implementation project(':core:inference')
|
||||
implementation project(':core:tools')
|
||||
implementation project(':core:artifacts')
|
||||
implementation project(':core:risk')
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.correx.core.kernel.execution
|
||||
|
||||
sealed interface ReplayStrategy {
|
||||
data object Full : ReplayStrategy
|
||||
data object SkipInference : ReplayStrategy // use recorded InferenceCompletedEvent artifacts
|
||||
data object SkipValidation : ReplayStrategy // trust recorded outcomes
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package com.correx.core.kernel.execution
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.correx.core.kernel.execution
|
||||
|
||||
import com.correx.core.approvals.model.DomainApprovalRequest
|
||||
import com.correx.core.artifacts.model.Artifact
|
||||
import com.correx.core.validation.model.ValidationReport
|
||||
|
||||
sealed interface StageOutcome {
|
||||
data class Success(val artifact: Artifact) : StageOutcome
|
||||
/**
|
||||
* @param retryable set by the validator based on failure type; the orchestrator must read this
|
||||
* and never override it.
|
||||
* Structural errors (e.g. invalid graph topology) are not retryable. Transient errors may be.
|
||||
*/
|
||||
data class ValidationFailure(val report: ValidationReport, val retryable: Boolean) : StageOutcome
|
||||
data class InferenceFailure(val reason: String, val retryable: Boolean) : StageOutcome
|
||||
data class ApprovalRequired(val request: DomainApprovalRequest) : StageOutcome
|
||||
data object Cancelled : StageOutcome
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.correx.core.kernel.execution
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
|
||||
sealed interface WorkflowResult {
|
||||
data class Completed(val sessionId: SessionId, val terminalStageId: StageId) : WorkflowResult
|
||||
data class Failed(val sessionId: SessionId, val reason: String, val retryExhausted: Boolean) : WorkflowResult
|
||||
data class Cancelled(val sessionId: SessionId) : WorkflowResult
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.RetryAttemptedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
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.orchestration.OrchestrationState
|
||||
import com.correx.core.events.orchestration.OrchestrationStatus
|
||||
|
||||
class DefaultOrchestrationReducer : OrchestrationReducer {
|
||||
override fun reduce(
|
||||
state: OrchestrationState,
|
||||
event: StoredEvent,
|
||||
): OrchestrationState = when (val p = event.payload) {
|
||||
is WorkflowStartedEvent -> state.copy(
|
||||
status = OrchestrationStatus.RUNNING,
|
||||
currentStageId = p.startStageId,
|
||||
retryPolicy = p.retryPolicy,
|
||||
)
|
||||
is WorkflowCompletedEvent -> state.copy(
|
||||
status = OrchestrationStatus.COMPLETED,
|
||||
currentStageId = p.terminalStageId,
|
||||
)
|
||||
|
||||
is WorkflowFailedEvent -> state.copy(status = OrchestrationStatus.FAILED, failureReason = p.reason)
|
||||
is OrchestrationPausedEvent -> state.copy(
|
||||
status = OrchestrationStatus.PAUSED,
|
||||
pendingApproval = true,
|
||||
pauseReason = p.reason,
|
||||
)
|
||||
|
||||
is OrchestrationResumedEvent -> state.copy(
|
||||
status = OrchestrationStatus.RUNNING,
|
||||
pendingApproval = false,
|
||||
pauseReason = null,
|
||||
)
|
||||
|
||||
is RetryAttemptedEvent -> state.copy(
|
||||
status = OrchestrationStatus.RUNNING,
|
||||
retryCount = p.attemptNumber,
|
||||
failureReason = null,
|
||||
)
|
||||
|
||||
else -> state
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.orchestration.OrchestrationState
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.kernel.execution.WorkflowResult
|
||||
import com.correx.core.kernel.retry.RetryCoordinator
|
||||
import com.correx.core.sessions.Session
|
||||
import com.correx.core.transitions.execution.StageExecutionResult
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.transitions.resolution.TransitionDecision
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
|
||||
@SuppressWarnings(
|
||||
"ReturnCount",
|
||||
"NestedBlockDepth",
|
||||
)
|
||||
class DefaultSessionOrchestrator(
|
||||
private val repositories: OrchestratorRepositories,
|
||||
engines: OrchestratorEngines,
|
||||
private val retryCoordinator: RetryCoordinator,
|
||||
) : SessionOrchestrator(repositories, engines) {
|
||||
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> =
|
||||
ConcurrentHashMap<SessionId, AtomicBoolean>()
|
||||
|
||||
override suspend fun run(
|
||||
sessionId: SessionId,
|
||||
graph: WorkflowGraph,
|
||||
config: OrchestrationConfig,
|
||||
): WorkflowResult {
|
||||
emitWorkflowStarted(sessionId, graph, config)
|
||||
val base = ExecutionContext(graph, sessionId, 0, graph.start, config, null, null)
|
||||
return step(base.enrich())
|
||||
}
|
||||
|
||||
private tailrec suspend fun step(ctx: EnrichedExecutionContext): WorkflowResult {
|
||||
if (isCancelled(ctx.sessionId)) return handleCancellation(ctx.sessionId, ctx.currentStageId)
|
||||
|
||||
val enriched = EnrichedExecutionContext(
|
||||
graph = ctx.graph,
|
||||
sessionId = ctx.sessionId,
|
||||
stageCount = ctx.stageCount,
|
||||
currentStageId = ctx.currentStageId,
|
||||
config = ctx.config,
|
||||
state = orchestrationRepository.getState(ctx.sessionId),
|
||||
session = repositories.sessionRepository.getSession(ctx.sessionId),
|
||||
)
|
||||
|
||||
return when (val decision = resolveTransition(enriched.graph, enriched.sessionId, enriched.currentStageId)) {
|
||||
is TransitionDecision.Move -> when (val r = executeMove(enriched, decision)) {
|
||||
is StepResult.Continue -> step(r.ctx)
|
||||
is StepResult.Terminal -> r.result
|
||||
}
|
||||
|
||||
is TransitionDecision.Stay -> if (isTerminal(enriched.graph, enriched.currentStageId)) {
|
||||
completeWorkflow(enriched.sessionId, enriched.currentStageId, enriched.stageCount)
|
||||
} else {
|
||||
step(enriched)
|
||||
}
|
||||
|
||||
is TransitionDecision.Blocked -> failWorkflow(
|
||||
sessionId = enriched.sessionId,
|
||||
stageId = enriched.currentStageId,
|
||||
reason = decision.reason,
|
||||
retryExhausted = false,
|
||||
)
|
||||
|
||||
is TransitionDecision.NoMatch -> failWorkflow(
|
||||
sessionId = enriched.sessionId,
|
||||
stageId = enriched.currentStageId,
|
||||
reason = "no matching transition from stage ${enriched.currentStageId.value}",
|
||||
retryExhausted = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override suspend fun cancel(sessionId: SessionId) {
|
||||
cancellations.getOrPut(sessionId) { AtomicBoolean(false) }.set(true)
|
||||
}
|
||||
|
||||
private suspend fun executeMove(
|
||||
ctx: EnrichedExecutionContext,
|
||||
decision: TransitionDecision.Move,
|
||||
): StepResult {
|
||||
val nextStageId = decision.to
|
||||
|
||||
if (isTerminal(ctx.graph, nextStageId)) {
|
||||
return StepResult.Terminal(
|
||||
completeWorkflow(ctx.sessionId, nextStageId, ctx.stageCount + 1),
|
||||
)
|
||||
}
|
||||
|
||||
return when (val result = executeStage(ctx.sessionId, nextStageId, ctx.graph, ctx.session, ctx.config)) {
|
||||
is StageExecutionResult.Success -> StepResult.Continue(
|
||||
ctx.copy(
|
||||
currentStageId = advanceStage(ctx.sessionId, ctx.currentStageId, decision),
|
||||
stageCount = ctx.stageCount + 1,
|
||||
),
|
||||
)
|
||||
|
||||
is StageExecutionResult.Failure -> {
|
||||
val shouldRetry = retryCoordinator.shouldRetry(
|
||||
sessionId = ctx.sessionId,
|
||||
stageId = nextStageId,
|
||||
currentAttempt = ctx.state.retryCount,
|
||||
policy = ctx.config.retryPolicy,
|
||||
failureReason = result.reason,
|
||||
)
|
||||
if (shouldRetry) {
|
||||
StepResult.Continue(ctx) // retryCount updated via projection on next iteration
|
||||
} else {
|
||||
StepResult.Terminal(
|
||||
failWorkflow(
|
||||
ctx.sessionId,
|
||||
ctx.currentStageId,
|
||||
result.reason,
|
||||
retryExhausted = result.retryable,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ExecutionContext.enrich() = EnrichedExecutionContext(
|
||||
graph, sessionId, stageCount, currentStageId, config,
|
||||
session = repositories.sessionRepository.getSession(sessionId),
|
||||
state = orchestrationRepository.getState(sessionId),
|
||||
)
|
||||
}
|
||||
|
||||
private sealed class StepResult {
|
||||
data class Continue(val ctx: EnrichedExecutionContext) : StepResult()
|
||||
data class Terminal(val result: WorkflowResult) : StepResult()
|
||||
}
|
||||
|
||||
private data class ExecutionContext(
|
||||
val graph: WorkflowGraph,
|
||||
val sessionId: SessionId,
|
||||
val stageCount: Int,
|
||||
val currentStageId: StageId,
|
||||
val config: OrchestrationConfig,
|
||||
val session: Session?,
|
||||
val state: OrchestrationState?,
|
||||
)
|
||||
|
||||
private data class EnrichedExecutionContext(
|
||||
val graph: WorkflowGraph,
|
||||
val sessionId: SessionId,
|
||||
val stageCount: Int,
|
||||
val currentStageId: StageId,
|
||||
val config: OrchestrationConfig,
|
||||
val session: Session,
|
||||
val state: OrchestrationState,
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.execution.RetryPolicy
|
||||
import com.correx.core.kernel.execution.ReplayStrategy
|
||||
|
||||
data class OrchestrationConfig(
|
||||
val retryPolicy: RetryPolicy = RetryPolicy(maxAttempts = 3),
|
||||
val replayStrategy: ReplayStrategy = ReplayStrategy.Full,
|
||||
val stageTimeoutMs: Long = 60_000L,
|
||||
)
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.orchestration.OrchestrationState
|
||||
import com.correx.core.sessions.projections.Projection
|
||||
|
||||
class OrchestrationProjector(
|
||||
private val orchestrationReducer: OrchestrationReducer,
|
||||
) : Projection<OrchestrationState> {
|
||||
override fun initial(): OrchestrationState = OrchestrationState()
|
||||
|
||||
override fun apply(
|
||||
state: OrchestrationState,
|
||||
event: StoredEvent,
|
||||
): OrchestrationState = orchestrationReducer.reduce(state, event)
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.orchestration.OrchestrationState
|
||||
|
||||
interface OrchestrationReducer {
|
||||
fun reduce(state: OrchestrationState, event: StoredEvent): OrchestrationState
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.orchestration.OrchestrationState
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||
|
||||
class OrchestrationRepository(
|
||||
private val replayer: EventReplayer<OrchestrationState>,
|
||||
) {
|
||||
fun getState(sessionId: SessionId): OrchestrationState = replayer.rebuild(sessionId)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
+1
@@ -0,0 +1 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.approvals.domain.ApprovalEngine
|
||||
import com.correx.core.context.builder.ContextPackBuilder
|
||||
import com.correx.core.inference.InferenceRouter
|
||||
import com.correx.core.risk.RiskAssessor
|
||||
import com.correx.core.transitions.resolution.TransitionResolver
|
||||
import com.correx.core.validation.pipeline.ValidationPipeline
|
||||
|
||||
data class OrchestratorEngines(
|
||||
val transitionResolver: TransitionResolver,
|
||||
val contextPackBuilder: ContextPackBuilder,
|
||||
val inferenceRouter: InferenceRouter,
|
||||
val validationPipeline: ValidationPipeline,
|
||||
val approvalEngine: ApprovalEngine,
|
||||
val riskAssessor: RiskAssessor,
|
||||
)
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.inference.InferenceRepository
|
||||
import com.correx.core.sessions.DefaultSessionRepository
|
||||
|
||||
data class OrchestratorRepositories(
|
||||
val eventStore: EventStore,
|
||||
val inferenceRepository: InferenceRepository,
|
||||
val orchestrationRepository: OrchestrationRepository,
|
||||
val sessionRepository: DefaultSessionRepository,
|
||||
)
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.approvals.domain.NoOpApprovalEngine
|
||||
import com.correx.core.context.model.ContextPack
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.InferenceRequestId
|
||||
import com.correx.core.inference.GenerationConfig
|
||||
import com.correx.core.inference.InferenceRequest
|
||||
import com.correx.core.kernel.execution.ReplayStrategy
|
||||
import com.correx.core.kernel.replay.ReplayInferenceProvider
|
||||
import com.correx.core.kernel.retry.exception.ReplayArtifactMissingException
|
||||
import com.correx.core.risk.NoOpRiskAssessor
|
||||
import com.correx.core.transitions.execution.StageExecutionResult
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.transitions.resolution.TransitionDecision
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.kernel.execution.WorkflowResult
|
||||
import com.correx.core.validation.model.ValidationContext
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
private data class ReplayContext(
|
||||
val graph: WorkflowGraph,
|
||||
val sessionId: SessionId,
|
||||
val currentStageId: StageId,
|
||||
val stageCount: Int,
|
||||
val config: OrchestrationConfig,
|
||||
)
|
||||
|
||||
/**
|
||||
* Replay is environment-independent. No live risk assessment or approval evaluation —
|
||||
* both are reconstructed from the event log (ApprovalGrantedEvent / ApprovalDeniedEvent).
|
||||
*/
|
||||
@SuppressWarnings("ReturnCount")
|
||||
class ReplayOrchestrator(
|
||||
private val repositories: OrchestratorRepositories,
|
||||
engines: OrchestratorEngines,
|
||||
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean>,
|
||||
private val strategy: ReplayStrategy,
|
||||
) : SessionOrchestrator(
|
||||
repositories,
|
||||
engines.copy(approvalEngine = NoOpApprovalEngine(), riskAssessor = NoOpRiskAssessor()),
|
||||
) {
|
||||
private val replayProvider = ReplayInferenceProvider(repositories.eventStore)
|
||||
|
||||
override suspend fun run(
|
||||
sessionId: SessionId,
|
||||
graph: WorkflowGraph,
|
||||
config: OrchestrationConfig,
|
||||
): WorkflowResult {
|
||||
emitWorkflowStarted(sessionId, graph, config)
|
||||
return step(ReplayContext(graph, sessionId, graph.start, 0, config))
|
||||
}
|
||||
|
||||
private tailrec suspend fun step(ctx: ReplayContext): WorkflowResult {
|
||||
if (isCancelled(ctx.sessionId)) return handleCancellation(ctx.sessionId, ctx.currentStageId)
|
||||
|
||||
val session = repositories.sessionRepository.getSession(ctx.sessionId)
|
||||
|
||||
return when (val decision = resolveTransition(ctx.graph, ctx.sessionId, ctx.currentStageId)) {
|
||||
is TransitionDecision.Move -> {
|
||||
val nextStageId = decision.to
|
||||
|
||||
if (isTerminal(ctx.graph, nextStageId)) {
|
||||
return completeWorkflow(ctx.sessionId, nextStageId, ctx.stageCount + 1)
|
||||
}
|
||||
|
||||
when (val result = executeStage(ctx.sessionId, nextStageId, ctx.graph, session, ctx.config)) {
|
||||
is StageExecutionResult.Success -> step(
|
||||
ctx.copy(
|
||||
currentStageId = advanceStage(ctx.sessionId, ctx.currentStageId, decision),
|
||||
stageCount = ctx.stageCount + 1,
|
||||
),
|
||||
)
|
||||
is StageExecutionResult.Failure ->
|
||||
failWorkflow(ctx.sessionId, ctx.currentStageId, result.reason, retryExhausted = false)
|
||||
}
|
||||
}
|
||||
|
||||
is TransitionDecision.Stay -> if (isTerminal(ctx.graph, ctx.currentStageId)) {
|
||||
completeWorkflow(ctx.sessionId, ctx.currentStageId, ctx.stageCount)
|
||||
} else {
|
||||
step(ctx)
|
||||
}
|
||||
|
||||
is TransitionDecision.Blocked -> failWorkflow(
|
||||
sessionId = ctx.sessionId,
|
||||
stageId = ctx.currentStageId,
|
||||
reason = decision.reason,
|
||||
retryExhausted = false,
|
||||
)
|
||||
|
||||
is TransitionDecision.NoMatch -> failWorkflow(
|
||||
sessionId = ctx.sessionId,
|
||||
stageId = ctx.currentStageId,
|
||||
reason = "no matching transition from stage ${ctx.currentStageId.value}",
|
||||
retryExhausted = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancel(sessionId: SessionId) {
|
||||
cancellations.getOrPut(sessionId) { AtomicBoolean(false) }.set(true)
|
||||
}
|
||||
|
||||
override suspend fun runInference(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
contextPack: ContextPack,
|
||||
stageConfig: StageConfig,
|
||||
timeoutMs: Long,
|
||||
): InferenceResult = when (strategy) {
|
||||
is ReplayStrategy.SkipInference -> {
|
||||
// bypass router entirely — use recorded artifact
|
||||
val request = InferenceRequest(
|
||||
requestId = InferenceRequestId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
contextPack = contextPack,
|
||||
generationConfig = GenerationConfig(
|
||||
temperature = 0.0,
|
||||
topP = 1.0,
|
||||
maxTokens = 0,
|
||||
seed = 0L,
|
||||
),
|
||||
)
|
||||
runCatching { replayProvider.infer(request) }
|
||||
.fold(
|
||||
onSuccess = { InferenceResult.Success(it) },
|
||||
onFailure = { e ->
|
||||
if (e is ReplayArtifactMissingException) {
|
||||
InferenceResult.Failed(e.message ?: "replay artifact missing")
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
else -> super.runInference(sessionId, stageId, contextPack, stageConfig, timeoutMs)
|
||||
}
|
||||
|
||||
override fun mapValidationOutcome(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
context: ValidationContext,
|
||||
): StageExecutionResult = when (strategy) {
|
||||
is ReplayStrategy.SkipValidation -> {
|
||||
// Replay-only: emit a synthetic ArtifactValidatedEvent for every artifact left in
|
||||
// VALIDATING at this stage. Without this, artifacts are permanently stuck because
|
||||
// the real validation path (which emits ArtifactValidatedEvent) is bypassed.
|
||||
val events = repositories.eventStore.read(sessionId)
|
||||
val alreadyValidated = events
|
||||
.mapNotNull { (it.payload as? ArtifactValidatedEvent) }
|
||||
.filter { it.stageId == stageId }
|
||||
.map { it.artifactId }
|
||||
.toSet()
|
||||
events
|
||||
.mapNotNull { (it.payload as? ArtifactValidatingEvent)?.takeIf { e -> e.stageId == stageId } }
|
||||
.filter { it.artifactId !in alreadyValidated }
|
||||
.forEach { emit(sessionId, ArtifactValidatedEvent(it.artifactId, sessionId, stageId)) }
|
||||
StageExecutionResult.Success(emptyList())
|
||||
}
|
||||
else -> super.mapValidationOutcome(sessionId, stageId, context)
|
||||
}
|
||||
}
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
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.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.EventMetadata
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.events.InferenceFailedEvent
|
||||
import com.correx.core.events.events.InferenceStartedEvent
|
||||
import com.correx.core.events.events.InferenceTimeoutEvent
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.RiskAssessedEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
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.ApprovalRequestId
|
||||
import com.correx.core.events.types.ContextPackId
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.InferenceRequestId
|
||||
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.inference.InferenceRepository
|
||||
import com.correx.core.inference.InferenceRequest
|
||||
import com.correx.core.inference.InferenceResponse
|
||||
import com.correx.core.inference.InferenceRouter
|
||||
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
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.correx.core.transitions.resolution.TransitionDecision
|
||||
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.TimeoutCancellationException
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.datetime.Clock
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
import java.util.concurrent.atomic.*
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@SuppressWarnings(
|
||||
"ForbiddenComment",
|
||||
"UnusedParameter",
|
||||
"ReturnCount",
|
||||
"TooGenericExceptionCaught",
|
||||
"TooManyFunctions",
|
||||
"NestedBlockDepth",
|
||||
)
|
||||
abstract class SessionOrchestrator(
|
||||
repositories: OrchestratorRepositories,
|
||||
engines: OrchestratorEngines,
|
||||
) {
|
||||
private val eventStore: EventStore = repositories.eventStore
|
||||
private val transitionResolver: TransitionResolver = engines.transitionResolver
|
||||
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>
|
||||
|
||||
abstract suspend fun run(
|
||||
sessionId: SessionId,
|
||||
graph: WorkflowGraph,
|
||||
config: OrchestrationConfig,
|
||||
): WorkflowResult
|
||||
|
||||
abstract suspend fun cancel(sessionId: SessionId)
|
||||
|
||||
// --- stage execution ---
|
||||
|
||||
internal suspend fun executeStage(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
graph: WorkflowGraph,
|
||||
session: Session,
|
||||
config: OrchestrationConfig,
|
||||
): StageExecutionResult {
|
||||
val stageConfig = requireNotNull(graph.stages[stageId]) {
|
||||
"Stage '${stageId.value}' not declared in workflow graph"
|
||||
}
|
||||
val contextPack = contextPackBuilder.build(
|
||||
id = ContextPackId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
entries = emptyList(),
|
||||
budget = TokenBudget(limit = stageConfig.tokenBudget),
|
||||
)
|
||||
|
||||
val inferenceResult = runInference(sessionId, stageId, contextPack, stageConfig, config.stageTimeoutMs)
|
||||
return when (inferenceResult) {
|
||||
is InferenceResult.Cancelled -> StageExecutionResult.Failure("CANCELLED", retryable = false)
|
||||
is InferenceResult.Failed -> StageExecutionResult.Failure(inferenceResult.reason, retryable = true)
|
||||
is InferenceResult.Success -> {
|
||||
if (isCancelled(sessionId)) return StageExecutionResult.Failure("CANCELLED", retryable = false)
|
||||
val validationCtx = ValidationContext(graph = graph, sessionState = session.state)
|
||||
mapValidationOutcome(sessionId, stageId, validationCtx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal open fun mapValidationOutcome(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
context: ValidationContext,
|
||||
): StageExecutionResult = when (val outcome = validationPipeline.validate(context)) {
|
||||
is ValidationOutcome.Passed -> StageExecutionResult.Success(emptyList())
|
||||
is ValidationOutcome.Rejected ->
|
||||
StageExecutionResult.Failure("validation failed", retryable = outcome.retryable)
|
||||
is ValidationOutcome.NeedsApproval -> handleApproval(sessionId, stageId, outcome)
|
||||
}
|
||||
|
||||
internal open suspend fun runInference(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
contextPack: ContextPack,
|
||||
stageConfig: StageConfig,
|
||||
timeoutMs: Long,
|
||||
): InferenceResult {
|
||||
val provider = inferenceRouter.route(stageId, stageConfig.requiredCapabilities)
|
||||
val requestId = InferenceRequestId(UUID.randomUUID().toString())
|
||||
val request = InferenceRequest(
|
||||
requestId = requestId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
contextPack = contextPack,
|
||||
generationConfig = stageConfig.generationConfig,
|
||||
)
|
||||
|
||||
emit(sessionId, InferenceStartedEvent(requestId, sessionId, stageId, provider.id))
|
||||
|
||||
if (isCancelled(sessionId)) return InferenceResult.Cancelled
|
||||
|
||||
return try {
|
||||
val response = withTimeout(timeoutMs) { provider.infer(request) }
|
||||
|
||||
if (isCancelled(sessionId)) return InferenceResult.Cancelled
|
||||
|
||||
emit(
|
||||
sessionId,
|
||||
InferenceCompletedEvent(
|
||||
requestId = requestId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
providerId = provider.id,
|
||||
tokensUsed = response.tokensUsed,
|
||||
latencyMs = response.latencyMs,
|
||||
),
|
||||
)
|
||||
|
||||
InferenceResult.Success(response)
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
emit(
|
||||
sessionId,
|
||||
InferenceTimeoutEvent(
|
||||
requestId = requestId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
providerId = provider.id,
|
||||
timeoutMs = timeoutMs,
|
||||
),
|
||||
)
|
||||
InferenceResult.Failed("TIMEOUT: ${e.message}")
|
||||
} catch (e: CancellationException) {
|
||||
throw e // never swallow
|
||||
} catch (e: Exception) {
|
||||
emit(
|
||||
sessionId,
|
||||
InferenceFailedEvent(
|
||||
requestId = requestId,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
providerId = provider.id,
|
||||
reason = e.message ?: "unknown",
|
||||
),
|
||||
)
|
||||
InferenceResult.Failed(e.message ?: "inference failed")
|
||||
}
|
||||
}
|
||||
|
||||
// --- transition helpers ---
|
||||
|
||||
internal fun resolveTransition(
|
||||
graph: WorkflowGraph,
|
||||
sessionId: SessionId,
|
||||
currentStageId: StageId,
|
||||
): TransitionDecision {
|
||||
val ctx = EvaluationContext(sessionId, currentStageId, emptyMap())
|
||||
return transitionResolver.resolve(graph, ctx)
|
||||
}
|
||||
|
||||
internal fun isTerminal(graph: WorkflowGraph, stageId: StageId): Boolean =
|
||||
graph.transitions.none { it.from == stageId }
|
||||
|
||||
internal fun advanceStage(
|
||||
sessionId: SessionId,
|
||||
fromStageId: StageId,
|
||||
decision: TransitionDecision.Move,
|
||||
): StageId {
|
||||
emit(sessionId, TransitionExecutedEvent(sessionId, fromStageId, decision.to, decision.transitionId))
|
||||
return decision.to
|
||||
}
|
||||
|
||||
// --- terminal state helpers ---
|
||||
|
||||
internal fun emitWorkflowStarted(sessionId: SessionId, graph: WorkflowGraph, config: OrchestrationConfig) {
|
||||
emit(
|
||||
sessionId,
|
||||
WorkflowStartedEvent(
|
||||
sessionId = sessionId,
|
||||
startStageId = graph.start,
|
||||
retryPolicy = config.retryPolicy,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun completeWorkflow(
|
||||
sessionId: SessionId,
|
||||
terminalStageId: StageId,
|
||||
stageCount: Int,
|
||||
): WorkflowResult.Completed {
|
||||
emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount))
|
||||
cancellations.remove(sessionId)
|
||||
return WorkflowResult.Completed(sessionId, terminalStageId)
|
||||
}
|
||||
|
||||
internal fun failWorkflow(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
reason: String,
|
||||
retryExhausted: Boolean,
|
||||
): WorkflowResult.Failed {
|
||||
emit(sessionId, WorkflowFailedEvent(sessionId, stageId, reason, retryExhausted))
|
||||
cancellations.remove(sessionId)
|
||||
return WorkflowResult.Failed(sessionId, reason, retryExhausted)
|
||||
}
|
||||
|
||||
internal fun handleCancellation(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
): WorkflowResult.Cancelled {
|
||||
emit(sessionId, WorkflowFailedEvent(sessionId, stageId, "CANCELLED", retryExhausted = false))
|
||||
cancellations.remove(sessionId)
|
||||
return WorkflowResult.Cancelled(sessionId)
|
||||
}
|
||||
|
||||
// --- cancellation ---
|
||||
|
||||
internal fun isCancelled(sessionId: SessionId): Boolean =
|
||||
cancellations[sessionId]?.get() == true
|
||||
|
||||
// --- event emission ---
|
||||
|
||||
internal fun emit(sessionId: SessionId, payload: EventPayload) {
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- private functions ---
|
||||
|
||||
private fun handleApproval(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
outcome: ValidationOutcome.NeedsApproval,
|
||||
): StageExecutionResult {
|
||||
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
|
||||
|
||||
val state = orchestrationRepository.getState(sessionId)
|
||||
val inferenceState = inferenceRepository.getInferenceState(sessionId)
|
||||
val riskSummary = riskAssessor.assess(
|
||||
RiskContext(
|
||||
validationReport = outcome.request.validationReport,
|
||||
orchestrationState = state,
|
||||
inferenceState = inferenceState,
|
||||
),
|
||||
)
|
||||
|
||||
val riskSummaryId = RiskSummaryId(UUID.randomUUID().toString())
|
||||
emit(
|
||||
sessionId,
|
||||
RiskAssessedEvent(sessionId, stageId, riskSummaryId, riskSummary.level, riskSummary.recommendedAction),
|
||||
)
|
||||
|
||||
val domainRequest = 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,
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed interface InferenceResult {
|
||||
data class Success(val response: InferenceResponse) : InferenceResult
|
||||
data class Failed(val reason: String) : InferenceResult
|
||||
data object Cancelled : InferenceResult
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.correx.core.kernel.replay
|
||||
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.inference.CapabilityScore
|
||||
import com.correx.core.inference.FinishReason
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.core.inference.InferenceRequest
|
||||
import com.correx.core.inference.InferenceResponse
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
import com.correx.core.inference.Token
|
||||
import com.correx.core.inference.Tokenizer
|
||||
import com.correx.core.kernel.retry.exception.ReplayArtifactMissingException
|
||||
|
||||
class ReplayInferenceProvider(
|
||||
private val eventStore: EventStore,
|
||||
override val name: String = "Replay Provider",
|
||||
) : InferenceProvider {
|
||||
|
||||
override val id: ProviderId = ProviderId("replay-provider")
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
override val tokenizer: Tokenizer = object : Tokenizer {
|
||||
override suspend fun tokenize(text: String): List<Token> =
|
||||
List(text.chunked(4).size) { i -> Token(i) } // 1 token ≈ 4 chars
|
||||
|
||||
override suspend fun countTokens(text: String): Int =
|
||||
(text.length + 3) / 4
|
||||
}
|
||||
|
||||
override suspend fun infer(request: InferenceRequest): InferenceResponse {
|
||||
val recorded = eventStore.read(request.sessionId)
|
||||
.map { it.payload }
|
||||
.filterIsInstance<InferenceCompletedEvent>()
|
||||
.firstOrNull { it.stageId == request.stageId }
|
||||
|
||||
?: throw ReplayArtifactMissingException(request.sessionId, request.stageId)
|
||||
|
||||
return InferenceResponse(
|
||||
requestId = request.requestId,
|
||||
text = "", // inference text is not recorded — replay produces empty text by design
|
||||
finishReason = FinishReason.Stop,
|
||||
tokensUsed = recorded.tokensUsed,
|
||||
latencyMs = recorded.latencyMs,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy
|
||||
|
||||
override fun capabilities(): Set<CapabilityScore> = emptySet()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.correx.core.kernel.retry
|
||||
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.execution.RetryPolicy
|
||||
import com.correx.core.events.events.RetryAttemptedEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class DefaultRetryCoordinator(
|
||||
private val eventStore: EventStore,
|
||||
) : RetryCoordinator {
|
||||
|
||||
override suspend fun shouldRetry(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
currentAttempt: Int,
|
||||
policy: RetryPolicy,
|
||||
failureReason: String,
|
||||
): Boolean {
|
||||
if (currentAttempt >= policy.maxAttempts) return false
|
||||
|
||||
val eventId = EventId(UUID.randomUUID().toString())
|
||||
val event = NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = eventId,
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = RetryAttemptedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
attemptNumber = currentAttempt + 1,
|
||||
maxAttempts = policy.maxAttempts,
|
||||
failureReason = failureReason,
|
||||
),
|
||||
)
|
||||
|
||||
eventStore.append(event)
|
||||
|
||||
if (policy.backoffMs > 0L) {
|
||||
delay(policy.backoffMs)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.correx.core.kernel.retry
|
||||
|
||||
import com.correx.core.events.execution.RetryPolicy
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
|
||||
interface RetryCoordinator {
|
||||
suspend fun shouldRetry(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
currentAttempt: Int,
|
||||
policy: RetryPolicy,
|
||||
failureReason: String,
|
||||
): Boolean
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.correx.core.kernel.retry.exception
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
|
||||
class ReplayArtifactMissingException(
|
||||
val sessionId: SessionId,
|
||||
val stageId: StageId,
|
||||
) : Exception("no recorded inference artifact for session=${sessionId.value} stage=${stageId.value}")
|
||||
Reference in New Issue
Block a user