From e40e41555b3c933808be05b3bf93086858a42bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 25 May 2026 05:01:43 +0000 Subject: [PATCH] refactor: dashboard status-based edge routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .workflows/solve-issue.yaml | 85 +++++-------------- .../solve-issue-tea-worktree.test.ts | 8 +- packages/workflow-dashboard/server/api.ts | 2 +- .../workflow-dashboard/server/workflow.ts | 55 ++++-------- packages/workflow-dashboard/shared/types.ts | 2 +- .../src/editor/edges/index.tsx | 4 +- .../edges/{conditional.tsx => status.tsx} | 76 ++++++----------- .../src/editor/model/edges.ts | 8 +- .../src/editor/model/handlers.ts | 23 +---- .../editor/trans/__tests__/trans-in.test.ts | 54 +++++++----- .../editor/trans/__tests__/validate.test.ts | 28 +++--- .../src/editor/trans/trans-in.ts | 47 +++++----- .../src/editor/trans/trans-out.ts | 17 ++-- .../src/editor/trans/validate.ts | 20 ++--- .../workflow-dashboard/src/editor/type.ts | 8 +- .../workflow-dashboard/src/pages/editor.tsx | 8 +- 16 files changed, 175 insertions(+), 270 deletions(-) rename packages/workflow-dashboard/src/editor/edges/{conditional.tsx => status.tsx} (72%) diff --git a/.workflows/solve-issue.yaml b/.workflows/solve-issue.yaml index d132095..1f9fb94 100644 --- a/.workflows/solve-issue.yaml +++ b/.workflows/solve-issue.yaml @@ -95,13 +95,14 @@ roles: Only review standards compliance. Do NOT test functionality. 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: type: object properties: - approved: - type: boolean - required: [approved] + status: + type: string + enum: [approved, rejected] + required: [status] tester: description: "Functional correctness verification" 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: - `cd ~/repos/workflow` - `git worktree remove ~/repos/workflow-worktrees/fix/-` - 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: type: object properties: - success: - type: boolean - required: [success] -conditions: - 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" + status: + type: string + enum: [committed, hook_failed] + required: [status] graph: $START: - - role: "planner" - condition: null - prompt: "Analyze the issue and produce an implementation plan." + _: { role: "planner", prompt: "Analyze the issue and produce an implementation plan." } planner: - - role: "$END" - condition: "insufficientInfo" - prompt: "Insufficient information to proceed; end the workflow." - - role: "developer" - condition: null - prompt: "Implement the plan from the planner." + insufficient_info: { role: "$END", prompt: "Insufficient information to proceed; end the workflow." } + ready: { role: "developer", prompt: "Implement the plan from the planner." } developer: - - role: "$END" - condition: "devFailed" - prompt: "Development failed; end the workflow." - - role: "reviewer" - condition: null - prompt: "Send the implementation to the reviewer." + failed: { role: "$END", prompt: "Development failed; end the workflow." } + done: { role: "reviewer", prompt: "Send the implementation to the reviewer." } reviewer: - - role: "developer" - condition: "rejected" - prompt: "Reviewer rejected the implementation; fix the issues." - - role: "tester" - condition: null - prompt: "Review passed; run tests on the implementation." + rejected: { role: "developer", prompt: "Reviewer rejected the implementation; fix the issues." } + approved: { role: "tester", prompt: "Review passed; run tests on the implementation." } tester: - - role: "developer" - condition: "fixCode" - prompt: "Tests found code issues; return to developer." - - role: "planner" - condition: "fixSpec" - prompt: "Tests found spec issues; return to planner." - - role: "committer" - condition: null - prompt: "Tests passed; commit and push the changes." + fix_code: { role: "developer", prompt: "Tests found code issues; return to developer." } + fix_spec: { role: "planner", prompt: "Tests found spec issues; return to planner." } + passed: { role: "committer", prompt: "Tests passed; commit and push the changes." } committer: - - role: "developer" - condition: "hookFailed" - prompt: "Push hook failed; return to developer to fix." - - role: "$END" - condition: null - prompt: "Commit succeeded; complete the workflow." + hook_failed: { role: "developer", prompt: "Push hook failed; return to developer to fix." } + committed: { role: "$END", prompt: "Commit succeeded; complete the workflow." } diff --git a/packages/cli-workflow/src/__tests__/solve-issue-tea-worktree.test.ts b/packages/cli-workflow/src/__tests__/solve-issue-tea-worktree.test.ts index fafb86a..d9f22a8 100644 --- a/packages/cli-workflow/src/__tests__/solve-issue-tea-worktree.test.ts +++ b/packages/cli-workflow/src/__tests__/solve-issue-tea-worktree.test.ts @@ -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"); }); }); diff --git a/packages/workflow-dashboard/server/api.ts b/packages/workflow-dashboard/server/api.ts index a2636dd..66916fe 100644 --- a/packages/workflow-dashboard/server/api.ts +++ b/packages/workflow-dashboard/server/api.ts @@ -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(), }), ), }), diff --git a/packages/workflow-dashboard/server/workflow.ts b/packages/workflow-dashboard/server/workflow.ts index 9d3df26..6a1c683 100644 --- a/packages/workflow-dashboard/server/workflow.ts +++ b/packages/workflow-dashboard/server/workflow.ts @@ -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(); - 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 = {}; - const conditions: WorkflowPayload["conditions"] = {}; - const graph: Record = {}; - - const expressionToName = new Map(); - let condIdx = 0; + const graph: Record> = {}; 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 = {}; + 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 { @@ -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"); diff --git a/packages/workflow-dashboard/shared/types.ts b/packages/workflow-dashboard/shared/types.ts index 9e9aa5f..635931a 100644 --- a/packages/workflow-dashboard/shared/types.ts +++ b/packages/workflow-dashboard/shared/types.ts @@ -9,7 +9,7 @@ export type WorkFlowRole = { export type WorkFlowTransition = { target: string; - condition: string | null; + status: string; }; export type WorkFlowStep = { diff --git a/packages/workflow-dashboard/src/editor/edges/index.tsx b/packages/workflow-dashboard/src/editor/edges/index.tsx index 173972c..0a0514c 100644 --- a/packages/workflow-dashboard/src/editor/edges/index.tsx +++ b/packages/workflow-dashboard/src/editor/edges/index.tsx @@ -1,6 +1,6 @@ -import { ConditionalEdge, GradientEdge } from "./conditional"; +import { GradientEdge, StatusEdge } from "./status"; export const edgeTypes = { - conditional: ConditionalEdge, + status: StatusEdge, default: GradientEdge, }; diff --git a/packages/workflow-dashboard/src/editor/edges/conditional.tsx b/packages/workflow-dashboard/src/editor/edges/status.tsx similarity index 72% rename from packages/workflow-dashboard/src/editor/edges/conditional.tsx rename to packages/workflow-dashboard/src/editor/edges/status.tsx index b7c038e..a5aecb9 100644 --- a/packages/workflow-dashboard/src/editor/edges/conditional.tsx +++ b/packages/workflow-dashboard/src/editor/edges/status.tsx @@ -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 ( -
- - else - -
- ); -} - -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(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 (
- if + {displayStatus ?? "status"}
{isOpen && ( @@ -155,7 +144,7 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro 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): ReactNode { +}: EdgeProps): 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} /> - {isElse ? ( - - ) : ( - - )} + ); @@ -269,7 +241,7 @@ export function GradientEdge({ sourceY={sourceY} targetX={targetX} targetY={targetY} - hasCondition={null} + hasStatus={true} selected={!!selected} /> ); diff --git a/packages/workflow-dashboard/src/editor/model/edges.ts b/packages/workflow-dashboard/src/editor/model/edges.ts index 76e7206..c3e51f0 100644 --- a/packages/workflow-dashboard/src/editor/model/edges.ts +++ b/packages/workflow-dashboard/src/editor/model/edges.ts @@ -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; }); diff --git a/packages/workflow-dashboard/src/editor/model/handlers.ts b/packages/workflow-dashboard/src/editor/model/handlers.ts index c29410d..80d1fc4 100644 --- a/packages/workflow-dashboard/src/editor/model/handlers.ts +++ b/packages/workflow-dashboard/src/editor/model/handlers.ts @@ -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 = async ({ nodes, edges }) => { + const onBeforeDelete: OnBeforeDelete = 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; diff --git a/packages/workflow-dashboard/src/editor/trans/__tests__/trans-in.test.ts b/packages/workflow-dashboard/src/editor/trans/__tests__/trans-in.test.ts index fd14778..4e73cac 100644 --- a/packages/workflow-dashboard/src/editor/trans/__tests__/trans-in.test.ts +++ b/packages/workflow-dashboard/src/editor/trans/__tests__/trans-in.test.ts @@ -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; diff --git a/packages/workflow-dashboard/src/editor/trans/__tests__/validate.test.ts b/packages/workflow-dashboard/src/editor/trans/__tests__/validate.test.ts index 0196dd3..0ba0511 100644 --- a/packages/workflow-dashboard/src/editor/trans/__tests__/validate.test.ts +++ b/packages/workflow-dashboard/src/editor/trans/__tests__/validate.test.ts @@ -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"), ]; diff --git a/packages/workflow-dashboard/src/editor/trans/trans-in.ts b/packages/workflow-dashboard/src/editor/trans/trans-in.ts index 1d3807b..69424e7 100644 --- a/packages/workflow-dashboard/src/editor/trans/trans-in.ts +++ b/packages/workflow-dashboard/src/editor/trans/trans-in.ts @@ -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, -): { 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, ): 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); diff --git a/packages/workflow-dashboard/src/editor/trans/trans-out.ts b/packages/workflow-dashboard/src/editor/trans/trans-out.ts index f9b8ee7..7ea613d 100644 --- a/packages/workflow-dashboard/src/editor/trans/trans-out.ts +++ b/packages/workflow-dashboard/src/editor/trans/trans-out.ts @@ -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(); 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; diff --git a/packages/workflow-dashboard/src/editor/trans/validate.ts b/packages/workflow-dashboard/src/editor/trans/validate.ts index 0bf8c66..4c69d55 100644 --- a/packages/workflow-dashboard/src/editor/trans/validate.ts +++ b/packages/workflow-dashboard/src/editor/trans/validate.ts @@ -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: "状态边的状态值不能为空" }); } } diff --git a/packages/workflow-dashboard/src/editor/type.ts b/packages/workflow-dashboard/src/editor/type.ts index 601f078..9a74979 100644 --- a/packages/workflow-dashboard/src/editor/type.ts +++ b/packages/workflow-dashboard/src/editor/type.ts @@ -21,9 +21,9 @@ export type WorkNodeType = keyof NodeMap; export type WorkNode = Node; 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; -export type AnyWorkEdge = ConditionalEdge | Edge; +export type StatusEdge = Edge; +export type AnyWorkEdge = StatusEdge | Edge; diff --git a/packages/workflow-dashboard/src/pages/editor.tsx b/packages/workflow-dashboard/src/pages/editor.tsx index 0d0f64d..57e675b 100644 --- a/packages/workflow-dashboard/src/pages/editor.tsx +++ b/packages/workflow-dashboard/src/pages/editor.tsx @@ -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" }, ], }, ];