Compare commits

...

4 Commits

Author SHA1 Message Date
xiaoju e40e41555b 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)
2026-05-25 05:05:57 +00:00
xiaoju 5a7f417899 feat: migrate examples to status-based routing + fix mustache HTML escape
- Migrate solve-issue.yaml, analyze-topic.yaml, debate.yaml to new format
- Add status enum field to all role frontmatter schemas
- Use {{{ }}} (triple mustache) for prompt templates with user content
- Disable mustache HTML escaping globally (prompts are plain text, not HTML)
- Add 2 new tests for HTML escape behavior
- 9 moderator tests pass

Phase 2 of #490 (closes #492)
2026-05-25 04:52:53 +00:00
xiaoju d00f9df2dd refactor: status-based graph routing + mustache prompt templates
- Delete ConditionDefinition, Transition types from workflow-protocol
- Add Target type, change graph to Record<string, Record<string, Target>>
- Remove conditions from WorkflowPayload and WORKFLOW_SCHEMA
- Replace jsonata with mustache in workflow-moderator
- Rewrite evaluate() to simple map lookup + mustache render
- Update cli-workflow to use new 3-arg evaluate(graph, role, output)
- 296 tests pass, 0 fail

Phase 1 of #490 (closes #491)
2026-05-25 04:50:06 +00:00
xiaoju ff959be3ef Merge pull request 'refactor(cli-workflow): reduce cmdStepRead cognitive complexity' (#488) from fix/487-refactor-step-read into main 2026-05-25 02:25:32 +00:00
29 changed files with 370 additions and 780 deletions
+22 -63
View File
@@ -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/<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:
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." }
+5 -8
View File
@@ -22,6 +22,8 @@ roles:
frontmatter:
type: object
properties:
status:
enum: ["_"]
thesis:
type: string
keyPoints:
@@ -30,14 +32,9 @@ roles:
type: string
caveats:
type: string
required: [thesis, keyPoints]
conditions: {}
required: [status, thesis, keyPoints]
graph:
$START:
- role: "analyst"
condition: null
prompt: "Analyze the topic in the task and produce a structured summary with key points."
_: { role: "analyst", prompt: "Analyze the topic in the task and produce a structured summary with key points." }
analyst:
- role: "$END"
condition: null
prompt: "Analysis complete. Finish the workflow."
_: { role: "$END", prompt: "Analysis complete. Finish the workflow." }
+15 -30
View File
@@ -16,15 +16,16 @@ roles:
3. If you find yourself genuinely convinced by the other side, you may concede.
output: |
Provide your argument in the frontmatter.
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
Otherwise set status to "continue".
frontmatter:
type: object
properties:
status:
enum: ["continue", "conceded"]
argument:
type: string
conceded:
type: boolean
required: [argument, conceded]
required: [status, argument]
for:
description: "Argues for the proposition"
goal: |
@@ -40,38 +41,22 @@ roles:
3. If you find yourself genuinely convinced by the other side, you may concede.
output: |
Provide your argument in the frontmatter.
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
Set status to "conceded" ONLY if you are genuinely convinced and wish to stop debating.
Otherwise set status to "continue".
frontmatter:
type: object
properties:
status:
enum: ["continue", "conceded"]
argument:
type: string
conceded:
type: boolean
required: [argument, conceded]
conditions:
againstConceded:
description: "The against side conceded"
expression: "$last('against').conceded = true"
forConceded:
description: "The for side conceded"
expression: "$last('for').conceded = true"
required: [status, argument]
graph:
$START:
- role: "against"
condition: null
prompt: "Present your opening argument against the proposition."
_: { role: "against", prompt: "Present your opening argument against the proposition." }
against:
- role: "$END"
condition: "againstConceded"
prompt: "The against side conceded. Debate over."
- role: "for"
condition: null
prompt: "Counter the opposing argument. Address their points directly."
conceded: { role: "$END", prompt: "The against side conceded. Debate over." }
continue: { role: "for", prompt: "Counter the opposing argument: {{{argument}}}" }
for:
- role: "$END"
condition: "forConceded"
prompt: "The for side conceded. Debate over."
- role: "against"
condition: null
prompt: "Counter the opposing argument. Address their points directly."
conceded: { role: "$END", prompt: "The for side conceded. Debate over." }
continue: { role: "against", prompt: "Counter the opposing argument: {{{argument}}}" }
+14 -24
View File
@@ -27,11 +27,13 @@ roles:
frontmatter:
type: object
properties:
status:
enum: ["_"]
repoPath:
type: string
plan:
type: string
required: [repoPath, plan]
required: [status, repoPath, plan]
developer:
description: "Implements code changes"
goal: "You are a developer agent. You implement code changes according to plans."
@@ -50,13 +52,15 @@ roles:
frontmatter:
type: object
properties:
status:
enum: ["_"]
filesChanged:
type: array
items:
type: string
summary:
type: string
required: [filesChanged, summary]
required: [status, filesChanged, summary]
reviewer:
description: "Reviews code changes"
goal: "You are a code reviewer. You review implementations for correctness and quality."
@@ -71,32 +75,18 @@ roles:
frontmatter:
type: object
properties:
approved:
type: boolean
status:
enum: ["approved", "rejected"]
comments:
type: string
required: [approved, comments]
conditions:
notApproved:
description: "Reviewer rejected the implementation"
expression: "$last('reviewer').approved = false"
required: [status, comments]
graph:
$START:
- role: "planner"
condition: null
prompt: "Analyze the issue described in the task and produce a detailed implementation plan."
_: { role: "planner", prompt: "Analyze the issue described in the task and produce a detailed implementation plan." }
planner:
- role: "developer"
condition: null
prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass."
_: { role: "developer", prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass." }
developer:
- role: "reviewer"
condition: null
prompt: "Review the developer's implementation against the plan for correctness and quality."
_: { role: "reviewer", prompt: "Review the developer's implementation against the plan for correctness and quality." }
reviewer:
- role: "developer"
condition: "notApproved"
prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues."
- role: "$END"
condition: null
prompt: "The review passed. Complete the workflow."
approved: { role: "$END", prompt: "The review passed. Complete the workflow." }
rejected: { role: "developer", prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues: {{{comments}}}" }
@@ -70,7 +70,6 @@ describe("solve-issue workflow: tea pr create worktree fix", () => {
// Basic structure validation
expect(workflow.name).toBe("solve-issue");
expect(workflow.roles).toBeDefined();
expect(workflow.conditions).toBeDefined();
expect(workflow.graph).toBeDefined();
// Verify committer role exists with required fields
@@ -82,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
@@ -91,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");
});
});
@@ -25,7 +25,6 @@ async function storeWorkflow(uwf: UwfStore, name: string): Promise<CasRef> {
name,
description: "Test workflow",
roles: {},
conditions: {},
graph: {},
};
return await uwf.store.put(uwf.schemas.workflow, payload);
@@ -36,7 +35,6 @@ async function createWorkflowYaml(name: string, version: string | null = null):
name,
description: version !== null ? `Test workflow (${version})` : "Test workflow",
roles: {},
conditions: {},
graph: {},
};
const yaml = stringify(payload);
@@ -145,7 +143,7 @@ describe("Strategy 2: File Path Resolution", () => {
test("should fail on valid YAML with invalid WorkflowPayload shape", async () => {
await makeUwfStore(storageRoot);
const yamlPath = join(tmpDir, "invalid-workflow.yaml");
await writeFile(yamlPath, "name: test\n# missing roles, conditions, and graph");
await writeFile(yamlPath, "name: test\n# missing roles and graph");
await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow();
});
+34 -17
View File
@@ -8,10 +8,8 @@ import type {
AgentAlias,
AgentConfig,
CasRef,
ModeratorContext,
StartNodePayload,
StartOutput,
StepContext,
StepNodePayload,
StepOutput,
ThreadId,
@@ -53,6 +51,7 @@ import {
import { materializeWorkflowPayload } from "./workflow.js";
const END_ROLE = "$END";
const START_ROLE = "$START";
export const THREAD_READ_DEFAULT_QUOTA = 4000;
const PL_THREAD_START = "7HNQ4B2X";
@@ -670,17 +669,32 @@ function formatThreadReadMarkdown(options: {
return parts.join("\n\n---\n\n");
}
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
const chronological = [...chain.stepsNewestFirst].reverse();
const steps: StepContext[] = chronological.map((step) => ({
role: step.role,
output: expandOutput(uwf, step.output),
detail: step.detail,
agent: step.agent,
edgePrompt: step.edgePrompt ?? "",
content: null, // Moderator doesn't need content
}));
return { start: chain.start, steps };
type EvaluateLastOutput = Record<string, unknown> & { status: string };
function resolveEvaluateArgs(
uwf: UwfStore,
chain: ChainState,
): { lastRole: string; lastOutput: EvaluateLastOutput } {
if (chain.headIsStart) {
return { lastRole: START_ROLE, lastOutput: { status: "_" } };
}
const lastStep = chain.stepsNewestFirst[0];
if (lastStep === undefined) {
fail("empty step chain");
}
const raw = expandOutput(uwf, lastStep.output);
const base =
typeof raw === "object" && raw !== null && !Array.isArray(raw)
? (raw as Record<string, unknown>)
: {};
const status = typeof base.status === "string" ? base.status : "_";
return {
lastRole: lastStep.role,
lastOutput: { ...base, status },
};
}
function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
@@ -924,9 +938,9 @@ async function cmdThreadStepOnce(
const chain = walkChain(uwf, headHash);
const workflowHash = chain.start.workflow;
const workflow = loadWorkflowPayload(uwf, workflowHash);
const context = buildModeratorContext(uwf, chain);
const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
const nextResult = await evaluate(workflow, context);
const nextResult = evaluate(workflow.graph, lastRole, lastOutput);
if (!nextResult.ok) {
failStep(plog, `moderator evaluate failed: ${nextResult.error.message}`);
}
@@ -976,8 +990,11 @@ async function cmdThreadStepOnce(
await saveThreadsIndex(storageRoot, freshIndex);
const chainAfter = walkChain(uwfAfter, newHead);
const contextAfter = buildModeratorContext(uwfAfter, chainAfter);
const afterResult = await evaluate(workflow, contextAfter);
const { lastRole: lastRoleAfter, lastOutput: lastOutputAfter } = resolveEvaluateArgs(
uwfAfter,
chainAfter,
);
const afterResult = evaluate(workflow.graph, lastRoleAfter, lastOutputAfter);
if (!afterResult.ok) {
failStep(plog, `post-step moderator evaluate failed: ${afterResult.error.message}`);
}
+16 -19
View File
@@ -2,12 +2,7 @@ import { readFile } from "node:fs/promises";
import type { JSONSchema } from "@uncaged/json-cas";
import { putSchema, validate } from "@uncaged/json-cas";
import type {
CasRef,
RoleDefinition,
Transition,
WorkflowPayload,
} from "@uncaged/workflow-protocol";
import type { CasRef, RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
import { parse } from "yaml";
import {
@@ -51,20 +46,23 @@ function isJsonSchema(value: unknown): value is JSONSchema {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Normalize graph transitions: ensure condition is null (not undefined) for fallback entries. */
function normalizeGraph(graph: Record<string, Transition[]>): Record<string, Transition[]> {
const result: Record<string, Transition[]> = {};
for (const [node, transitions] of Object.entries(graph)) {
result[node] = transitions.map((t) => {
if (typeof t.prompt !== "string" || t.prompt.trim() === "") {
fail(`graph[${node}] transition to "${t.role}": prompt is required (non-empty string)`);
/** Normalize graph: validate each status → target mapping. */
function normalizeGraph(
graph: Record<string, Record<string, Target>>,
): Record<string, Record<string, Target>> {
const result: Record<string, Record<string, Target>> = {};
for (const [node, statusMap] of Object.entries(graph)) {
const normalized: Record<string, Target> = {};
for (const [status, target] of Object.entries(statusMap)) {
if (typeof target.prompt !== "string" || target.prompt.trim() === "") {
fail(`graph[${node}][${status}] → "${target.role}": prompt is required (non-empty string)`);
}
return {
role: t.role,
condition: t.condition ?? null,
prompt: t.prompt,
normalized[status] = {
role: target.role,
prompt: target.prompt,
};
});
}
result[node] = normalized;
}
return result;
}
@@ -106,7 +104,6 @@ export async function materializeWorkflowPayload(
name: raw.name,
description: raw.description,
roles,
conditions: raw.conditions,
graph: normalizeGraph(raw.graph),
};
}
+4 -19
View File
@@ -30,23 +30,12 @@ function isRoleDefinition(value: unknown): boolean {
);
}
function isConditionDefinition(value: unknown): boolean {
function isTarget(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return typeof value.description === "string" && typeof value.expression === "string";
}
function isTransition(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const condition = value.condition;
return (
typeof value.role === "string" &&
typeof value.prompt === "string" &&
value.prompt.trim() !== "" &&
(condition === null || condition === undefined || typeof condition === "string")
typeof value.role === "string" && typeof value.prompt === "string" && value.prompt.trim() !== ""
);
}
@@ -62,7 +51,7 @@ function isGraph(value: unknown): boolean {
return false;
}
return Object.values(value).every(
(transitions) => Array.isArray(transitions) && transitions.every((t) => isTransition(t)),
(statusMap) => isRecord(statusMap) && Object.values(statusMap).every((t) => isTarget(t)),
);
}
@@ -101,11 +90,7 @@ export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
if (typeof raw.name !== "string" || typeof raw.description !== "string") {
return null;
}
if (
!isStringRecord(raw.roles, isRoleDefinition) ||
!isStringRecord(raw.conditions, isConditionDefinition) ||
!isGraph(raw.graph)
) {
if (!isStringRecord(raw.roles, isRoleDefinition) || !isGraph(raw.graph)) {
return null;
}
return raw as WorkflowPayload;
+1 -1
View File
@@ -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(),
}),
),
}),
+15 -40
View File
@@ -1,6 +1,6 @@
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { RoleDefinition, Transition, WorkflowPayload } from "@uncaged/workflow-protocol";
import type { RoleDefinition, Target, WorkflowPayload } from "@uncaged/workflow-protocol";
import YAML from "yaml";
import type { WorkFlowSteps, WorkFlowTransition, WorkflowSummary } from "../shared/types.ts";
@@ -11,17 +11,12 @@ async function ensureDir() {
}
function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
const conditionMap = new Map<string, string>();
for (const [name, def] of Object.entries(payload.conditions)) {
conditionMap.set(name, def.expression);
}
const steps: WorkFlowSteps = [];
for (const [roleName, roleDef] of Object.entries(payload.roles)) {
const graphTransitions = payload.graph[roleName] ?? [];
const transitions: WorkFlowTransition[] = graphTransitions.map((t) => ({
target: t.role === "$END" ? "END" : t.role,
condition: t.condition ? (conditionMap.get(t.condition) ?? t.condition) : null,
const statusMap = payload.graph[roleName] ?? {};
const transitions: WorkFlowTransition[] = Object.entries(statusMap).map(([status, target]) => ({
target: target.role === "$END" ? "END" : target.role,
status,
}));
steps.push({
@@ -42,11 +37,7 @@ function payloadToSteps(payload: WorkflowPayload): WorkFlowSteps {
function stepsToPayload(name: string, description: string, steps: WorkFlowSteps): WorkflowPayload {
const roles: Record<string, RoleDefinition> = {};
const conditions: WorkflowPayload["conditions"] = {};
const graph: Record<string, Transition[]> = {};
const expressionToName = new Map<string, string>();
let condIdx = 0;
const graph: Record<string, Record<string, Target>> = {};
for (const step of steps) {
const r = step.role;
@@ -59,43 +50,28 @@ function stepsToPayload(name: string, description: string, steps: WorkFlowSteps)
frontmatter: "",
};
const transitions: Transition[] = step.transitions.map((t) => {
let condName: string | null = null;
if (t.condition) {
if (expressionToName.has(t.condition)) {
condName = expressionToName.get(t.condition) ?? null;
} else {
condName = `cond${condIdx++}`;
expressionToName.set(t.condition, condName);
conditions[condName] = {
description: "",
expression: t.condition,
};
}
}
const statusMap: Record<string, Target> = {};
for (const t of step.transitions) {
const targetRole = t.target === "END" ? "$END" : t.target;
return {
statusMap[t.status] = {
role: targetRole,
condition: condName,
prompt: `Transition to ${targetRole}.`,
};
});
graph[r.name] = transitions;
}
graph[r.name] = statusMap;
}
if (steps.length > 0) {
const firstRole = steps[0].role.name;
graph.$START = [
{
graph.$START = {
_: {
role: firstRole,
condition: null,
prompt: `Begin workflow at role ${firstRole}.`,
},
];
};
}
return { name, description, roles, conditions, graph };
return { name, description, roles, graph };
}
export async function listWorkflows(): Promise<WorkflowSummary[]> {
@@ -125,7 +101,6 @@ export async function createWorkflow(name: string, description: string): Promise
name,
description,
roles: {},
conditions: {},
graph: {},
};
await writeFile(join(WORKFLOW_DIR, `${name}.yaml`), YAML.stringify(payload), "utf-8");
+1 -1
View File
@@ -9,7 +9,7 @@ export type WorkFlowRole = {
export type WorkFlowTransition = {
target: string;
condition: string | null;
status: string;
};
export type WorkFlowStep = {
@@ -1,6 +1,6 @@
import { ConditionalEdge, GradientEdge } from "./conditional";
import { GradientEdge, StatusEdge } from "./status";
export const edgeTypes = {
conditional: ConditionalEdge,
status: StatusEdge,
default: GradientEdge,
};
@@ -6,10 +6,10 @@ import {
useReactFlow,
} from "@xyflow/react";
import { Check } from "lucide-react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { cn } from "../../lib/utils.ts";
import { useModel } from "../context.tsx";
import type { ConditionalEdge as ConditionalEdgeType } from "../type.ts";
import type { StatusEdge as StatusEdgeType } from "../type.ts";
const SOURCE_COLOR = "#10b981";
const TARGET_COLOR = "#3b82f6";
@@ -23,7 +23,7 @@ function GradientPath({
sourceY,
targetX,
targetY,
hasCondition,
hasStatus,
selected,
}: {
id: string;
@@ -32,11 +32,11 @@ function GradientPath({
sourceY: number;
targetX: number;
targetY: number;
hasCondition: boolean | null;
hasStatus: boolean;
selected: boolean;
}) {
const gradientId = `gradient-${id}`;
const showLack = hasCondition === false;
const showLack = !hasStatus;
const strokeStyle = selected
? { stroke: "#f59e0b", strokeWidth: 2 }
: { stroke: `url(#${gradientId})`, strokeWidth: 1.5 };
@@ -68,35 +68,20 @@ function GradientPath({
);
}
function ElseBadge({ labelX, labelY }: { labelX: number; labelY: number }): ReactNode {
return (
<div
className="absolute pointer-events-none"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
}}
>
<span className="inline-block px-1 bg-white rounded text-[10px] border border-gray-300 text-gray-500">
else
</span>
</div>
);
}
type ConditionLabelProps = {
condition: string | undefined;
type StatusLabelProps = {
status: string | undefined;
labelX: number;
labelY: number;
onSave: (value: string) => void;
};
function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelProps): ReactNode {
function StatusLabel({ status, labelX, labelY, onSave }: StatusLabelProps): ReactNode {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
function handleBadgeClick() {
setInputValue(condition || "");
setInputValue(status || "");
setIsOpen(true);
}
@@ -127,6 +112,8 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
return () => document.removeEventListener("pointerdown", handleClickOutside, true);
}, [isOpen]);
const displayStatus = status?.trim() || null;
return (
<div
ref={containerRef}
@@ -142,11 +129,13 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
<span
className={cn(
"inline-block px-1 bg-white rounded text-[10px]",
condition ? "border border-gray-300 text-black" : "border border-dashed text-red-500",
displayStatus
? "border border-gray-300 text-black"
: "border border-dashed text-red-500",
)}
style={condition ? undefined : { borderColor: LACK_COLOR }}
style={displayStatus ? undefined : { borderColor: LACK_COLOR }}
>
if
{displayStatus ?? "status"}
</span>
</div>
{isOpen && (
@@ -155,7 +144,7 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
<input
type="text"
className="w-32 rounded border border-gray-300 px-1 py-0.5 text-[10px] focus:border-blue-500 focus:outline-none"
placeholder="输入条件"
placeholder="输入状态"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
@@ -174,14 +163,8 @@ function ConditionLabel({ condition, labelX, labelY, onSave }: ConditionLabelPro
);
}
export function isElseEdge(edgeId: string, source: string, allEdges: Edge[]): boolean {
const siblings = allEdges.filter((e) => e.source === source && e.type === "conditional");
return siblings.length >= 2 && siblings[0].id === edgeId;
}
export function ConditionalEdge({
export function StatusEdge({
id,
source,
sourceX,
sourceY,
targetX,
@@ -190,7 +173,7 @@ export function ConditionalEdge({
targetPosition,
selected,
data,
}: EdgeProps<ConditionalEdgeType>): ReactNode {
}: EdgeProps<StatusEdgeType>): ReactNode {
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
@@ -203,13 +186,11 @@ export function ConditionalEdge({
const flow = useReactFlow();
const model = useModel();
const allEdges = flow.getEdges();
const isElse = useMemo(() => isElseEdge(id, source, allEdges), [id, source, allEdges]);
const status = data?.status;
const condition = data?.condition;
function handleSave(value: string) {
model.startTransaction();
flow.updateEdgeData(id, { condition: value });
flow.updateEdgeData(id, { status: value });
requestAnimationFrame(model.endTransaction);
}
@@ -222,20 +203,11 @@ export function ConditionalEdge({
sourceY={sourceY}
targetX={targetX}
targetY={targetY}
hasCondition={isElse ? null : !!condition}
hasStatus={!!status?.trim()}
selected={!!selected}
/>
<EdgeLabelRenderer>
{isElse ? (
<ElseBadge labelX={labelX} labelY={labelY} />
) : (
<ConditionLabel
condition={condition}
labelX={labelX}
labelY={labelY}
onSave={handleSave}
/>
)}
<StatusLabel status={status} labelX={labelX} labelY={labelY} onSave={handleSave} />
</EdgeLabelRenderer>
</>
);
@@ -269,7 +241,7 @@ export function GradientEdge({
sourceY={sourceY}
targetX={targetX}
targetY={targetY}
hasCondition={null}
hasStatus={true}
selected={!!selected}
/>
);
@@ -65,12 +65,12 @@ export const edgesModel = define.model("edges", makeEdges, (set, get, model) =>
const existingFromSource = currentEdges.filter((e) => e.source === normalized.source);
if (existingFromSource.length > 0) {
edge.type = "conditional";
edge.data = { condition: "" };
edge.type = "status";
edge.data = { status: "" };
const promoted = currentEdges.map((e) => {
if (e.source === normalized.source && e.type !== "conditional") {
return { ...e, type: "conditional" as const, data: { condition: "" } };
if (e.source === normalized.source && e.type !== "status") {
return { ...e, type: "status" as const, data: { status: "_" } };
}
return e;
});
@@ -34,21 +34,8 @@ export const handlers = define.memoize((use, model) => {
return node.type === "start" || node.type === "end";
}
function isFirstConditionalSibling(
edge: { id: string; source: string; type: string | null },
allEdges: { id: string; source: string; type: string | null }[],
): boolean {
if (edge.type !== "conditional") return false;
const siblings = allEdges.filter((e) => e.source === edge.source && e.type === "conditional");
return siblings.length >= 2 && siblings[0].id === edge.id;
}
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => {
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes }) => {
if (nodes.some(isProtectedNode)) return false;
if (edges.length > 0) {
const allEdges = use(edgesModel)[0];
if (edges.some((e) => isFirstConditionalSibling(e, allEdges))) return false;
}
model.startTransaction();
return true;
};
@@ -56,16 +43,14 @@ export const handlers = define.memoize((use, model) => {
if (deletedEdges.length > 0) {
const currentEdges = use(edgesModel)[0];
const sourcesToCheck = new Set(
deletedEdges.filter((e) => e.type === "conditional").map((e) => e.source),
deletedEdges.filter((e) => e.type === "status").map((e) => e.source),
);
if (sourcesToCheck.size > 0) {
let needsDowngrade = false;
const updatedEdges = currentEdges.map((e) => {
if (!sourcesToCheck.has(e.source) || e.type !== "conditional") return e;
const siblings = currentEdges.filter(
(s) => s.source === e.source && s.type === "conditional",
);
if (!sourcesToCheck.has(e.source) || e.type !== "status") return e;
const siblings = currentEdges.filter((s) => s.source === e.source && s.type === "status");
if (siblings.length === 1) {
needsDowngrade = true;
const { data: _, ...rest } = e;
@@ -36,7 +36,7 @@ describe("transIn", () => {
});
it("4.3 Single step with END transition → edge to end node exists", () => {
const steps = [makeStep("A", [{ condition: null, target: "END" }])];
const steps = [makeStep("A", [{ status: "_", target: "END" }])];
const { edges } = transIn(steps);
const endEdge = edges.find((e) => e.target === "end");
expect(endEdge).toBeDefined();
@@ -44,8 +44,8 @@ describe("transIn", () => {
it("4.4 Two steps with default transitions chain", () => {
const steps = [
makeStep("A", [{ condition: null, target: "B" }]),
makeStep("B", [{ condition: null, target: "END" }]),
makeStep("A", [{ status: "_", target: "B" }]),
makeStep("B", [{ status: "_", target: "END" }]),
];
const { edges } = transIn(steps);
// Should have start→A, A→B, B→end
@@ -53,15 +53,15 @@ describe("transIn", () => {
const nodeAId = edges.find((e) => e.source === "start")?.target;
expect(edges.find((e) => e.source === nodeAId && e.target !== "end")).toBeDefined();
expect(edges.find((e) => e.target === "end")).toBeDefined();
// No conditional edges
expect(edges.every((e) => e.type !== "conditional")).toBe(true);
// No status edges for single default transitions
expect(edges.every((e) => e.type !== "status")).toBe(true);
});
it("4.5 Step with multiple transitions → conditional edges", () => {
it("4.5 Step with multiple transitions → status edges", () => {
const steps = [
makeStep("A", [
{ condition: null, target: "B" },
{ condition: "x>0", target: "C" },
{ status: "_", target: "B" },
{ status: "approved", target: "C" },
]),
makeStep("B", []),
makeStep("C", []),
@@ -69,23 +69,35 @@ describe("transIn", () => {
const { edges } = transIn(steps);
const nodeAId = edges.find((e) => e.source === "start")?.target;
const outEdges = edges.filter((e) => e.source === nodeAId);
expect(outEdges.every((e) => e.type === "conditional")).toBe(true);
// else-branch has empty condition
const elseEdge = outEdges.find(
(e) => (e as { data?: { condition?: string } }).data?.condition === "",
expect(outEdges.every((e) => e.type === "status")).toBe(true);
});
it("4.5b Multiple transitions include expected status values", () => {
const steps = [
makeStep("A", [
{ status: "_", target: "B" },
{ status: "approved", target: "C" },
]),
makeStep("B", []),
makeStep("C", []),
];
const { edges } = transIn(steps);
const nodeAId = edges.find((e) => e.source === "start")?.target;
const outEdges = edges.filter((e) => e.source === nodeAId);
const defaultEdge = outEdges.find(
(e) => (e as { data?: { status?: string } }).data?.status === "_",
);
expect(elseEdge).toBeDefined();
// if-branch has condition
const ifEdge = outEdges.find(
(e) => (e as { data?: { condition?: string } }).data?.condition === "x>0",
expect(defaultEdge).toBeDefined();
const approvedEdge = outEdges.find(
(e) => (e as { data?: { status?: string } }).data?.status === "approved",
);
expect(ifEdge).toBeDefined();
expect(approvedEdge).toBeDefined();
});
it("4.6 With 1 incoming edge: targetHandle = 'input'; with 2: first gets 'input'", () => {
const steps = [
makeStep("A", [{ condition: null, target: "END" }]),
makeStep("B", [{ condition: null, target: "END" }]),
makeStep("A", [{ status: "_", target: "END" }]),
makeStep("B", [{ status: "_", target: "END" }]),
];
const { edges } = transIn(steps);
// start→A and start→B; end has 2 incoming edges
@@ -95,8 +107,8 @@ describe("transIn", () => {
it("4.7 Same role name maps to same node id across steps", () => {
const steps = [
makeStep("A", [{ condition: null, target: "B" }]),
makeStep("B", [{ condition: null, target: "A" }]),
makeStep("A", [{ status: "_", target: "B" }]),
makeStep("B", [{ status: "_", target: "A" }]),
];
const { edges } = transIn(steps);
const aId = edges.find((e) => e.source === "start")?.target;
@@ -33,13 +33,13 @@ function defaultEdge(source: string, target: string): AnyWorkEdge {
return { id: `${source}-${target}`, source, target, animated: true } as AnyWorkEdge;
}
function conditionalEdge(source: string, target: string, condition: string): AnyWorkEdge {
function statusEdge(source: string, target: string, status: string): AnyWorkEdge {
return {
id: `${source}-${target}-cond`,
id: `${source}-${target}-status`,
source,
target,
type: "conditional" as const,
data: { condition },
type: "status" as const,
data: { status },
animated: true,
} as AnyWorkEdge;
}
@@ -76,36 +76,36 @@ describe("validateRoleNodes (via validate)", () => {
expect(nodeErrors.some((e) => e.message.includes("缺少输出连接"))).toBe(true);
});
it("5.3 Empty condition on non-first conditional edge → error", () => {
it("5.3 Empty status on status edge → error", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const n3 = roleNode("n3");
const nodes = baseNodes(n1, n2, n3);
const edges = [
defaultEdge("start", "n1"),
conditionalEdge("n1", "n2", ""), // else-branch (index 0) - exempt
conditionalEdge("n1", "n3", ""), // if-branch (index 1) - empty condition → error
statusEdge("n1", "n2", "_"),
statusEdge("n1", "n3", ""), // empty status → error
defaultEdge("n2", "end"),
defaultEdge("n3", "end"),
];
const result = validate(nodes, edges);
expect(result.errors.some((e) => e.message.includes("条件表达式不能为空"))).toBe(true);
expect(result.errors.some((e) => e.message.includes("状态值不能为空"))).toBe(true);
});
it("5.4 Mix of conditional and non-conditional outgoing → error", () => {
it("5.4 Mix of status and non-status outgoing → error", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const n3 = roleNode("n3");
const nodes = baseNodes(n1, n2, n3);
const edges = [
defaultEdge("start", "n1"),
conditionalEdge("n1", "n2", "x>0"),
statusEdge("n1", "n2", "approved"),
defaultEdge("n1", "n3"), // mix → error
defaultEdge("n2", "end"),
defaultEdge("n3", "end"),
];
const result = validate(nodes, edges);
expect(result.errors.some((e) => e.message.includes("所有出边必须附带条件"))).toBe(true);
expect(result.errors.some((e) => e.message.includes("所有出边必须附带状态"))).toBe(true);
});
it("5.5 Valid role node (1 in, 1 out default) → no errors for that node", () => {
@@ -118,15 +118,15 @@ describe("validateRoleNodes (via validate)", () => {
expect(roleErrors).toHaveLength(0);
});
it("5.6 Valid role node (1 in, 2 conditional out with conditions) → no errors", () => {
it("5.6 Valid role node (1 in, 2 status out with statuses) → no errors", () => {
const n1 = roleNode("n1");
const n2 = roleNode("n2");
const n3 = roleNode("n3");
const nodes = baseNodes(n1, n2, n3);
const edges = [
defaultEdge("start", "n1"),
conditionalEdge("n1", "n2", ""), // else-branch
conditionalEdge("n1", "n3", "x>0"), // if-branch
statusEdge("n1", "n2", "_"),
statusEdge("n1", "n3", "approved"),
defaultEdge("n2", "end"),
defaultEdge("n3", "end"),
];
@@ -1,4 +1,4 @@
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
import type { AnyWorkEdge, AnyWorkNode, StatusEdge } from "../type";
import { uuid } from "../utils";
import type { WorkFlowStep } from "./type";
@@ -9,6 +9,7 @@ type Result = {
const _OUT_HANDLES = ["output-top", "output", "output-bottom"] as const;
const IN_HANDLES = ["input-top", "input", "input-bottom"] as const;
const DEFAULT_STATUS = "_";
function assignHandles(
indices: number[],
@@ -50,8 +51,8 @@ function buildNodeMap(
function sortTransitions(step: WorkFlowStep): WorkFlowStep["transitions"] {
if (step.transitions.length <= 1) return step.transitions;
return [...step.transitions].sort((a, b) => {
if (a.condition === null && b.condition !== null) return -1;
if (a.condition !== null && b.condition === null) return 1;
if (a.status === DEFAULT_STATUS && b.status !== DEFAULT_STATUS) return -1;
if (a.status !== DEFAULT_STATUS && b.status === DEFAULT_STATUS) return 1;
return 0;
});
}
@@ -60,32 +61,32 @@ function buildStepEdges(
sourceId: string,
step: WorkFlowStep,
nameToId: Map<string, string>,
): { elseEdges: AnyWorkEdge[]; ifEdges: AnyWorkEdge[] } {
): { primaryEdges: AnyWorkEdge[]; statusEdges: AnyWorkEdge[] } {
const hasMultiple = step.transitions.length > 1;
const sorted = sortTransitions(step);
const elseEdges: AnyWorkEdge[] = [];
const ifEdges: AnyWorkEdge[] = [];
const primaryEdges: AnyWorkEdge[] = [];
const statusEdges: AnyWorkEdge[] = [];
for (let i = 0; i < sorted.length; i++) {
const t = sorted[i];
const targetId = nameToId.get(t.target);
if (!targetId) continue;
const edgeId = `e-${sourceId}-${targetId}-${i}`;
if (hasMultiple || t.condition !== null) {
const edge: ConditionalEdge = {
if (hasMultiple || t.status !== DEFAULT_STATUS) {
const edge: StatusEdge = {
id: edgeId,
source: sourceId,
target: targetId,
sourceHandle: "output",
targetHandle: "input",
type: "conditional",
data: { condition: t.condition ?? "" },
type: "status",
data: { status: t.status },
animated: true,
};
if (hasMultiple && i === 0) elseEdges.push(edge);
else ifEdges.push(edge);
if (hasMultiple && t.status === DEFAULT_STATUS) primaryEdges.push(edge);
else statusEdges.push(edge);
} else {
elseEdges.push({
primaryEdges.push({
id: edgeId,
source: sourceId,
target: targetId,
@@ -95,23 +96,23 @@ function buildStepEdges(
});
}
}
return { elseEdges, ifEdges };
return { primaryEdges, statusEdges };
}
function pushStepEdges(
edges: AnyWorkEdge[],
elseEdges: AnyWorkEdge[],
ifEdges: AnyWorkEdge[],
primaryEdges: AnyWorkEdge[],
statusEdges: AnyWorkEdge[],
idToOrder: Map<string, number>,
): void {
for (const e of elseEdges) edges.push({ ...e, sourceHandle: "output" });
if (ifEdges.length > 0) {
const ifHandles = ["output-top", "output-bottom"] as const;
const sorted = [...ifEdges].sort(
for (const e of primaryEdges) edges.push({ ...e, sourceHandle: "output" });
if (statusEdges.length > 0) {
const statusHandles = ["output-top", "output-bottom"] as const;
const sorted = [...statusEdges].sort(
(a, b) => (idToOrder.get(b.target) ?? 0) - (idToOrder.get(a.target) ?? 0),
);
for (let i = 0; i < sorted.length; i++) {
edges.push({ ...sorted[i], sourceHandle: ifHandles[i % ifHandles.length] });
edges.push({ ...sorted[i], sourceHandle: statusHandles[i % statusHandles.length] });
}
}
}
@@ -164,8 +165,8 @@ export function transIn(steps: WorkFlowStep[]): Result {
for (const step of steps) {
const sourceId = nameToId.get(step.role.name) ?? "";
const { elseEdges, ifEdges } = buildStepEdges(sourceId, step, nameToId);
pushStepEdges(edges, elseEdges, ifEdges, idToOrder);
const { primaryEdges, statusEdges } = buildStepEdges(sourceId, step, nameToId);
pushStepEdges(edges, primaryEdges, statusEdges, idToOrder);
}
assignTargetHandles(edges, idToOrder);
@@ -1,6 +1,8 @@
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge, WorkNode } from "../type";
import type { AnyWorkEdge, AnyWorkNode, StatusEdge, WorkNode } from "../type";
import type { WorkFlowStep, WorkFlowTransition } from "./type";
const DEFAULT_STATUS = "_";
export function transOut(nodes: AnyWorkNode[], edges: AnyWorkEdge[]): WorkFlowStep[] {
const nodeMap = new Map<string, AnyWorkNode>();
for (const node of nodes) {
@@ -43,7 +45,7 @@ function traverse(
const roleNode = node as WorkNode<"role">;
const outEdges = outgoingEdges.get(nodeId) ?? [];
const transitions: WorkFlowTransition[] = outEdges.map((edge, index) => {
const transitions: WorkFlowTransition[] = outEdges.map((edge) => {
const targetNode = nodeMap.get(edge.target);
const target =
edge.target === "end"
@@ -52,13 +54,12 @@ function traverse(
? (targetNode as WorkNode<"role">).data.name
: edge.target;
let condition: string | null = null;
if (edge.type === "conditional") {
const isElse = outEdges.length >= 2 && index === 0;
condition = isElse ? null : ((edge as ConditionalEdge).data?.condition ?? null);
}
const status =
edge.type === "status"
? ((edge as StatusEdge).data?.status ?? DEFAULT_STATUS)
: DEFAULT_STATUS;
return { target, condition };
return { target, status };
});
const { name, description, identity, prepare, execute, report } = roleNode.data;
@@ -1,4 +1,4 @@
import type { AnyWorkEdge, AnyWorkNode, ConditionalEdge } from "../type";
import type { AnyWorkEdge, AnyWorkNode, StatusEdge } from "../type";
export type ValidationError = {
nodeId: string | null;
@@ -91,10 +91,10 @@ function validateEndNode(
}
}
function hasEmptyConditionOnIfEdge(conditionalEdges: AnyWorkEdge[]): boolean {
return conditionalEdges.slice(1).some((edge) => {
const cond = (edge as ConditionalEdge).data?.condition?.trim();
return !cond;
function hasEmptyStatusOnEdge(statusEdges: AnyWorkEdge[]): boolean {
return statusEdges.some((edge) => {
const status = (edge as StatusEdge).data?.status?.trim();
return !status;
});
}
@@ -113,11 +113,11 @@ function validateRoleNodeEdges(
}
if (outEdges.length <= 1) return;
const conditionalEdges = outEdges.filter((e) => e.type === "conditional");
if (conditionalEdges.length !== outEdges.length) {
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" });
} else if (hasEmptyConditionOnIfEdge(conditionalEdges)) {
errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" });
const statusEdges = outEdges.filter((e) => e.type === "status");
if (statusEdges.length !== outEdges.length) {
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带状态" });
} else if (hasEmptyStatusOnEdge(statusEdges)) {
errors.push({ nodeId: node.id, message: "状态边的状态值不能为空" });
}
}
@@ -21,9 +21,9 @@ export type WorkNodeType = keyof NodeMap;
export type WorkNode<T extends WorkNodeType> = Node<NodeMap[T], T>;
export type AnyWorkNode = WorkNode<"start"> | WorkNode<"end"> | WorkNode<"role">;
export type ConditionalEdgeData = AnyKeyBase & {
condition: string;
export type StatusEdgeData = AnyKeyBase & {
status: string;
};
export type ConditionalEdge = Edge<ConditionalEdgeData, "conditional">;
export type AnyWorkEdge = ConditionalEdge | Edge;
export type StatusEdge = Edge<StatusEdgeData, "status">;
export type AnyWorkEdge = StatusEdge | Edge;
@@ -11,7 +11,7 @@ const DEFAULT_STEPS: WorkFlowSteps = [
execute: "制定详细的实施计划和步骤分解",
report: "输出结构化的计划文档,包含步骤列表和预期产出",
},
transitions: [{ target: "developer", condition: null }],
transitions: [{ target: "developer", status: "_" }],
},
{
role: {
@@ -22,7 +22,7 @@ const DEFAULT_STEPS: WorkFlowSteps = [
execute: "编写高质量的代码实现",
report: "输出变更文件列表和实现摘要",
},
transitions: [{ target: "reviewer", condition: null }],
transitions: [{ target: "reviewer", status: "_" }],
},
{
role: {
@@ -34,8 +34,8 @@ const DEFAULT_STEPS: WorkFlowSteps = [
report: "输出审查结果,包含 approved 状态和评审意见",
},
transitions: [
{ target: "END", condition: null },
{ target: "developer", condition: "steps[-1].output.approved = false" },
{ target: "END", status: "approved" },
{ target: "developer", status: "rejected" },
],
},
];
@@ -1,312 +1,122 @@
import { describe, expect, test } from "bun:test";
import type { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
import type { Target, WorkflowPayload } from "@uncaged/workflow-protocol";
import { evaluate } from "../src/evaluate.js";
const solveIssueWorkflow: WorkflowPayload = {
name: "solve-issue",
description: "End-to-end issue resolution",
roles: {
planner: {
description: "Creates implementation plan",
goal: "You are a planning agent.",
capabilities: ["planning"],
procedure: "Create a step-by-step plan.",
output: "Output the plan and steps.",
frontmatter: "5GWKR8TN1V3JA",
},
developer: {
description: "Implements code changes",
goal: "You are a developer agent.",
capabilities: ["coding"],
procedure: "Implement the plan.",
output: "List files changed and summary.",
frontmatter: "8CNWT4KR6D1HV",
},
reviewer: {
description: "Reviews code changes",
goal: "You are a code reviewer.",
capabilities: ["code-review"],
procedure: "Review the implementation.",
output: "Approve or reject with comments.",
frontmatter: "1VPBG9SM5E7WK",
},
const solveIssueGraph: WorkflowPayload["graph"] = {
$START: {
_: { role: "planner", prompt: "Start planning from the issue in the task." },
},
conditions: {
needsClarification: {
description: "Planner requests clarification from user",
expression: "$exists($last('planner').needsClarification)",
},
rejected: {
description: "Reviewer rejected the implementation",
expression: "$last('reviewer').approved = false",
},
planner: {
_: { role: "developer", prompt: "Implement the plan: {{plan}}" },
},
graph: {
$START: [
{
role: "planner",
condition: null,
prompt: "Start planning from the issue in the task.",
},
],
planner: [
{
role: "developer",
condition: "needsClarification",
prompt: "Clarification is needed; hand off to developer.",
},
{ role: "$END", condition: null, prompt: "Planning complete; end workflow." },
],
developer: [
{
role: "reviewer",
condition: null,
prompt: "Implementation done; send to reviewer.",
},
],
reviewer: [
{
role: "developer",
condition: "rejected",
prompt: "Reviewer rejected; return to developer.",
},
{ role: "$END", condition: null, prompt: "Review passed; end workflow." },
],
developer: {
_: { role: "reviewer", prompt: "Review the changes: {{summary}}" },
},
reviewer: {
approved: { role: "$END", prompt: "Done." },
rejected: { role: "developer", prompt: "Fix: {{comments}}" },
},
};
function makeContext(steps: ModeratorContext["steps"]): ModeratorContext {
return {
start: {
workflow: "4KNM2PXR3B1QW",
prompt: "Fix the login bug",
},
steps,
};
}
describe("evaluate", () => {
test("$START → first role (fallback)", async () => {
const result = await evaluate(solveIssueWorkflow, makeContext([]));
test("$START → first role (unit status _)", () => {
const result = evaluate(solveIssueGraph, "$START", { status: "_" });
expect(result).toEqual({
ok: true,
value: { role: "planner", prompt: "Start planning from the issue in the task." },
});
});
test("condition match (rejected → developer)", async () => {
const context = makeContext([
{
role: "reviewer",
output: { approved: false },
detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes",
},
]);
const result = await evaluate(solveIssueWorkflow, context);
test("status-based routing (reviewer rejected → developer)", () => {
const result = evaluate(solveIssueGraph, "reviewer", {
status: "rejected",
comments: "missing tests",
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Reviewer rejected; return to developer." },
value: { role: "developer", prompt: "Fix: missing tests" },
});
});
test("fallback when condition does not match → $END", async () => {
const context = makeContext([
{
role: "reviewer",
output: { approved: true },
detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes",
},
]);
const result = await evaluate(solveIssueWorkflow, context);
test("status-based routing (reviewer approved → $END)", () => {
const result = evaluate(solveIssueGraph, "reviewer", { status: "approved" });
expect(result).toEqual({
ok: true,
value: { role: "$END", prompt: "Review passed; end workflow." },
value: { role: "$END", prompt: "Done." },
});
});
test("missing role in graph → error", async () => {
const context = makeContext([
{
role: "unknown-role",
output: {},
detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes",
},
]);
const result = await evaluate(solveIssueWorkflow, context);
test("missing role in graph → error", () => {
const result = evaluate(solveIssueGraph, "unknown-role", { status: "_" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
}
});
test("output expansion in context works with JSONata", async () => {
const context = makeContext([
{
role: "planner",
output: { needsClarification: true },
detail: "7BQST3VW9F2MA",
agent: "uwf-hermes",
},
]);
const result = await evaluate(solveIssueWorkflow, context);
test("missing status in graph → error", () => {
const result = evaluate(solveIssueGraph, "reviewer", { status: "pending" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe('no transition for role "reviewer" with status "pending"');
}
});
test("mustache template rendering with simple fields", () => {
const result = evaluate(solveIssueGraph, "planner", {
status: "_",
plan: "Add auth middleware",
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "Clarification is needed; hand off to developer." },
value: { role: "developer", prompt: "Implement the plan: Add auth middleware" },
});
});
test("$last returns most recent matching role's frontmatter", async () => {
const workflow: WorkflowPayload = {
...solveIssueWorkflow,
conditions: {
devFailed: {
description: "Developer failed",
expression: "$last('developer').status = 'failed'",
},
},
graph: {
$START: [
{
role: "developer",
condition: null,
prompt: "Begin development.",
},
],
developer: [
{ role: "$END", condition: "devFailed", prompt: "Development failed; end." },
{
role: "reviewer",
condition: null,
prompt: "Development succeeded; review.",
},
],
},
};
const context = makeContext([
{
role: "developer",
output: { status: "done" },
detail: "1VPBG9SM5E7WK",
agent: "uwf-hermes",
},
{
role: "reviewer",
output: { approved: false },
detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes",
},
{
role: "developer",
output: { status: "failed" },
detail: "3QNTH7WK8D2PA",
agent: "uwf-hermes",
},
]);
const result = await evaluate(workflow, context);
test("mustache does not HTML-escape prompt content", () => {
const result = evaluate(solveIssueGraph, "reviewer", {
status: "rejected",
comments: 'use <T> & "Result<T, E>" types',
});
expect(result).toEqual({
ok: true,
value: { role: "$END", prompt: "Development failed; end." },
value: { role: "developer", prompt: 'Fix: use <T> & "Result<T, E>" types' },
});
});
test("$first returns earliest matching role's frontmatter", async () => {
const workflow: WorkflowPayload = {
...solveIssueWorkflow,
conditions: {
firstPlanReady: {
description: "First planner run was ready",
expression: "$first('planner').status = 'ready'",
},
},
graph: {
$START: [
{
role: "planner",
condition: null,
prompt: "Begin planning.",
},
],
planner: [
{ role: "$END", condition: "firstPlanReady", prompt: "First plan was ready; end." },
{
role: "developer",
condition: null,
prompt: "Plan not ready on first pass; implement.",
},
],
test("triple mustache also works for unescaped output", () => {
const graph: Record<string, Record<string, Target>> = {
reviewer: {
_: { role: "developer", prompt: "Fix: {{{comments}}}" },
},
};
const context = makeContext([
{
role: "planner",
output: { status: "ready", plan: "ABC123" },
detail: "7BQST3VW9F2MA",
agent: "uwf-hermes",
},
{
role: "developer",
output: { status: "done" },
detail: "1VPBG9SM5E7WK",
agent: "uwf-hermes",
},
{
role: "planner",
output: { status: "revised", plan: "DEF456" },
detail: "4RNMK6PX8B3WQ",
agent: "uwf-hermes",
},
]);
const result = await evaluate(workflow, context);
const result = evaluate(graph, "reviewer", {
status: "_",
comments: "<script>alert(1)</script>",
});
expect(result).toEqual({
ok: true,
value: { role: "$END", prompt: "First plan was ready; end." },
value: { role: "developer", prompt: "Fix: <script>alert(1)</script>" },
});
});
test("$last returns undefined for unmatched role", async () => {
const workflow: WorkflowPayload = {
...solveIssueWorkflow,
conditions: {
hasReviewer: {
description: "Reviewer has run",
expression: "$exists($last('reviewer'))",
test("mustache template with nested object paths", () => {
const graph: Record<string, Record<string, Target>> = {
reviewer: {
_: {
role: "developer",
prompt: "Address: {{review.comments}}",
},
},
graph: {
$START: [
{
role: "planner",
condition: null,
prompt: "Begin planning.",
},
],
planner: [
{ role: "$END", condition: "hasReviewer", prompt: "Reviewer already ran; end." },
{
role: "developer",
condition: null,
prompt: "No reviewer yet; implement.",
},
],
},
};
const context = makeContext([
{
role: "planner",
output: { status: "ready" },
detail: "7BQST3VW9F2MA",
agent: "uwf-hermes",
},
]);
const result = await evaluate(workflow, context);
// no reviewer step → $exists returns false → fallback to developer
const result = evaluate(graph, "reviewer", {
status: "_",
review: { comments: "refactor the handler" },
});
expect(result).toEqual({
ok: true,
value: { role: "developer", prompt: "No reviewer yet; implement." },
value: { role: "developer", prompt: "Address: refactor the handler" },
});
});
});
+2 -1
View File
@@ -19,9 +19,10 @@
},
"dependencies": {
"@uncaged/workflow-protocol": "workspace:^",
"jsonata": "^1.8.7"
"mustache": "^4.2.0"
},
"devDependencies": {
"@types/mustache": "^4.2.6",
"typescript": "^5.8.3"
},
"publishConfig": {
+31 -102
View File
@@ -1,65 +1,42 @@
import type { ModeratorContext, WorkflowPayload } from "@uncaged/workflow-protocol";
import jsonata from "jsonata";
import type { Target } from "@uncaged/workflow-protocol";
import mustache from "mustache";
import type { EvaluateResult, Result } from "./types.js";
// Disable HTML escaping — prompts are plain text, not HTML.
mustache.escape = (text: string) => text;
const START_ROLE = "$START";
const UNIT_STATUS = "_";
function isTruthy(value: unknown): boolean {
if (value === null || value === undefined) {
return false;
}
if (typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return value !== 0 && !Number.isNaN(value);
}
if (typeof value === "string") {
return value.length > 0;
}
return true;
}
type LastOutput = Record<string, unknown> & { status: string };
function findByRole(
steps: ModeratorContext["steps"],
role: string,
direction: "first" | "last",
): unknown {
if (direction === "last") {
for (let i = steps.length - 1; i >= 0; i--) {
if (steps[i].role === role) {
return steps[i].output;
}
}
} else {
for (const step of steps) {
if (step.role === role) {
return step.output;
}
}
}
return undefined;
}
export function evaluate(
graph: Record<string, Record<string, Target>>,
lastRole: string,
lastOutput: LastOutput,
): Result<EvaluateResult, Error> {
const status = lastRole === START_ROLE ? UNIT_STATUS : lastOutput.status;
const roleTargets = graph[lastRole];
if (roleTargets === undefined) {
return {
ok: false,
error: new Error(`no transitions defined for role "${lastRole}"`),
};
}
const target = roleTargets[status];
if (target === undefined) {
return {
ok: false,
error: new Error(`no transition for role "${lastRole}" with status "${status}"`),
};
}
async function evaluateJsonata(
expression: string,
context: ModeratorContext,
): Promise<Result<unknown, Error>> {
try {
const expr = jsonata(expression);
expr.registerFunction(
"first",
(role: string) => findByRole(context.steps, role, "first"),
"<s:x>",
);
expr.registerFunction(
"last",
(role: string) => findByRole(context.steps, role, "last"),
"<s:x>",
);
const result = await expr.evaluate(context);
return { ok: true, value: result };
const prompt = mustache.render(target.prompt, lastOutput);
return { ok: true, value: { role: target.role, prompt } };
} catch (error) {
return {
ok: false,
@@ -67,51 +44,3 @@ async function evaluateJsonata(
};
}
}
function currentRole(context: ModeratorContext): string {
if (context.steps.length === 0) {
return START_ROLE;
}
return context.steps[context.steps.length - 1].role;
}
export async function evaluate(
workflow: WorkflowPayload,
context: ModeratorContext,
): Promise<Result<EvaluateResult, Error>> {
const role = currentRole(context);
const transitions = workflow.graph[role];
if (transitions === undefined) {
return {
ok: false,
error: new Error(`no transitions defined for role "${role}"`),
};
}
for (const transition of transitions) {
if (transition.condition === null) {
return { ok: true, value: { role: transition.role, prompt: transition.prompt } };
}
const conditionDef = workflow.conditions[transition.condition];
if (conditionDef === undefined) {
return {
ok: false,
error: new Error(`unknown condition "${transition.condition}"`),
};
}
const evalResult = await evaluateJsonata(conditionDef.expression, context);
if (!evalResult.ok) {
return evalResult;
}
if (isTruthy(evalResult.value)) {
return { ok: true, value: { role: transition.role, prompt: transition.prompt } };
}
}
return {
ok: false,
error: new Error(`no transition matched for role "${role}"`),
};
}
+1 -2
View File
@@ -7,7 +7,6 @@ export type {
AgentAlias,
AgentConfig,
CasRef,
ConditionDefinition,
ModelAlias,
ModelConfig,
ModeratorContext,
@@ -26,12 +25,12 @@ export type {
StepNodePayload,
StepOutput,
StepRecord,
Target,
ThreadForkOutput,
ThreadId,
ThreadListItem,
ThreadStepsOutput,
ThreadsIndex,
Transition,
WorkflowConfig,
WorkflowName,
WorkflowPayload,
+5 -20
View File
@@ -14,22 +14,11 @@ const ROLE_DEFINITION: JSONSchema = {
additionalProperties: false,
};
const CONDITION_DEFINITION: JSONSchema = {
const TARGET: JSONSchema = {
type: "object",
required: ["description", "expression"],
properties: {
description: { type: "string" },
expression: { type: "string" },
},
additionalProperties: false,
};
const TRANSITION: JSONSchema = {
type: "object",
required: ["role", "condition", "prompt"],
required: ["role", "prompt"],
properties: {
role: { type: "string" },
condition: { anyOf: [{ type: "string" }, { type: "null" }] },
prompt: { type: "string" },
},
additionalProperties: false,
@@ -38,7 +27,7 @@ const TRANSITION: JSONSchema = {
export const WORKFLOW_SCHEMA: JSONSchema = {
title: "Workflow",
type: "object",
required: ["name", "description", "roles", "conditions", "graph"],
required: ["name", "description", "roles", "graph"],
properties: {
name: { type: "string" },
description: { type: "string" },
@@ -46,15 +35,11 @@ export const WORKFLOW_SCHEMA: JSONSchema = {
type: "object",
additionalProperties: ROLE_DEFINITION,
},
conditions: {
type: "object",
additionalProperties: CONDITION_DEFINITION,
},
graph: {
type: "object",
additionalProperties: {
type: "array",
items: TRANSITION,
type: "object",
additionalProperties: TARGET,
},
},
},
+2 -9
View File
@@ -27,23 +27,16 @@ export type RoleDefinition = {
frontmatter: CasRef;
};
export type Transition = {
export type Target = {
role: string;
condition: string | null;
prompt: string;
};
export type ConditionDefinition = {
description: string;
expression: string;
};
export type WorkflowPayload = {
name: string;
description: string;
roles: Record<string, RoleDefinition>;
conditions: Record<string, ConditionDefinition>;
graph: Record<string, Transition[]>;
graph: Record<string, Record<string, Target>>;
};
// ── 4.3 Thread 节点 ─────────────────────────────────────────────────