Files
fusion/packages/dashboard/app/components/workflow-flow-mapping.ts
gsxdsm eb67d08213 Address PR review feedback (#1363)
Greptile + CodeRabbit findings across core/engine/dashboard. Stale findings
(written against earlier commits) verified and skipped; valid ones fixed.

Engine:
- await-input: do not clear pausedReason in the /input route (the node's
  marker must survive unpause); the node clears it after consuming input.
  Embed a colon-free epoch watermark in the marker so only post-pause steering
  comments count as the reply (ISO timestamps collided with the colon
  separator and the dashboard question parser).
- gate nodes without a registered runner now fail closed (throw) instead of
  silently passing.
- a thrown interpreter error in maybeExecuteWorkflowGraph now falls back to the
  legacy pipeline instead of stranding the task in-progress.
- approved-CLI path clears the stale awaiting-cli-approval status/marker.

Core:
- persist+cascade workflow selection: purge task_workflow_selection rows and
  compiled workflow_steps on physical task deletes; migration 105 cleans
  already-orphaned rows; catch-cleanup for materialized steps when the owner
  write fails; WF-id allocation now in a BEGIN IMMEDIATE transaction.
- compiler validates the canonical execute->review->merge seam order (rejects
  duplicate/misordered seams).
- disk-backed reopen round-trip + tightened updatedAt/list assertions.

Dashboard:
- WorkflowSelector clears stale default/options across project changes and on
  fetch failure; InlineCreateCard/NewTaskModal reset the workflow on all
  clear/discard paths and include it in dirty-state.
- WorkflowNodeEditor: config-key deletion now persists; removed an invalid
  eslint-disable that was itself a hard lint error.
- TaskCard: single status badge for awaiting-input (no duplicate).
- WorkflowResultsTab: reset paused-action UI between pauses; surface
  resume/approve failures inline.
- TaskDetailModal: treat awaiting-user-input/awaiting-cli-approval/paused as
  not-in-progress for the live-log subscription.
- workflow-flow-mapping: don't write synthetic node names back into IR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:32:12 -07:00

110 lines
3.8 KiB
TypeScript

import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react";
import type { WorkflowIr, WorkflowDefinition } from "@fusion/core";
import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
/** Resolve the editor node "type" for an IR node (merge seam → "merge"). */
function editorKind(node: WorkflowIr["nodes"][number]): WorkflowEditorNodeKind {
const seam = node.config?.seam;
if (seam === "merge") return "merge";
return node.kind;
}
function nodeLabel(node: WorkflowIr["nodes"][number]): string {
const name = node.config?.name;
if (typeof name === "string" && name.trim()) return name;
if (node.config?.seam === "merge") return "Merge boundary";
return node.id;
}
/** Build React Flow nodes/edges from a stored workflow definition. */
export function irToFlow(def: WorkflowDefinition): {
nodes: FlowNode<WorkflowFlowNodeData>[];
edges: FlowEdge[];
} {
const nodes = def.ir.nodes.map((node, index): FlowNode<WorkflowFlowNodeData> => {
const pos = def.layout?.[node.id];
const kind = editorKind(node);
return {
id: node.id,
type: kind,
position: pos ?? { x: 80 + index * 180, y: 120 },
data: { kind, label: nodeLabel(node), config: { ...(node.config ?? {}) } },
deletable: node.kind !== "start" && node.kind !== "end",
};
});
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 },
};
});
return { nodes, edges };
}
/** Project React Flow nodes/edges back into a WorkflowIr plus a layout map. */
export function flowToIr(
name: string,
nodes: FlowNode<WorkflowFlowNodeData>[],
edges: FlowEdge[],
): { ir: WorkflowIr; layout: Record<string, { x: number; y: number }> } {
const irNodes: WorkflowIr["nodes"] = nodes.map((node) => {
const data = node.data;
const config: Record<string, unknown> = { ...(data.config ?? {}) };
// `irToFlow` synthesizes display labels for unnamed nodes (matching the
// fallback below), so only persist a label that the user actually set —
// otherwise saving an untouched workflow injects synthetic names like
// "start"/"end"/"Merge boundary" and breaks IR round-trips.
const fallbackLabel = data.kind === "merge" ? "Merge boundary" : node.id;
if (
data.kind !== "start" &&
data.kind !== "end" &&
data.label &&
data.label !== fallbackLabel
) {
config.name = data.label;
} else {
delete config.name;
}
if (data.kind === "merge") {
config.seam = "merge";
return { id: node.id, kind: "prompt", config };
}
return { id: node.id, kind: data.kind, config: Object.keys(config).length ? config : undefined };
});
const irEdges: WorkflowIr["edges"] = edges.map((edge) => {
const condition = (edge.data?.condition as string | undefined) ?? "success";
return { from: edge.source, to: edge.target, condition };
});
const layout = nodes.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;
}, {});
return { ir: { version: "v1", name, nodes: irNodes, edges: irEdges }, layout };
}
/** Seed graph for a brand-new workflow: start → end with room to insert steps. */
export function emptyWorkflowIr(name: string): WorkflowIr {
return {
version: "v1",
name,
nodes: [
{ id: "start", kind: "start" },
{ id: "end", kind: "end" },
],
edges: [{ from: "start", to: "end", condition: "success" }],
};
}
export function emptyWorkflowLayout(): Record<string, { x: number; y: number }> {
return { start: { x: 80, y: 140 }, end: { x: 460, y: 140 } };
}