View suspended workflow in studio fix (#6646)

* Refactor ActivityRegistry to populate all activities in workflow editor page

- Modify ListAll to return distinct activity descriptors.
- Update RegisterAsync to call Add with correct parameters.
- Refactor RefreshDescriptorsAsync for better collection usage.
- Split Add method into two overloads for clarity.
- Improve logging for replacing existing activity descriptors.

* Add FuncExpressionValueConverter for JSON serialization

Implemented FuncExpressionValueConverter to handle serialization
and deserialization of Func<ExpressionExecutionContext,
ValueTask<object>> types, ensuring delegates are not serialized
and cannot be rehydrated from JSON. Updated multiple serializers
including ApiSerializer, BookmarkPayloadSerializer,
JsonActivitySerializer, JsonPayloadSerializer,
JsonWorkflowStateSerializer, and SafeSerializer to utilize
the new converter in their JSON serialization options.

* fix broken studio when when viewing suspended workflows

* fix for tests

---------

Co-authored-by: Max Brooks <Max@compyl.com>
Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>
This commit is contained in:
Max Brooks 2025-05-20 13:47:15 -04:00 committed by GitHub
parent de8c692f48
commit 9cbf75115f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 231 additions and 76 deletions

View file

@ -1,4 +1,4 @@
using System.Text.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Elsa.Workflows.Activities.Flowchart.Models;
@ -26,26 +26,63 @@ public class ConnectionJsonConverter : JsonConverter<Connection>
if (!JsonDocument.TryParseValue(ref reader, out var doc))
throw new JsonException("Failed to parse JsonDocument");
var sourceElement = doc.RootElement.GetProperty("source");
var targetElement = doc.RootElement.GetProperty("target");
var sourceId = sourceElement.GetProperty("activity").GetString()!;
var targetId = targetElement.TryGetProperty("activity", out var targetIdValue) ? targetIdValue.GetString() : null;
var sourcePort = sourceElement.TryGetProperty("port", out var sourcePortValue) ? sourcePortValue.GetString() : null;
var targetPort = targetElement.TryGetProperty("port", out var targetPortValue) ? targetPortValue.GetString() : null;
var sourceActivity = _activities.TryGetValue(sourceId, out var s) ? s : null!;
var targetActivity = targetId != null ? _activities.TryGetValue(targetId, out var t) ? t : null! : null!;
var source = new Endpoint(sourceActivity, sourcePort);
var target = new Endpoint(targetActivity, targetPort);
var verticesElement = doc.RootElement.TryGetProperty("vertices", out var verticesValue) ? verticesValue : default;
var vertices = Array.Empty<Position>();
var root = doc.RootElement;
if (verticesElement.ValueKind == JsonValueKind.Array)
vertices = verticesElement.Deserialize<Position[]>(options)!;
return new(source, target)
// case‐insensitive get
JsonElement Get(string name)
{
Vertices = vertices
};
if (root.TryGetProperty(name, out var e))
return e;
var alt = char.ToUpperInvariant(name[0]) + name.Substring(1);
if (root.TryGetProperty(alt, out e))
return e;
throw new JsonException($"Missing property '{name}' or '{alt}'");
}
var sourceElement = Get("source");
var targetElement = Get("target");
// now inside sourceElement and targetElement, their children
// are again PascalCased (“Activity”, “Port”), so do the same thing:
string GetId(JsonElement container, string propName)
{
if (container.TryGetProperty(propName, out var p))
return p.GetString()!;
var alt = char.ToUpperInvariant(propName[0]) + propName.Substring(1);
return container.GetProperty(alt).GetString()!;
}
string? GetPort(JsonElement container, string propName)
{
if (container.TryGetProperty(propName, out var p))
return p.GetString();
var alt = char.ToUpperInvariant(propName[0]) + propName.Substring(1);
return container.TryGetProperty(alt, out p) ? p.GetString() : null;
}
var sourceId = GetId(sourceElement, "activity");
var targetId = GetPort(targetElement, "activity"); // note: this could be null
var sourcePort = GetPort(sourceElement, "port");
var targetPort = GetPort(targetElement, "port");
var sourceAct = _activities.TryGetValue(sourceId, out var s) ? s : throw new JsonException($"Unknown activity ID '{sourceId}'");
var targetAct =
targetId != null
? _activities.TryGetValue(targetId, out var t)
? t
: throw new JsonException($"Unknown activity ID '{targetId}'")
: null;
var source = new Endpoint(sourceAct, sourcePort);
var target = new Endpoint(targetAct!, targetPort);
// vertices is already correct:
var vertices = Array.Empty<Position>();
if (doc.RootElement.TryGetProperty("vertices", out var vertsEl) && vertsEl.ValueKind == JsonValueKind.Array)
vertices = vertsEl.Deserialize<Position[]>(options)!;
return new Connection(source, target) { Vertices = vertices };
}
/// <inheritdoc />
@ -53,22 +90,14 @@ public class ConnectionJsonConverter : JsonConverter<Connection>
{
if (value.Source.Activity == null! || value.Target.Activity == null!)
return;
var model = new
{
Source = new
{
Activity = value.Source.Activity.Id,
Port = value.Source.Port
},
Target = new
{
Activity = value.Target.Activity.Id,
Port = value.Target.Port
},
Vertices = value.Vertices
Source = new { Activity = value.Source.Activity.Id, Port = value.Source.Port },
Target = new { Activity = value.Target.Activity.Id, Port = value.Target.Port },
Vertices = value.Vertices,
};
JsonSerializer.Serialize(writer, model, options);
}
}
}

