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
@@ -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())
}
}