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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user