feat(tasks): surface ready/blockedBy on GET /tasks and a readable decompose preview
GET /tasks now reports dependency-graph status per task — ready (TODO with no unmet deps) and blockedBy (ids of unfinished blockers) — resolved via TaskGraph over each task's FULL project board, so a status filter can't hide a blocker. Default-valued fields are omitted (encodeDefaults=false); consumers read absent as zero. The task_decompose approval card now shows a readable graph (epic + numbered children with their "after" dependency labels) via renderDecomposePreview wired into computeToolPreview, instead of the truncated raw JSON the generic fallback gave. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+33
@@ -2042,6 +2042,7 @@ abstract class SessionOrchestrator(
|
||||
*/
|
||||
private suspend fun computeToolPreview(toolName: String, parameters: Map<String, Any>): 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<String, Any> = 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, Any>): 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(")")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -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 "[]")))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user