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:
@@ -11,6 +11,7 @@ import com.correx.core.events.types.TaskNoteAuthor
|
|||||||
import com.correx.core.events.types.TaskTargetKind
|
import com.correx.core.events.types.TaskTargetKind
|
||||||
import com.correx.core.tasks.Task
|
import com.correx.core.tasks.Task
|
||||||
import com.correx.core.tasks.TaskContextAssembler
|
import com.correx.core.tasks.TaskContextAssembler
|
||||||
|
import com.correx.core.tasks.TaskGraph
|
||||||
import com.correx.core.tasks.TaskHistory
|
import com.correx.core.tasks.TaskHistory
|
||||||
import com.correx.core.tasks.TaskMarkdown
|
import com.correx.core.tasks.TaskMarkdown
|
||||||
import com.correx.core.tasks.TaskSearch
|
import com.correx.core.tasks.TaskSearch
|
||||||
@@ -52,6 +53,11 @@ data class TaskResponse(
|
|||||||
val notes: List<TaskNoteDto>,
|
val notes: List<TaskNoteDto>,
|
||||||
val createdAt: String?,
|
val createdAt: String?,
|
||||||
val updatedAt: 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<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -132,7 +138,7 @@ private fun Route.taskCollectionRoutes(service: TaskService) {
|
|||||||
if (call.request.queryParameters["ready"]?.toBoolean() == true) {
|
if (call.request.queryParameters["ready"]?.toBoolean() == true) {
|
||||||
val readyProject = call.request.queryParameters["project"]
|
val readyProject = call.request.queryParameters["project"]
|
||||||
val workable = service.ready(readyProject?.let { ProjectId(it) })
|
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 statusRaw = call.request.queryParameters["status"]
|
||||||
val statusFilter = statusRaw?.let {
|
val statusFilter = statusRaw?.let {
|
||||||
@@ -149,7 +155,18 @@ private fun Route.taskCollectionRoutes(service: TaskService) {
|
|||||||
} else {
|
} else {
|
||||||
TaskSearch.search(base, q)
|
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<ProjectId, List<Task>>()
|
||||||
|
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 {
|
post {
|
||||||
val body = call.receive<CreateTaskRequest>()
|
val body = call.receive<CreateTaskRequest>()
|
||||||
|
|||||||
@@ -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<String> =
|
||||||
|
(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
|
@Test
|
||||||
fun `lifecycle transitions move a task to DONE`(@TempDir tempDir: Path) {
|
fun `lifecycle transitions move a task to DONE`(@TempDir tempDir: Path) {
|
||||||
testApplication {
|
testApplication {
|
||||||
|
|||||||
+33
@@ -2042,6 +2042,7 @@ abstract class SessionOrchestrator(
|
|||||||
*/
|
*/
|
||||||
private suspend fun computeToolPreview(toolName: String, parameters: Map<String, Any>): String? {
|
private suspend fun computeToolPreview(toolName: String, parameters: Map<String, Any>): String? {
|
||||||
if (toolName == "shell") return shellCommandPreview(parameters)
|
if (toolName == "shell") return shellCommandPreview(parameters)
|
||||||
|
if (toolName == "task_decompose") return renderDecomposePreview(parameters)
|
||||||
if (toolName != "file_write") return null
|
if (toolName != "file_write") return null
|
||||||
val path = parameters["path"] as? String ?: return null
|
val path = parameters["path"] as? String ?: return null
|
||||||
val operation = parameters["operation"] as? String
|
val operation = parameters["operation"] as? String
|
||||||
@@ -2126,3 +2127,35 @@ internal fun parseToolArguments(arguments: String): Map<String, Any> = runCatchi
|
|||||||
} as Any
|
} as Any
|
||||||
}
|
}
|
||||||
}.getOrElse { emptyMap() }
|
}.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
|
package com.correx.core.kernel.orchestration
|
||||||
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
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.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
@@ -37,4 +38,33 @@ class ParseToolArgumentsTest {
|
|||||||
assertTrue(parseToolArguments("not json").isEmpty())
|
assertTrue(parseToolArguments("not json").isEmpty())
|
||||||
assertTrue(parseToolArguments("[]").isEmpty()) // top-level non-object
|
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