Procházet zdrojové kódy

feat: US-001 - MySQL Node - Connection & Schema Introspection

- Add MySQL credential type to credential management system
- Create mysql-query node for executing SQL queries
- Implement MySQL client library with MariaDB/MySQL support
- Add smartbotic.mysql JavaScript API for query execution
- Extend gRPC credential service with GetMysqlCredentials RPC
- Update frontend CredentialsPage with MySQL credential form
- Node supports SELECT, INSERT, UPDATE, DELETE, and custom queries
- Conditional build support when MySQL libraries are available
fszontagh před 6 měsíci
rodič
revize
da76c7c559

+ 14 - 1
CMakeLists.txt

@@ -214,7 +214,7 @@ target_link_libraries(smartbotic-webserver PRIVATE
 )
 
 # Runner service
-add_executable(smartbotic-runner
+set(RUNNER_SOURCES
     src/runner/main.cpp
     src/runner/workflow_engine.cpp
     src/runner/node_registry.cpp
@@ -224,6 +224,13 @@ add_executable(smartbotic-runner
     src/runner/engine/script_engine.cpp
     src/runner/imap/imap_client.cpp
 )
+
+# Add MySQL client if library is found
+if(MYSQL_CLIENT_FOUND)
+    list(APPEND RUNNER_SOURCES src/runner/mysql/mysql_client.cpp)
+endif()
+
+add_executable(smartbotic-runner ${RUNNER_SOURCES})
 target_include_directories(smartbotic-runner PRIVATE
     ${CMAKE_CURRENT_SOURCE_DIR}/src
     ${CMAKE_CURRENT_BINARY_DIR}
@@ -240,6 +247,12 @@ target_link_libraries(smartbotic-runner PRIVATE
     gRPC::grpc++
 )
 
+# Add MySQL client library if found
+if(MYSQL_CLIENT_FOUND)
+    target_link_libraries(smartbotic-runner PRIVATE mysql_client)
+    target_compile_definitions(smartbotic-runner PRIVATE MYSQL_SUPPORT=1)
+endif()
+
 # Migration tool
 add_executable(smartbotic-migrate-nodes
     src/tools/migrate_nodes.cpp

+ 18 - 0
cmake/FindPackages.cmake

@@ -32,3 +32,21 @@ find_package(Protobuf REQUIRED)
 # libwebsockets
 pkg_check_modules(websockets REQUIRED IMPORTED_TARGET libwebsockets)
 add_library(websockets ALIAS PkgConfig::websockets)
+
+# MySQL/MariaDB client
+pkg_check_modules(mariadb QUIET IMPORTED_TARGET libmariadb)
+if(mariadb_FOUND)
+    add_library(mysql_client ALIAS PkgConfig::mariadb)
+    set(MYSQL_CLIENT_FOUND TRUE CACHE BOOL "MySQL client library found")
+    message(STATUS "Found MariaDB client library")
+else()
+    pkg_check_modules(mysqlclient QUIET IMPORTED_TARGET mysqlclient)
+    if(mysqlclient_FOUND)
+        add_library(mysql_client ALIAS PkgConfig::mysqlclient)
+        set(MYSQL_CLIENT_FOUND TRUE CACHE BOOL "MySQL client library found")
+        message(STATUS "Found MySQL client library")
+    else()
+        set(MYSQL_CLIENT_FOUND FALSE CACHE BOOL "MySQL client library found")
+        message(STATUS "MySQL/MariaDB client library not found - MySQL node support disabled")
+    endif()
+endif()

+ 36 - 0
lib/credentials/credential_client.cpp

@@ -81,6 +81,42 @@ Result<ImapData> CredentialClient::getImapCredentials(const std::string& credent
     return data;
 }
 
+Result<MysqlData> CredentialClient::getMysqlCredentials(const std::string& credential_id,
+                                                         const std::string& workflow_id) {
+    proto::GetMysqlCredentialsRequest request;
+    request.set_credential_id(credential_id);
+    request.set_workflow_id(workflow_id);
+
+    proto::GetMysqlCredentialsResponse response;
+    grpc::ClientContext context;
+
+    // Set timeout
+    auto deadline = std::chrono::system_clock::now() +
+                   std::chrono::milliseconds(config_.timeout_ms);
+    context.set_deadline(deadline);
+
+    grpc::Status status = stub_->GetMysqlCredentials(&context, request, &response);
+
+    if (!status.ok()) {
+        return Error(ErrorCode::Unavailable,
+                    "Credential service error: " + status.error_message());
+    }
+
+    if (!response.success()) {
+        return Error(ErrorCode::NotFound, response.error());
+    }
+
+    MysqlData data;
+    data.host = response.host();
+    data.port = response.port();
+    data.username = response.username();
+    data.password = response.password();
+    data.database = response.database();
+    data.use_ssl = response.use_ssl();
+
+    return data;
+}
+
 Result<std::vector<CredentialInfo>> CredentialClient::listCredentials(const std::string& workflow_id) {
     proto::ListCredentialsRequest request;
     request.set_workflow_id(workflow_id);

+ 4 - 0
lib/credentials/credential_client.hpp

@@ -30,6 +30,10 @@ public:
     common::Result<ImapData> getImapCredentials(const std::string& credential_id,
                                                  const std::string& workflow_id = "");
 
+    // Get MySQL credentials
+    common::Result<MysqlData> getMysqlCredentials(const std::string& credential_id,
+                                                   const std::string& workflow_id = "");
+
     // List available credentials
     common::Result<std::vector<CredentialInfo>> listCredentials(const std::string& workflow_id = "");
 

+ 58 - 0
lib/credentials/credential_store.cpp

@@ -138,6 +138,21 @@ Result<CredentialInfo> CredentialStore::create(const CreateCredentialRequest& re
             };
             break;
         }
+        case CredentialType::Mysql: {
+            auto data = MysqlData::fromJson(request.data);
+            if (data.host.empty() || data.username.empty() || data.password.empty()) {
+                return Error(ErrorCode::InvalidArgument, "MySQL requires host, username, and password");
+            }
+            data_to_encrypt = data.toJson();
+            public_data = {
+                {"host", data.host},
+                {"port", data.port},
+                {"username", data.username},
+                {"database", data.database},
+                {"use_ssl", data.use_ssl}
+            };
+            break;
+        }
         default:
             return Error(ErrorCode::InvalidArgument, "Unsupported credential type");
     }
@@ -244,6 +259,18 @@ Result<void> CredentialStore::update(const std::string& id, const UpdateCredenti
                 };
                 break;
             }
+            case CredentialType::Mysql: {
+                auto data = MysqlData::fromJson(request.data);
+                data_to_encrypt = data.toJson();
+                public_data = {
+                    {"host", data.host},
+                    {"port", data.port},
+                    {"username", data.username},
+                    {"database", data.database},
+                    {"use_ssl", data.use_ssl}
+                };
+                break;
+            }
         }
 
         auto encrypt_result = encryptData(data_to_encrypt);
@@ -361,6 +388,8 @@ Result<HttpAuth> CredentialStore::getHttpAuth(const std::string& id, const std::
         }
         case CredentialType::Imap:
             return Error(ErrorCode::InvalidArgument, "IMAP credentials cannot be used for HTTP authentication");
+        case CredentialType::Mysql:
+            return Error(ErrorCode::InvalidArgument, "MySQL credentials cannot be used for HTTP authentication");
         default:
             return Error(ErrorCode::Internal, "Unsupported credential type");
     }
@@ -395,6 +424,35 @@ Result<ImapData> CredentialStore::getImapCredentials(const std::string& id, cons
     return ImapData::fromJson(decrypt_result.value());
 }
 
