From 6e044d0ab9e6ade8ee6f70978493035351fcfd05 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 23 Jun 2026 19:27:19 +0000 Subject: [PATCH] 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 --- .../correx/apps/server/routes/TaskRoutes.kt | 238 +++++++++++++- .../apps/server/routes/TaskRoutesTest.kt | 310 ++++++++++++++++++ .../com/correx/core/tasks/TaskTargetKinds.kt | 16 + .../tools/task/TaskUpdateTool.kt | 8 +- 4 files changed, 555 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt create mode 100644 core/tasks/src/main/kotlin/com/correx/core/tasks/TaskTargetKinds.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index 9e91b282..1df939a1 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -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, + val affectedPaths: List, + val claimant: String?, + val links: List, + val notes: List, + val createdAt: String?, + val updatedAt: String?, +) + +@Serializable +data class CreateTaskRequest( + val project: String, + val title: String, + val goal: String, + val acceptanceCriteria: List = emptyList(), + val affectedPaths: List = 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? = null, + val affectedPaths: List? = 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}") { - get("/context") { - val id = call.parameters["id"] - ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id") - val bundle = assembler.assemble(TypeId(id)) - ?: return@get call.respond(HttpStatusCode.NotFound, "Task not found") - call.respond(bundle) + 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(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() + 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() + 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().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().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() }.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() + val type = parseEnum(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(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(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() + val author = body.author?.let { + parseEnum(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 > parseEnum(raw: String): T? = + enumValues().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(), +) diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt new file mode 100644 index 00000000..2ae7aeae --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt @@ -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() + override suspend fun healthCheckAll() = emptyMap() + } + val noopWorkflowRegistry = object : WorkflowRegistry { + override fun listAll(): List = 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, + ) + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskTargetKinds.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskTargetKinds.kt new file mode 100644 index 00000000..8a163cff --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskTargetKinds.kt @@ -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 +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt index 15a8f447..80bdcb9c 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -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)