Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
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 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).
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 Maintain role or higher)
3. Resolve names to IDs and apply them to the workflow run

**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

- [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
99 changes: 98 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 All @@ -113,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 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}`);
}
}
}

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()}` });

Expand All @@ -137,6 +207,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 @@ -292,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();
Expand All @@ -313,7 +400,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 +436,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