import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs'; import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagram, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs'; import { throwDiagnosticProblems } from '../shared/diagnostics.mjs'; import { resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs'; import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs'; import { asArray, isFinitePoint, rectsOverlap, cleanFlowProblems, cleanCrossingProblems, cleanAmbiguousCorridorProblems, cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, suggestLabelObstacleFix, suggestLabelPairFix, anchor, automaticPortSpread, defaultFromSide, defaultToSide, chosenSide, polylinePath, routePointsValue, labelPoint, componentFill, componentText, arrowClassMap, variantAccent } from '../shared/geometry.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const { diagram: workflow, template, outPath } = loadDiagram({ rendererDir: __dirname, diagramType: 'workflow', defaultExample: 'agent-tool-call.workflow.json' }); const layout = { laneX: 40, laneY: 52, laneW: 640, laneH: 104, laneGap: 20, laneTitleH: 30, colXs: [88, 220, 300, 430, 500, 625], nodeW: 92, nodeH: 52 }; // Content is 680px wide (laneX + laneW); auto height fits the lanes plus legend. const autoHeight = layout.laneY + (workflow.lanes?.length || 1) * layout.laneH + ((workflow.lanes?.length || 1) - 1) * layout.laneGap + 124; const viewBox = workflow.meta?.viewBox || [720, autoHeight]; const laneIndex = new Map(asArray(workflow.lanes).map((lane, index) => [lane.id, index])); const laneLabels = new Map(asArray(workflow.lanes).map((lane) => [lane.id, lane.label])); function nodeContext(node) { const group = asArray(workflow.groups).find((candidate) => ( candidate.lane === node.lane && node.col >= candidate.fromCol && node.col <= candidate.toCol )); const phase = asArray(workflow.phases).find((candidate) => ( node.col >= candidate.fromCol && node.col <= candidate.toCol )); return [laneLabels.get(node.lane), group?.label, phase?.label].filter(Boolean).join(' › ') || 'Workflow node'; } function laneTop(id) { return layout.laneY + laneIndex.get(id) * (layout.laneH + layout.laneGap); } function lastLaneBottom() { return layout.laneY + workflow.lanes.length * layout.laneH + (workflow.lanes.length - 1) * layout.laneGap; } function legendY() { return lastLaneBottom() + 44; } function measureNode(node) { const width = node.width || layout.nodeW; const height = node.height || (node.tag ? 68 : layout.nodeH); const cx = layout.colXs[node.col]; const contentH = layout.laneH - layout.laneTitleH; const y = laneTop(node.lane) + layout.laneTitleH + (contentH - height) / 2 + (node.yOffset || 0); return { ...node, width, height, x: cx - width / 2, y, cx, cy: y + height / 2 }; } // Font sizes for this renderer's node text; the fitting geometry is shared. const nodeTextFit = { labelPreferred: 11, labelMinimum: 9, sublabelPreferred: 8, sublabelMinimum: 6, tagPreferred: 7, tagMinimum: 6, }; const nodes = new Map(asArray(workflow.nodes).map((node) => [node.id, measureNode(node)])); function workflowCompositionFrames() { const frames = []; for (const [index, lane] of asArray(workflow.lanes).entries()) { const y = layout.laneY + index * (layout.laneH + layout.laneGap); frames.push({ id: `lane-${index}`, label: lane.label, kind: 'lane', x: layout.laneX, y, width: layout.laneW, height: layout.laneH, radius: 10 }); if (lane.variant === 'exception') { frames.push({ id: `lane-${index}-exception`, label: `${lane.label} exception`, kind: 'exception-lane', x: layout.laneX + 6, y: y + 6, width: layout.laneW - 12, height: layout.laneH - 12, radius: 8 }); } } for (const [index, group] of asArray(workflow.groups).entries()) { const span = spanForCols(group.fromCol, group.toCol, 50); frames.push({ id: `group-${index}`, label: group.label, kind: 'group', x: span.x, y: laneTop(group.lane) + layout.laneTitleH + 8, width: span.width, height: layout.laneH - layout.laneTitleH - 16, radius: 9, }); } return frames; } const mainPathSteps = new Map(asArray(workflow.mainPath).map((id, index) => [id, index])); const edgeSteps = new Map(asArray(workflow.edges).map((edge, index) => { const fromStep = mainPathSteps.get(edge.from); const toStep = mainPathSteps.get(edge.to); const mainStep = Number.isInteger(fromStep) && toStep === fromStep + 1 ? fromStep : null; return [edge, mainStep ?? asArray(workflow.mainPath).length + index]; })); function nodeStep(node) { return mainPathSteps.get(node.id) ?? asArray(workflow.mainPath).length + asArray(workflow.nodes).findIndex((item) => item.id === node.id); } function validateWorkflow() { const problems = []; if (workflow.schema_version !== 1) { problems.push('Workflow files must set "schema_version": 1.'); } if (workflow.diagram_type !== 'workflow') { problems.push(`Unsupported diagram_type "${workflow.diagram_type}". Expected "workflow".`); } if (!workflow.meta || !workflow.meta.title) { problems.push('Workflow files must include meta.title.'); } if (!Array.isArray(workflow.lanes) || !workflow.lanes.length) { problems.push('Workflow files must include at least one lane.'); } if (!Array.isArray(workflow.nodes)) { problems.push('Workflow files must include a nodes array.'); } if (!Array.isArray(workflow.edges)) { problems.push('Workflow files must include an edges array.'); } if (workflow.phases !== undefined && !Array.isArray(workflow.phases)) { problems.push('Workflow "phases" must be an array.'); } if (workflow.groups !== undefined && !Array.isArray(workflow.groups)) { problems.push('Workflow "groups" must be an array.'); } if (workflow.mainPath !== undefined && !Array.isArray(workflow.mainPath)) { problems.push('Workflow "mainPath" must be an array of node ids.'); } if (workflow.cards !== undefined && !Array.isArray(workflow.cards)) { problems.push('Workflow "cards" must be an array.'); } if (problems.length) { throwDiagnosticProblems('Workflow layout validation failed', problems, { subject: { diagramType: 'workflow' }, }); } const laneIds = new Set(workflow.lanes.map((lane) => lane.id)); if (laneIds.size !== workflow.lanes.length) { problems.push('Lane ids must be unique.'); } if (nodes.size !== workflow.nodes.length) { problems.push('Node ids must be unique.'); } const phaseIds = new Set(asArray(workflow.phases).map((phase) => phase.id)); if (phaseIds.size !== asArray(workflow.phases).length) { problems.push('Phase ids must be unique.'); } const groupIds = new Set(asArray(workflow.groups).map((group) => group.id)); if (groupIds.size !== asArray(workflow.groups).length) { problems.push('Group ids must be unique.'); } for (const node of nodes.values()) { if (!laneIds.has(node.lane)) { problems.push(`Node "${node.id}" uses unknown lane "${node.lane}".`); continue; } if (!Number.isInteger(node.col) || node.col < 0 || node.col >= layout.colXs.length) { problems.push(`Node "${node.id}" uses column ${node.col}, but valid columns are integers 0..${layout.colXs.length - 1}.`); continue; } if (!isFinitePoint(node.x, node.y, node.cx, node.cy)) { problems.push(`Node "${node.id}" produced non-finite coordinates — check col, width, height, and yOffset are numbers.`); continue; } const estLabelW = textUnits(node.label) * 6.8; if (estLabelW > node.width + 6) { problems.push(`Label "${node.label}" (~${Math.round(estLabelW)}px) is wider than node "${node.id}" (${node.width}px) — shorten the label or increase node.width.`); } const availableTextW = availableNodeTextWidth(node.width); for (const [field, value, minimum] of [ ['Sublabel', node.sublabel, nodeTextFit.sublabelMinimum], ['Tag', node.tag, nodeTextFit.tagMinimum], ]) { if (!value) continue; const minimumW = minimumNodeTextWidth(value, minimum); if (minimumW > availableTextW) { problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but node "${node.id}" provides ${availableTextW}px — shorten the ${field.toLowerCase()} or increase node.width.`); } } const top = laneTop(node.lane); const contentTop = top + layout.laneTitleH; const laneRight = layout.laneX + layout.laneW; if (node.x < layout.laneX || node.x + node.width > laneRight) { problems.push(`Node "${node.id}" exceeds the horizontal bounds of lane "${node.lane}".`); } if (node.y < contentTop || node.y + node.height > top + layout.laneH) { problems.push(`Node "${node.id}" collides with the title or boundary of lane "${node.lane}".`); } } const phaseRanges = []; for (const phase of asArray(workflow.phases)) { if (!Number.isInteger(phase.fromCol) || !Number.isInteger(phase.toCol)) { problems.push(`Phase "${phase.id}" must use integer fromCol/toCol values.`); continue; } if (phase.fromCol < 0 || phase.toCol >= layout.colXs.length || phase.fromCol > phase.toCol) { problems.push(`Phase "${phase.id}" uses invalid columns ${phase.fromCol}..${phase.toCol}; use an ordered range within 0..${layout.colXs.length - 1}.`); } else { phaseRanges.push(phase); } const estLabelW = textUnits(phase.label) * 5.6; const width = spanForCols(phase.fromCol, phase.toCol).width; if (estLabelW > width + 8) { problems.push(`Phase label "${phase.label}" (~${Math.round(estLabelW)}px) is wider than its ${Math.round(width)}px span — shorten the label or widen the phase range.`); } } phaseRanges.sort((a, b) => a.fromCol - b.fromCol || a.toCol - b.toCol); for (let i = 0; i < phaseRanges.length; i += 1) { for (let j = i + 1; j < phaseRanges.length; j += 1) { const earlier = phaseRanges[i]; const later = phaseRanges[j]; if (later.fromCol > earlier.toCol) break; problems.push(`Phase "${later.id}" (${later.fromCol}..${later.toCol}) overlaps phase "${earlier.id}" (${earlier.fromCol}..${earlier.toCol}) — start at col ${earlier.toCol + 1} or later, or end the earlier phase at col ${later.fromCol - 1}.`); } } for (const group of asArray(workflow.groups)) { if (!laneIds.has(group.lane)) { problems.push(`Group "${group.id}" uses unknown lane "${group.lane}".`); continue; } if (!Number.isInteger(group.fromCol) || !Number.isInteger(group.toCol)) { problems.push(`Group "${group.id}" must use integer fromCol/toCol values.`); continue; } if (group.fromCol < 0 || group.toCol >= layout.colXs.length || group.fromCol > group.toCol) { problems.push(`Group "${group.id}" uses invalid columns ${group.fromCol}..${group.toCol}; use an ordered range within 0..${layout.colXs.length - 1}.`); } const contained = [...nodes.values()].some((node) => node.lane === group.lane && node.col >= group.fromCol && node.col <= group.toCol); if (!contained) { problems.push(`Group "${group.id}" does not contain any nodes — align its lane/columns with the parallel or branch work it frames.`); } } const byLane = new Map(); for (const node of nodes.values()) { byLane.set(node.lane, [...(byLane.get(node.lane) || []), node]); } for (const [lane, laneNodes] of byLane) { for (let i = 0; i < laneNodes.length; i += 1) { for (let j = i + 1; j < laneNodes.length; j += 1) { if (rectsOverlap(laneNodes[i], laneNodes[j], 8)) { problems.push(`Nodes "${laneNodes[i].id}" and "${laneNodes[j].id}" are less than 8px apart in lane "${lane}" — move one to another col, adjust yOffset, or reduce width/height.`); } } } } for (const edge of workflow.edges) { if (!nodes.has(edge.from)) problems.push(`Edge "${edge.label || edge.from}" references unknown source "${edge.from}".`); if (!nodes.has(edge.to)) problems.push(`Edge "${edge.label || edge.to}" references unknown target "${edge.to}".`); if (nodes.has(edge.from) && nodes.has(edge.to)) { const routed = pathFor(edge); if (routed.points.length === 2) { const [start, end] = routed.points; const segmentLength = Math.hypot(end[0] - start[0], end[1] - start[1]); if (segmentLength < 28) { problems.push(`Edge "${edge.from}" -> "${edge.to}" is too short (${Math.round(segmentLength)}px; minimum 28px) — drop its label or route it through a channel.`); } } } } problems.push(...cleanFlowProblems({ relations: workflow.edges, endpointIds: new Set(nodes.keys()), obstacles: nodes.values(), pathFor, diagramType: 'workflow', relationCollection: 'edges', obstacleKind: 'node', profile: workflow.meta?.quality_profile, routeHint: 'adjust fromSide/toSide, set route/via or channel coordinates, or move the node to a clearer lane/column' })); problems.push(...cleanCrossingProblems({ relations: workflow.edges, endpointIds: new Set(nodes.keys()), pathFor, diagramType: 'workflow', relationCollection: 'edges', profile: workflow.meta?.quality_profile, routeHint: 'adjust route/via, bias, or channel coordinates so the edges use separate lane corridors' })); problems.push(...cleanAmbiguousCorridorProblems({ relations: workflow.edges, endpointIds: new Set(nodes.keys()), pathFor, diagramType: 'workflow', relationCollection: 'edges', profile: workflow.meta?.quality_profile, routeHint: 'adjust route/via, bias, or channel coordinates so unrelated edges do not visually merge' })); problems.push(...cleanBorderRunProblems({ relations: workflow.edges, endpointIds: new Set(nodes.keys()), frames: workflowCompositionFrames(), pathFor, diagramType: 'workflow', relationCollection: 'edges', profile: workflow.meta?.quality_profile, routeHint: 'adjust route/via, bias, or channel coordinates so the edge crosses the lane or group perpendicularly instead of following its border' })); problems.push(...cleanRouteRhythmProblems({ relations: workflow.edges, endpointIds: new Set(nodes.keys()), pathFor, diagramType: 'workflow', relationCollection: 'edges', profile: workflow.meta?.quality_profile, routeHint: 'adjust route/via, bias, or channel coordinates so each turn has a readable run-up' })); if (Array.isArray(workflow.mainPath)) { for (const id of workflow.mainPath) { if (!nodes.has(id)) { problems.push(`mainPath references unknown node "${id}".`); } } for (let i = 0; i < workflow.mainPath.length - 1; i += 1) { const fromId = workflow.mainPath[i]; const toId = workflow.mainPath[i + 1]; const from = nodes.get(fromId); const to = nodes.get(toId); if (!from || !to) continue; const linked = workflow.edges.some((edge) => edge.from === fromId && edge.to === toId); if (!linked) { problems.push(`mainPath step "${fromId}" -> "${toId}" has no matching edge — add the edge or remove the pair from mainPath.`); } if (to.col < from.col) { problems.push(`mainPath step "${fromId}" -> "${toId}" moves backward from col ${from.col} to ${to.col} — use a return edge outside mainPath for loops.`); } } } const labelRects = []; for (const [edgeIndex, edge] of workflow.edges.entries()) { if (!edge.label || !nodes.has(edge.from) || !nodes.has(edge.to)) continue; const [lx, ly] = labelPoint(edge, pathFor(edge).points); const width = Math.max(30, textUnits(edge.label) * 4.8 + 10); labelRects.push({ relation: edge, relationIndex: edgeIndex, label: edge.label, x: lx - width / 2, y: ly - 10, width, height: 14, lx, ly }); } for (const rect of labelRects) { for (const node of nodes.values()) { if (rectsOverlap(rect, node, -2)) { problems.push(`Label "${rect.label}" overlaps node "${node.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, node, 'node')}`); } } } for (let i = 0; i < labelRects.length; i += 1) { for (let j = i + 1; j < labelRects.length; j += 1) { if (rectsOverlap(labelRects[i], labelRects[j], -2)) { problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — adjust labelDx/labelDy or remove one label.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`); } } } problems.push(...cleanLabelRouteClearanceProblems({ relations: workflow.edges, labels: labelRects, endpointIds: new Set(nodes.keys()), pathFor, diagramType: 'workflow', relationCollection: 'edges', profile: workflow.meta?.quality_profile, })); if (viewBox[0] < layout.laneX + layout.laneW + 16) { problems.push(`viewBox width ${viewBox[0]} clips the ${layout.laneW}px lanes — set meta.viewBox[0] to at least ${layout.laneX + layout.laneW + 16}.`); } if (legendY() + 18 > viewBox[1]) { problems.push(`Legend exceeds viewBox height ${viewBox[1]} — set meta.viewBox[1] to at least ${legendY() + 18}.`); } if (problems.length) { throwDiagnosticProblems('Workflow layout validation failed', problems, { subject: { diagramType: 'workflow' }, }); } } function gapYBetween(fromLane, toLane, bias = 0.5) { const a = laneTop(fromLane) + layout.laneH; const b = laneTop(toLane); return a + (b - a) * bias; } function spanForCols(fromCol, toCol, pad = 46) { const start = layout.colXs[fromCol] - pad; const end = layout.colXs[toCol] + pad; return { x: start, width: end - start, cx: (start + end) / 2 }; } function sameLaneAutoVia(start, end) { if (start[0] === end[0] || start[1] === end[1]) return []; const midX = (start[0] + end[0]) / 2; return [[midX, start[1]], [midX, end[1]]]; } function routeVia(edge, from, to, start, end) { if (edge.via) return edge.via; switch (edge.route || 'auto') { case 'straight': return []; case 'drop': { const y = gapYBetween(from.lane, to.lane, edge.bias ?? 0.5); return [[start[0], y], [end[0], y]]; } case 'outside-right': { const x = edge.channelX ?? layout.laneX + layout.laneW + 12; return [[x, start[1]], [x, end[1]]]; } case 'return-left': { const x = edge.channelX ?? Math.min(from.x, to.x) - 28; return [[x, start[1]], [x, end[1]]]; } case 'bottom-channel': { const y = edge.channelY ?? Math.max(from.y + from.height, to.y + to.height) + 32; return [[start[0], y], [end[0], y]]; } case 'up-channel': { const y = edge.channelY ?? Math.min(from.y, to.y) - 28; return [[start[0], y], [end[0], y]]; } case 'auto': default: { if (from.lane === to.lane) return sameLaneAutoVia(start, end); const y = gapYBetween(from.lane, to.lane, edge.bias ?? 0.5); return [[start[0], y], [end[0], y]]; } } } const pathCache = new Map(); const automaticPorts = automaticPortSpread(workflow.edges, nodes); function pathFor(edge) { if (pathCache.has(edge)) return pathCache.get(edge); const from = nodes.get(edge.from); const to = nodes.get(edge.to); const ports = automaticPorts.get(edge); const start = ports?.from || anchor(from, chosenSide(edge.fromSide, defaultFromSide(from, to))); const end = ports?.to || anchor(to, chosenSide(edge.toSide, defaultToSide(from, to))); const points = [start, ...routeVia(edge, from, to, start, end), end]; const routed = { d: polylinePath(points), points }; pathCache.set(edge, routed); return routed; } function renderLane(lane, index) { const y = layout.laneY + index * (layout.laneH + layout.laneGap); const exception = lane.variant === 'exception' ? `\n ` : ''; const labelClass = lane.variant === 'exception' ? 't-security' : 't-dim'; const prefix = lane.variant === 'exception' ? 'EX' : String(index + 1).padStart(2, '0'); return ` ${exception} ${prefix} / ${esc(lane.label)}`; } function renderPhase(phase) { const span = spanForCols(phase.fromCol, phase.toCol, 46); const accent = variantAccent(phase.variant); const [lineClass] = arrowClassMap[phase.variant || 'default'] || arrowClassMap.default; return ` ${esc(phase.label)}`; } function renderGroup(group, index) { const span = spanForCols(group.fromCol, group.toCol, 50); const y = laneTop(group.lane) + layout.laneTitleH + 8; const height = layout.laneH - layout.laneTitleH - 16; const cls = group.variant === 'security' ? 'c-security-group' : 'c-lane'; const textClass = variantAccent(group.variant); return ` ${esc(group.label)}`; } function renderNode(node) { const fill = componentFill[node.type] || 'c-external'; const accent = componentText[node.type] || 't-muted'; const hasSub = node.sublabel != null && node.sublabel !== ''; const labelFontSize = fittedNodeFontSize(node.label, node.width, nodeTextFit.labelPreferred, nodeTextFit.labelMinimum); const sublabelFontSize = hasSub ? fittedNodeFontSize(node.sublabel, node.width, nodeTextFit.sublabelPreferred, nodeTextFit.sublabelMinimum) : nodeTextFit.sublabelPreferred; const sub = hasSub ? `\n ${esc(node.sublabel)}` : ''; const tag = node.tag ? `\n ${esc(node.tag)}` : ''; const passport = { kind: node.type, sublabel: node.sublabel, tag: node.tag, context: nodeContext(node) }; return ` ${focusNodeTitle(node.label, passport)} ${renderSemanticSigil(node.type, { x: node.x + 6, y: node.y + 6 })} ${esc(node.label)}${sub}${tag} `; } function renderEdgePath(edge, index) { const [cls, marker] = arrowClassMap[edge.variant || 'default'] || arrowClassMap.default; const routed = pathFor(edge); const strokeWidth = edge.width || (edge.variant === 'emphasis' ? 1.8 : 1.4); return ` `; } function renderEdgeLabel(edge, index) { if (!edge.label) return ''; const routed = pathFor(edge); const [lx, ly] = labelPoint(edge, routed.points); const labelW = Math.max(30, textUnits(edge.label) * 4.8 + 10); return ` ${esc(edge.label)} `; } const LEGEND_CATALOG = [ ['frontend', 'User UI'], ['backend', 'Agent logic'], ['security', 'Policy'], ['messagebus', 'Tool action'], ['database', 'Context / trace'], ['cloud', 'Cloud service'], ['external', 'External system'], ].map(([kind, label]) => ({ kind, label })); function renderLegend() { const presentKinds = new Set([...nodes.values()].map((node) => node.type)); const entries = resolveLegend(workflow.meta?.legend, LEGEND_CATALOG, presentKinds); return renderResolvedLegend({ entries, layout: { x: 20, baselineY: legendY(), width: viewBox[0] - 40, fontSize: 7, itemGap: 7, minTitleY: lastLaneBottom() + 8, unfit: workflow.meta?.legend === undefined ? 'hide' : 'error', diagramType: 'workflow', }, renderSwatch: (entry) => ``, }); } function renderSvg() { return ` ${svgAccessibleText(workflow.meta, 'workflow diagram')} ${renderDefinitions()} ${workflow.lanes.map(renderLane).join('\n\n')} ${asArray(workflow.phases).map(renderPhase).join('\n')} ${asArray(workflow.groups).map(renderGroup).join('\n')} ${workflow.edges.map(renderEdgePath).join('\n')} ${[...nodes.values()].map(renderNode).join('\n\n')} ${workflow.edges.map(renderEdgeLabel).join('\n')} ${renderLegend()} `; } validateWorkflow(); writeDiagram({ outPath, template, diagramType: 'workflow', meta: workflow.meta, svg: renderSvg(), cards: workflow.cards, });