feat(workflow): ExecutionPlanCompiler + ExecutionPlanLockedEvent (Slice 3)
- ExecutionPlanLockedEvent: registered, replay-deterministic record of the locked plan (CAS ref + compiled stage/start ids). - ExecutionPlanCompiler: validated execution_plan JSON -> WorkflowGraph, reusing ConditionSpec.toCondition(); inline prompts via metadata[promptInline]; malformed plans throw WorkflowValidationException. - Replay test: locked event + plan JSON recompiles to the identical graph, no architect re-run, no FS reads.
This commit is contained in:
+68
@@ -0,0 +1,68 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
import com.correx.core.artifacts.kind.ArtifactKindRegistry
|
||||
import com.correx.core.artifacts.kind.TypedArtifactSlot
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper
|
||||
import com.fasterxml.jackson.module.kotlin.kotlinModule
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
|
||||
private const val TERMINAL = "done"
|
||||
|
||||
class ExecutionPlanCompiler(
|
||||
private val registry: ArtifactKindRegistry,
|
||||
) {
|
||||
private val mapper = JsonMapper.builder()
|
||||
.addModule(kotlinModule())
|
||||
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.build()
|
||||
|
||||
fun compile(planJson: String, workflowId: String): WorkflowGraph {
|
||||
val plan = runCatching { mapper.readValue<ExecutionPlanModel>(planJson) }
|
||||
.getOrElse { throw WorkflowValidationException("Failed to parse execution_plan JSON: ${it.message}") }
|
||||
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
|
||||
|
||||
val stageMap = plan.stages.associate { s ->
|
||||
val kind = registry.get(s.produces)
|
||||
?: throw WorkflowValidationException("Unknown artifact kind '${s.produces}' in stage '${s.id}'")
|
||||
StageId(s.id) to StageConfig(
|
||||
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
|
||||
needs = s.needs.map { ArtifactId(it) }.toSet(),
|
||||
allowedTools = s.tools.toSet(),
|
||||
metadata = mapOf("promptInline" to s.prompt),
|
||||
)
|
||||
}
|
||||
val declared = stageMap.keys.map { it.value }.toSet()
|
||||
|
||||
val transitions = plan.edges.map { e ->
|
||||
if (e.from !in declared) {
|
||||
throw WorkflowValidationException("edge from unknown stage '${e.from}'")
|
||||
}
|
||||
if (e.to !in declared && e.to != TERMINAL) {
|
||||
throw WorkflowValidationException("edge to unknown stage '${e.to}'")
|
||||
}
|
||||
TransitionEdge(
|
||||
id = TransitionId("${e.from}->${e.to}"),
|
||||
from = StageId(e.from),
|
||||
to = StageId(e.to),
|
||||
condition = ConditionSpec(
|
||||
type = e.condition.type,
|
||||
artifactId = e.condition.artifactId,
|
||||
key = e.condition.key,
|
||||
field = e.condition.field,
|
||||
value = e.condition.value,
|
||||
operator = e.condition.operator,
|
||||
).toCondition(),
|
||||
)
|
||||
}.toSet()
|
||||
|
||||
val start = StageId(plan.stages.first().id)
|
||||
return WorkflowGraph(id = workflowId, stages = stageMap, transitions = transitions, start = start)
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class ExecutionPlanModel(
|
||||
val goal: String = "",
|
||||
val stages: List<PlanStage> = emptyList(),
|
||||
val edges: List<PlanEdge> = emptyList(),
|
||||
)
|
||||
|
||||
data class PlanStage(
|
||||
val id: String = "",
|
||||
val prompt: String = "",
|
||||
val produces: String = "",
|
||||
val needs: List<String> = emptyList(),
|
||||
val tools: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class PlanEdge(
|
||||
val from: String = "",
|
||||
val to: String = "",
|
||||
val condition: PlanCondition = PlanCondition(),
|
||||
)
|
||||
|
||||
data class PlanCondition(
|
||||
val type: String = "always_true",
|
||||
@param:JsonProperty("artifact_id") val artifactId: String? = null,
|
||||
val key: String? = null,
|
||||
val field: String? = null,
|
||||
val value: String? = null,
|
||||
val operator: String? = null,
|
||||
)
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
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 org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
class ExecutionPlanCompilerTest {
|
||||
|
||||
private val trivialSchema = JsonSchema(type = "object")
|
||||
private val registry = DefaultArtifactKindRegistry().also {
|
||||
it.register(ConfigArtifactKind(id = "patch", schema = trivialSchema, llmEmitted = true))
|
||||
}
|
||||
private val compiler = ExecutionPlanCompiler(registry)
|
||||
|
||||
private val validPlan = """
|
||||
{
|
||||
"goal": "apply a patch",
|
||||
"stages": [
|
||||
{
|
||||
"id": "analyse",
|
||||
"prompt": "Analyse the codebase",
|
||||
"produces": "patch",
|
||||
"needs": [],
|
||||
"tools": ["ShellTool"]
|
||||
},
|
||||
{
|
||||
"id": "review",
|
||||
"prompt": "Review the patch",
|
||||
"produces": "patch",
|
||||
"needs": ["patch"],
|
||||
"tools": []
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "analyse",
|
||||
"to": "review",
|
||||
"condition": { "type": "always_true" }
|
||||
},
|
||||
{
|
||||
"from": "review",
|
||||
"to": "done",
|
||||
"condition": { "type": "always_true" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `valid plan produces correct WorkflowGraph`() {
|
||||
val graph = compiler.compile(validPlan, "test-workflow")
|
||||
|
||||
assertEquals("test-workflow", graph.id)
|
||||
assertEquals(2, graph.stages.size)
|
||||
assertEquals(2, graph.transitions.size)
|
||||
assertEquals("analyse", graph.start.value)
|
||||
|
||||
val analyseStage = graph.stages[graph.start]
|
||||
assertNotNull(analyseStage)
|
||||
assertEquals("Analyse the codebase", analyseStage.metadata["promptInline"])
|
||||
assertEquals(setOf("patch"), analyseStage.produces.map { it.name.value }.toSet())
|
||||
assertEquals(setOf("ShellTool"), analyseStage.allowedTools)
|
||||
|
||||
val reviewStage = graph.stages.values.first { it.metadata["promptInline"] == "Review the patch" }
|
||||
assertEquals(setOf("patch"), reviewStage.needs.map { it.value }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edge referencing unknown from-stage throws WorkflowValidationException`() {
|
||||
val bad = validPlan.replace("\"from\": \"analyse\"", "\"from\": \"nonexistent\"")
|
||||
assertThrows<WorkflowValidationException> { compiler.compile(bad, "bad-workflow") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edge referencing unknown to-stage throws WorkflowValidationException`() {
|
||||
val bad = validPlan.replace("\"to\": \"review\"", "\"to\": \"nonexistent\"")
|
||||
assertThrows<WorkflowValidationException> { compiler.compile(bad, "bad-workflow") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unregistered produces kind throws WorkflowValidationException`() {
|
||||
val bad = validPlan.replace("\"produces\": \"patch\"", "\"produces\": \"unknown_kind\"")
|
||||
assertThrows<WorkflowValidationException> { compiler.compile(bad, "bad-workflow") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty stages throws WorkflowValidationException`() {
|
||||
val bad = """{ "goal": "x", "stages": [], "edges": [] }"""
|
||||
assertThrows<WorkflowValidationException> { compiler.compile(bad, "bad-workflow") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed JSON throws WorkflowValidationException`() {
|
||||
assertThrows<WorkflowValidationException> { compiler.compile("not-json", "bad-workflow") }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user