feat(tasks): full REST surface for tasks

CRUD, lifecycle transitions, work-graph links and notes over /tasks;
share link-kind inference (TaskTargetKinds) between the REST route and
task_update tool. End-to-end route tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 19:27:19 +00:00
parent 0c6ac903b7
commit 6e044d0ab9
4 changed files with 555 additions and 17 deletions
@@ -1,33 +1,249 @@
package com.correx.apps.server.routes package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule import com.correx.apps.server.ServerModule
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.tasks.Task
import com.correx.core.tasks.TaskContextAssembler import com.correx.core.tasks.TaskContextAssembler
import com.correx.core.tasks.TaskService import com.correx.core.tasks.TaskService
import com.correx.core.tasks.TaskStatus
import com.correx.core.tasks.TaskTargetKinds
import com.correx.core.utils.TypeId import com.correx.core.utils.TypeId
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond import io.ktor.server.response.respond
import io.ktor.server.routing.Route import io.ktor.server.routing.Route
import io.ktor.server.routing.RoutingContext
import io.ktor.server.routing.delete
import io.ktor.server.routing.get import io.ktor.server.routing.get
import io.ktor.server.routing.patch
import io.ktor.server.routing.post
import io.ktor.server.routing.route import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
@Serializable
data class TaskLinkDto(val targetId: String, val type: String, val targetKind: String)
@Serializable
data class TaskNoteDto(val author: String, val body: String, val at: String)
@Serializable
data class TaskResponse(
val id: String,
val key: String?,
val status: String,
val title: String?,
val goal: String?,
val acceptanceCriteria: List<String>,
val affectedPaths: List<String>,
val claimant: String?,
val links: List<TaskLinkDto>,
val notes: List<TaskNoteDto>,
val createdAt: String?,
val updatedAt: String?,
)
@Serializable
data class CreateTaskRequest(
val project: String,
val title: String,
val goal: String,
val acceptanceCriteria: List<String> = emptyList(),
val affectedPaths: List<String> = emptyList(),
)
// Null fields are left untouched; a present list (even empty) replaces the stored one.
@Serializable
data class EditTaskRequest(
val title: String? = null,
val goal: String? = null,
val acceptanceCriteria: List<String>? = null,
val affectedPaths: List<String>? = null,
)
@Serializable
data class ClaimRequest(val claimant: String)
@Serializable
data class BlockRequest(val reason: String)
@Serializable
data class CancelRequest(val reason: String? = null)
@Serializable
data class LinkRequest(val targetId: String, val type: String = "RELATES_TO", val targetKind: String? = null)
@Serializable
data class NoteRequest(val body: String, val author: String? = null)
/** /**
* Read surface for tasks. Exposes the agent context bundle (`GET /tasks/{id}/context`) for * Full REST surface for tasks. The write path mirrors the [TaskService] / `task_*` tools; both
* external agents (Claude Code / aider / codex). In-process roles use the `task_context` tool, * append to the event log, which stays the single source of truth (nothing is stored separately).
* which shares the same [TaskContextAssembler]. Both read from the event log; nothing is stored. * A task's project is encoded in its id, so item routes need only the `{id}`; the collection
* routes take a `project` query/body field. [GET /tasks/{id}/context] serves the agent bundle.
*/ */
fun Route.taskRoutes(module: ServerModule) { fun Route.taskRoutes(module: ServerModule) {
val service = TaskService(module.eventStore)
val assembler = TaskContextAssembler( val assembler = TaskContextAssembler(
TaskService(module.eventStore), service,
module.taskKnowledgeRetriever, module.taskKnowledgeRetriever,
module.taskDocumentResolver, module.taskDocumentResolver,
) )
route("/tasks/{id}") { route("/tasks") {
get("/context") { taskCollectionRoutes(service)
val id = call.parameters["id"] route("/{id}") {
?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id") taskCrudRoutes(service, assembler)
val bundle = assembler.assemble(TypeId(id)) taskTransitionRoutes(service)
?: return@get call.respond(HttpStatusCode.NotFound, "Task not found") taskGraphRoutes(service)
call.respond(bundle)
} }
} }
} }
private fun Route.taskCollectionRoutes(service: TaskService) {
get {
val project = call.request.queryParameters["project"]
?: return@get call.respond(HttpStatusCode.BadRequest, "Missing 'project' query parameter")
val statusRaw = call.request.queryParameters["status"]
val statusFilter = statusRaw?.let {
parseEnum<TaskStatus>(it) ?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid status: $it")
}
val tasks = service.list(ProjectId(project))
.filter { statusFilter == null || it.state.status == statusFilter }
.map { it.toResponse() }
call.respond(tasks)
}
post {
val body = call.receive<CreateTaskRequest>()
val task = service.createTask(
projectId = ProjectId(body.project),
title = body.title,
goal = body.goal,
acceptanceCriteria = body.acceptanceCriteria,
affectedPaths = body.affectedPaths,
)
call.respond(HttpStatusCode.Created, task.toResponse())
}
}
private fun Route.taskCrudRoutes(service: TaskService, assembler: TaskContextAssembler) {
get {
val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.getTask(id))
}
patch {
val id = taskId() ?: return@patch call.respond(HttpStatusCode.BadRequest, "Missing task id")
if (service.getTask(id) == null) return@patch call.respond(HttpStatusCode.NotFound, "Task not found")
val body = call.receive<EditTaskRequest>()
if (body.title != null || body.goal != null) service.edit(id, body.title, body.goal)
if (body.acceptanceCriteria != null) service.setAcceptanceCriteria(id, body.acceptanceCriteria)
if (body.affectedPaths != null) service.setAffectedPaths(id, body.affectedPaths)
respondTask(service.getTask(id))
}
delete {
val id = taskId() ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing task id")
if (service.delete(id)) call.respond(HttpStatusCode.NoContent)
else call.respond(HttpStatusCode.NotFound, "Task not found")
}
get("/context") {
val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id")
val bundle = assembler.assemble(id) ?: return@get call.respond(HttpStatusCode.NotFound, "Task not found")
call.respond(bundle)
}
}
private fun Route.taskTransitionRoutes(service: TaskService) {
post("/claim") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.claim(id, call.receive<ClaimRequest>().claimant))
}
post("/release") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.release(id))
}
post("/block") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.block(id, call.receive<BlockRequest>().reason))
}
post("/unblock") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.unblock(id))
}
post("/submit-for-review") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.submitForReview(id))
}
post("/complete") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.complete(id))
}
post("/reopen") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
respondTask(service.reopen(id))
}
post("/cancel") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
val reason = runCatching { call.receive<CancelRequest>() }.getOrNull()?.reason
respondTask(service.cancel(id, reason))
}
}
private fun Route.taskGraphRoutes(service: TaskService) {
post("/links") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
val body = call.receive<LinkRequest>()
val type = parseEnum<TaskLinkType>(body.type)
?: return@post call.respond(HttpStatusCode.BadRequest, "Invalid link type: ${body.type}")
val kind = when (val raw = body.targetKind) {
null -> TaskTargetKinds.infer(body.targetId)
else -> parseEnum<TaskTargetKind>(raw)
?: return@post call.respond(HttpStatusCode.BadRequest, "Invalid target kind: $raw")
}
respondTask(service.link(id, body.targetId, type, kind))
}
delete("/links") {
val id = taskId() ?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing task id")
val targetId = call.request.queryParameters["targetId"]
?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing 'targetId' query parameter")
val typeRaw = call.request.queryParameters["type"]
?: return@delete call.respond(HttpStatusCode.BadRequest, "Missing 'type' query parameter")
val type = parseEnum<TaskLinkType>(typeRaw)
?: return@delete call.respond(HttpStatusCode.BadRequest, "Invalid link type: $typeRaw")
respondTask(service.unlink(id, targetId, type))
}
post("/notes") {
val id = taskId() ?: return@post call.respond(HttpStatusCode.BadRequest, "Missing task id")
val body = call.receive<NoteRequest>()
val author = body.author?.let {
parseEnum<TaskNoteAuthor>(it) ?: return@post call.respond(HttpStatusCode.BadRequest, "Invalid author: $it")
} ?: TaskNoteAuthor.HUMAN
respondTask(service.addNote(id, author, body.body))
}
}
private fun RoutingContext.taskId(): TaskId? = call.parameters["id"]?.let { TypeId(it) }
private suspend fun RoutingContext.respondTask(task: Task?) {
if (task == null) call.respond(HttpStatusCode.NotFound, "Task not found")
else call.respond(task.toResponse())
}
private inline fun <reified T : Enum<T>> parseEnum(raw: String): T? =
enumValues<T>().firstOrNull { it.name.equals(raw, ignoreCase = true) }
private fun Task.toResponse(): TaskResponse = TaskResponse(
id = taskId.value,
key = state.key,
status = state.status.name,
title = state.title,
goal = state.goal,
acceptanceCriteria = state.acceptanceCriteria,
affectedPaths = state.affectedPaths,
claimant = state.claimant,
links = state.links.map { TaskLinkDto(it.targetId, it.type.name, it.targetKind.name) },
notes = state.notes.map { TaskNoteDto(it.author.name, it.body, it.at.toString()) },
createdAt = state.createdAt?.toString(),
updatedAt = state.updatedAt?.toString(),
)
@@ -0,0 +1,310 @@
package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule
import com.correx.apps.server.configureServer
import com.correx.apps.server.registry.ProviderRegistry
import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.apps.server.registry.WorkflowSummary
import com.correx.apps.server.undo.SessionUndoService
import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.context.builder.DefaultContextPackBuilder
import com.correx.core.context.compression.DefaultContextCompressor
import com.correx.core.events.EventDispatcher
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId
import com.correx.core.inference.InferenceProjector
import com.correx.core.inference.InferenceProvider
import com.correx.core.inference.InferenceRepository
import com.correx.core.inference.ProviderHealth
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationProjector
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.kernel.orchestration.OrchestratorEngines
import com.correx.core.kernel.orchestration.OrchestratorRepositories
import com.correx.core.kernel.retry.DefaultRetryCoordinator
import com.correx.core.events.types.ProviderId
import com.correx.core.risk.DefaultRiskAssessor
import com.correx.core.router.model.RouterConfig
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.transitions.graph.WorkflowGraph
import com.correx.core.transitions.resolution.DefaultTransitionResolver
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.infrastructure.InfrastructureModule
import com.correx.infrastructure.inference.commons.UnavailableProbe
import com.correx.infrastructure.persistence.InMemoryEventStore
import com.correx.infrastructure.tools.FileEditConfig
import com.correx.infrastructure.tools.FileReadConfig
import com.correx.infrastructure.tools.FileWriteConfig
import com.correx.infrastructure.tools.ShellConfig
import com.correx.infrastructure.tools.ToolConfig
import io.ktor.client.HttpClient
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class TaskRoutesTest {
private val testJson = Json { ignoreUnknownKeys = true }
private suspend fun HttpResponse.json(): JsonObject =
testJson.parseToJsonElement(bodyAsText()) as JsonObject
private suspend fun HttpClient.createTask(project: String = "demo"): JsonObject =
post("/tasks") {
contentType(ContentType.Application.Json)
setBody("""{"project":"$project","title":"JWT refresh","goal":"users stay authed"}""")
}.json()
@Test
fun `POST creates a task and GET lists and fetches it`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
val created = client.post("/tasks") {
contentType(ContentType.Application.Json)
setBody(
"""{"project":"demo","title":"JWT refresh","goal":"stay authed",
"acceptanceCriteria":["rotates"],"affectedPaths":["auth/**"]}""",
)
}
assertEquals(HttpStatusCode.Created, created.status)
val body = created.json()
assertEquals("demo-1", body["id"]!!.jsonPrimitive.content)
assertEquals("TODO", body["status"]!!.jsonPrimitive.content)
assertEquals("rotates", body["acceptanceCriteria"]!!.jsonArray.single().jsonPrimitive.content)
val list = client.get("/tasks?project=demo")
assertEquals(HttpStatusCode.OK, list.status)
val rows = testJson.parseToJsonElement(list.bodyAsText()) as JsonArray
assertEquals(1, rows.size)
assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content)
val fetched = client.get("/tasks/demo-1")
assertEquals(HttpStatusCode.OK, fetched.status)
assertEquals("JWT refresh", fetched.json()["title"]!!.jsonPrimitive.content)
}
}
@Test
fun `lifecycle transitions move a task to DONE`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask()
val claimed = client.post("/tasks/demo-1/claim") {
contentType(ContentType.Application.Json)
setBody("""{"claimant":"claude-opus"}""")
}
assertEquals("IN_PROGRESS", claimed.json()["status"]!!.jsonPrimitive.content)
assertEquals("claude-opus", claimed.json()["claimant"]!!.jsonPrimitive.content)
assertEquals("IN_REVIEW", client.post("/tasks/demo-1/submit-for-review").json()["status"]!!.jsonPrimitive.content)
assertEquals("DONE", client.post("/tasks/demo-1/complete").json()["status"]!!.jsonPrimitive.content)
}
}
@Test
fun `PATCH edits fields and POST links and notes mutate the graph`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask()
val patched = client.patch("/tasks/demo-1") {
contentType(ContentType.Application.Json)
setBody("""{"title":"JWT refresh v2"}""")
}
assertEquals("JWT refresh v2", patched.json()["title"]!!.jsonPrimitive.content)
// No explicit kind: an ADR id infers to DOC.
val linked = client.post("/tasks/demo-1/links") {
contentType(ContentType.Application.Json)
setBody("""{"targetId":"adr-7","type":"IMPLEMENTS"}""")
}
val link = linked.json()["links"]!!.jsonArray.single() as JsonObject
assertEquals("adr-7", link["targetId"]!!.jsonPrimitive.content)
assertEquals("IMPLEMENTS", link["type"]!!.jsonPrimitive.content)
assertEquals("DOC", link["targetKind"]!!.jsonPrimitive.content)
val noted = client.post("/tasks/demo-1/notes") {
contentType(ContentType.Application.Json)
setBody("""{"body":"kickoff"}""")
}
val note = noted.json()["notes"]!!.jsonArray.single() as JsonObject
assertEquals("kickoff", note["body"]!!.jsonPrimitive.content)
assertEquals("HUMAN", note["author"]!!.jsonPrimitive.content)
// Unlink via query params clears the edge.
val unlinked = client.delete("/tasks/demo-1/links?targetId=adr-7&type=IMPLEMENTS")
assertTrue(unlinked.json()["links"]!!.jsonArray.isEmpty())
}
}
@Test
fun `DELETE soft-deletes and the task disappears from reads`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask()
assertEquals(HttpStatusCode.NoContent, client.delete("/tasks/demo-1").status)
assertEquals(HttpStatusCode.NotFound, client.get("/tasks/demo-1").status)
assertEquals(HttpStatusCode.NotFound, client.delete("/tasks/demo-1").status)
}
}
@Test
fun `context bundle is served for a known task`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask()
val bundle = client.get("/tasks/demo-1/context")
assertEquals(HttpStatusCode.OK, bundle.status)
assertEquals("demo-1", bundle.json()["id"]!!.jsonPrimitive.content)
assertEquals(HttpStatusCode.NotFound, client.get("/tasks/demo-999/context").status)
}
}
@Test
fun `bad requests are rejected`(@TempDir tempDir: Path) {
testApplication {
application { configureServer(buildModule(tempDir)) }
val client = createClient {}
client.createTask()
assertEquals(HttpStatusCode.BadRequest, client.get("/tasks").status)
assertEquals(HttpStatusCode.NotFound, client.get("/tasks/nope-1").status)
val badLink = client.post("/tasks/demo-1/links") {
contentType(ContentType.Application.Json)
setBody("""{"targetId":"x","type":"NONSENSE"}""")
}
assertEquals(HttpStatusCode.BadRequest, badLink.status)
}
}
// --- harness ----------------------------------------------------------------------------
private fun buildModule(tempDir: Path): ServerModule {
val eventStore: EventStore = InMemoryEventStore()
val artifactStore = object : ArtifactStore {
override suspend fun put(bytes: ByteArray): ArtifactId = ArtifactId("noop")
override suspend fun get(id: ArtifactId): ByteArray? = null
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
}
val provider = InfrastructureModule.createLlamaCppProvider(
modelId = "test-model",
modelPath = "/dev/null",
baseUrl = "http://127.0.0.1:1",
)
val providerRegistry = InfrastructureModule.createProviderRegistry(listOf(provider))
val inferenceRouter = com.correx.core.inference.DefaultInferenceRouter(
providerRegistry,
com.correx.infrastructure.inference.FirstAvailableRoutingStrategy(),
)
val toolConfig = ToolConfig(
shell = ShellConfig(enabled = false, allowedExecutables = emptySet(), workingDir = tempDir),
fileRead = FileReadConfig(enabled = false, allowedPaths = setOf(tempDir)),
fileWrite = FileWriteConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir),
fileEdit = FileEditConfig(enabled = false, allowedPaths = setOf(tempDir), workingDir = tempDir),
)
val toolRegistry = InfrastructureModule.createToolRegistry(toolConfig)
val eventDispatcher = EventDispatcher(eventStore)
val toolExecutor = InfrastructureModule.createToolExecutor(
registry = toolRegistry,
eventDispatcher = eventDispatcher,
workDir = tempDir,
artifactStore = null,
)
val engines = OrchestratorEngines(
transitionResolver = DefaultTransitionResolver { condition, ctx -> condition.evaluate(ctx) },
contextPackBuilder = DefaultContextPackBuilder(DefaultContextCompressor()),
inferenceRouter = inferenceRouter,
validationPipeline = ValidationPipeline(validators = emptyList()),
approvalEngine = com.correx.core.approvals.domain.DefaultApprovalEngine(),
riskAssessor = DefaultRiskAssessor(),
toolRegistry = toolRegistry,
toolExecutor = toolExecutor,
)
val repositories = OrchestratorRepositories(
eventStore = eventStore,
inferenceRepository = InferenceRepository(DefaultEventReplayer(eventStore, InferenceProjector())),
orchestrationRepository = OrchestrationRepository(
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
),
sessionRepository = DefaultSessionRepository(
DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
),
artifactRepository = InfrastructureModule.createArtifactRepository(eventStore),
approvalRepository = InfrastructureModule.createApprovalRepository(eventStore),
)
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
retryCoordinator = DefaultRetryCoordinator(eventStore),
artifactStore = artifactStore,
tokenizer = provider.tokenizer,
decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore),
)
val routerFacade = InfrastructureModule.createRouterFacade(
eventStore = eventStore,
inferenceRouter = inferenceRouter,
config = RouterConfig(),
tokenizer = provider.tokenizer,
)
val noopProviderRegistry = object : ProviderRegistry {
override fun listAll() = emptyList<InferenceProvider>()
override suspend fun healthCheckAll() = emptyMap<ProviderId, ProviderHealth>()
}
val noopWorkflowRegistry = object : WorkflowRegistry {
override fun listAll(): List<WorkflowSummary> = emptyList()
override fun find(workflowId: String): WorkflowGraph? = null
}
val sessionUndoService = SessionUndoService(
eventStore = eventStore,
artifactStore = artifactStore,
bootRoots = setOf(tempDir),
)
return ServerModule(
orchestrator = orchestrator,
eventStore = eventStore,
artifactStore = artifactStore,
sessionRepository = repositories.sessionRepository,
workflowRegistry = noopWorkflowRegistry,
providerRegistry = noopProviderRegistry,
defaultOrchestrationConfig = OrchestrationConfig(sandboxRoot = tempDir),
routerFacade = routerFacade,
orchestrationRepository = repositories.orchestrationRepository,
approvalRepository = repositories.approvalRepository,
toolRegistry = toolRegistry,
sessionUndoService = sessionUndoService,
resourceProbe = UnavailableProbe,
)
}
}
@@ -0,0 +1,16 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskTargetKind
/**
* Infers what a work-graph link points at from its target id, for callers that don't say
* explicitly. ADR ids (`adr-12`, `ADR_0007`) and `.md` paths are documents; everything else is
* assumed to be another task. Shared by the agent tool and the REST surface so the heuristic
* stays in one place. Always prefer an explicit [TaskTargetKind] when the caller provides one.
*/
object TaskTargetKinds {
private val ADR_ID = Regex("(?i)^adr[-_ ]?\\d+$")
fun infer(targetId: String): TaskTargetKind =
if (ADR_ID.matches(targetId) || targetId.endsWith(".md")) TaskTargetKind.DOC else TaskTargetKind.TASK
}
@@ -7,6 +7,7 @@ import com.correx.core.events.types.TaskLinkType
import com.correx.core.events.types.TaskNoteAuthor import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind import com.correx.core.events.types.TaskTargetKind
import com.correx.core.tasks.TaskService import com.correx.core.tasks.TaskService
import com.correx.core.tasks.TaskTargetKinds
import com.correx.core.tools.contract.Tool import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor import com.correx.core.tools.contract.ToolExecutor
@@ -20,8 +21,6 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject 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 * 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. * link, or attach a note. The task's project is derived from its id, so only the id is required.
@@ -148,10 +147,7 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor {
/** Explicit kind when given (null if unrecognised); otherwise inferred from the target id. */ /** Explicit kind when given (null if unrecognised); otherwise inferred from the target id. */
private fun resolveTargetKind(raw: String?, target: String): TaskTargetKind? = private fun resolveTargetKind(raw: String?, target: String): TaskTargetKind? =
if (raw != null) TaskTargetKind.entries.firstOrNull { it.name == raw.uppercase() } if (raw != null) TaskTargetKind.entries.firstOrNull { it.name == raw.uppercase() }
else inferTargetKind(target) else TaskTargetKinds.infer(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 = private fun fail(request: ToolRequest, reason: String): ToolResult.Failure =
ToolResult.Failure(request.invocationId, reason, recoverable = false) ToolResult.Failure(request.invocationId, reason, recoverable = false)