fix(server): event capturing and necessary logic for smoke test.
This commit is contained in:
@@ -16,6 +16,8 @@ dependencies {
|
||||
implementation project(":infrastructure:persistence")
|
||||
implementation project(":infrastructure:tools")
|
||||
implementation project(":infrastructure:tools:filesystem")
|
||||
implementation project(":infrastructure:workflow")
|
||||
implementation project(":core:artifacts")
|
||||
implementation "io.ktor:ktor-client-core:$ktor_version"
|
||||
implementation "io.ktor:ktor-client-cio:$ktor_version"
|
||||
implementation "io.ktor:ktor-client-content-negotiation:$ktor_version"
|
||||
|
||||
@@ -8,6 +8,7 @@ dependencies {
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinx_coroutines_version"
|
||||
implementation(project(":core:events"))
|
||||
implementation(project(":core:sessions"))
|
||||
implementation(project(":core:artifacts"))
|
||||
implementation "org.xerial:sqlite-jdbc"
|
||||
testImplementation(testFixtures(project(":testing:contracts")))
|
||||
testImplementation "org.junit.jupiter:junit-jupiter"
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package com.correx.infrastructure.persistence.artifact
|
||||
|
||||
import com.correx.core.artifacts.ArtifactProjector
|
||||
import com.correx.core.artifacts.ArtifactReducer
|
||||
import com.correx.core.artifacts.ArtifactState
|
||||
import com.correx.core.artifacts.repository.ArtifactRepository
|
||||
import com.correx.core.events.events.ArtifactArchivedEvent
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
import com.correx.core.events.events.ArtifactRejectedEvent
|
||||
import com.correx.core.events.events.ArtifactRelationshipAddedEvent
|
||||
import com.correx.core.events.events.ArtifactSupersededEvent
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class LiveArtifactRepository(
|
||||
private val eventStore: EventStore,
|
||||
private val reducer: ArtifactReducer,
|
||||
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
) : ArtifactRepository {
|
||||
|
||||
private val artifactCache = ConcurrentHashMap<SessionId, ConcurrentHashMap<ArtifactId, ArtifactState>>()
|
||||
private val stageIndex = ConcurrentHashMap<SessionId, ConcurrentHashMap<ArtifactId, StageId>>()
|
||||
private val subscriptions = ConcurrentHashMap<SessionId, Job>()
|
||||
private val projector = ArtifactProjector(reducer)
|
||||
|
||||
override fun getBySession(sessionId: SessionId): Map<ArtifactId, ArtifactState> {
|
||||
ensureSubscribed(sessionId)
|
||||
return artifactCache[sessionId]?.toMap() ?: emptyMap()
|
||||
}
|
||||
|
||||
override fun getByStage(sessionId: SessionId, stageId: StageId): Map<ArtifactId, ArtifactState> {
|
||||
ensureSubscribed(sessionId)
|
||||
val cache = artifactCache[sessionId] ?: emptyMap<ArtifactId, ArtifactState>()
|
||||
val index = stageIndex[sessionId] ?: emptyMap<ArtifactId, StageId>()
|
||||
return cache.filter { (artifactId, _) -> index[artifactId] == stageId }
|
||||
}
|
||||
|
||||
override fun getById(sessionId: SessionId, artifactId: ArtifactId): ArtifactState? {
|
||||
ensureSubscribed(sessionId)
|
||||
return artifactCache[sessionId]?.get(artifactId)
|
||||
}
|
||||
|
||||
override fun getByIds(sessionId: SessionId, artifactIds: Set<ArtifactId>): Map<ArtifactId, ArtifactState> {
|
||||
ensureSubscribed(sessionId)
|
||||
val cache = artifactCache[sessionId] ?: return emptyMap()
|
||||
return artifactIds.mapNotNull { id -> cache[id]?.let { id to it } }.toMap()
|
||||
}
|
||||
|
||||
private fun ensureSubscribed(sessionId: SessionId) {
|
||||
subscriptions.computeIfAbsent(sessionId) {
|
||||
scope.launch {
|
||||
eventStore.subscribe(sessionId).collect { event ->
|
||||
processEvent(sessionId, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processEvent(sessionId: SessionId, event: StoredEvent) {
|
||||
val artifactId = extractArtifactId(event) ?: return
|
||||
val sessionArtifacts = artifactCache.computeIfAbsent(sessionId) { ConcurrentHashMap() }
|
||||
val currentState = sessionArtifacts[artifactId] ?: projector.initial()
|
||||
val newState = projector.apply(currentState, event)
|
||||
sessionArtifacts[artifactId] = newState
|
||||
|
||||
val payload = event.payload
|
||||
if (payload is ArtifactCreatedEvent) {
|
||||
val sessionStages = stageIndex.computeIfAbsent(sessionId) { ConcurrentHashMap() }
|
||||
sessionStages[artifactId] = payload.stageId
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractArtifactId(event: StoredEvent): ArtifactId? =
|
||||
when (val p = event.payload) {
|
||||
is ArtifactCreatedEvent -> p.artifactId
|
||||
is ArtifactValidatingEvent -> p.artifactId
|
||||
is ArtifactValidatedEvent -> p.artifactId
|
||||
is ArtifactRejectedEvent -> p.artifactId
|
||||
is ArtifactSupersededEvent -> p.artifactId
|
||||
is ArtifactArchivedEvent -> p.artifactId
|
||||
is ArtifactRelationshipAddedEvent -> p.sourceId
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -12,21 +12,37 @@ import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
import com.correx.infrastructure.inference.DefaultProviderRegistry
|
||||
import com.correx.infrastructure.inference.FirstAvailableRoutingStrategy
|
||||
import com.correx.infrastructure.inference.commons.ModelDescriptor
|
||||
import com.correx.infrastructure.inference.commons.ModelManager
|
||||
import com.correx.infrastructure.inference.commons.ResidencyMode
|
||||
import com.correx.infrastructure.inference.llama.cpp.DefaultModelManager
|
||||
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
|
||||
import com.correx.infrastructure.persistence.SqliteEventStore
|
||||
import com.correx.infrastructure.tools.DefaultToolRegistry
|
||||
import com.correx.infrastructure.tools.DispatchingToolExecutor
|
||||
import com.correx.infrastructure.tools.SandboxedToolExecutor
|
||||
import com.correx.infrastructure.tools.ToolConfig
|
||||
import com.correx.infrastructure.tools.buildTools
|
||||
import com.correx.infrastructure.workflow.FileSystemPromptLoader
|
||||
import com.correx.infrastructure.workflow.PromptLoader
|
||||
import com.correx.infrastructure.workflow.TomlWorkflowLoader
|
||||
import com.correx.infrastructure.workflow.WorkflowLoader
|
||||
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
|
||||
import com.correx.core.artifacts.repository.ArtifactRepository
|
||||
import com.correx.core.artifacts.DefaultArtifactReducer
|
||||
import io.ktor.client.HttpClient
|
||||
import java.nio.file.Path
|
||||
import java.sql.DriverManager
|
||||
|
||||
object InfrastructureModule {
|
||||
fun createEventStore(dbPath: String): EventStore =
|
||||
SqliteEventStore(DriverManager.getConnection("jdbc:sqlite:$dbPath"))
|
||||
private val defaultDbPath: String =
|
||||
"${System.getProperty("user.home")}/.config/correx/correx.db"
|
||||
|
||||
fun createEventStore(dbPath: String = defaultDbPath): EventStore {
|
||||
val dir = java.io.File(dbPath).parentFile
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
return SqliteEventStore(DriverManager.getConnection("jdbc:sqlite:$dbPath"))
|
||||
}
|
||||
|
||||
fun createModelManager(
|
||||
eventStore: EventStore,
|
||||
@@ -47,6 +63,31 @@ object InfrastructureModule {
|
||||
strategy: RoutingStrategy = FirstAvailableRoutingStrategy(),
|
||||
): InferenceRouter = DefaultInferenceRouter(registry, strategy)
|
||||
|
||||
fun createLlamaCppProvider(
|
||||
modelId: String,
|
||||
modelPath: String,
|
||||
baseUrl: String = "http://127.0.0.1:10000",
|
||||
residencyMode: ResidencyMode = ResidencyMode.PERSISTENT,
|
||||
): LlamaCppInferenceProvider = LlamaCppInferenceProvider(
|
||||
descriptor = ModelDescriptor(
|
||||
modelId = modelId,
|
||||
modelPath = modelPath,
|
||||
residencyMode = residencyMode,
|
||||
),
|
||||
baseUrl = baseUrl,
|
||||
)
|
||||
|
||||
fun createProviderRegistry(
|
||||
providers: List<InferenceProvider> = emptyList(),
|
||||
): DefaultProviderRegistry = DefaultProviderRegistry(providers)
|
||||
|
||||
fun createArtifactRepository(eventStore: EventStore): ArtifactRepository =
|
||||
LiveArtifactRepository(eventStore, DefaultArtifactReducer())
|
||||
|
||||
fun createWorkflowLoader(): WorkflowLoader = TomlWorkflowLoader()
|
||||
|
||||
fun createPromptLoader(): PromptLoader = FileSystemPromptLoader()
|
||||
|
||||
fun createToolExecutor(
|
||||
registry: ToolRegistry,
|
||||
approvalEngine: ApprovalEngine,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:transitions"))
|
||||
implementation(project(":core:inference"))
|
||||
implementation(project(":core:events"))
|
||||
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-toml:2.17.0")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0")
|
||||
testImplementation "org.junit.jupiter:junit-jupiter"
|
||||
testImplementation "org.jetbrains.kotlin:kotlin-test-junit5"
|
||||
}
|
||||
+39
@@ -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"),
|
||||
)
|
||||
+10
@@ -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,
|
||||
)
|
||||
+19
@@ -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)")
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
interface PromptLoader {
|
||||
fun load(path: String): String
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
class PromptNotFoundException(message: String) : Exception(message)
|
||||
+130
@@ -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'"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -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
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
class WorkflowValidationException(message: String) : Exception(message)
|
||||
+45
@@ -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()))
|
||||
}
|
||||
}
|
||||
+93
@@ -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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user