feat(freestyle): two-phase planning->execution driver (Slice 4)

- 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).
This commit is contained in:
2026-06-08 02:50:36 +04:00
parent 6c8c5e2ad9
commit 37ac767d1f
11 changed files with 744 additions and 17 deletions
@@ -39,6 +39,7 @@ private data class StageSection(
@param:JsonProperty("allowed_tools") val allowedTools: List<String> = emptyList(),
@param:JsonProperty("token_budget") val tokenBudget: Int = 4096,
@param:JsonProperty("max_retries") val maxRetries: Int = 3,
@param:JsonProperty("requires_approval") val requiresApproval: Boolean = false,
)
// condition fields flattened into the transition row
@@ -94,6 +95,7 @@ class TomlWorkflowLoader(
metadata = buildMap {
s.prompt?.let { put("prompt", resolvePromptPath(it, workflowDir)) }
s.systemPrompt?.let { put("systemPrompt", resolvePromptPath(it, workflowDir)) }
if (s.requiresApproval) put("requiresApproval", "true")
},
)
}
@@ -0,0 +1,73 @@
package com.correx.infrastructure.workflow
import com.correx.core.artifacts.kind.ConfigArtifactKind
import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry
import com.correx.core.artifacts.kind.JsonSchema
import com.correx.core.artifacts.kind.JsonSchemaProperty
import com.correx.core.events.types.StageId
import com.correx.core.transitions.conditions.ArtifactValidated
import org.junit.jupiter.api.Assumptions.assumeTrue
import org.junit.jupiter.api.Test
import java.nio.file.Path
import kotlin.io.path.exists
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class FreestylePlanningWorkflowTest {
private fun llmKind(id: String) = ConfigArtifactKind(
id = id,
schema = JsonSchema(
type = "object",
properties = mapOf("summary" to JsonSchemaProperty(type = "string")),
additionalProperties = true,
),
llmEmitted = true,
)
private val registry = DefaultArtifactKindRegistry().apply {
register(llmKind("analysis"))
register(llmKind("execution_plan"))
}
private fun repoFile(rel: String): Path? {
var dir: Path? = Path.of(System.getProperty("user.dir")).toAbsolutePath()
while (dir != null) {
val candidate = dir.resolve(rel)
if (candidate.exists()) return candidate
dir = dir.parent
}
return null
}
@Test
fun `freestyle_planning toml parses with two non-terminal stages and both edges`() {
val path = repoFile("examples/workflows/freestyle_planning.toml")
assumeTrue(path != null, "freestyle_planning.toml not found from ${System.getProperty("user.dir")}")
val graph = TomlWorkflowLoader(registry).load(path!!)
assertEquals(
setOf("analyst", "architect"),
graph.stages.keys.map { it.value }.toSet(),
)
val architect = graph.stages[StageId("architect")]!!
assertTrue(
architect.produces.any { it.name.value == "execution_plan" && it.kind.llmEmitted },
"architect must produce execution_plan",
)
assertTrue(architect.needs.map { it.value }.contains("analysis"), "architect needs analysis")
val analystToArchitect = graph.transitions.first { it.from == StageId("analyst") }
assertEquals(StageId("architect"), analystToArchitect.to)
assertTrue(analystToArchitect.condition is ArtifactValidated)
val architectToDone = graph.transitions.first { it.from == StageId("architect") }
assertEquals(StageId("done"), architectToDone.to)
assertTrue(architectToDone.condition is ArtifactValidated)
assertEquals(2, graph.transitions.size)
}
}