View file

@ -1,4 +1,4 @@
using System.Text.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Elsa.Expressions.Contracts;
using Elsa.Extensions;
@ -16,7 +16,7 @@ public class FlowchartJsonConverter(IIdentityGenerator identityGenerator, IWellK
private const string AllActivitiesKey = "allActivities";
private const string AllConnectionsKey = "allConnections";
private const string NotFoundConnectionsKey = "notFoundConnections";
/// <inheritdoc />
public override Activities.Flowchart Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
@ -109,7 +109,12 @@ public class FlowchartJsonConverter(IIdentityGenerator identityGenerator, IWellK
{
var customPropertiesElement = rootElement.TryGetProperty("customProperties", out var customPropertiesEl) ? customPropertiesEl : default;
var notFoundConnectionsElement = customPropertiesElement.ValueKind != JsonValueKind.Undefined ? customPropertiesElement.TryGetProperty(NotFoundConnectionsKey, out var notFoundConnectionsEl) ? notFoundConnectionsEl : default : default;
var notFoundConnectionsElement =
customPropertiesElement.ValueKind != JsonValueKind.Undefined
? customPropertiesElement.TryGetProperty(NotFoundConnectionsKey, out var notFoundConnectionsEl)
? notFoundConnectionsEl
: default
: default;
var notFoundConnections = notFoundConnectionsElement.ValueKind != JsonValueKind.Undefined ? DeserializeConnections(notFoundConnectionsElement, activities, connectionSerializerOptions) : new List<Connection>();
// Add connections of NotFoundActivity to the list if they aren't already in it.
@ -141,7 +146,8 @@ public class FlowchartJsonConverter(IIdentityGenerator identityGenerator, IWellK
var target = foundActivities.FirstOrDefault(x => x.Id == missingTarget.Activity?.Id);
// ReSharper restore ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
if (source == null || target == null) continue;
if (source == null || target == null)
continue;
var connection = new Connection(new Endpoint(source, missingSource.Port), new Endpoint(target, missingTarget.Port));
connectionsThatCanBeRestored.Add(connection);
@ -152,29 +158,59 @@ public class FlowchartJsonConverter(IIdentityGenerator identityGenerator, IWellK
private static ICollection<Connection> DeserializeConnections(JsonElement connectionsElement, IDictionary<string, IActivity> activityDictionary, JsonSerializerOptions options)
{
if (connectionsElement.ValueKind == JsonValueKind.Undefined)
// 1) Nothing → empty
if (connectionsElement.ValueKind == JsonValueKind.Undefined || connectionsElement.ValueKind == JsonValueKind.Null)
return new List<Connection>();
// To not break existing workflow definitions, we need to support the old connection format.
var useOldConnectionConverter = connectionsElement.EnumerateArray().Any(x => x.TryGetProperty("sourcePort", out var sourcePort) && sourcePort.ValueKind == JsonValueKind.String);
var connectionSerializerOptions = new JsonSerializerOptions(options);
if (useOldConnectionConverter)
// 2) OData‐style wrapper: { "$values": [ … ] }
if (connectionsElement.ValueKind == JsonValueKind.Object && connectionsElement.TryGetProperty("$values", out var valuesEl) && valuesEl.ValueKind == JsonValueKind.Array)
{
connectionSerializerOptions.Converters.Add(new ObsoleteConnectionJsonConverter(activityDictionary));
var obsoleteConnections = connectionsElement.ValueKind != JsonValueKind.Undefined
? connectionsElement.Deserialize<ICollection<ObsoleteConnection>>(connectionSerializerOptions)?.Where(x => x.Source != null! && x.Target != null!).ToList() ?? []
: [];
return obsoleteConnections.Select(x => new Connection(new Endpoint(x.Source, x.SourcePort), new Endpoint(x.Target, x.TargetPort))).ToList();
connectionsElement = valuesEl;
}
// 3) Single‐object (old style): wrap into a 1‑element array if it has a "source" property
else if (connectionsElement.ValueKind == JsonValueKind.Object && connectionsElement.TryGetProperty("source", out _))
{
using var tmp = JsonDocument.Parse($"[{connectionsElement.GetRawText()}]");
connectionsElement = tmp.RootElement;
}
connectionSerializerOptions.Converters.Add(new ConnectionJsonConverter(activityDictionary));
// 4) If it’s still not an array, bail
if (connectionsElement.ValueKind != JsonValueKind.Array)
return new List<Connection>();
return connectionsElement.ValueKind != JsonValueKind.Undefined
? connectionsElement.Deserialize<ICollection<Connection>>(connectionSerializerOptions)?.Where(x => x.Source != null! && x.Target != null!).ToList() ?? []
: [];
// Shortcut: detect the classic flat‐connection JSON and parse manually
var arr = connectionsElement.EnumerateArray().ToArray();
if (arr.Length > 0 && arr[0].TryGetProperty("source", out var srcProp) && srcProp.ValueKind == JsonValueKind.String && arr[0].TryGetProperty("target", out var tgtProp) && tgtProp.ValueKind == JsonValueKind.String)
{
var list = new List<Connection>();
foreach (var el in arr)
{
var srcId = el.GetProperty("source").GetString()!;
var tgtId = el.GetProperty("target").GetString()!;
var srcPort = el.TryGetProperty("sourcePort", out var sp) && sp.ValueKind == JsonValueKind.String ? sp.GetString() : null;
var tgtPort = el.TryGetProperty("targetPort", out var tp) && tp.ValueKind == JsonValueKind.String ? tp.GetString() : null;
var srcAct = activityDictionary[srcId];
var tgtAct = activityDictionary[tgtId];
list.Add(new Connection(new Endpoint(srcAct, srcPort), new Endpoint(tgtAct, tgtPort)));
}
return list;
}
// Otherwise, it's an array of nested‐object connections → delegate to your converters
var serializer = new JsonSerializerOptions(options);
// Legacy check: look for "sourcePort" on the first item to choose the old converter
if (arr.Length > 0 && arr[0].TryGetProperty("sourcePort", out _))
serializer.Converters.Add(new ObsoleteConnectionJsonConverter(activityDictionary));
else
serializer.Converters.Add(new ConnectionJsonConverter(activityDictionary));
var raw = connectionsElement.Deserialize<ICollection<Connection>>(serializer) ?? new List<Connection>();
// drop any half‑baked entries
return raw.Where(c => c.Source?.Activity != null && c.Target?.Activity != null).ToList();
}
}
}

View file

@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using Elsa.Expressions.Helpers;
using Elsa.Extensions;
using Elsa.Workflows.Memory;
@ -10,28 +11,120 @@ namespace Elsa.Workflows;
/// <inheritdoc />
public class ActivityFactory : IActivityFactory
{
/// <inheritdoc />
public IActivity Create(Type type, ActivityConstructorContext context)
{
// Backwards compatibility for older JSON schemas.
var canStartWorkflow = GetBoolean(context.Element, "canStartWorkflow");
var runAsynchronously = GetBoolean(context.Element, "runAsynchronously");
var activityElement = context.Element;
var activityDescriptor = context.ActivityDescriptor;
var activity = (IActivity)context.Element.Deserialize(type, context.SerializerOptions)!;
var composite = activity as IComposite;
// 1) Grab the raw text
var raw = context.Element.GetRawText();
composite?.Setup();
// 2) Parse into a JsonNode so we can mutate
var node = JsonNode.Parse(raw)!;
ReadSyntheticInputs(activityDescriptor, activity, activityElement, context.SerializerOptions);
ReadSyntheticOutputs(activityDescriptor, activity, activityElement);
// 3) Strip out any "_type" metadata wrappers
StripTypeMetadata(node);
// 4) Get a cleaned JSON string
var cleanedJson = node.ToJsonString(new JsonSerializerOptions(JsonSerializerDefaults.Web));
// 5) Parse it back to a JsonDocument so we can get a clean JsonElement
using var doc = JsonDocument.Parse(cleanedJson);
var cleanedElement = doc.RootElement.Clone();
// 6) Deserialize into your IActivity
var activity = (IActivity)JsonSerializer.Deserialize(cleanedJson, type, context.SerializerOptions)!;
// 7) Pull out your boolean flags from the cleaned element
var canStartWorkflow = GetBoolean(cleanedElement, "canStartWorkflow");
var runAsynchronously = GetBoolean(cleanedElement, "runAsynchronously");
// 8) If composite, setup
if (activity is IComposite composite)
composite.Setup();
// 9) Your existing synthetic inputs/outputs routines, using the cleanedElement
ReadSyntheticInputs(context.ActivityDescriptor, activity, cleanedElement, context.SerializerOptions);
ReadSyntheticOutputs(context.ActivityDescriptor, activity, cleanedElement);
// 10) Finally re‑apply those flags
activity.SetCanStartWorkflow(canStartWorkflow);
activity.SetRunAsynchronously(runAsynchronously);
return activity;
}
/// <summary>
/// Recursively remove any "_type" properties and unwrap any
/// { "_type": "...", "items": [ ... ] } or "values": [ ... ] wrappers
/// </summary>
private void StripTypeMetadata(JsonNode node)
{
switch (node)
{
case JsonObject obj:
// First recurse into each child
foreach (var key in obj.Select(kvp => kvp.Key).ToList())
{
var child = obj[key];
if (child is JsonObject wrapper && wrapper.ContainsKey("_type"))
{
// If it has an "items" array, replace the whole property with that array
if (wrapper["items"] is JsonArray items)
{
obj[key] = items;
StripTypeMetadata(items);
continue;
}
// Or a "values" array
if (wrapper["values"] is JsonArray values)
{
obj[key] = values;
StripTypeMetadata(values);
continue;
}
}
// Otherwise just recurse normally
if (child != null)
StripTypeMetadata(child);
}
// And remove any stray _type on this object
obj.Remove("_type");
break;
case JsonArray arr:
for (var i = 0; i < arr.Count; i++)
{
var element = arr[i];
if (element is JsonObject w && w.ContainsKey("_type"))
{
if (w["items"] is JsonArray items)
{
arr[i] = items;
StripTypeMetadata(items);
continue;
}
if (w["values"] is JsonArray values)
{
arr[i] = values;
StripTypeMetadata(values);
continue;
}
}
if (element != null)
StripTypeMetadata(element);
}
break;
// primitives—nothing to do
default:
break;
}
}
private void ReadSyntheticInputs(ActivityDescriptor activityDescriptor, IActivity activity, JsonElement activityRoot, JsonSerializerOptions options)
{
foreach (var inputDescriptor in activityDescriptor.Inputs.Where(x => x.IsSynthetic))
@ -41,9 +134,9 @@ public class ActivityFactory : IActivityFactory
var nakedType = inputDescriptor.Type;
var wrappedType = typeof(Input<>).MakeGenericType(nakedType);
if (!activityRoot.TryGetProperty(propertyName, out var propertyElement) || propertyElement.ValueKind == JsonValueKind.Null || propertyElement.ValueKind == JsonValueKind.Undefined)
if (!activityRoot.TryGetProperty(propertyName, out var propertyElement) || propertyElement.ValueKind == JsonValueKind.Null || propertyElement.ValueKind == JsonValueKind.Undefined)
continue;
var isWrapped = propertyElement.ValueKind == JsonValueKind.Object && propertyElement.GetProperty("typeName").ValueKind != JsonValueKind.Undefined;
if (isWrapped)
@ -77,10 +170,7 @@ public class ActivityFactory : IActivityFactory
if (!memoryReferenceElement.TryGetProperty("id", out var memoryReferenceIdElement))
continue;
var variable = new Variable
{
Id = memoryReferenceIdElement.GetString()!
};
var variable = new Variable { Id = memoryReferenceIdElement.GetString()! };
variable.Name = variable.Id;
var output = Activator.CreateInstance(wrappedType, variable)!;
@ -107,4 +197,4 @@ public class ActivityFactory : IActivityFactory
return false;
}
}
}