|
|
@@ -87,6 +87,24 @@ void WorkflowController::registerRoutes(httplib::Server& server) {
|
|
|
});
|
|
|
});
|
|
|
|
|
|
+ server.Get(R"(/api/v1/workflows/([^/]+)/versions)", [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
+ listWorkflowVersions(req, res, ctx);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ server.Get(R"(/api/v1/workflows/([^/]+)/versions/(\d+))", [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
+ getWorkflowVersion(req, res, ctx);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ server.Post(R"(/api/v1/workflows/([^/]+)/publish)", [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
+ publishWorkflow(req, res, ctx);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
server.Post(R"(/api/v1/workflows/([^/]+)/activate)", [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
activateWorkflow(req, res, ctx);
|
|
|
@@ -438,6 +456,9 @@ void WorkflowController::executeWorkflow(const httplib::Request& req, httplib::R
|
|
|
proto::ExecuteWorkflowRequest grpc_req;
|
|
|
grpc_req.set_workflow_id(workflow_id);
|
|
|
grpc_req.set_trigger_type("manual");
|
|
|
+ // Deliberately NOT the published version. Somebody pressing Execute is
|
|
|
+ // testing what is on their canvas; making them publish first to see whether
|
|
|
+ // a change works would defeat the point of publishing being deliberate.
|
|
|
grpc_req.set_trigger_data(trigger_data.dump());
|
|
|
grpc_req.set_wait_for_completion(false);
|
|
|
|
|
|
@@ -477,6 +498,132 @@ void WorkflowController::executeWorkflow(const httplib::Request& req, httplib::R
|
|
|
sendJson(res, response, 202);
|
|
|
}
|
|
|
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// Versions and publishing
|
|
|
+//
|
|
|
+// The workflow record is the draft: it is whatever was written last. Publishing
|
|
|
+// records which version of it is the one that should actually run, so that
|
|
|
+// editing a live workflow does not put half-finished work into production the
|
|
|
+// moment it is saved. A manual run still uses the draft - that is how somebody
|
|
|
+// tests a change before publishing it.
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+void WorkflowController::listWorkflowVersions(const httplib::Request& req, httplib::Response& res,
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
+ (void)ctx;
|
|
|
+ std::string id = req.matches[1];
|
|
|
+
|
|
|
+ int limit = 50;
|
|
|
+ if (req.has_param("limit")) {
|
|
|
+ try {
|
|
|
+ limit = std::clamp(std::stoi(req.get_param_value("limit")), 1, 200);
|
|
|
+ } catch (const std::exception&) {
|
|
|
+ // A bad limit is not worth refusing the request over.
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ auto current = storage_.get("workflows", id);
|
|
|
+ if (current.failed()) {
|
|
|
+ sendError(res, "Workflow not found", 404);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ auto listed = storage_.listVersions("workflows", id, limit, 0);
|
|
|
+ if (listed.failed()) {
|
|
|
+ sendError(res, listed.error().message(), 500);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const int64_t published = current.value().value("publishedVersion", int64_t{0});
|
|
|
+ const int64_t live = current.value().value("_version", int64_t{0});
|
|
|
+
|
|
|
+ nlohmann::json versions = nlohmann::json::array();
|
|
|
+ for (const auto& v : listed.value().versions) {
|
|
|
+ versions.push_back({
|
|
|
+ {"version", v.version},
|
|
|
+ {"createdAt", v.created_at},
|
|
|
+ {"isPublished", v.version == published},
|
|
|
+ {"isCurrent", v.version == live},
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ sendJson(res, {
|
|
|
+ {"versions", versions},
|
|
|
+ {"totalCount", listed.value().total_count},
|
|
|
+ {"hasMore", listed.value().has_more},
|
|
|
+ {"currentVersion", live},
|
|
|
+ {"publishedVersion", published},
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+void WorkflowController::getWorkflowVersion(const httplib::Request& req, httplib::Response& res,
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
+ (void)ctx;
|
|
|
+ std::string id = req.matches[1];
|
|
|
+
|
|
|
+ int64_t version = 0;
|
|
|
+ try {
|
|
|
+ version = std::stoll(req.matches[2]);
|
|
|
+ } catch (const std::exception&) {
|
|
|
+ sendError(res, "Version must be a number", 400);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ auto doc = storage_.getVersion("workflows", id, version);
|
|
|
+ if (doc.failed()) {
|
|
|
+ sendError(res, "That version of the workflow is no longer stored", 404);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ sendJson(res, doc.value());
|
|
|
+}
|
|
|
+
|
|
|
+void WorkflowController::publishWorkflow(const httplib::Request& req, httplib::Response& res,
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
+ std::string id = req.matches[1];
|
|
|
+
|
|
|
+ auto current = storage_.get("workflows", id);
|
|
|
+ if (current.failed()) {
|
|
|
+ sendError(res, "Workflow not found", 404);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const int64_t version = current.value().value("_version", int64_t{0});
|
|
|
+ if (version <= 0) {
|
|
|
+ // Without a version there is nothing to point at, and publishing would
|
|
|
+ // be a promise the runner cannot keep.
|
|
|
+ sendError(res, "This workflow has no stored version to publish yet", 409);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ nlohmann::json patch;
|
|
|
+ patch["publishedVersion"] = version;
|
|
|
+ patch["publishedAt"] = common::TimeUtils::nowMs();
|
|
|
+ patch["publishedBy"] = ctx.username;
|
|
|
+
|
|
|
+ auto updated = storage_.update("workflows", id, patch, 0, true);
|
|
|
+ if (updated.failed()) {
|
|
|
+ sendError(res, updated.error().message(), 500);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Publishing is itself a write, so the record has moved on by one. The
|
|
|
+ // version that was published is the one the caller asked about, not the one
|
|
|
+ // this write produced.
|
|
|
+ ws_server_.broadcast("workflows." + id + ".published", {
|
|
|
+ {"workflowId", id},
|
|
|
+ {"publishedVersion", version},
|
|
|
+ {"publishedBy", ctx.username},
|
|
|
+ });
|
|
|
+
|
|
|
+ sendJson(res, {
|
|
|
+ {"id", id},
|
|
|
+ {"publishedVersion", version},
|
|
|
+ {"publishedAt", patch["publishedAt"]},
|
|
|
+ {"publishedBy", ctx.username},
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
void WorkflowController::activateWorkflow(const httplib::Request& req, httplib::Response& res,
|
|
|
const auth::AuthContext& ctx) {
|
|
|
std::string id = req.matches[1];
|
|
|
@@ -485,11 +632,26 @@ void WorkflowController::activateWorkflow(const httplib::Request& req, httplib::
|
|
|
// why it was switched off. Leaving the count would switch it off again on
|
|
|
// the very next failure, which is not what someone reaching for Activate
|
|
|
// means.
|
|
|
- auto result = storage_.update("workflows", id,
|
|
|
- {{"active", true},
|
|
|
- {"consecutiveFailures", 0},
|
|
|
- {"deactivatedReason", ""},
|
|
|
- {"updatedAt", TimeUtils::nowMs()}}, 0, true);
|
|
|
+ // An active workflow runs what was published, so switching one on without
|
|
|
+ // anything published would leave its triggers with nothing to run. Turning
|
|
|
+ // it on is a clear enough statement that what is there now should go live.
|
|
|
+ auto before = storage_.get("workflows", id);
|
|
|
+ nlohmann::json patch = {{"active", true},
|
|
|
+ {"consecutiveFailures", 0},
|
|
|
+ {"deactivatedReason", ""},
|
|
|
+ {"updatedAt", TimeUtils::nowMs()}};
|
|
|
+ bool published_now = false;
|
|
|
+ if (before.ok() && before.value().value("publishedVersion", int64_t{0}) <= 0) {
|
|
|
+ const int64_t version = before.value().value("_version", int64_t{0});
|
|
|
+ if (version > 0) {
|
|
|
+ patch["publishedVersion"] = version;
|
|
|
+ patch["publishedAt"] = TimeUtils::nowMs();
|
|
|
+ patch["publishedBy"] = ctx.username;
|
|
|
+ published_now = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ auto result = storage_.update("workflows", id, patch, 0, true);
|
|
|
if (result.failed()) {
|
|
|
sendError(res, "Workflow not found", 404);
|
|
|
return;
|
|
|
@@ -499,7 +661,10 @@ void WorkflowController::activateWorkflow(const httplib::Request& req, httplib::
|
|
|
updateScheduledTriggers(id, true);
|
|
|
|
|
|
ws_server_.broadcast("workflows.activated", {{"id", id}});
|
|
|
- sendJson(res, {{"success", true}, {"active", true}});
|
|
|
+ sendJson(res, {{"success", true},
|
|
|
+ {"active", true},
|
|
|
+ {"publishedVersion", patch.value("publishedVersion", int64_t{0})},
|
|
|
+ {"publishedOnActivate", published_now}});
|
|
|
}
|
|
|
|
|
|
void WorkflowController::deactivateWorkflow(const httplib::Request& req, httplib::Response& res,
|