From bce62c080c2f1ff73da738412da5d752aa8ef79e Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 16:37:50 +0000 Subject: [PATCH] feat(tasks): per-task history / audit timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` CLI command. Co-Authored-By: Claude Opus 4.8 --- .../correx/apps/cli/commands/TaskCommand.kt | 14 ++++ .../correx/apps/server/routes/TaskRoutes.kt | 9 +++ .../apps/server/routes/TaskRoutesTest.kt | 22 +++++ .../com/correx/core/tasks/TaskHistory.kt | 64 +++++++++++++++ .../com/correx/core/tasks/TaskService.kt | 10 +++ .../com/correx/core/tasks/TaskHistoryTest.kt | 81 +++++++++++++++++++ .../com/correx/core/tasks/TaskServiceTest.kt | 26 ++++++ 7 files changed, 226 insertions(+) create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskHistory.kt create mode 100644 core/tasks/src/test/kotlin/com/correx/core/tasks/TaskHistoryTest.kt diff --git a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt index 3a75d00c..32dd5a21 100644 --- a/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt +++ b/apps/cli/src/main/kotlin/com/correx/apps/cli/commands/TaskCommand.kt @@ -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() diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index b690000b..2a6da8c6 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -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) { diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt index beee6378..75062afb 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt @@ -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 { diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskHistory.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskHistory.kt new file mode 100644 index 00000000..ff4a0a64 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskHistory.kt @@ -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, + * ` `. 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): 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 + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt index 4aee0d68..9413b057 100644 --- a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt @@ -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 = + 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 = TaskSearch.search(projectId?.let { list(it) } ?: listAll(), query) diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskHistoryTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskHistoryTest.kt new file mode 100644 index 00000000..472fd313 --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskHistoryTest.kt @@ -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())) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt index 469692ef..b3a8bb62 100644 --- a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskServiceTest.kt @@ -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()) + } }