refactor: dashboard status-based edge routing
- Rename ConditionalEdge → StatusEdge, condition → status throughout - Rename conditional.tsx → status.tsx, edge label shows status value - Update trans-in/trans-out to use status field instead of condition - Update validate to check status edges - Align server/workflow.ts with new WorkflowPayload.graph format - 20 dashboard tests pass Phase 3 of #490 (closes #493)
This commit is contained in:
@@ -81,7 +81,7 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
|
||||
expect(workflow.roles.committer?.frontmatter).toBeDefined();
|
||||
});
|
||||
|
||||
test("committer frontmatter schema should require success field", async () => {
|
||||
test("committer frontmatter schema should require status field", async () => {
|
||||
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||
// Parse as any to access the raw YAML structure (frontmatter is inline JSON Schema in YAML)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -90,8 +90,8 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
|
||||
const frontmatter = workflow.roles.committer?.frontmatter;
|
||||
expect(frontmatter).toBeDefined();
|
||||
expect(frontmatter?.type).toBe("object");
|
||||
expect(frontmatter?.properties?.success).toBeDefined();
|
||||
expect(frontmatter?.properties?.success?.type).toBe("boolean");
|
||||
expect(frontmatter?.required).toContain("success");
|
||||
expect(frontmatter?.properties?.status).toBeDefined();
|
||||
expect(frontmatter?.properties?.status?.enum).toContain("committed");
|
||||
expect(frontmatter?.required).toContain("status");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,7 +57,7 @@ export function createApi() {
|
||||
transitions: t.Array(
|
||||
t.Object({
|
||||
target: t.String(),
|
||||
condition: t.Union([t.String(), t.Null()]),
|
||||
status: t.String(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { RoleDefinition, Transition, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import type { RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
|
||||
import YAML from "yaml";
|
||||
import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts";
|
||||
|
||||
@@ -11,17 +11,12 @@ async function ensureDir() {
|
||||
}
|
||||
|
||||
function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
|
||||
const conditionMap = new Map<string, string>();
|
||||
for (const [name, def] of Object.entries(payload.conditions)) {
|
||||
conditionMap.set(name, def.expression);
|
||||
}
|
||||
|
||||
const steps: WorkFlowSteps = [];
|
||||
for (const [roleName, roleDef] of Object.entries(payload.roles)) {
|
||||
const graphTransitions = payload.graph[roleName] ?? [];
|
||||
const transitions: WorkFlowTransition[] = graphTransitions.map((t) => ({
|
||||
target: t.role === "$END" ? "END" : t.role,
|
||||
condition: t.condition ? (conditionMap.get(t.condition) ?? t.condition) : null,
|
||||
const statusMap = payload.graph[roleName] ?? {};
|
||||
const transitions: WorkFlowTransition[] = Object.entries(statusMap).map(([status, target]) => ({
|
||||
target: target.role === "$END" ? "END" : target.role,
|
||||
status,
|
||||
}));
|
||||
|
||||
steps.push({
|
||||
@@ -42,11 +37,7 @@ function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
|
||||
|
||||
function stepsToPayload(name: string, description: string, steps: WorkFlowSteps): WorkflowPayload {
|
||||
const roles: Record<string, RoleDefinition> = {};
|
||||
const conditions: WorkflowPayload["conditions"] = {};
|
||||
const graph: Record<string, Transition[]> = {};
|
||||
|
||||
const expressionToName = new Map<string, string>();
|
||||
let condIdx = 0;
|
||||
const graph: Record<string, Record<string, Target>> = {};
|
||||
|
||||
for (const step of steps) {
|
||||
const r = step.role;
|
||||
@@ -59,43 +50,28 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
|
||||
frontmatter: "",
|
||||
};
|
||||
|
||||
const transitions: Transition[] = step.transitions.map((t) => {
|
||||
let condName: string | null = null;
|
||||
if (t.condition) {
|
||||
if (expressionToName.has(t.condition)) {
|
||||
condName = expressionToName.get(t.condition) ?? null;
|
||||
} else {
|
||||
condName = `cond${condIdx++}`;
|
||||
expressionToName.set(t.condition, condName);
|
||||
conditions[condName] = {
|
||||
description: "",
|
||||
expression: t.condition,
|
||||
};
|
||||
}
|
||||
}
|
||||
const statusMap: Record<string, Target> = {};
|
||||
for (const t of step.transitions) {
|
||||
const targetRole = t.target === "END" ? "$END" : t.target;
|
||||
return {
|
||||
statusMap[t.status] = {
|
||||
role: targetRole,
|
||||
condition: condName,
|
||||
prompt: `Transition to ${targetRole}.`,
|
||||
};
|
||||
});
|
||||
|
||||
graph[r.name] = transitions;
|
||||
}
|
||||
graph[r.name] = statusMap;
|
||||
}
|
||||
|
||||
if (steps.length > 0) {
|
||||
const firstRole = steps[0].role.name;
|
||||
graph.$START = [
|
||||
{
|
||||
graph.$START = {
|
||||
_: {
|
||||
role: firstRole,
|
||||
condition: null,
|
||||
prompt: `Begin workflow at role ${firstRole}.`,
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
return { name, description, roles, conditions, graph };
|
||||
return { name, description, roles, graph };
|
||||
}
|
||||
|
||||
export async function listWorkflows(): Promise<WorkflowSummary[]> {
|
||||
@@ -125,7 +101,6 @@ export async function createWorkflow(name: string, description: string): Promise
|
||||
name,
|
||||
description,
|
||||
roles: {},
|
||||
conditions: {},
|
||||
graph: {},
|
||||
};
|
||||
await writeFile(join(WORKFLOW_DIR, `${name}.yaml`), YAML.stringify(payload), "utf-8");
|
||||
|
||||
@@ -9,7 +9,7 @@ export type WorkFlowRole = {
|
||||
|
||||
export type WorkFlowTransition = {
|
||||
target: string;
|
||||
condition: string | null;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type WorkFlowStep = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ConditionalEdge, GradientEdge } from "./conditional";
|
||||
import { GradientEdge, StatusEdge } from "./status";
|
||||
|
||||
export const edgeTypes = {
|
||||
conditional: ConditionalEdge,
|
||||
status: StatusEdge,
|
||||
default: GradientEdge,
|
||||
};
|
||||
|
||||
+24
-52
@@ -6,10 +6,10 @@ import {
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { Check } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "../../lib/utils.ts";
|
||||
import { useModel } from "../context.tsx";
|
||||
import type { ConditionalEdge as ConditionalEdgeType } from "../type.ts";
|
||||
import type { StatusEdge as StatusEdgeType } from "../type.ts";
|
||||
|
||||
const SOURCE_COLOR = "#10b981";
|
||||
const TARGET_COLOR = "#3b82f6";
|
||||
@@ -23,7 +23,7 @@ function GradientPath({
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
hasCondition,
|
||||
hasStatus,
|
||||
selected,
|
||||
}: {
|
||||
id: string;
|
||||
@@ -32,11 +32,11 @@ function GradientPath({
|
||||
sourceY: number;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
hasCondition: boolean | null;
|
||||
hasStatus: boolean;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const gradientId = `gradient-${id}`;
|
||||
const showLack = hasCondition === false;
|
||||
const showLack = !hasStatus;
|
||||
const strokeStyle = selected
|
||||
? { stroke: "#f59e0b", strokeWidth: 2 }
|
||||
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
|
||||
@@ -68,35 +68,20 @@ function GradientPath({
|
||||
);
|
||||
}
|
||||
|
||||
function ElseBadge({ labelX, labelY }: { labelX: number; labelY: number }): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
}}
|
||||
>
|
||||
<span className="inline-block px-1 bg-white rounded text-[10px] border border-gray-300 text-gray-500">
|
||||
else
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ConditionLabelProps = {
|
||||
condition: string | undefined;
|
||||
type StatusLabelProps = {
|
||||
status: string | undefined;
|
||||
labelX: number;
|
||||
labelY: number;
|
||||
onSave: (value: string) => void;
|
||||
};
|
||||
|
||||
function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelProps): ReactNode {
|
||||
function StatusLabel({ status, labelX, labelY, onSave }: StatusLabelProps): ReactNode {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function handleBadgeClick() {
|
||||
setInputValue(condition || "");
|
||||
setInputValue(status || "");
|
||||
setIsOpen(true);
|
||||
}
|
||||
|
||||
@@ -127,6 +112,8 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
return () => document.removeEventListener("pointerdown", handleClickOutside, true);
|
||||
}, [isOpen]);
|
||||
|
||||
const displayStatus = status?.trim() || null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -142,11 +129,13 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block px-1 bg-white rounded text-[10px]",
|
||||
condition ? "border border-gray-300 text-black" : "border border-dashed text-red-500",
|
||||
displayStatus
|
||||
? "border border-gray-300 text-black"
|
||||
: "border border-dashed text-red-500",
|
||||
)}
|
||||
style={condition ? undefined : { borderColor: LACK_COLOR }}
|
||||
style={displayStatus ? undefined : { borderColor: LACK_COLOR }}
|
||||
>
|
||||
if
|
||||
{displayStatus ?? "status"}
|
||||
</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
@@ -155,7 +144,7 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
<input
|
||||
type="text"
|
||||
className="w-32 rounded border border-gray-300 px-1 py-0.5 text-[10px] focus:border-blue-500 focus:outline-none"
|
||||
placeholder="输入条件"
|
||||
placeholder="输入状态"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -174,14 +163,8 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
||||
);
|
||||
}
|
||||
|
||||
export function isElseEdge(edgeId: string, source: string, allEdges: Edge[]): boolean {
|
||||
const siblings = allEdges.filter((e) => e.source === source && e.type === "conditional");
|
||||
return siblings.length >= 2 && siblings[0].id === edgeId;
|
||||
}
|
||||
|
||||
export function ConditionalEdge({
|
||||
export function StatusEdge({
|
||||
id,
|
||||
source,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
@@ -190,7 +173,7 @@ export function ConditionalEdge({
|
||||
targetPosition,
|
||||
selected,
|
||||
data,
|
||||
}: EdgeProps<ConditionalEdgeType>): ReactNode {
|
||||
}: EdgeProps<StatusEdgeType>): ReactNode {
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
@@ -203,13 +186,11 @@ export function ConditionalEdge({
|
||||
const flow = useReactFlow();
|
||||
const model = useModel();
|
||||
|
||||
const allEdges = flow.getEdges();
|
||||
const isElse = useMemo(() => isElseEdge(id, source, allEdges), [id, source, allEdges]);
|
||||
const status = data?.status;
|
||||
|
||||
const condition = data?.condition;
|
||||
function handleSave(value: string) {
|
||||
model.startTransaction();
|
||||
flow.updateEdgeData(id, { condition: value });
|
||||
flow.updateEdgeData(id, { status: value });
|
||||
requestAnimationFrame(model.endTransaction);
|
||||
}
|
||||
|
||||
@@ -222,20 +203,11 @@ export function ConditionalEdge({
|
||||
sourceY={sourceY}
|
||||
targetX={targetX}
|
||||
targetY={targetY}
|
||||
hasCondition={isElse ? null : !!condition}
|
||||
hasStatus={!!status?.trim()}
|
||||
selected={!!selected}
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
{isElse ? (
|
||||
<ElseBadge labelX={labelX} labelY={labelY} />
|
||||
) : (
|
||||
<ConditionLabel
|
||||
condition={condition}
|
||||
labelX={labelX}
|
||||
labelY={labelY}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
<StatusLabel status={status} labelX={labelX} labelY={labelY} onSave={handleSave} />
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
);
|
||||
@@ -269,7 +241,7 @@ export function GradientEdge({
|
||||
sourceY={sourceY}
|
||||
targetX={targetX}
|
||||
targetY={targetY}
|
||||
hasCondition={null}
|
||||
hasStatus={true}
|
||||
selected={!!selected}
|
||||
/>
|
||||
);
|
||||
@@ -65,12 +65,12 @@ export const edgesModel = define.model("edges", makeEdges, (set, get, model) =>
|
||||
const existingFromSource = currentEdges.filter((e) => e.source === normalized.source);
|
||||
|
||||
if (existingFromSource.length > 0) {
|
||||
edge.type = "conditional";
|
||||
edge.data = { condition: "" };
|
||||
edge.type = "status";
|
||||
edge.data = { status: "" };
|
||||
|
||||
const promoted = currentEdges.map((e) => {
|
||||
if (e.source === normalized.source && e.type !== "conditional") {
|
||||
return { ...e, type: "conditional" as const, data: { condition: "" } };
|
||||
if (e.source === normalized.source && e.type !== "status") {
|
||||
return { ...e, type: "status" as const, data: { status: "_" } };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
|
||||
@@ -34,21 +34,8 @@ export const handlers = define.memoize((use, model) => {
|
||||
return node.type === "start" || node.type === "end";
|
||||
}
|
||||
|
||||
function isFirstConditionalSibling(
|
||||
edge: { id: string; source: string; type: string | null },
|
||||
allEdges: { id: string; source: string; type: string | null }[],
|
||||
): boolean {
|
||||
if (edge.type !== "conditional") return false;
|
||||
const siblings = allEdges.filter((e) => e.source === edge.source && e.type === "conditional");
|
||||
return siblings.length >= 2 && siblings[0].id === edge.id;
|
||||
}
|
||||
|
||||
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => {
|
||||
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes }) => {
|
||||
if (nodes.some(isProtectedNode)) return false;
|
||||
if (edges.length > 0) {
|
||||
const allEdges = use(edgesModel)[0];
|
||||
if (edges.some((e) => isFirstConditionalSibling(e, allEdges))) return false;
|
||||
}
|
||||
model.startTransaction();
|
||||
return true;
|
||||
};
|
||||
@@ -56,16 +43,14 @@ export const handlers = define.memoize((use, model) => {
|
||||
if (deletedEdges.length > 0) {
|
||||
const currentEdges = use(edgesModel)[0];
|
||||
const sourcesToCheck = new Set(
|
||||
deletedEdges.filter((e) => e.type === "conditional").map((e) => e.source),
|
||||
deletedEdges.filter((e) => e.type === "status").map((e) => e.source),
|
||||
);
|
||||
|
||||
if (sourcesToCheck.size > 0) {
|
||||
let needsDowngrade = false;
|
||||
const updatedEdges = currentEdges.map((e) => {
|
||||
if (!sourcesToCheck.has(e.source) || e.type !== "conditional") return e;
|
||||
const siblings = currentEdges.filter(
|
||||
(s) => s.source === e.source && s.type === "conditional",
|
||||
);
|
||||
if (!sourcesToCheck.has(e.source) || e.type !== "status") return e;
|
||||
const siblings = currentEdges.filter((s) => s.source === e.source && s.type === "status");
|
||||
if (siblings.length === 1) {
|
||||
needsDowngrade = true;
|
||||
const { data: _, ...rest } = e;
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("transIn", () => {
|
||||
});
|
||||
|
||||
it("4.3 Single step with END transition → edge to end node exists", () => {
|
||||
const steps = [makeStep("A", [{ condition: null, target: "END" }])];
|
||||
const steps = [makeStep("A", [{ status: "_", target: "END" }])];
|
||||
const { edges } = transIn(steps);
|
||||
const endEdge = edges.find((e) => e.target === "end");
|
||||
expect(endEdge).toBeDefined();
|
||||
@@ -44,8 +44,8 @@ describe("transIn", () => {
|
||||
|
||||
it("4.4 Two steps with default transitions chain", () => {
|
||||
const steps = [
|
||||
makeStep("A", [{ condition: null, target: "B" }]),
|
||||
makeStep("B", [{ condition: null, target: "END" }]),
|
||||
makeStep("A", [{ status: "_", target: "B" }]),
|
||||
makeStep("B", [{ status: "_", target: "END" }]),
|
||||
];
|
||||
const { edges } = transIn(steps);
|
||||
// Should have start→A, A→B, B→end
|
||||
@@ -53,15 +53,15 @@ describe("transIn", () => {
|
||||
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
||||
expect(edges.find((e) => e.source === nodeAId && e.target !== "end")).toBeDefined();
|
||||
expect(edges.find((e) => e.target === "end")).toBeDefined();
|
||||
// No conditional edges
|
||||
expect(edges.every((e) => e.type !== "conditional")).toBe(true);
|
||||
// No status edges for single default transitions
|
||||
expect(edges.every((e) => e.type !== "status")).toBe(true);
|
||||
});
|
||||
|
||||
it("4.5 Step with multiple transitions → conditional edges", () => {
|
||||
it("4.5 Step with multiple transitions → status edges", () => {
|
||||
const steps = [
|
||||
makeStep("A", [
|
||||
{ condition: null, target: "B" },
|
||||
{ condition: "x>0", target: "C" },
|
||||
{ status: "_", target: "B" },
|
||||
{ status: "approved", target: "C" },
|
||||
]),
|
||||
makeStep("B", []),
|
||||
makeStep("C", []),
|
||||
@@ -69,23 +69,35 @@ describe("transIn", () => {
|
||||
const { edges } = transIn(steps);
|
||||
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
||||
const outEdges = edges.filter((e) => e.source === nodeAId);
|
||||
expect(outEdges.every((e) => e.type === "conditional")).toBe(true);
|
||||
// else-branch has empty condition
|
||||
const elseEdge = outEdges.find(
|
||||
(e) => (e as { data?: { condition?: string } }).data?.condition === "",
|
||||
expect(outEdges.every((e) => e.type === "status")).toBe(true);
|
||||
});
|
||||
|
||||
it("4.5b Multiple transitions include expected status values", () => {
|
||||
const steps = [
|
||||
makeStep("A", [
|
||||
{ status: "_", target: "B" },
|
||||
{ status: "approved", target: "C" },
|
||||
]),
|
||||
makeStep("B", []),
|
||||
makeStep("C", []),
|
||||
];
|
||||
const { edges } = transIn(steps);
|
||||
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
||||
const outEdges = edges.filter((e) => e.source === nodeAId);
|
||||
const defaultEdge = outEdges.find(
|
||||
(e) => (e as { data?: { status?: string } }).data?.status === "_",
|
||||
);
|
||||
expect(elseEdge).toBeDefined();
|
||||
// if-branch has condition
|
||||
const ifEdge = outEdges.find(
|
||||
(e) => (e as { data?: { condition?: string } }).data?.condition === "x>0",
|
||||
expect(defaultEdge).toBeDefined();
|
||||
const approvedEdge = outEdges.find(
|
||||
(e) => (e as { data?: { status?: string } }).data?.status === "approved",
|
||||
);
|
||||
expect(ifEdge).toBeDefined();
|
||||
expect(approvedEdge).toBeDefined();
|
||||
});
|
||||
|
||||
it("4.6 With 1 incoming edge: targetHandle = 'input'; with 2: first gets 'input'", () => {
|
||||
const steps = [
|
||||
makeStep("A", [{ condition: null, target: "END" }]),
|
||||
makeStep("B", [{ condition: null, target: "END" }]),
|
||||
makeStep("A", [{ status: "_", target: "END" }]),
|
||||
makeStep("B", [{ status: "_", target: "END" }]),
|
||||
];
|
||||
const { edges } = transIn(steps);
|
||||
// start→A and start→B; end has 2 incoming edges
|
||||
@@ -95,8 +107,8 @@ describe("transIn", () => {
|
||||
|
||||
it("4.7 Same role name maps to same node id across steps", () => {
|
||||
const steps = [
|
||||
makeStep("A", [{ condition: null, target: "B" }]),
|
||||
makeStep("B", [{ condition: null, target: "A" }]),
|
||||
makeStep("A", [{ status: "_", target: "B" }]),
|
||||
makeStep("B", [{ status: "_", target: "A" }]),
|
||||
];
|
||||
const { edges } = transIn(steps);
|
||||
const aId = edges.find((e) => e.source === "start")?.target;
|
||||
|
||||
@@ -33,13 +33,13 @@ function defaultEdge(source: string, target: string): AnyWorkEdge {
|
||||
return { id: `${source}-${target}`, source, target, animated: true } as AnyWorkEdge;
|
||||
}
|
||||
|
||||
function conditionalEdge(source: string, target: string, condition: string): AnyWorkEdge {
|
||||
function statusEdge(source: string, target: string, status: string): AnyWorkEdge {
|
||||
return {
|
||||
id: `${source}-${target}-cond`,
|
||||
id: `${source}-${target}-status`,
|
||||
source,
|
||||
target,
|
||||
type: "conditional" as const,
|
||||
data: { condition },
|
||||
type: "status" as const,
|
||||
data: { status },
|
||||
animated: true,
|
||||
} as AnyWorkEdge;
|
||||
}
|
||||
@@ -76,36 +76,36 @@ describe("validateRoleNodes (via validate)", () => {
|
||||
expect(nodeErrors.some((e) => e.message.includes("缺少输出连接"))).toBe(true);
|
||||
});
|
||||
|
||||
it("5.3 Empty condition on non-first conditional edge → error", () => {
|
||||
it("5.3 Empty status on status edge → error", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const n3 = roleNode("n3");
|
||||
const nodes = baseNodes(n1, n2, n3);
|
||||
const edges = [
|
||||
defaultEdge("start", "n1"),
|
||||
conditionalEdge("n1", "n2", ""), // else-branch (index 0) - exempt
|
||||
conditionalEdge("n1", "n3", ""), // if-branch (index 1) - empty condition → error
|
||||
statusEdge("n1", "n2", "_"),
|
||||
statusEdge("n1", "n3", ""), // empty status → error
|
||||
defaultEdge("n2", "end"),
|
||||
defaultEdge("n3", "end"),
|
||||
];
|
||||
const result = validate(nodes, edges);
|
||||
expect(result.errors.some((e) => e.message.includes("条件表达式不能为空"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.message.includes("状态值不能为空"))).toBe(true);
|
||||
});
|
||||
|
||||
it("5.4 Mix of conditional and non-conditional outgoing → error", () => {
|
||||
it("5.4 Mix of status and non-status outgoing → error", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const n3 = roleNode("n3");
|
||||
const nodes = baseNodes(n1, n2, n3);
|
||||
const edges = [
|
||||
defaultEdge("start", "n1"),
|
||||
conditionalEdge("n1", "n2", "x>0"),
|
||||
statusEdge("n1", "n2", "approved"),
|
||||
defaultEdge("n1", "n3"), // mix → error
|
||||
defaultEdge("n2", "end"),
|
||||
defaultEdge("n3", "end"),
|
||||
];
|
||||
const result = validate(nodes, edges);
|
||||
expect(result.errors.some((e) => e.message.includes("所有出边必须附带条件"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.message.includes("所有出边必须附带状态"))).toBe(true);
|
||||
});
|
||||
|
||||
it("5.5 Valid role node (1 in, 1 out default) → no errors for that node", () => {
|
||||
@@ -118,15 +118,15 @@ describe("validateRoleNodes (via validate)", () => {
|
||||
expect(roleErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("5.6 Valid role node (1 in, 2 conditional out with conditions) → no errors", () => {
|
||||
it("5.6 Valid role node (1 in, 2 status out with statuses) → no errors", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const n3 = roleNode("n3");
|
||||
const nodes = baseNodes(n1, n2, n3);
|
||||
const edges = [
|
||||
defaultEdge("start", "n1"),
|
||||
conditionalEdge("n1", "n2", ""), // else-branch
|
||||
conditionalEdge("n1", "n3", "x>0"), // if-branch
|
||||
statusEdge("n1", "n2", "_"),
|
||||
statusEdge("n1", "n3", "approved"),
|
||||
defaultEdge("n2", "end"),
|
||||
defaultEdge("n3", "end"),
|
||||
];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
|
||||
import type { AnyWorkEdge, AnyWorkNode, StatusEdge } from "../type";
|
||||
import { uuid } from "../utils";
|
||||
import type { WorkFlowStep } from "./type";
|
||||
|
||||
@@ -9,6 +9,7 @@ type Result = {
|
||||
|
||||
const _OUT_HANDLES = ["output-top", "output", "output-bottom"] as const;
|
||||
const IN_HANDLES = ["input-top", "input", "input-bottom"] as const;
|
||||
const DEFAULT_STATUS = "_";
|
||||
|
||||
function assignHandles(
|
||||
indices: number[],
|
||||
@@ -50,8 +51,8 @@ function buildNodeMap(
|
||||
function sortTransitions(step: WorkFlowStep): WorkFlowStep["transitions"] {
|
||||
if (step.transitions.length <= 1) return step.transitions;
|
||||
return [...step.transitions].sort((a, b) => {
|
||||
if (a.condition === null && b.condition !== null) return -1;
|
||||
if (a.condition !== null && b.condition === null) return 1;
|
||||
if (a.status === DEFAULT_STATUS && b.status !== DEFAULT_STATUS) return -1;
|
||||
if (a.status !== DEFAULT_STATUS && b.status === DEFAULT_STATUS) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
@@ -60,32 +61,32 @@ function buildStepEdges(
|
||||
sourceId: string,
|
||||
step: WorkFlowStep,
|
||||
nameToId: Map<string, string>,
|
||||
): { elseEdges: AnyWorkEdge[]; ifEdges: AnyWorkEdge[] } {
|
||||
): { primaryEdges: AnyWorkEdge[]; statusEdges: AnyWorkEdge[] } {
|
||||
const hasMultiple = step.transitions.length > 1;
|
||||
const sorted = sortTransitions(step);
|
||||
const elseEdges: AnyWorkEdge[] = [];
|
||||
const ifEdges: AnyWorkEdge[] = [];
|
||||
const primaryEdges: AnyWorkEdge[] = [];
|
||||
const statusEdges: AnyWorkEdge[] = [];
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const t = sorted[i];
|
||||
const targetId = nameToId.get(t.target);
|
||||
if (!targetId) continue;
|
||||
const edgeId = `e-${sourceId}-${targetId}-${i}`;
|
||||
if (hasMultiple || t.condition !== null) {
|
||||
const edge: ConditionalEdge = {
|
||||
if (hasMultiple || t.status !== DEFAULT_STATUS) {
|
||||
const edge: StatusEdge = {
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: "output",
|
||||
targetHandle: "input",
|
||||
type: "conditional",
|
||||
data: { condition: t.condition ?? "" },
|
||||
type: "status",
|
||||
data: { status: t.status },
|
||||
animated: true,
|
||||
};
|
||||
if (hasMultiple && i === 0) elseEdges.push(edge);
|
||||
else ifEdges.push(edge);
|
||||
if (hasMultiple && t.status === DEFAULT_STATUS) primaryEdges.push(edge);
|
||||
else statusEdges.push(edge);
|
||||
} else {
|
||||
elseEdges.push({
|
||||
primaryEdges.push({
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
@@ -95,23 +96,23 @@ function buildStepEdges(
|
||||
});
|
||||
}
|
||||
}
|
||||
return { elseEdges, ifEdges };
|
||||
return { primaryEdges, statusEdges };
|
||||
}
|
||||
|
||||
function pushStepEdges(
|
||||
edges: AnyWorkEdge[],
|
||||
elseEdges: AnyWorkEdge[],
|
||||
ifEdges: AnyWorkEdge[],
|
||||
primaryEdges: AnyWorkEdge[],
|
||||
statusEdges: AnyWorkEdge[],
|
||||
idToOrder: Map<string, number>,
|
||||
): void {
|
||||
for (const e of elseEdges) edges.push({ ...e, sourceHandle: "output" });
|
||||
if (ifEdges.length > 0) {
|
||||
const ifHandles = ["output-top", "output-bottom"] as const;
|
||||
const sorted = [...ifEdges].sort(
|
||||
for (const e of primaryEdges) edges.push({ ...e, sourceHandle: "output" });
|
||||
if (statusEdges.length > 0) {
|
||||
const statusHandles = ["output-top", "output-bottom"] as const;
|
||||
const sorted = [...statusEdges].sort(
|
||||
(a, b) => (idToOrder.get(b.target) ?? 0) - (idToOrder.get(a.target) ?? 0),
|
||||
);
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
edges.push({ ...sorted[i], sourceHandle: ifHandles[i % ifHandles.length] });
|
||||
edges.push({ ...sorted[i], sourceHandle: statusHandles[i % statusHandles.length] });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,8 +165,8 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
||||
|
||||
for (const step of steps) {
|
||||
const sourceId = nameToId.get(step.role.name) ?? "";
|
||||
const { elseEdges, ifEdges } = buildStepEdges(sourceId, step, nameToId);
|
||||
pushStepEdges(edges, elseEdges, ifEdges, idToOrder);
|
||||
const { primaryEdges, statusEdges } = buildStepEdges(sourceId, step, nameToId);
|
||||
pushStepEdges(edges, primaryEdges, statusEdges, idToOrder);
|
||||
}
|
||||
|
||||
assignTargetHandles(edges, idToOrder);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge, WorkNode } from "../type";
|
||||
import type { AnyWorkEdge, AnyWorkNode, StatusEdge, WorkNode } from "../type";
|
||||
import type { WorkFlowStep, WorkFlowTransition } from "./type";
|
||||
|
||||
const DEFAULT_STATUS = "_";
|
||||
|
||||
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
|
||||
const nodeMap = new Map<string, AnyWorkNode>();
|
||||
for (const node of nodes) {
|
||||
@@ -43,7 +45,7 @@ function traverse(
|
||||
const roleNode = node as WorkNode<"role">;
|
||||
const outEdges = outgoingEdges.get(nodeId) ?? [];
|
||||
|
||||
const transitions: WorkFlowTransition[] = outEdges.map((edge, index) => {
|
||||
const transitions: WorkFlowTransition[] = outEdges.map((edge) => {
|
||||
const targetNode = nodeMap.get(edge.target);
|
||||
const target =
|
||||
edge.target === "end"
|
||||
@@ -52,13 +54,12 @@ function traverse(
|
||||
? (targetNode as WorkNode<"role">).data.name
|
||||
: edge.target;
|
||||
|
||||
let condition: string | null = null;
|
||||
if (edge.type === "conditional") {
|
||||
const isElse = outEdges.length >= 2 && index === 0;
|
||||
condition = isElse ? null : ((edge as ConditionalEdge).data?.condition ?? null);
|
||||
}
|
||||
const status =
|
||||
edge.type === "status"
|
||||
? ((edge as StatusEdge).data?.status ?? DEFAULT_STATUS)
|
||||
: DEFAULT_STATUS;
|
||||
|
||||
return { target, condition };
|
||||
return { target, status };
|
||||
});
|
||||
|
||||
const { name, description, identity, prepare, execute, report } = roleNode.data;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
|
||||
import type { AnyWorkEdge, AnyWorkNode, StatusEdge } from "../type";
|
||||
|
||||
export type ValidationError = {
|
||||
nodeId: string | null;
|
||||
@@ -91,10 +91,10 @@ function validateEndNode(
|
||||
}
|
||||
}
|
||||
|
||||
function hasEmptyConditionOnIfEdge(conditionalEdges: AnyWorkEdge[]): boolean {
|
||||
return conditionalEdges.slice(1).some((edge) => {
|
||||
const cond = (edge as ConditionalEdge).data?.condition?.trim();
|
||||
return !cond;
|
||||
function hasEmptyStatusOnEdge(statusEdges: AnyWorkEdge[]): boolean {
|
||||
return statusEdges.some((edge) => {
|
||||
const status = (edge as StatusEdge).data?.status?.trim();
|
||||
return !status;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,11 +113,11 @@ function validateRoleNodeEdges(
|
||||
}
|
||||
if (outEdges.length <= 1) return;
|
||||
|
||||
const conditionalEdges = outEdges.filter((e) => e.type === "conditional");
|
||||
if (conditionalEdges.length !== outEdges.length) {
|
||||
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" });
|
||||
} else if (hasEmptyConditionOnIfEdge(conditionalEdges)) {
|
||||
errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" });
|
||||
const statusEdges = outEdges.filter((e) => e.type === "status");
|
||||
if (statusEdges.length !== outEdges.length) {
|
||||
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带状态" });
|
||||
} else if (hasEmptyStatusOnEdge(statusEdges)) {
|
||||
errors.push({ nodeId: node.id, message: "状态边的状态值不能为空" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ export type WorkNodeType = keyof NodeMap;
|
||||
export type WorkNode<T extends WorkNodeType> = Node<NodeMap[T], T>;
|
||||
export type AnyWorkNode = WorkNode<"start"> | WorkNode<"end"> | WorkNode<"role">;
|
||||
|
||||
export type ConditionalEdgeData = AnyKeyBase & {
|
||||
condition: string;
|
||||
export type StatusEdgeData = AnyKeyBase & {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ConditionalEdge = Edge<ConditionalEdgeData, "conditional">;
|
||||
export type AnyWorkEdge = ConditionalEdge | Edge;
|
||||
export type StatusEdge = Edge<StatusEdgeData, "status">;
|
||||
export type AnyWorkEdge = StatusEdge | Edge;
|
||||
|
||||
@@ -11,7 +11,7 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
||||
execute: "制定详细的实施计划和步骤分解",
|
||||
report: "输出结构化的计划文档,包含步骤列表和预期产出",
|
||||
},
|
||||
transitions: [{ target: "developer", condition: null }],
|
||||
transitions: [{ target: "developer", status: "_" }],
|
||||
},
|
||||
{
|
||||
role: {
|
||||
@@ -22,7 +22,7 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
||||
execute: "编写高质量的代码实现",
|
||||
report: "输出变更文件列表和实现摘要",
|
||||
},
|
||||
transitions: [{ target: "reviewer", condition: null }],
|
||||
transitions: [{ target: "reviewer", status: "_" }],
|
||||
},
|
||||
{
|
||||
role: {
|
||||
@@ -34,8 +34,8 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
||||
report: "输出审查结果,包含 approved 状态和评审意见",
|
||||
},
|
||||
transitions: [
|
||||
{ target: "END", condition: null },
|
||||
{ target: "developer", condition: "steps[-1].output.approved = false" },
|
||||
{ target: "END", status: "approved" },
|
||||
{ target: "developer", status: "rejected" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user