From 4fbc75c752b7226a05b7009061407b6d089a11e6 Mon Sep 17 00:00:00 2001 From: FriederikeHanssen Date: Mon, 22 Dec 2025 13:52:41 +0100 Subject: [PATCH 1/4] Add label support to workflow-launch node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ability to specify comma-separated label names when launching workflows. Labels are automatically created if they don't exist in the workspace, following the same approach as tower-cli. The node resolves label names to IDs and includes them in the launch request. - Add labels field to workflow-launch node UI - Implement label name resolution and auto-creation - Support labels for both regular and resume workflows - Use workspaceId as query parameter per tower-cli implementation - Convert label IDs to strings as required by API - Add comprehensive error handling for label operations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- nodes/workflow-launch.html | 28 ++++++ nodes/workflow-launch.js | 181 ++++++++++++++++++++++++++++++++++++- 2 files changed, 208 insertions(+), 1 deletion(-) diff --git a/nodes/workflow-launch.html b/nodes/workflow-launch.html index 96717b9..c4d16c3 100644 --- a/nodes/workflow-launch.html +++ b/nodes/workflow-launch.html @@ -24,6 +24,12 @@ +
+ + +
    @@ -60,6 +66,7 @@ : Launchpad (string) : The human-readable name of a pipeline in the launchpad to launch. Supports autocomplete. : Run name (string) : Custom name for the workflow run (optional, defaults to auto-generated name). : Resume from (string) : Workflow ID from a previous run to resume (optional). Can be extracted from workflow monitor output using `msg.workflowId`. +: Labels (string) : Comma-separated label names to assign to the workflow run (optional), e.g., `production,rnaseq,urgent`. Labels are automatically created if they don't exist (requires API token with `label:write` permission). The node resolves names to IDs automatically. : Parameters (array) : Individual parameter key-value pairs added via the editable list. Each parameter can be configured with a name and a value of any type (string, number, boolean, JSON, etc.). These take highest precedence when merging. : Params JSON (object) : A JSON object containing multiple parameters to merge with the launchpad's default parameters. Merged before individual parameters. : Workspace ID (string) : Override the workspace ID from the Seqera config node (optional). @@ -95,6 +102,16 @@ The resumed workflow must use the same work directory as the original run, so ensure your compute environment has access to the cached results. +#### Labels + +Labels help organize and track workflow runs in your workspace. Simply provide comma-separated label names (e.g., `production,rnaseq,urgent`) and the node will: + +1. Check if labels exist in the workspace +2. Automatically create missing labels (requires `label:write` permission) +3. Resolve names to IDs and apply them to the workflow run + +**Token permissions:** Your API token needs `label:write` permission to create labels automatically. Without this permission, you'll need to create labels manually in the Seqera UI first. + ### References - [Seqera Platform API docs](https://docs.seqera.io/platform/latest/api) - information about launching workflows @@ -125,6 +142,8 @@ runNameType: { value: "str" }, resumeWorkflowId: { value: "" }, resumeWorkflowIdType: { value: "str" }, + labels: { value: "" }, + labelsType: { value: "str" }, workspaceId: { value: "" }, workspaceIdType: { value: "str" }, sourceWorkspaceId: { value: "" }, @@ -156,6 +175,14 @@ $("#node-input-resumeWorkflowId").typedInput("value", this.resumeWorkflowId || ""); $("#node-input-resumeWorkflowId").typedInput("type", this.resumeWorkflowIdType || "str"); + // Initialize labels typedInput (comma-separated string by default) + $("#node-input-labels").typedInput({ + default: "str", + types: ["str", "msg", "flow", "global", "env", "jsonata", "json"], + }); + $("#node-input-labels").typedInput("value", this.labels || ""); + $("#node-input-labels").typedInput("type", this.labelsType || "str"); + ti("#node-input-workspaceId", this.workspaceId || "", this.workspaceIdType || "str"); ti("#node-input-sourceWorkspaceId", this.sourceWorkspaceId || "", this.sourceWorkspaceIdType || "str"); @@ -298,6 +325,7 @@ save.call(this, "#node-input-paramsKey", "paramsKey", "paramsKeyType"); save.call(this, "#node-input-runName", "runName", "runNameType"); save.call(this, "#node-input-resumeWorkflowId", "resumeWorkflowId", "resumeWorkflowIdType"); + save.call(this, "#node-input-labels", "labels", "labelsType"); save.call(this, "#node-input-workspaceId", "workspaceId", "workspaceIdType"); save.call(this, "#node-input-sourceWorkspaceId", "sourceWorkspaceId", "sourceWorkspaceIdType"); diff --git a/nodes/workflow-launch.js b/nodes/workflow-launch.js index 1709ea9..2526f99 100644 --- a/nodes/workflow-launch.js +++ b/nodes/workflow-launch.js @@ -97,6 +97,8 @@ module.exports = function (RED) { node.sourceWorkspaceIdPropType = config.sourceWorkspaceIdType; node.resumeWorkflowIdProp = config.resumeWorkflowId; node.resumeWorkflowIdPropType = config.resumeWorkflowIdType; + node.labelsProp = config.labels; + node.labelsPropType = config.labelsType; node.seqeraConfig = RED.nodes.getNode(config.seqera); node.defaultBaseUrl = (node.seqeraConfig && node.seqeraConfig.baseUrl) || "https://api.cloud.seqera.io"; @@ -137,6 +139,7 @@ module.exports = function (RED) { const workspaceIdOverride = await evalProp(node.workspaceIdProp, node.workspaceIdPropType); const sourceWorkspaceIdOverride = await evalProp(node.sourceWorkspaceIdProp, node.sourceWorkspaceIdPropType); const resumeWorkflowId = await evalProp(node.resumeWorkflowIdProp, node.resumeWorkflowIdPropType); + const labels = await evalProp(node.labelsProp, node.labelsPropType); const baseUrl = baseUrlOverride || (node.seqeraConfig && node.seqeraConfig.baseUrl) || node.defaultBaseUrl; const workspaceId = workspaceIdOverride || (node.seqeraConfig && node.seqeraConfig.workspaceId) || null; @@ -218,6 +221,94 @@ module.exports = function (RED) { body.launch.runName = runName.trim(); } + // Resolve label names to IDs if provided, creating labels as needed + if (labels) { + body.launch = body.launch || {}; + + // Parse label names from input (array or comma-separated string) + let labelNames = []; + if (Array.isArray(labels)) { + labelNames = labels.map((l) => String(l).trim()).filter(Boolean); + } else if (typeof labels === "string" && labels.trim()) { + labelNames = labels + .split(",") + .map((l) => l.trim()) + .filter(Boolean); + } + + // Fetch labels from API to resolve names to IDs + if (labelNames.length > 0) { + try { + const labelsUrl = `${baseUrl.replace(/\/$/, "")}/labels?workspaceId=${workspaceId}`; + const labelsResp = await apiCall(node, "get", labelsUrl, { headers: { Accept: "application/json" } }); + let availableLabels = labelsResp.data?.labels || []; + + // Map label names to IDs, creating missing labels + const labelIds = []; + for (const labelName of labelNames) { + let match = availableLabels.find((l) => l.name === labelName); + + if (!match) { + // Label doesn't exist, try to create it + try { + // workspaceId goes as query param, NOT in request body + const createLabelUrl = `${baseUrl.replace(/\/$/, "")}/labels?workspaceId=${workspaceId}`; + node.log(`Attempting to create label '${labelName}'...`); + const createResp = await apiCall(node, "post", createLabelUrl, { + headers: { "Content-Type": "application/json", Accept: "application/json" }, + data: { + name: labelName, + // Only include value and resource for resource labels + }, + }); + + node.log(`Label creation response: ${JSON.stringify(createResp.data)}`); + + if (createResp.data?.id) { + match = createResp.data; + node.log(`Created label '${labelName}' (ID: ${match.id})`); + } else { + node.warn( + `Label creation returned unexpected structure for '${labelName}': ${JSON.stringify( + createResp.data, + )}`, + ); + } + } catch (errCreate) { + const errorStatus = errCreate.response?.status; + const errorDetail = + errCreate.response?.data?.message || errCreate.response?.data || errCreate.message; + + if (errorStatus === 403) { + node.error( + `Cannot create label '${labelName}': API token missing 'label:write' permission. ` + + `Add this permission or create labels manually in Seqera UI.`, + msg, + ); + } else { + node.warn(`Failed to create label '${labelName}': ${errorDetail}`); + } + } + } + + if (match && match.id) { + // Convert to string - API expects string array + labelIds.push(String(match.id)); + } + } + + if (labelIds.length > 0) { + body.launch.labelIds = labelIds; + node.log(`Final labelIds for launch: ${JSON.stringify(labelIds)}`); + } else { + node.warn(`No valid labels found to apply to workflow. Requested: ${labelNames.join(", ")}`); + } + } catch (errLabels) { + node.warn(`Failed to resolve label names: ${errLabels.message}. Labels will not be applied.`); + } + } + } + // Resume from a previous workflow if workflow ID is provided // This fetches the workflow's launch config and sessionId, then relaunches with resume enabled if (resumeWorkflowId && resumeWorkflowId.trim && resumeWorkflowId.trim()) { @@ -284,6 +375,84 @@ module.exports = function (RED) { resumeLaunch.runName = runName.trim(); } + // Resolve label names to IDs for resume workflow, creating labels as needed + if (labels) { + let labelNames = []; + if (Array.isArray(labels)) { + labelNames = labels.map((l) => String(l).trim()).filter(Boolean); + } else if (typeof labels === "string" && labels.trim()) { + labelNames = labels + .split(",") + .map((l) => l.trim()) + .filter(Boolean); + } + + if (labelNames.length > 0) { + try { + const labelsUrl = `${baseUrl.replace(/\/$/, "")}/labels?workspaceId=${workspaceId}`; + const labelsResp = await apiCall(node, "get", labelsUrl, { headers: { Accept: "application/json" } }); + let availableLabels = labelsResp.data?.labels || []; + + const labelIds = []; + for (const labelName of labelNames) { + let match = availableLabels.find((l) => l.name === labelName); + + if (!match) { + try { + // workspaceId goes as query param, NOT in request body + const createLabelUrl = `${baseUrl.replace(/\/$/, "")}/labels?workspaceId=${workspaceId}`; + node.log(`Attempting to create label '${labelName}'...`); + const createResp = await apiCall(node, "post", createLabelUrl, { + headers: { "Content-Type": "application/json", Accept: "application/json" }, + data: { + name: labelName, + }, + }); + + node.log(`Label creation response: ${JSON.stringify(createResp.data)}`); + + if (createResp.data?.id) { + match = createResp.data; + node.log(`Created label '${labelName}' (ID: ${match.id})`); + } else { + node.warn( + `Label creation returned unexpected structure for '${labelName}': ${JSON.stringify( + createResp.data, + )}`, + ); + } + } catch (errCreate) { + const errorStatus = errCreate.response?.status; + const errorDetail = + errCreate.response?.data?.message || errCreate.response?.data || errCreate.message; + + if (errorStatus === 403) { + node.error( + `Cannot create label '${labelName}': API token missing 'label:write' permission. ` + + `Add this permission or create labels manually in Seqera UI.`, + msg, + ); + } else { + node.warn(`Failed to create label '${labelName}': ${errorDetail}`); + } + } + } + + if (match && match.id) { + // Convert to string - API expects string array + labelIds.push(String(match.id)); + } + } + + if (labelIds.length > 0) { + resumeLaunch.labelIds = labelIds; + } + } catch (errLabels) { + node.warn(`Failed to resolve label names: ${errLabels.message}. Labels will not be applied.`); + } + } + } + body.launch = resumeLaunch; } catch (errResume) { node.error(`Failed to fetch workflow launch config for resume: ${errResume.message}`, msg); @@ -313,7 +482,15 @@ module.exports = function (RED) { send(outMsg); if (done) done(); } catch (err) { - node.error(`Seqera API request failed: ${err.message}\nRequest: POST ${url}`, msg); + const errorDetail = err.response?.data?.message || err.response?.data || err.message; + const errorStatus = err.response?.status; + let errorMsg = `Seqera API request failed: ${errorDetail}\nRequest: POST ${url}`; + + if (errorStatus === 403) { + errorMsg += `\n\nPermission denied. Please check:\n1. API token has 'Launch' or 'Maintain' role\n2. Workspace ID ${workspaceId} is correct\n3. Token has access to this workspace`; + } + + node.error(errorMsg, msg); node.status({ fill: "red", shape: "ring", text: `error: ${formatDateTime()}` }); return; } @@ -341,6 +518,8 @@ module.exports = function (RED) { sourceWorkspaceIdType: { value: "str" }, resumeWorkflowId: { value: "" }, resumeWorkflowIdType: { value: "str" }, + labels: { value: "" }, + labelsType: { value: "str" }, token: { value: "token" }, tokenType: { value: "str" }, }, From 0c32b07d18a56f0dfbf5be85afe92ca0e70e2b8c Mon Sep 17 00:00:00 2001 From: FriederikeHanssen Date: Mon, 22 Dec 2025 16:38:27 +0100 Subject: [PATCH 2/4] Refactor label resolution to fix code duplication and improve robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #34 review feedback by refactoring label resolution logic: - Extract duplicate code into resolveLabelIds helper function - Add case-insensitive label handling (normalize to lowercase) - Change from warnings to errors for fail-fast behavior on label failures - Fix error handling to pass msg parameter for proper Node-RED context - Use search parameter in API calls to avoid pagination issues - Remove redundant header specifications (handled by apiCall utility) This improves code maintainability, scalability, and error handling while ensuring labels with different casing are treated as the same label. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- nodes/workflow-launch.js | 239 ++++++++++++++------------------------- 1 file changed, 85 insertions(+), 154 deletions(-) diff --git a/nodes/workflow-launch.js b/nodes/workflow-launch.js index 2526f99..85133c2 100644 --- a/nodes/workflow-launch.js +++ b/nodes/workflow-launch.js @@ -115,6 +115,74 @@ module.exports = function (RED) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${d.toLocaleTimeString()}`; }; + // Helper function to resolve label names to IDs, creating labels if needed + const resolveLabelIds = async (labelInput, workspaceId, baseUrl, msg) => { + if (!labelInput || !workspaceId) { + return []; + } + + // Parse label names from comma-separated string (typedInput always returns string) + // Normalize to lowercase since platform treats labels as case-insensitive + const labelNames = String(labelInput) + .split(",") + .map((l) => l.trim().toLowerCase()) + .filter(Boolean); + + if (labelNames.length === 0) { + return []; + } + + const labelIds = []; + + for (const labelName of labelNames) { + // Query for specific label by name using search parameter to avoid pagination issues + // Note: API search returns partial matches, so we use find() to ensure exact match + const searchUrl = `${baseUrl.replace(/\/$/, "")}/labels?workspaceId=${workspaceId}&search=${encodeURIComponent( + labelName, + )}`; + const searchResp = await apiCall(node, "get", searchUrl); + const labels = searchResp.data?.labels || []; + // Case-insensitive comparison since platform normalizes label names + let match = labels.find((l) => l.name.toLowerCase() === labelName); + + if (!match) { + // Label doesn't exist, try to create it + try { + const createUrl = `${baseUrl.replace(/\/$/, "")}/labels?workspaceId=${workspaceId}`; + const createResp = await apiCall(node, "post", createUrl, { + data: { name: labelName }, + }); + + if (createResp.data?.id) { + match = createResp.data; + node.log(`Created label '${labelName}' (ID: ${match.id})`); + } else { + throw new Error(`Failed to create label '${labelName}': API returned no ID`); + } + } catch (errCreate) { + const errorStatus = errCreate.response?.status; + const errorDetail = errCreate.response?.data?.message || errCreate.response?.data || errCreate.message; + + if (errorStatus === 403) { + throw new Error( + `Cannot create label '${labelName}': API token missing 'label:write' permission. Add this permission or create labels manually in Seqera UI.`, + ); + } else { + throw new Error(`Failed to create label '${labelName}': ${errorDetail}`); + } + } + } + + if (match?.id) { + labelIds.push(String(match.id)); + } else { + throw new Error(`Failed to resolve label '${labelName}': No ID found`); + } + } + + return labelIds; + }; + node.on("input", async function (msg, send, done) { node.status({ fill: "blue", shape: "ring", text: `launching: ${formatDateTime()}` }); @@ -224,88 +292,15 @@ module.exports = function (RED) { // Resolve label names to IDs if provided, creating labels as needed if (labels) { body.launch = body.launch || {}; - - // Parse label names from input (array or comma-separated string) - let labelNames = []; - if (Array.isArray(labels)) { - labelNames = labels.map((l) => String(l).trim()).filter(Boolean); - } else if (typeof labels === "string" && labels.trim()) { - labelNames = labels - .split(",") - .map((l) => l.trim()) - .filter(Boolean); - } - - // Fetch labels from API to resolve names to IDs - if (labelNames.length > 0) { - try { - const labelsUrl = `${baseUrl.replace(/\/$/, "")}/labels?workspaceId=${workspaceId}`; - const labelsResp = await apiCall(node, "get", labelsUrl, { headers: { Accept: "application/json" } }); - let availableLabels = labelsResp.data?.labels || []; - - // Map label names to IDs, creating missing labels - const labelIds = []; - for (const labelName of labelNames) { - let match = availableLabels.find((l) => l.name === labelName); - - if (!match) { - // Label doesn't exist, try to create it - try { - // workspaceId goes as query param, NOT in request body - const createLabelUrl = `${baseUrl.replace(/\/$/, "")}/labels?workspaceId=${workspaceId}`; - node.log(`Attempting to create label '${labelName}'...`); - const createResp = await apiCall(node, "post", createLabelUrl, { - headers: { "Content-Type": "application/json", Accept: "application/json" }, - data: { - name: labelName, - // Only include value and resource for resource labels - }, - }); - - node.log(`Label creation response: ${JSON.stringify(createResp.data)}`); - - if (createResp.data?.id) { - match = createResp.data; - node.log(`Created label '${labelName}' (ID: ${match.id})`); - } else { - node.warn( - `Label creation returned unexpected structure for '${labelName}': ${JSON.stringify( - createResp.data, - )}`, - ); - } - } catch (errCreate) { - const errorStatus = errCreate.response?.status; - const errorDetail = - errCreate.response?.data?.message || errCreate.response?.data || errCreate.message; - - if (errorStatus === 403) { - node.error( - `Cannot create label '${labelName}': API token missing 'label:write' permission. ` + - `Add this permission or create labels manually in Seqera UI.`, - msg, - ); - } else { - node.warn(`Failed to create label '${labelName}': ${errorDetail}`); - } - } - } - - if (match && match.id) { - // Convert to string - API expects string array - labelIds.push(String(match.id)); - } - } - - if (labelIds.length > 0) { - body.launch.labelIds = labelIds; - node.log(`Final labelIds for launch: ${JSON.stringify(labelIds)}`); - } else { - node.warn(`No valid labels found to apply to workflow. Requested: ${labelNames.join(", ")}`); - } - } catch (errLabels) { - node.warn(`Failed to resolve label names: ${errLabels.message}. Labels will not be applied.`); + try { + const labelIds = await resolveLabelIds(labels, workspaceId, baseUrl, msg); + if (labelIds.length > 0) { + body.launch.labelIds = labelIds; } + } catch (errLabels) { + node.error(`Failed to resolve labels: ${errLabels.message}`, msg); + node.status({ fill: "red", shape: "ring", text: `error: ${formatDateTime()}` }); + return; } } @@ -375,81 +370,17 @@ module.exports = function (RED) { resumeLaunch.runName = runName.trim(); } - // Resolve label names to IDs for resume workflow, creating labels as needed + // Resolve label names to IDs if provided, creating labels as needed if (labels) { - let labelNames = []; - if (Array.isArray(labels)) { - labelNames = labels.map((l) => String(l).trim()).filter(Boolean); - } else if (typeof labels === "string" && labels.trim()) { - labelNames = labels - .split(",") - .map((l) => l.trim()) - .filter(Boolean); - } - - if (labelNames.length > 0) { - try { - const labelsUrl = `${baseUrl.replace(/\/$/, "")}/labels?workspaceId=${workspaceId}`; - const labelsResp = await apiCall(node, "get", labelsUrl, { headers: { Accept: "application/json" } }); - let availableLabels = labelsResp.data?.labels || []; - - const labelIds = []; - for (const labelName of labelNames) { - let match = availableLabels.find((l) => l.name === labelName); - - if (!match) { - try { - // workspaceId goes as query param, NOT in request body - const createLabelUrl = `${baseUrl.replace(/\/$/, "")}/labels?workspaceId=${workspaceId}`; - node.log(`Attempting to create label '${labelName}'...`); - const createResp = await apiCall(node, "post", createLabelUrl, { - headers: { "Content-Type": "application/json", Accept: "application/json" }, - data: { - name: labelName, - }, - }); - - node.log(`Label creation response: ${JSON.stringify(createResp.data)}`); - - if (createResp.data?.id) { - match = createResp.data; - node.log(`Created label '${labelName}' (ID: ${match.id})`); - } else { - node.warn( - `Label creation returned unexpected structure for '${labelName}': ${JSON.stringify( - createResp.data, - )}`, - ); - } - } catch (errCreate) { - const errorStatus = errCreate.response?.status; - const errorDetail = - errCreate.response?.data?.message || errCreate.response?.data || errCreate.message; - - if (errorStatus === 403) { - node.error( - `Cannot create label '${labelName}': API token missing 'label:write' permission. ` + - `Add this permission or create labels manually in Seqera UI.`, - msg, - ); - } else { - node.warn(`Failed to create label '${labelName}': ${errorDetail}`); - } - } - } - - if (match && match.id) { - // Convert to string - API expects string array - labelIds.push(String(match.id)); - } - } - - if (labelIds.length > 0) { - resumeLaunch.labelIds = labelIds; - } - } catch (errLabels) { - node.warn(`Failed to resolve label names: ${errLabels.message}. Labels will not be applied.`); + try { + const labelIds = await resolveLabelIds(labels, workspaceId, baseUrl, msg); + if (labelIds.length > 0) { + resumeLaunch.labelIds = labelIds; } + } catch (errLabels) { + node.error(`Failed to resolve labels for resume: ${errLabels.message}`, msg); + node.status({ fill: "red", shape: "ring", text: `error: ${formatDateTime()}` }); + return; } } From b4768465ca9a59a817eeebb267e666de45138a8d Mon Sep 17 00:00:00 2001 From: FriederikeHanssen Date: Mon, 22 Dec 2025 16:57:50 +0100 Subject: [PATCH 3/4] Eliminate duplicate label code and fix permission requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate label resolution to single location after both regular launch and resume workflow paths have set up body.launch. Both code paths ultimately populate body.launch, so labels can be applied once instead of duplicating the logic in two places. Fix permission requirements in error messages and documentation: - Change from incorrect "label:write" permission to "Maintain role or higher" - Workspace labels (used by this node) can be created by Maintain role - Resource labels (for cost tracking) require Admin/Owner roles This reduces code duplication and provides accurate guidance to users about required token permissions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- nodes/workflow-launch.html | 6 ++--- nodes/workflow-launch.js | 47 ++++++++++++++------------------------ 2 files changed, 20 insertions(+), 33 deletions(-) diff --git a/nodes/workflow-launch.html b/nodes/workflow-launch.html index c4d16c3..36942df 100644 --- a/nodes/workflow-launch.html +++ b/nodes/workflow-launch.html @@ -66,7 +66,7 @@ : Launchpad (string) : The human-readable name of a pipeline in the launchpad to launch. Supports autocomplete. : Run name (string) : Custom name for the workflow run (optional, defaults to auto-generated name). : Resume from (string) : Workflow ID from a previous run to resume (optional). Can be extracted from workflow monitor output using `msg.workflowId`. -: Labels (string) : Comma-separated label names to assign to the workflow run (optional), e.g., `production,rnaseq,urgent`. Labels are automatically created if they don't exist (requires API token with `label:write` permission). The node resolves names to IDs automatically. +: Labels (string) : Comma-separated label names to assign to the workflow run (optional), e.g., `production,rnaseq,urgent`. Labels are automatically created if they don't exist (requires API token with Maintain role or higher). The node resolves names to IDs automatically. : Parameters (array) : Individual parameter key-value pairs added via the editable list. Each parameter can be configured with a name and a value of any type (string, number, boolean, JSON, etc.). These take highest precedence when merging. : Params JSON (object) : A JSON object containing multiple parameters to merge with the launchpad's default parameters. Merged before individual parameters. : Workspace ID (string) : Override the workspace ID from the Seqera config node (optional). @@ -107,10 +107,10 @@ Labels help organize and track workflow runs in your workspace. Simply provide comma-separated label names (e.g., `production,rnaseq,urgent`) and the node will: 1. Check if labels exist in the workspace -2. Automatically create missing labels (requires `label:write` permission) +2. Automatically create missing labels (requires Maintain role or higher) 3. Resolve names to IDs and apply them to the workflow run -**Token permissions:** Your API token needs `label:write` permission to create labels automatically. Without this permission, you'll need to create labels manually in the Seqera UI first. +**Token permissions:** Your API token needs Maintain role or higher to create labels automatically. Without sufficient permissions, you'll need to create labels manually in the Seqera UI first. ### References diff --git a/nodes/workflow-launch.js b/nodes/workflow-launch.js index 85133c2..342c3ef 100644 --- a/nodes/workflow-launch.js +++ b/nodes/workflow-launch.js @@ -165,7 +165,7 @@ module.exports = function (RED) { if (errorStatus === 403) { throw new Error( - `Cannot create label '${labelName}': API token missing 'label:write' permission. Add this permission or create labels manually in Seqera UI.`, + `Cannot create label '${labelName}': API token requires 'Maintain' role or higher. Use a token with sufficient permissions or create labels manually in Seqera UI.`, ); } else { throw new Error(`Failed to create label '${labelName}': ${errorDetail}`); @@ -289,21 +289,6 @@ module.exports = function (RED) { body.launch.runName = runName.trim(); } - // Resolve label names to IDs if provided, creating labels as needed - if (labels) { - body.launch = body.launch || {}; - try { - const labelIds = await resolveLabelIds(labels, workspaceId, baseUrl, msg); - if (labelIds.length > 0) { - body.launch.labelIds = labelIds; - } - } catch (errLabels) { - node.error(`Failed to resolve labels: ${errLabels.message}`, msg); - node.status({ fill: "red", shape: "ring", text: `error: ${formatDateTime()}` }); - return; - } - } - // Resume from a previous workflow if workflow ID is provided // This fetches the workflow's launch config and sessionId, then relaunches with resume enabled if (resumeWorkflowId && resumeWorkflowId.trim && resumeWorkflowId.trim()) { @@ -370,20 +355,6 @@ module.exports = function (RED) { resumeLaunch.runName = runName.trim(); } - // Resolve label names to IDs if provided, creating labels as needed - if (labels) { - try { - const labelIds = await resolveLabelIds(labels, workspaceId, baseUrl, msg); - if (labelIds.length > 0) { - resumeLaunch.labelIds = labelIds; - } - } catch (errLabels) { - node.error(`Failed to resolve labels for resume: ${errLabels.message}`, msg); - node.status({ fill: "red", shape: "ring", text: `error: ${formatDateTime()}` }); - return; - } - } - body.launch = resumeLaunch; } catch (errResume) { node.error(`Failed to fetch workflow launch config for resume: ${errResume.message}`, msg); @@ -392,6 +363,22 @@ module.exports = function (RED) { } } + // Resolve label names to IDs if provided, creating labels as needed + // Applied after both regular launch and resume paths have set up body.launch + if (labels) { + body.launch = body.launch || {}; + try { + const labelIds = await resolveLabelIds(labels, workspaceId, baseUrl, msg); + if (labelIds.length > 0) { + body.launch.labelIds = labelIds; + } + } catch (errLabels) { + node.error(`Failed to resolve labels: ${errLabels.message}`, msg); + node.status({ fill: "red", shape: "ring", text: `error: ${formatDateTime()}` }); + return; + } + } + // Build URL with query params let url = `${baseUrl.replace(/\/$/, "")}/workflow/launch`; const qs = new URLSearchParams(); From aed3bc2432735fdaf49e0ff353b45fa547628ffd Mon Sep 17 00:00:00 2001 From: FriederikeHanssen Date: Tue, 23 Dec 2025 11:49:32 +0100 Subject: [PATCH 4/4] Add comprehensive unit tests for label functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 8 new test cases for workflow-launch node labels feature: - Label resolution and ID mapping for existing labels - Automatic label creation when labels don't exist - Permission error handling (403) with helpful error messages - Case-insensitive label matching - Whitespace trimming in comma-separated labels - Empty label handling - Integration with resume workflow functionality - Dynamic labels from message properties (typedInput) Also adds helper factories (createLabelsResponse, createLabelResponse) to test/helper.js for mocking Seqera API label endpoints. All 173 tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- test/helper.js | 40 +++ test/workflow-launch_spec.js | 510 +++++++++++++++++++++++++++++++++++ 2 files changed, 550 insertions(+) diff --git a/test/helper.js b/test/helper.js index 3beef44..9bec45b 100644 --- a/test/helper.js +++ b/test/helper.js @@ -272,6 +272,44 @@ function createWorkspacesResponse(workspaces = []) { }; } +/** + * Creates a mock labels response object. + * + * @param {Array} labels - Labels to include + * @returns {Object} A labels response object + */ +function createLabelsResponse(labels = []) { + const defaultLabels = [ + { + id: "1", + name: "production", + resource: true, + isDefault: false, + lastUsed: "2024-01-01T00:00:00Z", + }, + ]; + + return { + labels: labels.length > 0 ? labels : defaultLabels, + }; +} + +/** + * Creates a mock label creation response object. + * + * @param {Object} overrides - Properties to override + * @returns {Object} A label response object + */ +function createLabelResponse(overrides = {}) { + return { + id: "1", + name: "test-label", + resource: true, + isDefault: false, + ...overrides, + }; +} + /** * Helper to wait for a node to emit a message. * Wraps the assertion in try/catch to properly report failures. @@ -334,6 +372,8 @@ module.exports = { createUserInfoResponse, createOrganizationsResponse, createWorkspacesResponse, + createLabelsResponse, + createLabelResponse, expectMessage, expectMessages, }; diff --git a/test/workflow-launch_spec.js b/test/workflow-launch_spec.js index ee9e65a..17e1ac5 100644 --- a/test/workflow-launch_spec.js +++ b/test/workflow-launch_spec.js @@ -6,6 +6,7 @@ * - Parameter merging (paramsJSON and params array) * - Resume workflow functionality * - Custom run names + * - Label resolution and creation * - Error handling */ @@ -19,6 +20,8 @@ const { createPipelinesResponse, createLaunchConfigResponse, createWorkflowResponse, + createLabelsResponse, + createLabelResponse, } = require("./helper"); const { expect } = require("chai"); @@ -567,6 +570,513 @@ describe("seqera-workflow-launch Node", function () { }); }); + describe("labels", function () { + it("should resolve existing label names to IDs and launch with labels", function (done) { + const flow = [ + createConfigNode(), + { + id: "launch1", + type: "seqera-workflow-launch", + name: "Test Launch", + seqera: "config-node-1", + launchpadName: "my-pipeline", + launchpadNameType: "str", + labels: "production,rnaseq", + labelsType: "str", + wires: [["helper1"]], + }, + { id: "helper1", type: "helper" }, + ]; + + // Mock pipelines search + nock(DEFAULT_BASE_URL) + .get("/pipelines") + .query(true) + .reply(200, createPipelinesResponse([{ pipelineId: 42, name: "my-pipeline" }])); + + // Mock launch config fetch + nock(DEFAULT_BASE_URL).get(`/pipelines/42/launch`).query(true).reply(200, createLaunchConfigResponse()); + + // Mock label search for "production" + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "production" }) + .reply(200, createLabelsResponse([{ id: "101", name: "production" }])); + + // Mock label search for "rnaseq" + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "rnaseq" }) + .reply(200, createLabelsResponse([{ id: "102", name: "rnaseq" }])); + + // Mock workflow launch - verify labelIds are included + nock(DEFAULT_BASE_URL) + .post("/workflow/launch", (body) => { + return ( + body.launch.labelIds && + body.launch.labelIds.length === 2 && + body.launch.labelIds.includes("101") && + body.launch.labelIds.includes("102") + ); + }) + .query(true) + .reply(200, { workflowId: "wf-12345" }); + + helper.load([configNode, workflowLaunchNode], flow, createCredentials(), function () { + const launchNode = helper.getNode("launch1"); + const helperNode = helper.getNode("helper1"); + + helperNode.on("input", function (msg) { + try { + expect(msg.workflowId).to.equal("wf-12345"); + done(); + } catch (err) { + done(err); + } + }); + + launchNode.receive({ payload: {} }); + }); + }); + + it("should create missing labels automatically when they don't exist", function (done) { + const flow = [ + createConfigNode(), + { + id: "launch1", + type: "seqera-workflow-launch", + name: "Test Launch", + seqera: "config-node-1", + launchpadName: "my-pipeline", + launchpadNameType: "str", + labels: "newlabel", + labelsType: "str", + wires: [["helper1"]], + }, + { id: "helper1", type: "helper" }, + ]; + + // Mock pipelines search + nock(DEFAULT_BASE_URL) + .get("/pipelines") + .query(true) + .reply(200, createPipelinesResponse([{ pipelineId: 42, name: "my-pipeline" }])); + + // Mock launch config fetch + nock(DEFAULT_BASE_URL).get(`/pipelines/42/launch`).query(true).reply(200, createLaunchConfigResponse()); + + // Mock label search - return empty results + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "newlabel" }) + .reply(200, createLabelsResponse([])); + + // Mock label creation + nock(DEFAULT_BASE_URL) + .post("/labels", { name: "newlabel" }) + .query({ workspaceId: DEFAULT_WORKSPACE_ID }) + .reply(200, createLabelResponse({ id: "201", name: "newlabel" })); + + // Mock workflow launch + nock(DEFAULT_BASE_URL) + .post("/workflow/launch", (body) => { + return body.launch.labelIds && body.launch.labelIds.includes("201"); + }) + .query(true) + .reply(200, { workflowId: "wf-12345" }); + + helper.load([configNode, workflowLaunchNode], flow, createCredentials(), function () { + const launchNode = helper.getNode("launch1"); + const helperNode = helper.getNode("helper1"); + + helperNode.on("input", function (msg) { + try { + expect(msg.workflowId).to.equal("wf-12345"); + done(); + } catch (err) { + done(err); + } + }); + + launchNode.receive({ payload: {} }); + }); + }); + + it("should handle case-insensitive label matching", function (done) { + const flow = [ + createConfigNode(), + { + id: "launch1", + type: "seqera-workflow-launch", + name: "Test Launch", + seqera: "config-node-1", + launchpadName: "my-pipeline", + launchpadNameType: "str", + labels: "Production", + labelsType: "str", + wires: [["helper1"]], + }, + { id: "helper1", type: "helper" }, + ]; + + // Mock pipelines search + nock(DEFAULT_BASE_URL) + .get("/pipelines") + .query(true) + .reply(200, createPipelinesResponse([{ pipelineId: 42, name: "my-pipeline" }])); + + // Mock launch config fetch + nock(DEFAULT_BASE_URL).get(`/pipelines/42/launch`).query(true).reply(200, createLaunchConfigResponse()); + + // Mock label search - API normalizes to lowercase + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "production" }) + .reply(200, createLabelsResponse([{ id: "101", name: "production" }])); + + // Mock workflow launch + nock(DEFAULT_BASE_URL) + .post("/workflow/launch", (body) => { + return body.launch.labelIds && body.launch.labelIds.includes("101"); + }) + .query(true) + .reply(200, { workflowId: "wf-12345" }); + + helper.load([configNode, workflowLaunchNode], flow, createCredentials(), function () { + const launchNode = helper.getNode("launch1"); + const helperNode = helper.getNode("helper1"); + + helperNode.on("input", function (msg) { + try { + expect(msg.workflowId).to.equal("wf-12345"); + done(); + } catch (err) { + done(err); + } + }); + + launchNode.receive({ payload: {} }); + }); + }); + + it("should handle empty labels gracefully", function (done) { + const flow = [ + createConfigNode(), + { + id: "launch1", + type: "seqera-workflow-launch", + name: "Test Launch", + seqera: "config-node-1", + launchpadName: "my-pipeline", + launchpadNameType: "str", + labels: "", + labelsType: "str", + wires: [["helper1"]], + }, + { id: "helper1", type: "helper" }, + ]; + + // Mock pipelines search + nock(DEFAULT_BASE_URL) + .get("/pipelines") + .query(true) + .reply(200, createPipelinesResponse([{ pipelineId: 42, name: "my-pipeline" }])); + + // Mock launch config fetch + nock(DEFAULT_BASE_URL).get(`/pipelines/42/launch`).query(true).reply(200, createLaunchConfigResponse()); + + // Mock workflow launch - should not include labelIds + nock(DEFAULT_BASE_URL) + .post("/workflow/launch", (body) => { + return !body.launch.labelIds || body.launch.labelIds.length === 0; + }) + .query(true) + .reply(200, { workflowId: "wf-12345" }); + + helper.load([configNode, workflowLaunchNode], flow, createCredentials(), function () { + const launchNode = helper.getNode("launch1"); + const helperNode = helper.getNode("helper1"); + + helperNode.on("input", function (msg) { + try { + expect(msg.workflowId).to.equal("wf-12345"); + done(); + } catch (err) { + done(err); + } + }); + + launchNode.receive({ payload: {} }); + }); + }); + + it("should handle whitespace in comma-separated labels", function (done) { + const flow = [ + createConfigNode(), + { + id: "launch1", + type: "seqera-workflow-launch", + name: "Test Launch", + seqera: "config-node-1", + launchpadName: "my-pipeline", + launchpadNameType: "str", + labels: " production , rnaseq , urgent ", + labelsType: "str", + wires: [["helper1"]], + }, + { id: "helper1", type: "helper" }, + ]; + + // Mock pipelines search + nock(DEFAULT_BASE_URL) + .get("/pipelines") + .query(true) + .reply(200, createPipelinesResponse([{ pipelineId: 42, name: "my-pipeline" }])); + + // Mock launch config fetch + nock(DEFAULT_BASE_URL).get(`/pipelines/42/launch`).query(true).reply(200, createLaunchConfigResponse()); + + // Mock label searches - should trim whitespace + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "production" }) + .reply(200, createLabelsResponse([{ id: "101", name: "production" }])); + + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "rnaseq" }) + .reply(200, createLabelsResponse([{ id: "102", name: "rnaseq" }])); + + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "urgent" }) + .reply(200, createLabelsResponse([{ id: "103", name: "urgent" }])); + + // Mock workflow launch + nock(DEFAULT_BASE_URL) + .post("/workflow/launch", (body) => { + return body.launch.labelIds && body.launch.labelIds.length === 3; + }) + .query(true) + .reply(200, { workflowId: "wf-12345" }); + + helper.load([configNode, workflowLaunchNode], flow, createCredentials(), function () { + const launchNode = helper.getNode("launch1"); + const helperNode = helper.getNode("helper1"); + + helperNode.on("input", function (msg) { + try { + expect(msg.workflowId).to.equal("wf-12345"); + done(); + } catch (err) { + done(err); + } + }); + + launchNode.receive({ payload: {} }); + }); + }); + + it("should error when lacking permission to create labels", function (done) { + const flow = [ + createConfigNode(), + { + id: "launch1", + type: "seqera-workflow-launch", + name: "Test Launch", + seqera: "config-node-1", + launchpadName: "my-pipeline", + launchpadNameType: "str", + labels: "newlabel", + labelsType: "str", + wires: [["helper1"]], + }, + { id: "helper1", type: "helper" }, + ]; + + // Mock pipelines search + nock(DEFAULT_BASE_URL) + .get("/pipelines") + .query(true) + .reply(200, createPipelinesResponse([{ pipelineId: 42, name: "my-pipeline" }])); + + // Mock launch config fetch + nock(DEFAULT_BASE_URL).get(`/pipelines/42/launch`).query(true).reply(200, createLaunchConfigResponse()); + + // Mock label search - return empty results + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "newlabel" }) + .reply(200, createLabelsResponse([])); + + // Mock label creation - return 403 forbidden + nock(DEFAULT_BASE_URL) + .post("/labels", { name: "newlabel" }) + .query({ workspaceId: DEFAULT_WORKSPACE_ID }) + .reply(403, { message: "Insufficient permissions" }); + + helper.load([configNode, workflowLaunchNode], flow, createCredentials(), function () { + const launchNode = helper.getNode("launch1"); + + launchNode.on("call:error", function (call) { + try { + expect(call.firstArg).to.include("Cannot create label 'newlabel'"); + expect(call.firstArg).to.include("Maintain"); + done(); + } catch (err) { + done(err); + } + }); + + launchNode.receive({ payload: {} }); + }); + }); + + it("should work with labels in resume workflow", function (done) { + const flow = [ + createConfigNode(), + { + id: "launch1", + type: "seqera-workflow-launch", + name: "Test Launch", + seqera: "config-node-1", + resumeWorkflowId: "wf-original-123", + resumeWorkflowIdType: "str", + labels: "resumed,retry", + labelsType: "str", + wires: [["helper1"]], + }, + { id: "helper1", type: "helper" }, + ]; + + // Mock get workflow details + nock(DEFAULT_BASE_URL) + .get("/workflow/wf-original-123") + .query(true) + .reply( + 200, + createWorkflowResponse({ + id: "wf-original-123", + commitId: "abc123", + }), + ); + + // Mock get workflow launch config + nock(DEFAULT_BASE_URL) + .get("/workflow/wf-original-123/launch") + .query(true) + .reply( + 200, + createLaunchConfigResponse({ + sessionId: "session-123", + resumeCommitId: "abc123", + }), + ); + + // Mock label searches + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "resumed" }) + .reply(200, createLabelsResponse([{ id: "301", name: "resumed" }])); + + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "retry" }) + .reply(200, createLabelsResponse([{ id: "302", name: "retry" }])); + + // Mock workflow launch with resume and labels + nock(DEFAULT_BASE_URL) + .post("/workflow/launch", (body) => { + return ( + body.launch.resume === true && + body.launch.labelIds && + body.launch.labelIds.includes("301") && + body.launch.labelIds.includes("302") + ); + }) + .query(true) + .reply(200, { workflowId: "wf-resumed-456" }); + + helper.load([configNode, workflowLaunchNode], flow, createCredentials(), function () { + const launchNode = helper.getNode("launch1"); + const helperNode = helper.getNode("helper1"); + + helperNode.on("input", function (msg) { + try { + expect(msg.workflowId).to.equal("wf-resumed-456"); + done(); + } catch (err) { + done(err); + } + }); + + launchNode.receive({ + payload: { launch: {} }, + }); + }); + }); + + it("should work with labels from message property", function (done) { + const flow = [ + createConfigNode(), + { + id: "launch1", + type: "seqera-workflow-launch", + name: "Test Launch", + seqera: "config-node-1", + launchpadName: "my-pipeline", + launchpadNameType: "str", + labels: "customLabels", + labelsType: "msg", + wires: [["helper1"]], + }, + { id: "helper1", type: "helper" }, + ]; + + // Mock pipelines search + nock(DEFAULT_BASE_URL) + .get("/pipelines") + .query(true) + .reply(200, createPipelinesResponse([{ pipelineId: 42, name: "my-pipeline" }])); + + // Mock launch config fetch + nock(DEFAULT_BASE_URL).get(`/pipelines/42/launch`).query(true).reply(200, createLaunchConfigResponse()); + + // Mock label search + nock(DEFAULT_BASE_URL) + .get("/labels") + .query({ workspaceId: DEFAULT_WORKSPACE_ID, search: "dynamic" }) + .reply(200, createLabelsResponse([{ id: "401", name: "dynamic" }])); + + // Mock workflow launch + nock(DEFAULT_BASE_URL) + .post("/workflow/launch", (body) => { + return body.launch.labelIds && body.launch.labelIds.includes("401"); + }) + .query(true) + .reply(200, { workflowId: "wf-12345" }); + + helper.load([configNode, workflowLaunchNode], flow, createCredentials(), function () { + const launchNode = helper.getNode("launch1"); + const helperNode = helper.getNode("helper1"); + + helperNode.on("input", function (msg) { + try { + expect(msg.workflowId).to.equal("wf-12345"); + done(); + } catch (err) { + done(err); + } + }); + + launchNode.receive({ + payload: {}, + customLabels: "dynamic", + }); + }); + }); + }); + describe("error handling", function () { it("should report error when no body and no launchpad name", function (done) { const flow = [