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>
110 lines
3.8 KiB
TypeScript
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 } };
|
|
}
|