diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index 34bda54d..b1640b03 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -11,6 +11,7 @@ import com.correx.core.events.types.TaskNoteAuthor import com.correx.core.events.types.TaskTargetKind import com.correx.core.tasks.Task import com.correx.core.tasks.TaskContextAssembler +import com.correx.core.tasks.TaskGraph import com.correx.core.tasks.TaskHistory import com.correx.core.tasks.TaskMarkdown import com.correx.core.tasks.TaskSearch @@ -52,6 +53,11 @@ data class TaskResponse( val notes: List, val createdAt: String?, val updatedAt: String?, + // Dependency-graph status for the board: ready = TODO with every dependency satisfied (workable + // now); blockedBy = the ids of unfinished tasks this one waits on (its unmet DEPENDS_ON/BLOCKS). + // Default to "not blocked / not ready" for the endpoints that don't resolve the graph. + val ready: Boolean = false, + val blockedBy: List = emptyList(), ) @Serializable @@ -132,7 +138,7 @@ private fun Route.taskCollectionRoutes(service: TaskService) { if (call.request.queryParameters["ready"]?.toBoolean() == true) { val readyProject = call.request.queryParameters["project"] val workable = service.ready(readyProject?.let { ProjectId(it) }) - return@get call.respond(workable.map { it.toResponse() }) + return@get call.respond(workable.map { it.toResponse().copy(ready = true) }) } val statusRaw = call.request.queryParameters["status"] val statusFilter = statusRaw?.let { @@ -149,7 +155,18 @@ private fun Route.taskCollectionRoutes(service: TaskService) { } else { TaskSearch.search(base, q) } - call.respond(ordered.map { it.toResponse() }) + // Resolve ready/blockedBy against each task's FULL project board (not the filtered list — a + // status filter must not hide a finished blocker). One list() per distinct project, cached. + val boards = HashMap>() + fun enrich(t: Task): TaskResponse { + val pid = t.state.projectId ?: return t.toResponse() + val board = boards.getOrPut(pid) { service.list(pid) } + return t.toResponse().copy( + ready = TaskGraph.isReady(t, board), + blockedBy = TaskGraph.unmetBlockers(t, board).map { it.taskId.value }, + ) + } + call.respond(ordered.map(::enrich)) } post { val body = call.receive() diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt index 9ec3c1c0..7f97789c 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt @@ -116,6 +116,40 @@ class TaskRoutesTest { } } + @Test + fun `GET tasks reports dependency-graph ready and blockedBy`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1 (TODO, no deps) + client.post("/tasks") { + contentType(ContentType.Application.Json) + setBody("""{"project":"demo","title":"Rotate keys","goal":"rotate signing keys"}""") + } // demo-2 + // demo-2 DEPENDS_ON demo-1 (TASK kind inferred from the id). + client.post("/tasks/demo-2/links") { + contentType(ContentType.Application.Json) + setBody("""{"targetId":"demo-1","type":"DEPENDS_ON"}""") + } + + val rows = testJson.parseToJsonElement(client.get("/tasks?project=demo").bodyAsText()) as JsonArray + val byId = rows.associate { (it as JsonObject)["id"]!!.jsonPrimitive.content to it } + val one = byId.getValue("demo-1") as JsonObject + val two = byId.getValue("demo-2") as JsonObject + // default-valued fields are omitted from JSON (encodeDefaults=false), which the TUI reads + // back as zero values — so tolerate absence here too. + fun readyOf(o: JsonObject) = o["ready"]?.jsonPrimitive?.content == "true" + fun blockedByOf(o: JsonObject): List = + (o["blockedBy"] as? JsonArray)?.map { it.jsonPrimitive.content } ?: emptyList() + // demo-1: TODO with nothing in its way → ready, no blockers. + assertTrue(readyOf(one)) + assertTrue(blockedByOf(one).isEmpty()) + // demo-2: waits on the unfinished demo-1 → not ready, blockedBy = [demo-1]. + assertTrue(!readyOf(two)) + assertEquals(listOf("demo-1"), blockedByOf(two)) + } + } + @Test fun `lifecycle transitions move a task to DONE`(@TempDir tempDir: Path) { testApplication { diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index af7a37fe..0d576763 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -2042,6 +2042,7 @@ abstract class SessionOrchestrator( */ private suspend fun computeToolPreview(toolName: String, parameters: Map): String? { if (toolName == "shell") return shellCommandPreview(parameters) + if (toolName == "task_decompose") return renderDecomposePreview(parameters) if (toolName != "file_write") return null val path = parameters["path"] as? String ?: return null val operation = parameters["operation"] as? String @@ -2126,3 +2127,35 @@ internal fun parseToolArguments(arguments: String): Map = runCatchi } as Any } }.getOrElse { emptyMap() } + +/** + * Human-readable approval-card preview of a `task_decompose` call: the proposed epic and the numbered + * child tasks with their "after" (dependency) labels — instead of the truncated raw JSON the generic + * fallback would show. The nested `tasks`/`parent` args arrive as JSON text (flattened), so they are + * re-parsed here. Null on any parse miss, so the caller falls back to the raw arguments. + */ +internal fun renderDecomposePreview(parameters: Map): String? { + val project = parameters["project"] as? String ?: return null + val tasks = (parameters["tasks"] as? String) + ?.let { runCatching { Json.parseToJsonElement(it) }.getOrNull() } as? JsonArray ?: return null + if (tasks.isEmpty()) return null + val titleAt = { i: Int -> ((tasks.getOrNull(i) as? JsonObject)?.get("title") as? JsonPrimitive)?.content } + val refToIndex = tasks.mapIndexedNotNull { i, e -> + ((e as? JsonObject)?.get("ref") as? JsonPrimitive)?.content?.let { it to i } + }.toMap() + val parentTitle = (parameters["parent"] as? String) + ?.let { runCatching { Json.parseToJsonElement(it) }.getOrNull() as? JsonObject } + ?.let { (it["title"] as? JsonPrimitive)?.content } + + return buildString { + append("Decompose '").append(project).append("' into:") + parentTitle?.let { append("\n epic: ").append(it) } + tasks.forEachIndexed { i, e -> + val o = e as? JsonObject + append("\n ").append(i + 1).append(". ").append((o?.get("title") as? JsonPrimitive)?.content ?: "(untitled)") + val afters = ((o?.get("depends_on") as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content } ?: emptyList()) + .map { d -> (refToIndex[d] ?: d.toIntOrNull())?.let { titleAt(it) } ?: d } + if (afters.isNotEmpty()) append(" (after: ").append(afters.joinToString(", ")).append(")") + } + } +} diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt index d855eb5e..612a96b4 100644 --- a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ParseToolArgumentsTest.kt @@ -1,6 +1,7 @@ package com.correx.core.kernel.orchestration import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -37,4 +38,33 @@ class ParseToolArgumentsTest { assertTrue(parseToolArguments("not json").isEmpty()) assertTrue(parseToolArguments("[]").isEmpty()) // top-level non-object } + + @Test + fun `decompose preview renders the epic and children with dependency labels`() { + // Args arrive flattened: nested tasks/parent are JSON text, as in a real call. + val preview = renderDecomposePreview( + mapOf( + "project" to "webui", + "parent" to """{"title":"Frontend web UI"}""", + "tasks" to """ + [ + {"ref":"scaffold","title":"Scaffold app"}, + {"title":"Auth view","depends_on":["scaffold"]}, + {"title":"Dashboard","depends_on":["0"]} + ] + """.trimIndent(), + ), + )!! + assertTrue(preview.contains("epic: Frontend web UI")) + assertTrue(preview.contains("1. Scaffold app")) + // dependency labels resolve a ref and a numeric index back to the dependency's title. + assertTrue(preview.contains("2. Auth view (after: Scaffold app)")) + assertTrue(preview.contains("3. Dashboard (after: Scaffold app)")) + } + + @Test + fun `decompose preview is null when tasks are unparseable so the caller falls back`() { + assertNull(renderDecomposePreview(mapOf("project" to "webui", "tasks" to "oops"))) + assertNull(renderDecomposePreview(mapOf("tasks" to "[]"))) + } }