Compare commits
3 Commits
9722bea701
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d506d06f77 | |||
| b5a355247b | |||
| c7c301d3b9 |
@@ -224,6 +224,7 @@ public class WorkflowChatController {
|
|||||||
Map<String, Object> detail = new LinkedHashMap<>();
|
Map<String, Object> detail = new LinkedHashMap<>();
|
||||||
detail.put("record", recordView);
|
detail.put("record", recordView);
|
||||||
detail.put("steps", stepViews);
|
detail.put("steps", stepViews);
|
||||||
|
detail.put("runtime", eventStream.runtimeView(executeId));
|
||||||
return Result.ok(detail);
|
return Result.ok(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package tech.easyflow.admin.controller.ai;
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowExecutionErrorMapper;
|
||||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import cn.hutool.core.io.IoUtil;
|
import cn.hutool.core.io.IoUtil;
|
||||||
@@ -261,6 +262,7 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
|||||||
if (StpUtil.isLogin()) {
|
if (StpUtil.isLogin()) {
|
||||||
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
||||||
}
|
}
|
||||||
|
WorkflowExecutionErrorMapper.installRequestProfile();
|
||||||
Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables);
|
Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables);
|
||||||
return Result.ok(res);
|
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.Chain;
|
||||||
import com.easyagents.flow.core.chain.ChainConsts;
|
import com.easyagents.flow.core.chain.ChainConsts;
|
||||||
import com.easyagents.flow.core.chain.ChainStatus;
|
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.Edge;
|
||||||
import com.easyagents.flow.core.chain.Event;
|
import com.easyagents.flow.core.chain.Event;
|
||||||
import com.easyagents.flow.core.chain.Node;
|
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.ChainStatusChangeEvent;
|
||||||
import com.easyagents.flow.core.chain.event.EdgeConditionCheckFailedEvent;
|
import com.easyagents.flow.core.chain.event.EdgeConditionCheckFailedEvent;
|
||||||
import com.easyagents.flow.core.chain.event.EdgeTriggerEvent;
|
import com.easyagents.flow.core.chain.event.EdgeTriggerEvent;
|
||||||
@@ -39,6 +44,41 @@ import java.util.concurrent.atomic.AtomicLong;
|
|||||||
@Service
|
@Service
|
||||||
public class WorkflowChatEventStream {
|
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 =
|
private static final Logger log =
|
||||||
LoggerFactory.getLogger(WorkflowChatEventStream.class);
|
LoggerFactory.getLogger(WorkflowChatEventStream.class);
|
||||||
private static final long SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L;
|
private static final long SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L;
|
||||||
@@ -211,9 +251,9 @@ public class WorkflowChatEventStream {
|
|||||||
StreamSession session = findSession(chain);
|
StreamSession session = findSession(chain);
|
||||||
if (session != null
|
if (session != null
|
||||||
&& Objects.equals(chain.getStateInstanceId(), session.executeId)) {
|
&& Objects.equals(chain.getStateInstanceId(), session.executeId)) {
|
||||||
session.send("execution_error", Map.of(
|
WorkflowExecutionError detail = WorkflowExecutionErrorMapper.map(
|
||||||
"message", safeErrorMessage(error)
|
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
|
data.put("output", event.getResult() == null
|
||||||
? Map.of()
|
? Map.of()
|
||||||
: event.getResult());
|
: event.getResult());
|
||||||
if (event.getError() != null) {
|
if (event.getErrorSummary() != null) {
|
||||||
data.put("error", safeErrorMessage(event.getError()));
|
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));
|
send("node_finished", nodePayload(node, data));
|
||||||
}
|
}
|
||||||
@@ -561,6 +592,11 @@ public class WorkflowChatEventStream {
|
|||||||
Map<String, Object> data = new LinkedHashMap<>();
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
data.put("status", status.name());
|
data.put("status", status.name());
|
||||||
data.put("message", chain.getState().getMessage());
|
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) {
|
if (status == ChainStatus.SUCCEEDED) {
|
||||||
data.put(
|
data.put(
|
||||||
"output",
|
"output",
|
||||||
@@ -614,8 +650,12 @@ public class WorkflowChatEventStream {
|
|||||||
*/
|
*/
|
||||||
private void fail(Throwable error) {
|
private void fail(Throwable error) {
|
||||||
if (terminal.compareAndSet(false, true)) {
|
if (terminal.compareAndSet(false, true)) {
|
||||||
|
WorkflowExecutionError detail = WorkflowExecutionErrorMapper.map(
|
||||||
|
error == null ? null : new ExceptionSummary(error), true, null, null, false);
|
||||||
send("execution_failed", Map.of(
|
send("execution_failed", Map.of(
|
||||||
"message", safeErrorMessage(error)
|
"status", ChainStatus.FAILED.name(),
|
||||||
|
"message", detail.getMessage(),
|
||||||
|
"error", detail
|
||||||
));
|
));
|
||||||
removeSession(this);
|
removeSession(this);
|
||||||
if (connected.compareAndSet(true, false)) {
|
if (connected.compareAndSet(true, false)) {
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ public class WorkflowPublicChatService {
|
|||||||
.eq(WorkflowExecStep::getRecordId, record.getId())
|
.eq(WorkflowExecStep::getRecordId, record.getId())
|
||||||
.orderBy(WorkflowExecStep::getStartTime, true)
|
.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 视图。
|
* 构建刷新恢复所需的最小 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;
|
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 com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
import org.testng.Assert;
|
import org.testng.Assert;
|
||||||
import org.testng.annotations.Test;
|
import org.testng.annotations.Test;
|
||||||
@@ -138,6 +145,50 @@ public class WorkflowChatEventStreamTest {
|
|||||||
Assert.assertEquals(cleanupCount.get(), 1);
|
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 static final class CapturingSseEmitter extends SseEmitter {
|
||||||
|
|
||||||
private Runnable completion;
|
private Runnable completion;
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ public class WorkflowPublicChatServiceTest {
|
|||||||
when(fixture.chainExecutor.getChainStateRepository())
|
when(fixture.chainExecutor.getChainStateRepository())
|
||||||
.thenReturn(repository);
|
.thenReturn(repository);
|
||||||
when(repository.load("execution-1")).thenReturn(state);
|
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(
|
Map<String, Object> detail = fixture.service.detail(
|
||||||
"share-key", visitorId(), "execution-1");
|
"share-key", visitorId(), "execution-1");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package tech.easyflow.publicapi.controller;
|
package tech.easyflow.publicapi.controller;
|
||||||
|
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowExecutionErrorMapper;
|
||||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
@@ -125,6 +126,7 @@ public class PublicWorkflowController {
|
|||||||
if (StpUtil.isLogin()) {
|
if (StpUtil.isLogin()) {
|
||||||
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
variables.put(Constants.LOGIN_USER_KEY, SaTokenUtil.getLoginAccount());
|
||||||
}
|
}
|
||||||
|
WorkflowExecutionErrorMapper.installRequestProfile();
|
||||||
Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables);
|
Map<String, Object> res = chainExecutor.executeNode(workflowId.toString(), nodeId, variables);
|
||||||
return Result.ok(res);
|
return Result.ok(res);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,5 +22,10 @@ public record PublicWorkflowNodeStatus(
|
|||||||
PublicWorkflowExecutionStatus status,
|
PublicWorkflowExecutionStatus status,
|
||||||
String message,
|
String message,
|
||||||
Map<String, Object> result,
|
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 static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
private final String code;
|
private final String code;
|
||||||
|
private final String reasonCode;
|
||||||
private final String message;
|
private final String message;
|
||||||
private final String nodeId;
|
private final String nodeId;
|
||||||
private final String nodeName;
|
private final String nodeName;
|
||||||
@@ -30,7 +31,13 @@ public class PublicWorkflowStatusError implements Serializable {
|
|||||||
String nodeId,
|
String nodeId,
|
||||||
String nodeName,
|
String nodeName,
|
||||||
boolean retryable) {
|
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.code = code;
|
||||||
|
this.reasonCode = reasonCode;
|
||||||
this.message = message;
|
this.message = message;
|
||||||
this.nodeId = nodeId;
|
this.nodeId = nodeId;
|
||||||
this.nodeName = nodeName;
|
this.nodeName = nodeName;
|
||||||
@@ -46,6 +53,8 @@ public class PublicWorkflowStatusError implements Serializable {
|
|||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getReasonCode() { return reasonCode; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取安全消息。
|
* 获取安全消息。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package tech.easyflow.publicapi.service;
|
package tech.easyflow.publicapi.service;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
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.PublicWorkflowChainStatus;
|
||||||
import tech.easyflow.publicapi.dto.PublicWorkflowExecutionStatus;
|
import tech.easyflow.publicapi.dto.PublicWorkflowExecutionStatus;
|
||||||
import tech.easyflow.publicapi.dto.PublicWorkflowNodeStatus;
|
import tech.easyflow.publicapi.dto.PublicWorkflowNodeStatus;
|
||||||
@@ -12,130 +13,44 @@ import tech.easyflow.publicapi.dto.PublicWorkflowStatusError;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/** 复用公共错误规范,兼容没有结构化错误的旧状态。 */
|
||||||
* 将内部工作流执行错误转换为 Public API 安全状态。
|
|
||||||
*/
|
|
||||||
@Service
|
@Service
|
||||||
public class PublicWorkflowStatusSanitizer {
|
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) {
|
public PublicWorkflowChainStatus sanitize(ChainInfo source) {
|
||||||
if (source == null) {
|
if (source == null) throw new IllegalArgumentException("source must not be null");
|
||||||
throw new IllegalArgumentException(
|
PublicWorkflowExecutionStatus status = PublicWorkflowExecutionStatus.fromChainStatus(source.getStatus());
|
||||||
"source must not be null");
|
Map<String, PublicWorkflowNodeStatus> nodes = new LinkedHashMap<>();
|
||||||
}
|
|
||||||
PublicWorkflowExecutionStatus chainStatus =
|
|
||||||
PublicWorkflowExecutionStatus.fromChainStatus(
|
|
||||||
source.getStatus());
|
|
||||||
|
|
||||||
Map<String, PublicWorkflowNodeStatus> safeNodes =
|
|
||||||
new LinkedHashMap<>();
|
|
||||||
PublicWorkflowStatusError firstNodeError = null;
|
PublicWorkflowStatusError firstNodeError = null;
|
||||||
if (source.getNodes() != null) {
|
if (source.getNodes() != null) {
|
||||||
for (Map.Entry<String, NodeInfo> entry
|
for (var entry : source.getNodes().entrySet()) {
|
||||||
: source.getNodes().entrySet()) {
|
NodeInfo node = entry.getValue();
|
||||||
PublicWorkflowNodeStatus safeNode = copyNode(
|
if (node == null) continue;
|
||||||
entry.getValue());
|
PublicWorkflowExecutionStatus nodeStatus = PublicWorkflowExecutionStatus.fromNodeStatus(node.getStatus());
|
||||||
safeNodes.put(entry.getKey(), safeNode);
|
PublicWorkflowStatusError error = copyError(node.getError(), false, nodeStatus, node.getNodeId(), node.getNodeName(), status.isTerminal());
|
||||||
if (firstNodeError == null
|
nodes.put(entry.getKey(), new PublicWorkflowNodeStatus(node.getNodeId(), node.getNodeName(), nodeStatus,
|
||||||
&& StringUtils.hasText(safeNode.message())) {
|
error == null ? null : error.getMessage(), node.getResult(), node.getSuspendForParameters(), error));
|
||||||
firstNodeError = new PublicWorkflowStatusError(
|
if (firstNodeError == null && error != null) firstNodeError = error;
|
||||||
"NODE_EXECUTION_FAILED",
|
|
||||||
safeNode.message(),
|
|
||||||
safeNode.nodeId(),
|
|
||||||
safeNode.nodeName(),
|
|
||||||
isRetryable(safeNode.status()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
String message = null;
|
private PublicWorkflowStatusError copyError(WorkflowExecutionError source, boolean workflow,
|
||||||
PublicWorkflowStatusError error = null;
|
PublicWorkflowExecutionStatus status, String nodeId, String nodeName, boolean executionTerminal) {
|
||||||
if (StringUtils.hasText(source.getMessage())) {
|
if (status != PublicWorkflowExecutionStatus.FAILED && status != PublicWorkflowExecutionStatus.ERROR) return null;
|
||||||
message = chainMessage(chainStatus);
|
if (source != null) {
|
||||||
error = new PublicWorkflowStatusError(
|
nodeId = source.getNodeId();
|
||||||
"WORKFLOW_EXECUTION_FAILED",
|
nodeName = source.getNodeName();
|
||||||
message,
|
|
||||||
firstNodeError == null
|
|
||||||
? null
|
|
||||||
: firstNodeError.getNodeId(),
|
|
||||||
firstNodeError == null
|
|
||||||
? null
|
|
||||||
: firstNodeError.getNodeName(),
|
|
||||||
isRetryable(chainStatus));
|
|
||||||
} else if (firstNodeError != null) {
|
|
||||||
error = firstNodeError;
|
|
||||||
}
|
}
|
||||||
return new PublicWorkflowChainStatus(
|
WorkflowExecutionError safe = WorkflowExecutionErrorMapper.fromReason(source == null ? null : source.getReasonCode(), workflow, nodeId, nodeName,
|
||||||
source.getExecuteId(),
|
status == PublicWorkflowExecutionStatus.ERROR && !executionTerminal);
|
||||||
chainStatus,
|
return new PublicWorkflowStatusError(safe.getCode(), safe.getReasonCode(), safe.getMessage(),
|
||||||
chainStatus.isTerminal(),
|
safe.getNodeId(), safe.getNodeName(), safe.isRetryable());
|
||||||
message,
|
|
||||||
source.getResult(),
|
|
||||||
safeNodes,
|
|
||||||
error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 复制并脱敏单个节点状态。
|
|
||||||
*
|
|
||||||
* @param source 内部节点状态
|
|
||||||
* @return 安全节点状态
|
|
||||||
*/
|
|
||||||
private PublicWorkflowNodeStatus copyNode(NodeInfo source) {
|
|
||||||
if (source == null) {
|
|
||||||
return new PublicWorkflowNodeStatus(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
PublicWorkflowExecutionStatus.UNKNOWN,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null);
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package tech.easyflow.publicapi.service;
|
package tech.easyflow.publicapi.service;
|
||||||
|
|
||||||
import com.easyagents.flow.core.chain.ChainStatus;
|
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 com.easyagents.flow.core.chain.NodeStatus;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
@@ -39,11 +41,11 @@ public class PublicWorkflowStatusSanitizerTest {
|
|||||||
PublicWorkflowChainStatus result = sanitizer.sanitize(source);
|
PublicWorkflowChainStatus result = sanitizer.sanitize(source);
|
||||||
|
|
||||||
Assert.assertEquals(
|
Assert.assertEquals(
|
||||||
"工作流执行失败,请检查输入或稍后重试",
|
WorkflowErrorReason.NODE_EXECUTION_FAILED.getDefaultMessage(),
|
||||||
result.message());
|
result.message());
|
||||||
Assert.assertFalse(result.message().contains("minio"));
|
Assert.assertFalse(result.message().contains("minio"));
|
||||||
Assert.assertEquals(
|
Assert.assertEquals(
|
||||||
"节点执行失败,请检查输入或稍后重试",
|
WorkflowErrorReason.NODE_EXECUTION_FAILED.getDefaultMessage(),
|
||||||
result.nodes().get("node-1").message());
|
result.nodes().get("node-1").message());
|
||||||
Assert.assertEquals("node-1", result.error().getNodeId());
|
Assert.assertEquals("node-1", result.error().getNodeId());
|
||||||
Assert.assertFalse(result.error().isRetryable());
|
Assert.assertFalse(result.error().isRetryable());
|
||||||
@@ -101,4 +103,46 @@ public class PublicWorkflowStatusSanitizerTest {
|
|||||||
PublicWorkflowExecutionStatus.RUNNING,
|
PublicWorkflowExecutionStatus.RUNNING,
|
||||||
result.nodes().get("node-1").status());
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.easyagents.core.model.embedding.EmbeddingOptions;
|
|||||||
import com.easyagents.core.store.DocumentStore;
|
import com.easyagents.core.store.DocumentStore;
|
||||||
import com.easyagents.core.store.StoreOptions;
|
import com.easyagents.core.store.StoreOptions;
|
||||||
import com.easyagents.core.store.StoreResult;
|
import com.easyagents.core.store.StoreResult;
|
||||||
|
import com.easyagents.core.store.VectorData;
|
||||||
import com.easyagents.search.engine.service.DocumentSearcher;
|
import com.easyagents.search.engine.service.DocumentSearcher;
|
||||||
import com.easyagents.search.engine.service.KeywordSearchMetadataKeys;
|
import com.easyagents.search.engine.service.KeywordSearchMetadataKeys;
|
||||||
import com.easyagents.store.milvus.MilvusVectorStore;
|
import com.easyagents.store.milvus.MilvusVectorStore;
|
||||||
@@ -22,6 +23,7 @@ import tech.easyflow.ai.entity.DocumentChunk;
|
|||||||
import tech.easyflow.ai.entity.DocumentChunkSyncTask;
|
import tech.easyflow.ai.entity.DocumentChunkSyncTask;
|
||||||
import tech.easyflow.ai.entity.DocumentCollection;
|
import tech.easyflow.ai.entity.DocumentCollection;
|
||||||
import tech.easyflow.ai.entity.Model;
|
import tech.easyflow.ai.entity.Model;
|
||||||
|
import tech.easyflow.ai.dto.DocumentChunkSyncStatus;
|
||||||
import tech.easyflow.ai.mapper.DocumentChunkMapper;
|
import tech.easyflow.ai.mapper.DocumentChunkMapper;
|
||||||
import tech.easyflow.ai.mapper.DocumentChunkSyncTaskMapper;
|
import tech.easyflow.ai.mapper.DocumentChunkSyncTaskMapper;
|
||||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
@@ -33,7 +35,12 @@ import java.math.BigInteger;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 持久化分块索引同步任务的投递、执行和恢复。
|
* 持久化分块索引同步任务的投递、执行和恢复。
|
||||||
@@ -116,6 +123,31 @@ public class DocumentChunkSyncTaskAppService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<DocumentChunkSyncStatus> listSyncStatuses(List<DocumentChunk> chunks) {
|
||||||
|
if (chunks.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Map<BigInteger, DocumentChunkSyncTask> tasks = taskMapper.selectCurrentForChunks(
|
||||||
|
chunks.stream().map(DocumentChunk::getId).toList()
|
||||||
|
).stream().collect(Collectors.toMap(DocumentChunkSyncTask::getChunkId, Function.identity()));
|
||||||
|
return chunks.stream().map(chunk -> {
|
||||||
|
DocumentChunkSyncTask task = tasks.get(chunk.getId());
|
||||||
|
boolean current = task != null
|
||||||
|
&& Objects.equals(chunk.getIndexSyncVersion(), task.getSyncVersion());
|
||||||
|
// 正文状态可能在两次查询间推进,只有同版本仍在重试的任务提供失败原因。
|
||||||
|
boolean retrying = current && DocumentChunkSyncState.PENDING.equals(chunk.getIndexSyncStatus())
|
||||||
|
&& (DocumentChunkSyncState.PENDING.equals(task.getStatus())
|
||||||
|
|| DocumentChunkSyncState.TASK_RUNNING.equals(task.getStatus()));
|
||||||
|
return new DocumentChunkSyncStatus(
|
||||||
|
chunk.getId(), chunk.getIndexSyncStatus(), chunk.getIndexSyncVersion(),
|
||||||
|
retrying ? task.getErrorCode() : chunk.getIndexSyncErrorCode(),
|
||||||
|
retrying ? task.getErrorMessage() : chunk.getIndexSyncErrorMessage(),
|
||||||
|
current ? task.getAttemptCount() : null,
|
||||||
|
MAX_ATTEMPTS
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
public void dispatchPendingTasks() {
|
public void dispatchPendingTasks() {
|
||||||
Date now = new Date();
|
Date now = new Date();
|
||||||
taskMapper.recoverExpired(now);
|
taskMapper.recoverExpired(now);
|
||||||
@@ -228,6 +260,7 @@ public class DocumentChunkSyncTaskAppService {
|
|||||||
StoreContext context = prepareUpsertContext(collection, task.getVectorCollection());
|
StoreContext context = prepareUpsertContext(collection, task.getVectorCollection());
|
||||||
try {
|
try {
|
||||||
com.easyagents.core.document.Document document = toSearchDocument(chunk, task.getDocumentCollectionId());
|
com.easyagents.core.document.Document document = toSearchDocument(chunk, task.getDocumentCollectionId());
|
||||||
|
embedDocument(context, document);
|
||||||
StoreResult vectorResult = context.documentStore.update(
|
StoreResult vectorResult = context.documentStore.update(
|
||||||
Collections.singletonList(document),
|
Collections.singletonList(document),
|
||||||
context.storeOptions
|
context.storeOptions
|
||||||
@@ -248,6 +281,21 @@ public class DocumentChunkSyncTaskAppService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void embedDocument(StoreContext context, com.easyagents.core.document.Document document) {
|
||||||
|
try {
|
||||||
|
VectorData vectorData = context.documentStore.getEmbeddingModel().embed(
|
||||||
|
document, context.storeOptions.getEmbeddingOptions()
|
||||||
|
);
|
||||||
|
if (vectorData == null || vectorData.getVector() == null || vectorData.getVector().length == 0) {
|
||||||
|
throw new IllegalStateException("向量模型未返回有效向量");
|
||||||
|
}
|
||||||
|
// update 复用已生成的向量,不重复调用模型;分开捕获以区分模型与索引写入失败。
|
||||||
|
document.setVector(vectorData.getVector());
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new IndexSyncException("EMBEDDING_REQUEST_FAILED", "向量模型服务调用失败", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void synchronizeDelete(DocumentChunkSyncTask task) {
|
private void synchronizeDelete(DocumentChunkSyncTask task) {
|
||||||
MilvusVectorStoreConfig storeConfig = milvusConfig.copyForCollection(task.getVectorCollection());
|
MilvusVectorStoreConfig storeConfig = milvusConfig.copyForCollection(task.getVectorCollection());
|
||||||
AiMilvusClientManager clientManager = milvusClientManagerProvider.getObject();
|
AiMilvusClientManager clientManager = milvusClientManagerProvider.getObject();
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ public record DocumentChunkSyncStatus(
|
|||||||
String indexSyncStatus,
|
String indexSyncStatus,
|
||||||
Long indexSyncVersion,
|
Long indexSyncVersion,
|
||||||
String indexSyncErrorCode,
|
String indexSyncErrorCode,
|
||||||
String indexSyncErrorMessage
|
String indexSyncErrorMessage,
|
||||||
|
Integer indexSyncAttemptCount,
|
||||||
|
int indexSyncMaxAttempts
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ public class ChainInfo implements Serializable {
|
|||||||
* 消息,错误时显示
|
* 消息,错误时显示
|
||||||
*/
|
*/
|
||||||
private String message;
|
private String message;
|
||||||
|
private WorkflowExecutionError error;
|
||||||
|
|
||||||
|
public WorkflowExecutionError getError() { return error; }
|
||||||
|
public void setError(WorkflowExecutionError error) { this.error = error; }
|
||||||
/**
|
/**
|
||||||
* 执行结果
|
* 执行结果
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ public class NodeInfo implements Serializable {
|
|||||||
* 消息,错误时显示
|
* 消息,错误时显示
|
||||||
*/
|
*/
|
||||||
private String message;
|
private String message;
|
||||||
|
private WorkflowExecutionError error;
|
||||||
|
|
||||||
|
public WorkflowExecutionError getError() { return error; }
|
||||||
|
public void setError(WorkflowExecutionError error) { this.error = error; }
|
||||||
/**
|
/**
|
||||||
* 执行结果
|
* 执行结果
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/** 三个运行出口共用的安全错误信息。 */
|
||||||
|
public class WorkflowExecutionError 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;
|
||||||
|
private final boolean retryable;
|
||||||
|
|
||||||
|
public WorkflowExecutionError(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;
|
||||||
|
this.retryable = retryable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() { return code; }
|
||||||
|
public String getReasonCode() { return reasonCode; }
|
||||||
|
public String getMessage() { return message; }
|
||||||
|
public String getNodeId() { return nodeId; }
|
||||||
|
public String getNodeName() { return nodeName; }
|
||||||
|
public boolean isRetryable() { return retryable; }
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditEvent;
|
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditEvent;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.service.WorkflowExecutionErrorMapper;
|
||||||
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditProducer;
|
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditProducer;
|
||||||
import tech.easyflow.ai.easyagentsflow.repository.FrozenWorkflowDefinitionRegistry;
|
import tech.easyflow.ai.easyagentsflow.repository.FrozenWorkflowDefinitionRegistry;
|
||||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||||
@@ -111,7 +112,7 @@ public class ChainEventListenerForSave implements ChainEventListener {
|
|||||||
state.getExecuteResult()));
|
state.getExecuteResult()));
|
||||||
ExceptionSummary error = state.getError();
|
ExceptionSummary error = state.getError();
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
record.setErrorInfo(error.getRootCauseClass() + " --> " + error.getRootCauseMessage());
|
record.setErrorInfo(WorkflowExecutionErrorMapper.summary(WorkflowExecutionErrorMapper.chain(error, state.getStatus())));
|
||||||
}
|
}
|
||||||
sendAuditEvent(
|
sendAuditEvent(
|
||||||
WorkflowExecutionAuditEvent.Type.CHAIN_ENDED,
|
WorkflowExecutionAuditEvent.Type.CHAIN_ENDED,
|
||||||
@@ -209,14 +210,13 @@ public class ChainEventListenerForSave implements ChainEventListener {
|
|||||||
step.setEndTime(new Date());
|
step.setEndTime(new Date());
|
||||||
step.setStatus(nodeStatus.getValue());
|
step.setStatus(nodeStatus.getValue());
|
||||||
ExceptionSummary error =
|
ExceptionSummary error =
|
||||||
event.getError() == null
|
event.getErrorSummary() == null
|
||||||
? (legacyNodeState == null
|
? (legacyNodeState == null
|
||||||
? null
|
? null
|
||||||
: legacyNodeState.getError())
|
: legacyNodeState.getError())
|
||||||
: new ExceptionSummary(
|
: event.getErrorSummary();
|
||||||
event.getError());
|
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
step.setErrorInfo(error.getRootCauseClass() + " --> " + error.getRootCauseMessage());
|
step.setErrorInfo(WorkflowExecutionErrorMapper.summary(WorkflowExecutionErrorMapper.node(error, nodeStatus, node.getId(), node.getName())));
|
||||||
}
|
}
|
||||||
sendAuditEvent(
|
sendAuditEvent(
|
||||||
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
|
WorkflowExecutionAuditEvent.Type.NODE_ENDED,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package tech.easyflow.ai.easyagentsflow.service;
|
package tech.easyflow.ai.easyagentsflow.service;
|
||||||
|
|
||||||
import com.easyagents.document.core.exception.DocumentParseException;
|
|
||||||
import com.easyagents.flow.core.chain.ChainState;
|
import com.easyagents.flow.core.chain.ChainState;
|
||||||
import com.easyagents.flow.core.chain.ExceptionSummary;
|
import com.easyagents.flow.core.chain.ExceptionSummary;
|
||||||
import com.easyagents.flow.core.chain.NodeState;
|
import com.easyagents.flow.core.chain.NodeState;
|
||||||
@@ -8,11 +7,9 @@ import com.easyagents.flow.core.chain.NodeStatus;
|
|||||||
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
import com.easyagents.flow.core.chain.repository.ChainStateRepository;
|
||||||
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
|
import com.easyagents.flow.core.chain.repository.NodeStateRepository;
|
||||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||||
import com.easyagents.flow.core.code.impl.JavascriptExecutionException;
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||||
import tech.easyflow.common.util.StringUtil;
|
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
@@ -59,10 +56,8 @@ public class TinyFlowService {
|
|||||||
? Map.of()
|
? Map.of()
|
||||||
: resolvedNodeNames;
|
: resolvedNodeNames;
|
||||||
for (NodeInfo node : nodes) {
|
for (NodeInfo node : nodes) {
|
||||||
if (node != null
|
if (node == null) continue;
|
||||||
&& StringUtil.noText(node.getNodeName())) {
|
|
||||||
node.setNodeName(nodeNames.get(node.getNodeId()));
|
node.setNodeName(nodeNames.get(node.getNodeId()));
|
||||||
}
|
|
||||||
processNodeState(executeId, node, chainState, nodeStateRepository);
|
processNodeState(executeId, node, chainState, nodeStateRepository);
|
||||||
res.getNodes().put(node.getNodeId(), node);
|
res.getNodes().put(node.getNodeId(), node);
|
||||||
}
|
}
|
||||||
@@ -100,9 +95,8 @@ public class TinyFlowService {
|
|||||||
res.setExecuteId(executeId);
|
res.setExecuteId(executeId);
|
||||||
res.setStatus(chainState.getStatus().getValue());
|
res.setStatus(chainState.getStatus().getValue());
|
||||||
ExceptionSummary chainError = chainState.getError();
|
ExceptionSummary chainError = chainState.getError();
|
||||||
if (chainError != null) {
|
res.setError(WorkflowExecutionErrorMapper.chain(chainError, chainState.getStatus()));
|
||||||
res.setMessage(formatError(chainError));
|
res.setMessage(WorkflowExecutionErrorMapper.summary(res.getError()));
|
||||||
}
|
|
||||||
Map<String, Object> executeResult = chainState.getExecuteResult();
|
Map<String, Object> executeResult = chainState.getExecuteResult();
|
||||||
if (executeResult != null && !executeResult.isEmpty()) {
|
if (executeResult != null && !executeResult.isEmpty()) {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@@ -127,12 +121,9 @@ public class TinyFlowService {
|
|||||||
? NodeStatus.READY.getValue()
|
? NodeStatus.READY.getValue()
|
||||||
: nodeState.getStatus().getValue());
|
: nodeState.getStatus().getValue());
|
||||||
|
|
||||||
if (nodeState != null) {
|
node.setError(nodeState == null ? null : WorkflowExecutionErrorMapper.node(
|
||||||
ExceptionSummary error = nodeState.getError();
|
nodeState.getError(), nodeState.getStatus(), nodeId, node.getNodeName(), chainState.getStatus().isTerminal()));
|
||||||
if (error != null) {
|
node.setMessage(WorkflowExecutionErrorMapper.summary(node.getError()));
|
||||||
node.setMessage(formatError(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> nodeExecuteResult = chainState.getNodeExecuteResult(nodeId);
|
Map<String, Object> nodeExecuteResult = chainState.getNodeExecuteResult(nodeId);
|
||||||
if (nodeExecuteResult != null && !nodeExecuteResult.isEmpty()) {
|
if (nodeExecuteResult != null && !nodeExecuteResult.isEmpty()) {
|
||||||
@@ -151,34 +142,4 @@ public class TinyFlowService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 将执行异常转换为试运行界面可读的错误信息。
|
|
||||||
*
|
|
||||||
* @param error 持久化的异常摘要
|
|
||||||
* @return 可展示的错误信息
|
|
||||||
*/
|
|
||||||
private String formatError(ExceptionSummary error) {
|
|
||||||
if (JavascriptExecutionException.class.getName()
|
|
||||||
.equals(error.getExceptionClass())
|
|
||||||
&& StringUtil.hasText(error.getMessage())) {
|
|
||||||
return error.getMessage();
|
|
||||||
}
|
|
||||||
String rootClass = StringUtil.hasText(error.getRootCauseClass())
|
|
||||||
? error.getRootCauseClass()
|
|
||||||
: error.getExceptionClass();
|
|
||||||
String rootMessage = StringUtil.hasText(error.getRootCauseMessage())
|
|
||||||
? error.getRootCauseMessage()
|
|
||||||
: error.getMessage();
|
|
||||||
if (DocumentParseException.class.getName().equals(rootClass)
|
|
||||||
&& StringUtil.hasText(rootMessage)) {
|
|
||||||
return rootMessage;
|
|
||||||
}
|
|
||||||
if (StringUtil.noText(rootClass)) {
|
|
||||||
return rootMessage;
|
|
||||||
}
|
|
||||||
if (StringUtil.noText(rootMessage)) {
|
|
||||||
return rootClass;
|
|
||||||
}
|
|
||||||
return rootClass + " --> " + rootMessage;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.service;
|
||||||
|
|
||||||
|
import com.easyagents.flow.core.chain.ChainStatus;
|
||||||
|
import com.easyagents.flow.core.chain.ExceptionSummary;
|
||||||
|
import com.easyagents.flow.core.chain.NodeStatus;
|
||||||
|
import com.easyagents.flow.core.chain.WorkflowErrorReason;
|
||||||
|
import com.easyagents.flow.core.chain.WorkflowExecutionException;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowExecutionError;
|
||||||
|
import tech.easyflow.common.web.error.RequestErrorProfile;
|
||||||
|
import tech.easyflow.common.web.error.WebErrorMapping;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
|
||||||
|
/** 只通过稳定原因码生成外部信息,原始 cause 与响应正文不进入展示或审计摘要。 */
|
||||||
|
public final class WorkflowExecutionErrorMapper {
|
||||||
|
private WorkflowExecutionErrorMapper() { }
|
||||||
|
|
||||||
|
public static WorkflowExecutionError chain(ExceptionSummary error, ChainStatus status) {
|
||||||
|
if (status != ChainStatus.FAILED && status != ChainStatus.ERROR) return null;
|
||||||
|
return map(error, true, null, null, status == ChainStatus.ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WorkflowExecutionError node(ExceptionSummary error, NodeStatus status, String nodeId, String nodeName) {
|
||||||
|
return node(error, status, nodeId, nodeName, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WorkflowExecutionError node(ExceptionSummary error, NodeStatus status, String nodeId, String nodeName,
|
||||||
|
boolean executionTerminal) {
|
||||||
|
if (status != NodeStatus.FAILED && status != NodeStatus.ERROR) return null;
|
||||||
|
return map(error, false, nodeId, nodeName, status == NodeStatus.ERROR && !executionTerminal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WorkflowExecutionError map(ExceptionSummary error, boolean workflow,
|
||||||
|
String nodeId, String nodeName, boolean retryable) {
|
||||||
|
if (error != null && error.getNodeId() != null) {
|
||||||
|
nodeId = error.getNodeId();
|
||||||
|
nodeName = error.getNodeName() == null ? nodeName : error.getNodeName();
|
||||||
|
}
|
||||||
|
return fromReason(error == null ? null : error.getErrorCode(), workflow, nodeId, nodeName, retryable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WorkflowExecutionError fromReason(String reasonCode, boolean workflow,
|
||||||
|
String nodeId, String nodeName, boolean retryable) {
|
||||||
|
WorkflowErrorReason reason = WorkflowErrorReason.fromCode(reasonCode);
|
||||||
|
if (reason == null) {
|
||||||
|
reason = nodeId == null ? WorkflowErrorReason.WORKFLOW_INTERNAL_ERROR : WorkflowErrorReason.NODE_EXECUTION_FAILED;
|
||||||
|
}
|
||||||
|
return new WorkflowExecutionError(workflow ? "WORKFLOW_EXECUTION_FAILED" : "NODE_EXECUTION_FAILED",
|
||||||
|
reason.getCode(), reason.getDefaultMessage(), nodeId, nodeName, retryable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String summary(WorkflowExecutionError error) {
|
||||||
|
if (error == null) return null;
|
||||||
|
String name = error.getNodeName();
|
||||||
|
return name == null || name.isBlank() ? error.getMessage() : "「" + name + "」:" + error.getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单节点同步执行沿用全局 HTTP 错误处理,并保留原请求的其他错误契约。 */
|
||||||
|
public static void installRequestProfile() {
|
||||||
|
if (!(RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes)) return;
|
||||||
|
var request = attributes.getRequest();
|
||||||
|
Object previous = request.getAttribute(RequestErrorProfile.ATTRIBUTE_NAME);
|
||||||
|
request.setAttribute(RequestErrorProfile.ATTRIBUTE_NAME, (RequestErrorProfile) (req, exception) -> {
|
||||||
|
if (exception instanceof WorkflowExecutionException failure) {
|
||||||
|
WorkflowExecutionError error = map(new ExceptionSummary(failure), false, null, null, false);
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("error", error);
|
||||||
|
if (failure.getChainId() != null) data.put("executeId", failure.getChainId());
|
||||||
|
return new WebErrorMapping(500, 500, error.getMessage(), data);
|
||||||
|
}
|
||||||
|
return previous instanceof RequestErrorProfile profile ? profile.map(req, exception) : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -485,7 +485,7 @@ public class WorkflowApiUploadLifecycleService {
|
|||||||
return new BusinessException(
|
return new BusinessException(
|
||||||
500,
|
500,
|
||||||
50001,
|
50001,
|
||||||
"文件存储处理失败,请联系管理员并提供 requestId",
|
"文件存储处理失败",
|
||||||
error);
|
error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ public interface DocumentChunkSyncTaskMapper extends BaseMapper<DocumentChunkSyn
|
|||||||
+ "ORDER BY sync_version, id FOR UPDATE")
|
+ "ORDER BY sync_version, id FOR UPDATE")
|
||||||
List<BigInteger> lockChunkTasks(@Param("chunkId") BigInteger chunkId);
|
List<BigInteger> lockChunkTasks(@Param("chunkId") BigInteger chunkId);
|
||||||
|
|
||||||
|
@Select("<script>SELECT " + SELECT_COLUMNS + " FROM tb_document_chunk_sync_task "
|
||||||
|
+ "WHERE operation='UPSERT' AND (chunk_id, sync_version) IN "
|
||||||
|
+ "(SELECT id, index_sync_version FROM tb_document_chunk WHERE id IN "
|
||||||
|
+ "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>)"
|
||||||
|
+ "</script>")
|
||||||
|
List<DocumentChunkSyncTask> selectCurrentForChunks(@Param("ids") List<BigInteger> ids);
|
||||||
|
|
||||||
@Select("SELECT " + SELECT_COLUMNS + " FROM tb_document_chunk_sync_task "
|
@Select("SELECT " + SELECT_COLUMNS + " FROM tb_document_chunk_sync_task "
|
||||||
+ "WHERE status='PENDING' AND next_retry_at <= #{now} "
|
+ "WHERE status='PENDING' AND next_retry_at <= #{now} "
|
||||||
+ "AND (last_dispatched_at IS NULL OR last_dispatched_at <= #{redispatchBefore}) "
|
+ "AND (last_dispatched_at IS NULL OR last_dispatched_at <= #{redispatchBefore}) "
|
||||||
@@ -67,7 +74,7 @@ public interface DocumentChunkSyncTaskMapper extends BaseMapper<DocumentChunkSyn
|
|||||||
|
|
||||||
@Update("UPDATE tb_document_chunk_sync_task SET status='RUNNING', "
|
@Update("UPDATE tb_document_chunk_sync_task SET status='RUNNING', "
|
||||||
+ "attempt_count=attempt_count + 1, execution_token=#{token}, "
|
+ "attempt_count=attempt_count + 1, execution_token=#{token}, "
|
||||||
+ "lease_until=#{leaseUntil}, error_code=NULL, error_message=NULL, modified=#{now} "
|
+ "lease_until=#{leaseUntil}, modified=#{now} "
|
||||||
+ "WHERE id=#{id} AND status='PENDING' AND next_retry_at <= #{now}")
|
+ "WHERE id=#{id} AND status='PENDING' AND next_retry_at <= #{now}")
|
||||||
int claim(@Param("id") BigInteger id,
|
int claim(@Param("id") BigInteger id,
|
||||||
@Param("token") String token,
|
@Param("token") String token,
|
||||||
|
|||||||
@@ -263,13 +263,7 @@ public class DocumentChunkServiceImpl
|
|||||||
throw new BusinessException("分块不存在");
|
throw new BusinessException("分块不存在");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return chunks.stream().map(chunk -> new DocumentChunkSyncStatus(
|
return syncTaskAppService.listSyncStatuses(chunks);
|
||||||
chunk.getId(),
|
|
||||||
chunk.getIndexSyncStatus(),
|
|
||||||
chunk.getIndexSyncVersion(),
|
|
||||||
chunk.getIndexSyncErrorCode(),
|
|
||||||
chunk.getIndexSyncErrorMessage()
|
|
||||||
)).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private DocumentChunk requireChunk(BigInteger knowledgeId, BigInteger chunkId) {
|
private DocumentChunk requireChunk(BigInteger knowledgeId, BigInteger chunkId) {
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
package tech.easyflow.ai.documentchunk;
|
package tech.easyflow.ai.documentchunk;
|
||||||
|
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import com.easyagents.core.document.Document;
|
||||||
|
import com.easyagents.core.model.embedding.EmbeddingModel;
|
||||||
|
import com.easyagents.core.model.exception.ModelException;
|
||||||
|
import com.easyagents.core.store.DocumentStore;
|
||||||
|
import com.easyagents.core.store.StoreResult;
|
||||||
|
import com.easyagents.core.store.VectorData;
|
||||||
|
import com.easyagents.search.engine.service.DocumentSearcher;
|
||||||
import org.mockito.InOrder;
|
import org.mockito.InOrder;
|
||||||
import org.mockito.Mockito;
|
import org.mockito.Mockito;
|
||||||
import org.springframework.transaction.PlatformTransactionManager;
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
@@ -10,6 +18,8 @@ import tech.easyflow.ai.config.AiMilvusConfig;
|
|||||||
import tech.easyflow.ai.config.SearcherFactory;
|
import tech.easyflow.ai.config.SearcherFactory;
|
||||||
import tech.easyflow.ai.entity.DocumentChunk;
|
import tech.easyflow.ai.entity.DocumentChunk;
|
||||||
import tech.easyflow.ai.entity.DocumentChunkSyncTask;
|
import tech.easyflow.ai.entity.DocumentChunkSyncTask;
|
||||||
|
import tech.easyflow.ai.entity.DocumentCollection;
|
||||||
|
import tech.easyflow.ai.entity.Model;
|
||||||
import tech.easyflow.ai.mapper.DocumentChunkMapper;
|
import tech.easyflow.ai.mapper.DocumentChunkMapper;
|
||||||
import tech.easyflow.ai.mapper.DocumentChunkSyncTaskMapper;
|
import tech.easyflow.ai.mapper.DocumentChunkSyncTaskMapper;
|
||||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
@@ -25,6 +35,144 @@ import java.util.function.Supplier;
|
|||||||
*/
|
*/
|
||||||
public class DocumentChunkSyncTaskAppServiceTest {
|
public class DocumentChunkSyncTaskAppServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void pollingShouldExposeCurrentRetryReasonWhileWaitingAndRunning() {
|
||||||
|
Fixture fixture = fixture(1);
|
||||||
|
fixture.task.setErrorCode("EMBEDDING_REQUEST_FAILED");
|
||||||
|
fixture.task.setErrorMessage("向量模型服务调用失败");
|
||||||
|
Mockito.when(fixture.taskMapper.selectCurrentForChunks(List.of(fixture.chunkId)))
|
||||||
|
.thenReturn(List.of(fixture.task));
|
||||||
|
|
||||||
|
var pending = fixture.service.listSyncStatuses(List.of(fixture.chunk)).get(0);
|
||||||
|
Assert.assertEquals("PENDING", pending.indexSyncStatus());
|
||||||
|
Assert.assertEquals("EMBEDDING_REQUEST_FAILED", pending.indexSyncErrorCode());
|
||||||
|
Assert.assertEquals(Integer.valueOf(1), pending.indexSyncAttemptCount());
|
||||||
|
Assert.assertEquals(5, pending.indexSyncMaxAttempts());
|
||||||
|
|
||||||
|
fixture.task.setStatus(DocumentChunkSyncState.TASK_RUNNING);
|
||||||
|
fixture.task.setAttemptCount(2);
|
||||||
|
var running = fixture.service.listSyncStatuses(List.of(fixture.chunk)).get(0);
|
||||||
|
Assert.assertEquals("EMBEDDING_REQUEST_FAILED", running.indexSyncErrorCode());
|
||||||
|
Assert.assertEquals(Integer.valueOf(2), running.indexSyncAttemptCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void pollingShouldNotExposeAnotherVersionsOrFinishedTasksFailure() {
|
||||||
|
Fixture fixture = fixture(2);
|
||||||
|
fixture.task.setErrorCode("EMBEDDING_REQUEST_FAILED");
|
||||||
|
Mockito.when(fixture.taskMapper.selectCurrentForChunks(List.of(fixture.chunkId)))
|
||||||
|
.thenReturn(List.of(fixture.task));
|
||||||
|
fixture.task.setSyncVersion(2L);
|
||||||
|
var changed = fixture.service.listSyncStatuses(List.of(fixture.chunk)).get(0);
|
||||||
|
Assert.assertNull(changed.indexSyncErrorCode());
|
||||||
|
Assert.assertNull(changed.indexSyncAttemptCount());
|
||||||
|
|
||||||
|
fixture.task.setSyncVersion(1L);
|
||||||
|
fixture.task.setStatus(DocumentChunkSyncState.TASK_SUCCEEDED);
|
||||||
|
var finished = fixture.service.listSyncStatuses(List.of(fixture.chunk)).get(0);
|
||||||
|
Assert.assertNull(finished.indexSyncErrorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void pollingShouldHandleChunksWithoutTasksAndSkipEmptyBatch() {
|
||||||
|
Fixture fixture = fixture(1);
|
||||||
|
Assert.assertTrue(fixture.service.listSyncStatuses(List.of()).isEmpty());
|
||||||
|
Mockito.verify(fixture.taskMapper, Mockito.never()).selectCurrentForChunks(Mockito.anyList());
|
||||||
|
Mockito.when(fixture.taskMapper.selectCurrentForChunks(List.of(fixture.chunkId)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
var state = fixture.service.listSyncStatuses(List.of(fixture.chunk)).get(0);
|
||||||
|
Assert.assertEquals("PENDING", state.indexSyncStatus());
|
||||||
|
Assert.assertNull(state.indexSyncAttemptCount());
|
||||||
|
Assert.assertNull(state.indexSyncErrorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void embeddingFailureShouldBeReportedWithoutCallingIndexes() {
|
||||||
|
Fixture fixture = fixture(1);
|
||||||
|
IndexFixture indexes = prepareIndexes(fixture);
|
||||||
|
Mockito.when(indexes.embeddingModel.embed(Mockito.any(Document.class), Mockito.any()))
|
||||||
|
.thenThrow(new ModelException("response is null or empty."));
|
||||||
|
|
||||||
|
fixture.service.handleTask(fixture.taskId);
|
||||||
|
|
||||||
|
Mockito.verify(fixture.taskMapper).failOrRetryOwned(
|
||||||
|
Mockito.eq(fixture.taskId), Mockito.anyString(), Mockito.eq("PENDING"), Mockito.any(),
|
||||||
|
Mockito.eq("EMBEDDING_REQUEST_FAILED"), Mockito.eq("向量模型服务调用失败"), Mockito.any()
|
||||||
|
);
|
||||||
|
Mockito.verify(indexes.store, Mockito.never()).doUpdate(Mockito.anyList(), Mockito.any());
|
||||||
|
Mockito.verifyNoInteractions(indexes.searcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void vectorFailureShouldHaveItsOwnReasonAndEmbedOnlyOnce() {
|
||||||
|
Fixture fixture = fixture(1);
|
||||||
|
IndexFixture indexes = prepareIndexes(fixture);
|
||||||
|
Mockito.when(indexes.store.doUpdate(Mockito.anyList(), Mockito.any()))
|
||||||
|
.thenReturn(StoreResult.fail("vector write failed"));
|
||||||
|
|
||||||
|
fixture.service.handleTask(fixture.taskId);
|
||||||
|
|
||||||
|
Mockito.verify(fixture.taskMapper).failOrRetryOwned(
|
||||||
|
Mockito.eq(fixture.taskId), Mockito.anyString(), Mockito.eq("PENDING"), Mockito.any(),
|
||||||
|
Mockito.eq("VECTOR_UPSERT_FAILED"), Mockito.anyString(), Mockito.any()
|
||||||
|
);
|
||||||
|
Mockito.verify(indexes.embeddingModel).embed(Mockito.any(Document.class), Mockito.any());
|
||||||
|
Mockito.verifyNoInteractions(indexes.searcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void keywordFailureShouldHaveItsOwnReason() {
|
||||||
|
Fixture fixture = fixture(1);
|
||||||
|
IndexFixture indexes = prepareIndexes(fixture);
|
||||||
|
Mockito.when(indexes.searcher.addDocuments(Mockito.anyList())).thenReturn(false);
|
||||||
|
|
||||||
|
fixture.service.handleTask(fixture.taskId);
|
||||||
|
|
||||||
|
Mockito.verify(fixture.taskMapper).failOrRetryOwned(
|
||||||
|
Mockito.eq(fixture.taskId), Mockito.anyString(), Mockito.eq("PENDING"), Mockito.any(),
|
||||||
|
Mockito.eq("KEYWORD_UPSERT_FAILED"), Mockito.anyString(), Mockito.any()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void successfulIndexUpdateShouldClearFailureAndMarkChunkSynced() {
|
||||||
|
Fixture fixture = fixture(2);
|
||||||
|
fixture.task.setErrorCode("EMBEDDING_REQUEST_FAILED");
|
||||||
|
IndexFixture indexes = prepareIndexes(fixture);
|
||||||
|
Mockito.when(indexes.searcher.addDocuments(Mockito.anyList())).thenReturn(true);
|
||||||
|
|
||||||
|
fixture.service.handleTask(fixture.taskId);
|
||||||
|
|
||||||
|
Mockito.verify(fixture.taskMapper).finishOwned(
|
||||||
|
Mockito.eq(fixture.taskId), Mockito.anyString(), Mockito.eq("SUCCEEDED"),
|
||||||
|
Mockito.isNull(), Mockito.isNull(), Mockito.any()
|
||||||
|
);
|
||||||
|
Mockito.verify(fixture.chunkMapper).updateSyncState(fixture.chunkId, 1L, "SYNCED", null, null);
|
||||||
|
Mockito.verify(indexes.embeddingModel).embed(Mockito.any(Document.class), Mockito.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IndexFixture prepareIndexes(Fixture fixture) {
|
||||||
|
DocumentCollection collection = Mockito.mock(DocumentCollection.class);
|
||||||
|
DocumentStore store = Mockito.mock(DocumentStore.class, Mockito.CALLS_REAL_METHODS);
|
||||||
|
Model model = Mockito.mock(Model.class);
|
||||||
|
EmbeddingModel embeddingModel = Mockito.mock(EmbeddingModel.class);
|
||||||
|
DocumentSearcher searcher = Mockito.mock(DocumentSearcher.class);
|
||||||
|
Mockito.when(fixture.collectionService.getById(fixture.task.getDocumentCollectionId()))
|
||||||
|
.thenReturn(collection);
|
||||||
|
Mockito.when(collection.toDocumentStore()).thenReturn(store);
|
||||||
|
Mockito.when(fixture.modelService.getModelInstance(Mockito.any())).thenReturn(model);
|
||||||
|
Mockito.when(model.toEmbeddingModel()).thenReturn(embeddingModel);
|
||||||
|
Mockito.when(fixture.searcherFactory.getSearcher()).thenReturn(searcher);
|
||||||
|
VectorData vector = new VectorData();
|
||||||
|
vector.setVector(new float[] { 0.1f, 0.2f });
|
||||||
|
Mockito.when(embeddingModel.embed(Mockito.any(Document.class), Mockito.any())).thenReturn(vector);
|
||||||
|
Mockito.when(store.doUpdate(Mockito.anyList(), Mockito.any())).thenReturn(StoreResult.success());
|
||||||
|
return new IndexFixture(store, embeddingModel, searcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record IndexFixture(DocumentStore store, EmbeddingModel embeddingModel, DocumentSearcher searcher) {
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void dispatchPendingShouldRecoverExpiredTasksAndSendClaimedRows() {
|
public void dispatchPendingShouldRecoverExpiredTasksAndSendClaimedRows() {
|
||||||
Fixture fixture = fixture(1);
|
Fixture fixture = fixture(1);
|
||||||
@@ -215,7 +363,7 @@ public class DocumentChunkSyncTaskAppServiceTest {
|
|||||||
Mockito.mock(ObjectProvider.class)
|
Mockito.mock(ObjectProvider.class)
|
||||||
);
|
);
|
||||||
return new Fixture(service, taskMapper, chunkMapper, collectionService,
|
return new Fixture(service, taskMapper, chunkMapper, collectionService,
|
||||||
producer, task, chunk, taskId, chunkId);
|
producer, task, chunk, taskId, chunkId, modelService, searcherFactory);
|
||||||
}
|
}
|
||||||
|
|
||||||
private record Fixture(
|
private record Fixture(
|
||||||
@@ -227,7 +375,9 @@ public class DocumentChunkSyncTaskAppServiceTest {
|
|||||||
DocumentChunkSyncTask task,
|
DocumentChunkSyncTask task,
|
||||||
DocumentChunk chunk,
|
DocumentChunk chunk,
|
||||||
BigInteger taskId,
|
BigInteger taskId,
|
||||||
BigInteger chunkId
|
BigInteger chunkId,
|
||||||
|
ModelService modelService,
|
||||||
|
SearcherFactory searcherFactory
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package tech.easyflow.ai.easyagentsflow.service;
|
|||||||
|
|
||||||
import com.easyagents.document.core.exception.DocumentParseException;
|
import com.easyagents.document.core.exception.DocumentParseException;
|
||||||
import com.easyagents.flow.core.chain.ChainState;
|
import com.easyagents.flow.core.chain.ChainState;
|
||||||
|
import com.easyagents.flow.core.chain.WorkflowErrorReason;
|
||||||
|
import com.easyagents.flow.core.chain.WorkflowExecutionException;
|
||||||
import com.easyagents.flow.core.chain.ChainStatus;
|
import com.easyagents.flow.core.chain.ChainStatus;
|
||||||
import com.easyagents.flow.core.chain.ExceptionSummary;
|
import com.easyagents.flow.core.chain.ExceptionSummary;
|
||||||
import com.easyagents.flow.core.chain.NodeState;
|
import com.easyagents.flow.core.chain.NodeState;
|
||||||
@@ -205,7 +207,7 @@ public class TinyFlowServiceTest {
|
|||||||
* @throws Exception 测试依赖注入失败时抛出
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
public void shouldExposeJavascriptExecutionMessage()
|
public void shouldHideRawJavascriptExceptionDetails()
|
||||||
throws Exception {
|
throws Exception {
|
||||||
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
||||||
ChainStateRepository chainStateRepository =
|
ChainStateRepository chainStateRepository =
|
||||||
@@ -237,9 +239,9 @@ public class TinyFlowServiceTest {
|
|||||||
ChainInfo result = service.getChainStatus(
|
ChainInfo result = service.getChainStatus(
|
||||||
EXECUTE_ID, List.of(node(NodeStatus.READY)));
|
EXECUTE_ID, List.of(node(NodeStatus.READY)));
|
||||||
|
|
||||||
Assert.assertEquals(message, result.getMessage());
|
Assert.assertEquals(WorkflowErrorReason.WORKFLOW_INTERNAL_ERROR.getDefaultMessage(), result.getMessage());
|
||||||
Assert.assertEquals(
|
Assert.assertEquals(
|
||||||
message,
|
WorkflowErrorReason.WORKFLOW_INTERNAL_ERROR.getDefaultMessage(),
|
||||||
result.getNodes().get(NODE_ID).getMessage());
|
result.getNodes().get(NODE_ID).getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +251,7 @@ public class TinyFlowServiceTest {
|
|||||||
* @throws Exception 测试依赖注入失败时抛出
|
* @throws Exception 测试依赖注入失败时抛出
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
public void shouldExposeDocumentParseMessageWithoutExceptionClass()
|
public void shouldHideUnclassifiedDocumentCauseDetails()
|
||||||
throws Exception {
|
throws Exception {
|
||||||
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
ChainExecutor chainExecutor = mock(ChainExecutor.class);
|
||||||
ChainStateRepository chainStateRepository =
|
ChainStateRepository chainStateRepository =
|
||||||
@@ -274,7 +276,47 @@ public class TinyFlowServiceTest {
|
|||||||
|
|
||||||
ChainInfo result = service.getChainStatus(EXECUTE_ID, null);
|
ChainInfo result = service.getChainStatus(EXECUTE_ID, null);
|
||||||
|
|
||||||
Assert.assertEquals(message, result.getMessage());
|
Assert.assertEquals(WorkflowErrorReason.WORKFLOW_INTERNAL_ERROR.getDefaultMessage(), result.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldKeepFailureNodeWithEmptyNodeSelection() throws Exception {
|
||||||
|
ChainExecutor executor = mock(ChainExecutor.class);
|
||||||
|
ChainStateRepository states = mock(ChainStateRepository.class);
|
||||||
|
when(executor.getChainStateRepository()).thenReturn(states);
|
||||||
|
ChainState state = new ChainState();
|
||||||
|
state.setStatus(ChainStatus.FAILED);
|
||||||
|
state.setError(new ExceptionSummary(new WorkflowExecutionException(WorkflowErrorReason.MODEL_RATE_LIMITED,
|
||||||
|
"raw credentials"), EXECUTE_ID, NODE_ID, "模型分析"));
|
||||||
|
when(states.load(EXECUTE_ID)).thenReturn(state);
|
||||||
|
ChainInfo result = service(executor).getChainStatus(EXECUTE_ID, List.of());
|
||||||
|
Assert.assertTrue(result.getNodes().isEmpty());
|
||||||
|
Assert.assertEquals(NODE_ID, result.getError().getNodeId());
|
||||||
|
Assert.assertEquals("MODEL_RATE_LIMITED", result.getError().getReasonCode());
|
||||||
|
Assert.assertFalse(result.getMessage().contains("credentials"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void terminalWorkflowMustStopAdvertisingPendingRetry() throws Exception {
|
||||||
|
ChainExecutor executor = mock(ChainExecutor.class);
|
||||||
|
ChainStateRepository states = mock(ChainStateRepository.class);
|
||||||
|
NodeStateRepository nodes = mock(NodeStateRepository.class);
|
||||||
|
when(executor.getChainStateRepository()).thenReturn(states);
|
||||||
|
when(executor.getNodeStateRepository()).thenReturn(nodes);
|
||||||
|
NodeState failedAttempt = new NodeState();
|
||||||
|
failedAttempt.setStatus(NodeStatus.ERROR);
|
||||||
|
failedAttempt.setError(new ExceptionSummary(new WorkflowExecutionException(
|
||||||
|
WorkflowErrorReason.MODEL_TIMEOUT, "private cause"), EXECUTE_ID, NODE_ID, "模型分析"));
|
||||||
|
when(nodes.load(EXECUTE_ID, NODE_ID)).thenReturn(failedAttempt);
|
||||||
|
TinyFlowService service = service(executor);
|
||||||
|
for (ChainStatus status : new ChainStatus[]{ChainStatus.RUNNING, ChainStatus.FAILED, ChainStatus.CANCELLED}) {
|
||||||
|
ChainState state = new ChainState();
|
||||||
|
state.setStatus(status);
|
||||||
|
when(states.load(EXECUTE_ID)).thenReturn(state);
|
||||||
|
ChainInfo result = service.getChainStatus(EXECUTE_ID, List.of(node(NodeStatus.READY)));
|
||||||
|
Assert.assertEquals(status == ChainStatus.RUNNING, result.getNodes().get(NODE_ID).getError().isRetryable());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package tech.easyflow.ai.easyagentsflow.service;
|
||||||
|
|
||||||
|
import ch.qos.logback.classic.Logger;
|
||||||
|
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||||
|
import ch.qos.logback.core.read.ListAppender;
|
||||||
|
import com.easyagents.flow.core.chain.*;
|
||||||
|
import com.easyagents.flow.core.chain.event.ChainEndEvent;
|
||||||
|
import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
|
||||||
|
import com.easyagents.flow.core.chain.repository.InMemoryNodeStateRepository;
|
||||||
|
import com.easyagents.flow.core.chain.runtime.*;
|
||||||
|
import com.easyagents.flow.core.node.StartNode;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
import tech.easyflow.ai.easyagentsflow.entity.WorkflowExecutionError;
|
||||||
|
import tech.easyflow.common.web.error.RequestErrorProfile;
|
||||||
|
import tech.easyflow.common.web.error.WebErrorMapping;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
public class WorkflowFailureDiagnosticsTest {
|
||||||
|
@Test
|
||||||
|
public void singleRunResponseMustMatchLoggedExecutionId() {
|
||||||
|
Logger logger = (Logger) LoggerFactory.getLogger(ChainExecutor.class);
|
||||||
|
ListAppender<ILoggingEvent> logs = new ListAppender<>();
|
||||||
|
logs.start();
|
||||||
|
logger.addAppender(logs);
|
||||||
|
HttpServletRequest request = request();
|
||||||
|
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||||
|
try (Fixture fixture = new Fixture()) {
|
||||||
|
WorkflowExecutionErrorMapper.installRequestProfile();
|
||||||
|
RequestErrorProfile profile = (RequestErrorProfile) request.getAttribute(RequestErrorProfile.ATTRIBUTE_NAME);
|
||||||
|
String previousId = null;
|
||||||
|
for (int run = 0; run < 2; run++) {
|
||||||
|
WorkflowExecutionException failure = Assert.assertThrows(WorkflowExecutionException.class,
|
||||||
|
() -> fixture.executor.executeNode("diagnostics", "worker", Map.of("privateInput", "do-not-log")));
|
||||||
|
WebErrorMapping response = profile.map(request, failure);
|
||||||
|
Map<?, ?> data = (Map<?, ?>) response.data();
|
||||||
|
String executeId = (String) data.get("executeId");
|
||||||
|
Assert.assertNotNull(executeId);
|
||||||
|
Assert.assertNotEquals(previousId, executeId);
|
||||||
|
Assert.assertEquals(failure.getChainId(), executeId);
|
||||||
|
Assert.assertEquals(500, response.httpStatus());
|
||||||
|
WorkflowExecutionError error = (WorkflowExecutionError) data.get("error");
|
||||||
|
Assert.assertEquals("MODEL_TIMEOUT", error.getReasonCode());
|
||||||
|
Assert.assertEquals("worker", error.getNodeId());
|
||||||
|
ILoggingEvent event = logs.list.get(run);
|
||||||
|
assertLogContext(event, executeId);
|
||||||
|
Assert.assertTrue(event.getFormattedMessage().contains("attemptKey=" + executeId + ":worker:single"));
|
||||||
|
previousId = executeId;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
RequestContextHolder.resetRequestAttributes();
|
||||||
|
logger.detachAppender(logs);
|
||||||
|
logs.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void optionalExecutionIdMustPreserveExistingRequestMapping() {
|
||||||
|
HttpServletRequest request = request();
|
||||||
|
WebErrorMapping fallback = new WebErrorMapping(400, 400, "原请求错误", null);
|
||||||
|
request.setAttribute(RequestErrorProfile.ATTRIBUTE_NAME, (RequestErrorProfile) (req, failure) -> fallback);
|
||||||
|
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||||
|
try {
|
||||||
|
WorkflowExecutionErrorMapper.installRequestProfile();
|
||||||
|
RequestErrorProfile profile = (RequestErrorProfile) request.getAttribute(RequestErrorProfile.ATTRIBUTE_NAME);
|
||||||
|
Assert.assertSame(fallback, profile.map(request, new IllegalArgumentException()));
|
||||||
|
WebErrorMapping response = profile.map(request,
|
||||||
|
new WorkflowExecutionException(WorkflowErrorReason.INPUT_INVALID, "internal"));
|
||||||
|
Map<?, ?> data = (Map<?, ?>) response.data();
|
||||||
|
Assert.assertFalse(data.containsKey("executeId"));
|
||||||
|
Assert.assertEquals("INPUT_INVALID", ((WorkflowExecutionError) data.get("error")).getReasonCode());
|
||||||
|
} finally {
|
||||||
|
RequestContextHolder.resetRequestAttributes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void concurrentExecutionsAndRetriesMustHaveDistinctLogContexts() throws Exception {
|
||||||
|
Logger logger = (Logger) LoggerFactory.getLogger(Chain.class);
|
||||||
|
ListAppender<ILoggingEvent> logs = new ListAppender<>();
|
||||||
|
logs.start();
|
||||||
|
logger.addAppender(logs);
|
||||||
|
try (Fixture fixture = new Fixture()) {
|
||||||
|
CountDownLatch ended = new CountDownLatch(2);
|
||||||
|
fixture.executor.addEventListener((event, chain) -> {
|
||||||
|
if (event instanceof ChainEndEvent) ended.countDown();
|
||||||
|
});
|
||||||
|
String first = fixture.executor.executeAsync("diagnostics", Map.of());
|
||||||
|
String second = fixture.executor.executeAsync("diagnostics", Map.of());
|
||||||
|
Assert.assertTrue(ended.await(5, TimeUnit.SECONDS));
|
||||||
|
for (String executeId : List.of(first, second)) {
|
||||||
|
List<ILoggingEvent> attempts = logs.list.stream()
|
||||||
|
.filter(event -> event.getFormattedMessage().contains("executeId=" + executeId + ","))
|
||||||
|
.toList();
|
||||||
|
Assert.assertEquals(2, attempts.size());
|
||||||
|
attempts.forEach(event -> assertLogContext(event, executeId));
|
||||||
|
Assert.assertNotEquals(attempts.get(0).getArgumentArray()[4], attempts.get(1).getArgumentArray()[4]);
|
||||||
|
Assert.assertNotNull(attempts.get(0).getArgumentArray()[4]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
logger.detachAppender(logs);
|
||||||
|
logs.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpServletRequest request() {
|
||||||
|
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||||
|
Map<String, Object> attributes = new HashMap<>();
|
||||||
|
when(request.getAttribute(anyString())).thenAnswer(call -> attributes.get(call.getArgument(0)));
|
||||||
|
doAnswer(call -> {
|
||||||
|
attributes.put(call.getArgument(0), call.getArgument(1));
|
||||||
|
return null;
|
||||||
|
}).when(request).setAttribute(anyString(), any());
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertLogContext(ILoggingEvent event, String executeId) {
|
||||||
|
String message = event.getFormattedMessage();
|
||||||
|
Assert.assertTrue(message.contains("executeId=" + executeId + ","));
|
||||||
|
Assert.assertTrue(message.contains("chainInstanceId=" + executeId + ","));
|
||||||
|
Assert.assertTrue(message.contains("nodeId=worker, nodeName=模型分析,"));
|
||||||
|
Assert.assertFalse(message.contains("do-not-log"));
|
||||||
|
Assert.assertNotNull(event.getThrowableProxy());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class Fixture implements AutoCloseable {
|
||||||
|
final TriggerScheduler scheduler = new TriggerScheduler(new InMemoryTriggerStore(),
|
||||||
|
Executors.newSingleThreadScheduledExecutor(), Executors.newFixedThreadPool(3), 1000);
|
||||||
|
final ChainExecutor executor;
|
||||||
|
|
||||||
|
Fixture() {
|
||||||
|
ChainDefinition definition = new ChainDefinition();
|
||||||
|
definition.setId("diagnostics");
|
||||||
|
StartNode start = new StartNode(); start.setId("start"); definition.addNode(start);
|
||||||
|
Node worker = new Node() {
|
||||||
|
public Map<String, Object> execute(Chain chain) {
|
||||||
|
throw new WorkflowExecutionException(WorkflowErrorReason.MODEL_TIMEOUT, "synthetic cause");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
worker.setId("worker"); worker.setName("模型分析");
|
||||||
|
worker.setRetryEnable(true); worker.setMaxRetryCount(1); worker.setRetryIntervalMs(5);
|
||||||
|
definition.addNode(worker);
|
||||||
|
Edge edge = new Edge(); edge.setId("start-worker"); edge.setSource("start"); edge.setTarget("worker");
|
||||||
|
definition.addEdge(edge);
|
||||||
|
executor = new ChainExecutor(id -> definition, new InMemoryChainStateRepository(),
|
||||||
|
new InMemoryNodeStateRepository(), scheduler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void close() { scheduler.shutdown(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,6 +154,11 @@ public class DocumentChunkServiceImplTest {
|
|||||||
Mockito.when(fixture.chunkMapper.selectSyncStates(
|
Mockito.when(fixture.chunkMapper.selectSyncStates(
|
||||||
fixture.documentId, List.of(fixture.chunkId)
|
fixture.documentId, List.of(fixture.chunkId)
|
||||||
)).thenReturn(List.of(state));
|
)).thenReturn(List.of(state));
|
||||||
|
Mockito.when(fixture.syncTaskAppService.listSyncStatuses(List.of(state)))
|
||||||
|
.thenReturn(List.of(new tech.easyflow.ai.dto.DocumentChunkSyncStatus(
|
||||||
|
state.getId(), state.getIndexSyncStatus(), state.getIndexSyncVersion(),
|
||||||
|
null, null, 0, 5
|
||||||
|
)));
|
||||||
|
|
||||||
List<tech.easyflow.ai.dto.DocumentChunkSyncStatus> result =
|
List<tech.easyflow.ai.dto.DocumentChunkSyncStatus> result =
|
||||||
fixture.service.listIndexSyncStatus(
|
fixture.service.listIndexSyncStatus(
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ import {
|
|||||||
} from '#/utils/workflow-share-context';
|
} from '#/utils/workflow-share-context';
|
||||||
|
|
||||||
import { refreshTokenApi } from './core';
|
import { refreshTokenApi } from './core';
|
||||||
import { isInactiveSseRequest } from './sseRequestLifecycle';
|
import {
|
||||||
|
isInactiveSseRequest,
|
||||||
|
readSseRequestError,
|
||||||
|
SseRequestError,
|
||||||
|
} from './sseRequestLifecycle';
|
||||||
|
|
||||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||||
const ERROR_MESSAGE_DEDUP_WINDOW = 800;
|
const ERROR_MESSAGE_DEDUP_WINDOW = 800;
|
||||||
@@ -235,7 +239,7 @@ export class SseClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const error = new Error(`HTTP ${res.status}: ${res.statusText}`);
|
const error = await readSseRequestError(res);
|
||||||
options?.onError?.(error);
|
options?.onError?.(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -258,7 +262,7 @@ export class SseClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
showErrorOnce(errorMessage);
|
showErrorOnce(errorMessage);
|
||||||
options?.onError?.(new Error(errorMessage));
|
options?.onError?.(new SseRequestError(res.status, errorMessage));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { isInactiveSseRequest } from './sseRequestLifecycle';
|
import {
|
||||||
|
isInactiveSseRequest,
|
||||||
|
readSseRequestError,
|
||||||
|
SseRequestError,
|
||||||
|
} from './sseRequestLifecycle';
|
||||||
|
|
||||||
describe('sseRequestLifecycle', () => {
|
describe('sseRequestLifecycle', () => {
|
||||||
it('treats an explicit abort as an inactive request', () => {
|
it('treats an explicit abort as an inactive request', () => {
|
||||||
@@ -17,3 +21,19 @@ describe('sseRequestLifecycle', () => {
|
|||||||
expect(isInactiveSseRequest(controller.signal, 1, 1)).toBe(false);
|
expect(isInactiveSseRequest(controller.signal, 1, 1)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps HTTP rejection distinguishable from transport interruption', async () => {
|
||||||
|
for (const status of [400, 403, 500]) {
|
||||||
|
const error = await readSseRequestError(
|
||||||
|
new Response(JSON.stringify({ message: '运行请求被拒绝' }), { status }),
|
||||||
|
);
|
||||||
|
expect(error).toBeInstanceOf(SseRequestError);
|
||||||
|
expect(error.status).toBe(status);
|
||||||
|
expect(error.message).toBe('运行请求被拒绝');
|
||||||
|
}
|
||||||
|
const error = await readSseRequestError(
|
||||||
|
new Response('<html>PRIVATE_GATEWAY_BODY</html>', { status: 502 }),
|
||||||
|
);
|
||||||
|
expect(error.message).toContain('502');
|
||||||
|
expect(error.message).not.toContain('PRIVATE_GATEWAY_BODY');
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,3 +8,24 @@ export function isInactiveSseRequest(
|
|||||||
) {
|
) {
|
||||||
return signal.aborted || currentRequestId !== requestId;
|
return signal.aborted || currentRequestId !== requestId;
|
||||||
}
|
}
|
||||||
|
/** 服务端明确拒绝请求,与已经建立的事件流断线区分。 */
|
||||||
|
export class SseRequestError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SseRequestError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readSseRequestError(response: Response) {
|
||||||
|
let message = `请求失败(HTTP ${response.status})`;
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
if (typeof body?.message === 'string') message = body.message;
|
||||||
|
} catch {
|
||||||
|
// 网关可能返回 HTML;只展示状态,不把原始正文当作错误文案。
|
||||||
|
}
|
||||||
|
return new SseRequestError(response.status, message);
|
||||||
|
}
|
||||||
|
|||||||
@@ -282,6 +282,10 @@
|
|||||||
"discardChanges": "Discard changes",
|
"discardChanges": "Discard changes",
|
||||||
"chunkSourceFallback": "Switched to source editing",
|
"chunkSourceFallback": "Switched to source editing",
|
||||||
"chunkSyncPending": "Updating search index",
|
"chunkSyncPending": "Updating search index",
|
||||||
|
"chunkSyncEmbeddingRetry": "Retrying embedding service ({attempt}/{maxAttempts})",
|
||||||
|
"chunkSyncVectorRetry": "Retrying vector index ({attempt}/{maxAttempts})",
|
||||||
|
"chunkSyncKeywordRetry": "Retrying keyword index ({attempt}/{maxAttempts})",
|
||||||
|
"chunkSyncIndexRetry": "Retrying search index ({attempt}/{maxAttempts})",
|
||||||
"chunkSyncSucceeded": "Search index updated",
|
"chunkSyncSucceeded": "Search index updated",
|
||||||
"chunkSyncFailed": "Index sync failed. Click to retry",
|
"chunkSyncFailed": "Index sync failed. Click to retry",
|
||||||
"chunkSyncRetryFailed": "Failed to retry index sync",
|
"chunkSyncRetryFailed": "Failed to retry index sync",
|
||||||
|
|||||||
@@ -282,6 +282,10 @@
|
|||||||
"discardChanges": "放弃修改",
|
"discardChanges": "放弃修改",
|
||||||
"chunkSourceFallback": "已切换到源码编辑",
|
"chunkSourceFallback": "已切换到源码编辑",
|
||||||
"chunkSyncPending": "正在更新检索索引",
|
"chunkSyncPending": "正在更新检索索引",
|
||||||
|
"chunkSyncEmbeddingRetry": "向量模型服务重试中({attempt}/{maxAttempts})",
|
||||||
|
"chunkSyncVectorRetry": "向量索引重试中({attempt}/{maxAttempts})",
|
||||||
|
"chunkSyncKeywordRetry": "关键词索引重试中({attempt}/{maxAttempts})",
|
||||||
|
"chunkSyncIndexRetry": "检索索引重试中({attempt}/{maxAttempts})",
|
||||||
"chunkSyncSucceeded": "检索索引已更新",
|
"chunkSyncSucceeded": "检索索引已更新",
|
||||||
"chunkSyncFailed": "索引同步失败,点击重试",
|
"chunkSyncFailed": "索引同步失败,点击重试",
|
||||||
"chunkSyncRetryFailed": "索引同步重试失败",
|
"chunkSyncRetryFailed": "索引同步重试失败",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ vi.mock('element-plus', async (importOriginal) => {
|
|||||||
vi.mock('#/api/request', () => ({ api: {} }));
|
vi.mock('#/api/request', () => ({ api: {} }));
|
||||||
|
|
||||||
vi.mock('@easyflow/locales', () => ({
|
vi.mock('@easyflow/locales', () => ({
|
||||||
$t: (key: string) => {
|
$t: (key: string, params: Record<string, number> = {}) => {
|
||||||
const messages: Record<string, string> = {
|
const messages: Record<string, string> = {
|
||||||
'documentCollection.continueEditing': '继续编辑',
|
'documentCollection.continueEditing': '继续编辑',
|
||||||
'documentCollection.deleteChunk': '删除分块',
|
'documentCollection.deleteChunk': '删除分块',
|
||||||
@@ -54,8 +54,18 @@ vi.mock('@easyflow/locales', () => ({
|
|||||||
'documentCollection.chunkSyncSucceeded': '检索索引已更新',
|
'documentCollection.chunkSyncSucceeded': '检索索引已更新',
|
||||||
'documentCollection.chunkSyncFailed': '索引同步失败,点击重试',
|
'documentCollection.chunkSyncFailed': '索引同步失败,点击重试',
|
||||||
'documentCollection.chunkSyncRetryFailed': '索引同步重试失败',
|
'documentCollection.chunkSyncRetryFailed': '索引同步重试失败',
|
||||||
|
'documentCollection.chunkSyncEmbeddingRetry':
|
||||||
|
'向量模型服务重试中({attempt}/{maxAttempts})',
|
||||||
|
'documentCollection.chunkSyncVectorRetry':
|
||||||
|
'向量索引重试中({attempt}/{maxAttempts})',
|
||||||
|
'documentCollection.chunkSyncKeywordRetry':
|
||||||
|
'关键词索引重试中({attempt}/{maxAttempts})',
|
||||||
|
'documentCollection.chunkSyncIndexRetry':
|
||||||
|
'检索索引重试中({attempt}/{maxAttempts})',
|
||||||
};
|
};
|
||||||
return messages[key] || key;
|
return (messages[key] || key).replaceAll(/\{(\w+)\}/g, (_, name: string) =>
|
||||||
|
String(params[name]),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -652,6 +662,113 @@ describe('chunkDocumentTable', () => {
|
|||||||
expect(vi.getTimerCount()).toBe(0);
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['EMBEDDING_REQUEST_FAILED', '向量模型服务重试中'],
|
||||||
|
['VECTOR_UPSERT_FAILED', '向量索引重试中'],
|
||||||
|
['KEYWORD_UPSERT_FAILED', '关键词索引重试中'],
|
||||||
|
['INDEX_UPSERT_FAILED', '检索索引重试中'],
|
||||||
|
])('轮询展示真实次数并区分重试原因 %s', async (code, label) => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const pendingRow = { ...row, indexSyncStatus: 'PENDING' };
|
||||||
|
const post = vi.fn().mockResolvedValue({
|
||||||
|
errorCode: 0,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: row.id,
|
||||||
|
indexSyncStatus: 'PENDING',
|
||||||
|
indexSyncVersion: row.indexSyncVersion,
|
||||||
|
indexSyncErrorCode: code,
|
||||||
|
indexSyncAttemptCount: 1,
|
||||||
|
indexSyncMaxAttempts: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { wrapper } = mountTable(post, [pendingRow]);
|
||||||
|
await flushPromises();
|
||||||
|
expect(wrapper.find('.chunk-sync-retry-hint').exists()).toBe(false);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
await flushPromises();
|
||||||
|
expect(wrapper.get('.chunk-sync-retry-hint').text()).toBe(
|
||||||
|
`${label}(1/5)`,
|
||||||
|
);
|
||||||
|
expect(wrapper.get('.chunk-sync-retry-hint').attributes('role')).toBe(
|
||||||
|
'status',
|
||||||
|
);
|
||||||
|
|
||||||
|
post.mockResolvedValue({
|
||||||
|
errorCode: 0,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: row.id,
|
||||||
|
indexSyncStatus: 'SYNCED',
|
||||||
|
indexSyncVersion: row.indexSyncVersion,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
await flushPromises();
|
||||||
|
expect(wrapper.find('.chunk-sync-retry-hint').exists()).toBe(false);
|
||||||
|
expect(
|
||||||
|
(wrapper.findComponent(PageData).vm as any).getPageRows()[0]
|
||||||
|
.indexSyncErrorCode,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['xlsx', 'pdf'])(
|
||||||
|
'两种分块布局均展示重试提示:%s',
|
||||||
|
async (sourceFileExt) => {
|
||||||
|
const { wrapper } = mountTable(vi.fn(), [
|
||||||
|
{
|
||||||
|
...row,
|
||||||
|
options: {
|
||||||
|
...row.options,
|
||||||
|
sourceFileExt,
|
||||||
|
sheetName: sourceFileExt === 'xlsx' ? 'Sheet1' : undefined,
|
||||||
|
},
|
||||||
|
indexSyncStatus: 'PENDING',
|
||||||
|
indexSyncErrorCode: 'EMBEDDING_REQUEST_FAILED',
|
||||||
|
indexSyncAttemptCount: 2,
|
||||||
|
indexSyncMaxAttempts: 5,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await flushPromises();
|
||||||
|
expect(wrapper.get('.chunk-sync-retry-hint').text()).toBe(
|
||||||
|
'向量模型服务重试中(2/5)',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('新版本保存后清除上个版本的重试提示', async () => {
|
||||||
|
const { wrapper } = mountTable(
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
errorCode: 0,
|
||||||
|
data: {
|
||||||
|
content: '新正文',
|
||||||
|
indexSyncStatus: 'PENDING',
|
||||||
|
indexSyncVersion: 2,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...row,
|
||||||
|
indexSyncStatus: 'PENDING',
|
||||||
|
indexSyncVersion: 1,
|
||||||
|
indexSyncErrorCode: 'EMBEDDING_REQUEST_FAILED',
|
||||||
|
indexSyncAttemptCount: 2,
|
||||||
|
indexSyncMaxAttempts: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await flushPromises();
|
||||||
|
await getButton(wrapper, 'button.edit').trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
await wrapper.get('.editor-stub').setValue('新正文');
|
||||||
|
await getButton(wrapper, 'button.save').trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
expect(wrapper.find('.chunk-sync-retry-hint').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('实时编辑器保真失败时使用同一草稿切换源码模式', async () => {
|
it('实时编辑器保真失败时使用同一草稿切换源码模式', async () => {
|
||||||
const { wrapper } = mountTable();
|
const { wrapper } = mountTable();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|||||||
@@ -153,6 +153,30 @@ const isSyncSuccessVisible = (row: any) =>
|
|||||||
syncSuccessVersions.value[String(row?.id || '')] ===
|
syncSuccessVersions.value[String(row?.id || '')] ===
|
||||||
String(row?.indexSyncVersion ?? '');
|
String(row?.indexSyncVersion ?? '');
|
||||||
|
|
||||||
|
const getSyncRetryLabel = (row: any) => {
|
||||||
|
const attempt = Number(row?.indexSyncAttemptCount);
|
||||||
|
const maxAttempts = Number(row?.indexSyncMaxAttempts);
|
||||||
|
if (
|
||||||
|
row?.indexSyncStatus !== 'PENDING' ||
|
||||||
|
!row.indexSyncErrorCode ||
|
||||||
|
!Number.isInteger(attempt) ||
|
||||||
|
attempt <= 0 ||
|
||||||
|
!Number.isInteger(maxAttempts) ||
|
||||||
|
maxAttempts <= 0
|
||||||
|
) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const retryKeys: Record<string, string> = {
|
||||||
|
EMBEDDING_REQUEST_FAILED: 'chunkSyncEmbeddingRetry',
|
||||||
|
KEYWORD_UPSERT_FAILED: 'chunkSyncKeywordRetry',
|
||||||
|
VECTOR_UPSERT_FAILED: 'chunkSyncVectorRetry',
|
||||||
|
};
|
||||||
|
return $t(
|
||||||
|
`documentCollection.${retryKeys[row.indexSyncErrorCode] || 'chunkSyncIndexRetry'}`,
|
||||||
|
{ attempt, maxAttempts },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const getChunkOptions = (row: any) => row?.options || {};
|
const getChunkOptions = (row: any) => row?.options || {};
|
||||||
const getRawMarkdown = (row: any) =>
|
const getRawMarkdown = (row: any) =>
|
||||||
String(getChunkOptions(row)?.renderMarkdown ?? row?.content ?? '');
|
String(getChunkOptions(row)?.renderMarkdown ?? row?.content ?? '');
|
||||||
@@ -323,6 +347,8 @@ const updateRow = (row: any, updated: any) => {
|
|||||||
content: updated?.content ?? row.content,
|
content: updated?.content ?? row.content,
|
||||||
indexSyncErrorCode: updated?.indexSyncErrorCode ?? null,
|
indexSyncErrorCode: updated?.indexSyncErrorCode ?? null,
|
||||||
indexSyncErrorMessage: updated?.indexSyncErrorMessage ?? null,
|
indexSyncErrorMessage: updated?.indexSyncErrorMessage ?? null,
|
||||||
|
indexSyncAttemptCount: updated?.indexSyncAttemptCount ?? null,
|
||||||
|
indexSyncMaxAttempts: updated?.indexSyncMaxAttempts ?? null,
|
||||||
indexSyncStatus: nextSyncStatus,
|
indexSyncStatus: nextSyncStatus,
|
||||||
indexSyncVersion: nextSyncVersion,
|
indexSyncVersion: nextSyncVersion,
|
||||||
options: updated?.options ?? {
|
options: updated?.options ?? {
|
||||||
@@ -390,7 +416,13 @@ const pollSyncStatus = async (generation: number) => {
|
|||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
pageDataRef.value?.patchRowById?.(status.id, status);
|
pageDataRef.value?.patchRowById?.(status.id, {
|
||||||
|
...status,
|
||||||
|
indexSyncAttemptCount: status.indexSyncAttemptCount ?? null,
|
||||||
|
indexSyncMaxAttempts: status.indexSyncMaxAttempts ?? null,
|
||||||
|
indexSyncErrorCode: status.indexSyncErrorCode ?? null,
|
||||||
|
indexSyncErrorMessage: status.indexSyncErrorMessage ?? null,
|
||||||
|
});
|
||||||
if (status.indexSyncStatus === 'SYNCED') {
|
if (status.indexSyncStatus === 'SYNCED') {
|
||||||
showSyncSuccessFeedback(status);
|
showSyncSuccessFeedback(status);
|
||||||
} else {
|
} else {
|
||||||
@@ -458,6 +490,8 @@ const retrySync = async (row: any) => {
|
|||||||
pageDataRef.value?.patchRowById?.(row.id, {
|
pageDataRef.value?.patchRowById?.(row.id, {
|
||||||
indexSyncErrorCode: res.data?.indexSyncErrorCode ?? null,
|
indexSyncErrorCode: res.data?.indexSyncErrorCode ?? null,
|
||||||
indexSyncErrorMessage: res.data?.indexSyncErrorMessage ?? null,
|
indexSyncErrorMessage: res.data?.indexSyncErrorMessage ?? null,
|
||||||
|
indexSyncAttemptCount: res.data?.indexSyncAttemptCount ?? null,
|
||||||
|
indexSyncMaxAttempts: res.data?.indexSyncMaxAttempts ?? null,
|
||||||
indexSyncStatus: res.data?.indexSyncStatus,
|
indexSyncStatus: res.data?.indexSyncStatus,
|
||||||
indexSyncVersion: res.data?.indexSyncVersion,
|
indexSyncVersion: res.data?.indexSyncVersion,
|
||||||
});
|
});
|
||||||
@@ -785,6 +819,13 @@ const getChunkHeaderLabel = (row: any) => {
|
|||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="getSyncRetryLabel(row)"
|
||||||
|
class="chunk-sync-retry-hint text-xs"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ getSyncRetryLabel(row) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isEditing(row)"
|
v-if="isEditing(row)"
|
||||||
@@ -1033,6 +1074,13 @@ const getChunkHeaderLabel = (row: any) => {
|
|||||||
{{ $t('button.delete') }}
|
{{ $t('button.delete') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="getSyncRetryLabel(row)"
|
||||||
|
class="chunk-sync-retry-hint text-xs"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ getSyncRetryLabel(row) }}
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
</ElTable>
|
</ElTable>
|
||||||
@@ -1089,6 +1137,21 @@ const getChunkHeaderLabel = (row: any) => {
|
|||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chunk-sync-retry-hint {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
font-weight: normal;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
text-align: right;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-item > .chunk-sync-retry-hint {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.chunk-sync-status {
|
.chunk-sync-status {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
flex: 0 0 24px;
|
flex: 0 0 24px;
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ const workflowInfo = ref<any>({});
|
|||||||
const initializationError = ref(false);
|
const initializationError = ref(false);
|
||||||
const runParams = ref<any>(null);
|
const runParams = ref<any>(null);
|
||||||
const tinyFlowData = shallowRef<any>(null);
|
const tinyFlowData = shallowRef<any>(null);
|
||||||
|
const runFlowData = shallowRef<any>(null);
|
||||||
|
const runNodes = computed(() =>
|
||||||
|
runFlowData.value ? sortNodes(runFlowData.value) : [],
|
||||||
|
);
|
||||||
const onlyRenderVisibleWorkflowElements = computed(
|
const onlyRenderVisibleWorkflowElements = computed(
|
||||||
() =>
|
() =>
|
||||||
(tinyFlowData.value?.nodes?.length || 0) >=
|
(tinyFlowData.value?.nodes?.length || 0) >=
|
||||||
@@ -592,6 +596,9 @@ function getRunningParams() {
|
|||||||
.get(`/api/v1/workflow/getRunningParameters?id=${workflowId.value}`)
|
.get(`/api/v1/workflow/getRunningParameters?id=${workflowId.value}`)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
|
workflowForm.value?.reset();
|
||||||
|
runFlowData.value = tinyFlowData.value;
|
||||||
|
onSubmit();
|
||||||
runParams.value = res.data;
|
runParams.value = res.data;
|
||||||
drawerVisible.value = true;
|
drawerVisible.value = true;
|
||||||
}
|
}
|
||||||
@@ -752,6 +759,7 @@ async function handlePublishAction() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onSubmit() {
|
function onSubmit() {
|
||||||
|
chainInfo.value = null;
|
||||||
initState.value = !initState.value;
|
initState.value = !initState.value;
|
||||||
}
|
}
|
||||||
async function runIndependently(node: any) {
|
async function runIndependently(node: any) {
|
||||||
@@ -872,12 +880,12 @@ function onAsyncExecute(info: any) {
|
|||||||
:workflow-params="runParams"
|
:workflow-params="runParams"
|
||||||
:on-submit="onSubmit"
|
:on-submit="onSubmit"
|
||||||
:on-async-execute="onAsyncExecute"
|
:on-async-execute="onAsyncExecute"
|
||||||
:tiny-flow-data="tinyFlowData"
|
:tiny-flow-data="runFlowData"
|
||||||
/>
|
/>
|
||||||
<div class="mb-2.5 font-semibold">{{ $t('aiWorkflow.steps') }}:</div>
|
<div class="mb-2.5 font-semibold">{{ $t('aiWorkflow.steps') }}:</div>
|
||||||
<WorkflowSteps
|
<WorkflowSteps
|
||||||
:workflow-id="workflowId"
|
:workflow-id="workflowId"
|
||||||
:node-json="sortNodes(tinyFlowData)"
|
:node-json="runNodes"
|
||||||
:init-signal="initState"
|
:init-signal="initState"
|
||||||
:polling-data="chainInfo"
|
:polling-data="chainInfo"
|
||||||
@resume="resumeChain"
|
@resume="resumeChain"
|
||||||
@@ -887,7 +895,7 @@ function onAsyncExecute(info: any) {
|
|||||||
</div>
|
</div>
|
||||||
<ExecResult
|
<ExecResult
|
||||||
:workflow-id="workflowId"
|
:workflow-id="workflowId"
|
||||||
:node-json="sortNodes(tinyFlowData)"
|
:node-json="runNodes"
|
||||||
:init-signal="initState"
|
:init-signal="initState"
|
||||||
:polling-data="chainInfo"
|
:polling-data="chainInfo"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -987,7 +987,7 @@ const apiDocMarkdown = computed(() => {
|
|||||||
lines.push(`| 413 | 41301 | 文件、文件数量或请求总量超限 |`);
|
lines.push(`| 413 | 41301 | 文件、文件数量或请求总量超限 |`);
|
||||||
lines.push(`| 415 | 41501 | 顶层 Content-Type 缺失或不支持 |`);
|
lines.push(`| 415 | 41501 | 顶层 Content-Type 缺失或不支持 |`);
|
||||||
lines.push(`| 415 | 41502 | metadata Part 未使用 application/json |`);
|
lines.push(`| 415 | 41502 | metadata Part 未使用 application/json |`);
|
||||||
lines.push(`| 500 | 50001 | 服务端内部错误;携带 requestId 联系管理员 |`);
|
lines.push(`| 500 | 50001 | 服务端内部错误 |`);
|
||||||
lines.push(`| 503 | 50301 | 文件存储暂时不可用,可稍后重试 |`);
|
lines.push(`| 503 | 50301 | 文件存储暂时不可用,可稍后重试 |`);
|
||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(
|
lines.push(
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import WorkflowForm from '../components/WorkflowForm.vue';
|
||||||
|
import WorkflowSteps from '../components/WorkflowSteps.vue';
|
||||||
|
import WorkflowDesign from '../WorkflowDesign.vue';
|
||||||
|
|
||||||
|
const { get, post, getData, sortNodes } = vi.hoisted(() => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
getData: vi.fn(),
|
||||||
|
sortNodes: vi.fn((flow) =>
|
||||||
|
flow.nodes.map((node: any) => ({
|
||||||
|
key: node.id,
|
||||||
|
label: node.data.title,
|
||||||
|
original: node,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock('#/api/request', () => ({ api: { get, post } }));
|
||||||
|
vi.mock('#/router', () => ({ router: { replace: vi.fn() } }));
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRoute: () => ({ query: { id: 'workflow-1', navTitle: '测试' } }),
|
||||||
|
}));
|
||||||
|
vi.mock('@easyflow/preferences', () => ({
|
||||||
|
usePreferences: () => ({ isDark: false }),
|
||||||
|
}));
|
||||||
|
vi.mock('@easyflow/utils', () => ({ sortNodes }));
|
||||||
|
vi.mock('../customNode/index', () => ({ getCustomNode: async () => ({}) }));
|
||||||
|
vi.mock('#/views/ai/model/modelUtils/defaultIcon', () => ({
|
||||||
|
getIconByValue: () => '',
|
||||||
|
}));
|
||||||
|
vi.mock('#/components/commonSelectModal/CommonSelectDataModal.vue', () => ({
|
||||||
|
default: { template: '<div />' },
|
||||||
|
}));
|
||||||
|
vi.mock('../components/SingleRun.vue', () => ({
|
||||||
|
default: { template: '<div />' },
|
||||||
|
}));
|
||||||
|
vi.mock('../components/ExecResult.vue', () => ({
|
||||||
|
default: { props: ['nodeJson'], template: '<div />' },
|
||||||
|
}));
|
||||||
|
vi.mock('@tinyflow-ai/vue', async () => {
|
||||||
|
const { defineComponent, h } = await import('vue');
|
||||||
|
return {
|
||||||
|
Tinyflow: defineComponent({
|
||||||
|
props: {
|
||||||
|
data: { type: Object, required: true },
|
||||||
|
onRunTest: { type: Function, required: true },
|
||||||
|
},
|
||||||
|
setup(props, { expose }) {
|
||||||
|
expose({ getData });
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
'button',
|
||||||
|
{ 'data-test': 'try-run', onClick: props.onRunTest },
|
||||||
|
'试运行',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('workflow designer run snapshot', () => {
|
||||||
|
it('saves new nodes and edited configuration, shares cached display nodes and clears old status', async () => {
|
||||||
|
const graph: any = {
|
||||||
|
nodes: [{ id: 'start', type: 'startNode', data: { title: '开始' } }],
|
||||||
|
edges: [],
|
||||||
|
};
|
||||||
|
let saved = structuredClone(graph);
|
||||||
|
getData.mockImplementation(() => structuredClone(graph));
|
||||||
|
get.mockImplementation(async (url) => {
|
||||||
|
let data = {};
|
||||||
|
if (url.includes('/detail')) {
|
||||||
|
data = {
|
||||||
|
id: 'workflow-1',
|
||||||
|
title: '测试',
|
||||||
|
content: JSON.stringify(saved),
|
||||||
|
};
|
||||||
|
} else if (url.includes('/getRunningParameters')) {
|
||||||
|
data = { parameters: [], startFormMeta: { submitText: '开始' } };
|
||||||
|
}
|
||||||
|
return { errorCode: 0, data };
|
||||||
|
});
|
||||||
|
post.mockImplementation(async (url, body) => {
|
||||||
|
if (url.endsWith('/update')) saved = structuredClone(body.content);
|
||||||
|
if (url.endsWith('/check'))
|
||||||
|
return { errorCode: 0, data: { passed: true } };
|
||||||
|
if (url.endsWith('/runAsync')) return { errorCode: 0, data: 'run-1' };
|
||||||
|
if (url.endsWith('/getChainStatus')) {
|
||||||
|
return {
|
||||||
|
errorCode: 0,
|
||||||
|
data: {
|
||||||
|
status: 5,
|
||||||
|
nodes: Object.fromEntries(
|
||||||
|
body.nodes.map((node: any) => [
|
||||||
|
node.nodeId,
|
||||||
|
{
|
||||||
|
status: node.nodeId === 'confirm' ? 5 : 20,
|
||||||
|
suspendForParameters:
|
||||||
|
node.nodeId === 'confirm'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: 'selection__confirm',
|
||||||
|
formType: 'radio',
|
||||||
|
required: true,
|
||||||
|
options: saved.nodes
|
||||||
|
.find((item: any) => item.id === 'confirm')
|
||||||
|
.data.options.map((value: string) => ({
|
||||||
|
label: value,
|
||||||
|
value,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { errorCode: 0, data: {} };
|
||||||
|
});
|
||||||
|
const wrapper = mount(WorkflowDesign, {
|
||||||
|
global: {
|
||||||
|
directives: { loading: () => {} },
|
||||||
|
stubs: { ShowJson: true, WorkflowFormItem: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await flushPromises();
|
||||||
|
const openRun = async () => {
|
||||||
|
await wrapper.get('[data-test="try-run"]').trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
};
|
||||||
|
const startRun = async () => {
|
||||||
|
await wrapper.getComponent(WorkflowForm).get('button').trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
};
|
||||||
|
await openRun();
|
||||||
|
await startRun();
|
||||||
|
const originalForm = wrapper.getComponent(WorkflowForm).vm.$.uid;
|
||||||
|
graph.nodes.push({
|
||||||
|
id: 'confirm',
|
||||||
|
type: 'confirmNode',
|
||||||
|
data: {
|
||||||
|
title: '用户确认',
|
||||||
|
message: '请选择旧模板',
|
||||||
|
multiple: false,
|
||||||
|
options: ['旧选项'],
|
||||||
|
outputDefs: [{ name: 'selection', dataType: 'String' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await openRun();
|
||||||
|
expect(wrapper.getComponent(WorkflowForm).vm.$.uid).toBe(originalForm);
|
||||||
|
expect(
|
||||||
|
wrapper.getComponent(WorkflowSteps).props('pollingData'),
|
||||||
|
).toBeNull();
|
||||||
|
const latestNodes = wrapper.getComponent(WorkflowSteps).props('nodeJson');
|
||||||
|
const sortCount = sortNodes.mock.calls.length;
|
||||||
|
await startRun();
|
||||||
|
expect(wrapper.getComponent(WorkflowSteps).text()).toContain(
|
||||||
|
'请选择旧模板',
|
||||||
|
);
|
||||||
|
expect(sortNodes).toHaveBeenCalledTimes(sortCount);
|
||||||
|
expect(wrapper.getComponent(WorkflowSteps).props('nodeJson')).toBe(
|
||||||
|
latestNodes,
|
||||||
|
);
|
||||||
|
|
||||||
|
graph.nodes[1].data.message = '请选择新模板';
|
||||||
|
graph.nodes[1].data.options = ['新选项'];
|
||||||
|
await openRun();
|
||||||
|
await startRun();
|
||||||
|
expect(saved.nodes[1].data.options).toEqual(['新选项']);
|
||||||
|
expect(wrapper.getComponent(WorkflowSteps).text()).toContain(
|
||||||
|
'请选择新模板',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
wrapper.getComponent(WorkflowSteps).props('pollingData').nodes.confirm
|
||||||
|
.suspendForParameters[0].options,
|
||||||
|
).toEqual([{ label: '新选项', value: '新选项' }]);
|
||||||
|
expect(post.mock.calls.some(([url]) => url.includes('Publish'))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
wrapper.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -52,8 +52,7 @@ watch(
|
|||||||
success.value = true;
|
success.value = true;
|
||||||
}
|
}
|
||||||
if (newVal.status === 21) {
|
if (newVal.status === 21) {
|
||||||
ElMessage.error($t('message.fail'));
|
result.value = newVal.result || '';
|
||||||
result.value = newVal.message;
|
|
||||||
success.value = false;
|
success.value = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FormInstance } from 'element-plus';
|
import type { FormInstance } from 'element-plus';
|
||||||
|
|
||||||
|
import type { WorkflowExecutionError } from './workflowExecutionError';
|
||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import { Position } from '@element-plus/icons-vue';
|
import { Position } from '@element-plus/icons-vue';
|
||||||
@@ -10,8 +12,9 @@ import { api } from '#/api/request';
|
|||||||
import ShowJson from '#/components/json/ShowJson.vue';
|
import ShowJson from '#/components/json/ShowJson.vue';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
|
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
|
||||||
import { buildSingleRunModel } from '../../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
|
|
||||||
|
|
||||||
|
import { buildSingleRunModel } from '../../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
|
||||||
|
import WorkflowErrorDetail from './WorkflowErrorDetail.vue';
|
||||||
import { resolveWorkflowParameterDisplayName } from './workflowFormParameters';
|
import { resolveWorkflowParameterDisplayName } from './workflowFormParameters';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -25,6 +28,9 @@ const singleRunForm = ref<FormInstance>();
|
|||||||
const runParams = ref<any>({});
|
const runParams = ref<any>({});
|
||||||
const submitLoading = ref(false);
|
const submitLoading = ref(false);
|
||||||
const result = ref<any>('');
|
const result = ref<any>('');
|
||||||
|
const runError = ref<WorkflowExecutionError>();
|
||||||
|
const runErrorMessage = ref('');
|
||||||
|
const executeId = ref<string>();
|
||||||
const singleRunModel = computed(() => buildSingleRunModel(props.node));
|
const singleRunModel = computed(() => buildSingleRunModel(props.node));
|
||||||
const isFieldMode = computed(() => singleRunModel.value.mode === 'fields');
|
const isFieldMode = computed(() => singleRunModel.value.mode === 'fields');
|
||||||
const singleRunParameters = computed(() => singleRunModel.value.parameters);
|
const singleRunParameters = computed(() => singleRunModel.value.parameters);
|
||||||
@@ -41,7 +47,7 @@ const parameterDisplayNameMap = computed(() => {
|
|||||||
function buildFieldSegments(value: string) {
|
function buildFieldSegments(value: string) {
|
||||||
const source = String(value || '');
|
const source = String(value || '');
|
||||||
const segments: Array<{ text: string; token: boolean }> = [];
|
const segments: Array<{ text: string; token: boolean }> = [];
|
||||||
const regex = /\{\{\s*([^{}]+?)\s*}}/g;
|
const regex = /\{\{\s*([^{}]+?)\s*\}\}/g;
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
|
|
||||||
for (const match of source.matchAll(regex)) {
|
for (const match of source.matchAll(regex)) {
|
||||||
@@ -80,14 +86,28 @@ function submit() {
|
|||||||
variables: runParams.value,
|
variables: runParams.value,
|
||||||
};
|
};
|
||||||
submitLoading.value = true;
|
submitLoading.value = true;
|
||||||
api.post('/api/v1/workflow/singleRun', params).then((res) => {
|
result.value = '';
|
||||||
submitLoading.value = false;
|
runError.value = undefined;
|
||||||
|
runErrorMessage.value = '';
|
||||||
|
executeId.value = undefined;
|
||||||
|
api
|
||||||
|
.post('/api/v1/workflow/singleRun', params)
|
||||||
|
.then((res) => {
|
||||||
result.value = res.data;
|
result.value = res.data;
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success(res.message);
|
ElMessage.success(res.message);
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res.message);
|
ElMessage.error(res.message);
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const body = error?.response?.data ?? error;
|
||||||
|
runError.value = body?.data?.error;
|
||||||
|
executeId.value = body?.data?.executeId;
|
||||||
|
runErrorMessage.value = body?.message || '节点执行失败';
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
submitLoading.value = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -129,16 +149,10 @@ function submit() {
|
|||||||
v-for="(segment, index) in buildFieldSegments(field.value)"
|
v-for="(segment, index) in buildFieldSegments(field.value)"
|
||||||
:key="`${field.key}-${index}`"
|
:key="`${field.key}-${index}`"
|
||||||
>
|
>
|
||||||
<span
|
<span v-if="segment.token" class="single-run-token-chip">
|
||||||
v-if="segment.token"
|
|
||||||
class="single-run-token-chip"
|
|
||||||
>
|
|
||||||
{{ segment.text }}
|
{{ segment.text }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span v-else class="single-run-field-card__text">
|
||||||
v-else
|
|
||||||
class="single-run-field-card__text"
|
|
||||||
>
|
|
||||||
{{ segment.text }}
|
{{ segment.text }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -185,6 +199,11 @@ function submit() {
|
|||||||
</ElForm>
|
</ElForm>
|
||||||
<section class="single-run-result">
|
<section class="single-run-result">
|
||||||
<div class="single-run-result__title">{{ $t('workflow.result') }}:</div>
|
<div class="single-run-result__title">{{ $t('workflow.result') }}:</div>
|
||||||
|
<WorkflowErrorDetail
|
||||||
|
:error="runError"
|
||||||
|
:execute-id="executeId"
|
||||||
|
:message="runErrorMessage"
|
||||||
|
/>
|
||||||
<ShowJson class="single-run-result__viewer" :value="result" />
|
<ShowJson class="single-run-result__viewer" :value="result" />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
WorkflowExecutionStepStatus,
|
WorkflowExecutionStepStatus,
|
||||||
WorkflowExecutionStepView,
|
WorkflowExecutionStepView,
|
||||||
} from './workflowExecutionDetails';
|
} from './workflowExecutionDetails';
|
||||||
|
import type { WorkflowExecutionError } from './workflowExecutionError';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
@@ -50,6 +51,7 @@ import {
|
|||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
import { api, SseClient } from '#/api/request';
|
import { api, SseClient } from '#/api/request';
|
||||||
|
import { SseRequestError } from '#/api/sseRequestLifecycle';
|
||||||
import workflowIcon from '#/assets/ai/workflow/workflowIcon.png';
|
import workflowIcon from '#/assets/ai/workflow/workflowIcon.png';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
import { router } from '#/router';
|
import { router } from '#/router';
|
||||||
@@ -63,6 +65,7 @@ import {
|
|||||||
resolveWorkflowShareVisitorId,
|
resolveWorkflowShareVisitorId,
|
||||||
} from '#/utils/workflow-share-context';
|
} from '#/utils/workflow-share-context';
|
||||||
|
|
||||||
|
import WorkflowErrorDetail from './WorkflowErrorDetail.vue';
|
||||||
import {
|
import {
|
||||||
finalizeWorkflowExecutionSteps,
|
finalizeWorkflowExecutionSteps,
|
||||||
formatExecutionValue,
|
formatExecutionValue,
|
||||||
@@ -94,7 +97,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
formatWorkflowElapsed,
|
formatWorkflowElapsed,
|
||||||
formatWorkflowProgressLabel,
|
formatWorkflowProgressLabel,
|
||||||
summarizeWorkflowActiveNodes,
|
|
||||||
} from './workflowRunProgress';
|
} from './workflowRunProgress';
|
||||||
import {
|
import {
|
||||||
buildWorkflowShareConversationKey,
|
buildWorkflowShareConversationKey,
|
||||||
@@ -167,6 +169,10 @@ const detailVisible = ref(false);
|
|||||||
const detailLoading = ref(false);
|
const detailLoading = ref(false);
|
||||||
const detailLoadError = ref('');
|
const detailLoadError = ref('');
|
||||||
const executionDetail = ref<Record<string, any>>();
|
const executionDetail = ref<Record<string, any>>();
|
||||||
|
const liveExecutionError = ref<WorkflowExecutionError>();
|
||||||
|
const executionError = computed(
|
||||||
|
() => liveExecutionError.value || executionDetail.value?.runtime?.error,
|
||||||
|
);
|
||||||
const liveExecutionSteps = ref<WorkflowExecutionStepView[]>([]);
|
const liveExecutionSteps = ref<WorkflowExecutionStepView[]>([]);
|
||||||
const expandedExecutionStepKeys = ref<string[]>([]);
|
const expandedExecutionStepKeys = ref<string[]>([]);
|
||||||
const detailExpansionTouched = ref(false);
|
const detailExpansionTouched = ref(false);
|
||||||
@@ -271,7 +277,10 @@ const emptyText = computed(
|
|||||||
() => descriptor.value.description || '输入问题开始运行',
|
() => descriptor.value.description || '输入问题开始运行',
|
||||||
);
|
);
|
||||||
const persistedExecutionSteps = computed(() =>
|
const persistedExecutionSteps = computed(() =>
|
||||||
hydrateWorkflowExecutionSteps(executionDetail.value?.steps),
|
hydrateWorkflowExecutionSteps(
|
||||||
|
executionDetail.value?.steps,
|
||||||
|
executionError.value,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const executionSteps = computed(() =>
|
const executionSteps = computed(() =>
|
||||||
liveExecutionSteps.value.length > 0
|
liveExecutionSteps.value.length > 0
|
||||||
@@ -719,13 +728,6 @@ function clearProgressStatusTimer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeNodeSummary() {
|
|
||||||
return summarizeWorkflowActiveNodes(
|
|
||||||
liveExecutionSteps.value,
|
|
||||||
lastRunningNodeName.value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function progressLabel(prefix: string) {
|
function progressLabel(prefix: string) {
|
||||||
return formatWorkflowProgressLabel(
|
return formatWorkflowProgressLabel(
|
||||||
prefix,
|
prefix,
|
||||||
@@ -791,6 +793,7 @@ async function handleSend() {
|
|||||||
executeId.value = '';
|
executeId.value = '';
|
||||||
runStatusKey.value = `run-${Date.now()}-${userMessageSequence}`;
|
runStatusKey.value = `run-${Date.now()}-${userMessageSequence}`;
|
||||||
executionDetail.value = undefined;
|
executionDetail.value = undefined;
|
||||||
|
liveExecutionError.value = undefined;
|
||||||
detailLoadError.value = '';
|
detailLoadError.value = '';
|
||||||
liveExecutionSteps.value = [];
|
liveExecutionSteps.value = [];
|
||||||
expandedExecutionStepKeys.value = [];
|
expandedExecutionStepKeys.value = [];
|
||||||
@@ -817,27 +820,21 @@ async function handleSend() {
|
|||||||
if (manualAbort.value) {
|
if (manualAbort.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (error instanceof SseRequestError) {
|
||||||
|
finishExecution('failed', error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (beginExecutionRecovery()) {
|
if (beginExecutionRecovery()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
finishExecution(
|
showDisconnectedStatus(error?.message);
|
||||||
'failed',
|
|
||||||
error?.message || '工作流执行失败',
|
|
||||||
undefined,
|
|
||||||
`stream-error-${Date.now()}`,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onFinished: () => {
|
onFinished: () => {
|
||||||
if (running.value && !manualAbort.value) {
|
if (running.value && !manualAbort.value) {
|
||||||
if (beginExecutionRecovery()) {
|
if (beginExecutionRecovery()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
finishExecution(
|
showDisconnectedStatus();
|
||||||
'failed',
|
|
||||||
'运行连接已结束,请重试',
|
|
||||||
undefined,
|
|
||||||
`stream-finished-${Date.now()}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMessage: (message) => {
|
onMessage: (message) => {
|
||||||
@@ -853,6 +850,14 @@ async function handleSend() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showDisconnectedStatus(message?: string) {
|
||||||
|
running.value = false;
|
||||||
|
executionState.value = 'idle';
|
||||||
|
clearProgressStatusTimer();
|
||||||
|
appendStatus('连接已断开,运行结果尚未确认', 'done', runStatusKey.value);
|
||||||
|
detailLoadError.value = message || '可在运行记录中查看最终结果';
|
||||||
|
}
|
||||||
|
|
||||||
function parseStreamEvent(raw: string): null | WorkflowStreamEnvelope {
|
function parseStreamEvent(raw: string): null | WorkflowStreamEnvelope {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw) as WorkflowStreamEnvelope;
|
return JSON.parse(raw) as WorkflowStreamEnvelope;
|
||||||
@@ -862,6 +867,8 @@ function parseStreamEvent(raw: string): null | WorkflowStreamEnvelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleStreamEvent(event: WorkflowStreamEnvelope) {
|
function handleStreamEvent(event: WorkflowStreamEnvelope) {
|
||||||
|
if (!executeId.value && event.executeId)
|
||||||
|
executeId.value = String(event.executeId);
|
||||||
updateLiveExecutionSteps(event);
|
updateLiveExecutionSteps(event);
|
||||||
const data = event.data || {};
|
const data = event.data || {};
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
@@ -870,14 +877,15 @@ function handleStreamEvent(event: WorkflowStreamEnvelope) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'execution_error': {
|
case 'execution_error': {
|
||||||
appendError(
|
liveExecutionError.value = data.error;
|
||||||
data.message || '工作流执行失败',
|
|
||||||
`execution-error-${event.executeId}`,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'execution_failed': {
|
case 'execution_failed': {
|
||||||
finishExecution('failed', data.message);
|
liveExecutionError.value = data.error || liveExecutionError.value;
|
||||||
|
finishExecution(
|
||||||
|
'failed',
|
||||||
|
data.message || liveExecutionError.value?.message,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'execution_finished': {
|
case 'execution_finished': {
|
||||||
@@ -931,7 +939,7 @@ function finishExecution(
|
|||||||
output?: unknown,
|
output?: unknown,
|
||||||
eventId = executeId.value || String(Date.now()),
|
eventId = executeId.value || String(Date.now()),
|
||||||
) {
|
) {
|
||||||
const failedNodeName = activeNodeSummary();
|
const failedNodeName = executionError.value?.nodeName;
|
||||||
executionRecoveryActive = false;
|
executionRecoveryActive = false;
|
||||||
clearExecutionRecoveryTimer();
|
clearExecutionRecoveryTimer();
|
||||||
clearProgressStatusTimer();
|
clearProgressStatusTimer();
|
||||||
@@ -974,11 +982,18 @@ function updateLiveExecutionSteps(event: WorkflowStreamEnvelope) {
|
|||||||
latestStep?.nodeName || lastRunningNodeName.value;
|
latestStep?.nodeName || lastRunningNodeName.value;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
event.type === 'node_started' &&
|
(event.type === 'node_started' || event.type === 'node_finished') &&
|
||||||
!detailExpansionTouched.value &&
|
!detailExpansionTouched.value &&
|
||||||
next.length > 0
|
next.length > 0
|
||||||
) {
|
) {
|
||||||
expandedExecutionStepKeys.value = [next[next.length - 1]!.key];
|
const target = [...next]
|
||||||
|
.reverse()
|
||||||
|
.find((step) =>
|
||||||
|
event.data?.attemptKey
|
||||||
|
? step.attemptKey === event.data.attemptKey
|
||||||
|
: step.nodeId === event.data?.nodeId,
|
||||||
|
);
|
||||||
|
if (target) expandedExecutionStepKeys.value = [target.key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,6 +1005,7 @@ function finalizeLiveExecutionSteps(
|
|||||||
liveExecutionSteps.value,
|
liveExecutionSteps.value,
|
||||||
status,
|
status,
|
||||||
finishedAt,
|
finishedAt,
|
||||||
|
executionError.value,
|
||||||
);
|
);
|
||||||
executionElapsed.value =
|
executionElapsed.value =
|
||||||
executionStartedAt.value === undefined
|
executionStartedAt.value === undefined
|
||||||
@@ -1092,6 +1108,7 @@ async function resetConversation() {
|
|||||||
runStatusKey.value = '';
|
runStatusKey.value = '';
|
||||||
question.value = '';
|
question.value = '';
|
||||||
executionDetail.value = undefined;
|
executionDetail.value = undefined;
|
||||||
|
liveExecutionError.value = undefined;
|
||||||
executionState.value = 'idle';
|
executionState.value = 'idle';
|
||||||
executionStartedAt.value = undefined;
|
executionStartedAt.value = undefined;
|
||||||
executionElapsed.value = undefined;
|
executionElapsed.value = undefined;
|
||||||
@@ -1115,7 +1132,7 @@ function clearExecutionRecoveryTimer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function beginExecutionRecovery() {
|
function beginExecutionRecovery() {
|
||||||
if (!props.shareMode || !executeId.value) {
|
if (!executeId.value) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
executionRecoveryActive = true;
|
executionRecoveryActive = true;
|
||||||
@@ -1137,7 +1154,6 @@ function beginExecutionRecovery() {
|
|||||||
function scheduleExecutionRecovery() {
|
function scheduleExecutionRecovery() {
|
||||||
if (
|
if (
|
||||||
!executionRecoveryActive ||
|
!executionRecoveryActive ||
|
||||||
!props.shareMode ||
|
|
||||||
!executeId.value ||
|
!executeId.value ||
|
||||||
executionState.value === 'waiting'
|
executionState.value === 'waiting'
|
||||||
) {
|
) {
|
||||||
@@ -1172,7 +1188,10 @@ async function recoverExecutionAfterRefresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncRecoveredExecution(detail: Record<string, any>) {
|
function syncRecoveredExecution(detail: Record<string, any>) {
|
||||||
liveExecutionSteps.value = hydrateWorkflowExecutionSteps(detail.steps);
|
liveExecutionSteps.value = hydrateWorkflowExecutionSteps(
|
||||||
|
detail.steps,
|
||||||
|
detail.runtime?.error,
|
||||||
|
);
|
||||||
const activeStep = [...liveExecutionSteps.value]
|
const activeStep = [...liveExecutionSteps.value]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((step) => step.status === 'running' || step.status === 'waiting');
|
.find((step) => step.status === 'running' || step.status === 'waiting');
|
||||||
@@ -1221,6 +1240,7 @@ function syncRecoveredExecution(detail: Record<string, any>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (status === 'FAILED') {
|
if (status === 'FAILED') {
|
||||||
|
liveExecutionError.value = detail.runtime?.error;
|
||||||
finishExecution(
|
finishExecution(
|
||||||
'failed',
|
'failed',
|
||||||
detail.runtime?.message || detail.record?.errorInfo,
|
detail.runtime?.message || detail.record?.errorInfo,
|
||||||
@@ -1355,6 +1375,7 @@ function executionStepStatusText(status: WorkflowExecutionStepStatus) {
|
|||||||
cancelled: '已中止',
|
cancelled: '已中止',
|
||||||
completed: '已完成',
|
completed: '已完成',
|
||||||
failed: '失败',
|
failed: '失败',
|
||||||
|
retrying: '等待重试',
|
||||||
running: '运行中',
|
running: '运行中',
|
||||||
waiting: '等待确认',
|
waiting: '等待确认',
|
||||||
};
|
};
|
||||||
@@ -1715,13 +1736,33 @@ function executionTraceText(
|
|||||||
<span class="workflow-chat__detail-id">{{ executeId }}</span>
|
<span class="workflow-chat__detail-id">{{ executeId }}</span>
|
||||||
</ElDescriptionsItem>
|
</ElDescriptionsItem>
|
||||||
<ElDescriptionsItem
|
<ElDescriptionsItem
|
||||||
v-if="executionDetail?.record?.errorInfo"
|
v-if="
|
||||||
|
executionDetail?.record?.errorInfo &&
|
||||||
|
!executionError &&
|
||||||
|
!executionSteps.some(
|
||||||
|
(step) => step.error === executionDetail?.record?.errorInfo,
|
||||||
|
)
|
||||||
|
"
|
||||||
label="错误"
|
label="错误"
|
||||||
>
|
>
|
||||||
{{ executionDetail.record.errorInfo }}
|
{{ executionDetail.record.errorInfo }}
|
||||||
</ElDescriptionsItem>
|
</ElDescriptionsItem>
|
||||||
</ElDescriptions>
|
</ElDescriptions>
|
||||||
|
|
||||||
|
<WorkflowErrorDetail
|
||||||
|
v-if="
|
||||||
|
!executionSteps.some(
|
||||||
|
(step) =>
|
||||||
|
step.errorDetail?.reasonCode === executionError?.reasonCode &&
|
||||||
|
executionError?.nodeId &&
|
||||||
|
(step.nodeId === executionError.nodeId ||
|
||||||
|
step.errorDetail?.nodeId === executionError.nodeId),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:error="executionError"
|
||||||
|
:execute-id="executeId"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-if="detailLoadError" class="workflow-chat__detail-load-error">
|
<div v-if="detailLoadError" class="workflow-chat__detail-load-error">
|
||||||
<span>{{ detailLoadError }}</span>
|
<span>{{ detailLoadError }}</span>
|
||||||
<ElButton text type="primary" @click="loadExecutionDetail()">
|
<ElButton text type="primary" @click="loadExecutionDetail()">
|
||||||
@@ -1791,9 +1832,13 @@ function executionTraceText(
|
|||||||
<h3>输出</h3>
|
<h3>输出</h3>
|
||||||
<pre>{{ formatExecutionValue(step.output, true) }}</pre>
|
<pre>{{ formatExecutionValue(step.output, true) }}</pre>
|
||||||
</section>
|
</section>
|
||||||
<section v-if="step.error">
|
<section v-if="step.error || step.errorDetail">
|
||||||
<h3>错误</h3>
|
<h3>错误</h3>
|
||||||
<p class="workflow-chat__detail-error">{{ step.error }}</p>
|
<WorkflowErrorDetail
|
||||||
|
:error="step.errorDetail"
|
||||||
|
:execute-id="executeId"
|
||||||
|
:message="step.error"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
<p
|
<p
|
||||||
v-if="
|
v-if="
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { WorkflowExecutionError } from './workflowExecutionError';
|
||||||
|
|
||||||
|
import { ElAlert, ElButton } from 'element-plus';
|
||||||
|
|
||||||
|
import { copyTextWithFeedback } from '#/utils/clipboard-feedback';
|
||||||
|
|
||||||
|
import { formatWorkflowErrorContext } from './workflowExecutionError';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
error?: WorkflowExecutionError;
|
||||||
|
executeId?: string;
|
||||||
|
message?: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElAlert
|
||||||
|
v-if="error || message"
|
||||||
|
:closable="false"
|
||||||
|
:title="error?.message || message"
|
||||||
|
:type="error?.retryable ? 'warning' : 'error'"
|
||||||
|
show-icon
|
||||||
|
>
|
||||||
|
<template v-if="error">
|
||||||
|
<div v-if="error.nodeName">节点:{{ error.nodeName }}</div>
|
||||||
|
<div>原因码:{{ error.reasonCode }}</div>
|
||||||
|
<div v-if="error.retryable">正在等待重试</div>
|
||||||
|
<ElButton
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
@click="
|
||||||
|
copyTextWithFeedback(
|
||||||
|
formatWorkflowErrorContext(error, executeId),
|
||||||
|
'排查信息已复制',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
复制排查信息
|
||||||
|
</ElButton>
|
||||||
|
<slot></slot>
|
||||||
|
</template>
|
||||||
|
</ElAlert>
|
||||||
|
</template>
|
||||||
@@ -31,6 +31,7 @@ const props = withDefaults(defineProps<WorkflowFormProps>(), {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
defineExpose({
|
defineExpose({
|
||||||
|
reset,
|
||||||
resume,
|
resume,
|
||||||
});
|
});
|
||||||
const runForm = ref<FormInstance>();
|
const runForm = ref<FormInstance>();
|
||||||
@@ -88,53 +89,66 @@ watch(
|
|||||||
);
|
);
|
||||||
const executeId = ref('');
|
const executeId = ref('');
|
||||||
async function resume(data: any) {
|
async function resume(data: any) {
|
||||||
data.executeId = executeId.value;
|
if (submitLoading.value || !executeId.value) return false;
|
||||||
|
const generation = pollingGeneration;
|
||||||
submitLoading.value = true;
|
submitLoading.value = true;
|
||||||
let accepted = false;
|
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/api/v1/workflow/resume', data);
|
const res = await api.post('/api/v1/workflow/resume', {
|
||||||
|
...data,
|
||||||
|
executeId: executeId.value,
|
||||||
|
});
|
||||||
|
if (generation !== pollingGeneration) return false;
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
accepted = true;
|
|
||||||
startPolling(executeId.value);
|
startPolling(executeId.value);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return accepted;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
if (!accepted) {
|
if (generation === pollingGeneration) {
|
||||||
submitLoading.value = false;
|
submitLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function submitV2() {
|
async function submitV2() {
|
||||||
runForm.value?.validate((valid) => {
|
if (submitLoading.value || !runForm.value) return;
|
||||||
if (valid) {
|
stopPolling();
|
||||||
const data = {
|
const generation = pollingGeneration;
|
||||||
id: props.workflowId,
|
|
||||||
variables: {
|
|
||||||
...runParams.value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
props.onSubmit?.(runParams.value);
|
|
||||||
submitLoading.value = true;
|
submitLoading.value = true;
|
||||||
api.post('/api/v1/workflow/runAsync', data).then((res) => {
|
try {
|
||||||
|
const valid = await runForm.value.validate().catch(() => false);
|
||||||
|
if (!valid || generation !== pollingGeneration) return;
|
||||||
|
|
||||||
|
executeId.value = '';
|
||||||
|
// 每轮执行只生成一次轻量列表,后续轮询和暂停恢复复用同一份节点。
|
||||||
|
nodes = (props.tinyFlowData?.nodes || []).map((node: any) => ({
|
||||||
|
nodeId: node.id,
|
||||||
|
nodeName: node.data?.title || node.id,
|
||||||
|
}));
|
||||||
|
props.onSubmit?.(runParams.value);
|
||||||
|
const res = await api.post('/api/v1/workflow/runAsync', {
|
||||||
|
id: props.workflowId,
|
||||||
|
variables: { ...runParams.value },
|
||||||
|
});
|
||||||
|
if (generation !== pollingGeneration) return;
|
||||||
if (res.errorCode === 0 && res.data) {
|
if (res.errorCode === 0 && res.data) {
|
||||||
// executeId
|
|
||||||
executeId.value = res.data;
|
executeId.value = res.data;
|
||||||
startPolling(res.data);
|
startPolling(res.data);
|
||||||
}
|
}
|
||||||
});
|
} catch (error) {
|
||||||
|
if (generation === pollingGeneration) {
|
||||||
|
console.error('工作流启动失败', error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (generation === pollingGeneration) {
|
||||||
|
submitLoading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const POLLING_INTERVAL_MS = 1000;
|
const POLLING_INTERVAL_MS = 1000;
|
||||||
const timer = ref<null | ReturnType<typeof setTimeout>>(null);
|
const timer = ref<null | ReturnType<typeof setTimeout>>(null);
|
||||||
let pollingActive = false;
|
let pollingActive = false;
|
||||||
let pollingGeneration = 0;
|
let pollingGeneration = 0;
|
||||||
const nodes = ref(
|
let nodes: { nodeId: string; nodeName: string }[] = [];
|
||||||
props.tinyFlowData.nodes.map((node: any) => ({
|
|
||||||
nodeId: node.id,
|
|
||||||
nodeName: node.data.title,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
// 轮询执行结果
|
// 轮询执行结果
|
||||||
function startPolling(executeId: any) {
|
function startPolling(executeId: any) {
|
||||||
if (pollingActive) return;
|
if (pollingActive) return;
|
||||||
@@ -152,7 +166,7 @@ async function executePolling(executeId: any, generation: number) {
|
|||||||
try {
|
try {
|
||||||
const res = await api.post('/api/v1/workflow/getChainStatus', {
|
const res = await api.post('/api/v1/workflow/getChainStatus', {
|
||||||
executeId,
|
executeId,
|
||||||
nodes: nodes.value,
|
nodes,
|
||||||
});
|
});
|
||||||
if (!pollingActive || generation !== pollingGeneration) return;
|
if (!pollingActive || generation !== pollingGeneration) return;
|
||||||
|
|
||||||
@@ -180,9 +194,12 @@ function stopPolling() {
|
|||||||
timer.value = null;
|
timer.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onUnmounted(() => {
|
function reset() {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
});
|
executeId.value = '';
|
||||||
|
nodes = [];
|
||||||
|
}
|
||||||
|
onUnmounted(reset);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
VideoPause,
|
VideoPause,
|
||||||
} from '@element-plus/icons-vue';
|
} from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
ElAlert,
|
|
||||||
ElButton,
|
ElButton,
|
||||||
ElCollapse,
|
ElCollapse,
|
||||||
ElCollapseItem,
|
ElCollapseItem,
|
||||||
@@ -20,6 +19,8 @@ import {
|
|||||||
import ShowJson from '#/components/json/ShowJson.vue';
|
import ShowJson from '#/components/json/ShowJson.vue';
|
||||||
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
|
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
|
||||||
|
|
||||||
|
import WorkflowErrorDetail from './WorkflowErrorDetail.vue';
|
||||||
|
|
||||||
export interface WorkflowStepsProps {
|
export interface WorkflowStepsProps {
|
||||||
workflowId: any;
|
workflowId: any;
|
||||||
nodeJson: any;
|
nodeJson: any;
|
||||||
@@ -42,7 +43,7 @@ const confirmBtnLoading = ref(false);
|
|||||||
const chainErrMsg = ref('');
|
const chainErrMsg = ref('');
|
||||||
|
|
||||||
function shouldAutoExpandStatus(status: unknown) {
|
function shouldAutoExpandStatus(status: unknown) {
|
||||||
return [1, 5, 20, 21].includes(Number(status));
|
return [1, 5, 10, 20, 21].includes(Number(status));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleManualExpansionChange() {
|
function handleManualExpansionChange() {
|
||||||
@@ -75,6 +76,7 @@ function hasNodeStateChanged(previous: any, current: any) {
|
|||||||
if (hasNodePayloadChanged(previous?.result, current?.result)) {
|
if (hasNodePayloadChanged(previous?.result, current?.result)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (hasNodePayloadChanged(previous?.error, current?.error)) return true;
|
||||||
return hasNodePayloadChanged(
|
return hasNodePayloadChanged(
|
||||||
previous?.suspendForParameters,
|
previous?.suspendForParameters,
|
||||||
current?.suspendForParameters,
|
current?.suspendForParameters,
|
||||||
@@ -93,7 +95,9 @@ watch(
|
|||||||
confirmBtnLoading.value = false;
|
confirmBtnLoading.value = false;
|
||||||
}
|
}
|
||||||
let autoExpandNodeId: string | undefined;
|
let autoExpandNodeId: string | undefined;
|
||||||
const failedNodeId = Object.keys(currentNodes).find(
|
const failedNodeId =
|
||||||
|
newVal.error?.nodeId ||
|
||||||
|
Object.keys(currentNodes).find(
|
||||||
(nodeId) => Number(currentNodes[nodeId]?.status) === 21,
|
(nodeId) => Number(currentNodes[nodeId]?.status) === 21,
|
||||||
);
|
);
|
||||||
for (const nodeId in currentNodes) {
|
for (const nodeId in currentNodes) {
|
||||||
@@ -162,6 +166,18 @@ const displayNodes = computed(() => {
|
|||||||
...nodeStatusMap.value[node.key],
|
...nodeStatusMap.value[node.key],
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
const showChainError = computed(() => {
|
||||||
|
const failedNodeId = props.pollingData?.error?.nodeId;
|
||||||
|
return (
|
||||||
|
chainErrMsg.value &&
|
||||||
|
(!failedNodeId ||
|
||||||
|
!displayNodes.value.some(
|
||||||
|
(node) =>
|
||||||
|
(node.key === failedNodeId || node.error?.nodeId === failedNodeId) &&
|
||||||
|
(node.error || node.message),
|
||||||
|
))
|
||||||
|
);
|
||||||
|
});
|
||||||
// 动态设置 Ref 的辅助函数
|
// 动态设置 Ref 的辅助函数
|
||||||
const setFormRef = (el: any, key: string) => {
|
const setFormRef = (el: any, key: string) => {
|
||||||
if (el) {
|
if (el) {
|
||||||
@@ -213,13 +229,11 @@ function handleConfirm(node: any) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1">
|
<div v-if="showChainError" class="mb-1">
|
||||||
<ElAlert
|
<WorkflowErrorDetail
|
||||||
v-if="chainErrMsg"
|
:error="pollingData?.error"
|
||||||
:closable="false"
|
:execute-id="pollingData?.executeId"
|
||||||
show-icon
|
:message="chainErrMsg"
|
||||||
:title="chainErrMsg"
|
|
||||||
type="error"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ElCollapse
|
<ElCollapse
|
||||||
@@ -249,6 +263,9 @@ function handleConfirm(node: any) {
|
|||||||
<ElIcon v-if="node.status === 5" color="orange" size="20">
|
<ElIcon v-if="node.status === 5" color="orange" size="20">
|
||||||
<VideoPause />
|
<VideoPause />
|
||||||
</ElIcon>
|
</ElIcon>
|
||||||
|
<span v-if="node.status === 10">{{
|
||||||
|
node.error?.retryable ? '等待重试' : '已停止'
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -288,7 +305,12 @@ function handleConfirm(node: any) {
|
|||||||
</ElForm>
|
</ElForm>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<ShowJson :value="node.result || node.message" />
|
<WorkflowErrorDetail
|
||||||
|
:error="node.error"
|
||||||
|
:execute-id="pollingData?.executeId"
|
||||||
|
:message="node.message"
|
||||||
|
/>
|
||||||
|
<ShowJson v-if="node.result != null" :value="node.result" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElCollapseItem>
|
</ElCollapseItem>
|
||||||
|
|||||||
@@ -1,50 +1,33 @@
|
|||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import ExecResult from '../ExecResult.vue';
|
import ExecResult from '../ExecResult.vue';
|
||||||
|
|
||||||
vi.mock('#/locales', () => ({
|
|
||||||
$t: (key: string) => key,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('execResult', () => {
|
describe('execResult', () => {
|
||||||
it('结束节点执行失败时展示工作流错误信息', async () => {
|
it('失败时保留部分输出,结果区不重复显示节点错误', async () => {
|
||||||
const wrapper = mount(ExecResult, {
|
const wrapper = mount(ExecResult, {
|
||||||
props: {
|
props: { workflowId: 'test', nodeJson: [] },
|
||||||
initSignal: false,
|
|
||||||
nodeJson: [
|
|
||||||
{
|
|
||||||
original: {
|
|
||||||
data: {
|
|
||||||
outputDefs: [],
|
|
||||||
},
|
|
||||||
type: 'endNode',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
pollingData: undefined,
|
|
||||||
workflowId: 'workflow-1',
|
|
||||||
},
|
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
ShowJson: {
|
ShowJson: { props: ['value'], template: '<pre>{{ value }}</pre>' },
|
||||||
props: ['value'],
|
ElEmpty: true,
|
||||||
template: '<div data-test="show-json">{{ value }}</div>',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await wrapper.setProps({
|
||||||
|
pollingData: { status: 21, message: '模型不存在' },
|
||||||
|
});
|
||||||
|
expect(wrapper.text()).not.toContain('模型不存在');
|
||||||
await wrapper.setProps({
|
await wrapper.setProps({
|
||||||
pollingData: {
|
pollingData: {
|
||||||
message: 'JavaScript 执行失败(第 3 行,第 5 列):boom',
|
|
||||||
status: 21,
|
status: 21,
|
||||||
|
message: '模型不存在',
|
||||||
|
result: { output: '部分输出' },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(wrapper.text()).toContain('部分输出');
|
||||||
expect(wrapper.get('[data-test="show-json"]').text()).toContain(
|
expect(wrapper.text()).not.toContain('模型不存在');
|
||||||
'JavaScript 执行失败(第 3 行,第 5 列):boom',
|
wrapper.unmount();
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import SingleRun from '../SingleRun.vue';
|
||||||
|
|
||||||
|
const { post, copy } = vi.hoisted(() => ({ post: vi.fn(), copy: vi.fn() }));
|
||||||
|
vi.mock('#/api/request', () => ({ api: { post } }));
|
||||||
|
vi.mock('#/utils/clipboard-feedback', () => ({ copyTextWithFeedback: copy }));
|
||||||
|
|
||||||
|
describe('singleRun', () => {
|
||||||
|
it('copies the current execution id and clears it before a new run', async () => {
|
||||||
|
const detail = {
|
||||||
|
code: 'NODE_EXECUTION_FAILED',
|
||||||
|
reasonCode: 'MODEL_NOT_FOUND',
|
||||||
|
message: '模型不存在',
|
||||||
|
nodeId: 'llm',
|
||||||
|
nodeName: '模型分析',
|
||||||
|
retryable: false,
|
||||||
|
};
|
||||||
|
const rejectWith = (executeId?: string) => ({
|
||||||
|
errorCode: 500,
|
||||||
|
message: detail.message,
|
||||||
|
data: { error: detail, executeId },
|
||||||
|
});
|
||||||
|
post.mockRejectedValueOnce(rejectWith('execution-first'));
|
||||||
|
const wrapper = mount(SingleRun, {
|
||||||
|
props: {
|
||||||
|
workflowId: 'test',
|
||||||
|
node: { id: 'llm', type: 'llmNode', data: { userPrompt: 'test' } },
|
||||||
|
},
|
||||||
|
global: { stubs: { ShowJson: true, WorkflowFormItem: true } },
|
||||||
|
});
|
||||||
|
const findButton = (copyButton: boolean) => {
|
||||||
|
const button = wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find((item) => item.text().includes('复制排查信息') === copyButton);
|
||||||
|
if (!button) throw new Error('Expected button was not rendered');
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
const run = () => findButton(false);
|
||||||
|
const copyButton = () => findButton(true);
|
||||||
|
const copiedContext = () => {
|
||||||
|
const call = copy.mock.lastCall;
|
||||||
|
if (!call)
|
||||||
|
throw new Error('Expected diagnostic information to be copied');
|
||||||
|
return JSON.parse(call[0]);
|
||||||
|
};
|
||||||
|
await run().trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
await copyButton().trigger('click');
|
||||||
|
expect(copiedContext()).toMatchObject({
|
||||||
|
executeId: 'execution-first',
|
||||||
|
reasonCode: 'MODEL_NOT_FOUND',
|
||||||
|
});
|
||||||
|
|
||||||
|
let rejectNext!: (error: unknown) => void;
|
||||||
|
post.mockReturnValueOnce(
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
rejectNext = reject;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await run().trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
expect(wrapper.text()).not.toContain('复制排查信息');
|
||||||
|
rejectNext(rejectWith('execution-second'));
|
||||||
|
await flushPromises();
|
||||||
|
await copyButton().trigger('click');
|
||||||
|
expect(copiedContext().executeId).toBe('execution-second');
|
||||||
|
|
||||||
|
post.mockRejectedValueOnce(rejectWith());
|
||||||
|
await run().trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
await copyButton().trigger('click');
|
||||||
|
expect(copiedContext()).not.toHaveProperty('executeId');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import WorkflowForm from '../WorkflowForm.vue';
|
||||||
|
|
||||||
|
const { post } = vi.hoisted(() => ({ post: vi.fn() }));
|
||||||
|
vi.mock('#/api/request', () => ({ api: { post } }));
|
||||||
|
|
||||||
|
function flow(...ids: string[]) {
|
||||||
|
return {
|
||||||
|
nodes: ids.map((id) => ({ id, data: { title: id } })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: (value: any) => void;
|
||||||
|
const promise = new Promise((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createForm(tinyFlowData = flow('start', 'end')) {
|
||||||
|
return mount(WorkflowForm, {
|
||||||
|
props: {
|
||||||
|
tinyFlowData,
|
||||||
|
workflowId: 'workflow-1',
|
||||||
|
workflowParams: { parameters: [], startFormMeta: { submitText: '开始' } },
|
||||||
|
onAsyncExecute: vi.fn(),
|
||||||
|
onSubmit: vi.fn(),
|
||||||
|
},
|
||||||
|
global: { stubs: { WorkflowFormItem: true } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
type FormWrapper = ReturnType<typeof createForm>;
|
||||||
|
const wrappers: FormWrapper[] = [];
|
||||||
|
function mountForm(tinyFlowData?: ReturnType<typeof flow>) {
|
||||||
|
const wrapper = createForm(tinyFlowData);
|
||||||
|
wrappers.push(wrapper);
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
async function start(wrapper: FormWrapper) {
|
||||||
|
await wrapper.get('button').trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
}
|
||||||
|
function polls() {
|
||||||
|
return post.mock.calls
|
||||||
|
.filter(([url]) => url.endsWith('/getChainStatus'))
|
||||||
|
.map(([, body]) => body);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('workflowForm execution snapshot', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
post.mockReset();
|
||||||
|
post.mockImplementation(async (url, body) => {
|
||||||
|
if (url.endsWith('/runAsync')) return { errorCode: 0, data: 'run-1' };
|
||||||
|
if (url.endsWith('/resume')) return { errorCode: 0 };
|
||||||
|
return { errorCode: 0, data: { executeId: body.executeId, status: 5 } };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
for (const wrapper of wrappers.splice(0)) wrapper.unmount();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses the mounted form but polls newly added nodes on the next run', async () => {
|
||||||
|
const wrapper = mountForm();
|
||||||
|
await start(wrapper);
|
||||||
|
await wrapper.setProps({
|
||||||
|
tinyFlowData: flow('start', 'confirm-new', 'end'),
|
||||||
|
});
|
||||||
|
await start(wrapper);
|
||||||
|
expect(polls()[1].nodes.map((node: any) => node.nodeId)).toEqual([
|
||||||
|
'start',
|
||||||
|
'confirm-new',
|
||||||
|
'end',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes deleted nodes and uses the latest node name on a new run', async () => {
|
||||||
|
const wrapper = mountForm(flow('start', 'old-confirm', 'end'));
|
||||||
|
await start(wrapper);
|
||||||
|
const updated = flow('start', 'end');
|
||||||
|
updated.nodes = updated.nodes.map((node) => ({
|
||||||
|
...node,
|
||||||
|
data: { title: node.id === 'end' ? '新版结束节点' : node.id },
|
||||||
|
}));
|
||||||
|
await wrapper.setProps({ tinyFlowData: updated });
|
||||||
|
await start(wrapper);
|
||||||
|
expect(polls()[1].nodes).toEqual([
|
||||||
|
{ nodeId: 'start', nodeName: 'start' },
|
||||||
|
{ nodeId: 'end', nodeName: '新版结束节点' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a large node list once and reuses it for polling and resume', async () => {
|
||||||
|
const data = flow(...Array.from({ length: 1000 }, (_, i) => `node-${i}`));
|
||||||
|
const readNodes = vi.fn(() => data.nodes);
|
||||||
|
const wrapper = mountForm(
|
||||||
|
Object.defineProperty({}, 'nodes', { get: readNodes }) as typeof data,
|
||||||
|
);
|
||||||
|
readNodes.mockClear();
|
||||||
|
const states = [1, 1, 5, 20];
|
||||||
|
post.mockImplementation(async (url) => {
|
||||||
|
if (url.endsWith('/runAsync')) return { errorCode: 0, data: 'run-1' };
|
||||||
|
if (url.endsWith('/resume')) return { errorCode: 0 };
|
||||||
|
return { errorCode: 0, data: { status: states.shift() } };
|
||||||
|
});
|
||||||
|
await start(wrapper);
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
await wrapper.vm.resume({ confirmParams: { selection: '继续' } });
|
||||||
|
await flushPromises();
|
||||||
|
expect(readNodes).toHaveBeenCalledTimes(1);
|
||||||
|
expect(polls()).toHaveLength(4);
|
||||||
|
expect(polls().every((poll) => poll.nodes === polls()[0].nodes)).toBe(true);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the suspended execution nodes when props change before resume', async () => {
|
||||||
|
const wrapper = mountForm(flow('start', 'confirm', 'end'));
|
||||||
|
await start(wrapper);
|
||||||
|
await wrapper.setProps({ tinyFlowData: flow('different-node') });
|
||||||
|
await wrapper.vm.resume({ confirmParams: { selection__confirm: '继续' } });
|
||||||
|
await flushPromises();
|
||||||
|
expect(polls()[1].nodes).toBe(polls()[0].nodes);
|
||||||
|
expect(polls()[1].executeId).toBe('run-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an old poll after reset and does not unlock a new request', async () => {
|
||||||
|
const oldPoll = deferred();
|
||||||
|
const newRun = deferred();
|
||||||
|
const wrapper = mountForm();
|
||||||
|
post.mockResolvedValueOnce({ errorCode: 0, data: 'old-run' });
|
||||||
|
post.mockReturnValueOnce(oldPoll.promise);
|
||||||
|
await start(wrapper);
|
||||||
|
wrapper.vm.reset();
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
post.mockReturnValueOnce(newRun.promise);
|
||||||
|
await start(wrapper);
|
||||||
|
oldPoll.resolve({ errorCode: 0, data: { status: 5 } });
|
||||||
|
await flushPromises();
|
||||||
|
expect(wrapper.props('onAsyncExecute')).not.toHaveBeenCalled();
|
||||||
|
expect(wrapper.get('button').classes()).toContain('is-loading');
|
||||||
|
newRun.resolve({ errorCode: 0, data: 'new-run' });
|
||||||
|
await flushPromises();
|
||||||
|
expect(polls().at(-1).executeId).toBe('new-run');
|
||||||
|
expect(wrapper.props('onAsyncExecute')).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['start', 'resume'])(
|
||||||
|
'ignores a late %s response after reset',
|
||||||
|
async (operation) => {
|
||||||
|
const wrapper = mountForm();
|
||||||
|
if (operation === 'resume') await start(wrapper);
|
||||||
|
const request = deferred();
|
||||||
|
post.mockReturnValueOnce(request.promise);
|
||||||
|
const pending =
|
||||||
|
operation === 'resume'
|
||||||
|
? wrapper.vm.resume({ confirmParams: {} })
|
||||||
|
: start(wrapper);
|
||||||
|
await flushPromises();
|
||||||
|
const previousPollCount = polls().length;
|
||||||
|
wrapper.vm.reset();
|
||||||
|
request.resolve({ errorCode: 0, data: 'old-run' });
|
||||||
|
await pending;
|
||||||
|
await flushPromises();
|
||||||
|
expect(polls()).toHaveLength(previousPollCount);
|
||||||
|
expect(wrapper.get('button').classes()).not.toContain('is-loading');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('allows retry after a rejected start request and prevents duplicate starts', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const request = deferred();
|
||||||
|
const wrapper = mountForm();
|
||||||
|
post.mockReturnValueOnce(request.promise);
|
||||||
|
await start(wrapper);
|
||||||
|
await start(wrapper);
|
||||||
|
expect(post).toHaveBeenCalledTimes(1);
|
||||||
|
request.resolve({ errorCode: 1 });
|
||||||
|
await flushPromises();
|
||||||
|
expect(wrapper.get('button').classes()).not.toContain('is-loading');
|
||||||
|
post.mockRejectedValueOnce(new Error('请求失败'));
|
||||||
|
await start(wrapper);
|
||||||
|
expect(wrapper.get('button').classes()).not.toContain('is-loading');
|
||||||
|
await start(wrapper);
|
||||||
|
expect(polls()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -323,4 +323,79 @@ describe('workflowSteps', () => {
|
|||||||
expect(wrapper.findAll('workflow-form-item-stub')).toHaveLength(1);
|
expect(wrapper.findAll('workflow-form-item-stub')).toHaveLength(1);
|
||||||
expect(wrapper.findAll('button.el-button')).toHaveLength(1);
|
expect(wrapper.findAll('button.el-button')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
it('错误仅在失败节点内展示一次,同时保留部分输出和自动展开', async () => {
|
||||||
|
const wrapper = mountWorkflowSteps();
|
||||||
|
const error = {
|
||||||
|
code: 'WORKFLOW_EXECUTION_FAILED',
|
||||||
|
reasonCode: 'MODEL_TIMEOUT',
|
||||||
|
message: '模型响应超时',
|
||||||
|
nodeId: 'node-b',
|
||||||
|
nodeName: '节点 B',
|
||||||
|
retryable: false,
|
||||||
|
};
|
||||||
|
await wrapper.setProps({
|
||||||
|
pollingData: {
|
||||||
|
executeId: 'run-1',
|
||||||
|
status: 21,
|
||||||
|
message: error.message,
|
||||||
|
error,
|
||||||
|
nodes: {
|
||||||
|
'node-b': {
|
||||||
|
status: 21,
|
||||||
|
error,
|
||||||
|
message: error.message,
|
||||||
|
result: { partial: '保留输出' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.text()).toContain('MODEL_TIMEOUT');
|
||||||
|
expect(wrapper.text()).toContain('模型响应超时');
|
||||||
|
expect(wrapper.findComponent({ name: 'ShowJson' }).exists()).toBe(true);
|
||||||
|
expect(wrapper.text()).toContain('复制排查信息');
|
||||||
|
expect(wrapper.text().match(/MODEL_TIMEOUT/g)).toHaveLength(1);
|
||||||
|
expect(wrapper.text()).not.toContain('定位失败节点');
|
||||||
|
expect(getCollapseItems(wrapper)[1]?.classes()).toContain('is-active');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
it('没有可展示的失败节点时仍保留工作流错误', async () => {
|
||||||
|
const wrapper = mountWorkflowSteps();
|
||||||
|
await wrapper.setProps({
|
||||||
|
pollingData: {
|
||||||
|
status: 21,
|
||||||
|
message: '工作流内部执行异常',
|
||||||
|
error: {
|
||||||
|
code: 'WORKFLOW_EXECUTION_FAILED',
|
||||||
|
reasonCode: 'WORKFLOW_INTERNAL_ERROR',
|
||||||
|
message: '工作流内部执行异常',
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
nodes: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.text()).toContain('WORKFLOW_INTERNAL_ERROR');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
it('工作流终止后不把遗留错误尝试展示为等待重试', async () => {
|
||||||
|
const wrapper = mountWorkflowSteps();
|
||||||
|
await wrapper.setProps({
|
||||||
|
pollingData: {
|
||||||
|
status: 21,
|
||||||
|
nodes: {
|
||||||
|
'node-b': {
|
||||||
|
status: 10,
|
||||||
|
error: {
|
||||||
|
code: 'NODE_EXECUTION_FAILED',
|
||||||
|
reasonCode: 'MODEL_TIMEOUT',
|
||||||
|
message: '模型响应超时',
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.text()).toContain('已停止');
|
||||||
|
expect(wrapper.text()).not.toContain('等待重试');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,38 @@ import {
|
|||||||
} from './workflowExecutionDetails';
|
} from './workflowExecutionDetails';
|
||||||
|
|
||||||
describe('workflowExecutionDetails', () => {
|
describe('workflowExecutionDetails', () => {
|
||||||
|
it('restores runtime error detail only on the final failed attempt', () => {
|
||||||
|
const error = {
|
||||||
|
code: 'WORKFLOW_EXECUTION_FAILED',
|
||||||
|
reasonCode: 'MODEL_NOT_FOUND',
|
||||||
|
message: '模型不存在',
|
||||||
|
nodeId: 'llm',
|
||||||
|
nodeName: '模型分析',
|
||||||
|
retryable: false,
|
||||||
|
};
|
||||||
|
const steps = hydrateWorkflowExecutionSteps(
|
||||||
|
[
|
||||||
|
{ nodeId: 'llm', attemptKey: 'old', status: 10, errorInfo: '早先超时' },
|
||||||
|
{
|
||||||
|
nodeId: 'llm',
|
||||||
|
attemptKey: 'final',
|
||||||
|
status: 21,
|
||||||
|
errorInfo: '模型不存在',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
expect(steps[0]?.errorDetail).toBeUndefined();
|
||||||
|
expect(steps[1]?.errorDetail).toEqual(error);
|
||||||
|
expect(
|
||||||
|
hydrateWorkflowExecutionSteps([{ nodeId: 'llm', status: 21 }], error)[0],
|
||||||
|
).toMatchObject({ error: error.message, errorDetail: error });
|
||||||
|
expect(
|
||||||
|
hydrateWorkflowExecutionSteps([
|
||||||
|
{ nodeId: 'llm', status: 21, errorInfo: '模型不存在' },
|
||||||
|
])[0]?.error,
|
||||||
|
).toBe('模型不存在');
|
||||||
|
});
|
||||||
it('keeps loop attempts separate and completes each output', () => {
|
it('keeps loop attempts separate and completes each output', () => {
|
||||||
const first = reduceWorkflowExecutionSteps([], {
|
const first = reduceWorkflowExecutionSteps([], {
|
||||||
data: {
|
data: {
|
||||||
@@ -132,4 +164,78 @@ describe('workflowExecutionDetails', () => {
|
|||||||
expect(formatExecutionValue(value, true)).toContain('最终答案');
|
expect(formatExecutionValue(value, true)).toContain('最终答案');
|
||||||
expect(formatExecutionValue(value, true)).not.toContain('内部推理');
|
expect(formatExecutionValue(value, true)).not.toContain('内部推理');
|
||||||
});
|
});
|
||||||
|
it('keeps retrying distinct and clears current error on success', () => {
|
||||||
|
const retry = reduceWorkflowExecutionSteps([], {
|
||||||
|
data: {
|
||||||
|
nodeId: 'llm',
|
||||||
|
status: 'ERROR',
|
||||||
|
error: '限流',
|
||||||
|
errorDetail: { retryable: true },
|
||||||
|
},
|
||||||
|
eventId: 'retry',
|
||||||
|
type: 'node_finished',
|
||||||
|
});
|
||||||
|
expect(retry[0]?.status).toBe('retrying');
|
||||||
|
const success = reduceWorkflowExecutionSteps(retry, {
|
||||||
|
data: { nodeId: 'llm', status: 'SUCCEEDED', output: { text: 'ok' } },
|
||||||
|
eventId: 'success',
|
||||||
|
type: 'node_finished',
|
||||||
|
});
|
||||||
|
expect(success[0]).toMatchObject({
|
||||||
|
status: 'completed',
|
||||||
|
output: { text: 'ok' },
|
||||||
|
});
|
||||||
|
expect(success[0]?.error).toBeUndefined();
|
||||||
|
expect(success[0]?.errorDetail).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not blame parallel siblings for the first failure', () => {
|
||||||
|
let steps: ReturnType<typeof reduceWorkflowExecutionSteps> = [];
|
||||||
|
for (const nodeId of ['a', 'b']) {
|
||||||
|
steps = reduceWorkflowExecutionSteps(steps, {
|
||||||
|
data: { nodeId },
|
||||||
|
eventId: nodeId,
|
||||||
|
type: 'node_started',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const finalized = finalizeWorkflowExecutionSteps(steps, 'failed', 200, {
|
||||||
|
code: 'WORKFLOW_EXECUTION_FAILED',
|
||||||
|
reasonCode: 'MODEL_TIMEOUT',
|
||||||
|
message: '超时',
|
||||||
|
nodeId: 'a',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
expect(finalized[0]?.status).toBe('failed');
|
||||||
|
expect(finalized[1]?.status).toBe('cancelled');
|
||||||
|
});
|
||||||
|
it('preserves a failed attempt when a new retry succeeds', () => {
|
||||||
|
const retry = reduceWorkflowExecutionSteps([], {
|
||||||
|
data: {
|
||||||
|
nodeId: 'llm',
|
||||||
|
attemptKey: 'llm:1',
|
||||||
|
status: 'ERROR',
|
||||||
|
error: '限流',
|
||||||
|
errorDetail: { retryable: true },
|
||||||
|
},
|
||||||
|
eventId: '1',
|
||||||
|
type: 'node_finished',
|
||||||
|
});
|
||||||
|
const started = reduceWorkflowExecutionSteps(retry, {
|
||||||
|
data: { nodeId: 'llm', attemptKey: 'llm:2' },
|
||||||
|
eventId: '2',
|
||||||
|
type: 'node_started',
|
||||||
|
});
|
||||||
|
const success = reduceWorkflowExecutionSteps(started, {
|
||||||
|
data: { nodeId: 'llm', attemptKey: 'llm:2', status: 'SUCCEEDED' },
|
||||||
|
eventId: '3',
|
||||||
|
type: 'node_finished',
|
||||||
|
});
|
||||||
|
const completed = finalizeWorkflowExecutionSteps(success, 'completed', 200);
|
||||||
|
expect(completed[0]).toMatchObject({
|
||||||
|
status: 'failed',
|
||||||
|
errorDetail: { retryable: false },
|
||||||
|
});
|
||||||
|
expect(completed[1]?.status).toBe('completed');
|
||||||
|
expect(completed[1]?.error).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { WorkflowExecutionError } from './workflowExecutionError';
|
||||||
|
|
||||||
import { sanitizeWorkflowOutputForDisplay } from './workflowFinalOutput';
|
import { sanitizeWorkflowOutputForDisplay } from './workflowFinalOutput';
|
||||||
|
|
||||||
export interface WorkflowExecutionTrace {
|
export interface WorkflowExecutionTrace {
|
||||||
@@ -10,6 +12,7 @@ export type WorkflowExecutionStepStatus =
|
|||||||
| 'cancelled'
|
| 'cancelled'
|
||||||
| 'completed'
|
| 'completed'
|
||||||
| 'failed'
|
| 'failed'
|
||||||
|
| 'retrying'
|
||||||
| 'running'
|
| 'running'
|
||||||
| 'waiting';
|
| 'waiting';
|
||||||
|
|
||||||
@@ -18,6 +21,7 @@ export interface WorkflowExecutionStepView {
|
|||||||
duration?: number;
|
duration?: number;
|
||||||
endTime?: number;
|
endTime?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
errorDetail?: WorkflowExecutionError;
|
||||||
hasInput: boolean;
|
hasInput: boolean;
|
||||||
hasOutput: boolean;
|
hasOutput: boolean;
|
||||||
input?: unknown;
|
input?: unknown;
|
||||||
@@ -58,6 +62,17 @@ export function reduceWorkflowExecutionSteps(
|
|||||||
const stepIndex = findStepIndex(current, attemptKey, nodeId);
|
const stepIndex = findStepIndex(current, attemptKey, nodeId);
|
||||||
|
|
||||||
if (event.type === 'node_started') {
|
if (event.type === 'node_started') {
|
||||||
|
current = current.map((step) =>
|
||||||
|
step.nodeId === nodeId && step.status === 'retrying'
|
||||||
|
? {
|
||||||
|
...step,
|
||||||
|
status: 'failed',
|
||||||
|
errorDetail: step.errorDetail
|
||||||
|
? { ...step.errorDetail, retryable: false }
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
: step,
|
||||||
|
);
|
||||||
const startTime = numberValue(data.startedAt) ?? now;
|
const startTime = numberValue(data.startedAt) ?? now;
|
||||||
const nextStep: WorkflowExecutionStepView = {
|
const nextStep: WorkflowExecutionStepView = {
|
||||||
attemptKey: attemptKey || undefined,
|
attemptKey: attemptKey || undefined,
|
||||||
@@ -83,6 +98,8 @@ export function reduceWorkflowExecutionSteps(
|
|||||||
next[stepIndex] = {
|
next[stepIndex] = {
|
||||||
...existingStep,
|
...existingStep,
|
||||||
...nextStep,
|
...nextStep,
|
||||||
|
error: undefined,
|
||||||
|
errorDetail: undefined,
|
||||||
traces: existingStep.traces,
|
traces: existingStep.traces,
|
||||||
};
|
};
|
||||||
return next;
|
return next;
|
||||||
@@ -120,6 +137,7 @@ export function reduceWorkflowExecutionSteps(
|
|||||||
startTime === undefined ? undefined : Math.max(0, endTime - startTime),
|
startTime === undefined ? undefined : Math.max(0, endTime - startTime),
|
||||||
endTime,
|
endTime,
|
||||||
error: textValue(data.error) || undefined,
|
error: textValue(data.error) || undefined,
|
||||||
|
errorDetail: data.errorDetail,
|
||||||
hasOutput: hasOwn(data, 'output'),
|
hasOutput: hasOwn(data, 'output'),
|
||||||
output: data.output,
|
output: data.output,
|
||||||
status: resolveLiveStatus(data.status, data.error),
|
status: resolveLiveStatus(data.status, data.error),
|
||||||
@@ -139,11 +157,13 @@ export function reduceWorkflowExecutionSteps(
|
|||||||
*/
|
*/
|
||||||
export function hydrateWorkflowExecutionSteps(
|
export function hydrateWorkflowExecutionSteps(
|
||||||
steps: unknown,
|
steps: unknown,
|
||||||
|
error?: WorkflowExecutionError,
|
||||||
): WorkflowExecutionStepView[] {
|
): WorkflowExecutionStepView[] {
|
||||||
if (!Array.isArray(steps)) {
|
if (!Array.isArray(steps)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return steps.map((step: Record<string, any>, index) => ({
|
const result: WorkflowExecutionStepView[] = steps.map(
|
||||||
|
(step: Record<string, any>, index) => ({
|
||||||
attemptKey: textValue(step.attemptKey) || undefined,
|
attemptKey: textValue(step.attemptKey) || undefined,
|
||||||
duration: numberValue(step.execTime),
|
duration: numberValue(step.execTime),
|
||||||
endTime: timeValue(step.endTime),
|
endTime: timeValue(step.endTime),
|
||||||
@@ -162,7 +182,18 @@ export function hydrateWorkflowExecutionSteps(
|
|||||||
startTime: timeValue(step.startTime),
|
startTime: timeValue(step.startTime),
|
||||||
status: resolvePersistedStatus(step.status),
|
status: resolvePersistedStatus(step.status),
|
||||||
traces: [],
|
traces: [],
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
if (error?.nodeId) {
|
||||||
|
const failedStep = [...result]
|
||||||
|
.reverse()
|
||||||
|
.find((step) => step.nodeId === error.nodeId && step.status === 'failed');
|
||||||
|
if (failedStep) {
|
||||||
|
failedStep.errorDetail = error;
|
||||||
|
failedStep.error ||= error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -172,13 +203,22 @@ export function finalizeWorkflowExecutionSteps(
|
|||||||
steps: WorkflowExecutionStepView[],
|
steps: WorkflowExecutionStepView[],
|
||||||
status: 'cancelled' | 'completed' | 'failed',
|
status: 'cancelled' | 'completed' | 'failed',
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
|
error?: WorkflowExecutionError,
|
||||||
): WorkflowExecutionStepView[] {
|
): WorkflowExecutionStepView[] {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const next = steps.map((step) => {
|
const next = steps.map((step) => {
|
||||||
if (step.status !== 'running' && step.status !== 'waiting') {
|
if (!['retrying', 'running', 'waiting'].includes(step.status)) {
|
||||||
return step;
|
return step;
|
||||||
}
|
}
|
||||||
changed = true;
|
changed = true;
|
||||||
|
let finalStatus: WorkflowExecutionStepStatus = status;
|
||||||
|
if (step.status === 'retrying') finalStatus = 'failed';
|
||||||
|
else if (status === 'failed' && error?.nodeId !== step.nodeId)
|
||||||
|
finalStatus = 'cancelled';
|
||||||
|
let detail = step.errorDetail
|
||||||
|
? { ...step.errorDetail, retryable: false }
|
||||||
|
: undefined;
|
||||||
|
if (error?.nodeId === step.nodeId) detail = error;
|
||||||
return {
|
return {
|
||||||
...step,
|
...step,
|
||||||
duration:
|
duration:
|
||||||
@@ -186,7 +226,9 @@ export function finalizeWorkflowExecutionSteps(
|
|||||||
? step.duration
|
? step.duration
|
||||||
: Math.max(0, now - step.startTime),
|
: Math.max(0, now - step.startTime),
|
||||||
endTime: now,
|
endTime: now,
|
||||||
status,
|
status: finalStatus,
|
||||||
|
error: error?.nodeId === step.nodeId ? error.message : step.error,
|
||||||
|
errorDetail: detail,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return changed ? next : steps;
|
return changed ? next : steps;
|
||||||
@@ -272,7 +314,9 @@ function resolveLiveStatus(
|
|||||||
error: unknown,
|
error: unknown,
|
||||||
): WorkflowExecutionStepStatus {
|
): WorkflowExecutionStepStatus {
|
||||||
const normalized = textValue(status).toUpperCase();
|
const normalized = textValue(status).toUpperCase();
|
||||||
if (error || normalized === 'ERROR' || normalized === 'FAILED') {
|
if (normalized === 'ERROR') return 'retrying';
|
||||||
|
if (normalized === 'SUCCEEDED') return 'completed';
|
||||||
|
if (error || normalized === 'FAILED') {
|
||||||
return 'failed';
|
return 'failed';
|
||||||
}
|
}
|
||||||
if (normalized === 'SUSPEND') {
|
if (normalized === 'SUSPEND') {
|
||||||
@@ -289,7 +333,9 @@ function resolvePersistedStatus(status: unknown): WorkflowExecutionStepStatus {
|
|||||||
case '5': {
|
case '5': {
|
||||||
return 'waiting';
|
return 'waiting';
|
||||||
}
|
}
|
||||||
case '10':
|
case '10': {
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
case '21': {
|
case '21': {
|
||||||
return 'failed';
|
return 'failed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export interface WorkflowExecutionError {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
nodeId?: string;
|
||||||
|
nodeName?: string;
|
||||||
|
reasonCode: string;
|
||||||
|
retryable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只复制排查所需的公开字段,避免带出输入、输出及原始异常。 */
|
||||||
|
export function formatWorkflowErrorContext(
|
||||||
|
error: WorkflowExecutionError,
|
||||||
|
executeId?: string,
|
||||||
|
) {
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
executeId,
|
||||||
|
nodeId: error.nodeId,
|
||||||
|
nodeName: error.nodeName,
|
||||||
|
reasonCode: error.reasonCode,
|
||||||
|
message: error.message,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user