From 0de02bb3a71d9096b77bd77ca5491a88de833a39 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 10 Jun 2026 21:36:17 +0400 Subject: [PATCH] =?UTF-8?q?feat(server,kernel):=20post-plan=20approval=20g?= =?UTF-8?q?ate=20=E2=80=94=20operator=20reviews=20plan=20before=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a freestyle plan compiles successfully, requestPlanApproval is invoked before ExecutionPlanLockedEvent is emitted. Rejection emits ExecutionPlanRejectedEvent(source="operator") with no lock and no phase-2 run. Adds public SessionOrchestrator.requestPlanApproval seam delegating to requestStageApproval, wired in Main.kt. Two new tests cover the reject and approve paths; existing tests stay green via the default passthrough param. --- .../kotlin/com/correx/apps/server/Main.kt | 1 + .../apps/server/freestyle/FreestyleDriver.kt | 7 ++ .../server/freestyle/FreestyleDriverTest.kt | 74 +++++++++++++++++++ .../orchestration/SessionOrchestrator.kt | 4 + 4 files changed, 86 insertions(+) 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(