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:
2026-06-24 16:37:50 +00:00
parent a96c05e228
commit bce62c080c
7 changed files with 226 additions and 0 deletions
@@ -91,6 +91,7 @@ class TaskCommand : CliktCommand(name = "task") {
TaskListCommand(),
TaskSearchCommand(),
TaskShowCommand(),
TaskHistoryCommand(),
TaskCreateCommand(),
TaskClaimCommand(),
TaskCompleteCommand(),
@@ -160,6 +161,19 @@ class TaskShowCommand : TaskHttpCommand("show") {
}
}
class TaskHistoryCommand : TaskHttpCommand("history") {
private val id by argument("TASK_ID")
override fun run() = http { client ->
val resp = client.get("${baseUrl()}/tasks/$id/history")
if (resp.status == HttpStatusCode.NotFound) {
System.err.println("No such task: $id")
} else {
print(resp.bodyAsText())
}
}
}
class TaskCreateCommand : TaskHttpCommand("create") {
private val project by option("--project", help = "Project key, e.g. 'auth'").required()
private val title by option("--title").required()
@@ -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 {