fix: 统一工作流三个出口的错误反馈
- 关联 EASY-2,补齐安全错误、节点展示与执行标识 - 保留历史可读摘要并验证接口、SSE 与界面兼容
This commit is contained in:
@@ -224,6 +224,7 @@ public class WorkflowChatController {
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("record", recordView);
|
||||
detail.put("steps", stepViews);
|
||||
detail.put("runtime", eventStream.runtimeView(executeId));
|
||||
return Result.ok(detail);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowExecutionErrorMapper;
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.hutool.core.io.IoUtil;
|
||||
@@ -261,6 +262,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@ import com.alibaba.fastjson.JSON;
|
||||
import com.easyagents.flow.core.chain.Chain;
|
||||
import com.easyagents.flow.core.chain.ChainConsts;
|
||||
import com.easyagents.flow.core.chain.ChainStatus;
|
||||
import com.easyagents.flow.core.chain.ChainState;
|
||||
import com.easyagents.flow.core.chain.Edge;
|
||||
import com.easyagents.flow.core.chain.Event;
|
||||
import com.easyagents.flow.core.chain.Node;
|
||||
import com.easyagents.flow.core.chain.NodeStatus;
|
||||
import com.easyagents.flow.core.chain.ExceptionSummary;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowExecutionError;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowExecutionErrorMapper;
|
||||
import com.easyagents.flow.core.chain.event.ChainStatusChangeEvent;
|
||||
import com.easyagents.flow.core.chain.event.EdgeConditionCheckFailedEvent;
|
||||
import com.easyagents.flow.core.chain.event.EdgeTriggerEvent;
|
||||
@@ -39,6 +44,41 @@ import java.util.concurrent.atomic.AtomicLong;
|
||||
@Service
|
||||
public class WorkflowChatEventStream {
|
||||
|
||||
public Map<String, Object> runtimeView(String executeId) {
|
||||
try {
|
||||
ChainState state = chainExecutor.getChainStateRepository()
|
||||
.load(executeId);
|
||||
if (state == null || state.getStatus() == null) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("status", state.getStatus().name());
|
||||
view.put("statusValue", state.getStatus().getValue());
|
||||
WorkflowExecutionError error = WorkflowExecutionErrorMapper.chain(state.getError(), state.getStatus());
|
||||
view.put("error", error);
|
||||
view.put("message", state.getStatus() == ChainStatus.SUSPEND ? state.getMessage()
|
||||
: WorkflowExecutionErrorMapper.summary(error));
|
||||
if (state.getStatus() == ChainStatus.SUSPEND) {
|
||||
view.put("parameters", state.getSuspendForParameters());
|
||||
}
|
||||
if (state.getStatus() == ChainStatus.SUCCEEDED) {
|
||||
view.put(
|
||||
"output",
|
||||
WorkflowChatEventStream.visibleFinalOutput(
|
||||
state.getExecuteResult())
|
||||
);
|
||||
}
|
||||
return view;
|
||||
} catch (RuntimeException error) {
|
||||
log.warn(
|
||||
"failed to load public workflow runtime state, executeId={}",
|
||||
executeId,
|
||||
error
|
||||
);
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static final Logger log =
|
||||
LoggerFactory.getLogger(WorkflowChatEventStream.class);
|
||||
private static final long SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L;
|
||||
@@ -211,9 +251,9 @@ public class WorkflowChatEventStream {
|
||||
StreamSession session = findSession(chain);
|
||||
if (session != null
|
||||
&& Objects.equals(chain.getStateInstanceId(), session.executeId)) {
|
||||
session.send("execution_error", Map.of(
|
||||
"message", safeErrorMessage(error)
|
||||
));
|
||||
WorkflowExecutionError detail = WorkflowExecutionErrorMapper.map(
|
||||
chain.getState().getError(), true, null, null, false);
|
||||
session.send("execution_error", Map.of("message", detail.getMessage(), "error", detail));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,20 +309,6 @@ public class WorkflowChatEventStream {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取适合返回给用户的异常信息。
|
||||
*
|
||||
* @param error 异常
|
||||
* @return 非空异常信息
|
||||
*/
|
||||
private String safeErrorMessage(Throwable error) {
|
||||
if (error == null || error.getMessage() == null
|
||||
|| error.getMessage().isBlank()) {
|
||||
return "工作流执行失败";
|
||||
}
|
||||
return error.getMessage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 去掉顶级工作流结果中的内部状态控制字段。
|
||||
*
|
||||
@@ -431,8 +457,13 @@ public class WorkflowChatEventStream {
|
||||
data.put("output", event.getResult() == null
|
||||
? Map.of()
|
||||
: event.getResult());
|
||||
if (event.getError() != null) {
|
||||
data.put("error", safeErrorMessage(event.getError()));
|
||||
if (event.getErrorSummary() != null) {
|
||||
WorkflowExecutionError detail = WorkflowExecutionErrorMapper.node(event.getErrorSummary(),
|
||||
event.getStatus() == null ? NodeStatus.FAILED : event.getStatus(), node.getId(), node.getName());
|
||||
if (detail != null) {
|
||||
data.put("error", detail.getMessage());
|
||||
data.put("errorDetail", detail);
|
||||
}
|
||||
}
|
||||
send("node_finished", nodePayload(node, data));
|
||||
}
|
||||
@@ -561,6 +592,11 @@ public class WorkflowChatEventStream {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("status", status.name());
|
||||
data.put("message", chain.getState().getMessage());
|
||||
WorkflowExecutionError detail = WorkflowExecutionErrorMapper.chain(chain.getState().getError(), status);
|
||||
if (detail != null) {
|
||||
data.put("error", detail);
|
||||
data.put("message", WorkflowExecutionErrorMapper.summary(detail));
|
||||
}
|
||||
if (status == ChainStatus.SUCCEEDED) {
|
||||
data.put(
|
||||
"output",
|
||||
@@ -614,8 +650,12 @@ public class WorkflowChatEventStream {
|
||||
*/
|
||||
private void fail(Throwable error) {
|
||||
if (terminal.compareAndSet(false, true)) {
|
||||
WorkflowExecutionError detail = WorkflowExecutionErrorMapper.map(
|
||||
error == null ? null : new ExceptionSummary(error), true, null, null, false);
|
||||
send("execution_failed", Map.of(
|
||||
"message", safeErrorMessage(error)
|
||||
"status", ChainStatus.FAILED.name(),
|
||||
"message", detail.getMessage(),
|
||||
"error", detail
|
||||
));
|
||||
removeSession(this);
|
||||
if (connected.compareAndSet(true, false)) {
|
||||
|
||||
@@ -161,7 +161,7 @@ public class WorkflowPublicChatService {
|
||||
.eq(WorkflowExecStep::getRecordId, record.getId())
|
||||
.orderBy(WorkflowExecStep::getStartTime, true)
|
||||
));
|
||||
return buildExecutionDetail(record, steps, runtimeView(executeId));
|
||||
return buildExecutionDetail(record, steps, eventStream.runtimeView(executeId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -292,35 +292,4 @@ public class WorkflowPublicChatService {
|
||||
/**
|
||||
* 构建刷新恢复所需的最小 Runtime 视图。
|
||||
*/
|
||||
private Map<String, Object> runtimeView(String executeId) {
|
||||
try {
|
||||
ChainState state = chainExecutor.getChainStateRepository()
|
||||
.load(executeId);
|
||||
if (state == null || state.getStatus() == null) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("status", state.getStatus().name());
|
||||
view.put("statusValue", state.getStatus().getValue());
|
||||
view.put("message", state.getMessage());
|
||||
if (state.getStatus() == ChainStatus.SUSPEND) {
|
||||
view.put("parameters", state.getSuspendForParameters());
|
||||
}
|
||||
if (state.getStatus() == ChainStatus.SUCCEEDED) {
|
||||
view.put(
|
||||
"output",
|
||||
WorkflowChatEventStream.visibleFinalOutput(
|
||||
state.getExecuteResult())
|
||||
);
|
||||
}
|
||||
return view;
|
||||
} catch (RuntimeException error) {
|
||||
log.warn(
|
||||
"failed to load public workflow runtime state, executeId={}",
|
||||
executeId,
|
||||
error
|
||||
);
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainConsts;
|
||||
import com.easyagents.flow.core.chain.*;
|
||||
import com.easyagents.flow.core.chain.repository.*;
|
||||
import com.easyagents.flow.core.chain.runtime.InMemoryTriggerStore;
|
||||
import com.easyagents.flow.core.chain.runtime.TriggerScheduler;
|
||||
import com.easyagents.flow.core.node.StartNode;
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import java.util.concurrent.*;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.Test;
|
||||
@@ -138,6 +145,50 @@ public class WorkflowChatEventStreamTest {
|
||||
Assert.assertEquals(cleanupCount.get(), 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void terminalMustCarryReasonAfterFailedNode() throws Exception {
|
||||
ChainDefinition definition = new ChainDefinition(); definition.setId("sse-test");
|
||||
StartNode start = new StartNode(); start.setId("start"); definition.addNode(start);
|
||||
Node failed = new Node() {
|
||||
@Override public Map<String, Object> execute(Chain chain) {
|
||||
throw new WorkflowExecutionException(WorkflowErrorReason.MODEL_UNAVAILABLE, "PRIVATE_PROVIDER_BODY");
|
||||
}
|
||||
};
|
||||
failed.setId("llm"); failed.setName("模型分析"); definition.addNode(failed);
|
||||
Edge edge = new Edge(); edge.setId("edge"); edge.setSource("start"); edge.setTarget("llm"); definition.addEdge(edge);
|
||||
TriggerScheduler scheduler = new TriggerScheduler(new InMemoryTriggerStore(), Executors.newSingleThreadScheduledExecutor(), Executors.newFixedThreadPool(2), 1000);
|
||||
ChainExecutor executor = new ChainExecutor(id -> definition, new InMemoryChainStateRepository(), new InMemoryNodeStateRepository(), scheduler);
|
||||
List<JSONObject> events = new CopyOnWriteArrayList<>();
|
||||
CountDownLatch complete = new CountDownLatch(1);
|
||||
SseEmitter emitter = new SseEmitter() {
|
||||
@Override public void send(SseEventBuilder event) {
|
||||
event.build().forEach(data -> {
|
||||
String value = String.valueOf(data.getData());
|
||||
if (value.startsWith("{")) events.add(JSON.parseObject(value));
|
||||
});
|
||||
}
|
||||
@Override public void complete() { complete.countDown(); }
|
||||
};
|
||||
WorkflowChatEventStream stream = new WorkflowChatEventStream(executor) {
|
||||
@Override SseEmitter createEmitter() { return emitter; }
|
||||
};
|
||||
try {
|
||||
stream.registerListeners(); stream.start("sse-test", Map.of());
|
||||
Assert.assertTrue(complete.await(5, TimeUnit.SECONDS));
|
||||
List<JSONObject> terminals = events.stream().filter(e -> "execution_failed".equals(e.getString("type"))).toList();
|
||||
Assert.assertEquals(terminals.size(), 1);
|
||||
JSONObject detail = terminals.get(0).getJSONObject("data").getJSONObject("error");
|
||||
Assert.assertEquals(detail.getString("reasonCode"), "MODEL_UNAVAILABLE");
|
||||
Assert.assertEquals(detail.getString("nodeId"), "llm");
|
||||
JSONObject ended = events.stream().filter(e -> "node_finished".equals(e.getString("type")) && "llm".equals(e.getJSONObject("data").getString("nodeId"))).findFirst().orElseThrow();
|
||||
Assert.assertEquals(ended.getJSONObject("data").getString("status"), "FAILED");
|
||||
Assert.assertTrue(events.indexOf(ended) < events.indexOf(terminals.get(0)));
|
||||
Assert.assertTrue(ended.getJSONObject("data").get("error") instanceof String);
|
||||
Assert.assertEquals(ended.getJSONObject("data").getJSONObject("errorDetail").getString("reasonCode"), "MODEL_UNAVAILABLE");
|
||||
Assert.assertFalse(JSON.toJSONString(events).contains("PRIVATE_PROVIDER_BODY"));
|
||||
} finally { stream.shutdown(); scheduler.shutdown(); }
|
||||
}
|
||||
|
||||
private static final class CapturingSseEmitter extends SseEmitter {
|
||||
|
||||
private Runnable completion;
|
||||
|
||||
@@ -123,6 +123,8 @@ public class WorkflowPublicChatServiceTest {
|
||||
when(fixture.chainExecutor.getChainStateRepository())
|
||||
.thenReturn(repository);
|
||||
when(repository.load("execution-1")).thenReturn(state);
|
||||
Map<String, Object> runtimeSnapshot = new WorkflowChatEventStream(fixture.chainExecutor).runtimeView("execution-1");
|
||||
when(fixture.eventStream.runtimeView("execution-1")).thenReturn(runtimeSnapshot);
|
||||
|
||||
Map<String, Object> detail = fixture.service.detail(
|
||||
"share-key", visitorId(), "execution-1");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
/**
|
||||
* 获取安全消息。
|
||||
*
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user