Merge pull request 'chore: fix biome cognitive complexity warnings' (#287) from chore/fix-biome-complexity-warnings into main

This commit is contained in:
2026-05-16 10:40:27 +00:00
4 changed files with 436 additions and 349 deletions
@@ -53,6 +53,35 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
return states;
}
function isClickableGraphNode(nodeStates: Map<string, NodeState>, 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<string, number[]>,
clickCycleRef: { current: Map<string, number> },
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<Map<string, number>>(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(() => {
@@ -39,67 +39,119 @@ function resolveType(prop: Record<string, unknown>): string {
return String(prop.type ?? "unknown");
}
function variantLabel(
variantProps: Record<string, Record<string, unknown>>,
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<Record<string, unknown>>,
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<string, Record<string, unknown>>;
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<string>(
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<string, unknown>,
depth: number,
parentPrefix: string,
keyPrefix: string,
): SchemaRow[] {
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = new Set<string>(
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<string, unknown>,
depth: number,
parentPrefix: string,
keyPrefix: string,
): SchemaRow[] {
const rows: SchemaRow[] = [];
// Handle oneOf / discriminatedUnion
const oneOf = schema.oneOf as Array<Record<string, unknown>> | 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<string, Record<string, unknown>>;
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<string>(
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<string, unknown>,
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<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
}
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = new Set<string>(
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<string, unknown> | 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<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
}
return [];
}
function flattenProperty(
@@ -110,55 +162,23 @@ function flattenProperty(
keyPrefix: string,
required: Set<string>,
): 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<string, unknown>,
depth + 1,
childPrefix,
`${keyPrefix}${name}-`,
),
);
}
if (prop.type === "array") {
const items = prop.items as Record<string, unknown> | 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<string, unknown>,
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;
}
@@ -36,6 +36,128 @@ function edgeKey(e: WorkflowGraphEdge): string {
return `${e.from}->${e.to}::${e.condition}`;
}
function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set<string> {
const ids = new Set<string>();
for (const e of edges) {
ids.add(e.from);
ids.add(e.to);
}
return ids;
}
function detectBackEdges(ids: Set<string>, edges: readonly WorkflowGraphEdge[]): Set<string> {
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
const backEdges = new Set<string>();
const color = new Map<string, number>();
for (const id of ids) color.set(id, WHITE);
const fullAdj = new Map<string, string[]>();
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<string>,
edges: readonly WorkflowGraphEdge[],
backEdges: Set<string>,
): Map<string, string[]> {
const adj = new Map<string, string[]>();
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<string>, adj: Map<string, string[]>): Map<string, number> {
const inDegree = new Map<string, number>();
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<string, string[]>,
rank: Map<string, number>,
inDegree: Map<string, number>,
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<string>, adj: Map<string, string[]>): Map<string, number> {
const inDegree = computeInDegrees(ids, adj);
const rank = new Map<string, number>();
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, number>): 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<string>();
for (const e of edges) {
ids.add(e.from);
ids.add(e.to);
}
// Build adjacency (excluding self-loops)
const adj = new Map<string, string[]>();
const inEdges = new Map<string, string[]>();
for (const id of ids) {
adj.set(id, []);
inEdges.set(id, []);
}
// Detect back-edges via DFS to break cycles
const backEdges = new Set<string>();
{
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
const color = new Map<string, number>();
for (const id of ids) color.set(id, WHITE);
// Temporary full adjacency for cycle detection
const fullAdj = new Map<string, string[]>();
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<string, number>();
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<string, number>();
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<string, number>;
nodePositions: Map<string, { x: number; y: number; w: number; h: number }>;
centerX: number;
routedCountByTarget: Map<string, number>;
};
// 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<string, number> {
const rank = new Map<string, number>();
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<string, { x: number; y: number; w: number; h: number }>();
// 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<string, NodePosition> {
const nodePositions = new Map<string, NodePosition>();
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<string, NodePosition>,
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<string, number>();
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<string, number>(),
};
const edges: Edge[] = input.edges.map((e) => buildConditionEdge(e, edgeCtx));
return { nodes, edges };
}
@@ -60,6 +60,30 @@ function validateDescriptorGraph(graphRaw: unknown): Result<WorkflowGraph, strin
return ok({ edges });
}
function validateDescriptorRole(
roleName: string,
specUnknown: unknown,
): Result<WorkflowRoleDescriptor, string> {
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<string, unknown>;
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<WorkflowDescriptor, string> {
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<WorkflowDescr
const roles: Record<string, WorkflowRoleDescriptor> = {};
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<string, unknown>;
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);