diff --git a/packages/workflow-dashboard/src/components/thread-detail.tsx b/packages/workflow-dashboard/src/components/thread-detail.tsx index 1eebc0a..cbe97d8 100644 --- a/packages/workflow-dashboard/src/components/thread-detail.tsx +++ b/packages/workflow-dashboard/src/components/thread-detail.tsx @@ -53,6 +53,35 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map, nodeId: string): boolean { + const state = nodeStates.get(nodeId); + return state !== undefined && state !== "default"; +} + +function scrollToFirstRecord(): void { + const firstCard = document.querySelector('[data-record-index="0"]'); + if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" }); +} + +function scrollToRoleOccurrence( + nodeId: string, + indicesByRole: Map, + clickCycleRef: { current: Map }, + onHighlight: (role: string) => void, +): void { + const indices = indicesByRole.get(nodeId); + if (indices === undefined || indices.length === 0) return; + + const cycle = clickCycleRef.current.get(nodeId) ?? 0; + const idx = indices[cycle % indices.length]; + clickCycleRef.current.set(nodeId, cycle + 1); + + const el = document.querySelector(`[data-record-index="${idx}"]`); + if (el === null) return; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + onHighlight(nodeId); +} + export function ThreadDetail({ client, threadId, onBack }: Props) { const sse = useSSE(client, threadId); const { status, data, error } = useFetch(() => getThread(client, threadId), [client, threadId]); @@ -96,44 +125,29 @@ export function ThreadDetail({ client, threadId, onBack }: Props) { // Track which occurrence to jump to next per role (cycling) const clickCycleRef = useRef>(new Map()); + const highlightRole = useCallback((role: string) => { + if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current); + setHighlightedRole(role); + highlightTimerRef.current = setTimeout(() => { + setHighlightedRole(null); + highlightTimerRef.current = null; + }, 1500); + }, []); + const handleGraphNodeClick = useCallback( (nodeId: string) => { - // Only allow clicks on lit (non-default) nodes - if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return; - - // __start__: scroll to the first record (thread-start prompt) + if (!isClickableGraphNode(nodeStates, nodeId)) return; if (nodeId === "__start__") { - const firstCard = document.querySelector('[data-record-index="0"]'); - if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" }); + scrollToFirstRecord(); return; } - - // __end__: scroll to bottom if (nodeId === "__end__") { recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); return; } - - // Role nodes: cycle through occurrences - const indices = indicesByRole.get(nodeId); - if (indices === undefined || indices.length === 0) return; - - const cycle = clickCycleRef.current.get(nodeId) ?? 0; - const idx = indices[cycle % indices.length]; - clickCycleRef.current.set(nodeId, cycle + 1); - - const el = document.querySelector(`[data-record-index="${idx}"]`); - if (el !== null) { - el.scrollIntoView({ behavior: "smooth", block: "center" }); - if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current); - setHighlightedRole(nodeId); - highlightTimerRef.current = setTimeout(() => { - setHighlightedRole(null); - highlightTimerRef.current = null; - }, 1500); - } + scrollToRoleOccurrence(nodeId, indicesByRole, clickCycleRef, highlightRole); }, - [nodeStates, indicesByRole], + [nodeStates, indicesByRole, highlightRole], ); useEffect(() => { diff --git a/packages/workflow-dashboard/src/components/workflow-detail.tsx b/packages/workflow-dashboard/src/components/workflow-detail.tsx index 77d2530..76568e3 100644 --- a/packages/workflow-dashboard/src/components/workflow-detail.tsx +++ b/packages/workflow-dashboard/src/components/workflow-detail.tsx @@ -39,67 +39,119 @@ function resolveType(prop: Record): string { return String(prop.type ?? "unknown"); } +function variantLabel( + variantProps: Record>, + variantIndex: number, +): string { + for (const [pName, pDef] of Object.entries(variantProps)) { + if (pDef.const !== undefined) return `${pName}: ${String(pDef.const)}`; + } + return `Variant ${variantIndex + 1}`; +} + +function childPrefixForDepth(depth: number, parentPrefix: string): string { + return depth > 0 ? `${parentPrefix} ` : " "; +} + +function flattenOneOfVariants( + oneOf: Array>, + depth: number, + parentPrefix: string, + keyPrefix: string, +): SchemaRow[] { + const rows: SchemaRow[] = []; + for (let vi = 0; vi < oneOf.length; vi++) { + const variant = oneOf[vi]; + const variantProps = (variant.properties ?? {}) as Record>; + const isLast = vi === oneOf.length - 1; + const connector = isLast ? "└" : "├"; + rows.push({ + key: `${keyPrefix}variant-${vi}`, + name: `${parentPrefix}${connector} ${variantLabel(variantProps, vi)}`, + type: "", + description: "", + depth, + prefix: parentPrefix, + isVariantHeader: true, + }); + const variantChildPrefix = `${parentPrefix}${isLast ? " " : "│ "}`; + const variantRequired = new Set( + Array.isArray(variant.required) ? (variant.required as string[]) : [], + ); + for (const [pName, pDef] of Object.entries(variantProps)) { + if (pDef.const !== undefined) continue; + rows.push( + ...flattenProperty( + pName, + pDef, + depth + 1, + variantChildPrefix, + `${keyPrefix}v${vi}-`, + variantRequired, + ), + ); + } + } + return rows; +} + +function flattenSchemaProperties( + schema: Record, + depth: number, + parentPrefix: string, + keyPrefix: string, +): SchemaRow[] { + const props = (schema.properties ?? {}) as Record>; + const required = new Set( + Array.isArray(schema.required) ? (schema.required as string[]) : [], + ); + const rows: SchemaRow[] = []; + for (const [name, prop] of Object.entries(props)) { + rows.push(...flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required)); + } + return rows; +} + function flattenSchema( schema: Record, depth: number, parentPrefix: string, keyPrefix: string, ): SchemaRow[] { - const rows: SchemaRow[] = []; - - // Handle oneOf / discriminatedUnion const oneOf = schema.oneOf as Array> | undefined; if (Array.isArray(oneOf) && oneOf.length > 0) { - for (let vi = 0; vi < oneOf.length; vi++) { - const variant = oneOf[vi]; - const variantProps = (variant.properties ?? {}) as Record>; - let variantLabel = `Variant ${vi + 1}`; - for (const [pName, pDef] of Object.entries(variantProps)) { - if (pDef.const !== undefined) { - variantLabel = `${pName}: ${String(pDef.const)}`; - break; - } - } - const isLast = vi === oneOf.length - 1; - const connector = isLast ? "└" : "├"; - rows.push({ - key: `${keyPrefix}variant-${vi}`, - name: `${parentPrefix}${connector} ${variantLabel}`, - type: "", - description: "", - depth, - prefix: parentPrefix, - isVariantHeader: true, - }); - const childPrefix = `${parentPrefix}${isLast ? " " : "│ "}`; - const variantRequired = new Set( - Array.isArray(variant.required) ? (variant.required as string[]) : [], - ); - for (const [pName, pDef] of Object.entries(variantProps)) { - if (pDef.const !== undefined) continue; - const subRows = flattenProperty( - pName, - pDef, - depth + 1, - childPrefix, - `${keyPrefix}v${vi}-`, - variantRequired, - ); - rows.push(...subRows); - } - } - return rows; + return flattenOneOfVariants(oneOf, depth, parentPrefix, keyPrefix); + } + return flattenSchemaProperties(schema, depth, parentPrefix, keyPrefix); +} + +function flattenNestedPropertyRows( + name: string, + prop: Record, + depth: number, + parentPrefix: string, + keyPrefix: string, + hasOneOf: boolean, +): SchemaRow[] { + const childPrefix = childPrefixForDepth(depth, parentPrefix); + const nestedKeyPrefix = `${keyPrefix}${name}-`; + + if (prop.type === "object" && prop.properties !== undefined) { + return flattenSchema(prop as Record, depth + 1, childPrefix, nestedKeyPrefix); } - const props = (schema.properties ?? {}) as Record>; - const required = new Set( - Array.isArray(schema.required) ? (schema.required as string[]) : [], - ); - for (const [name, prop] of Object.entries(props)) { - const subRows = flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required); - rows.push(...subRows); + if (prop.type === "array") { + const items = prop.items as Record | undefined; + if (items !== undefined && items.type === "object" && items.properties !== undefined) { + return flattenSchema(items, depth + 1, childPrefix, nestedKeyPrefix); + } } - return rows; + + if (hasOneOf) { + return flattenSchema(prop as Record, depth + 1, childPrefix, nestedKeyPrefix); + } + + return []; } function flattenProperty( @@ -110,55 +162,23 @@ function flattenProperty( keyPrefix: string, required: Set, ): SchemaRow[] { - const rows: SchemaRow[] = []; const hasOneOf = Array.isArray(prop.oneOf) && (prop.oneOf as unknown[]).length > 0; let type = hasOneOf ? "⊕ oneOf" : resolveType(prop); if (!required.has(name)) type += "?"; - const description = String(prop.description ?? ""); - const displayName = depth > 0 ? `${parentPrefix}└─ ${name}` : name; - rows.push({ - key: `${keyPrefix}${name}`, - name: displayName, - type, - description, - depth, - prefix: parentPrefix, - isVariantHeader: false, - }); - - if (prop.type === "object" && prop.properties !== undefined) { - const childPrefix = depth > 0 ? `${parentPrefix} ` : " "; - rows.push( - ...flattenSchema( - prop as Record, - depth + 1, - childPrefix, - `${keyPrefix}${name}-`, - ), - ); - } - - if (prop.type === "array") { - const items = prop.items as Record | undefined; - if (items !== undefined && items.type === "object" && items.properties !== undefined) { - const childPrefix = depth > 0 ? `${parentPrefix} ` : " "; - rows.push(...flattenSchema(items, depth + 1, childPrefix, `${keyPrefix}${name}-`)); - } - } - - if (hasOneOf) { - const childPrefix = depth > 0 ? `${parentPrefix} ` : " "; - rows.push( - ...flattenSchema( - prop as Record, - depth + 1, - childPrefix, - `${keyPrefix}${name}-`, - ), - ); - } + const rows: SchemaRow[] = [ + { + key: `${keyPrefix}${name}`, + name: depth > 0 ? `${parentPrefix}└─ ${name}` : name, + type, + description: String(prop.description ?? ""), + depth, + prefix: parentPrefix, + isVariantHeader: false, + }, + ]; + rows.push(...flattenNestedPropertyRows(name, prop, depth, parentPrefix, keyPrefix, hasOneOf)); return rows; } diff --git a/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts b/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts index a688474..d89fef7 100644 --- a/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts +++ b/packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts @@ -36,6 +36,128 @@ function edgeKey(e: WorkflowGraphEdge): string { return `${e.from}->${e.to}::${e.condition}`; } +function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set { + const ids = new Set(); + for (const e of edges) { + ids.add(e.from); + ids.add(e.to); + } + return ids; +} + +function detectBackEdges(ids: Set, edges: readonly WorkflowGraphEdge[]): Set { + const WHITE = 0; + const GRAY = 1; + const BLACK = 2; + const backEdges = new Set(); + const color = new Map(); + for (const id of ids) color.set(id, WHITE); + + const fullAdj = new Map(); + for (const id of ids) fullAdj.set(id, []); + for (const e of edges) { + if (e.from !== e.to) fullAdj.get(e.from)?.push(e.to); + } + + function dfs(u: string): void { + color.set(u, GRAY); + for (const v of fullAdj.get(u) ?? []) { + const c = color.get(v) ?? WHITE; + if (c === GRAY) { + backEdges.add(`${u}->${v}`); + } else if (c === WHITE) { + dfs(v); + } + } + color.set(u, BLACK); + } + + if (ids.has(START_ID)) dfs(START_ID); + for (const id of ids) { + if ((color.get(id) ?? WHITE) === WHITE) dfs(id); + } + return backEdges; +} + +function buildDagAdjacency( + ids: Set, + edges: readonly WorkflowGraphEdge[], + backEdges: Set, +): Map { + const adj = new Map(); + for (const id of ids) adj.set(id, []); + for (const e of edges) { + if (e.from === e.to) continue; + if (backEdges.has(`${e.from}->${e.to}`)) continue; + adj.get(e.from)?.push(e.to); + } + return adj; +} + +function computeInDegrees(ids: Set, adj: Map): Map { + const inDegree = new Map(); + for (const id of ids) inDegree.set(id, 0); + for (const id of ids) { + for (const next of adj.get(id) ?? []) { + inDegree.set(next, (inDegree.get(next) ?? 0) + 1); + } + } + return inDegree; +} + +function relaxLongestPathNeighbors( + cur: string, + curRank: number, + adj: Map, + rank: Map, + inDegree: Map, + queue: string[], +): void { + for (const next of adj.get(cur) ?? []) { + const prevRank = rank.get(next) ?? 0; + if (curRank + 1 > prevRank) rank.set(next, curRank + 1); + const deg = (inDegree.get(next) ?? 1) - 1; + inDegree.set(next, deg); + if (deg === 0) queue.push(next); + } +} + +function longestPathRanks(ids: Set, adj: Map): Map { + const inDegree = computeInDegrees(ids, adj); + const rank = new Map(); + const queue: string[] = []; + for (const id of ids) { + if ((inDegree.get(id) ?? 0) === 0) { + queue.push(id); + rank.set(id, 0); + } + } + + while (queue.length > 0) { + const cur = queue.shift(); + if (cur === undefined) break; + relaxLongestPathNeighbors(cur, rank.get(cur) ?? 0, adj, rank, inDegree, queue); + } + return rank; +} + +function compareLayerNodes(a: string, b: string): number { + if (a === START_ID) return -1; + if (b === START_ID) return 1; + if (a === END_ID) return 1; + if (b === END_ID) return -1; + return a.localeCompare(b); +} + +function ranksToLayers(rank: Map): string[][] { + const maxRank = Math.max(...[...rank.values()], 0); + const layers: string[][] = []; + for (let r = 0; r <= maxRank; r++) layers.push([]); + for (const [id, r] of rank) layers[r].push(id); + for (const layer of layers) layer.sort(compareLayerNodes); + return layers.filter((l) => l.length > 0); +} + // ── Strategy 1: Longest-path layering (Sugiyama step 1) ───────────── /** @@ -49,123 +171,11 @@ function edgeKey(e: WorkflowGraphEdge): string { * on the resulting DAG, then the removed edges become feedback edges. */ function computeLayersLongestPath(edges: readonly WorkflowGraphEdge[]): string[][] { - // Collect all node IDs - const ids = new Set(); - for (const e of edges) { - ids.add(e.from); - ids.add(e.to); - } - - // Build adjacency (excluding self-loops) - const adj = new Map(); - const inEdges = new Map(); - for (const id of ids) { - adj.set(id, []); - inEdges.set(id, []); - } - // Detect back-edges via DFS to break cycles - const backEdges = new Set(); - { - const WHITE = 0; - const GRAY = 1; - const BLACK = 2; - const color = new Map(); - for (const id of ids) color.set(id, WHITE); - - // Temporary full adjacency for cycle detection - const fullAdj = new Map(); - for (const id of ids) fullAdj.set(id, []); - for (const e of edges) { - if (e.from !== e.to) fullAdj.get(e.from)?.push(e.to); - } - - function dfs(u: string): void { - color.set(u, GRAY); - for (const v of fullAdj.get(u) ?? []) { - const c = color.get(v) ?? WHITE; - if (c === GRAY) { - // Back-edge: u -> v where v is an ancestor - backEdges.add(`${u}->${v}`); - } else if (c === WHITE) { - dfs(v); - } - } - color.set(u, BLACK); - } - - // Start DFS from __start__ first for determinism - if (ids.has(START_ID)) dfs(START_ID); - for (const id of ids) { - if ((color.get(id) ?? WHITE) === WHITE) dfs(id); - } - } - - // Build DAG adjacency (without back-edges) - for (const e of edges) { - if (e.from === e.to) continue; - if (backEdges.has(`${e.from}->${e.to}`)) continue; - adj.get(e.from)?.push(e.to); - inEdges.get(e.to)?.push(e.from); - } - - // Longest-path ranking via topological order (Kahn's algorithm) - const inDegree = new Map(); - for (const id of ids) inDegree.set(id, 0); - for (const id of ids) { - for (const next of adj.get(id) ?? []) { - inDegree.set(next, (inDegree.get(next) ?? 0) + 1); - } - } - - const rank = new Map(); - const queue: string[] = []; - for (const id of ids) { - if ((inDegree.get(id) ?? 0) === 0) { - queue.push(id); - rank.set(id, 0); - } - } - - while (queue.length > 0) { - const cur = queue.shift()!; - const curRank = rank.get(cur) ?? 0; - for (const next of adj.get(cur) ?? []) { - // Longest path: take max - const prevRank = rank.get(next) ?? 0; - if (curRank + 1 > prevRank) { - rank.set(next, curRank + 1); - } - const deg = (inDegree.get(next) ?? 1) - 1; - inDegree.set(next, deg); - if (deg === 0) { - queue.push(next); - } - } - } - - // Group by rank - const maxRank = Math.max(...[...rank.values()], 0); - const layers: string[][] = []; - for (let r = 0; r <= maxRank; r++) { - layers.push([]); - } - for (const [id, r] of rank) { - layers[r].push(id); - } - - // Sort within layers alphabetically for stability, but __start__ first, __end__ last - for (const layer of layers) { - layer.sort((a, b) => { - if (a === START_ID) return -1; - if (b === START_ID) return 1; - if (a === END_ID) return 1; - if (b === END_ID) return -1; - return a.localeCompare(b); - }); - } - - // Remove empty layers - return layers.filter((l) => l.length > 0); + const ids = collectNodeIds(edges); + const backEdges = detectBackEdges(ids, edges); + const adj = buildDagAdjacency(ids, edges, backEdges); + const rank = longestPathRanks(ids, adj); + return ranksToLayers(rank); } // ── Shared helpers ────────────────────────────────────────────────── @@ -201,132 +211,164 @@ function buildTerminalNode( }; } -// ── Longest-path layout (uses same edge-building as before) ───────── +type EdgeLayoutContext = { + rank: Map; + nodePositions: Map; + centerX: number; + routedCountByTarget: Map; +}; -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: layout logic is inherently branchy -function computeLayoutLongestPath(input: LayoutInput): LayoutResult { - const layers = computeLayersLongestPath(input.edges); +function computeEdgeLabelPosition( + e: WorkflowGraphEdge, + ctx: EdgeLayoutContext, + isFeedback: boolean, + isSkipForward: boolean, + isSelfLoop: boolean, +): { labelX: number | null; labelY: number | null; feedbackSide: "right" | "left" | null } { + const sourcePos = ctx.nodePositions.get(e.from); + const targetPos = ctx.nodePositions.get(e.to); + if (sourcePos === undefined || targetPos === undefined) { + return { labelX: null, labelY: null, feedbackSide: null }; + } - // Flatten layers into a rank map (layer index = rank) + if (isFeedback || isSkipForward) { + const count = ctx.routedCountByTarget.get(e.to) ?? 0; + ctx.routedCountByTarget.set(e.to, count + 1); + const feedbackSide = count % 2 === 0 ? "right" : "left"; + const offsetX = + feedbackSide === "right" + ? ctx.centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X + : ctx.centerX - ROLE_NODE_WIDTH / 2 - FEEDBACK_OFFSET_X; + const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2; + return { labelX: offsetX, labelY: midY, feedbackSide }; + } + + if (isSelfLoop) { + return { labelX: null, labelY: null, feedbackSide: null }; + } + + const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2; + return { labelX: ctx.centerX, labelY: midY, feedbackSide: null }; +} + +function buildConditionEdge(e: WorkflowGraphEdge, ctx: EdgeLayoutContext): Edge { + const isFallback = e.condition === "FALLBACK"; + const isSelfLoop = e.from === e.to; + const sourceRank = ctx.rank.get(e.from) ?? 0; + const targetRank = ctx.rank.get(e.to) ?? 0; + const isFeedback = !isSelfLoop && targetRank <= sourceRank; + const isSkipForward = !isSelfLoop && !isFeedback && targetRank - sourceRank > 1; + const routed = isFeedback || isSkipForward; + + const { labelX, labelY, feedbackSide } = computeEdgeLabelPosition( + e, + ctx, + isFeedback, + isSkipForward, + isSelfLoop, + ); + + return { + id: edgeKey(e), + source: e.from, + target: e.to, + sourceHandle: routed ? (feedbackSide === "left" ? "left-out" : "right-out") : "bottom-out", + targetHandle: routed ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in", + type: "condition", + data: { + condition: e.condition, + conditionDescription: e.conditionDescription, + isFallback, + isFeedback: routed, + isSelfLoop, + feedbackSide, + labelX, + labelY, + }, + }; +} + +const LAYER_H_GAP = 40; + +type NodePosition = { x: number; y: number; w: number; h: number }; + +function layerIndexRank(layers: string[][]): Map { const rank = new Map(); for (let i = 0; i < layers.length; i++) { - for (const id of layers[i]) { - rank.set(id, i); - } + for (const id of layers[i]) rank.set(id, i); } + return rank; +} - // Horizontal gap between nodes in the same layer - const H_GAP = 40; - - // Position nodes: each layer is a horizontal row - const nodePositions = new Map(); - - // Find max layer width for centering - const layerWidths: number[] = []; - for (const layer of layers) { +function computeLayerWidths(layers: string[][], hGap: number): number[] { + return layers.map((layer) => { let w = 0; - for (const id of layer) { - w += nodeSize(id).width; - } - w += (layer.length - 1) * H_GAP; - layerWidths.push(w); - } - const maxLayerWidth = Math.max(...layerWidths, ROLE_NODE_WIDTH); - const centerX = maxLayerWidth / 2; + for (const id of layer) w += nodeSize(id).width; + return w + (layer.length - 1) * hGap; + }); +} +function layoutNodePositions( + layers: string[][], + layerWidths: number[], + centerX: number, + hGap: number, +): Map { + const nodePositions = new Map(); let y = 0; for (let li = 0; li < layers.length; li++) { const layer = layers[li]; - const totalWidth = layerWidths[li]; - let x = centerX - totalWidth / 2; + let x = centerX - layerWidths[li] / 2; let maxH = 0; for (const id of layer) { const size = nodeSize(id); nodePositions.set(id, { x, y, w: size.width, h: size.height }); - x += size.width + H_GAP; + x += size.width + hGap; if (size.height > maxH) maxH = size.height; } y += maxH + LAYER_GAP; } + return nodePositions; +} - // Build nodes +function buildLayoutNodes( + layers: string[][], + nodePositions: Map, + input: LayoutInput, +): Node[] { const nodes: Node[] = []; for (const layer of layers) { for (const id of layer) { const pos = nodePositions.get(id); if (pos === undefined) continue; const state = input.nodeStates.get(id) ?? "default"; + const xy = { x: pos.x, y: pos.y }; if (id === START_ID || id === END_ID) { - nodes.push(buildTerminalNode(id, { x: pos.x, y: pos.y }, state)); + nodes.push(buildTerminalNode(id, xy, state)); } else { - nodes.push(buildRoleNode(id, { x: pos.x, y: pos.y }, input.roles, state)); + nodes.push(buildRoleNode(id, xy, input.roles, state)); } } } + return nodes; +} - // Build edges with label positions - const routedCountByTarget = new Map(); - const edges: Edge[] = input.edges.map((e) => { - const isFallback = e.condition === "FALLBACK"; - const isSelfLoop = e.from === e.to; - const sourceRank = rank.get(e.from) ?? 0; - const targetRank = rank.get(e.to) ?? 0; - const isFeedback = !isSelfLoop && targetRank <= sourceRank; - const isSkipForward = !isSelfLoop && !isFeedback && targetRank - sourceRank > 1; - - const sourcePos = nodePositions.get(e.from); - const targetPos = nodePositions.get(e.to); - - let labelX: number | null = null; - let labelY: number | null = null; - let feedbackSide: "right" | "left" | null = null; - - if (sourcePos !== undefined && targetPos !== undefined) { - if (isFeedback || isSkipForward) { - const count = routedCountByTarget.get(e.to) ?? 0; - routedCountByTarget.set(e.to, count + 1); - feedbackSide = count % 2 === 0 ? "right" : "left"; - const offsetX = - feedbackSide === "right" - ? centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X - : centerX - ROLE_NODE_WIDTH / 2 - FEEDBACK_OFFSET_X; - const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2; - labelX = offsetX; - labelY = midY; - } else if (!isSelfLoop) { - const midX = centerX; - const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2; - labelX = midX; - labelY = midY; - } - } - - return { - id: edgeKey(e), - source: e.from, - target: e.to, - sourceHandle: - isFeedback || isSkipForward - ? feedbackSide === "left" - ? "left-out" - : "right-out" - : "bottom-out", - targetHandle: - isFeedback || isSkipForward ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in", - type: "condition", - data: { - condition: e.condition, - conditionDescription: e.conditionDescription, - isFallback, - isFeedback: isFeedback || isSkipForward, - isSelfLoop, - feedbackSide, - labelX, - labelY, - }, - }; - }); +// ── Longest-path layout (uses same edge-building as before) ───────── +function computeLayoutLongestPath(input: LayoutInput): LayoutResult { + const layers = computeLayersLongestPath(input.edges); + const rank = layerIndexRank(layers); + const layerWidths = computeLayerWidths(layers, LAYER_H_GAP); + const centerX = Math.max(...layerWidths, ROLE_NODE_WIDTH) / 2; + const nodePositions = layoutNodePositions(layers, layerWidths, centerX, LAYER_H_GAP); + const nodes = buildLayoutNodes(layers, nodePositions, input); + const edgeCtx: EdgeLayoutContext = { + rank, + nodePositions, + centerX, + routedCountByTarget: new Map(), + }; + const edges: Edge[] = input.edges.map((e) => buildConditionEdge(e, edgeCtx)); return { nodes, edges }; } diff --git a/packages/workflow-register/src/bundle/workflow-descriptor.ts b/packages/workflow-register/src/bundle/workflow-descriptor.ts index e8cebbc..85e5e2e 100644 --- a/packages/workflow-register/src/bundle/workflow-descriptor.ts +++ b/packages/workflow-register/src/bundle/workflow-descriptor.ts @@ -60,6 +60,30 @@ function validateDescriptorGraph(graphRaw: unknown): Result { + if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) { + return err(`descriptor.roles.${roleName} must be a non-array object`); + } + const spec = specUnknown as Record; + const roleDesc = spec.description; + if (typeof roleDesc !== "string") { + return err(`descriptor.roles.${roleName}.description must be a string`); + } + const schema = spec.schema; + if (schema === null || typeof schema !== "object" || Array.isArray(schema)) { + return err(`descriptor.roles.${roleName}.schema must be a non-array object`); + } + const systemPrompt = typeof spec.systemPrompt === "string" ? spec.systemPrompt : ""; + return ok({ + description: roleDesc, + systemPrompt, + schema: schema as WorkflowRoleSchema, + }); +} + export function validateWorkflowDescriptor(value: unknown): Result { if (value === null || typeof value !== "object" || Array.isArray(value)) { return err("descriptor must be a non-array object"); @@ -76,24 +100,11 @@ export function validateWorkflowDescriptor(value: unknown): Result = {}; for (const [roleName, specUnknown] of Object.entries(rolesRaw)) { - if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) { - return err(`descriptor.roles.${roleName} must be a non-array object`); + const roleResult = validateDescriptorRole(roleName, specUnknown); + if (!roleResult.ok) { + return roleResult; } - const spec = specUnknown as Record; - const roleDesc = spec.description; - if (typeof roleDesc !== "string") { - return err(`descriptor.roles.${roleName}.description must be a string`); - } - const schema = spec.schema; - if (schema === null || typeof schema !== "object" || Array.isArray(schema)) { - return err(`descriptor.roles.${roleName}.schema must be a non-array object`); - } - const systemPrompt = typeof spec.systemPrompt === "string" ? spec.systemPrompt : ""; - roles[roleName] = { - description: roleDesc, - systemPrompt, - schema: schema as WorkflowRoleSchema, - }; + roles[roleName] = roleResult.value; } const graphResult = validateDescriptorGraph(root.graph);