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:
@@ -20,6 +20,7 @@ dependencies {
|
||||
implementation project(':core:events')
|
||||
implementation project(':core:approvals')
|
||||
implementation project(':core:sessions')
|
||||
implementation project(':core:tasks')
|
||||
implementation project(':core:kernel')
|
||||
implementation project(':core:journal')
|
||||
implementation project(':core:inference')
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.correx.apps.server
|
||||
import com.correx.apps.server.health.HealthInspectionService
|
||||
import com.correx.apps.server.routes.providerRoutes
|
||||
import com.correx.apps.server.routes.sessionRoutes
|
||||
import com.correx.apps.server.routes.taskRoutes
|
||||
import com.correx.apps.server.routes.workflowRoutes
|
||||
import com.correx.apps.server.ws.GlobalStreamHandler
|
||||
import io.ktor.http.HttpStatusCode
|
||||
@@ -57,5 +58,6 @@ fun Application.configureServer(module: ServerModule) {
|
||||
sessionRoutes(module)
|
||||
workflowRoutes(module)
|
||||
providerRoutes(module)
|
||||
taskRoutes(module)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import com.correx.core.sessions.DefaultSessionReducer
|
||||
import com.correx.core.sessions.DefaultSessionRepository
|
||||
import com.correx.core.sessions.SessionProjector
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.core.tasks.TaskService
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
import com.correx.core.transitions.resolution.DefaultTransitionResolver
|
||||
import com.correx.core.validation.artifact.ArtifactPayloadValidator
|
||||
@@ -80,6 +81,7 @@ import com.correx.infrastructure.inference.commons.UnavailableProbe
|
||||
import com.correx.core.inference.InferenceProvider
|
||||
import com.correx.infrastructure.inference.llama.cpp.LlamaCppInferenceProvider
|
||||
import com.correx.infrastructure.tools.DispatchingToolExecutor
|
||||
import com.correx.infrastructure.tools.task.TaskTools
|
||||
import com.correx.infrastructure.tools.FileEditConfig
|
||||
import com.correx.infrastructure.tools.FileReadConfig
|
||||
import com.correx.infrastructure.tools.FileWriteConfig
|
||||
@@ -211,6 +213,16 @@ fun main() {
|
||||
null
|
||||
},
|
||||
)
|
||||
// Agents create/update/delete tasks through the tool system (tier-gated like any tool);
|
||||
// the service appends to the same event log. Project is derived from the task id prefix.
|
||||
// The knowledge retriever is late-bound: the L3 retriever isn't built until later in this
|
||||
// composition root, so the holder is captured now and its delegate set once L3 exists (below).
|
||||
val taskKnowledgeRetriever = com.correx.apps.server.memory.DeferredTaskKnowledgeRetriever()
|
||||
// Inlines linked ADRs/docs into the context bundle. The repo root is known here, so unlike the
|
||||
// L3 retriever this needs no late binding.
|
||||
val taskDocumentResolver = com.correx.apps.server.tasks.FileTaskDocumentResolver(workspaceRoot)
|
||||
val taskService = TaskService(eventStore)
|
||||
val taskTools = TaskTools.forService(taskService, taskKnowledgeRetriever, taskDocumentResolver)
|
||||
val toolRegistry = InfrastructureModule.createToolRegistry(
|
||||
buildToolConfig(
|
||||
workspaceRoot,
|
||||
@@ -219,6 +231,7 @@ fun main() {
|
||||
toolsConfig,
|
||||
researchToolConfig,
|
||||
),
|
||||
extraTools = taskTools,
|
||||
)
|
||||
val toolExecutor = InfrastructureModule.createToolExecutor(
|
||||
registry = toolRegistry,
|
||||
@@ -246,6 +259,7 @@ fun main() {
|
||||
val wsToolRegistryProvider = WorkspaceToolRegistryProvider { workspace ->
|
||||
val wsRegistry = InfrastructureModule.createToolRegistry(
|
||||
buildToolConfigForWorkspace(workspace, shellAllowedExecutables, toolsConfig, researchToolConfig),
|
||||
extraTools = taskTools,
|
||||
)
|
||||
val wsExecutor = DispatchingToolExecutor(wsRegistry)
|
||||
WorkspaceTools(registry = wsRegistry, executor = wsExecutor)
|
||||
@@ -311,6 +325,10 @@ fun main() {
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// L3 retriever now exists — bind it into the task context bundle's knowledge port so the
|
||||
// task_context tool and GET /tasks/{id}/context return semantically-relevant snippets.
|
||||
taskKnowledgeRetriever.delegate =
|
||||
repoKnowledgeRetriever?.let { com.correx.apps.server.memory.RepoKnowledgeTaskRetriever(it) }
|
||||
val orchestrator = DefaultSessionOrchestrator(
|
||||
repositories = repositories,
|
||||
engines = engines,
|
||||
@@ -501,6 +519,8 @@ fun main() {
|
||||
operatorProfile = operatorProfile,
|
||||
profileAdaptationService = profileAdaptationService,
|
||||
healthMonitor = healthMonitor,
|
||||
taskKnowledgeRetriever = taskKnowledgeRetriever,
|
||||
taskDocumentResolver = taskDocumentResolver,
|
||||
)
|
||||
// Wire live config editing: persist to TOML, swap the holder, and rebuild config-derived
|
||||
// services. Built after the module so the rebuild hook can swap them in place.
|
||||
|
||||
@@ -115,6 +115,10 @@ class ServerModule(
|
||||
// Continuous health watch (observability-spec §4). Null disables it (tests / health.enabled=false);
|
||||
// records degraded/restored edge events on the system session, started in [start].
|
||||
private val healthMonitor: com.correx.apps.server.health.HealthMonitor? = null,
|
||||
// Semantic enrichment for the task context bundle (GET /tasks/{id}/context). Null disables it.
|
||||
val taskKnowledgeRetriever: com.correx.core.tasks.TaskKnowledgeRetriever? = null,
|
||||
// Resolves linked ADRs/docs inline in the task context bundle. Null leaves them as raw links.
|
||||
val taskDocumentResolver: com.correx.core.tasks.TaskDocumentResolver? = null,
|
||||
) {
|
||||
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
|
||||
orchestrator = orchestrator,
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.correx.apps.server.memory
|
||||
|
||||
import com.correx.core.tasks.KnowledgeHit
|
||||
import com.correx.core.tasks.TaskKnowledgeRetriever
|
||||
|
||||
/**
|
||||
* Late-bound holder for the task knowledge retriever. The task tools are built at tool-registry
|
||||
* construction time, which is before the L3 retriever exists in the composition root; this holder
|
||||
* is created early, captured by the tools and the route, and has its [delegate] set once the L3
|
||||
* retriever is available. Until then (or when L3 is disabled) it returns no hits.
|
||||
*/
|
||||
class DeferredTaskKnowledgeRetriever : TaskKnowledgeRetriever {
|
||||
@Volatile
|
||||
var delegate: TaskKnowledgeRetriever? = null
|
||||
|
||||
override suspend fun retrieve(query: String, limit: Int): List<KnowledgeHit> =
|
||||
delegate?.retrieve(query, limit) ?: emptyList()
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.correx.apps.server.memory
|
||||
|
||||
import com.correx.apps.server.health.SYSTEM_SESSION
|
||||
import com.correx.core.kernel.orchestration.RepoKnowledgeRetriever
|
||||
import com.correx.core.tasks.KnowledgeHit
|
||||
import com.correx.core.tasks.TaskKnowledgeRetriever
|
||||
|
||||
/**
|
||||
* Bridges the task context bundle's [TaskKnowledgeRetriever] port to the existing L3 vector
|
||||
* retriever ([RepoKnowledgeRetriever], typically [L3RepoKnowledgeRetriever]). Tasks are not
|
||||
* session-scoped for retrieval, so the system session is used (the L3 repo retriever ignores it).
|
||||
*/
|
||||
class RepoKnowledgeTaskRetriever(
|
||||
private val delegate: RepoKnowledgeRetriever,
|
||||
) : TaskKnowledgeRetriever {
|
||||
override suspend fun retrieve(query: String, limit: Int): List<KnowledgeHit> =
|
||||
delegate.retrieve(SYSTEM_SESSION, query, limit)
|
||||
.map { KnowledgeHit(source = it.path, text = it.text, score = it.score.toDouble()) }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.correx.apps.server.routes
|
||||
|
||||
import com.correx.apps.server.ServerModule
|
||||
import com.correx.core.tasks.TaskContextAssembler
|
||||
import com.correx.core.tasks.TaskService
|
||||
import com.correx.core.utils.TypeId
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.route
|
||||
|
||||
/**
|
||||
* Read surface for tasks. Exposes the agent context bundle (`GET /tasks/{id}/context`) for
|
||||
* external agents (Claude Code / aider / codex). In-process roles use the `task_context` tool,
|
||||
* which shares the same [TaskContextAssembler]. Both read from the event log; nothing is stored.
|
||||
*/
|
||||
fun Route.taskRoutes(module: ServerModule) {
|
||||
val assembler = TaskContextAssembler(
|
||||
TaskService(module.eventStore),
|
||||
module.taskKnowledgeRetriever,
|
||||
module.taskDocumentResolver,
|
||||
)
|
||||
route("/tasks/{id}") {
|
||||
get("/context") {
|
||||
val id = call.parameters["id"]
|
||||
?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id")
|
||||
val bundle = assembler.assemble(TypeId(id))
|
||||
?: return@get call.respond(HttpStatusCode.NotFound, "Task not found")
|
||||
call.respond(bundle)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.correx.apps.server.tasks
|
||||
|
||||
import com.correx.core.tasks.ResolvedDocument
|
||||
import com.correx.core.tasks.TaskDocumentResolver
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.io.path.isDirectory
|
||||
import kotlin.io.path.isRegularFile
|
||||
import kotlin.io.path.name
|
||||
import kotlin.io.path.readText
|
||||
|
||||
private const val EXCERPT_MAX = 280
|
||||
private val ADR_ID = Regex("(?i)^adr[-_ ]?0*(\\d+)$")
|
||||
private val ADR_FILE = Regex("^adr-0*(\\d+)")
|
||||
private val FRONTMATTER_NAME = Regex("(?m)^name:\\s*\"?(.+?)\"?\\s*$")
|
||||
private val FRONTMATTER_DESC = Regex("(?m)^description:\\s*\"?(.+?)\"?\\s*$")
|
||||
|
||||
/**
|
||||
* Resolves link targets to repo documents for the task context bundle:
|
||||
* - ADR ids ("adr-7", "ADR-0007", "adr 12") → `docs/decisions/adr-<n>-*.md` (matched by number,
|
||||
* padding-agnostic);
|
||||
* - explicit "*.md" paths under the repo root.
|
||||
* Title is the frontmatter `name:` or first `# ` heading; excerpt is the frontmatter
|
||||
* `description:` or the first prose paragraph, capped. Returns null for anything else.
|
||||
*/
|
||||
class FileTaskDocumentResolver(
|
||||
private val repoRoot: Path,
|
||||
private val decisionsDir: Path = repoRoot.resolve("docs/decisions"),
|
||||
) : TaskDocumentResolver {
|
||||
|
||||
override suspend fun resolve(targetId: String): ResolvedDocument? = withContext(Dispatchers.IO) {
|
||||
resolveAdr(targetId.trim()) ?: resolveMarkdownPath(targetId.trim())
|
||||
}
|
||||
|
||||
private fun resolveAdr(targetId: String): ResolvedDocument? {
|
||||
val num = ADR_ID.matchEntire(targetId)?.groupValues?.get(1)?.toIntOrNull() ?: return null
|
||||
if (!decisionsDir.isDirectory()) return null
|
||||
val file = Files.list(decisionsDir).use { stream ->
|
||||
stream.filter { it.isRegularFile() && it.name.endsWith(".md") }
|
||||
.filter { adrNumber(it.name) == num }
|
||||
.findFirst()
|
||||
.orElse(null)
|
||||
} ?: return null
|
||||
return readDocument(file)
|
||||
}
|
||||
|
||||
private fun resolveMarkdownPath(targetId: String): ResolvedDocument? {
|
||||
if (!targetId.endsWith(".md")) return null
|
||||
val candidate = repoRoot.resolve(targetId).normalize()
|
||||
if (!candidate.startsWith(repoRoot) || !candidate.exists() || !candidate.isRegularFile()) return null
|
||||
return readDocument(candidate)
|
||||
}
|
||||
|
||||
private fun adrNumber(fileName: String): Int? =
|
||||
ADR_FILE.find(fileName.lowercase())?.groupValues?.get(1)?.toIntOrNull()
|
||||
|
||||
private fun readDocument(file: Path): ResolvedDocument {
|
||||
val text = runCatching { file.readText() }.getOrDefault("")
|
||||
return ResolvedDocument(
|
||||
title = extractTitle(text) ?: file.name,
|
||||
path = repoRoot.relativize(file).toString(),
|
||||
excerpt = extractExcerpt(text),
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractTitle(text: String): String? {
|
||||
FRONTMATTER_NAME.find(text)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
return text.lineSequence().firstOrNull { it.startsWith("# ") }?.removePrefix("# ")?.trim()
|
||||
}
|
||||
|
||||
private fun extractExcerpt(text: String): String {
|
||||
val desc = FRONTMATTER_DESC.find(text)?.groupValues?.get(1)?.trim()
|
||||
return (desc ?: firstParagraph(text)).take(EXCERPT_MAX).trim()
|
||||
}
|
||||
|
||||
private fun firstParagraph(text: String): String =
|
||||
stripFrontmatter(text).lineSequence()
|
||||
.map { it.trim() }
|
||||
.firstOrNull { it.isNotEmpty() && !it.startsWith("#") && !it.startsWith("---") }
|
||||
?: ""
|
||||
|
||||
private fun stripFrontmatter(text: String): String {
|
||||
if (!text.startsWith("---")) return text
|
||||
val lines = text.lines()
|
||||
val closeIdx = lines.drop(1).indexOfFirst { it.trim() == "---" }
|
||||
return if (closeIdx >= 0) lines.drop(closeIdx + 2).joinToString("\n") else text
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.correx.apps.server.tasks
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.createDirectories
|
||||
import kotlin.io.path.writeText
|
||||
|
||||
class FileTaskDocumentResolverTest {
|
||||
|
||||
@TempDir
|
||||
lateinit var repo: Path
|
||||
|
||||
private fun writeAdr() {
|
||||
val dir = repo.resolve("docs/decisions").also { it.createDirectories() }
|
||||
dir.resolve("adr-0007-redis.md").writeText(
|
||||
"""
|
||||
---
|
||||
name: "ADR 7 Redis Sessions"
|
||||
description: "use redis for session storage"
|
||||
---
|
||||
|
||||
# ADR 7: use redis for session storage
|
||||
|
||||
**status:** accepted
|
||||
|
||||
## decision
|
||||
Redis backs session storage.
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolves an ADR id padding-agnostically`() = runBlocking {
|
||||
writeAdr()
|
||||
val resolver = FileTaskDocumentResolver(repo)
|
||||
|
||||
val byShort = resolver.resolve("adr-7")
|
||||
val byPadded = resolver.resolve("ADR-0007")
|
||||
|
||||
assertEquals("ADR 7 Redis Sessions", byShort?.title)
|
||||
assertEquals("use redis for session storage", byShort?.excerpt)
|
||||
assertEquals("docs/decisions/adr-0007-redis.md", byShort?.path)
|
||||
assertEquals(byShort, byPadded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown ADR resolves to null`() = runBlocking {
|
||||
writeAdr()
|
||||
assertNull(FileTaskDocumentResolver(repo).resolve("adr-999"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolves an explicit markdown path and falls back to heading and first paragraph`() = runBlocking {
|
||||
repo.resolve("docs/notes").createDirectories()
|
||||
repo.resolve("docs/notes/plan.md").writeText("# Plan\n\nThe plan body.\n")
|
||||
|
||||
val doc = FileTaskDocumentResolver(repo).resolve("docs/notes/plan.md")
|
||||
|
||||
assertEquals("Plan", doc?.title)
|
||||
assertEquals("The plan body.", doc?.excerpt)
|
||||
assertEquals("docs/notes/plan.md", doc?.path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-document target resolves to null`() = runBlocking {
|
||||
assertNull(FileTaskDocumentResolver(repo).resolve("ext-123"))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'org.jetbrains.kotlin.jvm'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:events"))
|
||||
testImplementation(testFixtures(project(":testing:contracts")))
|
||||
}
|
||||
|
||||
tasks.named("koverVerify").configure { enabled = false }
|
||||
@@ -0,0 +1,147 @@
|
||||
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
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Folds task events into [TaskState]. Field-update events always apply; lifecycle events
|
||||
* apply only on a legal transition — an illegal transition is ignored and counted in
|
||||
* [TaskState.invalidTransitions] (mirrors how `SessionState` tracks invalid transitions).
|
||||
*/
|
||||
class DefaultTaskReducer : TaskReducer {
|
||||
|
||||
override fun reduce(state: TaskState, event: StoredEvent): TaskState {
|
||||
val payload = event.payload as? TaskEvent ?: return state
|
||||
val ts = event.metadata.timestamp
|
||||
val base = state.copy(
|
||||
createdAt = state.createdAt ?: ts,
|
||||
updatedAt = ts,
|
||||
)
|
||||
return reduceContent(state, base, payload, ts)
|
||||
?: reduceTransition(state, base, payload)
|
||||
}
|
||||
|
||||
/** Field updates that do not touch status. Returns null for lifecycle events. */
|
||||
private fun reduceContent(
|
||||
state: TaskState,
|
||||
base: TaskState,
|
||||
payload: TaskEvent,
|
||||
ts: Instant,
|
||||
): TaskState? = when (payload) {
|
||||
is TaskCreatedEvent -> base.copy(
|
||||
status = TaskStatus.TODO,
|
||||
key = payload.key,
|
||||
projectId = payload.projectId,
|
||||
title = payload.title,
|
||||
goal = payload.goal,
|
||||
acceptanceCriteria = payload.acceptanceCriteria,
|
||||
affectedPaths = payload.affectedPaths,
|
||||
)
|
||||
|
||||
is TaskEditedEvent -> base.copy(
|
||||
title = payload.title ?: state.title,
|
||||
goal = payload.goal ?: state.goal,
|
||||
)
|
||||
|
||||
is TaskAcceptanceCriteriaSetEvent -> base.copy(acceptanceCriteria = payload.criteria)
|
||||
|
||||
is TaskAffectedPathsSetEvent -> base.copy(affectedPaths = payload.paths)
|
||||
|
||||
is TaskLinkedEvent -> base.copy(
|
||||
links = (state.links + TaskLink(payload.targetId, payload.type, payload.targetKind)).distinct(),
|
||||
)
|
||||
|
||||
is TaskUnlinkedEvent -> base.copy(
|
||||
links = state.links.filterNot {
|
||||
it.targetId == payload.targetId && it.type == payload.type
|
||||
},
|
||||
)
|
||||
|
||||
is TaskNoteAddedEvent -> base.copy(
|
||||
notes = state.notes + TaskNote(payload.author, payload.body, ts),
|
||||
)
|
||||
|
||||
is TaskDeletedEvent -> base.copy(deleted = true)
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Lifecycle events: apply on a legal transition, else count it invalid. */
|
||||
private fun reduceTransition(
|
||||
state: TaskState,
|
||||
base: TaskState,
|
||||
payload: TaskEvent,
|
||||
): TaskState {
|
||||
val from = state.status
|
||||
return when (payload) {
|
||||
is TaskClaimedEvent ->
|
||||
base.transitionTo(TaskStatus.IN_PROGRESS, from == TaskStatus.TODO) {
|
||||
it.copy(claimant = payload.claimant)
|
||||
}
|
||||
|
||||
is TaskReleasedEvent ->
|
||||
base.transitionTo(TaskStatus.TODO, from == TaskStatus.IN_PROGRESS) {
|
||||
it.copy(claimant = null)
|
||||
}
|
||||
|
||||
is TaskBlockedEvent ->
|
||||
base.transitionTo(TaskStatus.BLOCKED, from in WORKING)
|
||||
|
||||
is TaskUnblockedEvent ->
|
||||
base.transitionTo(
|
||||
if (state.claimant != null) TaskStatus.IN_PROGRESS else TaskStatus.TODO,
|
||||
from == TaskStatus.BLOCKED,
|
||||
)
|
||||
|
||||
is TaskSubmittedForReviewEvent ->
|
||||
base.transitionTo(TaskStatus.IN_REVIEW, from == TaskStatus.IN_PROGRESS)
|
||||
|
||||
is TaskCompletedEvent ->
|
||||
base.transitionTo(TaskStatus.DONE, from == TaskStatus.IN_REVIEW)
|
||||
|
||||
is TaskReopenedEvent ->
|
||||
base.transitionTo(TaskStatus.TODO, from in TERMINAL) {
|
||||
it.copy(claimant = null)
|
||||
}
|
||||
|
||||
is TaskCancelledEvent ->
|
||||
base.transitionTo(TaskStatus.CANCELLED, from in CANCELLABLE)
|
||||
|
||||
else -> state
|
||||
} ?: state.copy(invalidTransitions = state.invalidTransitions + 1)
|
||||
}
|
||||
|
||||
private inline fun TaskState.transitionTo(
|
||||
to: TaskStatus,
|
||||
legal: Boolean,
|
||||
mutate: (TaskState) -> TaskState = { it },
|
||||
): TaskState? = if (legal) mutate(copy(status = to)) else null
|
||||
|
||||
private companion object {
|
||||
val WORKING = setOf(TaskStatus.TODO, TaskStatus.IN_PROGRESS, TaskStatus.IN_REVIEW)
|
||||
val TERMINAL = setOf(TaskStatus.DONE, TaskStatus.CANCELLED)
|
||||
val CANCELLABLE = setOf(
|
||||
TaskStatus.TODO,
|
||||
TaskStatus.IN_PROGRESS,
|
||||
TaskStatus.IN_REVIEW,
|
||||
TaskStatus.BLOCKED,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.TaskId
|
||||
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||
|
||||
/**
|
||||
* Reads a project's task board by replaying its stream. Mirrors `DefaultSessionRepository`:
|
||||
* the repository is a thin read model over an [EventReplayer]; the event log is the source
|
||||
* of truth and the board is rebuilt from it.
|
||||
*/
|
||||
class DefaultTaskRepository(
|
||||
private val replayer: EventReplayer<TaskBoard>,
|
||||
private val streamId: SessionId,
|
||||
) {
|
||||
|
||||
fun board(): TaskBoard = replayer.rebuild(streamId)
|
||||
|
||||
fun getTask(taskId: TaskId): Task? =
|
||||
board()[taskId]?.takeUnless { it.deleted }?.let { Task(taskId, it) }
|
||||
|
||||
fun list(): List<Task> =
|
||||
board().filterValues { !it.deleted }.map { (id, state) -> Task(id, state) }
|
||||
|
||||
fun count(): Int = list().size
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
import com.correx.core.events.types.TaskId
|
||||
|
||||
data class Task(
|
||||
val taskId: TaskId,
|
||||
val state: TaskState,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
import com.correx.core.events.types.TaskId
|
||||
|
||||
/** All tasks in a project stream, keyed by id — the unit a single replay produces. */
|
||||
typealias TaskBoard = Map<TaskId, TaskState>
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.TaskEvent
|
||||
import com.correx.core.sessions.projections.Projection
|
||||
|
||||
/**
|
||||
* Folds a per-project task stream into a [TaskBoard]. Each event is routed to its owning
|
||||
* task by [TaskEvent.taskId]; non-task payloads leave the board untouched.
|
||||
*/
|
||||
class TaskBoardProjector(
|
||||
private val reducer: TaskReducer,
|
||||
) : Projection<TaskBoard> {
|
||||
|
||||
override fun initial(): TaskBoard = emptyMap()
|
||||
|
||||
override fun apply(state: TaskBoard, event: StoredEvent): TaskBoard {
|
||||
val taskId = (event.payload as? TaskEvent)?.taskId ?: return state
|
||||
val current = state[taskId] ?: TaskState()
|
||||
return state + (taskId to reducer.reduce(current, event))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
import com.correx.core.events.types.TaskId
|
||||
import com.correx.core.events.types.TaskTargetKind
|
||||
|
||||
/**
|
||||
* Assembles a [TaskContextBundle] for a task. Links are partitioned by their recorded
|
||||
* [com.correx.core.events.types.TaskTargetKind]: TASK targets are resolved to [RelatedTask]
|
||||
* dependencies (with live status), DOC targets are resolved inline via [documentResolver], and
|
||||
* anything unresolved (or ARTIFACT/SESSION, not inlined yet) becomes a raw [RelatedLink].
|
||||
*
|
||||
* When a [TaskKnowledgeRetriever] is supplied, the bundle is additionally enriched with
|
||||
* semantically-relevant snippets over the task's title+goal. Retrieval and doc-resolution failures
|
||||
* are swallowed (the bundle still returns) so context assembly never fails on a flaky dependency.
|
||||
*/
|
||||
class TaskContextAssembler(
|
||||
private val service: TaskService,
|
||||
private val retriever: TaskKnowledgeRetriever? = null,
|
||||
private val documentResolver: TaskDocumentResolver? = null,
|
||||
private val knowledgeLimit: Int = DEFAULT_KNOWLEDGE_LIMIT,
|
||||
) {
|
||||
|
||||
suspend fun assemble(taskId: TaskId): TaskContextBundle? {
|
||||
val state = service.getTask(taskId)?.state ?: return null
|
||||
|
||||
val dependencies = mutableListOf<RelatedTask>()
|
||||
val documents = mutableListOf<TaskDocument>()
|
||||
val related = mutableListOf<RelatedLink>()
|
||||
for (link in state.links) {
|
||||
// Resolution dispatches on the link's recorded targetKind — no guessing from the id.
|
||||
// A kind whose resolution comes up empty (missing task, unresolvable doc, or an
|
||||
// ARTIFACT/SESSION we don't inline yet) falls back to a raw related link.
|
||||
when (link.targetKind) {
|
||||
TaskTargetKind.TASK -> addTask(link, dependencies, related)
|
||||
TaskTargetKind.DOC -> addDocument(link, documents, related)
|
||||
TaskTargetKind.ARTIFACT, TaskTargetKind.SESSION ->
|
||||
related += RelatedLink(link.targetId, link.type.name)
|
||||
}
|
||||
}
|
||||
|
||||
return TaskContextBundle(
|
||||
id = taskId.value,
|
||||
key = state.key,
|
||||
status = state.status.name,
|
||||
title = state.title,
|
||||
goal = state.goal,
|
||||
acceptanceCriteria = state.acceptanceCriteria,
|
||||
relevantFiles = state.affectedPaths,
|
||||
dependencies = dependencies,
|
||||
documents = documents,
|
||||
relatedLinks = related,
|
||||
notes = state.notes.map { NoteEntry(it.author.name, it.body) },
|
||||
relevantKnowledge = retrieveKnowledge(state),
|
||||
)
|
||||
}
|
||||
|
||||
private fun addTask(
|
||||
link: TaskLink,
|
||||
dependencies: MutableList<RelatedTask>,
|
||||
related: MutableList<RelatedLink>,
|
||||
) {
|
||||
val linked = runCatching { service.getTask(TaskId(link.targetId)) }.getOrNull()
|
||||
if (linked != null) {
|
||||
dependencies += RelatedTask(
|
||||
id = linked.taskId.value,
|
||||
status = linked.state.status.name,
|
||||
title = linked.state.title,
|
||||
link = link.type.name,
|
||||
)
|
||||
} else {
|
||||
related += RelatedLink(link.targetId, link.type.name)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun addDocument(
|
||||
link: TaskLink,
|
||||
documents: MutableList<TaskDocument>,
|
||||
related: MutableList<RelatedLink>,
|
||||
) {
|
||||
val doc = resolveDocument(link.targetId)
|
||||
if (doc != null) {
|
||||
documents += TaskDocument(
|
||||
targetId = link.targetId,
|
||||
type = link.type.name,
|
||||
title = doc.title,
|
||||
path = doc.path,
|
||||
excerpt = doc.excerpt,
|
||||
)
|
||||
} else {
|
||||
related += RelatedLink(link.targetId, link.type.name)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveDocument(targetId: String): ResolvedDocument? {
|
||||
val resolver = documentResolver ?: return null
|
||||
return runCatching { resolver.resolve(targetId) }.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun retrieveKnowledge(state: TaskState): List<KnowledgeHit> {
|
||||
val active = retriever ?: return emptyList()
|
||||
val query = listOfNotNull(state.title, state.goal).joinToString(" ").trim()
|
||||
if (query.isEmpty()) return emptyList()
|
||||
return runCatching { active.retrieve(query, knowledgeLimit) }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_KNOWLEDGE_LIMIT = 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Token-optimized context bundle for a task — the single payload an agent needs to start work:
|
||||
* the task itself, its acceptance criteria, resolved dependency tasks, the related (non-task)
|
||||
* links it should fetch, the relevant files, and notes. Assembled by [TaskContextAssembler].
|
||||
*/
|
||||
@Serializable
|
||||
data class TaskContextBundle(
|
||||
val id: String,
|
||||
val key: String?,
|
||||
val status: String,
|
||||
val title: String?,
|
||||
val goal: String?,
|
||||
val acceptanceCriteria: List<String>,
|
||||
val relevantFiles: List<String>,
|
||||
val dependencies: List<RelatedTask>,
|
||||
val documents: List<TaskDocument>,
|
||||
val relatedLinks: List<RelatedLink>,
|
||||
val notes: List<NoteEntry>,
|
||||
val relevantKnowledge: List<KnowledgeHit> = emptyList(),
|
||||
) {
|
||||
/** Compact, label-per-line rendering for tool output (cheaper than JSON for the model). */
|
||||
fun render(): String = buildString {
|
||||
appendLine("task $id [$status] ${title.orEmpty()}".trimEnd())
|
||||
goal?.let { appendLine("goal: $it") }
|
||||
appendList("acceptance_criteria", acceptanceCriteria) { " - $it" }
|
||||
if (relevantFiles.isNotEmpty()) appendLine("relevant_files: ${relevantFiles.joinToString(", ")}")
|
||||
appendList("dependencies", dependencies) { " - ${it.id} [${it.status}] ${it.title.orEmpty()} (${it.link})".trimEnd() }
|
||||
appendList("documents", documents) { " - ${it.targetId} (${it.type}) ${it.title} [${it.path}]: ${it.excerpt}" }
|
||||
appendList("related", relatedLinks) { " - ${it.targetId} (${it.type})" }
|
||||
appendList("relevant_knowledge", relevantKnowledge) { " - ${it.source}: ${it.text}" }
|
||||
appendList("notes", notes) { " - [${it.author}] ${it.body}" }
|
||||
}.trimEnd()
|
||||
|
||||
private fun <T> StringBuilder.appendList(label: String, items: List<T>, line: (T) -> String) {
|
||||
if (items.isEmpty()) return
|
||||
appendLine("$label:")
|
||||
items.forEach { appendLine(line(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** A linked task resolved against the board: enough for the agent to judge readiness. */
|
||||
@Serializable
|
||||
data class RelatedTask(
|
||||
val id: String,
|
||||
val status: String,
|
||||
val title: String?,
|
||||
val link: String,
|
||||
)
|
||||
|
||||
/** A link whose target resolved to a document (ADR/doc): title + excerpt inlined for the agent. */
|
||||
@Serializable
|
||||
data class TaskDocument(
|
||||
val targetId: String,
|
||||
val type: String,
|
||||
val title: String,
|
||||
val path: String,
|
||||
val excerpt: String,
|
||||
)
|
||||
|
||||
/** A link whose target is neither a task nor a resolvable doc — left for the agent to fetch. */
|
||||
@Serializable
|
||||
data class RelatedLink(
|
||||
val targetId: String,
|
||||
val type: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NoteEntry(
|
||||
val author: String,
|
||||
val body: String,
|
||||
)
|
||||
|
||||
/** A semantically-retrieved snippet relevant to the task's goal (e.g. a repo file or doc). */
|
||||
@Serializable
|
||||
data class KnowledgeHit(
|
||||
val source: String,
|
||||
val text: String,
|
||||
val score: Double,
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.TaskCreatedEvent
|
||||
import com.correx.core.sessions.projections.Projection
|
||||
|
||||
/**
|
||||
* Counts created tasks in a project stream. The numeric suffix of a human key
|
||||
* (`<prefix>-<n>`) is taken from this count, so ids never collide even after a task is
|
||||
* cancelled — it counts creations, not currently-live tasks.
|
||||
*/
|
||||
class TaskCounterProjection(
|
||||
private val projectId: String,
|
||||
) : Projection<TaskCounterState> {
|
||||
|
||||
override fun initial(): TaskCounterState =
|
||||
TaskCounterState(projectId, 0)
|
||||
|
||||
override fun apply(
|
||||
state: TaskCounterState,
|
||||
event: StoredEvent,
|
||||
): TaskCounterState =
|
||||
if (event.payload is TaskCreatedEvent) state.copy(count = state.count + 1) else state
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
data class TaskCounterState(
|
||||
val projectId: String,
|
||||
val count: Int,
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Port for resolving a link target (e.g. "adr-7" or a "docs/…/x.md" path) to an inline document
|
||||
* summary, so the context bundle can carry the doc's title + excerpt instead of a bare id the
|
||||
* agent has to fetch separately. Implemented in a higher layer (apps/server reads the repo's
|
||||
* docs); `core:tasks` stays decoupled from the filesystem. Returns null when the target is not a
|
||||
* resolvable document — the link then stays a raw related link.
|
||||
*/
|
||||
interface TaskDocumentResolver {
|
||||
suspend fun resolve(targetId: String): ResolvedDocument?
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ResolvedDocument(
|
||||
val title: String,
|
||||
val path: String,
|
||||
val excerpt: String,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
/**
|
||||
* Port for semantic enrichment of a task context bundle. Implemented in a higher layer
|
||||
* (apps/server bridges it to the L3 vector retriever) and injected into [TaskContextAssembler];
|
||||
* `core:tasks` stays decoupled from the retrieval stack. Implementations must be side-effect-free
|
||||
* and tolerate being called with an arbitrary natural-language query.
|
||||
*/
|
||||
interface TaskKnowledgeRetriever {
|
||||
suspend fun retrieve(query: String, limit: Int): List<KnowledgeHit>
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
import com.correx.core.events.types.TaskLinkType
|
||||
import com.correx.core.events.types.TaskTargetKind
|
||||
|
||||
/** One edge in the work graph: a typed pointer from a task to another entity, tagged by kind. */
|
||||
data class TaskLink(
|
||||
val targetId: String,
|
||||
val type: TaskLinkType,
|
||||
val targetKind: TaskTargetKind,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
import com.correx.core.events.types.TaskNoteAuthor
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/** A note on a task, kept in agent/human lanes so consumers can filter by author. */
|
||||
data class TaskNote(
|
||||
val author: TaskNoteAuthor,
|
||||
val body: String,
|
||||
val at: Instant,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
|
||||
interface TaskReducer {
|
||||
fun reduce(
|
||||
state: TaskState,
|
||||
event: StoredEvent,
|
||||
): TaskState
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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.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.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
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.EventId
|
||||
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 com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import kotlinx.datetime.Clock
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Write path for tasks: turns intents into events appended to the shared [EventStore].
|
||||
* The event log stays the single source of truth — the board is rebuilt by replay, never
|
||||
* stored. All task events for a project live in one stream ([TaskStreams.forProject]).
|
||||
*
|
||||
* Keys are `<project>-<n>`, so a task's project is recoverable from its id; mutating
|
||||
* methods therefore need only the [TaskId]. They return the rebuilt [Task], or null when the
|
||||
* task does not exist (so callers — e.g. tools — can report a clean failure). Mutations that
|
||||
* are illegal for the current status are still appended but are no-ops on replay (the reducer
|
||||
* counts them in `invalidTransitions`).
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class TaskService(
|
||||
private val eventStore: EventStore,
|
||||
private val clock: Clock = Clock.System,
|
||||
) {
|
||||
private val boardReplayer =
|
||||
DefaultEventReplayer(eventStore, TaskBoardProjector(DefaultTaskReducer()))
|
||||
|
||||
fun board(projectId: ProjectId): TaskBoard =
|
||||
boardReplayer.rebuild(TaskStreams.forProject(projectId))
|
||||
|
||||
fun list(projectId: ProjectId): List<Task> =
|
||||
board(projectId).filterValues { !it.deleted }.map { (id, state) -> Task(id, state) }
|
||||
|
||||
fun getTask(taskId: TaskId): Task? {
|
||||
val state = board(projectOf(taskId))[taskId] ?: return null
|
||||
return if (state.deleted) null else Task(taskId, state)
|
||||
}
|
||||
|
||||
suspend fun createTask(
|
||||
projectId: ProjectId,
|
||||
title: String,
|
||||
goal: String,
|
||||
acceptanceCriteria: List<String> = emptyList(),
|
||||
affectedPaths: List<String> = emptyList(),
|
||||
): Task {
|
||||
val key = nextKey(projectId)
|
||||
val taskId = TaskId(key)
|
||||
append(
|
||||
projectId,
|
||||
TaskCreatedEvent(
|
||||
taskId = taskId,
|
||||
projectId = projectId,
|
||||
key = key,
|
||||
title = title,
|
||||
goal = goal,
|
||||
acceptanceCriteria = acceptanceCriteria,
|
||||
affectedPaths = affectedPaths,
|
||||
),
|
||||
)
|
||||
return requireNotNull(getTask(taskId)) { "task $key missing after creation" }
|
||||
}
|
||||
|
||||
suspend fun edit(taskId: TaskId, title: String? = null, goal: String? = null): Task? =
|
||||
mutate(taskId) { TaskEditedEvent(it, title = title, goal = goal) }
|
||||
|
||||
suspend fun setAcceptanceCriteria(taskId: TaskId, criteria: List<String>): Task? =
|
||||
mutate(taskId) { TaskAcceptanceCriteriaSetEvent(it, criteria) }
|
||||
|
||||
suspend fun setAffectedPaths(taskId: TaskId, paths: List<String>): Task? =
|
||||
mutate(taskId) { TaskAffectedPathsSetEvent(it, paths) }
|
||||
|
||||
suspend fun claim(taskId: TaskId, claimant: String): Task? =
|
||||
mutate(taskId) { TaskClaimedEvent(it, claimant) }
|
||||
|
||||
suspend fun release(taskId: TaskId): Task? =
|
||||
mutate(taskId) { TaskReleasedEvent(it) }
|
||||
|
||||
suspend fun block(taskId: TaskId, reason: String): Task? =
|
||||
mutate(taskId) { TaskBlockedEvent(it, reason) }
|
||||
|
||||
suspend fun unblock(taskId: TaskId): Task? =
|
||||
mutate(taskId) { TaskUnblockedEvent(it) }
|
||||
|
||||
suspend fun submitForReview(taskId: TaskId): Task? =
|
||||
mutate(taskId) { TaskSubmittedForReviewEvent(it) }
|
||||
|
||||
suspend fun complete(taskId: TaskId): Task? =
|
||||
mutate(taskId) { TaskCompletedEvent(it) }
|
||||
|
||||
suspend fun reopen(taskId: TaskId): Task? =
|
||||
mutate(taskId) { TaskReopenedEvent(it) }
|
||||
|
||||
suspend fun cancel(taskId: TaskId, reason: String? = null): Task? =
|
||||
mutate(taskId) { TaskCancelledEvent(it, reason) }
|
||||
|
||||
suspend fun link(taskId: TaskId, targetId: String, type: TaskLinkType, targetKind: TaskTargetKind): Task? =
|
||||
mutate(taskId) { TaskLinkedEvent(it, targetId, type, targetKind) }
|
||||
|
||||
suspend fun unlink(taskId: TaskId, targetId: String, type: TaskLinkType): Task? =
|
||||
mutate(taskId) { TaskUnlinkedEvent(it, targetId, type) }
|
||||
|
||||
suspend fun addNote(taskId: TaskId, author: TaskNoteAuthor, body: String): Task? =
|
||||
mutate(taskId) { TaskNoteAddedEvent(it, author, body) }
|
||||
|
||||
/** Soft delete: appends a tombstone. After this, [getTask] returns null and [list] omits it. */
|
||||
suspend fun delete(taskId: TaskId): Boolean {
|
||||
if (getTask(taskId) == null) return false
|
||||
append(projectOf(taskId), TaskDeletedEvent(taskId))
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun mutate(taskId: TaskId, event: (TaskId) -> EventPayload): Task? {
|
||||
if (getTask(taskId) == null) return null
|
||||
append(projectOf(taskId), event(taskId))
|
||||
return getTask(taskId)
|
||||
}
|
||||
|
||||
private fun nextKey(projectId: ProjectId): String {
|
||||
val count = DefaultEventReplayer(eventStore, TaskCounterProjection(projectId.value))
|
||||
.rebuild(TaskStreams.forProject(projectId)).count
|
||||
return "${projectId.value}-${count + 1}"
|
||||
}
|
||||
|
||||
private fun projectOf(taskId: TaskId): ProjectId =
|
||||
ProjectId(taskId.value.substringBeforeLast('-'))
|
||||
|
||||
private suspend fun append(projectId: ProjectId, payload: EventPayload) {
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = TaskStreams.forProject(projectId),
|
||||
timestamp = clock.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
import com.correx.core.events.types.ProjectId
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
/**
|
||||
* Projected state of a single task, rebuilt by folding its events through [TaskReducer].
|
||||
* Mirrors the shape of `SessionState`: defaults make the empty/pre-creation state valid.
|
||||
*/
|
||||
data class TaskState(
|
||||
val status: TaskStatus = TaskStatus.TODO,
|
||||
val key: String? = null,
|
||||
val projectId: ProjectId? = null,
|
||||
val title: String? = null,
|
||||
val goal: String? = null,
|
||||
val acceptanceCriteria: List<String> = emptyList(),
|
||||
val affectedPaths: List<String> = emptyList(),
|
||||
val links: List<TaskLink> = emptyList(),
|
||||
val claimant: String? = null,
|
||||
val notes: List<TaskNote> = emptyList(),
|
||||
val createdAt: Instant? = null,
|
||||
val updatedAt: Instant? = null,
|
||||
val invalidTransitions: Int = 0,
|
||||
/** Soft-deleted tombstone — repository reads hide these; the events remain in the log. */
|
||||
val deleted: Boolean = false,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
/**
|
||||
* Derived task lifecycle status. Never serialized on events — the reducer computes it from
|
||||
* the lifecycle facts. Workflow: TODO → IN_PROGRESS → IN_REVIEW → DONE, with BLOCKED
|
||||
* (reversible) and CANCELLED (terminal).
|
||||
*/
|
||||
enum class TaskStatus {
|
||||
TODO,
|
||||
IN_PROGRESS,
|
||||
IN_REVIEW,
|
||||
BLOCKED,
|
||||
DONE,
|
||||
CANCELLED,
|
||||
;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
import com.correx.core.events.types.ProjectId
|
||||
import com.correx.core.events.types.SessionId
|
||||
|
||||
/**
|
||||
* Task events live in one event stream per project, keyed `tasks:<projectId>`. The
|
||||
* `tasks:` prefix keeps these streams distinguishable from real agent sessions (the
|
||||
* EventStore is partitioned by [SessionId], and [SessionId]/`TaskId` are both `TypeId`).
|
||||
*/
|
||||
object TaskStreams {
|
||||
const val PREFIX: String = "tasks:"
|
||||
|
||||
fun forProject(projectId: ProjectId): SessionId =
|
||||
SessionId("$PREFIX${projectId.value}")
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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.TaskAcceptanceCriteriaSetEvent
|
||||
import com.correx.core.events.events.TaskBlockedEvent
|
||||
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.TaskLinkedEvent
|
||||
import com.correx.core.events.events.TaskNoteAddedEvent
|
||||
import com.correx.core.events.events.RefinementIterationEvent
|
||||
import com.correx.core.events.events.TaskSubmittedForReviewEvent
|
||||
import com.correx.core.events.events.TaskUnblockedEvent
|
||||
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.TaskLinkType
|
||||
import com.correx.core.events.types.TaskNoteAuthor
|
||||
import com.correx.core.events.types.TaskTargetKind
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class DefaultTaskReducerTest {
|
||||
|
||||
private val reducer = DefaultTaskReducer()
|
||||
private val taskId = TaskId("auth-1")
|
||||
private val projectId = ProjectId("auth")
|
||||
|
||||
private fun stored(payload: EventPayload, seq: Long = 1L) = 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 reduceAll(vararg payloads: EventPayload): TaskState =
|
||||
payloads.foldIndexed(TaskState()) { i, state, payload ->
|
||||
reducer.reduce(state, stored(payload, (i + 1).toLong()))
|
||||
}
|
||||
|
||||
private fun created() = TaskCreatedEvent(
|
||||
taskId = taskId,
|
||||
projectId = projectId,
|
||||
key = "auth-1",
|
||||
title = "Implement JWT refresh flow",
|
||||
goal = "users stay authenticated",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `TaskCreatedEvent sets fields and TODO status`() {
|
||||
val state = reduceAll(created())
|
||||
|
||||
assertEquals(TaskStatus.TODO, state.status)
|
||||
assertEquals("auth-1", state.key)
|
||||
assertEquals(projectId, state.projectId)
|
||||
assertEquals("Implement JWT refresh flow", state.title)
|
||||
assertEquals("users stay authenticated", state.goal)
|
||||
assertEquals(Instant.parse("2026-01-01T00:00:00Z"), state.createdAt)
|
||||
assertEquals(0, state.invalidTransitions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `claiming moves TODO to IN_PROGRESS and records claimant`() {
|
||||
val state = reduceAll(created(), TaskClaimedEvent(taskId, claimant = "claude-opus"))
|
||||
|
||||
assertEquals(TaskStatus.IN_PROGRESS, state.status)
|
||||
assertEquals("claude-opus", state.claimant)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `happy path created to claimed to review to done`() {
|
||||
val state = reduceAll(
|
||||
created(),
|
||||
TaskClaimedEvent(taskId, claimant = "claude-opus"),
|
||||
TaskSubmittedForReviewEvent(taskId),
|
||||
TaskCompletedEvent(taskId),
|
||||
)
|
||||
|
||||
assertEquals(TaskStatus.DONE, state.status)
|
||||
assertEquals(0, state.invalidTransitions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unblock restores the working status of a claimed task`() {
|
||||
val state = reduceAll(
|
||||
created(),
|
||||
TaskClaimedEvent(taskId, claimant = "claude-opus"),
|
||||
TaskBlockedEvent(taskId, reason = "waiting on auth-138"),
|
||||
TaskUnblockedEvent(taskId),
|
||||
)
|
||||
|
||||
assertEquals(TaskStatus.IN_PROGRESS, state.status)
|
||||
assertEquals(0, state.invalidTransitions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `illegal transition is ignored and counted`() {
|
||||
val state = reduceAll(created(), TaskCompletedEvent(taskId))
|
||||
|
||||
assertEquals(TaskStatus.TODO, state.status)
|
||||
assertEquals(1, state.invalidTransitions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `links and notes accumulate without affecting status`() {
|
||||
val state = reduceAll(
|
||||
created(),
|
||||
TaskAcceptanceCriteriaSetEvent(taskId, listOf("refresh endpoint exists")),
|
||||
TaskLinkedEvent(taskId, targetId = "auth-138", type = TaskLinkType.DEPENDS_ON, targetKind = TaskTargetKind.TASK),
|
||||
TaskNoteAddedEvent(taskId, author = TaskNoteAuthor.AGENT, body = "migration failed"),
|
||||
)
|
||||
|
||||
assertEquals(TaskStatus.TODO, state.status)
|
||||
assertEquals(listOf("refresh endpoint exists"), state.acceptanceCriteria)
|
||||
assertEquals(1, state.links.size)
|
||||
assertEquals(TaskLink("auth-138", TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK), state.links.single())
|
||||
assertEquals(1, state.notes.size)
|
||||
assertEquals(TaskNoteAuthor.AGENT, state.notes.single().author)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-task payload leaves state untouched`() {
|
||||
val before = reduceAll(created())
|
||||
val nonTask = RefinementIterationEvent(
|
||||
sessionId = SessionId("s-1"),
|
||||
cycleKey = "implement->review",
|
||||
iteration = 1,
|
||||
maxIterations = 3,
|
||||
)
|
||||
val after = reducer.reduce(before, stored(nonTask, seq = 9L))
|
||||
|
||||
assertEquals(before, after)
|
||||
assertNull(after.claimant)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
|
||||
/** Minimal append-capable in-memory event store for core:tasks tests, keyed per session. */
|
||||
internal class InMemoryEventStore : EventStore {
|
||||
private val events = mutableListOf<StoredEvent>()
|
||||
private var global = 0L
|
||||
private val perSession = mutableMapOf<SessionId, Long>()
|
||||
|
||||
override suspend fun append(event: NewEvent): StoredEvent {
|
||||
global += 1
|
||||
val seq = (perSession[event.metadata.sessionId] ?: 0L) + 1
|
||||
perSession[event.metadata.sessionId] = seq
|
||||
val stored = StoredEvent(
|
||||
metadata = event.metadata,
|
||||
sequence = global,
|
||||
sessionSequence = seq,
|
||||
payload = event.payload,
|
||||
)
|
||||
events += stored
|
||||
return stored
|
||||
}
|
||||
|
||||
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = events.map { append(it) }
|
||||
|
||||
override fun read(sessionId: SessionId): List<StoredEvent> =
|
||||
events.filter { it.metadata.sessionId == sessionId }.sortedBy { it.sessionSequence }
|
||||
|
||||
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
|
||||
read(sessionId).filter { it.sessionSequence >= fromSequence }
|
||||
|
||||
override fun lastSequence(sessionId: SessionId): Long? =
|
||||
read(sessionId).maxOfOrNull { it.sessionSequence }
|
||||
|
||||
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
|
||||
|
||||
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
|
||||
|
||||
override suspend fun lastGlobalSequence(): Long = global
|
||||
|
||||
override fun allEvents(): Sequence<StoredEvent> = events.asSequence()
|
||||
|
||||
override fun allSessionIds(): Set<SessionId> = events.map { it.metadata.sessionId }.toSet()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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.RefinementIterationEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.TaskClaimedEvent
|
||||
import com.correx.core.events.events.TaskCreatedEvent
|
||||
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 kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TaskBoardProjectorTest {
|
||||
|
||||
private val projector = TaskBoardProjector(DefaultTaskReducer())
|
||||
private val projectId = ProjectId("auth")
|
||||
|
||||
private fun stored(payload: EventPayload, seq: Long) = StoredEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId("e-$seq"),
|
||||
sessionId = SessionId("tasks:auth"),
|
||||
timestamp = Instant.parse("2026-01-01T00:00:0${seq}Z"),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
sequence = seq,
|
||||
sessionSequence = seq,
|
||||
payload = payload,
|
||||
)
|
||||
|
||||
private fun created(id: TaskId) =
|
||||
TaskCreatedEvent(id, projectId, id.value, "title ${id.value}", "goal")
|
||||
|
||||
private fun fold(vararg events: StoredEvent): TaskBoard =
|
||||
events.fold(projector.initial()) { board, event -> projector.apply(board, event) }
|
||||
|
||||
@Test
|
||||
fun `interleaved events update each task independently`() {
|
||||
val one = TaskId("auth-1")
|
||||
val two = TaskId("auth-2")
|
||||
|
||||
val board = fold(
|
||||
stored(created(one), 1),
|
||||
stored(created(two), 2),
|
||||
stored(TaskClaimedEvent(one, claimant = "claude-opus"), 3),
|
||||
)
|
||||
|
||||
assertEquals(2, board.size)
|
||||
assertEquals(TaskStatus.IN_PROGRESS, board[one]?.status)
|
||||
assertEquals("claude-opus", board[one]?.claimant)
|
||||
assertEquals(TaskStatus.TODO, board[two]?.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-task payload leaves the board unchanged`() {
|
||||
val one = TaskId("auth-1")
|
||||
val withTask = fold(stored(created(one), 1))
|
||||
|
||||
val nonTask = RefinementIterationEvent(
|
||||
sessionId = SessionId("s-1"),
|
||||
cycleKey = "implement->review",
|
||||
iteration = 1,
|
||||
maxIterations = 3,
|
||||
)
|
||||
val after = projector.apply(withTask, stored(nonTask, 2))
|
||||
|
||||
assertEquals(withTask, after)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
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.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TaskContextAssemblerTest {
|
||||
|
||||
private val store = InMemoryEventStore()
|
||||
private val service = TaskService(store)
|
||||
private val assembler = TaskContextAssembler(service)
|
||||
private val project = ProjectId("auth")
|
||||
|
||||
@Test
|
||||
fun `bundle resolves linked tasks as dependencies and keeps non-task links raw`() = runBlocking {
|
||||
val dep = service.createTask(project, "Design auth model", "model")
|
||||
service.claim(dep.taskId, "claude-opus")
|
||||
service.submitForReview(dep.taskId)
|
||||
service.complete(dep.taskId) // dep is DONE
|
||||
|
||||
val task = service.createTask(
|
||||
project,
|
||||
title = "Implement JWT refresh",
|
||||
goal = "users stay authenticated",
|
||||
acceptanceCriteria = listOf("refresh endpoint exists"),
|
||||
affectedPaths = listOf("backend/auth/**"),
|
||||
)
|
||||
service.link(task.taskId, dep.taskId.value, TaskLinkType.DEPENDS_ON, TaskTargetKind.TASK)
|
||||
// DOC-tagged, but this assembler has no document resolver → stays a raw related link
|
||||
service.link(task.taskId, "adr-7", TaskLinkType.IMPLEMENTS, TaskTargetKind.DOC)
|
||||
service.addNote(task.taskId, TaskNoteAuthor.AGENT, "kickoff")
|
||||
|
||||
val bundle = assembler.assemble(task.taskId)!!
|
||||
|
||||
assertEquals("auth-2", bundle.id)
|
||||
assertEquals("users stay authenticated", bundle.goal)
|
||||
assertEquals(listOf("refresh endpoint exists"), bundle.acceptanceCriteria)
|
||||
assertEquals(listOf("backend/auth/**"), bundle.relevantFiles)
|
||||
|
||||
val dependency = bundle.dependencies.single()
|
||||
assertEquals("auth-1", dependency.id)
|
||||
assertEquals("DONE", dependency.status)
|
||||
assertEquals("DEPENDS_ON", dependency.link)
|
||||
|
||||
assertEquals(RelatedLink("adr-7", "IMPLEMENTS"), bundle.relatedLinks.single())
|
||||
assertEquals("kickoff", bundle.notes.single().body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `render produces a compact labelled bundle`() = runBlocking {
|
||||
val task = service.createTask(project, "JWT refresh", "stay authed", listOf("rotates"))
|
||||
val text = assembler.assemble(task.taskId)!!.render()
|
||||
|
||||
assertTrue(text.startsWith("task auth-1 [TODO] JWT refresh"))
|
||||
assertTrue(text.contains("goal: stay authed"))
|
||||
assertTrue(text.contains("- rotates"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `assemble returns null for an unknown task`() = runBlocking {
|
||||
assertNull(assembler.assemble(TaskId("auth-999")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bundle is enriched with knowledge retrieved over the goal`() = runBlocking {
|
||||
val captured = mutableListOf<String>()
|
||||
val fakeRetriever = object : TaskKnowledgeRetriever {
|
||||
override suspend fun retrieve(query: String, limit: Int): List<KnowledgeHit> {
|
||||
captured += query
|
||||
return listOf(KnowledgeHit("backend/auth/Jwt.kt", "fun refresh(token)", 0.91))
|
||||
}
|
||||
}
|
||||
val enriched = TaskContextAssembler(service, fakeRetriever)
|
||||
val task = service.createTask(project, "JWT refresh", "users stay authenticated")
|
||||
|
||||
val bundle = enriched.assemble(task.taskId)!!
|
||||
|
||||
assertEquals(listOf("JWT refresh users stay authenticated"), captured)
|
||||
assertEquals("backend/auth/Jwt.kt", bundle.relevantKnowledge.single().source)
|
||||
assertTrue(bundle.render().contains("relevant_knowledge"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linked docs are resolved inline and dropped from raw related links`() = runBlocking {
|
||||
val docs = object : TaskDocumentResolver {
|
||||
override suspend fun resolve(targetId: String): ResolvedDocument? =
|
||||
if (targetId == "adr-7") {
|
||||
ResolvedDocument("ADR 7: use redis", "docs/decisions/adr-0007-redis.md", "faster invalidation")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val enriched = TaskContextAssembler(service, documentResolver = docs)
|
||||
val task = service.createTask(project, "JWT refresh", "auth")
|
||||
service.link(task.taskId, "adr-7", TaskLinkType.IMPLEMENTS, TaskTargetKind.DOC) // resolves to a doc
|
||||
service.link(task.taskId, "ext-123", TaskLinkType.RELATES_TO, TaskTargetKind.ARTIFACT) // stays raw
|
||||
|
||||
val bundle = enriched.assemble(task.taskId)!!
|
||||
|
||||
val doc = bundle.documents.single()
|
||||
assertEquals("adr-7", doc.targetId)
|
||||
assertEquals("ADR 7: use redis", doc.title)
|
||||
assertEquals("IMPLEMENTS", doc.type)
|
||||
assertEquals(RelatedLink("ext-123", "RELATES_TO"), bundle.relatedLinks.single())
|
||||
assertTrue(bundle.render().contains("documents:"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrieval failure is swallowed and the bundle still returns`() = runBlocking {
|
||||
val flaky = object : TaskKnowledgeRetriever {
|
||||
override suspend fun retrieve(query: String, limit: Int): List<KnowledgeHit> =
|
||||
error("embedder offline")
|
||||
}
|
||||
val task = service.createTask(project, "JWT refresh", "stay authed")
|
||||
|
||||
val bundle = TaskContextAssembler(service, flaky).assemble(task.taskId)!!
|
||||
|
||||
assertTrue(bundle.relevantKnowledge.isEmpty())
|
||||
assertEquals("JWT refresh", bundle.title)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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.TaskCreatedEvent
|
||||
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 kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TaskCounterProjectionTest {
|
||||
|
||||
private val projection = TaskCounterProjection("auth")
|
||||
private val projectId = ProjectId("auth")
|
||||
|
||||
private fun stored(payload: EventPayload, seq: Long) = StoredEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId("e-$seq"),
|
||||
sessionId = SessionId("tasks:auth"),
|
||||
timestamp = Instant.parse("2026-01-01T00:00:0${seq}Z"),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
sequence = seq,
|
||||
sessionSequence = seq,
|
||||
payload = payload,
|
||||
)
|
||||
|
||||
private fun created(id: TaskId) =
|
||||
TaskCreatedEvent(id, projectId, id.value, "title", "goal")
|
||||
|
||||
@Test
|
||||
fun `counts only created events and survives cancellation`() {
|
||||
val events = listOf(
|
||||
stored(created(TaskId("auth-1")), 1),
|
||||
stored(created(TaskId("auth-2")), 2),
|
||||
stored(TaskCancelledEvent(TaskId("auth-1")), 3),
|
||||
)
|
||||
|
||||
val state = events.fold(projection.initial()) { s, e -> projection.apply(s, e) }
|
||||
|
||||
assertEquals("auth", state.projectId)
|
||||
assertEquals(2, state.count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.correx.core.tasks
|
||||
|
||||
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.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TaskServiceTest {
|
||||
|
||||
private val store = InMemoryEventStore()
|
||||
private val service = TaskService(store)
|
||||
private val project = ProjectId("auth")
|
||||
|
||||
@Test
|
||||
fun `createTask allocates sequential human keys per project`() = runBlocking {
|
||||
val first = service.createTask(project, "Add JWT refresh", "users stay authenticated")
|
||||
val second = service.createTask(project, "Rotate keys", "tokens rotate")
|
||||
|
||||
assertEquals("auth-1", first.taskId.value)
|
||||
assertEquals("auth-2", second.taskId.value)
|
||||
assertEquals(TaskStatus.TODO, first.state.status)
|
||||
assertEquals("Add JWT refresh", first.state.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lifecycle round-trips through append and replay`() = runBlocking {
|
||||
val task = service.createTask(project, "Add JWT refresh", "users stay authenticated")
|
||||
val id = task.taskId
|
||||
|
||||
assertEquals(TaskStatus.IN_PROGRESS, service.claim(id, "claude-opus")?.state?.status)
|
||||
assertEquals(TaskStatus.IN_REVIEW, service.submitForReview(id)?.state?.status)
|
||||
val done = service.complete(id)
|
||||
|
||||
assertEquals(TaskStatus.DONE, done?.state?.status)
|
||||
assertEquals("claude-opus", done?.state?.claimant)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `links and notes are persisted on the task`() = runBlocking {
|
||||
val id = service.createTask(project, "Add JWT refresh", "auth").taskId
|
||||
|
||||
service.link(id, targetId = "adr-7", type = TaskLinkType.IMPLEMENTS, targetKind = TaskTargetKind.DOC)
|
||||
val withNote = service.addNote(id, TaskNoteAuthor.AGENT, "migration failed, retrying")
|
||||
|
||||
assertEquals(
|
||||
TaskLink("adr-7", TaskLinkType.IMPLEMENTS, TaskTargetKind.DOC),
|
||||
withNote?.state?.links?.single(),
|
||||
)
|
||||
assertEquals("migration failed, retrying", withNote?.state?.notes?.single()?.body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete is a soft tombstone hidden from reads`() = runBlocking {
|
||||
val id = service.createTask(project, "throwaway", "oops").taskId
|
||||
|
||||
assertTrue(service.delete(id))
|
||||
assertNull(service.getTask(id))
|
||||
assertTrue(service.list(project).none { it.taskId == id })
|
||||
// A second delete is a no-op: the task is already gone from reads.
|
||||
assertFalse(service.delete(id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mutating an unknown task returns null`() = runBlocking {
|
||||
assertNull(service.claim(TaskId("auth-999"), "claude-opus"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keys keep incrementing even after deletion`() = runBlocking {
|
||||
val first = service.createTask(project, "one", "g").taskId
|
||||
service.delete(first)
|
||||
val third = service.createTask(project, "two", "g")
|
||||
|
||||
// count() is creation-based, so the deleted id is never reused.
|
||||
assertEquals("auth-2", third.taskId.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: "Adr 0012 Task Tracking"
|
||||
description: "Native task tracking as an event-sourced aggregate; tasks are a first-class work-graph node"
|
||||
depth: 2
|
||||
links: ["../index.md", "./adr-0001-event-sourcing.md", "./adr-0005-persistence-choice.md", "./adr-0006-event-store-append-only.md"]
|
||||
---
|
||||
|
||||
# ADR 0012: native task tracking as an event-sourced aggregate
|
||||
|
||||
**status:** accepted
|
||||
**date:** 23.06.2026 (June)
|
||||
**deciders:** correx design team
|
||||
|
||||
---
|
||||
|
||||
## context
|
||||
|
||||
Correx is to gain **native task tracking** — a durable "task" (work item / issue) entity,
|
||||
agent-first. Today the system has no concept of a task; it has `sessions` (agent runs) and
|
||||
session/stage-scoped `artifacts`. A task is conceptually different: a durable unit of intent
|
||||
that outlives any single session and *references* the artifacts and sessions that act on it.
|
||||
|
||||
An originating pitch proposed storing tasks as YAML/markdown files as the **source of truth**
|
||||
with Postgres as a cache, plus a `GET /tasks/next` assignment scheduler. Those technical
|
||||
choices are **rejected** here: they contradict correx's foundational invariants —
|
||||
"the event log is the only source of truth; projections are disposable and rebuilt from
|
||||
events" (`.correx/project.toml`, ADR-0001, ADR-0006). Adopting hand-edited files as truth
|
||||
would create two masters (files vs. log) and forfeit replay/idempotency. We keep the *goal*
|
||||
(native tasks) and realise it with correx's actual architecture.
|
||||
|
||||
This ADR covers the **foundational slice**: the aggregate, its events, and the work-graph
|
||||
edge model. REST/CLI/agent-context-bundle/search are deliberately later phases.
|
||||
|
||||
## decision
|
||||
|
||||
**A task is a first-class event-sourced aggregate**, modelled on the existing `core:sessions`
|
||||
aggregate (Status / State / Reducer / Projector / Repository / Counter), living in a new
|
||||
`core:tasks` module. It is *not* an `ArtifactKind` — artifacts are session/stage-scoped LLM
|
||||
outputs; a task is durable and cross-session.
|
||||
|
||||
### 1. event log is the source of truth
|
||||
Tasks are rebuilt by folding events. Any future markdown/file representation is a *disposable
|
||||
projection* (export), never the master copy.
|
||||
|
||||
### 2. per-project task stream
|
||||
The `EventStore` is partitioned by `sessionId`. Since `SessionId` and `TaskId` are both
|
||||
`TypeId`, all task events for a project live in one stream keyed `tasks:<projectId>`
|
||||
(`TaskStreams.forProject`). One read rebuilds the whole board (`TaskBoard = Map<TaskId,
|
||||
TaskState>`). The `tasks:` prefix keeps these streams distinguishable from real agent
|
||||
sessions; when task streams are later wired into the live server, session-listing consumers
|
||||
filter the prefix.
|
||||
|
||||
### 3. lifecycle as facts; status is derived
|
||||
Mirroring sessions (whose events never carry `SessionStatus`), task events are facts
|
||||
(`TaskClaimed`, `TaskBlocked`, `TaskCompleted`, …) and `DefaultTaskReducer` derives
|
||||
`TaskStatus`. This keeps the `TaskStatus` enum out of the lowest module (`core:events`). A
|
||||
`sealed interface TaskEvent { val taskId }` (a marker, *not* part of the serialized
|
||||
`EventPayload` hierarchy) lets projections route any payload to its task without a giant `when`.
|
||||
|
||||
### 4. status workflow
|
||||
`TODO → IN_PROGRESS → IN_REVIEW → DONE`, with `BLOCKED` (reversible; unblock returns to
|
||||
IN_PROGRESS if claimed, else TODO) and `CANCELLED` (terminal, reopenable). Illegal
|
||||
transitions are ignored and counted in `TaskState.invalidTransitions` (as `SessionState` does).
|
||||
|
||||
### 5. work-graph edges
|
||||
`TaskLinkType` (sibling to the existing `ArtifactRelationshipType`):
|
||||
`DEPENDS_ON, BLOCKS, IMPLEMENTS, RELATES_TO, PRODUCED, CONTEXT`. A link target is a plain id
|
||||
string — another task, an `ArtifactId`, or a `SessionId` — plus a type. This is the work-graph
|
||||
backbone the agent-context bundle (later phase) will traverse.
|
||||
|
||||
### 6. human-friendly ids
|
||||
`TaskCounterProjection(projectId)` counts `TaskCreatedEvent`s in the project stream; the
|
||||
numeric suffix of a key (`<prefix>-<n>`, e.g. `auth-142`) is the count of *created* tasks, so
|
||||
ids never collide even after cancellation. The creating caller composes the key and stores it
|
||||
in `TaskCreatedEvent.key`. (The creating service is a later phase; the projection and `key`
|
||||
field land now.)
|
||||
|
||||
## consequences
|
||||
|
||||
**positive:**
|
||||
|
||||
* reuses the entire event-sourcing backbone (sequencing, idempotency, replay, projections);
|
||||
no new persistence model, no second source of truth
|
||||
* the work graph (tasks ↔ tasks/artifacts/sessions) is native, enabling later
|
||||
"everything touching X" queries and the agent-context bundle
|
||||
* deterministic, replayable task state; status derivation is unit-testable in isolation
|
||||
|
||||
**negative:**
|
||||
|
||||
* overloading the `sessionId`-partitioned stream key with `tasks:<projectId>` means
|
||||
session-enumerating consumers must learn to filter the prefix once tasks go live
|
||||
(acceptable; isolated to the live-wiring phase)
|
||||
* per-project (not per-task) stream means rebuilding one task replays the project's task
|
||||
events — fine at expected volumes; revisit with snapshots if a project's task log grows large
|
||||
|
||||
## alternatives considered
|
||||
|
||||
* **Task as an `ArtifactKind`:** rejected — artifacts are session/stage-scoped outputs;
|
||||
tasks are durable, cross-session, and carry their own lifecycle/claiming.
|
||||
* **YAML/markdown files as source of truth + Postgres cache (the pitch):** rejected —
|
||||
violates ADR-0001/0006; creates two masters and loses replay/idempotency.
|
||||
* **`GET /tasks/next` assignment scheduler (the pitch):** dropped — scheduling/prioritisation
|
||||
is a separate hard problem; v1 offers `claim` (pull), not `assign` (push).
|
||||
* **One stream per task:** cleaner per-task sequencing but pollutes session enumeration with
|
||||
one stream per task and makes board/list queries fan out across many streams; the
|
||||
per-project stream is the better default for a tracker that lists/boards constantly.
|
||||
|
||||
## status
|
||||
|
||||
Governs the foundational task-tracking slice. Re-evaluation expected when the REST surface,
|
||||
the `GET /tasks/{id}/context` agent bundle, search, and git-driven auto status updates are
|
||||
designed (those phases build on, and must not contradict, this ADR).
|
||||
@@ -35,6 +35,7 @@ import com.correx.core.router.model.RouterConfig
|
||||
import com.correx.core.router.model.WorkflowSummary
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.registry.ToolRegistry
|
||||
import com.correx.infrastructure.artifactscas.CasArtifactStore
|
||||
@@ -104,8 +105,11 @@ object InfrastructureModule {
|
||||
return runBlocking { CasArtifactStore.open(CasConfig(rootDir), index) }
|
||||
}
|
||||
|
||||
fun createToolRegistry(config: ToolConfig): ToolRegistry =
|
||||
DefaultToolRegistry.build(config.buildTools())
|
||||
fun createToolRegistry(
|
||||
config: ToolConfig,
|
||||
extraTools: List<Tool> = emptyList(),
|
||||
): ToolRegistry =
|
||||
DefaultToolRegistry.build(config.buildTools() + extraTools)
|
||||
|
||||
fun createLlamaCppProvider(
|
||||
modelId: String,
|
||||
|
||||
@@ -13,6 +13,7 @@ dependencies {
|
||||
implementation project(":core:events")
|
||||
implementation project(":core:approvals")
|
||||
implementation project(":core:sessions")
|
||||
implementation project(":core:tasks")
|
||||
implementation project(":infrastructure:tools:filesystem")
|
||||
implementation project(":core:artifacts")
|
||||
implementation project(":core:artifacts-store")
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.correx.infrastructure.tools.task
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.TaskId
|
||||
import com.correx.core.tasks.TaskContextAssembler
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
|
||||
/**
|
||||
* Agent-facing tool: fetch a task's token-optimized context bundle in one call. Read-only, so
|
||||
* it sits at the lowest tier (like file_read) — agents call it before starting work on a task.
|
||||
*/
|
||||
class TaskContextTool(private val assembler: TaskContextAssembler) : Tool, ToolExecutor {
|
||||
|
||||
override val name: String = "task_context"
|
||||
override val description: String =
|
||||
"Fetch a task's context bundle (goal, acceptance criteria, resolved dependencies, " +
|
||||
"related links, relevant files, notes) in one call. Use before working a task."
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") }
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("id")) })
|
||||
}
|
||||
override val tier: Tier = Tier.T1
|
||||
override val requiredCapabilities: Set<ToolCapability> = emptySet()
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).")
|
||||
return ValidationResult.Valid
|
||||
}
|
||||
|
||||
override suspend fun execute(request: ToolRequest): ToolResult {
|
||||
val id = request.stringParam("id")
|
||||
?: return ToolResult.Failure(request.invocationId, "Missing 'id' (string).", recoverable = false)
|
||||
val bundle = assembler.assemble(TaskId(id))
|
||||
?: return ToolResult.Failure(request.invocationId, "No such task: $id", recoverable = false)
|
||||
return ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = bundle.render(),
|
||||
metadata = mapOf("taskId" to id, "status" to bundle.status),
|
||||
)
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package com.correx.infrastructure.tools.task
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.ProjectId
|
||||
import com.correx.core.tasks.TaskService
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
|
||||
/** Agent-facing tool: create a task in a project's work graph. */
|
||||
class TaskCreateTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
|
||||
override val name: String = "task_create"
|
||||
override val description: String =
|
||||
"Create a task in a project's work graph and return its id (e.g. 'auth-142')."
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("project") {
|
||||
put("type", "string")
|
||||
put("description", "Project key, e.g. 'auth'. The new task id is '<project>-<n>'.")
|
||||
}
|
||||
putJsonObject("title") {
|
||||
put("type", "string")
|
||||
put("description", "Short imperative title.")
|
||||
}
|
||||
putJsonObject("goal") {
|
||||
put("type", "string")
|
||||
put("description", "What 'done' looks like, in a sentence or two.")
|
||||
}
|
||||
putJsonObject("acceptance_criteria") {
|
||||
put("type", "array")
|
||||
putJsonObject("items") { put("type", "string") }
|
||||
put("description", "Concrete, verifiable criteria.")
|
||||
}
|
||||
putJsonObject("affected_paths") {
|
||||
put("type", "array")
|
||||
putJsonObject("items") { put("type", "string") }
|
||||
put("description", "Globs/paths this task is expected to touch.")
|
||||
}
|
||||
}
|
||||
put(
|
||||
"required",
|
||||
buildJsonArray {
|
||||
add(JsonPrimitive("project"))
|
||||
add(JsonPrimitive("title"))
|
||||
add(JsonPrimitive("goal"))
|
||||
},
|
||||
)
|
||||
}
|
||||
override val tier: Tier = Tier.T2
|
||||
override val requiredCapabilities: Set<ToolCapability> = emptySet()
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
request.stringParam("project") ?: return ValidationResult.Invalid("Missing 'project' (string).")
|
||||
request.stringParam("title") ?: return ValidationResult.Invalid("Missing 'title' (string).")
|
||||
request.stringParam("goal") ?: return ValidationResult.Invalid("Missing 'goal' (string).")
|
||||
return ValidationResult.Valid
|
||||
}
|
||||
|
||||
override suspend fun execute(request: ToolRequest): ToolResult {
|
||||
val invalid = validateRequest(request) as? ValidationResult.Invalid
|
||||
if (invalid != null) {
|
||||
return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false)
|
||||
}
|
||||
val task = service.createTask(
|
||||
projectId = ProjectId(request.stringParam("project")!!),
|
||||
title = request.stringParam("title")!!,
|
||||
goal = request.stringParam("goal")!!,
|
||||
acceptanceCriteria = request.listParam("acceptance_criteria"),
|
||||
affectedPaths = request.listParam("affected_paths"),
|
||||
)
|
||||
return ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = "Created ${task.taskId.value}: ${task.state.title}",
|
||||
metadata = mapOf("taskId" to task.taskId.value, "status" to task.state.status.name),
|
||||
)
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.correx.infrastructure.tools.task
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.TaskId
|
||||
import com.correx.core.tasks.TaskService
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
|
||||
/**
|
||||
* Agent-facing tool: soft-delete a task (tombstone). History is preserved in the event log;
|
||||
* the task drops out of active views. Use 'task_update' with action=cancel to record an
|
||||
* abandoned-but-visible outcome instead.
|
||||
*/
|
||||
class TaskDeleteTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
|
||||
override val name: String = "task_delete"
|
||||
override val description: String =
|
||||
"Soft-delete a task (removes it from active views; history is kept). " +
|
||||
"For an abandoned-but-visible task, use task_update action=cancel instead."
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") }
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("id")) })
|
||||
}
|
||||
override val tier: Tier = Tier.T2
|
||||
override val requiredCapabilities: Set<ToolCapability> = emptySet()
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).")
|
||||
return ValidationResult.Valid
|
||||
}
|
||||
|
||||
override suspend fun execute(request: ToolRequest): ToolResult {
|
||||
val id = request.stringParam("id")
|
||||
?: return ToolResult.Failure(request.invocationId, "Missing 'id' (string).", recoverable = false)
|
||||
return if (service.delete(TaskId(id))) {
|
||||
ToolResult.Success(request.invocationId, output = "Deleted $id", metadata = mapOf("taskId" to id))
|
||||
} else {
|
||||
ToolResult.Failure(request.invocationId, "No such task: $id", recoverable = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.correx.infrastructure.tools.task
|
||||
|
||||
import com.correx.core.tasks.TaskContextAssembler
|
||||
import com.correx.core.tasks.TaskDocumentResolver
|
||||
import com.correx.core.tasks.TaskKnowledgeRetriever
|
||||
import com.correx.core.tasks.TaskService
|
||||
import com.correx.core.tools.contract.Tool
|
||||
|
||||
/** Builds the task tool set over a single [TaskService] for registration in the tool registry. */
|
||||
object TaskTools {
|
||||
/**
|
||||
* [retriever] semantically enriches the `task_context` bundle; [documentResolver] inlines
|
||||
* linked ADRs/docs. Both optional — null leaves the bundle deterministic.
|
||||
*/
|
||||
fun forService(
|
||||
service: TaskService,
|
||||
retriever: TaskKnowledgeRetriever? = null,
|
||||
documentResolver: TaskDocumentResolver? = null,
|
||||
): List<Tool> =
|
||||
listOf(
|
||||
TaskCreateTool(service),
|
||||
TaskUpdateTool(service),
|
||||
TaskDeleteTool(service),
|
||||
TaskContextTool(TaskContextAssembler(service, retriever, documentResolver)),
|
||||
)
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package com.correx.infrastructure.tools.task
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
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 com.correx.core.tasks.TaskService
|
||||
import com.correx.core.tools.contract.Tool
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.core.tools.contract.ToolExecutor
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import com.correx.core.tools.contract.ValidationResult
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
|
||||
private val ADR_ID = Regex("(?i)^adr[-_ ]?\\d+$")
|
||||
|
||||
/**
|
||||
* Agent-facing tool: update an existing task — edit fields, transition status, add a work-graph
|
||||
* link, or attach a note. The task's project is derived from its id, so only the id is required.
|
||||
*/
|
||||
class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
|
||||
override val name: String = "task_update"
|
||||
override val description: String =
|
||||
"Update a task: edit title/goal/criteria/paths, change status via 'action' " +
|
||||
"(claim, release, block, unblock, submit_for_review, complete, reopen, cancel), " +
|
||||
"add a link, or add a note."
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("id") { put("type", "string"); put("description", "Task id, e.g. 'auth-142'.") }
|
||||
putJsonObject("title") { put("type", "string"); put("description", "New title.") }
|
||||
putJsonObject("goal") { put("type", "string"); put("description", "New goal.") }
|
||||
putJsonObject("acceptance_criteria") {
|
||||
put("type", "array")
|
||||
putJsonObject("items") { put("type", "string") }
|
||||
put("description", "Replaces the acceptance criteria.")
|
||||
}
|
||||
putJsonObject("affected_paths") {
|
||||
put("type", "array")
|
||||
putJsonObject("items") { put("type", "string") }
|
||||
put("description", "Replaces the affected paths.")
|
||||
}
|
||||
putJsonObject("action") {
|
||||
put("type", "string")
|
||||
put(
|
||||
"description",
|
||||
"Status transition: claim, release, block, unblock, submit_for_review, " +
|
||||
"complete, reopen, cancel.",
|
||||
)
|
||||
}
|
||||
putJsonObject("claimant") { put("type", "string"); put("description", "Who claims it (for action=claim).") }
|
||||
putJsonObject("reason") { put("type", "string"); put("description", "Reason (for action=block/cancel).") }
|
||||
putJsonObject("link_target") { put("type", "string"); put("description", "Id to link to (task/artifact/session).") }
|
||||
putJsonObject("link_type") {
|
||||
put("type", "string")
|
||||
put("description", "DEPENDS_ON, BLOCKS, IMPLEMENTS, RELATES_TO, PRODUCED, CONTEXT. Default RELATES_TO.")
|
||||
}
|
||||
putJsonObject("link_kind") {
|
||||
put("type", "string")
|
||||
put("description", "What the link points at: TASK, DOC, ARTIFACT, SESSION. Omit to infer from the target id.")
|
||||
}
|
||||
putJsonObject("note") { put("type", "string"); put("description", "Append an agent note.") }
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("id")) })
|
||||
}
|
||||
override val tier: Tier = Tier.T2
|
||||
override val requiredCapabilities: Set<ToolCapability> = emptySet()
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
request.stringParam("id") ?: return ValidationResult.Invalid("Missing 'id' (string).")
|
||||
return ValidationResult.Valid
|
||||
}
|
||||
|
||||
override suspend fun execute(request: ToolRequest): ToolResult {
|
||||
val id = request.stringParam("id")
|
||||
?: return fail(request, "Missing 'id' (string).")
|
||||
val taskId = TaskId(id)
|
||||
if (service.getTask(taskId) == null) return fail(request, "No such task: $id")
|
||||
|
||||
applyFieldEdits(request, taskId)
|
||||
|
||||
val action = request.stringParam("action")
|
||||
if (action != null && !applyAction(taskId, action, request)) {
|
||||
return fail(request, "Unknown action '$action'.")
|
||||
}
|
||||
|
||||
val linkTarget = request.stringParam("link_target")
|
||||
if (linkTarget != null) {
|
||||
val type = parseLinkType(request.stringParam("link_type"))
|
||||
?: return fail(request, "Invalid 'link_type'.")
|
||||
val kind = resolveTargetKind(request.stringParam("link_kind"), linkTarget)
|
||||
?: return fail(request, "Invalid 'link_kind'.")
|
||||
service.link(taskId, linkTarget, type, kind)
|
||||
}
|
||||
|
||||
request.stringParam("note")?.let { service.addNote(taskId, TaskNoteAuthor.AGENT, it) }
|
||||
|
||||
val status = service.getTask(taskId)?.state?.status?.name ?: "UNKNOWN"
|
||||
return ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = "Updated $id [$status]",
|
||||
metadata = mapOf("taskId" to id, "status" to status),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun applyFieldEdits(request: ToolRequest, taskId: TaskId) {
|
||||
val title = request.stringParam("title")
|
||||
val goal = request.stringParam("goal")
|
||||
if (title != null || goal != null) service.edit(taskId, title, goal)
|
||||
if (request.parameters.containsKey("acceptance_criteria")) {
|
||||
service.setAcceptanceCriteria(taskId, request.listParam("acceptance_criteria"))
|
||||
}
|
||||
if (request.parameters.containsKey("affected_paths")) {
|
||||
service.setAffectedPaths(taskId, request.listParam("affected_paths"))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyAction(taskId: TaskId, action: String, request: ToolRequest): Boolean {
|
||||
val claimant = request.stringParam("claimant") ?: request.sessionId.value
|
||||
val reason = request.stringParam("reason")
|
||||
when (action) {
|
||||
"claim" -> service.claim(taskId, claimant)
|
||||
"release" -> service.release(taskId)
|
||||
"block" -> service.block(taskId, reason ?: "blocked")
|
||||
"unblock" -> service.unblock(taskId)
|
||||
"submit_for_review" -> service.submitForReview(taskId)
|
||||
"complete" -> service.complete(taskId)
|
||||
"reopen" -> service.reopen(taskId)
|
||||
"cancel" -> service.cancel(taskId, reason)
|
||||
else -> return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun parseLinkType(raw: String?): TaskLinkType? =
|
||||
if (raw == null) TaskLinkType.RELATES_TO
|
||||
else TaskLinkType.entries.firstOrNull { it.name == raw.uppercase() }
|
||||
|
||||
/** Explicit kind when given (null if unrecognised); otherwise inferred from the target id. */
|
||||
private fun resolveTargetKind(raw: String?, target: String): TaskTargetKind? =
|
||||
if (raw != null) TaskTargetKind.entries.firstOrNull { it.name == raw.uppercase() }
|
||||
else inferTargetKind(target)
|
||||
|
||||
private fun inferTargetKind(target: String): TaskTargetKind =
|
||||
if (ADR_ID.matches(target) || target.endsWith(".md")) TaskTargetKind.DOC else TaskTargetKind.TASK
|
||||
|
||||
private fun fail(request: ToolRequest, reason: String): ToolResult.Failure =
|
||||
ToolResult.Failure(request.invocationId, reason, recoverable = false)
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.correx.infrastructure.tools.task
|
||||
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
|
||||
/** A non-blank string parameter, or null when absent/blank/not a string. */
|
||||
internal fun ToolRequest.stringParam(name: String): String? =
|
||||
(parameters[name] as? String)?.takeIf { it.isNotBlank() }
|
||||
|
||||
/** A string-list parameter (JSON array), coerced element-wise; empty when absent. */
|
||||
internal fun ToolRequest.listParam(name: String): List<String> =
|
||||
(parameters[name] as? List<*>)?.mapNotNull { it?.toString()?.takeIf(String::isNotBlank) } ?: emptyList()
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
package com.correx.infrastructure.tools.task
|
||||
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.ToolInvocationId
|
||||
import com.correx.core.tasks.TaskContextAssembler
|
||||
import com.correx.core.tasks.TaskService
|
||||
import com.correx.core.tasks.TaskStatus
|
||||
import com.correx.core.tools.contract.ToolResult
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TaskToolsTest {
|
||||
|
||||
private val service = TaskService(InMemoryEventStore())
|
||||
private val create = TaskCreateTool(service)
|
||||
private val update = TaskUpdateTool(service)
|
||||
private val delete = TaskDeleteTool(service)
|
||||
private val context = TaskContextTool(TaskContextAssembler(service))
|
||||
|
||||
private var counter = 0
|
||||
private fun request(tool: String, params: Map<String, Any>) = ToolRequest(
|
||||
invocationId = ToolInvocationId("inv-${counter++}"),
|
||||
sessionId = SessionId("s-1"),
|
||||
stageId = StageId("stage-1"),
|
||||
toolName = tool,
|
||||
parameters = params,
|
||||
)
|
||||
|
||||
private suspend fun createTask(): String {
|
||||
val result = create.execute(
|
||||
request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "stay authed")),
|
||||
)
|
||||
assertTrue(result is ToolResult.Success)
|
||||
return (result as ToolResult.Success).metadata.getValue("taskId")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_create creates a task and returns its id`() = runBlocking {
|
||||
val id = createTask()
|
||||
assertEquals("auth-1", id)
|
||||
assertEquals(TaskStatus.TODO, service.getTask(com.correx.core.events.types.TaskId(id))?.state?.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_create rejects missing required fields`() = runBlocking {
|
||||
val result = create.execute(request("task_create", mapOf("project" to "auth")))
|
||||
assertTrue(result is ToolResult.Failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_update applies status action, edits, link and note`() = runBlocking {
|
||||
val id = createTask()
|
||||
val taskId = com.correx.core.events.types.TaskId(id)
|
||||
|
||||
val result = update.execute(
|
||||
request(
|
||||
"task_update",
|
||||
mapOf(
|
||||
"id" to id,
|
||||
"action" to "claim",
|
||||
"claimant" to "claude-opus",
|
||||
"title" to "JWT refresh flow",
|
||||
"link_target" to "adr-7",
|
||||
"link_type" to "implements",
|
||||
"note" to "starting",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(result is ToolResult.Success)
|
||||
val state = service.getTask(taskId)!!.state
|
||||
assertEquals(TaskStatus.IN_PROGRESS, state.status)
|
||||
assertEquals("claude-opus", state.claimant)
|
||||
assertEquals("JWT refresh flow", state.title)
|
||||
assertEquals("adr-7", state.links.single().targetId)
|
||||
assertEquals("starting", state.notes.single().body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_update rejects an unknown action`() = runBlocking {
|
||||
val id = createTask()
|
||||
val result = update.execute(request("task_update", mapOf("id" to id, "action" to "frobnicate")))
|
||||
assertTrue(result is ToolResult.Failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_update on a missing task fails`() = runBlocking {
|
||||
val result = update.execute(request("task_update", mapOf("id" to "auth-999", "title" to "x")))
|
||||
assertTrue(result is ToolResult.Failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_context returns a rendered bundle with dependencies`() = runBlocking {
|
||||
val id = createTask()
|
||||
update.execute(
|
||||
request("task_update", mapOf("id" to id, "link_target" to "adr-7", "link_type" to "implements")),
|
||||
)
|
||||
|
||||
val result = context.execute(request("task_context", mapOf("id" to id)))
|
||||
|
||||
assertTrue(result is ToolResult.Success)
|
||||
val output = (result as ToolResult.Success).output
|
||||
assertTrue(output.startsWith("task $id [TODO]"))
|
||||
assertTrue(output.contains("adr-7 (IMPLEMENTS)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_context on a missing task fails`() = runBlocking {
|
||||
assertTrue(context.execute(request("task_context", mapOf("id" to "auth-999"))) is ToolResult.Failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_delete tombstones the task`() = runBlocking {
|
||||
val id = createTask()
|
||||
val deleted = delete.execute(request("task_delete", mapOf("id" to id)))
|
||||
|
||||
assertTrue(deleted is ToolResult.Success)
|
||||
assertNull(service.getTask(com.correx.core.events.types.TaskId(id)))
|
||||
// Deleting again now fails: it is gone from active views.
|
||||
assertTrue(delete.execute(request("task_delete", mapOf("id" to id))) is ToolResult.Failure)
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal append-capable in-memory store for tool tests. */
|
||||
private class InMemoryEventStore : EventStore {
|
||||
private val events = mutableListOf<StoredEvent>()
|
||||
private var global = 0L
|
||||
private val perSession = mutableMapOf<SessionId, Long>()
|
||||
|
||||
override suspend fun append(event: NewEvent): StoredEvent {
|
||||
global += 1
|
||||
val seq = (perSession[event.metadata.sessionId] ?: 0L) + 1
|
||||
perSession[event.metadata.sessionId] = seq
|
||||
val stored = StoredEvent(event.metadata, global, seq, event.payload)
|
||||
events += stored
|
||||
return stored
|
||||
}
|
||||
|
||||
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = events.map { append(it) }
|
||||
|
||||
override fun read(sessionId: SessionId): List<StoredEvent> =
|
||||
events.filter { it.metadata.sessionId == sessionId }.sortedBy { it.sessionSequence }
|
||||
|
||||
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
|
||||
read(sessionId).filter { it.sessionSequence >= fromSequence }
|
||||
|
||||
override fun lastSequence(sessionId: SessionId): Long? = read(sessionId).maxOfOrNull { it.sessionSequence }
|
||||
|
||||
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
|
||||
|
||||
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
|
||||
|
||||
override suspend fun lastGlobalSequence(): Long = global
|
||||
|
||||
override fun allEvents(): Sequence<StoredEvent> = events.asSequence()
|
||||
|
||||
override fun allSessionIds(): Set<SessionId> = events.map { it.metadata.sessionId }.toSet()
|
||||
}
|
||||
@@ -23,6 +23,7 @@ include ':core:tools'
|
||||
include ':core:toolintent'
|
||||
include ':core:router'
|
||||
include ':core:sessions'
|
||||
include ':core:tasks'
|
||||
include ':core:config'
|
||||
include ':core:risk'
|
||||
include ':core:journal'
|
||||
|
||||
Reference in New Issue
Block a user