feat(workflow): root description field in workflow TOML surfaced through registry
This commit is contained in:
+19
-7
@@ -9,6 +9,8 @@ import kotlin.io.path.listDirectoryEntries
|
|||||||
|
|
||||||
private val log = LoggerFactory.getLogger(FileSystemWorkflowRegistry::class.java)
|
private val log = LoggerFactory.getLogger(FileSystemWorkflowRegistry::class.java)
|
||||||
|
|
||||||
|
private data class WorkflowEntry(val graph: WorkflowGraph, val description: String)
|
||||||
|
|
||||||
class FileSystemWorkflowRegistry(
|
class FileSystemWorkflowRegistry(
|
||||||
private val loader: WorkflowLoader,
|
private val loader: WorkflowLoader,
|
||||||
private val workflowsDir: Path = Path.of(
|
private val workflowsDir: Path = Path.of(
|
||||||
@@ -16,23 +18,33 @@ class FileSystemWorkflowRegistry(
|
|||||||
),
|
),
|
||||||
) : WorkflowRegistry {
|
) : WorkflowRegistry {
|
||||||
|
|
||||||
private val cache: Map<String, WorkflowGraph> by lazy { loadAll() }
|
private val cache: Map<String, WorkflowEntry> by lazy { loadAll() }
|
||||||
|
|
||||||
override fun listAll(): List<WorkflowSummary> =
|
override fun listAll(): List<WorkflowSummary> =
|
||||||
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<String, WorkflowGraph> {
|
private fun loadAll(): Map<String, WorkflowEntry> {
|
||||||
if (!workflowsDir.exists()) return emptyMap()
|
if (!workflowsDir.exists()) return emptyMap()
|
||||||
return workflowsDir.listDirectoryEntries("*.toml")
|
return workflowsDir.listDirectoryEntries("*.toml")
|
||||||
.mapNotNull { file ->
|
.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) }
|
.onFailure { logWarning(file, it) }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
.associateBy { graph ->
|
.associateBy { entry ->
|
||||||
graph.id.ifBlank { graph.start.value }
|
entry.graph.id.ifBlank { entry.graph.start.value }
|
||||||
}.also { log.info("cache init done, cache keys: {}", it.keys) }
|
}.also { log.info("cache init done, cache keys: {}", it.keys) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package com.correx.apps.server.registry
|
package com.correx.apps.server.registry
|
||||||
|
|
||||||
import com.correx.core.transitions.graph.WorkflowGraph
|
import com.correx.core.transitions.graph.WorkflowGraph
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
interface WorkflowRegistry {
|
interface WorkflowRegistry {
|
||||||
fun listAll(): List<WorkflowSummary>
|
fun listAll(): List<WorkflowSummary>
|
||||||
fun find(workflowId: String): WorkflowGraph?
|
fun find(workflowId: String): WorkflowGraph?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class WorkflowSummary(
|
data class WorkflowSummary(
|
||||||
val workflowId: String,
|
val workflowId: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
|
val stageIds: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|||||||
+71
@@ -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")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -22,10 +22,13 @@ import kotlin.io.path.readText
|
|||||||
private data class WorkflowFile(
|
private data class WorkflowFile(
|
||||||
val id: String = "",
|
val id: String = "",
|
||||||
val start: String = "",
|
val start: String = "",
|
||||||
|
val description: String = "",
|
||||||
val stages: List<StageSection> = emptyList(),
|
val stages: List<StageSection> = emptyList(),
|
||||||
val transitions: List<TransitionSection> = emptyList(),
|
val transitions: List<TransitionSection> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class WorkflowFileMeta(val id: String, val description: String)
|
||||||
|
|
||||||
private data class ProducesEntry(
|
private data class ProducesEntry(
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
val kind: String = "",
|
val kind: String = "",
|
||||||
@@ -74,6 +77,13 @@ class TomlWorkflowLoader(
|
|||||||
return file.toWorkflowGraph(path.parent)
|
return file.toWorkflowGraph(path.parent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun loadMeta(path: Path): WorkflowFileMeta {
|
||||||
|
val raw = path.readText()
|
||||||
|
val file = runCatching { mapper.readValue<WorkflowFile>(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 {
|
private fun resolvePromptPath(raw: String, workflowDir: Path?): String {
|
||||||
val p = Path.of(raw)
|
val p = Path.of(raw)
|
||||||
if (p.isAbsolute) return raw
|
if (p.isAbsolute) return raw
|
||||||
|
|||||||
+1
@@ -5,4 +5,5 @@ import java.nio.file.Path
|
|||||||
|
|
||||||
interface WorkflowLoader {
|
interface WorkflowLoader {
|
||||||
fun load(path: Path): WorkflowGraph
|
fun load(path: Path): WorkflowGraph
|
||||||
|
fun loadMeta(path: Path): WorkflowFileMeta = WorkflowFileMeta("", "")
|
||||||
}
|
}
|
||||||
|
|||||||
+18
@@ -117,4 +117,22 @@ class TomlWorkflowLoaderTest {
|
|||||||
val collectStage = graph.stages[graph.start]!!
|
val collectStage = graph.stages[graph.start]!!
|
||||||
assertEquals(null, collectStage.metadata["injectArtifactKinds"])
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user