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:
2026-05-25 05:01:43 +00:00
parent 5a7f417899
commit e40e41555b
16 changed files with 175 additions and 270 deletions
+22 -63
View File
@@ -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");
}); });
}); });
+1 -1
View File
@@ -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(),
}), }),
), ),
}), }),
+15 -40
View File
@@ -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");
+1 -1
View File
@@ -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,
}; };
@@ -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" },
], ],
}, },
]; ];