Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,138 @@
import { describe, expect, it, vi } from "vitest";
import type { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
import type { WorkflowDto } from "../dtos/WorkflowDto";
vi.mock("i18next", () => ({
t: (key: string, values?: Record<string, unknown>) => {
const suffix = values
? Object.entries(values)
.map(([name, value]) => `${name}=${String(value)}`)
.join(",")
: "";
return suffix ? `${key}:${suffix}` : key;
},
}));
const { default: WorkflowBlockUtils } = await import("./WorkflowBlockUtils");
function block(overrides: Partial<WorkflowBlockDto> = {}): WorkflowBlockDto {
return {
id: "block_1",
index: 0,
type: "log",
variableName: "log",
description: "",
input: { message: "hello" },
isTrigger: false,
isBlock: true,
conditionGroups: [],
toBlocks: [],
...overrides,
};
}
function workflow(blocks: WorkflowBlockDto[]): WorkflowDto {
return {
id: "workflow_1",
tenantId: "tenant_1",
tenant: null,
name: "Workflow",
description: "",
status: "live",
createdAt: new Date("2026-05-08T10:00:00.000Z"),
updatedAt: new Date("2026-05-08T10:00:00.000Z"),
appliesToAllTenants: false,
blocks,
inputExamples: [],
_count: { executions: 0 },
};
}
describe("WorkflowBlockUtils.getBlockErrors", () => {
it("indexes incoming block ids once for validation callers", () => {
const trigger = block({
id: "trigger_1",
type: "manual",
isTrigger: true,
isBlock: false,
input: {},
toBlocks: [{ id: "edge_1", fromBlockId: "trigger_1", toBlockId: "action_1", condition: null }],
});
const action = block({ id: "action_1" });
expect(Array.from(WorkflowBlockUtils.getIncomingBlockIds(workflow([trigger, action])))).toEqual(["action_1"]);
expect(
WorkflowBlockUtils.getBlockErrors({
workflow: workflow([trigger, action]),
block: action,
validationContext: { incomingBlockIds: new Set(["action_1"]) },
})
).not.toContain("workflows.validation.errors.nothingConnectingToBlock");
});
it("returns translated trigger and missing-caller validation errors", () => {
const trigger = block({ id: "trigger_1", type: "manual", isTrigger: true, isBlock: false, input: {} });
const action = block({ id: "action_1", input: { message: "hello" } });
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([trigger, action]), block: trigger })).toContain(
"workflows.validation.errors.addNextBlockForTrigger",
);
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([trigger, action]), block: action })).toContain(
"workflows.validation.errors.nothingConnectingToBlock",
);
});
it("returns translated branch connection errors", () => {
const branch = block({
id: "branch_1",
type: "if",
input: {},
toBlocks: [{ id: "edge_1", fromBlockId: "branch_1", toBlockId: "next_1", condition: "true" }],
conditionGroups: [{ index: 0, type: "AND", conditions: [{ index: 0, variable: "{{$params.plan}}", operator: "equals", value: "pro" }] }],
});
const next = block({ id: "next_1" });
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([branch, next]), block: branch })).toContain(
"workflows.validation.errors.addNextBlockForFalseCondition",
);
});
it("returns translated required-input and condition errors", () => {
const email = block({ id: "email_1", type: "email", input: { provider: "smtp" } });
const invalidCondition = block({
id: "if_1",
type: "if",
input: {},
conditionGroups: [{ index: 0, type: "AND", conditions: [{ index: 0, variable: "", operator: "equals", value: "" }] }],
});
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([email]), block: email })).toContain(
"workflows.validation.errors.missingRequiredInput:name=to",
);
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([invalidCondition]), block: invalidCondition })).toEqual(
expect.arrayContaining([expect.stringContaining("workflows.validation.errors.invalidConditions:errors=")]),
);
});
it("returns translated invalid block type errors", () => {
const invalid = block({ type: "missingBlockType" as WorkflowBlockDto["type"] });
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([invalid]), block: invalid })).toContain(
"workflows.validation.errors.invalidWorkflowBlockType:type=missingBlockType",
);
});
it("returns translated manual validation schema errors", () => {
const manual = block({
id: "manual_1",
type: "manual",
isTrigger: true,
isBlock: false,
input: { validation: "{not-json" },
});
expect(WorkflowBlockUtils.getBlockErrors({ workflow: workflow([manual]), block: manual })).toEqual(
expect.arrayContaining([expect.stringContaining("workflows.validation.errors.invalidManualValidationSchema:error=")])
);
});
});
@@ -0,0 +1,180 @@
import { t as i18nT } from "i18next";
import { WorkflowDto } from "../dtos/WorkflowDto";
import { WorkflowBlockInput, WorkflowBlockType, WorkflowBlockTypes } from "../dtos/WorkflowBlockTypes";
import { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
import WorkflowConditionUtils from "./WorkflowConditionUtils";
import { WorkflowBlockWithDetails } from "../db/workflowBlocks.db.server";
import { WorkflowConditionOperator } from "../dtos/WorkflowConditionDtos";
import WorkflowsValidationService from "../services/WorkflowsValidationService";
function rowToDto(block: WorkflowBlockWithDetails, index: number = 0) {
let input: any = {};
try {
input = JSON.parse(block.input);
} catch (e) {}
const workflowBlock: WorkflowBlockDto = {
id: block.id,
index,
type: block.type as WorkflowBlockType,
variableName: "",
description: block.description,
input,
isTrigger: block.isTrigger,
isBlock: block.isBlock,
toBlocks: block.toBlocks.map((toBlock) => {
return {
id: toBlock.id,
fromBlockId: block.id,
toBlockId: toBlock.toBlockId,
condition: toBlock.condition,
};
}),
conditionGroups: block.conditionsGroups
.sort((a, b) => a.index - b.index)
.map((group) => {
return {
index: group.index,
type: group.type as "AND" | "OR",
conditions: group.conditions
.sort((a, b) => a.index - b.index)
.map((condition) => {
return {
index: condition.index,
variable: condition.variable,
operator: condition.operator as WorkflowConditionOperator,
value: condition.value,
};
}),
};
}),
};
return workflowBlock;
}
export interface WorkflowValidationContext {
incomingBlockIds?: ReadonlySet<string>;
}
function getIncomingBlockIds(workflow: Pick<WorkflowDto, "blocks">): Set<string> {
const incomingBlockIds = new Set<string>();
workflow.blocks.forEach((fromBlock) => {
fromBlock.toBlocks.forEach((toBlock) => {
incomingBlockIds.add(toBlock.toBlockId);
});
});
return incomingBlockIds;
}
function getBlockErrors({ workflow, block, validationContext }: { workflow: WorkflowDto; block: WorkflowBlockDto; validationContext?: WorkflowValidationContext }) {
const errors: string[] = [];
if (block.isTrigger) {
if (block.toBlocks.length === 0) {
errors.push(i18nT("workflows.validation.errors.addNextBlockForTrigger"));
}
// if (!block.description) {
// errors.push("Add a description for this trigger");
// }
} else if (block.isBlock) {
const hasIncomingConnection =
validationContext?.incomingBlockIds?.has(block.id) ?? workflow.blocks.some((fromBlock) => fromBlock.toBlocks.some((toBlock) => toBlock.toBlockId === block.id));
if (!hasIncomingConnection) {
errors.push(i18nT("workflows.validation.errors.nothingConnectingToBlock"));
}
if (block.type === "if") {
const hasTrue = block.toBlocks.find((f) => f.condition === "true");
const hasFalse = block.toBlocks.find((f) => f.condition === "false");
if (!hasTrue) {
errors.push(i18nT("workflows.validation.errors.addNextBlockForTrueCondition"));
}
if (!hasFalse) {
errors.push(i18nT("workflows.validation.errors.addNextBlockForFalseCondition"));
}
} else if (block.type === "switch") {
const hasDefault = block.toBlocks.find((f) => f.condition === "default");
if (!hasDefault) {
errors.push(i18nT("workflows.validation.errors.addNextBlockForDefaultCondition"));
}
block.conditionGroups.forEach((conditionGroup) => {
const hasCase = block.toBlocks.find((f) => f.condition === `case${conditionGroup.index + 1}`);
if (!hasCase) {
errors.push(i18nT("workflows.validation.errors.addNextBlockForCase", { index: conditionGroup.index + 1 }));
}
});
} else if (block.type === "iterator") {
const hasLoopNext = block.toBlocks.find((f) => f.condition === "loopNext");
const hasLoopEnd = block.toBlocks.find((f) => f.condition === "loopEnd");
if (!hasLoopNext) {
errors.push(i18nT("workflows.validation.errors.addNextBlockForLoopNextCondition"));
}
if (!hasLoopEnd) {
errors.push(i18nT("workflows.validation.errors.addNextBlockForLoopEndCondition"));
}
}
}
const workflowBlock = WorkflowBlockTypes.find((f) => f.value === block.type);
if (!workflowBlock) {
errors.push(i18nT("workflows.validation.errors.invalidWorkflowBlockType", { type: block.type }));
}
if (workflowBlock?.inputs) {
workflowBlock.inputs.forEach((input) => {
const { required } = input as WorkflowBlockInput;
if (required && !block.input[input.name]) {
errors.push(i18nT("workflows.validation.errors.missingRequiredInput", { name: input.name }));
}
});
}
if (workflowBlock?.value === "manual" && block.input.validation) {
const { error } = WorkflowsValidationService.parseJsonSchema(block.input.validation);
if (error) {
errors.push(
i18nT("workflows.validation.errors.invalidManualValidationSchema", {
error,
defaultValue: "Manual trigger validation must be valid JSON Schema: {{error}}",
})
);
}
}
if (workflowBlock?.value === "if") {
const conditionGroups = block.conditionGroups;
if (conditionGroups.length !== 1) {
errors.push(i18nT("workflows.validation.errors.conditionGroupRequired"));
} else {
if (conditionGroups[0].conditions.length === 0) {
errors.push(i18nT("workflows.validation.errors.conditionRequired"));
} else {
const groupErrors = WorkflowConditionUtils.getConditionsErrors(conditionGroups[0].conditions);
if (groupErrors.length > 0) {
errors.push(i18nT("workflows.validation.errors.invalidConditions", { errors: groupErrors.flatMap((f) => f.errors).join(", ") }));
}
}
}
} else if (workflowBlock?.value === "switch") {
const conditionGroups = block.conditionGroups;
if (conditionGroups.length < 2) {
errors.push(i18nT("workflows.validation.errors.twoConditionGroupsRequired"));
} else {
conditionGroups.forEach((group) => {
if (group.conditions.length === 0) {
errors.push(i18nT("workflows.validation.errors.conditionRequired"));
} else {
const groupErrors = WorkflowConditionUtils.getConditionsErrors(group.conditions);
if (groupErrors.length > 0) {
errors.push(i18nT("workflows.validation.errors.invalidConditions", { errors: groupErrors.flatMap((f) => f.errors).join(", ") }));
}
}
});
}
}
return errors;
}
export default {
rowToDto,
getIncomingBlockIds,
getBlockErrors,
};
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import type { WorkflowConditionsDto } from "../dtos/WorkflowConditionDtos";
vi.mock("i18next", () => ({
t: (key: string, values?: Record<string, unknown>) => {
const suffix = values
? Object.entries(values)
.map(([name, value]) => `${name}=${String(value)}`)
.join(",")
: "";
return suffix ? `${key}:${suffix}` : key;
},
}));
const { default: WorkflowConditionUtils } = await import("./WorkflowConditionUtils");
function condition(overrides: Partial<WorkflowConditionsDto> = {}): WorkflowConditionsDto {
return {
index: 0,
variable: "{{$params.plan}}",
operator: "=",
value: "pro",
...overrides,
};
}
describe("WorkflowConditionUtils.getConditionString", () => {
it("uses the workflow validation translation for unknown operators", () => {
const translate = vi.fn((key: string, values?: Record<string, unknown>) => `${key}:${values?.value ?? ""}`);
expect(WorkflowConditionUtils.getConditionString(condition({ operator: "mystery" as WorkflowConditionsDto["operator"] }), translate)).toBe(
"workflows.validation.errors.invalidOperator:mystery"
);
expect(translate).toHaveBeenCalledWith("workflows.validation.errors.invalidOperator", { value: "mystery" });
});
it("falls back to the i18next translator for unknown operators", () => {
expect(WorkflowConditionUtils.getConditionString(condition({ operator: "missing" as WorkflowConditionsDto["operator"] }))).toBe(
"workflows.validation.errors.invalidOperator:value=missing"
);
});
it("keeps legacy one-argument translators working for valid operators", () => {
expect(WorkflowConditionUtils.getConditionString(condition(), (key: string) => `translated:${key}`)).toBe(
"{{$params.plan}} translated:workflows.conditions.operators.equals pro"
);
});
});
@@ -0,0 +1,64 @@
import { t as i18nT } from "i18next";
import { WorkflowConditionOperators, WorkflowConditionsDto } from "../dtos/WorkflowConditionDtos";
type WorkflowConditionTranslator = (key: string, values?: Record<string, unknown>) => string;
function getConditionErrors(condition: WorkflowConditionsDto) {
const errors: string[] = [];
const operator = WorkflowConditionOperators.find((f) => f.value === condition.operator);
if (!operator) {
errors.push(i18nT("workflows.validation.errors.invalidOperator", { value: condition.operator }));
}
if (!condition.variable) {
errors.push(i18nT("workflows.validation.errors.variableRequired"));
}
if (!condition.operator) {
errors.push(i18nT("workflows.validation.errors.operatorRequired"));
}
if (operator?.requiresValue && !condition.value) {
errors.push(i18nT("workflows.validation.errors.valueRequired"));
}
return errors;
}
function getConditionsErrors(conditions: WorkflowConditionsDto[]) {
const errors: {
condition: WorkflowConditionsDto;
errors: string[];
}[] = [];
conditions.forEach((condition) => {
const conditionErrors = getConditionErrors(condition);
if (conditionErrors.length > 0) {
errors.push({
condition,
errors: conditionErrors,
});
}
});
return errors;
}
function getOperator(operatorValue: string) {
const operator = WorkflowConditionOperators.find((f) => f.value === operatorValue);
return operator;
}
function getConditionString(condition: WorkflowConditionsDto, translate?: WorkflowConditionTranslator) {
const operator = getOperator(condition.operator);
if (!operator) {
const t = translate ?? i18nT;
return t("workflows.validation.errors.invalidOperator", { value: condition.operator });
}
const operatorLabel = translate ? translate(operator.translationKey) : operator.name;
if (operator.requiresValue) {
return `${condition.variable} ${operatorLabel} ${condition.value}`;
}
return `${condition.variable} ${operatorLabel}`;
}
export default {
getConditionErrors,
getConditionsErrors,
getOperator,
getConditionString,
};
@@ -0,0 +1,86 @@
import { WorkflowExecutionWithDetails } from "../db/workflowExecutions.db.server";
import { WorkflowBlockExecutionDto } from "../dtos/WorkflowBlockExecutionDto";
import { WorkflowExecutionDto } from "../dtos/WorkflowExecutionDto";
import WorkflowBlockUtils from "./WorkflowBlockUtils";
function rowToDto(item: WorkflowExecutionWithDetails): WorkflowExecutionDto {
const execution: WorkflowExecutionDto = {
id: item.id,
createdAt: item.createdAt,
status: item.status as WorkflowExecutionDto["status"],
input: parseData(item.input),
output: parseData(item.output),
duration: item.duration,
endedAt: item.endedAt,
error: item.error,
blockRuns: item.blockRuns.map((block) => {
const blockRun: WorkflowBlockExecutionDto = {
id: block.id,
workflowBlockId: block.workflowBlockId,
fromWorkflowBlockId: block.fromWorkflowBlockId,
status: block.status as WorkflowBlockExecutionDto["status"],
startedAt: block.startedAt,
endedAt: block.endedAt,
input: parseData(block.input),
output: parseData(block.output),
error: block.error,
workflowBlock: {
type: block.workflowBlock.type as WorkflowBlockExecutionDto["workflowBlock"]["type"],
description: block.workflowBlock.description,
},
};
return blockRun;
}),
executionAlerts: [],
waitingBlock: null,
};
const alertUserBlockRuns = execution.blockRuns.filter((block) => block.workflowBlock.type === "alertUser");
alertUserBlockRuns.forEach((blockRun) => {
if (blockRun.output?.type && blockRun.output?.message) {
execution.executionAlerts.push({
type: blockRun.output?.type,
message: blockRun.output?.message,
});
}
});
if (item.waitingBlock) {
execution.waitingBlock = WorkflowBlockUtils.rowToDto(item.waitingBlock);
}
return execution;
}
// function executionToDto(item: WorkflowBlockExecution, workflowBlock: WorkflowBlockDto): WorkflowBlockExecutionDto {
// const blockRun: WorkflowBlockExecutionDto = {
// id: item.id,
// workflowBlockId: item.workflowBlockId,
// fromWorkflowBlockId: item.fromWorkflowBlockId,
// status: item.status as WorkflowBlockExecutionDto["status"],
// startedAt: item.startedAt,
// endedAt: item.endedAt,
// input: parseData(item.input),
// output: parseData(item.output),
// error: item.error,
// workflowBlock: {
// type: workflowBlock.type as WorkflowBlockExecutionDto["workflowBlock"]["type"],
// description: workflowBlock.description,
// },
// };
// return blockRun;
// }
function parseData(data: string | null): { [key: string]: any } | null {
if (!data) {
return null;
}
try {
return JSON.parse(data);
} catch {
return null;
}
}
export default {
rowToDto,
};
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import WorkflowLayoutUtils from "./WorkflowLayoutUtils";
import type { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
import type { WorkflowDto } from "../dtos/WorkflowDto";
import type { WorkflowExecutionDto } from "../dtos/WorkflowExecutionDto";
function createBlock(id: string, toBlocks: WorkflowBlockDto["toBlocks"] = []): WorkflowBlockDto {
return {
id,
index: 0,
type: "log",
variableName: id,
description: id,
input: {},
isTrigger: id === "trigger",
isBlock: id !== "trigger",
conditionGroups: [],
toBlocks,
};
}
function createWorkflow(blocks: WorkflowBlockDto[]): WorkflowDto {
return {
id: "workflow-1",
tenantId: "tenant-1",
tenant: null,
name: "Workflow",
description: "",
status: "draft",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: null,
appliesToAllTenants: false,
blocks,
inputExamples: [],
_count: { executions: 0 },
};
}
describe("WorkflowLayoutUtils", () => {
it("lays out nodes without mutating the input node objects", () => {
const nodes = [
{
id: "trigger",
type: "blockEditorNode",
data: { label: "Trigger" },
position: { x: 10, y: 20 },
draggable: false,
},
{
id: "next",
type: "blockEditorNode",
data: { label: "Next" },
position: { x: 30, y: 40 },
draggable: false,
},
];
const edges = [{ id: "edge-1", source: "trigger", target: "next" }];
const originalNodes = structuredClone(nodes);
const result = WorkflowLayoutUtils.getLayoutedElements({ nodes, edges, direction: "TB", readOnly: true });
expect(nodes).toEqual(originalNodes);
expect(result.nodes).not.toBe(nodes);
expect(result.nodes[0]).not.toBe(nodes[0]);
expect(result.nodes[0]?.position).not.toEqual(originalNodes[0]?.position);
expect(result.nodes[0]?.draggable).toBe(false);
expect(result.edges).not.toBe(edges);
expect(result.edges).toEqual(edges);
});
it("sorts rendered edges without mutating workflow block connections", () => {
const originalConnections = [
{ id: "edge-later", toBlockId: "block-later", condition: "z" },
{ id: "edge-earlier", toBlockId: "block-earlier", condition: "a" },
];
const trigger = createBlock("trigger", originalConnections);
const blocks = [trigger, createBlock("block-earlier"), createBlock("block-later")];
const { edges } = WorkflowLayoutUtils.workflowToReactFlow({
workflow: createWorkflow(blocks),
workflowBlocks: blocks,
workflowExecution: null,
});
expect(edges.map((edge) => edge.id)).toEqual(["edge-earlier", "edge-later"]);
expect(trigger.toBlocks.map((connection) => connection.id)).toEqual(["edge-later", "edge-earlier"]);
});
it("marks executed nodes and edges from indexed block runs", () => {
const trigger = createBlock("trigger", [{ id: "edge-executed", toBlockId: "block-next", condition: null }]);
const nextBlock = createBlock("block-next");
const blocks = [trigger, nextBlock];
const blockRun = {
id: "run-next",
workflowBlockId: "block-next",
fromWorkflowBlockId: "trigger",
status: "success",
startedAt: new Date("2026-01-01T00:00:00.000Z"),
endedAt: null,
input: null,
output: null,
error: null,
workflowBlock: { type: "log", description: "Log" },
} satisfies WorkflowExecutionDto["blockRuns"][number];
const workflowExecution: WorkflowExecutionDto = {
id: "execution-1",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
status: "success",
input: null,
output: null,
duration: null,
endedAt: null,
error: null,
blockRuns: [blockRun],
executionAlerts: [],
waitingBlock: null,
};
const { nodes, edges } = WorkflowLayoutUtils.workflowToReactFlow({
workflow: createWorkflow(blocks),
workflowBlocks: blocks,
workflowExecution,
});
expect(nodes.find((node) => node.id === "block-next")?.data.workflowBlockExecution).toBe(blockRun);
expect(edges).toHaveLength(1);
expect(edges[0]?.data?.isExecuted).toBe(true);
expect(edges[0]?.style?.stroke).toBe("#14b8a6");
});
});
@@ -0,0 +1,136 @@
import { type Node, type Edge, Position, MarkerType } from "@xyflow/react";
import { t as i18nT } from "i18next";
import dagre from "dagre";
import { WorkflowDto } from "../dtos/WorkflowDto";
import { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
import WorkflowUtils from "./WorkflowUtils";
import { WorkflowExecutionDto } from "../dtos/WorkflowExecutionDto";
import { WorkflowBlockExecutionDto } from "../dtos/WorkflowBlockExecutionDto";
const nodeWidth = 250;
const nodeHeight = 80;
const getLayoutedElements = ({
nodes,
edges,
direction = "LR",
readOnly = false,
}: {
nodes: Node[];
edges: Edge[];
direction?: "LR" | "TB" | "RL" | "BT";
readOnly?: boolean;
}) => {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
const isHorizontal = direction === "LR";
dagreGraph.setGraph({ rankdir: direction });
nodes.forEach((node) => {
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
});
edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
// We are shifting the dagre node position (anchor=center center) to the top left
// so it matches the React Flow node anchor point (top left).
return {
...node,
targetPosition: isHorizontal ? Position.Left : Position.Top,
sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
position: {
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - nodeHeight / 2,
},
draggable: !readOnly,
};
});
return { nodes: layoutedNodes, edges: [...edges] };
};
const workflowToReactFlow = ({
workflow,
workflowBlocks,
workflowExecution,
}: {
workflow: WorkflowDto;
workflowBlocks: WorkflowBlockDto[];
workflowExecution: WorkflowExecutionDto | null;
}) => {
const blockRunsByWorkflowBlockId = new Map((workflowExecution?.blockRuns ?? []).map((blockRun) => [blockRun.workflowBlockId, blockRun]));
const executedEdgeKeys = new Set(
(workflowExecution?.blockRuns ?? [])
.filter((blockRun) => blockRun.fromWorkflowBlockId)
.map((blockRun) => `${blockRun.fromWorkflowBlockId}:${blockRun.workflowBlockId}`)
);
let nodes: Node[] = workflowBlocks.map((block) => {
const workflowBlockExecution: WorkflowBlockExecutionDto | null = blockRunsByWorkflowBlockId.get(block.id) ?? null;
return {
id: block.id,
type: workflowExecution ? "blockExecutionNode" : "blockEditorNode",
data: { workflow, block, workflowExecution, workflowBlockExecution },
position: { x: 0, y: 0 },
draggable: false,
};
});
let edges = workflowBlocks.flatMap((block) =>
[...block.toBlocks]
.sort((a, b) => a.condition?.localeCompare(b.condition || "") || 0)
.map((toBlock) => {
const fromBlockId = block.id;
const toBlockId = toBlock.toBlockId;
const isExecuted = executedEdgeKeys.has(`${fromBlockId}:${toBlockId}`);
const edge: Edge = {
id: toBlock.id,
source: block.id,
target: toBlock.toBlockId,
type: "customEdge",
animated: true,
deletable: false,
markerEnd: {
type: MarkerType.ArrowClosed,
width: 10,
height: 10,
},
style: {
strokeWidth: 1.5,
stroke: isExecuted ? "#14b8a6" : "#9ca3af",
},
data: {
condition: toBlock.condition,
isExecuted,
},
};
return edge;
})
);
const hasTrigger = WorkflowUtils.hasTriggerNode(workflow);
if (!hasTrigger) {
nodes = [
{
type: "setTriggerNode",
id: "1",
data: { label: i18nT("workflows.builder.setTriggerPrompt") },
position: { x: 0, y: 0 },
},
];
}
return {
nodes,
edges,
};
};
export default {
getLayoutedElements,
workflowToReactFlow,
};
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
import { extractWorkflowReferences, getMissingWorkflowReferences } from "./WorkflowReferenceUtils";
function block(overrides: Partial<WorkflowBlockDto>): WorkflowBlockDto {
return {
id: "block_1",
index: 0,
type: "trigger",
variableName: "trigger",
description: "",
input: {},
isTrigger: false,
isBlock: true,
conditionGroups: [],
toBlocks: [],
...overrides,
};
}
describe("WorkflowReferenceUtils", () => {
it("extracts variable and credential references from nested block inputs", () => {
const references = extractWorkflowReferences({
blocks: [
block({
id: "http",
input: {
url: "{{$vars.apiBaseUrl}}/users",
headers: [{ key: "Authorization", value: "Bearer {{ $credentials.apiToken }}" }],
body: { tenant: "{{$vars.tenantId}}" },
},
}),
],
});
expect(references.variables).toEqual(["apiBaseUrl", "tenantId"]);
expect(references.credentials).toEqual(["apiToken"]);
expect(references.byBlock[0]).toMatchObject({
blockId: "http",
variables: ["apiBaseUrl", "tenantId"],
credentials: ["apiToken"],
});
});
it("extracts references from condition variables and values", () => {
const references = extractWorkflowReferences({
blocks: [
block({
id: "condition",
type: "if",
conditionGroups: [
{
index: 0,
type: "AND",
conditions: [{ index: 0, variable: "{{$vars.plan}}", operator: "equals", value: "{{$vars.expectedPlan}}" }],
},
],
}),
],
});
expect(references.variables).toEqual(["expectedPlan", "plan"]);
});
it("deduplicates references and ignores params session and block outputs", () => {
const references = extractWorkflowReferences({
blocks: [
block({
id: "message",
input: {
message: "{{ $vars.subject }} {{$vars.subject}} {{$params.email}} {{$session.user.email}} {{httpRequest.body}}",
},
}),
],
});
expect(references.variables).toEqual(["subject"]);
expect(references.credentials).toEqual([]);
});
it("extracts references from supported Handlebars lookup and with expressions", () => {
const references = extractWorkflowReferences({
blocks: [
block({
id: "message",
input: {
message: 'Bearer {{lookup $credentials "apiToken"}} for {{lookup $vars \'planTier\'}}',
audit: "{{#with $credentials}}{{webhookSecret}}{{/with}} {{#with $vars}}{{region}}{{/with}}",
},
}),
],
});
expect(references.variables).toEqual(["planTier", "region"]);
expect(references.credentials).toEqual(["apiToken", "webhookSecret"]);
});
it("treats variable blocks as local variable definitions", () => {
const references = extractWorkflowReferences({
blocks: [
block({ id: "define", type: "variable", input: { variableName: "temporaryToken", value: "abc" } }),
block({ id: "use", input: { value: "{{$vars.temporaryToken}} {{$credentials.mailer}}" } }),
],
});
expect(references.variables).toEqual([]);
expect(references.credentials).toEqual(["mailer"]);
});
it("reports missing references against available names", () => {
expect(
getMissingWorkflowReferences({
workflow: { blocks: [block({ input: { value: "{{$vars.present}} {{$vars.missing}} {{$credentials.missingToken}}" } })] },
variables: ["present"],
credentials: [],
})
).toEqual({
missingVariables: ["missing"],
missingCredentials: ["missingToken"],
});
});
});
@@ -0,0 +1,11 @@
export {
extractWorkflowReferences,
getMissingWorkflowReferences,
preflightWorkflow,
validateWorkflowRequiredInputs,
type WorkflowPreflightBlock,
type WorkflowPreflightInput,
type WorkflowPreflightResult,
type WorkflowReferenceSummary,
type WorkflowRequiredInputIssue,
} from "@toolctrl/foundation";
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import type { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
import type { WorkflowDto } from "../dtos/WorkflowDto";
import WorkflowUtils from "./WorkflowUtils";
function block(overrides: Partial<WorkflowBlockDto> = {}): WorkflowBlockDto {
return {
id: "block_1",
index: 0,
type: "log",
variableName: "log",
description: "",
input: {},
isTrigger: false,
isBlock: true,
conditionGroups: [],
toBlocks: [],
...overrides,
};
}
function workflow(overrides: Partial<WorkflowDto> = {}): WorkflowDto {
return {
id: "workflow_1",
tenantId: "tenant_1",
tenant: null,
name: "Workflow",
description: "",
status: "live",
createdAt: new Date("2026-05-08T10:00:00.000Z"),
updatedAt: new Date("2026-05-08T10:00:00.000Z"),
appliesToAllTenants: false,
blocks: [block()],
inputExamples: [],
_count: { executions: 0 },
...overrides,
};
}
describe("WorkflowUtils.canRun", () => {
it("rejects non-live workflows even when references are satisfied", () => {
expect(
WorkflowUtils.canRun(
workflow({
status: "draft",
$variables: ["recipient"],
$credentials: ["mailer"],
blocks: [block({ input: { message: "{{$vars.recipient}} {{$credentials.mailer}}" } })],
})
)
).toBe(false);
});
it("rejects live workflows with missing variable or credential references", () => {
expect(
WorkflowUtils.canRun(
workflow({
blocks: [block({ input: { message: "{{$vars.recipient}} {{$credentials.mailer}}" } })],
$variables: ["recipient"],
$credentials: [],
})
)
).toBe(false);
});
it("rejects live workflows with missing required block inputs", () => {
expect(
WorkflowUtils.canRun(
workflow({
blocks: [
block({
input: { message: "{{$vars.recipient}}" },
type: "email",
}),
],
$variables: ["recipient"],
$credentials: ["mailer"],
})
)
).toBe(false);
});
it("allows live workflows when referenced variables and credentials are available", () => {
expect(
WorkflowUtils.canRun(
workflow({
blocks: [block({ input: { message: "{{$vars.recipient}} {{$credentials.mailer}}" } })],
$variables: ["recipient"],
$credentials: ["mailer"],
})
)
).toBe(true);
});
});
@@ -0,0 +1,192 @@
import { Colors } from "~/application/enums/shared/Colors";
import { t as i18nT } from "i18next";
import { WorkflowDto } from "../dtos/WorkflowDto";
import { WorkflowBlockDto } from "../dtos/WorkflowBlockDto";
import WorkflowBlockUtils from "./WorkflowBlockUtils";
import { preflightWorkflow } from "./WorkflowReferenceUtils";
import { getWorkflowBlockName, getWorkflowBlockOutputLabel, WorkflowBlockTypes } from "../dtos/WorkflowBlockTypes";
function canRun(workflow: WorkflowDto) {
const preflight = preflightWorkflow({
workflow,
variables: workflow.$variables ?? [],
credentials: workflow.$credentials ?? [],
});
return (
workflow.status === "live" &&
preflight.missingVariables.length === 0 &&
preflight.missingCredentials.length === 0 &&
preflight.missingRequiredInputs.length === 0
);
}
function canRunManually(workflow: WorkflowDto) {
return workflow.blocks.find((f) => f.type === "manual");
}
function getStatusColor(workflow: WorkflowDto): Colors {
switch (workflow.status) {
case "draft":
return Colors.SLATE;
case "live":
return Colors.GREEN;
case "archived":
return Colors.GRAY;
}
}
function hasTriggerNode(workflow: WorkflowDto) {
return workflow.blocks.find((f) => f.isTrigger);
}
function getBlocksErrors(workflow: WorkflowDto): {
block: WorkflowBlockDto;
errors: string[];
}[] {
const blockErrors: { block: WorkflowBlockDto; errors: string[] }[] = [];
const validationContext = { incomingBlockIds: WorkflowBlockUtils.getIncomingBlockIds(workflow) };
workflow.blocks.forEach((block) => {
const errors = WorkflowBlockUtils.getBlockErrors({ workflow, block, validationContext });
if (errors.length > 0) {
blockErrors.push({ block, errors });
}
});
return blockErrors;
}
function getBlocksWithoutCallers(workflow: WorkflowDto): WorkflowBlockDto[] {
const blocksWithoutCallers: WorkflowBlockDto[] = [];
const incomingBlockIds = WorkflowBlockUtils.getIncomingBlockIds(workflow);
workflow.blocks.forEach((block) => {
if (block.isBlock) {
if (!incomingBlockIds.has(block.id)) {
blocksWithoutCallers.push(block);
}
}
});
return blocksWithoutCallers;
}
function isReady(workflow: WorkflowDto) {
const errors = getBlocksErrors(workflow);
const blocksWithoutCallers = getBlocksWithoutCallers(workflow);
const hasTrigger = hasTriggerNode(workflow);
return errors.length === 0 && blocksWithoutCallers.length === 0 && hasTrigger;
}
function getVariables({ workflow, currentBlock, onlyInBlock }: { workflow: WorkflowDto; currentBlock: WorkflowBlockDto; onlyInBlock?: boolean }) {
const triggerNode = workflow.blocks.find((f) => f.isTrigger);
if (!triggerNode) return [];
// const paths = buildPaths(workflow, triggerNode.id, [], {});
// const currentPath = paths[currentBlock.id] || [];
const variables: { group: string; name: string; label: string }[] = [];
if (!onlyInBlock) {
variables.push(
{ group: i18nT("workflows.variables.groups.params"), name: "{{$params.}}", label: i18nT("workflows.variables.suggestions.paramsValue") },
{ group: i18nT("workflows.variables.groups.session"), name: "{{$session.tenant.id}}", label: i18nT("workflows.variables.suggestions.sessionTenantId") },
{ group: i18nT("workflows.variables.groups.session"), name: "{{$session.user.email}}", label: i18nT("workflows.variables.suggestions.sessionUserEmail") }
);
}
workflow.blocks.forEach((block, idxBlock) => {
// if (!currentPath.includes(block.id)) return;
if (onlyInBlock && block.id !== currentBlock.id) return;
const workflowBlock = WorkflowBlockTypes.find((f) => f.value === block.type);
if (!workflowBlock) return;
workflowBlock.outputs.forEach((output) => {
const variableName = `{{${block.variableName}.${output.name}}}`;
let variableLabel = getWorkflowBlockOutputLabel(output, i18nT);
if (onlyInBlock) {
variableLabel = getWorkflowBlockOutputLabel(output, i18nT);
}
variables.push({
group: `${getWorkflowBlockName(workflowBlock, i18nT)} ${idxBlock + 1}`,
name: variableName,
label: variableLabel,
});
});
});
return variables;
}
function getVariableName({ workflow, currentBlock }: { workflow: WorkflowDto; currentBlock: { id: string; type: string } }) {
// Function to recursively build a list of block IDs in execution order
function buildExecutionOrder(blockId: string, order: string[] = [], visited = new Set()) {
if (visited.has(blockId)) return order;
visited.add(blockId);
const block = workflow.blocks.find((b) => b.id === blockId);
if (!block) return order;
order.push(blockId);
const orders = ["true", "false", "default", "case", "loopNext", "loopEnd"];
block.toBlocks
.sort((a, b) => {
if (!a.condition) {
return -1;
}
if (!b.condition) {
return 1;
}
const aOrder = orders.findIndex((f) => a.condition?.startsWith(f));
const bOrder = orders.findIndex((f) => b.condition?.startsWith(f));
return aOrder - bOrder;
})
.forEach((toBlock) => {
buildExecutionOrder(toBlock.toBlockId, order, visited);
});
return order;
}
// Get execution order starting from the first block (assuming it's a trigger)
const executionOrder = buildExecutionOrder(workflow.blocks.find((b) => b.isTrigger)?.id || "");
// Count occurrences of block types in execution order
const blockTypeCounts: { [key: string]: number } = {};
executionOrder.forEach((blockId) => {
const block = workflow.blocks.find((b) => b.id === blockId);
if (block) {
blockTypeCounts[block.type] = (blockTypeCounts[block.type] || 0) + 1;
}
});
// Determine the index of the current block in its type group
const currentBlockIndex = executionOrder.filter((id) => workflow.blocks.find((b) => b.id === id)?.type === currentBlock.type).indexOf(currentBlock.id) + 1;
// Append index if there are multiple blocks of the same type
return {
variableName: blockTypeCounts[currentBlock.type] > 1 ? `${currentBlock.type}${currentBlockIndex}` : currentBlock.type,
index: currentBlockIndex,
};
}
function getBlockTypeName(workflowBlock: { type: string }) {
const workflowBlockType = WorkflowBlockTypes.find((f) => f.value === workflowBlock.type);
if (!workflowBlockType) return "";
return getWorkflowBlockName(workflowBlockType, i18nT);
}
export default {
canRun,
canRunManually,
getStatusColor,
hasTriggerNode,
getBlocksErrors,
getBlocksWithoutCallers,
isReady,
getVariables,
getVariableName,
getBlockTypeName,
};