Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions nodes/workflow-launch.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
</label>
<input type="text" id="node-input-resumeWorkflowId" />
</div>
<div class="form-row">
<label for="node-input-labels">
<i class="fa fa-tags"></i> <abbr title="Comma-separated label names">Labels</abbr>
</label>
<input type="text" id="node-input-labels" />
</div>
<div class="form-row">
<label style="width: auto; margin-right: 10px;"><i class="fa fa-list"></i> Parameters</label>
<ol id="node-input-params-container"></ol>
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -125,6 +142,8 @@
runNameType: { value: "str" },
resumeWorkflowId: { value: "" },
resumeWorkflowIdType: { value: "str" },
labels: { value: "" },
labelsType: { value: "str" },
workspaceId: { value: "" },
workspaceIdType: { value: "str" },
sourceWorkspaceId: { value: "" },
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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");

Expand Down
181 changes: 180 additions & 1 deletion nodes/workflow-launch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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" },
},
Expand Down