feat(server): REST stage-approval route POST /sessions/{id}/approve

Stage approvals were WS-only (ClientMessage.ApprovalResponse), so a headless
run had no way to clear an approval gate — and apps/cli ApproveCommand already
POSTs to this path (it 404'd). The route is keyed by session: a session
suspends on at most one approval at a time, so ApprovalCoordinator resolves the
pending requestId server-side (new pendingRequestFor reverse lookup). Decision
maps approve/steer->APPROVE, reject->REJECT; routes through the same
ApprovalCoordinator.handleResponse the WS path uses.
This commit is contained in:
2026-06-30 21:17:10 +04:00
parent e72051a9ea
commit 5e0c7dfed5
3 changed files with 62 additions and 0 deletions
@@ -62,6 +62,14 @@ open class ApprovalCoordinator(
fun lookupSession(requestId: ApprovalRequestId): SessionId? = requestSessions[requestId] fun lookupSession(requestId: ApprovalRequestId): SessionId? = requestSessions[requestId]
/**
* The pending approval request for [sessionId], if any. A session suspends on at most one
* approval at a time, so this reverse lookup is unambiguous. Used by the REST approve route,
* which is keyed by session (the caller need not know the requestId).
*/
fun pendingRequestFor(sessionId: SessionId): ApprovalRequestId? =
requestSessions.entries.firstOrNull { it.value == sessionId }?.key
/** /**
* Register a pending approval request that was reconstructed from the persisted event store * Register a pending approval request that was reconstructed from the persisted event store
* during snapshot replay. This makes [lookupSession] work for approvals that were created * during snapshot replay. This makes [lookupSession] work for approvals that were created
@@ -0,0 +1,50 @@
package com.correx.apps.server.routes
import com.correx.apps.server.ServerModule
import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.ServerMessage
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
// REST parity for stage approvals (WS already has ClientMessage.ApprovalResponse). Keyed by
// session — a session suspends on at most one approval at a time, so the pending requestId is
// resolved server-side and the caller need not know it. Lets a headless run (a script, `correx
// run`) clear an approval gate without a WebSocket. STEER attaches the note alongside an APPROVE.
internal fun Route.approveStageRoute(module: ServerModule) {
post("/approve") {
val id = call.parameters["id"]
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Missing session id")
return@post
}
val body = call.receive<ApproveStageRequest>()
val decision = when (body.decision.uppercase()) {
"APPROVE", "STEER" -> ApprovalDecision.APPROVE
"REJECT" -> ApprovalDecision.REJECT
else -> null
}
if (decision == null) {
call.respond(HttpStatusCode.BadRequest, "Invalid decision '${body.decision}'.")
return@post
}
val sessionId: SessionId = TypeId(id)
val requestId = module.approvalCoordinator.pendingRequestFor(sessionId)
if (requestId == null) {
call.respond(HttpStatusCode.NotFound, "No pending approval for session $id")
return@post
}
val response = ClientMessage.ApprovalResponse(requestId, decision, body.steeringNote)
val error = module.approvalCoordinator.handleResponse(response, sessionId)
if (error is ServerMessage.ProtocolError) {
call.respond(HttpStatusCode.Conflict, error.message)
} else {
call.respond(HttpStatusCode.OK)
}
}
}
@@ -65,6 +65,9 @@ data class SessionStateResponse(val sessionId: String, val status: String, val c
@Serializable @Serializable
data class StartSessionResponse(val sessionId: String) data class StartSessionResponse(val sessionId: String)
@Serializable
data class ApproveStageRequest(val decision: String, val steeringNote: String? = null)
@Serializable @Serializable
data class ApproveSourcesRequest(val urls: List<String>) data class ApproveSourcesRequest(val urls: List<String>)
@@ -94,6 +97,7 @@ fun Route.sessionRoutes(module: ServerModule) {
route("/{id}") { route("/{id}") {
getSessionRoute(module) getSessionRoute(module)
cancelSessionRoute(module) cancelSessionRoute(module)
approveStageRoute(module)
approveSourcesRoute(module) approveSourcesRoute(module)
undoSessionRoute(module) undoSessionRoute(module)
resumeSessionRoute(module) resumeSessionRoute(module)