feat(engine): 取消贯通/超时真中止/重试门控/图校验(迁移阶段1-A3)
- 节点尝试级 AbortController:run 取消与节点超时汇聚到同一信号, handler 收到的 ctx.signal 即尝试信号,其内部 sleep/HTTP/子进程可被真正中止 - 超时中止底层后 handler 先抛 cancelled 的竞态归一为 timeout - executeNode 取消映射 NodeState=cancelled:不适用 skipOnError、不进异常分支 - 重试门控:CapabilityMeta.idempotent===false 时无视 retryCount(防重复副作用) - 新增 validateFlowGraph:重复ID/悬空边/自环/顶层环/孤儿节点/不可达节点, 阻断性错误在 runFlow 内统一收口为 validation_error - 新增 11 项测试;既有 error-route/integration 回归通过
This commit is contained in:
1 parent
d972a18bda
commit
9b76dc6987
4 files changed
+389
-5
No files matched your search
@@ -0,0 +1,202 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowDefinition, FlowNode } from "../../types";
|
||||
import { validateFlowGraph, hasBlockingIssues } from "./graph-validator";
|
||||
import { register } from "./registry";
|
||||
import { RuntimeError } from "./types";
|
||||
import { runFlow } from "./runtime";
|
||||
|
||||
type RunFlowOptions = Parameters<typeof runFlow>[1];
|
||||
|
||||
function makeFlow(partial: Partial<FlowDefinition> & { nodes: FlowNode[] }): FlowDefinition {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: "flow_test",
|
||||
name: "测试流程",
|
||||
variables: [],
|
||||
nodes: partial.nodes,
|
||||
edges: partial.edges || []
|
||||
} as FlowDefinition;
|
||||
}
|
||||
|
||||
function node(id: string, componentId: string, overrides: Partial<FlowNode> = {}): FlowNode {
|
||||
return {
|
||||
id,
|
||||
componentId,
|
||||
position: { x: 0, y: 0 },
|
||||
values: {},
|
||||
options: {},
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
const fakeComponent = (id: string): CatalogComponent => ({
|
||||
id,
|
||||
name: id,
|
||||
version: "1.0.0",
|
||||
tags: [],
|
||||
platformTag: null,
|
||||
icon: "",
|
||||
risk: "low",
|
||||
fields: [],
|
||||
output: { type: "any", description: "" },
|
||||
slots: [],
|
||||
functionCalls: []
|
||||
});
|
||||
|
||||
const baseOptions = (signal: AbortSignal): RunFlowOptions => ({
|
||||
runId: "run_test",
|
||||
evidenceDir: ".",
|
||||
signal,
|
||||
emit: () => {},
|
||||
log: () => {},
|
||||
componentOf: (n: FlowNode) => fakeComponent(n.componentId),
|
||||
variables: {}
|
||||
});
|
||||
|
||||
const hangUntilAborted = (ctx: { signal: AbortSignal }): Promise<never> =>
|
||||
new Promise((_, reject) => {
|
||||
const onAbort = () => reject(new RuntimeError("cancelled", "运行已取消"));
|
||||
if (ctx.signal.aborted) return onAbort();
|
||||
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
|
||||
register("Test_HangHandler", (ctx) => hangUntilAborted(ctx));
|
||||
|
||||
register("Test_SlowHandler", async (ctx) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, 5000);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new RuntimeError("cancelled", "运行已取消"));
|
||||
};
|
||||
if (ctx.signal.aborted) return onAbort();
|
||||
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
return "done";
|
||||
});
|
||||
|
||||
let nonIdempotentCalls = 0;
|
||||
register("Test_NonIdempotent", async () => {
|
||||
nonIdempotentCalls += 1;
|
||||
throw new Error("boom");
|
||||
}, { idempotent: false });
|
||||
|
||||
let idempotentCalls = 0;
|
||||
register("Test_Idempotent", async () => {
|
||||
idempotentCalls += 1;
|
||||
if (idempotentCalls < 3) throw new Error("transient");
|
||||
return "ok";
|
||||
}, { idempotent: true });
|
||||
|
||||
describe("validateFlowGraph", () => {
|
||||
it("正常图无问题", () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("a", "Trigger.Manual"), node("b", "File_Read")],
|
||||
edges: [{ id: "e1", source: "a", target: "b" }]
|
||||
});
|
||||
expect(hasBlockingIssues(validateFlowGraph(flow))).toBe(false);
|
||||
});
|
||||
|
||||
it("重复节点 ID 记为错误", () => {
|
||||
const flow = makeFlow({ nodes: [node("a", "Trigger.Manual"), node("a", "File_Read")] });
|
||||
const issues = validateFlowGraph(flow);
|
||||
expect(issues.some((issue) => issue.code === "duplicate_node_id" && issue.severity === "error")).toBe(true);
|
||||
});
|
||||
|
||||
it("悬空边记为错误", () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("a", "Trigger.Manual")],
|
||||
edges: [{ id: "e1", source: "a", target: "ghost" }]
|
||||
});
|
||||
expect(validateFlowGraph(flow).some((issue) => issue.code === "dangling_edge")).toBe(true);
|
||||
});
|
||||
|
||||
it("自环记为错误", () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("a", "Trigger.Manual")],
|
||||
edges: [{ id: "e1", source: "a", target: "a" }]
|
||||
});
|
||||
expect(validateFlowGraph(flow).some((issue) => issue.code === "self_loop")).toBe(true);
|
||||
});
|
||||
|
||||
it("顶层环记为错误", () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("a", "Trigger.Manual"), node("b", "File_Read"), node("c", "File_Write")],
|
||||
edges: [
|
||||
{ id: "e1", source: "a", target: "b" },
|
||||
{ id: "e2", source: "b", target: "c" },
|
||||
{ id: "e3", source: "c", target: "b" }
|
||||
]
|
||||
});
|
||||
expect(validateFlowGraph(flow).some((issue) => issue.code === "cycle" && issue.severity === "error")).toBe(true);
|
||||
});
|
||||
|
||||
it("孤儿子节点与不可达节点", () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("a", "Trigger.Manual"), { ...node("x", "File_Read"), parentId: "ghost" }, node("y", "Http_Get")]
|
||||
});
|
||||
const issues = validateFlowGraph(flow);
|
||||
expect(issues.some((issue) => issue.code === "orphan_child")).toBe(true);
|
||||
expect(issues.some((issue) => issue.code === "unreachable_node")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime 取消/超时/重试门控", () => {
|
||||
it("超时会中止监听尝试信号的 handler", async () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [{ ...node("slow", "Test_SlowHandler"), options: { timeoutMs: 120 } }]
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = await runFlow(flow, baseOptions(controller.signal));
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.error?.code).toBe("timeout");
|
||||
expect(result.error?.reason).toContain("执行超时");
|
||||
});
|
||||
|
||||
it("运行取消时节点状态为 cancelled 且不走 error route", async () => {
|
||||
const flow = makeFlow({
|
||||
nodes: [node("n1", "Test_HangHandler"), node("err", "File_Write")],
|
||||
edges: [{ id: "e1", source: "n1", target: "err", sourceHandle: "error" }]
|
||||
});
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 50);
|
||||
const result = await runFlow(flow, baseOptions(controller.signal));
|
||||
expect(result.status).toBe("cancelled");
|
||||
const nodeState = result.nodes.find((item) => item.nodeId === "n1");
|
||||
expect(nodeState?.status).toBe("cancelled");
|
||||
// error 边存在,但取消不应路由到 err 节点
|
||||
expect(result.nodes.some((item) => item.nodeId === "err")).toBe(false);
|
||||
});
|
||||
|
||||
it("非幂等能力忽略 retryCount", async () => {
|
||||
nonIdempotentCalls = 0;
|
||||
const flow = makeFlow({
|
||||
nodes: [{ ...node("r", "Test_NonIdempotent"), options: { retryCount: 3, retryIntervalMs: 1000 } }]
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = await runFlow(flow, baseOptions(controller.signal));
|
||||
expect(result.status).toBe("failed");
|
||||
expect(nonIdempotentCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("幂等能力遵循 retryCount 重试后成功", async () => {
|
||||
idempotentCalls = 0;
|
||||
const flow = makeFlow({
|
||||
nodes: [{ ...node("r", "Test_Idempotent"), options: { retryCount: 3, retryIntervalMs: 1000, timeoutMs: 5000 } }]
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = await runFlow(flow, baseOptions(controller.signal));
|
||||
expect(result.status).toBe("succeeded");
|
||||
expect(idempotentCalls).toBe(3);
|
||||
});
|
||||
|
||||
it("流程图校验失败返回 validation_error 且不执行任何节点", async () => {
|
||||
const flow = makeFlow({ nodes: [node("a", "Trigger.Manual"), node("a", "File_Read")] });
|
||||
const controller = new AbortController();
|
||||
const result = await runFlow(flow, baseOptions(controller.signal));
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.error?.code).toBe("validation_error");
|
||||
expect(result.error?.reason).toContain("流程图校验未通过");
|
||||
expect(result.nodes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user