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