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:
+22
-63
@@ -95,13 +95,14 @@ roles:
|
|||||||
|
|
||||||
Only review standards compliance. Do NOT test functionality.
|
Only review standards compliance. Do NOT test functionality.
|
||||||
If rejecting, you MUST explain the specific reason in your output.
|
If rejecting, you MUST explain the specific reason in your output.
|
||||||
output: "Explain your decision with specific file/line references. Frontmatter must include: approved (true or false)."
|
output: "Explain your decision with specific file/line references. Frontmatter must include: status (approved or rejected)."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
approved:
|
status:
|
||||||
type: boolean
|
type: string
|
||||||
required: [approved]
|
enum: [approved, rejected]
|
||||||
|
required: [status]
|
||||||
tester:
|
tester:
|
||||||
description: "Functional correctness verification"
|
description: "Functional correctness verification"
|
||||||
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
|
||||||
@@ -145,72 +146,30 @@ roles:
|
|||||||
5. After PR creation, clean up the worktree:
|
5. After PR creation, clean up the worktree:
|
||||||
- `cd ~/repos/workflow`
|
- `cd ~/repos/workflow`
|
||||||
- `git worktree remove ~/repos/workflow-worktrees/fix/<issue-number>-<slug>`
|
- `git worktree remove ~/repos/workflow-worktrees/fix/<issue-number>-<slug>`
|
||||||
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
|
output: "Include PR URL on success or error log on failure. Frontmatter must include: status (committed or hook_failed)."
|
||||||
frontmatter:
|
frontmatter:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
success:
|
status:
|
||||||
type: boolean
|
type: string
|
||||||
required: [success]
|
enum: [committed, hook_failed]
|
||||||
conditions:
|
required: [status]
|
||||||
insufficientInfo:
|
|
||||||
description: "Planner determined there's not enough info to proceed"
|
|
||||||
expression: "$last('planner').status = 'insufficient_info'"
|
|
||||||
devFailed:
|
|
||||||
description: "Developer failed to implement"
|
|
||||||
expression: "$last('developer').status = 'failed'"
|
|
||||||
rejected:
|
|
||||||
description: "Reviewer rejected the implementation"
|
|
||||||
expression: "$last('reviewer').approved = false"
|
|
||||||
fixCode:
|
|
||||||
description: "Tester found code issues"
|
|
||||||
expression: "$last('tester').status = 'fix_code'"
|
|
||||||
fixSpec:
|
|
||||||
description: "Tester found spec issues"
|
|
||||||
expression: "$last('tester').status = 'fix_spec'"
|
|
||||||
hookFailed:
|
|
||||||
description: "Push hook failed"
|
|
||||||
expression: "$last('committer').success = false"
|
|
||||||
graph:
|
graph:
|
||||||
$START:
|
$START:
|
||||||
- role: "planner"
|
_: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." }
|
||||||
condition: null
|
|
||||||
prompt: "Analyze the issue and produce an implementation plan."
|
|
||||||
planner:
|
planner:
|
||||||
- role: "$END"
|
insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." }
|
||||||
condition: "insufficientInfo"
|
ready: { role: "developer", prompt: "Implement the plan from the planner." }
|
||||||
prompt: "Insufficient information to proceed; end the workflow."
|
|
||||||
- role: "developer"
|
|
||||||
condition: null
|
|
||||||
prompt: "Implement the plan from the planner."
|
|
||||||
developer:
|
developer:
|
||||||
- role: "$END"
|
failed: { role: "$END", prompt: "Development failed; end the workflow." }
|
||||||
condition: "devFailed"
|
done: { role: "reviewer", prompt: "Send the implementation to the reviewer." }
|
||||||
prompt: "Development failed; end the workflow."
|
|
||||||
- role: "reviewer"
|
|
||||||
condition: null
|
|
||||||
prompt: "Send the implementation to the reviewer."
|
|
||||||
reviewer:
|
reviewer:
|
||||||
- role: "developer"
|
rejected: { role: "developer", prompt: "Reviewer rejected the implementation; fix the issues." }
|
||||||
condition: "rejected"
|
approved: { role: "tester", prompt: "Review passed; run tests on the implementation." }
|
||||||
prompt: "Reviewer rejected the implementation; fix the issues."
|
|
||||||
- role: "tester"
|
|
||||||
condition: null
|
|
||||||
prompt: "Review passed; run tests on the implementation."
|
|
||||||
tester:
|
tester:
|
||||||
- role: "developer"
|
fix_code: { role: "developer", prompt: "Tests found code issues; return to developer." }
|
||||||
condition: "fixCode"
|
fix_spec: { role: "planner", prompt: "Tests found spec issues; return to planner." }
|
||||||
prompt: "Tests found code issues; return to developer."
|
passed: { role: "committer", prompt: "Tests passed; commit and push the changes." }
|
||||||
- role: "planner"
|
|
||||||
condition: "fixSpec"
|
|
||||||
prompt: "Tests found spec issues; return to planner."
|
|
||||||
- role: "committer"
|
|
||||||
condition: null
|
|
||||||
prompt: "Tests passed; commit and push the changes."
|
|
||||||
committer:
|
committer:
|
||||||
- role: "developer"
|
hook_failed: { role: "developer", prompt: "Push hook failed; return to developer to fix." }
|
||||||
condition: "hookFailed"
|
committed: { role: "$END", prompt: "Commit succeeded; complete the workflow." }
|
||||||
prompt: "Push hook failed; return to developer to fix."
|
|
||||||
- role: "$END"
|
|
||||||
condition: null
|
|
||||||
prompt: "Commit succeeded; complete the workflow."
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
|
|||||||
expect(workflow.roles.committer?.frontmatter).toBeDefined();
|
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");
|
const yamlContent = await readFile(workflowPath, "utf-8");
|
||||||
// Parse as any to access the raw YAML structure (frontmatter is inline JSON Schema in YAML)
|
// 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
|
// 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;
|
const frontmatter = workflow.roles.committer?.frontmatter;
|
||||||
expect(frontmatter).toBeDefined();
|
expect(frontmatter).toBeDefined();
|
||||||
expect(frontmatter?.type).toBe("object");
|
expect(frontmatter?.type).toBe("object");
|
||||||
expect(frontmatter?.properties?.success).toBeDefined();
|
expect(frontmatter?.properties?.status).toBeDefined();
|
||||||
expect(frontmatter?.properties?.success?.type).toBe("boolean");
|
expect(frontmatter?.properties?.status?.enum).toContain("committed");
|
||||||
expect(frontmatter?.required).toContain("success");
|
expect(frontmatter?.required).toContain("status");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function createApi() {
|
|||||||
transitions: t.Array(
|
transitions: t.Array(
|
||||||
t.Object({
|
t.Object({
|
||||||
target: t.String(),
|
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 { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
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 YAML from "yaml";
|
||||||
import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts";
|
import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts";
|
||||||
|
|
||||||
@@ -11,17 +11,12 @@ async function ensureDir() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
|
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 = [];
|
const steps: WorkFlowSteps = [];
|
||||||
for (const [roleName, roleDef] of Object.entries(payload.roles)) {
|
for (const [roleName, roleDef] of Object.entries(payload.roles)) {
|
||||||
const graphTransitions = payload.graph[roleName] ?? [];
|
const statusMap = payload.graph[roleName] ?? {};
|
||||||
const transitions: WorkFlowTransition[] = graphTransitions.map((t) => ({
|
const transitions: WorkFlowTransition[] = Object.entries(statusMap).map(([status, target]) => ({
|
||||||
target: t.role === "$END" ? "END" : t.role,
|
target: target.role === "$END" ? "END" : target.role,
|
||||||
condition: t.condition ? (conditionMap.get(t.condition) ?? t.condition) : null,
|
status,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
steps.push({
|
steps.push({
|
||||||
@@ -42,11 +37,7 @@ function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
|
|||||||
|
|
||||||
function stepsToPayload(name: string, description: string, steps: WorkFlowSteps): WorkflowPayload {
|
function stepsToPayload(name: string, description: string, steps: WorkFlowSteps): WorkflowPayload {
|
||||||
const roles: Record<string, RoleDefinition> = {};
|
const roles: Record<string, RoleDefinition> = {};
|
||||||
const conditions: WorkflowPayload["conditions"] = {};
|
const graph: Record<string, Record<string, Target>> = {};
|
||||||
const graph: Record<string, Transition[]> = {};
|
|
||||||
|
|
||||||
const expressionToName = new Map<string, string>();
|
|
||||||
let condIdx = 0;
|
|
||||||
|
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
const r = step.role;
|
const r = step.role;
|
||||||
@@ -59,43 +50,28 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
|
|||||||
frontmatter: "",
|
frontmatter: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const transitions: Transition[] = step.transitions.map((t) => {
|
const statusMap: Record<string, Target> = {};
|
||||||
let condName: string | null = null;
|
for (const t of step.transitions) {
|
||||||
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 targetRole = t.target === "END" ? "$END" : t.target;
|
const targetRole = t.target === "END" ? "$END" : t.target;
|
||||||
return {
|
statusMap[t.status] = {
|
||||||
role: targetRole,
|
role: targetRole,
|
||||||
condition: condName,
|
|
||||||
prompt: `Transition to ${targetRole}.`,
|
prompt: `Transition to ${targetRole}.`,
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
graph[r.name] = statusMap;
|
||||||
graph[r.name] = transitions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (steps.length > 0) {
|
if (steps.length > 0) {
|
||||||
const firstRole = steps[0].role.name;
|
const firstRole = steps[0].role.name;
|
||||||
graph.$START = [
|
graph.$START = {
|
||||||
{
|
_: {
|
||||||
role: firstRole,
|
role: firstRole,
|
||||||
condition: null,
|
|
||||||
prompt: `Begin workflow at role ${firstRole}.`,
|
prompt: `Begin workflow at role ${firstRole}.`,
|
||||||
},
|
},
|
||||||
];
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { name, description, roles, conditions, graph };
|
return { name, description, roles, graph };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listWorkflows(): Promise<WorkflowSummary[]> {
|
export async function listWorkflows(): Promise<WorkflowSummary[]> {
|
||||||
@@ -125,7 +101,6 @@ export async function createWorkflow(name: string, description: string): Promise
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
roles: {},
|
roles: {},
|
||||||
conditions: {},
|
|
||||||
graph: {},
|
graph: {},
|
||||||
};
|
};
|
||||||
await writeFile(join(WORKFLOW_DIR, `${name}.yaml`), YAML.stringify(payload), "utf-8");
|
await writeFile(join(WORKFLOW_DIR, `${name}.yaml`), YAML.stringify(payload), "utf-8");
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type WorkFlowRole = {
|
|||||||
|
|
||||||
export type WorkFlowTransition = {
|
export type WorkFlowTransition = {
|
||||||
target: string;
|
target: string;
|
||||||
condition: string | null;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkFlowStep = {
|
export type WorkFlowStep = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ConditionalEdge, GradientEdge } from "./conditional";
|
import { GradientEdge, StatusEdge } from "./status";
|
||||||
|
|
||||||
export const edgeTypes = {
|
export const edgeTypes = {
|
||||||
conditional: ConditionalEdge,
|
status: StatusEdge,
|
||||||
default: GradientEdge,
|
default: GradientEdge,
|
||||||
};
|
};
|
||||||
|
|||||||
+24
-52
@@ -6,10 +6,10 @@ import {
|
|||||||
useReactFlow,
|
useReactFlow,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import { Check } from "lucide-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 { cn } from "../../lib/utils.ts";
|
||||||
import { useModel } from "../context.tsx";
|
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 SOURCE_COLOR = "#10b981";
|
||||||
const TARGET_COLOR = "#3b82f6";
|
const TARGET_COLOR = "#3b82f6";
|
||||||
@@ -23,7 +23,7 @@ function GradientPath({
|
|||||||
sourceY,
|
sourceY,
|
||||||
targetX,
|
targetX,
|
||||||
targetY,
|
targetY,
|
||||||
hasCondition,
|
hasStatus,
|
||||||
selected,
|
selected,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,11 +32,11 @@ function GradientPath({
|
|||||||
sourceY: number;
|
sourceY: number;
|
||||||
targetX: number;
|
targetX: number;
|
||||||
targetY: number;
|
targetY: number;
|
||||||
hasCondition: boolean | null;
|
hasStatus: boolean;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
}) {
|
}) {
|
||||||
const gradientId = `gradient-${id}`;
|
const gradientId = `gradient-${id}`;
|
||||||
const showLack = hasCondition === false;
|
const showLack = !hasStatus;
|
||||||
const strokeStyle = selected
|
const strokeStyle = selected
|
||||||
? { stroke: "#f59e0b", strokeWidth: 2 }
|
? { stroke: "#f59e0b", strokeWidth: 2 }
|
||||||
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
|
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
|
||||||
@@ -68,35 +68,20 @@ function GradientPath({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ElseBadge({ labelX, labelY }: { labelX: number; labelY: number }): ReactNode {
|
type StatusLabelProps = {
|
||||||
return (
|
status: string | undefined;
|
||||||
<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;
|
|
||||||
labelX: number;
|
labelX: number;
|
||||||
labelY: number;
|
labelY: number;
|
||||||
onSave: (value: string) => void;
|
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 [isOpen, setIsOpen] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
function handleBadgeClick() {
|
function handleBadgeClick() {
|
||||||
setInputValue(condition || "");
|
setInputValue(status || "");
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +112,8 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
|||||||
return () => document.removeEventListener("pointerdown", handleClickOutside, true);
|
return () => document.removeEventListener("pointerdown", handleClickOutside, true);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const displayStatus = status?.trim() || null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -142,11 +129,13 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block px-1 bg-white rounded text-[10px]",
|
"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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
@@ -155,7 +144,7 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="w-32 rounded border border-gray-300 px-1 py-0.5 text-[10px] focus:border-blue-500 focus:outline-none"
|
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}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@@ -174,14 +163,8 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isElseEdge(edgeId: string, source: string, allEdges: Edge[]): boolean {
|
export function StatusEdge({
|
||||||
const siblings = allEdges.filter((e) => e.source === source && e.type === "conditional");
|
|
||||||
return siblings.length >= 2 && siblings[0].id === edgeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConditionalEdge({
|
|
||||||
id,
|
id,
|
||||||
source,
|
|
||||||
sourceX,
|
sourceX,
|
||||||
sourceY,
|
sourceY,
|
||||||
targetX,
|
targetX,
|
||||||
@@ -190,7 +173,7 @@ export function ConditionalEdge({
|
|||||||
targetPosition,
|
targetPosition,
|
||||||
selected,
|
selected,
|
||||||
data,
|
data,
|
||||||
}: EdgeProps<ConditionalEdgeType>): ReactNode {
|
}: EdgeProps<StatusEdgeType>): ReactNode {
|
||||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||||
sourceX,
|
sourceX,
|
||||||
sourceY,
|
sourceY,
|
||||||
@@ -203,13 +186,11 @@ export function ConditionalEdge({
|
|||||||
const flow = useReactFlow();
|
const flow = useReactFlow();
|
||||||
const model = useModel();
|
const model = useModel();
|
||||||
|
|
||||||
const allEdges = flow.getEdges();
|
const status = data?.status;
|
||||||
const isElse = useMemo(() => isElseEdge(id, source, allEdges), [id, source, allEdges]);
|
|
||||||
|
|
||||||
const condition = data?.condition;
|
|
||||||
function handleSave(value: string) {
|
function handleSave(value: string) {
|
||||||
model.startTransaction();
|
model.startTransaction();
|
||||||
flow.updateEdgeData(id, { condition: value });
|
flow.updateEdgeData(id, { status: value });
|
||||||
requestAnimationFrame(model.endTransaction);
|
requestAnimationFrame(model.endTransaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,20 +203,11 @@ export function ConditionalEdge({
|
|||||||
sourceY={sourceY}
|
sourceY={sourceY}
|
||||||
targetX={targetX}
|
targetX={targetX}
|
||||||
targetY={targetY}
|
targetY={targetY}
|
||||||
hasCondition={isElse ? null : !!condition}
|
hasStatus={!!status?.trim()}
|
||||||
selected={!!selected}
|
selected={!!selected}
|
||||||
/>
|
/>
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
{isElse ? (
|
<StatusLabel status={status} labelX={labelX} labelY={labelY} onSave={handleSave} />
|
||||||
<ElseBadge labelX={labelX} labelY={labelY} />
|
|
||||||
) : (
|
|
||||||
<ConditionLabel
|
|
||||||
condition={condition}
|
|
||||||
labelX={labelX}
|
|
||||||
labelY={labelY}
|
|
||||||
onSave={handleSave}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</EdgeLabelRenderer>
|
</EdgeLabelRenderer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -269,7 +241,7 @@ export function GradientEdge({
|
|||||||
sourceY={sourceY}
|
sourceY={sourceY}
|
||||||
targetX={targetX}
|
targetX={targetX}
|
||||||
targetY={targetY}
|
targetY={targetY}
|
||||||
hasCondition={null}
|
hasStatus={true}
|
||||||
selected={!!selected}
|
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);
|
const existingFromSource = currentEdges.filter((e) => e.source === normalized.source);
|
||||||
|
|
||||||
if (existingFromSource.length > 0) {
|
if (existingFromSource.length > 0) {
|
||||||
edge.type = "conditional";
|
edge.type = "status";
|
||||||
edge.data = { condition: "" };
|
edge.data = { status: "" };
|
||||||
|
|
||||||
const promoted = currentEdges.map((e) => {
|
const promoted = currentEdges.map((e) => {
|
||||||
if (e.source === normalized.source && e.type !== "conditional") {
|
if (e.source === normalized.source && e.type !== "status") {
|
||||||
return { ...e, type: "conditional" as const, data: { condition: "" } };
|
return { ...e, type: "status" as const, data: { status: "_" } };
|
||||||
}
|
}
|
||||||
return e;
|
return e;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,21 +34,8 @@ export const handlers = define.memoize((use, model) => {
|
|||||||
return node.type === "start" || node.type === "end";
|
return node.type === "start" || node.type === "end";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFirstConditionalSibling(
|
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes }) => {
|
||||||
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 }) => {
|
|
||||||
if (nodes.some(isProtectedNode)) return false;
|
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();
|
model.startTransaction();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -56,16 +43,14 @@ export const handlers = define.memoize((use, model) => {
|
|||||||
if (deletedEdges.length > 0) {
|
if (deletedEdges.length > 0) {
|
||||||
const currentEdges = use(edgesModel)[0];
|
const currentEdges = use(edgesModel)[0];
|
||||||
const sourcesToCheck = new Set(
|
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) {
|
if (sourcesToCheck.size > 0) {
|
||||||
let needsDowngrade = false;
|
let needsDowngrade = false;
|
||||||
const updatedEdges = currentEdges.map((e) => {
|
const updatedEdges = currentEdges.map((e) => {
|
||||||
if (!sourcesToCheck.has(e.source) || e.type !== "conditional") return e;
|
if (!sourcesToCheck.has(e.source) || e.type !== "status") return e;
|
||||||
const siblings = currentEdges.filter(
|
const siblings = currentEdges.filter((s) => s.source === e.source && s.type === "status");
|
||||||
(s) => s.source === e.source && s.type === "conditional",
|
|
||||||
);
|
|
||||||
if (siblings.length === 1) {
|
if (siblings.length === 1) {
|
||||||
needsDowngrade = true;
|
needsDowngrade = true;
|
||||||
const { data: _, ...rest } = e;
|
const { data: _, ...rest } = e;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ describe("transIn", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("4.3 Single step with END transition → edge to end node exists", () => {
|
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 { edges } = transIn(steps);
|
||||||
const endEdge = edges.find((e) => e.target === "end");
|
const endEdge = edges.find((e) => e.target === "end");
|
||||||
expect(endEdge).toBeDefined();
|
expect(endEdge).toBeDefined();
|
||||||
@@ -44,8 +44,8 @@ describe("transIn", () => {
|
|||||||
|
|
||||||
it("4.4 Two steps with default transitions chain", () => {
|
it("4.4 Two steps with default transitions chain", () => {
|
||||||
const steps = [
|
const steps = [
|
||||||
makeStep("A", [{ condition: null, target: "B" }]),
|
makeStep("A", [{ status: "_", target: "B" }]),
|
||||||
makeStep("B", [{ condition: null, target: "END" }]),
|
makeStep("B", [{ status: "_", target: "END" }]),
|
||||||
];
|
];
|
||||||
const { edges } = transIn(steps);
|
const { edges } = transIn(steps);
|
||||||
// Should have start→A, A→B, B→end
|
// Should have start→A, A→B, B→end
|
||||||
@@ -53,15 +53,15 @@ describe("transIn", () => {
|
|||||||
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
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.source === nodeAId && e.target !== "end")).toBeDefined();
|
||||||
expect(edges.find((e) => e.target === "end")).toBeDefined();
|
expect(edges.find((e) => e.target === "end")).toBeDefined();
|
||||||
// No conditional edges
|
// No status edges for single default transitions
|
||||||
expect(edges.every((e) => e.type !== "conditional")).toBe(true);
|
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 = [
|
const steps = [
|
||||||
makeStep("A", [
|
makeStep("A", [
|
||||||
{ condition: null, target: "B" },
|
{ status: "_", target: "B" },
|
||||||
{ condition: "x>0", target: "C" },
|
{ status: "approved", target: "C" },
|
||||||
]),
|
]),
|
||||||
makeStep("B", []),
|
makeStep("B", []),
|
||||||
makeStep("C", []),
|
makeStep("C", []),
|
||||||
@@ -69,23 +69,35 @@ describe("transIn", () => {
|
|||||||
const { edges } = transIn(steps);
|
const { edges } = transIn(steps);
|
||||||
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
||||||
const outEdges = edges.filter((e) => e.source === nodeAId);
|
const outEdges = edges.filter((e) => e.source === nodeAId);
|
||||||
expect(outEdges.every((e) => e.type === "conditional")).toBe(true);
|
expect(outEdges.every((e) => e.type === "status")).toBe(true);
|
||||||
// else-branch has empty condition
|
});
|
||||||
const elseEdge = outEdges.find(
|
|
||||||
(e) => (e as { data?: { condition?: string } }).data?.condition === "",
|
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();
|
expect(defaultEdge).toBeDefined();
|
||||||
// if-branch has condition
|
const approvedEdge = outEdges.find(
|
||||||
const ifEdge = outEdges.find(
|
(e) => (e as { data?: { status?: string } }).data?.status === "approved",
|
||||||
(e) => (e as { data?: { condition?: string } }).data?.condition === "x>0",
|
|
||||||
);
|
);
|
||||||
expect(ifEdge).toBeDefined();
|
expect(approvedEdge).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("4.6 With 1 incoming edge: targetHandle = 'input'; with 2: first gets 'input'", () => {
|
it("4.6 With 1 incoming edge: targetHandle = 'input'; with 2: first gets 'input'", () => {
|
||||||
const steps = [
|
const steps = [
|
||||||
makeStep("A", [{ condition: null, target: "END" }]),
|
makeStep("A", [{ status: "_", target: "END" }]),
|
||||||
makeStep("B", [{ condition: null, target: "END" }]),
|
makeStep("B", [{ status: "_", target: "END" }]),
|
||||||
];
|
];
|
||||||
const { edges } = transIn(steps);
|
const { edges } = transIn(steps);
|
||||||
// start→A and start→B; end has 2 incoming edges
|
// 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", () => {
|
it("4.7 Same role name maps to same node id across steps", () => {
|
||||||
const steps = [
|
const steps = [
|
||||||
makeStep("A", [{ condition: null, target: "B" }]),
|
makeStep("A", [{ status: "_", target: "B" }]),
|
||||||
makeStep("B", [{ condition: null, target: "A" }]),
|
makeStep("B", [{ status: "_", target: "A" }]),
|
||||||
];
|
];
|
||||||
const { edges } = transIn(steps);
|
const { edges } = transIn(steps);
|
||||||
const aId = edges.find((e) => e.source === "start")?.target;
|
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;
|
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 {
|
return {
|
||||||
id: `${source}-${target}-cond`,
|
id: `${source}-${target}-status`,
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
type: "conditional" as const,
|
type: "status" as const,
|
||||||
data: { condition },
|
data: { status },
|
||||||
animated: true,
|
animated: true,
|
||||||
} as AnyWorkEdge;
|
} as AnyWorkEdge;
|
||||||
}
|
}
|
||||||
@@ -76,36 +76,36 @@ describe("validateRoleNodes (via validate)", () => {
|
|||||||
expect(nodeErrors.some((e) => e.message.includes("缺少输出连接"))).toBe(true);
|
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 n1 = roleNode("n1");
|
||||||
const n2 = roleNode("n2");
|
const n2 = roleNode("n2");
|
||||||
const n3 = roleNode("n3");
|
const n3 = roleNode("n3");
|
||||||
const nodes = baseNodes(n1, n2, n3);
|
const nodes = baseNodes(n1, n2, n3);
|
||||||
const edges = [
|
const edges = [
|
||||||
defaultEdge("start", "n1"),
|
defaultEdge("start", "n1"),
|
||||||
conditionalEdge("n1", "n2", ""), // else-branch (index 0) - exempt
|
statusEdge("n1", "n2", "_"),
|
||||||
conditionalEdge("n1", "n3", ""), // if-branch (index 1) - empty condition → error
|
statusEdge("n1", "n3", ""), // empty status → error
|
||||||
defaultEdge("n2", "end"),
|
defaultEdge("n2", "end"),
|
||||||
defaultEdge("n3", "end"),
|
defaultEdge("n3", "end"),
|
||||||
];
|
];
|
||||||
const result = validate(nodes, edges);
|
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 n1 = roleNode("n1");
|
||||||
const n2 = roleNode("n2");
|
const n2 = roleNode("n2");
|
||||||
const n3 = roleNode("n3");
|
const n3 = roleNode("n3");
|
||||||
const nodes = baseNodes(n1, n2, n3);
|
const nodes = baseNodes(n1, n2, n3);
|
||||||
const edges = [
|
const edges = [
|
||||||
defaultEdge("start", "n1"),
|
defaultEdge("start", "n1"),
|
||||||
conditionalEdge("n1", "n2", "x>0"),
|
statusEdge("n1", "n2", "approved"),
|
||||||
defaultEdge("n1", "n3"), // mix → error
|
defaultEdge("n1", "n3"), // mix → error
|
||||||
defaultEdge("n2", "end"),
|
defaultEdge("n2", "end"),
|
||||||
defaultEdge("n3", "end"),
|
defaultEdge("n3", "end"),
|
||||||
];
|
];
|
||||||
const result = validate(nodes, edges);
|
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", () => {
|
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);
|
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 n1 = roleNode("n1");
|
||||||
const n2 = roleNode("n2");
|
const n2 = roleNode("n2");
|
||||||
const n3 = roleNode("n3");
|
const n3 = roleNode("n3");
|
||||||
const nodes = baseNodes(n1, n2, n3);
|
const nodes = baseNodes(n1, n2, n3);
|
||||||
const edges = [
|
const edges = [
|
||||||
defaultEdge("start", "n1"),
|
defaultEdge("start", "n1"),
|
||||||
conditionalEdge("n1", "n2", ""), // else-branch
|
statusEdge("n1", "n2", "_"),
|
||||||
conditionalEdge("n1", "n3", "x>0"), // if-branch
|
statusEdge("n1", "n3", "approved"),
|
||||||
defaultEdge("n2", "end"),
|
defaultEdge("n2", "end"),
|
||||||
defaultEdge("n3", "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 { uuid } from "../utils";
|
||||||
import type { WorkFlowStep } from "./type";
|
import type { WorkFlowStep } from "./type";
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ type Result = {
|
|||||||
|
|
||||||
const _OUT_HANDLES = ["output-top", "output", "output-bottom"] as const;
|
const _OUT_HANDLES = ["output-top", "output", "output-bottom"] as const;
|
||||||
const IN_HANDLES = ["input-top", "input", "input-bottom"] as const;
|
const IN_HANDLES = ["input-top", "input", "input-bottom"] as const;
|
||||||
|
const DEFAULT_STATUS = "_";
|
||||||
|
|
||||||
function assignHandles(
|
function assignHandles(
|
||||||
indices: number[],
|
indices: number[],
|
||||||
@@ -50,8 +51,8 @@ function buildNodeMap(
|
|||||||
function sortTransitions(step: WorkFlowStep): WorkFlowStep["transitions"] {
|
function sortTransitions(step: WorkFlowStep): WorkFlowStep["transitions"] {
|
||||||
if (step.transitions.length <= 1) return step.transitions;
|
if (step.transitions.length <= 1) return step.transitions;
|
||||||
return [...step.transitions].sort((a, b) => {
|
return [...step.transitions].sort((a, b) => {
|
||||||
if (a.condition === null && b.condition !== null) return -1;
|
if (a.status === DEFAULT_STATUS && b.status !== DEFAULT_STATUS) return -1;
|
||||||
if (a.condition !== null && b.condition === null) return 1;
|
if (a.status !== DEFAULT_STATUS && b.status === DEFAULT_STATUS) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -60,32 +61,32 @@ function buildStepEdges(
|
|||||||
sourceId: string,
|
sourceId: string,
|
||||||
step: WorkFlowStep,
|
step: WorkFlowStep,
|
||||||
nameToId: Map<string, string>,
|
nameToId: Map<string, string>,
|
||||||
): { elseEdges: AnyWorkEdge[]; ifEdges: AnyWorkEdge[] } {
|
): { primaryEdges: AnyWorkEdge[]; statusEdges: AnyWorkEdge[] } {
|
||||||
const hasMultiple = step.transitions.length > 1;
|
const hasMultiple = step.transitions.length > 1;
|
||||||
const sorted = sortTransitions(step);
|
const sorted = sortTransitions(step);
|
||||||
const elseEdges: AnyWorkEdge[] = [];
|
const primaryEdges: AnyWorkEdge[] = [];
|
||||||
const ifEdges: AnyWorkEdge[] = [];
|
const statusEdges: AnyWorkEdge[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < sorted.length; i++) {
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
const t = sorted[i];
|
const t = sorted[i];
|
||||||
const targetId = nameToId.get(t.target);
|
const targetId = nameToId.get(t.target);
|
||||||
if (!targetId) continue;
|
if (!targetId) continue;
|
||||||
const edgeId = `e-${sourceId}-${targetId}-${i}`;
|
const edgeId = `e-${sourceId}-${targetId}-${i}`;
|
||||||
if (hasMultiple || t.condition !== null) {
|
if (hasMultiple || t.status !== DEFAULT_STATUS) {
|
||||||
const edge: ConditionalEdge = {
|
const edge: StatusEdge = {
|
||||||
id: edgeId,
|
id: edgeId,
|
||||||
source: sourceId,
|
source: sourceId,
|
||||||
target: targetId,
|
target: targetId,
|
||||||
sourceHandle: "output",
|
sourceHandle: "output",
|
||||||
targetHandle: "input",
|
targetHandle: "input",
|
||||||
type: "conditional",
|
type: "status",
|
||||||
data: { condition: t.condition ?? "" },
|
data: { status: t.status },
|
||||||
animated: true,
|
animated: true,
|
||||||
};
|
};
|
||||||
if (hasMultiple && i === 0) elseEdges.push(edge);
|
if (hasMultiple && t.status === DEFAULT_STATUS) primaryEdges.push(edge);
|
||||||
else ifEdges.push(edge);
|
else statusEdges.push(edge);
|
||||||
} else {
|
} else {
|
||||||
elseEdges.push({
|
primaryEdges.push({
|
||||||
id: edgeId,
|
id: edgeId,
|
||||||
source: sourceId,
|
source: sourceId,
|
||||||
target: targetId,
|
target: targetId,
|
||||||
@@ -95,23 +96,23 @@ function buildStepEdges(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { elseEdges, ifEdges };
|
return { primaryEdges, statusEdges };
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushStepEdges(
|
function pushStepEdges(
|
||||||
edges: AnyWorkEdge[],
|
edges: AnyWorkEdge[],
|
||||||
elseEdges: AnyWorkEdge[],
|
primaryEdges: AnyWorkEdge[],
|
||||||
ifEdges: AnyWorkEdge[],
|
statusEdges: AnyWorkEdge[],
|
||||||
idToOrder: Map<string, number>,
|
idToOrder: Map<string, number>,
|
||||||
): void {
|
): void {
|
||||||
for (const e of elseEdges) edges.push({ ...e, sourceHandle: "output" });
|
for (const e of primaryEdges) edges.push({ ...e, sourceHandle: "output" });
|
||||||
if (ifEdges.length > 0) {
|
if (statusEdges.length > 0) {
|
||||||
const ifHandles = ["output-top", "output-bottom"] as const;
|
const statusHandles = ["output-top", "output-bottom"] as const;
|
||||||
const sorted = [...ifEdges].sort(
|
const sorted = [...statusEdges].sort(
|
||||||
(a, b) => (idToOrder.get(b.target) ?? 0) - (idToOrder.get(a.target) ?? 0),
|
(a, b) => (idToOrder.get(b.target) ?? 0) - (idToOrder.get(a.target) ?? 0),
|
||||||
);
|
);
|
||||||
for (let i = 0; i < sorted.length; i++) {
|
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) {
|
for (const step of steps) {
|
||||||
const sourceId = nameToId.get(step.role.name) ?? "";
|
const sourceId = nameToId.get(step.role.name) ?? "";
|
||||||
const { elseEdges, ifEdges } = buildStepEdges(sourceId, step, nameToId);
|
const { primaryEdges, statusEdges } = buildStepEdges(sourceId, step, nameToId);
|
||||||
pushStepEdges(edges, elseEdges, ifEdges, idToOrder);
|
pushStepEdges(edges, primaryEdges, statusEdges, idToOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
assignTargetHandles(edges, 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";
|
import type { WorkFlowStep, WorkFlowTransition } from "./type";
|
||||||
|
|
||||||
|
const DEFAULT_STATUS = "_";
|
||||||
|
|
||||||
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
|
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
|
||||||
const nodeMap = new Map<string, AnyWorkNode>();
|
const nodeMap = new Map<string, AnyWorkNode>();
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
@@ -43,7 +45,7 @@ function traverse(
|
|||||||
const roleNode = node as WorkNode<"role">;
|
const roleNode = node as WorkNode<"role">;
|
||||||
const outEdges = outgoingEdges.get(nodeId) ?? [];
|
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 targetNode = nodeMap.get(edge.target);
|
||||||
const target =
|
const target =
|
||||||
edge.target === "end"
|
edge.target === "end"
|
||||||
@@ -52,13 +54,12 @@ function traverse(
|
|||||||
? (targetNode as WorkNode<"role">).data.name
|
? (targetNode as WorkNode<"role">).data.name
|
||||||
: edge.target;
|
: edge.target;
|
||||||
|
|
||||||
let condition: string | null = null;
|
const status =
|
||||||
if (edge.type === "conditional") {
|
edge.type === "status"
|
||||||
const isElse = outEdges.length >= 2 && index === 0;
|
? ((edge as StatusEdge).data?.status ?? DEFAULT_STATUS)
|
||||||
condition = isElse ? null : ((edge as ConditionalEdge).data?.condition ?? null);
|
: DEFAULT_STATUS;
|
||||||
}
|
|
||||||
|
|
||||||
return { target, condition };
|
return { target, status };
|
||||||
});
|
});
|
||||||
|
|
||||||
const { name, description, identity, prepare, execute, report } = roleNode.data;
|
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 = {
|
export type ValidationError = {
|
||||||
nodeId: string | null;
|
nodeId: string | null;
|
||||||
@@ -91,10 +91,10 @@ function validateEndNode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasEmptyConditionOnIfEdge(conditionalEdges: AnyWorkEdge[]): boolean {
|
function hasEmptyStatusOnEdge(statusEdges: AnyWorkEdge[]): boolean {
|
||||||
return conditionalEdges.slice(1).some((edge) => {
|
return statusEdges.some((edge) => {
|
||||||
const cond = (edge as ConditionalEdge).data?.condition?.trim();
|
const status = (edge as StatusEdge).data?.status?.trim();
|
||||||
return !cond;
|
return !status;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,11 +113,11 @@ function validateRoleNodeEdges(
|
|||||||
}
|
}
|
||||||
if (outEdges.length <= 1) return;
|
if (outEdges.length <= 1) return;
|
||||||
|
|
||||||
const conditionalEdges = outEdges.filter((e) => e.type === "conditional");
|
const statusEdges = outEdges.filter((e) => e.type === "status");
|
||||||
if (conditionalEdges.length !== outEdges.length) {
|
if (statusEdges.length !== outEdges.length) {
|
||||||
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" });
|
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带状态" });
|
||||||
} else if (hasEmptyConditionOnIfEdge(conditionalEdges)) {
|
} else if (hasEmptyStatusOnEdge(statusEdges)) {
|
||||||
errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" });
|
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 WorkNode<T extends WorkNodeType> = Node<NodeMap[T], T>;
|
||||||
export type AnyWorkNode = WorkNode<"start"> | WorkNode<"end"> | WorkNode<"role">;
|
export type AnyWorkNode = WorkNode<"start"> | WorkNode<"end"> | WorkNode<"role">;
|
||||||
|
|
||||||
export type ConditionalEdgeData = AnyKeyBase & {
|
export type StatusEdgeData = AnyKeyBase & {
|
||||||
condition: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConditionalEdge = Edge<ConditionalEdgeData, "conditional">;
|
export type StatusEdge = Edge<StatusEdgeData, "status">;
|
||||||
export type AnyWorkEdge = ConditionalEdge | Edge;
|
export type AnyWorkEdge = StatusEdge | Edge;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
|||||||
execute: "制定详细的实施计划和步骤分解",
|
execute: "制定详细的实施计划和步骤分解",
|
||||||
report: "输出结构化的计划文档,包含步骤列表和预期产出",
|
report: "输出结构化的计划文档,包含步骤列表和预期产出",
|
||||||
},
|
},
|
||||||
transitions: [{ target: "developer", condition: null }],
|
transitions: [{ target: "developer", status: "_" }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: {
|
role: {
|
||||||
@@ -22,7 +22,7 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
|||||||
execute: "编写高质量的代码实现",
|
execute: "编写高质量的代码实现",
|
||||||
report: "输出变更文件列表和实现摘要",
|
report: "输出变更文件列表和实现摘要",
|
||||||
},
|
},
|
||||||
transitions: [{ target: "reviewer", condition: null }],
|
transitions: [{ target: "reviewer", status: "_" }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: {
|
role: {
|
||||||
@@ -34,8 +34,8 @@ const DEFAULT_STEPS: WorkFlowSteps = [
|
|||||||
report: "输出审查结果,包含 approved 状态和评审意见",
|
report: "输出审查结果,包含 approved 状态和评审意见",
|
||||||
},
|
},
|
||||||
transitions: [
|
transitions: [
|
||||||
{ target: "END", condition: null },
|
{ target: "END", status: "approved" },
|
||||||
{ target: "developer", condition: "steps[-1].output.approved = false" },
|
{ target: "developer", status: "rejected" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user