(undefined);
+
+ useEffect(() => {
+ latestConditionsRef.current = conditions;
+ }, [conditions]);
+
+ useEffect(() => {
+ if (!focusRequest) return;
+ if (handledFocusRequestIDRef.current === focusRequest.requestID) return;
+
+ const focusedCondition = latestConditionsRef.current.find((condition) =>
+ conditionMatchesName(condition, focusRequest.conditionName),
+ );
+ if (!focusedCondition) return;
+
+ const row = conditionRowRefs.current.get(
+ getConditionFocusKey(focusedCondition),
+ );
+ row?.scrollIntoView?.({ behavior: "smooth", block: "center" });
+ row?.focus({ preventScroll: true });
+ handledFocusRequestIDRef.current = focusRequest.requestID;
+ }, [focusRequest]);
+
+ const registerConditionRow = (
+ condition: WaitTermView,
+ node: HTMLDivElement | null,
+ ) => {
+ const key = getConditionFocusKey(condition);
+ if (node) {
+ conditionRowRefs.current.set(key, node);
+ return;
+ }
+
+ conditionRowRefs.current.delete(key);
+ };
+
+ return (
+
+
+ {matchedConditions.length.toString()} of {conditions.length.toString()}{" "}
+ conditions satisfied
+
+
+
+
+ Status
+ Condition
+
+
+ {conditions.map((condition) => {
+ const conditionSignalState = condition.signal
+ ? signalListStates[getConditionSignalStateKey(condition)]
+ : undefined;
+ const conditionSignalsOpen =
+ condition.signal !== undefined &&
+ openSignalSurface?.kind === "condition" &&
+ getSignalSurfaceStateKey(openSignalSurface) ===
+ getConditionSignalStateKey(condition);
+
+ return (
+
+ );
+ })}
+
+
+
+ );
+};
+
+const ConditionRow = ({
+ condition,
+ focused,
+ onLoadMore,
+ onRegisterRow,
+ onToggleConditionSignals,
+ openSignalSurface,
+ signalListState,
+ wait,
+}: {
+ condition: WaitTermView;
+ focused: boolean;
+ onLoadMore: (surface: SignalHistorySurface) => void;
+ onRegisterRow: (condition: WaitTermView, node: HTMLDivElement | null) => void;
+ onToggleConditionSignals: (surface: SignalHistorySurface) => void;
+ openSignalSurface: SignalHistorySurface | undefined;
+ signalListState: SignalInspectorState;
+ wait: WorkflowTaskWait;
+}) => {
+ const stateTone = getConditionStateTone(condition, wait.phase);
+ const signal = condition.signal;
+ const timer = condition.timer;
+ const hasEvidence =
+ condition.dependencyTask !== undefined ||
+ signal !== undefined ||
+ timer !== undefined;
+ const showRawTechnicalName = Boolean(condition.exprCel || timer);
+ const metadataContent: ReactNode = timer ? (
+
+ ) : (
+ condition.technicalName
+ );
+
+ return (
+ onRegisterRow(condition, node)}
+ tabIndex={-1}
+ >
+
+
+
+
+ {getConditionStateLabel(condition, wait.phase)}
+
+
+
+
+
+ {condition.label}
+
+ {showRawTechnicalName ? (
+
+ {condition.technicalName}
+
+ ) : null}
+
+
+
+ •
+
+ {timer ? (
+ {metadataContent}
+ ) : condition.exprCel ? (
+
+ ) : (
+ {metadataContent}
+ )}
+
+
+
+ {hasEvidence ? (
+
+
+
+ ) : null}
+
+
+ {signal ? (
+
+ onToggleConditionSignals(signalSurfaceForCondition(condition))
+ }
+ open={
+ openSignalSurface?.kind === "condition" &&
+ getSignalSurfaceStateKey(openSignalSurface) ===
+ getConditionSignalStateKey(condition)
+ }
+ phase={wait.phase}
+ signal={signal}
+ signalListState={signalListState}
+ surface={signalSurfaceForCondition(condition)}
+ />
+ ) : null}
+
+ );
+};
+
+const ConditionExpression = ({
+ conditionLabel,
+ expression,
+}: {
+ conditionLabel: string;
+ expression: string;
+}) => {
+ const [expanded, setExpanded] = useState(false);
+ const expressionID = useId();
+ const isLongExpression =
+ expression.length > INLINE_CEL_MAX_LENGTH || /[\r\n]/.test(expression);
+
+ if (!isLongExpression) {
+ return (
+
+ {expression}
+
+ );
+ }
+
+ const previewExpression = getExpressionPreview(expression);
+ const buttonLabel = `${expanded ? "Hide" : "Show"} full CEL expression for ${conditionLabel}`;
+
+ return (
+ <>
+ setExpanded((current) => !current)}
+ title={expression}
+ type="button"
+ >
+ {previewExpression}
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+ {expanded ? (
+
+ {expression}
+
+ ) : null}
+ >
+ );
+};
+
+const getExpressionPreview = (expression: string): string => {
+ const oneLineExpression = expression.replace(/\s+/g, " ").trim();
+ if (oneLineExpression.length <= INLINE_CEL_MAX_LENGTH) {
+ return `${oneLineExpression}...`;
+ }
+
+ return `${oneLineExpression.slice(0, INLINE_CEL_MAX_LENGTH)}...`;
+};
+
+const ConditionEvidence = ({
+ condition,
+ wait,
+}: {
+ condition: WaitTermView;
+ wait: WorkflowTaskWait;
+}) => {
+ const timer = condition.timer;
+ if (timer) {
+ return ;
+ }
+
+ const signal = condition.signal;
+ if (signal) {
+ return (
+
+ );
+ }
+
+ const dependencyTask = condition.dependencyTask;
+ if (dependencyTask) {
+ return (
+
+ );
+ }
+
+ return null;
+};
+
+const TimerConditionDefinition = ({
+ timer,
+}: {
+ timer: WorkflowTaskWait["inputs"]["timers"][number];
+}) => {
+ const delay = getTimerDelayLabel(timer);
+ const anchor = timer.anchor;
+
+ if (!delay) {
+ if (!anchor) return <>Immediate>;
+ switch (anchor.kind) {
+ case "task_finalized_at":
+ return anchor.task ? (
+ <>
+ When finalizes
+ >
+ ) : (
+ <>When dependency finalizes>
+ );
+ case "wait_started_at":
+ return <>When wait starts>;
+ case "workflow_created_at":
+ return <>When workflow starts>;
+ default:
+ return anchor.task ? (
+ <>
+ {anchor.kind.replaceAll("_", " ")} (
+ )
+ >
+ ) : (
+ <>{anchor.kind.replaceAll("_", " ")}>
+ );
+ }
+ }
+
+ if (!anchor) return <>After {delay}>;
+ switch (anchor.kind) {
+ case "task_finalized_at":
+ return anchor.task ? (
+ <>
+ {delay} after finalizes
+ >
+ ) : (
+ <>{delay} after dependency finalizes>
+ );
+ case "wait_started_at":
+ return <>{delay} after wait starts>;
+ case "workflow_created_at":
+ return <>{delay} after workflow starts>;
+ default:
+ return anchor.task ? (
+ <>
+ {delay} after {anchor.kind.replaceAll("_", " ")} (
+ )
+ >
+ ) : (
+ <>
+ {delay} after {anchor.kind.replaceAll("_", " ")}
+ >
+ );
+ }
+};
+
+const TimerTaskName = ({ taskName }: { taskName: string }) => (
+
+ {taskName}
+
+);
+
+const TimerConditionEvidence = ({
+ timer,
+}: {
+ timer: WorkflowTaskWait["inputs"]["timers"][number];
+}) => {
+ const fired = timer.result?.fired ?? false;
+ return (
+
+
+ {fired ? "Fired" : "Fires"}
+ {" "}
+
+
+ );
+};
+
+const DependencyConditionEvidence = ({
+ condition,
+ wait,
+}: {
+ condition: {
+ dependencyTask: WorkflowTask;
+ } & WaitTermView;
+ wait: WorkflowTaskWait;
+}) => {
+ if (condition.dependencyTask.finalizedAt) {
+ return (
+
+
+ Finalized
+ {" "}
+
+
+ );
+ }
+
+ return condition.matched ? (
+
+ ) : null;
+};
+
+const SignalConditionEvidence = ({
+ condition,
+ wait,
+}: {
+ condition: {
+ signal: WorkflowTaskWait["inputs"]["signals"][number];
+ } & WaitTermView;
+ wait: WorkflowTaskWait;
+}) => {
+ const signalResult = condition.signal.result;
+ const termResult = condition.result ?? undefined;
+ return (
+
+
+
+
+ {termResult ? (
+
+ ) : null}
+ {signalResult?.lastIncludedID ? (
+
+ ) : null}
+ {termResult?.lastMatchedID ? (
+
+ ) : null}
+
+ {condition.matched ? (
+
+ ) : null}
+
+ );
+};
+
+const ConditionSnapshotTiming = ({
+ label,
+ resolvedLabel,
+ wait,
+}: {
+ label: string;
+ resolvedLabel: string;
+ wait: WorkflowTaskWait;
+}) => {
+ const time = wait.resolvedAt ?? wait.evidence?.evaluatedAt;
+ if (!time) return null;
+
+ return (
+
+
+ {label}
+ {" "}
+ {wait.resolvedAt ? resolvedLabel : "by evaluation"}{" "}
+
+
+ );
+};
+
+const CompactEvidenceField = ({
+ label,
+ value,
+}: {
+ label: string;
+ value: ReactNode;
+}) => {
+ return (
+
+
{label}
+
+ {value}
+
+
+ );
+};
+
+const ConditionKindLabel = ({ kind }: { kind: string }) => {
+ return (
+
+
+ {getWaitTermKindLabel(kind)}
+
+ );
+};
+
+export const ConditionKindIcon = ({
+ className,
+ kind,
+}: {
+ className?: string;
+ kind: string;
+}) => {
+ switch (kind) {
+ case "dep_input":
+ case "generic":
+ return (
+
+ );
+ case "signal":
+ case "signal_input":
+ return (
+
+ );
+ case "timer":
+ case "timer_input":
+ return (
+
+ );
+ default:
+ return null;
+ }
+};
+
+const TimerTiming = ({
+ timer,
+}: {
+ timer: WorkflowTaskWait["inputs"]["timers"][number];
+}) => {
+ if (!timer.fireAt) {
+ return {formatTimerAnchorWait(timer.anchor)} ;
+ }
+
+ return ;
+};
diff --git a/src/components/WorkflowGateInspectorDiagnostics.tsx b/src/components/WorkflowGateInspectorDiagnostics.tsx
new file mode 100644
index 00000000..70aa4a56
--- /dev/null
+++ b/src/components/WorkflowGateInspectorDiagnostics.tsx
@@ -0,0 +1,125 @@
+import RelativeTimeFormatter from "@components/RelativeTimeFormatter";
+import { type WorkflowTaskWaitDiagnostics } from "@services/workflows";
+import { type ReactNode } from "react";
+
+import { type WaitDiagnosticsState } from "./WorkflowGateInspector.types";
+import { WaitSection } from "./WorkflowGateInspectorSummary";
+
+export const WaitDiagnosticsPanel = ({
+ diagnosticsState,
+}: {
+ diagnosticsState: WaitDiagnosticsState;
+}) => {
+ if (diagnosticsState.isLoading) {
+ return (
+
+
+ Loading current wait diagnostics…
+
+
+ );
+ }
+
+ if (diagnosticsState.error) {
+ return (
+
+
+ {diagnosticsState.error}
+
+
+ );
+ }
+
+ const diagnostics = diagnosticsState.value;
+ if (!diagnostics) return null;
+ const evalMessage = getDiagnosticsEvalMessage(diagnostics);
+
+ return (
+
+
+
Current status for declared wait inputs.
+
+
+
+ }
+ />
+
+
+
+ {diagnostics.truncated ? (
+
+ Signal diagnostics reached the scan limit, so expression and match
+ counts are best effort.
+
+ ) : null}
+ {evalMessage ? (
+
+ {evalMessage.message}
+
+ ) : null}
+
+
+ );
+};
+
+const CompactDiagnosticsField = ({
+ label,
+ value,
+}: {
+ label: string;
+ value: ReactNode;
+}) => (
+
+
+ {label}
+
+
+ {value}
+
+
+);
+
+const getDiagnosticsEvalMessage = (
+ diagnostics: WorkflowTaskWaitDiagnostics,
+): { message: string; tone: "neutral" | "warning" } | undefined => {
+ if (!diagnostics.evalError) return undefined;
+
+ const hasUnavailableDepOutput = diagnostics.inputs.deps.some(
+ (dep) => !dep.available,
+ );
+ if (diagnostics.phase === "not_started" && hasUnavailableDepOutput) {
+ return {
+ message: "Waiting for dependency output.",
+ tone: "neutral",
+ };
+ }
+
+ return {
+ message: diagnostics.evalError,
+ tone: "warning",
+ };
+};
diff --git a/src/components/WorkflowGateInspectorSignals.tsx b/src/components/WorkflowGateInspectorSignals.tsx
new file mode 100644
index 00000000..6234f59f
--- /dev/null
+++ b/src/components/WorkflowGateInspectorSignals.tsx
@@ -0,0 +1,262 @@
+import { Badge } from "@components/Badge";
+import JSONView from "@components/JSONView";
+import RelativeTimeFormatter from "@components/RelativeTimeFormatter";
+import { ChevronRightIcon } from "@heroicons/react/24/outline";
+import {
+ type WorkflowTaskSignal,
+ type WorkflowTaskWait,
+} from "@services/workflows";
+import clsx from "clsx";
+import { useState } from "react";
+
+import {
+ getLoadedSignalHistorySummary,
+ getSignalEvidenceSummary,
+} from "./WorkflowGateInspector.model";
+import {
+ type SignalHistorySurface,
+ type SignalInspectorState,
+} from "./WorkflowGateInspector.types";
+
+export const ConditionSignalEvidenceDisclosure = ({
+ onLoadMore,
+ onToggle,
+ open,
+ phase,
+ signal,
+ signalListState,
+ surface,
+}: {
+ onLoadMore: (surface: SignalHistorySurface) => void;
+ onToggle: () => void;
+ open: boolean;
+ phase: WorkflowTaskWait["phase"];
+ signal: WorkflowTaskWait["inputs"]["signals"][number];
+ signalListState: SignalInspectorState;
+ surface: SignalHistorySurface;
+}) => {
+ const scopeLabel =
+ phase === "resolved" ? "Resolution evidence" : "Signal history";
+ const signalSummary = getSignalEvidenceSummary(signal);
+
+ return (
+
+
+
+ {scopeLabel}
+
+ {signalSummary}
+
+
+
+ {open ? (
+ onLoadMore(surface)}
+ signalListState={signalListState}
+ />
+ ) : null}
+
+ );
+};
+
+export const AllTaskSignalsPanel = ({
+ onLoadMore,
+ onToggle,
+ open,
+ signalListState,
+}: {
+ onLoadMore: (surface: SignalHistorySurface) => void;
+ onToggle: () => void;
+ open: boolean;
+ signalListState: SignalInspectorState;
+}) => {
+ const signalSummary = getLoadedSignalHistorySummary(signalListState);
+
+ return (
+
+
+
+ All task signals
+ {signalSummary ? (
+
+ {signalSummary}
+
+ ) : null}
+
+
+ {open ? (
+
+ onLoadMore({ kind: "all" })}
+ signalListState={signalListState}
+ />
+
+ ) : null}
+
+ );
+};
+
+const SignalHistoryPanel = ({
+ emptyText,
+ helperText,
+ onLoadMore,
+ signalListState,
+}: {
+ emptyText: string;
+ helperText: string;
+ onLoadMore: () => void;
+ signalListState: SignalInspectorState;
+}) => {
+ const signalCount = signalListState.signals.length;
+
+ return (
+
+
{helperText}
+
+ {signalListState.error ? (
+
+ {signalListState.error}
+
+ ) : null}
+
+ {signalListState.isLoading ? (
+
+ Loading signal history…
+
+ ) : null}
+
+ {!signalListState.isLoading && signalListState.signals.length === 0 ? (
+
+ {emptyText}
+
+ ) : null}
+
+ {signalCount > 0 ? (
+
+ {signalListState.signals.map((signal) => (
+
+ ))}
+
+ ) : null}
+
+ {signalListState.hasMore ? (
+
+ {signalListState.isLoadingMore
+ ? "Loading older signals…"
+ : "Load older signals"}
+
+ ) : null}
+
+ );
+};
+
+const SignalHistoryItem = ({
+ defaultOpen,
+ signal,
+}: {
+ defaultOpen: boolean;
+ signal: WorkflowTaskSignal;
+}) => {
+ const [open, setOpen] = useState(defaultOpen);
+
+ return (
+ setOpen(event.currentTarget.open)}
+ open={open}
+ >
+
+
+ #{signal.id.toString()}
+
+ {signal.key}
+
+
+ workflow attempt {signal.attempt.toString()}
+
+
+
+
+
+
+
+
+ );
+};
+
+const SignalPayloadPanel = ({
+ copyTitle,
+ data,
+ title,
+}: {
+ copyTitle: string;
+ data: unknown;
+ title: string;
+}) => {
+ return (
+
+
+ {title}
+
+
+
+ );
+};
diff --git a/src/components/WorkflowGateInspectorSummary.tsx b/src/components/WorkflowGateInspectorSummary.tsx
new file mode 100644
index 00000000..c3705357
--- /dev/null
+++ b/src/components/WorkflowGateInspectorSummary.tsx
@@ -0,0 +1,151 @@
+import { Badge } from "@components/Badge";
+import RelativeTimeFormatter from "@components/RelativeTimeFormatter";
+import { type WorkflowTaskWait } from "@services/workflows";
+import { formatDurationShort } from "@utils/time";
+import { type ReactNode } from "react";
+
+import {
+ getWaitStatusLabel,
+ getWaitSummary,
+} from "./WorkflowGateInspector.model";
+import { type WaitTermView } from "./WorkflowGateInspector.types";
+
+export const WaitSummary = ({
+ matchedConditions,
+ onSelectCondition,
+ wait,
+}: {
+ matchedConditions: WaitTermView[];
+ onSelectCondition: (conditionName: string) => void;
+ wait: WorkflowTaskWait;
+}) => {
+ if (wait.phase === "resolved" && matchedConditions.length > 0) {
+ return (
+
+ Resolved by:{" "}
+
+ .
+
+ );
+ }
+
+ return (
+
+ {getWaitSummary(wait)}
+
+ );
+};
+
+const InlineConditionList = ({
+ conditions,
+ onSelectCondition,
+}: {
+ conditions: WaitTermView[];
+ onSelectCondition: (conditionName: string) => void;
+}) => {
+ return (
+ <>
+ {conditions.map((condition, index) => (
+
+ {index > 0 ? ", " : null}
+ onSelectCondition(condition.technicalName)}
+ type="button"
+ >
+ {condition.label}
+
+
+ ))}
+ >
+ );
+};
+
+export const WaitStatusPill = ({ wait }: { wait: WorkflowTaskWait }) => {
+ const color =
+ wait.phase === "resolved"
+ ? "green"
+ : wait.phase === "unknown"
+ ? "zinc"
+ : "amber";
+
+ return (
+
+ {getWaitStatusLabel(wait.phase)}
+
+ );
+};
+
+export const WaitSection = ({
+ children,
+ title,
+}: {
+ children: ReactNode;
+ title: string;
+}) => {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+};
+
+export const WaitFacts = ({ wait }: { wait: WorkflowTaskWait }) => {
+ const items = [
+ wait.startedAt
+ ? {
+ label: "Started",
+ value: ,
+ }
+ : undefined,
+ wait.resolvedAt
+ ? {
+ label: "Resolved",
+ value: ,
+ }
+ : undefined,
+ wait.startedAt && wait.resolvedAt
+ ? {
+ label: "Waited",
+ value: formatDurationShort(wait.resolvedAt, wait.startedAt, false),
+ }
+ : undefined,
+ wait.evidence
+ ? {
+ label: "Evaluated",
+ value: (
+
+ ),
+ }
+ : undefined,
+ wait.evidence
+ ? {
+ label: "Workflow attempt",
+ value: wait.evidence.workflowAttempt.toString(),
+ }
+ : undefined,
+ ].filter((item) => item !== undefined);
+
+ if (items.length === 0) return null;
+
+ return (
+
+ {items.map((item) => (
+
+
+ {item.label}
+
+
+ {item.value}
+
+
+ ))}
+
+ );
+};
diff --git a/src/components/WorkflowListEmptyState.tsx b/src/components/WorkflowListEmptyState.tsx
index a61f3584..a3351061 100644
--- a/src/components/WorkflowListEmptyState.tsx
+++ b/src/components/WorkflowListEmptyState.tsx
@@ -6,12 +6,14 @@ import { listWorkflows, listWorkflowsKey } from "@services/workflows";
import { queryOptions, useQuery } from "@tanstack/react-query";
export default function WorkflowListEmptyState({
+ probeForExistingWorkflows = true,
showingAll,
}: {
+ probeForExistingWorkflows?: boolean;
showingAll: boolean;
}) {
const opts = queryOptions({
- enabled: !showingAll,
+ enabled: probeForExistingWorkflows && !showingAll,
queryFn: listWorkflows,
queryKey: listWorkflowsKey({ limit: 1, state: undefined }),
refetchInterval: 60000,
@@ -20,7 +22,9 @@ export default function WorkflowListEmptyState({
const anyWorkflowsQuery = useQuery(opts);
const hasExistingWorkflows =
anyWorkflowsQuery.isLoading ||
- (!showingAll && (anyWorkflowsQuery.data || []).length > 0);
+ (probeForExistingWorkflows &&
+ !showingAll &&
+ (anyWorkflowsQuery.data || []).length > 0);
return (
<>
diff --git a/src/components/job-search/JobSearch.test.tsx b/src/components/job-search/JobSearch.test.tsx
index bc5aaee0..853cacf6 100644
--- a/src/components/job-search/JobSearch.test.tsx
+++ b/src/components/job-search/JobSearch.test.tsx
@@ -8,6 +8,47 @@ import {
import { userEvent } from "storybook/test";
import { beforeEach, describe, expect, it, vi } from "vitest";
+vi.mock("./api", () => ({
+ fetchSuggestions: async (
+ filterTypeId: string,
+ query: string,
+ selectedValues: string[],
+ ) => {
+ if (filterTypeId === "kind") {
+ const mockKinds = [
+ "batch",
+ "stream",
+ "scheduled",
+ "one-time",
+ "recurring",
+ "Chaos",
+ "AITrainingBatch",
+ "UtilizeNewModel",
+ ];
+ return mockKinds
+ .filter((kind) => kind.toLowerCase().includes(query.toLowerCase()))
+ .filter((kind) => !selectedValues.includes(kind));
+ } else if (filterTypeId === "queue") {
+ const mockQueues = [
+ "default",
+ "high-priority",
+ "low-priority",
+ "batch",
+ "realtime",
+ ];
+ return mockQueues
+ .filter((queue) => queue.includes(query.toLowerCase()))
+ .filter((queue) => !selectedValues.includes(queue));
+ } else if (filterTypeId === "priority") {
+ const priorities = ["1", "2", "3", "4"];
+ return priorities
+ .filter((priority) => priority.includes(query))
+ .filter((priority) => !selectedValues.includes(priority));
+ }
+ return [];
+ },
+}));
+
import { Filter, FilterTypeId, JobSearch } from "./JobSearch";
vi.mock("./api", () => ({
diff --git a/src/components/workflow-diagram/WorkflowDiagram.stories.tsx b/src/components/workflow-diagram/WorkflowDiagram.stories.tsx
new file mode 100644
index 00000000..c1ae3329
--- /dev/null
+++ b/src/components/workflow-diagram/WorkflowDiagram.stories.tsx
@@ -0,0 +1,662 @@
+import type { WorkflowTask } from "@services/workflows";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { JobState } from "@services/types";
+import { workflowJobFactory } from "@test/factories/workflowJob";
+import { add, sub } from "date-fns";
+import { useEffect, useState } from "react";
+
+import WorkflowDiagram from "./WorkflowDiagram";
+
+const recentWorkflowStart = (secondsAgo: number) =>
+ sub(new Date(), { seconds: secondsAgo });
+
+const withTimestamps = (
+ task: WorkflowTask,
+ overrides: {
+ attemptedAt?: Date;
+ finalizedAt?: Date;
+ scheduledAt?: Date;
+ },
+): WorkflowTask => ({ ...task, ...overrides });
+
+const buildBaselineTasks = (): WorkflowTask[] => {
+ const startedAt = recentWorkflowStart(12);
+
+ return [
+ withTimestamps(
+ workflowJobFactory.build({
+ id: 1,
+ state: JobState.Completed,
+ task: "classify_intake",
+ waitReason: "none",
+ workflowStagedAt: startedAt,
+ }),
+ {
+ attemptedAt: add(startedAt, { seconds: 1 }),
+ finalizedAt: add(startedAt, { seconds: 3 }),
+ scheduledAt: startedAt,
+ },
+ ),
+ workflowJobFactory.build({
+ deps: ["classify_intake"],
+ id: 2,
+ state: JobState.Pending,
+ task: "compose_draft_response",
+ waitReason: "dependencies",
+ workflowStagedAt: startedAt,
+ }),
+ workflowJobFactory.build({
+ deps: ["compose_draft_response"],
+ id: 3,
+ state: JobState.Pending,
+ task: "send_response",
+ waitReason: "dependencies",
+ workflowStagedAt: startedAt,
+ }),
+ ];
+};
+
+const buildResolvedAndWaitingTasks = (): WorkflowTask[] => {
+ const startedAt = recentWorkflowStart(20);
+
+ return [
+ withTimestamps(
+ workflowJobFactory.build({
+ id: 31,
+ state: JobState.Completed,
+ task: "collect_inputs",
+ waitReason: "none",
+ workflowStagedAt: startedAt,
+ }),
+ {
+ attemptedAt: add(startedAt, { seconds: 1 }),
+ finalizedAt: add(startedAt, { seconds: 2 }),
+ scheduledAt: startedAt,
+ },
+ ),
+ withTimestamps(
+ workflowJobFactory.build({
+ deps: ["collect_inputs"],
+ id: 32,
+ state: JobState.Completed,
+ task: "review_ready",
+ wait: {
+ evidence: {
+ evaluatedAt: add(startedAt, { seconds: 8 }),
+ workflowAttempt: 1,
+ },
+ exprCel: "approval_received",
+ inputs: {
+ deps: [{ taskName: "compose_draft" }],
+ signals: [
+ {
+ key: "approval.received",
+ },
+ ],
+ timers: [],
+ },
+ phase: "resolved",
+ resolvedAt: add(startedAt, { seconds: 8 }),
+ startedAt: add(startedAt, { seconds: 2 }),
+ summary: "Human approval received",
+ terms: [
+ {
+ kind: "signal",
+ label: "Human approval received",
+ name: "approval_received",
+ result: { matchedCount: 0, requiredCount: 0, satisfied: true },
+ },
+ ],
+ },
+ waitReason: "none",
+ workflowStagedAt: startedAt,
+ }),
+ {
+ attemptedAt: add(startedAt, { seconds: 9 }),
+ finalizedAt: add(startedAt, { seconds: 11 }),
+ scheduledAt: add(startedAt, { seconds: 2 }),
+ },
+ ),
+ workflowJobFactory.build({
+ deps: ["review_ready"],
+ id: 33,
+ state: JobState.Pending,
+ task: "publish_response",
+ wait: {
+ exprCel: "final_sign_off_received",
+ inputs: {
+ deps: [],
+ signals: [
+ {
+ key: "final_sign_off.received",
+ },
+ ],
+ timers: [],
+ },
+ phase: "waiting",
+ startedAt: add(startedAt, { seconds: 11 }),
+ summary: "Waiting for final sign-off.",
+ terms: [
+ {
+ kind: "signal",
+ label: "Final sign-off received",
+ name: "final_sign_off_received",
+ result: { matchedCount: 0, requiredCount: 0, satisfied: false },
+ },
+ ],
+ },
+ waitReason: "wait",
+ workflowStagedAt: startedAt,
+ }),
+ ];
+};
+
+const buildAgentCustomerResolutionTasks = (): WorkflowTask[] => {
+ const startedAt = recentWorkflowStart(30);
+
+ return [
+ withTimestamps(
+ workflowJobFactory.build({
+ deps: [],
+ id: 106,
+ state: JobState.Completed,
+ task: "plan_execution",
+ waitReason: "none",
+ workflowStagedAt: startedAt,
+ }),
+ {
+ attemptedAt: add(startedAt, { seconds: 1 }),
+ finalizedAt: add(startedAt, { seconds: 2 }),
+ scheduledAt: startedAt,
+ },
+ ),
+ withTimestamps(
+ workflowJobFactory.build({
+ deps: ["plan_execution"],
+ id: 107,
+ state: JobState.Completed,
+ task: "call_billing_tool",
+ waitReason: "none",
+ workflowStagedAt: startedAt,
+ }),
+ {
+ attemptedAt: add(startedAt, { seconds: 3 }),
+ finalizedAt: add(startedAt, { seconds: 4 }),
+ scheduledAt: add(startedAt, { seconds: 2 }),
+ },
+ ),
+ withTimestamps(
+ workflowJobFactory.build({
+ deps: ["plan_execution"],
+ id: 108,
+ state: JobState.Completed,
+ task: "call_crm_tool",
+ waitReason: "none",
+ workflowStagedAt: startedAt,
+ }),
+ {
+ attemptedAt: add(startedAt, { seconds: 3 }),
+ finalizedAt: add(startedAt, { seconds: 4 }),
+ scheduledAt: add(startedAt, { seconds: 2 }),
+ },
+ ),
+ withTimestamps(
+ workflowJobFactory.build({
+ deps: ["plan_execution"],
+ id: 109,
+ state: JobState.Completed,
+ task: "call_entitlements_tool",
+ waitReason: "none",
+ workflowStagedAt: startedAt,
+ }),
+ {
+ attemptedAt: add(startedAt, { seconds: 3 }),
+ finalizedAt: add(startedAt, { seconds: 6 }),
+ scheduledAt: add(startedAt, { seconds: 2 }),
+ },
+ ),
+ withTimestamps(
+ workflowJobFactory.build({
+ deps: ["plan_execution"],
+ id: 110,
+ state: JobState.Running,
+ task: "call_risk_tool",
+ waitReason: "none",
+ workflowStagedAt: startedAt,
+ }),
+ {
+ attemptedAt: add(startedAt, { seconds: 3 }),
+ scheduledAt: add(startedAt, { seconds: 2 }),
+ },
+ ),
+ workflowJobFactory.build({
+ deps: ["call_billing_tool", "call_crm_tool", "call_entitlements_tool"],
+ id: 111,
+ state: JobState.Available,
+ task: "compose_draft_response",
+ wait: {
+ evidence: {
+ evaluatedAt: add(startedAt, { seconds: 6 }),
+ workflowAttempt: 1,
+ },
+ exprCel: "draft_ready",
+ inputs: {
+ deps: [],
+ signals: [],
+ timers: [],
+ },
+ phase: "resolved",
+ resolvedAt: add(startedAt, { seconds: 6 }),
+ startedAt: add(startedAt, { seconds: 4 }),
+ summary: "Draft requirements already satisfied",
+ terms: [
+ {
+ exprCel: `deps["compose_draft"].output.ready == true`,
+ kind: "generic",
+ label: "Draft requirements already satisfied",
+ name: "draft_ready",
+ result: { matchedCount: 0, requiredCount: 0, satisfied: true },
+ },
+ ],
+ },
+ waitReason: "none",
+ workflowStagedAt: startedAt,
+ }),
+ workflowJobFactory.build({
+ deps: ["call_risk_tool", "compose_draft_response", "plan_execution"],
+ id: 113,
+ state: JobState.Pending,
+ task: "send_response",
+ wait: {
+ exprCel: "human_approval_received || review_sla_timeout",
+ inputs: {
+ deps: [],
+ signals: [
+ {
+ key: "request_human_approval",
+ },
+ ],
+ timers: [
+ {
+ afterSeconds: 55,
+ anchor: { kind: "wait_started_at" },
+ fireAt: add(startedAt, { seconds: 61 }),
+ name: "review_sla_timeout",
+ },
+ ],
+ },
+ phase: "waiting",
+ startedAt: add(startedAt, { seconds: 6 }),
+ summary: "Waiting for human approval or review SLA timeout.",
+ terms: [
+ {
+ kind: "signal",
+ label: "Human approval received",
+ name: "human_approval_received",
+ result: { matchedCount: 0, requiredCount: 0, satisfied: false },
+ },
+ {
+ kind: "timer",
+ label: "Review SLA timeout reached",
+ name: "review_sla_timeout",
+ result: { matchedCount: 0, requiredCount: 0, satisfied: false },
+ },
+ ],
+ },
+ waitReason: "dependencies_and_wait",
+ workflowStagedAt: startedAt,
+ }),
+ workflowJobFactory.build({
+ deps: ["send_response"],
+ id: 114,
+ state: JobState.Pending,
+ task: "queue_follow_up_survey",
+ wait: {
+ exprCel: "follow_up_survey_delay",
+ inputs: {
+ deps: [],
+ signals: [],
+ timers: [
+ {
+ afterSeconds: 1800,
+ anchor: { kind: "wait_started_at" },
+ name: "follow_up_survey_delay",
+ },
+ ],
+ },
+ phase: "waiting",
+ terms: [
+ {
+ kind: "timer",
+ label: "Follow-up survey delay reached",
+ name: "follow_up_survey_delay",
+ result: { matchedCount: 0, requiredCount: 0, satisfied: false },
+ },
+ ],
+ },
+ waitReason: "dependencies_and_wait",
+ workflowStagedAt: startedAt,
+ }),
+ workflowJobFactory.build({
+ deps: ["send_response"],
+ id: 115,
+ state: JobState.Pending,
+ task: "sync_crm_case_notes",
+ waitReason: "dependencies",
+ workflowStagedAt: startedAt,
+ }),
+ workflowJobFactory.build({
+ deps: ["send_response"],
+ id: 116,
+ state: JobState.Pending,
+ task: "write_resolution_analytics",
+ waitReason: "dependencies",
+ workflowStagedAt: startedAt,
+ }),
+ withTimestamps(
+ workflowJobFactory.build({
+ deps: ["plan_execution"],
+ id: 118,
+ state: JobState.Retryable,
+ task: "retry_charge_lookup",
+ waitReason: "none",
+ workflowStagedAt: startedAt,
+ }),
+ {
+ attemptedAt: add(startedAt, { seconds: 3 }),
+ scheduledAt: add(new Date(), { minutes: 15 }),
+ },
+ ),
+ withTimestamps(
+ workflowJobFactory.build({
+ deps: ["plan_execution"],
+ id: 119,
+ state: JobState.Scheduled,
+ task: "schedule_manual_review_ping",
+ waitReason: "none",
+ workflowStagedAt: startedAt,
+ }),
+ { scheduledAt: add(new Date(), { minutes: 5 }) },
+ ),
+ withTimestamps(
+ workflowJobFactory.build({
+ deps: ["plan_execution"],
+ id: 120,
+ state: JobState.Discarded,
+ task: "discard_stale_context_refresh",
+ waitReason: "none",
+ workflowStagedAt: startedAt,
+ }),
+ {
+ attemptedAt: add(startedAt, { seconds: 3 }),
+ finalizedAt: add(startedAt, { seconds: 4 }),
+ scheduledAt: add(startedAt, { seconds: 2 }),
+ },
+ ),
+ workflowJobFactory.build({
+ deps: [
+ "send_response",
+ "sync_crm_case_notes",
+ "write_resolution_analytics",
+ ],
+ id: 117,
+ state: JobState.Pending,
+ task: "close_case",
+ wait: {
+ exprCel: "customer_ack_received || close_case_timeout",
+ inputs: {
+ deps: [],
+ signals: [
+ {
+ key: "request_customer_ack",
+ },
+ ],
+ timers: [
+ {
+ afterSeconds: 1800,
+ anchor: { kind: "wait_started_at" },
+ name: "close_case_timeout",
+ },
+ ],
+ },
+ phase: "waiting",
+ terms: [
+ {
+ kind: "signal",
+ label: "Customer acknowledgement received",
+ name: "customer_ack_received",
+ result: { matchedCount: 0, requiredCount: 0, satisfied: false },
+ },
+ {
+ kind: "timer",
+ label: "Close case timeout reached",
+ name: "close_case_timeout",
+ result: { matchedCount: 0, requiredCount: 0, satisfied: false },
+ },
+ ],
+ },
+ waitReason: "dependencies_and_wait",
+ workflowStagedAt: startedAt,
+ }),
+ ];
+};
+
+const buildToggleTasks = (resolved: boolean): WorkflowTask[] => {
+ const startedAt = recentWorkflowStart(15);
+
+ return [
+ withTimestamps(
+ workflowJobFactory.build({
+ id: 41,
+ state: JobState.Completed,
+ task: "collect_inputs",
+ waitReason: "none",
+ workflowStagedAt: startedAt,
+ }),
+ {
+ attemptedAt: add(startedAt, { seconds: 1 }),
+ finalizedAt: add(startedAt, { seconds: 2 }),
+ scheduledAt: startedAt,
+ },
+ ),
+ resolved
+ ? withTimestamps(
+ workflowJobFactory.build({
+ deps: ["collect_inputs"],
+ id: 42,
+ state: JobState.Completed,
+ task: "await_review",
+ wait: {
+ evidence: {
+ evaluatedAt: add(startedAt, { seconds: 8 }),
+ workflowAttempt: 1,
+ },
+ exprCel: "approval_received",
+ inputs: {
+ deps: [],
+ signals: [
+ {
+ key: "approval.received",
+ },
+ ],
+ timers: [],
+ },
+ phase: "resolved",
+ resolvedAt: add(startedAt, { seconds: 8 }),
+ startedAt: add(startedAt, { seconds: 2 }),
+ summary: "Human approval received",
+ terms: [
+ {
+ kind: "signal",
+ label: "Human approval received",
+ name: "approval_received",
+ result: {
+ matchedCount: 0,
+ requiredCount: 0,
+ satisfied: true,
+ },
+ },
+ ],
+ },
+ waitReason: "none",
+ workflowStagedAt: startedAt,
+ }),
+ {
+ attemptedAt: add(startedAt, { seconds: 9 }),
+ finalizedAt: add(startedAt, { seconds: 10 }),
+ scheduledAt: add(startedAt, { seconds: 2 }),
+ },
+ )
+ : workflowJobFactory.build({
+ deps: ["collect_inputs"],
+ id: 42,
+ state: JobState.Pending,
+ task: "await_review",
+ wait: {
+ exprCel: "approval_received",
+ inputs: {
+ deps: [],
+ signals: [
+ {
+ key: "approval.received",
+ },
+ ],
+ timers: [],
+ },
+ phase: "waiting",
+ startedAt: add(startedAt, { seconds: 2 }),
+ summary: "Waiting for human approval.",
+ terms: [
+ {
+ kind: "signal",
+ label: "Human approval received",
+ name: "approval_received",
+ result: { matchedCount: 0, requiredCount: 0, satisfied: false },
+ },
+ ],
+ },
+ waitReason: "wait",
+ workflowStagedAt: startedAt,
+ }),
+ workflowJobFactory.build({
+ deps: ["await_review"],
+ id: 43,
+ state: JobState.Pending,
+ task: "send_response",
+ waitReason: "dependencies",
+ workflowStagedAt: startedAt,
+ }),
+ ];
+};
+
+const meta: Meta = {
+ component: WorkflowDiagram,
+ parameters: {
+ layout: "fullscreen",
+ },
+ title: "Components/WorkflowDiagram",
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+const StatefulRender = ({
+ initialSelectedJobId,
+ tasks,
+}: {
+ initialSelectedJobId?: bigint;
+ tasks: WorkflowTask[];
+}) => {
+ const [selectedJobId, setSelectedJobId] = useState(
+ initialSelectedJobId ?? tasks[0]?.id,
+ );
+
+ return (
+
+
+
+ );
+};
+
+const WaitToggleRender = () => {
+ const [resolved, setResolved] = useState(false);
+ const [selectedJobId, setSelectedJobId] = useState(42n);
+
+ useEffect(() => {
+ const interval = window.setInterval(() => {
+ setResolved((current) => !current);
+ }, 5000);
+
+ return () => window.clearInterval(interval);
+ }, []);
+
+ return (
+
+
+ Wait is{" "}
+
+ {resolved ? "resolved" : "waiting"}
+ {" "}
+ and toggles every 5s.
+
+
+
+ );
+};
+
+export const Baseline: Story = {
+ render: () => (
+
+ ),
+};
+
+export const WaitingOnWait: Story = {
+ render: () => (
+
+ ),
+};
+
+export const Resolved: Story = {
+ render: () => (
+
+ ),
+};
+
+export const WaitsResolvedAndWaiting: Story = {
+ render: () => (
+
+ ),
+};
+
+export const AgentCustomerResolutionLarge: Story = {
+ render: () => (
+
+ ),
+};
+
+export const WaitTransitionAnimation: Story = {
+ name: "Wait Transition Animation",
+ render: WaitToggleRender,
+};
diff --git a/src/components/workflow-diagram/WorkflowDiagram.test.tsx b/src/components/workflow-diagram/WorkflowDiagram.test.tsx
index d0a8be79..084018b6 100644
--- a/src/components/workflow-diagram/WorkflowDiagram.test.tsx
+++ b/src/components/workflow-diagram/WorkflowDiagram.test.tsx
@@ -1,5 +1,6 @@
import type { PropsWithChildren } from "react";
+import { JobState } from "@services/types";
import { workflowJobFactory } from "@test/factories/workflowJob";
import { act, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -7,12 +8,26 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import WorkflowDiagram from "./WorkflowDiagram";
import * as workflowDiagramLayout from "./workflowDiagramLayout";
-type MockReactFlowProps = PropsWithChildren<{
- edges: unknown[];
- fitViewOptions?: {
- minZoom?: number;
- padding?: number;
+type MiniMapNodeLike = {
+ data?: {
+ job?: {
+ state?: JobState;
+ };
+ };
+};
+
+type MockEdge = {
+ style?: {
+ stroke?: string;
};
+};
+
+type MockMiniMapProps = {
+ nodeClassName?: (node: MiniMapNodeLike) => string;
+};
+
+type MockReactFlowProps = PropsWithChildren<{
+ edges: MockEdge[];
minZoom?: number;
nodes: unknown[];
onNodesChange?: (changes: SelectionChange[]) => void;
@@ -26,6 +41,7 @@ type SelectionChange = {
let currentTheme: "dark" | "light" = "light";
let latestReactFlowProps: MockReactFlowProps | undefined;
+let latestMiniMapProps: MockMiniMapProps | undefined;
vi.mock("next-themes", () => ({
useTheme: () => ({ resolvedTheme: currentTheme }),
@@ -38,7 +54,10 @@ vi.mock("./WorkflowNode", () => ({
vi.mock("@xyflow/react", () => ({
BaseEdge: () => null,
Controls: () =>
,
- MiniMap: () =>
,
+ MiniMap: (props: MockMiniMapProps) => {
+ latestMiniMapProps = props;
+ return
;
+ },
ReactFlow: (props: MockReactFlowProps) => {
latestReactFlowProps = props;
@@ -56,6 +75,7 @@ describe("WorkflowDiagram", () => {
beforeEach(() => {
currentTheme = "light";
latestReactFlowProps = undefined;
+ latestMiniMapProps = undefined;
vi.restoreAllMocks();
});
@@ -79,7 +99,6 @@ describe("WorkflowDiagram", () => {
expect(screen.getByTestId("edge-count")).toHaveTextContent("3");
expect(screen.getByTestId("diagram-controls")).toBeInTheDocument();
expect(latestReactFlowProps?.minZoom).toBe(0.2);
- expect(latestReactFlowProps?.fitViewOptions?.minZoom).toBe(0.55);
});
it("calls setSelectedJobId when a node is selected", () => {
@@ -167,13 +186,9 @@ describe("WorkflowDiagram", () => {
expect(layoutSpy).toHaveBeenCalledTimes(1);
});
- it("renders when metadata.deps is missing on a task", () => {
+ it("renders when deps is missing on a task", () => {
const malformedJob = workflowJobFactory.build({ id: 1, task: "a" });
- (
- malformedJob.metadata as unknown as {
- deps?: string[];
- }
- ).deps = undefined;
+ (malformedJob as unknown as { deps?: string[] }).deps = undefined;
render(
{
expect(screen.getByTestId("node-count")).toHaveTextContent("1");
expect(screen.getByTestId("edge-count")).toHaveTextContent("0");
});
+
+ it("uses the success edge color token for unblocked dependencies", () => {
+ const tasks = [
+ workflowJobFactory.build({ id: 1, state: JobState.Completed, task: "a" }),
+ workflowJobFactory.build({
+ deps: ["a"],
+ id: 2,
+ state: JobState.Pending,
+ task: "b",
+ waitReason: "dependencies",
+ }),
+ ];
+
+ const { rerender } = render(
+ ,
+ );
+
+ const lightEdge = latestReactFlowProps?.edges[0]?.style?.stroke;
+ expect(lightEdge).toBe("var(--workflow-diagram-edge-success)");
+
+ currentTheme = "dark";
+
+ rerender(
+ ,
+ );
+
+ const darkEdge = latestReactFlowProps?.edges[0]?.style?.stroke;
+ expect(darkEdge).toBe("var(--workflow-diagram-edge-success)");
+ });
+
+ it("uses the failed edge color token for failed dependencies", () => {
+ const tasks = [
+ workflowJobFactory.build({
+ id: 1,
+ state: JobState.Cancelled,
+ task: "failed-source",
+ }),
+ workflowJobFactory.build({
+ deps: ["failed-source"],
+ id: 2,
+ state: JobState.Pending,
+ task: "dependent",
+ waitReason: "dependencies",
+ }),
+ ];
+
+ render(
+ ,
+ );
+
+ const edgeStroke = latestReactFlowProps?.edges[0]?.style?.stroke;
+ expect(edgeStroke).toBe("var(--workflow-diagram-edge-failed)");
+ });
+
+ it("maps minimap node classes to the updated status palette", () => {
+ const tasks = [workflowJobFactory.build({ id: 1, task: "seed" })];
+
+ render(
+ ,
+ );
+
+ const nodeClassName = latestMiniMapProps?.nodeClassName;
+ expect(nodeClassName).toBeDefined();
+ if (!nodeClassName) return;
+
+ expect(
+ nodeClassName({ data: { job: { state: JobState.Available } } }),
+ ).toBe(
+ "fill-blue-300/60 stroke-blue-500/60 dark:fill-blue-700/50 dark:stroke-blue-400/50 stroke-1",
+ );
+ expect(nodeClassName({ data: { job: { state: JobState.Running } } })).toBe(
+ "fill-blue-300/60 stroke-blue-500/60 dark:fill-blue-700/50 dark:stroke-blue-400/50 stroke-1",
+ );
+ expect(nodeClassName({ data: { job: { state: JobState.Pending } } })).toBe(
+ "fill-slate-300/60 stroke-slate-600/60 dark:fill-slate-700/50 dark:stroke-slate-400/50 stroke-1",
+ );
+ expect(
+ nodeClassName({ data: { job: { state: JobState.Scheduled } } }),
+ ).toBe(
+ "fill-slate-300/60 stroke-slate-600/60 dark:fill-slate-700/50 dark:stroke-slate-400/50 stroke-1",
+ );
+ expect(
+ nodeClassName({ data: { job: { state: JobState.Retryable } } }),
+ ).toBe(
+ "fill-amber-300/60 stroke-amber-500/60 dark:fill-amber-700/50 dark:stroke-amber-400/50 stroke-1",
+ );
+ });
});
diff --git a/src/components/workflow-diagram/WorkflowDiagram.tsx b/src/components/workflow-diagram/WorkflowDiagram.tsx
index 00d50697..847c254f 100644
--- a/src/components/workflow-diagram/WorkflowDiagram.tsx
+++ b/src/components/workflow-diagram/WorkflowDiagram.tsx
@@ -1,3 +1,4 @@
+import type { WorkflowTask } from "@services/workflows";
import type {
EdgeTypes,
Node,
@@ -6,7 +7,6 @@ import type {
NodeTypes,
} from "@xyflow/react";
-import { JobWithKnownMetadata } from "@services/jobs";
import { JobState } from "@services/types";
import { Controls, MiniMap, ReactFlow } from "@xyflow/react";
import { useTheme } from "next-themes";
@@ -16,27 +16,23 @@ import WorkflowDiagramEdge from "./WorkflowDiagramEdge";
import {
applyEdgeVisuals,
buildWorkflowGraphModel,
+ type WorkflowDependencyStatus,
} from "./workflowDiagramGraphModel";
import WorkflowNode, { type WorkflowNodeData } from "./WorkflowNode";
import "./reactflow-base.css";
+import "./workflow-diagram.css";
type WorkflowDiagramProps = {
selectedJobId?: bigint;
setSelectedJobId: (id: bigint | undefined) => void;
- tasks: JobWithKnownMetadata[];
+ tasks: WorkflowTask[];
};
-const edgeColorsLight = {
- blocked: "#cbd5e1",
- failed: "#dc2626",
- unblocked: "#cbd5e1",
-};
-
-const edgeColorsDark = {
- blocked: "#475569",
- failed: "#dc2626",
- unblocked: "#475569",
-};
+const edgeColors = {
+ blocked: "var(--workflow-diagram-edge-muted)",
+ failed: "var(--workflow-diagram-edge-failed)",
+ unblocked: "var(--workflow-diagram-edge-success)",
+} satisfies Record;
const nodeTypes: NodeTypes = {
workflowNode: WorkflowNode,
@@ -47,8 +43,19 @@ const edgeTypes: EdgeTypes = {
};
const workflowDiagramMinZoom = 0.2;
-const workflowDiagramFitViewMinZoom = 0.55;
-const workflowDiagramFitViewPadding = 0.08;
+const workflowDiagramFitViewMaxZoom = 0.85;
+const workflowDiagramInitialFitViewMinZoom = 0.6;
+const workflowDiagramFitViewPadding = { x: "32px", y: "48px" } as const;
+const workflowDiagramInitialFitViewOptions = {
+ maxZoom: workflowDiagramFitViewMaxZoom,
+ minZoom: workflowDiagramInitialFitViewMinZoom,
+ padding: workflowDiagramFitViewPadding,
+};
+const workflowDiagramOverviewFitViewOptions = {
+ maxZoom: workflowDiagramFitViewMaxZoom,
+ minZoom: workflowDiagramMinZoom,
+ padding: workflowDiagramFitViewPadding,
+};
type NodeTypeKey = Extract;
@@ -59,17 +66,18 @@ const getMiniMapNodeClassName = (
switch (state) {
case JobState.Available:
- case JobState.Pending:
- case JobState.Retryable:
- case JobState.Scheduled:
- return "fill-amber-300/60 stroke-amber-500/60 dark:fill-amber-700/50 dark:stroke-amber-400/50 stroke-1";
+ case JobState.Running:
+ return "fill-blue-300/60 stroke-blue-500/60 dark:fill-blue-700/50 dark:stroke-blue-400/50 stroke-1";
case JobState.Cancelled:
case JobState.Discarded:
return "fill-red-300/60 stroke-red-500/60 dark:fill-red-700/50 dark:stroke-red-400/50 stroke-1";
case JobState.Completed:
return "fill-green-300/60 stroke-green-500/60 dark:fill-green-500/70 dark:stroke-green-300/70 stroke-1";
- case JobState.Running:
- return "fill-blue-300/60 stroke-blue-500/60 dark:fill-blue-700/50 dark:stroke-blue-400/50 stroke-1";
+ case JobState.Pending:
+ case JobState.Scheduled:
+ return "fill-slate-300/60 stroke-slate-600/60 dark:fill-slate-700/50 dark:stroke-slate-400/50 stroke-1";
+ case JobState.Retryable:
+ return "fill-amber-300/60 stroke-amber-500/60 dark:fill-amber-700/50 dark:stroke-amber-400/50 stroke-1";
default:
return "fill-slate-300/60 stroke-slate-600/60 dark:fill-slate-700/50 dark:stroke-slate-400/50 stroke-1";
}
@@ -88,9 +96,6 @@ export default function WorkflowDiagram({
}: WorkflowDiagramProps) {
const { resolvedTheme } = useTheme();
- const edgeColors =
- resolvedTheme === "dark" ? edgeColorsDark : edgeColorsLight;
-
const minimapMaskColor =
resolvedTheme === "dark" ? "rgb(5, 5, 5, 0.5)" : "rgb(250, 250, 250, 0.5)";
@@ -98,11 +103,11 @@ export default function WorkflowDiagram({
// that includes Dagre layout, so it must not depend on selection or theme.
const model = useMemo(() => buildWorkflowGraphModel(tasks), [tasks]);
- // Theme updates should only restyle existing edges, not recompute topology or
- // layout. Keeping this in a separate memo avoids unnecessary layout work.
+ // Styling is a separate pass from topology/layout so edge visuals can change
+ // independently from Dagre node positioning.
const layoutedEdges = useMemo(
() => applyEdgeVisuals(model.edges, edgeColors),
- [edgeColors, model.edges],
+ [model.edges],
);
// Node selection is UI state layered on top of static layout coordinates.
@@ -111,14 +116,21 @@ export default function WorkflowDiagram({
() =>
model.nodes.map((node) => ({
...node,
+ data: {
+ ...node.data,
+ onSelect: () => {
+ setSelectedJobId(
+ selectedJobId === node.data.job.id ? undefined : node.data.job.id,
+ );
+ },
+ },
selected: selectedJobId === node.data.job.id,
})),
- [model.nodes, selectedJobId],
+ [model.nodes, selectedJobId, setSelectedJobId],
);
// Use workflow id to scope/reset the ReactFlow instance between navigations.
- const workflowIdForInstance =
- tasks[0]?.metadata.workflow_id ?? "unknown-workflow";
+ const workflowIdForInstance = tasks[0]?.workflowID ?? "unknown-workflow";
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
@@ -135,16 +147,12 @@ export default function WorkflowDiagram({
);
return (
-
+
diff --git a/src/components/workflow-diagram/WorkflowDiagramEdge.test.tsx b/src/components/workflow-diagram/WorkflowDiagramEdge.test.tsx
new file mode 100644
index 00000000..f4c09ed8
--- /dev/null
+++ b/src/components/workflow-diagram/WorkflowDiagramEdge.test.tsx
@@ -0,0 +1,35 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { switchHandleCenterGap } from "./workflowDiagramConstants";
+import WorkflowDiagramEdge, {
+ type WorkflowDiagramEdgeProps,
+} from "./WorkflowDiagramEdge";
+
+vi.mock("@xyflow/react", () => ({
+ BaseEdge: ({ path }: { path: string }) => (
+
+ ),
+}));
+
+describe("WorkflowDiagramEdge", () => {
+ it("terminates at the gate hinge when a target anchor offset is provided", () => {
+ const props: WorkflowDiagramEdgeProps = {
+ data: {
+ preferredBendX: 248,
+ targetAnchorOffsetX: -switchHandleCenterGap,
+ },
+ sourceX: 100,
+ sourceY: 20,
+ targetX: 300,
+ targetY: 140,
+ };
+
+ render( );
+
+ expect(screen.getByTestId("base-edge")).toHaveAttribute(
+ "data-path",
+ `M 100,20 L ${300 - switchHandleCenterGap - 20},20 L ${300 - switchHandleCenterGap - 20},140 L ${300 - switchHandleCenterGap},140`,
+ );
+ });
+});
diff --git a/src/components/workflow-diagram/WorkflowDiagramEdge.tsx b/src/components/workflow-diagram/WorkflowDiagramEdge.tsx
index 84c36409..868469f6 100644
--- a/src/components/workflow-diagram/WorkflowDiagramEdge.tsx
+++ b/src/components/workflow-diagram/WorkflowDiagramEdge.tsx
@@ -7,13 +7,19 @@ import {
type WorkflowDiagramNodeRect,
} from "./workflowDiagramEdgePath";
-type WorkflowDiagramEdgeData = {
+export type WorkflowDiagramEdgeData = {
dagrePoints?: Array<{ x: number; y: number }>;
depStatus?: "blocked" | "failed" | "unblocked";
nodeRects?: WorkflowDiagramNodeRect[];
preferredBendX?: number;
+ targetAnchorOffsetX?: number;
};
+export type WorkflowDiagramEdgeProps = Pick<
+ EdgeProps,
+ "data" | "markerEnd" | "sourceX" | "sourceY" | "style" | "targetX" | "targetY"
+>;
+
export default function WorkflowDiagramEdge({
data,
markerEnd,
@@ -22,7 +28,7 @@ export default function WorkflowDiagramEdge({
style,
targetX,
targetY,
-}: EdgeProps) {
+}: WorkflowDiagramEdgeProps) {
const edgeData = data as undefined | WorkflowDiagramEdgeData;
const path = buildWorkflowDiagramEdgePath({
@@ -31,7 +37,7 @@ export default function WorkflowDiagramEdge({
preferredBendX: edgeData?.preferredBendX,
sourceX,
sourceY,
- targetX,
+ targetX: targetX + (edgeData?.targetAnchorOffsetX ?? 0),
targetY,
});
diff --git a/src/components/workflow-diagram/WorkflowGateGallery.stories.tsx b/src/components/workflow-diagram/WorkflowGateGallery.stories.tsx
new file mode 100644
index 00000000..45f343b1
--- /dev/null
+++ b/src/components/workflow-diagram/WorkflowGateGallery.stories.tsx
@@ -0,0 +1,519 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import clsx from "clsx";
+import { type ComponentType, type ReactNode, useEffect, useState } from "react";
+
+// ---------------------------------------------------------------------------
+// Dimensions match the production gate handle so each tile renders at true
+// scale: two 14px circles, 36px center-to-center. See
+// workflowDiagramConstants.ts / WorkflowNode.tsx for the source of truth.
+// ---------------------------------------------------------------------------
+
+const CIRCLE_DIAMETER = 14;
+const CIRCLE_RADIUS = CIRCLE_DIAMETER / 2;
+const CENTER_GAP = 36;
+const BOX_WIDTH = CENTER_GAP + CIRCLE_DIAMETER;
+const BOX_HEIGHT = CIRCLE_DIAMETER;
+const LINE_Y = CIRCLE_RADIUS;
+const LINE_START_X = CIRCLE_RADIUS;
+const LINE_END_X = CENTER_GAP - CIRCLE_RADIUS + 1;
+const MID_X = CENTER_GAP / 2;
+const STROKE = 2;
+
+const handleClass = clsx(
+ "size-3.5 rounded-full border-2 border-slate-300 bg-slate-50",
+ "dark:border-slate-600 dark:bg-slate-800",
+);
+
+// SVG viewBox origin at the left circle's center so variants can anchor to
+// it (e.g. the circuit-switch lever hinge).
+const viewBox = `-${CIRCLE_RADIUS} 0 ${BOX_WIDTH} ${BOX_HEIGHT}`;
+
+type GateProps = { blocking: boolean };
+
+const GateCanvas = ({ children }: { children: ReactNode }) => (
+
+
+
+ {children}
+
+
+
+);
+
+// ---------------------------------------------------------------------------
+// Variant 1: circuit switch (current production design). Lever pivots around
+// the left circle's center; -34° when blocking, 0° when resolved.
+// ---------------------------------------------------------------------------
+
+const CircuitSwitchGate = ({ blocking }: GateProps) => (
+
+
+
+);
+
+// ---------------------------------------------------------------------------
+// Variant 2: line that terminates in an X at ~45% across the gap while
+// blocking, then extends out to the right circle when resolved. Line reveal
+// is animated with stroke-dashoffset so the tip tracks the X position.
+// ---------------------------------------------------------------------------
+
+const LineWithXGate = ({ blocking }: GateProps) => {
+ const xCx = LINE_START_X + (LINE_END_X - LINE_START_X) * 0.45;
+ const xSize = 3.5;
+ const xStroke = 1.75;
+ const pathLength = LINE_END_X - LINE_START_X;
+ const blockingHiddenLength = LINE_END_X - xCx;
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Variant 2b: line that terminates at a capacitor-style marker (two short
+// parallel vertical lines) while blocking. Shares the stroke-dashoffset
+// reveal with the X variant; marker fades + scales away on resolve.
+// ---------------------------------------------------------------------------
+
+const LineWithCapacitorGate = ({ blocking }: GateProps) => {
+ const capCx = LINE_START_X + (LINE_END_X - LINE_START_X) * 0.45;
+ const capHalfHeight = 4;
+ const capGap = 1.5;
+ const capStroke = 1.75;
+ const pathLength = LINE_END_X - LINE_START_X;
+ const blockingHiddenLength = LINE_END_X - (capCx - capGap);
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Variant 2c: line that terminates at a single short vertical bar while
+// blocking. Minimal cousin of the capacitor variant — same reveal + fade.
+// ---------------------------------------------------------------------------
+
+const LineWithBarGate = ({ blocking }: GateProps) => {
+ const barCx = LINE_START_X + (LINE_END_X - LINE_START_X) * 0.45;
+ const barHalfHeight = 6;
+ const barStroke = 1.75;
+ const pathLength = LINE_END_X - LINE_START_X;
+ const blockingHiddenLength = LINE_END_X - barCx;
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Variant 3: drop-gate arm pivoting 90° about the midpoint of the gap. The
+// arm *is* the line: horizontal when resolved (through-line), rotates
+// clockwise to vertical when blocking (barrier across the gap).
+// ---------------------------------------------------------------------------
+
+const BarrierGate = ({ blocking }: GateProps) => (
+
+);
+
+// ---------------------------------------------------------------------------
+// Variant 4: iris — a solid disk plugs the gap when blocking; collapses to
+// zero and the line fills in underneath when resolved.
+// ---------------------------------------------------------------------------
+
+const IrisGate = ({ blocking }: GateProps) => (
+ <>
+
+
+ >
+);
+
+// ---------------------------------------------------------------------------
+// Variant 5: dashed marching-ants flow. While blocking the line is dashed and
+// the dashes march toward a blocker dot; when resolved the dot retracts and
+// the line becomes solid.
+// ---------------------------------------------------------------------------
+
+const DashedFlowGate = ({ blocking }: GateProps) => (
+ <>
+
+
+ >
+);
+
+// ---------------------------------------------------------------------------
+// Variant registry
+// ---------------------------------------------------------------------------
+
+type Variant = {
+ body: ComponentType;
+ caption: string;
+ current?: boolean;
+ label: string;
+};
+
+const variants: Variant[] = [
+ {
+ body: CircuitSwitchGate,
+ caption:
+ "Lever pivots at the left hinge. Rotates −34° when blocking, back to horizontal when resolved.",
+ current: true,
+ label: "Circuit switch",
+ },
+ {
+ body: LineWithXGate,
+ caption:
+ "Line runs the full gap but dims while blocking, with an X crossed over it at ~45% across.",
+ label: "Line with X",
+ },
+ {
+ body: LineWithCapacitorGate,
+ caption:
+ "Line terminates at a capacitor-style marker — two short parallel verticals — while blocking.",
+ label: "Line with capacitor",
+ },
+ {
+ body: LineWithBarGate,
+ caption: "Line terminates at a single short vertical bar while blocking.",
+ label: "Line with bar",
+ },
+ {
+ body: BarrierGate,
+ caption:
+ "Arm pivots about the center of the gap: horizontal to pass, rotates clockwise 90° to block.",
+ label: "Drop-gate barrier",
+ },
+ {
+ body: IrisGate,
+ caption:
+ "Solid disk plugs the gap. Collapses to zero when resolved, revealing the line.",
+ label: "Iris",
+ },
+ {
+ body: DashedFlowGate,
+ caption:
+ "Dashes march toward a blocker dot while blocking; collapse to a solid line when resolved.",
+ label: "Dashed flow",
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Tile + gallery
+// ---------------------------------------------------------------------------
+
+const GALLERY_STYLE = `
+@keyframes gate-gallery-march {
+ to { stroke-dashoffset: -6; }
+}
+.gate-gallery-march {
+ animation: gate-gallery-march 0.6s linear infinite;
+}
+`;
+
+const GateTile = ({
+ blocking,
+ variant,
+}: {
+ blocking: boolean;
+ variant: Variant;
+}) => {
+ const Body = variant.body;
+ return (
+
+
+
+
+ {variant.label}
+
+ {variant.current ? (
+
+ Current
+
+ ) : null}
+
+
+ {blocking ? "Waiting" : "Resolved"}
+
+
+
+
+
+
+
+
+ {variant.caption}
+
+
+ );
+};
+
+const TOGGLE_MS = 4000;
+
+const GalleryRender = () => {
+ const [blocking, setBlocking] = useState(true);
+
+ useEffect(() => {
+ const id = window.setInterval(() => {
+ setBlocking((prev) => !prev);
+ }, TOGGLE_MS);
+ return () => window.clearInterval(id);
+ }, []);
+
+ return (
+
+
+
+
+
+
+ Wait-gate design gallery
+
+
+ Side-by-side alternatives for the wait-transition visual. All
+ tiles share a timer that toggles every {TOGGLE_MS / 1000}s. Gate
+ lines use the same tone and weight as dependency edges so the
+ motion is evaluated against production styling.
+
+
+
setBlocking((prev) => !prev)}
+ type="button"
+ >
+ Toggle now
+
+
+
+ {variants.map((variant) => (
+
+ ))}
+
+
+
+ );
+};
+
+const meta: Meta = {
+ parameters: { layout: "fullscreen" },
+ title: "Components/WorkflowDiagram/Gate Gallery",
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Gallery: Story = {
+ render: () => ,
+};
diff --git a/src/components/workflow-diagram/WorkflowNode.test.tsx b/src/components/workflow-diagram/WorkflowNode.test.tsx
new file mode 100644
index 00000000..9c434bbc
--- /dev/null
+++ b/src/components/workflow-diagram/WorkflowNode.test.tsx
@@ -0,0 +1,146 @@
+import type { Node, NodeProps } from "@xyflow/react";
+
+import { JobState } from "@services/types";
+import { workflowJobFactory } from "@test/factories/workflowJob";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import WorkflowNode, { type WorkflowNodeData } from "./WorkflowNode";
+
+vi.mock("@xyflow/react", () => ({
+ Handle: ({
+ style,
+ type,
+ }: {
+ style?: { top?: number | string };
+ type?: "source" | "target";
+ }) => (
+
+ ),
+ Position: {
+ Left: "left",
+ Right: "right",
+ },
+ useUpdateNodeInternals: () => vi.fn(),
+}));
+
+vi.mock("react-time-sync", () => ({
+ useTime: () => 1000,
+}));
+
+const buildNodeProps = (
+ data: WorkflowNodeData,
+): NodeProps> => ({
+ data,
+ deletable: false,
+ draggable: false,
+ dragging: false,
+ id: data.job.id.toString(),
+ isConnectable: false,
+ positionAbsoluteX: 0,
+ positionAbsoluteY: 0,
+ selectable: true,
+ selected: false,
+ type: "workflow",
+ zIndex: 0,
+});
+
+const renderNode = (data: WorkflowNodeData) => {
+ return render( );
+};
+
+const buildWaitData = ({
+ id,
+ phase,
+}: {
+ id: number;
+ phase: "not_started" | "resolved" | "unknown" | "waiting";
+}): WorkflowNodeData => {
+ const resolved = phase === "resolved";
+ const job = workflowJobFactory.build({
+ id,
+ state: resolved ? JobState.Completed : JobState.Pending,
+ task: "compose_draft_response",
+ wait: {
+ exprCel: "approval_received",
+ inputs: {
+ deps: [],
+ signals: [
+ {
+ key: "approval",
+ result: resolved
+ ? { includedCount: 1, lastIncludedID: 1n }
+ : undefined,
+ },
+ ],
+ timers: [],
+ },
+ phase,
+ summary: resolved ? "Human approval received" : undefined,
+ terms: [],
+ },
+ waitReason: resolved ? "none" : "wait",
+ });
+
+ return {
+ hasDownstreamDeps: false,
+ hasUpstreamDeps: true,
+ job,
+ waitReason: resolved ? "none" : "wait",
+ };
+};
+
+describe("WorkflowNode", () => {
+ it("renders a physical gate handle for tasks with waits", () => {
+ const { container } = renderNode(
+ buildWaitData({ id: 11, phase: "waiting" }),
+ );
+
+ const arm = container.querySelector("[data-test-workflow-gate-phase]");
+ expect(arm).not.toBeNull();
+ expect(arm?.getAttribute("data-test-workflow-gate-phase")).toBe("waiting");
+ });
+
+ it("opens the gate when the wait is resolved", () => {
+ const { container } = renderNode(
+ buildWaitData({ id: 12, phase: "resolved" }),
+ );
+
+ const arm = container.querySelector("[data-test-workflow-gate-phase]");
+ expect(arm?.getAttribute("data-test-workflow-gate-phase")).toBe("resolved");
+ });
+
+ it("renders plain pending nodes without wait UI", () => {
+ const job = workflowJobFactory.build({
+ deps: ["classify_intake"],
+ id: 13,
+ state: JobState.Pending,
+ task: "compose_draft_response",
+ waitReason: "dependencies",
+ });
+
+ renderNode({
+ hasDownstreamDeps: false,
+ hasUpstreamDeps: true,
+ job,
+ waitReason: "dependencies",
+ });
+
+ expect(screen.queryByTestId("wait-row")).toBeNull();
+ expect(screen.queryByTestId("target-handle")).toBeInTheDocument();
+ });
+
+ it("uses the wait summary in the gate tooltip when available", () => {
+ const { container } = renderNode(
+ buildWaitData({ id: 14, phase: "resolved" }),
+ );
+
+ const tooltipTarget = container.querySelector(
+ '[title*="Human approval received"]',
+ );
+ expect(tooltipTarget).not.toBeNull();
+ });
+});
diff --git a/src/components/workflow-diagram/WorkflowNode.tsx b/src/components/workflow-diagram/WorkflowNode.tsx
index 17928974..0b46b36a 100644
--- a/src/components/workflow-diagram/WorkflowNode.tsx
+++ b/src/components/workflow-diagram/WorkflowNode.tsx
@@ -1,82 +1,97 @@
+import type { WorkflowTask, WorkflowTaskWaitReason } from "@services/workflows";
import type { Node, NodeProps } from "@xyflow/react";
import { TaskStateIcon } from "@components/TaskStateIcon";
-import { JobWithKnownMetadata } from "@services/jobs";
+import { CheckCircleIcon } from "@heroicons/react/24/outline";
import { JobState } from "@services/types";
import { Handle, Position, useUpdateNodeInternals } from "@xyflow/react";
import clsx from "clsx";
import { differenceInSeconds } from "date-fns";
-import { memo, useEffect, useMemo } from "react";
+import { memo, type ReactElement, useEffect, useMemo } from "react";
import { useTime } from "react-time-sync";
+import { switchHandleCenterGap } from "./workflowDiagramConstants";
+
+const handleStyleClasses =
+ "size-3.5 border-2 border-slate-300 bg-slate-50 dark:border-slate-600 dark:bg-slate-800";
+
export type WorkflowNodeData = {
hasDownstreamDeps: boolean;
hasUpstreamDeps: boolean;
- job: JobWithKnownMetadata;
+ job: WorkflowTask;
+ onSelect?: () => void;
+ waitReason: WorkflowTaskWaitReason;
};
type WorkflowNode = Node;
const WorkflowNode = memo(
({ data, isConnectable, selected }: NodeProps) => {
- const { hasDownstreamDeps, hasUpstreamDeps, job } = data;
+ const { hasDownstreamDeps, hasUpstreamDeps, job, onSelect, waitReason } =
+ data;
const updateNodeInternals = useUpdateNodeInternals();
+ const duration = getJobDuration(job);
- // Ask xyflow to re-measure this custom node after mount/updates so MiniMap gets correct bounds
useEffect(() => {
updateNodeInternals(String(job.id));
}, [job.id, updateNodeInternals]);
+ const tooltip = job.wait ? getWaitTooltipText(job.wait) : undefined;
+
+ const handleSelect = (event: React.PointerEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ onSelect?.();
+ };
+
return (
+ {job.wait ? (
+
+ ) : (
+
+ )}
-
-
-
-
-
-
-
-
Kind
-
- {job.kind}
-
-
-
-
Task Name
-
- {job.metadata.task}
-
-
-
-
-
-
+
+
);
@@ -85,16 +100,357 @@ const WorkflowNode = memo(
export default WorkflowNode;
-const JobDuration = ({ job }: { job: JobWithKnownMetadata }) => {
+// ---------------------------------------------------------------------------
+// Shared node content (used by both variants and original)
+// ---------------------------------------------------------------------------
+
+const WorkflowNodeContent = ({
+ duration,
+ job,
+}: {
+ duration: ReturnType
;
+ job: WorkflowTask;
+}) => (
+
+
+
+
+
+
+
Kind
+
+ {job.kind}
+
+
+
+
Task Name
+
+ {job.name}
+
+
+
+ {duration ? (
+
+ {duration}
+
+ ) : null}
+
+);
+
+// ---------------------------------------------------------------------------
+// LineWithBarHandle — three sibling elements in the node container:
+// 1. Left circle div (post) — absolute-positioned
+// 2. Line + bar SVG — absolute-positioned between the circles
+// 3. Handle div (right circle) — positioned by ReactFlow on the node edge
+// When blocking, the line terminates partway across the gap at a short
+// vertical bar; when resolved, the line extends through to the right circle
+// and the bar fades/scales away.
+// All three elements use the same classes as standard handles for
+// pixel-perfect matching.
+// ---------------------------------------------------------------------------
+
+const switchHandleClasses = clsx("left-px", handleStyleClasses);
+
+// Distance between circle centers (px).
+const switchGap = switchHandleCenterGap;
+const switchHandleDiameter = 14;
+const switchHandleRadius = switchHandleDiameter / 2;
+const switchHandleAnchorLeftOffset = 1;
+
+// Matches the dependency-edge stroke so the gate reads as a natural
+// terminal segment of the incoming edge.
+const leverStrokeWidth = 2;
+
+// SVG viewBox origin at the left circle center. The line spans the visible
+// gap between the two circles, revealed via stroke-dashoffset so its tip
+// tracks the bar marker when blocking.
+const leverVbWidth = switchGap;
+const leverVbHeight = leverStrokeWidth + 2;
+const leverCy = leverVbHeight / 2;
+const leverLineStartX = switchHandleRadius;
+const leverLineEndX = switchGap - switchHandleRadius + 1;
+const linePathLength = leverLineEndX - leverLineStartX;
+
+// Vertical bar sits at ~45% across the visible gap; 12px tall so it reads
+// as a definite marker without dominating the handle circles.
+const barCx = leverLineStartX + linePathLength * 0.45;
+const barHalfHeight = 6;
+const barStroke = 1.75;
+const blockingHiddenLength = leverLineEndX - barCx;
+
+const CircuitSwitchHandle = ({
+ activelyBlocking,
+ isConnectable,
+ tooltip,
+ visible,
+ wait,
+}: {
+ activelyBlocking: boolean;
+ isConnectable: boolean;
+ tooltip?: string;
+ visible: boolean;
+ wait: NonNullable;
+}) => {
+ const blocking = isWaitBlocking(wait);
+ const leftCircleLeft = -(
+ switchGap +
+ switchHandleRadius -
+ switchHandleAnchorLeftOffset
+ );
+
+ return (
+ <>
+ {/* Gate arm — rendered first so it layers behind the circles */}
+
+
+
+
+
+
+
+ {/* Left circle — post */}
+
+
+ {/* Tooltip hover target — spans the full gate area */}
+ {tooltip ? (
+
+ ) : null}
+
+ {/* Right circle — the actual Handle, positioned by ReactFlow */}
+
+ >
+ );
+};
+
+const getWaitTooltipText = (
+ wait: NonNullable,
+): string => {
+ const parts: string[] = [getWaitStatusLabel(wait)];
+
+ if (wait.summary) {
+ parts.push(
+ wait.phase === "resolved" ? `Resolved by: ${wait.summary}` : wait.summary,
+ );
+ }
+ if (wait.inputs.signals.length > 0) {
+ parts.push(
+ `Signals: ${wait.inputs.signals.map((signal) => signal.key).join(", ")}`,
+ );
+ }
+ if (wait.inputs.timers.length > 0) {
+ parts.push(
+ `Timers: ${wait.inputs.timers.map((timer) => timer.name).join(", ")}`,
+ );
+ }
+
+ return parts.join("\n");
+};
+
+const LeadingStateIcon = ({ job }: { job: WorkflowTask }) => {
+ return ;
+};
+
+export const GateRow = ({
+ wait,
+}: {
+ wait: NonNullable;
+}) => {
+ const statusLabel = getWaitStatusLabel(wait);
+ const toneClasses = getWaitRowToneClasses(wait);
+
+ return (
+
+ );
+};
+
+const GateRowIcon = ({ wait }: { wait: NonNullable }) => {
+ if (isWaitBlocking(wait)) {
+ return ;
+ }
+
+ if (wait.phase === "resolved") {
+ return ;
+ }
+
+ return ;
+};
+
+const getWaitStatusLabel = (
+ wait: NonNullable,
+): string => {
+ switch (wait.phase) {
+ case "not_started":
+ return "Wait not started";
+ case "resolved":
+ return "Wait resolved";
+ case "waiting":
+ return "Wait condition pending";
+ default:
+ return isWaitBlocking(wait)
+ ? "Wait condition pending"
+ : "Wait status unknown";
+ }
+};
+
+const getWaitRowToneClasses = (
+ wait: NonNullable,
+): { row: string } => {
+ if (isWaitBlocking(wait)) {
+ return {
+ row: "border-slate-200 bg-slate-50 text-slate-700 dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-200",
+ };
+ }
+
+ if (wait.phase === "resolved") {
+ return {
+ row: "border-green-100 bg-green-50/80 text-green-900 dark:border-green-900/50 dark:bg-green-950/20 dark:text-green-100",
+ };
+ }
+
+ return {
+ row: "border-slate-200 bg-slate-50 text-slate-700 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-200",
+ };
+};
+
+const isWaitBlocking = (wait: NonNullable): boolean => {
+ return wait.phase !== "resolved";
+};
+
+const getGateHingeClasses = (): string => "";
+
+const getNodeBorderClasses = (state: JobState): string => {
+ switch (state) {
+ case JobState.Available:
+ case JobState.Running:
+ return "border-blue-300 dark:border-blue-700";
+ case JobState.Cancelled:
+ case JobState.Discarded:
+ return "border-red-300 dark:border-red-800";
+ case JobState.Retryable:
+ return "border-amber-300 dark:border-amber-700";
+ default:
+ return "border-slate-200 dark:border-slate-700";
+ }
+};
+
+const GateStatusIcon = ({ className }: { className?: string }) => {
+ return (
+
+
+
+
+ );
+};
+
+const getJobDuration = (job: WorkflowTask): null | ReactElement | string => {
switch (job.state) {
case JobState.Available:
return (
);
case JobState.Cancelled:
@@ -109,7 +465,7 @@ const JobDuration = ({ job }: { job: JobWithKnownMetadata }) => {
case JobState.Discarded:
return "–";
case JobState.Pending:
- return "–";
+ return null;
case JobState.Retryable:
return "–";
case JobState.Running:
@@ -118,7 +474,7 @@ const JobDuration = ({ job }: { job: JobWithKnownMetadata }) => {
return ;
}
- return "–";
+ return null;
};
const DurationMicro = ({
diff --git a/src/components/workflow-diagram/workflow-diagram.css b/src/components/workflow-diagram/workflow-diagram.css
new file mode 100644
index 00000000..0f6f6e68
--- /dev/null
+++ b/src/components/workflow-diagram/workflow-diagram.css
@@ -0,0 +1,15 @@
+.workflow-diagram-root {
+ --workflow-diagram-edge-failed: var(--color-red-400);
+ --workflow-diagram-edge-muted: var(--color-slate-300);
+ --workflow-diagram-edge-success: var(--color-slate-300);
+}
+
+.dark .workflow-diagram-root {
+ --workflow-diagram-edge-failed: var(--color-red-500);
+ --workflow-diagram-edge-muted: var(--color-slate-600);
+ --workflow-diagram-edge-success: var(--color-slate-500);
+}
+
+.workflow-gate-lever {
+ color: var(--workflow-diagram-edge-muted);
+}
diff --git a/src/components/workflow-diagram/workflowDiagramConstants.ts b/src/components/workflow-diagram/workflowDiagramConstants.ts
index 2379a8b1..02c27c6b 100644
--- a/src/components/workflow-diagram/workflowDiagramConstants.ts
+++ b/src/components/workflow-diagram/workflowDiagramConstants.ts
@@ -1,8 +1,17 @@
/** Rendered width of each workflow node card (px). */
export const nodeWidth = 256;
-/** Rendered height of each workflow node card (px). */
-export const nodeHeight = 44;
+/** Rendered height of the main workflow node content row (px). */
+export const nodeBaseHeight = 44;
+
+/** Rendered height of the optional gate status row (px). */
+export const nodeGateRowHeight = 24;
+
+/** Default rendered height of a workflow node card without gate UI (px). */
+export const nodeHeight = nodeBaseHeight;
+
+/** Rendered height of a workflow node card with a gate status row (px). */
+export const nodeHeightWithGate = nodeBaseHeight + nodeGateRowHeight;
/**
* Extra padding around each node card where edge turns are forbidden (px).
@@ -20,6 +29,9 @@ export const minTargetApproach = 20;
/** Horizontal distance between successive candidate bend lanes (px). */
export const bendNudgeStep = 8;
+/** Horizontal distance between the two gate switch circle centers (px). */
+export const switchHandleCenterGap = 36;
+
/**
* Maximum nudge steps to probe in each direction from the baseline bend lane.
* Total search range: +/-192px.
@@ -37,3 +49,9 @@ export const sameRowTolerance = 1;
* lane is placed (px).
*/
export const targetMergePadding = 20;
+
+/**
+ * Extra horizontal extent of the circuit-switch gate handle beyond the node's
+ * left edge (px). Used to push merge lanes further left for gated targets.
+ */
+export const switchHandleExtent = 32;
diff --git a/src/components/workflow-diagram/workflowDiagramGraphModel.test.ts b/src/components/workflow-diagram/workflowDiagramGraphModel.test.ts
index 3fbb3917..92987049 100644
--- a/src/components/workflow-diagram/workflowDiagramGraphModel.test.ts
+++ b/src/components/workflow-diagram/workflowDiagramGraphModel.test.ts
@@ -1,27 +1,22 @@
-import type { Edge } from "@xyflow/react";
-
import { JobState } from "@services/types";
import { workflowJobFactory } from "@test/factories/workflowJob";
import { describe, expect, it } from "vitest";
+import { switchHandleCenterGap } from "./workflowDiagramConstants";
import {
- applyEdgeVisuals,
buildWorkflowGraphModel,
depStatusFromJob,
} from "./workflowDiagramGraphModel";
-describe("buildWorkflowGraphModel", () => {
- it("returns one node and zero edges for a single task", () => {
- const model = buildWorkflowGraphModel([
- workflowJobFactory.build({ id: 1, task: "a" }),
- ]);
-
- expect(model.nodes).toHaveLength(1);
- expect(model.edges).toHaveLength(0);
- expect(model.nodes[0].id).toBe("1");
- });
+const targetAnchorOffsetX = (edgeData: unknown): number | undefined => {
+ if (!edgeData || typeof edgeData !== "object") return undefined;
+ const value = (edgeData as { targetAnchorOffsetX?: unknown })
+ .targetAnchorOffsetX;
+ return typeof value === "number" ? value : undefined;
+};
- it("builds dependency edges in deterministic task/dep order", () => {
+describe("buildWorkflowGraphModel", () => {
+ it("builds deterministic dependency edges", () => {
const tasks = [
workflowJobFactory.build({ id: 1, task: "task-a" }),
workflowJobFactory.build({ deps: ["task-a"], id: 2, task: "task-b" }),
@@ -39,9 +34,6 @@ describe("buildWorkflowGraphModel", () => {
"e-1-3",
"e-2-3",
]);
- expect(model.edges.map((edge) => `${edge.source}->${edge.target}`)).toEqual(
- ["1->2", "1->3", "2->3"],
- );
});
it("maps job states to dependency statuses", () => {
@@ -65,172 +57,33 @@ describe("buildWorkflowGraphModel", () => {
).toBe("failed");
expect(
depStatusFromJob(
- workflowJobFactory.build({
- id: 3,
- state: JobState.Discarded,
- task: "c",
- }),
- ),
- ).toBe("failed");
- expect(
- depStatusFromJob(
- workflowJobFactory.build({
- id: 4,
- state: JobState.Running,
- task: "d",
- }),
+ workflowJobFactory.build({ id: 3, state: JobState.Running, task: "c" }),
),
).toBe("blocked");
});
- it("drops missing dependency targets", () => {
- const tasks = [
- workflowJobFactory.build({ id: 1, task: "existing" }),
- workflowJobFactory.build({
- deps: ["missing", "existing"],
- id: 2,
- task: "consumer",
- }),
- ];
-
- const model = buildWorkflowGraphModel(tasks);
-
- expect(model.edges).toHaveLength(1);
- expect(model.edges[0].id).toBe("e-1-2");
- });
-
- it("sets upstream and downstream flags on node data", () => {
- const tasks = [
- workflowJobFactory.build({ id: 1, task: "a" }),
- workflowJobFactory.build({ deps: ["a"], id: 2, task: "b" }),
- workflowJobFactory.build({ deps: ["b"], id: 3, task: "c" }),
- ];
-
- const model = buildWorkflowGraphModel(tasks);
- const nodeByID = new Map(model.nodes.map((node) => [node.id, node]));
-
- expect(nodeByID.get("1")?.data.hasUpstreamDeps).toBe(false);
- expect(nodeByID.get("1")?.data.hasDownstreamDeps).toBe(true);
-
- expect(nodeByID.get("2")?.data.hasUpstreamDeps).toBe(true);
- expect(nodeByID.get("2")?.data.hasDownstreamDeps).toBe(true);
-
- expect(nodeByID.get("3")?.data.hasUpstreamDeps).toBe(true);
- expect(nodeByID.get("3")?.data.hasDownstreamDeps).toBe(false);
- });
-
- it("animates only blocked dependencies when downstream job is pending", () => {
+ it("shifts edges to the gate hinge anchor for wait targets", () => {
const tasks = [
+ workflowJobFactory.build({ id: 1, task: "upstream" }),
workflowJobFactory.build({
- id: 1,
- state: JobState.Available,
- task: "source-blocked",
- }),
- workflowJobFactory.build({
+ deps: ["upstream"],
id: 2,
- state: JobState.Completed,
- task: "source-unblocked",
- }),
- workflowJobFactory.build({
- deps: ["source-blocked"],
- id: 3,
- state: JobState.Pending,
- task: "consumer-pending",
- }),
- workflowJobFactory.build({
- deps: ["source-unblocked"],
- id: 4,
state: JobState.Pending,
- task: "consumer-pending-unblocked",
- }),
- workflowJobFactory.build({
- deps: ["source-blocked"],
- id: 5,
- state: JobState.Cancelled,
- task: "consumer-not-waiting",
+ task: "await_review",
+ wait: {
+ exprCel: "approval_received",
+ inputs: { deps: [], signals: [], timers: [] },
+ phase: "waiting",
+ terms: [],
+ },
+ waitReason: "dependencies_and_wait",
}),
];
const model = buildWorkflowGraphModel(tasks);
- const edgeByID = new Map(model.edges.map((edge) => [edge.id, edge]));
-
- expect(edgeByID.get("e-1-3")?.animated).toBe(true);
- expect(edgeByID.get("e-2-4")?.animated).toBe(false);
- expect(edgeByID.get("e-1-5")?.animated).toBe(false);
- });
-
- it("returns empty arrays for empty input", () => {
- const model = buildWorkflowGraphModel([]);
-
- expect(model.nodes).toEqual([]);
- expect(model.edges).toEqual([]);
- });
- it("treats missing metadata.deps as an empty dependency list", () => {
- const malformedJob = workflowJobFactory.build({ id: 1, task: "a" });
- (
- malformedJob.metadata as unknown as {
- deps?: string[];
- }
- ).deps = undefined;
-
- const model = buildWorkflowGraphModel([malformedJob]);
-
- expect(model.nodes).toHaveLength(1);
- expect(model.nodes[0].data.hasUpstreamDeps).toBe(false);
- expect(model.edges).toEqual([]);
- });
-});
-
-describe("applyEdgeVisuals", () => {
- it("applies status-specific styles and preserves edge order", () => {
- const edges: Edge[] = [
- {
- data: { depStatus: "blocked" },
- id: "blocked-edge",
- source: "1",
- target: "2",
- },
- {
- data: { depStatus: "failed" },
- id: "failed-edge",
- source: "2",
- target: "3",
- },
- {
- data: { depStatus: "unblocked" },
- id: "unblocked-edge",
- source: "3",
- target: "4",
- },
- ];
-
- const styledEdges = applyEdgeVisuals(edges, {
- blocked: "#111111",
- failed: "#222222",
- unblocked: "#333333",
- });
-
- expect(styledEdges.map((edge) => edge.id)).toEqual([
- "blocked-edge",
- "failed-edge",
- "unblocked-edge",
- ]);
-
- expect(styledEdges[0].style).toMatchObject({
- stroke: "#111111",
- strokeDasharray: "6 3",
- strokeWidth: 2,
- });
- expect(styledEdges[1].style).toMatchObject({
- stroke: "#222222",
- strokeDasharray: "6 3",
- strokeWidth: 2,
- });
- expect(styledEdges[2].style).toMatchObject({
- stroke: "#333333",
- strokeDasharray: "0",
- strokeWidth: 2,
- });
+ expect(targetAnchorOffsetX(model.edges[0]?.data)).toBe(
+ -switchHandleCenterGap,
+ );
});
});
diff --git a/src/components/workflow-diagram/workflowDiagramGraphModel.ts b/src/components/workflow-diagram/workflowDiagramGraphModel.ts
index 24fdc979..3f26cf59 100644
--- a/src/components/workflow-diagram/workflowDiagramGraphModel.ts
+++ b/src/components/workflow-diagram/workflowDiagramGraphModel.ts
@@ -1,11 +1,15 @@
import type { Edge, Node } from "@xyflow/react";
-import { JobWithKnownMetadata } from "@services/jobs";
import { JobState } from "@services/types";
+import { type WorkflowTask } from "@services/workflows";
import type { WorkflowNodeData } from "./WorkflowNode";
-import { nodeHeight, nodeWidth } from "./workflowDiagramConstants";
+import {
+ nodeHeight,
+ nodeWidth,
+ switchHandleCenterGap,
+} from "./workflowDiagramConstants";
import {
getLayoutedElements,
type WorkflowDiagramNodeType,
@@ -19,6 +23,26 @@ export type WorkflowGraphModel = {
nodes: Node[];
};
+const withTargetAnchorOffsets = (
+ edges: Edge[],
+ nodes: Node[],
+): Edge[] => {
+ const nodeByID = new Map(nodes.map((node) => [node.id, node]));
+
+ return edges.map((edge) => {
+ const targetNode = nodeByID.get(edge.target);
+ if (!targetNode?.data.job.wait) return edge;
+
+ return {
+ ...edge,
+ data: {
+ ...((edge.data as Record | undefined) || {}),
+ targetAnchorOffsetX: -switchHandleCenterGap,
+ },
+ };
+ });
+};
+
const depStatusFromEdgeData = (
edgeData: unknown,
): undefined | WorkflowDependencyStatus => {
@@ -37,7 +61,7 @@ const depStatusFromEdgeData = (
};
export const depStatusFromJob = (
- job: JobWithKnownMetadata,
+ job: WorkflowTask,
): WorkflowDependencyStatus => {
switch (job.state) {
case JobState.Cancelled:
@@ -51,35 +75,36 @@ export const depStatusFromJob = (
};
export const buildWorkflowGraphModel = (
- tasks: JobWithKnownMetadata[],
+ tasks: WorkflowTask[],
+ opts?: { forceNodeHeight?: number },
): WorkflowGraphModel => {
- const jobsByTask = new Map();
+ const jobsByTask = new Map();
const tasksWithDownstreamDeps = new Set();
// Build dependency lookup structures in one pass so large workflows do not
// pay for repeated scans when building nodes and edges.
tasks.forEach((job) => {
- jobsByTask.set(job.metadata.task, job);
+ jobsByTask.set(job.name, job);
- (job.metadata.deps ?? []).forEach((depTaskName) => {
+ (job.deps ?? []).forEach((depTaskName) => {
tasksWithDownstreamDeps.add(depTaskName);
});
});
const initialNodes: Node[] =
tasks.map((job) => {
- // API payloads can omit `metadata.deps` for some historical rows. Treat
- // missing deps as "no upstream dependencies" rather than crashing.
- const deps = job.metadata.deps ?? [];
+ // Defensively handle malformed test data with a missing deps array.
+ const deps = job.deps ?? [];
return {
connectable: false,
data: {
- hasDownstreamDeps: tasksWithDownstreamDeps.has(job.metadata.task),
+ hasDownstreamDeps: tasksWithDownstreamDeps.has(job.name),
hasUpstreamDeps: deps.length > 0,
job,
+ waitReason: job.waitReason,
},
- height: nodeHeight,
+ height: opts?.forceNodeHeight ?? nodeHeight,
id: job.id.toString(),
position: { x: 0, y: 0 },
type: "workflowNode",
@@ -88,7 +113,7 @@ export const buildWorkflowGraphModel = (
});
const initialEdges = tasks.reduce((acc, job) => {
- const dependencies = job.metadata.deps ?? [];
+ const dependencies = job.deps ?? [];
dependencies.forEach((depName) => {
const dep = jobsByTask.get(depName);
@@ -98,13 +123,8 @@ export const buildWorkflowGraphModel = (
if (!dep) return;
const depStatus = depStatusFromJob(dep);
- // Animate only when the downstream job is currently waiting for upstream
- // dependencies. This keeps cancelled/discarded downstream jobs visually
- // static even if their upstream dependency is still blocked.
- const isActivelyWaiting = job.state === JobState.Pending;
acc.push({
- animated: depStatus === "blocked" && isActivelyWaiting,
data: { depStatus },
id: `e-${dep.id}-${job.id}`,
source: dep.id.toString(),
@@ -121,24 +141,28 @@ export const buildWorkflowGraphModel = (
initialEdges,
"LR",
);
+ const hintedEdges = withPreferredTargetMergeX(
+ layoutedEdgesRaw,
+ layoutedNodes,
+ );
return {
- edges: withPreferredTargetMergeX(layoutedEdgesRaw, layoutedNodes),
+ edges: withTargetAnchorOffsets(hintedEdges, layoutedNodes),
nodes: layoutedNodes,
};
};
export const applyEdgeVisuals = (
edges: Edge[],
- edgeColors: Record,
+ edgeColors: Record,
): Edge[] => {
// Styling is intentionally split from graph building so theme changes only
// trigger this cheap map instead of a full Dagre layout pass.
return edges.map((edge) => {
const depStatus = depStatusFromEdgeData(edge.data) ?? "blocked";
- // Keep the prior visual language:
+ // Visual language:
// - `unblocked`: solid line
- // - `blocked` / `failed`: dashed line (color distinguishes failure)
+ // - `blocked` / `failed`: dashed line (failure still distinguished by color)
const strokeDasharray = depStatus === "unblocked" ? "0" : "6 3";
return {
diff --git a/src/components/workflow-diagram/workflowDiagramLayout.ts b/src/components/workflow-diagram/workflowDiagramLayout.ts
index 6c43b495..fa9a6abd 100644
--- a/src/components/workflow-diagram/workflowDiagramLayout.ts
+++ b/src/components/workflow-diagram/workflowDiagramLayout.ts
@@ -30,7 +30,10 @@ export const getLayoutedElements = (
});
nodes.forEach((node) => {
- dagreGraph.setNode(node.id, { height: nodeHeight, width: nodeWidth });
+ dagreGraph.setNode(node.id, {
+ height: node.height ?? node.measured?.height ?? nodeHeight,
+ width: node.width ?? node.measured?.width ?? nodeWidth,
+ });
});
edges.forEach((edge) => {
@@ -41,13 +44,15 @@ export const getLayoutedElements = (
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
+ const renderedHeight = node.height ?? node.measured?.height ?? nodeHeight;
+ const renderedWidth = node.width ?? node.measured?.width ?? nodeWidth;
return {
...node,
// Dagre positions nodes by center; React Flow expects top-left coordinates.
position: {
- x: nodeWithPosition.x - nodeWidth / 2,
- y: nodeWithPosition.y - nodeHeight / 2,
+ x: nodeWithPosition.x - renderedWidth / 2,
+ y: nodeWithPosition.y - renderedHeight / 2,
},
sourcePosition: (isHorizontal ? "right" : "bottom") as Position,
targetPosition: (isHorizontal ? "left" : "top") as Position,
diff --git a/src/components/workflow-diagram/workflowDiagramMergeHints.ts b/src/components/workflow-diagram/workflowDiagramMergeHints.ts
index 6f079701..1ae763ab 100644
--- a/src/components/workflow-diagram/workflowDiagramMergeHints.ts
+++ b/src/components/workflow-diagram/workflowDiagramMergeHints.ts
@@ -1,8 +1,11 @@
import type { Edge, Node } from "@xyflow/react";
+import type { WorkflowNodeData } from "./WorkflowNode";
+
import {
nodeHeight,
sameRowTolerance,
+ switchHandleExtent,
targetMergePadding,
} from "./workflowDiagramConstants";
@@ -56,8 +59,14 @@ export const withPreferredTargetMergeX = (
if (!hasSameRowIncoming || offRowEdges.length === 0) return;
// Only off-row incoming edges are nudged to a shared lane. Same-row edges
- // keep their direct path into the target for readability.
- const preferredBendX = targetNode.position.x - targetMergePadding;
+ // keep their direct path into the target for readability. Push the lane
+ // further left when the target has a wait gate handle.
+ const targetData = targetNode.data as undefined | WorkflowNodeData;
+ const hasGate = !!targetData?.job?.wait;
+ const padding = hasGate
+ ? targetMergePadding + switchHandleExtent
+ : targetMergePadding;
+ const preferredBendX = targetNode.position.x - padding;
offRowEdges.forEach((edge) => {
preferredBendByEdgeID.set(edge.id, preferredBendX);
});
diff --git a/src/services/features.test.ts b/src/services/features.test.ts
index bfd2e548..31d9cc62 100644
--- a/src/services/features.test.ts
+++ b/src/services/features.test.ts
@@ -9,7 +9,6 @@ describe("apiFeaturesToFeatures", () => {
durable_periodic_jobs: true,
has_client_table: true,
has_producer_table: true,
- has_workflows: true,
producer_queries: true,
workflow_queries: true,
},
@@ -21,7 +20,6 @@ describe("apiFeaturesToFeatures", () => {
hasClientTable: true,
hasProducerTable: true,
hasSequenceTable: false,
- hasWorkflows: true,
jobListHideArgsByDefault: true,
producerQueries: true,
workflowQueries: true,
@@ -36,7 +34,6 @@ describe("apiFeaturesToFeatures", () => {
durable_periodic_jobs: false,
has_client_table: false,
has_producer_table: false,
- has_workflows: false,
producer_queries: false,
workflow_queries: false,
},
@@ -48,7 +45,6 @@ describe("apiFeaturesToFeatures", () => {
hasClientTable: false,
hasProducerTable: false,
hasSequenceTable: false,
- hasWorkflows: false,
jobListHideArgsByDefault: false,
producerQueries: false,
workflowQueries: false,
diff --git a/src/services/features.ts b/src/services/features.ts
index f9a51f78..a3d876db 100644
--- a/src/services/features.ts
+++ b/src/services/features.ts
@@ -23,7 +23,6 @@ const KNOWN_EXTENSIONS = [
"has_client_table",
"has_producer_table",
"has_sequence_table",
- "has_workflows",
] as const;
type KnownExtensionKey = (typeof KNOWN_EXTENSIONS)[number];
diff --git a/src/services/workflows.test.ts b/src/services/workflows.test.ts
new file mode 100644
index 00000000..9844c582
--- /dev/null
+++ b/src/services/workflows.test.ts
@@ -0,0 +1,254 @@
+import {
+ getWorkflowTaskSignals,
+ getWorkflowTaskWaitDiagnostics,
+} from "@services/workflows";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+describe("workflows service", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ document.body.innerHTML = "";
+ });
+
+ it("parses task signal dates, ids, cursor ids, evidence, and scope", async () => {
+ document.body.innerHTML =
+ '';
+
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ evidence: {
+ evaluated_at: "2026-04-21T17:59:00Z",
+ workflow_attempt: 3,
+ },
+ has_more: true,
+ next_cursor_id: "101",
+ scope: "history",
+ signals: [
+ {
+ attempt: 3,
+ created_at: "2026-04-21T17:58:00Z",
+ id: 102,
+ key: "approval.received",
+ payload: { decision: "approve" },
+ source: { actor: "manager" },
+ },
+ ],
+ }),
+ {
+ headers: { "Content-Type": "application/json" },
+ status: 200,
+ },
+ ),
+ );
+
+ const signalList = await getWorkflowTaskSignals({
+ cursorID: "99",
+ key: "approval.received",
+ scope: "history",
+ taskName: "await/review",
+ workflowID: "wf-123",
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
+ "http://example.test/api/pro/workflows/wf-123/task-signals?desc=true&limit=20&task_name=await%2Freview&key=approval.received&cursor_id=99&scope=history",
+ );
+ expect(signalList.hasMore).toBe(true);
+ expect(signalList.nextCursorID).toBe(101n);
+ expect(signalList.scope).toBe("history");
+ expect(signalList.evidence?.workflowAttempt).toBe(3);
+ expect(signalList.signals).toHaveLength(1);
+ expect(signalList.signals[0]).toMatchObject({
+ attempt: 3,
+ id: 102n,
+ key: "approval.received",
+ payload: { decision: "approve" },
+ source: { actor: "manager" },
+ });
+ expect(signalList.signals[0]?.createdAt).toBeInstanceOf(Date);
+ expect(signalList.signals[0]?.createdAt.toISOString()).toBe(
+ "2026-04-21T17:58:00.000Z",
+ );
+ });
+
+ it("omits the key query parameter when loading declared signal history", async () => {
+ document.body.innerHTML =
+ '';
+
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ has_more: false,
+ scope: "history",
+ signals: [],
+ }),
+ {
+ headers: { "Content-Type": "application/json" },
+ status: 200,
+ },
+ ),
+ );
+
+ await getWorkflowTaskSignals({
+ scope: "history",
+ taskName: "await/review",
+ workflowID: "wf-123",
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
+ "http://example.test/api/pro/workflows/wf-123/task-signals?desc=true&limit=20&task_name=await%2Freview&scope=history",
+ );
+ });
+
+ it("keeps absent cursors absent when a signal page has no more rows", async () => {
+ document.body.innerHTML =
+ '';
+
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ has_more: false,
+ scope: "history",
+ signals: [],
+ }),
+ {
+ headers: { "Content-Type": "application/json" },
+ status: 200,
+ },
+ ),
+ );
+
+ const signalList = await getWorkflowTaskSignals({
+ scope: "history",
+ taskName: "await/review",
+ workflowID: "wf-123",
+ });
+
+ expect(signalList.hasMore).toBe(false);
+ expect(signalList.nextCursorID).toBeUndefined();
+ });
+
+ it("sends explicit attempt selectors for signal history", async () => {
+ document.body.innerHTML =
+ '';
+
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ has_more: false,
+ scope: "history",
+ signals: [],
+ }),
+ {
+ headers: { "Content-Type": "application/json" },
+ status: 200,
+ },
+ ),
+ );
+
+ await getWorkflowTaskSignals({
+ scope: "history",
+ taskName: "await/review",
+ termName: "approval_received",
+ workflowAttempt: 2,
+ workflowID: "wf-123",
+ });
+
+ expect(fetchMock.mock.calls[0]?.[0]).toContain("workflow_attempt=2");
+ expect(fetchMock.mock.calls[0]?.[0]).toContain(
+ "term_name=approval_received",
+ );
+ });
+
+ it("parses current wait diagnostics", async () => {
+ document.body.innerHTML =
+ '';
+
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ eval_error: "undeclared input",
+ expr_result: false,
+ inputs: {
+ deps: [
+ {
+ available: true,
+ finalized_at: "2026-04-21T17:57:00Z",
+ state: "completed",
+ task_name: "build",
+ },
+ ],
+ signals: [
+ {
+ included_count: 2,
+ key: "approval.received",
+ last_id: "9002",
+ },
+ ],
+ timers: [
+ {
+ fire_at: "2026-04-21T18:10:00Z",
+ fired: false,
+ name: "review_sla",
+ },
+ ],
+ },
+ inspected_at: "2026-04-21T18:00:00Z",
+ phase: "waiting",
+ signal_scan_count: 10000,
+ signal_scan_limit: 10000,
+ terms: [
+ {
+ last_matched_id: "9001",
+ matched_count: 1,
+ name: "approval_received",
+ required_count: 2,
+ satisfied: false,
+ },
+ ],
+ truncated: true,
+ workflow_attempt: 4,
+ }),
+ {
+ headers: { "Content-Type": "application/json" },
+ status: 200,
+ },
+ ),
+ );
+
+ const diagnostics = await getWorkflowTaskWaitDiagnostics({
+ taskName: "await/review",
+ workflowID: "wf-123",
+ });
+
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
+ "http://example.test/api/pro/workflows/wf-123/task-wait-diagnostics?task_name=await%2Freview",
+ );
+ expect(diagnostics.evalError).toBe("undeclared input");
+ expect(diagnostics.exprResult).toBe(false);
+ expect(diagnostics.signalScanCount).toBe(10000);
+ expect(diagnostics.signalScanLimit).toBe(10000);
+ expect(diagnostics.truncated).toBe(true);
+ expect(diagnostics.workflowAttempt).toBe(4);
+ expect(diagnostics.inputs.deps[0]?.finalizedAt?.toISOString()).toBe(
+ "2026-04-21T17:57:00.000Z",
+ );
+ expect(diagnostics.inputs.signals[0]).toMatchObject({
+ includedCount: 2,
+ key: "approval.received",
+ lastID: 9002n,
+ });
+ expect(diagnostics.inputs.timers[0]?.fireAt?.toISOString()).toBe(
+ "2026-04-21T18:10:00.000Z",
+ );
+ expect(diagnostics.terms[0]).toMatchObject({
+ lastMatchedID: 9001n,
+ matchedCount: 1,
+ name: "approval_received",
+ requiredCount: 2,
+ satisfied: false,
+ });
+ });
+});
diff --git a/src/services/workflows.ts b/src/services/workflows.ts
index 094d3a0b..4e58a1ea 100644
--- a/src/services/workflows.ts
+++ b/src/services/workflows.ts
@@ -18,11 +18,182 @@ import {
} from "./types";
export type Workflow = {
- tasks: JobWithKnownMetadata[];
+ id: string;
+ name: string;
+ tasks: WorkflowTask[];
};
export type WorkflowRetryMode = "all" | "failed_and_downstream" | "failed_only";
+export type WorkflowTask = {
+ deps: string[];
+ ignoreCancelledDeps: boolean;
+ ignoreDeletedDeps: boolean;
+ ignoreDiscardedDeps: boolean;
+ name: string;
+ stagedAt?: Date;
+ wait?: WorkflowTaskWait;
+ waitReason: WorkflowTaskWaitReason;
+ workflowID: string;
+} & JobWithKnownMetadata;
+
+export type WorkflowTaskSignal = {
+ attempt: number;
+ createdAt: Date;
+ id: bigint;
+ key: string;
+ payload: unknown;
+ source: unknown;
+};
+
+export type WorkflowTaskSignalList = {
+ evidence?: WorkflowTaskWaitEvidence;
+ hasMore: boolean;
+ nextCursorID?: bigint;
+ scope: WorkflowTaskSignalListScope;
+ signals: WorkflowTaskSignal[];
+};
+
+export type WorkflowTaskSignalListScope = "evidence" | "history";
+
+export type WorkflowTaskWait = {
+ evidence?: WorkflowTaskWaitEvidence;
+ exprCel: string;
+ inputs: WorkflowTaskWaitInputs;
+ phase: WorkflowTaskWaitPhase;
+ resolvedAt?: Date;
+ startedAt?: Date;
+ summary?: string;
+ terms: WorkflowTaskWaitTerm[];
+};
+
+export type WorkflowTaskWaitDepInput = {
+ result?: WorkflowTaskWaitDepInputResult;
+ taskName: string;
+};
+
+export type WorkflowTaskWaitDepInputResult = {
+ available: boolean;
+ finalizedAt?: Date;
+ state?: string;
+};
+
+export type WorkflowTaskWaitDiagnostics = {
+ evalError?: string;
+ exprResult?: boolean;
+ inputs: WorkflowWaitInputDiagnostics;
+ inspectedAt: Date;
+ phase: WorkflowTaskWaitPhase;
+ signalScanCount: number;
+ signalScanLimit: number;
+ terms: WorkflowWaitTermDiagnostic[];
+ truncated: boolean;
+ workflowAttempt: number;
+};
+
+export type WorkflowTaskWaitEvidence = {
+ evaluatedAt: Date;
+ workflowAttempt: number;
+};
+
+export type WorkflowTaskWaitInputs = {
+ deps: WorkflowTaskWaitDepInput[];
+ signals: WorkflowTaskWaitSignalInput[];
+ timers: WorkflowTaskWaitTimer[];
+};
+
+export type WorkflowTaskWaitPhase =
+ | "not_started"
+ | "resolved"
+ | "unknown"
+ | "waiting";
+
+export type WorkflowTaskWaitReason =
+ | "dependencies_and_wait"
+ | "dependencies"
+ | "none"
+ | "wait";
+
+export type WorkflowTaskWaitSignalInput = {
+ key: string;
+ result?: WorkflowTaskWaitSignalInputResult;
+};
+
+export type WorkflowTaskWaitSignalInputResult = {
+ includedCount: number;
+ lastIncludedID?: bigint;
+};
+
+export type WorkflowTaskWaitTerm = {
+ exprCel?: string;
+ kind: string;
+ label: string;
+ name: string;
+ result?: WorkflowTaskWaitTermResult;
+ signalKey?: string;
+ timerName?: string;
+};
+
+export type WorkflowTaskWaitTermResult = {
+ lastMatchedID?: bigint;
+ matchedCount: number;
+ requiredCount: number;
+ satisfied: boolean;
+};
+
+export type WorkflowTaskWaitTimer = {
+ after?: string;
+ afterSeconds?: number;
+ afterUs?: number;
+ anchor?: WorkflowTaskWaitTimerAnchor;
+ fireAt?: Date;
+ name: string;
+ result?: WorkflowTaskWaitTimerResult;
+};
+
+export type WorkflowTaskWaitTimerAnchor = {
+ kind: string;
+ task?: string;
+};
+
+export type WorkflowTaskWaitTimerResult = {
+ fireAt?: Date;
+ fired: boolean;
+};
+
+export type WorkflowWaitDepDiagnostic = {
+ available: boolean;
+ finalizedAt?: Date;
+ state?: string;
+ taskName: string;
+};
+
+export type WorkflowWaitInputDiagnostics = {
+ deps: WorkflowWaitDepDiagnostic[];
+ signals: WorkflowWaitSignalDiagnostic[];
+ timers: WorkflowWaitTimerDiagnostic[];
+};
+
+export type WorkflowWaitSignalDiagnostic = {
+ includedCount: number;
+ key: string;
+ lastID?: bigint;
+};
+
+export type WorkflowWaitTermDiagnostic = {
+ lastMatchedID?: bigint;
+ matchedCount: number;
+ name: string;
+ requiredCount: number;
+ satisfied: boolean;
+};
+
+export type WorkflowWaitTimerDiagnostic = {
+ fireAt?: Date;
+ fired: boolean;
+ name: string;
+};
+
type CancelPayload = {
workflowID: string;
};
@@ -39,7 +210,166 @@ type CancelResponseFromAPI = {
// string dates instead of Date objects and keys as snake_case instead of
// camelCase.
type WorkflowFromAPI = {
- tasks: JobFromAPI[];
+ id: string;
+ name: string;
+ tasks: WorkflowTaskFromAPI[];
+};
+
+type WorkflowTaskFromAPI = {
+ deps: string[];
+ ignore_cancelled_deps: boolean;
+ ignore_deleted_deps: boolean;
+ ignore_discarded_deps: boolean;
+ name: string;
+ staged_at?: string;
+ wait?: WorkflowTaskWaitFromAPI;
+ wait_reason: WorkflowTaskWaitReasonFromAPI;
+ workflow_id: string;
+} & JobFromAPI;
+
+type WorkflowTaskSignalFromAPI = {
+ attempt: number;
+ created_at: string;
+ id: number | string;
+ key: string;
+ payload: unknown;
+ source: unknown;
+};
+
+type WorkflowTaskSignalListFromAPI = {
+ evidence?: WorkflowTaskWaitEvidenceFromAPI;
+ has_more: boolean;
+ next_cursor_id?: number | string;
+ scope: string;
+ signals: WorkflowTaskSignalFromAPI[];
+};
+
+type WorkflowTaskWaitDepInputFromAPI = {
+ result?: WorkflowTaskWaitDepInputResultFromAPI;
+ task_name: string;
+};
+
+type WorkflowTaskWaitDepInputResultFromAPI = {
+ available: boolean;
+ finalized_at?: string;
+ state?: string;
+};
+
+type WorkflowTaskWaitDiagnosticsFromAPI = {
+ eval_error?: string;
+ expr_result?: boolean;
+ inputs: WorkflowWaitInputDiagnosticsFromAPI;
+ inspected_at: string;
+ phase: string;
+ signal_scan_count: number;
+ signal_scan_limit: number;
+ terms: WorkflowWaitTermDiagnosticFromAPI[];
+ truncated: boolean;
+ workflow_attempt: number;
+};
+
+type WorkflowTaskWaitEvidenceFromAPI = {
+ evaluated_at: string;
+ workflow_attempt: number;
+};
+
+type WorkflowTaskWaitFromAPI = {
+ evidence?: WorkflowTaskWaitEvidenceFromAPI;
+ expr_cel: string;
+ inputs: WorkflowTaskWaitInputsFromAPI;
+ phase: string;
+ resolved_at?: string;
+ started_at?: string;
+ summary?: string;
+ terms: WorkflowTaskWaitTermFromAPI[];
+};
+
+type WorkflowTaskWaitInputsFromAPI = {
+ deps: WorkflowTaskWaitDepInputFromAPI[];
+ signals: WorkflowTaskWaitSignalInputFromAPI[];
+ timers: WorkflowTaskWaitTimerFromAPI[];
+};
+
+type WorkflowTaskWaitReasonFromAPI = WorkflowTaskWaitReason;
+
+type WorkflowTaskWaitSignalInputFromAPI = {
+ key: string;
+ result?: WorkflowTaskWaitSignalInputResultFromAPI;
+};
+
+type WorkflowTaskWaitSignalInputResultFromAPI = {
+ included_count: number;
+ last_included_id?: number | string;
+};
+
+type WorkflowTaskWaitTermFromAPI = {
+ expr_cel?: string;
+ kind: string;
+ label: string;
+ name: string;
+ result?: WorkflowTaskWaitTermResultFromAPI;
+ signal_key?: string;
+ timer_name?: string;
+};
+
+type WorkflowTaskWaitTermResultFromAPI = {
+ last_matched_id?: number | string;
+ matched_count: number;
+ required_count: number;
+ satisfied: boolean;
+};
+
+type WorkflowTaskWaitTimerAnchorFromAPI = {
+ kind: string;
+ task?: string;
+};
+
+type WorkflowTaskWaitTimerFromAPI = {
+ after?: string;
+ after_seconds?: number;
+ after_us?: number;
+ anchor?: WorkflowTaskWaitTimerAnchorFromAPI;
+ fire_at?: string;
+ name: string;
+ result?: WorkflowTaskWaitTimerResultFromAPI;
+};
+
+type WorkflowTaskWaitTimerResultFromAPI = {
+ fire_at?: string;
+ fired: boolean;
+};
+
+type WorkflowWaitDepDiagnosticFromAPI = {
+ available: boolean;
+ finalized_at?: string;
+ state?: string;
+ task_name: string;
+};
+
+type WorkflowWaitInputDiagnosticsFromAPI = {
+ deps: WorkflowWaitDepDiagnosticFromAPI[];
+ signals: WorkflowWaitSignalDiagnosticFromAPI[];
+ timers: WorkflowWaitTimerDiagnosticFromAPI[];
+};
+
+type WorkflowWaitSignalDiagnosticFromAPI = {
+ included_count: number;
+ key: string;
+ last_id?: number | string;
+};
+
+type WorkflowWaitTermDiagnosticFromAPI = {
+ last_matched_id?: number | string;
+ matched_count: number;
+ name: string;
+ required_count: number;
+ satisfied: boolean;
+};
+
+type WorkflowWaitTimerDiagnosticFromAPI = {
+ fire_at?: string;
+ fired: boolean;
+ name: string;
};
export const cancelJobs: MutationFunction<
@@ -107,10 +437,342 @@ export const getWorkflow: QueryFunction = async ({
).then(apiWorkflowToWorkflow);
};
-const apiWorkflowToWorkflow = (job: WorkflowFromAPI): Workflow => ({
- tasks: job.tasks.map(apiJobToJob) as JobWithKnownMetadata[],
+type GetWorkflowTaskSignalsPayload = {
+ cursorID?: bigint | number | string;
+ desc?: boolean;
+ key?: string;
+ limit?: number;
+ scope?: WorkflowTaskSignalListScope;
+ signal?: AbortSignal;
+ taskName: string;
+ termName?: string;
+ workflowAttempt?: number;
+ workflowID: string;
+};
+
+export const getWorkflowTaskSignals = async ({
+ cursorID,
+ desc = true,
+ key,
+ limit = 20,
+ scope,
+ signal,
+ taskName,
+ termName,
+ workflowAttempt,
+ workflowID,
+}: GetWorkflowTaskSignalsPayload): Promise => {
+ const query = new URLSearchParams({
+ desc: desc.toString(),
+ limit: limit.toString(),
+ task_name: taskName,
+ });
+ if (key) {
+ query.set("key", key);
+ }
+ if (cursorID !== undefined) {
+ query.set("cursor_id", cursorID.toString());
+ }
+ if (scope) {
+ query.set("scope", scope);
+ }
+ if (termName) {
+ query.set("term_name", termName);
+ }
+ if (workflowAttempt !== undefined) {
+ query.set("workflow_attempt", workflowAttempt.toString());
+ }
+ return API.get(
+ { path: `/pro/workflows/${workflowID}/task-signals`, query },
+ { signal },
+ ).then(apiWorkflowTaskSignalListToWorkflowTaskSignalList);
+};
+
+type GetWorkflowTaskWaitDiagnosticsPayload = {
+ signal?: AbortSignal;
+ taskName: string;
+ workflowID: string;
+};
+
+export const getWorkflowTaskWaitDiagnostics = async ({
+ signal,
+ taskName,
+ workflowID,
+}: GetWorkflowTaskWaitDiagnosticsPayload): Promise => {
+ const query = new URLSearchParams({ task_name: taskName });
+
+ return API.get(
+ { path: `/pro/workflows/${workflowID}/task-wait-diagnostics`, query },
+ { signal },
+ ).then(apiWorkflowTaskWaitDiagnosticsToWorkflowTaskWaitDiagnostics);
+};
+
+const apiWorkflowToWorkflow = (workflow: WorkflowFromAPI): Workflow => ({
+ id: workflow.id,
+ name: workflow.name,
+ tasks: workflow.tasks.map(apiWorkflowTaskToWorkflowTask),
+});
+
+const apiWorkflowTaskToWorkflowTask = (
+ taskFromAPI: WorkflowTaskFromAPI,
+): WorkflowTask => {
+ return {
+ ...(apiJobToJob(taskFromAPI) as JobWithKnownMetadata),
+ deps: taskFromAPI.deps,
+ ignoreCancelledDeps: taskFromAPI.ignore_cancelled_deps,
+ ignoreDeletedDeps: taskFromAPI.ignore_deleted_deps,
+ ignoreDiscardedDeps: taskFromAPI.ignore_discarded_deps,
+ name: taskFromAPI.name,
+ stagedAt: parseDate(taskFromAPI.staged_at),
+ wait: apiWorkflowTaskWaitToWorkflowTaskWait(taskFromAPI.wait),
+ waitReason: taskFromAPI.wait_reason,
+ workflowID: taskFromAPI.workflow_id,
+ };
+};
+
+const apiWorkflowTaskWaitToWorkflowTaskWait = (
+ wait: undefined | WorkflowTaskWaitFromAPI,
+): undefined | WorkflowTaskWait => {
+ if (!wait) return undefined;
+
+ const inputs = apiWorkflowTaskWaitInputsToWorkflowTaskWaitInputs(wait.inputs);
+
+ return {
+ evidence: apiWorkflowTaskWaitEvidenceToWorkflowTaskWaitEvidence(
+ wait.evidence,
+ ),
+ exprCel: wait.expr_cel,
+ inputs,
+ phase: parseWorkflowTaskWaitPhase(wait.phase),
+ resolvedAt: parseDate(wait.resolved_at),
+ startedAt: parseDate(wait.started_at),
+ summary: wait.summary,
+ terms: wait.terms.map(apiWorkflowTaskWaitTermToWorkflowTaskWaitTerm),
+ };
+};
+
+const parseWorkflowTaskWaitPhase = (phase: unknown): WorkflowTaskWaitPhase => {
+ if (phase === "not_started" || phase === "waiting" || phase === "resolved") {
+ return phase;
+ }
+
+ return "unknown";
+};
+
+const apiWorkflowTaskWaitTermToWorkflowTaskWaitTerm = (
+ term: WorkflowTaskWaitTermFromAPI,
+): WorkflowTaskWaitTerm => ({
+ exprCel: term.expr_cel,
+ kind: term.kind,
+ label: term.label,
+ name: term.name,
+ result: apiWorkflowTaskWaitTermResultToWorkflowTaskWaitTermResult(
+ term.result,
+ ),
+ signalKey: term.signal_key,
+ timerName: term.timer_name,
+});
+
+const apiWorkflowTaskWaitInputsToWorkflowTaskWaitInputs = (
+ inputs: undefined | WorkflowTaskWaitInputsFromAPI,
+): WorkflowTaskWaitInputs => ({
+ deps: (inputs?.deps ?? []).map(
+ apiWorkflowTaskWaitDepInputToWorkflowTaskWaitDepInput,
+ ),
+ signals: (inputs?.signals ?? []).map(
+ apiWorkflowTaskWaitSignalInputToWorkflowTaskWaitSignalInput,
+ ),
+ timers: (inputs?.timers ?? []).map(
+ apiWorkflowTaskWaitTimerToWorkflowTaskWaitTimer,
+ ),
+});
+
+const apiWorkflowTaskWaitEvidenceToWorkflowTaskWaitEvidence = (
+ evidence: undefined | WorkflowTaskWaitEvidenceFromAPI,
+): undefined | WorkflowTaskWaitEvidence => {
+ if (!evidence) return undefined;
+
+ return {
+ evaluatedAt: parseDateRequired(evidence.evaluated_at, "evaluated_at"),
+ workflowAttempt: evidence.workflow_attempt,
+ };
+};
+
+const apiWorkflowTaskWaitDepInputToWorkflowTaskWaitDepInput = (
+ dep: WorkflowTaskWaitDepInputFromAPI,
+): WorkflowTaskWaitDepInput => ({
+ result: dep.result
+ ? {
+ available: dep.result.available,
+ finalizedAt: parseDate(dep.result.finalized_at),
+ state: dep.result.state,
+ }
+ : undefined,
+ taskName: dep.task_name,
+});
+
+const apiWorkflowTaskWaitSignalInputToWorkflowTaskWaitSignalInput = (
+ signal: WorkflowTaskWaitSignalInputFromAPI,
+): WorkflowTaskWaitSignalInput => ({
+ key: signal.key,
+ result: signal.result
+ ? {
+ includedCount: signal.result.included_count,
+ lastIncludedID: parseBigInt(signal.result.last_included_id),
+ }
+ : undefined,
+});
+
+const apiWorkflowTaskWaitTermResultToWorkflowTaskWaitTermResult = (
+ result: undefined | WorkflowTaskWaitTermResultFromAPI,
+): undefined | WorkflowTaskWaitTermResult => {
+ if (!result) return undefined;
+
+ return {
+ lastMatchedID: parseBigInt(result.last_matched_id),
+ matchedCount: result.matched_count,
+ requiredCount: result.required_count,
+ satisfied: result.satisfied,
+ };
+};
+
+const apiWorkflowTaskWaitTimerToWorkflowTaskWaitTimer = (
+ timer: WorkflowTaskWaitTimerFromAPI,
+): WorkflowTaskWaitTimer => {
+ return {
+ after: timer.after,
+ afterSeconds: timer.after_seconds,
+ afterUs: timer.after_us,
+ anchor: apiWorkflowTaskWaitTimerAnchorToWorkflowTaskWaitTimerAnchor(
+ timer.anchor,
+ ),
+ fireAt: parseDate(timer.fire_at),
+ name: timer.name,
+ result: timer.result
+ ? {
+ fireAt: parseDate(timer.result.fire_at),
+ fired: timer.result.fired,
+ }
+ : undefined,
+ };
+};
+
+const apiWorkflowTaskWaitTimerAnchorToWorkflowTaskWaitTimerAnchor = (
+ anchor: undefined | WorkflowTaskWaitTimerAnchorFromAPI,
+): undefined | WorkflowTaskWaitTimerAnchor => {
+ if (!anchor || !anchor.kind) return undefined;
+
+ return {
+ kind: anchor.kind,
+ task: anchor.task,
+ };
+};
+
+const apiWorkflowTaskSignalListToWorkflowTaskSignalList = (
+ signalList: WorkflowTaskSignalListFromAPI,
+): WorkflowTaskSignalList => ({
+ evidence: apiWorkflowTaskWaitEvidenceToWorkflowTaskWaitEvidence(
+ signalList.evidence,
+ ),
+ hasMore: signalList.has_more,
+ nextCursorID: parseBigInt(signalList.next_cursor_id),
+ scope: parseWorkflowTaskSignalListScope(signalList.scope),
+ signals: signalList.signals.map(apiWorkflowTaskSignalToWorkflowTaskSignal),
});
+const parseWorkflowTaskSignalListScope = (
+ scope: unknown,
+): WorkflowTaskSignalListScope => {
+ if (scope === "evidence" || scope === "history") {
+ return scope;
+ }
+
+ return "history";
+};
+
+const apiWorkflowTaskSignalToWorkflowTaskSignal = (
+ signal: WorkflowTaskSignalFromAPI,
+): WorkflowTaskSignal => ({
+ attempt: signal.attempt,
+ createdAt: parseDateRequired(signal.created_at, "created_at"),
+ id: parseBigIntRequired(signal.id, "id"),
+ key: signal.key,
+ payload: signal.payload,
+ source: signal.source,
+});
+
+const apiWorkflowTaskWaitDiagnosticsToWorkflowTaskWaitDiagnostics = (
+ diagnostics: WorkflowTaskWaitDiagnosticsFromAPI,
+): WorkflowTaskWaitDiagnostics => ({
+ evalError: diagnostics.eval_error,
+ exprResult: diagnostics.expr_result,
+ inputs: {
+ deps: diagnostics.inputs.deps.map((dep) => ({
+ available: dep.available,
+ finalizedAt: parseDate(dep.finalized_at),
+ state: dep.state,
+ taskName: dep.task_name,
+ })),
+ signals: diagnostics.inputs.signals.map((signal) => ({
+ includedCount: signal.included_count,
+ key: signal.key,
+ lastID: parseBigInt(signal.last_id),
+ })),
+ timers: diagnostics.inputs.timers.map((timer) => ({
+ fireAt: parseDate(timer.fire_at),
+ fired: timer.fired,
+ name: timer.name,
+ })),
+ },
+ inspectedAt: parseDateRequired(diagnostics.inspected_at, "inspected_at"),
+ phase: parseWorkflowTaskWaitPhase(diagnostics.phase),
+ signalScanCount: diagnostics.signal_scan_count,
+ signalScanLimit: diagnostics.signal_scan_limit,
+ terms: diagnostics.terms.map((term) => ({
+ lastMatchedID: parseBigInt(term.last_matched_id),
+ matchedCount: term.matched_count,
+ name: term.name,
+ requiredCount: term.required_count,
+ satisfied: term.satisfied,
+ })),
+ truncated: diagnostics.truncated,
+ workflowAttempt: diagnostics.workflow_attempt,
+});
+
+const parseDate = (value: unknown): Date | undefined => {
+ if (typeof value !== "string") return undefined;
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed;
+};
+
+const parseDateRequired = (value: unknown, field: string): Date => {
+ const parsed = parseDate(value);
+ if (!parsed) {
+ throw new Error(`Invalid ${field} value in workflow response`);
+ }
+ return parsed;
+};
+
+const parseBigInt = (value: unknown): bigint | undefined => {
+ if (typeof value !== "number" && typeof value !== "string") {
+ return undefined;
+ }
+
+ try {
+ return BigInt(value);
+ } catch {
+ return undefined;
+ }
+};
+
+const parseBigIntRequired = (value: unknown, field: string): bigint => {
+ const parsed = parseBigInt(value);
+ if (parsed === undefined) {
+ throw new Error(`Invalid ${field} value in workflow response`);
+ }
+ return parsed;
+};
+
export type ListWorkflowsKey = [
"listWorkflows",
undefined | WorkflowState,
diff --git a/src/test/factories/workflowJob.test.ts b/src/test/factories/workflowJob.test.ts
new file mode 100644
index 00000000..6cdfaa99
--- /dev/null
+++ b/src/test/factories/workflowJob.test.ts
@@ -0,0 +1,75 @@
+import { JobState } from "@services/types";
+import { describe, expect, it } from "vitest";
+
+import { workflowJobFactory } from "./workflowJob";
+
+const expectValidDate = (date: Date | undefined) => {
+ expect(date).toBeInstanceOf(Date);
+ expect(Number.isNaN(date?.getTime())).toBe(false);
+};
+
+describe("workflowJobFactory", () => {
+ it("infers valid completed task timing from finalizedAt", () => {
+ const finalizedAt = new Date("2026-04-21T18:00:00.000Z");
+ const task = workflowJobFactory.build({
+ finalizedAt,
+ id: 2001n,
+ state: JobState.Completed,
+ task: "collect_inputs",
+ });
+
+ expect(task.finalizedAt).toEqual(finalizedAt);
+ expectValidDate(task.attemptedAt);
+ expectValidDate(task.createdAt);
+ expectValidDate(task.scheduledAt);
+ expectValidDate(task.stagedAt);
+ expect(task.attemptedAt!.getTime()).toBeLessThan(finalizedAt.getTime());
+ expect(task.createdAt.getTime()).toBeLessThan(task.attemptedAt!.getTime());
+ });
+
+ it("keeps explicit completed timing coherent", () => {
+ const attemptedAt = new Date("2026-04-21T18:00:05.000Z");
+ const createdAt = new Date("2026-04-21T18:00:00.000Z");
+ const finalizedAt = new Date("2026-04-21T18:00:12.000Z");
+ const task = workflowJobFactory.build({
+ attemptedAt,
+ createdAt,
+ finalizedAt,
+ id: 2003n,
+ state: JobState.Completed,
+ task: "await_review",
+ });
+
+ expect(task.createdAt).toEqual(createdAt);
+ expect(task.attemptedAt).toEqual(attemptedAt);
+ expect(task.finalizedAt).toEqual(finalizedAt);
+ expect(task.scheduledAt).toEqual(createdAt);
+ expect(task.stagedAt).toEqual(createdAt);
+ });
+
+ it("ignores undefined lifecycle overrides after inferring timing", () => {
+ const finalizedAt = new Date("2026-04-21T18:00:00.000Z");
+ const task = workflowJobFactory.build({
+ attemptedAt: undefined,
+ createdAt: undefined,
+ finalizedAt,
+ id: 2002n,
+ scheduledAt: undefined,
+ stagedAt: undefined,
+ state: JobState.Completed,
+ task: "safety_review",
+ });
+
+ expect(task.finalizedAt).toEqual(finalizedAt);
+ expectValidDate(task.attemptedAt);
+ expectValidDate(task.createdAt);
+ expectValidDate(task.scheduledAt);
+ expectValidDate(task.stagedAt);
+ expect(task.finalizedAt!.getTime()).toBeGreaterThan(
+ task.attemptedAt!.getTime(),
+ );
+ expect(task.attemptedAt!.getTime()).toBeGreaterThan(
+ task.createdAt.getTime(),
+ );
+ });
+});
diff --git a/src/test/factories/workflowJob.ts b/src/test/factories/workflowJob.ts
index a0aa7935..0f8ce16e 100644
--- a/src/test/factories/workflowJob.ts
+++ b/src/test/factories/workflowJob.ts
@@ -1,5 +1,10 @@
import { JobWithKnownMetadata } from "@services/jobs";
import { JobState } from "@services/types";
+import {
+ type WorkflowTask,
+ type WorkflowTaskWait,
+ type WorkflowTaskWaitReason,
+} from "@services/workflows";
import { Factory } from "fishery";
import { jobFactory } from "./job";
@@ -8,35 +13,83 @@ const defaultWorkflowStagedAt = new Date("2025-01-01T00:00:00.000Z");
const defaultWorkflowID = "wf-1";
type WorkflowJobFactoryParams = {
+ attemptedAt?: Date;
+ createdAt?: Date;
deps?: string[];
+ finalizedAt?: Date;
id?: bigint | number;
+ ignoreCancelledDeps?: boolean;
+ ignoreDeletedDeps?: boolean;
+ ignoreDiscardedDeps?: boolean;
+ scheduledAt?: Date;
+ stagedAt?: Date;
state?: JobState;
task?: string;
+ wait?: WorkflowTaskWait;
+ waitReason?: WorkflowTaskWaitReason;
workflowID?: string;
workflowStagedAt?: Date;
};
+const addSeconds = (date: Date, seconds: number): Date =>
+ new Date(date.getTime() + seconds * 1000);
+
+const sampleRuntimeSeconds = (id: bigint): number => 4 + Number(id % 9n) * 3;
+
+const sampleQueueDelaySeconds = (id: bigint): number => 8 + Number(id % 5n) * 4;
+
export const workflowJobFactory = Factory.define<
- JobWithKnownMetadata,
+ WorkflowTask,
object,
- JobWithKnownMetadata,
+ WorkflowTask,
WorkflowJobFactoryParams
->(({ params, sequence }) => {
+>(({ afterBuild, params, sequence }) => {
const id =
typeof params.id === "bigint" ? params.id : BigInt(params.id ?? sequence);
const task = params.task ?? `task-${id.toString()}`;
- const workflowStagedAt = params.workflowStagedAt ?? defaultWorkflowStagedAt;
+ const state = params.state ?? JobState.Available;
+ const runtimeSeconds = sampleRuntimeSeconds(id);
+ const queueDelaySeconds = sampleQueueDelaySeconds(id);
+
+ let attemptedAt = params.attemptedAt;
+ let finalizedAt = params.finalizedAt;
+
+ if (state === JobState.Completed && !attemptedAt && finalizedAt) {
+ attemptedAt = addSeconds(finalizedAt, -runtimeSeconds);
+ }
+
+ const createdAt =
+ params.createdAt ??
+ (attemptedAt
+ ? addSeconds(attemptedAt, -queueDelaySeconds)
+ : (params.scheduledAt ??
+ params.stagedAt ??
+ params.workflowStagedAt ??
+ defaultWorkflowStagedAt));
+ const scheduledAt = params.scheduledAt ?? createdAt;
+ const workflowStagedAt =
+ params.workflowStagedAt ?? params.stagedAt ?? createdAt;
+ const stagedAt = params.stagedAt ?? workflowStagedAt;
+
+ if (state === JobState.Completed) {
+ attemptedAt ??= addSeconds(createdAt, queueDelaySeconds);
+ finalizedAt ??= addSeconds(attemptedAt, runtimeSeconds);
+ } else if (state === JobState.Running) {
+ attemptedAt ??= addSeconds(createdAt, queueDelaySeconds);
+ }
const baseJob = jobFactory.build({
- createdAt: workflowStagedAt,
+ ...(attemptedAt ? { attemptedAt } : {}),
+ createdAt,
+ ...(finalizedAt ? { finalizedAt } : {}),
id,
kind: `job-${task}`,
- scheduledAt: workflowStagedAt,
- state: params.state ?? JobState.Available,
+ scheduledAt,
+ state,
});
- return {
+ const job: JobWithKnownMetadata = {
...baseJob,
metadata: {
deps: params.deps ?? [],
@@ -45,4 +98,48 @@ export const workflowJobFactory = Factory.define<
workflow_staged_at: workflowStagedAt.toISOString(),
},
};
+
+ const workflowTask: WorkflowTask = {
+ ...job,
+ deps: params.deps ?? [],
+ ignoreCancelledDeps: params.ignoreCancelledDeps ?? false,
+ ignoreDeletedDeps: params.ignoreDeletedDeps ?? false,
+ ignoreDiscardedDeps: params.ignoreDiscardedDeps ?? false,
+ name: task,
+ stagedAt,
+ wait: params.wait,
+ waitReason:
+ params.waitReason ??
+ (() => {
+ if (state !== JobState.Pending) return "none";
+
+ const hasDependencyBlockers = (params.deps ?? []).length > 0;
+ const hasWaitBlocker =
+ params.wait !== undefined && params.wait.phase !== "resolved";
+
+ if (hasDependencyBlockers && hasWaitBlocker) {
+ return "dependencies_and_wait";
+ }
+ if (hasDependencyBlockers) return "dependencies";
+ if (hasWaitBlocker) return "wait";
+
+ return "none";
+ })(),
+ workflowID: params.workflowID ?? defaultWorkflowID,
+ };
+
+ afterBuild((builtTask) => {
+ builtTask.createdAt = createdAt;
+ builtTask.scheduledAt = scheduledAt;
+ builtTask.stagedAt = stagedAt;
+
+ if (attemptedAt) {
+ builtTask.attemptedAt = attemptedAt;
+ }
+ if (finalizedAt) {
+ builtTask.finalizedAt = finalizedAt;
+ }
+ });
+
+ return workflowTask;
});
diff --git a/src/test/utils/features.ts b/src/test/utils/features.ts
index c40af055..d7f73952 100644
--- a/src/test/utils/features.ts
+++ b/src/test/utils/features.ts
@@ -7,7 +7,6 @@ export const createFeatures = (
hasClientTable: false,
hasProducerTable: false,
hasSequenceTable: false,
- hasWorkflows: false,
jobListHideArgsByDefault: false,
producerQueries: false,
workflowQueries: false,