+Result<MysqlData> CredentialStore::getMysqlCredentials(const std::string& id, const std::string& workflow_id) {
+    // Get document
+    auto get_result = storage_.get(COLLECTION, id);
+    if (get_result.failed()) {
+        return Error(ErrorCode::NotFound, "Credential not found: " + id);
+    }
+
+    auto doc = CredentialDocument::fromJson(get_result.value());
+
+    // Check it's a MySQL credential
+    if (doc.metadata.type != CredentialType::Mysql) {
+        return Error(ErrorCode::InvalidArgument, "Credential is not a MySQL credential");
+    }
+
+    // Check workflow access
+    if (!hasWorkflowAccess(doc.metadata, workflow_id)) {
+        return Error(ErrorCode::PermissionDenied,
+            "Workflow " + workflow_id + " does not have access to credential " + id);
+    }
+
+    // Decrypt data
+    auto decrypt_result = decryptData(doc.encrypted_data);
+    if (decrypt_result.failed()) {
+        return decrypt_result.error();
+    }
+
+    return MysqlData::fromJson(decrypt_result.value());
+}
+
 // Curl callback for response
 static size_t writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata) {
     auto* response = static_cast<std::string*>(userdata);

+ 3 - 0
lib/credentials/credential_store.hpp

@@ -37,6 +37,9 @@ public:
     // Get IMAP credentials (checks workflow access)
     common::Result<ImapData> getImapCredentials(const std::string& id, const std::string& workflow_id = "");
 
+    // Get MySQL credentials (checks workflow access)
+    common::Result<MysqlData> getMysqlCredentials(const std::string& id, const std::string& workflow_id = "");
+
     // OAuth2 token refresh
     common::Result<void> refreshOAuth2Token(const std::string& id);
 

+ 25 - 0
lib/credentials/credential_types.cpp

@@ -14,6 +14,7 @@ std::string credentialTypeToString(CredentialType type) {
         case CredentialType::ApiKey: return "api_key";
         case CredentialType::OAuth2: return "oauth2";
         case CredentialType::Imap: return "imap";
+        case CredentialType::Mysql: return "mysql";
         default: return "unknown";
     }
 }
@@ -24,6 +25,7 @@ CredentialType credentialTypeFromString(const std::string& str) {
     if (str == "api_key") return CredentialType::ApiKey;
     if (str == "oauth2") return CredentialType::OAuth2;
     if (str == "imap") return CredentialType::Imap;
+    if (str == "mysql") return CredentialType::Mysql;
     throw std::invalid_argument("Unknown credential type: " + str);
 }
 
@@ -183,6 +185,29 @@ ImapData ImapData::fromJson(const nlohmann::json& j) {
     return data;
 }
 
