From 5e0c7dfed5a8a8b05e825faea40b20f47164ed1d Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 30 Jun 2026 21:17:10 +0400 Subject: [PATCH] feat(server): REST stage-approval route POST /sessions/{id}/approve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../server/approval/ApprovalCoordinator.kt | 8 +++ .../apps/server/routes/ApprovalStageRoute.kt | 50 +++++++++++++++++++ .../apps/server/routes/SessionRoutes.kt | 4 ++ 3 files changed, 62 insertions(+) create mode 100644 apps/server/src/main/kotlin/com/correx/apps/server/routes/ApprovalStageRoute.kt 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)