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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user