diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/registry/FileSystemWorkflowRegistry.kt b/apps/server/src/main/kotlin/com/correx/apps/server/registry/FileSystemWorkflowRegistry.kt index 37b3d57a..d2255e7d 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/registry/FileSystemWorkflowRegistry.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/registry/FileSystemWorkflowRegistry.kt @@ -9,6 +9,8 @@ import kotlin.io.path.listDirectoryEntries private val log = LoggerFactory.getLogger(FileSystemWorkflowRegistry::class.java) +private data class WorkflowEntry(val graph: WorkflowGraph, val description: String) + class FileSystemWorkflowRegistry( private val loader: WorkflowLoader, private val workflowsDir: Path = Path.of( @@ -16,23 +18,33 @@ class FileSystemWorkflowRegistry( ), ) : WorkflowRegistry { - private val cache: Map by lazy { loadAll() } + private val cache: Map by lazy { loadAll() } override fun listAll(): List = - cache.values.map { WorkflowSummary(workflowId = it.id, description = it.id) } + cache.values.map { entry -> + WorkflowSummary( + workflowId = entry.graph.id, + description = entry.description.ifBlank { entry.graph.id }, + stageIds = entry.graph.stages.keys.map { it.value }, + ) + } - override fun find(workflowId: String): WorkflowGraph? = cache[workflowId] + override fun find(workflowId: String): WorkflowGraph? = cache[workflowId]?.graph - private fun loadAll(): Map { + private fun loadAll(): Map { if (!workflowsDir.exists()) return emptyMap() return workflowsDir.listDirectoryEntries("*.toml") .mapNotNull { file -> - runCatching { loader.load(file) } + runCatching { + val graph = loader.load(file) + val meta = loader.loadMeta(file) + WorkflowEntry(graph = graph, description = meta.description) + } .onFailure { logWarning(file, it) } .getOrNull() } - .associateBy { graph -> - graph.id.ifBlank { graph.start.value } + .associateBy { entry -> + entry.graph.id.ifBlank { entry.graph.start.value } }.also { log.info("cache init done, cache keys: {}", it.keys) } } diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/registry/WorkflowRegistry.kt b/apps/server/src/main/kotlin/com/correx/apps/server/registry/WorkflowRegistry.kt index d1a0b972..eabdc9ce 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/registry/WorkflowRegistry.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/registry/WorkflowRegistry.kt @@ -1,13 +1,16 @@ package com.correx.apps.server.registry import com.correx.core.transitions.graph.WorkflowGraph +import kotlinx.serialization.Serializable interface WorkflowRegistry { fun listAll(): List fun find(workflowId: String): WorkflowGraph? } +@Serializable data class WorkflowSummary( val workflowId: String, val description: String, + val stageIds: List = emptyList(), ) diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/registry/FileSystemWorkflowRegistryTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/registry/FileSystemWorkflowRegistryTest.kt new file mode 100644 index 00000000..df5b617f --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/registry/FileSystemWorkflowRegistryTest.kt @@ -0,0 +1,71 @@ +package com.correx.apps.server.registry + +import com.correx.infrastructure.workflow.TomlWorkflowLoader +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FileSystemWorkflowRegistryTest { + + @TempDir + lateinit var dir: Path + + private val validToml = """ + id = "healthcheck" + start = "collect" + description = "Runs the health check pipeline" + + [[stages]] + id = "collect" + prompt = "prompts/collect.md" + produces = [{ name = "system_stats", kind = "file_written" }] + allowed_tools = ["ShellTool"] + token_budget = 2048 + max_retries = 2 + + [[stages]] + id = "report" + prompt = "prompts/report.md" + needs = ["system_stats"] + produces = [{ name = "report", kind = "file_written" }] + 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 `listAll returns description from toml`() { + dir.resolve("healthcheck.toml").writeText(validToml) + val summaries = FileSystemWorkflowRegistry(TomlWorkflowLoader(), dir).listAll() + assertEquals("Runs the health check pipeline", summaries.single().description) + } + + @Test + fun `listAll falls back to workflowId when description absent`() { + dir.resolve("healthcheck.toml").writeText(validToml.replace("description = \"Runs the health check pipeline\"\n", "")) + assertEquals("healthcheck", FileSystemWorkflowRegistry(TomlWorkflowLoader(), dir).listAll().single().description) + } + + @Test + fun `listAll returns stageIds for each workflow`() { + dir.resolve("healthcheck.toml").writeText(validToml) + assertTrue( + FileSystemWorkflowRegistry(TomlWorkflowLoader(), dir).listAll().single() + .stageIds.containsAll(listOf("collect", "report")), + ) + } +} diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt index c2dce953..6800f5c0 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt @@ -22,10 +22,13 @@ import kotlin.io.path.readText private data class WorkflowFile( val id: String = "", val start: String = "", + val description: String = "", val stages: List = emptyList(), val transitions: List = emptyList(), ) +data class WorkflowFileMeta(val id: String, val description: String) + private data class ProducesEntry( val name: String = "", val kind: String = "", @@ -74,6 +77,13 @@ class TomlWorkflowLoader( return file.toWorkflowGraph(path.parent) } + override fun loadMeta(path: Path): WorkflowFileMeta { + val raw = path.readText() + val file = runCatching { mapper.readValue(raw) } + .getOrElse { throw WorkflowValidationException("Failed to parse workflow TOML: ${it.message}") } + return WorkflowFileMeta(id = file.id, description = file.description) + } + private fun resolvePromptPath(raw: String, workflowDir: Path?): String { val p = Path.of(raw) if (p.isAbsolute) return raw diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/WorkflowLoader.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/WorkflowLoader.kt index 1c4aa2df..008b7e26 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/WorkflowLoader.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/WorkflowLoader.kt @@ -5,4 +5,5 @@ import java.nio.file.Path interface WorkflowLoader { fun load(path: Path): WorkflowGraph + fun loadMeta(path: Path): WorkflowFileMeta = WorkflowFileMeta("", "") } diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoaderTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoaderTest.kt index 17777dc8..0bd3a9f1 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoaderTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoaderTest.kt @@ -117,4 +117,22 @@ class TomlWorkflowLoaderTest { val collectStage = graph.stages[graph.start]!! assertEquals(null, collectStage.metadata["injectArtifactKinds"]) } + + @Test + fun `description field is parsed and returned by loadMeta`() { + val toml = validToml.replace( + "start = \"collect\"", + "start = \"collect\"\ndescription = \"Runs the health check pipeline\"", + ) + val path = Files.createTempFile("workflow", ".toml").also { it.writeText(toml) } + val meta = loader.loadMeta(path) + assertEquals("Runs the health check pipeline", meta.description) + assertEquals("healthcheck", meta.id) + } + + @Test + fun `description absent in loadMeta returns empty string`() { + val path = Files.createTempFile("workflow", ".toml").also { it.writeText(validToml) } + assertEquals("", loader.loadMeta(path).description) + } }