fix(server): event capturing and necessary logic for smoke test.

This commit is contained in:
2026-05-17 03:17:12 +04:00
parent 0c1876a549
commit 7d46f46f01
39 changed files with 1097 additions and 67 deletions
@@ -0,0 +1,39 @@
package com.correx.infrastructure.workflow
import com.correx.core.events.types.ArtifactId
import com.correx.core.transitions.conditions.AllOf
import com.correx.core.transitions.conditions.AlwaysTrue
import com.correx.core.transitions.conditions.AnyOf
import com.correx.core.transitions.conditions.ArtifactAbsent
import com.correx.core.transitions.conditions.ArtifactPresent
import com.correx.core.transitions.conditions.ArtifactValidated
import com.correx.core.transitions.conditions.Not
import com.correx.core.transitions.conditions.VariableEquals
import com.correx.core.transitions.graph.TransitionCondition
fun ConditionSpec.toCondition(): TransitionCondition = when (type) {
"always_true" -> AlwaysTrue
"artifact_present" -> buildArtifactPresent()
"artifact_absent" -> buildArtifactAbsent()
"artifact_validated" -> buildArtifactValidated()
"variable_equals" -> buildVariableEquals()
"all_of" -> AllOf(conditions.map { it.toCondition() })
"any_of" -> AnyOf(conditions.map { it.toCondition() })
"not" -> Not((condition ?: error("condition required for not")).toCondition())
else -> throw WorkflowValidationException("unknown condition type: $type")
}
private fun ConditionSpec.buildArtifactPresent() =
ArtifactPresent(ArtifactId(artifactId ?: error("artifact_id required for artifact_present")))
private fun ConditionSpec.buildArtifactAbsent() =
ArtifactAbsent(ArtifactId(artifactId ?: error("artifact_id required for artifact_absent")))
private fun ConditionSpec.buildArtifactValidated() =
ArtifactValidated(ArtifactId(artifactId ?: error("artifact_id required for artifact_validated")))
private fun ConditionSpec.buildVariableEquals() =
VariableEquals(
key ?: error("key required for variable_equals"),
value ?: error("value required for variable_equals"),
)
@@ -0,0 +1,10 @@
package com.correx.infrastructure.workflow
data class ConditionSpec(
val type: String,
val artifactId: String? = null,
val key: String? = null,
val value: String? = null,
val conditions: List<ConditionSpec> = emptyList(),
val condition: ConditionSpec? = null,
)
@@ -0,0 +1,19 @@
package com.correx.infrastructure.workflow
import java.nio.file.Path
import kotlin.io.path.exists
import kotlin.io.path.readText
class FileSystemPromptLoader(
private val configDir: Path = Path.of(System.getProperty("user.home"), ".config", "correx", "prompts"),
) : PromptLoader {
override fun load(path: String): String {
val local = Path.of(path)
if (local.exists()) return local.readText()
val fromConfig = configDir.resolve(path)
if (fromConfig.exists()) return fromConfig.readText()
throw PromptNotFoundException("Prompt not found: $path (searched: $local, $fromConfig)")
}
}
@@ -0,0 +1,5 @@
package com.correx.infrastructure.workflow
interface PromptLoader {
fun load(path: String): String
}
@@ -0,0 +1,3 @@
package com.correx.infrastructure.workflow
class PromptNotFoundException(message: String) : Exception(message)
@@ -0,0 +1,130 @@
package com.correx.infrastructure.workflow
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.dataformat.toml.TomlMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule
import com.fasterxml.jackson.module.kotlin.readValue
import java.nio.file.Path
import kotlin.io.path.readText
private data class WorkflowFile(
val id: String = "",
val start: String = "",
val stages: List<StageSection> = emptyList(),
val transitions: List<TransitionSection> = emptyList(),
)
private data class StageSection(
val id: String = "",
val prompt: String? = null,
val produces: List<String> = emptyList(),
val needs: List<String> = emptyList(),
@JsonProperty("allowed_tools") val allowedTools: List<String> = emptyList(),
@JsonProperty("token_budget") val tokenBudget: Int = 4096,
@JsonProperty("max_retries") val maxRetries: Int = 3,
)
// condition fields flattened into the transition row
private data class TransitionSection(
val id: String = "",
val from: String = "",
val to: String = "",
@JsonProperty("condition_type") val conditionType: String = "",
@JsonProperty("condition_artifact_id") val conditionArtifactId: String? = null,
@JsonProperty("condition_key") val conditionKey: String? = null,
@JsonProperty("condition_value") val conditionValue: String? = null,
)
private const val TERMINAL = "done"
private val mapper = TomlMapper.builder()
.addModule(kotlinModule())
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build()
class TomlWorkflowLoader : WorkflowLoader {
override fun load(path: Path): WorkflowGraph {
val raw = path.readText()
val file = runCatching { mapper.readValue<WorkflowFile>(raw) }
.getOrElse { throw WorkflowValidationException("Failed to parse workflow TOML: ${it.message}") }
return file.toWorkflowGraph()
}
private fun WorkflowFile.toWorkflowGraph(): WorkflowGraph {
val startId = StageId(start)
val stageMap = stages.associate { s ->
StageId(s.id) to StageConfig(
produces = s.produces.map { ArtifactId(it) }.toSet(),
needs = s.needs.map { ArtifactId(it) }.toSet(),
allowedTools = s.allowedTools.toSet(),
tokenBudget = s.tokenBudget,
maxRetries = s.maxRetries,
metadata = buildMap { s.prompt?.let { put("prompt", it) } },
)
}
val declaredIds = stageMap.keys.map { it.value }.toSet()
validate(start, declaredIds, transitions)
val allProduced = stageMap.values.flatMap { it.produces }.toSet()
stageMap.forEach { (stageId, config) ->
config.needs.forEach { needed ->
if (needed !in allProduced) {
throw WorkflowValidationException(
"Stage '${stageId.value}' needs artifact '${needed.value}' but no stage produces it"
)
}
}
}
val transitionSet = transitions.map { t ->
TransitionEdge(
id = TransitionId(t.id),
from = StageId(t.from),
to = StageId(t.to),
condition = ConditionSpec(
type = t.conditionType,
artifactId = t.conditionArtifactId,
key = t.conditionKey,
value = t.conditionValue,
).toCondition(),
)
}.toSet()
return WorkflowGraph(id = id, stages = stageMap, transitions = transitionSet, start = startId)
}
@Suppress("ThrowsCount")
private fun validate(
start: String,
declaredStages: Set<String>,
transitions: List<TransitionSection>,
) {
if (start !in declaredStages) {
throw WorkflowValidationException("start stage '$start' is not declared in stages")
}
val seenIds = mutableSetOf<String>()
transitions.forEach { t ->
if (!seenIds.add(t.id)) {
throw WorkflowValidationException("duplicate transition id: '${t.id}'")
}
checkTransitionStage(t, "from", t.from, declaredStages)
checkTransitionStage(t, "to", t.to, declaredStages)
}
}
private fun checkTransitionStage(t: TransitionSection, field: String, stageRef: String, declared: Set<String>) {
if (stageRef !in declared && (field != "to" || stageRef != TERMINAL)) {
throw WorkflowValidationException(
"transition '${t.id}' references unknown $field-stage '$stageRef'"
)
}
}
}
@@ -0,0 +1,8 @@
package com.correx.infrastructure.workflow
import com.correx.core.transitions.graph.WorkflowGraph
import java.nio.file.Path
interface WorkflowLoader {
fun load(path: Path): WorkflowGraph
}
@@ -0,0 +1,3 @@
package com.correx.infrastructure.workflow
class WorkflowValidationException(message: String) : Exception(message)
@@ -0,0 +1,45 @@
package com.correx.infrastructure.workflow
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.nio.file.Files
import kotlin.io.path.writeText
import kotlin.test.assertEquals
import kotlin.test.assertContains
class FileSystemPromptLoaderTest {
@Test
fun `loads from absolute path when file exists`() {
val file = Files.createTempFile("prompt", ".md").also { it.writeText("hello world") }
val loader = FileSystemPromptLoader()
assertEquals("hello world", loader.load(file.toAbsolutePath().toString()))
}
@Test
fun `falls back to configDir when local path not found`() {
val configDir = Files.createTempDirectory("correx-prompts")
configDir.resolve("test.md").also { it.writeText("from config") }
val loader = FileSystemPromptLoader(configDir = configDir)
assertEquals("from config", loader.load("test.md"))
}
@Test
fun `throws PromptNotFoundException with both paths when not found`() {
val configDir = Files.createTempDirectory("correx-prompts-empty")
val loader = FileSystemPromptLoader(configDir = configDir)
val ex = assertThrows<PromptNotFoundException> { loader.load("missing.md") }
assertContains(ex.message!!, "missing.md")
assertContains(ex.message!!, configDir.toString())
}
@Test
fun `local path takes precedence over configDir`() {
val localFile = Files.createTempFile("prompt", ".md").also { it.writeText("local") }
val configDir = Files.createTempDirectory("correx-prompts")
configDir.resolve(localFile.fileName.toString()).also { it.writeText("from config") }
val loader = FileSystemPromptLoader(configDir = configDir)
assertEquals("local", loader.load(localFile.toAbsolutePath().toString()))
}
}
@@ -0,0 +1,93 @@
package com.correx.infrastructure.workflow
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import java.nio.file.Files
import kotlin.io.path.writeText
import kotlin.test.assertEquals
class TomlWorkflowLoaderTest {
private val loader = TomlWorkflowLoader()
private val validToml = """
id = "healthcheck"
start = "collect"
[[stages]]
id = "collect"
prompt = "prompts/collect.md"
produces = ["system_stats"]
allowed_tools = ["ShellTool"]
token_budget = 2048
max_retries = 2
[[stages]]
id = "report"
prompt = "prompts/report.md"
needs = ["system_stats"]
produces = ["report"]
token_budget = 2048
[[transitions]]
id = "collect-to-report"
from = "collect"
to = "report"
condition_type = "artifact_present"
condition_artifact_id = "system_stats"
[[transitions]]
id = "report-to-done"
from = "report"
to = "done"
condition_type = "always_true"
""".trimIndent()
@Test
fun `valid toml produces correct WorkflowGraph`() {
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(validToml) }
val graph = loader.load(path)
assertEquals("collect", graph.start.value)
assertEquals(2, graph.stages.size)
assertEquals(2, graph.transitions.size)
val collectStage = graph.stages[graph.start]!!
assertEquals(setOf("system_stats"), collectStage.produces.map { it.value }.toSet())
assertEquals("prompts/collect.md", collectStage.metadata["prompt"])
val reportStage = graph.stages.values.first { it.produces.any { a -> a.value == "report" } }
assertEquals(setOf("system_stats"), reportStage.needs.map { it.value }.toSet())
}
@Test
fun `missing stage reference throws WorkflowValidationException`() {
val bad = validToml.replace("from = \"collect\"", "from = \"nonexistent\"")
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(bad) }
assertThrows<WorkflowValidationException> { loader.load(path) }
}
@Test
fun `duplicate transition ids throw WorkflowValidationException`() {
val bad = validToml.replace("id = \"report-to-done\"", "id = \"collect-to-report\"")
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(bad) }
assertThrows<WorkflowValidationException> { loader.load(path) }
}
@Test
fun `unknown start stage throws WorkflowValidationException`() {
val bad = validToml.replace("start = \"collect\"", "start = \"missing\"")
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(bad) }
assertThrows<WorkflowValidationException> { loader.load(path) }
}
@Test
fun `unsatisfied needs throws WorkflowValidationException`() {
val bad = validToml.replace(
"needs = [\"system_stats\"]",
"needs = [\"nonexistent_artifact\"]",
)
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(bad) }
assertThrows<WorkflowValidationException> { loader.load(path) }
}
}