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 {
@@ -0,0 +1,64 @@
package com.correx.core.tasks
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent
import com.correx.core.events.events.TaskAffectedPathsSetEvent
import com.correx.core.events.events.TaskBlockedEvent
import com.correx.core.events.events.TaskCancelledEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskCompletedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.events.TaskDeletedEvent
import com.correx.core.events.events.TaskEditedEvent
import com.correx.core.events.events.TaskEvent
import com.correx.core.events.events.TaskLinkedEvent
import com.correx.core.events.events.TaskNoteAddedEvent
import com.correx.core.events.events.TaskReleasedEvent
import com.correx.core.events.events.TaskReopenedEvent
import com.correx.core.events.events.TaskSubmittedForReviewEvent
import com.correx.core.events.events.TaskUnblockedEvent
import com.correx.core.events.events.TaskUnlinkedEvent
/**
* Renders a task's event log as a human-readable lifecycle timeline. The log is the source of
* truth, so this is the audit trail straight from the facts — one line per event, in order,
* `<timestamp> <what happened>`. Useful for seeing what an agent actually did to a task
* (claim/submit/complete, notes, links, git-driven status) rather than just its current state.
*/
object TaskHistory {
fun render(events: List<StoredEvent>): String {
val lines = events.mapNotNull { e ->
val payload = e.payload as? TaskEvent ?: return@mapNotNull null
"${e.metadata.timestamp} ${describe(payload)}"
}
return if (lines.isEmpty()) "no history" else lines.joinToString("\n")
}
private fun describe(p: TaskEvent): String =
describeContent(p) ?: describeLifecycle(p) ?: (p::class.simpleName ?: "event")
private fun describeContent(p: TaskEvent): String? = when (p) {
is TaskCreatedEvent -> "created ${p.key}: ${p.title}"
is TaskEditedEvent -> "edited"
is TaskAcceptanceCriteriaSetEvent -> "acceptance criteria set (${p.criteria.size})"
is TaskAffectedPathsSetEvent -> "affected paths set (${p.paths.size})"
is TaskLinkedEvent -> "linked ${p.type} -> ${p.targetId} (${p.targetKind})"
is TaskUnlinkedEvent -> "unlinked ${p.type} -> ${p.targetId}"
is TaskNoteAddedEvent -> "note [${p.author}] ${p.body}"
is TaskDeletedEvent -> "deleted"
else -> null
}
private fun describeLifecycle(p: TaskEvent): String? = when (p) {
is TaskClaimedEvent -> "claimed by ${p.claimant}"
is TaskReleasedEvent -> "released"
is TaskBlockedEvent -> "blocked: ${p.reason}"
is TaskUnblockedEvent -> "unblocked"
is TaskSubmittedForReviewEvent -> "submitted for review"
is TaskCompletedEvent -> "completed"
is TaskReopenedEvent -> "reopened"
is TaskCancelledEvent -> "cancelled" + (p.reason?.let { ": $it" } ?: "")
else -> null
}
}
@@ -3,6 +3,7 @@ package com.correx.core.tasks
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent
import com.correx.core.events.events.TaskAffectedPathsSetEvent
import com.correx.core.events.events.TaskBlockedEvent
@@ -12,6 +13,7 @@ import com.correx.core.events.events.TaskCompletedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.events.TaskDeletedEvent
import com.correx.core.events.events.TaskEditedEvent
import com.correx.core.events.events.TaskEvent
import com.correx.core.events.events.TaskLinkedEvent
import com.correx.core.events.events.TaskNoteAddedEvent
import com.correx.core.events.events.TaskReleasedEvent
@@ -70,6 +72,14 @@ class TaskService(
return if (state.deleted) null else Task(taskId, state)
}
/**
* A task's own events in order — the lifecycle audit trail straight from the log (survives
* soft-delete, unlike [getTask]). Empty when the task never existed. Render with [TaskHistory].
*/
fun history(taskId: TaskId): List<StoredEvent> =
eventStore.read(TaskStreams.forProject(projectOf(taskId)))
.filter { (it.payload as? TaskEvent)?.taskId == taskId }
/** Ranked text search over one project (when given) or the whole cross-project board. */
fun search(query: String, projectId: ProjectId? = null): List<Task> =
TaskSearch.search(projectId?.let { list(it) } ?: listAll(), query)
@@ -0,0 +1,81 @@
package com.correx.core.tasks
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.TaskCancelledEvent
import com.correx.core.events.events.TaskClaimedEvent
import com.correx.core.events.events.TaskCompletedEvent
import com.correx.core.events.events.TaskCreatedEvent
import com.correx.core.events.events.TaskNoteAddedEvent
import com.correx.core.events.events.TaskSubmittedForReviewEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.types.ProjectId
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.TaskId
import com.correx.core.events.types.TaskNoteAuthor
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class TaskHistoryTest {
private val taskId = TaskId("auth-1")
private fun stored(payload: EventPayload, seq: Long) = StoredEvent(
metadata = EventMetadata(
eventId = EventId("e-$seq"),
sessionId = SessionId("tasks:auth"),
timestamp = Instant.parse("2026-01-0${seq}T00:00:00Z"),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = seq,
sessionSequence = seq,
payload = payload,
)
private fun created() = TaskCreatedEvent(
taskId = taskId,
projectId = ProjectId("auth"),
key = "auth-1",
title = "Add JWT refresh",
goal = "users stay authenticated",
)
@Test
fun `renders the lifecycle as a timeline in order`() {
val events = listOf(
stored(created(), 1),
stored(TaskClaimedEvent(taskId, "claude-opus"), 2),
stored(TaskNoteAddedEvent(taskId, TaskNoteAuthor.AGENT, "migration retried"), 3),
stored(TaskSubmittedForReviewEvent(taskId), 4),
stored(TaskCompletedEvent(taskId), 5),
)
val lines = TaskHistory.render(events).lines()
assertEquals(5, lines.size)
assertTrue(lines[0].contains("2026-01-01T00:00:00Z"), lines[0])
assertTrue(lines[0].contains("created auth-1: Add JWT refresh"), lines[0])
assertTrue(lines[1].contains("claimed by claude-opus"), lines[1])
assertTrue(lines[2].contains("note [AGENT] migration retried"), lines[2])
assertTrue(lines[3].contains("submitted for review"), lines[3])
assertTrue(lines[4].contains("completed"), lines[4])
}
@Test
fun `cancellation reason is included`() {
val lines = TaskHistory.render(
listOf(stored(created(), 1), stored(TaskCancelledEvent(taskId, "superseded"), 2)),
).lines()
assertTrue(lines[1].contains("cancelled: superseded"), lines[1])
}
@Test
fun `empty event list renders as no history`() {
assertEquals("no history", TaskHistory.render(emptyList()))
}
}
@@ -89,4 +89,30 @@ class TaskServiceTest {
// count() is creation-based, so the deleted id is never reused.
assertEquals("auth-2", third.taskId.value)
}
@Test
fun `history returns the task's own events in order`() = runBlocking {
val id = service.createTask(project, "Add JWT refresh", "auth").taskId
service.claim(id, "claude-opus")
service.submitForReview(id)
service.complete(id)
// A second task in the same stream must not bleed into this one's history.
service.createTask(project, "unrelated", "g")
val lines = TaskHistory.render(service.history(id)).lines()
assertEquals(4, lines.size)
assertTrue(lines.first().contains("created"))
assertTrue(lines.last().contains("completed"))
}
@Test
fun `history survives soft-delete but is empty for an unknown task`() = runBlocking {
val id = service.createTask(project, "throwaway", "oops").taskId
service.delete(id)
// getTask hides a deleted task, but its audit trail (incl. the deletion) remains.
assertNull(service.getTask(id))
assertTrue(service.history(id).isNotEmpty())
assertTrue(service.history(TaskId("auth-999")).isEmpty())
}
}