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