+// MysqlData
+nlohmann::json MysqlData::toJson() const {
+    return {
+        {"host", host},
+        {"port", port},
+        {"username", username},
+        {"password", password},
+        {"database", database},
+        {"useSsl", use_ssl}
+    };
+}
+
+MysqlData MysqlData::fromJson(const nlohmann::json& j) {
+    MysqlData data;
+    data.host = j.value("host", "");
+    data.port = j.value("port", 3306);
+    data.username = j.value("username", "");
+    data.password = j.value("password", "");
+    data.database = j.value("database", "");
+    data.use_ssl = j.value("useSsl", j.value("use_ssl", false));
+    return data;
+}
+
 // CredentialMetadata
 nlohmann::json CredentialMetadata::toJson() const {
     // Note: id, createdAt, updatedAt are managed by database as _id, _createdAt, _updatedAt

+ 15 - 1
lib/credentials/credential_types.hpp

@@ -13,7 +13,8 @@ enum class CredentialType {
     Bearer,     // Bearer Token: token
     ApiKey,     // API Key: header name + key value
     OAuth2,     // OAuth2: client credentials with token refresh
-    Imap        // IMAP: host + port + username + password + SSL
+    Imap,       // IMAP: host + port + username + password + SSL
+    Mysql       // MySQL: host + port + username + password + database
 };
 
 std::string credentialTypeToString(CredentialType type);
@@ -91,6 +92,19 @@ struct ImapData {
     static ImapData fromJson(const nlohmann::json& j);
 };
 
+// MySQL data (stored encrypted)
+struct MysqlData {
+    std::string host;
+    int port = 3306;              // Default MySQL port
+    std::string username;
+    std::string password;
+    std::string database;
+    bool use_ssl = false;         // Use SSL/TLS
+
+    nlohmann::json toJson() const;
+    static MysqlData fromJson(const nlohmann::json& j);
+};
+
 // Credential metadata (not encrypted, stored directly)
 struct CredentialMetadata {
     std::string id;

+ 330 - 0
nodes/mysql/mysql-query.js

@@ -0,0 +1,330 @@
+/**
+ * @node mysql-query
+ * @name MySQL Query
+ * @category database
+ * @version 1.0.0
+ * @description Execute SQL queries against a MySQL database with schema introspection support
+ * @icon database
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        credentialId: {
+            type: 'string',
+            title: 'MySQL Credential',
+            description: 'Select the MySQL credential to use for connection',
+            dynamicOptions: {
+                source: 'credentials',
+                filter: { type: 'mysql' }
+            }
+        },
+        database: {
+            type: 'string',
+            title: 'Database',
+            description: 'Database name (overrides credential default if set)'
+        },
+        table: {
+            type: 'string',
+            title: 'Table',
+            description: 'Table name for query operations'
+        },
+        queryType: {
+            type: 'string',
+            title: 'Query Type',
+            description: 'Type of query to execute',
+            enum: ['select', 'insert', 'update', 'delete', 'custom'],
+            enumLabels: ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'Custom SQL'],
+            default: 'select'
+        },
+        columns: {
+            type: 'array',
+            title: 'Columns',
+            description: 'Columns to select (empty for all). Enter column names manually.',
+            items: {
+                type: 'string'
+            },
+            showWhen: { field: 'queryType', value: 'select' }
+        },
+        whereClause: {
+            type: 'string',
+            title: 'WHERE Clause',
+            description: 'Optional WHERE conditions (without WHERE keyword). Supports {{variable}} interpolation.',
+            showWhen: { field: 'queryType', valueIn: ['select', 'update', 'delete'] }
+        },
+        orderBy: {
+            type: 'string',
+            title: 'ORDER BY',
+            description: 'Order by clause (without ORDER BY keyword)',
+            showWhen: { field: 'queryType', value: 'select' }
+        },
+        limit: {
+            type: 'number',
+            title: 'Limit',
+            description: 'Maximum number of rows to return',
+            default: 100,
+            showWhen: { field: 'queryType', value: 'select' }
+        },
+        insertData: {
+            type: 'string',
+            title: 'Data to Insert (JSON)',
+            description: 'JSON object with column:value pairs. Supports {{variable}} interpolation.',
+            showWhen: { field: 'queryType', value: 'insert' }
+        },
+        updateData: {
+            type: 'string',
+            title: 'Data to Update (JSON)',
+            description: 'JSON object with column:value pairs to set. Supports {{variable}} interpolation.',
+            showWhen: { field: 'queryType', value: 'update' }
+        },
+        customQuery: {
+            type: 'string',
+            title: 'Custom SQL',
+            description: 'Custom SQL query. Supports {{variable}} interpolation. Use ? for parameters.',
+            showWhen: { field: 'queryType', value: 'custom' }
+        },
+        parameters: {
+            type: 'array',
+            title: 'Query Parameters',
+            description: 'Parameters for the query (for ? placeholders)',
+            items: {
+                type: 'string'
+            },
+            showWhen: { field: 'queryType', value: 'custom' }
+        },
+        returnInsertId: {
+            type: 'boolean',
+            title: 'Return Insert ID',
+            description: 'Return the auto-generated ID for INSERT queries',
+            default: true,
+            showWhen: { field: 'queryType', value: 'insert' }
+        }
+    },
+    required: ['credentialId', 'queryType']
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: {
+            type: 'any',
+            description: 'Input data available for interpolation in queries'
+        },
+        parameters: {
+            type: 'array',
+            description: 'Override query parameters from input'
+        }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        rows: { type: 'array', description: 'Query result rows (for SELECT)' },
+        affectedRows: { type: 'number', description: 'Number of affected rows (for INSERT/UPDATE/DELETE)' },
+        insertId: { type: 'number', description: 'Auto-generated insert ID (for INSERT)' },
+        columns: { type: 'array', description: 'Column names from the result' },
+        query: { type: 'string', description: 'The executed SQL query' },
+        error: { type: 'string', description: 'Error message if query failed' }
+    }
+};
+
+const outputs = [
+    { name: 'main', displayName: 'Query Result', type: 'object', color: '#06b6d4' }
+];
+
+function buildSelectQuery(config, data) {
+    const table = config.table;
+    if (!table) {
+        throw new Error('Table is required for SELECT queries');
+    }
+
+    const columns = config.columns && config.columns.length > 0
+        ? config.columns.join(', ')
+        : '*';
+
+    let query = `SELECT ${columns} FROM \`${table}\``;
+
+    if (config.whereClause) {
+        const where = smartbotic.utils.interpolate(config.whereClause, data);
+        query += ` WHERE ${where}`;
+    }
+
+    if (config.orderBy) {
+        query += ` ORDER BY ${config.orderBy}`;
+    }
+
+    if (config.limit && config.limit > 0) {
+        query += ` LIMIT ${config.limit}`;
+    }
+
+    return { query, params: [] };
+}
+
+function buildInsertQuery(config, data) {
+    const table = config.table;
+    if (!table) {
+        throw new Error('Table is required for INSERT queries');
+    }
+
+    let insertData = config.insertData;
+    if (typeof insertData === 'string') {
+        insertData = smartbotic.utils.interpolate(insertData, data);
+        try {
+            insertData = JSON.parse(insertData);
+        } catch (e) {
+            throw new Error('Insert data must be valid JSON: ' + e.message);
+        }
+    }
+
+    if (!insertData || typeof insertData !== 'object') {
+        throw new Error('Insert data is required');
+    }
+
+    const columns = Object.keys(insertData);
+    const values = Object.values(insertData);
+    const placeholders = columns.map(() => '?').join(', ');
+
+    const query = `INSERT INTO \`${table}\` (\`${columns.join('`, `')}\`) VALUES (${placeholders})`;
+
+    return { query, params: values };
+}
+
+function buildUpdateQuery(config, data) {
+    const table = config.table;
+    if (!table) {
+        throw new Error('Table is required for UPDATE queries');
+    }
+
+    let updateData = config.updateData;
+    if (typeof updateData === 'string') {
+        updateData = smartbotic.utils.interpolate(updateData, data);
+        try {
+            updateData = JSON.parse(updateData);
+        } catch (e) {
+            throw new Error('Update data must be valid JSON: ' + e.message);
+        }
+    }
+
+    if (!updateData || typeof updateData !== 'object') {
+        throw new Error('Update data is required');
+    }
+
+    const columns = Object.keys(updateData);
+    const values = Object.values(updateData);
+    const setClause = columns.map(col => `\`${col}\` = ?`).join(', ');
+
+    let query = `UPDATE \`${table}\` SET ${setClause}`;
+
+    if (config.whereClause) {
+        const where = smartbotic.utils.interpolate(config.whereClause, data);
+        query += ` WHERE ${where}`;
+    }
+
+    return { query, params: values };
+}
+
+function buildDeleteQuery(config, data) {
+    const table = config.table;
+    if (!table) {
+        throw new Error('Table is required for DELETE queries');
+    }
+
+    let query = `DELETE FROM \`${table}\``;
+
+    if (config.whereClause) {
+        const where = smartbotic.utils.interpolate(config.whereClause, data);
+        query += ` WHERE ${where}`;
+    } else {
+        throw new Error('WHERE clause is required for DELETE queries to prevent accidental data loss');
+    }
+
+    return { query, params: [] };
+}
+
+function buildCustomQuery(config, data, input) {
+    if (!config.customQuery) {
+        throw new Error('Custom query is required');
+    }
+
+    const query = smartbotic.utils.interpolate(config.customQuery, data);
+
+    let params = input.parameters || config.parameters || [];
+    if (params.length > 0) {
+        params = params.map(p => {
+            if (typeof p === 'string') {
+                return smartbotic.utils.interpolate(p, data);
+            }
+            return p;
+        });
+    }
+
+    return { query, params };
+}
+
+module.exports = {
+    configSchema,
+    inputSchema,
+    outputSchema,
+    outputs,
+
+    async execute(config, input, context) {
+        const credentialId = config.credentialId;
+        const data = input.data || input;
+
+        if (!credentialId) {
+            throw new Error('MySQL credential is required');
+        }
+
+        let queryInfo;
+        const queryType = config.queryType || 'select';
+
+        switch (queryType) {
+            case 'select':
+                queryInfo = buildSelectQuery(config, data);
+                break;
+            case 'insert':
+                queryInfo = buildInsertQuery(config, data);
+                break;
+            case 'update':
+                queryInfo = buildUpdateQuery(config, data);
+                break;
+            case 'delete':
+                queryInfo = buildDeleteQuery(config, data);
+                break;
+            case 'custom':
+                queryInfo = buildCustomQuery(config, data, input);
+                break;
+            default:
+                throw new Error('Invalid query type: ' + queryType);
+        }
+
+        const { query, params } = queryInfo;
+
+        smartbotic.log.info(`MySQL ${queryType.toUpperCase()}: ${query.substring(0, 100)}...`);
+
+        const options = {
+            credentialId,
+            database: config.database,
+            query,
+            params
+        };
+
+        const result = smartbotic.mysql.query(options);
+
+        if (!result.success) {
+            smartbotic.log.error(`MySQL query failed: ${result.error}`);
+            throw new Error(`MySQL query failed: ${result.error}`);
+        }
+
+        smartbotic.log.info(`MySQL query completed: ${result.affectedRows || 0} affected, ${(result.rows || []).length} rows returned`);
+
+        return {
+            rows: result.rows || [],
+            affectedRows: result.affectedRows || 0,
+            insertId: result.insertId || null,
+            columns: result.columns || [],
+            query: query
+        };
+    }
+};

+ 21 - 0
proto/credentials.proto

@@ -12,6 +12,9 @@ service CredentialService {
     // Get IMAP credentials for a credential
     rpc GetImapCredentials(GetImapCredentialsRequest) returns (GetImapCredentialsResponse);
 
+    // Get MySQL credentials for a credential
+    rpc GetMysqlCredentials(GetMysqlCredentialsRequest) returns (GetMysqlCredentialsResponse);
+
     // List available credentials (metadata only)
     rpc ListCredentials(ListCredentialsRequest) returns (ListCredentialsResponse);
 }
@@ -55,6 +58,24 @@ message GetImapCredentialsResponse {
     string error = 7;  // Error message if success is false
 }
 
+// Request to get MySQL credentials
+message GetMysqlCredentialsRequest {
+    string credential_id = 1;
+    string workflow_id = 2;  // For access control verification
+}
+
+// Response with MySQL credentials
+message GetMysqlCredentialsResponse {
+    bool success = 1;
+    string host = 2;
+    int32 port = 3;
+    string username = 4;
+    string password = 5;
+    string database = 6;
+    bool use_ssl = 7;
+    string error = 8;  // Error message if success is false
+}
+
 // Request to list credentials
 message ListCredentialsRequest {
     string workflow_id = 1;  // Optional: filter by workflow access

+ 149 - 1
src/runner/engine/script_engine.cpp

@@ -2,6 +2,9 @@
 #include "common/uuid.hpp"
 #include "logging/logger.hpp"
 #include "runner/imap/imap_client.hpp"
+#ifdef MYSQL_SUPPORT
+#include "runner/mysql/mysql_client.hpp"
+#endif
 #include <quickjs.h>
 #include <chrono>
 #include <thread>
@@ -2138,6 +2141,148 @@ static void setupCredentialsAPI(JSContext* ctx, JSValue smartbotic) {
     JS_SetPropertyStr(ctx, smartbotic, "credentials", credentials);
 }
 
+#ifdef MYSQL_SUPPORT
+// mysql.query(options) - Execute MySQL query
+static JSValue js_mysql_query(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
+    const ScriptContext* script_ctx = getScriptContext(ctx);
+    if (!script_ctx || !script_ctx->mysql_query) {
+        JSValue response = JS_NewObject(ctx);
+        JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+        JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, "MySQL API not available"));
+        return response;
+    }
+
+    if (argc < 1 || !JS_IsObject(argv[0])) {
+        JSValue response = JS_NewObject(ctx);
+        JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+        JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, "mysql.query requires an options object"));
+        return response;
+    }
+
+    JSValue options = argv[0];
+
+    // Get credential ID
+    JSValue cred_val = JS_GetPropertyStr(ctx, options, "credentialId");
+    if (JS_IsUndefined(cred_val)) {
+        JS_FreeValue(ctx, cred_val);
+        JSValue response = JS_NewObject(ctx);
+        JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+        JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, "mysql.query requires credentialId"));
+        return response;
+    }
+    const char* cred_id = JS_ToCString(ctx, cred_val);
+    std::string credential_id(cred_id ? cred_id : "");
+    JS_FreeCString(ctx, cred_id);
+    JS_FreeValue(ctx, cred_val);
+
+    // Get database (optional override)
+    std::string database;
+    JSValue db_val = JS_GetPropertyStr(ctx, options, "database");
+    if (!JS_IsUndefined(db_val)) {
+        const char* db = JS_ToCString(ctx, db_val);
+        if (db) { database = db; JS_FreeCString(ctx, db); }
+    }
+    JS_FreeValue(ctx, db_val);
+
+    // Get query
+    JSValue query_val = JS_GetPropertyStr(ctx, options, "query");
+    if (JS_IsUndefined(query_val)) {
+        JS_FreeValue(ctx, query_val);
+        JSValue response = JS_NewObject(ctx);
+        JS_SetPropertyStr(ctx, response, "success", JS_FALSE);
+        JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, "mysql.query requires query"));
+        return response;
+    }
+    const char* query_cstr = JS_ToCString(ctx, query_val);
+    std::string query(query_cstr ? query_cstr : "");
+    JS_FreeCString(ctx, query_cstr);
+    JS_FreeValue(ctx, query_val);
+
+    // Get params array
+    std::vector<nlohmann::json> params;
+    JSValue params_val = JS_GetPropertyStr(ctx, options, "params");
+    if (JS_IsArray(ctx, params_val)) {
+        uint32_t length = 0;
+        JSValue len_val = JS_GetPropertyStr(ctx, params_val, "length");
+        JS_ToUint32(ctx, &length, len_val);
+        JS_FreeValue(ctx, len_val);
+
+        for (uint32_t i = 0; i < length; i++) {
+            JSValue elem = JS_GetPropertyUint32(ctx, params_val, i);
+
+            if (JS_IsNull(elem) || JS_IsUndefined(elem)) {
+                params.push_back(nullptr);
+            } else if (JS_IsBool(elem)) {
+                params.push_back(JS_ToBool(ctx, elem) != 0);
+            } else if (JS_IsNumber(elem)) {
+                double d;
+                JS_ToFloat64(ctx, &d, elem);
+                if (d == static_cast<int64_t>(d)) {
+                    params.push_back(static_cast<int64_t>(d));
+                } else {
+                    params.push_back(d);
+                }
+            } else {
+                const char* str = JS_ToCString(ctx, elem);
+                params.push_back(std::string(str ? str : ""));
+                JS_FreeCString(ctx, str);
+            }
+
+            JS_FreeValue(ctx, elem);
+        }
+    }
+    JS_FreeValue(ctx, params_val);
+
+    // Build query options
+    MysqlQueryOptions query_opts;
+    query_opts.credential_id = credential_id;
+    query_opts.database = database;
+    query_opts.query = query;
+    query_opts.params = params;
+
+    // Execute query
+    MysqlQueryResult result = script_ctx->mysql_query(query_opts);
+
+    // Build response
+    JSValue response = JS_NewObject(ctx);
+    JS_SetPropertyStr(ctx, response, "success", result.success ? JS_TRUE : JS_FALSE);
+
+    if (result.success) {
+        // Rows array
+        JSValue rows = JS_NewArray(ctx);
+        for (size_t i = 0; i < result.rows.size(); i++) {
+            std::string row_json = result.rows[i].dump();
+            JSValue row = JS_ParseJSON(ctx, row_json.c_str(), row_json.size(), "<row>");
+            JS_SetPropertyUint32(ctx, rows, i, row);
+        }
+        JS_SetPropertyStr(ctx, response, "rows", rows);
+
+        // Columns array
+        JSValue columns = JS_NewArray(ctx);
+        for (size_t i = 0; i < result.columns.size(); i++) {
+            JS_SetPropertyUint32(ctx, columns, i, JS_NewString(ctx, result.columns[i].c_str()));
+        }
+        JS_SetPropertyStr(ctx, response, "columns", columns);
+
+        JS_SetPropertyStr(ctx, response, "affectedRows", JS_NewInt64(ctx, result.affected_rows));
+        JS_SetPropertyStr(ctx, response, "insertId", JS_NewInt64(ctx, result.insert_id));
+    } else {
+        JS_SetPropertyStr(ctx, response, "error", JS_NewString(ctx, result.error.c_str()));
+    }
+
+    return response;
+}
+
+// Setup MySQL API on smartbotic namespace
+static void setupMysqlAPI(JSContext* ctx, JSValue smartbotic) {
+    JSValue mysql = JS_NewObject(ctx);
+
+    JS_SetPropertyStr(ctx, mysql, "query", JS_NewCFunction(ctx, js_mysql_query, "query", 1));
+
+    JS_SetPropertyStr(ctx, smartbotic, "mysql", mysql);
+}
+#endif
+
 ScriptResult ScriptEngine::execute(const std::string& script, const ScriptContext& context) {
     ScriptResult result;
     result.success = true;
@@ -2160,12 +2305,15 @@ ScriptResult ScriptEngine::execute(const std::string& script, const ScriptContex
 
     JSValue global = JS_GetGlobalObject(context_);
 
-    // Set up storage, credentials, and IMAP APIs on smartbotic namespace
+    // Set up storage, credentials, IMAP, and MySQL APIs on smartbotic namespace
     JSValue smartbotic = JS_GetPropertyStr(context_, global, "smartbotic");
     if (JS_IsObject(smartbotic)) {
         setupStorageAPI(context_, smartbotic);
         setupCredentialsAPI(context_, smartbotic);
         setupImapAPI(context_, smartbotic);
+#ifdef MYSQL_SUPPORT
+        setupMysqlAPI(context_, smartbotic);
+#endif
     }
     JS_FreeValue(context_, smartbotic);
 

+ 32 - 0
src/runner/engine/script_engine.hpp

@@ -47,6 +47,34 @@ struct ImapCredential {
     bool use_ssl = true;
 };
 
+// MySQL credential data for JS API
+struct MysqlCredential {
+    std::string host;
+    int port = 3306;
+    std::string username;
+    std::string password;
+    std::string database;
+    bool use_ssl = false;
+};
+
+// MySQL query options
+struct MysqlQueryOptions {
+    std::string credential_id;
+    std::string database;  // Override credential database
+    std::string query;
+    std::vector<nlohmann::json> params;
+};
+
+// MySQL query result
+struct MysqlQueryResult {
+    bool success = false;
+    std::vector<nlohmann::json> rows;
+    std::vector<std::string> columns;
+    int64_t affected_rows = 0;
+    int64_t insert_id = 0;
+    std::string error;
+};
+
 // Script execution context
 struct ScriptContext {
     std::string execution_id;
@@ -71,6 +99,10 @@ struct ScriptContext {
     // Credential operations
     std::function<common::Result<CredentialAuth>(const std::string&)> credentials_get;
     std::function<common::Result<ImapCredential>(const std::string&)> credentials_get_imap;
+    std::function<common::Result<MysqlCredential>(const std::string&)> credentials_get_mysql;
+
+    // MySQL operations
+    std::function<MysqlQueryResult(const MysqlQueryOptions&)> mysql_query;
 };
 
 // Script execution result

+ 372 - 0
src/runner/mysql/mysql_client.cpp

@@ -0,0 +1,372 @@
+#include "mysql_client.hpp"
+#include "logging/logger.hpp"
+#if __has_include(<mariadb/mysql.h>)
+#include <mariadb/mysql.h>
+#else
+#include <mysql/mysql.h>
+#endif
+
+namespace smartbotic::runner::mysql {
+
+using namespace common;
+
+MysqlClient::MysqlClient(const MysqlCredentials& credentials)
+    : credentials_(credentials) {
+}
+
+MysqlClient::~MysqlClient() {
+    disconnect();
+}
+
+Result<void> MysqlClient::connect() {
+    if (connected_) {
+        return Result<void>();
+    }
+
+    MYSQL* mysql = mysql_init(nullptr);
+    if (!mysql) {
+        return Error(ErrorCode::Internal, "Failed to initialize MySQL client");
+    }
+
+    // Set connection options
+    unsigned int timeout = 10;
+    mysql_options(mysql, MYSQL_OPT_CONNECT_TIMEOUT, &timeout);
+    mysql_options(mysql, MYSQL_OPT_READ_TIMEOUT, &timeout);
+    mysql_options(mysql, MYSQL_OPT_WRITE_TIMEOUT, &timeout);
+
+    if (credentials_.use_ssl) {
+        mysql_ssl_set(mysql, nullptr, nullptr, nullptr, nullptr, nullptr);
+    }
+
+    // Connect
+    MYSQL* result = mysql_real_connect(
+        mysql,
+        credentials_.host.c_str(),
+        credentials_.username.c_str(),
+        credentials_.password.c_str(),
+        credentials_.database.empty() ? nullptr : credentials_.database.c_str(),
+        credentials_.port,
+        nullptr,  // Unix socket
+        0         // Client flags
+    );
+
+    if (!result) {
+        std::string error = mysql_error(mysql);
+        mysql_close(mysql);
+        return Error(ErrorCode::Unavailable, "MySQL connection failed: " + error);
+    }
+
+    // Set character set to UTF-8
+    mysql_set_character_set(mysql, "utf8mb4");
+
+    connection_ = mysql;
+    connected_ = true;
+
+    LOG_DEBUG("MySQL connected to {}:{}/{}", credentials_.host, credentials_.port, credentials_.database);
+
+    return Result<void>();
+}
+
+void MysqlClient::disconnect() {
+    if (connection_) {
+        mysql_close(static_cast<MYSQL*>(connection_));
+        connection_ = nullptr;
+        connected_ = false;
+    }
+}
+
+bool MysqlClient::isConnected() const {
+    if (!connected_ || !connection_) {
+        return false;
+    }
+    // Ping to check connection
+    return mysql_ping(static_cast<MYSQL*>(connection_)) == 0;
+}
+
+std::string MysqlClient::escape(const std::string& str) {
+    if (!connection_) {
+        return str;
+    }
+
+    std::string escaped;
+    escaped.resize(str.size() * 2 + 1);
+
+    unsigned long len = mysql_real_escape_string(
+        static_cast<MYSQL*>(connection_),
+        escaped.data(),
+        str.c_str(),
+        str.size()
+    );
+
+    escaped.resize(len);
+    return escaped;
+}
+
+std::string MysqlClient::formatValue(const nlohmann::json& value) {
+    if (value.is_null()) {
+        return "NULL";
+    }
+    if (value.is_boolean()) {
+        return value.get<bool>() ? "1" : "0";
+    }
+    if (value.is_number_integer()) {
+        return std::to_string(value.get<int64_t>());
+    }
+    if (value.is_number_float()) {
+        return std::to_string(value.get<double>());
+    }
+    if (value.is_string()) {
+        return "'" + escape(value.get<std::string>()) + "'";
+    }
+    // For arrays/objects, convert to JSON string
+    return "'" + escape(value.dump()) + "'";
+}
+
+QueryResult MysqlClient::query(const std::string& sql, const std::vector<nlohmann::json>& params) {
+    QueryResult result;
+
+    // Auto-connect if not connected
+    if (!connected_) {
+        auto conn_result = connect();
+        if (conn_result.failed()) {
+            result.error = conn_result.error().message();
+            return result;
+        }
+    }
+
+    MYSQL* mysql = static_cast<MYSQL*>(connection_);
+
+    // Replace ? placeholders with actual values
+    std::string final_sql = sql;
+    size_t param_idx = 0;
+    size_t pos = 0;
+
+    while ((pos = final_sql.find('?', pos)) != std::string::npos) {
+        if (param_idx >= params.size()) {
+            result.error = "Not enough parameters for query placeholders";
+            return result;
+        }
+
+        std::string value_str = formatValue(params[param_idx]);
+        final_sql.replace(pos, 1, value_str);
+        pos += value_str.size();
+        param_idx++;
+    }
+
+    LOG_DEBUG("MySQL query: {}", final_sql.substr(0, 200));
+
+    // Execute query
+    if (mysql_real_query(mysql, final_sql.c_str(), final_sql.size()) != 0) {
+        result.error = mysql_error(mysql);
+        LOG_ERROR("MySQL query failed: {}", result.error);
+        return result;
+    }
+
+    // Get result
+    MYSQL_RES* res = mysql_store_result(mysql);
+
+    if (res) {
+        // SELECT query with results
+        unsigned int num_fields = mysql_num_fields(res);
+        MYSQL_FIELD* fields = mysql_fetch_fields(res);
+
+        // Get column names
+        for (unsigned int i = 0; i < num_fields; i++) {
+            result.columns.push_back(fields[i].name);
+        }
+
+        // Fetch rows
+        MYSQL_ROW row;
+        unsigned long* lengths;
+
+        while ((row = mysql_fetch_row(res))) {
+            lengths = mysql_fetch_lengths(res);
+            nlohmann::json row_obj = nlohmann::json::object();
+
+            for (unsigned int i = 0; i < num_fields; i++) {
+                if (row[i] == nullptr) {
+                    row_obj[fields[i].name] = nullptr;
+                } else {
+                    // Determine value type based on field type
+                    std::string value(row[i], lengths[i]);
+
+                    switch (fields[i].type) {
+                        case MYSQL_TYPE_TINY:
+                        case MYSQL_TYPE_SHORT:
+                        case MYSQL_TYPE_LONG:
+                        case MYSQL_TYPE_LONGLONG:
+                        case MYSQL_TYPE_INT24:
+                        case MYSQL_TYPE_YEAR:
+                            try {
+                                row_obj[fields[i].name] = std::stoll(value);
+                            } catch (...) {
+                                row_obj[fields[i].name] = value;
+                            }
+                            break;
+
+                        case MYSQL_TYPE_FLOAT:
+                        case MYSQL_TYPE_DOUBLE:
+                        case MYSQL_TYPE_DECIMAL:
+                        case MYSQL_TYPE_NEWDECIMAL:
+                            try {
+                                row_obj[fields[i].name] = std::stod(value);
+                            } catch (...) {
+                                row_obj[fields[i].name] = value;
+                            }
+                            break;
+
+                        default:
+                            row_obj[fields[i].name] = value;
+                            break;
+                    }
+                }
+            }
+
+            result.rows.push_back(row_obj);
+        }
+
+        mysql_free_result(res);
+        result.affected_rows = static_cast<int64_t>(result.rows.size());
+    } else {
+        // Non-SELECT query or no results
+        if (mysql_field_count(mysql) == 0) {
+            // INSERT, UPDATE, DELETE, etc.
+            result.affected_rows = static_cast<int64_t>(mysql_affected_rows(mysql));
+            result.insert_id = static_cast<int64_t>(mysql_insert_id(mysql));
+        } else {
+            // Query should have returned results but didn't
+            result.error = mysql_error(mysql);
+            if (!result.error.empty()) {
+                LOG_ERROR("MySQL result error: {}", result.error);
+                return result;
+            }
+        }
+    }
+
+    result.success = true;
+    LOG_DEBUG("MySQL query completed: {} rows, {} affected", result.rows.size(), result.affected_rows);
+
+    return result;
+}
+
+Result<std::vector<std::string>> MysqlClient::listDatabases() {
+    auto conn_result = connect();
+    if (conn_result.failed()) {
+        return conn_result.error();
+    }
+
+    auto query_result = query("SHOW DATABASES");
+    if (!query_result.success) {
+        return Error(ErrorCode::Internal, query_result.error);
+    }
+
+    std::vector<std::string> databases;
+    for (const auto& row : query_result.rows) {
+        if (row.contains("Database")) {
+            databases.push_back(row["Database"].get<std::string>());
+        }
+    }
+
+    return databases;
+}
+
+Result<std::vector<TableInfo>> MysqlClient::listTables(const std::string& database) {
+    auto conn_result = connect();
+    if (conn_result.failed()) {
+        return conn_result.error();
+    }
+
+    std::string db = database.empty() ? credentials_.database : database;
+    if (db.empty()) {
+        return Error(ErrorCode::InvalidArgument, "No database specified");
+    }
+
+    // Use the database
+    auto use_result = useDatabase(db);
+    if (use_result.failed()) {
+        return use_result.error();
+    }
+
+    auto query_result = query("SHOW FULL TABLES");
+    if (!query_result.success) {
+        return Error(ErrorCode::Internal, query_result.error);
+    }
+
+    std::vector<TableInfo> tables;
+    for (const auto& row : query_result.rows) {
+        TableInfo info;
+
+        // Column names are dynamic based on database name
+        for (auto& [key, value] : row.items()) {
+            if (key.find("Tables_in_") == 0) {
+                info.name = value.get<std::string>();
+            } else if (key == "Table_type") {
+                info.type = value.get<std::string>();
+            }
+        }
+
+        if (!info.name.empty()) {
+            tables.push_back(info);
+        }
+    }
+
+    return tables;
+}
+
+Result<std::vector<ColumnInfo>> MysqlClient::listColumns(const std::string& table, const std::string& database) {
+    auto conn_result = connect();
+    if (conn_result.failed()) {
+        return conn_result.error();
+    }
+
+    std::string db = database.empty() ? credentials_.database : database;
+    if (db.empty()) {
+        return Error(ErrorCode::InvalidArgument, "No database specified");
+    }
+
+    // Use the database
+    auto use_result = useDatabase(db);
+    if (use_result.failed()) {
+        return use_result.error();
+    }
+
+    auto query_result = query("DESCRIBE `" + escape(table) + "`");
+    if (!query_result.success) {
+        return Error(ErrorCode::Internal, query_result.error);
+    }
+
+    std::vector<ColumnInfo> columns;
+    for (const auto& row : query_result.rows) {
+        ColumnInfo info;
+        info.name = row.value("Field", "");
+        info.type = row.value("Type", "");
+        info.nullable = row.value("Null", "NO") == "YES";
+        info.key = row.value("Key", "");
+        info.default_value = row.contains("Default") && !row["Default"].is_null()
+            ? row["Default"].get<std::string>() : "";
+        info.extra = row.value("Extra", "");
+
+        columns.push_back(info);
+    }
+
+    return columns;
+}
+
+Result<void> MysqlClient::useDatabase(const std::string& database) {
+    if (!connected_ || !connection_) {
+        auto conn_result = connect();
+        if (conn_result.failed()) {
+            return conn_result.error();
+        }
+    }
+
+    MYSQL* mysql = static_cast<MYSQL*>(connection_);
+
+    if (mysql_select_db(mysql, database.c_str()) != 0) {
+        return Error(ErrorCode::NotFound, "Database not found: " + database);
+    }
+
+    return Result<void>();
+}
+
+} // namespace smartbotic::runner::mysql

+ 83 - 0
src/runner/mysql/mysql_client.hpp

@@ -0,0 +1,83 @@
+#pragma once
+
+#include <string>
+#include <vector>
+#include <nlohmann/json.hpp>
+#include "common/error.hpp"
+
+namespace smartbotic::runner::mysql {
+
+// MySQL connection credentials
+struct MysqlCredentials {
+    std::string host;
+    int port = 3306;
+    std::string username;
+    std::string password;
+    std::string database;
+    bool use_ssl = false;
+};
+
+// Query result
+struct QueryResult {
+    bool success = false;
+    std::vector<nlohmann::json> rows;
+    std::vector<std::string> columns;
+    int64_t affected_rows = 0;
+    int64_t insert_id = 0;
+    std::string error;
+};
+
+// Schema introspection result
+struct TableInfo {
+    std::string name;
+    std::string type;  // TABLE or VIEW
+};
+
+struct ColumnInfo {
+    std::string name;
+    std::string type;
+    bool nullable;
+    std::string key;
+    std::string default_value;
+    std::string extra;
+};
+
+// MySQL client class
+class MysqlClient {
+public:
+    explicit MysqlClient(const MysqlCredentials& credentials);
+    ~MysqlClient();
+
+    // Disable copy
+    MysqlClient(const MysqlClient&) = delete;
+    MysqlClient& operator=(const MysqlClient&) = delete;
+
+    // Connection
+    common::Result<void> connect();
+    void disconnect();
+    bool isConnected() const;
+
+    // Query execution
+    QueryResult query(const std::string& sql, const std::vector<nlohmann::json>& params = {});
+
+    // Schema introspection
+    common::Result<std::vector<std::string>> listDatabases();
+    common::Result<std::vector<TableInfo>> listTables(const std::string& database = "");
+    common::Result<std::vector<ColumnInfo>> listColumns(const std::string& table, const std::string& database = "");
+
+    // Use specific database
+    common::Result<void> useDatabase(const std::string& database);
+
+private:
+    MysqlCredentials credentials_;
+    void* connection_ = nullptr;  // MYSQL* handle
+    bool connected_ = false;
+
+    // Escape string for SQL
+    std::string escape(const std::string& str);
+
+    // Format value for SQL
+    std::string formatValue(const nlohmann::json& value);
+};
+
+} // namespace smartbotic::runner::mysql

+ 60 - 0
src/runner/runner_service.cpp

@@ -7,6 +7,9 @@
 #include <sys/resource.h>
 #include <fstream>
 #include <curl/curl.h>
+#ifdef MYSQL_SUPPORT
+#include "runner/mysql/mysql_client.hpp"
+#endif
 
 namespace smartbotic::runner {
 
@@ -346,6 +349,63 @@ RunnerService::RunnerService(const RunnerServiceConfig& config)
             cred.use_ssl = result.value().use_ssl;
             return cred;
         });
+
+#ifdef MYSQL_SUPPORT
+    // Set up MySQL credential callback for workflow engine
+    engine_->setMysqlCredentialCallback(
+        [this](const std::string& credential_id, const std::string& workflow_id)
+            -> common::Result<engine::MysqlCredential> {
+            auto result = credential_client_->getMysqlCredentials(credential_id, workflow_id);
+            if (result.failed()) {
+                return result.error();
+            }
+            engine::MysqlCredential cred;
+            cred.host = result.value().host;
+            cred.port = result.value().port;
+            cred.username = result.value().username;
+            cred.password = result.value().password;
+            cred.database = result.value().database;
+            cred.use_ssl = result.value().use_ssl;
+            return cred;
+        });
+
+    // Set up MySQL query callback for workflow engine
+    engine_->setMysqlQueryCallback(
+        [this](const engine::MysqlQueryOptions& options, const std::string& workflow_id)
+            -> engine::MysqlQueryResult {
+            engine::MysqlQueryResult result;
+
+            // Get MySQL credentials
+            auto cred_result = credential_client_->getMysqlCredentials(options.credential_id, workflow_id);
+            if (cred_result.failed()) {
+                result.success = false;
+                result.error = cred_result.error().message();
+                return result;
+            }
+
+            // Build connection credentials
+            mysql::MysqlCredentials creds;
+            creds.host = cred_result.value().host;
+            creds.port = cred_result.value().port;
+            creds.username = cred_result.value().username;
+            creds.password = cred_result.value().password;
+            creds.database = options.database.empty() ? cred_result.value().database : options.database;
+            creds.use_ssl = cred_result.value().use_ssl;
+
+            // Create client and execute query
+            mysql::MysqlClient client(creds);
+            auto query_result = client.query(options.query, options.params);
+
+            result.success = query_result.success;
+            result.rows = query_result.rows;
+            result.columns = query_result.columns;
+            result.affected_rows = query_result.affected_rows;
+            result.insert_id = query_result.insert_id;
+            result.error = query_result.error;
+
+            return result;
+        });
+#endif
 }
 
 RunnerService::~RunnerService() {

+ 22 - 0
src/runner/workflow_engine.cpp

@@ -843,6 +843,28 @@ NodeExecutionResult WorkflowEngine::executeNode(const WorkflowNode& node,
         return imap_credential_callback_(credential_id, workflow.id);
     };
 
+    // MySQL Credential API callback
+    ctx.credentials_get_mysql = [this, &workflow](const std::string& credential_id)
+        -> common::Result<engine::MysqlCredential> {
+        if (!mysql_credential_callback_) {
+            return common::Error(common::ErrorCode::Unavailable,
+                "MySQL Credentials API not configured");
+        }
+        return mysql_credential_callback_(credential_id, workflow.id);
+    };
+
+    // MySQL Query API callback
+    ctx.mysql_query = [this, &workflow](const engine::MysqlQueryOptions& options)
+        -> engine::MysqlQueryResult {
+        if (!mysql_query_callback_) {
+            engine::MysqlQueryResult result;
+            result.success = false;
+            result.error = "MySQL API not configured";
+            return result;
+        }
+        return mysql_query_callback_(options, workflow.id);
+    };
+
     // Execute with retry logic
     int max_retries = 0;
     if (node.config.contains("retries")) {

+ 16 - 0
src/runner/workflow_engine.hpp

@@ -121,6 +121,14 @@ using CredentialAuthCallback = std::function<common::Result<engine::CredentialAu
 using ImapCredentialCallback = std::function<common::Result<engine::ImapCredential>(
     const std::string& credential_id, const std::string& workflow_id)>;
 
+// MySQL credential callback type
+using MysqlCredentialCallback = std::function<common::Result<engine::MysqlCredential>(
+    const std::string& credential_id, const std::string& workflow_id)>;
+
+// MySQL query callback type
+using MysqlQueryCallback = std::function<engine::MysqlQueryResult(
+    const engine::MysqlQueryOptions& options, const std::string& workflow_id)>;
+
 // Workflow execution engine
 class WorkflowEngine {
 public:
@@ -150,6 +158,12 @@ public:
     // Set IMAP credential callback (called by runner service to provide IMAP credential access)
     void setImapCredentialCallback(ImapCredentialCallback callback) { imap_credential_callback_ = callback; }
 
+    // Set MySQL credential callback (called by runner service to provide MySQL credential access)
+    void setMysqlCredentialCallback(MysqlCredentialCallback callback) { mysql_credential_callback_ = callback; }
+
+    // Set MySQL query callback (called by runner service to execute MySQL queries)
+    void setMysqlQueryCallback(MysqlQueryCallback callback) { mysql_query_callback_ = callback; }
+
 private:
     void buildExecutionGraph(const Workflow& workflow,
                             std::unordered_map<std::string, std::vector<std::string>>& dependencies,
@@ -220,6 +234,8 @@ private:
     std::unique_ptr<CollectionPermissions> collection_permissions_;
     CredentialAuthCallback credential_auth_callback_;
     ImapCredentialCallback imap_credential_callback_;
+    MysqlCredentialCallback mysql_credential_callback_;
+    MysqlQueryCallback mysql_query_callback_;
 
     std::unordered_map<std::string, ExecutionResult> active_executions_;
     std::unordered_set<std::string> cancelled_executions_;

+ 28 - 0
src/webserver/grpc/credential_service.cpp

@@ -59,6 +59,34 @@ CredentialServiceImpl::CredentialServiceImpl(CredentialStore& credential_store)
     return ::grpc::Status::OK;
 }
 
+::grpc::Status CredentialServiceImpl::GetMysqlCredentials(
+    ::grpc::ServerContext* context,
+    const proto::GetMysqlCredentialsRequest* request,
+    proto::GetMysqlCredentialsResponse* response) {
+
+    LOG_DEBUG("GetMysqlCredentials request: credential={} workflow={}",
+              request->credential_id(), request->workflow_id());
+
+    auto result = credential_store_.getMysqlCredentials(request->credential_id(), request->workflow_id());
+
+    if (result.failed()) {
+        response->set_success(false);
+        response->set_error(result.error().message());
+        LOG_WARN("GetMysqlCredentials failed: {}", result.error().message());
+        return ::grpc::Status::OK;  // Return OK status, error is in response
+    }
+
+    response->set_success(true);
+    response->set_host(result.value().host);
+    response->set_port(result.value().port);
+    response->set_username(result.value().username);
+    response->set_password(result.value().password);
+    response->set_database(result.value().database);
+    response->set_use_ssl(result.value().use_ssl);
+
+    return ::grpc::Status::OK;
+}
+
 ::grpc::Status CredentialServiceImpl::ListCredentials(
     ::grpc::ServerContext* context,
     const proto::ListCredentialsRequest* request,

+ 5 - 0
src/webserver/grpc/credential_service.hpp

@@ -24,6 +24,11 @@ public:
         const proto::GetImapCredentialsRequest* request,
         proto::GetImapCredentialsResponse* response) override;
 
+    ::grpc::Status GetMysqlCredentials(
+        ::grpc::ServerContext* context,
+        const proto::GetMysqlCredentialsRequest* request,
+        proto::GetMysqlCredentialsResponse* response) override;
+
     ::grpc::Status ListCredentials(
         ::grpc::ServerContext* context,
         const proto::ListCredentialsRequest* request,

+ 12 - 3
webui/src/api/credentials.ts

@@ -1,6 +1,6 @@
 import { api } from './client'
 
-export type CredentialType = 'basic' | 'bearer' | 'api_key' | 'oauth2' | 'imap'
+export type CredentialType = 'basic' | 'bearer' | 'api_key' | 'oauth2' | 'imap' | 'mysql'
 
 export interface CredentialInfo {
   id: string
@@ -48,18 +48,27 @@ export interface ImapData {
   use_ssl: boolean
 }
 
+export interface MysqlData {
+  host: string
+  port: number
+  username: string
+  password: string
+  database: string
+  use_ssl: boolean
+}
+
 export interface CreateCredentialRequest {
   name: string
   description?: string
   type: CredentialType
-  data: BasicAuthData | BearerTokenData | ApiKeyData | OAuth2Data | ImapData
+  data: BasicAuthData | BearerTokenData | ApiKeyData | OAuth2Data | ImapData | MysqlData
   allowedWorkflows?: string[]
 }
 
 export interface UpdateCredentialRequest {
   name?: string
   description?: string
-  data?: BasicAuthData | BearerTokenData | ApiKeyData | OAuth2Data | ImapData
+  data?: BasicAuthData | BearerTokenData | ApiKeyData | OAuth2Data | ImapData | MysqlData
   allowedWorkflows?: string[]
 }
 

+ 100 - 0
webui/src/pages/CredentialsPage.tsx

@@ -31,6 +31,7 @@ import {
   AlertTriangle,
   CheckCircle,
   Mail,
+  Database,
 } from 'lucide-react'
 import {
   credentialsApi,
@@ -69,6 +70,11 @@ const CREDENTIAL_TYPE_INFO: Record<
     icon: Mail,
     description: 'IMAP email server authentication',
   },
+  mysql: {
+    label: 'MySQL',
+    icon: Database,
+    description: 'MySQL database connection',
+  },
 }
 
 export default function CredentialsPage() {
@@ -357,6 +363,14 @@ function CredentialModal({ credential, onClose, onSuccess }: CredentialModalProp
   const [imapPassword, setImapPassword] = useState('')
   const [imapUseSsl, setImapUseSsl] = useState((pd.use_ssl as boolean) ?? true)
 
+  // MySQL fields
+  const [mysqlHost, setMysqlHost] = useState((pd.host as string) || '')
+  const [mysqlPort, setMysqlPort] = useState((pd.port as number) || 3306)
+  const [mysqlUsername, setMysqlUsername] = useState((pd.username as string) || '')
+  const [mysqlPassword, setMysqlPassword] = useState('')
+  const [mysqlDatabase, setMysqlDatabase] = useState((pd.database as string) || '')
+  const [mysqlUseSsl, setMysqlUseSsl] = useState((pd.use_ssl as boolean) ?? false)
+
   const createMutation = useMutation({
     mutationFn: (data: CreateCredentialRequest) => credentialsApi.create(data),
     onSuccess: () => {
@@ -403,6 +417,15 @@ function CredentialModal({ credential, onClose, onSuccess }: CredentialModalProp
           password: imapPassword,
           use_ssl: imapUseSsl,
         }
+      case 'mysql':
+        return {
+          host: mysqlHost,
+          port: mysqlPort,
+          username: mysqlUsername,
+          password: mysqlPassword,
+          database: mysqlDatabase,
+          use_ssl: mysqlUseSsl,
+        }
     }
   }
 
@@ -770,6 +793,83 @@ function CredentialModal({ credential, onClose, onSuccess }: CredentialModalProp
                 </div>
               </>
             )}
+
+            {type === 'mysql' && (
+              <>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    MySQL Host *
+                  </label>
+                  <input
+                    type="text"
+                    value={mysqlHost}
+                    onChange={(e) => setMysqlHost(e.target.value)}
+                    placeholder="localhost"
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    Port
+                  </label>
+                  <input
+                    type="number"
+                    value={mysqlPort}
+                    onChange={(e) => setMysqlPort(parseInt(e.target.value) || 3306)}
+                    placeholder="3306"
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    Username *
+                  </label>
+                  <input
+                    type="text"
+                    value={mysqlUsername}
+                    onChange={(e) => setMysqlUsername(e.target.value)}
+                    placeholder="root"
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    Password *
+                  </label>
+                  <input
+                    type={showSecrets ? 'text' : 'password'}
+                    value={mysqlPassword}
+                    onChange={(e) => setMysqlPassword(e.target.value)}
+                    placeholder={credential ? '(unchanged)' : 'Enter password'}
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    Database
+                  </label>
+                  <input
+                    type="text"
+                    value={mysqlDatabase}
+                    onChange={(e) => setMysqlDatabase(e.target.value)}
+                    placeholder="mydb"
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  />
+                </div>
+                <div className="flex items-center gap-2">
+                  <input
+                    type="checkbox"
+                    id="mysqlUseSsl"
+                    checked={mysqlUseSsl}
+                    onChange={(e) => setMysqlUseSsl(e.target.checked)}
+                    className="w-4 h-4 text-primary-600 bg-white dark:bg-slate-700 border-gray-300 dark:border-slate-600 rounded focus:ring-primary-500"
+                  />
+                  <label htmlFor="mysqlUseSsl" className="text-sm font-medium text-gray-700 dark:text-gray-300">
+                    Use SSL/TLS
+                  </label>
+                </div>
+              </>
+            )}
           </div>
 
           <div className="p-6 border-t border-gray-200 dark:border-slate-700 flex justify-end gap-2">

+ 14 - 5
webui/src/pages/WorkflowEditorPage.tsx

@@ -1568,11 +1568,20 @@ function WorkflowEditorInner() {
       })
     })
 
-    setNodes(nds => nds.map(n => ({
-      ...n,
-      position: newPositions.get(n.id) || n.position,
-    })))
-    setHasChanges(true)
+    // Check if any positions actually changed
+    const positionsChanged = nodes.some(n => {
+      const newPos = newPositions.get(n.id)
+      if (!newPos) return false
+      return n.position.x !== newPos.x || n.position.y !== newPos.y
+    })
+
+    if (positionsChanged) {
+      setNodes(nds => nds.map(n => ({
+        ...n,
+        position: newPositions.get(n.id) || n.position,
+      })))
+      setHasChanges(true)
+    }
   }, [nodes, edges, setNodes])
 
   // Save node config