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 kind = registry.get(kindId)
|
||||
?: 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(
|
||||
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
|
||||
needs = s.needs.map { ArtifactId(it) }.toSet(),
|
||||
allowedTools = s.tools.toSet(),
|
||||
writeManifest = writeManifest,
|
||||
metadata = mapOf("promptInline" to s.prompt),
|
||||
)
|
||||
}
|
||||
|
||||
+6
@@ -15,6 +15,12 @@ data class PlanStage(
|
||||
val kind: String? = null,
|
||||
val needs: 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(
|
||||
|
||||
+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 kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ExecutionPlanCompilerTest {
|
||||
|
||||
@@ -70,6 +71,37 @@ class ExecutionPlanCompilerTest {
|
||||
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
|
||||
fun `edge referencing unknown from-stage throws WorkflowValidationException`() {
|
||||
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