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