diff --git a/1.txt b/1.txt new file mode 100644 index 00000000..7999426c --- /dev/null +++ b/1.txt @@ -0,0 +1 @@ +1.txt \ No newline at end of file diff --git a/2.txt b/2.txt new file mode 100644 index 00000000..6757339c --- /dev/null +++ b/2.txt @@ -0,0 +1 @@ +2.txt \ No newline at end of file diff --git a/3.txt b/3.txt new file mode 100644 index 00000000..66c5144b --- /dev/null +++ b/3.txt @@ -0,0 +1 @@ +3.txt \ No newline at end of file diff --git a/examples/workflows/prompts/architect_freestyle.md b/examples/workflows/prompts/architect_freestyle.md index 66e88db6..2487e8e8 100644 --- a/examples/workflows/prompts/architect_freestyle.md +++ b/examples/workflows/prompts/architect_freestyle.md @@ -70,6 +70,10 @@ Emit a JSON object that validates against the `execution_plan` schema: - Chain sequential stages: stage N's edge goes `to` stage N+1, not to `"done"`. Only the final stage's edge points to `"done"`. A plan where an intermediate stage jumps to `"done"` ends the whole workflow there and never runs the remaining stages. +- An `artifact_field_equals` edge may only check a field that the producing stage's + `kind` schema declares. A verdict-checking stage must use a kind with a `verdict` + field (`review_report`) — a plan that checks a field its kind cannot emit fails to + compile. - For a review loop (implementer ↔ reviewer): emit two conditional edges from the reviewer stage: - `"type": "artifact_field_equals"`, `artifact_id`: reviewer's produces id, diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt index 97e9d6b7..5991750b 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt @@ -50,6 +50,7 @@ class ExecutionPlanCompiler( if (e.to !in declared && e.to != TERMINAL) { throw WorkflowValidationException("edge to unknown stage '${e.to}'") } + validateFieldCondition(plan, e) TransitionEdge( id = TransitionId("${e.from}->${e.to}"), from = StageId(e.from), @@ -66,6 +67,32 @@ class ExecutionPlanCompiler( }.toSet() val start = StageId(plan.stages.first().id) + validateReachability(declared, transitions, start) + return WorkflowGraph(id = workflowId, stages = stageMap, transitions = transitions, start = start) + } + + /** + * A field-equals edge can only ever fire if the producing stage's kind schema declares + * that field — the kind's schema is also the LLM response format, so an undeclared field + * (live repro: 'verdict' checked on an 'analysis' artifact) is never emitted and the + * condition fails the whole workflow at runtime instead of the plan failing at lock time. + */ + private fun validateFieldCondition(plan: ExecutionPlanModel, e: PlanEdge) { + if (e.condition.type != "artifact_field_equals") return + val field = e.condition.field ?: return + val producing = plan.stages.firstOrNull { it.produces == e.condition.artifactId } ?: return + val kindId = producing.kind ?: producing.produces + val schema = registry.get(kindId)?.deriveJsonSchema() ?: return + if (!schema.properties.containsKey(field)) { + throw WorkflowValidationException( + "edge '${e.from}->${e.to}' checks field '$field' of artifact '${e.condition.artifactId}', " + + "but its kind '$kindId' does not declare that field — use a kind whose schema has it " + + "(e.g. review_report for verdict)", + ) + } + } + + private fun validateReachability(declared: Set, transitions: Set, start: StageId) { // A declared stage with no path from start can never run — typical LLM topology // failure: every stage wired straight to "done" instead of chained, which silently // truncates the plan to its first stage. @@ -85,6 +112,5 @@ class ExecutionPlanCompiler( "chain stages with edges instead of pointing every stage to 'done'", ) } - return WorkflowGraph(id = workflowId, stages = stageMap, transitions = transitions, start = start) } } diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt index 0eb21285..8e2163a9 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt @@ -3,6 +3,7 @@ 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 org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import kotlin.test.assertEquals @@ -174,6 +175,62 @@ class ExecutionPlanCompilerTest { assertEquals("create_1", graph.start.value) } + @Test + fun `field condition on a kind without that field throws WorkflowValidationException`() { + // Live repro (2026-06-12): a reviewer stage with kind=analysis and a verdict-equals + // edge — analysis never emits 'verdict', so the workflow failed at runtime. + val plan = """ + { + "goal": "verify", + "stages": [ + { "id": "verify", "prompt": "verify", "produces": "verify_out", "kind": "patch" } + ], + "edges": [ + { + "from": "verify", "to": "done", + "condition": { "type": "artifact_field_equals", "artifact_id": "verify_out", "field": "verdict", "value": "approved", "operator": "eq" } + } + ] + } + """.trimIndent() + val ex = assertThrows { compiler.compile(plan, "bad-workflow") } + assertEquals(true, ex.message?.contains("verdict")) + } + + @Test + fun `field condition on a kind declaring the field compiles`() { + val verdictRegistry = DefaultArtifactKindRegistry().also { + it.register( + ConfigArtifactKind( + id = "review_report", + schema = JsonSchema( + type = "object", + properties = mapOf("verdict" to JsonSchemaProperty(type = "string")), + required = listOf("verdict"), + additionalProperties = true, + ), + llmEmitted = true, + ), + ) + } + val plan = """ + { + "goal": "verify", + "stages": [ + { "id": "verify", "prompt": "verify", "produces": "verify_out", "kind": "review_report" } + ], + "edges": [ + { + "from": "verify", "to": "done", + "condition": { "type": "artifact_field_equals", "artifact_id": "verify_out", "field": "verdict", "value": "approved", "operator": "eq" } + } + ] + } + """.trimIndent() + val graph = ExecutionPlanCompiler(verdictRegistry).compile(plan, "verdict-workflow") + assertEquals(1, graph.stages.size) + } + @Test fun `empty stages throws WorkflowValidationException`() { val bad = """{ "goal": "x", "stages": [], "edges": [] }"""