Browse Source

feat: an endpoint to answer a paused execution, and one to list them

Adds a gRPC ResumeExecution RPC that hands token, execution id and
answer payload to WorkflowEngine::resume, plus two REST routes:
POST /api/v1/executions/{id}/resume and GET /api/v1/executions/pending.
The pending listing deliberately omits the pause token, since listing
is a weaker permission than answering.

Also fixes a pre-existing bug in runner_service.cpp: set_status used a
bare static_cast from the engine's ExecutionStatus to proto's
ExecutionStatus, but the two enums are not numerically aligned (proto
reserves 0 for UNSPECIFIED), so every status was silently shifted by
one on the wire. Replaced with an explicit toProtoStatus switch with
no default label, so an unhandled case is a compiler warning instead
of a silent mismatch.

ExecutionController now takes a LoadBalancer& (matching how
WorkflowController already reaches runners) instead of the load
balancer dependency the task brief assumed was already there, which
required updating its one instantiation site in webserver_service.cpp.
fszontagh 1 tháng trước cách đây
mục cha
commit
78258bff63

+ 10 - 0
proto/runner.proto

@@ -201,6 +201,13 @@ message ExecuteNodeResponse {
     int64 execution_time_ms = 4;
 }
 
+// Resume execution request
+message ResumeExecutionRequest {
+    string execution_id = 1;
+    string token = 2;
+    string payload = 3;  // JSON given by whoever answered
+}
+
 // Runner service - called by WebServer to execute workflows
 service RunnerService {
     // Execute a workflow
@@ -209,6 +216,9 @@ service RunnerService {
     // Cancel an execution
     rpc CancelExecution(CancelExecutionRequest) returns (Empty);
 
+    // Continue an execution that paused for an answer
+    rpc ResumeExecution(ResumeExecutionRequest) returns (ExecuteWorkflowResponse);
+
     // Node management
     rpc ListNodes(ListNodesRequest) returns (ListNodesResponse);
     rpc ReloadNode(ReloadNodeRequest) returns (ReloadNodeResponse);

+ 43 - 1
src/runner/runner_service.cpp

@@ -18,6 +18,24 @@ namespace smartbotic::runner {
 
 using namespace common;
 
+// Maps the engine's ExecutionStatus onto the wire enum explicitly. The two
+// enums are not numerically aligned: proto::ExecutionStatus reserves 0 for
+// EXECUTION_STATUS_UNSPECIFIED, while ExecutionStatus::Pending is 0, so a
+// bare static_cast silently shifts every status by one. Deliberately no
+// default label, so an unhandled case is a compiler warning rather than a
+// silent mismatch.
+static proto::ExecutionStatus toProtoStatus(ExecutionStatus status) {
+    switch (status) {
+        case ExecutionStatus::Pending:   return proto::EXECUTION_STATUS_PENDING;
+        case ExecutionStatus::Running:   return proto::EXECUTION_STATUS_RUNNING;
+        case ExecutionStatus::Completed: return proto::EXECUTION_STATUS_COMPLETED;
+        case ExecutionStatus::Failed:    return proto::EXECUTION_STATUS_FAILED;
+        case ExecutionStatus::Cancelled: return proto::EXECUTION_STATUS_CANCELLED;
+        case ExecutionStatus::Waiting:   return proto::EXECUTION_STATUS_WAITING;
+    }
+    return proto::EXECUTION_STATUS_UNSPECIFIED;
+}
+
 // RunnerServiceImpl implementation
 RunnerServiceImpl::RunnerServiceImpl(WorkflowEngine& engine, NodeRegistry& registry,
                                      storage::StorageClient& storage,
@@ -119,7 +137,7 @@ grpc::Status RunnerServiceImpl::ExecuteWorkflow(grpc::ServerContext* context,
     }
 
     response->set_execution_id(result.value().execution_id);
-    response->set_status(static_cast<proto::ExecutionStatus>(result.value().status));
+    response->set_status(toProtoStatus(result.value().status));
 
     if (request->wait_for_completion()) {
         if (!result.value().webhook_response.is_null()) {
@@ -141,6 +159,30 @@ grpc::Status RunnerServiceImpl::CancelExecution(grpc::ServerContext* context,
     return grpc::Status::OK;
 }
 
+grpc::Status RunnerServiceImpl::ResumeExecution(grpc::ServerContext* context,
+                                                const proto::ResumeExecutionRequest* request,
+                                                proto::ExecuteWorkflowResponse* response) {
+    nlohmann::json payload = nlohmann::json::object();
+    if (!request->payload().empty()) {
+        try {
+            payload = nlohmann::json::parse(request->payload());
+        } catch (const std::exception& e) {
+            return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT,
+                                std::string("payload is not JSON: ") + e.what());
+        }
+    }
+
+    auto result = engine_.resume(request->execution_id(), request->token(), payload);
+    if (result.failed()) {
+        return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, result.error().message());
+    }
+
+    response->set_execution_id(result.value().execution_id);
+    response->set_status(toProtoStatus(result.value().status));
+    response->set_result(result.value().final_output.dump());
+    return grpc::Status::OK;
+}
+
 grpc::Status RunnerServiceImpl::ListNodes(grpc::ServerContext* context,
                                           const proto::ListNodesRequest* request,
                                           proto::ListNodesResponse* response) {

+ 4 - 0
src/runner/runner_service.hpp

@@ -38,6 +38,10 @@ public:
                                  const proto::CancelExecutionRequest* request,
                                  proto::Empty* response) override;
 
+    grpc::Status ResumeExecution(grpc::ServerContext* context,
+                                 const proto::ResumeExecutionRequest* request,
+                                 proto::ExecuteWorkflowResponse* response) override;
+
     grpc::Status ListNodes(grpc::ServerContext* context,
                           const proto::ListNodesRequest* request,
                           proto::ListNodesResponse* response) override;

+ 107 - 1
src/webserver/api/execution_controller.cpp

@@ -1,6 +1,8 @@
 #include "execution_controller.hpp"
 #include "../webserver_service.hpp"
 #include "logging/logger.hpp"
+#include "proto/runner.grpc.pb.h"
+#include <grpcpp/grpcpp.h>
 
 namespace smartbotic::webserver::api {
 
@@ -8,9 +10,10 @@ ExecutionController::ExecutionController(storage::StorageClient& storage,
                                          auth::AuthMiddleware& middleware,
                                          WebSocketServer& ws_server,
                                          WorkflowScheduler& scheduler,
+                                         runners::LoadBalancer& load_balancer,
                                          FailureHandler on_failure)
     : storage_(storage), middleware_(middleware), ws_server_(ws_server),
-      scheduler_(scheduler), on_failure_(std::move(on_failure)) {}
+      scheduler_(scheduler), load_balancer_(load_balancer), on_failure_(std::move(on_failure)) {}
 
 void ExecutionController::registerRoutes(httplib::Server& server) {
     server.Get("/api/v1/executions", [this](const httplib::Request& req, httplib::Response& res) {
@@ -19,6 +22,15 @@ void ExecutionController::registerRoutes(httplib::Server& server) {
         });
     });
 
+    // Registered before the generic "/api/v1/executions/([^/]+)" GET route
+    // below, otherwise that regex captures "pending" as an execution id and
+    // this listing 404s.
+    server.Get("/api/v1/executions/pending", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            listPending(req, res, ctx);
+        });
+    });
+
     server.Get(R"(/api/v1/executions/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
         middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
             getExecution(req, res, ctx);
@@ -37,6 +49,12 @@ void ExecutionController::registerRoutes(httplib::Server& server) {
         });
     });
 
