37ac767d1f
- freestyle_planning.toml: analyst -> (approval) -> architect, producing execution_plan; analyst_freestyle.md surfaces open questions. - TomlWorkflowLoader: requires_approval stage flag -> metadata. - SessionOrchestrator: inline-prompt branch (metadata[promptInline]) + requestStageApproval helper reusing the existing pause/approve path. - DefaultSessionOrchestrator: enterStage gates on requiresApproval (idempotent via resolved-approval lookup), preview derived from the gated stage's needs artifact; validatedArtifactContent accessor. - FreestyleDriver: compiles validated plan, emits ExecutionPlanLockedEvent, runs phase 2 in-session via a runPhase2 seam; wired through ServerModule + Main (execution_plan kind registered).
255 lines
11 KiB
Kotlin
255 lines
11 KiB
Kotlin
import com.correx.core.approvals.ApprovalOutcome
|
|
import com.correx.core.approvals.ApprovalProjector
|
|
import com.correx.core.approvals.ApprovalStatus
|
|
import com.correx.core.approvals.DefaultApprovalReducer
|
|
import com.correx.core.approvals.DefaultApprovalRepository
|
|
import com.correx.core.approvals.Tier
|
|
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
|
import com.correx.core.approvals.model.ApprovalContext
|
|
import com.correx.core.approvals.model.ApprovalDecision
|
|
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
|
import com.correx.core.artifacts.DefaultArtifactReducer
|
|
import com.correx.core.artifacts.kind.ConfigArtifactKind
|
|
import com.correx.core.artifacts.kind.JsonSchema
|
|
import com.correx.core.artifacts.kind.TypedArtifactSlot
|
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
|
import com.correx.core.events.events.OrchestrationPausedEvent
|
|
import com.correx.core.events.events.WorkflowCompletedEvent
|
|
import com.correx.core.events.execution.RetryPolicy
|
|
import com.correx.core.events.types.ArtifactId
|
|
import com.correx.core.events.types.SessionId
|
|
import com.correx.core.events.types.StageId
|
|
import com.correx.core.events.types.TransitionId
|
|
import com.correx.core.inference.InferenceRepository
|
|
import com.correx.core.inference.InferenceState
|
|
import com.correx.core.inference.ModelCapability
|
|
import com.correx.core.journal.DecisionJournalProjector
|
|
import com.correx.core.journal.DefaultDecisionJournalReducer
|
|
import com.correx.core.journal.DefaultDecisionJournalRepository
|
|
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
|
|
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
|
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
|
import com.correx.core.kernel.orchestration.OrchestrationProjector
|
|
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
|
import com.correx.core.kernel.orchestration.OrchestratorEngines
|
|
import com.correx.core.kernel.orchestration.OrchestratorRepositories
|
|
import com.correx.core.kernel.retry.DefaultRetryCoordinator
|
|
import com.correx.core.risk.DefaultRiskAssessor
|
|
import com.correx.core.sessions.ApprovalMode
|
|
import com.correx.core.sessions.DefaultSessionRepository
|
|
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
|
import com.correx.core.sessions.projections.replay.EventReplayer
|
|
import com.correx.core.transitions.graph.StageConfig
|
|
import com.correx.core.transitions.graph.TransitionEdge
|
|
import com.correx.core.transitions.graph.WorkflowGraph
|
|
import com.correx.core.validation.pipeline.ValidationPipeline
|
|
import com.correx.infrastructure.persistence.InMemoryEventStore
|
|
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
|
|
import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore
|
|
import com.correx.testing.fixtures.cyclePolicyMissingValidator
|
|
import com.correx.testing.fixtures.context.ContextFixtures
|
|
import com.correx.testing.fixtures.inference.MockInferenceProvider
|
|
import com.correx.testing.fixtures.transitions.TransitionFixtures
|
|
import com.correx.testing.kernel.MockSessionEventReplayer
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.runBlocking
|
|
import kotlinx.coroutines.withTimeout
|
|
import kotlinx.coroutines.yield
|
|
import kotlinx.datetime.Clock
|
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
|
import org.junit.jupiter.api.Assertions.assertTrue
|
|
import org.junit.jupiter.api.Test
|
|
|
|
/**
|
|
* Integration test for Task 4.2: the approval gate on stages flagged `requiresApproval`.
|
|
*
|
|
* Drives analyst→architect with a sequenced stub provider. Asserts that:
|
|
* 1. An [OrchestrationPausedEvent] is emitted before the architect runs, with the analyst
|
|
* summary in the paired [ApprovalRequestedEvent.preview].
|
|
* 2. Submitting an approval unblocks the run and the workflow completes with the architect's
|
|
* execution_plan artifact.
|
|
*/
|
|
class FreestyleApprovalGateTest {
|
|
|
|
private val eventStore = InMemoryEventStore()
|
|
private val sessionRepository = DefaultSessionRepository(MockSessionEventReplayer())
|
|
private val orchestrationRepository = OrchestrationRepository(
|
|
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
|
|
)
|
|
private val inferenceRepository = InferenceRepository(
|
|
object : EventReplayer<InferenceState> {
|
|
override fun rebuild(sessionId: SessionId) = InferenceState()
|
|
},
|
|
)
|
|
private val approvalRepository = DefaultApprovalRepository(
|
|
DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())),
|
|
)
|
|
private val decisionJournalRepository = DefaultDecisionJournalRepository(
|
|
DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())),
|
|
)
|
|
|
|
private val repositories = OrchestratorRepositories(
|
|
eventStore = eventStore,
|
|
inferenceRepository = inferenceRepository,
|
|
orchestrationRepository = orchestrationRepository,
|
|
sessionRepository = sessionRepository,
|
|
artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()),
|
|
approvalRepository = approvalRepository,
|
|
)
|
|
|
|
private val analysisKind = ConfigArtifactKind(
|
|
id = "analysis",
|
|
schema = JsonSchema(type = "object", properties = emptyMap()),
|
|
llmEmitted = true,
|
|
)
|
|
|
|
private val executionPlanKind = ConfigArtifactKind(
|
|
id = "execution_plan",
|
|
schema = JsonSchema(type = "object", properties = emptyMap()),
|
|
llmEmitted = true,
|
|
)
|
|
|
|
private val analysisId = ArtifactId("analysis")
|
|
private val executionPlanId = ArtifactId("execution_plan")
|
|
private val analystStage = StageId("analyst")
|
|
private val architectStage = StageId("architect")
|
|
|
|
/** Graph mirrors freestyle_planning.toml: architect stage has requiresApproval metadata. */
|
|
private fun freestyleGraph() = WorkflowGraph(
|
|
id = "freestyle_planning",
|
|
stages = mapOf(
|
|
analystStage to StageConfig(
|
|
produces = listOf(TypedArtifactSlot(name = analysisId, kind = analysisKind)),
|
|
),
|
|
architectStage to StageConfig(
|
|
produces = listOf(TypedArtifactSlot(name = executionPlanId, kind = executionPlanKind)),
|
|
needs = setOf(analysisId),
|
|
metadata = mapOf("requiresApproval" to "true"),
|
|
),
|
|
),
|
|
transitions = setOf(
|
|
TransitionEdge(
|
|
id = TransitionId("analyst-to-architect"),
|
|
from = analystStage,
|
|
to = architectStage,
|
|
condition = { true },
|
|
),
|
|
TransitionEdge(
|
|
id = TransitionId("architect-to-done"),
|
|
from = architectStage,
|
|
to = StageId("done"),
|
|
condition = { true },
|
|
),
|
|
),
|
|
start = analystStage,
|
|
)
|
|
|
|
private val analysisSummary = """{"summary":"open questions: feasibility of X, timeline for Y"}"""
|
|
private val executionPlanJson = """{"plan":"step 1: do A, step 2: do B"}"""
|
|
|
|
private fun buildOrchestrator(): DefaultSessionOrchestrator {
|
|
// Sequenced provider: first call (analyst) returns the analysis JSON,
|
|
// second call (architect) returns the execution plan JSON.
|
|
var callCount = 0
|
|
val router = object : com.correx.core.inference.InferenceRouter {
|
|
override suspend fun route(
|
|
stageId: StageId,
|
|
requiredCapabilities: Set<ModelCapability>,
|
|
) = MockInferenceProvider(
|
|
fixedResponse = if (callCount++ == 0) analysisSummary else executionPlanJson,
|
|
)
|
|
}
|
|
|
|
return DefaultSessionOrchestrator(
|
|
repositories = repositories,
|
|
engines = OrchestratorEngines(
|
|
transitionResolver = TransitionFixtures.simpleResolver(),
|
|
contextPackBuilder = ContextFixtures.simpleBuilder(),
|
|
inferenceRouter = router,
|
|
validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())),
|
|
approvalEngine = DefaultApprovalEngine(),
|
|
riskAssessor = DefaultRiskAssessor(),
|
|
),
|
|
retryCoordinator = DefaultRetryCoordinator(eventStore),
|
|
artifactStore = NoopArtifactStore(),
|
|
decisionJournalRepository = decisionJournalRepository,
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun `pause event is emitted with analyst summary before architect runs`(): Unit = runBlocking {
|
|
val sessionId = SessionId("freestyle-gate-1")
|
|
val orchestrator = buildOrchestrator()
|
|
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
|
|
|
|
val runJob = launch { orchestrator.run(sessionId, freestyleGraph(), config) }
|
|
|
|
// Wait for the pause event before architect
|
|
withTimeout(5_000) {
|
|
while (eventStore.read(sessionId).none { it.payload is OrchestrationPausedEvent }) {
|
|
yield()
|
|
}
|
|
}
|
|
|
|
val events = eventStore.read(sessionId)
|
|
val pause = events.firstNotNullOfOrNull { it.payload as? OrchestrationPausedEvent }
|
|
assertNotNull(pause, "Expected OrchestrationPausedEvent")
|
|
assertTrue(pause!!.stageId == architectStage, "Pause should be for architect stage, got ${pause.stageId}")
|
|
assertTrue(pause.reason == "APPROVAL_PENDING", "Expected reason APPROVAL_PENDING, got ${pause.reason}")
|
|
|
|
val approvalRequest = events.firstNotNullOfOrNull { it.payload as? ApprovalRequestedEvent }
|
|
assertNotNull(approvalRequest, "Expected ApprovalRequestedEvent")
|
|
assertTrue(
|
|
approvalRequest!!.preview?.contains("open questions") == true ||
|
|
approvalRequest.preview?.contains("summary") == true,
|
|
"Approval preview should carry analyst summary, got: ${approvalRequest.preview}",
|
|
)
|
|
|
|
runJob.cancel()
|
|
runJob.join()
|
|
}
|
|
|
|
@Test
|
|
fun `approval resumes workflow and architect produces execution plan`(): Unit = runBlocking {
|
|
val sessionId = SessionId("freestyle-gate-2")
|
|
val orchestrator = buildOrchestrator()
|
|
val config = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
|
|
|
|
val runJob = launch { orchestrator.run(sessionId, freestyleGraph(), config) }
|
|
|
|
// Wait for the approval request
|
|
val requestId = withTimeout(5_000) {
|
|
var id: com.correx.core.events.types.ApprovalRequestId? = null
|
|
while (id == null) {
|
|
id = eventStore.read(sessionId)
|
|
.firstNotNullOfOrNull { it.payload as? ApprovalRequestedEvent }
|
|
?.requestId
|
|
if (id == null) yield()
|
|
}
|
|
id
|
|
}
|
|
|
|
val identity = ApprovalScopeIdentity(sessionId = sessionId, stageId = architectStage, projectId = null)
|
|
val context = ApprovalContext(identity = identity, mode = ApprovalMode.PROMPT)
|
|
val decision = ApprovalDecision(
|
|
id = null,
|
|
requestId = requestId,
|
|
outcome = ApprovalOutcome.APPROVED,
|
|
state = ApprovalStatus.COMPLETED,
|
|
tier = Tier.T2,
|
|
contextSnapshot = context,
|
|
resolutionTimestamp = Clock.System.now(),
|
|
reason = null,
|
|
)
|
|
orchestrator.submitApprovalDecision(requestId, decision)
|
|
|
|
runJob.join()
|
|
|
|
val events = eventStore.read(sessionId)
|
|
assertNotNull(
|
|
events.find { it.payload is WorkflowCompletedEvent },
|
|
"Workflow should complete after approval",
|
|
)
|
|
}
|
|
}
|