fix: 统一工作流三个出口的错误反馈

- 关联 EASY-2,补齐安全错误、节点展示与执行标识

- 保留历史可读摘要并验证接口、SSE 与界面兼容
This commit is contained in:
2026-09-08 10:49:02 +08:00
parent 9722bea701
commit c7c301d3b9
35 changed files with 1171 additions and 358 deletions

View File

@@ -1,5 +1,6 @@
package tech.easyflow.publicapi.controller;
import tech.easyflow.ai.easyagentsflow.service.WorkflowExecutionErrorMapper;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.stp.StpUtil;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
@@ -125,6 +126,7 @@ public class PublicWorkflowController {
if (StpUtil.isLogin()) {
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
}
WorkflowExecutionErrorMapper.installRequestProfile();
Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables);
return Result.ok(res);
}

View File

@@ -22,5 +22,10 @@ public record PublicWorkflowNodeStatus(
PublicWorkflowExecutionStatus status,
String message,
Map<String, Object> result,
List<Parameter> suspendForParameters) implements Serializable {
List<Parameter> suspendForParameters,
PublicWorkflowStatusError error) implements Serializable {
public PublicWorkflowNodeStatus(String nodeId, String nodeName, PublicWorkflowExecutionStatus status,
String message, Map<String, Object> result, List<Parameter> suspendForParameters) {
this(nodeId, nodeName, status, message, result, suspendForParameters, null);
}
}

View File

