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
@@ -9,6 +9,8 @@ import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.context.builder.ContextPackBuilder
import com.correx.core.context.model.ContextPack
import com.correx.core.events.events.ClarificationAnswer
import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.ClarificationRequestedEvent
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.InferenceFailedEvent
import com.correx.core.events.events.InferenceStartedEvent
@@ -260,6 +262,22 @@ abstract class SessionOrchestrator(
fun liveClarificationRequestIds(): Set<String> =
pendingClarifications.keys.mapTo(mutableSetOf()) { it.value }
/**
* The clarification a headless caller should answer for this session: the newest still-live,
* unanswered request. Null if the session is not parked on a clarification. Lets a REST answer
* route resolve the (stageId, requestId) server-side so a script need only supply answer values —
* the WS path knows them because it received the [ClarificationRequestedEvent]; a curl caller does not.
*/
suspend fun pendingClarificationFor(sessionId: SessionId): ClarificationRequestedEvent? {
val live = liveClarificationRequestIds()
if (live.isEmpty()) return null
val events = eventStore.read(sessionId)
val answered = events.mapNotNull { it.payload as? ClarificationAnsweredEvent }
.mapTo(mutableSetOf()) { it.requestId }
return events.mapNotNull { it.payload as? ClarificationRequestedEvent }
.lastOrNull { it.requestId.value in live && it.requestId !in answered }
}
/** Public seam for out-of-band gates (e.g. freestyle plan review) that reuse the stage-approval flow. */
suspend fun requestPlanApproval(sessionId: SessionId, preview: String): Boolean =
requestStageApproval(sessionId, StageId("plan_review"), preview)