feat(server,kernel): post-plan approval gate — operator reviews plan before lock

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.
This commit is contained in:
2026-06-10 21:36:17 +04:00
parent 3d43f34d3b
commit 0de02bb3a7
4 changed files with 86 additions and 0 deletions
@@ -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,
@@ -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(
@@ -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<ExecutionPlanLockedEvent>()
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<ExecutionPlanRejectedEvent>()
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<ExecutionPlanLockedEvent>()
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")
}
}
@@ -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(