diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/approval/ApprovalCoordinator.kt b/apps/server/src/main/kotlin/com/correx/apps/server/approval/ApprovalCoordinator.kt index 1a9c774a..e249b678 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/approval/ApprovalCoordinator.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/approval/ApprovalCoordinator.kt @@ -62,6 +62,14 @@ open class ApprovalCoordinator( 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 * during snapshot replay. This makes [lookupSession] work for approvals that were created diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/ApprovalStageRoute.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/ApprovalStageRoute.kt new file mode 100644 index 00000000..62f12a05 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/ApprovalStageRoute.kt @@ -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() + 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) + } + } +} diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt index 18277694..b40ee3cb 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/SessionRoutes.kt @@ -65,6 +65,9 @@ data class SessionStateResponse(val sessionId: String, val status: String, val c @Serializable data class StartSessionResponse(val sessionId: String) +@Serializable +data class ApproveStageRequest(val decision: String, val steeringNote: String? = null) + @Serializable data class ApproveSourcesRequest(val urls: List) @@ -94,6 +97,7 @@ fun Route.sessionRoutes(module: ServerModule) { route("/{id}") { getSessionRoute(module) cancelSessionRoute(module) + approveStageRoute(module) approveSourcesRoute(module) undoSessionRoute(module) resumeSessionRoute(module)