diff --git a/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnDiagnosticEventNames.cs b/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnDiagnosticEventNames.cs
new file mode 100644
index 000000000..1d7cdbeb5
--- /dev/null
+++ b/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnDiagnosticEventNames.cs
@@ -0,0 +1,98 @@
+namespace Elsa.Studio.Workflows.Domain.Models.Bpmn;
+
+///
+/// The stable execution log eventName for each Bpmn.Model.State.BpmnDiagnosticKind member, and the
+/// source every one of them carries. Mirrors elsa-core's own
+/// Elsa.Bpmn.Hosting.BpmnDiagnosticEventNames -- the source of truth for these strings, and for the
+/// projection rules that decide which diagnostics are journaled at all -- so Studio has one place to keep in step
+/// with it rather than depending on Bpmn.Model's own enum values, which it never references.
+///
+public static class BpmnDiagnosticEventNames
+{
+ /// The source every execution log entry projected from a BPMN diagnostic carries.
+ public const string Source = "BPMN";
+
+ ///
+ /// A token arrived at an element via a sequence flow, or an element (a start event, or an error/cancel boundary
+ /// firing without an inbound flow) emitted a token of its own.
+ ///
+ public const string TokenEmitted = "TokenEmitted";
+
+ /// An element started bound work: a single unit, or one instance of a multi-instance loop.
+ public const string Scheduled = "Scheduled";
+
+ /// A token arrived at a join and is waiting for its siblings.
+ public const string Waiting = "Waiting";
+
+ /// A join fired after its arrivals were satisfied.
+ public const string Joined = "Joined";
+
+ /// An end event consumed a token, or a multi-instance loop consumed a finished instance's token.
+ public const string Consumed = "Consumed";
+
+ /// A unit of work was cancelled.
+ public const string Canceled = "Canceled";
+
+ /// A terminate end event ended the process.
+ public const string Terminated = "Terminated";
+
+ /// An element's behavior failed.
+ public const string BehaviorFailure = "BehaviorFailure";
+
+ ///
+ /// The scope itself finished. Never projected: unlike every other kind, it names neither an element nor a flow,
+ /// and the scope's own activity lifecycle already journals its completion.
+ ///
+ public const string Completed = "Completed";
+
+ /// A unit of work faulted.
+ public const string Faulted = "Faulted";
+
+ /// A host completion carrying an attached compensation boundary registered a compensable.
+ public const string CompensationRegistered = "CompensationRegistered";
+
+ /// A compensate throw/end event triggered a compensation replay.
+ public const string CompensationTriggered = "CompensationTriggered";
+
+ /// A compensation handler ran to completion for one registered compensable.
+ public const string Compensated = "Compensated";
+
+ /// A cancel end event began (or completed) cancelling a transaction scope.
+ public const string TransactionCancelled = "TransactionCancelled";
+
+ /// An escalation throw/end event staged an enclosing-scope signal notification.
+ public const string EscalationRaised = "EscalationRaised";
+
+ /// An escalation notification matched an attached boundary and fired it.
+ public const string EscalationCaught = "EscalationCaught";
+
+ /// An escalation reached a scope that could not catch it; a no-op, never a fault.
+ public const string EscalationUnhandled = "EscalationUnhandled";
+
+ ///
+ /// An interrupting escalation boundary matched a notification whose host had already terminalized; a no-op,
+ /// never a fault.
+ ///
+ public const string EscalationLate = "EscalationLate";
+
+ /// An event subprocess was activated by its start-event trigger.
+ public const string EventSubprocessActivated = "EventSubprocessActivated";
+
+ /// An event subprocess body ran to completion.
+ public const string EventSubprocessCompleted = "EventSubprocessCompleted";
+
+ ///
+ /// A call activity's bound child failed and the engine routed the call-activity failure ladder instead of
+ /// normal outbound flows.
+ ///
+ public const string CallActivityFailureRouted = "CallActivityFailureRouted";
+
+ /// A message, signal or timer triggered scope listener was armed.
+ public const string ScopeListenerArmed = "ScopeListenerArmed";
+
+ /// A message, signal or timer triggered scope listener fired.
+ public const string ScopeListenerFired = "ScopeListenerFired";
+
+ /// A message, signal or timer triggered scope listener was retired.
+ public const string ScopeListenerRetired = "ScopeListenerRetired";
+}
diff --git a/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnDiagnosticLogPayload.cs b/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnDiagnosticLogPayload.cs
new file mode 100644
index 000000000..307d48407
--- /dev/null
+++ b/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnDiagnosticLogPayload.cs
@@ -0,0 +1,21 @@
+namespace Elsa.Studio.Workflows.Domain.Models.Bpmn;
+
+///
+/// The payload every execution log entry projected from a BPMN diagnostic carries. Mirrors elsa-core's
+/// Elsa.Bpmn.Hosting.BpmnDiagnosticLogPayload -- the source of truth for this shape -- field for field, since
+/// that record is exactly what arrives, camelCase, as
+/// for an entry whose Source is .
+///
+/// The diagnostic's id in the interpreter's own pinned id stream (diag:N).
+/// The BPMN element the diagnostic is about, or null when it names none.
+/// The BPMN sequence flow the diagnostic is about, or null when it names none.
+/// The token the diagnostic is about, or null when it names none.
+/// The diagnostic kind's enum member name; see .
+/// Free-form key/value details the interpreter attached, carried verbatim.
+public sealed record BpmnDiagnosticLogPayload(
+ string DiagnosticId,
+ string? ElementId,
+ string? FlowId,
+ string? TokenId,
+ string Kind,
+ IReadOnlyDictionary? Details);
diff --git a/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnElementStats.cs b/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnElementStats.cs
new file mode 100644
index 000000000..9175403d6
--- /dev/null
+++ b/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnElementStats.cs
@@ -0,0 +1,35 @@
+namespace Elsa.Studio.Workflows.Domain.Models.Bpmn;
+
+///
+/// Instance state for one BPMN element or sequence flow, keyed by its BPMN document id -- an element id, or, for a
+/// sequence flow, a flow id, since element and flow ids are both document-unique.
+///
+///
+/// This is a write-only projection derived from diagnostics: is the only
+/// thing that produces it, and nothing reads it back into the engine or edits it. It exists so a gateway, an
+/// intermediate event or a sequence flow -- none of which has an Elsa activity id under Option A -- has something
+/// for the instance viewer's overlay to show, alongside the existing activity-keyed for
+/// bound work. Mirrors the ClientLib's canvas-neutral BpmnElementStats interface (src/bpmn/model.ts),
+/// which owns the shape from the rendering side; every field here is nullable for the same reason that one is
+/// all-optional: a projection that cannot compute a field says nothing about it rather than reporting a zero.
+///
+public sealed class BpmnElementStats
+{
+ /// How many tokens have entered this element or been carried by this flow.
+ public int? Started { get; set; }
+
+ /// How many tokens have left this element having completed, or how many times this flow was taken.
+ public int? Completed { get; set; }
+
+ /// How many tokens are sitting on this element right now (a waiting catch event, an armed listener).
+ public int? Active { get; set; }
+
+ /// Whether a token is parked here waiting for something external (e.g. a join awaiting its siblings).
+ public bool? Blocked { get; set; }
+
+ /// Whether execution faulted at this element.
+ public bool? Faulted { get; set; }
+
+ /// Whether a token here was cancelled (an interrupted activity, a lost event race, a torn-down scope).
+ public bool? Canceled { get; set; }
+}
diff --git a/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnElementStatsProjector.cs b/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnElementStatsProjector.cs
new file mode 100644
index 000000000..b5c941c3d
--- /dev/null
+++ b/src/modules/Elsa.Studio.Workflows.Core/Domain/Models/Bpmn/BpmnElementStatsProjector.cs
@@ -0,0 +1,164 @@
+using System.Linq;
+using System.Text.Json;
+using Elsa.Api.Client.Resources.WorkflowInstances.Models;
+
+namespace Elsa.Studio.Workflows.Domain.Models.Bpmn;
+
+///
+/// Folds the BPMN diagnostics projected onto a workflow instance's journal (see
+/// and ) into a single element-keyed
+/// map.
+///
+///
+/// The one place the mapping from a diagnostic kind to a stats change lives: nothing on the ClientLib side repeats
+/// it, so a BPMN element's badge and a sequence flow's "taken" styling always agree with what this class decided.
+/// Entries from a nested scope (each nested BpmnProcess writes diagnostics on its own activity) fold into
+/// the very same map, because BPMN element and flow ids are unique across the whole document, not merely within
+/// one scope.
+///
+public static class BpmnElementStatsProjector
+{
+ private static readonly JsonSerializerOptions PayloadSerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
+
+ ///
+ /// Projects the BPMN diagnostic entries in into an element-keyed stats map.
+ /// Entries whose Source is not , or whose payload does not
+ /// deserialize as a , are ignored, so callers may pass an unfiltered
+ /// journal page.
+ ///
+ public static IReadOnlyDictionary Project(IEnumerable journalEntries)
+ {
+ var stats = new Dictionary();
+ Fold(journalEntries, stats);
+ return stats;
+ }
+
+ ///
+ /// Folds into an already-populated element-keyed stats map, mutating it in
+ /// place, so a caller that has already folded earlier journal pages need only fold the new ones rather than
+ /// re-fold the whole history on every refresh.
+ ///
+ public static void Fold(IEnumerable journalEntries, Dictionary stats)
+ {
+ foreach (var entry in journalEntries.Where(e => e.Source == BpmnDiagnosticEventNames.Source))
+ {
+ if (!TryReadPayload(entry.Payload, out var payload))
+ continue;
+
+ if (!string.IsNullOrEmpty(payload.ElementId))
+ ApplyToElement(GetOrAdd(stats, payload.ElementId), payload.Kind);
+
+ // A diagnostic that names a flow id represents that flow having just been taken -- a sequence flow
+ // has no state beyond that -- regardless of which kind carried it (normally TokenEmitted).
+ if (!string.IsNullOrEmpty(payload.FlowId))
+ ApplyToFlow(GetOrAdd(stats, payload.FlowId));
+ }
+ }
+
+ private static BpmnElementStats GetOrAdd(Dictionary stats, string id)
+ {
+ if (stats.TryGetValue(id, out var entry))
+ return entry;
+
+ entry = new BpmnElementStats();
+ stats[id] = entry;
+ return entry;
+ }
+
+ ///
+ /// Applies one diagnostic kind's effect on the element (or flow-target) it names. See the parenthetical list in
+ /// the design: token emitted/consumed change how many tokens are present; a join that is Waiting is
+ /// blocked, and stops being blocked once it is Joined; work being scheduled, completed or torn down
+ /// moves the same active/completed counters bound work's own ActivityStats reports, so an element with
+ /// no activity id still tells the same story.
+ ///
+ private static void ApplyToElement(BpmnElementStats stats, string kind)
+ {
+ switch (kind)
+ {
+ case BpmnDiagnosticEventNames.TokenEmitted:
+ case BpmnDiagnosticEventNames.Scheduled:
+ case BpmnDiagnosticEventNames.EventSubprocessActivated:
+ case BpmnDiagnosticEventNames.CompensationTriggered:
+ case BpmnDiagnosticEventNames.ScopeListenerArmed:
+ stats.Started = (stats.Started ?? 0) + 1;
+ stats.Active = (stats.Active ?? 0) + 1;
+ break;
+
+ case BpmnDiagnosticEventNames.Waiting:
+ // A join arrived and is waiting on its siblings: a token is present (blocked), not yet consumed.
+ stats.Blocked = true;
+ break;
+
+ case BpmnDiagnosticEventNames.Joined:
+ stats.Blocked = false;
+ stats.Completed = (stats.Completed ?? 0) + 1;
+ stats.Active = Decrement(stats.Active);
+ break;
+
+ case BpmnDiagnosticEventNames.Consumed:
+ case BpmnDiagnosticEventNames.Terminated:
+ case BpmnDiagnosticEventNames.EventSubprocessCompleted:
+ case BpmnDiagnosticEventNames.Compensated:
+ case BpmnDiagnosticEventNames.EscalationCaught:
+ case BpmnDiagnosticEventNames.ScopeListenerFired:
+ stats.Completed = (stats.Completed ?? 0) + 1;
+ stats.Active = Decrement(stats.Active);
+ break;
+
+ case BpmnDiagnosticEventNames.ScopeListenerRetired:
+ stats.Active = Decrement(stats.Active);
+ break;
+
+ case BpmnDiagnosticEventNames.Canceled:
+ case BpmnDiagnosticEventNames.TransactionCancelled:
+ stats.Canceled = true;
+ stats.Active = Decrement(stats.Active);
+ break;
+
+ case BpmnDiagnosticEventNames.Faulted:
+ case BpmnDiagnosticEventNames.BehaviorFailure:
+ case BpmnDiagnosticEventNames.CallActivityFailureRouted:
+ stats.Faulted = true;
+ break;
+
+ // CompensationRegistered, EscalationRaised, EscalationUnhandled and EscalationLate are bookkeeping
+ // that leaves no per-element state worth showing; EscalationUnhandled and EscalationLate are
+ // documented as never a fault, so they must not be folded into Faulted.
+ default:
+ break;
+ }
+ }
+
+ private static void ApplyToFlow(BpmnElementStats stats)
+ {
+ stats.Started = (stats.Started ?? 0) + 1;
+ stats.Completed = (stats.Completed ?? 0) + 1;
+ }
+
+ private static int Decrement(int? value) => Math.Max(0, (value ?? 0) - 1);
+
+ private static bool TryReadPayload(object? payload, out BpmnDiagnosticLogPayload result)
+ {
+ switch (payload)
+ {
+ case BpmnDiagnosticLogPayload direct:
+ result = direct;
+ return true;
+
+ case JsonElement { ValueKind: JsonValueKind.Object } element:
+ var deserialized = element.Deserialize(PayloadSerializerOptions);
+
+ if (deserialized != null)
+ {
+ result = deserialized;
+ return true;
+ }
+
+ break;
+ }
+
+ result = null!;
+ return false;
+ }
+}
diff --git a/src/modules/Elsa.Studio.Workflows.Core/UI/Contracts/IBpmnElementStatsSink.cs b/src/modules/Elsa.Studio.Workflows.Core/UI/Contracts/IBpmnElementStatsSink.cs
new file mode 100644
index 000000000..ac7703295
--- /dev/null
+++ b/src/modules/Elsa.Studio.Workflows.Core/UI/Contracts/IBpmnElementStatsSink.cs
@@ -0,0 +1,22 @@
+using Elsa.Studio.Workflows.Domain.Models.Bpmn;
+
+namespace Elsa.Studio.Workflows.UI.Contracts;
+
+///
+/// An optional capability an may implement to accept the element-keyed instance
+/// overlay: a gateway, an intermediate event or a sequence flow has no Elsa activity id under BPMN's Option A, so
+/// alone never lights one up.
+///
+///
+/// Kept as a separate interface, rather than a member on itself, so that every other
+/// diagram designer -- which has no BPMN element ids to key anything on -- is untouched by this projection.
+///
+public interface IBpmnElementStatsSink
+{
+ ///
+ /// Replaces the whole element-keyed instance overlay. The map is authoritative: an element or flow it no
+ /// longer mentions loses its badge or "taken" styling.
+ ///
+ /// The stats, keyed by BPMN element id or, for a sequence flow, by flow id.
+ Task UpdateElementStatsAsync(IReadOnlyDictionary elementStats);
+}
diff --git a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/bpmn/model.ts b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/bpmn/model.ts
index 0b4b58012..605350f31 100644
--- a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/bpmn/model.ts
+++ b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/bpmn/model.ts
@@ -237,6 +237,8 @@ export interface BpmnViewFlow {
readonly conditionOutcome: string | null;
/** Whether this is the source gateway's default flow. */
readonly isDefault: boolean;
+ /** Instance state keyed by this flow's own id -- whether, and how often, it has been taken. */
+ readonly stats: BpmnElementStats | null;
/** Empty when the document carries no DI edge for this flow; see {@link geometrySource}. */
readonly waypoints: readonly BpmnPoint[];
readonly geometrySource: BpmnGeometrySource;
diff --git a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/bpmn/view-model.ts b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/bpmn/view-model.ts
index 5955a29e1..c0db215c8 100644
--- a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/bpmn/view-model.ts
+++ b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/bpmn/view-model.ts
@@ -185,6 +185,7 @@ export function buildBpmnViewModel(input: BpmnViewModelInput): BpmnViewModel {
// is only set by documents that say it there instead; honour both so neither
// representation of the same fact goes missing.
isDefault: flow.isDefault === true || source.defaultFlowId === flow.flowId,
+ stats: input.elementStats?.[flow.flowId] ?? null,
waypoints: edge?.waypoints ?? [],
geometrySource: edge != null && edge.waypoints.length > 0 ? 'document' : 'fallback',
labelGeometry: toBounds(edge?.labelBounds ?? null),
diff --git a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/__tests__/stats.test.ts b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/__tests__/stats.test.ts
index 84f24f93f..0a91a2bd5 100644
--- a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/__tests__/stats.test.ts
+++ b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/__tests__/stats.test.ts
@@ -7,9 +7,9 @@
* Both are asserted here in both directions.
*/
import { describe, expect, it } from 'vitest';
-import { statsBadgeAttrs } from '../cells';
-import { BADGE_SURFACE_BY_TONE } from '../palette';
-import { resolveBpmnStatsBadge } from '../stats';
+import { flowTakenLineAttrs, statsBadgeAttrs } from '../cells';
+import { BADGE_SURFACE_BY_TONE, EDGE } from '../palette';
+import { isBpmnFlowTaken, resolveBpmnStatsBadge } from '../stats';
describe('resolveBpmnStatsBadge', () => {
it('says nothing about an element the overlay says nothing about', () => {
@@ -114,3 +114,36 @@ describe('statsBadgeAttrs', () => {
expect(attrs.statsBadgeGroup['aria-label']).toBe('Blocked (1)');
});
});
+
+describe('isBpmnFlowTaken', () => {
+ it('says no about a flow the overlay says nothing about', () => {
+ expect(isBpmnFlowTaken(null)).toBe(false);
+ expect(isBpmnFlowTaken(undefined)).toBe(false);
+ expect(isBpmnFlowTaken({})).toBe(false);
+ });
+
+ it('says yes once the flow has been taken, by either counter the projector might set', () => {
+ expect(isBpmnFlowTaken({ completed: 1 })).toBe(true);
+ expect(isBpmnFlowTaken({ started: 1 })).toBe(true);
+ });
+});
+
+describe('flowTakenLineAttrs', () => {
+ it('paints an untaken flow in the plain edge colour, at the plain width', () => {
+ expect(flowTakenLineAttrs(null)).toEqual({ stroke: EDGE, strokeWidth: 1.5 });
+ expect(flowTakenLineAttrs({})).toEqual({ stroke: EDGE, strokeWidth: 1.5 });
+ });
+
+ it('paints a taken flow in the same tone a completed node badge uses, thicker', () => {
+ const attrs = flowTakenLineAttrs({ completed: 1 });
+
+ expect(attrs.stroke).toBe(BADGE_SURFACE_BY_TONE.completed);
+ expect(attrs.strokeWidth).toBeGreaterThan(1.5);
+ });
+
+ it('returns both properties whichever way it decides, so a flow that stops being taken falls back instead of keeping its old colour', () => {
+ // updateBpmnElementStats merges this into the edge's existing `line` attrs rather than
+ // replacing them; a partial object here would leave a cleared flow looking taken forever.
+ expect(Object.keys(flowTakenLineAttrs(null)).sort()).toEqual(Object.keys(flowTakenLineAttrs({ completed: 1 })).sort());
+ });
+});
diff --git a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/cells.ts b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/cells.ts
index 0043d9a52..99c06d1e5 100644
--- a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/cells.ts
+++ b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/cells.ts
@@ -62,7 +62,7 @@ import {
TEXT,
UNBOUND,
} from './palette';
-import { resolveBpmnStatsBadge, type BpmnStatsBadge } from './stats';
+import { isBpmnFlowTaken, resolveBpmnStatsBadge, type BpmnStatsBadge } from './stats';
const CATCHING_EVENT_TYPES: readonly string[] = ['startEvent', 'intermediateCatchEvent', BOUNDARY_EVENT_ELEMENT_TYPE];
const RINGED_EVENT_TYPES: readonly string[] = ['intermediateCatchEvent', 'intermediateThrowEvent', BOUNDARY_EVENT_ELEMENT_TYPE];
@@ -113,6 +113,8 @@ export interface BpmnFlowCellData {
readonly targetElementId: string;
readonly isDefault: boolean;
readonly conditionOutcome: string | null;
+ /** Instance state keyed by this flow's own id. Always null for an association. */
+ readonly stats: BpmnElementStats | null;
}
export type BpmnCellData = BpmnElementCellData | BpmnContainerCellData | BpmnFlowCellData;
@@ -888,6 +890,7 @@ function edgeForFlow(flow: BpmnViewFlow, context: BuildContext): Edge.Metadata {
targetElementId: flow.targetElementId,
isDefault: flow.isDefault,
conditionOutcome: flow.conditionOutcome,
+ stats: flow.stats,
};
const source = context.elementsByScopedId.get(scopedKey(flow.scopeId, flow.sourceElementId)) ?? null;
const target = context.elementsByScopedId.get(scopedKey(flow.scopeId, flow.targetElementId)) ?? null;
@@ -900,7 +903,7 @@ function edgeForFlow(flow: BpmnViewFlow, context: BuildContext): Edge.Metadata {
source: terminal(flow.sourceElementId, source, waypoints[0]),
target: terminal(flow.targetElementId, target, waypoints[waypoints.length - 1]),
vertices: waypoints.length > 2 ? waypoints.slice(1, -1).map(point => ({ x: point.x, y: point.y })) : [],
- attrs: { line: flowLineAttrs(flow, source) },
+ attrs: { line: { ...flowLineAttrs(flow, source), ...flowTakenLineAttrs(flow.stats) } },
labels: flow.name == null || flow.name.length === 0 ? [] : [edgeLabel(flow.name)],
data,
};
@@ -919,6 +922,7 @@ function edgeForAssociation(
targetElementId,
isDefault: false,
conditionOutcome: null,
+ stats: null,
};
return {
@@ -975,6 +979,25 @@ function flowLineAttrs(flow: BpmnViewFlow, source: BpmnViewElement | null): Reco
return attrs;
}
+/**
+ * The line-only style override for whether a sequence flow has been taken, reusing the "completed"
+ * badge tone -- the same colour a node turns once its own token count says it is done -- so a taken
+ * flow reads as part of the same visual language rather than inventing a second one.
+ *
+ * Always returns both properties, never a partial object: a flow that stops being taken (the map no
+ * longer mentions it) must fall back to the plain, untaken line exactly as loudly as one that starts
+ * being taken lights up, since {@link updateBpmnElementStats} merges this into the edge's existing
+ * `line` attrs rather than replacing them wholesale.
+ */
+export function flowTakenLineAttrs(stats: BpmnElementStats | null | undefined): Record {
+ const taken = isBpmnFlowTaken(stats);
+
+ return {
+ stroke: taken ? BADGE_SURFACE_BY_TONE.completed : EDGE,
+ strokeWidth: taken ? 2.5 : 1.5,
+ };
+}
+
function edgeLabel(text: string): Record {
return {
position: { distance: 0.5 },
diff --git a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/mount.ts b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/mount.ts
index 57f2d3e13..f3e7f6bd6 100644
--- a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/mount.ts
+++ b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/mount.ts
@@ -13,8 +13,10 @@ import type { DotNetComponentRef } from '../api/graph-bindings';
import { whenCanvasHasHeight } from '../internal/canvas-ready';
import {
buildBpmnX6Cells,
+ flowTakenLineAttrs,
statsBadgeAttrs,
type BpmnElementCellData,
+ type BpmnFlowCellData,
type BpmnX6Cells,
} from './cells';
import { BPMN_DESIGNER_CLASS } from './constants';
@@ -179,6 +181,17 @@ export function updateBpmnElementStats(
node.setData({ ...data, stats }, { overwrite: true });
node.attr(statsBadgeAttrs(resolveBpmnStatsBadge(stats, data.activityStats)));
}
+
+ for (const edge of binding.graph.getEdges()) {
+ const data = flowData(edge.getData());
+
+ if (data == null) continue;
+
+ const stats = elementStats?.[data.id] ?? null;
+
+ edge.setData({ ...data, stats }, { overwrite: true });
+ edge.attr({ line: flowTakenLineAttrs(stats) });
+ }
}
/** Updates the activity-keyed overlay for one Elsa activity, on every element bound to it. */
@@ -227,6 +240,11 @@ function elementData(data: unknown): BpmnElementCellData | null {
return (data as BpmnElementCellData | null)?.cellKind === 'element' ? data as BpmnElementCellData : null;
}
+/** Only a sequence flow can be "taken"; an association's id is a synthetic pair, never a document id. */
+function flowData(data: unknown): BpmnFlowCellData | null {
+ return (data as BpmnFlowCellData | null)?.cellKind === 'flow' ? data as BpmnFlowCellData : null;
+}
+
function toSelection(data: BpmnElementCellData): BpmnElementSelection {
return {
elementId: data.elementId,
diff --git a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/stats.ts b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/stats.ts
index 45b367a8f..ff411e58b 100644
--- a/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/stats.ts
+++ b/src/modules/Elsa.Studio.Workflows.Designer/ClientLib/src/designer/bpmn/stats.ts
@@ -76,3 +76,14 @@ function firstNumber(...values: readonly (number | null | undefined)[]): number
return null;
}
+
+/**
+ * Whether a sequence flow's own stats entry says it has been taken at least once.
+ *
+ * A flow has no state beyond that: `BpmnElementStatsProjector` (the C# side, the one place this
+ * mapping lives) marks a flow's entry the moment a token travels along it, by its own flow id, so
+ * either counter being positive is enough.
+ */
+export function isBpmnFlowTaken(stats: BpmnElementStats | null | undefined): boolean {
+ return (stats?.completed ?? 0) > 0 || (stats?.started ?? 0) > 0;
+}
diff --git a/src/modules/Elsa.Studio.Workflows.Designer/Components/BpmnDesigner.razor.cs b/src/modules/Elsa.Studio.Workflows.Designer/Components/BpmnDesigner.razor.cs
index 83d31611f..f16b61268 100644
--- a/src/modules/Elsa.Studio.Workflows.Designer/Components/BpmnDesigner.razor.cs
+++ b/src/modules/Elsa.Studio.Workflows.Designer/Components/BpmnDesigner.razor.cs
@@ -6,6 +6,7 @@
using Elsa.Studio.Workflows.Designer.Services;
using Elsa.Studio.Workflows.Domain.Contracts;
using Elsa.Studio.Workflows.Domain.Models;
+using Elsa.Studio.Workflows.Domain.Models.Bpmn;
using Elsa.Studio.Workflows.Extensions;
using Elsa.Studio.Workflows.UI.Contracts;
using Microsoft.AspNetCore.Components;
@@ -153,6 +154,14 @@ public async Task LoadBpmnAsync(JsonObject activity, string? sourceXml, IDiction
public async Task UpdateActivityStatsAsync(string activityId, ActivityStats stats) =>
await ScheduleGraphActionAsync(() => _graphApi!.UpdateActivityStatsAsync(activityId, stats));
+ ///
+ /// Replaces the whole element-keyed instance overlay: a gateway, an intermediate event or a sequence flow, none
+ /// of which has an Elsa activity id, keyed instead by BPMN element id (or, for a flow, by flow id).
+ ///
+ /// The stats to apply, keyed by BPMN element or flow id.
+ public async Task UpdateElementStatsAsync(IReadOnlyDictionary elementStats) =>
+ await ScheduleGraphActionAsync(() => _graphApi!.UpdateElementStatsAsync(elementStats));
+
///
/// Keeps the held activity tree in step with an edit made elsewhere (the properties panel): the
/// matching node -- the root itself or, recursively, a child bound to a BPMN element or to a
diff --git a/src/modules/Elsa.Studio.Workflows.Designer/Interop/BpmnGraphApi.cs b/src/modules/Elsa.Studio.Workflows.Designer/Interop/BpmnGraphApi.cs
index 37a6c7dd3..bf550703d 100644
--- a/src/modules/Elsa.Studio.Workflows.Designer/Interop/BpmnGraphApi.cs
+++ b/src/modules/Elsa.Studio.Workflows.Designer/Interop/BpmnGraphApi.cs
@@ -3,6 +3,7 @@
using System.Text.Json.Serialization;
using Elsa.Studio.Workflows.Designer.Models;
using Elsa.Studio.Workflows.Domain.Models;
+using Elsa.Studio.Workflows.Domain.Models.Bpmn;
using Microsoft.JSInterop;
namespace Elsa.Studio.Workflows.Designer.Interop;
@@ -49,6 +50,14 @@ public async Task> LoadDiagramAsync(JsonObject inp
public async Task UpdateActivityStatsAsync(string activityId, ActivityStats? stats) =>
await InvokeAsync(module => module.InvokeVoidAsync("updateBpmnActivityStats", _graphId, activityId, stats));
+ ///
+ /// Replaces the whole element-keyed instance overlay: instance state for a gateway, an intermediate event or a
+ /// sequence flow, keyed by BPMN element id (or, for a flow, by flow id).
+ ///
+ /// The stats to apply, or null (or empty) to clear the overlay entirely.
+ public async Task UpdateElementStatsAsync(IReadOnlyDictionary? elementStats) =>
+ await InvokeAsync(module => module.InvokeVoidAsync("updateBpmnElementStats", _graphId, elementStats));
+
///
/// Selects the specified element, optionally centering the viewport on it.
///
diff --git a/src/modules/Elsa.Studio.Workflows.Tests/BpmnDiagramDesignerElementStatsMountRaceTests.cs b/src/modules/Elsa.Studio.Workflows.Tests/BpmnDiagramDesignerElementStatsMountRaceTests.cs
new file mode 100644
index 000000000..f7b4e8729
--- /dev/null
+++ b/src/modules/Elsa.Studio.Workflows.Tests/BpmnDiagramDesignerElementStatsMountRaceTests.cs
@@ -0,0 +1,113 @@
+using System.Text.Json.Nodes;
+using Bunit;
+using Elsa.Api.Client.Resources.ActivityDescriptors.Models;
+using Elsa.Studio.Extensions;
+using Elsa.Studio.Localization;
+using Elsa.Studio.Workflows.DiagramDesigners.Bpmn;
+using Elsa.Studio.Workflows.Designer.Extensions;
+using Elsa.Studio.Workflows.Designer.Models;
+using Elsa.Studio.Workflows.Designer.Options;
+using Elsa.Studio.Workflows.Domain.Contracts;
+using Elsa.Studio.Workflows.Domain.Models.Bpmn;
+using Elsa.Studio.Workflows.Extensions;
+using Elsa.Studio.Workflows.UI.Contexts;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Localization;
+using MudBlazor.Services;
+using Xunit;
+
+namespace Elsa.Studio.Workflows.Tests;
+
+///
+/// Covers the element-stats mount race (issue's W13 follow-up): a refresh can name a
+/// before its -- and, in turn, the canvas
+/// underneath it -- has ever been rendered, most notably the one unconditional refresh a freshly opened instance
+/// gets on load, which runs before the designer has had a chance to render anything at all. That update must not
+/// be silently dropped: it is the only chance a finished instance (no observer-driven refresh to fall back on)
+/// ever gets to show its gateway, event and taken-flow overlay.
+///
+public sealed class BpmnDiagramDesignerElementStatsMountRaceTests : BunitContext, IAsyncLifetime
+{
+ public BpmnDiagramDesignerElementStatsMountRaceTests()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+ JSInterop.Setup("loadBpmnDiagram", _ => true).SetResult([]);
+ Services.AddMudServices();
+ Services.AddLogging();
+ Services.AddCoreInternal();
+ Services.AddWorkflowsCore();
+ Services.AddWorkflowsDesigner();
+ Services.AddSingleton();
+ Services.AddSingleton();
+ }
+
+ Task IAsyncLifetime.InitializeAsync() => Task.CompletedTask;
+ async Task IAsyncLifetime.DisposeAsync() => await base.DisposeAsync();
+
+ [Fact]
+ public async Task UpdateElementStatsAsync_BeforeTheCanvasIsMounted_IsAppliedOnceItMounts()
+ {
+ var handler = JSInterop.SetupVoid("updateBpmnElementStats", _ => true);
+
+ var designer = new BpmnDiagramDesigner(new TestLocalizer(), Microsoft.Extensions.Options.Options.Create(new DesignerOptions()), null!, null!, null!, null!);
+ var activity = CreateActivity("root");
+ var elementStats = new Dictionary
+ {
+ ["Element_1"] = new() { Completed = 1 }
+ };
+
+ // Simulate the race: the refresh arrives before the designer's wrapper -- and its canvas -- has ever been
+ // rendered, exactly as it does today during DiagramDesignerWrapper.OnInitializedAsync's unconditional
+ // first refresh, which runs before the render tree that would mount BpmnDesignerWrapper exists yet.
+ await designer.UpdateElementStatsAsync(elementStats);
+
+ Assert.Empty(handler.Invocations);
+
+ // Mounting the designer's render fragment is what today's production code does next, once the render
+ // tree containing it is actually processed by the renderer.
+ var context = new DisplayContext(activity);
+ Render(designer.DisplayDesigner(context));
+
+ var invocation = Assert.Single(handler.Invocations);
+ var appliedStats = Assert.IsType>(invocation.Arguments[1]);
+ Assert.Equal(1, appliedStats["Element_1"].Completed);
+ }
+
+ ///
+ /// Creates a BpmnProcess root carrying one element, so mounts the
+ /// canvas rather than the "empty scope" notice.
+ ///
+ private static JsonObject CreateActivity(string id) => new()
+ {
+ ["id"] = id,
+ ["type"] = "Elsa.BpmnProcess",
+ ["version"] = 1,
+ ["activities"] = new JsonArray(),
+ ["process"] = new JsonObject
+ {
+ ["processId"] = "process",
+ ["elements"] = new JsonArray
+ {
+ new JsonObject { ["elementId"] = "Element_1", ["elementType"] = "task" }
+ }
+ }
+ };
+
+ private sealed class TestLocalizer : ILocalizer
+ {
+ public LocalizedString this[string? key] => new(key ?? string.Empty, key ?? string.Empty);
+ public LocalizedString this[string? key, params object[] arguments] => new(key ?? string.Empty, string.Format(key ?? string.Empty, arguments));
+ }
+
+ private sealed class NoOpActivityRegistry : IActivityRegistry
+ {
+ public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+ public Task EnsureLoadedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+ public IEnumerable List() => [];
+ public ActivityDescriptor? Find(string activityType, int? version = default) => null;
+ public IEnumerable FindAll(string activityType) => [];
+ public void MarkStale()
+ {
+ }
+ }
+}
diff --git a/src/modules/Elsa.Studio.Workflows.Tests/BpmnElementStatsProjectorTests.cs b/src/modules/Elsa.Studio.Workflows.Tests/BpmnElementStatsProjectorTests.cs
new file mode 100644
index 000000000..7c7b4b706
--- /dev/null
+++ b/src/modules/Elsa.Studio.Workflows.Tests/BpmnElementStatsProjectorTests.cs
@@ -0,0 +1,233 @@
+using System.Text.Json;
+using Elsa.Api.Client.Resources.WorkflowInstances.Models;
+using Elsa.Studio.Workflows.Domain.Models.Bpmn;
+using Xunit;
+
+namespace Elsa.Studio.Workflows.Tests;
+
+///
+/// Covers : folding the BPMN diagnostics elsa-core projects onto a workflow
+/// instance's journal (see and ) into
+/// the element-keyed overlay the instance viewer's canvas reads.
+///
+///
+/// The parallel-gateway scenario below is this item's own verification scenario (D1): a join waiting on a still
+/// blocked sibling, and a completed branch whose flows were taken, both visible with nothing bound to the gateway
+/// itself.
+///
+public class BpmnElementStatsProjectorTests
+{
+ [Fact(DisplayName = "A parallel split where one branch blocks: the join is waiting, and the completed branch's flows are taken")]
+ public void Project_ParallelSplitOneBranchBlocked_ShowsTheJoinWaitingAndTheCompletedBranchesFlowsTaken()
+ {
+ var entries = new[]
+ {
+ DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, flowId: "flow-split-completed"),
+ DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, flowId: "flow-completed-join"),
+ DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "join"),
+ };
+
+ var stats = BpmnElementStatsProjector.Project(entries);
+
+ Assert.True(stats["join"].Blocked);
+ Assert.True(IsTaken(stats["flow-split-completed"]));
+ Assert.True(IsTaken(stats["flow-completed-join"]));
+ }
+
+ [Fact(DisplayName = "A join firing clears its blocked flag and counts as completed")]
+ public void Project_Joined_ClearsBlockedAndCountsAsCompleted()
+ {
+ var entries = new[]
+ {
+ DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "join"),
+ DiagnosticEntry(BpmnDiagnosticEventNames.Joined, elementId: "join"),
+ };
+
+ var stats = BpmnElementStatsProjector.Project(entries).Single(x => x.Key == "join").Value;
+
+ Assert.False(stats.Blocked);
+ Assert.Equal(1, stats.Completed);
+ }
+
+ [Fact(DisplayName = "A single diagnostic naming both an element and a flow updates both entries independently")]
+ public void Project_DiagnosticNamingBothAnElementAndAFlow_UpdatesBothEntries()
+ {
+ var entries = new[] { DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, elementId: "catch-event", flowId: "flow-into-catch") };
+
+ var stats = BpmnElementStatsProjector.Project(entries);
+
+ Assert.Equal(1, stats["catch-event"].Started);
+ Assert.Equal(1, stats["catch-event"].Active);
+ Assert.True(IsTaken(stats["flow-into-catch"]));
+ }
+
+ [Theory(DisplayName = "Faulted and BehaviorFailure both mark the element faulted")]
+ [InlineData(nameof(BpmnDiagnosticEventNames.Faulted))]
+ [InlineData(nameof(BpmnDiagnosticEventNames.BehaviorFailure))]
+ [InlineData(nameof(BpmnDiagnosticEventNames.CallActivityFailureRouted))]
+ public void Project_FaultKinds_MarkTheElementFaulted(string kind)
+ {
+ var entries = new[] { DiagnosticEntry(kind, elementId: "task") };
+
+ var stats = BpmnElementStatsProjector.Project(entries);
+
+ Assert.True(stats["task"].Faulted);
+ }
+
+ [Theory(DisplayName = "EscalationUnhandled and EscalationLate are documented as never a fault, and must not be folded into one")]
+ [InlineData(nameof(BpmnDiagnosticEventNames.EscalationUnhandled))]
+ [InlineData(nameof(BpmnDiagnosticEventNames.EscalationLate))]
+ public void Project_NeverFaultKinds_DoNotMarkTheElementFaulted(string kind)
+ {
+ var entries = new[] { DiagnosticEntry(kind, elementId: "boundary") };
+
+ var stats = BpmnElementStatsProjector.Project(entries);
+
+ Assert.False(stats["boundary"].Faulted ?? false);
+ }
+
+ [Fact(DisplayName = "Canceled marks the element cancelled and decrements its active count")]
+ public void Project_Canceled_MarksCancelledAndDecrementsActive()
+ {
+ var entries = new[]
+ {
+ DiagnosticEntry(BpmnDiagnosticEventNames.Scheduled, elementId: "task"),
+ DiagnosticEntry(BpmnDiagnosticEventNames.Canceled, elementId: "task"),
+ };
+
+ var stats = BpmnElementStatsProjector.Project(entries)["task"];
+
+ Assert.True(stats.Canceled);
+ Assert.Equal(0, stats.Active);
+ }
+
+ [Fact(DisplayName = "Entries from a nested scope's own activity land in the same, single element-keyed map")]
+ public void Project_EntriesFromDifferentScopeActivities_FoldIntoTheSameMap()
+ {
+ // Diagnostics for the outer BpmnProcess scope and a nested one are both written on their own scope's
+ // activity id (never a child's), but element and flow ids are unique across the whole document, so the
+ // resulting map is global per instance rather than segregated by which scope wrote the entry.
+ var entries = new[]
+ {
+ DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, elementId: "outer-task", activityId: "outer-scope"),
+ DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "inner-join", activityId: "inner-scope"),
+ };
+
+ var stats = BpmnElementStatsProjector.Project(entries);
+
+ Assert.Equal(2, stats.Count);
+ Assert.Equal(1, stats["outer-task"].Started);
+ Assert.True(stats["inner-join"].Blocked);
+ }
+
+ [Fact(DisplayName = "Entries whose Source is not BPMN are ignored, even if their event name coincides with a diagnostic kind")]
+ public void Project_NonBpmnSourcedEntries_AreIgnored()
+ {
+ var entry = DiagnosticEntry(BpmnDiagnosticEventNames.Faulted, elementId: "task") with { Source = "SomethingElse" };
+
+ var stats = BpmnElementStatsProjector.Project([entry]);
+
+ Assert.Empty(stats);
+ }
+
+ [Fact(DisplayName = "The scope's own terminal Completed diagnostic, which names neither an element nor a flow, contributes nothing")]
+ public void Project_ScopeCompletionDiagnostic_ContributesNothing()
+ {
+ var entries = new[] { DiagnosticEntry(BpmnDiagnosticEventNames.Completed) };
+
+ var stats = BpmnElementStatsProjector.Project(entries);
+
+ Assert.Empty(stats);
+ }
+
+ [Fact(DisplayName = "A payload that arrives as a JsonElement -- exactly how it comes over the wire -- is read the same as a payload constructed directly")]
+ public void Project_PayloadAsJsonElement_IsReadCorrectly()
+ {
+ var json = """{"diagnosticId":"diag:1","elementId":"join","flowId":null,"tokenId":null,"kind":"Waiting","details":{}}""";
+ var payload = JsonSerializer.Deserialize(json);
+ var entry = new WorkflowExecutionLogRecord(
+ Id: "log-1",
+ ActivityInstanceId: "instance-1",
+ ParentActivityInstanceId: null,
+ ActivityId: "scope",
+ ActivityType: "Elsa.BpmnProcess",
+ ActivityTypeVersion: 1,
+ ActivityName: null,
+ NodeId: "scope-node",
+ Timestamp: DateTimeOffset.UtcNow,
+ Sequence: 1,
+ EventName: BpmnDiagnosticEventNames.Waiting,
+ Message: null,
+ Source: BpmnDiagnosticEventNames.Source,
+ ActivityState: null,
+ Payload: payload);
+
+ var stats = BpmnElementStatsProjector.Project([entry]);
+
+ Assert.True(stats["join"].Blocked);
+ }
+
+ [Fact(DisplayName = "Fold mutates an already-populated map in place, combining new entries with what was already folded")]
+ public void Fold_AlreadyPopulatedMap_CombinesNewEntriesWithExistingOnes()
+ {
+ var stats = new Dictionary();
+ BpmnElementStatsProjector.Fold([DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "join")], stats);
+
+ BpmnElementStatsProjector.Fold([DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, elementId: "task")], stats);
+
+ Assert.Equal(2, stats.Count);
+ Assert.True(stats["join"].Blocked);
+ Assert.Equal(1, stats["task"].Started);
+ }
+
+ [Fact(DisplayName = "Folding the same element's records across two refreshes matches a single pass over all of them")]
+ public void Fold_SameElementAcrossTwoRefreshes_MatchesASinglePassOverAllRecords()
+ {
+ // The join's Waiting record (refresh 1's whole take) lands first and marks it blocked; its later Joined
+ // record (refresh 2's whole take) must still clear that blocked flag rather than the map getting stuck on
+ // whatever refresh 1 last saw.
+ var waiting = DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "join");
+ var joined = DiagnosticEntry(BpmnDiagnosticEventNames.Joined, elementId: "join");
+
+ var stats = new Dictionary();
+ BpmnElementStatsProjector.Fold([waiting], stats);
+ BpmnElementStatsProjector.Fold([joined], stats);
+
+ var expected = BpmnElementStatsProjector.Project([waiting, joined]);
+
+ Assert.Equal(expected.Keys.OrderBy(k => k), stats.Keys.OrderBy(k => k));
+ var expectedJoin = expected["join"];
+ var actualJoin = stats["join"];
+ Assert.Equal(expectedJoin.Started, actualJoin.Started);
+ Assert.Equal(expectedJoin.Completed, actualJoin.Completed);
+ Assert.Equal(expectedJoin.Active, actualJoin.Active);
+ Assert.Equal(expectedJoin.Blocked, actualJoin.Blocked);
+ Assert.Equal(expectedJoin.Faulted, actualJoin.Faulted);
+ Assert.Equal(expectedJoin.Canceled, actualJoin.Canceled);
+ Assert.False(actualJoin.Blocked);
+ Assert.Equal(1, actualJoin.Completed);
+ }
+
+ private static bool IsTaken(BpmnElementStats stats) => (stats.Started ?? 0) > 0 || (stats.Completed ?? 0) > 0;
+
+ private static WorkflowExecutionLogRecord DiagnosticEntry(
+ string kind,
+ string? elementId = null,
+ string? flowId = null,
+ string activityId = "scope") => new(
+ Id: Guid.NewGuid().ToString(),
+ ActivityInstanceId: "instance-1",
+ ParentActivityInstanceId: null,
+ ActivityId: activityId,
+ ActivityType: "Elsa.BpmnProcess",
+ ActivityTypeVersion: 1,
+ ActivityName: null,
+ NodeId: $"{activityId}-node",
+ Timestamp: DateTimeOffset.UtcNow,
+ Sequence: 1,
+ EventName: kind,
+ Message: null,
+ Source: BpmnDiagnosticEventNames.Source,
+ ActivityState: null,
+ Payload: new BpmnDiagnosticLogPayload(Guid.NewGuid().ToString(), elementId, flowId, null, kind, new Dictionary()));
+}
diff --git a/src/modules/Elsa.Studio.Workflows.Tests/DiagramDesignerWrapperElementStatsTests.cs b/src/modules/Elsa.Studio.Workflows.Tests/DiagramDesignerWrapperElementStatsTests.cs
new file mode 100644
index 000000000..16e84cfc4
--- /dev/null
+++ b/src/modules/Elsa.Studio.Workflows.Tests/DiagramDesignerWrapperElementStatsTests.cs
@@ -0,0 +1,345 @@
+using System.Reflection;
+using System.Text.Json.Nodes;
+using Elsa.Api.Client.Resources.WorkflowInstances.Models;
+using Elsa.Api.Client.Resources.WorkflowInstances.Requests;
+using Elsa.Api.Client.Shared.Models;
+using Elsa.Studio.Workflows.Domain.Contracts;
+using Elsa.Studio.Workflows.Domain.Models;
+using Elsa.Studio.Workflows.Domain.Models.Bpmn;
+using Elsa.Studio.Workflows.Extensions;
+using Elsa.Studio.Workflows.Shared.Components;
+using Elsa.Studio.Workflows.UI.Contexts;
+using Elsa.Studio.Workflows.UI.Contracts;
+using Microsoft.AspNetCore.Components;
+using Refit;
+using Xunit;
+
+namespace Elsa.Studio.Workflows.Tests;
+
+///
+/// Covers : the element-keyed BPMN overlay's own
+/// refresh channel, alongside the existing activity-keyed
+/// one. Exercises the wrapper directly (constructed with new, its injected properties and private fields set
+/// through reflection) rather than through bUnit rendering, since none of what is under test here depends on the
+/// render pipeline.
+///
+public class DiagramDesignerWrapperElementStatsTests
+{
+ [Fact(DisplayName = "Does nothing when there is no workflow instance to read from")]
+ public async Task RefreshElementStatsAsync_NoWorkflowInstanceId_DoesNotCallTheJournal()
+ {
+ var journal = new RecordingWorkflowInstanceService();
+ var wrapper = CreateWrapper(journal, new RecordingSinkDesigner(), workflowInstanceId: null);
+
+ await wrapper.RefreshElementStatsAsync();
+
+ Assert.Empty(journal.Calls);
+ }
+
+ [Fact(DisplayName = "Does nothing when the current designer does not accept an element-keyed overlay")]
+ public async Task RefreshElementStatsAsync_DesignerIsNotASink_DoesNotCallTheJournal()
+ {
+ var journal = new RecordingWorkflowInstanceService();
+ var wrapper = CreateWrapper(journal, new NonSinkDesigner(), workflowInstanceId: "instance-1");
+
+ await wrapper.RefreshElementStatsAsync();
+
+ Assert.Empty(journal.Calls);
+ }
+
+ [Fact(DisplayName = "Does nothing when the workflow carries no Elsa.BpmnProcess scope at all")]
+ public async Task RefreshElementStatsAsync_NoBpmnProcessInTheGraph_DoesNotCallTheJournal()
+ {
+ var journal = new RecordingWorkflowInstanceService();
+ var wrapper = CreateWrapper(journal, new RecordingSinkDesigner(), workflowInstanceId: "instance-1", includeBpmnProcess: false);
+
+ await wrapper.RefreshElementStatsAsync();
+
+ Assert.Empty(journal.Calls);
+ }
+
+ [Fact(DisplayName = "Filters the journal by every Elsa.BpmnProcess activity id in the whole graph, including a nested scope")]
+ public async Task RefreshElementStatsAsync_FiltersByEveryBpmnProcessActivityId()
+ {
+ var journal = new RecordingWorkflowInstanceService(
+ new PagedListResponse { Items = [] });
+ var wrapper = CreateWrapper(journal, new RecordingSinkDesigner(), workflowInstanceId: "instance-1");
+
+ await wrapper.RefreshElementStatsAsync();
+
+ var call = Assert.Single(journal.Calls);
+ Assert.Equal(new[] { "bpmn-outer", "bpmn-inner" }, call.Filter?.ActivityIds);
+ }
+
+ [Fact(DisplayName = "Folds the fetched journal and pushes the result to the current designer's sink")]
+ public async Task RefreshElementStatsAsync_FoldsTheJournal_AndPushesItToTheSink()
+ {
+ var entry = DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "join");
+ var journal = new RecordingWorkflowInstanceService(
+ new PagedListResponse { Items = [entry] });
+ var sink = new RecordingSinkDesigner();
+ var wrapper = CreateWrapper(journal, sink, workflowInstanceId: "instance-1");
+
+ await wrapper.RefreshElementStatsAsync();
+
+ Assert.Equal(1, sink.UpdateCallCount);
+ Assert.True(sink.ReceivedStats!["join"].Blocked);
+ }
+
+ [Fact(DisplayName = "Pages through the journal until a short page ends it, within a single refresh's page cap")]
+ public async Task RefreshElementStatsAsync_PagesThroughTheJournal()
+ {
+ var fullPage = Enumerable.Range(0, 200).Select(i => DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, flowId: $"flow-{i}")).ToArray();
+ var shortPage = new[] { DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "join") };
+ var journal = new RecordingWorkflowInstanceService(
+ new PagedListResponse { Items = fullPage },
+ new PagedListResponse { Items = shortPage });
+ var sink = new RecordingSinkDesigner();
+ var wrapper = CreateWrapper(journal, sink, workflowInstanceId: "instance-1");
+
+ await wrapper.RefreshElementStatsAsync();
+
+ Assert.Equal(2, journal.Calls.Count);
+ Assert.Equal(0, journal.Calls[0].Skip);
+ Assert.Equal(200, journal.Calls[1].Skip);
+ Assert.True(sink.ReceivedStats!["join"].Blocked);
+ Assert.Equal(201, sink.ReceivedStats!.Count);
+ }
+
+ [Fact(DisplayName = "A backlog bigger than one refresh's page cap is folded a cap's worth at a time, continued on the next refresh")]
+ public async Task RefreshElementStatsAsync_BacklogLargerThanPageCap_IsFoldedAcrossRefreshes()
+ {
+ // Three full pages of 200 records each, plus a short page that ends the journal -- 650 total -- against a
+ // page cap of 2, so no single refresh fetches more than 2 pages (400 records) at once.
+ var fullPages = Enumerable.Range(0, 3)
+ .Select(p => new PagedListResponse
+ {
+ Items = Enumerable.Range(0, 200)
+ .Select(i => DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, flowId: $"flow-{p}-{i}"))
+ .ToArray()
+ });
+ var shortPage = new PagedListResponse
+ {
+ Items = Enumerable.Range(0, 50).Select(i => DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, flowId: $"flow-tail-{i}")).ToArray()
+ };
+ var journal = new RecordingWorkflowInstanceService([.. fullPages, shortPage]);
+ var sink = new RecordingSinkDesigner();
+ var wrapper = CreateWrapper(journal, sink, workflowInstanceId: "instance-1");
+
+ wrapper.ElementStatsMaxPagesPerRefresh = 2;
+
+ await wrapper.RefreshElementStatsAsync();
+
+ Assert.Equal(2, journal.Calls.Count);
+ Assert.Equal(0, journal.Calls[0].Skip);
+ Assert.Equal(200, journal.Calls[1].Skip);
+ Assert.Equal(400, sink.ReceivedStats!.Count);
+
+ await wrapper.RefreshElementStatsAsync();
+
+ Assert.Equal(4, journal.Calls.Count);
+ Assert.Equal(400, journal.Calls[2].Skip);
+ Assert.Equal(600, journal.Calls[3].Skip);
+ Assert.Equal(650, sink.ReceivedStats!.Count);
+ }
+
+ [Fact(DisplayName = "A second refresh after new records arrive requests only the new records, and folds them alongside the old ones")]
+ public async Task RefreshElementStatsAsync_SecondRefreshWithNewRecords_RequestsOnlyTheNewRecordsAndFoldsBoth()
+ {
+ var firstEntry = DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "join");
+ var secondEntry = DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, elementId: "task");
+ var journal = new RecordingWorkflowInstanceService(
+ new PagedListResponse { Items = [firstEntry] },
+ new PagedListResponse { Items = [secondEntry] });
+ var sink = new RecordingSinkDesigner();
+ var wrapper = CreateWrapper(journal, sink, workflowInstanceId: "instance-1");
+
+ await wrapper.RefreshElementStatsAsync();
+ await wrapper.RefreshElementStatsAsync();
+
+ Assert.Equal(2, journal.Calls.Count);
+ Assert.Equal(0, journal.Calls[0].Skip);
+ Assert.Equal(1, journal.Calls[1].Skip);
+ Assert.True(sink.ReceivedStats!["join"].Blocked);
+ Assert.Equal(1, sink.ReceivedStats!["task"].Started);
+ }
+
+ [Fact(DisplayName = "A refresh after the displayed instance changes starts over, rather than carrying over the previous instance's high-water mark or stats")]
+ public async Task RefreshElementStatsAsync_InstanceChanges_StartsOver()
+ {
+ var firstInstanceEntry = DiagnosticEntry(BpmnDiagnosticEventNames.Waiting, elementId: "join");
+ var secondInstanceEntry = DiagnosticEntry(BpmnDiagnosticEventNames.TokenEmitted, elementId: "task");
+ var journal = new RecordingWorkflowInstanceService(
+ new PagedListResponse { Items = [firstInstanceEntry] },
+ new PagedListResponse { Items = [secondInstanceEntry] });
+ var sink = new RecordingSinkDesigner();
+ var wrapper = CreateWrapper(journal, sink, workflowInstanceId: "instance-1");
+
+ await wrapper.RefreshElementStatsAsync();
+
+ typeof(DiagramDesignerWrapper)
+ .GetProperty(nameof(DiagramDesignerWrapper.WorkflowInstanceId))!
+ .SetValue(wrapper, "instance-2");
+ await wrapper.RefreshElementStatsAsync();
+
+ Assert.Equal(2, journal.Calls.Count);
+ Assert.Equal(0, journal.Calls[0].Skip);
+ Assert.Equal(0, journal.Calls[1].Skip);
+ Assert.DoesNotContain("join", sink.ReceivedStats!.Keys);
+ Assert.Equal(1, sink.ReceivedStats!["task"].Started);
+ }
+
+ private static WorkflowExecutionLogRecord DiagnosticEntry(string kind, string? elementId = null, string? flowId = null) => new(
+ Id: Guid.NewGuid().ToString(),
+ ActivityInstanceId: "instance-1",
+ ParentActivityInstanceId: null,
+ ActivityId: "bpmn-outer",
+ ActivityType: BpmnProcessConstants.ActivityTypeName,
+ ActivityTypeVersion: 1,
+ ActivityName: null,
+ NodeId: "bpmn-outer-node",
+ Timestamp: DateTimeOffset.UtcNow,
+ Sequence: 1,
+ EventName: kind,
+ Message: null,
+ Source: BpmnDiagnosticEventNames.Source,
+ ActivityState: null,
+ Payload: new BpmnDiagnosticLogPayload(Guid.NewGuid().ToString(), elementId, flowId, null, kind, new Dictionary()));
+
+ ///
+ /// Constructs a bare with its private WorkflowInstanceService and
+ /// _activityGraph/_diagramDesigner fields set through reflection, mirroring the
+ /// SetDesigner-style helpers WorkflowInstanceDesignerDisconnectRefreshTests already uses for the
+ /// same reason: none of this depends on the component ever being rendered.
+ ///
+ private static DiagramDesignerWrapper CreateWrapper(
+ IWorkflowInstanceService journal,
+ IDiagramDesigner designer,
+ string? workflowInstanceId,
+ bool includeBpmnProcess = true)
+ {
+ var wrapper = new DiagramDesignerWrapper();
+
+ typeof(DiagramDesignerWrapper)
+ .GetProperty(nameof(DiagramDesignerWrapper.WorkflowInstanceId))!
+ .SetValue(wrapper, workflowInstanceId);
+
+ typeof(DiagramDesignerWrapper)
+ .GetProperty("WorkflowInstanceService", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .SetValue(wrapper, journal);
+
+ typeof(DiagramDesignerWrapper)
+ .GetField("_diagramDesigner", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .SetValue(wrapper, designer);
+
+ var graph = BuildActivityGraph(includeBpmnProcess);
+ typeof(DiagramDesignerWrapper)
+ .GetField("_activityGraph", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .SetValue(wrapper, graph);
+
+ return wrapper;
+ }
+
+ private static ActivityGraph BuildActivityGraph(bool includeBpmnProcess)
+ {
+ var root = Activity("root", "Elsa.Flowchart");
+ var rootNode = new ActivityNode(root);
+
+ if (includeBpmnProcess)
+ {
+ var outer = Activity("bpmn-outer", BpmnProcessConstants.ActivityTypeName);
+ var inner = Activity("bpmn-inner", BpmnProcessConstants.ActivityTypeName);
+ var outerNode = new ActivityNode(outer);
+ var innerNode = new ActivityNode(inner);
+
+ outerNode.Children.Add(innerNode);
+ innerNode.Parents.Add(outerNode);
+ rootNode.Children.Add(outerNode);
+ outerNode.Parents.Add(rootNode);
+ }
+
+ var graph = new ActivityGraph(root, new FixedActivityVisitor(rootNode));
+ graph.IndexAsync().GetAwaiter().GetResult();
+ return graph;
+ }
+
+ private static JsonObject Activity(string id, string typeName) => new()
+ {
+ ["id"] = id,
+ ["nodeId"] = $"{id}-node",
+ ["type"] = typeName
+ };
+
+ private sealed class FixedActivityVisitor(ActivityNode root) : IActivityVisitor
+ {
+ public Task VisitAsync(JsonObject activity, CancellationToken cancellationToken = default) => Task.FromResult(root);
+ }
+
+ /// A diagram designer that accepts the element-keyed overlay and records what it was given.
+ private sealed class RecordingSinkDesigner : IDiagramDesigner, IBpmnElementStatsSink
+ {
+ public IReadOnlyDictionary? ReceivedStats { get; private set; }
+ public int UpdateCallCount { get; private set; }
+
+ public Task UpdateElementStatsAsync(IReadOnlyDictionary elementStats)
+ {
+ ReceivedStats = elementStats;
+ UpdateCallCount++;
+ return Task.CompletedTask;
+ }
+
+ public Task LoadRootActivityAsync(JsonObject activity, IDictionary? activityStatsMap) => throw new NotSupportedException();
+ public Task UpdateActivityAsync(string id, JsonObject activity) => throw new NotSupportedException();
+ public Task UpdateActivityStatsAsync(string id, ActivityStats stats) => throw new NotSupportedException();
+ public Task SelectActivityAsync(string id) => throw new NotSupportedException();
+ public Task ReadRootActivityAsync() => throw new NotSupportedException();
+ public RenderFragment DisplayDesigner(DisplayContext context) => throw new NotSupportedException();
+ }
+
+ /// An ordinary diagram designer -- a flowchart's, say -- that does not accept an element-keyed overlay.
+ private sealed class NonSinkDesigner : IDiagramDesigner
+ {
+ public Task LoadRootActivityAsync(JsonObject activity, IDictionary? activityStatsMap) => throw new NotSupportedException();
+ public Task UpdateActivityAsync(string id, JsonObject activity) => throw new NotSupportedException();
+ public Task UpdateActivityStatsAsync(string id, ActivityStats stats) => throw new NotSupportedException();
+ public Task SelectActivityAsync(string id) => throw new NotSupportedException();
+ public Task ReadRootActivityAsync() => throw new NotSupportedException();
+ public RenderFragment DisplayDesigner(DisplayContext context) => throw new NotSupportedException();
+ }
+
+ private sealed class RecordingWorkflowInstanceService : IWorkflowInstanceService
+ {
+ private readonly Queue> _pages;
+
+ public RecordingWorkflowInstanceService(params PagedListResponse[] pages) => _pages = new(pages);
+
+ public List<(JournalFilter? Filter, int? Skip, int? Take)> Calls { get; } = [];
+
+ public Task> GetJournalAsync(
+ string instanceId,
+ JournalFilter? filter = null,
+ int? skip = null,
+ int? take = null,
+ CancellationToken cancellationToken = default)
+ {
+ Calls.Add((filter, skip, take));
+
+ var page = _pages.Count > 0
+ ? _pages.Dequeue()
+ : new PagedListResponse { Items = [] };
+
+ return Task.FromResult(page);
+ }
+
+ public Task> ListAsync(ListWorkflowInstancesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task DeleteAsync(string instanceId, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task BulkDeleteAsync(IEnumerable instanceIds, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task CancelAsync(string instanceId, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task BulkCancelAsync(BulkCancelWorkflowInstancesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task GetAsync(string id, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task ExportAsync(string id, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task BulkExportAsync(IEnumerable ids, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task BulkImportAsync(IEnumerable streamParts, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task> GetVariablesAsync(string instanceId, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ }
+}
diff --git a/src/modules/Elsa.Studio.Workflows.Tests/DiagramDesignerWrapperFinishedInstanceElementStatsTests.cs b/src/modules/Elsa.Studio.Workflows.Tests/DiagramDesignerWrapperFinishedInstanceElementStatsTests.cs
new file mode 100644
index 000000000..3be16ba7e
--- /dev/null
+++ b/src/modules/Elsa.Studio.Workflows.Tests/DiagramDesignerWrapperFinishedInstanceElementStatsTests.cs
@@ -0,0 +1,201 @@
+using System.Text.Json.Nodes;
+using Bunit;
+using Elsa.Api.Client.Extensions;
+using Elsa.Api.Client.Resources.ActivityDescriptors.Models;
+using Elsa.Api.Client.Resources.ActivityExecutions.Models;
+using Elsa.Api.Client.Resources.Resilience.Models;
+using Elsa.Api.Client.Resources.WorkflowDefinitions.Models;
+using Elsa.Api.Client.Resources.WorkflowInstances.Models;
+using Elsa.Api.Client.Resources.WorkflowInstances.Requests;
+using Elsa.Api.Client.Shared.Models;
+using Elsa.Studio.DomInterop.Contracts;
+using Elsa.Studio.DomInterop.Models;
+using Elsa.Studio.Extensions;
+using Elsa.Studio.Localization;
+using Elsa.Studio.Workflows.DiagramDesigners.Bpmn;
+using Elsa.Studio.Workflows.Designer.Models;
+using Elsa.Studio.Workflows.Domain.Contracts;
+using Elsa.Studio.Workflows.Domain.Models;
+using Elsa.Studio.Workflows.Domain.Models.Bpmn;
+using Elsa.Studio.Workflows.Extensions;
+using Elsa.Studio.Workflows.Shared.Components;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Localization;
+using MudBlazor;
+using MudBlazor.Services;
+using Refit;
+using Xunit;
+
+namespace Elsa.Studio.Workflows.Tests;
+
+///
+/// Covers the case the mount race hid completely: a finished workflow instance never gets a
+/// -driven
+/// observer tick (UpdateObserverAsync only creates one while the instance is running), so the one
+/// unconditional refresh already performs on load -- before it has ever
+/// rendered its -- is the only chance the overlay ever gets to reach the canvas.
+///
+public sealed class DiagramDesignerWrapperFinishedInstanceElementStatsTests : BunitContext, IAsyncLifetime
+{
+ private const string ElementId = "join";
+
+ public DiagramDesignerWrapperFinishedInstanceElementStatsTests()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+ JSInterop.Setup("loadBpmnDiagram", _ => true).SetResult([]);
+ Services.AddMudServices();
+ Services.AddLogging();
+ Services.AddCoreInternal();
+ Services.AddRemoteBackend();
+ Services.AddWorkflowsModule();
+ Services.AddSingleton();
+ Services.AddSingleton();
+ Services.AddSingleton();
+ Services.AddSingleton(new StubActivityExecutionService());
+ Services.AddSingleton(new StubWorkflowInstanceService(JournalEntry()));
+ }
+
+ Task IAsyncLifetime.InitializeAsync() => Task.CompletedTask;
+ async Task IAsyncLifetime.DisposeAsync() => await base.DisposeAsync();
+
+ [Fact]
+ public void OpeningAFinishedInstance_RefreshesTheElementOverlayExactlyOnce_WithNoObserverTicksAtAll()
+ {
+ var handler = JSInterop.SetupVoid("updateBpmnElementStats", _ => true);
+ var root = CreateBpmnRoot();
+
+ Render();
+ Render(parameters => parameters
+ .Add(x => x.WorkflowDefinitionVersionId, "version-1")
+ .Add(x => x.Activity, root)
+ .Add(x => x.WorkflowDefinition, CreateDefinition(root))
+ .Add(x => x.WorkflowInstanceId, "instance-1"));
+
+ var invocation = Assert.Single(handler.Invocations);
+ var appliedStats = Assert.IsType>(invocation.Arguments[1]);
+ Assert.True(appliedStats[ElementId].Blocked);
+ }
+
+ private static WorkflowExecutionLogRecord JournalEntry() => new(
+ Id: Guid.NewGuid().ToString(),
+ ActivityInstanceId: "instance-1",
+ ParentActivityInstanceId: null,
+ ActivityId: "order-process",
+ ActivityType: BpmnProcessConstants.ActivityTypeName,
+ ActivityTypeVersion: 1,
+ ActivityName: null,
+ NodeId: "Workflow1:order-process",
+ Timestamp: DateTimeOffset.UtcNow,
+ Sequence: 1,
+ EventName: BpmnDiagnosticEventNames.Waiting,
+ Message: null,
+ Source: BpmnDiagnosticEventNames.Source,
+ ActivityState: null,
+ Payload: new BpmnDiagnosticLogPayload(Guid.NewGuid().ToString(), ElementId, null, null, BpmnDiagnosticEventNames.Waiting, new Dictionary()));
+
+ private static JsonObject CreateBpmnRoot() => new()
+ {
+ ["id"] = "order-process",
+ ["nodeId"] = "Workflow1:order-process",
+ ["name"] = "Order Process",
+ ["type"] = BpmnProcessConstants.ActivityTypeName,
+ ["version"] = 1,
+ ["customProperties"] = new JsonObject { ["canStartWorkflow"] = true },
+ ["process"] = new JsonObject
+ {
+ ["processId"] = "order-process",
+ ["name"] = "Order Process",
+ ["isExecutable"] = true,
+ ["elements"] = new JsonArray
+ {
+ new JsonObject { ["elementId"] = "StartEvent_1", ["elementType"] = "startEvent" },
+ new JsonObject { ["elementId"] = ElementId, ["elementType"] = "parallelGateway" },
+ new JsonObject { ["elementId"] = "EndEvent_1", ["elementType"] = "endEvent" }
+ },
+ ["sequenceFlows"] = new JsonArray()
+ },
+ ["workBindings"] = new JsonObject(),
+ ["activities"] = new JsonArray()
+ };
+
+ private static WorkflowDefinition CreateDefinition(JsonObject root) => new()
+ {
+ Id = "version-1",
+ DefinitionId = "definition-1",
+ Name = "Order Process",
+ Root = root
+ };
+
+ private sealed class StubActivityExecutionService : IActivityExecutionService
+ {
+ public Task GetReportAsync(string workflowInstanceId, JsonObject containerActivity, CancellationToken cancellationToken = default) =>
+ Task.FromResult(new ActivityExecutionReport([]));
+
+ public Task> ListAsync(string workflowInstanceId, string activityNodeId, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task> ListSummariesAsync(string workflowInstanceId, string activityNodeId, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task GetAsync(string id, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task GetCallStackAsync(string activityExecutionId, bool? includeCrossWorkflowChain = null, int? skip = null, int? take = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task> GetRetriesAsync(string activityInstanceId, int? skip = null, int? take = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ }
+
+ private sealed class StubWorkflowInstanceService(params WorkflowExecutionLogRecord[] entries) : IWorkflowInstanceService
+ {
+ public Task> GetJournalAsync(
+ string instanceId,
+ JournalFilter? filter = null,
+ int? skip = null,
+ int? take = null,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(new PagedListResponse { Items = skip is null or 0 ? entries : [] });
+
+ public Task> ListAsync(ListWorkflowInstancesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task DeleteAsync(string instanceId, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task BulkDeleteAsync(IEnumerable instanceIds, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task CancelAsync(string instanceId, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task BulkCancelAsync(BulkCancelWorkflowInstancesRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task GetAsync(string id, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task ExportAsync(string id, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task BulkExportAsync(IEnumerable ids, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task BulkImportAsync(IEnumerable streamParts, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task> GetVariablesAsync(string instanceId, CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ }
+
+ private sealed class TestActivityRegistry : IActivityRegistry
+ {
+ private readonly IReadOnlyDictionary _descriptors = new Dictionary
+ {
+ [BpmnProcessConstants.ActivityTypeName] = new()
+ {
+ TypeName = BpmnProcessConstants.ActivityTypeName,
+ Name = BpmnProcessConstants.ActivityTypeName,
+ DisplayName = "BPMN Process",
+ Version = 1,
+ IsBrowsable = true,
+ IsContainer = true
+ }
+ };
+
+ public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+ public Task EnsureLoadedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+ public IEnumerable List() => _descriptors.Values;
+ public ActivityDescriptor? Find(string activityType, int? version = null) => _descriptors.GetValueOrDefault(activityType);
+ public IEnumerable FindAll(string activityType) => _descriptors.TryGetValue(activityType, out var descriptor) ? [descriptor] : [];
+
+ public void MarkStale()
+ {
+ }
+ }
+
+ private sealed class NoOpDomAccessor : IDomAccessor
+ {
+ public Task GetBoundingClientRectAsync(ElementRef elementRef, CancellationToken cancellationToken = default) => Task.FromResult(new DomRect());
+ public Task GetVisibleHeightAsync(ElementRef elementRef, CancellationToken cancellationToken = default) => Task.FromResult(0d);
+ public Task ClickElementAsync(ElementRef elementRef, CancellationToken cancellationToken = default) => Task.CompletedTask;
+ }
+
+ private sealed class TestLocalizer : ILocalizer
+ {
+ public LocalizedString this[string? key] => new(key ?? string.Empty, key ?? string.Empty);
+ public LocalizedString this[string? key, params object[] arguments] => new(key ?? string.Empty, string.Format(key ?? string.Empty, arguments));
+ }
+}
diff --git a/src/modules/Elsa.Studio.Workflows.Tests/WorkflowInstanceDesignerElementStatsRefreshTests.cs b/src/modules/Elsa.Studio.Workflows.Tests/WorkflowInstanceDesignerElementStatsRefreshTests.cs
new file mode 100644
index 000000000..1b9ae6723
--- /dev/null
+++ b/src/modules/Elsa.Studio.Workflows.Tests/WorkflowInstanceDesignerElementStatsRefreshTests.cs
@@ -0,0 +1,198 @@
+using System.Reflection;
+using System.Text.Json.Nodes;
+using Bunit;
+using Elsa.Api.Client.RealTime.Messages;
+using Elsa.Api.Client.Resources.ActivityExecutions.Models;
+using Elsa.Api.Client.Resources.WorkflowInstances.Enums;
+using Elsa.Api.Client.Resources.WorkflowInstances.Models;
+using Elsa.Studio.Contracts;
+using Elsa.Studio.DomInterop.Contracts;
+using Elsa.Studio.Localization;
+using Elsa.Studio.Workflows.Components.WorkflowInstanceViewer.Components;
+using Elsa.Studio.Workflows.Contracts;
+using Elsa.Studio.Workflows.Domain.Contracts;
+using Elsa.Studio.Workflows.Domain.Models;
+using Elsa.Studio.Workflows.Shared.Components;
+using Elsa.Studio.Workflows.UI.Contexts;
+using Elsa.Studio.Workflows.UI.Contracts;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Rendering;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Localization;
+using Microsoft.JSInterop;
+using Xunit;
+
+namespace Elsa.Studio.Workflows.Tests;
+
+///
+/// Covers that refreshes the element-keyed BPMN overlay
+/// () on the very same event that already refreshes
+/// the activity-keyed one -- -- so a gateway or
+/// a sequence flow, which never appears in that message's own Stats, is refreshed on the same cadence.
+///
+///
+/// The refresh added here rides the very same event subscription WorkflowInstanceDesignerDisconnectRefreshTests
+/// already proves is detached on disposal (the #992 guarantee): it is not a new timer with a disposal race of its
+/// own to pin, so this file's own coverage is limited to proving the new call is actually wired into that handler.
+///
+public sealed class WorkflowInstanceDesignerElementStatsRefreshTests : BunitContext, IAsyncLifetime
+{
+ public WorkflowInstanceDesignerElementStatsRefreshTests()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+ Services.AddSingleton(new TestLocalizer());
+ Services.AddSingleton(new ActivityRegistryStub());
+ Services.AddSingleton(DispatchProxy.Create());
+ Services.AddSingleton(DispatchProxy.Create());
+ Services.AddSingleton(DispatchProxy.Create());
+ Services.AddSingleton(DispatchProxy.Create());
+ Services.AddSingleton(DispatchProxy.Create());
+ Services.AddSingleton(DispatchProxy.Create());
+ Services.AddSingleton(DispatchProxy.Create());
+ Services.AddSingleton(new RemoteFeatureProviderStub());
+ }
+
+ Task IAsyncLifetime.InitializeAsync() => Task.CompletedTask;
+ async Task IAsyncLifetime.DisposeAsync() => await base.DisposeAsync();
+
+ [Fact]
+ public async Task OnActivityExecutionLogUpdated_RefreshesTheElementKeyedOverlay()
+ {
+ var cut = RenderDesigner();
+ var designer = new RecordingDiagramDesignerWrapper();
+ SetDesigner(cut.Instance, designer);
+
+ await InvokeOnActivityExecutionLogUpdated(cut.Instance, new ActivityExecutionLogUpdatedMessage([]));
+
+ Assert.Equal(1, designer.RefreshElementStatsCallCount);
+ }
+
+ [Fact]
+ public async Task OnActivityExecutionLogUpdated_RefreshesTheElementKeyedOverlay_AlongsideActivityStats()
+ {
+ // Both the pre-existing activity-keyed stats and the new element-keyed overlay are refreshed from the
+ // one message: the point of D1 is that a gateway or a flow, which never appears in message.Stats, is
+ // still covered by something driven from the very same journal update.
+ var stats = new ActivityExecutionStats { ActivityId = "activity-1", ActivityNodeId = "node-1" };
+ var cut = RenderDesigner();
+ var designer = new RecordingDiagramDesignerWrapper();
+ var innerDesigner = new RecordingActivityStatsDesigner();
+ SetDesigner(cut.Instance, designer);
+ SetInnerDiagramDesigner(designer, innerDesigner);
+
+ await InvokeOnActivityExecutionLogUpdated(cut.Instance, new ActivityExecutionLogUpdatedMessage([stats]));
+
+ Assert.Equal(1, innerDesigner.UpdateActivityStatsCallCount);
+ Assert.Equal(1, designer.RefreshElementStatsCallCount);
+ }
+
+ private IRenderedComponent RenderDesigner()
+ {
+ var workflowInstance = new WorkflowInstance
+ {
+ Id = "instance-1",
+ DefinitionId = "definition-1",
+ Status = WorkflowStatus.Finished
+ };
+
+ return Render(parameters => parameters
+ .Add(x => x.WorkflowInstance, workflowInstance));
+ }
+
+ ///
+ /// A that skips its own markup and first-render setup, exactly like
+ /// WorkflowInstanceDesignerDisconnectRefreshTests.TestWorkflowInstanceDesigner: nothing under test here
+ /// depends on the real render tree, and rendering it for real would pull in dependencies (Radzen's splitter,
+ /// MudBlazor's tabs) this test has no reason to stub.
+ ///
+ private sealed class TestWorkflowInstanceDesigner : WorkflowInstanceDesigner
+ {
+ protected override Task OnAfterRenderAsync(bool firstRender) => Task.CompletedTask;
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ }
+ }
+
+ private static Task InvokeOnActivityExecutionLogUpdated(WorkflowInstanceDesigner instance, ActivityExecutionLogUpdatedMessage message)
+ {
+ var method = typeof(WorkflowInstanceDesigner).GetMethod("OnActivityExecutionLogUpdated", BindingFlags.Instance | BindingFlags.NonPublic)!;
+ return (Task)method.Invoke(instance, [message])!;
+ }
+
+ ///
+ /// Attaches a bare subclass to _designer, mirroring
+ /// WorkflowInstanceDesignerDisconnectRefreshTests.SetDesigner.
+ ///
+ private static void SetDesigner(WorkflowInstanceDesigner instance, DiagramDesignerWrapper designer)
+ {
+ var field = typeof(WorkflowInstanceDesigner).GetField("_designer", BindingFlags.Instance | BindingFlags.NonPublic)!;
+ field.SetValue(instance, designer);
+ }
+
+ /// Attaches a fake to a wrapper's own private _diagramDesigner field.
+ private static void SetInnerDiagramDesigner(DiagramDesignerWrapper wrapper, IDiagramDesigner innerDesigner)
+ {
+ var field = typeof(DiagramDesignerWrapper).GetField("_diagramDesigner", BindingFlags.Instance | BindingFlags.NonPublic)!;
+ field.SetValue(wrapper, innerDesigner);
+ }
+
+ /// A whose element-stats refresh is counted instead of run for real.
+ private sealed class RecordingDiagramDesignerWrapper : DiagramDesignerWrapper
+ {
+ public int RefreshElementStatsCallCount { get; private set; }
+
+ internal override Task RefreshElementStatsAsync()
+ {
+ RefreshElementStatsCallCount++;
+ return Task.CompletedTask;
+ }
+ }
+
+ /// The fake a forwards to; records
+ /// activity-stats updates so the test can prove both overlays are refreshed from the one message.
+ private sealed class RecordingActivityStatsDesigner : IDiagramDesigner
+ {
+ public int UpdateActivityStatsCallCount { get; private set; }
+
+ public Task UpdateActivityStatsAsync(string id, ActivityStats stats)
+ {
+ UpdateActivityStatsCallCount++;
+ return Task.CompletedTask;
+ }
+
+ public Task LoadRootActivityAsync(JsonObject activity, IDictionary? activityStatsMap) => throw new NotSupportedException();
+ public Task UpdateActivityAsync(string id, JsonObject activity) => throw new NotSupportedException();
+ public Task SelectActivityAsync(string id) => throw new NotSupportedException();
+ public Task ReadRootActivityAsync() => throw new NotSupportedException();
+ public RenderFragment DisplayDesigner(DisplayContext context) => throw new NotSupportedException();
+ }
+
+ private sealed class RemoteFeatureProviderStub : IRemoteFeatureProvider
+ {
+ public Task IsEnabledAsync(string featureName, CancellationToken cancellationToken = default) => Task.FromResult(false);
+ public Task> ListAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ }
+
+ private sealed class ActivityRegistryStub : IActivityRegistry
+ {
+ public Task RefreshAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException();
+ public Task EnsureLoadedAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
+ public IEnumerable List() => throw new NotSupportedException();
+ public Elsa.Api.Client.Resources.ActivityDescriptors.Models.ActivityDescriptor? Find(string activityType, int? version = null) => throw new NotSupportedException();
+ public IEnumerable FindAll(string activityType) => throw new NotSupportedException();
+ public void MarkStale() => throw new NotSupportedException();
+ }
+
+ private sealed class TestLocalizer : ILocalizer
+ {
+ public LocalizedString this[string? key] => new(key ?? string.Empty, key ?? string.Empty);
+ public LocalizedString this[string? key, params object[] arguments] => new(key ?? string.Empty, string.Format(key ?? string.Empty, arguments));
+ }
+
+ /// A that throws for every call, for services this test never exercises.
+ private class ThrowingProxy : DispatchProxy
+ {
+ protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) =>
+ throw new InvalidOperationException($"Unexpected call to {targetMethod!.DeclaringType!.Name}.{targetMethod.Name}.");
+ }
+}
diff --git a/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/WorkflowInstanceDesigner.razor.cs b/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/WorkflowInstanceDesigner.razor.cs
index 885f04101..b6c97f8f8 100644
--- a/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/WorkflowInstanceDesigner.razor.cs
+++ b/src/modules/Elsa.Studio.Workflows/Components/WorkflowInstanceViewer/Components/WorkflowInstanceDesigner.razor.cs
@@ -290,6 +290,11 @@ private async Task OnActivityExecutionLogUpdated(ActivityExecutionLogUpdatedMess
await _designer.UpdateActivityStatsAsync(activityId, Map(stats));
}
+ // Refreshed on the same cadence as the activity-keyed stats above: a gateway, an intermediate event or a
+ // sequence flow has no activity id of its own, so it never appears in message.Stats, but the journal update
+ // that produced this message is exactly what its own BPMN diagnostics ride along on.
+ await _designer.RefreshElementStatsAsync();
+
await InvokeAsync(StateHasChanged);
// If we received an update for the selected activity, refresh the activity details.
diff --git a/src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDesignerWrapper.razor.cs b/src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDesignerWrapper.razor.cs
index e53a08812..2bc434220 100644
--- a/src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDesignerWrapper.razor.cs
+++ b/src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDesignerWrapper.razor.cs
@@ -2,6 +2,7 @@
using Elsa.Studio.Workflows.Designer.Components;
using Elsa.Studio.Workflows.Designer.Options;
using Elsa.Studio.Workflows.Domain.Models;
+using Elsa.Studio.Workflows.Domain.Models.Bpmn;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Options;
@@ -46,6 +47,14 @@ public partial class BpmnDesignerWrapper
private BpmnDesigner? Designer { get; set; }
private bool UseReactFlow => DesignerOptions.Value.UseReactFlow;
+ ///
+ /// The latest element-keyed instance overlay handed to , retained so it
+ /// can be applied on as soon as it exists (see ). Mirrors
+ /// BpmnDiagramDesigner._pendingElementStats one level up, since can still be null
+ /// when a call arrives just after this wrapper itself has mounted.
+ ///
+ private IReadOnlyDictionary? _pendingElementStats;
+
///
/// Whether the scope being displayed has nothing to draw: no process payload at all, or one that declares
/// no elements. That is what a BpmnProcess added from the toolbox looks like, and what a scope whose
@@ -94,6 +103,39 @@ public async Task UpdateActivityStatsAsync(string id, ActivityStats stats)
await Designer.UpdateActivityStatsAsync(id, stats);
}
+ ///
+ /// Updates the element-keyed instance overlay (gateways, events and sequence flows).
+ ///
+ public async Task UpdateElementStatsAsync(IReadOnlyDictionary elementStats)
+ {
+ _pendingElementStats = elementStats;
+
+ if (Designer != null)
+ await Designer.UpdateElementStatsAsync(elementStats);
+ }
+
+ ///
+ /// Synchronously stakes the latest element-stats overlay for to flush once
+ /// exists, without going through 's async call.
+ ///
+ ///
+ /// This is what lets hand a retained overlay to this wrapper the moment it is
+ /// captured -- from inside a synchronous component-reference-capture callback, where starting and discarding an
+ /// async call would swallow any exception it threw.
+ ///
+ internal void SetPendingElementStats(IReadOnlyDictionary elementStats) => _pendingElementStats = elementStats;
+
+ ///
+ /// Applies a retained element-stats overlay that arrived before existed, the moment it
+ /// does -- the same "drain pending work on first render" shape itself uses for its own
+ /// initial LoadBpmnAsync call.
+ ///
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender && Designer != null && _pendingElementStats != null)
+ await Designer.UpdateElementStatsAsync(_pendingElementStats);
+ }
+
///
/// Selects the element bound to the specified activity.
///
diff --git a/src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDiagramDesigner.cs b/src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDiagramDesigner.cs
index dd162e07c..e83a6dc64 100644
--- a/src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDiagramDesigner.cs
+++ b/src/modules/Elsa.Studio.Workflows/DiagramDesigners/Bpmn/BpmnDiagramDesigner.cs
@@ -28,7 +28,7 @@ public class BpmnDiagramDesigner(
IDialogService dialogService,
IBpmnInterchangeService bpmnInterchangeService,
IFiles files,
- IUserMessageService userMessageService) : IDiagramDesignerToolboxProvider
+ IUserMessageService userMessageService) : IDiagramDesignerToolboxProvider, IBpmnElementStatsSink
{
///
/// The custom property key elsa-core stores the imported BPMN document's source XML under.
@@ -41,6 +41,21 @@ public class BpmnDiagramDesigner(
private string? _sourceXml;
private WorkflowDefinition? _workflowDefinition;
+ ///
+ /// The latest element-keyed instance overlay handed to , held only until
+ /// is captured -- from that point on, is the
+ /// one that retains and flushes it (see ).
+ ///
+ ///
+ /// A refresh can arrive before the canvas exists -- most notably the one unconditional refresh a freshly
+ /// opened instance gets on load (see DiagramDesignerWrapper.LoadActivityCoreAsync), which runs during
+ /// OnInitializedAsync, well before this designer's own has been
+ /// created. Dropping that refresh instead of retaining it would mean a finished instance -- one that never
+ /// gets a later observer-driven refresh to fall back on -- never gets its overlay at all. Only the latest
+ /// value is kept, matching the semantics already documents.
+ ///
+ private IReadOnlyDictionary? _pendingElementStats;
+
///
public async Task LoadRootActivityAsync(JsonObject activity, IDictionary? activityStatsMap)
{
@@ -78,6 +93,13 @@ public async Task SelectActivityAsync(string id)
await InvokeDesignerActionAsync(x => x.SelectActivityAsync(id));
}
+ ///
+ public async Task UpdateElementStatsAsync(IReadOnlyDictionary elementStats)
+ {
+ _pendingElementStats = elementStats;
+ await InvokeDesignerActionAsync(x => x.UpdateElementStatsAsync(elementStats));
+ }
+
///
///
/// Returns the whole root activity JSON exactly as it was given and since edited by
@@ -106,7 +128,20 @@ public RenderFragment DisplayDesigner(DisplayContext context)
builder.AddAttribute(sequence++, nameof(BpmnDesignerWrapper.ActivityStats), context.ActivityStats);
builder.AddAttribute(sequence++, nameof(BpmnDesignerWrapper.ActivitySelected), context.ActivitySelectedCallback);
builder.AddAttribute(sequence++, nameof(BpmnDesignerWrapper.ActivityDoubleClick), context.ActivityDoubleClickCallback);
- builder.AddComponentReferenceCapture(sequence++, @ref => _designerWrapper = (BpmnDesignerWrapper)@ref);
+ builder.AddComponentReferenceCapture(sequence++, @ref =>
+ {
+ var isFirstMount = _designerWrapper == null;
+ _designerWrapper = (BpmnDesignerWrapper)@ref;
+
+ // Hand off the latest retained overlay the moment the wrapper exists, rather than only on the
+ // next explicit UpdateElementStatsAsync call, which -- for a finished instance -- may never come
+ // (see the remarks on _pendingElementStats). This is a synchronous field assignment, not an async
+ // call: this callback is itself synchronous, so starting and discarding a task here would swallow
+ // any exception it threw. BpmnDesignerWrapper's own awaited first-render flush is what actually
+ // delivers the value once its canvas is ready.
+ if (isFirstMount && _pendingElementStats != null)
+ _designerWrapper.SetPendingElementStats(_pendingElementStats);
+ });
builder.CloseComponent();
};
diff --git a/src/modules/Elsa.Studio.Workflows/Shared/Components/DiagramDesignerWrapper.razor.cs b/src/modules/Elsa.Studio.Workflows/Shared/Components/DiagramDesignerWrapper.razor.cs
index ec150fa5b..863387675 100644
--- a/src/modules/Elsa.Studio.Workflows/Shared/Components/DiagramDesignerWrapper.razor.cs
+++ b/src/modules/Elsa.Studio.Workflows/Shared/Components/DiagramDesignerWrapper.razor.cs
@@ -1,12 +1,15 @@
using System.Text.Json.Nodes;
using Elsa.Api.Client.Extensions;
using Elsa.Api.Client.Resources.WorkflowDefinitions.Models;
+using Elsa.Api.Client.Resources.WorkflowInstances.Models;
+using Elsa.Api.Client.Resources.WorkflowInstances.Requests;
using Elsa.Api.Client.Shared.Models;
using Elsa.Studio.Workflows.Domain.Contexts;
using Elsa.Studio.Workflows.Domain.Contracts;
using Elsa.Studio.Workflows.Domain.Extensions;
using Elsa.Studio.Workflows.DiagramDesigners;
using Elsa.Studio.Workflows.Domain.Models;
+using Elsa.Studio.Workflows.Domain.Models.Bpmn;
using Elsa.Studio.Workflows.Extensions;
using Elsa.Studio.Workflows.Models;
using Elsa.Studio.Workflows.Shared.Args;
@@ -33,6 +36,21 @@ public partial class DiagramDesignerWrapper
private List _breadcrumbItems = new();
private IDictionary _activityStats =
new Dictionary();
+ private readonly Dictionary _elementStats = new();
+ private string? _elementStatsInstanceId;
+ private HashSet _elementStatsBpmnProcessActivityIds = new();
+ private int _elementStatsHighWaterMark;
+
+ ///
+ /// The most journal pages a single tick will fetch, at 200 records a
+ /// page (see pageSize in ): 50 pages bounds one tick to 10,000
+ /// records. A backlog larger than that -- a first load of a long-running instance, or a burst built up while
+ /// the tab was backgrounded -- is not truncated, only spread across refreshes: the high-water mark advances by
+ /// whatever was actually fetched, so the next tick picks up exactly where this one left off. Settable (rather
+ /// than a plain const) only so a test can lower it to exercise the cap without paging 10,000 records.
+ ///
+ internal int ElementStatsMaxPagesPerRefresh { get; set; } = 50;
+
private ActivityGraph _activityGraph = null!;
private IDictionary _indexedActivityNodes =
new Dictionary();
@@ -116,6 +134,9 @@ public partial class DiagramDesignerWrapper
[Inject]
private IWorkflowDefinitionService WorkflowDefinitionService { get; set; } = null!;
+ [Inject]
+ private IWorkflowInstanceService WorkflowInstanceService { get; set; } = null!;
+
[Inject]
private ISnackbar Snackbar { get; set; } = null!;
@@ -207,6 +228,92 @@ public async Task UpdateActivityStatsAsync(string activityId, ActivityStats stat
await _diagramDesigner!.UpdateActivityStatsAsync(activityId, stats);
}
+ ///
+ /// Refreshes the element-keyed BPMN instance overlay (gateways, events and sequence flows -- anything without
+ /// an Elsa activity id) from the workflow instance's journal, and pushes it to the current diagram designer.
+ ///
+ ///
+ /// A no-op when there is no workflow instance to read from, or the current designer does not accept an
+ /// element-keyed overlay () -- fetching and folding the journal for a
+ /// flowchart or state machine instance would be wasted work. Called on the same cadence
+ /// already refreshes
+ /// on, so the two overlays stay in step.
+ ///
+ internal virtual async Task RefreshElementStatsAsync()
+ {
+ if (WorkflowInstanceId == null || _diagramDesigner is not IBpmnElementStatsSink sink)
+ return;
+
+ var elementStats = await FetchElementStatsAsync(WorkflowInstanceId);
+ await sink.UpdateElementStatsAsync(elementStats);
+ }
+
+ ///
+ /// Reads the BPMN diagnostics projected onto the journal of every Elsa.BpmnProcess scope anywhere in
+ /// the workflow (not merely the currently displayed container: a nested scope's diagnostics land on that
+ /// scope's own activity, and BPMN element and flow ids are unique across the whole document), and folds them
+ /// into an element-keyed stats map.
+ ///
+ ///
+ /// Incremental: is the number of matching journal records already
+ /// folded into , so a tick only ever fetches the records that arrived since the
+ /// previous one, rather than re-fetching and re-folding the whole journal from the start every time. This is
+ /// only safe because the journal is append-only in the order the API returns it (see the Sequence on
+ /// ): the high-water mark is a plain count of already-folded records,
+ /// not an id or timestamp, because has no way to filter by either. The map and
+ /// mark are reset whenever the displayed instance or the set of Elsa.BpmnProcess scope activity ids
+ /// changes, since a high-water mark from a different instance or a different filter has nothing to do with
+ /// the one about to be fetched. A single tick fetches at most
+ /// pages, so a backlog larger than that -- a first load, or a burst built up while the tab was backgrounded --
+ /// is folded a page cap's worth at a time across successive refreshes rather than in one unbounded loop.
+ ///
+ private async Task> FetchElementStatsAsync(string workflowInstanceId)
+ {
+ var bpmnProcessActivityIds = GetBpmnProcessActivityIds();
+
+ if (bpmnProcessActivityIds.Count == 0)
+ return _elementStats;
+
+ if (workflowInstanceId != _elementStatsInstanceId
+ || !_elementStatsBpmnProcessActivityIds.SetEquals(bpmnProcessActivityIds))
+ {
+ _elementStats.Clear();
+ _elementStatsHighWaterMark = 0;
+ _elementStatsInstanceId = workflowInstanceId;
+ _elementStatsBpmnProcessActivityIds = new HashSet(bpmnProcessActivityIds);
+ }
+
+ var filter = new JournalFilter { ActivityIds = bpmnProcessActivityIds };
+ var entries = new List();
+ const int pageSize = 200;
+ var skip = _elementStatsHighWaterMark;
+
+ for (var page = 0; page < ElementStatsMaxPagesPerRefresh; page++)
+ {
+ var response = await WorkflowInstanceService.GetJournalAsync(workflowInstanceId, filter, skip, pageSize);
+ entries.AddRange(response.Items);
+ skip += response.Items.Count;
+
+ if (response.Items.Count < pageSize)
+ break;
+ }
+
+ _elementStatsHighWaterMark = skip;
+ BpmnElementStatsProjector.Fold(entries, _elementStats);
+
+ return _elementStats;
+ }
+
+ ///
+ /// Every Elsa.BpmnProcess activity's own id, anywhere in the whole workflow -- the outermost scope and
+ /// every nested one -- since BpmnScopeHost only ever writes a diagnostic onto the scope's own activity.
+ ///
+ private ICollection GetBpmnProcessActivityIds() =>
+ _activityGraph.ActivityNodeLookup.Values
+ .Where(node => node.Activity.GetTypeName() == BpmnProcessConstants.ActivityTypeName)
+ .Select(node => node.Activity.GetId())
+ .ToList();
+
/// Reads the activity from the designer.
public async Task ReadActivityAsync()
{
@@ -457,6 +564,8 @@ private async Task RefreshActivityStatsAsync()
Uncompleted = x.UncompletedCount,
Metadata = x.Metadata
});
+
+ await RefreshElementStatsAsync();
}
}