/** * Data transformation layer for the Workflow Timeline DAG. * * Converts raw execution summaries + workflow definition into the internal * timeline structures (TimelineTask, TimelineEdge, TimelineMilestone) that * the layout engine and renderer consume. */ import type { ExecutionSummary } from "@/api"; import type { TimelineTask, TimelineEdge, TimelineMilestone, TaskState, EdgeKind, WithItemsGroupInfo, WorkflowDefinition, WorkflowDefinitionTask, WorkflowDefinitionTransition, } from "./types"; import { WITH_ITEMS_COLLAPSE_THRESHOLD } from "./types"; // --------------------------------------------------------------------------- // Execution status → TaskState mapping // --------------------------------------------------------------------------- function toTaskState(status: string): TaskState { switch (status) { case "completed": return "completed"; case "running": return "running"; case "failed": return "failed"; case "timeout": return "timeout"; case "canceling": case "cancelled": return "cancelled"; case "abandoned": return "abandoned"; case "requested": case "scheduling": case "scheduled": case "pending": default: return "pending"; } } // --------------------------------------------------------------------------- // Classify a `when` expression into an edge kind // --------------------------------------------------------------------------- function classifyWhen(when?: string): EdgeKind { if (!when) return "always"; const lower = when.toLowerCase().replace(/\s+/g, ""); if (lower.includes("succeeded()")) return "success"; if (lower.includes("failed()")) return "failure"; if (lower.includes("timed_out()")) return "timeout"; return "custom"; } /** * Check whether a transition's `when` condition would have fired given the * source task's actual execution status. * * - `"always"` fires for any terminal status. * - `"success"` fires only when the source completed. * - `"failure"` fires only when the source failed (non-timeout). * - `"timeout"` fires only when the source timed out. * - `"custom"` is conservatively assumed to match (we can't evaluate the * expression client-side). * - If the source task hasn't reached a terminal state yet, we allow * all transitions so that in-progress workflows still show their * potential edges. */ function transitionMatchesStatus( kind: EdgeKind, sourceStatus: string | undefined, ): boolean { // If the source hasn't executed or isn't terminal yet, show all edges // so the user can see the full graph for in-progress workflows. if ( !sourceStatus || !["completed", "failed", "timeout", "cancelled", "abandoned"].includes( sourceStatus, ) ) { return true; } switch (kind) { case "always": return true; case "success": return sourceStatus === "completed"; case "failure": return sourceStatus === "failed"; case "timeout": return sourceStatus === "timeout"; case "custom": // Can't evaluate custom expressions client-side — show them return true; default: return true; } } /** * Determine the representative terminal status for a task name from a group * of executions. For with_items tasks, if any item failed that dominates; * otherwise use the first terminal status found. */ function representativeStatus( executions: ExecutionSummary[], ): string | undefined { let hasCompleted = false; let hasFailed = false; let hasTimeout = false; for (const exec of executions) { switch (exec.status) { case "failed": hasFailed = true; break; case "timeout": hasTimeout = true; break; case "completed": hasCompleted = true; break; } } // Failure takes priority (matches how advance_workflow determines outcome) if (hasFailed) return "failed"; if (hasTimeout) return "timeout"; if (hasCompleted) return "completed"; // No terminal status yet — return undefined return undefined; } /** Derive a short human-readable label for a `when` expression */ function labelForWhen(when?: string, chartLabel?: string): string | undefined { if (chartLabel) return chartLabel; if (!when) return undefined; // unconditional — no label needed const lower = when.toLowerCase().replace(/\s+/g, ""); if (lower.includes("succeeded()")) return "succeeded"; if (lower.includes("failed()")) return "failed"; if (lower.includes("timed_out()")) return "timed out"; // Custom expression — truncate for display if (when.length > 30) return when.slice(0, 27) + "…"; return when; } // --------------------------------------------------------------------------- // Normalize legacy transition formats into a unified `next` array // --------------------------------------------------------------------------- function normalizeLegacyTransitions( task: WorkflowDefinitionTask, ): WorkflowDefinitionTransition[] { if (task.next && task.next.length > 0) return task.next; const transitions: WorkflowDefinitionTransition[] = []; const toArray = (v: string | string[] | undefined): string[] => { if (!v) return []; return typeof v === "string" ? [v] : v; }; const successTargets = toArray(task.on_success); if (successTargets.length > 0) { transitions.push({ when: "{{ succeeded() }}", do: successTargets }); } const failureTargets = toArray(task.on_failure); if (failureTargets.length > 0) { transitions.push({ when: "{{ failed() }}", do: failureTargets }); } const completeTargets = toArray(task.on_complete); if (completeTargets.length > 0) { transitions.push({ do: completeTargets }); // unconditional } const timeoutTargets = toArray(task.on_timeout); if (timeoutTargets.length > 0) { transitions.push({ when: "{{ timed_out() }}", do: timeoutTargets }); } return transitions; } // --------------------------------------------------------------------------- // Build task name → execution ID(s) mapping // --------------------------------------------------------------------------- /** * Group child executions by their workflow task_name. * * For with_items tasks there may be multiple executions per task name. * We pick a canonical "representative" execution for each task name for * the purpose of edge routing (the earliest-started or lowest-index one). */ function groupByTaskName( executions: ExecutionSummary[], ): Map { const groups = new Map(); for (const exec of executions) { const taskName = exec.workflow_task?.task_name; if (!taskName) continue; const list = groups.get(taskName) ?? []; list.push(exec); groups.set(taskName, list); } // Sort each group by task_index, then by created timestamp for (const [, list] of groups) { list.sort((a, b) => { const ai = a.workflow_task?.task_index ?? 0; const bi = b.workflow_task?.task_index ?? 0; if (ai !== bi) return ai - bi; return new Date(a.created).getTime() - new Date(b.created).getTime(); }); } return groups; } // --------------------------------------------------------------------------- // Public API: buildTimelineTasks // --------------------------------------------------------------------------- /** * Convert child execution summaries into TimelineTask structures. * * The `workflowDef` is optional — when provided it's used to derive * dependency edges via the transition model. When absent, dependencies * are inferred from timing (heuristic). */ export function buildTimelineTasks( childExecutions: ExecutionSummary[], workflowDef?: WorkflowDefinition | null, /** Current wall-clock time (epoch ms). When supplied, running tasks extend * to this value instead of a one-shot `Date.now()`, allowing the caller to * drive periodic updates via a ticking state variable. */ nowMs?: number, ): TimelineTask[] { // workflowDef is accepted for API symmetry with buildEdges but not used // in task construction (edges are derived separately). void workflowDef; const now = nowMs ?? Date.now(); const tasks: TimelineTask[] = []; for (const exec of childExecutions) { const wt = exec.workflow_task; const taskName = wt?.task_name ?? `Task #${exec.id}`; // Determine time bounds const startedAt = wt?.started_at ?? exec.started_at; const startMs = startedAt ? new Date(startedAt).getTime() : null; // For terminal states, use updated as end time; for running use now const isTerminal = [ "completed", "failed", "timeout", "cancelled", "abandoned", ].includes(exec.status); let endMs: number | null = null; if (isTerminal) { const completedAt = wt?.completed_at; endMs = completedAt ? new Date(completedAt).getTime() : new Date(exec.updated).getTime(); } else if (exec.status === "running" && startMs) { endMs = now; } // Duration let durationMs: number | null = wt?.duration_ms ?? null; if (durationMs == null && startMs != null && endMs != null) { durationMs = endMs - startMs; } tasks.push({ id: String(exec.id), name: taskName, actionRef: exec.action_ref, state: toTaskState(exec.status), startMs, endMs, upstreamIds: [], // filled in by buildEdges downstreamIds: [], // filled in by buildEdges taskIndex: wt?.task_index ?? null, timedOut: wt?.timed_out ?? false, retryCount: wt?.retry_count ?? 0, maxRetries: wt?.max_retries ?? 0, durationMs, execution: exec, }); } return tasks; } // --------------------------------------------------------------------------- // Public API: collapseWithItemsGroups // --------------------------------------------------------------------------- /** * Detect with_items task groups that exceed the collapse threshold and merge * each group into a single synthetic TimelineTask. The synthetic node spans * the full time range of its members and carries a `groupInfo` descriptor so * the renderer can display "task ×N" with a progress summary. * * Returns a new task array (individual member tasks removed, group nodes * inserted) plus a mapping from each removed member ID to the group node ID * so that `buildEdges` can redirect connectivity. * * Must be called BEFORE `buildEdges` because edges reference task IDs. */ export function collapseWithItemsGroups( tasks: TimelineTask[], _childExecutions: ExecutionSummary[], workflowDef?: WorkflowDefinition | null, ): { tasks: TimelineTask[]; /** Map from removed member task ID → group node ID */ memberToGroup: Map; } { // 1. Identify with_items groups: tasks sharing the same name where at // least one has a non-null taskIndex (indicating with_items expansion). const byName = new Map(); for (const t of tasks) { const list = byName.get(t.name) ?? []; list.push(t); byName.set(t.name, list); } const memberToGroup = new Map(); const removedIds = new Set(); const groupNodes: TimelineTask[] = []; for (const [name, group] of byName) { // Only collapse if this looks like a with_items expansion const hasIndices = group.some((t) => t.taskIndex != null); if (!hasIndices || group.length < WITH_ITEMS_COLLAPSE_THRESHOLD) continue; // Look up concurrency from the workflow definition let concurrency = 0; if (workflowDef?.tasks) { const defTask = workflowDef.tasks.find((dt) => dt.name === name); if (defTask?.concurrency != null) { concurrency = defTask.concurrency; } } // Compute aggregate time bounds let minStart: number | null = null; let maxEnd: number | null = null; for (const t of group) { if (t.startMs != null) { if (minStart === null || t.startMs < minStart) minStart = t.startMs; } if (t.endMs != null) { if (maxEnd === null || t.endMs > maxEnd) maxEnd = t.endMs; } } // Compute per-state counts let completed = 0; let failed = 0; let running = 0; let pending = 0; let timedOutCount = 0; let cancelled = 0; let anyTimedOut = false; for (const t of group) { switch (t.state) { case "completed": completed++; break; case "failed": failed++; break; case "running": running++; break; case "pending": pending++; break; case "timeout": timedOutCount++; anyTimedOut = true; break; case "cancelled": case "abandoned": cancelled++; break; } if (t.timedOut) anyTimedOut = true; } // Determine aggregate state: running if any are running/pending, // failed if any failed (and none still running), else completed let aggregateState: TaskState; if (running > 0 || pending > 0) { aggregateState = "running"; } else if (failed > 0 || timedOutCount > 0) { aggregateState = failed > 0 ? "failed" : "timeout"; } else if (cancelled > 0 && completed === 0) { aggregateState = "cancelled"; } else { aggregateState = "completed"; } const memberIds = group.map((t) => t.id); // Use a synthetic ID that won't collide with real execution IDs const groupId = `__group_${name}__`; const durationMs = minStart != null && maxEnd != null ? maxEnd - minStart : null; // Build display name — progress counter (N/M) rendered by TimelineRenderer const displayName = name; const groupInfo: WithItemsGroupInfo = { totalItems: group.length, completed, failed, running, pending, timedOut: timedOutCount, cancelled, concurrency, memberIds, }; // Use the first member's execution as the representative (for action ref, etc.) const representative = group[0]; groupNodes.push({ id: groupId, name: displayName, actionRef: representative.actionRef, state: aggregateState, startMs: minStart, endMs: maxEnd, upstreamIds: [], downstreamIds: [], taskIndex: null, timedOut: anyTimedOut, retryCount: 0, maxRetries: 0, durationMs, execution: representative.execution, groupInfo, }); // Record mappings and mark members for removal for (const t of group) { memberToGroup.set(t.id, groupId); removedIds.add(t.id); } } // Build final task list: keep non-removed tasks, append group nodes const result = tasks.filter((t) => !removedIds.has(t.id)); result.push(...groupNodes); return { tasks: result, memberToGroup }; } // --------------------------------------------------------------------------- // Public API: buildEdges // --------------------------------------------------------------------------- /** * Determine the edge kind for an actual transition based on the predecessor * task's terminal status. */ function edgeKindFromPredecessorStatus(status: string): EdgeKind { switch (status) { case "completed": return "success"; case "failed": return "failure"; case "timeout": return "timeout"; default: return "always"; } } /** * Look up transition metadata (label, color) from the workflow definition * for a specific source→target edge. Returns `undefined` values when the * definition is unavailable or the transition cannot be matched. */ function lookupTransitionMeta( sourceTaskName: string, targetTaskName: string, firedKind: EdgeKind, workflowDef?: WorkflowDefinition | null, ): { label?: string; color?: string } { if (!workflowDef?.tasks) return {}; const defTask = workflowDef.tasks.find((t) => t.name === sourceTaskName); if (!defTask) return {}; const transitions = normalizeLegacyTransitions(defTask); // Try to find a transition that targets this task AND matches the kind for (const tr of transitions) { if (!tr.do?.includes(targetTaskName)) continue; const trKind = classifyWhen(tr.when); if (trKind === firedKind || trKind === "always") { return { label: labelForWhen(tr.when, tr.__chart_meta__?.label), color: tr.__chart_meta__?.color, }; } } // Fallback 1: prefer custom/always transitions that target this task // (custom conditions may not classify neatly into success/failure/timeout). for (const tr of transitions) { if (!tr.do?.includes(targetTaskName)) continue; const trKind = classifyWhen(tr.when); if (trKind === "custom" || trKind === "always") { return { label: labelForWhen(tr.when, tr.__chart_meta__?.label), color: tr.__chart_meta__?.color, }; } } // Fallback 2: there IS a transition targeting this task, but its // classified kind doesn't match the actual fired kind. This means the // definition says e.g. failed()→X but the predecessor actually // succeeded (and the task was reached via a different path). Return // the color from the definition for visual consistency, but omit the // label to avoid displaying a misleading condition like "init failed" // on an edge that actually represents a different transition path. for (const tr of transitions) { if (!tr.do?.includes(targetTaskName)) continue; return { label: undefined, color: tr.__chart_meta__?.color, }; } return {}; } /** * Build dependency edges between timeline tasks. * * **Primary strategy** (`triggered_by` metadata): * When child executions carry `workflow_task.triggered_by`, we draw only the * edges that actually fired during execution. This produces an accurate * representation of the path taken through the workflow — unused transitions * (e.g. a `failed()` branch when the task succeeded) are omitted. * * **Fallback** (definition-based / timing heuristic): * For older executions that lack `triggered_by` data, or when no workflow * definition is available, we fall back to drawing all defined transitions * or inferring edges from timing. */ /** * Helper: record full upstream/downstream connectivity on task objects * independently of visual edges. This ensures milestone detection (fork/ * merge junctions) works correctly regardless of how many visual edges we * choose to render. */ function linkConnectivity( taskById: Map, sourceIds: string[], targetIds: string[], ): void { for (const sid of sourceIds) { const src = taskById.get(sid); if (src) { for (const tid of targetIds) { src.downstreamIds.push(tid); } } } for (const tid of targetIds) { const tgt = taskById.get(tid); if (tgt) { for (const sid of sourceIds) { tgt.upstreamIds.push(sid); } } } } /** * Helper: push a bounded number of visual edges for a source→target group. * For small groups (≤5 per side) we create the full cross-product. * For larger groups we create representative pairs (first, last, and a * few evenly-spaced samples) — the milestone system handles the visual * simplification of the fan-out/fan-in. */ const VISUAL_EDGE_THRESHOLD = 5; function pushGroupEdges( edges: TimelineEdge[], sourceIds: string[], targetIds: string[], kind: EdgeKind, label: string | undefined, color: string | undefined, ): void { if ( sourceIds.length <= VISUAL_EDGE_THRESHOLD && targetIds.length <= VISUAL_EDGE_THRESHOLD ) { // Small group — full cross-product for (const sid of sourceIds) { for (const tid of targetIds) { edges.push({ from: sid, to: tid, kind, label, color }); } } } else { // Large group — sample representative edges. // Pick up to VISUAL_EDGE_THRESHOLD evenly-spaced indices from each side. const sampleIndices = (len: number, max: number): number[] => { if (len <= max) return Array.from({ length: len }, (_, i) => i); const indices = new Set(); indices.add(0); indices.add(len - 1); for (let i = 1; indices.size < max && i < max - 1; i++) { indices.add(Math.round((i * (len - 1)) / (max - 1))); } return [...indices].sort((a, b) => a - b); }; const srcSample = sampleIndices(sourceIds.length, VISUAL_EDGE_THRESHOLD); const tgtSample = sampleIndices(targetIds.length, VISUAL_EDGE_THRESHOLD); // Connect each sampled source to each sampled target let first = true; for (const si of srcSample) { for (const ti of tgtSample) { edges.push({ from: sourceIds[si], to: targetIds[ti], kind, label: first ? label : undefined, color, }); first = false; } } } } export function buildEdges( tasks: TimelineTask[], childExecutions: ExecutionSummary[], workflowDef?: WorkflowDefinition | null, /** Map from collapsed member ID → group node ID (from collapseWithItemsGroups) */ memberToGroup?: Map, ): TimelineEdge[] { const edges: TimelineEdge[] = []; // Pre-build task lookup for connectivity tracking const globalTaskById = new Map(tasks.map((t) => [t.id, t])); // Helper: resolve a task ID through the collapse mapping. // If the ID was collapsed into a group node, return the group ID instead. const resolve = memberToGroup?.size ? (id: string) => memberToGroup.get(id) ?? id : (id: string) => id; // Check whether triggered_by data is available. If ANY non-entry-point // execution has a populated triggered_by we use the actual-path strategy. const hasTriggeredBy = childExecutions.some( (e) => e.workflow_task?.triggered_by != null, ); if (hasTriggeredBy) { // --------------------------------------------------------------- // Actual-path strategy: draw only edges that fired // --------------------------------------------------------------- const groups = groupByTaskName(childExecutions); // Map task name → list of execution IDs (for with_items fan-out). // Resolve through collapse mapping and deduplicate so that collapsed // group members all map to the single group node ID. const taskIdsByName = new Map(); for (const [name, execs] of groups) { const ids = [...new Set(execs.map((e) => resolve(String(e.id))))]; taskIdsByName.set(name, ids); } // Map execution ID → execution summary (for status lookup) const execById = new Map(); for (const e of childExecutions) { execById.set(String(e.id), e); } // Collect unique (predecessorName → taskName) pairs with edge metadata const pairsSeen = new Set(); for (const exec of childExecutions) { const wt = exec.workflow_task; if (!wt?.triggered_by) continue; const pairKey = `${wt.triggered_by}→${wt.task_name}`; if (pairsSeen.has(pairKey)) continue; pairsSeen.add(pairKey); const predecessorName = wt.triggered_by; const predecessorIds = taskIdsByName.get(predecessorName) ?? []; const targetIds = taskIdsByName.get(wt.task_name) ?? []; if (predecessorIds.length === 0 || targetIds.length === 0) continue; // Determine edge kind from the predecessor group's terminal status. // Use the first terminal predecessor's status as representative. let firedKind: EdgeKind = "success"; for (const pid of predecessorIds) { const predExec = execById.get(pid); if (predExec) { firedKind = edgeKindFromPredecessorStatus(predExec.status); if (["completed", "failed", "timeout"].includes(predExec.status)) { break; } } } // Look up visual metadata (label, color) from the workflow definition const meta = lookupTransitionMeta( predecessorName, wt.task_name, firedKind, workflowDef, ); // Resolve IDs through collapse mapping for connectivity and edges const resolvedPredecessorIds = [...new Set(predecessorIds.map(resolve))]; const resolvedTargetIds = [...new Set(targetIds.map(resolve))]; // Filter out self-edges (collapsed group → itself) const filteredTargetIds = resolvedTargetIds.filter( (tid) => !resolvedPredecessorIds.includes(tid), ); if (filteredTargetIds.length === 0) continue; // Always record full connectivity so upstreamIds/downstreamIds are // correct for milestone detection (fork/merge junctions), regardless // of group size. Visual edges are capped separately. linkConnectivity( globalTaskById, resolvedPredecessorIds, filteredTargetIds, ); pushGroupEdges( edges, resolvedPredecessorIds, filteredTargetIds, firedKind, meta.label, meta.color, ); } // --------------------------------------------------------------- // Supplement with definition-based edges for join tasks. // // `triggered_by` only records the *last* predecessor that caused // the advance, so for join tasks (which wait for N predecessors) // only one inbound edge gets created above. We consult the // workflow definition to find ALL tasks that transition into a // join target, then draw the missing edges so that all // predecessor branches correctly show connectivity. // --------------------------------------------------------------- if (workflowDef?.tasks && workflowDef.tasks.length > 0) { // Build a set of join task names (explicit `join` field) and also // detect implicit joins: tasks that are the target of transitions // from multiple different source tasks in the definition. // Map target task name → Set of source task names that have ANY // transition to it (used for join detection). const inboundSourcesByTarget = new Map>(); // Map "source→target" → list of transition conditions (EdgeKind) // that route from source to target. Used to filter supplemental // edges: we only draw an edge if the source's actual status matches // at least one of the declared transition conditions. const transitionKindsByPair = new Map(); for (const defTask of workflowDef.tasks) { const transitions = normalizeLegacyTransitions(defTask); for (const tr of transitions) { if (!tr.do) continue; const trKind = classifyWhen(tr.when); for (const targetName of tr.do) { let sources = inboundSourcesByTarget.get(targetName); if (!sources) { sources = new Set(); inboundSourcesByTarget.set(targetName, sources); } sources.add(defTask.name); const pk = `${defTask.name}→${targetName}`; const kinds = transitionKindsByPair.get(pk) ?? []; kinds.push(trKind); transitionKindsByPair.set(pk, kinds); } } } // Identify join targets: tasks with `join` property OR with // inbound edges from more than one distinct source task. const joinTargets = new Set(); for (const defTask of workflowDef.tasks) { if (defTask.join != null && defTask.join > 0) { joinTargets.add(defTask.name); } } for (const [targetName, sources] of inboundSourcesByTarget) { if (sources.size > 1) { joinTargets.add(targetName); } } // For each join target that has executions, add missing edges // from all definition-declared source tasks. for (const joinName of joinTargets) { const targetIds = taskIdsByName.get(joinName); if (!targetIds || targetIds.length === 0) continue; const defSources = inboundSourcesByTarget.get(joinName); if (!defSources) continue; for (const sourceName of defSources) { // Skip if this pair was already covered by triggered_by const pairKey = `${sourceName}→${joinName}`; if (pairsSeen.has(pairKey)) continue; pairsSeen.add(pairKey); const sourceIds = taskIdsByName.get(sourceName); if (!sourceIds || sourceIds.length === 0) continue; // Determine the source task's actual terminal status let sourceStatus: string | undefined; for (const sid of sourceIds) { const srcExec = execById.get(sid); if ( srcExec && ["completed", "failed", "timeout"].includes(srcExec.status) ) { sourceStatus = srcExec.status; break; } } // Check whether any of the definition's transitions from this // source to this join target would actually fire given the // source's real status. If none match, this source didn't // contribute to the join target — skip the edge entirely. const declaredKinds = transitionKindsByPair.get(`${sourceName}→${joinName}`) ?? []; if (sourceStatus) { const anyMatch = declaredKinds.some((k) => transitionMatchesStatus(k, sourceStatus), ); if (!anyMatch) continue; } // Determine edge kind from the source task's actual status const edgeKind: EdgeKind = sourceStatus ? edgeKindFromPredecessorStatus(sourceStatus) : "success"; // Look up visual metadata from the definition const meta = lookupTransitionMeta( sourceName, joinName, edgeKind, workflowDef, ); // Resolve IDs through collapse mapping const resolvedSourceIds = [...new Set(sourceIds.map(resolve))]; const resolvedTargetIds = [...new Set(targetIds.map(resolve))]; const filteredTargetIds = resolvedTargetIds.filter( (tid) => !resolvedSourceIds.includes(tid), ); if (filteredTargetIds.length === 0) continue; // Full connectivity + bounded visual edges linkConnectivity( globalTaskById, resolvedSourceIds, filteredTargetIds, ); pushGroupEdges( edges, resolvedSourceIds, filteredTargetIds, edgeKind, meta.label, meta.color, ); } } } // Deduplicate connectivity (linkConnectivity may add duplicates) for (const task of tasks) { task.upstreamIds = [...new Set(task.upstreamIds)]; task.downstreamIds = [...new Set(task.downstreamIds)]; } } else if (workflowDef?.tasks && workflowDef.tasks.length > 0) { // --------------------------------------------------------------- // Fallback: definition-based edge derivation (status-filtered) // Used for older executions that lack triggered_by metadata. // // Unlike the naive "draw all transitions" approach, this filters // transitions based on the source task's actual execution status. // For example, if task "initialize" completed successfully, only // its `succeeded()` and `always` transitions are drawn — the // `failed()` branch is omitted because that path was never taken. // --------------------------------------------------------------- const groups = groupByTaskName(childExecutions); const taskIdsByName = new Map(); // Build a map of task name → representative terminal status const taskStatusByName = new Map(); for (const [name, execs] of groups) { const ids = [...new Set(execs.map((e) => resolve(String(e.id))))]; taskIdsByName.set(name, ids); taskStatusByName.set(name, representativeStatus(execs)); } for (const defTask of workflowDef.tasks) { const sourceIds = taskIdsByName.get(defTask.name); if (!sourceIds || sourceIds.length === 0) continue; const sourceStatus = taskStatusByName.get(defTask.name); const transitions = normalizeLegacyTransitions(defTask); for (const transition of transitions) { if (!transition.do || transition.do.length === 0) continue; const kind = classifyWhen(transition.when); // Skip transitions whose condition doesn't match the source // task's actual terminal status. This prevents phantom edges // like "init failed" from appearing when the task succeeded. if (!transitionMatchesStatus(kind, sourceStatus)) continue; const label = labelForWhen( transition.when, transition.__chart_meta__?.label, ); const color = transition.__chart_meta__?.color; for (const targetName of transition.do) { const targetIds = taskIdsByName.get(targetName); if (!targetIds || targetIds.length === 0) continue; const resolvedSourceIds = [...new Set(sourceIds.map(resolve))]; const resolvedTargetIds = [...new Set(targetIds.map(resolve))]; const filteredTargetIds = resolvedTargetIds.filter( (tid) => !resolvedSourceIds.includes(tid), ); if (filteredTargetIds.length === 0) continue; linkConnectivity( globalTaskById, resolvedSourceIds, filteredTargetIds, ); pushGroupEdges( edges, resolvedSourceIds, filteredTargetIds, kind, label, color, ); } } } // Deduplicate connectivity for (const task of tasks) { task.upstreamIds = [...new Set(task.upstreamIds)]; task.downstreamIds = [...new Set(task.downstreamIds)]; } } else { // Heuristic fallback: infer edges from timing inferEdgesFromTiming(tasks, edges); } return edges; } // --------------------------------------------------------------------------- // Timing-based edge inference (fallback) // --------------------------------------------------------------------------- /** * When no workflow definition is available, infer likely dependencies * from task timing: * - Tasks that start after another task ends are likely downstream. * - Among candidates, pick the closest predecessor. * * This produces a reasonable approximation but won't capture all patterns. */ function inferEdgesFromTiming(tasks: TimelineTask[], edges: TimelineEdge[]) { // Sort by start time const sorted = [...tasks] .filter((t) => t.startMs != null) .sort((a, b) => a.startMs! - b.startMs!); for (const task of sorted) { if (task.startMs == null) continue; // Find the best predecessor: a task whose end time is closest to // (and before or at) this task's start time let bestPredecessor: TimelineTask | null = null; let bestGap = Infinity; for (const candidate of sorted) { if (candidate.id === task.id) continue; if (candidate.endMs == null) continue; if (candidate.endMs > task.startMs!) continue; // must finish before we start const gap = task.startMs! - candidate.endMs; if (gap < bestGap) { bestGap = gap; bestPredecessor = candidate; } } if (bestPredecessor) { edges.push({ from: bestPredecessor.id, to: task.id, kind: "success", label: undefined, }); task.upstreamIds.push(bestPredecessor.id); bestPredecessor.downstreamIds.push(task.id); } } } // --------------------------------------------------------------------------- // Public API: buildMilestones // --------------------------------------------------------------------------- /** * Create synthetic start/end milestones, and optionally merge/fork junctions. * * Milestones anchor the visual start and end of the timeline and provide * clean attachment points for fan-out / fan-in edges. */ export function buildMilestones( tasks: TimelineTask[], parentExecution: ExecutionSummary, ): { milestones: TimelineMilestone[]; milestoneEdges: TimelineEdge[]; suppressedEdgeKeys: Set; } { const milestones: TimelineMilestone[] = []; const milestoneEdges: TimelineEdge[] = []; /** Direct task→task edge keys that are replaced by milestone-routed paths. * `computeLayout` should exclude these from `taskEdges` to avoid duplicates. */ const suppressedEdgeKeys = new Set(); // Compute time bounds const startTimes = tasks .filter((t) => t.startMs != null) .map((t) => t.startMs!); const parentStartMs = parentExecution.started_at ? new Date(parentExecution.started_at).getTime() : new Date(parentExecution.created).getTime(); const minTimeMs = startTimes.length > 0 ? Math.min(parentStartMs, ...startTimes) : parentStartMs; // Start milestone const startId = "__start__"; milestones.push({ id: startId, kind: "start", timeMs: minTimeMs, label: "Start", }); // Identify root tasks (no upstream among task nodes) const rootTasks = tasks.filter((t) => t.upstreamIds.length === 0); // --- Fork junction detection --- const FORK_THRESHOLD = 3; if (rootTasks.length > FORK_THRESHOLD) { const earliestRootStart = Math.min( ...rootTasks.map((t) => t.startMs ?? minTimeMs), ); const forkTime = minTimeMs + (earliestRootStart - minTimeMs) * 0.5; const forkId = "__fork_start__"; milestones.push({ id: forkId, kind: "fork", timeMs: forkTime, label: `fan-out ×${rootTasks.length}`, }); milestoneEdges.push({ from: startId, to: forkId, kind: "success", }); // Cap visual edges from fork to root tasks (same as merge cap) const FORK_EDGE_CAP = 8; if (rootTasks.length <= FORK_EDGE_CAP) { for (const root of rootTasks) { milestoneEdges.push({ from: forkId, to: root.id, kind: "success", }); } } else { // Sample evenly-spaced root tasks for visual edges const indices = new Set(); indices.add(0); indices.add(rootTasks.length - 1); for ( let i = 1; indices.size < FORK_EDGE_CAP && i < FORK_EDGE_CAP - 1; i++ ) { indices.add( Math.round((i * (rootTasks.length - 1)) / (FORK_EDGE_CAP - 1)), ); } for (const idx of indices) { milestoneEdges.push({ from: forkId, to: rootTasks[idx].id, kind: "success", }); } } } else { // Connect start directly to root tasks for (const root of rootTasks) { milestoneEdges.push({ from: startId, to: root.id, kind: "success", }); } } // --- Internal merge junctions --- // Detect tasks that share the exact same set of upstreamIds (> 2 upstream). // Group by the upstream signature, then insert merge milestones. // // For large with_items groups the upstream set can be very large (100s or // 1000s). We use a stable hash of the sorted IDs as the map key instead // of the full comma-joined string to avoid huge string comparisons. const taskByIdLocal = new Map(tasks.map((t) => [t.id, t])); // Map: signature key → { upstreamIds, downstreamTasks } const upstreamSignatureGroups = new Map< string, { upstreamIds: string[]; tasks: TimelineTask[] } >(); for (const task of tasks) { if (task.upstreamIds.length <= 2) continue; const sorted = [...task.upstreamIds].sort(); // Use a short hash for the map key to avoid huge string keys. // For groups up to ~20, the full join is fine. Beyond that, hash. // For small sets the full join is a perfect key. For large sets we // sample enough points that accidental collision within a single // workflow is effectively impossible (count + 5 evenly-spaced IDs). const sig = sorted.length <= 20 ? sorted.join(",") : [ `n${sorted.length}`, sorted[0], sorted[Math.floor(sorted.length * 0.25)], sorted[Math.floor(sorted.length * 0.5)], sorted[Math.floor(sorted.length * 0.75)], sorted[sorted.length - 1], ].join("_"); const group = upstreamSignatureGroups.get(sig); if (group) { group.tasks.push(task); } else { upstreamSignatureGroups.set(sig, { upstreamIds: sorted, tasks: [task], }); } } // Maximum number of visual edges from upstream tasks to a merge milestone. // Beyond this we sample evenly-spaced representatives — the merge diamond // still conceptually represents the full fan-in. const MERGE_EDGE_CAP = 8; const sampleIds = (ids: string[], max: number): string[] => { if (ids.length <= max) return ids; const result = new Set(); result.add(ids[0]); result.add(ids[ids.length - 1]); for (let i = 1; result.size < max && i < max - 1; i++) { result.add(ids[Math.round((i * (ids.length - 1)) / (max - 1))]); } return [...result]; }; for (const [ , { upstreamIds, tasks: downstreamGroup }, ] of upstreamSignatureGroups) { if (downstreamGroup.length < 1) continue; if (upstreamIds.length <= 2) continue; // Find the time position: latest end of the upstream tasks let maxEndMs = 0; for (const uid of upstreamIds) { const t = taskByIdLocal.get(uid); if (t) { const end = t.endMs ?? t.startMs ?? 0; if (end > maxEndMs) maxEndMs = end; } } const mergeTimeMs = maxEndMs > 0 ? maxEndMs + 50 : minTimeMs; const mergeId = `__merge_${upstreamIds.length}_${upstreamIds[0]}__`; // Avoid duplicating milestones if (milestones.some((m) => m.id === mergeId)) continue; // Determine label: if all upstream tasks share the same name, this is // a with_items convergence ("all items done"); otherwise it's a join // of distinct parallel branches ("join N"). let allSameName = true; let firstName: string | null = null; for (const uid of upstreamIds) { const t = taskByIdLocal.get(uid); if (!t) continue; if (firstName === null) { firstName = t.name; } else if (t.name !== firstName) { allSameName = false; break; } } const mergeLabel = allSameName && firstName != null && upstreamIds.length > 1 ? `all ${upstreamIds.length} items done` : `join ${upstreamIds.length}`; milestones.push({ id: mergeId, kind: "merge", timeMs: mergeTimeMs, label: mergeLabel, }); // Redirect edges: replace direct upstream→downstream edges with // upstream→merge→downstream. For large fan-ins we only draw a // capped number of visual edges to the merge diamond to keep SVG // rendering fast; ALL direct edges are still suppressed so the // merge cleanly replaces them. const sampledUpstream = sampleIds(upstreamIds, MERGE_EDGE_CAP); for (const uid of sampledUpstream) { milestoneEdges.push({ from: uid, to: mergeId, kind: "success", }); } // Suppress ALL direct edges (not just the sampled ones) for (const uid of upstreamIds) { for (const dt of downstreamGroup) { suppressedEdgeKeys.add(`${uid}→${dt.id}`); } } for (const dt of downstreamGroup) { milestoneEdges.push({ from: mergeId, to: dt.id, kind: "success", }); } } return { milestones, milestoneEdges, suppressedEdgeKeys }; } // --------------------------------------------------------------------------- // Public API: findConnectedPath // --------------------------------------------------------------------------- /** * Given a selected task ID, find all upstream and downstream tasks * reachable within `maxHops` hops. Returns a Set of connected node IDs * (including the selected node itself) and a Set of connected edge keys. */ export function findConnectedPath( selectedId: string, tasks: TimelineTask[], allEdges: TimelineEdge[], maxHops: number = 999, ): { nodeIds: Set; edgeKeys: Set } { const nodeIds = new Set(); const edgeKeys = new Set(); const taskMap = new Map(tasks.map((t) => [t.id, t])); nodeIds.add(selectedId); // BFS upstream const upQueue: { id: string; depth: number }[] = [ { id: selectedId, depth: 0 }, ]; while (upQueue.length > 0) { const { id, depth } = upQueue.shift()!; if (depth >= maxHops) continue; const task = taskMap.get(id); if (!task) continue; for (const uid of task.upstreamIds) { edgeKeys.add(edgeKey(uid, id)); if (!nodeIds.has(uid)) { nodeIds.add(uid); upQueue.push({ id: uid, depth: depth + 1 }); } } } // BFS downstream const downQueue: { id: string; depth: number }[] = [ { id: selectedId, depth: 0 }, ]; while (downQueue.length > 0) { const { id, depth } = downQueue.shift()!; if (depth >= maxHops) continue; const task = taskMap.get(id); if (!task) continue; for (const did of task.downstreamIds) { edgeKeys.add(edgeKey(id, did)); if (!nodeIds.has(did)) { nodeIds.add(did); downQueue.push({ id: did, depth: depth + 1 }); } } } // Also include milestone edges that touch connected nodes for (const edge of allEdges) { if (nodeIds.has(edge.from) && nodeIds.has(edge.to)) { edgeKeys.add(edgeKey(edge.from, edge.to)); } } return { nodeIds, edgeKeys }; } /** Create a stable key for an edge (used for Set membership) */ export function edgeKey(from: string, to: string): string { return `${from}→${to}`; }