feat(tasks): task search across REST, tool, and TUI

Ranked exact/substring search: TaskSearch (all terms AND, ranked title >
key > goal > criteria > notes) over one project or the whole board via
TaskService.search. Surfaced as GET /tasks?q=, a read-only task_search tool
for agents, and a `/` filter in the TUI board.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 22:08:11 +00:00
parent 1d7fab4ee4
commit 99f781687f
11 changed files with 357 additions and 28 deletions
@@ -8,6 +8,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.TaskSearch
import com.correx.core.tasks.TaskService
import com.correx.core.tasks.TaskStatus
import com.correx.core.tasks.TaskTargetKinds
@@ -83,8 +84,8 @@ data class NoteRequest(val body: String, val author: String? = null)
* Full REST surface for tasks. The write path mirrors the [TaskService] / `task_*` tools; both
* append to the event log, which stays the single source of truth (nothing is stored separately).
* A task's project is encoded in its id, so item routes need only the `{id}`. The list route takes
* an optional `project` (cross-project board without it); create requires `project` in the body.
* [GET /tasks/{id}/context] serves the agent bundle.
* optional `project` (cross-project board without it), `status`, and `q` (ranked text search);
* create requires `project` in the body. [GET /tasks/{id}/context] serves the agent bundle.
*/
fun Route.taskRoutes(module: ServerModule) {
val service = TaskService(module.eventStore)
@@ -113,11 +114,16 @@ private fun Route.taskCollectionRoutes(service: TaskService) {
}
// project is optional: scoped to one project when given, else the whole cross-project board.
val project = call.request.queryParameters["project"]
val tasks = (if (project != null) service.list(ProjectId(project)) else service.listAll())
val base = (if (project != null) service.list(ProjectId(project)) else service.listAll())
.filter { statusFilter == null || it.state.status == statusFilter }
.sortedByDescending { it.state.updatedAt } // most recently touched first
.map { it.toResponse() }
call.respond(tasks)
// q ranks by relevance; without it the board is most-recently-touched first.
val q = call.request.queryParameters["q"]
val ordered = if (q.isNullOrBlank()) {
base.sortedByDescending { it.state.updatedAt }
} else {
TaskSearch.search(base, q)
}
call.respond(ordered.map { it.toResponse() })
}
post {
val body = call.receive<CreateTaskRequest>()
@@ -130,8 +130,10 @@ class TaskRoutesTest {
assertEquals("IN_PROGRESS", claimed.json()["status"]!!.jsonPrimitive.content)
assertEquals("claude-opus", claimed.json()["claimant"]!!.jsonPrimitive.content)
assertEquals("IN_REVIEW", client.post("/tasks/demo-1/submit-for-review").json()["status"]!!.jsonPrimitive.content)
assertEquals("DONE", client.post("/tasks/demo-1/complete").json()["status"]!!.jsonPrimitive.content)
val reviewed = client.post("/tasks/demo-1/submit-for-review").json()
assertEquals("IN_REVIEW", reviewed["status"]!!.jsonPrimitive.content)
val completed = client.post("/tasks/demo-1/complete").json()
assertEquals("DONE", completed["status"]!!.jsonPrimitive.content)
}
}
@@ -185,6 +187,25 @@ class TaskRoutesTest {
}
}
@Test
fun `GET tasks with q returns only ranked search hits`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask() // demo-1: "JWT refresh" / "users stay authed"
client.post("/tasks") {
contentType(ContentType.Application.Json)
setBody("""{"project":"demo","title":"Rotate keys","goal":"rotate signing keys"}""")
}
val res = client.get("/tasks?q=jwt")
assertEquals(HttpStatusCode.OK, res.status)
val rows = testJson.parseToJsonElement(res.bodyAsText()) as JsonArray
assertEquals(1, rows.size)
assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content)
}
}
@Test
fun `context bundle is served for a known task`(@TempDir tempDir: Path) {
testApplication {