@@ -10,6 +10,7 @@ public class PublicWorkflowStatusError implements Serializable {
private static final long serialVersionUID = 1L;
private final String code;
private final String reasonCode;
private final String message;
private final String nodeId;
private final String nodeName;
@@ -30,7 +31,13 @@ public class PublicWorkflowStatusError implements Serializable {
String nodeId,
String nodeName,
boolean retryable) {
this(code, null, message, nodeId, nodeName, retryable);
}
public PublicWorkflowStatusError(String code, String reasonCode, String message,
String nodeId, String nodeName, boolean retryable) {
this.code = code;
this.reasonCode = reasonCode;
this.message = message;
this.nodeId = nodeId;
this.nodeName = nodeName;
@@ -46,6 +53,8 @@ public class PublicWorkflowStatusError implements Serializable {
return code;
}
public String getReasonCode() { return reasonCode; }
/**
* 获取安全消息。
*

View File

@@ -1,9 +1,10 @@
package tech.easyflow.publicapi.service;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowExecutionError;
import tech.easyflow.ai.easyagentsflow.service.WorkflowExecutionErrorMapper;
import tech.easyflow.publicapi.dto.PublicWorkflowChainStatus;
import tech.easyflow.publicapi.dto.PublicWorkflowExecutionStatus;
import tech.easyflow.publicapi.dto.PublicWorkflowNodeStatus;
@@ -12,130 +13,44 @@ import tech.easyflow.publicapi.dto.PublicWorkflowStatusError;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 将内部工作流执行错误转换为 Public API 安全状态。
*/
/** 复用公共错误规范,兼容没有结构化错误的旧状态。 */
@Service
public class PublicWorkflowStatusSanitizer {
private static final String CHAIN_FAILED_MESSAGE =
"工作流执行失败,请检查输入或稍后重试";
private static final String NODE_FAILED_MESSAGE =
"节点执行失败,请检查输入或稍后重试";
/**
* 复制执行状态并移除异常类名、底层地址和内部错误详情。
*
* @param source 内部执行状态
* @return 可公开状态
*/
public PublicWorkflowChainStatus sanitize(ChainInfo source) {
if (source == null) {
throw new IllegalArgumentException(
"source must not be null");
}
PublicWorkflowExecutionStatus chainStatus =
PublicWorkflowExecutionStatus.fromChainStatus(
source.getStatus());
Map<String, PublicWorkflowNodeStatus> safeNodes =
new LinkedHashMap<>();
if (source == null) throw new IllegalArgumentException("source must not be null");
PublicWorkflowExecutionStatus status = PublicWorkflowExecutionStatus.fromChainStatus(source.getStatus());
Map<String, PublicWorkflowNodeStatus> nodes = new LinkedHashMap<>();
PublicWorkflowStatusError firstNodeError = null;
if (source.getNodes() != null) {
for (Map.Entry<String, NodeInfo> entry
: source.getNodes().entrySet()) {
PublicWorkflowNodeStatus safeNode = copyNode(
entry.getValue());
safeNodes.put(entry.getKey(), safeNode);
if (firstNodeError == null
&& StringUtils.hasText(safeNode.message())) {
firstNodeError = new PublicWorkflowStatusError(
"NODE_EXECUTION_FAILED",
safeNode.message(),
safeNode.nodeId(),
safeNode.nodeName(),
isRetryable(safeNode.status()));
}
for (var entry : source.getNodes().entrySet()) {
NodeInfo node = entry.getValue();
if (node == null) continue;
PublicWorkflowExecutionStatus nodeStatus = PublicWorkflowExecutionStatus.fromNodeStatus(node.getStatus());
PublicWorkflowStatusError error = copyError(node.getError(), false, nodeStatus, node.getNodeId(), node.getNodeName(), status.isTerminal());
nodes.put(entry.getKey(), new PublicWorkflowNodeStatus(node.getNodeId(), node.getNodeName(), nodeStatus,
error == null ? null : error.getMessage(), node.getResult(), node.getSuspendForParameters(), error));
if (firstNodeError == null && error != null) firstNodeError = error;
}
}
String message = null;
PublicWorkflowStatusError error = null;
if (StringUtils.hasText(source.getMessage())) {
message = chainMessage(chainStatus);
error = new PublicWorkflowStatusError(
"WORKFLOW_EXECUTION_FAILED",
message,
firstNodeError == null
? null
: firstNodeError.getNodeId(),
firstNodeError == null
? null
: firstNodeError.getNodeName(),
isRetryable(chainStatus));
} else if (firstNodeError != null) {
error = firstNodeError;
}
return new PublicWorkflowChainStatus(
source.getExecuteId(),
chainStatus,
chainStatus.isTerminal(),
message,
source.getResult(),
safeNodes,
error);
PublicWorkflowStatusError error = copyError(source.getError(), true, status,
firstNodeError == null ? null : firstNodeError.getNodeId(),
firstNodeError == null ? null : firstNodeError.getNodeName(), status.isTerminal());
// 暂态节点错误仍可查询;成功、取消和挂起不携带旧错误。
if (error == null && status == PublicWorkflowExecutionStatus.RUNNING) error = firstNodeError;
return new PublicWorkflowChainStatus(source.getExecuteId(), status, status.isTerminal(),
error == null ? null : error.getMessage(), source.getResult(), nodes, error);
}
/**
* 复制并脱敏单个节点状态。
*
* @param source 内部节点状态
* @return 安全节点状态
*/
private PublicWorkflowNodeStatus copyNode(NodeInfo source) {
if (source == null) {
return new PublicWorkflowNodeStatus(
null,
null,
PublicWorkflowExecutionStatus.UNKNOWN,
null,
null,
null);
private PublicWorkflowStatusError copyError(WorkflowExecutionError source, boolean workflow,
PublicWorkflowExecutionStatus status, String nodeId, String nodeName, boolean executionTerminal) {
if (status != PublicWorkflowExecutionStatus.FAILED && status != PublicWorkflowExecutionStatus.ERROR) return null;
if (source != null) {
nodeId = source.getNodeId();
nodeName = source.getNodeName();
}
return new PublicWorkflowNodeStatus(
source.getNodeId(),
source.getNodeName(),
PublicWorkflowExecutionStatus.fromNodeStatus(
source.getStatus()),
StringUtils.hasText(source.getMessage())
? NODE_FAILED_MESSAGE
: null,
source.getResult(),
source.getSuspendForParameters());
}
/**
* 根据工作流状态生成安全消息。
*
* @param status 可读状态
* @return 安全消息
*/
private String chainMessage(
PublicWorkflowExecutionStatus status) {
if (status == PublicWorkflowExecutionStatus.CANCELLED) {
return "工作流执行已取消";
}
return CHAIN_FAILED_MESSAGE;
}
/**
* 判断执行状态是否仍可能由运行时继续处理。
*
* @param status 可读状态
* @return 是否可重试
*/
private boolean isRetryable(
PublicWorkflowExecutionStatus status) {
return status == PublicWorkflowExecutionStatus.ERROR;
WorkflowExecutionError safe = WorkflowExecutionErrorMapper.fromReason(source == null ? null : source.getReasonCode(), workflow, nodeId, nodeName,
status == PublicWorkflowExecutionStatus.ERROR && !executionTerminal);
return new PublicWorkflowStatusError(safe.getCode(), safe.getReasonCode(), safe.getMessage(),
safe.getNodeId(), safe.getNodeName(), safe.isRetryable());
}
}

View File

@@ -1,6 +1,8 @@
package tech.easyflow.publicapi.service;
import com.easyagents.flow.core.chain.ChainStatus;
import com.easyagents.flow.core.chain.WorkflowErrorReason;
import tech.easyflow.ai.easyagentsflow.entity.WorkflowExecutionError;
import com.easyagents.flow.core.chain.NodeStatus;
import org.junit.Assert;
import org.junit.Test;
@@ -39,11 +41,11 @@ public class PublicWorkflowStatusSanitizerTest {
PublicWorkflowChainStatus result = sanitizer.sanitize(source);
Assert.assertEquals(
"工作流执行失败,请检查输入或稍后重试",
WorkflowErrorReason.NODE_EXECUTION_FAILED.getDefaultMessage(),
result.message());
Assert.assertFalse(result.message().contains("minio"));
Assert.assertEquals(
"节点执行失败,请检查输入或稍后重试",
WorkflowErrorReason.NODE_EXECUTION_FAILED.getDefaultMessage(),
result.nodes().get("node-1").message());
Assert.assertEquals("node-1", result.error().getNodeId());
Assert.assertFalse(result.error().isRetryable());
@@ -101,4 +103,46 @@ public class PublicWorkflowStatusSanitizerTest {
PublicWorkflowExecutionStatus.RUNNING,
result.nodes().get("node-1").status());
}
@Test
public void shouldReturnStructuredReasonWithoutRequestedNodes() {
for (WorkflowErrorReason reason : WorkflowErrorReason.values()) {
ChainInfo source = new ChainInfo();
source.setStatus(ChainStatus.FAILED.getValue());
source.setError(new WorkflowExecutionError("WORKFLOW_EXECUTION_FAILED", reason.getCode(),
"raw provider body must not escape", "llm", "分析", false));
var result = sanitizer.sanitize(source);
Assert.assertEquals(reason.getCode(), result.error().getReasonCode());
Assert.assertEquals(reason.getDefaultMessage(), result.message());
Assert.assertEquals("llm", result.error().getNodeId());
Assert.assertTrue(result.nodes().isEmpty());
}
}
@Test
public void successfulRetryMustNotExposeStaleErrors() {
ChainInfo source = new ChainInfo();
source.setStatus(ChainStatus.SUCCEEDED.getValue());
source.setMessage("stale error");
NodeInfo node = new NodeInfo();
node.setNodeId("llm"); node.setStatus(NodeStatus.SUCCEEDED.getValue()); node.setMessage("old failure");
source.setNodes(Map.of("llm", node));
Assert.assertNull(sanitizer.sanitize(source).error());
Assert.assertNull(sanitizer.sanitize(source).nodes().get("llm").message());
}
@Test
public void terminalWorkflowMustNotAdvertiseNodeRetry() {
for (ChainStatus status : new ChainStatus[]{ChainStatus.RUNNING, ChainStatus.FAILED, ChainStatus.CANCELLED}) {
ChainInfo source = new ChainInfo();
source.setStatus(status.getValue());
NodeInfo node = new NodeInfo();
node.setNodeId("llm");
node.setStatus(NodeStatus.ERROR.getValue());
node.setError(new WorkflowExecutionError("NODE_EXECUTION_FAILED", "MODEL_TIMEOUT",
"raw error", "llm", "模型分析", true));
source.setNodes(Map.of("llm", node));
var result = sanitizer.sanitize(source);
Assert.assertEquals(status == ChainStatus.RUNNING, result.nodes().get("llm").error().isRetryable());
}
}
}