diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 8c8dff1f..2cc8fed4 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -375,6 +375,7 @@ fun main() { planContent = { sid -> orchestrator.validatedArtifactContent(sid, ArtifactId("execution_plan")) }, config = defaultOrchestrationConfig, runPhase2 = { sid, graph, cfg -> orchestrator.run(sid, graph, cfg) }, + requestPlanApproval = { sid, planJson -> orchestrator.requestPlanApproval(sid, planJson) }, ) val module = ServerModule( orchestrator = orchestrator, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt b/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt index 98b3579d..2d0e12f2 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt @@ -29,6 +29,7 @@ class FreestyleDriver( private val planContent: (SessionId) -> String?, private val config: OrchestrationConfig, private val runPhase2: suspend (SessionId, WorkflowGraph, OrchestrationConfig) -> WorkflowResult, + private val requestPlanApproval: suspend (SessionId, String) -> Boolean = { _, _ -> true }, ) { suspend fun lockAndRun(sessionId: SessionId) { val json = planContent(sessionId) ?: run { @@ -42,6 +43,12 @@ class FreestyleDriver( emitRejected(sessionId, "plan failed to compile: ${it.message}", "compile") return } + val approved = requestPlanApproval(sessionId, json) + if (!approved) { + log.info("freestyle: execution plan rejected by operator for session={}", sessionId.value) + emitRejected(sessionId, "rejected by operator at plan review", "operator") + return + } eventStore.append( NewEvent( metadata = EventMetadata( diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt index 00ef6356..58930a37 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt @@ -167,4 +167,78 @@ class FreestyleDriverTest { assertEquals(1, rejectedEvents.size, "Expected exactly one ExecutionPlanRejectedEvent") assertEquals("missing_content", rejectedEvents.single().source) } + + @Test + fun `operator rejection at plan gate emits rejected and skips lock and phase2`(): Unit = runBlocking { + val sessionId = SessionId("driver-operator-reject-session") + val eventStore = InMemoryEventStore() + val registry = buildRegistry() + val compiler = ExecutionPlanCompiler(registry) + + var runPhase2Invocations = 0 + + val driver = FreestyleDriver( + eventStore = eventStore, + compiler = compiler, + planContent = { validPlanJson }, + config = OrchestrationConfig(), + runPhase2 = { _, _, _ -> + runPhase2Invocations++ + WorkflowResult.Completed(sessionId, com.correx.core.events.types.StageId("done")) + }, + requestPlanApproval = { _, _ -> false }, + ) + + driver.lockAndRun(sessionId) + + val lockedEvents = eventStore.read(sessionId) + .map { it.payload } + .filterIsInstance() + + assertTrue(lockedEvents.isEmpty(), "No lock event expected when operator rejects") + assertEquals(0, runPhase2Invocations, "runPhase2 must not be called when operator rejects") + + val rejectedEvents = eventStore.read(sessionId) + .map { it.payload } + .filterIsInstance() + assertEquals(1, rejectedEvents.size, "Expected exactly one ExecutionPlanRejectedEvent") + assertEquals("operator", rejectedEvents.single().source) + } + + @Test + fun `operator approval at plan gate locks and runs phase2`(): Unit = runBlocking { + val sessionId = SessionId("driver-operator-approve-session") + val eventStore = InMemoryEventStore() + val registry = buildRegistry() + val compiler = ExecutionPlanCompiler(registry) + + var runPhase2Invocations = 0 + var approvalPreview: String? = null + + val driver = FreestyleDriver( + eventStore = eventStore, + compiler = compiler, + planContent = { validPlanJson }, + config = OrchestrationConfig(), + runPhase2 = { sid, graph, _ -> + runPhase2Invocations++ + WorkflowResult.Completed(sid, graph.start) + }, + requestPlanApproval = { _, preview -> + approvalPreview = preview + assertTrue(preview.contains("stages"), "Preview should contain plan JSON with stages") + true + }, + ) + + driver.lockAndRun(sessionId) + + val lockedEvents = eventStore.read(sessionId) + .map { it.payload } + .filterIsInstance() + + assertEquals(1, lockedEvents.size, "Expected exactly one ExecutionPlanLockedEvent after approval") + assertEquals(1, runPhase2Invocations, "runPhase2 should be called exactly once after approval") + assertTrue(approvalPreview != null, "requestPlanApproval should have been invoked") + } } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index f959cc5d..5f1f974d 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -1560,6 +1560,10 @@ abstract class SessionOrchestrator( } } + /** Public seam for out-of-band gates (e.g. freestyle plan review) that reuse the stage-approval flow. */ + suspend fun requestPlanApproval(sessionId: SessionId, preview: String): Boolean = + requestStageApproval(sessionId, StageId("plan_review"), preview) + // --- private functions --- private suspend fun handleApproval(