Files
attune/web/src/components/executions/workflow-timeline/data.ts
2026-03-04 22:02:34 -06:00

1377 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, ExecutionSummary[]> {
const groups = new Map<string, ExecutionSummary[]>();
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<string, string>;
} {
// 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<string, TimelineTask[]>();
for (const t of tasks) {
const list = byName.get(t.name) ?? [];
list.push(t);
byName.set(t.name, list);
}
const memberToGroup = new Map<string, string>();
const removedIds = new Set<string>();
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<string, TimelineTask>,
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<number>();
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<string, string>,
): 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<string, string[]>();
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<string, ExecutionSummary>();
for (const e of childExecutions) {
execById.set(String(e.id), e);
}
// Collect unique (predecessorName → taskName) pairs with edge metadata
const pairsSeen = new Set<string>();
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<string, Set<string>>();
// 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<string, EdgeKind[]>();
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<string>();
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<string, string[]>();
// Build a map of task name → representative terminal status
const taskStatusByName = new Map<string, string | undefined>();
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<string>;
} {
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<string>();
// 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<number>();
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<string>();
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<string>; edgeKeys: Set<string> } {
const nodeIds = new Set<string>();
const edgeKeys = new Set<string>();
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}`;
}