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:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user