+    server.Post(R"(/api/v1/executions/([^/]+)/resume)", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            resumeExecution(req, res, ctx);
+        });
+    });
+
     // Internal endpoint for runner to send execution events (no auth required from internal network)
     server.Post("/api/v1/internal/execution-event", [this](const httplib::Request& req, httplib::Response& res) {
         receiveExecutionEvent(req, res);
@@ -131,6 +149,94 @@ void ExecutionController::retryExecution(const httplib::Request& req, httplib::R
     sendJson(res, {{"success", true}, {"message", "Retry queued"}});
 }
 
+void ExecutionController::resumeExecution(const httplib::Request& req, httplib::Response& res,
+                                          const auth::AuthContext& ctx) {
+    std::string execution_id = req.matches[1];
+
+    nlohmann::json body;
+    try {
+        body = req.body.empty() ? nlohmann::json::object() : nlohmann::json::parse(req.body);
+    } catch (...) {
+        sendError(res, "Body is not valid JSON", 400);
+        return;
+    }
+
+    const std::string token = body.value("token", "");
+    if (token.empty()) {
+        sendError(res, "A token is required to answer a paused execution", 400);
+        return;
+    }
+
+    nlohmann::json payload = body.value("data", nlohmann::json::object());
+    payload["approved"] = body.value("approved", true);
+    payload["answeredBy"] = ctx.user_id;
+
+    auto runner = load_balancer_.selectRunner();
+    if (!runner) {
+        sendError(res, "No runners available", 503);
+        return;
+    }
+
+    // Qualified with the leading "::" because smartbotic::webserver::grpc (a
+    // forward-declared namespace for the node sync / credential gRPC servers,
+    // pulled in via webserver_service.hpp) would otherwise shadow the real
+    // ::grpc namespace here.
+    auto channel = ::grpc::CreateChannel(runner->address, ::grpc::InsecureChannelCredentials());
+    auto stub = proto::RunnerService::NewStub(channel);
+
+    proto::ResumeExecutionRequest grpc_req;
+    grpc_req.set_execution_id(execution_id);
+    grpc_req.set_token(token);
+    grpc_req.set_payload(payload.dump());
+
+    proto::ExecuteWorkflowResponse grpc_res;
+    ::grpc::ClientContext grpc_ctx;
+    grpc_ctx.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(60));
+
+    auto status = stub->ResumeExecution(&grpc_ctx, grpc_req, &grpc_res);
+    if (!status.ok()) {
+        sendError(res, "Could not resume: " + status.error_message(), 400);
+        return;
+    }
+
+    ws_server_.broadcast("executions." + execution_id + ".resumed", {
+        {"executionId", execution_id},
+        {"answeredBy", ctx.user_id}
+    });
+
+    sendJson(res, {{"executionId", execution_id}, {"status", "resumed"}});
+}
+
+void ExecutionController::listPending(const httplib::Request& req, httplib::Response& res,
+                                      const auth::AuthContext& ctx) {
+    storage::QueryOptions options;
+    options.filters.push_back({"status", "waiting"});
+
+    auto result = storage_.query("executions", options);
+    if (result.failed()) {
+        sendError(res, "Could not list pending approvals", 500);
+        return;
+    }
+
+    // Deliberately no pause token here: listing is a weaker permission than
+    // answering, and anyone who could list every pending approval would
+    // otherwise be able to answer all of them. The token reaches an approver
+    // through the node's own output on the execution detail.
+    nlohmann::json pending = nlohmann::json::array();
+    for (const auto& record : result.value().documents) {
+        pending.push_back({
+            {"executionId", record.value("_id", "")},
+            {"workflowId", record.value("workflowId", "")},
+            {"workflowName", record.value("workflowName", "")},
+            {"pausedNodeId", record.value("pausedNodeId", "")},
+            {"pauseExpiresAt", record.value("pauseExpiresAt", static_cast<int64_t>(0))},
+            {"startedAt", record.value("startedAt", static_cast<int64_t>(0))}
+        });
+    }
+
+    sendJson(res, {{"pending", pending}, {"total", pending.size()}});
+}
+
 void ExecutionController::receiveExecutionEvent(const httplib::Request& req, httplib::Response& res) {
     LOG_DEBUG("Received execution event request: {}", req.body);
 

+ 9 - 0
src/webserver/api/execution_controller.hpp

@@ -2,10 +2,13 @@
 
 #include <httplib.h>
 #include <nlohmann/json.hpp>
+#include <optional>
+#include <string>
 #include "../auth/auth_middleware.hpp"
 #include "../websocket_server.hpp"
 #include "storage/storage_client.hpp"
 #include "../scheduler/workflow_scheduler.hpp"
+#include "../runners/load_balancer.hpp"
 
 namespace smartbotic::webserver::api {
 
@@ -20,6 +23,7 @@ public:
 
     ExecutionController(storage::StorageClient& storage, auth::AuthMiddleware& middleware,
                         WebSocketServer& ws_server, WorkflowScheduler& scheduler,
+                        runners::LoadBalancer& load_balancer,
                         FailureHandler on_failure = nullptr);
 
     void registerRoutes(httplib::Server& server);
@@ -33,6 +37,10 @@ private:
                          const auth::AuthContext& ctx);
     void retryExecution(const httplib::Request& req, httplib::Response& res,
                         const auth::AuthContext& ctx);
+    void resumeExecution(const httplib::Request& req, httplib::Response& res,
+                         const auth::AuthContext& ctx);
+    void listPending(const httplib::Request& req, httplib::Response& res,
+                     const auth::AuthContext& ctx);
     void receiveExecutionEvent(const httplib::Request& req, httplib::Response& res);
 
     void sendJson(httplib::Response& res, const nlohmann::json& data, int status = 200);
@@ -42,6 +50,7 @@ private:
     auth::AuthMiddleware& middleware_;
     WebSocketServer& ws_server_;
     WorkflowScheduler& scheduler_;
+    runners::LoadBalancer& load_balancer_;
     FailureHandler on_failure_;
 };
 

+ 1 - 1
src/webserver/webserver_service.cpp

@@ -216,7 +216,7 @@ void WebServerService::setupRoutes() {
     workflow_group_ctrl_->registerRoutes(server);
 
     execution_ctrl_ = std::make_unique<api::ExecutionController>(
-        *storage_, *auth_middleware_, *ws_server_, *scheduler_,
+        *storage_, *auth_middleware_, *ws_server_, *scheduler_, *load_balancer_,
         [this](const std::string& workflow_id, const std::string& execution_id,
                const std::string& error) {
             runErrorWorkflow(workflow_id, execution_id, error);