feat(dashboard): U8 — node editor authoring for foreach/step-review/parse-steps/code, rework edge inspector, template round-trip
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -307,6 +307,73 @@
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* ── Step-inversion nodes (KTD-3/4/12/15, U8) ── */
|
||||
|
||||
.wf-node-step-execute {
|
||||
border-color: var(--accent, var(--ws-info));
|
||||
}
|
||||
|
||||
.wf-node-step-review {
|
||||
border-color: var(--ws-info);
|
||||
}
|
||||
|
||||
.wf-node-parse-steps {
|
||||
border-color: var(--ws-info);
|
||||
}
|
||||
|
||||
.wf-node-code {
|
||||
border-color: var(--text-muted);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
/* A foreach renders as a React Flow group node containing its template
|
||||
* subgraph. Children are positioned inside the group's box. */
|
||||
.wf-foreach-group {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed var(--accent, var(--ws-info));
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--accent, var(--ws-info)) 6%, transparent);
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.wf-foreach-group.wf-node--error {
|
||||
border-color: var(--ws-error);
|
||||
}
|
||||
|
||||
.wf-foreach-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.wf-foreach-empty {
|
||||
margin-top: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Rework edges (KTD-5): dashed accent stroke with a loop affordance. */
|
||||
.wf-edge-rework .react-flow__edge-path {
|
||||
stroke: var(--accent, var(--ws-info));
|
||||
stroke-dasharray: 5 4;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.wf-code-source {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.72rem;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.wf-node-icon {
|
||||
display: inline-flex;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type Edge as FlowEdge,
|
||||
} from "@xyflow/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge } from "lucide-react";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2 } from "lucide-react";
|
||||
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
@@ -46,6 +46,12 @@ import {
|
||||
validateColumnsClient,
|
||||
unplacedNodeIds,
|
||||
isColumnBandNode,
|
||||
foreachChildFlowId,
|
||||
shortConditionLabel,
|
||||
FOREACH_GROUP_WIDTH,
|
||||
FOREACH_GROUP_HEIGHT,
|
||||
FOREACH_CHILD_X,
|
||||
FOREACH_CHILD_Y,
|
||||
} from "./workflow-flow-mapping";
|
||||
import { fetchTraits, type TraitCatalogEntry } from "../api";
|
||||
import { WorkflowColumnPanel } from "./WorkflowColumnPanel";
|
||||
@@ -84,6 +90,14 @@ function newNodeId(): string {
|
||||
return `n-${Date.now().toString(36)}-${nodeSeq}`;
|
||||
}
|
||||
|
||||
/** Built-in step parsers (KTD-12). Hardcoded for now; TODO: source from the live
|
||||
* parser registry once a catalog endpoint exists (incl. plugin parsers). */
|
||||
const BUILTIN_STEP_PARSERS = ["step-headings", "json-steps"] as const;
|
||||
|
||||
/** Step-review verdict outcomes (KTD-4), authored as `outcome:<verdict>` edge
|
||||
* conditions and displayed as short labels. */
|
||||
const STEP_REVIEW_VERDICTS = ["approve", "revise", "rethink", "unavailable"] as const;
|
||||
|
||||
const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof MessageSquare; presetConfig?: Record<string, unknown> }> = [
|
||||
{ kind: "prompt", label: "Prompt", icon: MessageSquare },
|
||||
{ kind: "prompt", label: "User input", icon: HelpCircle, presetConfig: { awaitInput: true } },
|
||||
@@ -93,6 +107,11 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof
|
||||
{ kind: "hold", label: "Hold", icon: PauseCircle, presetConfig: { release: "manual" } },
|
||||
{ kind: "split", label: "Split", icon: Split },
|
||||
{ kind: "join", label: "Join", icon: Merge, presetConfig: { mode: "all", onBranchFailure: "collect" } },
|
||||
// Step-inversion (KTD-3/4/12/15).
|
||||
{ kind: "foreach", label: "For-each step", icon: Repeat, presetConfig: { source: "task-steps" } },
|
||||
{ kind: "step-review", label: "Step review", icon: ClipboardCheck, presetConfig: { type: "code" } },
|
||||
{ kind: "parse-steps", label: "Parse steps", icon: ListChecks, presetConfig: { artifact: "PROMPT.md", parser: "step-headings" } },
|
||||
{ kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } },
|
||||
];
|
||||
|
||||
function InnerEditor({
|
||||
@@ -109,6 +128,7 @@ function InnerEditor({
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<FlowNode<WorkflowFlowNodeData>>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<FlowEdge>([]);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
const { t } = useTranslation("app");
|
||||
// v2 columns the editor is authoring for the active workflow.
|
||||
const [columns, setColumns] = useState<WorkflowIrColumn[]>([]);
|
||||
@@ -172,6 +192,7 @@ function InnerEditor({
|
||||
setEdges(flow.edges);
|
||||
setColumns(columnsOf(activeWorkflow));
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
setValidationError(null);
|
||||
}, [activeWorkflow, setNodes, setEdges]);
|
||||
|
||||
@@ -219,6 +240,41 @@ function InnerEditor({
|
||||
const label = nodeLabel ?? (kind === "merge" ? "Merge boundary" : kind.charAt(0).toUpperCase() + kind.slice(1));
|
||||
const baseConfig = kind === "gate" ? { gateMode: "gate" } : {};
|
||||
const config = presetConfig ? { ...baseConfig, ...presetConfig } : baseConfig;
|
||||
|
||||
if (kind === "foreach") {
|
||||
// A foreach renders as a React Flow group node. It auto-populates ONE
|
||||
// step-execute child (a prompt node with seam=step-execute) so the group
|
||||
// is never confusingly empty (KTD-3 / U8). The group node must precede
|
||||
// its child in the array for React Flow's parent extent to apply.
|
||||
const childId = foreachChildFlowId(id, newNodeId());
|
||||
setNodes((ns) => [
|
||||
...ns,
|
||||
{
|
||||
id,
|
||||
type: "foreach",
|
||||
position: { x: 200 + ns.length * 40, y: 240 + (ns.length % 3) * 70 },
|
||||
data: { kind: "foreach", label, config, templateEmpty: false },
|
||||
style: { width: FOREACH_GROUP_WIDTH, height: FOREACH_GROUP_HEIGHT },
|
||||
deletable: true,
|
||||
},
|
||||
{
|
||||
id: childId,
|
||||
type: "prompt",
|
||||
position: { x: FOREACH_CHILD_X, y: FOREACH_CHILD_Y },
|
||||
parentId: id,
|
||||
extent: "parent",
|
||||
data: {
|
||||
kind: "prompt",
|
||||
label: t("workflowNodes.stepExecuteLabel", "Step execute"),
|
||||
config: { seam: "step-execute" },
|
||||
},
|
||||
deletable: true,
|
||||
},
|
||||
]);
|
||||
setSelectedNodeId(id);
|
||||
return;
|
||||
}
|
||||
|
||||
setNodes((ns) => [
|
||||
...ns,
|
||||
{
|
||||
@@ -231,7 +287,7 @@ function InnerEditor({
|
||||
]);
|
||||
setSelectedNodeId(id);
|
||||
},
|
||||
[setNodes],
|
||||
[setNodes, t],
|
||||
);
|
||||
|
||||
const updateSelectedData = useCallback(
|
||||
@@ -269,6 +325,30 @@ function InnerEditor({
|
||||
[selectedNodeId, setNodes],
|
||||
);
|
||||
|
||||
// Edge inspector (KTD-4/5): mutate the selected edge's condition + rework
|
||||
// kind, keeping its display label in sync. Rework edges render dashed/animated.
|
||||
const updateSelectedEdge = useCallback(
|
||||
(patch: { condition?: string; rework?: boolean }) => {
|
||||
if (!selectedEdgeId) return;
|
||||
setEdges((eds) =>
|
||||
eds.map((e) => {
|
||||
if (e.id !== selectedEdgeId) return e;
|
||||
const condition = patch.condition ?? (e.data?.condition as string | undefined) ?? "success";
|
||||
const rework = patch.rework ?? (e.data?.kind as string | undefined) === "rework";
|
||||
return {
|
||||
...e,
|
||||
label: rework ? `${shortConditionLabel(condition)} (rework)` : shortConditionLabel(condition),
|
||||
data: { ...(e.data ?? {}), condition, kind: rework ? "rework" : undefined },
|
||||
type: rework ? "step" : undefined,
|
||||
animated: rework,
|
||||
className: rework ? "wf-edge-rework" : undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[selectedEdgeId, setEdges],
|
||||
);
|
||||
|
||||
const handleCreateWorkflow = useCallback(async () => {
|
||||
const name = window.prompt("New workflow name");
|
||||
if (!name?.trim()) return;
|
||||
@@ -383,16 +463,54 @@ function InnerEditor({
|
||||
// (WorkflowNodeErrorBadge) renders both, keyed off data.errorBadge.
|
||||
const nodesForRender = useMemo(() => {
|
||||
const unplacedSet = new Set(unplaced);
|
||||
// Count current template children per foreach group so the empty-state hint
|
||||
// (KTD-3 / U8) reflects live deletions even though the palette seeds one.
|
||||
const childCount = new Map<string, number>();
|
||||
for (const n of nodes) {
|
||||
if (n.parentId) childCount.set(n.parentId, (childCount.get(n.parentId) ?? 0) + 1);
|
||||
}
|
||||
const emptyHint = t("workflowNodes.foreachEmptyHint", "Drag a step-execute node here");
|
||||
return nodes.map((n) => {
|
||||
let errorBadge: string | undefined;
|
||||
if (unplacedSet.has(n.id)) errorBadge = t("workflowColumns.nodeUnplaced", "Not placed in a column");
|
||||
if (serverNodeError?.nodeId === n.id) errorBadge = serverNodeError.message;
|
||||
if (errorBadge === n.data.errorBadge) return n;
|
||||
return { ...n, data: { ...n.data, errorBadge } };
|
||||
const templateEmpty = n.data.kind === "foreach" ? (childCount.get(n.id) ?? 0) === 0 : undefined;
|
||||
if (
|
||||
errorBadge === n.data.errorBadge &&
|
||||
(n.data.kind !== "foreach" || (templateEmpty === n.data.templateEmpty && n.data.emptyHint === emptyHint))
|
||||
)
|
||||
return n;
|
||||
return {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
errorBadge,
|
||||
...(n.data.kind === "foreach" ? { templateEmpty, emptyHint } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [nodes, unplaced, serverNodeError, t]);
|
||||
|
||||
const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null;
|
||||
const selectedEdge = edges.find((e) => e.id === selectedEdgeId) ?? null;
|
||||
// The edge inspector's verdict/rework controls apply only when the edge's
|
||||
// source node is a step-review node (KTD-4).
|
||||
const selectedEdgeSourceIsReview = useMemo(() => {
|
||||
if (!selectedEdge) return false;
|
||||
const src = nodes.find((n) => n.id === selectedEdge.source);
|
||||
return src?.data.kind === "step-review";
|
||||
}, [selectedEdge, nodes]);
|
||||
|
||||
// Artifacts the active workflow declares (KTD-12). The parse-steps inspector
|
||||
// offers a select over these; when none are declared it falls back to a
|
||||
// free-text input defaulting to PROMPT.md.
|
||||
const declaredArtifacts = useMemo(() => {
|
||||
const ir = activeWorkflow?.ir;
|
||||
if (ir && ir.version === "v2" && Array.isArray(ir.artifacts)) {
|
||||
return ir.artifacts.map((a) => a.key);
|
||||
}
|
||||
return [];
|
||||
}, [activeWorkflow]);
|
||||
|
||||
// Lazy-loaded executor resources
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
@@ -402,6 +520,13 @@ function InnerEditor({
|
||||
const currentExecutor = (selectedNode?.data.config?.executor as ExecutorKind | undefined) ?? "model";
|
||||
|
||||
useEffect(() => {
|
||||
// step-review offers an optional review model picker (KTD-4).
|
||||
if (selectedNode?.data.kind === "step-review" && models.length === 0) {
|
||||
fetchModels().then((res) => setModels(res.models)).catch((err) => {
|
||||
addToast(getErrorMessage(err) || "Failed to load models", "error");
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!selectedNode || (selectedNode.data.kind !== "prompt" && selectedNode.data.kind !== "gate")) return;
|
||||
if (currentExecutor === "model" && models.length === 0) {
|
||||
fetchModels().then((res) => setModels(res.models)).catch((err) => {
|
||||
@@ -527,8 +652,18 @@ function InnerEditor({
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
|
||||
onPaneClick={() => setSelectedNodeId(null)}
|
||||
onNodeClick={(_, node) => {
|
||||
setSelectedNodeId(node.id);
|
||||
setSelectedEdgeId(null);
|
||||
}}
|
||||
onEdgeClick={(_, edge) => {
|
||||
setSelectedEdgeId(edge.id);
|
||||
setSelectedNodeId(null);
|
||||
}}
|
||||
onPaneClick={() => {
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
}}
|
||||
fitView
|
||||
>
|
||||
<Background />
|
||||
@@ -837,6 +972,258 @@ function InnerEditor({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{selectedNode.data.kind === "foreach" ? (
|
||||
(() => {
|
||||
const mode = String(selectedNode.data.config?.mode ?? "sequential");
|
||||
const isParallel = mode === "parallel";
|
||||
return (
|
||||
<>
|
||||
<label className="wf-field">
|
||||
<span>{t("workflowNodes.foreachMode", "Mode")}</span>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
// parallel+shared is rejected by the validator; flip
|
||||
// isolation to worktree when switching to parallel.
|
||||
updateSelectedData({
|
||||
config: (prev) => ({
|
||||
...prev,
|
||||
mode: v,
|
||||
...(v === "parallel" && prev.isolation === "shared"
|
||||
? { isolation: "worktree" }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="sequential">{t("workflowNodes.foreachSequential", "Sequential")}</option>
|
||||
<option value="parallel">{t("workflowNodes.foreachParallel", "Parallel")}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="wf-field">
|
||||
<span>{t("workflowNodes.foreachIsolation", "Isolation")}</span>
|
||||
<select
|
||||
value={String(
|
||||
selectedNode.data.config?.isolation ?? (isParallel ? "worktree" : "shared"),
|
||||
)}
|
||||
onChange={(e) => updateSelectedData({ config: { isolation: e.target.value } })}
|
||||
>
|
||||
<option value="shared" disabled={isParallel}>
|
||||
{t("workflowNodes.foreachShared", "Shared worktree")}
|
||||
</option>
|
||||
<option value="worktree">{t("workflowNodes.foreachWorktree", "Per-step worktree")}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{isParallel && (
|
||||
<label className="wf-field">
|
||||
<span>{t("workflowNodes.foreachConcurrency", "Concurrency")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={8}
|
||||
placeholder="2"
|
||||
value={
|
||||
selectedNode.data.config?.concurrency != null
|
||||
? String(selectedNode.data.config.concurrency)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.trim();
|
||||
if (val === "") {
|
||||
updateSelectedData({
|
||||
config: (prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.concurrency;
|
||||
return next;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const num = parseInt(val, 10);
|
||||
if (!isNaN(num)) updateSelectedData({ config: { concurrency: num } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="wf-field">
|
||||
<span>{t("workflowNodes.foreachMaxRework", "Max rework cycles")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
placeholder="3"
|
||||
value={
|
||||
selectedNode.data.config?.maxReworkCycles != null
|
||||
? String(selectedNode.data.config.maxReworkCycles)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.trim();
|
||||
if (val === "") {
|
||||
updateSelectedData({
|
||||
config: (prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.maxReworkCycles;
|
||||
return next;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const num = parseInt(val, 10);
|
||||
if (!isNaN(num)) updateSelectedData({ config: { maxReworkCycles: num } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p className="wf-inspector-note wf-inspector-note--info">
|
||||
{t(
|
||||
"workflowNodes.foreachNote",
|
||||
"Expands once per planned step. Drop a step-execute node (and optional step-review) into the region.",
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : null}
|
||||
|
||||
{selectedNode.data.kind === "step-review" ? (
|
||||
<>
|
||||
<label className="wf-field">
|
||||
<span>{t("workflowNodes.reviewType", "Review type")}</span>
|
||||
<select
|
||||
value={String(selectedNode.data.config?.type ?? "code")}
|
||||
onChange={(e) => updateSelectedData({ config: { type: e.target.value } })}
|
||||
>
|
||||
<option value="plan">{t("workflowNodes.reviewPlan", "Plan review")}</option>
|
||||
<option value="code">{t("workflowNodes.reviewCode", "Code review")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="wf-field">
|
||||
<span>{t("workflowNodes.reviewModel", "Review model (optional)")}</span>
|
||||
<CustomModelDropdown
|
||||
label={t("workflowNodes.reviewModel", "Review model (optional)")}
|
||||
models={models}
|
||||
value={getModelDropdownValue(
|
||||
String(selectedNode.data.config?.modelProvider ?? ""),
|
||||
String(selectedNode.data.config?.modelId ?? ""),
|
||||
)}
|
||||
onChange={(value) => {
|
||||
const { provider, modelId } = parseModelDropdownValue(value);
|
||||
updateSelectedData({
|
||||
config: {
|
||||
modelProvider: provider || undefined,
|
||||
modelId: modelId || undefined,
|
||||
model: value || undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p className="wf-inspector-note wf-inspector-note--info">
|
||||
{t(
|
||||
"workflowNodes.reviewNote",
|
||||
"Verdicts route as outcome edges. Click an outgoing edge to set its verdict and rework behavior.",
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{selectedNode.data.kind === "parse-steps" ? (
|
||||
<>
|
||||
{declaredArtifacts.length > 0 ? (
|
||||
<label className="wf-field">
|
||||
<span>{t("workflowNodes.parseArtifact", "Artifact")}</span>
|
||||
<select
|
||||
value={String(selectedNode.data.config?.artifact ?? declaredArtifacts[0])}
|
||||
onChange={(e) => updateSelectedData({ config: { artifact: e.target.value } })}
|
||||
>
|
||||
{declaredArtifacts.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<label className="wf-field">
|
||||
<span>{t("workflowNodes.parseArtifact", "Artifact")}</span>
|
||||
<input
|
||||
placeholder="PROMPT.md"
|
||||
value={String(selectedNode.data.config?.artifact ?? "PROMPT.md")}
|
||||
onChange={(e) => updateSelectedData({ config: { artifact: e.target.value } })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="wf-field">
|
||||
<span>{t("workflowNodes.parseParser", "Parser")}</span>
|
||||
{/* TODO: source from the live parser registry (incl. plugin parsers). */}
|
||||
<select
|
||||
value={String(selectedNode.data.config?.parser ?? "step-headings")}
|
||||
onChange={(e) => updateSelectedData({ config: { parser: e.target.value } })}
|
||||
>
|
||||
{BUILTIN_STEP_PARSERS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{selectedNode.data.kind === "code" ? (
|
||||
<>
|
||||
<label className="wf-field">
|
||||
<span>{t("workflowNodes.codeSource", "Source (TypeScript)")}</span>
|
||||
<textarea
|
||||
className="wf-code-source"
|
||||
rows={8}
|
||||
spellCheck={false}
|
||||
placeholder={"export default async (ctx) => ({ outcome: \"success\" });"}
|
||||
value={String(selectedNode.data.config?.source ?? "")}
|
||||
onChange={(e) => updateSelectedData({ config: { source: e.target.value } })}
|
||||
/>
|
||||
</label>
|
||||
<label className="wf-field">
|
||||
<span>{t("workflowNodes.codeTimeout", "Timeout (ms)")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="30000"
|
||||
value={
|
||||
selectedNode.data.config?.timeoutMs != null
|
||||
? String(selectedNode.data.config.timeoutMs)
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.trim();
|
||||
if (val === "") {
|
||||
updateSelectedData({
|
||||
config: (prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.timeoutMs;
|
||||
return next;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const num = parseInt(val, 10);
|
||||
if (!isNaN(num)) updateSelectedData({ config: { timeoutMs: num } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p className="wf-inspector-note wf-inspector-note--info">
|
||||
{t(
|
||||
"workflowNodes.codeNote",
|
||||
"Runs sandboxed TypeScript. Syntax is validated at save.",
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{selectedNode.data.kind === "prompt" ||
|
||||
selectedNode.data.kind === "gate" ||
|
||||
selectedNode.data.kind === "script" ? (
|
||||
@@ -866,6 +1253,62 @@ function InnerEditor({
|
||||
</fieldset>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{selectedEdge && (
|
||||
<aside className="wf-editor-inspector" data-testid="wf-edge-inspector">
|
||||
<h3>{t("workflowNodes.edgeInspector", "Edge")}</h3>
|
||||
<fieldset className="wf-inspector-fields" disabled={isBuiltin}>
|
||||
{selectedEdgeSourceIsReview ? (
|
||||
<>
|
||||
<label className="wf-field">
|
||||
<span>{t("workflowNodes.edgeVerdict", "Review verdict")}</span>
|
||||
<select
|
||||
data-testid="wf-edge-verdict"
|
||||
value={(() => {
|
||||
const c = String(selectedEdge.data?.condition ?? "success");
|
||||
return c.startsWith("outcome:") ? c.slice("outcome:".length) : "";
|
||||
})()}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
updateSelectedEdge({ condition: v ? `outcome:${v}` : "success" });
|
||||
}}
|
||||
>
|
||||
<option value="">{t("workflowNodes.edgeNoVerdict", "— success (no verdict) —")}</option>
|
||||
{STEP_REVIEW_VERDICTS.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="wf-field wf-field--checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="wf-edge-rework"
|
||||
checked={(selectedEdge.data?.kind as string | undefined) === "rework"}
|
||||
onChange={(e) => updateSelectedEdge({ rework: e.target.checked })}
|
||||
/>
|
||||
<span>{t("workflowNodes.edgeRework", "Rework edge (loop back, bounded)")}</span>
|
||||
</label>
|
||||
<p className="wf-inspector-note wf-inspector-note--info">
|
||||
{t(
|
||||
"workflowNodes.edgeReworkNote",
|
||||
"Rework edges are the only legal cycles — they loop back within the for-each step instance, bounded by Max rework cycles.",
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="wf-inspector-note">
|
||||
{t(
|
||||
"workflowNodes.edgeConditionLabel",
|
||||
"Condition: {{condition}}",
|
||||
{ condition: String(selectedEdge.data?.condition ?? "success") },
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock("../../api", () => ({
|
||||
}));
|
||||
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import { fetchWorkflows, fetchTraits, updateWorkflow, compileWorkflow, createWorkflow } from "../../api";
|
||||
import { fetchWorkflows, fetchTraits, updateWorkflow, compileWorkflow, createWorkflow, fetchModels } from "../../api";
|
||||
import type { TraitCatalogEntry } from "../../api";
|
||||
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
|
||||
|
||||
@@ -255,3 +255,221 @@ describe("WorkflowNodeEditor — U10 columns/traits/holds", () => {
|
||||
expect((updates as { ir: { columns: unknown[] } }).ir.columns).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── U8: step-inversion authoring (foreach/step-review/parse-steps/code) ──────
|
||||
|
||||
/** A custom v2 workflow with a foreach (one step-execute child + a step-review)
|
||||
* so the editor's group/template + edge inspector surfaces have something to
|
||||
* render and round-trip. */
|
||||
function stepwiseDef(): WorkflowDefinition {
|
||||
return {
|
||||
id: "WF-STEP",
|
||||
name: "Stepwise",
|
||||
description: "",
|
||||
ir: {
|
||||
version: "v2",
|
||||
name: "Stepwise",
|
||||
columns: [
|
||||
{ id: "plan", name: "Plan", traits: [{ trait: "intake" }] },
|
||||
{ id: "in-progress", name: "In progress", traits: [] },
|
||||
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
artifacts: [{ key: "PROMPT.md", role: "step-source" }],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "plan" },
|
||||
{ id: "parse", kind: "parse-steps", column: "plan", config: { artifact: "PROMPT.md", parser: "step-headings" } },
|
||||
{
|
||||
id: "loop",
|
||||
kind: "foreach",
|
||||
column: "in-progress",
|
||||
config: {
|
||||
source: "task-steps",
|
||||
mode: "sequential",
|
||||
isolation: "shared",
|
||||
template: {
|
||||
nodes: [
|
||||
{ id: "exec", kind: "prompt", config: { seam: "step-execute" } },
|
||||
{ id: "review", kind: "step-review", config: { type: "code" } },
|
||||
],
|
||||
edges: [
|
||||
{ from: "exec", to: "review", condition: "success" },
|
||||
{ from: "review", to: "exec", condition: "outcome:approve" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "parse", condition: "success" },
|
||||
{ from: "parse", to: "loop", condition: "success" },
|
||||
{ from: "loop", to: "end", condition: "success" },
|
||||
],
|
||||
},
|
||||
layout: {},
|
||||
createdAt: "2026-06-04T00:00:00.000Z",
|
||||
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("offers the new step-inversion palette entries (i18n defaults present)", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByText("Save");
|
||||
expect(screen.getByText("For-each step")).toBeInTheDocument();
|
||||
expect(screen.getByText("Step review")).toBeInTheDocument();
|
||||
expect(screen.getByText("Parse steps")).toBeInTheDocument();
|
||||
expect(screen.getByText("Code")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("auto-populates a step-execute child when a foreach is added from the palette", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...v2Def(), ...(updates as object) }));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByText("Save");
|
||||
// Adding a foreach renders a group node with an empty inspector hint absent
|
||||
// (it has a child) and an inspector for the foreach.
|
||||
fireEvent.click(screen.getByText("For-each step").closest("button")!);
|
||||
await waitFor(() => expect(screen.getByTestId("wf-node-foreach")).toBeInTheDocument());
|
||||
// The foreach inspector shows the Mode select (KTD-3).
|
||||
expect(screen.getByText("Mode")).toBeInTheDocument();
|
||||
// No empty-state hint because the palette seeded a step-execute child.
|
||||
expect(screen.queryByTestId("wf-foreach-empty")).not.toBeInTheDocument();
|
||||
|
||||
// Save and assert the foreach round-trips with exactly one step-execute child.
|
||||
await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
|
||||
const ir = (updates as { ir: { nodes: { kind: string; config?: Record<string, unknown> }[] } }).ir;
|
||||
const foreach = ir.nodes.find((n) => n.kind === "foreach");
|
||||
expect(foreach).toBeTruthy();
|
||||
const template = foreach!.config!.template as { nodes: { config?: Record<string, unknown> }[] };
|
||||
expect(template.nodes).toHaveLength(1);
|
||||
expect(template.nodes[0].config?.seam).toBe("step-execute");
|
||||
});
|
||||
|
||||
it("edits foreach mode/isolation/concurrency/maxReworkCycles inspector fields", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([stepwiseDef()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
const group = await screen.findByTestId("wf-node-foreach");
|
||||
fireEvent.click(group);
|
||||
|
||||
const modeSel = (await screen.findByText("Mode")).parentElement!.querySelector("select")!;
|
||||
// Switching to parallel flips isolation away from the (now disabled) shared
|
||||
// option and reveals the concurrency input.
|
||||
fireEvent.change(modeSel, { target: { value: "parallel" } });
|
||||
await waitFor(() => expect(screen.getByText("Concurrency")).toBeInTheDocument());
|
||||
const isoSel = screen.getByText("Isolation").parentElement!.querySelector("select")! as HTMLSelectElement;
|
||||
expect(isoSel.value).toBe("worktree");
|
||||
const sharedOpt = isoSel.querySelector('option[value="shared"]') as HTMLOptionElement;
|
||||
expect(sharedOpt.disabled).toBe(true);
|
||||
|
||||
const maxRework = screen.getByText("Max rework cycles").parentElement!.querySelector("input")!;
|
||||
fireEvent.change(maxRework, { target: { value: "5" } });
|
||||
expect((maxRework as HTMLInputElement).value).toBe("5");
|
||||
});
|
||||
|
||||
it("edits step-review type and shows the verdict edge inspector with a rework toggle", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([stepwiseDef()]);
|
||||
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
// Select the step-review template child.
|
||||
const reviewNode = await screen.findByTestId("wf-node-step-review");
|
||||
fireEvent.click(reviewNode);
|
||||
const typeSel = (await screen.findByText("Review type")).parentElement!.querySelector("select")! as HTMLSelectElement;
|
||||
expect(typeSel.value).toBe("code");
|
||||
fireEvent.change(typeSel, { target: { value: "plan" } });
|
||||
expect(typeSel.value).toBe("plan");
|
||||
});
|
||||
|
||||
it("round-trips a rework edge created/removed via the edge inspector contract", () => {
|
||||
// React Flow does not render edges under jsdom (it needs measured node
|
||||
// dimensions), so the in-browser edge-click path is exercised at the mapping
|
||||
// level: the edge inspector's only effect is to stamp `data.kind` (rework)
|
||||
// and the `outcome:<verdict>` condition onto the selected flow edge; flowToIr
|
||||
// must fold that into the foreach template as kind:"rework". (The full
|
||||
// template round-trip — including rework edges — is covered in
|
||||
// workflow-flow-mapping.test.ts.)
|
||||
const def = stepwiseDef();
|
||||
const { nodes, edges } = irToFlow(def);
|
||||
const columns = def.ir.version === "v2" ? def.ir.columns : [];
|
||||
|
||||
// Simulate the edge inspector toggling the review→exec edge to rework.
|
||||
const reworked = edges.map((e) =>
|
||||
e.source.endsWith("::review") && e.target.endsWith("::exec")
|
||||
? { ...e, data: { ...(e.data ?? {}), condition: "outcome:approve", kind: "rework" } }
|
||||
: e,
|
||||
);
|
||||
const { ir: out } = flowToIr("Stepwise", nodes, reworked, columns);
|
||||
const foreach = out.nodes.find((n) => n.kind === "foreach")!;
|
||||
const template = foreach.config!.template as { edges: { condition?: string; kind?: string }[] };
|
||||
expect(template.edges.find((e) => e.condition === "outcome:approve")?.kind).toBe("rework");
|
||||
|
||||
// Removing rework (toggle off) drops the kind on round-trip.
|
||||
const cleared = edges.map((e) =>
|
||||
e.source.endsWith("::review") && e.target.endsWith("::exec")
|
||||
? { ...e, data: { ...(e.data ?? {}), condition: "outcome:approve", kind: undefined } }
|
||||
: e,
|
||||
);
|
||||
const { ir: out2 } = flowToIr("Stepwise", nodes, cleared, columns);
|
||||
const fe2 = out2.nodes.find((n) => n.kind === "foreach")!;
|
||||
const tpl2 = fe2.config!.template as { edges: { condition?: string; kind?: string }[] };
|
||||
expect(tpl2.edges.find((e) => e.condition === "outcome:approve")?.kind).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces a parseWorkflowIr validation error inline at save (unrouted approve edge)", async () => {
|
||||
const addToast = vi.fn();
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([stepwiseDef()]);
|
||||
vi.mocked(updateWorkflow).mockRejectedValue(
|
||||
new Error("step-review node 'review' must route outcome:revise"),
|
||||
);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={addToast} />);
|
||||
await screen.findByText("Save");
|
||||
await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
// Validation banner renders the server error inline.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/must route outcome:revise/i)).toBeInTheDocument(),
|
||||
);
|
||||
expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/must route outcome:revise/i), "error");
|
||||
});
|
||||
|
||||
it("edits parse-steps artifact (from declared artifacts) and parser", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([stepwiseDef()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
const parseNode = await screen.findByTestId("wf-node-parse-steps");
|
||||
fireEvent.click(parseNode);
|
||||
const artifactSel = (await screen.findByText("Artifact")).parentElement!.querySelector("select")! as HTMLSelectElement;
|
||||
// Sourced from the workflow's declared artifacts.
|
||||
expect(artifactSel.value).toBe("PROMPT.md");
|
||||
const parserSel = screen.getByText("Parser").parentElement!.querySelector("select")! as HTMLSelectElement;
|
||||
fireEvent.change(parserSel, { target: { value: "json-steps" } });
|
||||
expect(parserSel.value).toBe("json-steps");
|
||||
});
|
||||
|
||||
it("edits a code node source and timeout", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByText("Save");
|
||||
fireEvent.click(screen.getByText("Code").closest("button")!);
|
||||
const source = (await screen.findByText("Source (TypeScript)")).parentElement!.querySelector("textarea")! as HTMLTextAreaElement;
|
||||
fireEvent.change(source, { target: { value: "export default async()=>({outcome:'success'})" } });
|
||||
expect(source.value).toContain("outcome:'success'");
|
||||
const timeout = screen.getByText("Timeout (ms)").parentElement!.querySelector("input")! as HTMLInputElement;
|
||||
fireEvent.change(timeout, { target: { value: "12000" } });
|
||||
expect(timeout.value).toBe("12000");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
isColumnBandNode,
|
||||
validateColumnsClient,
|
||||
unplacedNodeIds,
|
||||
foreachChildFlowId,
|
||||
templateNodeIdFromChild,
|
||||
shortConditionLabel,
|
||||
COLUMN_BAND_HEIGHT,
|
||||
} from "../workflow-flow-mapping";
|
||||
import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
|
||||
@@ -302,3 +305,135 @@ describe("workflow-flow-mapping validation helpers", () => {
|
||||
expect(COLUMN_BAND_HEIGHT).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── U8: step-inversion round-trip (foreach template, rework edges) ───────────
|
||||
|
||||
describe("workflow-flow-mapping foreach + rework round-trip", () => {
|
||||
const ir: WorkflowDefinition["ir"] = {
|
||||
version: "v2",
|
||||
name: "stepwise",
|
||||
columns: [
|
||||
{ id: "plan", name: "Plan", traits: [] },
|
||||
{ id: "in-progress", name: "In progress", traits: [] },
|
||||
{ id: "done", name: "Done", traits: [] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "plan" },
|
||||
{ id: "parse", kind: "parse-steps", column: "plan", config: { artifact: "PROMPT.md", parser: "step-headings" } },
|
||||
{
|
||||
id: "loop",
|
||||
kind: "foreach",
|
||||
column: "in-progress",
|
||||
config: {
|
||||
source: "task-steps",
|
||||
mode: "sequential",
|
||||
isolation: "shared",
|
||||
maxReworkCycles: 3,
|
||||
template: {
|
||||
nodes: [
|
||||
{ id: "exec", kind: "prompt", config: { seam: "step-execute", prompt: "do step" } },
|
||||
{ id: "review", kind: "step-review", config: { type: "code" } },
|
||||
],
|
||||
edges: [
|
||||
{ from: "exec", to: "review", condition: "success" },
|
||||
{ from: "review", to: "exec", condition: "outcome:revise", kind: "rework" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "parse", condition: "success" },
|
||||
{ from: "parse", to: "loop", condition: "success" },
|
||||
{ from: "loop", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
|
||||
it("round-trips foreach template (children partitioned by parentId) losslessly", () => {
|
||||
const def = makeDef(ir);
|
||||
const { nodes, edges } = irToFlow(def);
|
||||
const columns = columnsOf(def);
|
||||
|
||||
// The foreach group + its two template children render as parented nodes.
|
||||
const group = nodes.find((n) => n.id === "loop");
|
||||
expect(group?.type).toBe("foreach");
|
||||
const children = nodes.filter((n) => n.parentId === "loop");
|
||||
expect(children.map((c) => c.id).sort()).toEqual(
|
||||
[foreachChildFlowId("loop", "exec"), foreachChildFlowId("loop", "review")].sort(),
|
||||
);
|
||||
// Template edges (incl. the rework edge) live inside the group's id-scope.
|
||||
const reworkFlowEdge = edges.find((e) => e.data?.kind === "rework");
|
||||
expect(reworkFlowEdge).toBeTruthy();
|
||||
expect(reworkFlowEdge?.source).toBe(foreachChildFlowId("loop", "review"));
|
||||
|
||||
const { ir: out } = flowToIr("stepwise", nodes, edges, columns);
|
||||
if (out.version !== "v2") throw new Error("expected v2");
|
||||
const loop = out.nodes.find((n) => n.id === "loop");
|
||||
expect(loop?.kind).toBe("foreach");
|
||||
const cfg = loop?.config as Record<string, unknown>;
|
||||
expect(cfg.source).toBe("task-steps");
|
||||
expect(cfg.mode).toBe("sequential");
|
||||
expect(cfg.maxReworkCycles).toBe(3);
|
||||
const template = cfg.template as { nodes: unknown[]; edges: { from: string; to: string; condition?: string; kind?: string }[] };
|
||||
// Template node ids are template-local (de-namespaced), not flow ids.
|
||||
expect((template.nodes as { id: string }[]).map((n) => n.id).sort()).toEqual(["exec", "review"]);
|
||||
// The rework edge survives with its kind and outcome condition.
|
||||
const rework = template.edges.find((e) => e.kind === "rework");
|
||||
expect(rework).toEqual({ from: "review", to: "exec", condition: "outcome:revise", kind: "rework" });
|
||||
// The plain success edge has no kind.
|
||||
const success = template.edges.find((e) => e.condition === "success");
|
||||
expect(success?.kind).toBeUndefined();
|
||||
// Top-level edges exclude the intra-template ones.
|
||||
expect(out.edges.map((e) => `${e.from}->${e.to}`)).toEqual([
|
||||
"start->parse",
|
||||
"parse->loop",
|
||||
"loop->end",
|
||||
]);
|
||||
// parse-steps config preserved.
|
||||
const parse = out.nodes.find((n) => n.id === "parse");
|
||||
expect(parse?.config).toMatchObject({ artifact: "PROMPT.md", parser: "step-headings" });
|
||||
});
|
||||
|
||||
it("round-trips a code node config (source + timeoutMs)", () => {
|
||||
const codeIr: WorkflowDefinition["ir"] = {
|
||||
version: "v1",
|
||||
name: "wf",
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "c1", kind: "code", config: { source: "export default async()=>({})", timeoutMs: 5000 } },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "c1", condition: "success" },
|
||||
{ from: "c1", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
const { nodes, edges } = irToFlow(makeDef(codeIr));
|
||||
const { ir: out } = flowToIr("wf", nodes, edges);
|
||||
const c1 = out.nodes.find((n) => n.id === "c1");
|
||||
expect(c1?.kind).toBe("code");
|
||||
expect(c1?.config).toMatchObject({ source: "export default async()=>({})", timeoutMs: 5000 });
|
||||
});
|
||||
|
||||
it("child id namespacing helpers are inverse", () => {
|
||||
const fid = foreachChildFlowId("loop", "exec");
|
||||
expect(templateNodeIdFromChild("loop", fid)).toBe("exec");
|
||||
// A non-namespaced id passes through unchanged.
|
||||
expect(templateNodeIdFromChild("loop", "other")).toBe("other");
|
||||
});
|
||||
|
||||
it("shortens outcome:<verdict> edge labels", () => {
|
||||
expect(shortConditionLabel("outcome:approve")).toBe("approve");
|
||||
expect(shortConditionLabel("success")).toBe("success");
|
||||
});
|
||||
|
||||
it("does not flag foreach template children as unplaced", () => {
|
||||
const def = makeDef(ir);
|
||||
const { nodes } = irToFlow(def);
|
||||
const columns = columnsOf(def);
|
||||
const ids = unplacedNodeIds(nodes, columns);
|
||||
expect(ids).not.toContain(foreachChildFlowId("loop", "exec"));
|
||||
expect(ids).not.toContain(foreachChildFlowId("loop", "review"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle } from "lucide-react";
|
||||
import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle, Repeat, ClipboardCheck, ListChecks, Code2 } from "lucide-react";
|
||||
|
||||
/** Node kinds the editor can render. "merge" is the pre/post-merge seam marker.
|
||||
* v2 adds "hold" (passive dwell), "split"/"join" (parallel fan-out). */
|
||||
* v2 adds "hold" (passive dwell), "split"/"join" (parallel fan-out). The
|
||||
* step-inversion additions (KTD-3/4/12/15): "foreach" (runtime-expanding
|
||||
* per-step template region, rendered as a React Flow group), "step-review"
|
||||
* (per-step review verdicts as outcome edges), "parse-steps" (graph-native
|
||||
* step-list parsing), and "code" (sandboxed TypeScript). */
|
||||
export type WorkflowEditorNodeKind =
|
||||
| "start"
|
||||
| "end"
|
||||
@@ -12,7 +16,11 @@ export type WorkflowEditorNodeKind =
|
||||
| "merge"
|
||||
| "hold"
|
||||
| "split"
|
||||
| "join";
|
||||
| "join"
|
||||
| "foreach"
|
||||
| "step-review"
|
||||
| "parse-steps"
|
||||
| "code";
|
||||
|
||||
export interface WorkflowFlowNodeData {
|
||||
kind: WorkflowEditorNodeKind;
|
||||
@@ -26,6 +34,11 @@ export interface WorkflowFlowNodeData {
|
||||
/** When true, render the shared error-state badge on the node (unplaced node
|
||||
* or seam-in-branch). Set by the editor from validation. */
|
||||
errorBadge?: string;
|
||||
/** foreach group only: true when it has no template children (deletion can
|
||||
* empty it even though the palette auto-populates one). */
|
||||
templateEmpty?: boolean;
|
||||
/** foreach group only: the localized empty-state hint string. */
|
||||
emptyHint?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -39,6 +52,10 @@ const KIND_ICON: Record<WorkflowEditorNodeKind, typeof Play> = {
|
||||
hold: PauseCircle,
|
||||
split: Split,
|
||||
join: Merge,
|
||||
foreach: Repeat,
|
||||
"step-review": ClipboardCheck,
|
||||
"parse-steps": ListChecks,
|
||||
code: Code2,
|
||||
};
|
||||
|
||||
/** Shared error-state component (U10): one component renders both the
|
||||
@@ -66,9 +83,14 @@ function NodeShell({ data, kind }: { data: WorkflowFlowNodeData; kind: WorkflowE
|
||||
return typeof m === "string" ? m : "all";
|
||||
})()
|
||||
: undefined;
|
||||
// Step-execute seam prompt nodes (only legal inside a foreach template) carry
|
||||
// a distinguishing badge so the template's execute node reads clearly.
|
||||
const seam = kind === "prompt" ? (data.config?.seam as string | undefined) : undefined;
|
||||
const reviewType = kind === "step-review" ? (data.config?.type as string | undefined) : undefined;
|
||||
const parser = kind === "parse-steps" ? (data.config?.parser as string | undefined) : undefined;
|
||||
return (
|
||||
<div
|
||||
className={`wf-node wf-node-${kind}${data.errorBadge ? " wf-node--error" : ""}`}
|
||||
className={`wf-node wf-node-${kind}${seam === "step-execute" ? " wf-node-step-execute" : ""}${data.errorBadge ? " wf-node--error" : ""}`}
|
||||
data-testid={`wf-node-${kind}`}
|
||||
>
|
||||
{showTarget && <Handle type="target" position={Position.Left} />}
|
||||
@@ -79,12 +101,48 @@ function NodeShell({ data, kind }: { data: WorkflowFlowNodeData; kind: WorkflowE
|
||||
{kind === "gate" && <span className="wf-node-badge">gate</span>}
|
||||
{release && <span className="wf-node-badge">{release}</span>}
|
||||
{joinMode && <span className="wf-node-badge">{joinMode}</span>}
|
||||
{seam === "step-execute" && <span className="wf-node-badge">step</span>}
|
||||
{reviewType && <span className="wf-node-badge">{reviewType}</span>}
|
||||
{parser && <span className="wf-node-badge">{parser}</span>}
|
||||
{data.errorBadge && <WorkflowNodeErrorBadge message={data.errorBadge} />}
|
||||
{showSource && <Handle type="source" position={Position.Right} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A `foreach` node renders as a React Flow group: template nodes are children
|
||||
* (parentId = the group id) laid out inside it. When empty, an empty-state hint
|
||||
* prompts the author to drop a step-execute node in. The mode/isolation config
|
||||
* is summarized in a header badge row. */
|
||||
function ForeachGroupNode({ data }: { data: WorkflowFlowNodeData }) {
|
||||
const mode = (data.config?.mode as string | undefined) ?? "sequential";
|
||||
const isolation = (data.config?.isolation as string | undefined) ?? (mode === "parallel" ? "worktree" : "shared");
|
||||
const isEmpty = data.templateEmpty === true;
|
||||
return (
|
||||
<div
|
||||
className={`wf-foreach-group${data.errorBadge ? " wf-node--error" : ""}`}
|
||||
data-testid="wf-node-foreach"
|
||||
>
|
||||
<Handle type="target" position={Position.Left} />
|
||||
<div className="wf-foreach-header">
|
||||
<span className="wf-node-icon">
|
||||
<Repeat size={14} aria-hidden />
|
||||
</span>
|
||||
<span className="wf-node-label">{data.label || "foreach"}</span>
|
||||
<span className="wf-node-badge">{mode}</span>
|
||||
<span className="wf-node-badge">{isolation}</span>
|
||||
</div>
|
||||
{isEmpty && (
|
||||
<div className="wf-foreach-empty" data-testid="wf-foreach-empty">
|
||||
{data.emptyHint || "Drag a step-execute node here"}
|
||||
</div>
|
||||
)}
|
||||
{data.errorBadge && <WorkflowNodeErrorBadge message={data.errorBadge} />}
|
||||
<Handle type="source" position={Position.Right} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const workflowNodeTypes = {
|
||||
start: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="start" />,
|
||||
end: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="end" />,
|
||||
@@ -95,4 +153,8 @@ export const workflowNodeTypes = {
|
||||
hold: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="hold" />,
|
||||
split: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="split" />,
|
||||
join: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="join" />,
|
||||
foreach: ({ data }: NodeProps) => <ForeachGroupNode data={data as WorkflowFlowNodeData} />,
|
||||
"step-review": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="step-review" />,
|
||||
"parse-steps": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="parse-steps" />,
|
||||
code: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="code" />,
|
||||
};
|
||||
|
||||
@@ -3,10 +3,50 @@ import type {
|
||||
WorkflowIr,
|
||||
WorkflowIrV2,
|
||||
WorkflowIrColumn,
|
||||
WorkflowIrNode,
|
||||
WorkflowIrEdge,
|
||||
WorkflowDefinition,
|
||||
} from "@fusion/core";
|
||||
import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
|
||||
|
||||
/** Local mirror of @fusion/core's WorkflowForeachConfig (KTD-3). The core index
|
||||
* barrel does not re-export it, and the dashboard build aliases @fusion/core to
|
||||
* a types-only entry, so we describe just the shape this mapping needs. */
|
||||
interface WorkflowForeachConfig {
|
||||
source: "task-steps";
|
||||
maxReworkCycles?: number;
|
||||
mode?: "sequential" | "parallel";
|
||||
concurrency?: number;
|
||||
isolation?: "shared" | "worktree";
|
||||
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
|
||||
}
|
||||
|
||||
// ── foreach template region (KTD-3, U8) ──────────────────────────────────────
|
||||
//
|
||||
// A `foreach` node is authored inline as a React Flow group node whose template
|
||||
// subgraph nodes are children with `parentId` set to the group id. To keep child
|
||||
// flow-node ids globally unique while preserving the *template-local* ids that
|
||||
// the IR's `config.template` stores, child flow ids are namespaced as
|
||||
// `<groupId>::<templateNodeId>`; flowToIr strips the prefix back out when it
|
||||
// reassembles the template. Geometry for the group + auto-layout for template
|
||||
// nodes lacking persisted layout data.
|
||||
export const FOREACH_GROUP_WIDTH = 520;
|
||||
export const FOREACH_GROUP_HEIGHT = 200;
|
||||
export const FOREACH_CHILD_X = 30;
|
||||
export const FOREACH_CHILD_Y = 56;
|
||||
export const FOREACH_CHILD_STEP_X = 170;
|
||||
|
||||
const FOREACH_CHILD_SEP = "::";
|
||||
/** Compose a globally-unique flow-node id for a template child. */
|
||||
export function foreachChildFlowId(groupId: string, templateNodeId: string): string {
|
||||
return `${groupId}${FOREACH_CHILD_SEP}${templateNodeId}`;
|
||||
}
|
||||
/** Recover the template-local node id from a namespaced child flow id. */
|
||||
export function templateNodeIdFromChild(groupId: string, childFlowId: string): string {
|
||||
const prefix = `${groupId}${FOREACH_CHILD_SEP}`;
|
||||
return childFlowId.startsWith(prefix) ? childFlowId.slice(prefix.length) : childFlowId;
|
||||
}
|
||||
|
||||
/** Layout geometry for column swimlane bands. Bands stack vertically; each band
|
||||
* is full-width and a node's `column` is derived by hit-testing the node's y
|
||||
* against the band rows (position-based, so the editor's existing absolute
|
||||
@@ -85,14 +125,52 @@ export function columnsToBandNodes(columns: WorkflowIrColumn[]): FlowNode<Workfl
|
||||
}));
|
||||
}
|
||||
|
||||
/** Read a node's foreach template config, or undefined when it is not a foreach
|
||||
* node carrying a template. */
|
||||
function foreachConfigOf(node: WorkflowIrNode): WorkflowForeachConfig | undefined {
|
||||
if (node.kind !== "foreach") return undefined;
|
||||
const cfg = node.config as Partial<WorkflowForeachConfig> | undefined;
|
||||
if (!cfg || !cfg.template) return undefined;
|
||||
return cfg as WorkflowForeachConfig;
|
||||
}
|
||||
|
||||
/** Build a React Flow edge from an IR edge. Rework edges (KTD-5) carry kind so
|
||||
* the editor renders them dashed in the accent color. */
|
||||
function irEdgeToFlow(edge: WorkflowIrEdge, index: number, idScope = ""): FlowEdge {
|
||||
const condition = edge.condition ?? "success";
|
||||
const isRework = edge.kind === "rework";
|
||||
return {
|
||||
id: `e-${idScope}${edge.from}-${edge.to}-${index}`,
|
||||
source: idScope ? `${idScope}${edge.from}` : edge.from,
|
||||
target: idScope ? `${idScope}${edge.to}` : edge.to,
|
||||
label: isRework ? `${shortConditionLabel(condition)} (rework)` : shortConditionLabel(condition),
|
||||
data: { condition, kind: isRework ? "rework" : undefined },
|
||||
type: isRework ? "step" : undefined,
|
||||
animated: isRework,
|
||||
className: isRework ? "wf-edge-rework" : undefined,
|
||||
markerEnd: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Short display label for an edge condition. `outcome:<verdict>` conditions
|
||||
* render as the verdict alone (KTD-4); everything else verbatim. */
|
||||
export function shortConditionLabel(condition: string): string {
|
||||
if (condition.startsWith("outcome:")) return condition.slice("outcome:".length);
|
||||
return condition;
|
||||
}
|
||||
|
||||
/** Build React Flow nodes/edges from a stored workflow definition. v2 columns
|
||||
* render as swimlane band group nodes; step nodes carry their `column`. */
|
||||
* render as swimlane band group nodes; step nodes carry their `column`. A
|
||||
* `foreach` node renders as a group whose template subgraph nodes are children
|
||||
* (parentId = the group id). */
|
||||
export function irToFlow(def: WorkflowDefinition): {
|
||||
nodes: FlowNode<WorkflowFlowNodeData>[];
|
||||
edges: FlowEdge[];
|
||||
} {
|
||||
const columns = isV2(def.ir) ? def.ir.columns : [];
|
||||
const bandNodes = columnsToBandNodes(columns);
|
||||
const childNodes: FlowNode<WorkflowFlowNodeData>[] = [];
|
||||
const childEdges: FlowEdge[] = [];
|
||||
|
||||
const stepNodes = def.ir.nodes.map((node, index): FlowNode<WorkflowFlowNodeData> => {
|
||||
const pos = def.layout?.[node.id];
|
||||
@@ -102,6 +180,51 @@ export function irToFlow(def: WorkflowDefinition): {
|
||||
// Default placement seeds the node inside its column band when no persisted
|
||||
// layout exists; otherwise we honor the saved absolute position.
|
||||
const fallbackY = colIndex >= 0 ? bandTop(colIndex) + 70 : 120;
|
||||
|
||||
const foreachCfg = foreachConfigOf(node);
|
||||
if (foreachCfg) {
|
||||
const template = foreachCfg.template;
|
||||
// Render template nodes as children of this group (parentId = group id).
|
||||
template.nodes.forEach((inner, innerIdx) => {
|
||||
const childFlowId = foreachChildFlowId(node.id, inner.id);
|
||||
// Template layout lives under namespaced keys; auto-layout otherwise.
|
||||
const childPos =
|
||||
def.layout?.[childFlowId] ?? {
|
||||
x: FOREACH_CHILD_X + innerIdx * FOREACH_CHILD_STEP_X,
|
||||
y: FOREACH_CHILD_Y,
|
||||
};
|
||||
const innerKind = editorKind(inner);
|
||||
childNodes.push({
|
||||
id: childFlowId,
|
||||
type: innerKind,
|
||||
position: childPos,
|
||||
parentId: node.id,
|
||||
extent: "parent",
|
||||
data: { kind: innerKind, label: nodeLabel(inner), config: { ...(inner.config ?? {}) } },
|
||||
deletable: true,
|
||||
});
|
||||
});
|
||||
template.edges.forEach((edge, eIdx) => {
|
||||
childEdges.push(irEdgeToFlow(edge, eIdx, `${node.id}${FOREACH_CHILD_SEP}`));
|
||||
});
|
||||
// Strip the template off the group node's own config (children carry it).
|
||||
const { template: _t, ...restCfg } = (node.config ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: node.id,
|
||||
type: "foreach",
|
||||
position: pos ?? { x: 80 + index * 180, y: fallbackY },
|
||||
data: {
|
||||
kind: "foreach",
|
||||
label: nodeLabel(node),
|
||||
config: { ...restCfg },
|
||||
column,
|
||||
templateEmpty: template.nodes.length === 0,
|
||||
},
|
||||
style: { width: FOREACH_GROUP_WIDTH, height: FOREACH_GROUP_HEIGHT },
|
||||
deletable: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
type: kind,
|
||||
@@ -116,18 +239,10 @@ export function irToFlow(def: WorkflowDefinition): {
|
||||
};
|
||||
});
|
||||
|
||||
const edges = def.ir.edges.map((edge, index): FlowEdge => {
|
||||
const condition = edge.condition ?? "success";
|
||||
return {
|
||||
id: `e-${edge.from}-${edge.to}-${index}`,
|
||||
source: edge.from,
|
||||
target: edge.to,
|
||||
label: condition,
|
||||
data: { condition },
|
||||
};
|
||||
});
|
||||
const edges = def.ir.edges.map((edge, index): FlowEdge => irEdgeToFlow(edge, index));
|
||||
|
||||
return { nodes: [...bandNodes, ...stepNodes], edges };
|
||||
// Group nodes must precede their children in the array for React Flow.
|
||||
return { nodes: [...bandNodes, ...stepNodes, ...childNodes], edges: [...edges, ...childEdges] };
|
||||
}
|
||||
|
||||
/** Sanitize a node config, applying the v1 round-trip name rules. */
|
||||
@@ -158,35 +273,77 @@ export function flowToIr(
|
||||
edges: FlowEdge[],
|
||||
columns?: WorkflowIrColumn[],
|
||||
): { ir: WorkflowIr; layout: Record<string, { x: number; y: number }> } {
|
||||
const stepNodes = nodes.filter((n) => !isColumnBandNode(n.id) && n.type !== "group");
|
||||
const realNodes = nodes.filter((n) => !isColumnBandNode(n.id));
|
||||
// Partition by parentId: foreach group children reassemble into that group's
|
||||
// config.template; everything else (no parentId) is top-level. (Column band
|
||||
// group nodes are already excluded above.)
|
||||
const topNodes = realNodes.filter((n) => !n.parentId);
|
||||
const childrenByGroup = new Map<string, FlowNode<WorkflowFlowNodeData>[]>();
|
||||
for (const n of realNodes) {
|
||||
if (n.parentId) {
|
||||
const arr = childrenByGroup.get(n.parentId) ?? [];
|
||||
arr.push(n);
|
||||
childrenByGroup.set(n.parentId, arr);
|
||||
}
|
||||
}
|
||||
const groupIds = new Set(topNodes.filter((n) => n.data.kind === "foreach").map((n) => n.id));
|
||||
const v2 = Array.isArray(columns) && columns.length > 0;
|
||||
const layout: Record<string, { x: number; y: number }> = {};
|
||||
|
||||
const irNodes: WorkflowIr["nodes"] = stepNodes.map((node) => {
|
||||
/** Project one flow node (top-level or template child) into an IR node. */
|
||||
function toIrNode(node: FlowNode<WorkflowFlowNodeData>, localId: string): WorkflowIrNode {
|
||||
const data = node.data;
|
||||
const config = nodeConfig(node);
|
||||
// Derive column placement from the node's y position relative to the bands.
|
||||
const column = v2 ? data.column ?? columnForY(node.position.y, columns!) : undefined;
|
||||
if (data.kind === "merge") {
|
||||
const cfg = { ...(config ?? {}), seam: "merge" };
|
||||
return { id: node.id, kind: "prompt" as const, ...(column ? { column } : {}), config: cfg };
|
||||
return { id: localId, kind: "prompt", config: { ...(config ?? {}), seam: "merge" } };
|
||||
}
|
||||
if (data.kind === "foreach") {
|
||||
// Reassemble the template from this group's children.
|
||||
const children = childrenByGroup.get(node.id) ?? [];
|
||||
const templateNodes: WorkflowIrNode[] = children.map((c) => {
|
||||
const innerId = templateNodeIdFromChild(node.id, c.id);
|
||||
layout[c.id] = { x: Math.round(c.position.x), y: Math.round(c.position.y) };
|
||||
return toIrNode(c, innerId);
|
||||
});
|
||||
const childIdSet = new Set(children.map((c) => c.id));
|
||||
const templateEdges: WorkflowIrEdge[] = edges
|
||||
.filter((e) => childIdSet.has(e.source) && childIdSet.has(e.target))
|
||||
.map((e) => flowEdgeToIr(e, node.id));
|
||||
const baseCfg = (config ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: localId,
|
||||
kind: "foreach",
|
||||
config: { ...baseCfg, template: { nodes: templateNodes, edges: templateEdges } },
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: node.id,
|
||||
kind: data.kind,
|
||||
...(column ? { column } : {}),
|
||||
id: localId,
|
||||
kind: data.kind as WorkflowIrNode["kind"],
|
||||
config: config && Object.keys(config).length ? config : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const irNodes: WorkflowIr["nodes"] = topNodes.map((node) => {
|
||||
const column = v2 ? node.data.column ?? columnForY(node.position.y, columns!) : undefined;
|
||||
const base = toIrNode(node, node.id);
|
||||
layout[node.id] = { x: Math.round(node.position.x), y: Math.round(node.position.y) };
|
||||
return column ? { ...base, column } : base;
|
||||
});
|
||||
|
||||
const irEdges: WorkflowIr["edges"] = edges.map((edge) => {
|
||||
const condition = (edge.data?.condition as string | undefined) ?? "success";
|
||||
return { from: edge.source, to: edge.target, condition };
|
||||
});
|
||||
// Top-level edges: exclude any edge that lives entirely inside a foreach
|
||||
// template (both endpoints are children of the same group) — those are folded
|
||||
// into the group's template above.
|
||||
const childIdToGroup = new Map<string, string>();
|
||||
for (const [gid, kids] of childrenByGroup) for (const k of kids) childIdToGroup.set(k.id, gid);
|
||||
const irEdges: WorkflowIr["edges"] = edges
|
||||
.filter((e) => {
|
||||
const sg = childIdToGroup.get(e.source);
|
||||
const tg = childIdToGroup.get(e.target);
|
||||
return !(sg && tg && sg === tg);
|
||||
})
|
||||
.map((e) => flowEdgeToIr(e));
|
||||
|
||||
const layout = stepNodes.reduce<Record<string, { x: number; y: number }>>((acc, node) => {
|
||||
acc[node.id] = { x: Math.round(node.position.x), y: Math.round(node.position.y) };
|
||||
return acc;
|
||||
}, {});
|
||||
void groupIds;
|
||||
|
||||
if (v2) {
|
||||
const ir: WorkflowIrV2 = {
|
||||
@@ -202,6 +359,17 @@ export function flowToIr(
|
||||
return { ir: { version: "v1", name, nodes: irNodes, edges: irEdges }, layout };
|
||||
}
|
||||
|
||||
/** Project a React Flow edge into an IR edge. Rework edges carry `kind`. When
|
||||
* `groupId` is given the endpoints are de-namespaced back to template-local
|
||||
* ids. */
|
||||
function flowEdgeToIr(edge: FlowEdge, groupId?: string): WorkflowIrEdge {
|
||||
const condition = (edge.data?.condition as string | undefined) ?? "success";
|
||||
const isRework = (edge.data?.kind as string | undefined) === "rework";
|
||||
const from = groupId ? templateNodeIdFromChild(groupId, edge.source) : edge.source;
|
||||
const to = groupId ? templateNodeIdFromChild(groupId, edge.target) : edge.target;
|
||||
return { from, to, condition, ...(isRework ? { kind: "rework" as const } : {}) };
|
||||
}
|
||||
|
||||
// ── Client-side validation (U10) ─────────────────────────────────────────────
|
||||
//
|
||||
// The server's parseWorkflowIr (run on PATCH) is the authority for structural
|
||||
@@ -321,6 +489,8 @@ export function unplacedNodeIds(
|
||||
const ids: string[] = [];
|
||||
for (const node of nodes) {
|
||||
if (isColumnBandNode(node.id) || node.type === "group") continue;
|
||||
// foreach template children are placed by their parent group, not a column.
|
||||
if (node.parentId) continue;
|
||||
if (node.data.kind === "start" || node.data.kind === "end") continue;
|
||||
// A node is placed if it carries a valid column id, or if its y falls
|
||||
// strictly within a band's extent. A node parked outside every band with
|
||||
|
||||
@@ -6717,9 +6717,28 @@
|
||||
},
|
||||
"workflowNodes": {
|
||||
"advisory": "Advisory",
|
||||
"codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
|
||||
"codeSource": "Source (TypeScript)",
|
||||
"codeTimeout": "Timeout (ms)",
|
||||
"edgeConditionLabel": "Condition: {{condition}}",
|
||||
"edgeInspector": "Edge",
|
||||
"edgeNoVerdict": "— success (no verdict) —",
|
||||
"edgeRework": "Rework edge (loop back, bounded)",
|
||||
"edgeReworkNote": "Rework edges are the only legal cycles — they loop back within the for-each step instance, bounded by Max rework cycles.",
|
||||
"edgeVerdict": "Review verdict",
|
||||
"failureCollect": "Collect (wait for all)",
|
||||
"failureFailFast": "Fail-fast (cancel siblings)",
|
||||
"failurePolicy": "On branch failure",
|
||||
"foreachConcurrency": "Concurrency",
|
||||
"foreachEmptyHint": "Drag a step-execute node here",
|
||||
"foreachIsolation": "Isolation",
|
||||
"foreachMaxRework": "Max rework cycles",
|
||||
"foreachMode": "Mode",
|
||||
"foreachNote": "Expands once per planned step. Drop a step-execute node (and optional step-review) into the region.",
|
||||
"foreachParallel": "Parallel",
|
||||
"foreachSequential": "Sequential",
|
||||
"foreachShared": "Shared worktree",
|
||||
"foreachWorktree": "Per-step worktree",
|
||||
"gateBlocks": "Gate (blocks)",
|
||||
"gateMode": "Gate mode",
|
||||
"joinAll": "All branches",
|
||||
@@ -6727,6 +6746,8 @@
|
||||
"joinMode": "Join mode",
|
||||
"joinQuorum": "Quorum (n)",
|
||||
"mergeBoundaryNote": "Steps before this marker run pre-merge; steps after run post-merge.",
|
||||
"parseArtifact": "Artifact",
|
||||
"parseParser": "Parser",
|
||||
"quorumN": "Quorum count (n)",
|
||||
"releaseCapacity": "Downstream capacity",
|
||||
"releaseCondition": "Release condition",
|
||||
@@ -6734,7 +6755,13 @@
|
||||
"releaseExternal": "External event",
|
||||
"releaseManual": "Manual promote",
|
||||
"releaseTimer": "Timer",
|
||||
"splitNote": "Branches run concurrently from this node. Execute and merge seams are not allowed inside a branch."
|
||||
"reviewCode": "Code review",
|
||||
"reviewModel": "Review model (optional)",
|
||||
"reviewNote": "Verdicts route as outcome edges. Click an outgoing edge to set its verdict and rework behavior.",
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"splitNote": "Branches run concurrently from this node. Execute and merge seams are not allowed inside a branch.",
|
||||
"stepExecuteLabel": "Step execute"
|
||||
},
|
||||
"workflows": {
|
||||
"duplicateToCustomize": "Duplicate to customize",
|
||||
|
||||
@@ -6717,9 +6717,28 @@
|
||||
},
|
||||
"workflowNodes": {
|
||||
"advisory": "",
|
||||
"codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
|
||||
"codeSource": "Source (TypeScript)",
|
||||
"codeTimeout": "Timeout (ms)",
|
||||
"edgeConditionLabel": "Condition: {{condition}}",
|
||||
"edgeInspector": "Edge",
|
||||
"edgeNoVerdict": "— success (no verdict) —",
|
||||
"edgeRework": "Rework edge (loop back, bounded)",
|
||||
"edgeReworkNote": "Rework edges are the only legal cycles — they loop back within the for-each step instance, bounded by Max rework cycles.",
|
||||
"edgeVerdict": "Review verdict",
|
||||
"failureCollect": "",
|
||||
"failureFailFast": "",
|
||||
"failurePolicy": "",
|
||||
"foreachConcurrency": "Concurrency",
|
||||
"foreachEmptyHint": "Drag a step-execute node here",
|
||||
"foreachIsolation": "Isolation",
|
||||
"foreachMaxRework": "Max rework cycles",
|
||||
"foreachMode": "Mode",
|
||||
"foreachNote": "Expands once per planned step. Drop a step-execute node (and optional step-review) into the region.",
|
||||
"foreachParallel": "Parallel",
|
||||
"foreachSequential": "Sequential",
|
||||
"foreachShared": "Shared worktree",
|
||||
"foreachWorktree": "Per-step worktree",
|
||||
"gateBlocks": "",
|
||||
"gateMode": "",
|
||||
"joinAll": "",
|
||||
@@ -6727,6 +6746,8 @@
|
||||
"joinMode": "",
|
||||
"joinQuorum": "",
|
||||
"mergeBoundaryNote": "",
|
||||
"parseArtifact": "Artifact",
|
||||
"parseParser": "Parser",
|
||||
"quorumN": "",
|
||||
"releaseCapacity": "",
|
||||
"releaseCondition": "",
|
||||
@@ -6734,7 +6755,13 @@
|
||||
"releaseExternal": "",
|
||||
"releaseManual": "",
|
||||
"releaseTimer": "",
|
||||
"splitNote": ""
|
||||
"reviewCode": "Code review",
|
||||
"reviewModel": "Review model (optional)",
|
||||
"reviewNote": "Verdicts route as outcome edges. Click an outgoing edge to set its verdict and rework behavior.",
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"splitNote": "",
|
||||
"stepExecuteLabel": "Step execute"
|
||||
},
|
||||
"workflows": {
|
||||
"duplicateToCustomize": "",
|
||||
|
||||
@@ -6717,9 +6717,28 @@
|
||||
},
|
||||
"workflowNodes": {
|
||||
"advisory": "",
|
||||
"codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
|
||||
"codeSource": "Source (TypeScript)",
|
||||
"codeTimeout": "Timeout (ms)",
|
||||
"edgeConditionLabel": "Condition: {{condition}}",
|
||||
"edgeInspector": "Edge",
|
||||
"edgeNoVerdict": "— success (no verdict) —",
|
||||
"edgeRework": "Rework edge (loop back, bounded)",
|
||||
"edgeReworkNote": "Rework edges are the only legal cycles — they loop back within the for-each step instance, bounded by Max rework cycles.",
|
||||
"edgeVerdict": "Review verdict",
|
||||
"failureCollect": "",
|
||||
"failureFailFast": "",
|
||||
"failurePolicy": "",
|
||||
"foreachConcurrency": "Concurrency",
|
||||
"foreachEmptyHint": "Drag a step-execute node here",
|
||||
"foreachIsolation": "Isolation",
|
||||
"foreachMaxRework": "Max rework cycles",
|
||||
"foreachMode": "Mode",
|
||||
"foreachNote": "Expands once per planned step. Drop a step-execute node (and optional step-review) into the region.",
|
||||
"foreachParallel": "Parallel",
|
||||
"foreachSequential": "Sequential",
|
||||
"foreachShared": "Shared worktree",
|
||||
"foreachWorktree": "Per-step worktree",
|
||||
"gateBlocks": "",
|
||||
"gateMode": "",
|
||||
"joinAll": "",
|
||||
@@ -6727,6 +6746,8 @@
|
||||
"joinMode": "",
|
||||
"joinQuorum": "",
|
||||
"mergeBoundaryNote": "",
|
||||
"parseArtifact": "Artifact",
|
||||
"parseParser": "Parser",
|
||||
"quorumN": "",
|
||||
"releaseCapacity": "",
|
||||
"releaseCondition": "",
|
||||
@@ -6734,7 +6755,13 @@
|
||||
"releaseExternal": "",
|
||||
"releaseManual": "",
|
||||
"releaseTimer": "",
|
||||
"splitNote": ""
|
||||
"reviewCode": "Code review",
|
||||
"reviewModel": "Review model (optional)",
|
||||
"reviewNote": "Verdicts route as outcome edges. Click an outgoing edge to set its verdict and rework behavior.",
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"splitNote": "",
|
||||
"stepExecuteLabel": "Step execute"
|
||||
},
|
||||
"workflows": {
|
||||
"duplicateToCustomize": "",
|
||||
|
||||
@@ -6717,9 +6717,28 @@
|
||||
},
|
||||
"workflowNodes": {
|
||||
"advisory": "",
|
||||
"codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
|
||||
"codeSource": "Source (TypeScript)",
|
||||
"codeTimeout": "Timeout (ms)",
|
||||
"edgeConditionLabel": "Condition: {{condition}}",
|
||||
"edgeInspector": "Edge",
|
||||
"edgeNoVerdict": "— success (no verdict) —",
|
||||
"edgeRework": "Rework edge (loop back, bounded)",
|
||||
"edgeReworkNote": "Rework edges are the only legal cycles — they loop back within the for-each step instance, bounded by Max rework cycles.",
|
||||
"edgeVerdict": "Review verdict",
|
||||
"failureCollect": "",
|
||||
"failureFailFast": "",
|
||||
"failurePolicy": "",
|
||||
"foreachConcurrency": "Concurrency",
|
||||
"foreachEmptyHint": "Drag a step-execute node here",
|
||||
"foreachIsolation": "Isolation",
|
||||
"foreachMaxRework": "Max rework cycles",
|
||||
"foreachMode": "Mode",
|
||||
"foreachNote": "Expands once per planned step. Drop a step-execute node (and optional step-review) into the region.",
|
||||
"foreachParallel": "Parallel",
|
||||
"foreachSequential": "Sequential",
|
||||
"foreachShared": "Shared worktree",
|
||||
"foreachWorktree": "Per-step worktree",
|
||||
"gateBlocks": "",
|
||||
"gateMode": "",
|
||||
"joinAll": "",
|
||||
@@ -6727,6 +6746,8 @@
|
||||
"joinMode": "",
|
||||
"joinQuorum": "",
|
||||
"mergeBoundaryNote": "",
|
||||
"parseArtifact": "Artifact",
|
||||
"parseParser": "Parser",
|
||||
"quorumN": "",
|
||||
"releaseCapacity": "",
|
||||
"releaseCondition": "",
|
||||
@@ -6734,7 +6755,13 @@
|
||||
"releaseExternal": "",
|
||||
"releaseManual": "",
|
||||
"releaseTimer": "",
|
||||
"splitNote": ""
|
||||
"reviewCode": "Code review",
|
||||
"reviewModel": "Review model (optional)",
|
||||
"reviewNote": "Verdicts route as outcome edges. Click an outgoing edge to set its verdict and rework behavior.",
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"splitNote": "",
|
||||
"stepExecuteLabel": "Step execute"
|
||||
},
|
||||
"workflows": {
|
||||
"duplicateToCustomize": "",
|
||||
|
||||
@@ -6717,9 +6717,28 @@
|
||||
},
|
||||
"workflowNodes": {
|
||||
"advisory": "",
|
||||
"codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
|
||||
"codeSource": "Source (TypeScript)",
|
||||
"codeTimeout": "Timeout (ms)",
|
||||
"edgeConditionLabel": "Condition: {{condition}}",
|
||||
"edgeInspector": "Edge",
|
||||
"edgeNoVerdict": "— success (no verdict) —",
|
||||
"edgeRework": "Rework edge (loop back, bounded)",
|
||||
"edgeReworkNote": "Rework edges are the only legal cycles — they loop back within the for-each step instance, bounded by Max rework cycles.",
|
||||
"edgeVerdict": "Review verdict",
|
||||
"failureCollect": "",
|
||||
"failureFailFast": "",
|
||||
"failurePolicy": "",
|
||||
"foreachConcurrency": "Concurrency",
|
||||
"foreachEmptyHint": "Drag a step-execute node here",
|
||||
"foreachIsolation": "Isolation",
|
||||
"foreachMaxRework": "Max rework cycles",
|
||||
"foreachMode": "Mode",
|
||||
"foreachNote": "Expands once per planned step. Drop a step-execute node (and optional step-review) into the region.",
|
||||
"foreachParallel": "Parallel",
|
||||
"foreachSequential": "Sequential",
|
||||
"foreachShared": "Shared worktree",
|
||||
"foreachWorktree": "Per-step worktree",
|
||||
"gateBlocks": "",
|
||||
"gateMode": "",
|
||||
"joinAll": "",
|
||||
@@ -6727,6 +6746,8 @@
|
||||
"joinMode": "",
|
||||
"joinQuorum": "",
|
||||
"mergeBoundaryNote": "",
|
||||
"parseArtifact": "Artifact",
|
||||
"parseParser": "Parser",
|
||||
"quorumN": "",
|
||||
"releaseCapacity": "",
|
||||
"releaseCondition": "",
|
||||
@@ -6734,7 +6755,13 @@
|
||||
"releaseExternal": "",
|
||||
"releaseManual": "",
|
||||
"releaseTimer": "",
|
||||
"splitNote": ""
|
||||
"reviewCode": "Code review",
|
||||
"reviewModel": "Review model (optional)",
|
||||
"reviewNote": "Verdicts route as outcome edges. Click an outgoing edge to set its verdict and rework behavior.",
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"splitNote": "",
|
||||
"stepExecuteLabel": "Step execute"
|
||||
},
|
||||
"workflows": {
|
||||
"duplicateToCustomize": "",
|
||||
|
||||
@@ -6717,9 +6717,28 @@
|
||||
},
|
||||
"workflowNodes": {
|
||||
"advisory": "",
|
||||
"codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
|
||||
"codeSource": "Source (TypeScript)",
|
||||
"codeTimeout": "Timeout (ms)",
|
||||
"edgeConditionLabel": "Condition: {{condition}}",
|
||||
"edgeInspector": "Edge",
|
||||
"edgeNoVerdict": "— success (no verdict) —",
|
||||
"edgeRework": "Rework edge (loop back, bounded)",
|
||||
"edgeReworkNote": "Rework edges are the only legal cycles — they loop back within the for-each step instance, bounded by Max rework cycles.",
|
||||
"edgeVerdict": "Review verdict",
|
||||
"failureCollect": "",
|
||||
"failureFailFast": "",
|
||||
"failurePolicy": "",
|
||||
"foreachConcurrency": "Concurrency",
|
||||
"foreachEmptyHint": "Drag a step-execute node here",
|
||||
"foreachIsolation": "Isolation",
|
||||
"foreachMaxRework": "Max rework cycles",
|
||||
"foreachMode": "Mode",
|
||||
"foreachNote": "Expands once per planned step. Drop a step-execute node (and optional step-review) into the region.",
|
||||
"foreachParallel": "Parallel",
|
||||
"foreachSequential": "Sequential",
|
||||
"foreachShared": "Shared worktree",
|
||||
"foreachWorktree": "Per-step worktree",
|
||||
"gateBlocks": "",
|
||||
"gateMode": "",
|
||||
"joinAll": "",
|
||||
@@ -6727,6 +6746,8 @@
|
||||
"joinMode": "",
|
||||
"joinQuorum": "",
|
||||
"mergeBoundaryNote": "",
|
||||
"parseArtifact": "Artifact",
|
||||
"parseParser": "Parser",
|
||||
"quorumN": "",
|
||||
"releaseCapacity": "",
|
||||
"releaseCondition": "",
|
||||
@@ -6734,7 +6755,13 @@
|
||||
"releaseExternal": "",
|
||||
"releaseManual": "",
|
||||
"releaseTimer": "",
|
||||
"splitNote": ""
|
||||
"reviewCode": "Code review",
|
||||
"reviewModel": "Review model (optional)",
|
||||
"reviewNote": "Verdicts route as outcome edges. Click an outgoing edge to set its verdict and rework behavior.",
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"splitNote": "",
|
||||
"stepExecuteLabel": "Step execute"
|
||||
},
|
||||
"workflows": {
|
||||
"duplicateToCustomize": "",
|
||||
|
||||
Reference in New Issue
Block a user