feat(core:validation): ArtifactPayloadValidator for file_written and process_result; make Validator suspend
This commit is contained in:
@@ -8,4 +8,6 @@ dependencies {
|
|||||||
implementation(project(":core:events"))
|
implementation(project(":core:events"))
|
||||||
implementation(project(":core:sessions"))
|
implementation(project(":core:sessions"))
|
||||||
implementation(project(":core:transitions"))
|
implementation(project(":core:transitions"))
|
||||||
|
implementation(project(":core:artifacts"))
|
||||||
|
implementation(project(":core:artifacts-store"))
|
||||||
}
|
}
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
package com.correx.core.validation.artifact
|
||||||
|
|
||||||
|
import com.correx.core.artifactstore.ArtifactStore
|
||||||
|
import com.correx.core.artifacts.kind.FileWrittenArtifact
|
||||||
|
import com.correx.core.artifacts.kind.ProcessResultArtifact
|
||||||
|
import com.correx.core.artifacts.kind.TypedArtifactSlot
|
||||||
|
import com.correx.core.events.types.ArtifactId
|
||||||
|
import com.correx.core.validation.model.ValidationContext
|
||||||
|
import com.correx.core.validation.model.ValidationIssue
|
||||||
|
import com.correx.core.validation.model.ValidationSection
|
||||||
|
import com.correx.core.validation.model.ValidationSeverity
|
||||||
|
import com.correx.core.validation.pipeline.Validator
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
class ArtifactPayloadValidator(
|
||||||
|
private val artifactStore: ArtifactStore,
|
||||||
|
) : Validator {
|
||||||
|
|
||||||
|
override suspend fun validate(context: ValidationContext): ValidationSection {
|
||||||
|
val issues = mutableListOf<ValidationIssue>()
|
||||||
|
|
||||||
|
for ((_, stageConfig) in context.graph.stages) {
|
||||||
|
for (slot in stageConfig.produces) {
|
||||||
|
validateSlot(slot, issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidationSection(name = "artifact_payload", issues = issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun validateSlot(
|
||||||
|
slot: TypedArtifactSlot,
|
||||||
|
issues: MutableList<ValidationIssue>,
|
||||||
|
) {
|
||||||
|
val bytes = artifactStore.get(slot.name) ?: return
|
||||||
|
|
||||||
|
val payloadStr = bytes.toString(Charsets.UTF_8)
|
||||||
|
|
||||||
|
when (slot.kind.id) {
|
||||||
|
"file_written" ->
|
||||||
|
validateSlotAsFileWritten(slot.name, slot.kind.id, payloadStr, issues)
|
||||||
|
"process_result" ->
|
||||||
|
validateSlotAsProcessResult(slot.name, slot.kind.id, payloadStr, issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateSlotAsFileWritten(
|
||||||
|
slotName: ArtifactId,
|
||||||
|
kindId: String,
|
||||||
|
payloadStr: String,
|
||||||
|
issues: MutableList<ValidationIssue>,
|
||||||
|
) {
|
||||||
|
val artifact = runCatching {
|
||||||
|
Json.decodeFromString(FileWrittenArtifact.serializer(), payloadStr)
|
||||||
|
}.getOrElse {
|
||||||
|
issues += decodeFailedIssue(slotName, kindId, it.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
validateFileWritten(artifact, slotName, issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateSlotAsProcessResult(
|
||||||
|
slotName: ArtifactId,
|
||||||
|
kindId: String,
|
||||||
|
payloadStr: String,
|
||||||
|
issues: MutableList<ValidationIssue>,
|
||||||
|
) {
|
||||||
|
val artifact = runCatching {
|
||||||
|
Json.decodeFromString(ProcessResultArtifact.serializer(), payloadStr)
|
||||||
|
}.getOrElse {
|
||||||
|
issues += decodeFailedIssue(slotName, kindId, it.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
validateProcessResult(artifact, slotName, issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateFileWritten(
|
||||||
|
artifact: FileWrittenArtifact,
|
||||||
|
name: ArtifactId,
|
||||||
|
issues: MutableList<ValidationIssue>,
|
||||||
|
) {
|
||||||
|
if (artifact.path.isBlank()) issues += semanticIssue(name, "path is blank")
|
||||||
|
if (Path.of(artifact.path).isAbsolute) issues += semanticIssue(name, "path must be relative")
|
||||||
|
if (artifact.path.contains("..")) issues += semanticIssue(name, "path contains '..'")
|
||||||
|
if (artifact.contentHash.isBlank()) issues += semanticIssue(name, "contentHash is blank")
|
||||||
|
// content non-empty cannot be checked here: FileWrittenArtifact is the canonical (CAS) form
|
||||||
|
// which contains only the hash, not the raw content bytes.
|
||||||
|
if (!Regex("^0?[0-7]{3,4}$").matches(artifact.mode)) {
|
||||||
|
issues += semanticIssue(name, "mode '${artifact.mode}' is not a valid octal permission string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateProcessResult(
|
||||||
|
artifact: ProcessResultArtifact,
|
||||||
|
name: ArtifactId,
|
||||||
|
issues: MutableList<ValidationIssue>,
|
||||||
|
) {
|
||||||
|
if (artifact.command.isBlank()) issues += semanticIssue(name, "command is blank")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeFailedIssue(name: ArtifactId, kindId: String, errorMessage: String?) = ValidationIssue(
|
||||||
|
code = "ARTIFACT_DECODE_FAILED",
|
||||||
|
message = "Artifact '${name.value}' failed to decode as $kindId: $errorMessage",
|
||||||
|
severity = ValidationSeverity.ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun semanticIssue(name: ArtifactId, message: String) = ValidationIssue(
|
||||||
|
code = "ARTIFACT_SEMANTIC_INVALID",
|
||||||
|
message = "Artifact '${name.value}': $message",
|
||||||
|
severity = ValidationSeverity.ERROR,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import com.correx.core.validation.pipeline.Validator
|
|||||||
|
|
||||||
class GraphValidator : Validator {
|
class GraphValidator : Validator {
|
||||||
|
|
||||||
override fun validate(context: ValidationContext): ValidationSection {
|
override suspend fun validate(context: ValidationContext): ValidationSection {
|
||||||
|
|
||||||
val graph = context.graph
|
val graph = context.graph
|
||||||
val issues = mutableListOf<ValidationIssue>()
|
val issues = mutableListOf<ValidationIssue>()
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ class ValidationPipeline(
|
|||||||
private val approvalTrigger: ApprovalTrigger? = null
|
private val approvalTrigger: ApprovalTrigger? = null
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun validate(context: ValidationContext): ValidationOutcome {
|
suspend fun validate(context: ValidationContext): ValidationOutcome {
|
||||||
val sections = mutableListOf<ValidationSection>()
|
val sections = mutableListOf<ValidationSection>()
|
||||||
for (validator in validators) {
|
for (validator in validators) {
|
||||||
val section = validator.validate(context)
|
val section = validator.validate(context)
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ import com.correx.core.validation.model.ValidationContext
|
|||||||
import com.correx.core.validation.model.ValidationSection
|
import com.correx.core.validation.model.ValidationSection
|
||||||
|
|
||||||
fun interface Validator {
|
fun interface Validator {
|
||||||
fun validate(context: ValidationContext): ValidationSection
|
suspend fun validate(context: ValidationContext): ValidationSection
|
||||||
}
|
}
|
||||||
+1
-1
@@ -8,7 +8,7 @@ class SemanticValidator(
|
|||||||
private val rules: List<SemanticRule>
|
private val rules: List<SemanticRule>
|
||||||
) : Validator {
|
) : Validator {
|
||||||
|
|
||||||
override fun validate(context: ValidationContext): ValidationSection {
|
override suspend fun validate(context: ValidationContext): ValidationSection {
|
||||||
|
|
||||||
val issues = rules.flatMap { it.validate(context) }
|
val issues = rules.flatMap { it.validate(context) }
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ import com.correx.core.validation.model.ValidationSeverity
|
|||||||
import com.correx.core.validation.pipeline.Validator
|
import com.correx.core.validation.pipeline.Validator
|
||||||
|
|
||||||
class SessionValidator : Validator {
|
class SessionValidator : Validator {
|
||||||
override fun validate(context: ValidationContext): ValidationSection {
|
override suspend fun validate(context: ValidationContext): ValidationSection {
|
||||||
|
|
||||||
val state = context.sessionState ?: return ValidationSection(
|
val state = context.sessionState ?: return ValidationSection(
|
||||||
name = "session",
|
name = "session",
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ class TransitionValidator(
|
|||||||
private val ordering: Comparator<TransitionEdge> = TransitionOrdering.comparator
|
private val ordering: Comparator<TransitionEdge> = TransitionOrdering.comparator
|
||||||
) : Validator {
|
) : Validator {
|
||||||
|
|
||||||
override fun validate(context: ValidationContext): ValidationSection {
|
override suspend fun validate(context: ValidationContext): ValidationSection {
|
||||||
|
|
||||||
val graph = context.graph
|
val graph = context.graph
|
||||||
val issues = mutableListOf<ValidationIssue>()
|
val issues = mutableListOf<ValidationIssue>()
|
||||||
|
|||||||
Reference in New Issue
Block a user