1377 lines
45 KiB
TypeScript
1377 lines
45 KiB
TypeScript
/**
|
||
* 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}`;
|
||
}
|