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
|
||||
|
||||
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.TaskService
|
||||
import com.correx.core.tasks.TaskStatus
|
||||
import com.correx.core.tasks.TaskTargetKinds
|
||||
import com.correx.core.utils.TypeId
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
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.patch
|
||||
import io.ktor.server.routing.post
|
||||
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
|
||||
* 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.
|
||||
* Full REST surface for tasks. The write path mirrors the [TaskService] / `task_*` tools; both
|
||||
* append to the event log, which stays the single source of truth (nothing is stored separately).
|
||||
* 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) {
|
||||
val service = TaskService(module.eventStore)
|
||||
val assembler = TaskContextAssembler(
|
||||
TaskService(module.eventStore),
|
||||
service,
|
||||
module.taskKnowledgeRetriever,
|
||||
module.taskDocumentResolver,
|
||||
)
|
||||
route("/tasks/{id}") {
|
||||
route("/tasks") {
|
||||
taskCollectionRoutes(service)
|
||||
route("/{id}") {
|
||||
taskCrudRoutes(service, assembler)
|
||||
taskTransitionRoutes(service)
|
||||
taskGraphRoutes(service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = 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")
|
||||
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.TaskTargetKind
|
||||
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.ToolCapability
|
||||
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.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.
|
||||
@@ -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. */
|
||||
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
|
||||
else TaskTargetKinds.infer(target)
|
||||
|
||||
private fun fail(request: ToolRequest, reason: String): ToolResult.Failure =
|
||||
ToolResult.Failure(request.invocationId, reason, recoverable = false)
|
||||
|
||||
Reference in New Issue
Block a user