feat(tasks): native task tracking — aggregate, agent tools, context bundle
Adds task tracking as a first-class event-sourced aggregate (ADR-0012). The
event log stays the only source of truth; the board is a projection, and all of
a project's task events live in one stream (tasks:<projectId>).
core:
- new core:tasks module mirroring core:sessions: TaskStatus/State/Reducer/
BoardProjector/CounterProjection/Repository + TaskService write path that
appends events via the existing EventStore (no new storage)
- task events in core:events (sealed marker TaskEvent), registered in
Serialization.kt; status is derived by the reducer from lifecycle facts
- work-graph edges: TaskLinkType + typed TaskTargetKind on the link, so bundle
resolution dispatches on the recorded kind instead of guessing from the id
- delete is a soft tombstone (TaskDeletedEvent); cancel stays a visible outcome
surfaces:
- agent tools (Tier T2 mutators, T1 read): task_create / task_update /
task_delete / task_context, registered in both the main and per-workspace
tool registries
- REST GET /tasks/{id}/context for external agents
context bundle (TaskContextAssembler):
- task fields + acceptance criteria + relevant files + resolved dependency
tasks (live status) + raw related links + notes, with a compact render()
- semantic enrichment via a TaskKnowledgeRetriever port bridged to the existing
L3 retriever (late-bound holder for composition-root ordering)
- ADR/doc resolution via a TaskDocumentResolver port + filesystem adapter
(docs/decisions/adr-*.md, padding-agnostic; *.md paths)
Unit-verified across core:tasks / infrastructure:tools / apps:server; full
gradlew check green. Not yet live-QA'd end-to-end against a running server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
package com.correx.core.events.events
|
||||
|
||||
import com.correx.core.events.types.ProjectId
|
||||
import com.correx.core.events.types.TaskId
|
||||
import com.correx.core.events.types.TaskLinkType
|
||||
import com.correx.core.events.types.TaskNoteAuthor
|
||||
import com.correx.core.events.types.TaskTargetKind
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Marker over every task-lifecycle event. Deliberately *not* part of the serialized
|
||||
* [EventPayload] hierarchy — concrete events implement both interfaces — so projections
|
||||
* can recover the owning [taskId] from any payload via `payload as? TaskEvent` without a
|
||||
* giant `when`. Status is never carried on the wire: it is derived by the task reducer
|
||||
* from these facts (mirroring how session status is derived from stage events).
|
||||
*/
|
||||
sealed interface TaskEvent {
|
||||
val taskId: TaskId
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskCreated")
|
||||
data class TaskCreatedEvent(
|
||||
override val taskId: TaskId,
|
||||
val projectId: ProjectId,
|
||||
val key: String,
|
||||
val title: String,
|
||||
val goal: String,
|
||||
val acceptanceCriteria: List<String> = emptyList(),
|
||||
val affectedPaths: List<String> = emptyList(),
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskClaimed")
|
||||
data class TaskClaimedEvent(
|
||||
override val taskId: TaskId,
|
||||
val claimant: String,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskReleased")
|
||||
data class TaskReleasedEvent(
|
||||
override val taskId: TaskId,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskBlocked")
|
||||
data class TaskBlockedEvent(
|
||||
override val taskId: TaskId,
|
||||
val reason: String,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskUnblocked")
|
||||
data class TaskUnblockedEvent(
|
||||
override val taskId: TaskId,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskSubmittedForReview")
|
||||
data class TaskSubmittedForReviewEvent(
|
||||
override val taskId: TaskId,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskCompleted")
|
||||
data class TaskCompletedEvent(
|
||||
override val taskId: TaskId,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskReopened")
|
||||
data class TaskReopenedEvent(
|
||||
override val taskId: TaskId,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskCancelled")
|
||||
data class TaskCancelledEvent(
|
||||
override val taskId: TaskId,
|
||||
val reason: String? = null,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskEdited")
|
||||
data class TaskEditedEvent(
|
||||
override val taskId: TaskId,
|
||||
val title: String? = null,
|
||||
val goal: String? = null,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskAcceptanceCriteriaSet")
|
||||
data class TaskAcceptanceCriteriaSetEvent(
|
||||
override val taskId: TaskId,
|
||||
val criteria: List<String>,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskAffectedPathsSet")
|
||||
data class TaskAffectedPathsSetEvent(
|
||||
override val taskId: TaskId,
|
||||
val paths: List<String>,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskLinked")
|
||||
data class TaskLinkedEvent(
|
||||
override val taskId: TaskId,
|
||||
val targetId: String,
|
||||
// `linkType`, not `type`: the JSON polymorphic class discriminator is "type".
|
||||
@SerialName("linkType") val type: TaskLinkType,
|
||||
val targetKind: TaskTargetKind,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskUnlinked")
|
||||
data class TaskUnlinkedEvent(
|
||||
override val taskId: TaskId,
|
||||
val targetId: String,
|
||||
@SerialName("linkType") val type: TaskLinkType,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
@Serializable
|
||||
@SerialName("TaskNoteAdded")
|
||||
data class TaskNoteAddedEvent(
|
||||
override val taskId: TaskId,
|
||||
val author: TaskNoteAuthor,
|
||||
val body: String,
|
||||
) : EventPayload, TaskEvent
|
||||
|
||||
/**
|
||||
* Soft-delete tombstone. The event stays in the log (history is never rewritten); the board
|
||||
* projection drops the task from active views. Distinct from cancellation, which is a
|
||||
* lifecycle outcome that stays visible as CANCELLED.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("TaskDeleted")
|
||||
data class TaskDeletedEvent(
|
||||
override val taskId: TaskId,
|
||||
) : EventPayload, TaskEvent
|
||||
@@ -72,6 +72,22 @@ import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowProposedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.events.TaskCreatedEvent
|
||||
import com.correx.core.events.events.TaskClaimedEvent
|
||||
import com.correx.core.events.events.TaskReleasedEvent
|
||||
import com.correx.core.events.events.TaskBlockedEvent
|
||||
import com.correx.core.events.events.TaskUnblockedEvent
|
||||
import com.correx.core.events.events.TaskSubmittedForReviewEvent
|
||||
import com.correx.core.events.events.TaskCompletedEvent
|
||||
import com.correx.core.events.events.TaskReopenedEvent
|
||||
import com.correx.core.events.events.TaskCancelledEvent
|
||||
import com.correx.core.events.events.TaskEditedEvent
|
||||
import com.correx.core.events.events.TaskAcceptanceCriteriaSetEvent
|
||||
import com.correx.core.events.events.TaskAffectedPathsSetEvent
|
||||
import com.correx.core.events.events.TaskLinkedEvent
|
||||
import com.correx.core.events.events.TaskUnlinkedEvent
|
||||
import com.correx.core.events.events.TaskNoteAddedEvent
|
||||
import com.correx.core.events.events.TaskDeletedEvent
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.polymorphic
|
||||
@@ -151,6 +167,22 @@ val eventModule = SerializersModule {
|
||||
subclass(LowQualityExtractionEvent::class)
|
||||
subclass(EgressHostsGrantedEvent::class)
|
||||
subclass(StaticFindingsRecordedEvent::class)
|
||||
subclass(TaskCreatedEvent::class)
|
||||
subclass(TaskClaimedEvent::class)
|
||||
subclass(TaskReleasedEvent::class)
|
||||
subclass(TaskBlockedEvent::class)
|
||||
subclass(TaskUnblockedEvent::class)
|
||||
subclass(TaskSubmittedForReviewEvent::class)
|
||||
subclass(TaskCompletedEvent::class)
|
||||
subclass(TaskReopenedEvent::class)
|
||||
subclass(TaskCancelledEvent::class)
|
||||
subclass(TaskEditedEvent::class)
|
||||
subclass(TaskAcceptanceCriteriaSetEvent::class)
|
||||
subclass(TaskAffectedPathsSetEvent::class)
|
||||
subclass(TaskLinkedEvent::class)
|
||||
subclass(TaskUnlinkedEvent::class)
|
||||
subclass(TaskNoteAddedEvent::class)
|
||||
subclass(TaskDeletedEvent::class)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ typealias TransitionId = TypeId
|
||||
typealias ArtifactId = TypeId
|
||||
|
||||
|
||||
// Tasks types
|
||||
typealias TaskId = TypeId
|
||||
|
||||
|
||||
// Context types
|
||||
typealias ContextPackId = TypeId
|
||||
typealias ContextEntryId = TypeId
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.correx.core.events.types
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Edge types for the work graph. A task links to other tasks, artifacts (by [ArtifactId]),
|
||||
* or sessions (by [SessionId]) — the target is stored as a plain id string plus a type.
|
||||
*/
|
||||
@Serializable
|
||||
enum class TaskLinkType {
|
||||
@SerialName("DEPENDS_ON") DEPENDS_ON,
|
||||
@SerialName("BLOCKS") BLOCKS,
|
||||
@SerialName("IMPLEMENTS") IMPLEMENTS,
|
||||
@SerialName("RELATES_TO") RELATES_TO,
|
||||
@SerialName("PRODUCED") PRODUCED,
|
||||
@SerialName("CONTEXT") CONTEXT,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.correx.core.events.types
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Who authored a task note — used to keep the agent and human lanes separable. */
|
||||
@Serializable
|
||||
enum class TaskNoteAuthor {
|
||||
@SerialName("AGENT") AGENT,
|
||||
@SerialName("HUMAN") HUMAN,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.correx.core.events.types
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* What a task link points at — recorded on the link so the context bundle dispatches resolution
|
||||
* deterministically (task lookup vs doc resolution vs left-raw) instead of inferring from the id.
|
||||
*/
|
||||
@Serializable
|
||||
enum class TaskTargetKind {
|
||||
@SerialName("TASK") TASK,
|
||||
@SerialName("DOC") DOC,
|
||||
@SerialName("ARTIFACT") ARTIFACT,
|
||||
@SerialName("SESSION") SESSION,
|
||||
}
|
||||
Reference in New Issue
Block a user