chore: fix biome cognitive complexity warnings
Refactor dashboard graph/schema helpers and descriptor role validation into smaller functions so bun run check passes without warnings. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -53,6 +53,35 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
|
|||||||
return states;
|
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) {
|
export function ThreadDetail({ client, threadId, onBack }: Props) {
|
||||||
const sse = useSSE(client, threadId);
|
const sse = useSSE(client, threadId);
|
||||||
const { status, data, error } = useFetch(() => getThread(client, threadId), [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)
|
// Track which occurrence to jump to next per role (cycling)
|
||||||
const clickCycleRef = useRef<Map<string, number>>(new Map());
|
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(
|
const handleGraphNodeClick = useCallback(
|
||||||
(nodeId: string) => {
|
(nodeId: string) => {
|
||||||
// Only allow clicks on lit (non-default) nodes
|
if (!isClickableGraphNode(nodeStates, nodeId)) return;
|
||||||
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
|
|
||||||
|
|
||||||
// __start__: scroll to the first record (thread-start prompt)
|
|
||||||
if (nodeId === "__start__") {
|
if (nodeId === "__start__") {
|
||||||
const firstCard = document.querySelector('[data-record-index="0"]');
|
scrollToFirstRecord();
|
||||||
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// __end__: scroll to bottom
|
|
||||||
if (nodeId === "__end__") {
|
if (nodeId === "__end__") {
|
||||||
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
scrollToRoleOccurrence(nodeId, indicesByRole, clickCycleRef, highlightRole);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[nodeStates, indicesByRole],
|
[nodeStates, indicesByRole, highlightRole],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -39,67 +39,119 @@ function resolveType(prop: Record<string, unknown>): string {
|
|||||||
return String(prop.type ?? "unknown");
|
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(
|
function flattenSchema(
|
||||||
schema: Record<string, unknown>,
|
schema: Record<string, unknown>,
|
||||||
depth: number,
|
depth: number,
|
||||||
parentPrefix: string,
|
parentPrefix: string,
|
||||||
keyPrefix: string,
|
keyPrefix: string,
|
||||||
): SchemaRow[] {
|
): SchemaRow[] {
|
||||||
const rows: SchemaRow[] = [];
|
|
||||||
|
|
||||||
// Handle oneOf / discriminatedUnion
|
|
||||||
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
|
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
|
||||||
if (Array.isArray(oneOf) && oneOf.length > 0) {
|
if (Array.isArray(oneOf) && oneOf.length > 0) {
|
||||||
for (let vi = 0; vi < oneOf.length; vi++) {
|
return flattenOneOfVariants(oneOf, depth, parentPrefix, keyPrefix);
|
||||||
const variant = oneOf[vi];
|
}
|
||||||
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
|
return flattenSchemaProperties(schema, depth, parentPrefix, keyPrefix);
|
||||||
let variantLabel = `Variant ${vi + 1}`;
|
}
|
||||||
for (const [pName, pDef] of Object.entries(variantProps)) {
|
|
||||||
if (pDef.const !== undefined) {
|
function flattenNestedPropertyRows(
|
||||||
variantLabel = `${pName}: ${String(pDef.const)}`;
|
name: string,
|
||||||
break;
|
prop: Record<string, unknown>,
|
||||||
}
|
depth: number,
|
||||||
}
|
parentPrefix: string,
|
||||||
const isLast = vi === oneOf.length - 1;
|
keyPrefix: string,
|
||||||
const connector = isLast ? "└" : "├";
|
hasOneOf: boolean,
|
||||||
rows.push({
|
): SchemaRow[] {
|
||||||
key: `${keyPrefix}variant-${vi}`,
|
const childPrefix = childPrefixForDepth(depth, parentPrefix);
|
||||||
name: `${parentPrefix}${connector} ${variantLabel}`,
|
const nestedKeyPrefix = `${keyPrefix}${name}-`;
|
||||||
type: "",
|
|
||||||
description: "",
|
if (prop.type === "object" && prop.properties !== undefined) {
|
||||||
depth,
|
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
if (prop.type === "array") {
|
||||||
const required = new Set<string>(
|
const items = prop.items as Record<string, unknown> | undefined;
|
||||||
Array.isArray(schema.required) ? (schema.required as string[]) : [],
|
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
|
||||||
);
|
return flattenSchema(items, depth + 1, childPrefix, nestedKeyPrefix);
|
||||||
for (const [name, prop] of Object.entries(props)) {
|
}
|
||||||
const subRows = flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required);
|
|
||||||
rows.push(...subRows);
|
|
||||||
}
|
}
|
||||||
return rows;
|
|
||||||
|
if (hasOneOf) {
|
||||||
|
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenProperty(
|
function flattenProperty(
|
||||||
@@ -110,55 +162,23 @@ function flattenProperty(
|
|||||||
keyPrefix: string,
|
keyPrefix: string,
|
||||||
required: Set<string>,
|
required: Set<string>,
|
||||||
): SchemaRow[] {
|
): SchemaRow[] {
|
||||||
const rows: SchemaRow[] = [];
|
|
||||||
const hasOneOf = Array.isArray(prop.oneOf) && (prop.oneOf as unknown[]).length > 0;
|
const hasOneOf = Array.isArray(prop.oneOf) && (prop.oneOf as unknown[]).length > 0;
|
||||||
let type = hasOneOf ? "⊕ oneOf" : resolveType(prop);
|
let type = hasOneOf ? "⊕ oneOf" : resolveType(prop);
|
||||||
if (!required.has(name)) type += "?";
|
if (!required.has(name)) type += "?";
|
||||||
const description = String(prop.description ?? "");
|
|
||||||
const displayName = depth > 0 ? `${parentPrefix}└─ ${name}` : name;
|
|
||||||
|
|
||||||
rows.push({
|
const rows: SchemaRow[] = [
|
||||||
key: `${keyPrefix}${name}`,
|
{
|
||||||
name: displayName,
|
key: `${keyPrefix}${name}`,
|
||||||
type,
|
name: depth > 0 ? `${parentPrefix}└─ ${name}` : name,
|
||||||
description,
|
type,
|
||||||
depth,
|
description: String(prop.description ?? ""),
|
||||||
prefix: parentPrefix,
|
depth,
|
||||||
isVariantHeader: false,
|
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}-`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
rows.push(...flattenNestedPropertyRows(name, prop, depth, parentPrefix, keyPrefix, hasOneOf));
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,128 @@ function edgeKey(e: WorkflowGraphEdge): string {
|
|||||||
return `${e.from}->${e.to}::${e.condition}`;
|
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) ─────────────
|
// ── 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.
|
* on the resulting DAG, then the removed edges become feedback edges.
|
||||||
*/
|
*/
|
||||||
function computeLayersLongestPath(edges: readonly WorkflowGraphEdge[]): string[][] {
|
function computeLayersLongestPath(edges: readonly WorkflowGraphEdge[]): string[][] {
|
||||||
// Collect all node IDs
|
const ids = collectNodeIds(edges);
|
||||||
const ids = new Set<string>();
|
const backEdges = detectBackEdges(ids, edges);
|
||||||
for (const e of edges) {
|
const adj = buildDagAdjacency(ids, edges, backEdges);
|
||||||
ids.add(e.from);
|
const rank = longestPathRanks(ids, adj);
|
||||||
ids.add(e.to);
|
return ranksToLayers(rank);
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shared helpers ──────────────────────────────────────────────────
|
// ── 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 computeEdgeLabelPosition(
|
||||||
function computeLayoutLongestPath(input: LayoutInput): LayoutResult {
|
e: WorkflowGraphEdge,
|
||||||
const layers = computeLayersLongestPath(input.edges);
|
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>();
|
const rank = new Map<string, number>();
|
||||||
for (let i = 0; i < layers.length; i++) {
|
for (let i = 0; i < layers.length; i++) {
|
||||||
for (const id of layers[i]) {
|
for (const id of layers[i]) rank.set(id, i);
|
||||||
rank.set(id, i);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return rank;
|
||||||
|
}
|
||||||
|
|
||||||
// Horizontal gap between nodes in the same layer
|
function computeLayerWidths(layers: string[][], hGap: number): number[] {
|
||||||
const H_GAP = 40;
|
return layers.map((layer) => {
|
||||||
|
|
||||||
// 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) {
|
|
||||||
let w = 0;
|
let w = 0;
|
||||||
for (const id of layer) {
|
for (const id of layer) w += nodeSize(id).width;
|
||||||
w += nodeSize(id).width;
|
return w + (layer.length - 1) * hGap;
|
||||||
}
|
});
|
||||||
w += (layer.length - 1) * H_GAP;
|
}
|
||||||
layerWidths.push(w);
|
|
||||||
}
|
|
||||||
const maxLayerWidth = Math.max(...layerWidths, ROLE_NODE_WIDTH);
|
|
||||||
const centerX = maxLayerWidth / 2;
|
|
||||||
|
|
||||||
|
function layoutNodePositions(
|
||||||
|
layers: string[][],
|
||||||
|
layerWidths: number[],
|
||||||
|
centerX: number,
|
||||||
|
hGap: number,
|
||||||
|
): Map<string, NodePosition> {
|
||||||
|
const nodePositions = new Map<string, NodePosition>();
|
||||||
let y = 0;
|
let y = 0;
|
||||||
for (let li = 0; li < layers.length; li++) {
|
for (let li = 0; li < layers.length; li++) {
|
||||||
const layer = layers[li];
|
const layer = layers[li];
|
||||||
const totalWidth = layerWidths[li];
|
let x = centerX - layerWidths[li] / 2;
|
||||||
let x = centerX - totalWidth / 2;
|
|
||||||
let maxH = 0;
|
let maxH = 0;
|
||||||
for (const id of layer) {
|
for (const id of layer) {
|
||||||
const size = nodeSize(id);
|
const size = nodeSize(id);
|
||||||
nodePositions.set(id, { x, y, w: size.width, h: size.height });
|
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;
|
if (size.height > maxH) maxH = size.height;
|
||||||
}
|
}
|
||||||
y += maxH + LAYER_GAP;
|
y += maxH + LAYER_GAP;
|
||||||
}
|
}
|
||||||
|
return nodePositions;
|
||||||
|
}
|
||||||
|
|
||||||
// Build nodes
|
function buildLayoutNodes(
|
||||||
|
layers: string[][],
|
||||||
|
nodePositions: Map<string, NodePosition>,
|
||||||
|
input: LayoutInput,
|
||||||
|
): Node[] {
|
||||||
const nodes: Node[] = [];
|
const nodes: Node[] = [];
|
||||||
for (const layer of layers) {
|
for (const layer of layers) {
|
||||||
for (const id of layer) {
|
for (const id of layer) {
|
||||||
const pos = nodePositions.get(id);
|
const pos = nodePositions.get(id);
|
||||||
if (pos === undefined) continue;
|
if (pos === undefined) continue;
|
||||||
const state = input.nodeStates.get(id) ?? "default";
|
const state = input.nodeStates.get(id) ?? "default";
|
||||||
|
const xy = { x: pos.x, y: pos.y };
|
||||||
if (id === START_ID || id === END_ID) {
|
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 {
|
} 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
|
// ── Longest-path layout (uses same edge-building as before) ─────────
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
|
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 };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,30 @@ function validateDescriptorGraph(graphRaw: unknown): Result<WorkflowGraph, strin
|
|||||||
return ok({ edges });
|
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> {
|
export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescriptor, string> {
|
||||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||||
return err("descriptor must be a non-array object");
|
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> = {};
|
const roles: Record<string, WorkflowRoleDescriptor> = {};
|
||||||
for (const [roleName, specUnknown] of Object.entries(rolesRaw)) {
|
for (const [roleName, specUnknown] of Object.entries(rolesRaw)) {
|
||||||
if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) {
|
const roleResult = validateDescriptorRole(roleName, specUnknown);
|
||||||
return err(`descriptor.roles.${roleName} must be a non-array object`);
|
if (!roleResult.ok) {
|
||||||
|
return roleResult;
|
||||||
}
|
}
|
||||||
const spec = specUnknown as Record<string, unknown>;
|
roles[roleName] = roleResult.value;
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const graphResult = validateDescriptorGraph(root.graph);
|
const graphResult = validateDescriptorGraph(root.graph);
|
||||||
|
|||||||
Reference in New Issue
Block a user