diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 6fa5c5d9c..aa3b9cf2c 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -3,18 +3,7 @@ on: workflow_dispatch: push: branches: - - 'main' - - 'feature/*' - - 'feat/*' - - 'issue/*' - - 'bug/*' - - 'enhancement/*' - - 'enh/*' - - 'patch/*' - - 'fix/*' - - 'perf/*' - - 'hotfix/*' - - 'chore/*' + - 'blueberry' release: types: [ prereleased, published ] env: diff --git a/Directory.Build.props b/Directory.Build.props index 6dea5e839..720db3ec5 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -38,5 +38,6 @@ 3.3.0-preview.470 + 8.0.5 \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 9afe5d9ca..adee9e75e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -38,7 +38,8 @@ - + + @@ -67,7 +68,6 @@ - @@ -149,40 +149,34 @@ - - - - - - - - - - - - + + + + + + + + + + + - + - - - - - - - - + + + + + + + - + - + - - - - - + \ No newline at end of file diff --git a/Elsa.sln b/Elsa.sln index 0570a8bdd..fae61d87b 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -117,6 +117,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docker", "docker", "{986E54 docker\docker-compose.yml = docker\docker-compose.yml docker\docker-compose-datadog.yml = docker\docker-compose-datadog.yml docker\ElsaServer-Datadog.Dockerfile = docker\ElsaServer-Datadog.Dockerfile + docker\otel-collector-config.yaml = docker\otel-collector-config.yaml docker\init-db.sh = docker\init-db.sh EndProjectSection EndProject @@ -470,6 +471,45 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Secrets.Scripting", "s EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.JavaScript.Libraries", "src\modules\Elsa.JavaScript.Libraries\Elsa.JavaScript.Libraries.csproj", "{5F80663C-B072-49DE-9A67-FD804BAD787E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.OpenTelemetry", "src\modules\Elsa.OpenTelemetry\Elsa.OpenTelemetry.csproj", "{25880971-403E-4872-93A1-D33089E07C91}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{FE1AFEC0-7C63-4EF8-8E24-D9703590A778}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "k8s", "k8s", "{924BA0AE-46B1-40B5-992C-CFB4EB14EDB0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "elsa-server", "elsa-server", "{A2743EC6-7117-46E2-8AC1-8CDD52C8AAEF}" + ProjectSection(SolutionItems) = preProject + scripts\k8s\elsa-server\deployment.yaml = scripts\k8s\elsa-server\deployment.yaml + scripts\k8s\elsa-server\role-binding.yaml = scripts\k8s\elsa-server\role-binding.yaml + scripts\k8s\elsa-server\role.yaml = scripts\k8s\elsa-server\role.yaml + scripts\k8s\elsa-server\service-account.yml = scripts\k8s\elsa-server\service-account.yml + scripts\k8s\elsa-server\service.yaml = scripts\k8s\elsa-server\service.yaml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "elsa-studio", "elsa-studio", "{A782913B-142F-4EA8-A2C7-82679CD2ABC1}" + ProjectSection(SolutionItems) = preProject + scripts\k8s\elsa-studio\deployment.yaml = scripts\k8s\elsa-studio\deployment.yaml + scripts\k8s\elsa-studio\service.yaml = scripts\k8s\elsa-studio\service.yaml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "plant-uml", "plant-uml", "{BFB87B50-3FE9-4AB1-8F18-96BE667C953D}" + ProjectSection(SolutionItems) = preProject + scripts\k8s\plant-uml\deployment.yaml = scripts\k8s\plant-uml\deployment.yaml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "postgres", "postgres", "{399C61BC-A51B-4B57-AA32-46BC24D4AE1C}" + ProjectSection(SolutionItems) = preProject + scripts\k8s\postgres\config-map.yaml = scripts\k8s\postgres\config-map.yaml + scripts\k8s\postgres\deployment.yaml = scripts\k8s\postgres\deployment.yaml + scripts\k8s\postgres\service.yml = scripts\k8s\postgres\service.yml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "trace-lens", "trace-lens", "{2FDF67FD-2BD3-4F73-9A24-DA255641ED25}" + ProjectSection(SolutionItems) = preProject + scripts\k8s\trace-lens\deployment.yaml = scripts\k8s\trace-lens\deployment.yaml + scripts\k8s\trace-lens\service.yaml = scripts\k8s\trace-lens\service.yaml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1070,6 +1110,10 @@ Global {5F80663C-B072-49DE-9A67-FD804BAD787E}.Debug|Any CPU.Build.0 = Debug|Any CPU {5F80663C-B072-49DE-9A67-FD804BAD787E}.Release|Any CPU.ActiveCfg = Release|Any CPU {5F80663C-B072-49DE-9A67-FD804BAD787E}.Release|Any CPU.Build.0 = Release|Any CPU + {25880971-403E-4872-93A1-D33089E07C91}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {25880971-403E-4872-93A1-D33089E07C91}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25880971-403E-4872-93A1-D33089E07C91}.Release|Any CPU.ActiveCfg = Release|Any CPU + {25880971-403E-4872-93A1-D33089E07C91}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1257,6 +1301,13 @@ Global {29D12ADC-55E9-40D0-9E4C-F0EBB6E098EC} = {8CEEC194-820A-4C8D-AB9E-E51E6D3E9CC1} {6C606FEB-9A1F-4816-ABE4-22AFA8CEE771} = {8CEEC194-820A-4C8D-AB9E-E51E6D3E9CC1} {5F80663C-B072-49DE-9A67-FD804BAD787E} = {6EF07978-A6D2-40EB-891D-7D70C5F37E76} + {25880971-403E-4872-93A1-D33089E07C91} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} + {924BA0AE-46B1-40B5-992C-CFB4EB14EDB0} = {FE1AFEC0-7C63-4EF8-8E24-D9703590A778} + {A2743EC6-7117-46E2-8AC1-8CDD52C8AAEF} = {924BA0AE-46B1-40B5-992C-CFB4EB14EDB0} + {A782913B-142F-4EA8-A2C7-82679CD2ABC1} = {924BA0AE-46B1-40B5-992C-CFB4EB14EDB0} + {BFB87B50-3FE9-4AB1-8F18-96BE667C953D} = {924BA0AE-46B1-40B5-992C-CFB4EB14EDB0} + {399C61BC-A51B-4B57-AA32-46BC24D4AE1C} = {924BA0AE-46B1-40B5-992C-CFB4EB14EDB0} + {2FDF67FD-2BD3-4F73-9A24-DA255641ED25} = {924BA0AE-46B1-40B5-992C-CFB4EB14EDB0} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/Elsa.sln.DotSettings b/Elsa.sln.DotSettings index 4d587519c..08956874a 100644 --- a/Elsa.sln.DotSettings +++ b/Elsa.sln.DotSettings @@ -1,4 +1,4 @@ - + False 1000 1 @@ -16,6 +16,7 @@ True True True + True True True True diff --git a/design/custom/2x/elsa-logo.png b/design/custom/2x/elsa-logo.png new file mode 100644 index 000000000..baa1319c0 Binary files /dev/null and b/design/custom/2x/elsa-logo.png differ diff --git a/docker/ElsaServer-Datadog.Dockerfile b/docker/ElsaServer-Datadog.Dockerfile index 207e69e5d..6491995e5 100644 --- a/docker/ElsaServer-Datadog.Dockerfile +++ b/docker/ElsaServer-Datadog.Dockerfile @@ -1,28 +1,23 @@ -# Version: 1 +# Version: 1 # Description: Dockerfile for building and running Elsa Server FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim AS build WORKDIR /source -# Determine the architecture and download the appropriate version of the tracer -RUN ARCH=$(if [ "$(uname -m)" = "x86_64" ]; then echo "amd64"; elif [ "$(uname -m)" = "aarch64" ]; then echo "arm64"; else echo "amd64"; fi) \ - && TRACER_VERSION=$(curl -s https://api.github.com/repos/DataDog/dd-trace-dotnet/releases/latest | grep tag_name | cut -d '"' -f 4 | cut -c2-) \ - && curl -Lo /tmp/datadog-dotnet-apm.deb https://github.com/DataDog/dd-trace-dotnet/releases/download/v${TRACER_VERSION}/datadog-dotnet-apm_${TRACER_VERSION}_${ARCH}.deb - -# copy sources. +# Copy sources. COPY src/. ./src COPY ./NuGet.Config ./ COPY *.props ./ -# restore packages. -RUN dotnet restore "./src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj" +# Restore packages. +RUN dotnet restore "./src/bundles/Elsa.Server.Web/Elsa.Server.Web.csproj" -# build and publish (UseAppHost=false creates platform independent binaries). -WORKDIR /source/src/apps/Elsa.Server.Web +# Build and publish (UseAppHost=false creates platform independent binaries). +WORKDIR /source/src/bundles/Elsa.Server.Web RUN dotnet build "Elsa.Server.Web.csproj" -c Release -o /app/build RUN dotnet publish "Elsa.Server.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore -f net8.0 -# move binaries into smaller base image. +# Move binaries into smaller base image. FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS base WORKDIR /app COPY --from=build /app/publish ./ @@ -38,21 +33,25 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Set PYTHONNET_PYDLL environment variable ENV PYTHONNET_PYDLL=/usr/lib/aarch64-linux-gnu/libpython3.11.so -# Copy the tracer from build target -COPY --from=build /tmp/datadog-dotnet-apm.deb /tmp/datadog-dotnet-apm.deb -# Install the tracer -RUN mkdir -p /opt/datadog \ - && mkdir -p /var/log/datadog \ - && dpkg -i /tmp/datadog-dotnet-apm.deb \ - && rm /tmp/datadog-dotnet-apm.deb - -# Enable the tracer -ENV CORECLR_ENABLE_PROFILING=1 -ENV CORECLR_PROFILER={846F5F1C-F9AE-4B07-969E-05C26BC060D8} -ENV CORECLR_PROFILER_PATH=/opt/datadog/Datadog.Trace.ClrProfiler.Native.so -ENV DD_DOTNET_TRACER_HOME=/opt/datadog -ENV DD_INTEGRATIONS=/opt/datadog/integrations.json +# Install dependencies +RUN apt-get update && apt-get install -y wget unzip curl + +# Set environment variables for OpenTelemetry Auto-Instrumentation +ENV OTEL_DOTNET_AUTO_HOME=/otel +ENV OTEL_LOG_LEVEL="debug" + +# Download and extract OpenTelemetry Auto-Instrumentation +ARG OTEL_VERSION=1.7.0 +RUN mkdir /otel +RUN curl -L -o /otel/otel-dotnet-install.sh https://github.com/open-telemetry/opentelemetry-dotnet-instrumentation/releases/download/v${OTEL_VERSION}/otel-dotnet-auto-install.sh +RUN chmod +x /otel/otel-dotnet-install.sh +RUN /bin/bash /otel/otel-dotnet-install.sh + +# Provide necessary permissions for the script to execute +RUN chmod +x /otel/instrument.sh EXPOSE 8080/tcp EXPOSE 443/tcp -ENTRYPOINT ["dotnet", "Elsa.Server.Web.dll"] + +# Instrument the application and start it +ENTRYPOINT ["/bin/bash", "-c", "source /otel/instrument.sh && dotnet Elsa.Server.Web.dll"] \ No newline at end of file diff --git a/docker/docker-compose-datadog.yml b/docker/docker-compose-datadog.yml index d2f742b47..e90991c45 100644 --- a/docker/docker-compose-datadog.yml +++ b/docker/docker-compose-datadog.yml @@ -1,5 +1,3 @@ -version: '3.7' - services: postgres: image: postgres:latest @@ -12,24 +10,13 @@ services: - postgres-data:/var/lib/postgresql/data ports: - "5432:5432" - - cockroachdb: - image: cockroachdb/cockroach:v22.1.0 - command: start-single-node --insecure - ports: - - "26257:26257" # CockroachDB SQL port - - "8080:8080" # CockroachDB UI port - volumes: - - cockroachdb-data:/cockroach/cockroach-data - environment: - - COCKROACH_DATABASE=elsa - + rabbitmq: image: "rabbitmq:3-management" ports: - "15672:15672" - "5672:5672" - + redis: image: redis:latest ports: @@ -44,7 +31,7 @@ services: - postgres - rabbitmq - redis - - datadog-agent + - otel-collector environment: DD_AGENT_HOST: datadog-agent DD_ENV: development @@ -52,6 +39,21 @@ services: DD_TRACE_OTEL_ENABLED: true DD_SERVICE: "Elsa Server" DD_VERSION: "3.3.0" + # OpenTelemetry environment variables + OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4317" # Point to OpenTelemetry Collector + OTEL_EXPORTER_OTLP_PROTOCOL: "grpc" # Use gRPC for OTLP + OTEL_TRACES_EXPORTER: "otlp" + OTEL_METRICS_EXPORTER: "otlp" + OTEL_LOGS_EXPORTER: "otpl" + OTEL_RESOURCE_ATTRIBUTES: "service.name=elsa-server-local,service.version=3.2.1-blueberry,deployment.environment=development" + OTEL_DOTNET_AUTO_TRACES_ADDITIONAL_SOURCES: "Elsa.Workflows" + OTEL_DOTNET_AUTO_INSTRUMENTATION_ENABLED: "true" + OTEL_LOG_LEVEL: "debug" + OTEL_DOTNET_AUTO_RESOURCE_DETECTOR_ENABLED: "true" + OTEL_DOTNET_AUTO_LOGS_CONSOLE_EXPORTER_ENABLED: "true" + OTEL_DOTNET_AUTO_METRICS_CONSOLE_EXPORTER_ENABLED: "true" + OTEL_DOTNET_AUTO_TRACES_CONSOLE_EXPORTER_ENABLED: "true" + ASPNETCORE_ENVIRONMENT: Development PYTHONNET_PYDLL: /opt/homebrew/Cellar/python@3.11/3.11.6_1/Frameworks/Python.framework/Versions/3.11/bin/python3.11 PYTHONNET_RUNTIME: coreclr @@ -61,7 +63,7 @@ services: DISTRIBUTEDLOCKPROVIDER: "Postgres" ports: - "13000:8080" - + elsa-studio: pull_policy: always build: @@ -73,24 +75,41 @@ services: ports: - "14000:8080" + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + volumes: + - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml + command: [ "--config", "/etc/otel-collector-config.yaml", "--feature-gates", "-component.UseLocalHostAsDefaultHost" ] + environment: + DD_API_KEY: "secret api key" + DD_SITE: "datadoghq.eu" + ports: + - "13133:13133" + - "4317:4317" + - "4318:4318" + datadog-agent: - image: datadog/agent:7 + image: datadog/agent:latest environment: DD_API_KEY: "" DD_SITE: "datadoghq.eu" + DD_HOSTNAME: "otel-collector" DD_LOGS_ENABLED: "true" + DD_OTLP_CONFIG_LOGS_ENABLED: "true" DD_LOGS_CONFIG_CONTAINER_COLLECT_ALL: "true" DD_APM_ENABLED: "true" DD_APM_NON_LOCAL_TRAFFIC: "true" + DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT: 0.0.0.0:4317 # The Datadog Agent expects traces from OpenTelemetry Collector + DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_HTTP_ENDPOINT: 0.0.0.0:4318 + + # Service autodiscovery DD_AC_INCLUDE: "name:postgres,name:rabbitmq,name:redis,name:elsa-server" DD_AC_EXCLUDE: "name:datadog-agent" - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - /proc/:/host/proc/:ro - - /sys/fs/cgroup/:/host/sys/fs/cgroup:ro + ports: - "8126:8126" + - "14317:4317" + - "14318:4318" volumes: - postgres-data: - cockroachdb-data: + postgres-data: \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 5cc838dac..51cb37e4e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,6 +1,4 @@ -version: '3.7' - -services: +services: postgres: image: postgres:latest command: -c 'max_connections=2000' diff --git a/docker/otel-collector-config.yaml b/docker/otel-collector-config.yaml new file mode 100644 index 000000000..6cf8cefaf --- /dev/null +++ b/docker/otel-collector-config.yaml @@ -0,0 +1,49 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + batch: + send_batch_max_size: 100 + send_batch_size: 10 # Increased batch size for efficiency + timeout: 1s + tail_sampling: + decision_wait: 10s + num_traces: 10000 # Increased from 100 to handle more traces + expected_new_traces_per_sec: 100 # Increased from 10 + decision_cache: + sampled_cache_size: 100000 + policies: [ + { + name: incidents-policy, + type: boolean_attribute, + boolean_attribute: { key: hasIncidents, value: true } + } + ] + +exporters: + debug: + verbosity: detailed + datadog: + api: + site: ${env:DD_SITE} + key: ${env:DD_API_KEY} + +service: + pipelines: + metrics: + receivers: [ otlp ] + processors: [ batch ] + exporters: [ datadog ] + traces: + receivers: [ otlp ] + processors: [ batch ] # Added tail_sampling to the main traces pipeline + exporters: [ debug, datadog ] # Directly exporting to debug and datadog + logs: + receivers: [ otlp ] + processors: [ batch ] + exporters: [ debug, datadog ] diff --git a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj index d4e953462..692a1a9c5 100644 --- a/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj +++ b/src/apps/Elsa.Server.Web/Elsa.Server.Web.csproj @@ -51,6 +51,7 @@ + @@ -59,6 +60,10 @@ + + + + diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs index bbd5582c9..1b77df743 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -373,6 +373,9 @@ services elsa.UseRealTimeWorkflows(); } + if (useAzureServiceBus) + elsa.UseAzureServiceBus(asb => asb.AzureServiceBusOptions += options => configuration.GetSection("AzureServiceBus").Bind(options)); + if (useMassTransit) { elsa.UseMassTransit(massTransit => diff --git a/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj b/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj index 7a9f688cd..150e6d3cf 100644 --- a/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj +++ b/src/apps/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj @@ -48,4 +48,9 @@ + + + + + \ No newline at end of file diff --git a/src/apps/Elsa.ServerAndStudio.Web/appsettings.json b/src/apps/Elsa.ServerAndStudio.Web/appsettings.json index 130e0f35d..843df197e 100644 --- a/src/apps/Elsa.ServerAndStudio.Web/appsettings.json +++ b/src/apps/Elsa.ServerAndStudio.Web/appsettings.json @@ -1,14 +1,10 @@ { "Logging": { "LogLevel": { - "Default": "Information", - "Elsa.Mediator": "Warning", - "MassTransit": "Warning", - "Microsoft.Extensions.Http": "Warning", + "Default": "Warning", + "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information", - "Microsoft.EntityFrameworkCore": "Warning", - "Microsoft.AspNetCore": "Warning", - "System.Net.Http": "Warning" + "OpenTelemetry": "Debug" } }, "AllowedHosts": "*", @@ -115,5 +111,27 @@ } ] } + }, + "Scripting": { + "Python": { + "PythonDllPath": "", + "Scripts": [ + "def greet(name): return f'Hello {name}!'", + "def say_hello_world(): return greet('World')" + ] + } + }, + "AzureServiceBus": { + "ConnectionStringOrName": "AzureServiceBus", + "Queues": [ + { + "name": "order-created" + }, + { + "name": "order-completed" + } + ], + "Topics": [], + "Subscriptions": [] } } diff --git a/src/apps/Elsa.Studio.Web/Elsa.Studio.Web.csproj b/src/apps/Elsa.Studio.Web/Elsa.Studio.Web.csproj index 3faed797c..ca1886c1f 100644 --- a/src/apps/Elsa.Studio.Web/Elsa.Studio.Web.csproj +++ b/src/apps/Elsa.Studio.Web/Elsa.Studio.Web.csproj @@ -16,6 +16,10 @@ + + + + diff --git a/src/clients/Elsa.Api.Client/Converters/TypeJsonConverter.cs b/src/clients/Elsa.Api.Client/Converters/TypeJsonConverter.cs new file mode 100644 index 000000000..60602c7bc --- /dev/null +++ b/src/clients/Elsa.Api.Client/Converters/TypeJsonConverter.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Elsa.Api.Client.Extensions; +using JetBrains.Annotations; + +namespace Elsa.Api.Client.Converters; + +/// +/// Converts objects to and from their assembly-qualified name. +/// +[UsedImplicitly] +public class TypeJsonConverter : JsonConverter +{ + /// + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert == typeof(Type) || typeToConvert.FullName == "System.RuntimeType"; + } + + /// + public override Type? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var typeName = reader.GetString()!; + + // Handle collection types. + if (typeName.EndsWith("[]")) + { + var elementTypeName = typeName[..^"[]".Length]; + var elementType = Type.GetType(elementTypeName)!; + return typeof(List<>).MakeGenericType(elementType); + } + + return Type.GetType(typeName); + } + + /// + public override void Write(Utf8JsonWriter writer, Type value, JsonSerializerOptions options) + { + // Handle collection types. + if (value is { IsGenericType: true, GenericTypeArguments.Length: 1 }) + { + var elementType = value.GenericTypeArguments.First(); + var typedEnumerable = typeof(IEnumerable<>).MakeGenericType(elementType); + + if (typedEnumerable.IsAssignableFrom(value)) + { + var elementTypeName = value.GetSimpleAssemblyQualifiedName(); + JsonSerializer.Serialize(writer, $"{elementTypeName}[]", options); + return; + } + } + + var typeName = value.GetSimpleAssemblyQualifiedName(); + JsonSerializer.Serialize(writer, typeName, options); + } +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj b/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj index a740fd26b..bd58f9fc6 100644 --- a/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj +++ b/src/clients/Elsa.Api.Client/Elsa.Api.Client.csproj @@ -19,7 +19,7 @@ - + diff --git a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs index 96d4259ae..9f28a6070 100644 --- a/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs +++ b/src/clients/Elsa.Api.Client/Extensions/DependencyInjectionExtensions.cs @@ -121,7 +121,7 @@ public static class DependencyInjectionExtensions /// An options object that can be used to configure the HTTP client builder. public static IServiceCollection AddApi(this IServiceCollection services, Type apiType, ElsaClientBuilderOptions? httpClientBuilderOptions = default) { - var builder = services.AddRefitClient(apiType, _ => CreateRefitSettings(), apiType.Name).ConfigureHttpClient(ConfigureElsaApiHttpClient); + var builder = services.AddRefitClient(apiType, sp => CreateRefitSettings(sp, httpClientBuilderOptions?.ConfigureJsonSerializerOptions), apiType.Name).ConfigureHttpClient(ConfigureElsaApiHttpClient); httpClientBuilderOptions?.ConfigureHttpClientBuilder(builder); httpClientBuilderOptions?.ConfigureRetryPolicy?.Invoke(builder); return services; @@ -136,7 +136,7 @@ public static class DependencyInjectionExtensions public static void AddApiWithoutRetryPolicy(this IServiceCollection services, ElsaClientBuilderOptions? httpClientBuilderOptions = default) where T : class { var builder = services - .AddRefitClient(_ => CreateRefitSettings(), typeof(T).Name) + .AddRefitClient(sp => CreateRefitSettings(sp), typeof(T).Name) .ConfigureHttpClient(ConfigureElsaApiHttpClient); httpClientBuilderOptions?.ConfigureHttpClientBuilder(builder); } @@ -153,7 +153,7 @@ public static class DependencyInjectionExtensions /// Creates an API client for the specified API type. public static T CreateApi(this IServiceProvider serviceProvider, HttpClient httpClient) where T : class { - return RestService.For(httpClient, CreateRefitSettings()); + return RestService.For(httpClient, CreateRefitSettings(serviceProvider)); } private static void ConfigureElsaApiHttpClient(IServiceProvider serviceProvider, HttpClient httpClient) diff --git a/src/clients/Elsa.Api.Client/Helpers/RefitSettingsHelper.cs b/src/clients/Elsa.Api.Client/Helpers/RefitSettingsHelper.cs index ec47e8710..8a87220a3 100644 --- a/src/clients/Elsa.Api.Client/Helpers/RefitSettingsHelper.cs +++ b/src/clients/Elsa.Api.Client/Helpers/RefitSettingsHelper.cs @@ -10,17 +10,12 @@ namespace Elsa.Api.Client; /// public static class RefitSettingsHelper { - private static JsonSerializerOptions? _jsonSerializerOptions; - /// /// Creates a instance configured for Elsa. /// - public static RefitSettings CreateRefitSettings() + public static RefitSettings CreateRefitSettings(IServiceProvider serviceProvider, Action? configureJsonSerializerOptions = null) { - var settings = new RefitSettings - { - ContentSerializer = new SystemTextJsonContentSerializer(CreateJsonSerializerOptions()) - }; + var settings = new RefitSettings { ContentSerializer = new SystemTextJsonContentSerializer(CreateJsonSerializerOptions(serviceProvider, configureJsonSerializerOptions)) }; return settings; } @@ -28,11 +23,8 @@ public static class RefitSettingsHelper /// /// Creates a instance configured for Elsa. /// - public static JsonSerializerOptions CreateJsonSerializerOptions() + public static JsonSerializerOptions CreateJsonSerializerOptions(IServiceProvider serviceProvider, Action? configureJsonSerializerOptions = null) { - if (_jsonSerializerOptions != null) - return _jsonSerializerOptions; - var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -40,7 +32,10 @@ public static class RefitSettingsHelper options.Converters.Add(new JsonStringEnumConverter()); options.Converters.Add(new VersionOptionsJsonConverter()); + options.Converters.Add(new TypeJsonConverter()); - return _jsonSerializerOptions = options; + configureJsonSerializerOptions?.Invoke(serviceProvider, options); + + return options; } } \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Options/ElsaClientBuilderOptions.cs b/src/clients/Elsa.Api.Client/Options/ElsaClientBuilderOptions.cs index a7312eb60..52b0b1b77 100644 --- a/src/clients/Elsa.Api.Client/Options/ElsaClientBuilderOptions.cs +++ b/src/clients/Elsa.Api.Client/Options/ElsaClientBuilderOptions.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Elsa.Api.Client.HttpMessageHandlers; using Microsoft.Extensions.DependencyInjection; using Polly; @@ -39,4 +40,9 @@ public class ElsaClientBuilderOptions /// Gets or sets a delegate that can be used to configure the retry policy. /// public Action? ConfigureRetryPolicy { get; set; } = builder => builder.AddTransientHttpErrorPolicy(p => p.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)))); + + /// + /// Gets or sets a delegate that can be used to configure the JSON serializer options. + /// + public Action? ConfigureJsonSerializerOptions { get; set; } } \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Contracts/IActivityExecutionsApi.cs b/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Contracts/IActivityExecutionsApi.cs index f7ae99343..2238c8162 100644 --- a/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Contracts/IActivityExecutionsApi.cs +++ b/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Contracts/IActivityExecutionsApi.cs @@ -27,4 +27,22 @@ public interface IActivityExecutionsApi /// The response containing a list of activity executions. [Get("/activity-executions/list")] Task> ListAsync(ListActivityExecutionsRequest request, CancellationToken cancellationToken = default); + + /// + /// Lists activity execution summaries for a given activity in a workflow instance. + /// + /// The request. + /// An optional cancellation token. + /// The response containing a list of activity execution summaries. + [Get("/activity-execution-summaries/list")] + Task> ListSummariesAsync(ListActivityExecutionsRequest request, CancellationToken cancellationToken = default); + + /// + /// Gets a single activity execution by ID. + /// + /// The ID of the activity execution. + /// An optional cancellation token. + /// The activity execution. + [Get("/activity-executions/{id}")] + Task GetAsync(string id, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionRecordSummary.cs b/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionRecordSummary.cs new file mode 100644 index 000000000..a224c62de --- /dev/null +++ b/src/clients/Elsa.Api.Client/Resources/ActivityExecutions/Models/ActivityExecutionRecordSummary.cs @@ -0,0 +1,82 @@ +using System.Linq.Expressions; +using Elsa.Api.Client.Resources.WorkflowInstances.Models; +using Elsa.Api.Client.Shared.Models; + +namespace Elsa.Api.Client.Resources.ActivityExecutions.Models; + +/// +/// Represents a summarized view of a single activity execution of an activity instance. +/// +public class ActivityExecutionRecordSummary : Entity +{ + /// + /// Gets or sets the workflow instance ID. + /// + public string WorkflowInstanceId { get; set; } = default!; + + /// + /// Gets or sets the activity ID. + /// + public string ActivityId { get; set; } = default!; + + /// + /// Gets or sets the activity node ID. + /// + public string ActivityNodeId { get; set; } = default!; + + /// + /// The type of the activity. + /// + public string ActivityType { get; set; } = default!; + + /// + /// The version of the activity type. + /// + public int ActivityTypeVersion { get; set; } + + /// + /// The name of the activity. + /// + public string? ActivityName { get; set; } + + /// + /// Gets or sets the time at which the activity execution began. + /// + public DateTimeOffset StartedAt { get; set; } + + /// + /// Gets or sets whether the activity has any bookmarks. + /// + public bool HasBookmarks { get; set; } + + /// + /// Gets or sets the status of the activity. + /// + public ActivityStatus Status { get; set; } + + /// + /// Gets or sets the time at which the activity execution completed. + /// + public DateTimeOffset? CompletedAt { get; set; } + + /// + /// Returns a summary view of the specified . + /// + public static ActivityExecutionRecordSummary FromRecord(ActivityExecutionRecord record) + { + return new ActivityExecutionRecordSummary + { + Id = record.Id, + WorkflowInstanceId = record.WorkflowInstanceId, + ActivityId = record.ActivityId, + ActivityNodeId = record.ActivityNodeId, + ActivityType = record.ActivityType, + ActivityTypeVersion = record.ActivityTypeVersion, + ActivityName = record.ActivityName, + StartedAt = record.StartedAt, + HasBookmarks = record.HasBookmarks, + Status = record.Status, + CompletedAt = record.CompletedAt + }; + } +} \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Resources/StorageDrivers/Models/StorageDriverDescriptor.cs b/src/clients/Elsa.Api.Client/Resources/StorageDrivers/Models/StorageDriverDescriptor.cs index e0bab6929..835893b38 100644 --- a/src/clients/Elsa.Api.Client/Resources/StorageDrivers/Models/StorageDriverDescriptor.cs +++ b/src/clients/Elsa.Api.Client/Resources/StorageDrivers/Models/StorageDriverDescriptor.cs @@ -5,4 +5,4 @@ namespace Elsa.Api.Client.Resources.StorageDrivers.Models; /// /// The type name of the storage driver. /// The display name of the storage driver. -public record StorageDriverDescriptor(string TypeName, string DisplayName); \ No newline at end of file +public record StorageDriverDescriptor(string TypeName, string DisplayName, double Priority = 0, bool Deprecated = false); \ No newline at end of file diff --git a/src/common/Elsa.Api.Common/Models/PagedListResponse.cs b/src/common/Elsa.Api.Common/Models/PagedListResponse.cs index 07bfebe9e..06cdb55e9 100644 --- a/src/common/Elsa.Api.Common/Models/PagedListResponse.cs +++ b/src/common/Elsa.Api.Common/Models/PagedListResponse.cs @@ -16,4 +16,6 @@ public record PagedListResponse: LinkedResource public ICollection Items { get; set; } = default!; public long TotalCount { get; set; } + + public static PagedListResponse From(Page page) => new(page); } \ No newline at end of file diff --git a/src/common/Elsa.Testing.Shared.Component/Extensions/HttpResponseMessageExtensions.cs b/src/common/Elsa.Testing.Shared.Component/Extensions/HttpResponseMessageExtensions.cs index 41702cb24..d58fb70aa 100644 --- a/src/common/Elsa.Testing.Shared.Component/Extensions/HttpResponseMessageExtensions.cs +++ b/src/common/Elsa.Testing.Shared.Component/Extensions/HttpResponseMessageExtensions.cs @@ -5,10 +5,10 @@ namespace Elsa.Testing.Shared.Extensions; public static class HttpResponseMessageExtensions { - public static async Task ReadAsJsonAsync(this HttpResponseMessage response, CancellationToken cancellationToken = default) + public static async Task ReadAsJsonAsync(this HttpResponseMessage response, IServiceProvider serviceProvider, CancellationToken cancellationToken = default) { var json = await response.Content.ReadAsStringAsync(cancellationToken); - var options = RefitSettingsHelper.CreateJsonSerializerOptions(); + var options = RefitSettingsHelper.CreateJsonSerializerOptions(serviceProvider); return JsonSerializer.Deserialize(json, options)!; } } \ No newline at end of file diff --git a/src/modules/Elsa.AzureServiceBus/Features/AzureServiceBusFeature.cs b/src/modules/Elsa.AzureServiceBus/Features/AzureServiceBusFeature.cs index ed4dbf8be..3dc47bc16 100644 --- a/src/modules/Elsa.AzureServiceBus/Features/AzureServiceBusFeature.cs +++ b/src/modules/Elsa.AzureServiceBus/Features/AzureServiceBusFeature.cs @@ -70,9 +70,8 @@ public class AzureServiceBusFeature : FeatureBase .AddSingleton(ServiceBusAdministrationClientFactory) .AddSingleton(ServiceBusClientFactory) .AddSingleton() - .AddSingleton() .AddSingleton() - ; + .AddScoped(); // Definition providers. Services diff --git a/src/modules/Elsa.AzureServiceBus/HostedServices/CreateQueuesTopicsAndSubscriptions.cs b/src/modules/Elsa.AzureServiceBus/HostedServices/CreateQueuesTopicsAndSubscriptions.cs index b6285f044..3e488421e 100644 --- a/src/modules/Elsa.AzureServiceBus/HostedServices/CreateQueuesTopicsAndSubscriptions.cs +++ b/src/modules/Elsa.AzureServiceBus/HostedServices/CreateQueuesTopicsAndSubscriptions.cs @@ -1,4 +1,6 @@ using Elsa.AzureServiceBus.Contracts; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace Elsa.AzureServiceBus.HostedServices; @@ -6,16 +8,16 @@ namespace Elsa.AzureServiceBus.HostedServices; /// /// A blocking hosted service that creates queues, topics and subscriptions. /// -public class CreateQueuesTopicsAndSubscriptions : IHostedService +[UsedImplicitly] +public class CreateQueuesTopicsAndSubscriptions(IServiceScopeFactory scopeFactory) : IHostedService { - private readonly IServiceBusInitializer _serviceBusInitializer; - /// - /// Constructor. - /// - public CreateQueuesTopicsAndSubscriptions(IServiceBusInitializer serviceBusInitializer) => _serviceBusInitializer = serviceBusInitializer; - /// - public Task StartAsync(CancellationToken cancellationToken) => _serviceBusInitializer.InitializeAsync(cancellationToken); + public async Task StartAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var initializer = scope.ServiceProvider.GetRequiredService(); + await initializer.InitializeAsync(cancellationToken); + } /// public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/src/modules/Elsa.AzureServiceBus/HostedServices/StartWorkers.cs b/src/modules/Elsa.AzureServiceBus/HostedServices/StartWorkers.cs index 59d0cc72d..3c6526053 100644 --- a/src/modules/Elsa.AzureServiceBus/HostedServices/StartWorkers.cs +++ b/src/modules/Elsa.AzureServiceBus/HostedServices/StartWorkers.cs @@ -46,6 +46,6 @@ public class StartWorkers(IServiceScopeFactory scopeFactory) : IHostedService private async Task EnsureWorkersAsync(IWorkerManager workerManager, IEnumerable stimuli, CancellationToken cancellationToken) { - foreach (var payload in stimuli) await workerManager.StartWorkerAsync(payload.QueueOrTopic, payload.Subscription, cancellationToken); + foreach (var stimulus in stimuli) await workerManager.StartWorkerAsync(stimulus.QueueOrTopic, stimulus.Subscription, cancellationToken); } } \ No newline at end of file diff --git a/src/modules/Elsa.Dapper/Modules/Runtime/Stores/DapperActivityExecutionRecordStore.cs b/src/modules/Elsa.Dapper/Modules/Runtime/Stores/DapperActivityExecutionRecordStore.cs index ace176adf..c2012d190 100644 --- a/src/modules/Elsa.Dapper/Modules/Runtime/Stores/DapperActivityExecutionRecordStore.cs +++ b/src/modules/Elsa.Dapper/Modules/Runtime/Stores/DapperActivityExecutionRecordStore.cs @@ -101,7 +101,7 @@ internal class DapperActivityExecutionRecordStore(Store Map(ActivityExecutionRecord source, CancellationToken cancellationToken) + private ActivityExecutionRecordRecord Map(ActivityExecutionRecord source, CancellationToken cancellationToken) { return new ActivityExecutionRecordRecord { @@ -125,7 +125,7 @@ internal class DapperActivityExecutionRecordStore(Store MapAsync(ActivityExecutionRecordRecord source, CancellationToken cancellationToken) + private ActivityExecutionRecord Map(ActivityExecutionRecordRecord source, CancellationToken cancellationToken) { return new ActivityExecutionRecord { diff --git a/src/modules/Elsa.EntityFrameworkCore.Common/Elsa.EntityFrameworkCore.Common.csproj b/src/modules/Elsa.EntityFrameworkCore.Common/Elsa.EntityFrameworkCore.Common.csproj index 3e6b0e3dc..7c23bc98b 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Common/Elsa.EntityFrameworkCore.Common.csproj +++ b/src/modules/Elsa.EntityFrameworkCore.Common/Elsa.EntityFrameworkCore.Common.csproj @@ -1,4 +1,4 @@ - + @@ -20,7 +20,7 @@ - + diff --git a/src/modules/Elsa.EntityFrameworkCore.MySql/Elsa.EntityFrameworkCore.MySql.csproj b/src/modules/Elsa.EntityFrameworkCore.MySql/Elsa.EntityFrameworkCore.MySql.csproj index c00d3b8d7..7945447f3 100644 --- a/src/modules/Elsa.EntityFrameworkCore.MySql/Elsa.EntityFrameworkCore.MySql.csproj +++ b/src/modules/Elsa.EntityFrameworkCore.MySql/Elsa.EntityFrameworkCore.MySql.csproj @@ -12,6 +12,10 @@ + + + + diff --git a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Elsa.EntityFrameworkCore.PostgreSql.csproj b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Elsa.EntityFrameworkCore.PostgreSql.csproj index e0f1c22ec..f3216e047 100644 --- a/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Elsa.EntityFrameworkCore.PostgreSql.csproj +++ b/src/modules/Elsa.EntityFrameworkCore.PostgreSql/Elsa.EntityFrameworkCore.PostgreSql.csproj @@ -12,6 +12,10 @@ + + + + diff --git a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Elsa.EntityFrameworkCore.SqlServer.csproj b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Elsa.EntityFrameworkCore.SqlServer.csproj index 40e587431..b2b5b5804 100644 --- a/src/modules/Elsa.EntityFrameworkCore.SqlServer/Elsa.EntityFrameworkCore.SqlServer.csproj +++ b/src/modules/Elsa.EntityFrameworkCore.SqlServer/Elsa.EntityFrameworkCore.SqlServer.csproj @@ -18,9 +18,10 @@ - - - + + + + \ No newline at end of file diff --git a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Elsa.EntityFrameworkCore.Sqlite.csproj b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Elsa.EntityFrameworkCore.Sqlite.csproj index 7b7edfa04..aedd72175 100644 --- a/src/modules/Elsa.EntityFrameworkCore.Sqlite/Elsa.EntityFrameworkCore.Sqlite.csproj +++ b/src/modules/Elsa.EntityFrameworkCore.Sqlite/Elsa.EntityFrameworkCore.Sqlite.csproj @@ -13,6 +13,10 @@ + + + + diff --git a/src/modules/Elsa.EntityFrameworkCore/Elsa.EntityFrameworkCore.csproj b/src/modules/Elsa.EntityFrameworkCore/Elsa.EntityFrameworkCore.csproj index 1d6ce3fff..8bd6e0c7c 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Elsa.EntityFrameworkCore.csproj +++ b/src/modules/Elsa.EntityFrameworkCore/Elsa.EntityFrameworkCore.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/ActivityExecutionLogStore.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/ActivityExecutionLogStore.cs index 9e08035fe..c6f1c1b60 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/ActivityExecutionLogStore.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/ActivityExecutionLogStore.cs @@ -84,12 +84,12 @@ public class EFCoreActivityExecutionStore( { entity = entity.SanitizeLogMessage(); var compressionAlgorithm = options.Value.CompressionAlgorithm ?? nameof(None); - var serializedActivityState = entity.ActivityState != null ? await safeSerializer.SerializeAsync(entity.ActivityState, cancellationToken) : null; + var serializedActivityState = entity.ActivityState != null ? safeSerializer.Serialize(entity.ActivityState, cancellationToken) : null; var compressedSerializedActivityState = serializedActivityState != null ? await compressionCodecResolver.Resolve(compressionAlgorithm).CompressAsync(serializedActivityState, cancellationToken) : null; dbContext.Entry(entity).Property("SerializedActivityState").CurrentValue = compressedSerializedActivityState; dbContext.Entry(entity).Property("SerializedActivityStateCompressionAlgorithm").CurrentValue = compressionAlgorithm; - dbContext.Entry(entity).Property("SerializedOutputs").CurrentValue = entity.Outputs?.Any() == true ? await safeSerializer.SerializeAsync(entity.Outputs, cancellationToken) : null; + dbContext.Entry(entity).Property("SerializedOutputs").CurrentValue = entity.Outputs?.Any() == true ? safeSerializer.Serialize(entity.Outputs, cancellationToken) : null; dbContext.Entry(entity).Property("SerializedProperties").CurrentValue = entity.Properties.Any() ? payloadSerializer.Serialize(entity.Properties) : null; dbContext.Entry(entity).Property("SerializedException").CurrentValue = entity.Exception != null ? payloadSerializer.Serialize(entity.Exception) : null; dbContext.Entry(entity).Property("SerializedPayload").CurrentValue = entity.Payload?.Any() == true ? payloadSerializer.Serialize(entity.Payload) : null; @@ -103,7 +103,7 @@ public class EFCoreActivityExecutionStore( entity.ActivityState = await DeserializeActivityState(dbContext, entity, cancellationToken); entity.Outputs = Deserialize>(dbContext, entity, "SerializedOutputs"); - entity.Properties = Deserialize?>(dbContext, entity, "SerializedProperties") ?? new Dictionary(); + entity.Properties = DeserializePayload?>(dbContext, entity, "SerializedProperties") ?? new Dictionary(); entity.Exception = DeserializePayload(dbContext, entity, "SerializedException"); entity.Payload = DeserializePayload>(dbContext, entity, "SerializedPayload"); } diff --git a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowExecutionLogStore.cs b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowExecutionLogStore.cs index b8987247d..31c97306a 100644 --- a/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowExecutionLogStore.cs +++ b/src/modules/Elsa.EntityFrameworkCore/Modules/Runtime/WorkflowExecutionLogStore.cs @@ -75,8 +75,8 @@ public class EFCoreWorkflowExecutionLogStore(EntityStore - /// Returns true if the specified type is date-like type, false otherwise. + /// Returns true if the specified type is a date-like type, false otherwise. /// private static bool IsDateType(Type type) { diff --git a/src/modules/Elsa.FileStorage/Elsa.FileStorage.csproj b/src/modules/Elsa.FileStorage/Elsa.FileStorage.csproj index 60d30ee8d..432926232 100644 --- a/src/modules/Elsa.FileStorage/Elsa.FileStorage.csproj +++ b/src/modules/Elsa.FileStorage/Elsa.FileStorage.csproj @@ -17,7 +17,7 @@ - + diff --git a/src/modules/Elsa.Http/Elsa.Http.csproj b/src/modules/Elsa.Http/Elsa.Http.csproj index cd4a44274..b0cf13616 100644 --- a/src/modules/Elsa.Http/Elsa.Http.csproj +++ b/src/modules/Elsa.Http/Elsa.Http.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/modules/Elsa.MassTransit.AzureServiceBus/Features/AzureServiceBusFeature.cs b/src/modules/Elsa.MassTransit.AzureServiceBus/Features/AzureServiceBusFeature.cs index 0fc93e992..3ba3cc824 100644 --- a/src/modules/Elsa.MassTransit.AzureServiceBus/Features/AzureServiceBusFeature.cs +++ b/src/modules/Elsa.MassTransit.AzureServiceBus/Features/AzureServiceBusFeature.cs @@ -1,4 +1,5 @@ using Azure.Messaging.ServiceBus.Administration; +using Elsa.Common.Contracts; using Elsa.Extensions; using Elsa.Features.Abstractions; using Elsa.Features.Attributes; @@ -121,6 +122,13 @@ public class AzureServiceBusFeature : FeatureBase } configurator.ConfigureEndpoints(context, new KebabCaseEndpointNameFormatter("Elsa", false)); + + configurator.ConfigureJsonSerializerOptions(serializerOptions => + { + var serializer = context.GetRequiredService(); + serializer.ApplyOptions(serializerOptions); + return serializerOptions; + }); }); }; }); diff --git a/src/modules/Elsa.MassTransit.RabbitMq/Features/RabbitMqServiceBusFeature.cs b/src/modules/Elsa.MassTransit.RabbitMq/Features/RabbitMqServiceBusFeature.cs index 064d0a948..3359fffb0 100644 --- a/src/modules/Elsa.MassTransit.RabbitMq/Features/RabbitMqServiceBusFeature.cs +++ b/src/modules/Elsa.MassTransit.RabbitMq/Features/RabbitMqServiceBusFeature.cs @@ -1,3 +1,4 @@ +using Elsa.Common.Contracts; using Elsa.Extensions; using Elsa.Features.Abstractions; using Elsa.Features.Attributes; @@ -87,6 +88,13 @@ public class RabbitMqServiceBusFeature : FeatureBase } configurator.ConfigureEndpoints(context, new KebabCaseEndpointNameFormatter("Elsa", false)); + + configurator.ConfigureJsonSerializerOptions(serializerOptions => + { + var serializer = context.GetRequiredService(); + serializer.ApplyOptions(serializerOptions); + return serializerOptions; + }); }); }; }); diff --git a/src/modules/Elsa.MassTransit/Consumers/DispatchWorkflowRequestConsumer.cs b/src/modules/Elsa.MassTransit/Consumers/DispatchWorkflowRequestConsumer.cs index bc7f233cb..1460939ae 100644 --- a/src/modules/Elsa.MassTransit/Consumers/DispatchWorkflowRequestConsumer.cs +++ b/src/modules/Elsa.MassTransit/Consumers/DispatchWorkflowRequestConsumer.cs @@ -119,4 +119,12 @@ public class DispatchWorkflowRequestConsumer(IWorkflowDefinitionService workflow var workflowClient = await workflowRuntime.CreateClientAsync(message.InstanceId, cancellationToken); await workflowClient.RunInstanceAsync(request, cancellationToken); } + + private IDictionary? DeserializeInput(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + return jsonSerializer.Deserialize>(json); + } } \ No newline at end of file diff --git a/src/modules/Elsa.MassTransit/Messages/DispatchResumeWorkflows.cs b/src/modules/Elsa.MassTransit/Messages/DispatchResumeWorkflows.cs index 5496500ea..ec9f59969 100644 --- a/src/modules/Elsa.MassTransit/Messages/DispatchResumeWorkflows.cs +++ b/src/modules/Elsa.MassTransit/Messages/DispatchResumeWorkflows.cs @@ -14,6 +14,10 @@ public class DispatchResumeWorkflows(string activityTypeName, object stimulus) public string? CorrelationId { get; set; } public string? WorkflowInstanceId { get; set; } public string? ActivityInstanceId { get; set; } + + [Obsolete("This property is no longer used and will be removed in a future version. Use the SerializedInput property instead.")] public IDictionary? Input { get; set; } + + public string? SerializedInput { get; set; } public IDictionary? Properties { get; set; } } \ No newline at end of file diff --git a/src/modules/Elsa.MassTransit/Messages/DispatchTriggerWorkflowsRequest.cs b/src/modules/Elsa.MassTransit/Messages/DispatchTriggerWorkflowsRequest.cs index 86838495a..adbfd6d88 100644 --- a/src/modules/Elsa.MassTransit/Messages/DispatchTriggerWorkflowsRequest.cs +++ b/src/modules/Elsa.MassTransit/Messages/DispatchTriggerWorkflowsRequest.cs @@ -14,6 +14,10 @@ public class DispatchTriggerWorkflows(string activityTypeName, object stimulus) public string? CorrelationId { get; set; } public string? WorkflowInstanceId { get; set; } public string? ActivityInstanceId { get; set; } + + [Obsolete("This property is no longer used and will be removed in a future version. Use the SerializedInput property instead.")] public IDictionary? Input { get; set; } + + public string? SerializedInput { get; set; } public IDictionary? Properties { get; set; } } \ No newline at end of file diff --git a/src/modules/Elsa.MassTransit/Messages/DispatchWorkflowDefinition.cs b/src/modules/Elsa.MassTransit/Messages/DispatchWorkflowDefinition.cs index bd76648ec..7ae22b69c 100644 --- a/src/modules/Elsa.MassTransit/Messages/DispatchWorkflowDefinition.cs +++ b/src/modules/Elsa.MassTransit/Messages/DispatchWorkflowDefinition.cs @@ -32,8 +32,14 @@ public record DispatchWorkflowDefinition /// The ID of the parent workflow instance. public string? ParentWorkflowInstanceId { get; init; } + /// Deprecated. Use the property instead. + [Obsolete("This property is no longer used and will be removed in a future version. Use the SerializedInput property instead.")] + public IDictionary? Input { get; set; } + + /// /// Any input to pass to the workflow. - public IDictionary? Input { get; init; } + /// + public string? SerializedInput { get; set; } /// Any properties to attach to the workflow. public IDictionary? Properties { get; init; } diff --git a/src/modules/Elsa.MassTransit/Messages/DispatchWorkflowInstance.cs b/src/modules/Elsa.MassTransit/Messages/DispatchWorkflowInstance.cs index 16ee63bb7..4719083d2 100644 --- a/src/modules/Elsa.MassTransit/Messages/DispatchWorkflowInstance.cs +++ b/src/modules/Elsa.MassTransit/Messages/DispatchWorkflowInstance.cs @@ -8,6 +8,8 @@ public class DispatchWorkflowInstance(string instanceId) public string? BookmarkId { get; set; } public ActivityHandle? ActivityHandle { get; set; } public IDictionary? Input { get; set; } + + public string? SerializedInput { get; set; } public IDictionary? Properties { get; set; } public string? CorrelationId { get; set; } } \ No newline at end of file diff --git a/src/modules/Elsa.MassTransit/Services/MassTransitWorkflowDispatcher.cs b/src/modules/Elsa.MassTransit/Services/MassTransitWorkflowDispatcher.cs index 5f806672c..04a27055a 100644 --- a/src/modules/Elsa.MassTransit/Services/MassTransitWorkflowDispatcher.cs +++ b/src/modules/Elsa.MassTransit/Services/MassTransitWorkflowDispatcher.cs @@ -25,6 +25,7 @@ public class MassTransitWorkflowDispatcher( IStimulusHasher stimulusHasher, ITriggerStore triggerStore, IBookmarkStore bookmarkStore, + IPayloadSerializer jsonSerializer, ILogger logger) : IWorkflowDispatcher { @@ -53,6 +54,7 @@ public class MassTransitWorkflowDispatcher( public async Task DispatchAsync(DispatchWorkflowInstanceRequest request, DispatchWorkflowOptions? options = default, CancellationToken cancellationToken = default) { var sendEndpoint = await GetSendEndpointAsync(options); + var serializedInput = SerializeInput(request.Input); await sendEndpoint.Send(new DispatchWorkflowInstance(request.InstanceId) { @@ -173,4 +175,9 @@ public class MassTransitWorkflowDispatcher( var sendEndpoint = await bus.GetSendEndpoint(new Uri($"queue:{endpointName}")); return sendEndpoint; } + + private string? SerializeInput(object? input) + { + return input != null ? jsonSerializer.Serialize(input) : null; + } } \ No newline at end of file diff --git a/src/modules/Elsa.MongoDb/Elsa.MongoDb.csproj b/src/modules/Elsa.MongoDb/Elsa.MongoDb.csproj index 91fc3bdff..2a84690bf 100644 --- a/src/modules/Elsa.MongoDb/Elsa.MongoDb.csproj +++ b/src/modules/Elsa.MongoDb/Elsa.MongoDb.csproj @@ -13,6 +13,11 @@ + + + + + diff --git a/src/modules/Elsa.OpenTelemetry/Elsa.OpenTelemetry.csproj b/src/modules/Elsa.OpenTelemetry/Elsa.OpenTelemetry.csproj index 8660d883e..dc06e309b 100644 --- a/src/modules/Elsa.OpenTelemetry/Elsa.OpenTelemetry.csproj +++ b/src/modules/Elsa.OpenTelemetry/Elsa.OpenTelemetry.csproj @@ -11,8 +11,4 @@ - - - - \ No newline at end of file diff --git a/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingActivityExecutionMiddleware.cs b/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingActivityExecutionMiddleware.cs index 4c8e1e1aa..043a11092 100644 --- a/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingActivityExecutionMiddleware.cs +++ b/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingActivityExecutionMiddleware.cs @@ -1,36 +1,82 @@ using System.Diagnostics; +using Elsa.Common.Contracts; using Elsa.OpenTelemetry.Helpers; using Elsa.Workflows; +using Elsa.Workflows.Contracts; using Elsa.Workflows.Pipelines.ActivityExecution; using Elsa.Workflows.Pipelines.WorkflowExecution; using JetBrains.Annotations; +using Newtonsoft.Json; using Activity = System.Diagnostics.Activity; using ActivityKind = System.Diagnostics.ActivityKind; namespace Elsa.OpenTelemetry.Middleware; +/// [UsedImplicitly] -public class OpenTelemetryTracingActivityExecutionMiddleware(ActivityMiddlewareDelegate next) : IActivityExecutionMiddleware +public class OpenTelemetryTracingActivityExecutionMiddleware(ActivityMiddlewareDelegate next, ISystemClock systemClock) : IActivityExecutionMiddleware { + /// public async ValueTask InvokeAsync(ActivityExecutionContext context) { var activity = context.Activity; - using var span = ElsaOpenTelemetry.ActivitySource.StartActivity($"ActivityExecution {context.ActivityDescriptor.TypeName}", ActivityKind.Internal, Activity.Current?.Context ?? default); - span?.AddTag("activity.nodeId", activity.NodeId); - span?.AddTag("activity.type", activity.Type); - span?.AddTag("activity.name", activity.Name); - span?.AddTag("activityInstance.id", context.Id); - span?.AddTag("activityInstance.originalStatus", context.Status.ToString()); - span?.AddEvent(new ActivityEvent("Executing")); + using var span = ElsaOpenTelemetry.ActivitySource.StartActivity($"ActivityExecution", ActivityKind.Internal, Activity.Current?.Context ?? default); + + if (span == null) + { + await next(context); + return; + } + + span.SetTag("activity.nodeId", activity.NodeId); + span.SetTag("activity.type", activity.Type); + span.SetTag("activity.name", activity.Name); + span.SetTag("activityInstance.id", context.Id); + span.SetTag("activityExecution.startTimeUtc", span.StartTimeUtc); + + span.AddEvent(new ActivityEvent("Executing", tags: CreateStatusTags(context))); + await next(context); - span?.AddEvent(new ActivityEvent("Executed")); - span?.AddTag("activityInstance.newStatus", context.Status.ToString()); + + if (context.Status == ActivityStatus.Faulted) + { + span.AddEvent(new ActivityEvent("Faulted", tags: CreateStatusTags(context))); + span.SetStatus(ActivityStatusCode.Error); + span.SetTag("error", true); + span.SetTag("activityInstance.hasIncidents", true); + + var errorMessage = string.IsNullOrWhiteSpace(context.Exception?.Message) ? "Unknown error" : context.Exception.Message; + span.SetTag("error.message", errorMessage); + + if (!string.IsNullOrEmpty(context.Exception?.StackTrace)) + span.SetTag("error.stackTrace", context.Exception.StackTrace); + } + else + { + span.AddEvent(new ActivityEvent("Executed", tags: CreateStatusTags(context))); + span.SetStatus(ActivityStatusCode.Ok); + } + + var now = systemClock.UtcNow; + span.SetTag("activityExecution.endTimeUtc", now); + span.SetTag("activityExecution.durationMs", (now - span.StartTimeUtc).TotalMilliseconds); + } + + private ActivityTagsCollection CreateStatusTags(ActivityExecutionContext context) + { + return new ActivityTagsCollection(new Dictionary + { + ["activityInstance.status"] = context.Status.ToString() + }); } } +/// +/// Contains extension methods for . +/// [UsedImplicitly] public static class OpenTelemetryTracingActivityExecutionMiddlewareExtensions { /// Installs the component in the workflow execution pipeline. public static IActivityExecutionPipelineBuilder UseActivityExecutionTracing(this IActivityExecutionPipelineBuilder pipelineBuilder) => pipelineBuilder.Insert(0); -} +} \ No newline at end of file diff --git a/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingWorkflowExecutionMiddleware.cs b/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingWorkflowExecutionMiddleware.cs index b2a599765..f5d20b7c5 100644 --- a/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingWorkflowExecutionMiddleware.cs +++ b/src/modules/Elsa.OpenTelemetry/Middleware/OpenTelemetryTracingWorkflowExecutionMiddleware.cs @@ -1,42 +1,103 @@ using System.Diagnostics; +using System.Text.Json; +using Elsa.Common.Contracts; +using Elsa.Expressions.Services; +using Elsa.Extensions; using Elsa.OpenTelemetry.Helpers; using Elsa.Workflows; +using Elsa.Workflows.Contracts; using Elsa.Workflows.Pipelines.WorkflowExecution; +using Elsa.Workflows.Serialization.Converters; using JetBrains.Annotations; using Activity = System.Diagnostics.Activity; using ActivityKind = System.Diagnostics.ActivityKind; namespace Elsa.OpenTelemetry.Middleware; +/// +/// Middleware that traces workflow execution using OpenTelemetry. +/// [UsedImplicitly] -public class OpenTelemetryTracingWorkflowExecutionMiddleware(WorkflowMiddlewareDelegate next) : WorkflowExecutionMiddleware(next) +public class OpenTelemetryTracingWorkflowExecutionMiddleware(WorkflowMiddlewareDelegate next, ISystemClock systemClock) : WorkflowExecutionMiddleware(next) { + private readonly JsonSerializerOptions? _incidentSerializerOptions = new JsonSerializerOptions().WithConverters(new TypeJsonConverter(WellKnownTypeRegistry.CreateDefault())); + + /// public override async ValueTask InvokeAsync(WorkflowExecutionContext context) { var workflowInstanceId = context.Id; var workflow = context.Workflow; - using var activity = ElsaOpenTelemetry.ActivitySource.StartActivity($"WorkflowExecution {workflow.WorkflowMetadata.Name}", ActivityKind.Internal, Activity.Current?.Context ?? default); + using var span = ElsaOpenTelemetry.ActivitySource.StartActivity("WorkflowExecution", ActivityKind.Internal, Activity.Current?.Context ?? default); + + if (span == null) // No listener is registered. + { + await Next(context); + return; + } + + span.SetTag("workflowInstance.id", workflowInstanceId); + span.SetTag("workflowDefinition.definitionId", workflow.Identity.DefinitionId); + span.SetTag("workflowDefinition.version", workflow.Identity.Version); + span.SetTag("workflowDefinition.name", workflow.WorkflowMetadata.Name); + span.SetTag("workflowExecution.startTimeUtc", span.StartTimeUtc); - if(!string.IsNullOrWhiteSpace(context.CorrelationId)) - activity?.AddTag("correlationId", context.CorrelationId); + if(context.TriggerActivityId != null) + { + var activity = context.FindActivityById(context.TriggerActivityId) ?? throw new Exception($"Trigger activity with ID {context.TriggerActivityId} not found. This should not happen."); + span.SetTag("workflowExecution.trigger.activityId", activity.Id); + span.SetTag("workflowExecution.trigger.activityName", activity.Name); + span.SetTag("workflowExecution.trigger.activityType", activity.Type); + } - activity?.AddTag("workflowInstance.id", workflowInstanceId); - activity?.AddTag("workflowDefinition.definitionId", workflow.Identity.DefinitionId); - activity?.AddTag("workflowDefinition.version", workflow.Identity.Version); - activity?.AddTag("tenantId", workflow.Identity.TenantId); - activity?.AddTag("workflowInstance.originalStatus", context.Status.ToString()); - activity?.AddTag("workflowInstance.originalSubStatus", context.SubStatus.ToString()); - activity?.AddEvent(new ActivityEvent("Executing")); + span.AddEvent(new ActivityEvent("Executing", tags: CreateStatusTags(context))); await Next(context); - activity?.AddEvent(new ActivityEvent("Executed")); - activity?.AddTag("workflowInstance.newStatus", context.Status.ToString()); - activity?.AddTag("workflowInstance.newSubStatus", context.SubStatus.ToString()); + + if (context.SubStatus == WorkflowSubStatus.Faulted) + { + span.AddEvent(new ActivityEvent("Faulted", tags: CreateStatusTags(context))); + span.SetStatus(ActivityStatusCode.Error); + span.SetTag("error", true); + } + else + { + span.AddEvent(new ActivityEvent("Executed", tags: CreateStatusTags(context))); + span.SetStatus(ActivityStatusCode.Ok); + } + + if(context.Incidents.Any()) + { + span.SetStatus(ActivityStatusCode.Error); + span.SetTag("workflowInstance.hasIncidents", true); + span.SetTag("error", true); + + if (context.Incidents.Count > 0) + span.SetTag("error.message", JsonSerializer.Serialize(context.Incidents, _incidentSerializerOptions)); + } + + if (!string.IsNullOrWhiteSpace(context.CorrelationId)) + span.SetTag("workflowInstance.correlationId", context.CorrelationId); + + var now = systemClock.UtcNow; + span.SetTag("workflowExecution.endTimeUtc", now); + span.SetTag("workflowExecution.durationMs", (now - span.StartTimeUtc).TotalMilliseconds); + } + + private ActivityTagsCollection CreateStatusTags(WorkflowExecutionContext context) + { + return new ActivityTagsCollection(new Dictionary + { + ["workflowInstance.status"] = context.Status.ToString(), + ["workflowInstance.subStatus"] = context.SubStatus.ToString() + }); } } +/// +/// Contains extension methods for . +/// [UsedImplicitly] public static class OpenTelemetryWorkflowExecutionMiddlewareExtensions { /// Installs the component in the workflow execution pipeline. public static IWorkflowExecutionPipelineBuilder UseWorkflowExecutionTracing(this IWorkflowExecutionPipelineBuilder pipelineBuilder) => pipelineBuilder.Insert(0); -} +} \ No newline at end of file diff --git a/src/modules/Elsa.Quartz.EntityFrameworkCore.MySql/Elsa.Quartz.EntityFrameworkCore.MySql.csproj b/src/modules/Elsa.Quartz.EntityFrameworkCore.MySql/Elsa.Quartz.EntityFrameworkCore.MySql.csproj index d0ac5a8fa..145a64880 100644 --- a/src/modules/Elsa.Quartz.EntityFrameworkCore.MySql/Elsa.Quartz.EntityFrameworkCore.MySql.csproj +++ b/src/modules/Elsa.Quartz.EntityFrameworkCore.MySql/Elsa.Quartz.EntityFrameworkCore.MySql.csproj @@ -14,6 +14,10 @@ + + + + diff --git a/src/modules/Elsa.Quartz.EntityFrameworkCore.PostgreSql/Elsa.Quartz.EntityFrameworkCore.PostgreSql.csproj b/src/modules/Elsa.Quartz.EntityFrameworkCore.PostgreSql/Elsa.Quartz.EntityFrameworkCore.PostgreSql.csproj index 43788dd8e..8f6d2c9c6 100644 --- a/src/modules/Elsa.Quartz.EntityFrameworkCore.PostgreSql/Elsa.Quartz.EntityFrameworkCore.PostgreSql.csproj +++ b/src/modules/Elsa.Quartz.EntityFrameworkCore.PostgreSql/Elsa.Quartz.EntityFrameworkCore.PostgreSql.csproj @@ -15,6 +15,10 @@ + + + + diff --git a/src/modules/Elsa.Quartz.EntityFrameworkCore.SqlServer/Elsa.Quartz.EntityFrameworkCore.SqlServer.csproj b/src/modules/Elsa.Quartz.EntityFrameworkCore.SqlServer/Elsa.Quartz.EntityFrameworkCore.SqlServer.csproj index f150fdcdb..c6ede3ade 100644 --- a/src/modules/Elsa.Quartz.EntityFrameworkCore.SqlServer/Elsa.Quartz.EntityFrameworkCore.SqlServer.csproj +++ b/src/modules/Elsa.Quartz.EntityFrameworkCore.SqlServer/Elsa.Quartz.EntityFrameworkCore.SqlServer.csproj @@ -21,8 +21,9 @@ - - - + + + + \ No newline at end of file diff --git a/src/modules/Elsa.Quartz.EntityFrameworkCore.Sqlite/Elsa.Quartz.EntityFrameworkCore.Sqlite.csproj b/src/modules/Elsa.Quartz.EntityFrameworkCore.Sqlite/Elsa.Quartz.EntityFrameworkCore.Sqlite.csproj index 63abad7b2..1a5ad7e07 100644 --- a/src/modules/Elsa.Quartz.EntityFrameworkCore.Sqlite/Elsa.Quartz.EntityFrameworkCore.Sqlite.csproj +++ b/src/modules/Elsa.Quartz.EntityFrameworkCore.Sqlite/Elsa.Quartz.EntityFrameworkCore.Sqlite.csproj @@ -13,6 +13,11 @@ + + + + + diff --git a/src/modules/Elsa.Telnyx/Elsa.Telnyx.csproj b/src/modules/Elsa.Telnyx/Elsa.Telnyx.csproj index 12026827f..a3c269bb2 100644 --- a/src/modules/Elsa.Telnyx/Elsa.Telnyx.csproj +++ b/src/modules/Elsa.Telnyx/Elsa.Telnyx.csproj @@ -24,7 +24,7 @@ - + diff --git a/src/modules/Elsa.WorkflowProviders.BlobStorage/Elsa.WorkflowProviders.BlobStorage.csproj b/src/modules/Elsa.WorkflowProviders.BlobStorage/Elsa.WorkflowProviders.BlobStorage.csproj index 6e6ed94eb..ab9102873 100644 --- a/src/modules/Elsa.WorkflowProviders.BlobStorage/Elsa.WorkflowProviders.BlobStorage.csproj +++ b/src/modules/Elsa.WorkflowProviders.BlobStorage/Elsa.WorkflowProviders.BlobStorage.csproj @@ -17,7 +17,7 @@ - + diff --git a/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj b/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj index c216dc49d..c41a9e2d3 100644 --- a/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj +++ b/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj @@ -15,4 +15,8 @@ + + + + diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutionSummaries/ListSummaries/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutionSummaries/ListSummaries/Endpoint.cs new file mode 100644 index 000000000..55638298e --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutionSummaries/ListSummaries/Endpoint.cs @@ -0,0 +1,38 @@ +using Elsa.Abstractions; +using Elsa.Common.Entities; +using Elsa.Models; +using Elsa.Workflows.Runtime.Contracts; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Filters; +using Elsa.Workflows.Runtime.OrderDefinitions; +using JetBrains.Annotations; + +namespace Elsa.Workflows.Api.Endpoints.ActivityExecutionSummaries.ListSummaries; + +/// +/// Lists a summary view of the executions for a given activity. +/// +[PublicAPI] +internal class Endpoint(IActivityExecutionStore store) : ElsaEndpoint> +{ + /// + public override void Configure() + { + Get("/activity-execution-summaries/list"); + ConfigurePermissions("read:activity-execution"); + } + + /// + public override async Task> ExecuteAsync(Request request, CancellationToken cancellationToken) + { + var filter = new ActivityExecutionRecordFilter + { + WorkflowInstanceId = request.WorkflowInstanceId, + ActivityNodeId = request.ActivityNodeId, + Completed = request.Completed + }; + var order = new ActivityExecutionRecordOrder(x => x.StartedAt, OrderDirection.Ascending); + var records = (await store.FindManySummariesAsync(filter, order, cancellationToken)).ToList(); + return new ListResponse(records); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutionSummaries/ListSummaries/Models.cs b/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutionSummaries/ListSummaries/Models.cs new file mode 100644 index 000000000..b90456857 --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutionSummaries/ListSummaries/Models.cs @@ -0,0 +1,22 @@ +namespace Elsa.Workflows.Api.Endpoints.ActivityExecutionSummaries.ListSummaries; + +/// +/// A request for a list of activity execution log record summaries for a given activity in a workflow instance. +/// +internal class Request +{ + /// + /// The ID of the workflow instance to get the execution log for. + /// + public string WorkflowInstanceId { get; set; } = default!; + + /// + /// The node ID of the activity to get the execution record for. + /// + public string ActivityNodeId { get; set; } = default!; + + /// + /// Whether to include completed activity execution records. If not specified, all activity execution records will be included. + /// + public bool? Completed { get; set; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutions/Get/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutions/Get/Endpoint.cs new file mode 100644 index 000000000..8828ebb8b --- /dev/null +++ b/src/modules/Elsa.Workflows.Api/Endpoints/ActivityExecutions/Get/Endpoint.cs @@ -0,0 +1,41 @@ +using Elsa.Abstractions; +using Elsa.Workflows.Runtime.Contracts; +using Elsa.Workflows.Runtime.Entities; +using Elsa.Workflows.Runtime.Filters; +using JetBrains.Annotations; + +namespace Elsa.Workflows.Api.Endpoints.ActivityExecutions.Get; + +/// +/// Gets an individual execution for a given activity. +/// +[PublicAPI] +internal class Endpoint(IActivityExecutionStore store) : ElsaEndpointWithoutRequest +{ + /// + public override void Configure() + { + Get("/activity-executions/{id}"); + ConfigurePermissions("read:activity-execution"); + } + + /// + public override async Task HandleAsync(CancellationToken cancellationToken) + { + var id = Route("id"); + var filter = new ActivityExecutionRecordFilter + { + Id = id + }; + + var record = await store.FindAsync(filter, cancellationToken); + + if (record == null) + { + await SendNotFoundAsync(cancellationToken); + return; + } + + await SendOkAsync(record, cancellationToken); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/StorageDrivers/List/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/StorageDrivers/List/Endpoint.cs index a38030ea5..207501e21 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/StorageDrivers/List/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/StorageDrivers/List/Endpoint.cs @@ -31,7 +31,7 @@ public class List : ElsaEndpointWithoutRequest public override Task ExecuteAsync(CancellationToken ct) { var drivers = _registry.List(); - var descriptors = drivers.Select(FromDriver).ToList(); + var descriptors = drivers.Select(FromDriver).OrderByDescending(x => x.Priority).ToList(); var response = new Response(descriptors); return Task.FromResult(response); @@ -40,7 +40,9 @@ public class List : ElsaEndpointWithoutRequest private static StorageDriverDescriptor FromDriver(IStorageDriver driver) { var type = driver.GetType(); + var deprecated = type.GetCustomAttribute() != null; var displayName = type.GetCustomAttribute()?.Name ?? type.GetCustomAttribute()?.DisplayName ?? type.Name.Replace("StorageDriver", ""); - return new StorageDriverDescriptor(type.GetSimpleAssemblyQualifiedName(), displayName); + var priority = driver.Priority; + return new StorageDriverDescriptor(type.GetSimpleAssemblyQualifiedName(), displayName, priority, deprecated); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/StorageDrivers/List/Models.cs b/src/modules/Elsa.Workflows.Api/Endpoints/StorageDrivers/List/Models.cs index 127a1ce68..c52464164 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/StorageDrivers/List/Models.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/StorageDrivers/List/Models.cs @@ -5,4 +5,4 @@ public class Response(ICollection items) public ICollection Items { get; set; } = items; } -public record StorageDriverDescriptor(string TypeName, string DisplayName); \ No newline at end of file +public record StorageDriverDescriptor(string TypeName, string DisplayName, double Priority = 0, bool Deprecated = false); \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Export/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Export/Endpoint.cs index 8d86392ad..ccfd79067 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Export/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Export/Endpoint.cs @@ -123,8 +123,8 @@ internal class Export : ElsaEndpointWithMapper var bookmarks = request.IncludeBookmarks ? await LoadBookmarksAsync(workflowState.Id, cancellationToken) : null; var workflowStateElement = _workflowStateSerializer.SerializeToElement(workflowState); var bookmarksElement = bookmarks != null ? SerializeBookmarks(bookmarks) : default(JsonElement?); - var executionLogRecordsElement = executionLogRecords != null ? await _safeSerializer.SerializeToElementAsync(executionLogRecords, cancellationToken) : default(JsonElement?); - var activityExecutionLogRecordsElement = activityExecutionLogRecords != null ? await _safeSerializer.SerializeToElementAsync(activityExecutionLogRecords, cancellationToken) : default(JsonElement?); + var executionLogRecordsElement = executionLogRecords != null ? _safeSerializer.SerializeToElement(executionLogRecords, cancellationToken) : default(JsonElement?); + var activityExecutionLogRecordsElement = activityExecutionLogRecords != null ? _safeSerializer.SerializeToElement(activityExecutionLogRecords, cancellationToken) : default(JsonElement?); var model = new ExportedWorkflowState(workflowStateElement, bookmarksElement, activityExecutionLogRecordsElement, executionLogRecordsElement); return model; } diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Import/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Import/Endpoint.cs index db551b630..83af6f479 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Import/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Import/Endpoint.cs @@ -148,13 +148,13 @@ internal class Import : ElsaEndpointWithoutRequest if (model.ActivityExecutionRecords != null) { - var activityExecutionRecords = await _safeSerializer.DeserializeAsync>(model.ActivityExecutionRecords.Value, cancellationToken); + var activityExecutionRecords = _safeSerializer.Deserialize>(model.ActivityExecutionRecords.Value, cancellationToken); await _activityExecutionStore.SaveManyAsync(activityExecutionRecords, cancellationToken); } if (model.WorkflowExecutionLogRecords != null) { - var workflowExecutionLogRecords = await _safeSerializer.DeserializeAsync>(model.WorkflowExecutionLogRecords.Value, cancellationToken); + var workflowExecutionLogRecords = _safeSerializer.Deserialize>(model.WorkflowExecutionLogRecords.Value, cancellationToken); await _workflowExecutionLogStore.SaveManyAsync(workflowExecutionLogRecords, cancellationToken); } } diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Journal/GetLastEntry/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Journal/GetLastEntry/Endpoint.cs index 64dd32871..0c7378351 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Journal/GetLastEntry/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/Journal/GetLastEntry/Endpoint.cs @@ -12,16 +12,8 @@ namespace Elsa.Workflows.Api.Endpoints.WorkflowInstances.Journal.GetLastEntry; /// Return the last log entry for the specified workflow instance and activity ID. /// [PublicAPI] -public class Get : ElsaEndpoint +public class Get(IWorkflowExecutionLogStore store) : ElsaEndpoint { - private readonly IWorkflowExecutionLogStore _store; - - /// - public Get(IWorkflowExecutionLogStore store) - { - _store = store; - } - /// public override void Configure() { @@ -36,7 +28,7 @@ public class Get : ElsaEndpoint { WorkflowInstanceId = request.WorkflowInstanceId, ActivityId = request.ActivityId, - EventNames = new[] { "Started", "Completed", "Faulted" } + EventNames = ["Started", "Completed", "Faulted"] }; var sort = new WorkflowExecutionLogRecordOrder( @@ -44,7 +36,7 @@ public class Get : ElsaEndpoint OrderDirection.Descending ); - var entry = await _store.FindAsync(filter, sort, cancellationToken); + var entry = await store.FindAsync(filter, sort, cancellationToken); if (entry == null) { diff --git a/src/modules/Elsa.Workflows.Core/Activities/Composite.cs b/src/modules/Elsa.Workflows.Core/Activities/Composite.cs index f702057a8..6661adb95 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Composite.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Composite.cs @@ -17,7 +17,7 @@ namespace Elsa.Workflows.Activities; /// Represents a composite activity that has a single activity. Like a workflow, but without workflow-level properties. /// [PublicAPI] -public abstract class Composite : Activity, IVariableContainer +public abstract class Composite : Activity, IVariableContainer, IComposite { /// protected Composite(string? source = default, int? line = default) : base(source, line) @@ -172,6 +172,10 @@ public abstract class Composite : Activity, IVariableContainer /// Creates a new activity. /// protected static SetVariable SetVariable(Variable variable, Variable value, [CallerFilePath] string? source = default, [CallerLineNumber] int? line = default) => new(variable, value, source, line); + + public virtual void Setup() + { + } } /// diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IComposite.cs b/src/modules/Elsa.Workflows.Core/Contracts/IComposite.cs new file mode 100644 index 000000000..6953b59ef --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contracts/IComposite.cs @@ -0,0 +1,6 @@ +namespace Elsa.Workflows.Contracts; + +public interface IComposite +{ + void Setup(); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IPayloadSerializer.cs b/src/modules/Elsa.Workflows.Core/Contracts/IPayloadSerializer.cs index 5768049ea..762765bf4 100644 --- a/src/modules/Elsa.Workflows.Core/Contracts/IPayloadSerializer.cs +++ b/src/modules/Elsa.Workflows.Core/Contracts/IPayloadSerializer.cs @@ -48,4 +48,9 @@ public interface IPayloadSerializer /// The serialized state. /// The deserialized state. T Deserialize(JsonElement serializedData); + + /// + /// Gets the JSON serializer options. + /// + JsonSerializerOptions GetOptions(); } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contracts/ISafeSerializer.cs b/src/modules/Elsa.Workflows.Core/Contracts/ISafeSerializer.cs index e241694d3..1bf90ac24 100644 --- a/src/modules/Elsa.Workflows.Core/Contracts/ISafeSerializer.cs +++ b/src/modules/Elsa.Workflows.Core/Contracts/ISafeSerializer.cs @@ -11,24 +11,57 @@ public interface ISafeSerializer /// /// Serializes the specified state. /// + [Obsolete("Use the non-async Serialize instead.")] [RequiresUnreferencedCode("The type T may be trimmed.")] ValueTask SerializeAsync(object? value, CancellationToken cancellationToken = default); /// /// Serializes the specified state to a object. /// + [Obsolete("Use the non-async SerializeToElement instead.")] [RequiresUnreferencedCode("The type T may be trimmed.")] ValueTask SerializeToElementAsync(object? value, CancellationToken cancellationToken = default); /// /// Deserializes the specified state. /// + [Obsolete("Use the non-async Deserialize instead.")] [RequiresUnreferencedCode("The type T may be trimmed.")] ValueTask DeserializeAsync(string json, CancellationToken cancellationToken = default); /// /// Deserializes the specified state. /// + [Obsolete("Use the non-async Deserialize instead.")] [RequiresUnreferencedCode("The type T may be trimmed.")] ValueTask DeserializeAsync(JsonElement element, CancellationToken cancellationToken = default); + + /// + /// Serializes the specified state. + /// + [RequiresUnreferencedCode("The type T may be trimmed.")] + string Serialize(object? value, CancellationToken cancellationToken = default); + + /// + /// Serializes the specified state to a object. + /// + [RequiresUnreferencedCode("The type T may be trimmed.")] + JsonElement SerializeToElement(object? value, CancellationToken cancellationToken = default); + + /// + /// Deserializes the specified state. + /// + [RequiresUnreferencedCode("The type T may be trimmed.")] + T Deserialize(string json, CancellationToken cancellationToken = default); + + /// + /// Deserializes the specified state. + /// + [RequiresUnreferencedCode("The type T may be trimmed.")] + T Deserialize(JsonElement element, CancellationToken cancellationToken = default); + + /// + /// Gets the JSON serializer options. + /// + JsonSerializerOptions GetOptions(); } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IStorageDriver.cs b/src/modules/Elsa.Workflows.Core/Contracts/IStorageDriver.cs index d17ab4735..50ca0eec2 100644 --- a/src/modules/Elsa.Workflows.Core/Contracts/IStorageDriver.cs +++ b/src/modules/Elsa.Workflows.Core/Contracts/IStorageDriver.cs @@ -5,6 +5,11 @@ namespace Elsa.Workflows.Contracts; /// public interface IStorageDriver { + /// + /// The priority of the storage driver. Drivers with higher priority are used before drivers with lower priority. + /// + double Priority { get; } + /// /// Writes a value to the storage driver. /// diff --git a/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowStateSerializer.cs b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowStateSerializer.cs index 4a4fd0a58..b55fd8150 100644 --- a/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowStateSerializer.cs +++ b/src/modules/Elsa.Workflows.Core/Contracts/IWorkflowStateSerializer.cs @@ -13,27 +13,70 @@ public interface IWorkflowStateSerializer /// /// The workflow state to serialize. /// The serialized workflow state. + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The serialization process may require access to the type.")] + [Obsolete("Use the non-async version Serialize instead.")] + Task SerializeAsync(WorkflowState workflowState, CancellationToken cancellationToken = default); + + /// + /// Serializes the specified workflow state. + /// + /// The workflow state to serialize. + /// The serialized workflow state. + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The serialization process may require access to the type.")] string Serialize(WorkflowState workflowState); /// /// Serializes the specified workflow state. /// /// The workflow state to serialize. + /// The cancellation token. /// The serialized workflow state. + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The serialization process may require access to the type.")] + [Obsolete("Use the non-async version SerializeToUtfBytes instead.")] + Task SerializeToUtfBytesAsync(WorkflowState workflowState, CancellationToken cancellationToken = default); + + /// + /// Serializes the specified workflow state. + /// + /// The workflow state to serialize. + /// The serialized workflow state. + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The serialization process may require access to the type.")] byte[] SerializeToUtfBytes(WorkflowState workflowState); /// /// Serializes the specified workflow state. /// /// The workflow state to serialize. + /// The cancellation token. /// The serialized workflow state. - JsonElement SerializeToElement(WorkflowState workflowState); + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The serialization process may require access to the type.")] + [Obsolete("Use the non-async version SerializeToElement instead.")] + Task SerializeToElementAsync(WorkflowState workflowState, CancellationToken cancellationToken = default); /// /// Serializes the specified workflow state. /// /// The workflow state to serialize. /// The serialized workflow state. + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The serialization process may require access to the type.")] + JsonElement SerializeToElement(WorkflowState workflowState); + + /// + /// Serializes the specified workflow state. + /// + /// The workflow state to serialize. + /// The cancellation token. + /// The serialized workflow state. + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The serialization process may require access to the type.")] + [Obsolete("Use the non-async version Serialize instead.")] + Task SerializeAsync(object workflowState, CancellationToken cancellationToken = default); + + /// + /// Serializes the specified workflow state. + /// + /// The workflow state to serialize. + /// The serialized workflow state. + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The serialization process may require access to the type.")] string Serialize(object workflowState); /// @@ -41,19 +84,52 @@ public interface IWorkflowStateSerializer /// /// The serialized state. /// The deserialized workflow state. + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The deserialization process may require access to the type.")] + [Obsolete("Use the non-async version Deserialize instead.")] + Task DeserializeAsync(string serializedState, CancellationToken cancellationToken = default); + + /// + /// Deserializes the specified serialized state. + /// + /// The serialized state. + /// The deserialized workflow state. + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The deserialization process may require access to the type.")] WorkflowState Deserialize(string serializedState); /// /// Deserializes the specified serialized state. /// /// The serialized state. + /// The cancellation token. /// The deserialized workflow state. - WorkflowState Deserialize(JsonElement serializedState); + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The deserialization process may require access to the type.")] + [Obsolete("Use the non-async version Deserialize instead.")] + Task DeserializeAsync(JsonElement serializedState, CancellationToken cancellationToken = default); /// /// Deserializes the specified serialized state. /// /// The serialized state. /// The deserialized workflow state. + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The deserialization process may require access to the type.")] + WorkflowState Deserialize(JsonElement serializedState); + + /// + /// Deserializes the specified serialized state. + /// + /// The serialized state. + /// The cancellation token. + /// The deserialized workflow state. + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The deserialization process may require access to the type.")] + [Obsolete("Use the non-async version Deserialize instead.")] + Task DeserializeAsync(string serializedState, CancellationToken cancellationToken = default); + + /// + /// Deserializes the specified serialized state. + /// + /// The serialized state. + /// The cancellation token. + /// The deserialized workflow state. + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The deserialization process may require access to the type.")] T Deserialize(string serializedState); } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs index a075530c0..c66e759bf 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs @@ -108,6 +108,47 @@ public static partial class ActivityExecutionContextExtensions /// public static IDictionary GetVariableValues(this ActivityExecutionContext activityExecutionContext) => activityExecutionContext.ExpressionExecutionContext.ReadAndFlattenMemoryBlocks(); + /// + /// Evaluates each input property of the activity. + /// + public static async Task EvaluateInputPropertiesAsync(this ActivityExecutionContext context) + { + var activityDescriptor = context.ActivityDescriptor; + var inputDescriptors = activityDescriptor.Inputs.Where(x => x.AutoEvaluate).ToList(); + + // Evaluate inputs. + foreach (var inputDescriptor in inputDescriptors) + await EvaluateInputPropertyAsync(context, activityDescriptor, inputDescriptor); + + context.SetHasEvaluatedProperties(); + } + + /// + /// Evaluates the specified input property of the activity. + /// + public static async Task EvaluateInputPropertyAsync(this ActivityExecutionContext context, Expression>> propertyExpression) + { + var inputName = propertyExpression.GetProperty()!.Name; + var input = await EvaluateInputPropertyAsync(context, inputName); + return input.ConvertTo(); + } + + /// + /// Evaluates a specific input property of the activity. + /// + public static async Task EvaluateInputPropertyAsync(this ActivityExecutionContext context, string inputName) + { + var activity = context.Activity; + var activityRegistryLookup = context.GetRequiredService(); + var activityDescriptor = await activityRegistryLookup.FindAsync(activity.Type) ?? throw new Exception($"Activity descriptor \"{activity.Type}\" not found"); + var inputDescriptor = activityDescriptor.GetWrappedInputPropertyDescriptor(activity, inputName); + + if (inputDescriptor == null) + throw new Exception($"No input with name \"{inputName}\" could be found"); + + return await EvaluateInputPropertyAsync(context, activityDescriptor, inputDescriptor); + } + /// /// Returns a set of tuples containing the activity and its descriptor for all activities with outputs. /// @@ -164,6 +205,59 @@ public static partial class ActivityExecutionContextExtensions return node?.Activity; } + private static async Task EvaluateInputPropertyAsync(this ActivityExecutionContext context, ActivityDescriptor activityDescriptor, InputDescriptor inputDescriptor) + { + var activity = context.Activity; + var defaultValue = inputDescriptor.DefaultValue; + var value = defaultValue; + var input = inputDescriptor.ValueGetter(activity); + + if (inputDescriptor.IsWrapped) + { + var wrappedInput = (Input?)input; + + if (defaultValue != null && wrappedInput == null) + { + var typedInput = typeof(Input<>).MakeGenericType(inputDescriptor.Type); + var valueExpression = new Literal(defaultValue) + { + Id = Guid.NewGuid().ToString() + }; + wrappedInput = (Input)Activator.CreateInstance(typedInput, valueExpression)!; + inputDescriptor.ValueSetter(activity, wrappedInput); + } + else + { + var evaluator = context.GetRequiredService(); + var expressionExecutionContext = context.ExpressionExecutionContext; + value = wrappedInput?.Expression != null ? await evaluator.EvaluateAsync(wrappedInput, expressionExecutionContext) : defaultValue; + } + + var memoryReference = wrappedInput?.MemoryBlockReference(); + + // When input is created from an activity provider, there may be no memory block reference. + if (memoryReference?.Id != null!) + { + // Declare the input memory block on the current context. + context.ExpressionExecutionContext.Set(memoryReference, value!); + } + } + else + { + value = input; + } + + // Store the serialized input value in the activity state. + // Serializing the value ensures we store a copy of the value and not a reference to the input, which may change over time. + if (inputDescriptor.IsSerializable != false) + { + var serializedValue = context.GetRequiredService().SerializeToElement(value); + context.ActivityState[inputDescriptor.Name] = serializedValue; + } + + return value; + } + /// /// Returns the outcome name for the specified port property name. /// @@ -297,7 +391,7 @@ public static partial class ActivityExecutionContextExtensions if (outputValue == null!) continue; - var serializedOutputValue = await serializer.SerializeAsync(outputValue, cancellationToken); + var serializedOutputValue = serializer.Serialize(outputValue, cancellationToken); context.JournalData[outputName] = serializedOutputValue; } diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs index a4e5f6b61..221082302 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs @@ -140,7 +140,7 @@ public static class ExpressionExecutionContextExtensions var variable = new Variable(name, value) { - StorageDriverType = storageDriverType ?? typeof(WorkflowStorageDriver) + StorageDriverType = storageDriverType ?? typeof(WorkflowInstanceStorageDriver) }; // Find the first parent context that has a variable container. diff --git a/src/modules/Elsa.Workflows.Core/Extensions/VariableExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/VariableExtensions.cs index 1b9f9f218..8d98d0618 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/VariableExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/VariableExtensions.cs @@ -31,14 +31,14 @@ public static class VariableExtensions new ExpandoObjectConverterFactory()); /// - /// Configures the variable to use the . + /// Configures the variable to use the . /// - public static Variable WithWorkflowStorage(this Variable variable) => variable.WithStorage(); + public static Variable WithWorkflowStorage(this Variable variable) => variable.WithStorage(); /// - /// Configures the variable to use the . + /// Configures the variable to use the . /// - public static Variable WithWorkflowStorage(this Variable variable) => (Variable)variable.WithStorage(); + public static Variable WithWorkflowStorage(this Variable variable) => (Variable)variable.WithStorage(); /// /// Configures the variable to use the . diff --git a/src/modules/Elsa.Workflows.Core/Pipelines/ActivityExecution/ActivityExecutionPipelinePipelineBuilder.cs b/src/modules/Elsa.Workflows.Core/Pipelines/ActivityExecution/ActivityExecutionPipelinePipelineBuilder.cs index 76850bbb9..7fe44c9ff 100644 --- a/src/modules/Elsa.Workflows.Core/Pipelines/ActivityExecution/ActivityExecutionPipelinePipelineBuilder.cs +++ b/src/modules/Elsa.Workflows.Core/Pipelines/ActivityExecution/ActivityExecutionPipelinePipelineBuilder.cs @@ -21,6 +21,13 @@ public class ActivityExecutionPipelinePipelineBuilder(IServiceProvider servicePr return this; } + /// + public IActivityExecutionPipelineBuilder Insert(int index, Func middleware) + { + _components.Insert(index, middleware); + return this; + } + /// public ActivityMiddlewareDelegate Build() { diff --git a/src/modules/Elsa.Workflows.Core/Pipelines/WorkflowExecution/WorkflowExecutionPipelineBuilder.cs b/src/modules/Elsa.Workflows.Core/Pipelines/WorkflowExecution/WorkflowExecutionPipelineBuilder.cs index 6215c720c..fa32b2584 100644 --- a/src/modules/Elsa.Workflows.Core/Pipelines/WorkflowExecution/WorkflowExecutionPipelineBuilder.cs +++ b/src/modules/Elsa.Workflows.Core/Pipelines/WorkflowExecution/WorkflowExecutionPipelineBuilder.cs @@ -52,12 +52,14 @@ public class WorkflowExecutionPipelineBuilder : IWorkflowExecutionPipelineBuilde return this; } + /// public IWorkflowExecutionPipelineBuilder Insert(int index, Func middleware) { _components.Insert(index, middleware); return this; } + /// public IWorkflowExecutionPipelineBuilder Replace(int index, Func middleware) { _components[index] = middleware; diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs index 3a6e66f45..c4ce3984b 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/PolymorphicObjectConverter.cs @@ -93,9 +93,9 @@ public class PolymorphicObjectConverter(IWellKnownTypeRegistry wellKnownTypeRegi if (isDictionary) { // Remove the _type property name from the JSON, if any. - var parsedModel = (JsonObject)JsonNode.Parse(ref reader)!; - parsedModel.Remove(TypePropertyName); - return parsedModel.Deserialize(targetType, newOptions)!; + var parsedNode = JsonNode.Parse(ref reader)!; + if (parsedNode is JsonObject parsedModel) parsedModel.Remove(TypePropertyName); + return parsedNode.Deserialize(targetType, newOptions)!; } var isCollection = typeof(ICollection).IsAssignableFrom(targetType); @@ -291,6 +291,9 @@ public class PolymorphicObjectConverter(IWellKnownTypeRegistry wellKnownTypeRegi private Type? ReadType(Utf8JsonReader reader) { + if (reader.TokenType != JsonTokenType.StartObject) + return null; + reader.Read(); // Move to the first token inside the object. string? typeName = null; diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/TypeJsonConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/TypeJsonConverter.cs index 3442a9c11..dee636927 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/TypeJsonConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/TypeJsonConverter.cs @@ -9,7 +9,7 @@ namespace Elsa.Workflows.Serialization.Converters; /// /// Serializes objects to a simple alias representing the type. /// -[PublicAPI] +[UsedImplicitly] public class TypeJsonConverter : JsonConverter { private readonly IWellKnownTypeRegistry _wellKnownTypeRegistry; diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonPayloadSerializer.cs b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonPayloadSerializer.cs index cba5c7c01..820893889 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonPayloadSerializer.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonPayloadSerializer.cs @@ -25,14 +25,14 @@ public class JsonPayloadSerializer : IPayloadSerializer /// public string Serialize(object payload) { - var options = GetPayloadSerializerOptions(); + var options = GetOptions(); return JsonSerializer.Serialize(payload, options); } /// public JsonElement SerializeToElement(object payload) { - var options = GetPayloadSerializerOptions(); + var options = GetOptions(); return JsonSerializer.SerializeToElement(payload, options); } @@ -51,18 +51,19 @@ public class JsonPayloadSerializer : IPayloadSerializer /// public T Deserialize(string payload) { - var options = GetPayloadSerializerOptions(); + var options = GetOptions(); return JsonSerializer.Deserialize(payload, options)!; } /// public T Deserialize(JsonElement payload) { - var options = GetPayloadSerializerOptions(); + var options = GetOptions(); return payload.Deserialize(options)!; } - private JsonSerializerOptions GetPayloadSerializerOptions() + /// + public JsonSerializerOptions GetOptions() { var options = new JsonSerializerOptions { diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonWorkflowStateSerializer.cs b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonWorkflowStateSerializer.cs index 9c849e101..1db9a15a8 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonWorkflowStateSerializer.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonWorkflowStateSerializer.cs @@ -31,54 +31,96 @@ public class JsonWorkflowStateSerializer : ConfigurableSerializer, IWorkflowStat /// [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The serialization process may require access to the type.")] + [Obsolete("Use the non-async version Serialize instead.")] + public Task SerializeAsync(WorkflowState workflowState, CancellationToken cancellationToken = default) + { + return Task.FromResult(Serialize(workflowState, cancellationToken)); + } + + /// + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The serialization process may require access to the type.")] + [Obsolete("Use the non-async version SerializeToUtfBytes instead.")] + public Task SerializeToUtfBytesAsync(WorkflowState workflowState, CancellationToken cancellationToken = default) + { + return Task.FromResult(SerializeToUtfBytes(workflowState, cancellationToken)); + } + + /// + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The serialization process may require access to the type.")] + [Obsolete("Use the non-async version SerializeToElement instead.")] + public Task SerializeToElementAsync(WorkflowState workflowState, CancellationToken cancellationToken = default) + { + return Task.FromResult(SerializeToElement(workflowState, cancellationToken)); + } + + /// + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The serialization process may require access to the type.")] + [Obsolete("Use the non-async version Serialize instead.")] + public Task SerializeAsync(object workflowState, CancellationToken cancellationToken = default) + { + return Task.FromResult(Serialize(workflowState, cancellationToken)); + } + + /// + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The deserialization process may require access to the type.")] + [Obsolete("Use the non-async version Deserialize instead.")] + public Task DeserializeAsync(string serializedState, CancellationToken cancellationToken = default) + { + return Task.FromResult(Deserialize(serializedState, cancellationToken)); + } + + /// + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The deserialization process may require access to the type.")] + [Obsolete("Use the non-async version Deserialize instead.")] + public Task DeserializeAsync(JsonElement serializedState, CancellationToken cancellationToken = default) + { + return Task.FromResult(Deserialize(serializedState, cancellationToken)); + } + + /// + [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The deserialization process may require access to the type.")] + [Obsolete("Use the non-async version Deserialize instead.")] + public Task DeserializeAsync(string serializedState, CancellationToken cancellationToken = default) + { + return Task.FromResult(Deserialize(serializedState, cancellationToken)); + } + public string Serialize(WorkflowState workflowState) { var options = GetOptions(); return JsonSerializer.Serialize(workflowState, options); } - /// - [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The serialization process may require access to the type.")] public byte[] SerializeToUtfBytes(WorkflowState workflowState) { var options = GetOptions(); return JsonSerializer.SerializeToUtf8Bytes(workflowState, options); } - /// - [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The serialization process may require access to the type.")] public JsonElement SerializeToElement(WorkflowState workflowState) { var options = GetOptions(); return JsonSerializer.SerializeToElement(workflowState, options); } - /// - [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The deserialization process may require access to the type.")] public string Serialize(object workflowState) { var options = GetOptions(); return JsonSerializer.Serialize(workflowState, workflowState.GetType(), options); } - /// - [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The deserialization process may require access to the type.")] public WorkflowState Deserialize(string serializedState) { var options = GetOptions(); return JsonSerializer.Deserialize(serializedState, options)!; } - /// - [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The deserialization process may require access to the type.")] public WorkflowState Deserialize(JsonElement serializedState) { var options = GetOptions(); return serializedState.Deserialize(options)!; } - /// - [RequiresUnreferencedCode("The type 'T' may be trimmed from the output. The deserialization process may require access to the type.")] public T Deserialize(string serializedState) { var options = GetOptions(); diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/SafeSerializer.cs b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/SafeSerializer.cs index a9437dba0..6a868c337 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/SafeSerializer.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/SafeSerializer.cs @@ -22,39 +22,59 @@ public class SafeSerializer : ConfigurableSerializer, ISafeSerializer [RequiresUnreferencedCode("The type T may be trimmed.")] public ValueTask SerializeAsync(object? value, CancellationToken cancellationToken = default) { - var options = GetOptions(); - return ValueTask.FromResult(JsonSerializer.Serialize(value, options)); + return ValueTask.FromResult(Serialize(value, cancellationToken)); } /// [RequiresUnreferencedCode("The type T may be trimmed.")] public ValueTask SerializeToElementAsync(object? value, CancellationToken cancellationToken = default) { - var options = GetOptions(); - return new(JsonSerializer.SerializeToElement(value, options)); + return new(SerializeToElement(value, cancellationToken)); } /// [RequiresUnreferencedCode("The type T may be trimmed.")] public ValueTask DeserializeAsync(string json, CancellationToken cancellationToken = default) { - var options = GetOptions(); - return new(JsonSerializer.Deserialize(json, options)!); + return new(Deserialize(json, cancellationToken)); } /// [RequiresUnreferencedCode("The type T may be trimmed.")] public ValueTask DeserializeAsync(JsonElement element, CancellationToken cancellationToken = default) + { + return new(Deserialize(element, cancellationToken)); + } + + public string Serialize(object? value, CancellationToken cancellationToken = default) { var options = GetOptions(); - return new(element.Deserialize(options)!); + return JsonSerializer.Serialize(value, options); + } + + public JsonElement SerializeToElement(object? value, CancellationToken cancellationToken = default) + { + var options = GetOptions(); + return JsonSerializer.SerializeToElement(value, options); + } + + public T Deserialize(string json, CancellationToken cancellationToken = default) + { + var options = GetOptions(); + return JsonSerializer.Deserialize(json, options)!; + } + + public T Deserialize(JsonElement element, CancellationToken cancellationToken = default) + { + var options = GetOptions(); + return element.Deserialize(options)!; } /// protected override void AddConverters(JsonSerializerOptions options) { var expressionDescriptorRegistry = ServiceProvider.GetRequiredService(); - + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); options.Converters.Add(new TypeJsonConverter(WellKnownTypeRegistry.CreateDefault())); options.Converters.Add(new SafeValueConverterFactory()); diff --git a/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs b/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs index ded0fdda3..daf060ef6 100644 --- a/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs +++ b/src/modules/Elsa.Workflows.Core/Services/ActivityFactory.cs @@ -20,6 +20,9 @@ public class ActivityFactory : IActivityFactory var activityElement = context.Element; var activityDescriptor = context.ActivityDescriptor; var activity = (IActivity)context.Element.Deserialize(type, context.SerializerOptions)!; + var composite = activity as IComposite; + + composite?.Setup(); ReadSyntheticInputs(activityDescriptor, activity, activityElement, context.SerializerOptions); ReadSyntheticOutputs(activityDescriptor, activity, activityElement); diff --git a/src/modules/Elsa.Workflows.Core/Services/IdentityGraphService.cs b/src/modules/Elsa.Workflows.Core/Services/IdentityGraphService.cs index 6d95a38cd..b40faeaff 100644 --- a/src/modules/Elsa.Workflows.Core/Services/IdentityGraphService.cs +++ b/src/modules/Elsa.Workflows.Core/Services/IdentityGraphService.cs @@ -3,24 +3,13 @@ using Elsa.Workflows.Activities; using Elsa.Workflows.Contracts; using Elsa.Workflows.Models; using Humanizer; +using Microsoft.Extensions.Logging; namespace Elsa.Workflows; /// -public class IdentityGraphService : IIdentityGraphService +public class IdentityGraphService(IActivityVisitor activityVisitor, IActivityRegistryLookupService activityRegistryLookup, ILogger logger) : IIdentityGraphService { - private readonly IActivityVisitor _activityVisitor; - private readonly IActivityRegistryLookupService _activityRegistryLookup; - - /// - /// Constructor. - /// - public IdentityGraphService(IActivityVisitor activityVisitor, IActivityRegistryLookupService activityRegistryLookup) - { - _activityVisitor = activityVisitor; - _activityRegistryLookup = activityRegistryLookup; - } - /// public async Task AssignIdentitiesAsync(Workflow workflow, CancellationToken cancellationToken = default) { @@ -30,7 +19,7 @@ public class IdentityGraphService : IIdentityGraphService /// public async Task AssignIdentitiesAsync(IActivity root, CancellationToken cancellationToken = default) { - var graph = await _activityVisitor.VisitAsync(root, cancellationToken); + var graph = await activityVisitor.VisitAsync(root, cancellationToken); await AssignIdentitiesAsync(graph); } @@ -56,7 +45,14 @@ public class IdentityGraphService : IIdentityGraphService /// public async Task AssignInputOutputsAsync(IActivity activity) { - var activityDescriptor = await _activityRegistryLookup.FindAsync(activity.Type, activity.Version) ?? throw new Exception("Activity descriptor not found"); + var activityDescriptor = await activityRegistryLookup.FindAsync(activity.Type, activity.Version); + + if (activityDescriptor == null!) + { + logger.LogWarning("Activity descriptor not found for activity type {ActivityType}. Skipping identity assignment", activity.Type); + return; + } + var inputDictionary = activityDescriptor.GetWrappedInputProperties(activity); foreach (var (inputName, input) in inputDictionary) diff --git a/src/modules/Elsa.Workflows.Core/Services/MemoryStorageDriver.cs b/src/modules/Elsa.Workflows.Core/Services/MemoryStorageDriver.cs index faf3bec71..34792b591 100644 --- a/src/modules/Elsa.Workflows.Core/Services/MemoryStorageDriver.cs +++ b/src/modules/Elsa.Workflows.Core/Services/MemoryStorageDriver.cs @@ -11,6 +11,8 @@ public class MemoryStorageDriver : IStorageDriver { private readonly IDictionary _dictionary = new Dictionary(); + public double Priority => 0; + /// public ValueTask WriteAsync(string id, object value, StorageDriverContext context) { diff --git a/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs b/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs index cfd0b2106..900ef0002 100644 --- a/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs +++ b/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs @@ -71,7 +71,7 @@ public class WorkflowStateExtractor : IWorkflowStateExtractor private IDictionary GetPersistableInput(WorkflowExecutionContext workflowExecutionContext) { // TODO: This is a temporary solution. We need to find a better way to handle this. - var persistableInput = workflowExecutionContext.Workflow.Inputs.Where(x => x.StorageDriverType == typeof(WorkflowStorageDriver)).ToList(); + var persistableInput = workflowExecutionContext.Workflow.Inputs.Where(x => x.StorageDriverType == typeof(WorkflowStorageDriver) || x.StorageDriverType == typeof(WorkflowInstanceStorageDriver)).ToList(); var input = workflowExecutionContext.Input; var filteredInput = new Dictionary(); diff --git a/src/modules/Elsa.Workflows.Core/Services/WorkflowStorageDriver.cs b/src/modules/Elsa.Workflows.Core/Services/WorkflowStorageDriver.cs index 70c00bb0f..7992128bc 100644 --- a/src/modules/Elsa.Workflows.Core/Services/WorkflowStorageDriver.cs +++ b/src/modules/Elsa.Workflows.Core/Services/WorkflowStorageDriver.cs @@ -7,8 +7,8 @@ namespace Elsa.Workflows; /// /// A storage driver that stores objects in the workflow state itself. /// -[Display(Name = "Workflow (deprecated)")] -[Obsolete("This class is no longer used and will be removed in a future version.")] +[Display(Name = "Workflow")] +[Obsolete("This is no longer used and will be removed in a future version. Use the WorkflowInstanceStorageDriver instead.")] public class WorkflowStorageDriver : IStorageDriver { /// @@ -16,6 +16,9 @@ public class WorkflowStorageDriver : IStorageDriver /// public const string VariablesDictionaryStateKey = "PersistentVariablesDictionary"; + /// + public double Priority => -1; + /// public ValueTask WriteAsync(string id, object value, StorageDriverContext context) { diff --git a/src/modules/Elsa.Workflows.Runtime/Activities/BulkDispatchWorkflows.cs b/src/modules/Elsa.Workflows.Runtime/Activities/BulkDispatchWorkflows.cs index e5a7d15d8..6176472d6 100644 --- a/src/modules/Elsa.Workflows.Runtime/Activities/BulkDispatchWorkflows.cs +++ b/src/modules/Elsa.Workflows.Runtime/Activities/BulkDispatchWorkflows.cs @@ -206,7 +206,7 @@ public class BulkDispatchWorkflows : Activity var childInstanceId = new Variable("ChildInstanceId", workflowInstanceId) { - StorageDriverType = typeof(WorkflowStorageDriver) + StorageDriverType = typeof(WorkflowInstanceStorageDriver) }; var variables = new List diff --git a/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecordSummary.cs b/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecordSummary.cs index ae26ba8fe..9b13941cb 100644 --- a/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecordSummary.cs +++ b/src/modules/Elsa.Workflows.Runtime/Entities/ActivityExecutionRecordSummary.cs @@ -42,7 +42,7 @@ public class ActivityExecutionRecordSummary : Entity /// /// Gets or sets the time at which the activity execution began. /// - public DateTimeOffset StartedAt { get; set; } = default!; + public DateTimeOffset StartedAt { get; set; } /// /// Gets or sets whether the activity has any bookmarks. @@ -60,7 +60,7 @@ public class ActivityExecutionRecordSummary : Entity public DateTimeOffset? CompletedAt { get; set; } /// - /// Returns a summary view of the specified . + /// Returns a summary view of the specified . /// public static ActivityExecutionRecordSummary FromRecord(ActivityExecutionRecord record) { @@ -81,7 +81,7 @@ public class ActivityExecutionRecordSummary : Entity } /// - /// Returns a summary view of the specified . + /// Returns a summary view of the specified . /// public static Expression> FromRecordExpression() { diff --git a/src/modules/Elsa.Workflows.Runtime/Entities/WorkflowExecutionLogRecord.cs b/src/modules/Elsa.Workflows.Runtime/Entities/WorkflowExecutionLogRecord.cs index 69c8fb989..87460d6b6 100644 --- a/src/modules/Elsa.Workflows.Runtime/Entities/WorkflowExecutionLogRecord.cs +++ b/src/modules/Elsa.Workflows.Runtime/Entities/WorkflowExecutionLogRecord.cs @@ -26,7 +26,7 @@ public class WorkflowExecutionLogRecord : Entity, ILogRecord /// /// The version of the workflow definition. /// - public int WorkflowVersion { get; init; } + public int WorkflowVersion { get; set; } /// /// The ID of the activity instance. diff --git a/src/modules/Elsa.Workflows.Runtime/Extensions/PipelineWorkflowsFeatureExtensions.cs b/src/modules/Elsa.Workflows.Runtime/Extensions/PipelineWorkflowsFeatureExtensions.cs index b647f0c7e..b68a86779 100644 --- a/src/modules/Elsa.Workflows.Runtime/Extensions/PipelineWorkflowsFeatureExtensions.cs +++ b/src/modules/Elsa.Workflows.Runtime/Extensions/PipelineWorkflowsFeatureExtensions.cs @@ -1,10 +1,9 @@ -using Elsa.Extensions; +using Elsa.Workflows.Contracts; using Elsa.Workflows.Features; using Elsa.Workflows.Middleware.Activities; // ReSharper disable once CheckNamespace - -namespace Elsa.Workflows.Runtime.Extensions; +namespace Elsa.Extensions; /// Adds an extension method to the that installs a default workflow runtime execution pipeline. public static class PipelineWorkflowsFeatureExtensions diff --git a/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowsFeatureExtensions.cs b/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowsFeatureExtensions.cs new file mode 100644 index 000000000..7031ca4a1 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Extensions/WorkflowsFeatureExtensions.cs @@ -0,0 +1,34 @@ + + + + +using Elsa.Workflows.Middleware.Activities; +// ReSharper disable once CheckNamespace +using Elsa.Workflows.Features; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +/// +/// Adds an extension method to the that installs a default workflow runtime execution pipeline. +/// +public static class WorkflowsFeatureExtensions +{ + /// + /// Installs a default workflow runtime execution pipeline. + /// + public static WorkflowsFeature WithDefaultWorkflowExecutionPipeline(this WorkflowsFeature workflowsFeature) => + workflowsFeature.WithWorkflowExecutionPipeline(pipeline => + pipeline.UseDefaultPipeline()); + + /// + /// Installs an activity invoker that can run activities asynchronously in the background. + /// + public static WorkflowsFeature WithBackgroundActivityExecutionPipeline(this WorkflowsFeature workflowsFeature) => + workflowsFeature.WithActivityExecutionPipeline(pipeline => + pipeline + .UseExceptionHandling() + .UseExecutionLogging() + .UseNotifications() + .UseBackgroundActivityInvoker()); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/DefaultBackgroundActivityInvoker.cs b/src/modules/Elsa.Workflows.Runtime/Services/DefaultBackgroundActivityInvoker.cs new file mode 100644 index 000000000..1141abbbd --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Services/DefaultBackgroundActivityInvoker.cs @@ -0,0 +1,148 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Elsa.Common.Models; +using Elsa.Workflows.Contracts; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Contracts; +using Elsa.Workflows.Memory; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime.Contracts; +using Elsa.Workflows.Runtime.Middleware.Activities; +using Elsa.Workflows.Runtime.Models; +using Elsa.Workflows.Runtime.Requests; +using Elsa.Workflows.Services; +using Microsoft.Extensions.Logging; + +namespace Elsa.Workflows.Runtime.Services; + +/// +/// An activity invoker that invokes activities detached from the workflow. This is useful for invoking activities from a background worker. +/// +public class DefaultBackgroundActivityInvoker : IBackgroundActivityInvoker +{ + private readonly IWorkflowRuntime _workflowRuntime; + private readonly IWorkflowDispatcher _workflowDispatcher; + private readonly IWorkflowDefinitionService _workflowDefinitionService; + private readonly IVariablePersistenceManager _variablePersistenceManager; + private readonly IActivityInvoker _activityInvoker; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public DefaultBackgroundActivityInvoker( + IWorkflowRuntime workflowRuntime, + IWorkflowDispatcher workflowDispatcher, + IWorkflowDefinitionService workflowDefinitionService, + IVariablePersistenceManager variablePersistenceManager, + IActivityInvoker activityInvoker, + IServiceProvider serviceProvider, + ILogger logger) + { + _workflowRuntime = workflowRuntime; + _workflowDispatcher = workflowDispatcher; + _workflowDefinitionService = workflowDefinitionService; + _variablePersistenceManager = variablePersistenceManager; + _activityInvoker = activityInvoker; + _serviceProvider = serviceProvider; + _logger = logger; + } + + /// + [RequiresUnreferencedCode("The activity type may be trimmed")] + public async Task ExecuteAsync(ScheduledBackgroundActivity scheduledBackgroundActivity, CancellationToken cancellationToken = default) + { + var workflowInstanceId = scheduledBackgroundActivity.WorkflowInstanceId; + var workflowState = await _workflowRuntime.ExportWorkflowStateAsync(workflowInstanceId, cancellationToken); + + if (workflowState == null) + throw new Exception("Workflow state not found"); + + var workflowGraph = await _workflowDefinitionService.FindWorkflowGraphAsync(workflowState.DefinitionId, VersionOptions.SpecificVersion(workflowState.DefinitionVersion), cancellationToken); + + if (workflowGraph == null) + throw new Exception("Workflow definition not found"); + + var workflowExecutionContext = await WorkflowExecutionContext.CreateAsync(_serviceProvider, workflowGraph, workflowState, cancellationTokens: cancellationToken); + var activityNodeId = scheduledBackgroundActivity.ActivityNodeId; + var activityExecutionContext = workflowExecutionContext.ActivityExecutionContexts.First(x => x.NodeId == activityNodeId); + + // Load persistent variables for the activity to use. + await _variablePersistenceManager.LoadVariablesAsync(workflowExecutionContext); + + // Mark the activity as being invoked from a background worker. + activityExecutionContext.SetIsBackgroundExecution(); + + // Invoke the activity. + await _activityInvoker.InvokeAsync(activityExecutionContext); + + // Capture any activity output produced by the activity (but only if the associated memory block is stored in the workflow itself). + var outputDescriptors = activityExecutionContext.ActivityDescriptor.Outputs; + var outputValues = new Dictionary(); + + foreach (var outputDescriptor in outputDescriptors) + { + var output = (Output?)outputDescriptor.ValueGetter(activityExecutionContext.Activity); + + if (output == null) + continue; + + var memoryBlockReference = output.MemoryBlockReference(); + + if (!activityExecutionContext.ExpressionExecutionContext.TryGetBlock(memoryBlockReference, out var memoryBlock)) + continue; + + var variableMetadata = memoryBlock.Metadata as VariableBlockMetadata; + var driver = variableMetadata?.StorageDriverType; + + // We only capture output written to the workflow itself. Other drivers like blob storage, etc. will be ignored since the foreground context will be loading those. + if (driver != typeof(WorkflowStorageDriver) && driver != typeof(WorkflowInstanceStorageDriver)) + continue; + + var outputValue = activityExecutionContext.Get(memoryBlockReference); + + if (outputValue != null) + outputValues[outputDescriptor.Name] = outputValue; + } + + // Resume the workflow, passing along activity output, outcomes and scheduled activities. + var bookmarkId = scheduledBackgroundActivity.BookmarkId; + var inputKey = BackgroundActivityInvokerMiddleware.GetBackgroundActivityOutputKey(activityNodeId); + var outcomesKey = BackgroundActivityInvokerMiddleware.GetBackgroundActivityOutcomesKey(activityNodeId); + var completedKey = BackgroundActivityInvokerMiddleware.GetBackgroundActivityCompletedKey(activityNodeId); + var journalDataKey = BackgroundActivityInvokerMiddleware.GetBackgroundActivityJournalDataKey(activityNodeId); + var bookmarksKey = BackgroundActivityInvokerMiddleware.GetBackgroundActivityBookmarksKey(activityNodeId); + var scheduledActivitiesKey = BackgroundActivityInvokerMiddleware.GetBackgroundActivityScheduledActivitiesKey(activityNodeId); + var outcomes = activityExecutionContext.GetBackgroundOutcomes()?.ToList(); + var completed = activityExecutionContext.GetBackgroundCompleted(); + var scheduledActivities = activityExecutionContext.GetBackgroundScheduledActivities().ToList(); + + var dispatchRequest = new DispatchWorkflowInstanceRequest + { + InstanceId = workflowInstanceId, + BookmarkId = bookmarkId, + Properties = new Dictionary + { + [scheduledActivitiesKey] = JsonSerializer.Serialize(scheduledActivities), + [inputKey] = outputValues, + [journalDataKey] = activityExecutionContext.JournalData, + [bookmarksKey] = activityExecutionContext.Bookmarks.ToList() + } + }; + + if (outcomes != null) + dispatchRequest.Properties[outcomesKey] = outcomes; + + if (completed != null) + dispatchRequest.Properties[completedKey] = completed; + + if (cancellationToken.IsCancellationRequested) + { + _logger.LogInformation("Background execution for activity {ActivityNodeId} was canceled", activityNodeId); + return; + } + + await _workflowDispatcher.DispatchAsync(dispatchRequest, cancellationToken: cancellationToken); + } +} \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs index a0135d853..d9ad4d263 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs @@ -34,7 +34,7 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl var client = CreateClient(); client.BaseAddress = new Uri(client.BaseAddress!, "/elsa/api"); client.Timeout = TimeSpan.FromMinutes(1); - return RestService.For(client, CreateRefitSettings()); + return RestService.For(client, CreateRefitSettings(Services)); } public HttpClient CreateHttpWorkflowClient() diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/BasicWorkflows/HelloWorldTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/BasicWorkflows/HelloWorldTests.cs index e865806f2..4ad364963 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Scenarios/BasicWorkflows/HelloWorldTests.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/BasicWorkflows/HelloWorldTests.cs @@ -13,7 +13,7 @@ public class HelloWorldTests(App app) : AppComponentTest(app) { var client = WorkflowServer.CreateApiClient(); using var response = await client.ExecuteAsync("1590068018aa4f0a"); - var model = await response.ReadAsJsonAsync(); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus); } diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/LogPersistenceModes/InputOutputLoggingTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/LogPersistenceModes/InputOutputLoggingTests.cs index c3f3661e7..0309d2fbe 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Scenarios/LogPersistenceModes/InputOutputLoggingTests.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/LogPersistenceModes/InputOutputLoggingTests.cs @@ -1,4 +1,4 @@ -using Elsa.Api.Client.Resources.WorkflowDefinitions.Contracts; +using Elsa.Api.Client.Resources.WorkflowDefinitions.Contracts; using Elsa.Api.Client.Resources.WorkflowDefinitions.Models; using Elsa.Api.Client.Resources.WorkflowInstances.Models; using Elsa.Common.Entities; @@ -65,7 +65,7 @@ public class InputOutputLoggingTests(App app) : AppComponentTest(app) { var client = WorkflowServer.CreateApiClient(); using var response = await client.ExecuteAsync(workflowDefinitionId); - var model = await response.ReadAsJsonAsync(); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); return model.WorkflowState; } diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Variables/CountdownWorkflowTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Variables/CountdownWorkflowTests.cs index a8d9e93d9..428c28a7b 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Scenarios/Variables/CountdownWorkflowTests.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/Variables/CountdownWorkflowTests.cs @@ -62,8 +62,8 @@ public class CountdownWorkflowTests(App app) : AppComponentTest(app) } } - private IDictionary GetVariablesDictionary(ActivityExecutionContextState context) + private VariablesDictionary GetVariablesDictionary(ActivityExecutionContextState context) { - return context.Properties.GetOrAdd(WorkflowStorageDriver.VariablesDictionaryStateKey, () => new Dictionary()); + return context.Properties.GetOrAdd(WorkflowInstanceStorageDriver.VariablesDictionaryStateKey, () => new VariablesDictionary()); } } \ No newline at end of file diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowCompletion/WorkflowCompletionTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowCompletion/WorkflowCompletionTests.cs index 2479dd993..7dc704d0c 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowCompletion/WorkflowCompletionTests.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/WorkflowCompletion/WorkflowCompletionTests.cs @@ -15,7 +15,7 @@ public class WorkflowCompletionTests(App app) : AppComponentTest(app) { var client = WorkflowServer.CreateApiClient(); using var response = await client.ExecuteAsync(workflowDefinitionId); - var model = await response.ReadAsJsonAsync(); + var model = await response.ReadAsJsonAsync(WorkflowServer.Services); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(WorkflowSubStatus.Finished, model.WorkflowState.SubStatus); } diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Core/SerializerEncodingTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Core/SerializerEncodingTests.cs index 156d6caeb..7e7d4c461 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Core/SerializerEncodingTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Core/SerializerEncodingTests.cs @@ -32,14 +32,14 @@ public class SerializerUnicodeEncodingTests(ITestOutputHelper testOutputHelper) } [Fact] - public async Task TestSafeSerializer() + public void TestSafeSerializer() { var serializer = _serviceProvider.GetRequiredService(); - await TestSerializerAsync(input => serializer.SerializeAsync(input).AsTask()); + TestSerializer(input => serializer.Serialize(input)); } [Fact] - public async Task TestWorkflowStateSerializer() + public void TestWorkflowStateSerializer() { var serializer = _serviceProvider.GetRequiredService(); TestSerializer(input => serializer.Serialize(input)); @@ -56,18 +56,6 @@ public class SerializerUnicodeEncodingTests(ITestOutputHelper testOutputHelper) var serializedStringValue = GetSerializedTextValue(serializedJson); Assert.Equal(unicodeString, serializedStringValue); } - - private async Task TestSerializerAsync(Func> serialize) - { - var unicodeString = UnicodeRangeGenerator.GenerateUnicodeString(); - var anonymousObject = new - { - Text = unicodeString - }; - var serializedJson = await serialize(anonymousObject); - var serializedStringValue = GetSerializedTextValue(serializedJson); - Assert.Equal(unicodeString, serializedStringValue); - } private string GetSerializedTextValue(string serializedJson) {