diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 1a24c9b44..1cab3083a 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -31,12 +31,8 @@ jobs: else echo "VERSION=3.1.0-preview.${{github.run_number}}" >> $GITHUB_ENV fi - - name: Build - run: dotnet build --configuration Release /p:Version=${VERSION} - - name: Test - run: dotnet test --configuration Release /p:Version=${VERSION} --no-build - - name: Pack - run: dotnet pack --configuration Release /p:Version=${VERSION} /p:PackageOutputPath=$(pwd)/packages + - name: Compile+Test+Pack + run: ./build.sh Compile+Test+Pack --version ${VERSION} - name: Upload artifact uses: actions/upload-artifact@v3 with: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 000000000..6d7547a43 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,44 @@ +# ------------------------------------------------------------------------------ +# +# +# This code was generated. +# +# - To turn off auto-generation set: +# +# [CustomGitHubActions (AutoGenerate = false)] +# +# - To trigger manual generation invoke: +# +# nuke --generate-configuration GitHubActions_pr --host GitHubActions +# +# +# ------------------------------------------------------------------------------ + +name: pr + +on: + pull_request: + branches: + - main + - 'v3*' + paths: + - '**/*' + - '!docs/**/*' + - '!package.json' + - '!package-lock.json' + - '!readme.md' + +jobs: + ubuntu-latest: + name: ubuntu-latest + runs-on: ubuntu-latest + steps: + - if: ${{ runner.os == 'Windows' }} + name: 'Use GNU tar' + shell: cmd + run: | + echo "Adding GNU tar to PATH" + echo C:\Program Files\Git\usr\bin>>"%GITHUB_PATH%" + - uses: actions/checkout@v3 + - name: 'Run: Compile, Test, Pack' + run: ./build.cmd Compile Test Pack diff --git a/.gitignore b/.gitignore index 68b95e154..f772d8ea0 100644 --- a/.gitignore +++ b/.gitignore @@ -51,9 +51,6 @@ node_modules/ .stylelintrc.yml .eslintrc.yml -#github -.github/ - #volatile App_Data/ *.db @@ -76,3 +73,6 @@ src/designer/elsa-workflows-designer/loader src/modules/Elsa.Workflows.Designer/package-lock.json unlist.sh + +# build artifacts +/artifacts diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json new file mode 100644 index 000000000..3f9224ea7 --- /dev/null +++ b/.nuke/build.schema.json @@ -0,0 +1,120 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "$ref": "#/definitions/build", + "title": "Build Schema", + "definitions": { + "build": { + "type": "object", + "properties": { + "Configuration": { + "type": "string", + "enum": [ + "Debug", + "Release" + ] + }, + "Continue": { + "type": "boolean", + "description": "Indicates to continue a previously failed build attempt" + }, + "Help": { + "type": "boolean", + "description": "Shows the help text for this build assembly" + }, + "Host": { + "type": "string", + "description": "Host for execution. Default is 'automatic'", + "enum": [ + "AppVeyor", + "AzurePipelines", + "Bamboo", + "Bitbucket", + "Bitrise", + "GitHubActions", + "GitLab", + "Jenkins", + "Rider", + "SpaceAutomation", + "TeamCity", + "Terminal", + "TravisCI", + "VisualStudio", + "VSCode" + ] + }, + "IgnoreFailedSources": { + "type": "boolean", + "description": "Ignore unreachable sources during Restore" + }, + "NoLogo": { + "type": "boolean", + "description": "Disables displaying the NUKE logo" + }, + "Partition": { + "type": "string", + "description": "Partition to use on CI" + }, + "Plan": { + "type": "boolean", + "description": "Shows the execution plan (HTML)" + }, + "Profile": { + "type": "array", + "description": "Defines the profiles to load", + "items": { + "type": "string" + } + }, + "Root": { + "type": "string", + "description": "Root directory during build execution" + }, + "Skip": { + "type": "array", + "description": "List of targets to be skipped. Empty list skips all dependencies", + "items": { + "type": "string", + "enum": [ + "Clean", + "Compile", + "Pack", + "Restore", + "Test" + ] + } + }, + "Solution": { + "type": "string", + "description": "Path to a solution file that is automatically loaded" + }, + "Target": { + "type": "array", + "description": "List of targets to be invoked. Default is '{default_target}'", + "items": { + "type": "string", + "enum": [ + "Clean", + "Compile", + "Pack", + "Restore", + "Test" + ] + } + }, + "Verbosity": { + "type": "string", + "description": "Logging verbosity during build execution. Default is 'Normal'", + "enum": [ + "Minimal", + "Normal", + "Quiet", + "Verbose" + ] + }, + "Version": { + "type": "string" + } + } + } + } +} diff --git a/.nuke/parameters.json b/.nuke/parameters.json new file mode 100644 index 000000000..8535901f9 --- /dev/null +++ b/.nuke/parameters.json @@ -0,0 +1,4 @@ +{ + "$schema": "./build.schema.json", + "Solution": "Elsa.sln" +} diff --git a/Elsa.sln b/Elsa.sln index f873a4d89..f70ca412d 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.7.34003.232 @@ -16,12 +17,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "solution", "solution", "{7D common.props = common.props configureawait.props = configureawait.props docker-compose.yml = docker-compose.yml + frameworks.props = frameworks.props icon.png = icon.png NuGet.Config = NuGet.Config packages.props = packages.props README.md = README.md update-migrations.sh = update-migrations.sh - frameworks.props = frameworks.props EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{0354F050-3992-4DD4-B0EE-5FBA04AC72B6}" @@ -254,51 +255,52 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.EntityFrameworkCore.My EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.EntityFrameworkCore.PostgreSql", "src\modules\Elsa.EntityFrameworkCore.PostgreSql\Elsa.EntityFrameworkCore.PostgreSql.csproj", "{84AB9951-3DBF-43A4-8F5D-AEED1102CE88}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Alterations.MassTransit", "src\modules\Elsa.Alterations.MassTransit\Elsa.Alterations.MassTransit.csproj", "{1F621382-08FB-45EA-B94C-96A7FECB9489}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Alterations.MassTransit", "src\modules\Elsa.Alterations.MassTransit\Elsa.Alterations.MassTransit.csproj", "{1F621382-08FB-45EA-B94C-96A7FECB9489}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Activities.UnitTests", "test\unit\Elsa.Activities.UnitTests\Elsa.Activities.UnitTests.csproj", "{E6562B0F-AF64-472A-B009-BF6D40DDE99B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Activities.UnitTests", "test\unit\Elsa.Activities.UnitTests\Elsa.Activities.UnitTests.csproj", "{E6562B0F-AF64-472A-B009-BF6D40DDE99B}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.ServiceBus.IntegrationTests", "test\integration\Elsa.ServiceBus.IntegrationTests\Elsa.ServiceBus.IntegrationTests.csproj", "{9504E3F3-F77F-437A-8644-D23D7F5FCF8B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ElsaStudioWebAssembly", "src\bundles\ElsaStudioWebAssembly\ElsaStudioWebAssembly.csproj", "{E12A1BDF-5D65-493B-835D-AFBC480A5650}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ElsaStudioWebAssembly", "src\bundles\ElsaStudioWebAssembly\ElsaStudioWebAssembly.csproj", "{E12A1BDF-5D65-493B-835D-AFBC480A5650}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.EntityFrameworkCore.Common", "src\modules\Elsa.EntityFrameworkCore.Common\Elsa.EntityFrameworkCore.Common.csproj", "{FBA14F8A-9F14-4364-ABDB-B8AE81A0D755}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.EntityFrameworkCore.Common", "src\modules\Elsa.EntityFrameworkCore.Common\Elsa.EntityFrameworkCore.Common.csproj", "{FBA14F8A-9F14-4364-ABDB-B8AE81A0D755}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Quartz.EntityFrameworkCore.Sqlite", "src\modules\Elsa.Quartz.EntityFrameworkCore.Sqlite\Elsa.Quartz.EntityFrameworkCore.Sqlite.csproj", "{A71557F2-D5FB-4772-9391-F7D18A3B6C44}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Quartz.EntityFrameworkCore.Sqlite", "src\modules\Elsa.Quartz.EntityFrameworkCore.Sqlite\Elsa.Quartz.EntityFrameworkCore.Sqlite.csproj", "{A71557F2-D5FB-4772-9391-F7D18A3B6C44}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Quartz.EntityFrameworkCore.SqlServer", "src\modules\Elsa.Quartz.EntityFrameworkCore.SqlServer\Elsa.Quartz.EntityFrameworkCore.SqlServer.csproj", "{05F3B23E-CF28-467B-AD3C-595EA4ED6B96}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Quartz.EntityFrameworkCore.SqlServer", "src\modules\Elsa.Quartz.EntityFrameworkCore.SqlServer\Elsa.Quartz.EntityFrameworkCore.SqlServer.csproj", "{05F3B23E-CF28-467B-AD3C-595EA4ED6B96}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Quartz.EntityFrameworkCore.MySql", "src\modules\Elsa.Quartz.EntityFrameworkCore.MySql\Elsa.Quartz.EntityFrameworkCore.MySql.csproj", "{74ACD9D0-8B4C-42FA-A582-E93BF0075023}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Quartz.EntityFrameworkCore.MySql", "src\modules\Elsa.Quartz.EntityFrameworkCore.MySql\Elsa.Quartz.EntityFrameworkCore.MySql.csproj", "{74ACD9D0-8B4C-42FA-A582-E93BF0075023}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Quartz.EntityFrameworkCore.PostgreSql", "src\modules\Elsa.Quartz.EntityFrameworkCore.PostgreSql\Elsa.Quartz.EntityFrameworkCore.PostgreSql.csproj", "{CCCCEF8C-7D96-4BEA-B9D0-E91EDF08E65D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Quartz.EntityFrameworkCore.PostgreSql", "src\modules\Elsa.Quartz.EntityFrameworkCore.PostgreSql\Elsa.Quartz.EntityFrameworkCore.PostgreSql.csproj", "{CCCCEF8C-7D96-4BEA-B9D0-E91EDF08E65D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.FileStorage", "src\modules\Elsa.FileStorage\Elsa.FileStorage.csproj", "{732BF088-6AD7-4C4D-9A48-8074253596D4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.FileStorage", "src\modules\Elsa.FileStorage\Elsa.FileStorage.csproj", "{732BF088-6AD7-4C4D-9A48-8074253596D4}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "storage", "storage", "{B818988E-639C-4E6E-85C1-B231BCAD9DAB}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.CSharp", "src\modules\Elsa.CSharp\Elsa.CSharp.csproj", "{24331E82-D7AF-45B1-ACF0-CA6C3B0B77DC}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.CSharp", "src\modules\Elsa.CSharp\Elsa.CSharp.csproj", "{24331E82-D7AF-45B1-ACF0-CA6C3B0B77DC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Python", "src\modules\Elsa.Python\Elsa.Python.csproj", "{790E94F2-5393-47DF-AC52-D9247F5B243A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Python", "src\modules\Elsa.Python\Elsa.Python.csproj", "{790E94F2-5393-47DF-AC52-D9247F5B243A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.MassTransit.RabbitMq", "src\modules\Elsa.MassTransit.RabbitMq\Elsa.MassTransit.RabbitMq.csproj", "{169BEA3D-2A81-47EE-A6C1-3F8719EEC1F6}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.MassTransit.RabbitMq", "src\modules\Elsa.MassTransit.RabbitMq\Elsa.MassTransit.RabbitMq.csproj", "{169BEA3D-2A81-47EE-A6C1-3F8719EEC1F6}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.MassTransit.AzureServiceBus", "src\modules\Elsa.MassTransit.AzureServiceBus\Elsa.MassTransit.AzureServiceBus.csproj", "{5B1AF00E-1030-4BBA-9C1A-1DC5AEFDD44A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.MassTransit.AzureServiceBus", "src\modules\Elsa.MassTransit.AzureServiceBus\Elsa.MassTransit.AzureServiceBus.csproj", "{5B1AF00E-1030-4BBA-9C1A-1DC5AEFDD44A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Samples.AspNet.Heartbeats", "src\samples\aspnet\Elsa.Samples.AspNet.Heartbeats\Elsa.Samples.AspNet.Heartbeats.csproj", "{26888832-DBEA-4B23-8DC2-84478A702CC4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Samples.AspNet.Heartbeats", "src\samples\aspnet\Elsa.Samples.AspNet.Heartbeats\Elsa.Samples.AspNet.Heartbeats.csproj", "{26888832-DBEA-4B23-8DC2-84478A702CC4}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "pipelines", "pipelines", "{B789B743-0FBE-4BBC-9F45-84C20C8D4814}" ProjectSection(SolutionItems) = preProject .github\workflows\elsa-server-and-studio.yml = .github\workflows\elsa-server-and-studio.yml .github\workflows\elsa-server.yml = .github\workflows\elsa-server.yml - .github\workflows\packages.yml = .github\workflows\packages.yml .github\workflows\elsa-studio.yml = .github\workflows\elsa-studio.yml + .github\workflows\packages.yml = .github\workflows\packages.yml + .github\workflows\pr.yml = .github\workflows\pr.yml EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Studio.Web", "src\bundles\Elsa.Studio.Web\Elsa.Studio.Web.csproj", "{D5C149EE-276C-4C59-98F9-37F0EA2A7866}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Studio.Web", "src\bundles\Elsa.Studio.Web\Elsa.Studio.Web.csproj", "{D5C149EE-276C-4C59-98F9-37F0EA2A7866}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Elsa.Samples.AspNet.CustomUIHandler", "src\samples\aspnet\Elsa.Samples.AspNet.CustomUIHandler\Elsa.Samples.AspNet.CustomUIHandler.csproj", "{832D6FF5-16D3-41D1-8FDD-D9BEC26BDA6A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.MassTransit.RabbitMq", "src\modules\Elsa.MassTransit.RabbitMq\Elsa.MassTransit.RabbitMq.csproj", "{169BEA3D-2A81-47EE-A6C1-3F8719EEC1F6}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "_build", "build\_build.csproj", "{99F2B1DA-2F69-4D70-A2A3-AC985AD91EC4}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.MassTransit.AzureServiceBus", "src\modules\Elsa.MassTransit.AzureServiceBus\Elsa.MassTransit.AzureServiceBus.csproj", "{5B1AF00E-1030-4BBA-9C1A-1DC5AEFDD44A}" EndProject @@ -881,8 +883,8 @@ Global {05F3B23E-CF28-467B-AD3C-595EA4ED6B96} = {AB797AF0-C12C-46DE-A157-7E25625C6200} {74ACD9D0-8B4C-42FA-A582-E93BF0075023} = {AB797AF0-C12C-46DE-A157-7E25625C6200} {CCCCEF8C-7D96-4BEA-B9D0-E91EDF08E65D} = {AB797AF0-C12C-46DE-A157-7E25625C6200} - {B818988E-639C-4E6E-85C1-B231BCAD9DAB} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {732BF088-6AD7-4C4D-9A48-8074253596D4} = {B818988E-639C-4E6E-85C1-B231BCAD9DAB} + {B818988E-639C-4E6E-85C1-B231BCAD9DAB} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {24331E82-D7AF-45B1-ACF0-CA6C3B0B77DC} = {6EF07978-A6D2-40EB-891D-7D70C5F37E76} {790E94F2-5393-47DF-AC52-D9247F5B243A} = {6EF07978-A6D2-40EB-891D-7D70C5F37E76} {169BEA3D-2A81-47EE-A6C1-3F8719EEC1F6} = {DD089B8B-DA73-492A-9010-F772D1C178DA} diff --git a/build.cmd b/build.cmd new file mode 100755 index 000000000..b08cc590f --- /dev/null +++ b/build.cmd @@ -0,0 +1,7 @@ +:; set -eo pipefail +:; SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd) +:; ${SCRIPT_DIR}/build.sh "$@" +:; exit $? + +@ECHO OFF +powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0build.ps1" %* diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 000000000..4634dc03e --- /dev/null +++ b/build.ps1 @@ -0,0 +1,74 @@ +[CmdletBinding()] +Param( + [Parameter(Position=0,Mandatory=$false,ValueFromRemainingArguments=$true)] + [string[]]$BuildArguments +) + +Write-Output "PowerShell $($PSVersionTable.PSEdition) version $($PSVersionTable.PSVersion)" + +Set-StrictMode -Version 2.0; $ErrorActionPreference = "Stop"; $ConfirmPreference = "None"; trap { Write-Error $_ -ErrorAction Continue; exit 1 } +$PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent + +########################################################################### +# CONFIGURATION +########################################################################### + +$BuildProjectFile = "$PSScriptRoot\build\_build.csproj" +$TempDirectory = "$PSScriptRoot\\.nuke\temp" + +$DotNetGlobalFile = "$PSScriptRoot\\global.json" +$DotNetInstallUrl = "https://dot.net/v1/dotnet-install.ps1" +$DotNetChannel = "STS" + +$env:DOTNET_CLI_TELEMETRY_OPTOUT = 1 +$env:DOTNET_NOLOGO = 1 + +########################################################################### +# EXECUTION +########################################################################### + +function ExecSafe([scriptblock] $cmd) { + & $cmd + if ($LASTEXITCODE) { exit $LASTEXITCODE } +} + +# If dotnet CLI is installed globally and it matches requested version, use for execution +if ($null -ne (Get-Command "dotnet" -ErrorAction SilentlyContinue) -and ` + $(dotnet --version) -and $LASTEXITCODE -eq 0) { + $env:DOTNET_EXE = (Get-Command "dotnet").Path +} +else { + # Download install script + $DotNetInstallFile = "$TempDirectory\dotnet-install.ps1" + New-Item -ItemType Directory -Path $TempDirectory -Force | Out-Null + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + (New-Object System.Net.WebClient).DownloadFile($DotNetInstallUrl, $DotNetInstallFile) + + # If global.json exists, load expected version + if (Test-Path $DotNetGlobalFile) { + $DotNetGlobal = $(Get-Content $DotNetGlobalFile | Out-String | ConvertFrom-Json) + if ($DotNetGlobal.PSObject.Properties["sdk"] -and $DotNetGlobal.sdk.PSObject.Properties["version"]) { + $DotNetVersion = $DotNetGlobal.sdk.version + } + } + + # Install by channel or version + $DotNetDirectory = "$TempDirectory\dotnet-win" + if (!(Test-Path variable:DotNetVersion)) { + ExecSafe { & powershell $DotNetInstallFile -InstallDir $DotNetDirectory -Channel $DotNetChannel -NoPath } + } else { + ExecSafe { & powershell $DotNetInstallFile -InstallDir $DotNetDirectory -Version $DotNetVersion -NoPath } + } + $env:DOTNET_EXE = "$DotNetDirectory\dotnet.exe" + $env:PATH = "$DotNetDirectory;$env:PATH" +} + +Write-Output "Microsoft (R) .NET SDK version $(& $env:DOTNET_EXE --version)" + +if (Test-Path env:NUKE_ENTERPRISE_TOKEN) { + & $env:DOTNET_EXE nuget remove source "nuke-enterprise" > $null + & $env:DOTNET_EXE nuget add source "https://f.feedz.io/nuke/enterprise/nuget" --name "nuke-enterprise" --username "PAT" --password $env:NUKE_ENTERPRISE_TOKEN > $null +} + +ExecSafe { & $env:DOTNET_EXE build $BuildProjectFile /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet } +ExecSafe { & $env:DOTNET_EXE run --project $BuildProjectFile --no-build -- $BuildArguments } diff --git a/build.sh b/build.sh new file mode 100755 index 000000000..fdff0c623 --- /dev/null +++ b/build.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +bash --version 2>&1 | head -n 1 + +set -eo pipefail +SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd) + +########################################################################### +# CONFIGURATION +########################################################################### + +BUILD_PROJECT_FILE="$SCRIPT_DIR/build/_build.csproj" +TEMP_DIRECTORY="$SCRIPT_DIR//.nuke/temp" + +DOTNET_GLOBAL_FILE="$SCRIPT_DIR//global.json" +DOTNET_INSTALL_URL="https://dot.net/v1/dotnet-install.sh" +DOTNET_CHANNEL="STS" + +export DOTNET_CLI_TELEMETRY_OPTOUT=1 +export DOTNET_NOLOGO=1 + +########################################################################### +# EXECUTION +########################################################################### + +function FirstJsonValue { + perl -nle 'print $1 if m{"'"$1"'": "([^"]+)",?}' <<< "${@:2}" +} + +# If dotnet CLI is installed globally and it matches requested version, use for execution +if [ -x "$(command -v dotnet)" ] && dotnet --version &>/dev/null; then + export DOTNET_EXE="$(command -v dotnet)" +else + # Download install script + DOTNET_INSTALL_FILE="$TEMP_DIRECTORY/dotnet-install.sh" + mkdir -p "$TEMP_DIRECTORY" + curl -Lsfo "$DOTNET_INSTALL_FILE" "$DOTNET_INSTALL_URL" + chmod +x "$DOTNET_INSTALL_FILE" + + # If global.json exists, load expected version + if [[ -f "$DOTNET_GLOBAL_FILE" ]]; then + DOTNET_VERSION=$(FirstJsonValue "version" "$(cat "$DOTNET_GLOBAL_FILE")") + if [[ "$DOTNET_VERSION" == "" ]]; then + unset DOTNET_VERSION + fi + fi + + # Install by channel or version + DOTNET_DIRECTORY="$TEMP_DIRECTORY/dotnet-unix" + if [[ -z ${DOTNET_VERSION+x} ]]; then + "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --channel "$DOTNET_CHANNEL" --no-path + else + "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version "$DOTNET_VERSION" --no-path + fi + export DOTNET_EXE="$DOTNET_DIRECTORY/dotnet" + export PATH="$DOTNET_DIRECTORY:$PATH" +fi + +echo "Microsoft (R) .NET SDK version $("$DOTNET_EXE" --version)" + +if [[ ! -z ${NUKE_ENTERPRISE_TOKEN+x} && "$NUKE_ENTERPRISE_TOKEN" != "" ]]; then + "$DOTNET_EXE" nuget remove source "nuke-enterprise" &>/dev/null || true + "$DOTNET_EXE" nuget add source "https://f.feedz.io/nuke/enterprise/nuget" --name "nuke-enterprise" --username "PAT" --password "$NUKE_ENTERPRISE_TOKEN" --store-password-in-clear-text &>/dev/null || true +fi + +"$DOTNET_EXE" build "$BUILD_PROJECT_FILE" /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet +"$DOTNET_EXE" run --project "$BUILD_PROJECT_FILE" --no-build -- "$@" diff --git a/build/.editorconfig b/build/.editorconfig new file mode 100644 index 000000000..31e43dcd8 --- /dev/null +++ b/build/.editorconfig @@ -0,0 +1,11 @@ +[*.cs] +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning +dotnet_style_require_accessibility_modifiers = never:warning + +csharp_style_expression_bodied_methods = true:silent +csharp_style_expression_bodied_properties = true:warning +csharp_style_expression_bodied_indexers = true:warning +csharp_style_expression_bodied_accessors = true:warning diff --git a/build/Build.CI.GitHubActions.cs b/build/Build.CI.GitHubActions.cs new file mode 100644 index 000000000..a6e559699 --- /dev/null +++ b/build/Build.CI.GitHubActions.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using Nuke.Common.CI.GitHubActions; +using Nuke.Common.CI.GitHubActions.Configuration; +using Nuke.Common.Execution; +using Nuke.Common.Utilities; +using Nuke.Components; + +[CustomGitHubActions( + "pr", + GitHubActionsImage.UbuntuLatest, + OnPullRequestBranches = ["main"], + OnPullRequestIncludePaths = ["**/*"], + OnPullRequestExcludePaths = ["docs/**/*", "package.json", "package-lock.json", "readme.md"], + PublishArtifacts = false, + InvokedTargets = [nameof(ICompile.Compile), nameof(ITest.Test), nameof(IPack.Pack)], + CacheKeyFiles = [] + ) +] +public partial class Build; + +class CustomGitHubActionsAttribute : GitHubActionsAttribute +{ + public CustomGitHubActionsAttribute(string name, GitHubActionsImage image, params GitHubActionsImage[] images) : base(name, image, images) + { + } + + protected override GitHubActionsJob GetJobs(GitHubActionsImage image, IReadOnlyCollection relevantTargets) + { + var job = base.GetJobs(image, relevantTargets); + + var newSteps = new List(job.Steps); + newSteps.Insert(0, new GitHubActionsUseGnuTarStep()); + + job.Steps = newSteps.ToArray(); + return job; + } +} + +class GitHubActionsUseGnuTarStep : GitHubActionsStep +{ + public override void Write(CustomFileWriter writer) + { + writer.WriteLine("- if: ${{ runner.os == 'Windows' }}"); + using (writer.Indent()) + { + writer.WriteLine("name: 'Use GNU tar'"); + writer.WriteLine("shell: cmd"); + writer.WriteLine("run: |"); + using (writer.Indent()) + { + writer.WriteLine("echo \"Adding GNU tar to PATH\""); + writer.WriteLine("echo C:\\Program Files\\Git\\usr\\bin>>\"%GITHUB_PATH%\""); + } + } + } +} \ No newline at end of file diff --git a/build/Build.cs b/build/Build.cs new file mode 100644 index 000000000..c3f644925 --- /dev/null +++ b/build/Build.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Nuke.Common; +using Nuke.Common.CI; +using Nuke.Common.Git; +using Nuke.Common.IO; +using Nuke.Common.ProjectModel; +using Nuke.Common.Tooling; +using Nuke.Common.Tools.DotNet; +using Nuke.Common.Utilities.Collections; +using Nuke.Components; +using Serilog; + +[ShutdownDotNetAfterServerBuild] +partial class Build : NukeBuild, ITest, IPack +{ + public static int Main() => Execute(x => ((ICompile) x).Compile); + + [Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release; + + [GitRepository] readonly GitRepository GitRepository; + + AbsolutePath SourceDirectory => RootDirectory / "src"; + + public AbsolutePath PackagesDirectory => RootDirectory / "packages"; + + string TagVersion => GitRepository.Tags.SingleOrDefault(x => "v".StartsWith(x))?[1..]; + bool IsTaggedBuild => !string.IsNullOrWhiteSpace(TagVersion); + + string VersionSuffix; + + [Parameter] + string Version; + + protected override void OnBuildInitialized() + { + VersionSuffix = !IsTaggedBuild + ? $"preview-{DateTime.UtcNow:yyyyMMdd-HHmm}" + : ""; + + if (IsLocalBuild) + { + VersionSuffix = $"dev-{DateTime.UtcNow:yyyyMMdd-HHmm}"; + } + + Log.Information("BUILD SETUP"); + Log.Information("Configuration:\t{Configuration}", Configuration); + Log.Information("Version suffix:\t{VersionSuffix}", VersionSuffix); + Log.Information("Version:\t\t{Version}", Version); + Log.Information("Tagged build:\t{IsTaggedBuild}", IsTaggedBuild); + } + + Target Clean => _ => _ + .Before(x => x.Restore) + .Executes(() => + { + SourceDirectory.GlobDirectories("**/bin", "**/obj").ForEach(x => x.DeleteDirectory()); + ((IHazArtifacts) this).ArtifactsDirectory.CreateOrCleanDirectory(); + }); + + public Configure CompileSettings => _ => _ + // ensure we don't generate too much output in CI run + // 0 Turns off emission of all warning messages + // 1 Displays severe warning messages + .SetWarningLevel(IsServerBuild ? 0 : 1); + + public IEnumerable TestProjects => ((IHazSolution) this).Solution.AllProjects.Where(x => x.Name.EndsWith("Tests")); +} \ No newline at end of file diff --git a/build/Configuration.cs b/build/Configuration.cs new file mode 100644 index 000000000..9c08b1ae9 --- /dev/null +++ b/build/Configuration.cs @@ -0,0 +1,16 @@ +using System; +using System.ComponentModel; +using System.Linq; +using Nuke.Common.Tooling; + +[TypeConverter(typeof(TypeConverter))] +public class Configuration : Enumeration +{ + public static Configuration Debug = new Configuration { Value = nameof(Debug) }; + public static Configuration Release = new Configuration { Value = nameof(Release) }; + + public static implicit operator string(Configuration configuration) + { + return configuration.Value; + } +} diff --git a/build/Directory.Build.props b/build/Directory.Build.props new file mode 100644 index 000000000..61fd9f44b --- /dev/null +++ b/build/Directory.Build.props @@ -0,0 +1,7 @@ + + + + false + + + \ No newline at end of file diff --git a/build/Directory.Build.targets b/build/Directory.Build.targets new file mode 100644 index 000000000..253260956 --- /dev/null +++ b/build/Directory.Build.targets @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/build/_build.csproj b/build/_build.csproj new file mode 100644 index 000000000..cdd7e0336 --- /dev/null +++ b/build/_build.csproj @@ -0,0 +1,22 @@ + + + + Exe + net8.0 + + CS0649;CS0169;CA1050;CA1822;CA2211;IDE1006 + .. + .. + 1 + true + true + false + true + false + + + + + + + diff --git a/build/_build.csproj.DotSettings b/build/_build.csproj.DotSettings new file mode 100644 index 000000000..eb3f4c27c --- /dev/null +++ b/build/_build.csproj.DotSettings @@ -0,0 +1,28 @@ + + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + Implicit + Implicit + ExpressionBody + 0 + NEXT_LINE + True + False + 120 + IF_OWNER_IS_SINGLE_LINE + WRAP_IF_LONG + False + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + True + True + True + True + True + True + True + True + True diff --git a/src/bundles/Elsa.Server.Web/Program.cs b/src/bundles/Elsa.Server.Web/Program.cs index 28631b6a1..bb2fc19ad 100644 --- a/src/bundles/Elsa.Server.Web/Program.cs +++ b/src/bundles/Elsa.Server.Web/Program.cs @@ -10,7 +10,6 @@ using Elsa.EntityFrameworkCore.Modules.Identity; using Elsa.EntityFrameworkCore.Modules.Management; using Elsa.EntityFrameworkCore.Modules.Runtime; using Elsa.Extensions; -using Elsa.Http.Handlers; using Elsa.Http.Options; using Elsa.MongoDb.Extensions; using Elsa.MongoDb.Modules.Identity; diff --git a/src/bundles/Elsa.Server.Web/appsettings.json b/src/bundles/Elsa.Server.Web/appsettings.json index 9859fc4f8..131ccbe59 100644 --- a/src/bundles/Elsa.Server.Web/appsettings.json +++ b/src/bundles/Elsa.Server.Web/appsettings.json @@ -17,7 +17,7 @@ "Sqlite": "Data Source=App_Data/elsa.sqlite.db;Cache=Shared;", "MongoDb": "mongodb://localhost:27017/elsa-workflows", "AzureServiceBus": "", - "RabbitMq": "amqp://guest:guest@localhost:5672/elsa" + "RabbitMq": "amqp://guest:guest@localhost:5672" }, "Smtp": { "Host": "localhost", diff --git a/src/bundles/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj b/src/bundles/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj index 8fa4d53b5..d58697184 100644 --- a/src/bundles/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj +++ b/src/bundles/Elsa.ServerAndStudio.Web/Elsa.ServerAndStudio.Web.csproj @@ -18,6 +18,7 @@ + @@ -34,8 +35,8 @@ - - + + \ No newline at end of file diff --git a/src/bundles/Elsa.ServerAndStudio.Web/Program.cs b/src/bundles/Elsa.ServerAndStudio.Web/Program.cs index eb19d40ac..a83d2cf6a 100644 --- a/src/bundles/Elsa.ServerAndStudio.Web/Program.cs +++ b/src/bundles/Elsa.ServerAndStudio.Web/Program.cs @@ -3,9 +3,10 @@ using Elsa.EntityFrameworkCore.Extensions; using Elsa.EntityFrameworkCore.Modules.Management; using Elsa.EntityFrameworkCore.Modules.Runtime; using Elsa.Extensions; -using Elsa.Webhooks.Extensions; using Microsoft.AspNetCore.Mvc; +const bool useMassTransit = true; + var builder = WebApplication.CreateBuilder(args); builder.WebHost.UseStaticWebAssets(); var services = builder.Services; @@ -16,32 +17,39 @@ var identityTokenSection = identitySection.GetSection("Tokens"); // Add Elsa services. services - .AddElsa(elsa => elsa - .UseSasTokens() - .UseIdentity(identity => + .AddElsa(elsa => + { + elsa + .UseSasTokens() + .UseIdentity(identity => + { + identity.IdentityOptions = options => identitySection.Bind(options); + identity.TokenOptions = options => identityTokenSection.Bind(options); + identity.UseConfigurationBasedUserProvider(options => identitySection.Bind(options)); + identity.UseConfigurationBasedApplicationProvider(options => identitySection.Bind(options)); + identity.UseConfigurationBasedRoleProvider(options => identitySection.Bind(options)); + }) + .UseDefaultAuthentication() + .UseWorkflowManagement(management => management.UseEntityFrameworkCore(ef => ef.UseSqlite(sqliteConnectionString))) + .UseWorkflowRuntime(runtime => runtime.UseEntityFrameworkCore(ef => ef.UseSqlite(sqliteConnectionString))) + .UseScheduling() + .UseJavaScript(options => options.AllowClrAccess = true) + .UseLiquid() + .UseCSharp() + .UsePython() + .UseHttp(http => http.ConfigureHttpOptions = options => configuration.GetSection("Http").Bind(options)) + .UseEmail(email => email.ConfigureOptions = options => configuration.GetSection("Smtp").Bind(options)) + .UseWebhooks(webhooks => webhooks.WebhookOptions = options => builder.Configuration.GetSection("Webhooks").Bind(options)) + .UseWorkflowsApi() + .UseRealTimeWorkflows() + .AddActivitiesFrom() + .AddWorkflowsFrom(); + + if (useMassTransit) { - identity.IdentityOptions = options => identitySection.Bind(options); - identity.TokenOptions = options => identityTokenSection.Bind(options); - identity.UseConfigurationBasedUserProvider(options => identitySection.Bind(options)); - identity.UseConfigurationBasedApplicationProvider(options => identitySection.Bind(options)); - identity.UseConfigurationBasedRoleProvider(options => identitySection.Bind(options)); - }) - .UseDefaultAuthentication() - .UseWorkflowManagement(management => management.UseEntityFrameworkCore(ef => ef.UseSqlite(sqliteConnectionString))) - .UseWorkflowRuntime(runtime => runtime.UseEntityFrameworkCore(ef => ef.UseSqlite(sqliteConnectionString))) - .UseScheduling() - .UseJavaScript(options => options.AllowClrAccess = true) - .UseLiquid() - .UseCSharp() - .UsePython() - .UseHttp(http => http.ConfigureHttpOptions = options => configuration.GetSection("Http").Bind(options)) - .UseEmail(email => email.ConfigureOptions = options => configuration.GetSection("Smtp").Bind(options)) - .UseWebhooks(webhooks => webhooks.WebhookOptions = options => builder.Configuration.GetSection("Webhooks").Bind(options)) - .UseWorkflowsApi() - .UseRealTimeWorkflows() - .AddActivitiesFrom() - .AddWorkflowsFrom() - ); + elsa.UseMassTransit(); + } + }); services.AddHealthChecks(); diff --git a/src/bundles/Elsa.ServerAndStudio.Web/appsettings.json b/src/bundles/Elsa.ServerAndStudio.Web/appsettings.json index 6a427b4da..4f0c9d1bc 100644 --- a/src/bundles/Elsa.ServerAndStudio.Web/appsettings.json +++ b/src/bundles/Elsa.ServerAndStudio.Web/appsettings.json @@ -13,7 +13,8 @@ }, "AllowedHosts": "*", "ConnectionStrings": { - "Sqlite": "Data Source=elsa.sqlite.db;Cache=Shared;" + "Sqlite": "Data Source=elsa.sqlite.db;Cache=Shared;", + "RabbitMq": "rabbitmq://guest:guest@localhost:5672" }, "Hosting": { "BasePath": "" diff --git a/src/bundles/Elsa.Studio.Web/Elsa.Studio.Web.csproj b/src/bundles/Elsa.Studio.Web/Elsa.Studio.Web.csproj index 3799baa6f..3964f9121 100644 --- a/src/bundles/Elsa.Studio.Web/Elsa.Studio.Web.csproj +++ b/src/bundles/Elsa.Studio.Web/Elsa.Studio.Web.csproj @@ -21,8 +21,8 @@ - - + + diff --git a/src/bundles/ElsaStudioWebAssembly/ElsaStudioWebAssembly.csproj b/src/bundles/ElsaStudioWebAssembly/ElsaStudioWebAssembly.csproj index f58d34aae..2da3d44eb 100644 --- a/src/bundles/ElsaStudioWebAssembly/ElsaStudioWebAssembly.csproj +++ b/src/bundles/ElsaStudioWebAssembly/ElsaStudioWebAssembly.csproj @@ -19,9 +19,9 @@ - - - + + + diff --git a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs index 2663488ad..6ae48eab8 100644 --- a/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs +++ b/src/clients/Elsa.Api.Client/Extensions/ActivityExtensions.cs @@ -145,11 +145,26 @@ public static class ActivityExtensions /// Sets the root activity in the specified activity. /// public static void SetRoot(this JsonObject container, JsonObject root) => container.SetProperty(root, "root"); - + /// /// Gets the root activity in the specified activity. /// - public static string? GetWorkflowDefinitionId(this JsonObject activity) => activity.GetProperty("workflowDefinitionId"); + public static string? GetWorkflowDefinitionId(this JsonObject activity) + { + // Determine if this activity represents a workflow definition activity based on the presence of the following properties. + var workflowDefinitionIdNode = activity.GetProperty("workflowDefinitionId"); + var workflowDefinitionVersionId = activity.GetProperty("workflowDefinitionVersionId"); + var latestAvailablePublishedVersion = activity.GetProperty("latestAvailablePublishedVersion"); + var latestAvailablePublishedVersionId = activity.GetProperty("latestAvailablePublishedVersionId"); + + if (workflowDefinitionIdNode == null || workflowDefinitionVersionId == null || latestAvailablePublishedVersion == null || latestAvailablePublishedVersionId == null) + return null; // Not a workflow definition activity. + + if (workflowDefinitionIdNode is JsonValue value) + return value.ToString(); + + return null; // Not a workflow definition activity. + } /// /// Determines whether the given activity is a workflow definition activity. @@ -157,7 +172,7 @@ public static class ActivityExtensions /// The JsonObject representing the activity. /// Returns true if the activity is a workflow definition activity; otherwise, false. public static bool GetIsWorkflowDefinitionActivity(this JsonObject activity) => activity.ContainsKey("workflowDefinitionId") && activity.ContainsKey("workflowDefinitionVersionId"); - + /// /// Gets a value indicating whether the specified activity can trigger the workflow. /// diff --git a/src/modules/Elsa.Alterations/AlterationHandlers/ScheduleActivityHandler.cs b/src/modules/Elsa.Alterations/AlterationHandlers/ScheduleActivityHandler.cs index 43a05acb1..727267988 100644 --- a/src/modules/Elsa.Alterations/AlterationHandlers/ScheduleActivityHandler.cs +++ b/src/modules/Elsa.Alterations/AlterationHandlers/ScheduleActivityHandler.cs @@ -28,7 +28,7 @@ public class ScheduleActivityHandler : AlterationHandlerBase { // If the activity is in a faulted state, reset it to Running. if (existingActivityExecutionContext.Status == ActivityStatus.Faulted) - existingActivityExecutionContext.Status = ActivityStatus.Running; + existingActivityExecutionContext.TransitionTo(ActivityStatus.Running); // Schedule the activity execution context. var parentContext = existingActivityExecutionContext.ParentActivityExecutionContext; diff --git a/src/modules/Elsa.Dapper/Elsa.Dapper.csproj b/src/modules/Elsa.Dapper/Elsa.Dapper.csproj index 6f25c445b..714945f04 100644 --- a/src/modules/Elsa.Dapper/Elsa.Dapper.csproj +++ b/src/modules/Elsa.Dapper/Elsa.Dapper.csproj @@ -32,7 +32,7 @@ - + diff --git a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs index 1fdd7b0a1..6630d693b 100644 --- a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs +++ b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs @@ -211,8 +211,8 @@ public class HttpEndpoint : Trigger var routeData = GetRouteData(httpContext, path); var routeDictionary = routeData.Values.ToDictionary(route => route.Key, route => route.Value!); - var queryStringDictionary = httpContext.Request.Query.ToDictionary, string, object>(queryString => queryString.Key, queryString => queryString.Value[0]!); - var headersDictionary = httpContext.Request.Headers.ToDictionary, string, object>(header => header.Key, header => header.Value[0]!); + var queryStringDictionary = httpContext.Request.Query.ToObjectDictionary(); + var headersDictionary = httpContext.Request.Headers.ToObjectDictionary(); context.Set(RouteData, routeDictionary); context.Set(QueryStringData, queryStringDictionary); @@ -225,48 +225,60 @@ public class HttpEndpoint : Trigger return; } - // Read content, if any. - try + // Handle Form Fields + if (request.HasFormContentType) { - var content = await ParseContentAsync(context, request); - ParsedContent.Set(context, content); + + var formFields = request.Form.ToObjectDictionary(); + + ParsedContent.Set(context, formFields); + + // Read files, if any. + var files = ReadFilesAsync(context, request); + + if (files.Any()) + { + if (!ValidateFileSizes(context, httpContext, files)) + { + await HandleFileSizeTooLargeAsync(context, httpContext); + return; + } + + if (!ValidateFileExtensionWhitelist(context, httpContext, files)) + { + await HandleInvalidFileExtensionWhitelistAsync(context, httpContext); + return; + } + + if (!ValidateFileExtensionBlacklist(context, httpContext, files)) + { + await HandleInvalidFileExtensionBlacklistAsync(context, httpContext); + return; + } + + if (!ValidateFileMimeTypes(context, httpContext, files)) + { + await HandleInvalidFileMimeTypesAsync(context, httpContext); + return; + } + + Files.Set(context, files.ToArray()); + } } - catch (JsonException e) + else { - await HandleInvalidJsonPayloadAsync(context, httpContext, e); - throw; - } - - // Read files, if any. - var files = ReadFilesAsync(context, request); - - if (files.Any()) - { - if (!ValidateFileSizes(context, httpContext, files)) + // Parse Non-Form content. + try { - await HandleFileSizeTooLargeAsync(context, httpContext); - return; + var content = await ParseContentAsync(context, request); + ParsedContent.Set(context, content); + } + catch (JsonException e) + { + await HandleInvalidJsonPayloadAsync(context, httpContext, e); + throw; } - if (!ValidateFileExtensionWhitelist(context, httpContext, files)) - { - await HandleInvalidFileExtensionWhitelistAsync(context, httpContext); - return; - } - - if (!ValidateFileExtensionBlacklist(context, httpContext, files)) - { - await HandleInvalidFileExtensionBlacklistAsync(context, httpContext); - return; - } - - if (!ValidateFileMimeTypes(context, httpContext, files)) - { - await HandleInvalidFileMimeTypesAsync(context, httpContext); - return; - } - - Files.Set(context, files.ToArray()); } // Complete. diff --git a/src/modules/Elsa.Http/Extensions/StringValueExtensions.cs b/src/modules/Elsa.Http/Extensions/StringValueExtensions.cs new file mode 100644 index 000000000..3b7948c1e --- /dev/null +++ b/src/modules/Elsa.Http/Extensions/StringValueExtensions.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Primitives; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Elsa.Extensions; + +/// +/// Contains extension methods for the Enumerable dictonaries. +/// +public static class StringValueExtensions +{ + /// + /// Convert the collection to the desired dictionary type. + /// + /// + /// + public static Dictionary ToObjectDictionary(this IEnumerable> collection) + { + return collection.ToDictionary, string, object>( + item => item.Key, + item => item.Value.Count <= 1 ? + item.Value[0]! + : item.Value.ToArray()); + } +} diff --git a/src/modules/Elsa.Http/Features/HttpFeature.cs b/src/modules/Elsa.Http/Features/HttpFeature.cs index 796eddc66..ece6c375c 100644 --- a/src/modules/Elsa.Http/Features/HttpFeature.cs +++ b/src/modules/Elsa.Http/Features/HttpFeature.cs @@ -158,12 +158,10 @@ public class HttpFeature : FeatureBase .AddRequestHandler() .AddNotificationHandler() - // Content parsers. - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - + // Content parsers. + .AddSingleton() + .AddSingleton() + // HTTP content factories. .AddScoped() .AddScoped() diff --git a/src/modules/Elsa.Http/Parsers/FormHttpContentParser.cs b/src/modules/Elsa.Http/Parsers/FormHttpContentParser.cs deleted file mode 100644 index 315ccc01b..000000000 --- a/src/modules/Elsa.Http/Parsers/FormHttpContentParser.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Xml.Serialization; -using Elsa.Extensions; -using Elsa.Http.Contracts; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.Primitives; - -namespace Elsa.Http.Parsers; - -/// -/// Reads application/xml and text/xml content type streams. -/// -public class FormHttpContentParser : IHttpContentParser -{ - /// - public int Priority => 0; - - /// - public bool GetSupportsContentType(string contentType) => contentType.StartsWith("application/x-www-form-urlencoded", StringComparison.InvariantCultureIgnoreCase); - - /// - public async Task ReadAsync(Stream content, Type? returnType, CancellationToken cancellationToken) - { - using var reader = new FormReader(content); - - return (await reader.ReadFormAsync(cancellationToken)).ToDictionary, string, object>(formField => formField.Key, formField => formField.Value[0]!); ; - } -} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Parsers/StringHttpContentParser.cs b/src/modules/Elsa.Http/Parsers/StringHttpContentParser.cs deleted file mode 100644 index 23fb9caf6..000000000 --- a/src/modules/Elsa.Http/Parsers/StringHttpContentParser.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Elsa.Expressions.Helpers; -using Elsa.Http.Contracts; - -namespace Elsa.Http.Parsers; - -/// -/// Reads any content type streams as a string and attempts to convert the string to the specified return type. -/// -public class StringHttpContentParser : IHttpContentParser -{ - /// - public int Priority => -10; - - /// - public bool GetSupportsContentType(string contentType) => !contentType.StartsWith("multipart/form-data"); - - /// - public async Task ReadAsync(Stream content, Type? returnType, CancellationToken cancellationToken) - { - using var reader = new StreamReader(content, leaveOpen: true); - var text = await reader.ReadToEndAsync(); - return returnType == null ? text : text.ConvertTo(returnType)!; - } -} \ No newline at end of file diff --git a/src/modules/Elsa.MassTransit/Activities/PublishMessage.cs b/src/modules/Elsa.MassTransit/Activities/PublishMessage.cs index e5972aa46..1802f3bd8 100644 --- a/src/modules/Elsa.MassTransit/Activities/PublishMessage.cs +++ b/src/modules/Elsa.MassTransit/Activities/PublishMessage.cs @@ -6,6 +6,7 @@ using Elsa.MassTransit.Implementations; using Elsa.Workflows; using Elsa.Workflows.Attributes; using Elsa.Workflows.Models; +using Elsa.Workflows.UIHints; using MassTransit; namespace Elsa.MassTransit.Activities; @@ -29,7 +30,10 @@ public class PublishMessage : CodeActivity /// /// The message to send. Must be a concrete implementation of the configured . /// - [Input(Description = "The message to send. Must be a concrete implementation of the configured message type.")] + [Input( + Description = "The message to send. Must be a concrete implementation of the configured message type.", + UIHint = InputUIHints.MultiLine + )] public Input Message { get; set; } = default!; /// diff --git a/src/modules/Elsa.MassTransit/Implementations/MassTransitActivityTypeProvider.cs b/src/modules/Elsa.MassTransit/Implementations/MassTransitActivityTypeProvider.cs index 75d9b32a6..e2dabe143 100644 --- a/src/modules/Elsa.MassTransit/Implementations/MassTransitActivityTypeProvider.cs +++ b/src/modules/Elsa.MassTransit/Implementations/MassTransitActivityTypeProvider.cs @@ -6,8 +6,8 @@ using Elsa.MassTransit.Options; using Elsa.Workflows; using Elsa.Workflows.Attributes; using Elsa.Workflows.Contracts; -using Elsa.Workflows.UIHints; using Elsa.Workflows.Helpers; +using Elsa.Workflows.Management; using Elsa.Workflows.Models; using Humanizer; using Microsoft.Extensions.Options; @@ -17,40 +17,31 @@ namespace Elsa.MassTransit.Implementations; /// /// Provides activities to the system from the configured MassTransit message types. /// -public class MassTransitActivityTypeProvider : IActivityProvider +public class MassTransitActivityTypeProvider(IActivityFactory activityFactory, IOptions options, IActivityDescriber activityDescriber) : IActivityProvider { - private readonly IActivityFactory _activityFactory; - private readonly MassTransitActivityOptions _options; - - /// - /// Constructor. - /// - public MassTransitActivityTypeProvider(IActivityFactory activityFactory, IOptions options) - { - _activityFactory = activityFactory; - _options = options.Value; - } - /// - public ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) + public async ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) { - var messageTypes = _options.MessageTypes; - var descriptors = CreateDescriptors(messageTypes).ToList(); - return new(descriptors); + var messageTypes = options.Value.MessageTypes; + var descriptors = await CreateDescriptorsAsync(messageTypes, cancellationToken); + return descriptors.ToList(); } - private IEnumerable CreateDescriptors(IEnumerable messageTypes) + private async Task> CreateDescriptorsAsync(IEnumerable messageTypes, CancellationToken cancellationToken = default) { + var descriptors = new List(); foreach (var messageType in messageTypes) { - yield return CreateMessageReceivedDescriptor(messageType); + descriptors.Add(await CreateMessageReceivedDescriptor(messageType, cancellationToken)); if(messageType.IsClass) - yield return CreatePublishMessageDescriptor(messageType); + descriptors.Add(await CreatePublishMessageDescriptor(messageType, cancellationToken)); } + + return descriptors; } - private ActivityDescriptor CreateMessageReceivedDescriptor(Type messageType) + private async Task CreateMessageReceivedDescriptor(Type messageType, CancellationToken cancellationToken = default) { var activityAttr = messageType.GetCustomAttribute(); var typeName = activityAttr?.Type ?? messageType.Name; @@ -61,6 +52,11 @@ public class MassTransitActivityTypeProvider : IActivityProvider var category = categoryAttr?.Category ?? activityAttr?.Category ?? "MassTransit"; var descriptionAttr = messageType.GetCustomAttribute(); var description = descriptionAttr?.Description ?? activityAttr?.Description; + + var outputDescriptor = await activityDescriber.DescribeOutputProperty(x => x.Result!, cancellationToken); + var openOutputType = typeof(Output<>); + var outputType = openOutputType.MakeGenericType(messageType); + outputDescriptor.Type = outputType; return new() { @@ -73,17 +69,11 @@ public class MassTransitActivityTypeProvider : IActivityProvider IsBrowsable = true, Outputs = { - new OutputDescriptor - { - Description = "The received message", - DisplayName = "Received Message", - Name = nameof(MessageReceived.Result), - Type = typeof(object) - } + outputDescriptor }, Constructor = context => { - var activity = _activityFactory.Create(context); + var activity = activityFactory.Create(context); activity.Type = fullTypeName; activity.MessageType = messageType; return activity; @@ -91,7 +81,7 @@ public class MassTransitActivityTypeProvider : IActivityProvider }; } - private ActivityDescriptor CreatePublishMessageDescriptor(Type messageType) + private async Task CreatePublishMessageDescriptor(Type messageType, CancellationToken cancellationToken = default) { var activityAttr = messageType.GetCustomAttribute(); var typeName = activityAttr?.Type ?? messageType.Name; @@ -104,6 +94,11 @@ public class MassTransitActivityTypeProvider : IActivityProvider var descriptionAttr = messageType.GetCustomAttribute(); var description = descriptionAttr?.Description ?? activityAttr?.Description; + var messageInputDescriptor = await activityDescriber.DescribeInputPropertyAsync(x => x.Message, cancellationToken: cancellationToken); + var openInputType = typeof(Input<>); + var inputType = openInputType.MakeGenericType(messageType); + messageInputDescriptor.Type = inputType; + return new() { TypeName = fullTypeName, @@ -115,18 +110,11 @@ public class MassTransitActivityTypeProvider : IActivityProvider IsBrowsable = true, Inputs = { - new InputDescriptor - { - Description = "The message to publish.", - UIHint = InputUIHints.MultiLine, - DisplayName = "Message", - Type = typeof(Input), - Name = nameof(PublishMessage.Message) - } + messageInputDescriptor }, Constructor = context => { - var activity = _activityFactory.Create(context); + var activity = activityFactory.Create(context); activity.Type = fullTypeName; activity.MessageType = messageType; return activity; diff --git a/src/modules/Elsa.ProtoActor/Grains/WorkflowInstance.cs b/src/modules/Elsa.ProtoActor/Grains/WorkflowInstance.cs index 0dd27ad33..21f680608 100644 --- a/src/modules/Elsa.ProtoActor/Grains/WorkflowInstance.cs +++ b/src/modules/Elsa.ProtoActor/Grains/WorkflowInstance.cs @@ -3,16 +3,22 @@ using Elsa.ProtoActor.Extensions; using Elsa.ProtoActor.Mappers; using Elsa.ProtoActor.ProtoBuf; using Elsa.ProtoActor.Snapshots; +using Elsa.Workflows; using Elsa.Workflows.Contracts; +using Elsa.Workflows.Helpers; using Elsa.Workflows.Management.Contracts; using Elsa.Workflows.Management.Mappers; using Elsa.Workflows.Runtime.Contracts; using Elsa.Workflows.Runtime.Options; +using Elsa.Workflows.Runtime.Requests; using Elsa.Workflows.State; using Microsoft.Extensions.DependencyInjection; using Proto; using Proto.Cluster; using Proto.Persistence; +using CancellationTokens = Elsa.Workflows.Models.CancellationTokens; +using WorkflowStatus = Elsa.Workflows.WorkflowStatus; +using WorkflowSubStatus = Elsa.Workflows.WorkflowSubStatus; namespace Elsa.ProtoActor.Grains; @@ -38,6 +44,8 @@ internal class WorkflowInstance : WorkflowInstanceBase private IWorkflowHost _workflowHost = default!; private WorkflowState _workflowState = default!; + private readonly ICollection _cancellationTokenSources = new List(); + /// public WorkflowInstance( IServiceScopeFactory scopeFactory, @@ -139,6 +147,10 @@ internal class WorkflowInstance : WorkflowInstanceBase var versionOptions = VersionOptions.FromString(request.VersionOptions); var cancellationToken = Context.CancellationToken; + var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _cancellationTokenSources.Add(cancellationTokenSource); + cancellationToken = cancellationTokenSource.Token; + // Only need to reconstruct a workflow host if not already done so during CanStart. if (_workflowHost == null!) { @@ -155,7 +167,9 @@ internal class WorkflowInstance : WorkflowInstanceBase CorrelationId = correlationId, Input = input, Properties = properties, - TriggerActivityId = request.TriggerActivityId + TriggerActivityId = request.TriggerActivityId, + StatusUpdatedCallback = StatusUpdated, + CancellationTokens = new CancellationTokens(cancellationToken) }; var task = _workflowHost.StartWorkflowAsync(startWorkflowOptions, cancellationToken); @@ -187,6 +201,31 @@ internal class WorkflowInstance : WorkflowInstanceBase }); } + private void StatusUpdated(WorkflowExecutionContext context) + { + _ = Task.Run(async () => await Update(context)); + } + + private async Task Update(WorkflowExecutionContext context) + { + using var scope = _scopeFactory.CreateScope(); + var extractor = scope.ServiceProvider.GetRequiredService(); + var bookmarkPersistor = scope.ServiceProvider.GetRequiredService(); + var workflowState = extractor.Extract(context); + var originalBookmarks = _workflowHost.WorkflowState.Bookmarks; + + _workflowState = workflowState; + + await SaveSnapshotAsync(); + SaveWorkflowInstance(workflowState); + var newBookmarks = workflowState.Bookmarks; + + var diff = Diff.For(originalBookmarks, newBookmarks); + + var bookmarkRequest = new UpdateBookmarksRequest(workflowState.DefinitionId, diff, workflowState.CorrelationId); + await bookmarkPersistor.PersistBookmarksAsync(bookmarkRequest); + } + /// public override Task Stop() { @@ -208,6 +247,10 @@ internal class WorkflowInstance : WorkflowInstanceBase var activityInstanceId = request.ActivityInstanceId.NullIfEmpty(); var activityHash = request.ActivityHash.NullIfEmpty(); var cancellationToken = Context.CancellationToken; + + var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _cancellationTokenSources.Add(cancellationTokenSource); + cancellationToken = cancellationTokenSource.Token; var resumeWorkflowHostOptions = new ResumeWorkflowHostOptions { @@ -218,7 +261,8 @@ internal class WorkflowInstance : WorkflowInstanceBase ActivityInstanceId = activityInstanceId, ActivityHash = activityHash, Input = _input, - Properties = _properties + Properties = _properties, + CancellationTokens = cancellationToken }; var definitionId = _definitionId; @@ -260,6 +304,18 @@ internal class WorkflowInstance : WorkflowInstanceBase /// public override Task Resume(ResumeWorkflowRequest request) => Task.FromResult(new WorkflowExecutionResponse()); + public override async Task Cancel() + { + if (_workflowState.Status != WorkflowStatus.Finished) + { + _workflowState.SubStatus = WorkflowSubStatus.Cancelled; + _workflowState.Status = WorkflowStatus.Finished; + } + + foreach(var source in _cancellationTokenSources) + source.Cancel(); + } + /// public override async Task ExportState(ExportWorkflowStateRequest request) { diff --git a/src/modules/Elsa.ProtoActor/Proto/WorkflowInstance.proto b/src/modules/Elsa.ProtoActor/Proto/WorkflowInstance.proto index 1e5b533ac..c6570ac8d 100644 --- a/src/modules/Elsa.ProtoActor/Proto/WorkflowInstance.proto +++ b/src/modules/Elsa.ProtoActor/Proto/WorkflowInstance.proto @@ -12,6 +12,7 @@ service WorkflowInstance { rpc Start (StartWorkflowRequest) returns (WorkflowExecutionResponse); rpc Stop (Empty) returns (Empty); rpc Resume (ResumeWorkflowRequest) returns (WorkflowExecutionResponse); + rpc Cancel (Empty) returns (Empty); rpc ExportState(ExportWorkflowStateRequest) returns (ExportWorkflowStateResponse); rpc ImportState(ImportWorkflowStateRequest) returns (ImportWorkflowStateResponse); } \ No newline at end of file diff --git a/src/modules/Elsa.ProtoActor/Services/ProtoActorWorkflowRuntime.cs b/src/modules/Elsa.ProtoActor/Services/ProtoActorWorkflowRuntime.cs index c10b9f788..0d47a9625 100644 --- a/src/modules/Elsa.ProtoActor/Services/ProtoActorWorkflowRuntime.cs +++ b/src/modules/Elsa.ProtoActor/Services/ProtoActorWorkflowRuntime.cs @@ -16,6 +16,7 @@ using Elsa.Workflows.State; using Proto.Cluster; using Bookmark = Elsa.Workflows.Models.Bookmark; using CountRunningWorkflowsRequest = Elsa.Workflows.Runtime.Requests.CountRunningWorkflowsRequest; +using WorkflowStatus = Elsa.Workflows.WorkflowStatus; namespace Elsa.ProtoActor.Services; @@ -246,6 +247,13 @@ internal class ProtoActorWorkflowRuntime : IWorkflowRuntime return result!; } + /// + public async Task CancelWorkflowAsync(string workflowInstanceId, CancellationToken cancellationToken) + { + var client = _cluster.GetNamedWorkflowGrain(workflowInstanceId); + await client.Cancel(cancellationToken); + } + /// public async Task> FindWorkflowsAsync(WorkflowsFilter filter, CancellationToken cancellationToken = default) { @@ -296,7 +304,7 @@ internal class ProtoActorWorkflowRuntime : IWorkflowRuntime DefinitionId = request.DefinitionId, Version = request.Version, CorrelationId = request.CorrelationId, - WorkflowStatus = Workflows.WorkflowStatus.Running + WorkflowStatus = WorkflowStatus.Running }; return await _workflowInstanceStore.CountAsync(filter, cancellationToken); } diff --git a/src/modules/Elsa.Webhooks/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Webhooks/Extensions/ModuleExtensions.cs index 6b8a30599..fb1cb8845 100644 --- a/src/modules/Elsa.Webhooks/Extensions/ModuleExtensions.cs +++ b/src/modules/Elsa.Webhooks/Extensions/ModuleExtensions.cs @@ -1,7 +1,8 @@ using Elsa.Features.Services; using Elsa.Webhooks.Features; -namespace Elsa.Webhooks.Extensions; +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; /// /// Adds extensions to that enables the feature. diff --git a/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj b/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj index c85487ae3..43fc92b01 100644 --- a/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj +++ b/src/modules/Elsa.Workflows.Api/Elsa.Workflows.Api.csproj @@ -5,7 +5,6 @@ - 3.0.0 Provides API endpoints for client applications such as the Elsa Designer web component. diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/BulkDelete/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/BulkDelete/Endpoint.cs index 60edf36b7..c7756c25e 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/BulkDelete/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowDefinitions/BulkDelete/Endpoint.cs @@ -22,7 +22,7 @@ internal class BulkDelete : ElsaEndpoint public override async Task ExecuteAsync(Request request, CancellationToken cancellationToken) { - var count = await _workflowDefinitionManager.BulkDeleteByDefinitionIdsAsync(request!.DefinitionIds, cancellationToken); + var count = await _workflowDefinitionManager.BulkDeleteByDefinitionIdsAsync(request.DefinitionIds, cancellationToken); return new Response(count); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkCancel/Endpoint.cs b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkCancel/Endpoint.cs index 19a2bb69d..1f25f003d 100644 --- a/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkCancel/Endpoint.cs +++ b/src/modules/Elsa.Workflows.Api/Endpoints/WorkflowInstances/BulkCancel/Endpoint.cs @@ -1,25 +1,30 @@ -using System.Text.Json.Serialization; using Elsa.Abstractions; +using Elsa.Workflows.Runtime.Contracts; namespace Elsa.Workflows.Api.Endpoints.WorkflowInstances.BulkCancel; public class BulkCancel : ElsaEndpoint { + private readonly IWorkflowRuntime _workflowRuntime; + + public BulkCancel(IWorkflowRuntime workflowRuntime) + { + _workflowRuntime = workflowRuntime; + } + public override void Configure() { Post("/bulk-actions/cancel/workflow-instances/by-id"); ConfigurePermissions("cancel:workflow-instances"); } - + public override async Task ExecuteAsync(Request request, CancellationToken cancellationToken) { - // TODO: Implement workflow cancellation. - var count = -1; + var tasks = request.Ids.Select(id => _workflowRuntime.CancelWorkflowAsync(id, cancellationToken)).ToList(); + await Task.WhenAll(tasks); + + var count = tasks.Count(t => t.IsCompletedSuccessfully); return new(count); } - - public record BulkCancelWorkflowInstancesRequest(ICollection Ids); - - public record BulkCancelWorkflowInstancesResponse([property: JsonPropertyName("cancelled")] int CancelledCount); } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.Cancel.cs b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.Cancel.cs new file mode 100644 index 000000000..3732bfeea --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.Cancel.cs @@ -0,0 +1,44 @@ +using Elsa.Extensions; +using Elsa.Mediator.Contracts; +using Elsa.Workflows.Notifications; +using Elsa.Workflows.Signals; + +namespace Elsa.Workflows; + +public partial class ActivityExecutionContext +{ + private readonly CancellationTokenRegistration _cancellationRegistration; + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly INotificationSender _publisher; + + private void CancelActivity() + { + // If the activity is not running, do nothing. + if (Status != ActivityStatus.Running && Status != ActivityStatus.Faulted) + return; + + _ = Task.Run(async () => await CancelActivityAsync()); + } + + private async Task CancelActivityAsync() + { + // Select all child contexts. + var childContexts = WorkflowExecutionContext.ActivityExecutionContexts.Where(x => x.ParentActivityExecutionContext == this).ToList(); + + foreach (var childContext in childContexts) + childContext._cancellationTokenSource.Cancel(); + + TransitionTo(ActivityStatus.Canceled); + ClearBookmarks(); + ClearCompletionCallbacks(); + WorkflowExecutionContext.Bookmarks.RemoveWhere(x => x.ActivityNodeId == NodeId); + + // Add an execution log entry. + AddExecutionLogEntry("Canceled", payload: JournalData, includeActivityState: true); + + _cancellationRegistration.Dispose(); + + await this.SendSignalAsync(new CancelSignal()); + await _publisher.SendAsync(new ActivityCancelled(this)); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.ExecutionLogEntry.cs b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.ExecutionLogEntry.cs new file mode 100644 index 000000000..a7ce2cffb --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.ExecutionLogEntry.cs @@ -0,0 +1,40 @@ +using Elsa.Extensions; +using Elsa.Workflows.Models; + +namespace Elsa.Workflows; + +public partial class ActivityExecutionContext +{ + /// + /// Adds a new to the execution log of the current . + /// + /// The name of the event. + /// The message of the event. + /// The source of the activity. For example, the source file name and line number in case of composite activities. + /// Any contextual data related to this event. + /// True to include activity state with this event, false otherwise. + /// Returns the created . + public WorkflowExecutionLogEntry AddExecutionLogEntry(string eventName, string? message = default, string? source = default, object? payload = default, bool includeActivityState = false) + { + var activityState = includeActivityState ? ActivityState : default; + + var logEntry = new WorkflowExecutionLogEntry( + Id, + ParentActivityExecutionContext?.Id, + Activity.Id, + Activity.Type, + Activity.Version, + Activity.Name, + NodeId, + activityState, + _systemClock.UtcNow, + WorkflowExecutionContext.ExecutionLogSequence++, + eventName, + message, + source ?? Activity.GetSource(), + payload); + + WorkflowExecutionContext.ExecutionLog.Add(logEntry); + return logEntry; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs index b53b7958a..9583fbc20 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/ActivityExecutionContext.cs @@ -4,6 +4,7 @@ using Elsa.Common.Contracts; using Elsa.Expressions.Helpers; using Elsa.Expressions.Models; using Elsa.Extensions; +using Elsa.Mediator.Contracts; using Elsa.Workflows.Contracts; using Elsa.Workflows.Memory; using Elsa.Workflows.Models; @@ -15,7 +16,7 @@ namespace Elsa.Workflows; /// /// Represents the context of an activity execution. /// -public class ActivityExecutionContext : IExecutionContext +public partial class ActivityExecutionContext : IExecutionContext { private readonly ISystemClock _systemClock; private readonly List _bookmarks = new(); @@ -47,6 +48,10 @@ public class ActivityExecutionContext : IExecutionContext Tag = tag; CancellationToken = cancellationToken; Id = id; + _publisher = GetRequiredService(); + + _cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _cancellationRegistration = _cancellationTokenSource.Token.Register(CancelActivity); } /// @@ -123,8 +128,21 @@ public class ActivityExecutionContext : IExecutionContext /// /// The current status of the activity. /// - public ActivityStatus Status { get; set; } + public ActivityStatus Status { get; private set; } + /// + /// Sets the current status of the activity. + /// + public void TransitionTo(ActivityStatus status) + { + Status = status; + + if (Status is ActivityStatus.Completed + or ActivityStatus.Canceled + or ActivityStatus.Faulted) + _cancellationRegistration.Dispose(); + } + /// /// Gets or sets the exception that occurred during the activity execution, if any. /// diff --git a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.Cancel.cs b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.Cancel.cs new file mode 100644 index 000000000..74a21f7f6 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.Cancel.cs @@ -0,0 +1,37 @@ +namespace Elsa.Workflows; + +/// +/// Provides context to the currently executing workflow. +/// +public partial class WorkflowExecutionContext +{ + private ICollection _cancellationTokenSources = new List(); + private ICollection _cancellationRegistrations = new List(); + + /// + /// Cancels the workflow and all it's children. + /// + public void Cancel() + { + foreach (var source in _cancellationTokenSources) + source.Cancel(); + + _cancellationTokenSources.Clear(); + } + + private void CancelWorkflow() + { + Bookmarks.Clear(); + _completionCallbackEntries.Clear(); + + if (Status != WorkflowStatus.Running && SubStatus != WorkflowSubStatus.Faulted) + return; + + AddExecutionLogEntry("Workflow cancelled"); + + TransitionTo(WorkflowSubStatus.Cancelled); + + foreach (var registration in _cancellationRegistrations) + registration.Dispose(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.ExecutionLogEntry.cs b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.ExecutionLogEntry.cs new file mode 100644 index 000000000..137f372fd --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.ExecutionLogEntry.cs @@ -0,0 +1,36 @@ +using Elsa.Extensions; +using Elsa.Workflows.Models; + +namespace Elsa.Workflows; + +public partial class WorkflowExecutionContext +{ + /// + /// Adds a new to the execution log of the current . + /// + /// The name of the event. + /// The message of the event. + /// Any contextual data related to this event. + /// Returns the created . + public WorkflowExecutionLogEntry AddExecutionLogEntry(string eventName, string? message = default, object? payload = default) + { + var logEntry = new WorkflowExecutionLogEntry( + Id, + default, + Workflow.Id, + Workflow.Type, + Workflow.Identity.Version, + Workflow.Name, + Workflow.Identity.Id, + default, + SystemClock.UtcNow, + ExecutionLogSequence++, + eventName, + message, + Workflow.GetSource(), + payload); + + ExecutionLog.Add(logEntry); + return logEntry; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs index 51bcc4ae3..30df7271b 100644 --- a/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs +++ b/src/modules/Elsa.Workflows.Core/Contexts/WorkflowExecutionContext.cs @@ -28,7 +28,7 @@ public record ActivityCompletionCallbackEntry(ActivityExecutionContext Owner, Ac /// Provides context to the currently executing workflow. /// [PublicAPI] -public class WorkflowExecutionContext : IExecutionContext +public partial class WorkflowExecutionContext : IExecutionContext { private static readonly object ActivityOutputRegistryKey = new(); private static readonly object LastActivityResultKey = new(); @@ -37,6 +37,7 @@ public class WorkflowExecutionContext : IExecutionContext private readonly IList _completionCallbackEntries = new List(); private IList _activityExecutionContexts; private readonly IHasher _hasher; + private readonly Action? _statusUpdatedCallback; /// /// Initializes a new instance of . @@ -51,6 +52,7 @@ public class WorkflowExecutionContext : IExecutionContext string? triggerActivityId, IEnumerable incidents, DateTimeOffset createdAt, + Action? statusUpdatedCallback, CancellationTokens cancellationTokens) { ServiceProvider = serviceProvider; @@ -70,6 +72,13 @@ public class WorkflowExecutionContext : IExecutionContext CreatedAt = createdAt; CancellationTokens = cancellationTokens; Incidents = incidents.ToList(); + + var appSource = CancellationTokenSource.CreateLinkedTokenSource(CancellationTokens.ApplicationCancellationToken); + _cancellationTokenSources.Add(appSource); + var sysSource = CancellationTokenSource.CreateLinkedTokenSource(CancellationTokens.SystemCancellationToken); + _cancellationTokenSources.Add(sysSource); + _cancellationRegistrations.Add(appSource.Token.Register(CancelWorkflow)); + _cancellationRegistrations.Add(sysSource.Token.Register(CancelWorkflow)); } /// @@ -84,6 +93,7 @@ public class WorkflowExecutionContext : IExecutionContext IDictionary? properties = default, ExecuteActivityDelegate? executeDelegate = default, string? triggerActivityId = default, + Action? statusUpdatedCallback = default, CancellationTokens cancellationTokens = default) { var systemClock = serviceProvider.GetRequiredService(); @@ -99,6 +109,7 @@ public class WorkflowExecutionContext : IExecutionContext properties, executeDelegate, triggerActivityId, + statusUpdatedCallback, cancellationTokens ); } @@ -115,6 +126,7 @@ public class WorkflowExecutionContext : IExecutionContext IDictionary? properties = default, ExecuteActivityDelegate? executeDelegate = default, string? triggerActivityId = default, + Action? statusUpdatedCallback = default, CancellationTokens cancellationTokens = default) { var workflowExecutionContext = await CreateAsync( @@ -128,6 +140,7 @@ public class WorkflowExecutionContext : IExecutionContext properties, executeDelegate, triggerActivityId, + statusUpdatedCallback, cancellationTokens); var workflowStateExtractor = serviceProvider.GetRequiredService(); @@ -150,6 +163,7 @@ public class WorkflowExecutionContext : IExecutionContext IDictionary? properties = default, ExecuteActivityDelegate? executeDelegate = default, string? triggerActivityId = default, + Action? statusUpdatedCallback = default, CancellationTokens cancellationTokens = default) { // Setup a workflow execution context. @@ -163,6 +177,7 @@ public class WorkflowExecutionContext : IExecutionContext triggerActivityId, incidents, createdAt, + statusUpdatedCallback, cancellationTokens) { MemoryRegister = workflow.CreateRegister() @@ -521,6 +536,20 @@ public class WorkflowExecutionContext : IExecutionContext throw new Exception($"Cannot transition from {SubStatus} to {subStatus}"); SubStatus = subStatus; + + //For now only trigger on Cancelled, since the other statuses are handling via the host/runner + if (SubStatus == WorkflowSubStatus.Cancelled + && _statusUpdatedCallback is not null) + _statusUpdatedCallback(this); + + if (Status == WorkflowStatus.Finished + || SubStatus == WorkflowSubStatus.Suspended) + { + foreach (var registration in _cancellationRegistrations) + { + registration.Dispose(); + } + } } /// diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs index 937934592..631ba1fa6 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ActivityExecutionContextExtensions.cs @@ -1,7 +1,6 @@ using System.Linq.Expressions; using System.Reflection; using System.Text.Json; -using Elsa.Common.Contracts; using Elsa.Expressions.Contracts; using Elsa.Expressions.Helpers; using Elsa.Expressions.Models; @@ -74,45 +73,6 @@ public static class ActivityExecutionContextExtensions /// public static bool IsTriggerOfWorkflow(this ActivityExecutionContext context) => context.WorkflowExecutionContext.TriggerActivityId == context.Activity.Id; - /// - /// Adds a new to the execution log of the current . - /// - /// The being extended. - /// The name of the event. - /// The message of the event. - /// The source of the activity. For example, the source file name and line number in case of composite activities. - /// Any contextual data related to this event. - /// True to include activity state with this event, false otherwise. - /// Returns the created . - public static WorkflowExecutionLogEntry AddExecutionLogEntry(this ActivityExecutionContext context, string eventName, string? message = default, string? source = default, object? payload = default, bool includeActivityState = false) - { - var activity = context.Activity; - var activityInstanceId = context.Id; - var parentActivityInstanceId = context.ParentActivityExecutionContext?.Id; - var workflowExecutionContext = context.WorkflowExecutionContext; - var now = context.GetRequiredService().UtcNow; - var activityState = includeActivityState ? context.ActivityState : default; - - var logEntry = new WorkflowExecutionLogEntry( - activityInstanceId, - parentActivityInstanceId, - activity.Id, - activity.Type, - activity.Version, - activity.Name, - context.NodeId, - activityState, - now, - workflowExecutionContext.ExecutionLogSequence++, - eventName, - message, - source ?? activity.GetSource(), - payload); - - workflowExecutionContext.ExecutionLog.Add(logEntry); - return logEntry; - } - /// /// Creates a workflow variable by name and optionally sets the value. /// @@ -477,7 +437,7 @@ public static class ActivityExecutionContextExtensions await childContext.CancelActivityAsync(); // Mark the activity as complete. - context.Status = ActivityStatus.Completed; + context.TransitionTo(ActivityStatus.Completed); // Record the outcomes, if any. if (outcomes != null) @@ -595,7 +555,7 @@ public static class ActivityExecutionContextExtensions await CancelActivityAsync(childContext); var publisher = context.GetRequiredService(); - context.Status = ActivityStatus.Canceled; + context.TransitionTo(ActivityStatus.Canceled); context.ClearBookmarks(); context.ClearCompletionCallbacks(); context.WorkflowExecutionContext.Bookmarks.RemoveWhere(x => x.ActivityNodeId == context.NodeId); diff --git a/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs index 882883686..cf93acdcb 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/ExpressionExecutionContextExtensions.cs @@ -292,7 +292,12 @@ public static class ExpressionExecutionContextExtensions select v; var variable = q.FirstOrDefault(); - variable?.Set(context, value); + + if(variable != null) + variable.Set(context, value); + + if (variable == null) + CreateVariable(context, variableName, value); } /// diff --git a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs index 8e1ac8d32..e3cb8db38 100644 --- a/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs +++ b/src/modules/Elsa.Workflows.Core/Extensions/WorkflowExecutionContextExtensions.cs @@ -125,36 +125,4 @@ public static class WorkflowExecutionContextExtensions /// Returns true if all activities have completed or canceled, false otherwise. /// public static bool AllActivitiesCompleted(this WorkflowExecutionContext workflowExecutionContext) => workflowExecutionContext.ActivityExecutionContexts.All(x => x.IsCompleted); - - /// - /// Adds a new to the execution log of the current . - /// - /// The being extended. - /// The name of the event. - /// The message of the event. - /// Any contextual data related to this event. - /// Returns the created . - public static WorkflowExecutionLogEntry AddExecutionLogEntry(this WorkflowExecutionContext context, string eventName, string? message = default, object? payload = default) - { - var now = context.GetRequiredService().UtcNow; - - var logEntry = new WorkflowExecutionLogEntry( - context.Id, - default, - context.Workflow.Id, - context.Workflow.Type, - context.Workflow.Identity.Version, - context.Workflow.Name, - context.Workflow.Identity.Id, - default, - now, - context.ExecutionLogSequence++, - eventName, - message, - context.Workflow.GetSource(), - payload); - - context.ExecutionLog.Add(logEntry); - return logEntry; - } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs index 32d02f4b6..fe25241c6 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/DefaultActivityInvokerMiddleware.cs @@ -41,16 +41,24 @@ public class DefaultActivityInvokerMiddleware : IActivityExecutionMiddleware // Evaluate input properties. await EvaluateInputPropertiesAsync(context); - + + // Prevent the activity from being started if cancellation is requested. + if (context.CancellationToken.IsCancellationRequested) + { + context.TransitionTo(ActivityStatus.Canceled); + context.AddExecutionLogEntry("Activity cancelled"); + return; + } + // Check if the activity can be executed. if (!await context.Activity.CanExecuteAsync(context)) { - context.Status = ActivityStatus.Pending; + context.TransitionTo(ActivityStatus.Pending); context.AddExecutionLogEntry("Precondition Failed", "Cannot execute at this time"); return; } - context.Status = ActivityStatus.Running; + context.TransitionTo(ActivityStatus.Running); // Execute activity. await ExecuteActivityAsync(context); diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs index 6a8e851ca..5d47eebc8 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Activities/ExceptionHandlingMiddleware.cs @@ -50,7 +50,7 @@ public class ExceptionHandlingMiddleware : IActivityExecutionMiddleware { _logger.LogWarning(e, "An exception was caught from a downstream middleware component"); context.Exception = e; - context.Status = ActivityStatus.Faulted; + context.TransitionTo(ActivityStatus.Faulted); var activity = context.Activity; var exceptionState = ExceptionState.FromException(e); diff --git a/src/modules/Elsa.Workflows.Core/Middleware/Workflows/DefaultWorkSchedulerMiddleware.cs b/src/modules/Elsa.Workflows.Core/Middleware/Workflows/DefaultActivitySchedulerMiddleware.cs similarity index 91% rename from src/modules/Elsa.Workflows.Core/Middleware/Workflows/DefaultWorkSchedulerMiddleware.cs rename to src/modules/Elsa.Workflows.Core/Middleware/Workflows/DefaultActivitySchedulerMiddleware.cs index d8b6313b1..e4744d65f 100644 --- a/src/modules/Elsa.Workflows.Core/Middleware/Workflows/DefaultWorkSchedulerMiddleware.cs +++ b/src/modules/Elsa.Workflows.Core/Middleware/Workflows/DefaultActivitySchedulerMiddleware.cs @@ -39,6 +39,10 @@ public class DefaultActivitySchedulerMiddleware : WorkflowExecutionMiddleware while (scheduler.HasAny) { + // Do not start a workflow if cancellation has been requested. + if (context.CancellationTokens.ApplicationCancellationToken.IsCancellationRequested) + break; + var currentWorkItem = scheduler.Take(); await ExecuteWorkItemAsync(context, currentWorkItem); } diff --git a/src/modules/Elsa.Workflows.Core/Options/RunWorkflowOptions.cs b/src/modules/Elsa.Workflows.Core/Options/RunWorkflowOptions.cs index c3eb7201e..fc4a9d20c 100644 --- a/src/modules/Elsa.Workflows.Core/Options/RunWorkflowOptions.cs +++ b/src/modules/Elsa.Workflows.Core/Options/RunWorkflowOptions.cs @@ -18,4 +18,5 @@ public class RunWorkflowOptions public IDictionary? Properties { get; set; } public string? TriggerActivityId { get; set; } public CancellationTokens CancellationTokens { get; set; } + public Action? StatusUpdatedCallback { get; set; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonWorkflowStateSerializer.cs b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonWorkflowStateSerializer.cs index 8145e7a98..c09642d94 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonWorkflowStateSerializer.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Serializers/JsonWorkflowStateSerializer.cs @@ -36,8 +36,8 @@ public class JsonWorkflowStateSerializer : IWorkflowStateSerializer { var options = GetSerializerOptions(); var serializingWorkflowState = new SerializingWorkflowState(options); - await _notificationSender.SendAsync(serializingWorkflowState, cancellationToken); - + await _notificationSender.SendAsync(serializingWorkflowState, cancellationToken); + return JsonSerializer.Serialize(workflowState, options); } @@ -46,8 +46,8 @@ public class JsonWorkflowStateSerializer : IWorkflowStateSerializer { var options = GetSerializerOptions(); var serializingWorkflowState = new SerializingWorkflowState(options); - await _notificationSender.SendAsync(serializingWorkflowState, cancellationToken); - + await _notificationSender.SendAsync(serializingWorkflowState, cancellationToken); + return JsonSerializer.SerializeToUtf8Bytes(workflowState, options); } @@ -56,8 +56,8 @@ public class JsonWorkflowStateSerializer : IWorkflowStateSerializer { var options = GetSerializerOptions(); var serializingWorkflowState = new SerializingWorkflowState(options); - await _notificationSender.SendAsync(serializingWorkflowState, cancellationToken); - + await _notificationSender.SendAsync(serializingWorkflowState, cancellationToken); + return JsonSerializer.SerializeToElement(workflowState, options); } @@ -110,8 +110,8 @@ public class JsonWorkflowStateSerializer : IWorkflowStateSerializer options.Converters.Add(JsonMetadataServices.TimeSpanConverter); options.Converters.Add(new PolymorphicObjectConverterFactory()); options.Converters.Add(new TypeJsonConverter(_wellKnownTypeRegistry)); - options.Converters.Add(new VariableConverterFactory(_wellKnownTypeRegistry, _loggerFactory)); - + options.Converters.Add(new VariableConverterFactory(_wellKnownTypeRegistry, _loggerFactory)); + return options; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Core/Services/VariablePersistenceManager.cs b/src/modules/Elsa.Workflows.Core/Services/VariablePersistenceManager.cs index 3ce3ed598..0bd89f183 100644 --- a/src/modules/Elsa.Workflows.Core/Services/VariablePersistenceManager.cs +++ b/src/modules/Elsa.Workflows.Core/Services/VariablePersistenceManager.cs @@ -61,7 +61,7 @@ public class VariablePersistenceManager : IVariablePersistenceManager foreach (var context in contexts) { - var variables = GetLocalVariables(context); + var variables = GetLocalVariables(context).ToList(); var storageDriverContext = new StorageDriverContext(context, cancellationToken); foreach (var variable in variables) @@ -89,7 +89,7 @@ public class VariablePersistenceManager : IVariablePersistenceManager public async Task DeleteVariablesAsync(ActivityExecutionContext context) { var register = context.ExpressionExecutionContext.Memory; - var variableList = GetLocalVariables(context); + var variableList = GetLocalVariables(context).ToList(); var cancellationToken = context.CancellationToken; var storageDriverContext = new StorageDriverContext(context, cancellationToken); diff --git a/src/modules/Elsa.Workflows.Core/Services/WorkflowRunner.cs b/src/modules/Elsa.Workflows.Core/Services/WorkflowRunner.cs index 7f3ccd4e5..01f07bdda 100644 --- a/src/modules/Elsa.Workflows.Core/Services/WorkflowRunner.cs +++ b/src/modules/Elsa.Workflows.Core/Services/WorkflowRunner.cs @@ -94,6 +94,7 @@ public class WorkflowRunner : IWorkflowRunner var properties = options?.Properties; var correlationId = options?.CorrelationId; var triggerActivityId = options?.TriggerActivityId; + var statusUpdatedCallback = options?.StatusUpdatedCallback; var workflowExecutionContext = await WorkflowExecutionContext.CreateAsync( scope.ServiceProvider, workflow, @@ -103,6 +104,7 @@ public class WorkflowRunner : IWorkflowRunner properties, default, triggerActivityId, + statusUpdatedCallback, options?.CancellationTokens ?? cancellationToken); // Schedule the first activity. @@ -122,6 +124,7 @@ public class WorkflowRunner : IWorkflowRunner var properties = options?.Properties; var correlationId = options?.CorrelationId ?? workflowState.CorrelationId; var triggerActivityId = options?.TriggerActivityId; + var statusUpdatedCallback = options?.StatusUpdatedCallback; var workflowExecutionContext = await WorkflowExecutionContext.CreateAsync( scope.ServiceProvider, workflow, @@ -130,6 +133,7 @@ public class WorkflowRunner : IWorkflowRunner input, properties, default, triggerActivityId, + statusUpdatedCallback, options?.CancellationTokens ?? cancellationToken); var bookmarkId = options?.BookmarkId; diff --git a/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs b/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs index aa8ef62da..942d8931b 100644 --- a/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs +++ b/src/modules/Elsa.Workflows.Core/Services/WorkflowStateExtractor.cs @@ -36,6 +36,31 @@ public class WorkflowStateExtractor : IWorkflowStateExtractor return state; } + /// + public WorkflowExecutionContext Apply(WorkflowExecutionContext workflowExecutionContext, WorkflowState state) + { + workflowExecutionContext.Id = state.Id; + workflowExecutionContext.CorrelationId = state.CorrelationId; + workflowExecutionContext.SubStatus = state.SubStatus; + workflowExecutionContext.Bookmarks = state.Bookmarks; + workflowExecutionContext.Output = state.Output; + workflowExecutionContext.ExecutionLogSequence = state.ExecutionLogSequence; + workflowExecutionContext.CreatedAt = state.CreatedAt; + ApplyInput(state, workflowExecutionContext); + ApplyProperties(state, workflowExecutionContext); + ApplyActivityExecutionContexts(state, workflowExecutionContext); + ApplyCompletionCallbacks(state, workflowExecutionContext); + ApplyScheduledActivities(state, workflowExecutionContext); + return workflowExecutionContext; + } + + private void ApplyInput(WorkflowState state, WorkflowExecutionContext workflowExecutionContext) + { + // Only add input from state if the input doesn't already exist on the workflow execution context. + foreach (var inputItem in state.Input) + if (!workflowExecutionContext.Input.ContainsKey(inputItem.Key)) workflowExecutionContext.Input.Add(inputItem.Key, inputItem.Value); + } + private IDictionary GetPersistableInput(WorkflowExecutionContext workflowExecutionContext) { // TODO: This is a temporary solution. We need to find a better way to handle this. @@ -52,24 +77,6 @@ public class WorkflowStateExtractor : IWorkflowStateExtractor return filteredInput; } - /// - public WorkflowExecutionContext Apply(WorkflowExecutionContext workflowExecutionContext, WorkflowState state) - { - // Do not map input. We don't want to overwrite the input that was passed to the workflow. - workflowExecutionContext.Id = state.Id; - workflowExecutionContext.CorrelationId = state.CorrelationId; - workflowExecutionContext.SubStatus = state.SubStatus; - workflowExecutionContext.Bookmarks = state.Bookmarks; - workflowExecutionContext.Output = state.Output; - workflowExecutionContext.ExecutionLogSequence = state.ExecutionLogSequence; - workflowExecutionContext.CreatedAt = state.CreatedAt; - ApplyProperties(state, workflowExecutionContext); - ApplyActivityExecutionContexts(state, workflowExecutionContext); - ApplyCompletionCallbacks(state, workflowExecutionContext); - ApplyScheduledActivities(state, workflowExecutionContext); - return workflowExecutionContext; - } - private void ExtractProperties(WorkflowState state, WorkflowExecutionContext workflowExecutionContext) { state.Properties = workflowExecutionContext.Properties; @@ -127,7 +134,7 @@ public class WorkflowStateExtractor : IWorkflowStateExtractor activityExecutionContext.Id = activityExecutionContextState.Id; activityExecutionContext.Properties = properties; activityExecutionContext.ActivityState = activityExecutionContextState.ActivityState ?? new Dictionary(); - activityExecutionContext.Status = activityExecutionContextState.Status; + activityExecutionContext.TransitionTo(activityExecutionContextState.Status); activityExecutionContext.StartedAt = activityExecutionContextState.StartedAt; activityExecutionContext.CompletedAt = activityExecutionContextState.CompletedAt; activityExecutionContext.Tag = activityExecutionContextState.Tag; diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkPersister.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkPersister.cs index f46df1e7c..c826d66c5 100644 --- a/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkPersister.cs +++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IBookmarkPersister.cs @@ -1,5 +1,4 @@ -using Elsa.Workflows.Helpers; -using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime.Requests; namespace Elsa.Workflows.Runtime.Contracts; @@ -11,5 +10,5 @@ public interface IBookmarksPersister /// /// Persists bookmarks and raises events. /// - Task PersistBookmarksAsync(WorkflowExecutionContext context, Diff diff); + Task PersistBookmarksAsync(UpdateBookmarksRequest updateBookmarksRequest); } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowExecutionContextStore.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowExecutionContextStore.cs new file mode 100644 index 000000000..ba90ce747 --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowExecutionContextStore.cs @@ -0,0 +1,24 @@ +namespace Elsa.Workflows.Runtime.Contracts; + +/// +/// Stores records. +/// +public interface IWorkflowExecutionContextStore +{ + /// + /// Saves a record of the . + /// + /// The to save. + Task SaveAsync(WorkflowExecutionContext context); + + /// + /// Finds a with the specified ID. + /// + /// The matching entity or null if no match was found. + Task FindAsync(string workflowExecutionContextId); + + /// + /// Deletes the record of the with the specified ID if it exists. + /// + Task DeleteAsync(string workflowExecutionContextId); +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowRuntime.cs b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowRuntime.cs index 746481379..d55af768b 100644 --- a/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowRuntime.cs +++ b/src/modules/Elsa.Workflows.Runtime/Contracts/IWorkflowRuntime.cs @@ -60,6 +60,13 @@ public interface IWorkflowRuntime /// Options for executing the workflow. Task ExecuteWorkflowAsync(WorkflowMatch match, ExecuteWorkflowOptions? options = default); + /// + /// Cancels the execution of a workflow. + /// + /// The ID of the workflow instance to cancel. + /// + Task CancelWorkflowAsync(string workflowInstanceId, CancellationToken cancellationToken = default); + /// /// Finds all the workflows that can be started or resumed based on a query model. /// diff --git a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs index 1f2ac45b3..23919dcde 100644 --- a/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs +++ b/src/modules/Elsa.Workflows.Runtime/Features/WorkflowRuntimeFeature.cs @@ -73,6 +73,11 @@ public class WorkflowRuntimeFeature : FeatureBase /// public Func WorkflowInboxStore { get; set; } = sp => sp.GetRequiredService(); + /// + /// A factory that instantiates an . + /// + public Func WorkflowExecutionContextStore { get; set; } = sp => sp.GetRequiredService(); + /// /// A factory that instantiates an . /// @@ -157,6 +162,7 @@ public class WorkflowRuntimeFeature : FeatureBase .AddScoped(WorkflowExecutionLogStore) .AddScoped(ActivityExecutionLogStore) .AddScoped(WorkflowInboxStore) + .AddScoped(WorkflowExecutionContextStore) .AddSingleton(RunTaskDispatcher) .AddSingleton(BackgroundActivityScheduler) .AddScoped() @@ -188,6 +194,7 @@ public class WorkflowRuntimeFeature : FeatureBase .AddMemoryStore() .AddMemoryStore() .AddMemoryStore() + .AddMemoryStore() // Distributed locking. .AddScoped(DistributedLockProvider) @@ -207,7 +214,8 @@ public class WorkflowRuntimeFeature : FeatureBase .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() - .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() // Workflow activation strategies. .AddScoped() diff --git a/src/modules/Elsa.Workflows.Runtime/Handlers/WorkflowExecutionContextNotificationsHandler.cs b/src/modules/Elsa.Workflows.Runtime/Handlers/WorkflowExecutionContextNotificationsHandler.cs new file mode 100644 index 000000000..992de6ede --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Handlers/WorkflowExecutionContextNotificationsHandler.cs @@ -0,0 +1,36 @@ +using Elsa.Mediator.Contracts; +using Elsa.Workflows.Management.Notifications; +using Elsa.Workflows.Notifications; +using Elsa.Workflows.Runtime.Contracts; + +namespace Elsa.Workflows.Runtime.Handlers; + +/// +/// Deletes workflow execution log records in response to the notification. +/// +internal class WorkflowExecutionContextNotificationsHandler : + INotificationHandler, + INotificationHandler +{ + private readonly IWorkflowExecutionContextStore _store; + + /// + /// Initializes a new instance of the class. + /// + public WorkflowExecutionContextNotificationsHandler(IWorkflowExecutionContextStore store) + { + _store = store; + } + + /// + public Task HandleAsync(WorkflowExecuting notification, CancellationToken cancellationToken) + { + return _store.SaveAsync(notification.WorkflowExecutionContext); + } + + /// + public Task HandleAsync(WorkflowExecuted notification, CancellationToken cancellationToken) + { + return _store.DeleteAsync(notification.WorkflowExecutionContext.Id); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistBookmarkMiddleware.cs b/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistBookmarkMiddleware.cs index d712632a0..12126f734 100644 --- a/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistBookmarkMiddleware.cs +++ b/src/modules/Elsa.Workflows.Runtime/Middleware/Workflows/PersistBookmarkMiddleware.cs @@ -1,6 +1,7 @@ using Elsa.Workflows.Helpers; using Elsa.Workflows.Pipelines.WorkflowExecution; using Elsa.Workflows.Runtime.Contracts; +using Elsa.Workflows.Runtime.Requests; namespace Elsa.Workflows.Runtime.Middleware.Workflows; @@ -24,6 +25,7 @@ public class PersistBookmarkMiddleware : WorkflowExecutionMiddleware await Next(context); var updatedBookmarks = context.Bookmarks.ToList(); var diff = Diff.For(originalBookmarks, updatedBookmarks); - await _bookmarksPersister.PersistBookmarksAsync(context, diff); + var bookmarkRequest = new UpdateBookmarksRequest(context.Id, diff, context.CorrelationId); + await _bookmarksPersister.PersistBookmarksAsync(bookmarkRequest); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksIndexed.cs b/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksIndexed.cs index 11cda2d6f..6303effd1 100644 --- a/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksIndexed.cs +++ b/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksIndexed.cs @@ -7,5 +7,5 @@ namespace Elsa.Workflows.Runtime.Notifications; /// /// The workflow execution context. /// The bookmarks that were added, removed, or unchanged. -public record WorkflowBookmarksIndexed(WorkflowExecutionContext WorkflowExecutionContext, IndexedWorkflowBookmarks IndexedWorkflowBookmarks) : INotification; +public record WorkflowBookmarksIndexed(IndexedWorkflowBookmarks IndexedWorkflowBookmarks) : INotification; diff --git a/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksPersisted.cs b/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksPersisted.cs index 66768f625..b5b5f2fb2 100644 --- a/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksPersisted.cs +++ b/src/modules/Elsa.Workflows.Runtime/Notifications/WorkflowBookmarksPersisted.cs @@ -9,4 +9,4 @@ namespace Elsa.Workflows.Runtime.Notifications; /// /// The workflow execution context. /// The bookmarks that were added, removed, or unchanged. -public record WorkflowBookmarksPersisted(WorkflowExecutionContext Context, Diff Diff) : INotification; \ No newline at end of file +public record WorkflowBookmarksPersisted(Diff Diff) : INotification; \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Options/StartWorkflowHostOptions.cs b/src/modules/Elsa.Workflows.Runtime/Options/StartWorkflowHostOptions.cs index 361dfc2d9..1a4697c81 100644 --- a/src/modules/Elsa.Workflows.Runtime/Options/StartWorkflowHostOptions.cs +++ b/src/modules/Elsa.Workflows.Runtime/Options/StartWorkflowHostOptions.cs @@ -24,4 +24,9 @@ public class StartWorkflowHostOptions /// Cancellation tokens that can be used to cancel the workflow instance without cancelling system-level operations. public CancellationTokens CancellationTokens { get; set; } + + /// + /// Callback method that will be called when the status of the workflow has been updated + /// + public Action? StatusUpdatedCallback { get; set; } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkPersister.cs b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkPersister.cs index f2cc05b53..775ade777 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/BookmarkPersister.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/BookmarkPersister.cs @@ -1,7 +1,5 @@ using Elsa.Mediator; using Elsa.Mediator.Contracts; -using Elsa.Workflows.Helpers; -using Elsa.Workflows.Models; using Elsa.Workflows.Runtime.Contracts; using Elsa.Workflows.Runtime.Notifications; using Elsa.Workflows.Runtime.Requests; @@ -11,25 +9,15 @@ namespace Elsa.Workflows.Runtime.Services; /// public class BookmarksPersister(IBookmarkUpdater bookmarkUpdater, INotificationSender notificationSender) : IBookmarksPersister { - - /// - public async Task PersistBookmarksAsync(WorkflowExecutionContext context, Diff diff) + public async Task PersistBookmarksAsync(UpdateBookmarksRequest updateBookmarksRequest) { - var cancellationToken = context.CancellationTokens.SystemCancellationToken; - var updateBookmarksContext = new UpdateBookmarksRequest(context.Id, diff, context.CorrelationId); - await bookmarkUpdater.UpdateBookmarksAsync(updateBookmarksContext, cancellationToken); - + await bookmarkUpdater.UpdateBookmarksAsync(updateBookmarksRequest); + // Publish domain event. - await notificationSender.SendAsync(new WorkflowBookmarksIndexed(context, new IndexedWorkflowBookmarks(context.Id, diff.Added, diff.Removed, diff.Unchanged)), cancellationToken); - - // Notify all interested activities that the bookmarks have been persisted. - var activityExecutionContexts = context.ActivityExecutionContexts.Where(x => x.Activity is IBookmarksPersistedHandler && x.Bookmarks.Any()).ToList(); - - foreach (var activityExecutionContext in activityExecutionContexts) - await ((IBookmarksPersistedHandler)activityExecutionContext.Activity).BookmarksPersistedAsync(activityExecutionContext); - + await notificationSender.SendAsync(new WorkflowBookmarksIndexed(new IndexedWorkflowBookmarks(updateBookmarksRequest.WorkflowInstanceId, updateBookmarksRequest.Diff.Added, updateBookmarksRequest.Diff.Removed, updateBookmarksRequest.Diff.Unchanged))); + // Publish domain event. - await notificationSender.SendAsync(new WorkflowBookmarksPersisted(context, diff), NotificationStrategy.Background, cancellationToken); + await notificationSender.SendAsync(new WorkflowBookmarksPersisted(updateBookmarksRequest.Diff), NotificationStrategy.Background); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowRuntime.cs b/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowRuntime.cs index 11730d0af..8b5bf4ad8 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowRuntime.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/DefaultWorkflowRuntime.cs @@ -1,6 +1,7 @@ using Elsa.Common.Models; using Elsa.Extensions; using Elsa.Workflows.Contracts; +using Elsa.Workflows.Helpers; using Elsa.Workflows.Management.Contracts; using Elsa.Workflows.Management.Filters; using Elsa.Workflows.Management.Mappers; @@ -32,6 +33,10 @@ public class DefaultWorkflowRuntime : IWorkflowRuntime private readonly IWorkflowInstanceFactory _workflowInstanceFactory; private readonly WorkflowStateMapper _workflowStateMapper; private readonly IIdentityGenerator _identityGenerator; + private readonly IWorkflowExecutionContextStore _workflowExecutionContextStore; + private readonly IWorkflowStateExtractor _workflowStateExtractor; + private readonly IServiceProvider _serviceProvider; + private readonly IBookmarksPersister _bookmarksPersister; /// /// Constructor. @@ -47,7 +52,11 @@ public class DefaultWorkflowRuntime : IWorkflowRuntime IDistributedLockProvider distributedLockProvider, IWorkflowInstanceFactory workflowInstanceFactory, WorkflowStateMapper workflowStateMapper, - IIdentityGenerator identityGenerator) + IIdentityGenerator identityGenerator, + IWorkflowExecutionContextStore workflowExecutionContextStore, + IWorkflowStateExtractor workflowStateExtractor, + IServiceProvider serviceProvider, + IBookmarksPersister bookmarksPersister) { _workflowHostFactory = workflowHostFactory; _workflowDefinitionService = workflowDefinitionService; @@ -60,6 +69,10 @@ public class DefaultWorkflowRuntime : IWorkflowRuntime _workflowInstanceFactory = workflowInstanceFactory; _workflowStateMapper = workflowStateMapper; _identityGenerator = identityGenerator; + _workflowExecutionContextStore = workflowExecutionContextStore; + _workflowStateExtractor = workflowStateExtractor; + _serviceProvider = serviceProvider; + _bookmarksPersister = bookmarksPersister; } /// @@ -90,6 +103,67 @@ public class DefaultWorkflowRuntime : IWorkflowRuntime { return await StartWorkflowAsync(definitionId, options); } + + /// + public async Task CancelWorkflowAsync(string workflowInstanceId, CancellationToken cancellationToken) + { + var workflowExecutionContext = await _workflowExecutionContextStore.FindAsync(workflowInstanceId); + + if (workflowExecutionContext is null) + { + // The execution context is not running on this instance. + // It might not be running on any instance, so check the db and update the record. + // Use lock to prevent race conditions and other instances from updating the workflow context + await using var cancelLock = await _distributedLockProvider.TryAcquireLockAsync($"{workflowInstanceId}-cancel"); + if (cancelLock == null) + return; + + var workflowInstance = await _workflowInstanceStore.FindAsync(workflowInstanceId, cancellationToken); + if (workflowInstance is null + || workflowInstance.SubStatus == WorkflowSubStatus.Cancelled + || workflowInstance.SubStatus == WorkflowSubStatus.Faulted) + return; + + var workflowState = await ExportWorkflowStateAsync(workflowInstanceId, cancellationToken); + + if (workflowState == null) + throw new Exception("Workflow state not found"); + + var workflowDefinition = await _workflowDefinitionService.FindAsync(workflowState.DefinitionId, VersionOptions.SpecificVersion(workflowState.DefinitionVersion), cancellationToken); + + if (workflowDefinition == null) + throw new Exception("Workflow definition not found"); + + var workflow = await _workflowDefinitionService.MaterializeWorkflowAsync(workflowDefinition, cancellationToken); + workflowExecutionContext = await WorkflowExecutionContext.CreateAsync(_serviceProvider, workflow, workflowState, cancellationTokens: cancellationToken); + + if (!cancellationToken.IsCancellationRequested) + await CancelWorkflowExecutionContextAsync(); + + return; + } + + await using var mainCancelLock = await _distributedLockProvider.AcquireLockAsync($"{workflowInstanceId}-cancel", TimeSpan.FromMinutes(1)); + + await CancelWorkflowExecutionContextAsync(); + + async Task CancelWorkflowExecutionContextAsync() + { + var originalBookmarks = workflowExecutionContext.Bookmarks.ToList(); + + workflowExecutionContext.Cancel(); + + var newBookmarks = workflowExecutionContext.Bookmarks.ToList(); + var diff = Diff.For(originalBookmarks, newBookmarks); + var bookmarkRequest = new UpdateBookmarksRequest(workflowExecutionContext.Id, + diff, + workflowExecutionContext.CorrelationId); + await _bookmarksPersister.PersistBookmarksAsync(bookmarkRequest); + + var instance = await _workflowInstanceManager.SaveAsync(workflowExecutionContext); + await _workflowInstanceStore.SaveAsync(instance); + } + } /// public async Task> StartWorkflowsAsync(string activityTypeName, object bookmarkPayload, TriggerWorkflowsOptions options) diff --git a/src/modules/Elsa.Workflows.Runtime/Services/MemoryWorkflowExecutionContextStore.cs b/src/modules/Elsa.Workflows.Runtime/Services/MemoryWorkflowExecutionContextStore.cs new file mode 100644 index 000000000..7e262cd4c --- /dev/null +++ b/src/modules/Elsa.Workflows.Runtime/Services/MemoryWorkflowExecutionContextStore.cs @@ -0,0 +1,42 @@ +using Elsa.Common.Services; +using Elsa.Workflows.Runtime.Contracts; +using Elsa.Workflows.Runtime.Stores; + +namespace Elsa.Workflows.Runtime.Services; + +/// +/// Stores in memory. +/// +public class MemoryWorkflowExecutionContextStore : IWorkflowExecutionContextStore +{ + private readonly MemoryStore _store; + + /// + /// Initializes a new instance of the class. + /// + public MemoryWorkflowExecutionContextStore(MemoryStore store) + { + _store = store; + } + + /// + public Task SaveAsync(WorkflowExecutionContext context) + { + _store.Save(context, x => x.Id); + return Task.CompletedTask; + } + + /// + public Task FindAsync(string workflowExecutionContextId) + { + var result = _store.Find((context) => context.Id == workflowExecutionContextId); + return Task.FromResult(result); + } + + /// + public Task DeleteAsync(string workflowExecutionContextId) + { + _store.Delete(workflowExecutionContextId); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Services/WorkflowHost.cs b/src/modules/Elsa.Workflows.Runtime/Services/WorkflowHost.cs index 9980c8639..07eb48939 100644 --- a/src/modules/Elsa.Workflows.Runtime/Services/WorkflowHost.cs +++ b/src/modules/Elsa.Workflows.Runtime/Services/WorkflowHost.cs @@ -80,6 +80,7 @@ public class WorkflowHost : IWorkflowHost Input = input, Properties = properties, TriggerActivityId = options?.TriggerActivityId, + StatusUpdatedCallback = options?.StatusUpdatedCallback, CancellationTokens = options?.CancellationTokens ?? cancellationToken }; diff --git a/src/samples/aspnet/Elsa.Samples.AspNet.MassTransitActivities/Elsa.Samples.AspNet.MassTransitActivities.csproj b/src/samples/aspnet/Elsa.Samples.AspNet.MassTransitActivities/Elsa.Samples.AspNet.MassTransitActivities.csproj index 6241d130a..00da2f741 100644 --- a/src/samples/aspnet/Elsa.Samples.AspNet.MassTransitActivities/Elsa.Samples.AspNet.MassTransitActivities.csproj +++ b/src/samples/aspnet/Elsa.Samples.AspNet.MassTransitActivities/Elsa.Samples.AspNet.MassTransitActivities.csproj @@ -9,6 +9,7 @@ + diff --git a/src/samples/aspnet/Elsa.Samples.AspNet.MassTransitActivities/Program.cs b/src/samples/aspnet/Elsa.Samples.AspNet.MassTransitActivities/Program.cs index 5ba3ecd92..405f28a5a 100644 --- a/src/samples/aspnet/Elsa.Samples.AspNet.MassTransitActivities/Program.cs +++ b/src/samples/aspnet/Elsa.Samples.AspNet.MassTransitActivities/Program.cs @@ -28,16 +28,20 @@ builder.Services.AddElsa(elsa => // Add services for HTTP activities and workflow middleware. elsa.UseHttp(); - - // Use JavaScript. - elsa.UseJavaScript(javaScript => + + // Use C#. + elsa.UseCSharp(options => { - javaScript.AllowClrAccess = true; - javaScript.ConfigureEngine(options => - { - options.RegisterType(); - options.RegisterType(); - }); + options.Assemblies.Add(typeof(OrderCreated).Assembly); + options.Namespaces.Add(typeof(OrderCreated).Namespace!); + }); + + // Use JavaScript. + elsa.UseJavaScript(options => + { + options.AllowClrAccess = true; + options.RegisterType(); + options.RegisterType(); }); // Use C#. @@ -56,7 +60,7 @@ builder.Services.AddElsa(elsa => identity.UseAdminUserProvider(); identity.TokenOptions = options => { - options.SigningKey = "secret-token-signing-key"; + options.SigningKey = "super-secret-and-securely-stored-token-signing-key"; options.AccessTokenLifetime = TimeSpan.FromDays(1); }; }); @@ -67,7 +71,7 @@ builder.Services.AddElsa(elsa => // Configure MassTransit. elsa.UseMassTransit(massTransit => { - massTransit.UseRabbitMq(rabbitMqConnectionString); + //massTransit.UseRabbitMq(rabbitMqConnectionString); massTransit.AddMessageType(); massTransit.AddMessageType(); }); diff --git a/src/samples/aspnet/Elsa.Samples.AspNet.Onboarding.WorkflowServer/Program.cs b/src/samples/aspnet/Elsa.Samples.AspNet.Onboarding.WorkflowServer/Program.cs index 1a5649b71..42cf941ac 100644 --- a/src/samples/aspnet/Elsa.Samples.AspNet.Onboarding.WorkflowServer/Program.cs +++ b/src/samples/aspnet/Elsa.Samples.AspNet.Onboarding.WorkflowServer/Program.cs @@ -2,7 +2,6 @@ using Elsa.EntityFrameworkCore.Modules.Management; using Elsa.EntityFrameworkCore.Modules.Runtime; using Elsa.Extensions; using Elsa.Samples.AspNet.Onboarding.WorkflowServer.Workflows; -using Elsa.Webhooks.Extensions; var builder = WebApplication.CreateBuilder(args); diff --git a/src/samples/aspnet/Elsa.Samples.AspNet.Webhooks.WorkflowServer/Program.cs b/src/samples/aspnet/Elsa.Samples.AspNet.Webhooks.WorkflowServer/Program.cs index 857640421..26335242e 100644 --- a/src/samples/aspnet/Elsa.Samples.AspNet.Webhooks.WorkflowServer/Program.cs +++ b/src/samples/aspnet/Elsa.Samples.AspNet.Webhooks.WorkflowServer/Program.cs @@ -1,7 +1,6 @@ using System; using Elsa.Extensions; using Elsa.Samples.AspNet.Webhooks.WorkflowServer.Workflows; -using Elsa.Webhooks.Extensions; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; diff --git a/test/integration/Elsa.IntegrationTests/Elsa.IntegrationTests.csproj b/test/integration/Elsa.IntegrationTests/Elsa.IntegrationTests.csproj index 29f7310ed..43f1f116e 100644 --- a/test/integration/Elsa.IntegrationTests/Elsa.IntegrationTests.csproj +++ b/test/integration/Elsa.IntegrationTests/Elsa.IntegrationTests.csproj @@ -9,6 +9,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -23,6 +24,7 @@ + diff --git a/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/DefaultRuntimeTests.cs b/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/DefaultRuntimeTests.cs new file mode 100644 index 000000000..47e8b025a --- /dev/null +++ b/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/DefaultRuntimeTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Elsa.IntegrationTests.Scenarios.WorkflowCancellation.Workflows; +using Elsa.Mediator.HostedServices; +using Elsa.Mediator.Options; +using Elsa.Testing.Shared; +using Elsa.Workflows; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime.Contracts; +using Elsa.Workflows.Runtime.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; +using Xunit.Abstractions; + +namespace Elsa.IntegrationTests.Scenarios.WorkflowCancellation; + +public class DefaultRuntimeTests +{ + private readonly IServiceProvider _services; + private readonly CapturingTextWriter _capturingTextWriter = new(); + + private readonly IWorkflowRuntime _workflowRuntime; + private readonly BackgroundCommandSenderHostedService _backgroundCommandSenderHostedService; + private readonly BackgroundEventPublisherHostedService _backgroundEventPublisherHostedService; + + public DefaultRuntimeTests(ITestOutputHelper testOutputHelper) + { + _services = new TestApplicationBuilder(testOutputHelper) + .WithCapturingTextWriter(_capturingTextWriter) + .AddWorkflow() + .AddWorkflow() + .AddWorkflow() + .AddWorkflow() + .ConfigureServices(services => + { + services + .AddSingleton(sp => + { + var options = sp.GetRequiredService>().Value; + return ActivatorUtilities.CreateInstance(sp, options.CommandWorkerCount); + }) + .AddSingleton(sp => + { + var options = sp.GetRequiredService>().Value; + return ActivatorUtilities.CreateInstance(sp, options.NotificationWorkerCount); + }) + ; + }) + .Build(); + + _backgroundCommandSenderHostedService = _services.GetRequiredService(); + _backgroundEventPublisherHostedService = _services.GetRequiredService(); + _workflowRuntime = _services.GetRequiredService(); + } + + [Fact(DisplayName = "Cancelling a suspended workflow")] + public async Task SuspendedCancelTest() + { + // Populate registries. + await _services.PopulateRegistriesAsync(); + + const string workflowDefinitionId = nameof(SimpleSuspendedWorkflow); + var workflowState = await _workflowRuntime.StartWorkflowAsync(workflowDefinitionId, new StartWorkflowRuntimeOptions()); + + Assert.Equal(WorkflowStatus.Running, workflowState.Status); + Assert.Equal(WorkflowSubStatus.Suspended, workflowState.SubStatus); + + await _workflowRuntime.CancelWorkflowAsync(workflowState.WorkflowInstanceId); + var lastWorkflowState = await _workflowRuntime.ExportWorkflowStateAsync(workflowState.WorkflowInstanceId); + + Assert.Equal(WorkflowStatus.Finished, lastWorkflowState!.Status); + Assert.Equal(WorkflowSubStatus.Cancelled, lastWorkflowState.SubStatus); + Assert.Empty(_capturingTextWriter.Lines); + } + + [Fact(DisplayName = "Cancelling a running workflow", + Skip= "Unpredictable result, need to create a dispatcher for tests that will run outside of the unit test")] + public async Task RunningCancelTest() + { + await _backgroundCommandSenderHostedService.StartAsync(CancellationToken.None); + await _backgroundEventPublisherHostedService.StartAsync(CancellationToken.None); + + // Populate registries. + await _services.PopulateRegistriesAsync(); + + const string workflowDefinitionId = nameof(BulkSuspendedWorkflow); + var workflowState = await _workflowRuntime.StartWorkflowAsync(workflowDefinitionId, new StartWorkflowRuntimeOptions()); + + var bookmarks = new Stack(workflowState.Bookmarks); + var resumeOptions = new ResumeWorkflowRuntimeOptions { BookmarkId = bookmarks.Pop().Id }; + var state = await _workflowRuntime.ResumeWorkflowAsync(workflowState.WorkflowInstanceId,resumeOptions); + + await _workflowRuntime.CancelWorkflowAsync(workflowState.WorkflowInstanceId); + var lastWorkflowState = await _workflowRuntime.ExportWorkflowStateAsync(workflowState.WorkflowInstanceId); + + Assert.Equal(WorkflowStatus.Finished, lastWorkflowState!.Status); + Assert.Equal(WorkflowSubStatus.Cancelled, lastWorkflowState.SubStatus); + Assert.NotEmpty(_capturingTextWriter.Lines); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/ProtoActorTests.cs b/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/ProtoActorTests.cs new file mode 100644 index 000000000..dcc19d259 --- /dev/null +++ b/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/ProtoActorTests.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Elsa.Extensions; +using Elsa.IntegrationTests.Scenarios.WorkflowCancellation.Workflows; +using Elsa.Mediator.HostedServices; +using Elsa.Mediator.Options; +using Elsa.ProtoActor.HostedServices; +using Elsa.Testing.Shared; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime.Contracts; +using Elsa.Workflows.Runtime.Options; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Proto.Persistence.Sqlite; +using Xunit; +using Xunit.Abstractions; +using WorkflowStatus = Elsa.Workflows.WorkflowStatus; +using WorkflowSubStatus = Elsa.Workflows.WorkflowSubStatus; + +namespace Elsa.IntegrationTests.Scenarios.WorkflowCancellation; + +public class ProtoActorTests +{ + private readonly IServiceProvider _services; + private readonly CapturingTextWriter _capturingTextWriter = new(); + + private readonly IWorkflowRuntime _workflowRuntime; + private readonly WorkflowServerHost _workflowServerHost; + private readonly BackgroundCommandSenderHostedService _backgroundCommandSenderHostedService; + private readonly BackgroundEventPublisherHostedService _backgroundEventPublisherHostedService; + + public ProtoActorTests(ITestOutputHelper testOutputHelper) + { + _services = new TestApplicationBuilder(testOutputHelper) + .WithCapturingTextWriter(_capturingTextWriter) + .AddWorkflow() + .AddWorkflow() + .AddWorkflow() + .AddWorkflow() + .ConfigureServices(services => + { + services + .AddSingleton(sp => ActivatorUtilities.CreateInstance(sp)); + + services + .AddSingleton(sp => + { + var options = sp.GetRequiredService>().Value; + return ActivatorUtilities.CreateInstance(sp, + options.CommandWorkerCount); + }) + .AddSingleton(sp => + { + var options = sp.GetRequiredService>().Value; + return ActivatorUtilities.CreateInstance(sp, + options.NotificationWorkerCount); + }); + }).ConfigureElsa(elsa => elsa.UseWorkflowRuntime(runtime => runtime.UseProtoActor(protoActor => + { + protoActor.PersistenceProvider = _ => + new SqliteProvider( + new SqliteConnectionStringBuilder("Data Source=elsa.sqlite.db;Cache=Shared;")); + } + ))) + .Build(); + + _backgroundCommandSenderHostedService = _services.GetRequiredService(); + _backgroundEventPublisherHostedService = _services.GetRequiredService(); + _workflowServerHost = _services.GetRequiredService(); + _workflowRuntime = _services.GetRequiredService(); + } + + [Fact(DisplayName = "Cancelling a suspended workflow")] + public async Task SuspendedCancelTest() + { + // Populate registries. + await _services.PopulateRegistriesAsync(); + await _workflowServerHost.StartAsync(default); + const string workflowDefinitionId = nameof(SimpleSuspendedWorkflow); + var workflowState = + await _workflowRuntime.StartWorkflowAsync(workflowDefinitionId, new StartWorkflowRuntimeOptions()); + + Assert.Equal(WorkflowStatus.Running, workflowState.Status); + Assert.Equal(WorkflowSubStatus.Suspended, workflowState.SubStatus); + + await _workflowRuntime.CancelWorkflowAsync(workflowState.WorkflowInstanceId); + + var lastWorkflowState = await _workflowRuntime.ExportWorkflowStateAsync(workflowState.WorkflowInstanceId); + + Assert.Equal(WorkflowStatus.Finished, lastWorkflowState!.Status); + Assert.Equal(WorkflowSubStatus.Cancelled, lastWorkflowState.SubStatus); + Assert.Empty(_capturingTextWriter.Lines); + } + + [Fact(DisplayName = "Cancelling a running workflow", + Skip= "Unpredictable result, need to create a dispatcher for tests that will run outside of the unit test")] + public async Task RunningCancelTest() + { + // Populate registries. + await _services.PopulateRegistriesAsync(); + await _workflowServerHost.StartAsync(default); + await _backgroundCommandSenderHostedService.StartAsync(CancellationToken.None); + await _backgroundEventPublisherHostedService.StartAsync(CancellationToken.None); + + const string workflowDefinitionId = nameof(BulkSuspendedWorkflow); + var workflowState = + await _workflowRuntime.StartWorkflowAsync(workflowDefinitionId, new StartWorkflowRuntimeOptions()); + + var bookmarks = new Stack(workflowState.Bookmarks); + var resumeOptions = new ResumeWorkflowRuntimeOptions { BookmarkId = bookmarks.Pop().Id }; + await _workflowRuntime.ResumeWorkflowAsync(workflowState.WorkflowInstanceId, resumeOptions); + + await _workflowRuntime.CancelWorkflowAsync(workflowState.WorkflowInstanceId); + var lastWorkflowState = await _workflowRuntime.ExportWorkflowStateAsync(workflowState.WorkflowInstanceId); + + Assert.Equal(WorkflowStatus.Finished, lastWorkflowState!.Status); + Assert.Equal(WorkflowSubStatus.Cancelled, lastWorkflowState.SubStatus); + + Assert.NotEmpty(_capturingTextWriter.Lines); + } +} \ No newline at end of file diff --git a/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/Workflows/BulkSuspendedWorkflow.cs b/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/Workflows/BulkSuspendedWorkflow.cs new file mode 100644 index 000000000..fa763bced --- /dev/null +++ b/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/Workflows/BulkSuspendedWorkflow.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Elsa.Scheduling.Activities; +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Contracts; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime.Activities; + +namespace Elsa.IntegrationTests.Scenarios.WorkflowCancellation.Workflows; + +public class BulkSuspendedWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + object[] items = Enumerable.Range(0,1500).Select(x => (object) x).ToArray(); + + builder.Root = new Sequence + { + Activities = + { + new Start(), + new Delay(TimeSpan.FromSeconds(10)), + new BulkDispatchWorkflows + { + WorkflowDefinitionId = new Input(nameof(SimpleChildWorkflow)), + Items = new Input>(items) + } + }, + }; + } +} \ No newline at end of file diff --git a/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/Workflows/ResumeDispatchWorkflow.cs b/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/Workflows/ResumeDispatchWorkflow.cs new file mode 100644 index 000000000..8292b7026 --- /dev/null +++ b/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/Workflows/ResumeDispatchWorkflow.cs @@ -0,0 +1,21 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Contracts; +using Elsa.Workflows.Models; +using Elsa.Workflows.Runtime.Activities; + +namespace Elsa.IntegrationTests.Scenarios.WorkflowCancellation.Workflows; + +public class ResumeDispatchWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + builder.Root = new Sequence + { + Activities = + { + new PublishEvent { EventName = new Input("ResumeBlockDispatch") } + } + }; + } +} \ No newline at end of file diff --git a/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/Workflows/SimpleChildWorkflow.cs b/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/Workflows/SimpleChildWorkflow.cs new file mode 100644 index 000000000..b8217afaf --- /dev/null +++ b/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/Workflows/SimpleChildWorkflow.cs @@ -0,0 +1,19 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Contracts; + +namespace Elsa.IntegrationTests.Scenarios.WorkflowCancellation.Workflows; + +public class SimpleChildWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + builder.Root = new Sequence + { + Activities = + { + new WriteLine("Executed SimpleChildWorkflow") + } + }; + } +} \ No newline at end of file diff --git a/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/Workflows/SimpleSuspendedWorkflow.cs b/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/Workflows/SimpleSuspendedWorkflow.cs new file mode 100644 index 000000000..7b0912b6d --- /dev/null +++ b/test/integration/Elsa.IntegrationTests/Scenarios/WorkflowCancellation/Workflows/SimpleSuspendedWorkflow.cs @@ -0,0 +1,22 @@ +using Elsa.Workflows; +using Elsa.Workflows.Activities; +using Elsa.Workflows.Contracts; +using Elsa.Workflows.Runtime.Activities; + +namespace Elsa.IntegrationTests.Scenarios.WorkflowCancellation.Workflows; + +public class SimpleSuspendedWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + builder.Root = new Sequence + { + Activities = + { + new Start(), + new Event("BlockingEvent"), + new WriteLine("Workflow was not properly blocked") + }, + }; + } +} \ No newline at end of file