feat(engine): NodeState 补 waiting_user/unknown 两态+StepStatus 映射表;node.finished 增检查点负载(A++ 字段定稿);补 sm-crypto 类型声明
This commit is contained in:
1 parent
4615fcab10
commit
a01667bdb3
6 files changed
+169
-3
No files matched your search
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CatalogComponent, FlowDefinition, FlowNode, RunEvent } from "../types";
|
||||
import { register } from "./registry";
|
||||
import { NODE_STATUS_TO_STEP_STATUS, toStepStatus } from "./types";
|
||||
import { runFlow } from "./runtime";
|
||||
|
||||
type EmittedEvent = Omit<RunEvent, "runId">;
|
||||
type RunFlowOptions = Parameters<typeof runFlow>[1];
|
||||
|
||||
function makeFlow(partial: Partial<FlowDefinition> & { nodes: FlowNode[] }): FlowDefinition {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: "flow_checkpoint",
|
||||
name: "检查点测试流程",
|
||||
variables: [],
|
||||
nodes: partial.nodes,
|
||||
edges: partial.edges || [],
|
||||
meta: { createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", appVersion: "test" }
|
||||
};
|
||||
}
|
||||
|
||||
function node(id: string, componentId: string): FlowNode {
|
||||
return { id, componentId, position: { x: 0, y: 0 }, values: {}, options: {} };
|
||||
}
|
||||
|
||||
const fakeComponent = (id: string): CatalogComponent =>
|
||||
({
|
||||
id,
|
||||
name: id,
|
||||
version: "1.0.0",
|
||||
tags: [],
|
||||
platformTag: null,
|
||||
icon: "",
|
||||
risk: "low",
|
||||
fields: [],
|
||||
output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] },
|
||||
slots: [],
|
||||
functionCalls: []
|
||||
}) as unknown as CatalogComponent;
|
||||
|
||||
const baseOptions = (emit: (event: EmittedEvent) => void): RunFlowOptions => ({
|
||||
runId: "run_cp",
|
||||
evidenceDir: ".",
|
||||
signal: new AbortController().signal,
|
||||
emit,
|
||||
log: () => {},
|
||||
componentOf: (n: FlowNode) => fakeComponent(n.componentId),
|
||||
variables: {}
|
||||
});
|
||||
|
||||
register("Cp_Idempotent", async () => "ok", { idempotent: true });
|
||||
register("Cp_NonIdempotent", async () => "ok", { idempotent: false });
|
||||
register("Cp_DefaultMeta", async () => "ok");
|
||||
|
||||
describe("状态映射(NodeState ↔ 对面 StepStatus)", () => {
|
||||
it("8 个状态全分支映射,唯一非同名是 idle→pending", () => {
|
||||
expect(toStepStatus("idle")).toBe("pending");
|
||||
for (const status of ["running", "waiting_user", "succeeded", "failed", "cancelled", "unknown", "skipped"] as const) {
|
||||
expect(toStepStatus(status)).toBe(status);
|
||||
}
|
||||
});
|
||||
|
||||
it("映射表为全量双射(无遗漏键)", () => {
|
||||
expect(Object.keys(NODE_STATUS_TO_STEP_STATUS).sort()).toEqual(
|
||||
["cancelled", "failed", "idle", "running", "skipped", "succeeded", "unknown", "waiting_user"]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("A++ 检查点事件负载", () => {
|
||||
it("node.finished 携带 checkpointId/resumable/幂等键,非幂等不带键", async () => {
|
||||
const events: EmittedEvent[] = [];
|
||||
const flow = makeFlow({
|
||||
nodes: [node("first", "Cp_Idempotent"), node("second", "Cp_NonIdempotent"), node("third", "Cp_DefaultMeta")],
|
||||
edges: [
|
||||
{ id: "e1", source: "first", target: "second" },
|
||||
{ id: "e2", source: "second", target: "third" }
|
||||
]
|
||||
});
|
||||
const result = await runFlow(flow, baseOptions((event) => events.push(event)));
|
||||
expect(result.status).toBe("succeeded");
|
||||
|
||||
const finished = events.filter((e) => e.type === "node.finished" && e.status === "succeeded");
|
||||
expect(finished).toHaveLength(3);
|
||||
|
||||
const first = finished.find((e) => e.nodeId === "first")!;
|
||||
expect(first.checkpointId).toBe("first");
|
||||
expect(first.resumable).toBe(true);
|
||||
expect(first.idempotencyKey).toBe("run_cp:first");
|
||||
|
||||
const second = finished.find((e) => e.nodeId === "second")!;
|
||||
expect(second.checkpointId).toBe("second");
|
||||
expect(second.resumable).toBe(false);
|
||||
expect(second.idempotencyKey).toBeUndefined();
|
||||
|
||||
const third = finished.find((e) => e.nodeId === "third")!;
|
||||
expect(third.resumable).toBe(true);
|
||||
expect(third.idempotencyKey).toBe("run_cp:third");
|
||||
});
|
||||
|
||||
it("幂等键由 runId+nodeId 派生,跨重入稳定(重入去重的基石)", async () => {
|
||||
const flow = makeFlow({ nodes: [node("only", "Cp_Idempotent")] });
|
||||
const runOnce = async (runId: string): Promise<string | undefined> => {
|
||||
const list: EmittedEvent[] = [];
|
||||
await runFlow(flow, { ...baseOptions((event) => list.push(event)), runId });
|
||||
return list.find((e) => e.type === "node.finished")?.idempotencyKey;
|
||||
};
|
||||
expect(await runOnce("run_a")).toBe("run_a:only");
|
||||
expect(await runOnce("run_a")).toBe("run_a:only");
|
||||
expect(await runOnce("run_b")).toBe("run_b:only");
|
||||
});
|
||||
});
|
||||
@@ -357,13 +357,20 @@ async function executeNode(node: FlowNode, ctx: RunContext, states: Map<string,
|
||||
state.output = result;
|
||||
state.finishedAt = new Date().toISOString();
|
||||
state.durationMs = Date.now() - startMs;
|
||||
// A++ 检查点协议:成功节点即续跑锚点;checkpointId 取节点 id(跨 attempt 稳定);
|
||||
// 幂等键同样跨 attempt 稳定,供带 resumeToken 重入时去重;非幂等能力不作为锚点
|
||||
const checkpointMeta = getCapabilityMeta(node.componentId) || getCapabilityMeta(component.functionName);
|
||||
const resumable = checkpointMeta?.idempotent !== false;
|
||||
ctx.emit({
|
||||
type: "node.finished",
|
||||
nodeId: node.id,
|
||||
status: "succeeded",
|
||||
output: result,
|
||||
durationMs: state.durationMs,
|
||||
evidence: state.evidence
|
||||
evidence: state.evidence,
|
||||
checkpointId: node.id,
|
||||
resumable,
|
||||
...(resumable ? { idempotencyKey: `${ctx.runId}:${node.id}` } : {})
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
|
||||
@@ -139,7 +139,8 @@ export type CapabilityHandler = (
|
||||
export interface NodeState {
|
||||
nodeId: string;
|
||||
componentId: string;
|
||||
status: "idle" | "running" | "succeeded" | "failed" | "skipped" | "cancelled";
|
||||
// waiting_user:节点挂起等待人审(对面 approval 流);unknown:崩溃后结果不可知(对面 workflow-recovery 对齐)
|
||||
status: "idle" | "running" | "succeeded" | "failed" | "skipped" | "cancelled" | "waiting_user" | "unknown";
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
durationMs?: number;
|
||||
@@ -149,6 +150,24 @@ export interface NodeState {
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
// 与对面 workflow-engine StepStatus(8 态)同构;唯一非同名映射是 idle↔pending
|
||||
export type WorkflowStepStatus = "pending" | "running" | "waiting_user" | "succeeded" | "failed" | "cancelled" | "unknown" | "skipped";
|
||||
|
||||
export const NODE_STATUS_TO_STEP_STATUS: Record<NodeState["status"], WorkflowStepStatus> = {
|
||||
idle: "pending",
|
||||
running: "running",
|
||||
waiting_user: "waiting_user",
|
||||
succeeded: "succeeded",
|
||||
failed: "failed",
|
||||
cancelled: "cancelled",
|
||||
unknown: "unknown",
|
||||
skipped: "skipped"
|
||||
};
|
||||
|
||||
export function toStepStatus(status: NodeState["status"]): WorkflowStepStatus {
|
||||
return NODE_STATUS_TO_STEP_STATUS[status];
|
||||
}
|
||||
|
||||
export interface RunResult {
|
||||
runId: string;
|
||||
status: "succeeded" | "failed" | "cancelled";
|
||||
|
||||
@@ -181,6 +181,10 @@ export interface RunEvent {
|
||||
durationMs?: number;
|
||||
evidence?: string[];
|
||||
attempt?: number;
|
||||
// A++ 检查点协议负载(ADR-R2 补记):node.finished 成功时由 runtime 填充
|
||||
checkpointId?: string;
|
||||
idempotencyKey?: string;
|
||||
resumable?: boolean;
|
||||
startedAt?: string;
|
||||
nodeLabel?: string;
|
||||
total?: number;
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
// sm-crypto 无官方类型声明;仅声明本仓库用到的 SM4 解密 API
|
||||
declare module "sm-crypto" {
|
||||
export interface Sm4Options {
|
||||
padding?: "pkcs#7" | "none";
|
||||
mode?: "cbc";
|
||||
iv?: string;
|
||||
}
|
||||
export const sm4: {
|
||||
decrypt(
|
||||
cipherText: string,
|
||||
key: string,
|
||||
options?: Sm4Options
|
||||
): string;
|
||||
encrypt(plainText: string, key: string, options?: Sm4Options): string;
|
||||
};
|
||||
export const sm2: {
|
||||
doEncrypt(msgString: string, publicKey: string, cipherMode?: 0 | 1): string;
|
||||
doDecrypt(
|
||||
cipherText: string,
|
||||
privateKey: string,
|
||||
cipherMode?: 0 | 1
|
||||
): string;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user