feat(tasks): per-task history / audit timeline
Surfaces a task's lifecycle straight from the event log (the source of truth) so
you can see what actually happened to it — claim/submit/complete, notes, links,
git-driven status — rather than only its current state. Useful for debugging a
live agent run, where current state alone doesn't say how it got there.
- TaskHistory.render: pure timeline renderer (split content/lifecycle to stay under
the complexity cap, mirroring DefaultTaskReducer).
- TaskService.history(id): the task's own events, in order; survives soft-delete
(the deletion is part of the trail), empty for an id that never existed.
- GET /tasks/{id}/history (200 text timeline, 404 when the id never existed) and
the `correx task history <id>` CLI command.
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.tasks.Task
|
||||
import com.correx.core.tasks.TaskContextAssembler
|
||||
import com.correx.core.tasks.TaskHistory
|
||||
import com.correx.core.tasks.TaskMarkdown
|
||||
import com.correx.core.tasks.TaskSearch
|
||||
import com.correx.core.tasks.TaskService
|
||||
@@ -200,6 +201,14 @@ private fun Route.taskCrudRoutes(service: TaskService, assembler: TaskContextAss
|
||||
val bundle = assembler.assemble(id) ?: return@get call.respond(HttpStatusCode.NotFound, "Task not found")
|
||||
call.respond(bundle)
|
||||
}
|
||||
// Lifecycle audit trail from the event log; visible even after a soft-delete. Empty history
|
||||
// means the task id never existed.
|
||||
get("/history") {
|
||||
val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id")
|
||||
val events = service.history(id)
|
||||
if (events.isEmpty()) return@get call.respond(HttpStatusCode.NotFound, "Task not found")
|
||||
call.respondText(TaskHistory.render(events), ContentType.Text.Plain)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.taskTransitionRoutes(service: TaskService) {
|
||||
|
||||
@@ -256,6 +256,28 @@ class TaskRoutesTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GET history renders the lifecycle timeline`(@TempDir tempDir: Path) {
|
||||
testApplication {
|
||||
application { configureServer(buildModule(tempDir)) }
|
||||
val client = createClient {}
|
||||
client.createTask() // demo-1
|
||||
client.post("/tasks/demo-1/claim") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"claimant":"claude-opus"}""")
|
||||
}
|
||||
|
||||
val res = client.get("/tasks/demo-1/history")
|
||||
assertEquals(HttpStatusCode.OK, res.status)
|
||||
val timeline = res.bodyAsText()
|
||||
assertTrue(timeline.contains("created demo-1"))
|
||||
assertTrue(timeline.contains("claimed by claude-opus"))
|
||||
|
||||
// An id that never existed has no history → 404.
|
||||
assertEquals(HttpStatusCode.NotFound, client.get("/tasks/demo-999/history").status)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bad requests are rejected`(@TempDir tempDir: Path) {
|
||||
testApplication {
|
||||
|
||||
Reference in New Issue
Block a user