feat(core:validation): ArtifactPayloadValidator for file_written and process_result; make Validator suspend

This commit is contained in:
2026-05-18 21:39:13 +04:00
parent a5dc983a57
commit fa9af002a1
8 changed files with 121 additions and 6 deletions
+2
View File
@@ -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"))
} }
@@ -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>()
@@ -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
} }
@@ -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) }
@@ -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",
@@ -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>()