feat(artifacts): config-driven custom artifact kinds with schema validation

Users declare artifact kinds in [[artifacts]] (id + schema_path to a JSON-schema file + llm_emitted); ConfigArtifactKind registers them at startup via createWorkflowLoader(extraKinds). New JsonSchemaValidator validates any non-built-in kind generically against its declared deriveJsonSchema(), so an LLM-emitted custom kind is checked against its shape, never trusted. Removed the dead payloadSerializer from the ArtifactKind contract. Schema source is path-to-file (not inline TOML — the hand-rolled parser can't nest).
This commit is contained in:
2026-06-02 13:58:23 +04:00
parent b976a5c92a
commit d1c6774d05
14 changed files with 315 additions and 30 deletions
@@ -4,6 +4,9 @@ import com.correx.apps.server.logging.LoggingEventStore
import com.correx.apps.server.registry.FileSystemWorkflowRegistry
import com.correx.apps.server.registry.ProviderRegistry
import com.correx.core.approvals.domain.DefaultApprovalEngine
import com.correx.core.artifacts.kind.ArtifactKind
import com.correx.core.artifacts.kind.ConfigArtifactKind
import com.correx.core.artifacts.kind.JsonSchema
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.config.ConfigLoader
import com.correx.core.config.CorrexConfig
@@ -62,7 +65,9 @@ import com.correx.infrastructure.tools.ToolConfig
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
@@ -244,7 +249,9 @@ fun main() {
eventStore = eventStore,
artifactStore = artifactStore,
sessionRepository = repositories.sessionRepository,
workflowRegistry = FileSystemWorkflowRegistry(InfrastructureModule.createWorkflowLoader()),
workflowRegistry = FileSystemWorkflowRegistry(
InfrastructureModule.createWorkflowLoader(loadConfigArtifactKinds(correxConfig)),
),
providerRegistry = infraRegistry.asServerRegistry(),
defaultOrchestrationConfig = defaultOrchestrationConfig,
routerFacade = routerFacade,
@@ -294,6 +301,39 @@ private fun logStoresInfo(
log.info(" artifact store: {}", artifactStore::class.simpleName)
}
// Build user-declared artifact kinds from config. Each [[artifacts]] entry points to a JSON
// schema file; we parse it into a JsonSchema and wrap it in a ConfigArtifactKind. A schema that
// can't be loaded is skipped with a warning — any workflow that references the kind then fails
// loudly at load time ("Unknown artifact kind"), rather than running with an unvalidated kind.
private fun loadConfigArtifactKinds(config: CorrexConfig): List<ArtifactKind> {
val configDir = ConfigLoader.configPath().parent
return config.artifacts.mapNotNull { decl ->
val schemaPath = resolveConfigRelativePath(decl.schemaPath, configDir)
val schema = runCatching {
Json.decodeFromString(JsonSchema.serializer(), Files.readString(schemaPath))
}.getOrElse { e ->
System.err.println(
"Warning: artifact kind '${decl.id}' schema '$schemaPath' failed to load: ${e.message}",
)
return@mapNotNull null
}
ConfigArtifactKind(id = decl.id, schema = schema, llmEmitted = decl.llmEmitted)
}
}
private fun resolveConfigRelativePath(raw: String, configDir: Path?): Path {
val expanded = if (raw.startsWith("~/")) {
Paths.get(System.getProperty("user.home"), raw.removePrefix("~/"))
} else {
Path.of(raw)
}
return when {
expanded.isAbsolute -> expanded
configDir != null -> configDir.resolve(expanded)
else -> expanded
}
}
private fun buildProviders(config: CorrexConfig): List<LlamaCppInferenceProvider> {
return if (config.providers.isNotEmpty()) {
log.info("Loading {} provider(s) from config", config.providers.size)