feat(server): REST /clarify route for headless clarification answers (#42)

Discovery-stage clarifications could only be answered over WebSocket
(ClientMessage.ClarificationResponse), so the curl-based headless QA
driver parked forever at discovery.

Add POST /sessions/{id}/clarify mirroring approveStageRoute. The server
resolves the live (stageId, requestId) from the session id via
SessionOrchestrator.pendingClarificationFor() — the newest still-live,
unanswered ClarificationRequestedEvent from the event log — so a curl
caller need not know the requestId. Empty answers = free-text skip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 23:18:47 +04:00
parent 95b16a5047
commit b43ab77eee
4 changed files with 90 additions and 0 deletions
@@ -0,0 +1,38 @@
package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule
import com.correx.core.events.events.ClarificationAnswer
import com.correx.core.events.types.SessionId
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.post
import kotlinx.serialization.Serializable
// REST parity for stage clarifications (WS already has ClientMessage.ClarificationResponse). Keyed by
// session — the server resolves the live pending (stageId, requestId) so a headless run (a script,
// `correx run`) can clear a discovery-stage clarification without a WebSocket. If the caller omits the
// answer for a question, its value is the empty string (free-text skip). Fixes Vikunja #42.
@Serializable
data class ClarifyStageRequest(val answers: List<ClarificationAnswer>)
internal fun Route.clarifyStageRoute(module: ServerModule) {
post("/clarify") {
val id = call.parameters["id"]
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Missing session id")
return@post
}
val sessionId: SessionId = TypeId(id)
val pending = module.orchestrator.pendingClarificationFor(sessionId)
if (pending == null) {
call.respond(HttpStatusCode.NotFound, "No pending clarification for session $id")
return@post
}
val answers = call.receive<ClarifyStageRequest>().answers
module.orchestrator.submitClarification(sessionId, pending.stageId, pending.requestId, answers)
call.respond(HttpStatusCode.OK)
}
}
@@ -100,6 +100,7 @@ fun Route.sessionRoutes(module: ServerModule) {
getSessionRoute(module)
cancelSessionRoute(module)
approveStageRoute(module)
clarifyStageRoute(module)
approveSourcesRoute(module)
undoSessionRoute(module)
resumeSessionRoute(module)