feat(workflow): plan-derived diff manifest (B§2)
PlanStage gains a `writes` field (planner-declared workspace-relative paths/globs); PlanDerivedManifest.deriveAllowedWrites + combine union it with the static baseline. ExecutionPlanCompiler wires the union into StageConfig.writeManifest, enforced by the existing ManifestContainmentRule via the same glob matcher (no parallel matcher). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+10
@@ -34,10 +34,20 @@ class ExecutionPlanCompiler(
|
|||||||
val kindId = s.kind ?: s.produces
|
val kindId = s.kind ?: s.produces
|
||||||
val kind = registry.get(kindId)
|
val kind = registry.get(kindId)
|
||||||
?: throw WorkflowValidationException("Unknown artifact kind '$kindId' in stage '${s.id}'")
|
?: throw WorkflowValidationException("Unknown artifact kind '$kindId' in stage '${s.id}'")
|
||||||
|
// Write-guard manifest = static per-stage globs UNION the plan-derived set (BACKLOG
|
||||||
|
// §B-§2). Plan stages carry no separate static `writes` glob list today, so the static
|
||||||
|
// baseline here is empty and the plan-derived paths form the manifest; the union is kept
|
||||||
|
// explicit so a future static source widens rather than replaces. Sorted for a
|
||||||
|
// deterministic, replay-stable manifest.
|
||||||
|
val writeManifest = PlanDerivedManifest.combine(
|
||||||
|
staticManifest = emptyList(),
|
||||||
|
planDerived = PlanDerivedManifest.deriveAllowedWrites(s),
|
||||||
|
).sorted()
|
||||||
StageId(s.id) to StageConfig(
|
StageId(s.id) to StageConfig(
|
||||||
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
|
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
|
||||||
needs = s.needs.map { ArtifactId(it) }.toSet(),
|
needs = s.needs.map { ArtifactId(it) }.toSet(),
|
||||||
allowedTools = s.tools.toSet(),
|
allowedTools = s.tools.toSet(),
|
||||||
|
writeManifest = writeManifest,
|
||||||
metadata = mapOf("promptInline" to s.prompt),
|
metadata = mapOf("promptInline" to s.prompt),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -15,6 +15,12 @@ data class PlanStage(
|
|||||||
val kind: String? = null,
|
val kind: String? = null,
|
||||||
val needs: List<String> = emptyList(),
|
val needs: List<String> = emptyList(),
|
||||||
val tools: List<String> = emptyList(),
|
val tools: List<String> = emptyList(),
|
||||||
|
// Workspace-relative file paths/globs this stage declares it will write (BACKLOG §B-§2).
|
||||||
|
// Drives the plan-derived diff manifest: these are unioned with the static per-stage
|
||||||
|
// write globs to form the enforced write-guard manifest. Absent/empty = no plan-derived
|
||||||
|
// contribution (the static manifest, if any, still applies). The planner emits this under
|
||||||
|
// the JSON key `writes`.
|
||||||
|
val writes: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PlanEdge(
|
data class PlanEdge(
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package com.correx.infrastructure.workflow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan-derived diff manifest (BACKLOG §B-§2).
|
||||||
|
*
|
||||||
|
* The write-guard manifest enforced by the plane-2 `ManifestContainmentRule` was originally a
|
||||||
|
* STATIC per-stage `writes = [globs]` set (role-reliability §2). This object DERIVES the
|
||||||
|
* allowed-write set for a stage from the confirmed `impl_plan` artifact instead — the file
|
||||||
|
* paths/globs a [PlanStage] declares it produces/touches — so the guard tracks the actual locked
|
||||||
|
* plan rather than only hand-written globs.
|
||||||
|
*
|
||||||
|
* The derived set is not a replacement: it AUGMENTS the static baseline. [combine] unions the two,
|
||||||
|
* and the resulting set is fed verbatim into `StageConfig.writeManifest`, so plan-derived paths are
|
||||||
|
* matched with the SAME glob/containment semantics the static manifest already uses — no parallel
|
||||||
|
* matcher is introduced.
|
||||||
|
*
|
||||||
|
* ## Extraction rule
|
||||||
|
* Write targets are read from [PlanStage.writes]: the workspace-relative file paths/globs the
|
||||||
|
* plan-stage explicitly declares it will write. `produces`/`kind`/`needs` name artifact *slots*
|
||||||
|
* (logical outputs), not filesystem paths, so they are deliberately NOT treated as write targets.
|
||||||
|
* Derivation is deterministic and lenient: a stage with no [PlanStage.writes] yields an empty set,
|
||||||
|
* blank/whitespace entries are dropped, and duplicates collapse.
|
||||||
|
*/
|
||||||
|
object PlanDerivedManifest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The set of workspace-relative paths/globs [planStage] is permitted to write, derived from its
|
||||||
|
* declared [PlanStage.writes]. Lenient: a stage with no declared write targets yields the empty
|
||||||
|
* set (the static manifest, if any, still governs). Deterministic for a given input.
|
||||||
|
*/
|
||||||
|
fun deriveAllowedWrites(planStage: PlanStage): Set<String> =
|
||||||
|
planStage.writes
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unions the static per-stage write globs with the plan-derived set. A write is allowed if it
|
||||||
|
* matches EITHER source. Blank entries are dropped so the combined set carries only matchable
|
||||||
|
* globs. The static path is never narrowed — the result only ever widens the static baseline by
|
||||||
|
* the plan-derived paths.
|
||||||
|
*/
|
||||||
|
fun combine(staticManifest: Collection<String>, planDerived: Set<String>): Set<String> =
|
||||||
|
(staticManifest + planDerived)
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
+32
@@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test
|
|||||||
import org.junit.jupiter.api.assertThrows
|
import org.junit.jupiter.api.assertThrows
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class ExecutionPlanCompilerTest {
|
class ExecutionPlanCompilerTest {
|
||||||
|
|
||||||
@@ -70,6 +71,37 @@ class ExecutionPlanCompilerTest {
|
|||||||
assertEquals(setOf("patch"), reviewStage.needs.map { it.value }.toSet())
|
assertEquals(setOf("patch"), reviewStage.needs.map { it.value }.toSet())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `plan-declared writes are derived into the stage write manifest`() {
|
||||||
|
val plan = """
|
||||||
|
{
|
||||||
|
"goal": "implement a feature",
|
||||||
|
"stages": [
|
||||||
|
{
|
||||||
|
"id": "impl",
|
||||||
|
"prompt": "Implement it",
|
||||||
|
"produces": "patch",
|
||||||
|
"needs": [],
|
||||||
|
"tools": ["file_write"],
|
||||||
|
"writes": ["core/feature/**", "docs/feature.md"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "from": "impl", "to": "done", "condition": { "type": "always_true" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
val graph = compiler.compile(plan, "manifest-workflow")
|
||||||
|
val stage = graph.stages.values.single()
|
||||||
|
assertEquals(setOf("core/feature/**", "docs/feature.md"), stage.writeManifest.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stage without declared writes has an empty write manifest`() {
|
||||||
|
val graph = compiler.compile(validPlan, "no-writes-workflow")
|
||||||
|
assertTrue(graph.stages.values.all { it.writeManifest.isEmpty() })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `edge referencing unknown from-stage throws WorkflowValidationException`() {
|
fun `edge referencing unknown from-stage throws WorkflowValidationException`() {
|
||||||
val bad = validPlan.replace("\"from\": \"analyse\"", "\"from\": \"nonexistent\"")
|
val bad = validPlan.replace("\"from\": \"analyse\"", "\"from\": \"nonexistent\"")
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package com.correx.infrastructure.workflow
|
||||||
|
|
||||||
|
import java.nio.file.FileSystems
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class PlanDerivedManifestTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `derives the declared write paths of a plan stage`() {
|
||||||
|
val stage = PlanStage(id = "impl", writes = listOf("core/a/B.kt", "docs/notes.md"))
|
||||||
|
assertEquals(setOf("core/a/B.kt", "docs/notes.md"), PlanDerivedManifest.deriveAllowedWrites(stage))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stage with no declared writes derives an empty set`() {
|
||||||
|
val stage = PlanStage(id = "analyse", produces = "patch", needs = listOf("x"))
|
||||||
|
assertTrue(PlanDerivedManifest.deriveAllowedWrites(stage).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multiple files are all derived and blank entries dropped`() {
|
||||||
|
val stage = PlanStage(id = "impl", writes = listOf("a.kt", " ", "b.kt", "", "c.kt"))
|
||||||
|
assertEquals(setOf("a.kt", "b.kt", "c.kt"), PlanDerivedManifest.deriveAllowedWrites(stage))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `combine unions the static globs with the plan-derived set`() {
|
||||||
|
val combined = PlanDerivedManifest.combine(
|
||||||
|
staticManifest = listOf("core/**"),
|
||||||
|
planDerived = setOf("docs/x.md"),
|
||||||
|
)
|
||||||
|
assertEquals(setOf("core/**", "docs/x.md"), combined)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `combine drops blank entries and de-duplicates across sources`() {
|
||||||
|
val combined = PlanDerivedManifest.combine(
|
||||||
|
staticManifest = listOf("core/**", " ", "shared.kt"),
|
||||||
|
planDerived = setOf("shared.kt", "docs/x.md", ""),
|
||||||
|
)
|
||||||
|
assertEquals(setOf("core/**", "shared.kt", "docs/x.md"), combined)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `combined manifest matches via the same glob semantics the static manifest uses`() {
|
||||||
|
// The combined set is fed verbatim into StageConfig.writeManifest, which the plane-2
|
||||||
|
// ManifestContainmentRule matches with FileSystems glob PathMatchers. Assert that a write
|
||||||
|
// allowed only by the plan-derived entry, one allowed only by the static glob, and one in
|
||||||
|
// neither resolve as expected under that exact matcher — no parallel matcher is introduced.
|
||||||
|
val combined = PlanDerivedManifest.combine(
|
||||||
|
staticManifest = listOf("core/**"),
|
||||||
|
planDerived = setOf("docs/x.md"),
|
||||||
|
)
|
||||||
|
val matchers = combined.map { FileSystems.getDefault().getPathMatcher("glob:$it") }
|
||||||
|
fun allowed(rel: String) = matchers.any { it.matches(Path.of(rel)) }
|
||||||
|
|
||||||
|
assertTrue(allowed("core/a/B.kt"), "static glob should allow core/a/B.kt")
|
||||||
|
assertTrue(allowed("docs/x.md"), "plan-derived path should allow docs/x.md")
|
||||||
|
assertFalse(allowed("apps/server/Main.kt"), "a path in neither source is denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user